diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 749592c..1360c73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, product-suite] pull_request: jobs: @@ -36,3 +36,63 @@ jobs: - name: Test run: pytest -q --cov=countbone --cov-report=term-missing + + dashboard: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: npm + cache-dependency-path: web/package-lock.json + + - run: npm ci + + - name: Typecheck and build + run: npm run build + + mobile: + runs-on: ubuntu-latest + defaults: + run: + working-directory: mobile + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: npm + cache-dependency-path: mobile/package-lock.json + + - run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Analysis tests + run: npm test + + - name: Lint + run: npx eslint src + + + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build the image + run: docker build -t countbone:ci . + + - name: Start it and check health and the first-run setup code + run: | + docker run -d --name cb -p 8000:8000 countbone:ci + for i in $(seq 1 30); do curl -fsS http://127.0.0.1:8000/api/health && break; sleep 2; done + curl -fsS http://127.0.0.1:8000/api/auth/status | grep '"setup_needed":true' + docker logs cb 2>&1 | grep "setup code" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 784800b..969065e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,16 +10,30 @@ venv/ .coverage htmlcov/ -# run artifacts and local data -runs/ +# run artifacts and local data (anchored: web/src/features/runs is source) +/runs/ *.db *.db-wal *.db-shm examples/*.mp4 examples/*.avi +examples/*.webm # editors / OS .vscode/ .idea/ .DS_Store Thumbs.db + +# dashboard build tooling (the built output in src/countbone/api/static is committed) +web/node_modules/ +web/*.tsbuildinfo +web/tsconfig.tsbuildinfo + +# deployment data: signing and secret keys, evidence packs, catalog photos (never commit) +countbone-data/ + +# mobile tooling +mobile/node_modules/ +mobile/.expo/ +mobile/dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..18f608a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# countbone server: API, dashboard, counting worker. One process, one volume. +# +# docker build -t countbone . +# docker run -p 8000:8000 -v countbone:/data countbone +# +# The dashboard is prebuilt into the package (src/countbone/api/static), so no +# Node is needed here. Everything the deployment owns lives under /data: +# the database, the keys, run artifacts, uploads, evidence packs and catalog +# photos. Back up /data and nothing else. +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY src ./src +RUN pip install ".[api]" && useradd --create-home --uid 10001 countbone && mkdir /data && chown countbone /data + +USER countbone +VOLUME ["/data"] +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=4)" + +# 0.0.0.0 inside the container; put it behind the Caddy service (TLS) in +# docker-compose.yml rather than exposing port 8000 to the internet. +CMD ["countbone", "serve", "--host", "0.0.0.0", "--port", "8000", \ + "--db", "/data/countbone.db", "--out", "/data/runs", "--data-dir", "/data/countbone-data"] diff --git a/README.md b/README.md index 08b033b..51966e4 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,28 @@ # countbone -**Video in, counts out.** A stock-counting pipeline where the backbone is the product and every -other capability is a plugin. - -Point a phone at a shelf, walk the aisle, and get a per-SKU count with a confidence score and a -queue of the things the system was not sure about. +**Film the shelf. Get the count.** Stock counting by phone video, from the +count itself to the signed-off correction in the ERP and the supplier claim +with the video as proof. No hardware to install. ``` Capture → Pre-process → Detect → Identify → Count → Output ``` -That sequence never changes. Guided capture, confidence scoring, multi-frame validation, human -review, exception reports, tolerance rules and audit packs all attach to it through hooks. Adding -a capability means writing a class and naming it in a config file — not editing the pipeline. +That backbone never changes; everything else attaches to it: adaptive +sampling, photo-trained product recognition, review, shelf checks, audit +packs. Around it sits the product: + +| Module | What it does | +|---|---| +| **Count** | Cycle counts by video from the phone app, with live coaching; bay QR labels; walks that merge overlapping videos; recount tasks when a count disagrees with the book | +| **Catalog studio** | Teach a product with about five photos; tells look-alikes apart that colour cannot | +| **Reconcile** | Differences become valued adjustments, approved by rules (auto / manager / finance) and posted to Shopify, NetSuite, SAP, a signed webhook, or CSV. Recounts go to someone other than the first counter, and nobody approves a change they counted or recounted (both switchable for a one-person shop) | +| **Receive** | Film a delivery against its PO; shortages draft supplier claims | +| **Evidence** | Ed25519-signed claim packs anyone can verify offline, over a hash-chained audit trail | +| **Shelf** | Empty facings (with photos) and planogram compliance from the same walk | +| **Count-as-a-Service** | Scheduled crew visits; consented footage exported as COCO training data | + +What it is (and is not yet) good for: [docs/PRODUCT.md](docs/PRODUCT.md). --- @@ -26,32 +36,69 @@ countbone demo `demo` renders a synthetic shelf with known ground truth, counts it, and scores itself: ``` -synthetic shelf: examples/demo_shelf.mp4 (34 units, 144 frames) +synthetic shelf: examples/demo_shelf.webm (34 units, 144 frames) -run run_628c61b93d2b (0.64s) -frames: 27 used, 2 dropped detections: 416 tracks: 33 +run run_ad7e76965deb (1.30s) +frames: 26 used, 3 dropped detections: 388 tracks: 34 SKU count expected var conf truth err --------------------------------------------------------- SKU-BLU 11 11 +0 94% 11 +0 - SKU-GRN 8 8 +0 96% 8 +0 - SKU-RED 9 10 -1 90% 10 -1 - SKU-YEL 5 5 +0 95% 5 +0 - TOTAL 33 + SKU-GRN 8 8 +0 97% 8 +0 + SKU-RED 10 10 +0 95% 10 +0 + SKU-YEL 5 5 +0 97% 5 +0 + TOTAL 34 + +confidence: 95% needs review: no (0 item(s)) -confidence: 93% needs review: yes (18 item(s)) - ! 5 object(s) changed identity between frames +absolute count error: 0 of 34 (0.0%) +``` + +## Run the product -absolute count error: 1 of 34 (2.9%) +```bash +countbone serve # http://127.0.0.1:8000 ``` -Then on your own footage: +The first start prints a **setup code**; open the dashboard, enter it, and +create the admin account (only someone who can see the server's console can +claim a new server). Then add people, bays and products, and install the +phone app ([mobile/README.md](mobile/README.md)). + +Accounts from the command line, for scripts and lost passwords: ```bash -countbone run shelf.mp4 --expect SKU-RED=10 --expect SKU-BLU=11 -countbone serve # dashboard + API on http://127.0.0.1:8000 +countbone user add maria --role manager +countbone user reset-password maria ``` +For a customer deployment (Docker, TLS, backups, upgrades), see +[docs/DEPLOY.md](docs/DEPLOY.md). `countbone serve --no-auth` gives a +single-user local demo and refuses to listen on anything but localhost. + +## The dashboard + +Dark, dense and keyboard-driven, served by `countbone serve`: + +- **Counts**: every video counted, live progress, results against the book, the review drawer + (crop, the model's own candidates, keyboard shortcuts), the frame inspector, and a shelf tab + with gap photos, planogram differences and a picture of every counted object. +- **Recount tasks, Walks, Receive, Locations** (book stock, planograms, printable QR labels), + **Catalog studio**, **Reconcile** (inbox, bulk approval, posting, rules, period report), + **Evidence & claims** (signed packs, pack and audit-chain verification), **Service jobs**, and + **Settings** (people and roles, API keys, integrations). + +The source lives in [`web/`](web/README.md). Its build ships inside the Python package, so running +the dashboard needs no Node. + +## The phone app + +[`mobile/`](mobile/README.md): scan the bay label, film with live coaching +(sharpness, light, pace, direction, tilt, distance, with a direction arrow and +a status pill), and the video uploads by itself in resumable chunks, even +after a dead zone. Then the summary against the book, swipe-to-review on the +spot, and recount tasks. Try it in a browser with `npm run web`. + ## What you get | | | @@ -79,7 +126,7 @@ Each stage does one thing and is replaceable. Counting is the hard part, and it is the part most systems get wrong. A camera panning an aisle moves every box at once, so `stages/motion.py` estimates the global camera displacement between frames by phase correlation and the tracker predicts each box forward by it. Without that step, -the demo above counts 56 instead of 34 — one carton becomes several. With it, 33. +the demo above counts 52 instead of 34 — one carton becomes several. With it, 34. Motion is tracked through frames the quality gate drops, too: skipping it split every carton in view at a dropped frame (see `docs/ARCHITECTURE.md`). ## The plugins @@ -154,41 +201,51 @@ cannot take the backbone down mid-count. ## Honest limitations -- **The default detector is a classical baseline.** `contour` uses edges and contours: it works on - separated items with visible edges against a contrasting background, and degrades on dense, - touching, or occluded stock. That limitation is exactly why `confidence` and `review_queue` - exist. For real inventory, train a detector and use `detect.backend: yolo`. -- **The default identifier matches colour**, not artwork. Real SKUs need a trained classifier, OCR - or a barcode fallback — all of which fit behind the `Identifier` protocol or the `on_items` hook. -- **The 2.9% demo error is on synthetic footage.** It is a regression guard for the backbone, not a - claim about a warehouse. Accuracy on real stock depends entirely on the detector you plug in. -- **The audit pack is tamper-evident, not tamper-proof.** Anyone who can rewrite the pack can - rewrite the hashes. Notarising the manifest hash externally is the next step, deliberately out - of scope here. +- **The default detector is a classical baseline.** `contour` finds boxes by edges and contrast: it + works on separated items against a contrasting background and degrades on dense, touching or + occluded stock. For real stores, train a detector (the consented Count-as-a-Service footage + exports as COCO for exactly this) and use `detect.backend: yolo`. +- **Product recognition is few-shot, not deep.** The photo index (OpenCV features, nearest + exemplar, self-calibrated thresholds) separates look-alike packaging on the test range and + flags products nobody photographed, but it is not a trained deep model; a learned embedder + drops in behind `appearance.embed`. +- **Accuracy is proven on synthetic footage.** 30/30 shelves exact with adaptive sampling, + 10/10 look-alike shelves after five photos per product, every empty facing found: regression + guards with known ground truth, not a claim about a warehouse. A pilot measures that + (see [docs/PRODUCT.md](docs/PRODUCT.md)). +- **Video sees the front of the shelf.** Stock hidden behind the front facing is not counted. +- **Evidence packs are signed, not notarised.** Ed25519 proves a pack came unaltered from this + deployment; proving *when* needs an external timestamp, which is not built. - **Compliance is policy, not code.** Face blurring, retention limits and a DPA are configuration and contract decisions; this repo gives you the hooks to implement them, not the legal position. ## Roadmap -Phase 1 — the MVP, and what this repo is: backbone, confidence, review queue, dashboard. -Phase 2 — hardening: anti-gaming controls, blind recounts, sampling audits, retention policy. -Phase 3 — value-add: shrinkage analytics, drift tracking, ERP export, cycle-count scheduling. +Done on the `product-suite` branch: accounts and roles, adaptive sampling with continuity-aware +confidence, photo-trained identity, locations and QR labels, walks, recount tasks, Reconcile with +ERP connectors, Receive, signed Evidence packs, shelf checks, Catalog studio, service jobs, the +phone app with an offline upload queue, and a Docker deployment. -Each phase adds plugins. The backbone does not change. See [docs/ROADMAP.md](docs/ROADMAP.md). +Next: a trained detector from pilot footage, external timestamping of evidence, multi-worker +counting on a shared queue and database for multi-site customers, and native-device testing of the +phone app. Review and reasoning: [docs/REVIEW_AND_ROADMAP.md](docs/REVIEW_AND_ROADMAP.md). ## Docs -- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — why the seams are where they are -- [docs/PLUGINS.md](docs/PLUGINS.md) — the full plugin contract and every built-in's options -- [examples/config.example.yaml](examples/config.example.yaml) — every knob, with defaults -- [examples/catalog.example.yaml](examples/catalog.example.yaml) — the SKU catalog format +- [docs/PRODUCT.md](docs/PRODUCT.md): the modules, a pilot playbook, pricing shape, what is proven +- [docs/DEPLOY.md](docs/DEPLOY.md): running it for a customer, backups, upgrades, security checklist +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md): why the seams are where they are +- [docs/PLUGINS.md](docs/PLUGINS.md): the full plugin contract and every built-in's options +- [examples/config.example.yaml](examples/config.example.yaml): every knob, with defaults +- [examples/catalog.example.yaml](examples/catalog.example.yaml): the SKU catalog format ## Development ```bash pip install -e ".[api,dev]" -pytest # 105 tests -ruff check . +pytest && ruff check . +cd web && npm ci && npm run build # dashboard, into the Python package +cd mobile && npm ci && npm run typecheck && npm test ``` ## License diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..b21a8d7 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,20 @@ +{$COUNTBONE_DOMAIN} { + encode zstd gzip + # Aisle videos are large; the phone app uploads in 4 MB chunks, the + # dashboard in one request. + request_body { + max_size 8GB + } + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options nosniff + Referrer-Policy strict-origin-when-cross-origin + X-Frame-Options DENY + } + reverse_proxy countbone:8000 { + transport http { + read_timeout 30m + write_timeout 30m + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..64d74f8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +# A customer deployment: countbone behind Caddy, which gets and renews a TLS +# certificate for COUNTBONE_DOMAIN automatically. +# +# COUNTBONE_DOMAIN=counts.example.com docker compose up -d +# docker compose logs countbone | grep "setup code" # first run only +# +# Then open https://counts.example.com and enter the setup code. +services: + countbone: + build: . + restart: unless-stopped + volumes: + - countbone-data:/data + environment: + # Optional: take the secret key from your secret manager instead of the + # generated file in /data/countbone-data/keys (see docs/DEPLOY.md). + - COUNTBONE_SECRET_KEY=${COUNTBONE_SECRET_KEY:-} + # Port 8000 is reachable only from this compose network (expose, not + # ports), so the one thing that can send X-Forwarded-For is Caddy. + # Without this every user shares Caddy's address in the login throttle + # and the audit trail. + - FORWARDED_ALLOW_IPS=* + expose: + - "8000" + + caddy: + image: caddy:2 + restart: unless-stopped + depends_on: [countbone] + ports: + - "80:80" + - "443:443" + environment: + - COUNTBONE_DOMAIN=${COUNTBONE_DOMAIN:?set COUNTBONE_DOMAIN to your hostname} + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + +volumes: + countbone-data: + caddy-data: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 227a022..24065dd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,8 +75,19 @@ The effect on the bundled demo (34 units of ground truth): | | counted | error | | --- | ---: | ---: | -| without motion compensation | 56 | +65% | -| with motion compensation | 33 | −2.9% | +| without motion compensation | 52 | +53% | +| with motion compensation | 34 | 0% | + +Motion must be accumulated through **every** frame, including those the quality gate drops. +An earlier version only applied motion on frames that reached the tracker, so each dropped frame +left every prediction a whole frame of pan behind; at walking speed that split every object in +view into two tracks. On ten synthetic shelves that bug alone produced errors of up to +36% +(mean 11%). Accumulating it (`Tracker.observe_motion`) brought the same ten to exact totals. + +The contour detector also pads each frame with its median tone before finding edges. Without it, +a carton cut off by the frame edge has an open outline, and its label, the next closed shape +inside, was reported as an object of its own: 145 of 145 "unidentified" sightings in one test +video were exactly this. Track identity is a majority vote across sightings, not the first guess, because identification flickers and object permanence does not. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..997c11d --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,89 @@ +# Deploying countbone for a customer + +One deployment per customer: their data, keys and users never share a +database with anyone else's. A deployment is one container and one volume. + +## 1. Run it + +```bash +COUNTBONE_DOMAIN=counts.acme.com docker compose up -d +docker compose logs countbone | grep "setup code" +``` + +Open `https://counts.acme.com`, enter the setup code and create the first +admin. The code exists only until the first account is made, and only +someone who can read the server's logs can see it. + +Without Docker: + +```bash +pip install "countbone[api]" +countbone serve --host 127.0.0.1 --port 8000 --db /srv/countbone/countbone.db \ + --out /srv/countbone/runs --data-dir /srv/countbone/countbone-data +``` + +Put it behind TLS (Caddy, nginx, a cloud load balancer). Never expose the +plain HTTP port: sign-in sends passwords, and the phone app sends a token. + +Tell countbone which proxy to believe about the real client address, or +every user looks like the proxy: `--forwarded-allow-ips 10.0.0.5` (the +proxy's address), or `'*'` when nothing but the proxy can reach the port. +`docker-compose.yml` already sets `FORWARDED_ALLOW_IPS=*` for Caddy. Never +use `'*'` with the port open to the network: anyone could then claim any +address in the audit trail. + +## 2. Set it up (the customer's admin, in the dashboard) + +1. **Settings → People**: add counters and managers. Roles: + - counter: films, reviews, recounts, approves small differences + - manager: locations, catalog, deliveries, claims, approvals up to the manager limit + - admin: people, integrations, rules, training-data export +2. **Locations**: add bays (or import a CSV `location,name,zone`), then + **Print labels** and stick one on each bay. +3. **Book stock**: import `location,sku,qty` from the ERP export, or connect + the ERP under **Settings → Integrations** and pull it. +4. **Catalog studio**: add products (or import `sku,label,unit_value`) and + photograph each about five times. Watch for "Photograph these too". +5. **Reconcile → Rules**: set the auto-approve and manager limits, the + recount policy, and where approved adjustments are posted. +6. **Phones**: install the app, sign in with the server address and a + counter account. + +## 3. Back it up + +Everything is under the data volume (`/data` in the container): + +| Path | What | If lost | +|---|---|---| +| `countbone.db` | counts, reviews, tasks, adjustments, users, audit trail | everything | +| `countbone-data/keys/` | secret key (sealed ERP credentials), evidence signing key | ERP credentials must be re-entered; new evidence packs get a new signing identity | +| `runs/` | per-count artifacts: audit packs, crops, contact sheets | evidence for past counts | +| `runs/_uploads/` | the source videos | re-verification of past counts | +| `countbone-data/evidence/`, `countbone-data/catalog/` | built claim packs, product photos | rebuildable / re-photograph | + +Back up the database consistently with SQLite's online backup, not a file copy +while it is being written: + +```bash +docker compose exec countbone python -c "import sqlite3; s=sqlite3.connect('/data/countbone.db'); d=sqlite3.connect('/data/backup.db'); s.backup(d)" +``` + +then copy `/data` (with `backup.db`) off the machine. Keep the keys in a +different place from the database: together they are everything. + +## 4. Upgrade + +Pull the new image and restart. Database migrations run on start, each in +its own transaction; a run that was queued or counting when the server +stopped is picked up again. + +## 5. Security checklist + +- [ ] TLS in front, plain port not reachable from outside +- [ ] `--forwarded-allow-ips` names the proxy (sign-ins in the audit trail show real client addresses) +- [ ] `countbone-data/keys` backed up separately and readable only by the service user +- [ ] Admin accounts only for people who administer; everyone else counter or manager +- [ ] Former staff: **Settings → People → Disable** (their sessions end at once) +- [ ] API keys named after what uses them, with the lowest role that works, revoked when unused +- [ ] **Evidence → Verify & audit** shows the audit chain intact +- [ ] `--no-auth` is for a laptop demo only (it refuses to bind to anything but localhost) diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index a68d074..5109bc7 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -30,6 +30,7 @@ Options come from the config file and are passed to `configure()` as keyword arg | `on_frame` | `(ctx, frame)` | `Frame` or `None` | `None` drops the frame | | `on_detections` | `(ctx, frame, detections)` | `list[Detection]` | before identification | | `on_items` | `(ctx, frame, items)` | `list[Item]` | the frame is still in hand here | +| `on_frame_tracked` | `(ctx, frame, items)` | — | track ids are assigned, pixels still in hand; `frame.meta["offset"]` is the camera offset (world = image − offset) | | `on_tracks` | `(ctx, tracks)` | `list[Track]` | after grouping, before counting | | `on_counts` | `(ctx, result)` | `CountResult` | the last chance to change the answer | | `on_output` | `(ctx, result)` | — | side effects only; the result is final | @@ -111,12 +112,16 @@ Weights are normalised, so editing one cannot silently rescale the score. ### `tolerance` — analytics, priority 40 -Turns raw variance into a decision, banded by unit value from the catalog. Not enabled by default, -because it only does something when expected counts exist. +Turns raw variance into a decision, banded by unit value from the catalog. **On by default** and, +by default, flags every miss: a count that disagrees with the expected number must never pass as +clean. It only acts when a count has an expected number (a location's book stock, a PO line, or the +catalog), and an expected SKU that was never seen is a zero row with its full variance. | Option | Default | | --- | --- | -| `bands` | `[{max_unit_value: 10, abs: 2, pct: 0.03}, {max_unit_value: 100, abs: 1, pct: 0.01}, {max_unit_value: null, abs: 0, pct: 0.0}]` | +| `bands` | `[{max_unit_value: null, abs: 0, pct: 0.0}]` (loosen per value band once the business decides, e.g. `{max_unit_value: 10, abs: 2, pct: 0.03}`) | + +What gets *approved* automatically is a separate decision, made by the Reconcile rules. The allowance for a SKU is `max(abs, round(pct × expected))`. A `max_unit_value: null` band is the catch-all and must come last. Breaches are priced: `value_at_risk = |variance| × unit_value`. @@ -132,8 +137,47 @@ Routes what the system is unsure about to a person, with the crop attached. | `max_items` | `40` | cap per run; the rest are counted as truncated | | `save_crops` | `true` | write JPEG crops into `/crops/` | | `review_unknown` | `true` | always review anything identified as `UNKNOWN` | +| `review_single_sightings` | `true` | raise `possible_missed_item` for an object seen clearly but only once | +| `single_min_confidence` | `0.6` | how clear that one sighting must be | +| `settled_agreement` | `0.7` | an object seen 3+ times whose sightings agree this much raises no question | + +A doubtful SKU is one review task, not one per unit. Item questions are asked once per tracked object, +carry its `track_id`, rank by its least confident sighting and show its most complete view; the +decision changes the final count (see `countbone.ops.final`). + +### `location_tag` — capture, priority 20 + +Reads bay QR labels (`CB1:LOC:`, printed by the dashboard) from the frames. Files a run with no +location under the bay it shows, flags a run filed under a different bay, flags videos showing +several bays, and drops detections inside the label (a label is not stock). + +| Option | Default | Meaning | +| --- | --- | --- | +| `search_every` | `3` | scan every Nth frame until a label is found | +| `watch_every` | `12` | afterwards, scan every Nth frame for a second label (every frame while one is in view) | + +### `shelf_check` — analytics, priority 45 -A doubtful SKU is one review task, not one per unit. +Empty facings from the counted objects' shelf positions (rows by height, gaps wider than a typical +gap plus most of an object), each with a photo re-read from the source; with a planogram for the +location, the differences named per position. Writes `shelf.json` and `gaps/`. + +| Option | Default | Meaning | +| --- | --- | --- | +| `min_fill` | `0.8` | a gap must fit this share of an object to count as an empty facing | +| `photos` | `true` | save a marked photo per gap | +| `max_photos` | `20` | cap per run | + +### `contact_sheet` — output, priority 80 + +The clearest view of every counted object, in `tracks/` and on `contact_sheet.jpg`, grouped by SKU. +Hashed into the audit pack and included in evidence packs. + +| Option | Default | +| --- | --- | +| `max_objects` | `600` | +| `columns` | `12` | +| `save_tracks` | `true` | ### `exception_report` — output, priority 60 @@ -156,7 +200,8 @@ that manifest. | `hash_source` | `true` | | `hash_artifacts` | `true` | -Runs last so it can hash the outputs of earlier output plugins. Tamper-evident, not tamper-proof. +Runs last so it can hash the outputs of earlier output plugins. Tamper-evident on its own; claim +packs built from it are signed with the deployment's Ed25519 key (`countbone.ops.evidence`). --- diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md new file mode 100644 index 0000000..0162264 --- /dev/null +++ b/docs/PRODUCT.md @@ -0,0 +1,94 @@ +# What countbone sells + +A phone and a video replace the clipboard: film a bay, get the count, and +turn every disagreement with the book into a signed-off correction or +recovered money. No hardware to install. + +## The modules + +Each module reuses the same backbone (video → count → review → proof); what +changes is where the expected numbers come from and what happens after. + +| Module | Buyer | What it does | Where it lives | +|---|---|---|---| +| **Count** | Operations / inventory control | Cycle counts by video. Adaptive sampling, photo-trained product recognition, review queue, recount tasks, walks that merge overlapping videos, printable bay labels | Phone app, dashboard *Counts, Walks, Recount tasks, Locations* | +| **Catalog studio** | Inventory control | Teach products with ~5 photos; readiness and look-alike report; reviewers' corrections become examples | *Catalog studio* | +| **Reconcile** | Finance | Differences as adjustments with a value; rules decide auto-approve / manager / finance; separation of duties (independent recount, four-eyes approval); post to Shopify, NetSuite, SAP, a signed webhook, or CSV; period report | *Reconcile* | +| **Receive** | Receiving / procurement | Count a delivery against its PO at the dock; shortages draft supplier claims with the video as evidence | *Receive* | +| **Evidence** | Finance / legal / loss prevention | Ed25519-signed claim packs anyone can verify offline; tamper-evident audit chain | *Evidence & claims* | +| **Shelf** | Retail merchandising | Empty facings with photos and planogram compliance from the same walk | Run → *Shelf & evidence* | +| **Count-as-a-Service** | Customers without spare staff | Your crews film their sites on a schedule; consented footage trains better models | *Service jobs*, COCO export | + +## Why a customer stays + +- **Reconcile is where finance signs off.** Once approvals and ERP postings + run through it, it is the system of record for stock corrections. +- **The catalog compounds.** Every correction is a new example; switching + vendor means re-teaching every product. +- **Evidence pays for itself.** A recovered supplier shortage is a number + finance can put next to the subscription. + +## Running a pilot (4 weeks) + +**Week 0, setup (half a day on site).** Deploy (docs/DEPLOY.md). Import +locations and book stock for 2-3 aisles. Print and stick labels. Photograph +the 20-50 fastest-moving products. Create counter accounts; install the app. + +**Week 1, calibrate.** Count the pilot aisles daily by video *and* by hand. +Compare in *Counts*. Photograph anything the studio flags; tune the reconcile +limits with the customer's finance lead. + +**Weeks 2-3, run.** Video only. Recount tasks close the loop; managers +approve in *Reconcile*. Connect the ERP (or agree the CSV hand-off). +Receive two or three deliveries per week at one dock door. + +**Week 4, review.** Report: counts per hour vs. the manual baseline, variance +found and its value, recount hit rate (how often the video was right), +claims raised and recovered, and accuracy against the hand counts from week 1. + +**Success criteria to agree up front:** counting time per bay, share of +counts that needed no human input, accuracy vs. hand count on the pilot +products, value of shortages evidenced. + +## Pricing shape (to test in pilots) + +| Package | Includes | Shape | +|---|---|---| +| Count | Count, Catalog studio, Shelf | per site per month, by bay count | +| Count + Reconcile | adds Reconcile and ERP connectors | higher tier per site | +| Receive | Receive + Evidence for deliveries | per dock door per month | +| Evidence | claim packs, audit exports | premium add-on, or % of recovered claims | +| Count-as-a-Service | our crews + the software | per visit or per bay counted | + +## What is true today, and what is not yet + +Proven on synthetic footage with known ground truth (see the test suite): + +- 30/30 synthetic shelves counted exactly with adaptive sampling; a wrong + count is flagged, never reported as certain. +- Look-alike products (same colour, different artwork): 10/10 shelves exact + after 5 photos per product, versus 448 errors by colour alone; an + unenrolled look-alike is flagged, not misfiled. +- Overlapping videos merged exactly on 12/12 test pairs, with no false merges + on disjoint pairs. +- Every empty facing found on the test shelves. + +**Not yet proven, and must be before promising accuracy numbers to a customer:** + +- **Real shelves.** The detector finds boxes by contrast (`contour`); real + stores bring clutter, glare and occlusion. The YOLO detector backend exists + but no model has been trained on real shelves yet: the pilot's + Count-as-a-Service footage (with consent) is how to get one. +- **Depth.** A video sees the front of the shelf. Items stacked behind the + front facing are not counted; Count is for faced shelves and pallet faces, + not for deep bins. +- **Native phone builds.** The app is verified in its web preview end to + end; the iOS/Android camera path (VisionCamera) needs a development build + on real devices, and the tilt sensor's sign should be checked per device. +- **ERP connectors** are written against the documented APIs (Shopify + 2026-07 GraphQL, NetSuite REST + TBA, SAP S/4HANA OData) and tested + against mocks of those requests, not against live customer tenants. Do a + sandbox run with each customer before switching on automatic posting. +- **Scale.** One counting worker per deployment and SQLite. Fine for a pilot + and a mid-size site; multi-site customers with many phones need the job + queue and database moved to shared services. diff --git a/docs/REVIEW_AND_ROADMAP.md b/docs/REVIEW_AND_ROADMAP.md new file mode 100644 index 0000000..e2fca67 --- /dev/null +++ b/docs/REVIEW_AND_ROADMAP.md @@ -0,0 +1,218 @@ +# Countbone: health check, feature roadmap, ecosystem + +## Status, 2026-09-27 (branch `product-suite`, not merged) + +Everything in sections 3 and 4 below was built, tested and verified in a browser: + +| Item | Built as | Verified by | +|---|---|---| +| Lost tracking on fast pans | closed-loop sampler (back-fill, retry, adaptive stride), continuity score in confidence, scene-cut detection | 30/30 synthetic shelves exact (was 29/30 with the one error silent); tests | +| Learned product identity | `appearance.py` photo index, self-calibrated per product, open set; barcodes when legible; Catalog studio | look-alike range: 0 errors on 10 shelves (colour: 448); unenrolled look-alike flagged | +| Logins | scrypt, hashed tokens, cookie + CSRF header / Bearer / API keys, roles, throttling, console setup code | 22 platform tests; setup and sign-in in the browser | +| Mobile app | Expo app: guided capture, QR scan, offline resumable queue, summary, swipe review, recounts | web preview end to end, including recording offline | +| Flag misses by default | tolerance on by default, every miss flagged, zero rows for unseen expected SKUs | tests | +| Bay QR labels | printable labels; read from the video to file or flag a run | labels decode; label-first video filed correctly | +| Overlapping videos | walks merged by shelf-coordinate alignment with symmetric conflicts | 12/12 overlaps exact, 12/12 disjoint pairs untouched | +| Offline, upload later | resumable chunked uploads, idempotent by recording id; durable job queue | tests; offline recording in the browser | +| Mismatch → recount task | tasks with assignee and due date, closed by hand count or video | browser: task → recount → approval | +| ERP connectors | Shopify (2026-07 GraphQL), NetSuite (REST + TBA), SAP S/4HANA (OData), signed webhook, CSV | mocked request-shape tests; OAuth signature matches oauthlib | +| Receive | receipts from PO (typed, CSV, ERP), counted against lines, shortages draft claims | browser: short delivery → claim → pack | +| Reconcile | valued adjustments, rules, approvals by role, posting, period report | browser and tests | +| Evidence | Ed25519-signed claim packs with custody trail and verify.py; hash-chained audit | pack verifies; one changed byte fails | +| Shelf | empty facings with photos, planogram compliance | every empty slot found | +| Catalog studio, Count-as-a-Service | studio pages; sites, service jobs, consented COCO export | tests | + +Bugs found and fixed along the way are in the commit messages (OpenCV 5 `phaseCorrelate` mutating +its inputs, red hue straddling 0/179 read as green, a false-merge path, label frames counted as +stock, one photographed product disabling colour identification for all others, and more). + +Still open, and stated in [PRODUCT.md](PRODUCT.md): real-shelf accuracy (needs a trained detector), +native-device testing of the phone app, live ERP sandbox runs, external timestamping of evidence, +and multi-worker scale. + +--- + +*Review of branch `dashboard-and-mobile`, 2026-09-27. Nothing in sections 3 and 4 +has been built; they are proposals awaiting a decision.* + +--- + +## 1. Health check: what was run, what came back + +| Part | Check | Result | +|---|---|---| +| Backend | `pytest` (123 tests), `ruff check` | all pass, lint clean | +| Backend | live `countbone serve --allow-origin …`, HTTP upload → poll → artifacts | works: seed 11 counted 37/37, CORS header correct, inspector.json served | +| Backbone accuracy | `countbone demo` on 7 seeds never used in tests (3, 11, 21, 31, 42, 55, 77) | 6 exact, **seed 21 off by 3 of 32** (see 2.1) | +| Dashboard | `npm run build` (tsc + vite) | builds, 148 kB gzip JS | +| Mobile | `tsc --noEmit` | **was failing (4 errors), fixed in this commit** | +| Mobile | analysis engine tests (10) | all pass | +| Mobile | `expo-doctor` | 21/21 checks pass | +| Mobile | `expo export --platform web` | bundles, but **there are no screens yet** (see 2.2) | + +## 2. Findings, in order of how much they matter + +### 2.1 A fast walk undercounts silently, with high confidence +Seed 21 counted 29 of 32 at **95% confidence, needs review: no**. Root cause +isolated by re-running at different frame strides: + +| every_n_frames | tracks | error | +|---|---|---| +| 1 | 32 | 0 | +| 2 | 32 | 0 | +| 3 | 32 | 0 | +| 5 (default) | 29 | −3 | + +The tracker links objects across frames by box overlap. The default stride of 5, +plus 5 of 29 sampled frames rejected by the quality gate, left gaps where items +moved more than about a box width between usable frames. Merged detections were +ruled out: no counted track has an abnormal box area. + +Why this matters: confidence only measures colour identification, not tracking +continuity, so the system reports a wrong answer as a certain one. For a +stock-count product, that is the worst failure mode. + +Two amplifiers: +- The `tolerance` plugin is not in `DEFAULT_PLUGINS`, so a count that misses + every expected SKU still says "needs review: no". +- The test seeds all pan slowly, so CI cannot see this. + +Proposed fix (not applied): +- Measure per-gap motion ÷ median box width. +- Adaptively re-sample the skipped frames in that window when the ratio exceeds about 0.5. +- Fold a "tracking continuity" term into confidence. +- Enable tolerance whenever expected counts exist. +- Add a fast-pan seed to the tests. + +The mobile app's "Slow Down" cue is the capture-side half of this fix. + +### 2.2 The mobile app is an engine without a body +Done and tested: +- The analysis engine (blur calibrated to the backend within 1%, exposure, + motion/shift, scale, edge boxes, debounced guidance cues). +- Theme tokens. +- Permissions. + +Not built: +- Every screen: capture, HUD, record button, processing/summary, swipe review, history, settings. +- The camera feeds (VisionCamera native, getUserMedia web). +- The API client. + +The app launches to Expo Router's "unmatched route" page. Native builds need +EAS or a Mac or Android SDK, and have not been run anywhere. + +### 2.3 No authentication, anywhere +Anyone who can reach the port can: +- Upload. +- Resolve or undo reviews. The `reviewer` field is free text and defaults to "anonymous", so the audit trail is not attributable. +- Submit a server-side path to `/api/runs`. + +This is fine on localhost and not sellable. Minimum: API keys per site, and a +signed-in reviewer identity written into `review_decision` audit rows. + +### 2.4 Works on synthetic footage; unproven on real shelves +Every accuracy number comes from generated video: flat, coloured, uncluttered +cartons. Real aisles bring: +- Look-alike packaging. The colour identifier can separate maybe 6–8 SKUs by hue, and a real store has thousands. +- Shrink-wrap glare. +- Items stacked behind items. Video only sees facings. +- Partial occlusion by price rails. + +The architecture (plugins, audit pack, review loop) will carry over. The +contour detector and colour identifier will not. A `yolo` detector backend +exists, but no learned SKU identifier does. + +### 2.5 Operational limits (fine now, fix before multi-site) +- The in-memory `RunTracker`: queued runs are lost on restart. +- One worker thread. +- SQLite. +- Local-disk video. +- Progress by polling. + +None of these are bugs at pilot scale. + +## 3. Features: add, replace, cut (proposals only) + +**P0: required before a paying pilot** +1. **Tracking-continuity confidence and adaptive stride** (2.1). Small change, removes the worst failure. +2. **Learned SKU identity** *(replace the colour identifier)*: an image-embedding model with few-shot enrolment ("photograph 5 facings of a new SKU"), plus reading barcodes and shelf labels when visible. +3. **Auth and attributable reviews** (2.3). +4. **Finish the mobile app** (2.2). +5. **Tolerance on by default** when expected counts exist. + +**P1: what makes customers stay** +6. **Location anchoring**: a QR or label per bay/rack scanned at the start of a walk, so counts attach to a location, not just a video. +7. **Overlap de-duplication**: the same bay walked twice, or two phones in adjacent aisles, must not double count. +8. **Offline-first mobile**: queue recordings and resume uploads (tus protocol), since warehouses have Wi-Fi dead zones. +9. **Recount tasks**: a variance creates a task, assigned to a person, with a due date and a closure proof. +10. **ERP/WMS connectors**: pull expected quantities and push approved adjustments. Start with CSV/SFTP and webhooks, then NetSuite, SAP, Odoo, Shopify. +11. **Per-location trend and shrink history** (the SKU history endpoint already exists). + +**P2: scale** +12. Postgres, a job queue (arq or Celery), object storage for video, and Server-Sent Events instead of polling *(replace)*. +13. On-device counting for no-connectivity sites. The mobile engine already runs frame analysis in a worklet. +14. Stacked-depth estimation (facings × depth from a side pass). This is a research problem, so keep it out of promises. + +**Cut or de-emphasise:** the "lux" readout on the mobile HUD. Phones expose +no calibrated lux sensor to apps on iOS, so it is an estimate. Label it +"light" rather than claiming a unit. + +## 4. Ecosystem: products that make customers buy more than one thing + +Market context: +- Retail inventory records are wrong most of the time: studies put 60–65% of SKU records inaccurate. +- Correcting them produced 4–8% sales lifts. +- Incumbents split by capture method: + - Drones for high-bay warehouses (Corvus, Gather AI). + - Fixed shelf cameras and robots for stores (Focal Systems, Simbe, Trax). + - Phone scanning (Scandit MatrixScan Count, ShelfView). + - Dock cameras for receiving (Vimaan). + +Countbone's opening: **phone video, no hardware install**. It is cheap to start, +which suits mid-market warehouses, 3PLs and multi-site retailers that cannot +justify drones or shelf cameras. + +The ecosystem should follow the stock's journey. Each product reuses the same +video → count → review → audit backbone with a different "expected" source. + +``` + RECEIVE ──────────► STORE ──────────► SELL / SHIP + Countbone Receive Countbone Count Countbone Shelf + (count vs ASN/PO) (cycle counts) (gaps, planogram) + \ | / + └──── Countbone Reconcile ──────┘ + (variance desk, ERP sync, approvals) + | + Countbone Evidence + (audit packs → vendor claims, insurance, auditors) +``` + +| Product | What it does | Why a Count customer buys it | Reuse of today's code | +|---|---|---|---| +| **Receive** | Film a pallet or truck unload; count vs the ASN/PO; flag shorts with photo evidence while the driver is still at the dock | Most inventory errors start at receiving; stops them before they reach the shelf | High: the pipeline as is; expected comes from the ASN instead of the catalog | +| **Shelf** | From the same walk video: out-of-stock gaps, facings, planogram compliance | Zero extra labour: the walk they already do for Count | Medium: new analytics plugin on existing detections | +| **Reconcile** | Variance inbox, approval rules by value (tolerance bands already exist), push adjustments to ERP | Becomes the system finance signs off on, which is the lock-in | High: review queue, tolerance, audit trail | +| **Evidence** | Tamper-evident audit packs (already hashed) turned into vendor chargeback claims, insurance and loss files, external-auditor year-end packs | Turns counting from a cost into recovered money; easy ROI story | High: `audit_pack` plugin already hashes artifacts | +| **Enrol** (Catalog Studio) | Photograph a SKU a few times to teach the identifier | Needed for P0 item 2 anyway; sell it as self-serve onboarding | New | +| **Count-as-a-Service** | Your crews walk their site with the app monthly | Services revenue; also your data engine for training models on real footage | Uses the mobile app | + +**Recommended sequencing:** +1. **Count + Reconcile** first. Reconcile is where the money decision lives, and it builds on code that exists. +2. **Receive** second. Same pipeline, a buyer persona the customer already has (the receiving lead), and the fastest ROI. +3. **Evidence** as the premium tier. +4. **Shelf** only for retail customers. + +**Pricing shape to test:** per site per month for Count; Reconcile bundled into +a higher tier; Receive per dock door; Evidence as a percentage of recovered +claims or a flat premium add-on. + +## Sources +- [Corvus One inventory drones (DroneDJ, 2026)](https://dronedj.com/2026/03/03/corvus-one-inventory-warehouse-drone/) +- [Gather AI drone vision](https://gather.ai/platform/vision/drone) +- [Warehouse drone market outlook (CXTMS)](https://cxtms.com/blog/warehouse-drones-autonomous-inventory-scanning-7-billion-market-2026) +- [Focal Systems retail OS (IBM)](https://www.ibm.com/new/product-blog/focal-systems-boosting-store-performance-with-an-ai-retail-operating-system-and-real-time-data) +- [Planogram compliance landscape (CB Insights)](https://www.cbinsights.com/esp/consumer-&-retail/in-store-tech/planogram-compliance-&-inventory-visibility) +- [Scandit store operations / MatrixScan Count](https://www.scandit.com/industries/retail/store-operations/) +- [Vimaan inbound pallet receiving](https://vimaan.ai/inbound-pallet-receiving-automation/) +- [Dock door verification (iFactory)](https://ifactoryapp.com/ai-vision-camera/ai-vision-dock-door-monitoring-inbound-outbound-verification) +- [Inventory accuracy and phantom inventory (RELEX)](https://www.relexsolutions.com/resources/how-to-optimize-cycle-counting-and-banish-phantom-inventory/) diff --git a/examples/config.example.yaml b/examples/config.example.yaml index a72a227..3210f92 100644 --- a/examples/config.example.yaml +++ b/examples/config.example.yaml @@ -2,8 +2,11 @@ # Copy, edit, and pass with: countbone run shelf.mp4 -c my-config.yaml capture: - every_n_frames: 5 # sample 1 frame in 5; lower = better tracking, slower - max_frames: 400 # hard cap per run + every_n_frames: 5 # the widest stride; adaptive sampling only ever narrows it + max_frames: 5000 # hard cap per run; reaching it is flagged for review + adaptive: true # back-fill skipped frames on fast pans, retry after a dropped frame + target_shift_frac: 0.25 # aim for this much scene movement per sample, in object widths + max_shift_frac: 0.6 # back-fill a gap that moved further than this start_s: 0.0 end_s: null resize_width: 960 # null keeps native resolution @@ -23,7 +26,8 @@ detect: device: cpu identify: - backend: color # color | classmap | fixture + backend: auto # auto: photos once any product has them (Catalog studio), else colour + # | appearance | color | classmap | fixture catalog: examples/catalog.example.yaml unknown_sku: UNKNOWN @@ -45,19 +49,22 @@ output: plugins: - quality_gate + - location_tag # bay QR labels in the video - multiframe - name: confidence options: sku_threshold: 0.60 run_threshold: 0.65 - - name: tolerance + - name: tolerance # default: every miss is flagged; loosen by value band if wanted options: bands: - { max_unit_value: 10.0, abs: 2, pct: 0.03 } - { max_unit_value: 100.0, abs: 1, pct: 0.01 } - { max_unit_value: null, abs: 0, pct: 0.0 } + - shelf_check # empty facings and planogram compliance - name: review_queue options: max_items: 40 - exception_report + - contact_sheet # a picture of every counted object - audit_pack diff --git a/mobile/.claude/settings.json b/mobile/.claude/settings.json new file mode 100644 index 0000000..176e6a5 --- /dev/null +++ b/mobile/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "expo@claude-plugins-official": true + } +} diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..d914c32 --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,41 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/mobile/AGENTS.md b/mobile/AGENTS.md new file mode 100644 index 0000000..be05e77 --- /dev/null +++ b/mobile/AGENTS.md @@ -0,0 +1,41 @@ +This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility. + +## Expo has changed — do not trust your training data + +Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API: + +1. Read the major version of the `expo` package in `package.json`. +2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v.0.0/` +3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory. + +## Commands + +Use `bunx` instead of `npx` if the project uses bun (`bun.lock` present). + +```bash +npx expo install # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions +npx expo start # start the dev server +npx expo lint # lint +npx tsc --noEmit # typecheck +npx expo-doctor # diagnose dependency and config issues +npx expo install --fix # fix incompatible package versions +``` + +Run lint and typecheck before declaring any task done. + +## Navigation & Routing + +- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`. +- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`. +- Docs: https://docs.expo.dev/router/introduction.md + +## Building with EAS + +Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `bunx eas-cli ` in Bun projects, or `npx eas-cli@latest ` otherwise; substitute that for bare `eas` in docs examples. +Docs: https://docs.expo.dev/eas/index.md + +## Rules + +- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins. +- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`. +- Prefer recommended Expo modules over third-party libraries, and check your available skills before adding dependencies. Docs: https://docs.expo.dev/versions/latest/index.md diff --git a/mobile/CLAUDE.md b/mobile/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/mobile/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/mobile/LICENSE b/mobile/LICENSE new file mode 100644 index 0000000..30b20e3 --- /dev/null +++ b/mobile/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..4d98e92 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,56 @@ +# countbone mobile + +The phone app counters use on the floor: pick what you are filming (a bay by +scanning its QR label, a delivery, or a recount), film it with live +coaching, and it uploads by itself, even after a dead zone. + +Expo SDK 57, Expo Router (`src/app`), React Native 0.86. + +## Try it in a browser (no phone needed) + +```bash +# the server, allowing the preview's origin +countbone serve --allow-origin http://localhost:8081 + +cd mobile +npm install +npm run web # http://localhost:8081 +``` + +Sign in with the server address (`http://127.0.0.1:8000`) and an account. +In the viewfinder, **Replay** plays a video file through the same guidance +and recording path the camera uses, so the whole flow can be tried on a desk. + +## On a phone + +The camera uses VisionCamera (a native module), so Expo Go is not enough: +make a development build. + +```bash +npx eas-cli@latest build --profile development --platform android # or ios +npx expo start --dev-client +``` + +Permissions are declared in `app.json` (camera; microphone only if audio is +turned on; motion). Check on each target device that the tilt cue points the +right way (see `src/sensors/useTilt.ts`). + +## How it fits together + +| Path | What | +|---|---| +| `src/analysis/` | frame metrics and the guidance engine; plain TypeScript, runs in the camera worklet, the browser and `node --test` | +| `src/camera/CameraFeed.tsx` | VisionCamera: Y-plane analysis in a frame worklet, MP4 recording, live ISO/shutter | +| `src/camera/CameraFeed.web.tsx` | getUserMedia or a replayed file, MediaRecorder | +| `src/api/uploadQueue.ts` | the offline queue: resumable 4 MB chunks, idempotent by recording id, retries with back-off | +| `src/storage/recordings*.ts` | recordings kept in app storage (IndexedDB on the web) until the server has them | +| `src/app/` | screens: sign-in, Count, capture, scan, run summary, review, Recounts, Uploads, Settings | + +## Checks + +```bash +npm run typecheck +npm test # analysis engine +npx expo lint +npx expo-doctor +``` diff --git a/mobile/app.json b/mobile/app.json new file mode 100644 index 0000000..25d70db --- /dev/null +++ b/mobile/app.json @@ -0,0 +1,41 @@ +{ + "expo": { + "name": "Countbone", + "slug": "countbone", + "scheme": "countbone", + "version": "0.1.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "dark", + "backgroundColor": "#0b0c0e", + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.countbone.mobile", + "infoPlist": { + "NSCameraUsageDescription": "Countbone films the shelf you are counting.", + "NSMicrophoneUsageDescription": "Countbone records audio with the shelf video only if you turn it on.", + "NSMotionUsageDescription": "Countbone uses the motion sensors to tell you when to hold the phone level." + } + }, + "android": { + "package": "com.countbone.mobile", + "permissions": ["android.permission.CAMERA", "android.permission.RECORD_AUDIO"], + "adaptiveIcon": { + "backgroundColor": "#0b0c0e", + "foregroundImage": "./assets/android-icon-foreground.png", + "backgroundImage": "./assets/android-icon-background.png", + "monochromeImage": "./assets/android-icon-monochrome.png" + }, + "predictiveBackGestureEnabled": false + }, + "web": { + "favicon": "./assets/favicon.png", + "output": "single", + "backgroundColor": "#0b0c0e" + }, + "plugins": ["expo-router", "expo-status-bar", "expo-font"], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/mobile/assets/android-icon-background.png b/mobile/assets/android-icon-background.png new file mode 100644 index 0000000..5ffefc5 Binary files /dev/null and b/mobile/assets/android-icon-background.png differ diff --git a/mobile/assets/android-icon-foreground.png b/mobile/assets/android-icon-foreground.png new file mode 100644 index 0000000..3a9e501 Binary files /dev/null and b/mobile/assets/android-icon-foreground.png differ diff --git a/mobile/assets/android-icon-monochrome.png b/mobile/assets/android-icon-monochrome.png new file mode 100644 index 0000000..77484eb Binary files /dev/null and b/mobile/assets/android-icon-monochrome.png differ diff --git a/mobile/assets/favicon.png b/mobile/assets/favicon.png new file mode 100644 index 0000000..408bd74 Binary files /dev/null and b/mobile/assets/favicon.png differ diff --git a/mobile/assets/icon.png b/mobile/assets/icon.png new file mode 100644 index 0000000..7165a53 Binary files /dev/null and b/mobile/assets/icon.png differ diff --git a/mobile/assets/splash-icon.png b/mobile/assets/splash-icon.png new file mode 100644 index 0000000..03d6f6b Binary files /dev/null and b/mobile/assets/splash-icon.png differ diff --git a/mobile/eslint.config.js b/mobile/eslint.config.js new file mode 100644 index 0000000..ba708ed --- /dev/null +++ b/mobile/eslint.config.js @@ -0,0 +1,10 @@ +// https://docs.expo.dev/guides/using-eslint/ +const { defineConfig } = require('eslint/config'); +const expoConfig = require("eslint-config-expo/flat"); + +module.exports = defineConfig([ + expoConfig, + { + ignores: ["dist/*"], + } +]); diff --git a/mobile/package-lock.json b/mobile/package-lock.json new file mode 100644 index 0000000..f533263 --- /dev/null +++ b/mobile/package-lock.json @@ -0,0 +1,11933 @@ +{ + "name": "countbone-mobile", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "countbone-mobile", + "version": "1.0.0", + "dependencies": { + "@noble/hashes": "^2.4.0", + "@react-native-async-storage/async-storage": "2.2.0", + "expo": "~57.0.25", + "expo-camera": "~57.0.5", + "expo-constants": "~57.0.19", + "expo-file-system": "~57.0.7", + "expo-font": "~57.0.4", + "expo-haptics": "~57.0.3", + "expo-linking": "~57.0.11", + "expo-network": "~57.0.2", + "expo-router": "~57.0.23", + "expo-sensors": "~57.0.3", + "expo-status-bar": "~57.0.1", + "lucide-react-native": "^1.48.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.86.3", + "react-native-gesture-handler": "~2.32.0", + "react-native-nitro-image": "^0.15.2", + "react-native-nitro-modules": "^0.37.1", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", + "react-native-svg": "15.15.4", + "react-native-vision-camera": "^5.2.3", + "react-native-vision-camera-worklets": "^5.2.3", + "react-native-web": "^0.21.2", + "react-native-worklets": "0.10.1" + }, + "devDependencies": { + "@types/react": "~19.2.2", + "eslint": "^9.0.0", + "eslint-config-expo": "~57.0.2", + "typescript": "~6.0.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", + "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", + "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", + "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", + "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.9.tgz", + "integrity": "sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@expo-google-fonts/material-symbols": { + "version": "0.4.48", + "resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.48.tgz", + "integrity": "sha512-aZztj44PyA/sj3TXSwwpLJwqMlTUJ49okbZJYURzEcuERxMQB0KbX7RrLBi8WJnuWz5HFOLlTRxORrJfNnUPNQ==", + "license": "MIT AND Apache-2.0" + }, + "node_modules/@expo/code-signing-certificates": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "license": "MIT", + "dependencies": { + "node-forge": "^1.3.3" + } + }, + "node_modules/@expo/config": { + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.9.tgz", + "integrity": "sha512-dmzlKraIFxa7wLwV6K7WzI8jp6QZpW6Mc5mGjLimJUFjzh4uQdYaT3m3plEutM5yxBoBEwqzks7l+I/ljCbxAQ==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~57.0.9", + "@expo/config-types": "^57.0.2", + "@expo/json-file": "^11.0.1", + "@expo/require-utils": "^57.0.5", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "node_modules/@expo/config-plugins": { + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.9.tgz", + "integrity": "sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^57.0.2", + "@expo/json-file": "~11.0.1", + "@expo/plist": "^0.8.1", + "@expo/require-utils": "^57.0.5", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/config-types": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-57.0.2.tgz", + "integrity": "sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g==", + "license": "MIT" + }, + "node_modules/@expo/devcert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "license": "MIT", + "dependencies": { + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" + } + }, + "node_modules/@expo/devcert/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@expo/devtools": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-57.0.1.tgz", + "integrity": "sha512-GyUf+wFNkbttaX0jR7MZa9bm77U0IrLg6d2AjpxdyoXw/w4abHoXG0oFufwLMgP9zLTd5+Ct4X/ffNUTnlzZgg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/dom-webview": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-57.0.1.tgz", + "integrity": "sha512-lAKsME4SAq+8sf56oN0DX5TBYyruupoRxbWbD2xf9RnKY8y6x8eb9LCE5pxSN0qyWdqnp+0wmyWzDkKboThKAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/env": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.3.tgz", + "integrity": "sha512-M1NXeZCA1mkMkYOyIe7PlyRX0/jqFtMoJgyblnlq/vpCRfmueFT7RnGSQG8uEFDF5WHOFGijAQ3fogPh3/n5Ng==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/@expo/expo-modules-macros-plugin": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@expo/expo-modules-macros-plugin/-/expo-modules-macros-plugin-0.6.1.tgz", + "integrity": "sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA==", + "license": "MIT" + }, + "node_modules/@expo/fingerprint": { + "version": "0.20.13", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.13.tgz", + "integrity": "sha512-h1x+w+JpNRu88a/5SMZRzJs9YDGR/f7R8ApCr7OdenhqmJ00hFxQMzvxajDLL+1P2Y2g/ND0xksusqvBqWSW5Q==", + "license": "MIT", + "dependencies": { + "@expo/env": "^2.4.3", + "@expo/spawn-async": "^1.8.0", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^10.2.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "bin": { + "fingerprint": "bin/cli.js" + } + }, + "node_modules/@expo/image-utils": { + "version": "0.11.5", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.5.tgz", + "integrity": "sha512-KPQBTpmpAfy/Vu9y4wPW808/qtZxjYmyJg8cm2QCPAupp+qEWA3b5zmk0ulOwQ9OgeHxuCPgUqWgkwHFo7UsrQ==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^57.0.5", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/inline-modules": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.1.7.tgz", + "integrity": "sha512-Bz/khd1gIJqDkje7t5ejD5e9jFbm4xEJzWSwRKadRo6gruepbw1xJ3Eb+e58OS8fY1ZNQYjzZbspnuph67sN0g==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~57.0.9" + } + }, + "node_modules/@expo/json-file": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.0.1.tgz", + "integrity": "sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/local-build-cache-provider": { + "version": "57.0.8", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-57.0.8.tgz", + "integrity": "sha512-SEdE0pAQrr90bRh3MNR0ZuwoIBw390cYdFgbn7Vk0Mtm9EHaBfP7kYj+2hXnXZJgOEm3/JMtfoD3rV7rWA9FGg==", + "license": "MIT", + "dependencies": { + "@expo/config": "~57.0.9", + "chalk": "^4.1.2" + } + }, + "node_modules/@expo/log-box": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-57.0.4.tgz", + "integrity": "sha512-IxwS9s1L2muj8mj8AQSuiy7u8OFJdc02NRFo2me/Tj6DiaeG5SREqmpBE4rQpR2cadqSg5jl8Qab8Cjie616dg==", + "license": "MIT", + "dependencies": { + "@expo/dom-webview": "^57.0.1", + "anser": "^1.4.9", + "stacktrace-parser": "^0.1.10" + }, + "peerDependencies": { + "@expo/dom-webview": "^57.0.1", + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/metro": { + "version": "56.0.2", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-56.0.2.tgz", + "integrity": "sha512-Ld5AeYMCCDa8bLeWhfuLbZFFjlV3f6ORqyPz2glGh6RltIngMuLf9BTC2yvHFjkKuGxL5SynijmA8xmNNWn5iA==", + "license": "MIT", + "dependencies": { + "metro": "0.84.5", + "metro-babel-transformer": "0.84.5", + "metro-cache": "0.84.5", + "metro-cache-key": "0.84.5", + "metro-config": "0.84.5", + "metro-core": "0.84.5", + "metro-file-map": "0.84.5", + "metro-minify-terser": "0.84.5", + "metro-resolver": "0.84.5", + "metro-runtime": "0.84.5", + "metro-source-map": "0.84.5", + "metro-symbolicate": "0.84.5", + "metro-transform-plugins": "0.84.5", + "metro-transform-worker": "0.84.5" + } + }, + "node_modules/@expo/metro-file-map": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-57.0.3.tgz", + "integrity": "sha512-1OXy+uPYY5uc7Tm4VBsd2NRn+3wHhqeqNuEO/Xo4kmYgv8FjYgUAc+bUXON9FpC2ikcLn4EVlGM9ce2exx9Mlg==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "fb-watchman": "^2.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + } + }, + "node_modules/@expo/metro-runtime": { + "version": "57.0.16", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.16.tgz", + "integrity": "sha512-x43WgZoICpp37BhDV7vixe0bNxbVPPGaBLHYc98ZJ8MU+UC2dk39Y2XvkfYMRRbAUSIkCIJiwdERnptlPg0Ckw==", + "license": "MIT", + "dependencies": { + "@expo/log-box": "^57.0.4", + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + }, + "peerDependencies": { + "@expo/log-box": "^57.0.4", + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@expo/osascript": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.1.tgz", + "integrity": "sha512-Zn03EX6In7ts2lPUW2ESUSkEhEWQN1qqsiXjadtZMJOuZRkMiAg1ZQHuvz9DjByDWNJ2pBwAGyrts9lj9k389g==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.8.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/package-manager": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.13.1.tgz", + "integrity": "sha512-y/K+CaYYpZpNGZhSX4HyLT/vyIunFjNfyoxNysPBCefeLKI/VCx6f9LNPzrxayr3rCYO5bl9O8H+HRQK265Nkg==", + "license": "MIT", + "dependencies": { + "@expo/json-file": "^11.0.1", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + } + }, + "node_modules/@expo/plist": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.8.1.tgz", + "integrity": "sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/prebuild-config": { + "version": "57.0.16", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.16.tgz", + "integrity": "sha512-QA8oMZ7y70kjOzw+abhRl9uYC8Nu/abeUTHr0YVxb3pgIC14gc7w543bYtI+5gEDI3q55jKUViIauC76QvZN1w==", + "license": "MIT", + "dependencies": { + "@expo/config": "~57.0.9", + "@expo/config-plugins": "~57.0.9", + "@expo/config-types": "^57.0.2", + "@expo/image-utils": "^0.11.5", + "@expo/json-file": "^11.0.1", + "@react-native/normalize-colors": "0.86.3", + "debug": "^4.3.1", + "expo-modules-autolinking": "~57.0.13", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/require-utils": { + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.5.tgz", + "integrity": "sha512-kTAXj9lDFEIPMsbAOGCGbjBbMF0oi7CqkYM79KOX0DDD9wSwXmlKL1z2h8OwsrBf7mbOo2DjlRvZu4BEjrIxGw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@expo/schema-utils": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-57.0.2.tgz", + "integrity": "sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw==", + "license": "MIT" + }, + "node_modules/@expo/sdk-runtime-versions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz", + "integrity": "sha512-Doz2bfiPndXYFPMRwPyGa1k5QaKDVpY806UJj570epIiMzWaYyCtobasyfC++qfIXVb5Ocy7r3tP9d62hAQ7IQ==", + "license": "MIT" + }, + "node_modules/@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", + "license": "MIT" + }, + "node_modules/@expo/ui": { + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.20.tgz", + "integrity": "sha512-0ChmuyWBwEy3JHMR5wDkJGyvkTR5zH3BTwjJrv3f2ymtamhpU9hD5uH4GsLoWuB6MJFOPTuchq4zByZbtgPyNw==", + "license": "MIT", + "dependencies": { + "sf-symbols-typescript": "^2.1.0", + "vaul": "^1.1.2" + }, + "peerDependencies": { + "@babel/core": "*", + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-worklets": "*" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-worklets": { + "optional": true + } + } + }, + "node_modules/@expo/xcpretty": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.5.tgz", + "integrity": "sha512-J3eL4n4h5QTwfD0SIz8OIk6/+sOL/hFZAMacgCM07UNlxBQfJipEpIC2AQxvGkbYeStByJ0TVhQAJo+DeNgaSQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "chalk": "^4.1.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "excpretty": "build/cli.js" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "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==", + "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==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, + "node_modules/@react-native-masked-view/masked-view": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", + "integrity": "sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16", + "react-native": ">=0.57" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.3.tgz", + "integrity": "sha512-TDhgCZA4wjJg84d5A9swiOQYPIWSKEEVdg9IwMFZDupQzW/F3QoLUrfAJOcalgqTDA9/buTB8awhE3Whwg6u9Q==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.86.3.tgz", + "integrity": "sha512-O6Xza4JBGPIU8J7YbKTyBoYL4thpy8jMW/oaLDWdAyOwYHKIjK47pAL5HUEbOe2bWz2PEKjbYRF2ApkJv1ottQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@react-native/codegen": "0.86.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/babel-preset": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.86.3.tgz", + "integrity": "sha512-/eqs/Hy9RZRcjdcs4wj3Cqmxvtb3NM5g+Uuh1RIvsjynMO8PRsrVWWLWgBcZL/jYUo+ogXd2NFB0W8L5Bg89xw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@react-native/babel-plugin-codegen": "0.86.3", + "babel-plugin-syntax-hermes-parser": "0.36.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.86.3.tgz", + "integrity": "sha512-Ux4jHi0fh+bdtVEcL0gaPLbY56V+SvFUDl/8sRAE1jdb4k+o7fT/4Nc29yz4X+qfjstkSqObQTMBGhdzxH9JvA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.29.0", + "hermes-parser": "0.36.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen/node_modules/hermes-estree": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", + "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", + "license": "MIT" + }, + "node_modules/@react-native/codegen/node_modules/hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", + "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.0" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.86.3.tgz", + "integrity": "sha512-qSDL9LQc5mZSZPNczT95WU9YQuPzxBklgON9vLhhqfI0yWIwKInqFx88dQ/uiEXBtf0yossthaQIqA4Ml6bF6g==", + "license": "MIT", + "dependencies": { + "@react-native/dev-middleware": "0.86.3", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.84.3", + "metro-config": "^0.84.3", + "metro-core": "^0.84.3", + "semver": "^7.1.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "0.86.3" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.86.3.tgz", + "integrity": "sha512-TQmeofQ0PcuylhhlleOeuzHYZfbrgm3gayXzowqUEzgRisTm1D40/J3ggqs7XkQi5HP5ZA3n8dHmKL9vIzPcsw==", + "license": "BSD-3-Clause", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/debugger-shell": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.86.3.tgz", + "integrity": "sha512-O4ds+J7xZfxkbih9T+cAGegBdvKSPKYJm/lDgC9CpEjFMkmzWTpVLU3Qsv9sqZuo58z+sGhIcJfPsCFFWHpqbQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.86.3.tgz", + "integrity": "sha512-LiEPTqTg/63bYUnrPyHLfjTDCNhA/+CUqI1+DsA9tYyewtSbULd5awsva6SgE10I+2iMhgKXS3ymkhU/kSCrGA==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.86.3", + "@react-native/debugger-shell": "0.86.3", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.3.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.86.3.tgz", + "integrity": "sha512-lxmx0GqLEWRIpZfpYFXlYVIs3ENQwaW6Vmp6oi29l2GoQJ1wZfFZRdMimDWlGEk8LKfHar3QH3iaPMkTcK9lEQ==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.3.tgz", + "integrity": "sha512-eYIJ0es967+tePBFQDnl/gidVFxLns3fnbiK6rxscQrGodvuUO6hwxpQnfNynJ8MjbbndImXihXjcnJdc7SzJg==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-babel-transformer": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.86.3.tgz", + "integrity": "sha512-0nlwVkG0uT9o72nrGTKUz/IqDnCkM4zE0ihe4r+OJcxH93yBdDm58AXJ3EXZeSjv3osHEFXU7ceajdZiS34mTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.25.2", + "@react-native/babel-preset": "0.86.3", + "hermes-parser": "0.36.0", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", + "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", + "license": "MIT", + "peer": true + }, + "node_modules/@react-native/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", + "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", + "license": "MIT", + "peer": true, + "dependencies": { + "hermes-estree": "0.36.0" + } + }, + "node_modules/@react-native/metro-config": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.86.3.tgz", + "integrity": "sha512-qdzDMepV2xdUhsO4XC7idnnbt8K6+Hd59AjeK9y4KS86fxSQq5oL0TAbqUuajDGR7beNQoYTuS6AowN/zP0oaQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@react-native/js-polyfills": "0.86.3", + "@react-native/metro-babel-transformer": "0.86.3", + "metro-config": "^0.84.3", + "metro-runtime": "^0.84.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.3.tgz", + "integrity": "sha512-Cv3CDkprb67GrzuaS9BGbBJC/6G4lIw3nyKOHRKTqTTum4bn37y5+R0Z04L8mcbQN85eEohNrRwb7IOM4j6uvg==", + "license": "MIT" + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.86.3.tgz", + "integrity": "sha512-1j44NEyNn05Ut40vHAmoSWbsIcybFkMAOBTwQt1PrESyfSS+qBoyU1LGIogNva0VIa0rQyEC5PzbA7R4/7Nhyw==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@types/react": "^19.2.0", + "react": "*", + "react-native": "0.86.3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/emscripten": { + "version": "1.41.6", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz", + "integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-test-renderer": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.3.0.tgz", + "integrity": "sha512-TX4p2D4Z2oigM3SXU7+QyLSUnSR0vR1S1Pfxm/qFL9nHQ3jclgi6ioGoWQmlIr1DeKRVmPJrmNlhgM0JlWlLUg==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agent-cli-detector": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.7.tgz", + "integrity": "sha512-d8OWDVdZMgjhLUT9ZPgSv/BdFFF9pVuscC0JdUSz3bjwE15gcp6u/o0/JooM2yyAWC49KThFhXlgTXRa9B7yng==", + "license": "MIT", + "bin": { + "agent-cli-detector": "dist/cli.js" + }, + "engines": { + "node": ">=18.18" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.2.0.tgz", + "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "es-shim-unscopables": "^1.1.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/babel-plugin-react-native-web": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.3.tgz", + "integrity": "sha512-e4fG8NbyRvk5GwU/7G2U9WxzPYtpC4IQN28AyRDzKE0kvywgohq8vT2x0/xBfg8a0RfSkXLlHAtUAGlSy3VhAw==", + "license": "MIT" + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz", + "integrity": "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.36.0" + } + }, + "node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-estree": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", + "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", + "license": "MIT" + }, + "node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", + "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.0" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/barcode-detector": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.2.tgz", + "integrity": "sha512-/4QOrrNrCRmDSBWiiP4aC72dnkuXUEdcFRidHgbPRXUpy82XcCMvJssI6fEs6JT42LS7DvBVZO70qasJbYKyrA==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.1.3" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.26", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", + "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.1.tgz", + "integrity": "sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.25", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.438", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001812", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001812.tgz", + "integrity": "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", + "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4" + } + }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "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==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dnssd-advertise": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.6.tgz", + "integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.439", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.439.tgz", + "integrity": "sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-expo": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/eslint-config-expo/-/eslint-config-expo-57.0.2.tgz", + "integrity": "sha512-dOWkx+MWclLVnYpPPBzas4sfnPaAjk+fe/dbvJSzbWtHUR4fGGaTT/lTcOpxqhwRIlTKKBUYGKude0tcTBsZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "^8.59.0", + "@typescript-eslint/parser": "^8.59.0", + "eslint-import-resolver-typescript": "^3.6.3", + "eslint-plugin-expo": "^1.1.0", + "eslint-plugin-import": "^2.30.0", + "eslint-plugin-react": "^7.37.3", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "^16.0.0" + }, + "peerDependencies": { + "eslint": ">=8.10" + } + }, + "node_modules/eslint-config-expo/node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.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/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-expo": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-expo/-/eslint-plugin-expo-1.1.0.tgz", + "integrity": "sha512-vPP0EPx7IA7ZfP49dY4rq9RV5jqkFWG+Pih3/oGjzIRjMI+ogcOE8i6isYkLXAdw/yvFV2BRZkTaQaiOGQqn6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.59.0", + "@typescript-eslint/utils": "^8.59.0", + "eslint": "^9.24.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "eslint": ">=8.10" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react-hooks/node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.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/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expo": { + "version": "57.0.25", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.25.tgz", + "integrity": "sha512-oqtX4PekGgU+pBQfsbnbwxh5tKG58o27mN13wTmAhRPlQyK9SHICfCNHxBo+uQyT1BlI3OLhqTwu1qKmHYL5Ug==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@expo/cli": "^57.0.27", + "@expo/config": "~57.0.9", + "@expo/config-plugins": "~57.0.9", + "@expo/devtools": "~57.0.1", + "@expo/dom-webview": "~57.0.1", + "@expo/fingerprint": "^0.20.13", + "@expo/local-build-cache-provider": "^57.0.8", + "@expo/log-box": "^57.0.4", + "@expo/metro": "~56.0.2", + "@expo/metro-config": "~57.0.12", + "@ungap/structured-clone": "^1.3.0", + "babel-preset-expo": "~57.0.13", + "expo-asset": "~57.0.18", + "expo-constants": "~57.0.19", + "expo-file-system": "~57.0.7", + "expo-font": "~57.0.4", + "expo-keep-awake": "~57.0.2", + "expo-modules-autolinking": "~57.0.13", + "expo-modules-core": "~57.0.19", + "pretty-format": "^29.7.0", + "react-refresh": "^0.14.2", + "whatwg-url-minimum": "^0.1.2" + }, + "bin": { + "expo": "bin/cli", + "expo-modules-autolinking": "bin/autolinking", + "fingerprint": "bin/fingerprint" + }, + "peerDependencies": { + "@expo/dom-webview": "*", + "@expo/metro-runtime": "*", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-web": "*", + "react-native-webview": "*" + }, + "peerDependenciesMeta": { + "@expo/dom-webview": { + "optional": true + }, + "@expo/metro-runtime": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-web": { + "optional": true + }, + "react-native-webview": { + "optional": true + } + } + }, + "node_modules/expo-camera": { + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-57.0.5.tgz", + "integrity": "sha512-0JaUXfDWMytmqm4pVKesZfLTSjAumfS2UGhnc1M4tqJ0qATCS0jxc7KjaoyKEzWJY+FEc8GlPCAGprS/7tNWZQ==", + "license": "MIT", + "dependencies": { + "barcode-detector": "^3.0.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-constants": { + "version": "57.0.19", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.19.tgz", + "integrity": "sha512-3eK1kZRwCdMizQLlM/PPlPwjaPnB5qKvdRDMBMnB1ZxmvWJBf9TB4uEO4DMMfIPaFUxYFs29dsR5fPr1gh3FTQ==", + "license": "MIT", + "dependencies": { + "@expo/env": "~2.4.3" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-file-system": { + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", + "integrity": "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", + "integrity": "sha512-7WOMC2oA6xCfOm8bbofqWn2bIqyWFm7YLEnpc/gA1MJH0Ll0TfT/3+CqRkoW2eNRWMS8sxrz5WukUmSvmNquHA==", + "license": "MIT", + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-glass-effect": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-glass-effect/-/expo-glass-effect-57.0.4.tgz", + "integrity": "sha512-/AG2Kljbqfgxu+GchI2YFIbxiF9cPsIpCy4iNDzlRiZk9rvR6BwRfB6gfQVu3V2ExaoGf2h5IsEnqVI5MYNBAg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-haptics": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-57.0.3.tgz", + "integrity": "sha512-qucUXtwtJdtECKSQK5D6TQBc/0SxKj7s5wQ4mrfSxhZsHrS9a4rcxINl/gbruMaY9spTXStco32ST4pV0vXmxw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-linking": { + "version": "57.0.11", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.11.tgz", + "integrity": "sha512-bfTyiPb8szacs8XkGowXt5ssH/vbF26vFG8WUWQar8CcRpQcIzXY980ZkP7pK7Pcdy5MattwFdujrWOxwv5s3Q==", + "license": "MIT", + "dependencies": { + "expo-constants": "~57.0.19", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-modules-autolinking": { + "version": "57.0.13", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.13.tgz", + "integrity": "sha512-Hj5NjZRlfccazhKab+wK19leRVUr1Y/PUERaRvegIbUSkq4GVxKq6lU9QHlKQyC16MdD/VS4iwsiLivB4o7lvQ==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^57.0.5", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.1.0", + "commander": "^7.2.0" + }, + "bin": { + "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + } + }, + "node_modules/expo-modules-core": { + "version": "57.0.19", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.19.tgz", + "integrity": "sha512-eAJwa5u30r4BTT8cFAcrY3809qG1cPszmEci7//+RjWGA1OMXtwirhVY1H+YtCJPyVGs9MuwrIjqQDMReguG2g==", + "license": "MIT", + "dependencies": { + "@expo/expo-modules-macros-plugin": "0.6.1", + "expo-modules-jsi": "~57.1.1", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-worklets": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0" + }, + "peerDependenciesMeta": { + "react-native-worklets": { + "optional": true + } + } + }, + "node_modules/expo-modules-jsi": { + "version": "57.1.1", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.1.1.tgz", + "integrity": "sha512-ssnwz3ZuIgCbZj5A3J6NlhQrXZojg+BjkeWrOWqVJhyMHlV/A/cRT7s986chKku97BFt6ucmDu6/BCzrV45Wcw==", + "license": "MIT", + "peerDependencies": { + "react-native": "*" + } + }, + "node_modules/expo-network": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-network/-/expo-network-57.0.2.tgz", + "integrity": "sha512-BfK1kdftZcM6o9o1FAG7/y0l6vWX0n8td0M5lwkvaMeRXZ9LkRwOi9CQXwvsMTQGQiPwtQXgwiAze13cqjt75g==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-router": { + "version": "57.0.23", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.23.tgz", + "integrity": "sha512-u/iWSdQovEma/owymgz8etxGAif5ZrC8YdXYeGQhDRZwXpAvraordYV0SWej1QWWbYoIkgXcoqHVkgsAff8P4w==", + "license": "MIT", + "dependencies": { + "@expo/log-box": "^57.0.4", + "@expo/metro-runtime": "^57.0.16", + "@expo/schema-utils": "^57.0.2", + "@expo/ui": "^57.0.20", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-tabs": "^1.1.12", + "@react-native-masked-view/masked-view": "^0.3.2", + "client-only": "^0.0.1", + "color": "^4.2.3", + "debug": "^4.3.4", + "escape-string-regexp": "^4.0.0", + "expo-glass-effect": "^57.0.4", + "expo-server": "^57.0.3", + "expo-symbols": "^57.0.3", + "fast-deep-equal": "^3.1.3", + "invariant": "^2.2.4", + "nanoid": "^3.3.18", + "query-string": "^7.1.3", + "react-fast-compare": "^3.2.2", + "react-is": "^19.1.0", + "react-native-drawer-layout": "^4.2.2", + "react-native-screens": "^4.26.0", + "server-only": "^0.0.1", + "sf-symbols-typescript": "^2.1.0", + "shallowequal": "^1.1.0", + "standard-navigation": "^0.0.5", + "vaul": "^1.1.2" + }, + "peerDependencies": { + "@expo/log-box": "^57.0.4", + "@expo/metro-runtime": "^57.0.16", + "@testing-library/react-native": ">= 13.2.0", + "expo": "*", + "expo-constants": "^57.0.19", + "expo-linking": "^57.0.11", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-gesture-handler": "*", + "react-native-reanimated": "*", + "react-native-safe-area-context": ">= 5.4.0", + "react-native-screens": "^4.26.0", + "react-native-web": "*", + "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" + }, + "peerDependenciesMeta": { + "@testing-library/react-native": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-gesture-handler": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + }, + "react-native-web": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-collection": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/@radix-ui/react-tabs": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/expo-router/node_modules/react-is": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.3.0.tgz", + "integrity": "sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==", + "license": "MIT" + }, + "node_modules/expo-sensors": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-sensors/-/expo-sensors-57.0.3.tgz", + "integrity": "sha512-3MmJ37S/fbN+Ejk9gCeo5pa50hMkW3BYe7IPZX1tKi0Tw1Wvy/FY9FKnVQowOacrDqyWEpmLGgwi3HmDY7DCnw==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-server": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz", + "integrity": "sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ==", + "license": "MIT", + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/expo-status-bar": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz", + "integrity": "sha512-Xwaq1gAoVRWx5dPG5VhT5RSbnI9OilhZnO5qoPBnUaBAa5VzRzfdS8q0/bsPt0jR2DKLtGuP0bQ6efMJ4RIMDg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-symbols": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-symbols/-/expo-symbols-57.0.3.tgz", + "integrity": "sha512-uuK5yHLVR7kExUCrO/ItBvp/kp1maQ9Nkb6KvNy5ZUQnN0NMKwuhJ4GvbfS9N3ACmvCjTT4gKvg4yH5UuKIYNA==", + "license": "MIT", + "dependencies": { + "@expo-google-fonts/material-symbols": "^0.4.1", + "sf-symbols-typescript": "^2.0.0" + }, + "peerDependencies": { + "expo": "*", + "expo-font": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo/node_modules/@expo/cli": { + "version": "57.0.27", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.27.tgz", + "integrity": "sha512-Jauk4chxmpVG5ElrMLTCwjFP20jbYU7PB4l3LYV8j+4e/3HU5jdl7qTQ4eVy1AaDUE9Ia2NCQ5A9kar2V1gaLg==", + "license": "MIT", + "dependencies": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~57.0.9", + "@expo/config-plugins": "~57.0.9", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.4.3", + "@expo/image-utils": "^0.11.5", + "@expo/inline-modules": "^0.1.7", + "@expo/json-file": "^11.0.1", + "@expo/log-box": "^57.0.4", + "@expo/metro": "~56.0.2", + "@expo/metro-config": "~57.0.12", + "@expo/metro-file-map": "^57.0.3", + "@expo/osascript": "^2.7.1", + "@expo/package-manager": "^1.13.1", + "@expo/plist": "^0.8.1", + "@expo/prebuild-config": "^57.0.16", + "@expo/require-utils": "^57.0.5", + "@expo/router-server": "^57.0.11", + "@expo/schema-utils": "^57.0.2", + "@expo/spawn-async": "^1.8.0", + "@expo/ws-tunnel": "^2.0.0", + "@expo/xcpretty": "^4.4.4", + "@react-native/dev-middleware": "0.86.3", + "accepts": "^1.3.8", + "agent-cli-detector": "0.1.7", + "arg": "^5.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "dnssd-advertise": "^1.1.4", + "expo-server": "^57.0.3", + "fetch-nodeshim": "^0.4.10", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.1", + "multitars": "^1.0.2", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.4", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "resolve-from": "^5.0.0", + "sandbox-cli-detector": "^0.2.0", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "terminal-link": "^2.1.1", + "toqr": "^0.1.1", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1", + "zod": "^3.25.76" + }, + "bin": { + "expo-internal": "main.js" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { + "version": "57.0.11", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.11.tgz", + "integrity": "sha512-0QxbqEkceb1WDeu5VqNdnTPVHlm3U183F7Y1t5LMohmGRAkHVL0PB4tCHBvEqKpLzOzHVObw3reGkJ1tX48h8Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "@expo/metro-runtime": "^57.0.16", + "expo": "*", + "expo-constants": "^57.0.19", + "expo-font": "^57.0.4", + "expo-router": "*", + "expo-server": "^57.0.3", + "react": "*", + "react-dom": "*", + "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" + }, + "peerDependenciesMeta": { + "@expo/metro-runtime": { + "optional": true + }, + "expo-router": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@expo/metro-config": { + "version": "57.0.12", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.12.tgz", + "integrity": "sha512-S62Lrq35HZqBFD55423pmWb8PjaiR/W02zQC1uECBmw1vTN8WZaFz4TJ0i21EeJzwfebMb9MLxL8JZ102Z6VbA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~57.0.9", + "@expo/env": "~2.4.3", + "@expo/json-file": "~11.0.1", + "@expo/metro": "~56.0.2", + "@expo/require-utils": "^57.0.5", + "@expo/spawn-async": "^1.8.0", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.5.5", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.36.0", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@expo/ws-tunnel": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz", + "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==", + "license": "MIT", + "peerDependencies": { + "ws": "^8.0.0" + } + }, + "node_modules/expo/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expo/node_modules/babel-preset-expo": { + "version": "57.0.13", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.13.tgz", + "integrity": "sha512-OOl3lqNQ4IagJfsNgtxuna0wpTWt1BswdyktGTOJ5EJCgdQUk/B2vaj/jsl03+kMyuIvpxKioVtbTzxh3mdvvQ==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.20.5", + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.28.6", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-plugin-codegen": "0.86.3", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.36.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "expo-widgets": "^57.0.21", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + }, + "expo-widgets": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/expo/node_modules/expo-asset": { + "version": "57.0.18", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.18.tgz", + "integrity": "sha512-zDIQkUr0NM2S7YRj98Zohf/OBuM1q0o4ctxLz1C1oe4/33lHNLjp+2v3qUtBWVwn0zKka8UGADmS8M+83Wi6pw==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.5", + "expo-constants": "~57.0.19" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo/node_modules/expo-keep-awake": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", + "integrity": "sha512-GqgH746wtJImmsbyHmCQoi7cOslKuWQBbJHXr53S35Bpf5UvxCAztvnitUEd7D0QvFN2rvkOhylbZpgZZfBLlQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo/node_modules/hermes-estree": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.1.tgz", + "integrity": "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==", + "license": "MIT" + }, + "node_modules/expo/node_modules/hermes-parser": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.1.tgz", + "integrity": "sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.1" + } + }, + "node_modules/expo/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expo/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expo/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expo/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/expo/node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "license": "MIT" + }, + "node_modules/fbjs/node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/fetch-nodeshim": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", + "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT" + }, + "node_modules/fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", + "license": "BSD-2-Clause" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-compiler": { + "version": "250829098.0.17", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.17.tgz", + "integrity": "sha512-qG1PXzTEtriF6oQLZF3vyHhSMxOdW5h2TqqLri0rdpstPustd2fSvRZQMVAPdlhgFwBfYnj3OUZtiO6LjYsEFw==", + "license": "MIT" + }, + "node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-prefixer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", + "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", + "license": "MIT", + "dependencies": { + "css-in-js-utils": "^3.1.0" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jimp-compact": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", + "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lan-network": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", + "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==", + "license": "MIT", + "bin": { + "lan-network": "dist/lan-network-cli.js" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "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.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "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.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react-native": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.48.0.tgz", + "integrity": "sha512-/URnAnnF+Nshnu1OXSvSuT+boZ7pjr+zYLjQtW4GL72IxzcecIT5mC7X5i35mWBz4bHrnIDGitIR8Wt9clN9Hw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-native": "*", + "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/metro": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.5.tgz", + "integrity": "sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.84.5", + "metro-cache": "0.84.5", + "metro-cache-key": "0.84.5", + "metro-config": "0.84.5", + "metro-core": "0.84.5", + "metro-file-map": "0.84.5", + "metro-resolver": "0.84.5", + "metro-runtime": "0.84.5", + "metro-source-map": "0.84.5", + "metro-symbolicate": "0.84.5", + "metro-transform-plugins": "0.84.5", + "metro-transform-worker": "0.84.5", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.5.tgz", + "integrity": "sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.84.5", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-cache": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.5.tgz", + "integrity": "sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.84.5" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-cache-key": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.5.tgz", + "integrity": "sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-config": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.5.tgz", + "integrity": "sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.84.5", + "metro-cache": "0.84.5", + "metro-core": "0.84.5", + "metro-runtime": "0.84.5", + "yaml": "^2.6.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-core": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.5.tgz", + "integrity": "sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.84.5" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-file-map": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.5.tgz", + "integrity": "sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-minify-terser": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.5.tgz", + "integrity": "sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-resolver": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.5.tgz", + "integrity": "sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-runtime": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.5.tgz", + "integrity": "sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-source-map": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.5.tgz", + "integrity": "sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.84.5", + "nullthrows": "^1.1.1", + "ob1": "0.84.5", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.5.tgz", + "integrity": "sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.84.5", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.5.tgz", + "integrity": "sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.5.tgz", + "integrity": "sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.84.5", + "metro-babel-transformer": "0.84.5", + "metro-cache": "0.84.5", + "metro-cache-key": "0.84.5", + "metro-minify-terser": "0.84.5", + "metro-source-map": "0.84.5", + "metro-transform-plugins": "0.84.5", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "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==", + "license": "MIT" + }, + "node_modules/multitars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.2.tgz", + "integrity": "sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "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/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm-package-arg": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", + "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT" + }, + "node_modules/ob1": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.5.tgz", + "integrity": "sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-png": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", + "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", + "license": "MIT", + "dependencies": { + "pngjs": "^3.3.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/plist/node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "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.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-freeze": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz", + "integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=17.0.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-native": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "integrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "license": "MIT", + "dependencies": { + "@react-native/assets-registry": "0.86.3", + "@react-native/codegen": "0.86.3", + "@react-native/community-cli-plugin": "0.86.3", + "@react-native/gradle-plugin": "0.86.3", + "@react-native/js-polyfills": "0.86.3", + "@react-native/normalize-colors": "0.86.3", + "@react-native/virtualized-lists": "0.86.3", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-plugin-syntax-hermes-parser": "0.36.0", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "hermes-compiler": "250829098.0.17", + "invariant": "^2.2.4", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.84.3", + "metro-source-map": "^0.84.3", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.27.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "tinyglobby": "^0.2.15", + "whatwg-fetch": "^3.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@react-native/jest-preset": "0.86.3", + "@types/react": "^19.1.1", + "react": "^19.2.3" + }, + "peerDependenciesMeta": { + "@react-native/jest-preset": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-drawer-layout": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.10.tgz", + "integrity": "sha512-O6TQdZ5LSm3dqnuR4rX9KPtE+9dVg7jsezEYz1l01rkQTe4fQvYZIoPu2sZFl5X2N70uhRdjnPULySVR3sBUwA==", + "license": "MIT", + "dependencies": { + "color": "^4.2.3", + "use-latest-callback": "^0.2.4" + }, + "peerDependencies": { + "react": ">= 18.2.0", + "react-native": "*", + "react-native-gesture-handler": ">= 2.0.0", + "react-native-reanimated": ">= 2.0.0" + } + }, + "node_modules/react-native-gesture-handler": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.32.0.tgz", + "integrity": "sha512-uYIMOKlKENORq2SABE+jIjbPU+h5I/sQKcq2v16zRq848nwEp1fWRVwML4QWqijc8UcXJC25o54S8GQd4Mf2OA==", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "@types/react-test-renderer": "^19.1.0", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-is-edge-to-edge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", + "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-nitro-image": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/react-native-nitro-image/-/react-native-nitro-image-0.15.2.tgz", + "integrity": "sha512-WiaGnORmfNIIqtRJW76TWYNet1YZx1MWDlSOxd0jfcIBN4KjkVVMDVpvO01eCfpxKVG0ez9nA2iLQkPVlGFj+A==", + "license": "MIT", + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*" + }, + "peerDependenciesMeta": { + "react-native-nitro-modules": { + "optional": true + } + } + }, + "node_modules/react-native-nitro-modules": { + "version": "0.37.1", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.37.1.tgz", + "integrity": "sha512-KpW6EQVQ/bfegpCGxN9Be+ndvsfj56t4IESEvCASu9gWpJa/NDfHpIeIwCOvZQ3eXmLEj6YD4wCLqcC1FJPr2w==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-reanimated": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.5.1.tgz", + "integrity": "sha512-RnMvtDuR+68ig864gAvZCOdZehqhC5rFmMo0kn+ARfgVSTvFeF6IFLBVgMPUu0KwihaapEyW24WRi6nEyy1kSA==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.3.1", + "semver": "^7.7.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "0.83 - 0.86", + "react-native-worklets": "0.10.x" + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", + "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-screens": { + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.26.2.tgz", + "integrity": "sha512-2XnWsZToKj76trGtEZzx5ELD/qOICFEprEeUntImmitQFVUkea27fiWdUSITArI356Y1qynpXZINW+Unbhky/A==", + "license": "MIT", + "dependencies": { + "react-freeze": "^1.0.0", + "warn-once": "^0.1.0" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-svg": { + "version": "15.15.4", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz", + "integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-vision-camera": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-5.2.3.tgz", + "integrity": "sha512-OHjgM8BPzxppTqbF1FEltj+fxvisj4xdghM7ypoT1p6cOe28HByhOJP1c7LkBiUiZuotbsdbQwGdJ8nU7ZvPzA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-image": "*", + "react-native-nitro-modules": "*" + }, + "peerDependenciesMeta": { + "react-native-nitro-modules": { + "optional": true + } + } + }, + "node_modules/react-native-vision-camera-worklets": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/react-native-vision-camera-worklets/-/react-native-vision-camera-worklets-5.2.3.tgz", + "integrity": "sha512-POwjbl1853KFSWYw3/yb3NnRpCWjg7w4dS5LvFVHeMGvPv1Qyu5CCOqYKcB3TUJlg3L4Tn/iBgs0p1NHcCrmxA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*", + "react-native-vision-camera": "*", + "react-native-worklets": "*" + }, + "peerDependenciesMeta": { + "react-native-nitro-modules": { + "optional": true + } + } + }, + "node_modules/react-native-web": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.3.tgz", + "integrity": "sha512-ozvZM0z8UWfduoKnYx03KUccM2LdHNaW8nNJu4Md0glyqWL+Ysa6dRZzMOO6Yp/hhTYT9yluwjknpd9l04uvDw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^7.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { + "version": "0.74.89", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz", + "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==", + "license": "MIT" + }, + "node_modules/react-native-web/node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/react-native-worklets": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz", + "integrity": "sha512-62mRM19bDpfpdI8HLkEErcdOsrAPDtE9lA/sw+5lLRpzBHNhxaoj9QyY2KjXqUmirelxkX4zuPGTC3VdA0feJA==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/preset-typescript": "^7.28.5", + "@babel/types": "^7.27.1", + "convert-source-map": "^2.0.0", + "semver": "^7.7.4" + }, + "peerDependencies": { + "@babel/core": "*", + "@react-native/metro-config": "*", + "react": "*", + "react-native": "0.83 - 0.86" + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.3.tgz", + "integrity": "sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "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/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve-workspace-root": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", + "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", + "license": "MIT" + }, + "node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sandbox-cli-detector": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sandbox-cli-detector/-/sandbox-cli-detector-0.2.0.tgz", + "integrity": "sha512-4lyHX0ZU0AZKwjgZ1InxZAa3PNpyEb8rOQ+Zss1ReYmhNzW0Q+h1zE5nvniXN0HaAWZaZE1zgVNEirb0R7LmNg==", + "license": "MIT", + "bin": { + "sandbox-cli-detector": "dist/cli.js" + }, + "engines": { + "node": ">=18.18" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sf-symbols-typescript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/sf-symbols-typescript/-/sf-symbols-typescript-2.2.0.tgz", + "integrity": "sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slugify": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/standard-navigation": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/standard-navigation/-/standard-navigation-0.0.5.tgz", + "integrity": "sha512-YAmzwAiiQVocZxO/VGPFiQHcu5pKiz09QIGC0MK6aRMoa3E0QkoTQgcqJr7ZZ3OMiNhu4DkaGElFI5htjOIDbw==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", + "integrity": "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "get-intrinsic": "^1.3.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.4", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/structured-headers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", + "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", + "license": "MIT" + }, + "node_modules/styleq": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", + "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "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/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toqr": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/toqr/-/toqr-0.1.1.tgz", + "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", + "license": "MIT" + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest-callback": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz", + "integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-dialog": { + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-portal": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-presence": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vaul/node_modules/@radix-ui/react-primitive": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT" + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/whatwg-url-minimum": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz", + "integrity": "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", + "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zxing-wasm": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.3.tgz", + "integrity": "sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.8.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/mobile/package.json b/mobile/package.json new file mode 100644 index 0000000..ff44722 --- /dev/null +++ b/mobile/package.json @@ -0,0 +1,51 @@ +{ + "name": "countbone-mobile", + "version": "1.0.0", + "main": "expo-router/entry", + "dependencies": { + "@noble/hashes": "^2.4.0", + "@react-native-async-storage/async-storage": "2.2.0", + "expo": "~57.0.25", + "expo-camera": "~57.0.5", + "expo-constants": "~57.0.19", + "expo-file-system": "~57.0.7", + "expo-font": "~57.0.4", + "expo-haptics": "~57.0.3", + "expo-linking": "~57.0.11", + "expo-network": "~57.0.2", + "expo-router": "~57.0.23", + "expo-sensors": "~57.0.3", + "expo-status-bar": "~57.0.1", + "lucide-react-native": "^1.48.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.86.3", + "react-native-gesture-handler": "~2.32.0", + "react-native-nitro-image": "^0.15.2", + "react-native-nitro-modules": "^0.37.1", + "react-native-reanimated": "4.5.1", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "~4.26.0", + "react-native-svg": "15.15.4", + "react-native-vision-camera": "^5.2.3", + "react-native-vision-camera-worklets": "^5.2.3", + "react-native-web": "^0.21.2", + "react-native-worklets": "0.10.1" + }, + "devDependencies": { + "@types/react": "~19.2.2", + "eslint": "^9.0.0", + "eslint-config-expo": "~57.0.2", + "typescript": "~6.0.3" + }, + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "typecheck": "tsc --noEmit", + "test": "node --no-warnings --test src/analysis/analysis.test.ts", + "lint": "expo lint" + }, + "private": true +} diff --git a/mobile/src/analysis/analysis.test.ts b/mobile/src/analysis/analysis.test.ts new file mode 100644 index 0000000..03dc7ec --- /dev/null +++ b/mobile/src/analysis/analysis.test.ts @@ -0,0 +1,233 @@ +/// +/** + * The analysis core is plain TypeScript, so it is tested under Node directly: + * npm test + * Frames here are synthetic with known answers: a shelf pattern we shift by + * a known amount, blur by a known kernel, or darken to a known level. + */ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; + +import { GuidanceEngine, THRESHOLDS } from './guidance.ts'; +import { + blurAtBackendScale, + downsample, + edgeBoxes, + estimateShift, + projections, + rotateLuma, + sampleFrame, + type FrameSample, + type Luma, +} from './metrics.ts'; + +const W = 960; +const H = 540; + +/** A shelf: grey wall, dark-outlined cartons with a pale label, repeating. */ +function shelf(offset = 0, value = 1): Uint8Array { + const img = new Uint8Array(W * H).fill(Math.round(150 * value)); + const pitch = 142; + for (let row = 0; row < 3; row++) { + const y0 = 30 + row * 175; + for (let k = -2; k < 10; k++) { + // Irregular widths so the pattern is not perfectly periodic. + const x0 = k * pitch + ((k * 37) % 23) - offset; + const bw = 96 + ((k * 13) % 9); + for (let y = y0; y < y0 + 130; y++) { + for (let x = Math.max(0, x0); x < Math.min(W, x0 + bw); x++) { + const edge = y < y0 + 3 || y >= y0 + 127 || x < x0 + 3 || x >= x0 + bw - 3; + const label = y > y0 + 24 && y < y0 + 52 && x > x0 + 16 && x < x0 + bw - 16; + img[y * W + x] = Math.round((edge ? 30 : label ? 235 : 60 + ((k * 40 + row * 70) % 120)) * value); + } + } + } + } + return img; +} + +/** Separable box blur, radius r. */ +function blur(img: Uint8Array, r: number): Uint8Array { + const tmp = new Float32Array(W * H); + const out = new Uint8Array(W * H); + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + let s = 0; + let n = 0; + for (let d = -r; d <= r; d++) { + const xx = x + d; + if (xx >= 0 && xx < W) { + s += img[y * W + xx]!; + n++; + } + } + tmp[y * W + x] = s / n; + } + } + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + let s = 0; + let n = 0; + for (let d = -r; d <= r; d++) { + const yy = y + d; + if (yy >= 0 && yy < H) { + s += tmp[yy * W + x]!; + n++; + } + } + out[y * W + x] = s / n; + } + } + return out; +} + +test('blur separates sharp from blurred on the backend scale', () => { + const sharp = blurAtBackendScale(shelf(), W, H, W, 1); + const soft = blurAtBackendScale(blur(shelf(), 6), W, H, W, 1); + assert.ok(sharp > THRESHOLDS.blurBad * 4, `sharp ${sharp}`); + assert.ok(soft < THRESHOLDS.blurBad, `blurred ${soft}`); +}); + +test('blur is resolution-independent: a 2x source reads the same', () => { + const small = shelf(); + const big = new Uint8Array(W * 2 * H * 2); + for (let y = 0; y < H * 2; y++) for (let x = 0; x < W * 2; x++) big[y * W * 2 + x] = small[(y >> 1) * W + (x >> 1)]!; + const a = blurAtBackendScale(small, W, H, W, 1); + const b = blurAtBackendScale(big, W * 2, H * 2, W * 2, 1); + assert.ok(Math.abs(a - b) / a < 0.1, `${a} vs ${b}`); +}); + +test('RGBA input gives the same luma metrics as grayscale', () => { + const g = shelf(); + const rgba = new Uint8Array(W * H * 4); + for (let i = 0; i < W * H; i++) rgba.set([g[i]!, g[i]!, g[i]!, 255], i * 4); + const a = sampleFrame(g, W, H, W, 1, 0, false); + const b = sampleFrame(rgba, W, H, W * 4, 4, 0, false); + assert.ok(Math.abs(a.brightness - b.brightness) < 1); + assert.ok(Math.abs(a.blur - b.blur) / a.blur < 0.02); +}); + +test('horizontal shift is recovered, including past one carton pitch of aliasing', () => { + const a = projections(downsample(shelf(0), W, H, W, 1)); + for (const px of [6, 24, 48]) { + const b = projections(downsample(shelf(px), W, H, W, 1)); + // px at 960 is px/6 at the 160 px analysis width; content moves left. + const s = estimateShift(a.cols, b.cols, 40, -px / 6); + assert.ok(Math.abs(s.shift - -px / 6) < 1, `moved ${px}: got ${s.shift}`); + assert.ok(s.confidence > 0.3, `confidence ${s.confidence}`); + } +}); + +test('edge preview outlines cartons and drops their labels', () => { + let full = 0; // cartons wholly inside the frame, from the same layout as shelf() + for (let row = 0; row < 3; row++) { + for (let k = -2; k < 10; k++) { + const x0 = k * 142 + ((k * 37) % 23); + if (x0 > 6 && x0 + 96 + ((k * 13) % 9) < W - 6) full++; + } + } + const boxes = edgeBoxes(downsample(shelf(), W, H, W, 1)); + // A preview at 160 px: most cartons, never more than exist. + assert.ok(boxes.length >= full - 3 && boxes.length <= full, `found ${boxes.length} of ${full}`); + // No box sits inside another (labels are nested inside cartons). + for (const b of boxes) { + const nested = boxes.some((o) => o !== b && b.x >= o.x && b.y >= o.y && b.x + b.w <= o.x + o.w && b.y + b.h <= o.y + o.h); + assert.equal(nested, false); + } +}); + +// -- guidance ------------------------------------------------------------- + +function run(frames: (i: number) => Uint8Array, n: number, fps = 10, opts: { tilt?: number; recording?: boolean } = {}) { + const engine = new GuidanceEngine(); + let g; + for (let i = 0; i < n; i++) { + const s: FrameSample = sampleFrame(frames(i), W, H, W, 1, i / fps, false); + g = engine.update({ sample: s, tiltDeg: opts.tilt ?? null, recording: opts.recording ?? true }); + } + return g!; +} + +test('a steady, sharp, well-lit walk is "Quality good"', () => { + const g = run((i) => shelf(i * 6), 20); // 6 px/frame at 10 fps = 0.06 frame widths/s + assert.equal(g.status, 'good', JSON.stringify(g.cues)); + assert.equal(g.label, 'Quality good'); +}); + +test('walking too fast raises "Too fast"', () => { + const g = run((i) => shelf(i * 90), 12); // 0.94 frame widths/s + assert.equal(g.pace, 'bad'); + assert.ok(g.cues.some((c) => c.id === 'fast'), JSON.stringify(g.cues)); +}); + +test('a blurred but still view blames the lens, after it persists', () => { + const soft = blur(shelf(), 6); + const early = run(() => soft, 5); // 0.4 s: not yet + assert.ok(!early.cues.some((c) => c.id === 'lens')); + const later = run(() => soft, 20); // 1.9 s + assert.ok(later.cues.some((c) => c.id === 'lens'), JSON.stringify(later.cues)); +}); + +test('darkness and tilt produce their cues', () => { + const dark = run(() => shelf(0, 0.25), 8); + assert.ok(dark.cues.some((c) => c.id === 'dark' && c.level === 'bad')); + const tilted = run((i) => shelf(i * 6), 8, 10, { tilt: -30 }); + assert.ok(tilted.cues.some((c) => c.id === 'tilt-up' && c.arrow === 'up')); +}); + +test('walking back over counted stock is flagged', () => { + // 1.5 s rightwards establishes the direction, then back at ~0.19 frame widths/s. + const g = run((i) => shelf(i < 15 ? i * 12 : 180 - (i - 15) * 18), 25); + assert.equal(g.hud.direction, 'right'); + assert.ok(g.cues.some((c) => c.id === 'reverse'), JSON.stringify(g.cues)); +}); + +// -- orientation ---------------------------------------------------------- + +/** Rotate a full frame 90° anticlockwise: what a portrait phone's landscape sensor delivers. */ +function sensorBuffer(upright: Uint8Array): Uint8Array { + const out = new Uint8Array(W * H); + // Output is H wide and W tall; output (x, y) = upright (W - 1 - y, x). + for (let y = 0; y < W; y++) for (let x = 0; x < H; x++) out[y * H + x] = upright[x * W + (W - 1 - y)]!; + return out; +} + +test('rotateLuma is a clockwise quarter turn and four of them are the identity', () => { + const l = { data: Uint8Array.from([1, 2, 3, 4, 5, 6]), width: 3, height: 2 }; + // 1 2 3 4 1 + // 4 5 6 -> 5 2 + // 6 3 + assert.deepEqual([...rotateLuma(l, 90).data], [4, 1, 5, 2, 6, 3]); + let r: Luma = l; + for (let i = 0; i < 4; i++) r = rotateLuma(r, 90); + assert.deepEqual([...r.data], [...l.data]); + assert.deepEqual([...rotateLuma(l, 180).data], [6, 5, 4, 3, 2, 1]); + assert.deepEqual([...rotateLuma(rotateLuma(l, 90), 270).data], [...l.data]); +}); + +test('a walk filmed on a sideways sensor reads as horizontal once rotated', () => { + const engine = new GuidanceEngine(); + let g; + for (let i = 0; i < 20; i++) { + const buf = sensorBuffer(shelf(i * 12)); // H wide, W tall + const s = sampleFrame(buf, H, W, H, 1, i / 10, false, 90); + assert.equal(s.srcWidth, W); + assert.equal(s.srcHeight, H); + g = engine.update({ sample: s, tiltDeg: null, recording: true }); + } + // Content moves left at 0.125 frame widths/s: a steady walk to the right. + assert.ok(g!.hud.speed < -0.08, `speed ${g!.hud.speed}`); + assert.ok(Math.abs(g!.hud.vertical) < 0.05, `vertical ${g!.hud.vertical}`); + assert.equal(g!.hud.direction, 'right'); +}); + +test('outlines persist between measurements and follow the pan', () => { + const engine = new GuidanceEngine(); + const a = engine.update({ sample: sampleFrame(shelf(0), W, H, W, 1, 0, true), tiltDeg: null, recording: false }); + assert.ok(a.boxes.length > 5, `measured ${a.boxes.length}`); + // Next sample skips outlines; the content moved 24 px left (0.025 of the width). + const b = engine.update({ sample: sampleFrame(shelf(24), W, H, W, 1, 0.1, false), tiltDeg: null, recording: false }); + assert.equal(b.boxes.length, a.boxes.length); + const moved = b.boxes[0]!.x - a.boxes[0]!.x; + assert.ok(Math.abs(moved - -24 / W) < 0.01, `moved ${moved}`); +}); diff --git a/mobile/src/analysis/guidance.ts b/mobile/src/analysis/guidance.ts new file mode 100644 index 0000000..89604ab --- /dev/null +++ b/mobile/src/analysis/guidance.ts @@ -0,0 +1,329 @@ +/** + * Live capture guidance: turns per-frame samples into what the operator + * should do right now. + * + * Every threshold is tied to what the countbone backend will do with the + * footage, so a warning on the phone means "this would hurt the count": + * + * - Blur uses the quality gate's own threshold (min_blur 45) on the same + * 960 px-scale measure (see metrics.blurAtBackendScale). + * - Light mirrors the gate's brightness band (35..225) and clipped limit + * (0.28), warning a little before the gate would drop a frame. + * - Pace: the backend samples every 5th frame (6/s at 30 fps) and needs an + * object in >= 2 kept frames; motion blur at a 1/60 s shutter grows by + * ~16 px per frame-width/s at 960 px. Past ~0.8 frame-widths/s objects get + * too few sharp sightings; 0.45 is the comfortable ceiling. + * - Reversing: the tracker forgets an object a few samples after it leaves + * view, so walking back over counted stock counts it again. + */ + +import { estimateScale, estimateShift, type Box, type FrameSample } from './metrics.ts'; + +export type Level = 'good' | 'caution' | 'bad'; +export type Arrow = 'left' | 'right' | 'up' | 'down' | 'in' | 'out'; + +export type CueId = + | 'lens' + | 'fast' + | 'soft' + | 'dark' + | 'glare' + | 'reverse' + | 'tilt-up' + | 'tilt-down' + | 'level' + | 'closer' + | 'back' + | 'aim'; + +export interface Cue { + id: CueId; + level: Exclude; + title: string; + detail: string; + arrow?: Arrow; +} + +export interface Guidance { + status: Level; + /** Short status for the pill: "Quality good", "Too fast", "Blurry", ... */ + label: string; + pace: Level; + cues: Cue[]; + hud: { + fps: number; + blur: number; + /** 0..1 mean luminance. */ + light: number; + /** Frame widths per second, signed (+ = scene moving right = camera panning left). */ + speed: number; + /** Frame heights per second, signed. */ + vertical: number; + /** Camera scan direction once established. */ + direction: 'left' | 'right' | null; + }; + boxes: Box[]; +} + +export const THRESHOLDS = { + blurBad: 45, // quality_gate min_blur + blurCaution: 90, + lensSeconds: 1.2, // blur must persist this long (while slow) to blame the lens + darkBad: 45, + darkCaution: 60, + brightCaution: 210, + clippedBad: 0.2, + paceCaution: 0.45, + paceBad: 0.8, + directionSpeed: 0.08, // below this the operator is effectively still + directionSeconds: 1.0, + reverseSpeed: 0.1, // walking back slowly still re-counts; just above the noise floor + verticalCaution: 0.15, + tiltCaution: 15, // degrees from upright + tiltBad: 25, + distanceBand: 0.12, // |log scale| drift since the recording started + contrastMin: 10, +} as const; + +/** Cues switch on quickly and off slowly, so the banner does not flicker. */ +const ENTER_S = 0.25; +const EXIT_S = 0.6; + +interface CueState { + since: number | null; // when the condition started holding + lastTrue: number; // last time the condition held + active: boolean; + cue: Cue | null; +} + +export interface EngineInput { + sample: FrameSample; + /** + * Camera aim in degrees above level (+ = aimed up, top of the phone tipped + * back towards the operator), or null if there is no sensor. Aimed up asks + * for "Tilt down"; aimed at the floor asks for "Tilt up". + */ + tiltDeg: number | null; + recording: boolean; +} + +export class GuidanceEngine { + private prev: FrameSample | null = null; + private vx = 0; + private vy = 0; + private priorDx = 0; + private priorDy = 0; + private fps = 0; + private logScale = 0; + private lowBlurSince: number | null = null; + private dirSign = 0; + private dirSince: number | null = null; + private direction: 'left' | 'right' | null = null; + private cues = new Map(); + private wasRecording = false; + private boxes: Box[] = []; + + /** Forget motion history, e.g. when a recording starts or the source changes. */ + reset(): void { + this.prev = null; + this.vx = this.vy = this.priorDx = this.priorDy = 0; + this.logScale = 0; + this.lowBlurSince = null; + this.dirSign = 0; + this.dirSince = null; + this.direction = null; + this.cues.clear(); + this.boxes = []; + } + + update({ sample, tiltDeg, recording }: EngineInput): Guidance { + if (recording && !this.wasRecording) { + // A new take: direction and distance are relative to how it started. + this.direction = null; + this.dirSince = null; + this.logScale = 0; + } + this.wasRecording = recording; + + const t = sample.t; + const prev = this.prev; + if (prev && t > prev.t && prev.thumbWidth === sample.thumbWidth) { + const dt = t - prev.t; + const cols = Float32Array.from(sample.cols); + const rows = Float32Array.from(sample.rows); + const maxX = Math.round(sample.thumbWidth * 0.25); + const maxY = Math.round(sample.thumbHeight * 0.2); + const sx = estimateShift(Float32Array.from(prev.cols), cols, maxX, this.priorDx); + const sy = estimateShift(Float32Array.from(prev.rows), rows, maxY, this.priorDy); + // A featureless view gives no trustworthy motion; hold the last estimate. + const dx = sx.confidence > 0.15 ? sx.shift : this.priorDx; + const dy = sy.confidence > 0.15 ? sy.shift : this.priorDy; + this.priorDx = dx; + this.priorDy = dy; + if (!sample.boxes) { + // Outlines from an earlier sample ride along with the measured + // motion, so they stay on their cartons between measurements. + const ox = dx / sample.thumbWidth; + const oy = dy / sample.thumbHeight; + this.boxes = this.boxes.map((b) => ({ ...b, x: b.x + ox, y: b.y + oy })); + } + const a = Math.min(1, dt / 0.3); // ~0.3 s smoothing + this.vx += a * (dx / sample.thumbWidth / dt - this.vx); + this.vy += a * (dy / sample.thumbHeight / dt - this.vy); + this.fps += Math.min(1, dt / 0.5) * (1 / dt - this.fps); + if (recording) { + const s = estimateScale(Float32Array.from(prev.rows), rows); + // Only confident, non-trivial steps accumulate: noise must not drift. + if (Math.abs(s - 1) >= 0.02 && sy.confidence > 0.3) this.logScale += Math.log(s); + } + } + this.prev = sample; + if (sample.boxes) this.boxes = sample.boxes; + + this.trackDirection(t, recording); + + const speed = Math.abs(this.vx); + const raw = this.rawCues(sample, t, tiltDeg, recording, speed); + const cues = this.debounce(raw, t); + + const pace: Level = + speed >= THRESHOLDS.paceBad ? 'bad' : speed >= THRESHOLDS.paceCaution ? 'caution' : 'good'; + const status: Level = cues.some((c) => c.level === 'bad') + ? 'bad' + : cues.length || pace === 'caution' + ? 'caution' + : 'good'; + return { + status, + label: status === 'good' ? 'Quality good' : (cues[0]?.title ?? (pace === 'caution' ? 'Ease off' : 'Adjust')), + pace, + cues, + hud: { + fps: this.fps, + blur: sample.blur, + light: sample.brightness / 255, + speed: this.vx, + vertical: this.vy, + direction: this.direction, + }, + boxes: this.boxes, + }; + } + + /** The scan direction is whichever way the operator first walks steadily. */ + private trackDirection(t: number, recording: boolean): void { + if (!recording) return; + const sign = Math.abs(this.vx) >= THRESHOLDS.directionSpeed ? Math.sign(this.vx) : 0; + if (sign !== this.dirSign) { + this.dirSign = sign; + this.dirSince = sign ? t : null; + } + if (!this.direction && this.dirSince != null && t - this.dirSince >= THRESHOLDS.directionSeconds) { + // Content moving left (vx < 0) means the camera is moving right. + this.direction = this.dirSign < 0 ? 'right' : 'left'; + } + } + + private rawCues(s: FrameSample, t: number, tilt: number | null, recording: boolean, speed: number): Cue[] { + const T = THRESHOLDS; + const out: Cue[] = []; + const lowTexture = s.contrast < T.contrastMin; + + if (lowTexture) { + out.push({ id: 'aim', level: 'caution', title: 'Aim at the shelf', detail: 'Nothing to count in view' }); + } + + if (!lowTexture && s.blur < T.blurBad) { + this.lowBlurSince ??= t; + if (speed >= T.paceCaution) { + out.push({ id: 'fast', level: 'bad', title: 'Too fast', detail: 'Motion blur — slow down', arrow: this.slowArrow() }); + } else if (t - this.lowBlurSince >= T.lensSeconds) { + out.push({ id: 'lens', level: 'bad', title: 'Lens blurry', detail: 'Wipe the lens or tap to focus' }); + } + } else { + this.lowBlurSince = null; + if (speed >= T.paceBad) { + out.push({ id: 'fast', level: 'bad', title: 'Too fast', detail: 'Slow down', arrow: this.slowArrow() }); + } else if (!lowTexture && s.blur < T.blurCaution) { + out.push({ id: 'soft', level: 'caution', title: 'Getting soft', detail: 'Hold steadier' }); + } + } + + if (s.brightness < T.darkBad) { + out.push({ id: 'dark', level: 'bad', title: 'Too dark', detail: 'Turn on the torch or find light' }); + } else if (s.brightness < T.darkCaution) { + out.push({ id: 'dark', level: 'caution', title: 'Dim', detail: 'More light will help' }); + } else if (s.clipped > T.clippedBad) { + out.push({ id: 'glare', level: 'bad', title: 'Glare', detail: 'Angle away from the light' }); + } else if (s.brightness > T.brightCaution) { + out.push({ id: 'glare', level: 'caution', title: 'Very bright', detail: 'Watch for glare' }); + } + + if (recording && this.direction) { + const backwards = this.direction === 'right' ? this.vx > T.reverseSpeed : this.vx < -T.reverseSpeed; + if (backwards) { + out.push({ + id: 'reverse', + level: 'bad', + title: 'Wrong way', + detail: 'Going back counts stock twice', + arrow: this.direction, + }); + } + } + + if (tilt != null && Math.abs(tilt) >= T.tiltCaution) { + const level = Math.abs(tilt) >= T.tiltBad ? 'bad' : 'caution'; + out.push( + tilt > 0 + ? { id: 'tilt-down', level, title: 'Tilt down', detail: 'Keep the phone upright', arrow: 'down' } + : { id: 'tilt-up', level, title: 'Tilt up', detail: 'Keep the phone upright', arrow: 'up' }, + ); + } else if (Math.abs(this.vy) >= T.verticalCaution) { + out.push({ + id: 'level', + level: 'caution', + title: 'Hold level', + detail: 'Keep the shelf at one height', + arrow: this.vy > 0 ? 'up' : 'down', + }); + } + + if (recording && this.logScale > T.distanceBand) { + out.push({ id: 'back', level: 'caution', title: 'Step back', detail: 'Keep a steady distance', arrow: 'out' }); + } else if (recording && this.logScale < -T.distanceBand) { + out.push({ id: 'closer', level: 'caution', title: 'Move closer', detail: 'Keep a steady distance', arrow: 'in' }); + } + + return out; + } + + private slowArrow(): Arrow | undefined { + return this.direction ?? undefined; + } + + private debounce(raw: Cue[], t: number): Cue[] { + const now = new Map(raw.map((c) => [c.id, c])); + for (const id of new Set([...this.cues.keys(), ...now.keys()])) { + const st = this.cues.get(id) ?? { since: null, lastTrue: -Infinity, active: false, cue: null }; + const cue = now.get(id); + if (cue) { + st.since ??= t; + st.lastTrue = t; + st.cue = cue; + if (!st.active && t - st.since >= ENTER_S) st.active = true; + // A bad cue is urgent: show it immediately. + if (cue.level === 'bad') st.active = true; + } else { + st.since = null; + if (st.active && t - st.lastTrue >= EXIT_S) st.active = false; + } + this.cues.set(id, st); + } + const rank = { bad: 0, caution: 1 } as const; + return [...this.cues.values()] + .filter((s) => s.active && s.cue) + .map((s) => s.cue!) + .sort((a, b) => rank[a.level] - rank[b.level]); + } +} diff --git a/mobile/src/analysis/metrics.ts b/mobile/src/analysis/metrics.ts new file mode 100644 index 0000000..382efb8 --- /dev/null +++ b/mobile/src/analysis/metrics.ts @@ -0,0 +1,508 @@ +/** + * Frame metrics for live capture guidance. + * + * Plain TypeScript over a small grayscale buffer, with no React and no + * platform APIs, so the same code runs in a VisionCamera worklet, in the + * browser preview, and under `node --test`. Every function here is written + * to be worklet-safe: no closures over module state, no classes. + * + * Definitions match the countbone backend (stages/preprocess.py) where one + * exists, so "blurry" on the phone means what the quality gate means by it: + * blur variance of the 3x3 Laplacian (cv2.Laplacian ksize=1) + * bright mean luminance 0-255 + * clipped fraction of pixels <= 4 or >= 251 + * The backend measures at 960 px wide; this runs at ANALYSIS_WIDTH, so the + * thresholds in guidance.ts are calibrated for this width, not copied. + */ + +export const ANALYSIS_WIDTH = 160; + +export interface Luma { + data: Uint8Array; + width: number; + height: number; +} + +export interface Box { + x: number; + y: number; + w: number; + h: number; +} + +/** + * Area-average a luma (1 byte/px) or RGBA (4 bytes/px) buffer down to + * `targetWidth`, keeping aspect. `stride` is bytes per row of the source. + */ +export function downsample( + src: Uint8Array | Uint8ClampedArray, + srcW: number, + srcH: number, + stride: number, + bytesPerPixel: 1 | 4, + targetWidth: number = ANALYSIS_WIDTH, +): Luma { + 'worklet'; + const w = Math.max(1, Math.min(targetWidth, srcW)); + const h = Math.max(1, Math.round((srcH * w) / srcW)); + const out = new Uint8Array(w * h); + const sx = srcW / w; + const sy = srcH / h; + // Sample a fixed grid inside each cell rather than every pixel: an average + // of 4x4 points is plenty for these metrics and ~20x cheaper at 1080p. + const n = 4; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + let sum = 0; + for (let j = 0; j < n; j++) { + const py = Math.min(srcH - 1, Math.floor((y + (j + 0.5) / n) * sy)); + const row = py * stride; + for (let i = 0; i < n; i++) { + const px = Math.min(srcW - 1, Math.floor((x + (i + 0.5) / n) * sx)); + const o = row + px * bytesPerPixel; + sum += + bytesPerPixel === 1 + ? src[o]! + : // Rec.601 luma, the same weights OpenCV's BGR2GRAY uses. + 0.299 * src[o]! + 0.587 * src[o + 1]! + 0.114 * src[o + 2]!; + } + } + out[y * w + x] = sum / (n * n); + } + } + return { data: out, width: w, height: h }; +} + +/** + * The backend resizes frames to 960 px wide (capture.resize_width) before its + * quality gate measures blur. Measuring blur on a 160 px thumbnail does not + * work: the downsample itself removes the fine detail blur destroys, and on + * calibration footage sharp and badly blurred frames overlapped completely. + * So blur is measured at that same 960 px scale (blurAtBackendScale), which + * lets the phone use the gate's own threshold and mean the same thing: + * on calibration frames the two agree within 1%. + */ +export const BACKEND_WIDTH = 960; + +/** + * Laplacian variance of the whole frame as the backend would measure it + * after resizing to BACKEND_WIDTH, without allocating that resized frame. + * + * A centre crop is not enough: when the centre falls between two cartons + * the crop is blank and reads as blurred while the frame is sharp. The + * whole-frame value matches the gate; evaluating it on every other row and + * column (an unbiased sample of the same statistic) keeps it cheap enough + * to run per frame on a phone. + */ +export function blurAtBackendScale( + src: Uint8Array | Uint8ClampedArray, + srcW: number, + srcH: number, + stride: number, + bytesPerPixel: 1 | 4, +): number { + 'worklet'; + const step = srcW / BACKEND_WIDTH; // source px per backend px + const outW = BACKEND_WIDTH; + const outH = Math.round(srcH / step); + const taps = step >= 2 ? 2 : 1; + // Luma at backend pixel (x, y), box-averaged when shrinking (like INTER_AREA). + const at = (x: number, y: number): number => { + 'worklet'; + let s = 0; + for (let j = 0; j < taps; j++) { + const py = Math.min(srcH - 1, Math.floor((y + (j + 0.5) / taps) * step)); + for (let i = 0; i < taps; i++) { + const px = Math.min(srcW - 1, Math.floor((x + (i + 0.5) / taps) * step)); + const o = py * stride + px * bytesPerPixel; + s += bytesPerPixel === 1 ? src[o]! : 0.299 * src[o]! + 0.587 * src[o + 1]! + 0.114 * src[o + 2]!; + } + } + return s / (taps * taps); + }; + let sum = 0; + let sumSq = 0; + let n = 0; + for (let y = 1; y < outH - 1; y += 2) { + for (let x = 1; x < outW - 1; x += 2) { + const v = at(x, y - 1) + at(x, y + 1) + at(x - 1, y) + at(x + 1, y) - 4 * at(x, y); + sum += v; + sumSq += v * v; + n++; + } + } + if (!n) return 0; + const mean = sum / n; + return sumSq / n - mean * mean; +} + +/** Variance of the 3x3 Laplacian. Low means soft focus or motion blur. */ +export function laplacianVariance(l: Luma): number { + 'worklet'; + const { data, width: w, height: h } = l; + let sum = 0; + let sumSq = 0; + let n = 0; + for (let y = 1; y < h - 1; y++) { + for (let x = 1; x < w - 1; x++) { + const i = y * w + x; + const v = data[i - w]! + data[i + w]! + data[i - 1]! + data[i + 1]! - 4 * data[i]!; + sum += v; + sumSq += v * v; + n++; + } + } + if (!n) return 0; + const mean = sum / n; + return sumSq / n - mean * mean; +} + +export function exposure(l: Luma): { brightness: number; clipped: number } { + 'worklet'; + let sum = 0; + let clipped = 0; + const { data } = l; + for (let i = 0; i < data.length; i++) { + const v = data[i]!; + sum += v; + if (v <= 4 || v >= 251) clipped++; + } + return { brightness: sum / data.length, clipped: clipped / data.length }; +} + +/** Mean of each column and each row, mean-removed (robust to exposure changes). */ +export function projections(l: Luma): { cols: Float32Array; rows: Float32Array } { + 'worklet'; + const { data, width: w, height: h } = l; + const cols = new Float32Array(w); + const rows = new Float32Array(h); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const v = data[y * w + x]!; + cols[x] = cols[x]! + v; + rows[y] = rows[y]! + v; + } + } + let cm = 0; + for (let x = 0; x < w; x++) cm += (cols[x]! /= h); + cm /= w; + for (let x = 0; x < w; x++) cols[x]! -= cm; + let rm = 0; + for (let y = 0; y < h; y++) rm += (rows[y]! /= w); + rm /= h; + for (let y = 0; y < h; y++) rows[y]! -= rm; + return { cols, rows }; +} + +export interface Shift { + /** Content displacement from `prev` to `curr`, in analysis pixels (+ = right/down). */ + shift: number; + /** + * 0..1: how much better the chosen shift explains the change than a typical + * candidate does. Near 0 on a featureless wall, where no shift is trustworthy. + */ + confidence: number; +} + +/** + * Global 1-D displacement between two projection profiles by minimising the + * mean absolute difference over the overlap. + * + * Shelves are periodic (a row of identical cartons), so a shift one carton + * pitch away can match almost as well as the true one. `prior` is the last + * measured shift: real camera motion is smooth, so candidates far from it + * pay a penalty. This is the same aliasing the backend's phase correlation + * has to survive, solved the cheap way. + */ +export function estimateShift( + prev: Float32Array, + curr: Float32Array, + maxShift: number, + prior = 0, + priorWeight = 0.35, +): Shift { + 'worklet'; + const n = Math.min(prev.length, curr.length); + const minOverlap = Math.max(8, Math.floor(n * 0.4)); + let bestS = 0; + let best = Infinity; + let bestRaw = Infinity; + const costs: number[] = []; + const raws: number[] = []; + let scale = 0; + for (let i = 0; i < n; i++) scale += Math.abs(prev[i]!); + scale = scale / n || 1; + for (let s = -maxShift; s <= maxShift; s++) { + let acc = 0; + let cnt = 0; + for (let i = Math.max(0, -s); i < n && i + s < n; i++) { + acc += Math.abs(prev[i]! - curr[i + s]!); + cnt++; + } + if (cnt < minOverlap) { + costs.push(Infinity); + continue; + } + const raw = acc / cnt / scale; + raws.push(raw); + const cost = raw + (priorWeight * Math.abs(s - prior)) / Math.max(1, maxShift); + costs.push(cost); + if (cost < best) { + best = cost; + bestS = s; + bestRaw = raw; + } + } + // Parabolic refinement to sub-pixel. + const k = bestS + maxShift; + const c0 = costs[k - 1]; + const c1 = costs[k]; + const c2 = costs[k + 1]; + let refined = bestS; + if (c0 != null && c2 != null && c1 != null && Number.isFinite(c0) && Number.isFinite(c2)) { + const denom = c0 - 2 * c1 + c2; + if (denom > 1e-9) refined = bestS + (0.5 * (c0 - c2)) / denom; + } + raws.sort((a, b) => a - b); + const typical = raws[Math.floor(raws.length / 2)] ?? 0; + const confidence = typical > 1e-6 ? Math.max(0, Math.min(1, 1 - bestRaw / typical)) : 0; + return { shift: refined, confidence }; +} + +/** + * Relative zoom between two row profiles: > 1 means the scene got bigger + * (camera moved closer). Row profiles barely change under a horizontal pan, + * which is what isolates distance from walking. + */ +export function estimateScale(prev: Float32Array, curr: Float32Array): number { + 'worklet'; + const n = Math.min(prev.length, curr.length); + const c = (n - 1) / 2; + let bestScale = 1; + let best = Infinity; + for (let k = -6; k <= 6; k++) { + const s = 1 + k * 0.01; + let acc = 0; + let cnt = 0; + for (let i = 0; i < n; i++) { + // Where row i of `prev` lands in `curr` if the scene scaled by s about the centre. + const j = c + (i - c) * s; + const j0 = Math.floor(j); + if (j0 < 0 || j0 + 1 >= n) continue; + const f = j - j0; + const v = curr[j0]! * (1 - f) + curr[j0 + 1]! * f; + acc += Math.abs(prev[i]! - v); + cnt++; + } + if (cnt < n * 0.6) continue; + const cost = acc / cnt; + if (cost < best) { + best = cost; + bestScale = s; + } + } + return bestScale; +} + +/** + * Cheap on-device object outlines for the viewfinder preview: Sobel edges, + * connected components, then drop anything nested inside a bigger outline + * (a carton's label inside the carton). A preview of what the detector will + * see, not the count; the count happens on the server. + */ +export function edgeBoxes(l: Luma, maxBoxes = 40): Box[] { + 'worklet'; + const { data, width: w, height: h } = l; + const mag = new Float32Array(w * h); + let sum = 0; + let sumSq = 0; + for (let y = 1; y < h - 1; y++) { + for (let x = 1; x < w - 1; x++) { + const i = y * w + x; + const gx = + data[i - w + 1]! + 2 * data[i + 1]! + data[i + w + 1]! - data[i - w - 1]! - 2 * data[i - 1]! - data[i + w - 1]!; + const gy = + data[i + w - 1]! + 2 * data[i + w]! + data[i + w + 1]! - data[i - w - 1]! - 2 * data[i - w]! - data[i - w + 1]!; + const m = Math.abs(gx) + Math.abs(gy); + mag[i] = m; + sum += m; + sumSq += m * m; + } + } + const n = (w - 2) * (h - 2); + const mean = sum / n; + const sd = Math.sqrt(Math.max(0, sumSq / n - mean * mean)); + const thr = Math.max(60, mean + 1.5 * sd); + + const label = new Int32Array(w * h).fill(-1); + const stack = new Int32Array(w * h); + const boxes: Box[] = []; + for (let start = 0; start < w * h; start++) { + if (label[start] !== -1 || mag[start]! < thr) continue; + let top = 0; + stack[top++] = start; + label[start] = boxes.length; + let x0 = w; + let y0 = h; + let x1 = 0; + let y1 = 0; + let count = 0; + while (top > 0) { + const p = stack[--top]!; + const px = p % w; + const py = (p - px) / w; + count++; + if (px < x0) x0 = px; + if (px > x1) x1 = px; + if (py < y0) y0 = py; + if (py > y1) y1 = py; + // 8-connected, with a one-pixel bridge so hairline gaps in an outline join up. + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + const qx = px + dx; + const qy = py + dy; + if (qx < 0 || qy < 0 || qx >= w || qy >= h) continue; + const q = qy * w + qx; + if (label[q] === -1 && mag[q]! >= thr) { + label[q] = boxes.length; + stack[top++] = q; + } + } + } + } + const bw = x1 - x0 + 1; + const bh = y1 - y0 + 1; + const aspect = bw / bh; + const areaFrac = (bw * bh) / (w * h); + const touchesEdge = x0 <= 1 || y0 <= 1 || x1 >= w - 2 || y1 >= h - 2; + boxes.push( + count >= 8 && bw >= 5 && bh >= 5 && aspect >= 0.2 && aspect <= 5 && areaFrac <= 0.3 && !touchesEdge + ? { x: x0, y: y0, w: bw, h: bh } + : { x: -1, y: -1, w: 0, h: 0 }, + ); + } + const real = boxes.filter((b) => b.w > 0); + const kept = real.filter( + (b) => + !real.some( + (o) => o !== b && o.w * o.h > b.w * b.h && b.x >= o.x && b.y >= o.y && b.x + b.w <= o.x + o.w && b.y + b.h <= o.y + o.h, + ), + ); + kept.sort((a, b) => b.w * b.h - a.w * a.h); + return kept.slice(0, maxBoxes); +} + +/** + * Clockwise rotation, in degrees, that turns a buffer upright. Phone cameras + * deliver buffers in the sensor's native (landscape) orientation; motion and + * outlines must be measured upright, or a sideways walk reads as vertical. + */ +export type Rotation = 0 | 90 | 180 | 270; + +/** Rotate a thumbnail clockwise. Cheap at thumbnail size, unlike the full frame. */ +export function rotateLuma(l: Luma, rotation: Rotation): Luma { + 'worklet'; + if (rotation === 0) return l; + const { data, width: w, height: h } = l; + const out = new Uint8Array(w * h); + const ow = rotation === 180 ? w : h; + const oh = rotation === 180 ? h : w; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const v = data[y * w + x]!; + let ox: number; + let oy: number; + if (rotation === 90) { + ox = h - 1 - y; + oy = x; + } else if (rotation === 180) { + ox = w - 1 - x; + oy = h - 1 - y; + } else { + ox = y; + oy = w - 1 - x; + } + out[oy * ow + ox] = v; + } + } + return { data: out, width: ow, height: oh }; +} + +export interface FrameSample { + /** Seconds, monotonic within one source. */ + t: number; + /** Upright frame size (after rotation). */ + srcWidth: number; + srcHeight: number; + /** Laplacian variance at the backend's 960 px scale (same scale as its quality gate). */ + blur: number; + /** Mean luminance 0-255. */ + brightness: number; + /** Fraction of pixels crushed or blown out. */ + clipped: number; + /** Luminance standard deviation of the thumbnail: near 0 on a blank wall. */ + contrast: number; + /** Column / row projection profiles of the thumbnail (for motion). */ + cols: number[]; + rows: number[]; + thumbWidth: number; + thumbHeight: number; + /** + * Edge-preview outlines, normalised to 0..1 of the frame, or null when + * this sample skipped them (they are the costliest metric, so callers + * measure them on a fraction of frames). + */ + boxes: Box[] | null; +} + +/** + * Everything measured from one frame, in one pass, stateless: the worklet on + * native and the canvas loop on web both call this and hand the result to the + * guidance engine on the JS thread. Motion needs the previous frame, so it is + * not computed here; the profiles it needs travel with the sample. + */ +export function sampleFrame( + src: Uint8Array | Uint8ClampedArray, + srcW: number, + srcH: number, + stride: number, + bytesPerPixel: 1 | 4, + t: number, + withBoxes: boolean, + rotation: Rotation = 0, +): FrameSample { + 'worklet'; + // Blur and exposure do not depend on orientation (the Laplacian is + // symmetric), so only the thumbnail is rotated, never the full frame. + const thumb = rotateLuma(downsample(src, srcW, srcH, stride, bytesPerPixel), rotation); + const sideways = rotation === 90 || rotation === 270; + const { brightness, clipped } = exposure(thumb); + let sq = 0; + for (let i = 0; i < thumb.data.length; i++) { + const d = thumb.data[i]! - brightness; + sq += d * d; + } + const { cols, rows } = projections(thumb); + const boxes = withBoxes + ? edgeBoxes(thumb).map((b) => ({ + x: b.x / thumb.width, + y: b.y / thumb.height, + w: b.w / thumb.width, + h: b.h / thumb.height, + })) + : null; + return { + t, + srcWidth: sideways ? srcH : srcW, + srcHeight: sideways ? srcW : srcH, + blur: blurAtBackendScale(src, srcW, srcH, stride, bytesPerPixel), + brightness, + clipped, + contrast: Math.sqrt(sq / thumb.data.length), + cols: Array.from(cols), + rows: Array.from(rows), + thumbWidth: thumb.width, + thumbHeight: thumb.height, + boxes, + }; +} diff --git a/mobile/src/analysis/useGuidance.ts b/mobile/src/analysis/useGuidance.ts new file mode 100644 index 0000000..f53c0f4 --- /dev/null +++ b/mobile/src/analysis/useGuidance.ts @@ -0,0 +1,54 @@ +/** + * Frame samples in, what to tell the operator out. + * + * Samples arrive at camera rate on the JS thread; the engine keeps its own + * timing and debouncing, and React state is updated at most ~11 times a + * second. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { GuidanceEngine, type Guidance } from './guidance.ts'; +import type { FrameSample } from './metrics.ts'; + +const INITIAL: Guidance = { + status: 'good', + label: 'Point at the shelf', + pace: 'good', + cues: [], + hud: { fps: 0, blur: 0, light: 0, speed: 0, vertical: 0, direction: null }, + boxes: [], +}; + +export function useGuidance(tiltDeg: number | null, recording: boolean) { + const engine = useRef(new GuidanceEngine()); + const [guidance, setGuidance] = useState(INITIAL); + const lastPush = useRef(0); + const tiltRef = useRef(tiltDeg); + const recRef = useRef(recording); + // Refs, so the sample callback stays stable while reading the latest values. + useEffect(() => { + tiltRef.current = tiltDeg; + recRef.current = recording; + }, [tiltDeg, recording]); + + useEffect(() => { + // A new recording starts from a clean slate (direction, distance, ...). + if (recording) engine.current.reset(); + }, [recording]); + + const onSample = useCallback((sample: FrameSample) => { + // useTilt is + when the top tips away (camera looking down); the engine + // wants + for aimed up, so it can say "Tilt up" to a camera aimed at the floor. + const tilt = tiltRef.current == null ? null : -tiltRef.current; + const g = engine.current.update({ sample, tiltDeg: tilt, recording: recRef.current }); + const now = Date.now(); + // Just under the ~100 ms sample interval, so every sample renders: skipping + // one leaves the outlines a whole interval behind a moving shelf. + if (now - lastPush.current >= 90) { + lastPush.current = now; + setGuidance(g); + } + }, []); + + return { guidance, onSample }; +} diff --git a/mobile/src/api/client.ts b/mobile/src/api/client.ts new file mode 100644 index 0000000..3103e63 --- /dev/null +++ b/mobile/src/api/client.ts @@ -0,0 +1,309 @@ +/** + * The countbone API, from the phone. + * + * The phone signs in once and keeps a Bearer token (the dashboard uses an + * HttpOnly cookie instead; the server accepts both). Server URL and token + * live in AsyncStorage, so the app reopens signed in and aimed at the right + * server. Shapes mirror the server (countbone/api/*.py). + */ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Platform } from 'react-native'; + +export type Role = 'counter' | 'manager' | 'admin'; +export type RunKind = 'count' | 'receive' | 'recount'; + +export interface Principal { + user_id: string; + username: string; + display_name: string; + role: Role; +} + +export interface Location { + code: string; + name: string | null; + zone: string | null; + last_counted_at?: number | null; + skus_expected?: number; +} + +export interface Receipt { + receipt_id: string; + po_number: string; + supplier: string | null; + dock: string | null; + status: 'open' | 'counted' | 'discrepancy' | 'closed'; + expected_units?: number; + received_units?: number | null; +} + +export interface Task { + task_id: string; + status: 'open' | 'done' | 'escalated' | 'cancelled'; + location: string | null; + sku: string | null; + reason: string | null; + expected: number | null; + counted: number | null; + variance: number | null; + value_at_risk: number | null; + assignee_name: string | null; + due_at: number | null; + run_id: string | null; +} + +export interface Walk { + walk_id: string; + location: string | null; + name: string | null; + status: 'open' | 'closed'; +} + +export interface ServiceJob { + job_id: string; + title: string; + site_name: string | null; + status: 'planned' | 'in_progress' | 'done' | 'cancelled'; + locations: string[]; + scheduled_for: number | null; +} + +export interface CatalogEntry { + sku: string; + label: string; + swatch: string | null; + unit_value: number; +} + +export interface FinalRow { + sku: string; + label: string; + machine: number; + final: number; + expected: number | null; + variance: number | null; + confidence: number | null; + pending_reviews: number; +} + +export interface Review { + review_id: string; + run_id: string; + sku: string; + reason: string; + confidence: number; + crop_path: string | null; + status: 'pending' | 'accepted' | 'rejected' | 'corrected'; + resolved_sku: string | null; + meta: { + scope?: 'sku' | 'item'; + count?: number; + counted?: boolean; + track_sku?: string | null; + candidates?: { sku: string; score: number }[]; + }; +} + +export interface LiveRun { + run_id: string; + status: 'queued' | 'running' | 'done' | 'failed'; + error: string | null; + phase?: string; + telemetry?: { frames_read: number; frames_expected: number | null; tracks: number; detections: number }; + location?: string | null; +} + +export interface RunDetail { + run_id: string; + started_at: number; + total: number; + overall_confidence: number; + needs_review: boolean; + location: string | null; + kind: RunKind; + receipt_id: string | null; + task_id: string | null; + meta: { + warnings?: string[]; + continuity?: { score: number; broken_gaps: number }; + shelf?: { gaps: number; missing_facings: number; compliance: number | null }; + labels_seen?: string[]; + }; + reviews: Review[]; + final: { rows: FinalRow[]; total: number; machine_total: number; settled: boolean; open_reviews: number }; +} + +export type RunResponse = RunDetail | { run_id: string; pending: LiveRun }; +export const isPending = (r: RunResponse): r is { run_id: string; pending: LiveRun } => 'pending' in r; + +export interface RunSummary { + run_id: string; + started_at: number; + total: number; + location: string | null; + kind: RunKind; + needs_review: number | boolean; +} + +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} + +// -- connection state --------------------------------------------------------------- +const KEY_SERVER = 'countbone.server'; +const KEY_TOKEN = 'countbone.token'; + +/** The web preview is served next to the API in development; a phone is not. */ +export function defaultServer(): string { + if (Platform.OS === 'web' && typeof window !== 'undefined') { + return `${window.location.protocol}//${window.location.hostname}:8000`; + } + return 'http://192.168.1.10:8000'; +} + +let server = defaultServer(); +let token: string | null = null; +const listeners = new Set<() => void>(); + +export async function hydrate(): Promise<{ server: string; token: string | null }> { + const [s, t] = await Promise.all([AsyncStorage.getItem(KEY_SERVER), AsyncStorage.getItem(KEY_TOKEN)]); + server = s || server; + token = t; + return { server, token }; +} + +export const getServer = () => server; +export const hasToken = () => !!token; + +export async function setServer(url: string) { + server = url.trim().replace(/\/+$/, ''); + await AsyncStorage.setItem(KEY_SERVER, server); +} + +async function setToken(t: string | null) { + token = t; + if (t) await AsyncStorage.setItem(KEY_TOKEN, t); + else await AsyncStorage.removeItem(KEY_TOKEN); + listeners.forEach((l) => l()); +} + +/** Called when the server rejects the token: the app returns to sign-in. */ +export function onSignedOut(listener: () => void) { + listeners.add(listener); + return () => void listeners.delete(listener); +} + +export const authHeaders = (): Record => (token ? { Authorization: `Bearer ${token}` } : {}); + +/** A URL for an image the server serves behind auth (RN Image sends headers). */ +export const authedSource = (path: string) => ({ uri: `${server}${path}`, headers: authHeaders() }); + +export async function request(path: string, init: RequestInit = {}, timeoutMs = 15000): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + let res: Response; + try { + res = await fetch(`${server}${path}`, { + ...init, + signal: init.signal ?? ctrl.signal, + headers: { ...authHeaders(), ...(init.headers as Record | undefined) }, + }); + } catch { + throw new ApiError(0, ctrl.signal.aborted ? 'The server did not answer in time' : 'Cannot reach the server'); + } finally { + clearTimeout(timer); + } + if (!res.ok) { + let message = res.statusText || `HTTP ${res.status}`; + try { + const body = await res.json(); + message = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail); + } catch { + /* keep the status text */ + } + if (res.status === 401 && token && !path.startsWith('/api/auth/login')) await setToken(null); + throw new ApiError(res.status, message); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +const json = (method: string, body?: unknown): RequestInit => ({ + method, + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), +}); + +const qs = (params: Record) => { + const parts = Object.entries(params) + .filter(([, v]) => v != null && v !== '') + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`); + return parts.length ? `?${parts.join('&')}` : ''; +}; + +export const api = { + async login(username: string, password: string): Promise { + const res = await request<{ token: string; user: Principal }>( + '/api/auth/login', + json('POST', { username, password, client: `countbone-${Platform.OS}` }), + ); + await setToken(res.token); + return res.user; + }, + async logout() { + await request('/api/auth/logout', json('POST')).catch(() => undefined); + await setToken(null); + }, + me: () => request('/api/auth/me'), + health: () => request<{ status: string; version: string; identify: string }>('/api/health', {}, 5000), + + locations: () => request('/api/locations'), + receipts: () => request('/api/receipts'), + tasks: (mine = true) => request(`/api/tasks${qs({ status: 'open', mine })}`), + completeTask: (id: string, count: number, note?: string) => + request(`/api/tasks/${encodeURIComponent(id)}/complete`, json('POST', { count, note })), + walks: () => request('/api/walks'), + createWalk: (location: string | null) => request('/api/walks', json('POST', { location })), + serviceJobs: () => request(`/api/service-jobs${qs({ mine: true })}`), + catalog: () => request('/api/catalog'), + + runs: (limit = 30) => request<{ runs: RunSummary[]; in_flight: LiveRun[] }>(`/api/runs${qs({ limit })}`), + run: (id: string) => request(`/api/runs/${encodeURIComponent(id)}`), + resolveReview: (id: string, decision: { status: Review['status']; resolved_sku?: string | null; resolved_count?: number | null }) => + request<{ review_id: string }>(`/api/reviews/${encodeURIComponent(id)}`, json('POST', decision)), + + // resumable uploads (the offline queue) + startUpload: (body: { + filename: string; + size: number; + client_id: string; + kind: RunKind; + location?: string | null; + receipt_id?: string | null; + task_id?: string | null; + walk_id?: string | null; + job_id?: string | null; + }) => request<{ upload_id: string; offset: number; size: number; status: string; run_id: string | null }>('/api/uploads', json('POST', body)), + uploadStatus: (id: string) => request<{ offset: number; status: string; run_id: string | null }>(`/api/uploads/${encodeURIComponent(id)}`), + async putChunk(id: string, offset: number, bytes: Uint8Array): Promise { + const res = await fetch(`${server}/api/uploads/${encodeURIComponent(id)}`, { + method: 'PUT', + headers: { ...authHeaders(), 'Upload-Offset': String(offset), 'Content-Type': 'application/octet-stream' }, + body: bytes as unknown as BodyInit, + }); + const next = Number(res.headers.get('Upload-Offset')); + if (res.status === 204 || res.status === 409) { + // 409: the server has a different offset; resume from where it says. + if (Number.isFinite(next)) return next; + } + if (res.status === 401) await setToken(null); + throw new ApiError(res.status, `upload chunk failed (HTTP ${res.status})`); + }, + completeUpload: (id: string, sha256?: string) => + request<{ run_id: string }>(`/api/uploads/${encodeURIComponent(id)}/complete`, json('POST', sha256 ? { sha256 } : undefined), 60000), +}; diff --git a/mobile/src/api/uploadQueue.ts b/mobile/src/api/uploadQueue.ts new file mode 100644 index 0000000..f795458 --- /dev/null +++ b/mobile/src/api/uploadQueue.ts @@ -0,0 +1,274 @@ +/** + * The offline upload queue. + * + * A recording is queued the moment it stops, then sent in 4 MB chunks with + * the server's resumable upload API. Every chunk's progress is saved, so a + * dead zone, a closed app, or a reboot costs at most one chunk: the upload + * resumes where the server says it got to. The recording's own id is the + * upload's client_id, so a retry can never create a second count of the + * same walk. + * + * States: waiting -> uploading -> done (the server has it, counting) + * \-> waiting (retry later) network trouble, backs off + * \-> failed the server refused it: needs a person + * + * Each recording belongs to whoever was signed in when it was filmed. On a + * shared phone, the next person's sign-in must not upload it as theirs: a + * recording waits for its own filmer to sign in again. + * + * The phone hashes the video as it sends it and gives the server the SHA-256 + * at the end, so a file damaged on the way is never counted. + */ +import { sha256 } from '@noble/hashes/sha2.js'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Network from 'expo-network'; +import { AppState } from 'react-native'; + +import { forgetRecording, readChunk, recordingExists, type StoredRecording } from '@/storage/recordings'; +import { api, ApiError, hasToken, type RunKind } from './client'; + +export interface Target { + kind: RunKind; + location: string | null; + receipt_id: string | null; + task_id: string | null; + walk_id: string | null; + job_id: string | null; + /** How the item is shown in lists: "A07-B03 · cycle count". */ + title: string; +} + +export interface QueueItem { + id: string; + createdAt: number; + recording: StoredRecording; + target: Target; + durationS: number | null; + state: 'waiting' | 'uploading' | 'done' | 'failed'; + uploadId: string | null; + sent: number; + runId: string | null; + error: string | null; + attempts: number; + nextTryAt: number; + /** Who filmed it (user_id). Absent on items queued before this was recorded. */ + ownerId?: string | null; + ownerName?: string | null; +} + +const KEY = 'countbone.queue.v1'; +const USER_KEY = 'countbone.queue.user'; +const CHUNK = 4 * 1024 * 1024; + +let items: QueueItem[] = []; +let loaded = false; +let running = false; +/** The signed-in user, remembered so an offline launch still knows whose queue this is. */ +let currentUser: { id: string; name: string } | null = null; +const listeners = new Set<() => void>(); + +const emit = () => listeners.forEach((l) => l()); + +async function save() { + await AsyncStorage.setItem(KEY, JSON.stringify(items)); + emit(); +} + +async function load() { + if (loaded) return; + loaded = true; + try { + items = JSON.parse((await AsyncStorage.getItem(KEY)) ?? '[]'); + } catch { + items = []; + } + try { + currentUser ??= JSON.parse((await AsyncStorage.getItem(USER_KEY)) ?? 'null'); + } catch { + currentUser = null; + } + // An upload interrupted mid-chunk resumes from the server's offset. + items = items.map((i) => (i.state === 'uploading' ? { ...i, state: 'waiting' } : i)); + emit(); +} + +export function subscribe(listener: () => void) { + listeners.add(listener); + return () => void listeners.delete(listener); +} + +export const snapshot = () => items; + +/** Called by the session on sign-in (the user) and sign-out (null). */ +export async function setQueueUser(user: { id: string; name: string } | null) { + currentUser = user; + await AsyncStorage.setItem(USER_KEY, JSON.stringify(user)).catch(() => undefined); + emit(); + if (user) void pump(); +} + +/** A recording someone else filmed: it waits for them rather than uploading as the current user. */ +export const waitsForSomeoneElse = (i: QueueItem) => + !!i.ownerId && !!currentUser && i.ownerId !== currentUser.id; + +function update(id: string, patch: Partial) { + items = items.map((i) => (i.id === id ? { ...i, ...patch } : i)); +} + +export function newRecordingId(): string { + const rand = Math.random().toString(36).slice(2, 10); + return `rec_${Date.now().toString(36)}_${rand}`; +} + +export async function enqueue(recording: StoredRecording, target: Target, id: string, durationS: number | null) { + await load(); + items = [ + { id, createdAt: Date.now(), recording, target, durationS, state: 'waiting', uploadId: null, sent: 0, + runId: null, error: null, attempts: 0, nextTryAt: 0, + ownerId: currentUser?.id ?? null, ownerName: currentUser?.name ?? null }, + ...items, + ]; + await save(); + void pump(); +} + +export async function retry(id: string) { + update(id, { state: 'waiting', error: null, nextTryAt: 0, attempts: 0 }); + await save(); + void pump(); +} + +/** Remove from the queue, deleting the local copy unless the server has it. */ +export async function discard(id: string) { + const item = items.find((i) => i.id === id); + if (item) await forgetRecording(item.recording.ref).catch(() => undefined); + items = items.filter((i) => i.id !== id); + await save(); +} + +/** Forget finished items older than a week (the server keeps them). */ +async function prune() { + const cutoff = Date.now() - 7 * 86400 * 1000; + const before = items.length; + items = items.filter((i) => !(i.state === 'done' && i.createdAt < cutoff)); + if (items.length !== before) await save(); +} + +function retryable(e: unknown): boolean { + if (!(e instanceof ApiError)) return true; + return e.status === 0 || e.status >= 500 || e.status === 408 || e.status === 429 || e.status === 409; +} + +async function sendOne(item: QueueItem) { + if (!(await recordingExists(item.recording.ref))) { + update(item.id, { state: 'failed', error: 'The recording is no longer on this phone.' }); + return; + } + update(item.id, { state: 'uploading', error: null }); + await save(); + let uploadId = item.uploadId; + let sent = item.sent; + const t = item.target; + const started = await api.startUpload({ + filename: item.recording.name, + size: item.recording.size, + client_id: item.id, + kind: t.kind, + location: t.location, + receipt_id: t.receipt_id, + task_id: t.task_id, + walk_id: t.walk_id, + job_id: t.job_id, + }); + uploadId = started.upload_id; + if (started.status === 'complete' && started.run_id) { + update(item.id, { state: 'done', uploadId, sent: item.recording.size, runId: started.run_id }); + await forgetRecording(item.recording.ref).catch(() => undefined); + return; + } + sent = started.offset; // the server's word, not ours + update(item.id, { uploadId, sent }); + // The hash covers every byte in order, so on a resume the part the server + // already has is read back and hashed first (reading is far cheaper than + // the network it replaces). + const hash = sha256.create(); + for (let at = 0; at < sent; ) { + const bytes = await readChunk(item.recording.ref, at, Math.min(CHUNK, sent - at)); + hash.update(bytes); + at += bytes.length; + } + while (sent < item.recording.size) { + const bytes = await readChunk(item.recording.ref, sent, Math.min(CHUNK, item.recording.size - sent)); + const next = await api.putChunk(uploadId, sent, bytes); + if (next === sent + bytes.length) { + hash.update(bytes); + } else if (next !== sent) { + // The server is somewhere else (another attempt got further): the + // running hash no longer lines up, so start this upload over cleanly. + throw new ApiError(409, `server is at byte ${next}; resuming from there`); + } + sent = next; + update(item.id, { sent }); + await save(); + } + const digest = Array.from(hash.digest(), (b) => b.toString(16).padStart(2, '0')).join(''); + const done = await api.completeUpload(uploadId, digest); + update(item.id, { state: 'done', runId: done.run_id, sent: item.recording.size, attempts: 0 }); + // The server has the video and its hash: the phone's copy is no longer needed. + await forgetRecording(item.recording.ref).catch(() => undefined); +} + +/** Send whatever is due, one recording at a time. Safe to call any time. */ +export async function pump() { + await load(); + if (running || !hasToken()) return; + running = true; + try { + const net = await Network.getNetworkStateAsync().catch(() => ({ isConnected: true })); + if (net.isConnected === false) return; + for (;;) { + const now = Date.now(); + const next = items + .filter((i) => i.state === 'waiting' && i.nextTryAt <= now && !waitsForSomeoneElse(i)) + .sort((a, b) => a.createdAt - b.createdAt)[0]; + if (!next) break; + try { + await sendOne(next); + } catch (e) { + const attempts = next.attempts + 1; + if (retryable(e)) { + update(next.id, { + state: 'waiting', + attempts, + error: e instanceof Error ? e.message : String(e), + nextTryAt: Date.now() + Math.min(120_000, 2000 * 2 ** Math.min(attempts, 6)), + }); + } else { + update(next.id, { state: 'failed', attempts, error: e instanceof Error ? e.message : String(e) }); + } + } + await save(); + if (!hasToken()) break; + } + await prune(); + } finally { + running = false; + } +} + +let started = false; +/** Start the background triggers: network back, app foregrounded, and a timer. */ +export function startQueue() { + if (started) return; + started = true; + void pump(); + Network.addNetworkStateListener((s) => { + if (s.isConnected) void pump(); + }); + AppState.addEventListener('change', (s) => { + if (s === 'active') void pump(); + }); + setInterval(() => { + if (items.some((i) => i.state === 'waiting')) void pump(); + }, 15_000); +} diff --git a/mobile/src/app/(tabs)/_layout.tsx b/mobile/src/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..6fe2786 --- /dev/null +++ b/mobile/src/app/(tabs)/_layout.tsx @@ -0,0 +1,35 @@ +import { Tabs } from 'expo-router'; +import { Camera, ClipboardList, Settings, UploadCloud } from 'lucide-react-native'; + +import { useQueue } from '@/state/useQueue'; +import { color, touch } from '@/theme'; + +export default function TabsLayout() { + const queue = useQueue(); + const waiting = queue.filter((q) => q.state === 'waiting' || q.state === 'uploading' || q.state === 'failed').length; + return ( + + }} /> + }} /> + , + }} + /> + }} /> + + ); +} diff --git a/mobile/src/app/(tabs)/index.tsx b/mobile/src/app/(tabs)/index.tsx new file mode 100644 index 0000000..cf4e6b5 --- /dev/null +++ b/mobile/src/app/(tabs)/index.tsx @@ -0,0 +1,237 @@ +import { useFocusEffect, useRouter } from 'expo-router'; +import { Camera, Footprints, MapPin, QrCode, Truck, Undo2 } from 'lucide-react-native'; +import { useCallback, useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { api, type Location, type Receipt, type ServiceJob, type Task } from '@/api/client'; +import type { Target } from '@/api/uploadQueue'; +import { Badge, Button, Card, Empty, ErrorText, Field, Row, Screen, SectionLabel, Title } from '@/components/ui'; +import { useSession } from '@/state/session'; +import { color, font, radius, space, touch } from '@/theme'; + +type Kind = Target['kind']; + +const KINDS: { kind: Kind; label: string; icon: typeof Camera }[] = [ + { kind: 'count', label: 'Bay', icon: MapPin }, + { kind: 'receive', label: 'Delivery', icon: Truck }, + { kind: 'recount', label: 'Recount', icon: Undo2 }, +]; + +export default function CaptureHome() { + const router = useRouter(); + const { user, target, setTarget } = useSession(); + const [locations, setLocations] = useState([]); + const [receipts, setReceipts] = useState([]); + const [tasks, setTasks] = useState([]); + const [jobs, setJobs] = useState([]); + const [filter, setFilter] = useState(''); + const [error, setError] = useState(null); + const [offline, setOffline] = useState(false); + const [walkBusy, setWalkBusy] = useState(false); + + useFocusEffect( + useCallback(() => { + let alive = true; + Promise.all([api.locations(), api.receipts(), api.tasks(true), api.serviceJobs()]) + .then(([l, r, t, j]) => { + if (!alive) return; + setLocations(l); + setReceipts(r.filter((x) => x.status !== 'closed')); + setTasks(t); + setJobs(j.filter((x) => x.status === 'planned' || x.status === 'in_progress')); + setOffline(false); + }) + .catch(() => alive && setOffline(true)); + return () => { + alive = false; + }; + }, []), + ); + + const set = (patch: Partial) => setTarget({ ...target, ...patch }); + const pickKind = (kind: Kind) => + setTarget({ ...target, kind, receipt_id: null, task_id: null, title: kind === 'count' ? 'Cycle count' : kind === 'receive' ? 'Delivery' : 'Recount' }); + + const ready = + target.kind === 'receive' ? !!target.receipt_id : target.kind === 'recount' ? !!target.task_id : true; + const shown = locations.filter((l) => !filter || `${l.code} ${l.name ?? ''}`.toLowerCase().includes(filter.toLowerCase())).slice(0, 8); + + return ( + + What are you filming? + {offline && ( + + No connection to the server + You can still record: videos wait in Uploads and send themselves when you are back online. + + )} + + + {KINDS.map(({ kind, label, icon: Icon }) => { + const on = target.kind === kind; + return ( + pickKind(kind)} + style={[styles.kind, on && styles.kindOn]} + > + + {label} + + ); + })} + + + {target.kind === 'count' && ( + + Bay + {target.location ? ( + l.code === target.location)?.name ?? 'Selected'} + right={ - - - -
-
-
-

Runs

-

No runs yet.

-
-
-

Plugins

-
-
-
- -
-
-

Result

-

Select a run.

-
-
-

Review queue

-

Nothing waiting.

-
-
-
- - - + + + + + + + + + countbone + + + + +
+ + diff --git a/src/countbone/api/telemetry.py b/src/countbone/api/telemetry.py new file mode 100644 index 0000000..2adc9f6 --- /dev/null +++ b/src/countbone/api/telemetry.py @@ -0,0 +1,217 @@ +"""Live progress and the frame inspector, for the dashboard. + +Two plugins rather than one, because a plugin has a single priority and these +need opposite ends of the chain: the intake must see every frame before the +quality gate can drop it, and the tail must see detections, items and tracks +after every other plugin has had its say. + +Both are attached by the API, never named in a config: they exist to feed the +dashboard, and a CLI run has no one to report progress to. +""" + +from __future__ import annotations + +import json +import math +import time +from collections.abc import Callable +from typing import Any + +from ..context import RunContext +from ..plugins.base import Plugin +from ..stages import capture +from ..types import CountResult, Frame, Item, Track + +Report = Callable[..., None] + +# Frames arrive far faster than anyone reads a dashboard; publishing every one +# only buys lock contention with the request threads. +PUBLISH_EVERY_S = 0.25 + + +def _state(ctx: RunContext) -> dict[str, Any]: + return ctx.state["telemetry"] + + +def _publish(ctx: RunContext, report: Report, force: bool = False) -> None: + t = _state(ctx) + now = time.time() + if not force and now - t["published_at"] < PUBLISH_EVERY_S: + return + t["published_at"] = now + elapsed = max(now - t["started_at"], 1e-6) + blur = t["blur"] + bright = t["brightness"] + report( + ctx.run_id, + phase=t["phase"], + telemetry={ + "frames_expected": t["frames_expected"], + "frames_read": t["frames_read"], + "frames_kept": t["frames_kept"], + "frames_dropped": t["frames_read"] - t["frames_kept"], + "detections": t["detections"], + "items": t["items"], + "tracks": t["tracks"], + "fps": round(t["frames_read"] / elapsed, 2), + "elapsed_s": round(elapsed, 2), + "blur": round(blur[-1], 1) if blur else None, + "blur_avg": round(sum(blur) / len(blur), 1) if blur else None, + "brightness": round(bright[-1], 1) if bright else None, + "brightness_avg": round(sum(bright) / len(bright), 1) if bright else None, + }, + ) + + +class TelemetryIntake(Plugin): + """Sees every frame, before anything can drop it.""" + + name = "dashboard_intake" + layer = "capture" + priority = 0 + + def __init__(self, report: Report) -> None: + super().__init__() + self.report = report + + def on_run_start(self, ctx: RunContext) -> None: + cap = ctx.config.capture + try: + probe = capture.probe(ctx.source) + except Exception: # noqa: BLE001 - progress is best effort, the run is not + probe = {} + expected = None + if probe.get("frame_count"): + expected = math.ceil(probe["frame_count"] / max(1, cap.every_n_frames)) + if cap.max_frames: + expected = min(expected, cap.max_frames) + ctx.state["telemetry"] = { + "started_at": time.time(), + "published_at": 0.0, + "phase": "frames", + "frames_expected": expected, + "frames_read": 0, + "frames_kept": 0, + "detections": 0, + "items": 0, + "tracks": 0, + "blur": [], + "brightness": [], + "frames": {}, + "sightings": [], + "frame_size": None, + } + _publish(ctx, self.report, force=True) + + def on_frame(self, ctx: RunContext, frame: Frame) -> Frame: + t = _state(ctx) + t["frames_read"] += 1 + q = frame.meta.get("quality") or {} + if "blur" in q: + t["blur"].append(q["blur"]) + t["brightness"].append(q["brightness"]) + t["frames"][frame.index] = { + "index": frame.index, + "source_index": frame.source_index, + "t": frame.timestamp_s, + "kept": False, + "quality": {k: round(v, 3) for k, v in q.items()}, + } + if t["frame_size"] is None: + t["frame_size"] = list(frame.shape) + _publish(ctx, self.report) + return frame + + +class TelemetryTail(Plugin): + """Sees the final word on every frame, and writes the inspector file. + + Priority 85 puts on_output after the exception report and before the + audit pack, so inspector.json is hashed into the manifest like any other + artifact. + """ + + name = "dashboard_tail" + layer = "output" + priority = 85 + + def __init__(self, report: Report) -> None: + super().__init__() + self.report = report + + def on_detections(self, ctx: RunContext, frame: Frame, detections): + t = _state(ctx) + t["frames_kept"] += 1 + t["detections"] += len(detections) + if frame.index in t["frames"]: + t["frames"][frame.index]["kept"] = True + return detections + + def on_items(self, ctx: RunContext, frame: Frame, items: list[Item]) -> list[Item]: + t = _state(ctx) + t["items"] += len(items) + # References, not copies: the tracker writes track_id onto these same + # objects after this hook, and an Item holds no pixels. + t["sightings"].extend(items) + _publish(ctx, self.report) + return items + + def on_tracks(self, ctx: RunContext, tracks: list[Track]) -> list[Track]: + t = _state(ctx) + t["phase"] = "count" + t["tracks"] = len(tracks) + t["counted_tracks"] = {tr.track_id for tr in tracks} + # A track's SKU is the majority vote of its sightings; that is what + # was counted, so it is what the inspector should label the box with. + t["track_sku"] = {tr.track_id: tr.sku for tr in tracks} + _publish(ctx, self.report, force=True) + return tracks + + def on_counts(self, ctx: RunContext, result: CountResult) -> CountResult: + _state(ctx)["phase"] = "output" + _publish(ctx, self.report, force=True) + return result + + def on_output(self, ctx: RunContext, result: CountResult) -> None: + t = _state(ctx) + counted = t.get("counted_tracks", set()) + track_sku = t.get("track_sku", {}) + boxes = [ + { + "frame": item.frame_index, + "bbox": [round(v, 1) for v in item.detection.bbox], + "sku": item.sku, # this frame's guess + "track_sku": track_sku.get(item.track_id), # what the object was counted as + "label": item.label, + "confidence": round(item.confidence, 4), + "track_id": item.track_id, + "counted": item.track_id is not None and item.track_id in counted, + } + for item in t["sightings"] + ] + doc = { + "run_id": result.run_id, + "frame_size": t["frame_size"], + "source_info": result.meta.get("source_info"), + "frames": sorted(t["frames"].values(), key=lambda f: f["index"]), + "boxes": boxes, + } + path = ctx.artifacts_dir / "inspector.json" + path.write_text(json.dumps(doc, separators=(",", ":")), encoding="utf-8") + result.meta.setdefault("outputs", {})["inspector"] = str(path) + + def on_run_end(self, ctx: RunContext, result: CountResult | None) -> None: + if "telemetry" not in ctx.state: + return + _state(ctx)["phase"] = "done" if result is not None else "failed" + _publish(ctx, self.report, force=True) + # The sightings list is the one large thing held here; let it go now + # rather than whenever the context is collected. + _state(ctx)["sightings"] = [] + + +def attach(plugins: list[Plugin], report: Report) -> list[Plugin]: + """Return the plugin list with both telemetry plugins in priority order.""" + out = [*plugins, TelemetryIntake(report), TelemetryTail(report)] + out.sort(key=lambda p: p.priority) + return out diff --git a/src/countbone/appearance.py b/src/countbone/appearance.py new file mode 100644 index 0000000..29fb3ad --- /dev/null +++ b/src/countbone/appearance.py @@ -0,0 +1,347 @@ +"""Appearance embeddings: teach the system a product from a handful of photos. + +The colour identifier can separate as many SKUs as there are distinct hues, +which on a real shelf is a handful. Products are told apart by their artwork: +where the colours are, and the shapes of logos, text and stripes. This module +turns a product image into a vector that captures both, and a small index +that matches new sightings against enrolled examples. + +Design choices, for a pilot rather than a benchmark: + +* No deep-learning runtime. Everything is OpenCV + NumPy, runs on a laptop + CPU at thousands of crops a second, and is deterministic. The embedder is a + single function (`embed`) so a learned model (ONNX) can replace it later + without touching the index, the catalog or the API. +* Few-shot by nearest exemplar. Five photos per SKU are stored as-is (plus a + few augmented copies); a sighting is scored against its closest exemplar, + blended with the SKU's centroid. Adding a photo takes effect immediately, + with no training step, which is what makes review corrections usable as + new examples. +* Open set. A sighting unlike every enrolled SKU is reported as unknown + rather than forced onto the nearest one; a shelf always holds something + nobody enrolled. +""" + +from __future__ import annotations + +import zlib +from dataclasses import dataclass + +import cv2 +import numpy as np + +SIZE = 64 +EMBED_VERSION = 1 # bump when `embed` changes: stored vectors must be recomputed +_GRID = 4 # orientation cells +_LAYOUT_GRID = 8 # colour-layout cells: where colour sits is what tells look-alikes apart +_ORIENT_BINS = 8 + + +def _prepare(bgr: np.ndarray, inset: float = 0.06) -> np.ndarray: + h, w = bgr.shape[:2] + dy, dx = int(h * inset), int(w * inset) + core = bgr[dy : h - dy or h, dx : w - dx or w] if h > 8 and w > 8 else bgr + small = cv2.resize(core, (SIZE, SIZE), interpolation=cv2.INTER_AREA) + # A mild blur narrows the gap between crisp enrolment photos and + # motion-softened video crops more than it costs detail. + return cv2.GaussianBlur(small, (3, 3), 0) + + +def _l2(v: np.ndarray) -> np.ndarray: + n = float(np.linalg.norm(v)) + return v / n if n > 1e-9 else v + + +def _colour_hist(hsv: np.ndarray) -> np.ndarray: + h, s, v = hsv[:, :, 0], hsv[:, :, 1], hsv[:, :, 2] + chroma = s >= 40 + hs, _, _ = np.histogram2d( + h[chroma].ravel(), np.minimum(s[chroma].ravel(), 255), + bins=(18, 3), range=((0, 180), (40, 256)), + ) + grey, _ = np.histogram(v[~chroma].ravel(), bins=8, range=(0, 256)) + hist = np.concatenate([hs.ravel(), grey]).astype(np.float32) + hist /= max(hist.sum(), 1.0) + return np.sqrt(hist) # Hellinger: big bins stop drowning small ones + + +def _layout(lab: np.ndarray) -> np.ndarray: + g = _LAYOUT_GRID + cells = lab.reshape(g, SIZE // g, g, SIZE // g, 3).mean(axis=(1, 3)) + feats = cells.reshape(-1, 3).astype(np.float32) + feats[:, 0] = (feats[:, 0] - 128.0) / 128.0 * 0.5 # lightness matters less than colour + feats[:, 1:] = (feats[:, 1:] - 128.0) / 64.0 + return feats.ravel() + + +def _orientations(gray: np.ndarray) -> np.ndarray: + gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3) + gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3) + mag, ang = cv2.cartToPolar(gx, gy) + ang = np.mod(ang, np.pi) # unsigned: an edge is an edge whichever side is darker + bins = np.minimum((ang / np.pi * _ORIENT_BINS).astype(np.int32), _ORIENT_BINS - 1) + step = SIZE // _GRID + out = np.zeros((_GRID, _GRID, _ORIENT_BINS), np.float32) + for gy_ in range(_GRID): + for gx_ in range(_GRID): + sl = (slice(gy_ * step, (gy_ + 1) * step), slice(gx_ * step, (gx_ + 1) * step)) + out[gy_, gx_] = np.bincount( + bins[sl].ravel(), weights=mag[sl].ravel(), minlength=_ORIENT_BINS + ) + # Normalise per cell so contrast changes do not matter, keep an overall + # edge-energy term so a plain box and a busy label still differ. + energy = out.sum(axis=2, keepdims=True) + cells = out / np.maximum(energy, 1e-6) + density = np.log1p(energy / (step * step)) / 5.0 + return np.concatenate([np.sqrt(cells).ravel(), density.ravel()]) + + +def embed(bgr: np.ndarray) -> np.ndarray: + """A unit vector describing how a product looks. Cosine = similarity.""" + if bgr is None or bgr.size == 0 or bgr.ndim != 3: + raise ValueError("embed needs a colour image") + img = _prepare(bgr) + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB).astype(np.float32) + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32) + parts = [ + # Weights measured on a look-alike range (same red packaging, different + # artwork): the colour histogram alone cannot tell those apart and, at + # full weight, made unenrolled products look enrolled. + (_colour_hist(hsv), 0.3), + (_layout(lab), 2.0), + (_orientations(gray), 1.0), + ] + return _l2(np.concatenate([_l2(p) * w for p, w in parts]).astype(np.float32)) + + +def product_crop(photo: np.ndarray) -> np.ndarray: + """The product in an enrolment photo, without the table it stood on. + + Video crops are tight boxes from the detector; a phone photo has a + margin of whatever was behind the product. Embedding that margin teaches + the index the background. The product is taken to be the largest strong + rectangle-ish outline covering a fair share of the photo; if none is + found the photo is assumed to be framed on the product already. + """ + h, w = photo.shape[:2] + gray = cv2.cvtColor(photo, cv2.COLOR_BGR2GRAY) + gray = cv2.GaussianBlur(gray, (5, 5), 0) + edges = cv2.Canny(gray, 40, 120) + edges = cv2.dilate(edges, np.ones((5, 5), np.uint8), iterations=2) + contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + # An outline broken by glare or a label comes out as several contours; + # the product is the union of the substantial ones. Anything touching + # the photo's edge is background running out of frame. + boxes = [] + for c in contours: + x, y, bw, bh = cv2.boundingRect(c) + if bw * bh < 0.01 * w * h: + continue + if x <= 2 or y <= 2 or x + bw >= w - 2 or y + bh >= h - 2: + continue + boxes.append((x, y, x + bw, y + bh)) + if not boxes: + return photo + x1 = min(b[0] for b in boxes) + y1 = min(b[1] for b in boxes) + x2 = max(b[2] for b in boxes) + y2 = max(b[3] for b in boxes) + frac = (x2 - x1) * (y2 - y1) / float(w * h) + if not 0.2 <= frac <= 0.97: + return photo + # The dilation grew the outline outward; take back roughly what it added. + t = 4 + return photo[y1 + t : y2 - t, x1 + t : x2 - t] + + +def augment(bgr: np.ndarray, rng: np.random.Generator, n: int = 3) -> list[np.ndarray]: + """Plausible variations of one enrolment photo. + + Five phone photos are not five viewpoints of a shelf: video crops are + smaller, softer, differently lit and slightly off-centre. Stretching each + photo across those conditions is what lets five photos generalise. + """ + out = [] + h, w = bgr.shape[:2] + for _ in range(n): + img = bgr.astype(np.float32) + img = img * rng.uniform(0.75, 1.2) + rng.uniform(-18, 18) + img = np.clip(img, 0, 255).astype(np.uint8) + # a slightly loose or tight crop + m = rng.uniform(-0.06, 0.08) + dx, dy = int(w * m), int(h * m) + if dx > 0 and dy > 0: + img = img[dy : h - dy, dx : w - dx] + elif dx < 0 or dy < 0: + img = cv2.copyMakeBorder(img, -min(dy, 0), -min(dy, 0), -min(dx, 0), -min(dx, 0), + cv2.BORDER_REPLICATE) + # video-like softness and scale + scale = rng.uniform(0.25, 0.6) + small = cv2.resize(img, (max(8, int(img.shape[1] * scale)), max(8, int(img.shape[0] * scale))), + interpolation=cv2.INTER_AREA) + k = int(rng.choice([1, 3, 5])) + if k > 1: + small = cv2.GaussianBlur(small, (k, k), 0) + out.append(small) + return out + + +@dataclass +class Match: + sku: str + score: float # cosine similarity, blended nearest-exemplar and centroid + + +@dataclass +class Exemplar: + """One stored vector: an enrolment photo, or an augmented copy of one.""" + + sku: str + vector: np.ndarray + group: str # the photo it came from; copies of one photo share it + kind: str = "train" # train: matched against | probe: only used to calibrate + + +def vectors_for_photo(photo: np.ndarray, group: str, sku: str, + rng: np.random.Generator | None = None) -> list[Exemplar]: + """Everything one enrolment photo contributes: itself, 3 training copies + under video-like conditions, and 4 held-out probes for calibration.""" + # Seeded from the photo's id, not hash(): str hashes change per process. + rng = rng or np.random.default_rng(zlib.crc32(group.encode())) + product = product_crop(photo) + out = [Exemplar(sku, embed(product), group, "train")] + out += [Exemplar(sku, embed(a), group, "train") for a in augment(product, rng, 3)] + out += [Exemplar(sku, embed(a), group, "probe") for a in augment(product, rng, 4)] + return out + + +DEFAULT_ACCEPT = 0.93 # used until a catalog has enough photos to calibrate +# Below this many photos a product's own bar is set by its one or two least +# alike probes: with 5 photos it swung 0.914-0.968 between photo sets of the +# same product, and with 8 or more it stayed at or above 0.935. Such a bar +# is trusted only where a runner-up can still catch a wrong match. +MIN_PHOTOS_FOR_OWN_BAR = 8 +DEFAULT_MARGIN = 0.02 + + +class ExemplarIndex: + """Nearest-exemplar matching against enrolled products, self-calibrating. + + The similarity a genuine sighting reaches depends on the products (a + range of look-alikes scores everything high), so no constant threshold + is right for every customer. The index measures its own: each held-out + probe is scored against everything except the photo it came from, which + is how a real sighting (a view no photo captured exactly) is scored. + `accept` is set just under the low tail of those scores, so about 98% of + genuine sightings clear it and anything less alike is called unknown. + """ + + def __init__(self, exemplars: list[Exemplar] | dict[str, np.ndarray]) -> None: + if isinstance(exemplars, dict): # plain {sku: vectors}: one group per vector + exemplars = [ + Exemplar(sku, v, f"{sku}:{i}") + for sku, vecs in exemplars.items() + for i, v in enumerate(np.asarray(vecs, np.float32)) + ] + train = [e for e in exemplars if e.kind == "train"] + self.skus: list[str] = sorted({e.sku for e in train}) + pos = {s: i for i, s in enumerate(self.skus)} + if train: + self.matrix = np.vstack([e.vector for e in train]).astype(np.float32) + self.owner = np.asarray([pos[e.sku] for e in train], np.int32) + self.groups = np.asarray([e.group for e in train]) + self.centroids = np.vstack([ + _l2(self.matrix[self.owner == i].mean(axis=0)) for i in range(len(self.skus)) + ]) + self.photos = {s: len(set(self.groups[self.owner == i])) for i, s in enumerate(self.skus)} + else: + self.matrix = np.zeros((0, 1), np.float32) + self.owner = np.zeros(0, np.int32) + self.groups = np.zeros(0, dtype=object) + self.centroids = np.zeros((0, 1), np.float32) + self.photos = {} + self.accept = DEFAULT_ACCEPT + self.margin = DEFAULT_MARGIN + self.accept_by_sku: dict[str, float] = {} + self.calibration: dict[str, float | int | str] = {"source": "default"} + self._calibrate([e for e in exemplars if e.kind == "probe"]) + + def __len__(self) -> int: + return len(self.skus) + + def _scores(self, vec: np.ndarray, exclude_group: str | None = None) -> np.ndarray: + sims = self.matrix @ vec + if exclude_group is not None: + sims = np.where(self.groups == exclude_group, -1.0, sims) + best = np.full(len(self.skus), -1.0, np.float32) + np.maximum.at(best, self.owner, sims) + central = self.centroids @ vec + return 0.7 * best + 0.3 * central + + def match(self, vec: np.ndarray, k: int = 3) -> list[Match]: + """Best-first SKUs for one embedding.""" + if not self.skus: + return [] + scores = self._scores(vec) + order = np.argsort(-scores)[:k] + return [Match(self.skus[i], float(scores[i])) for i in order] + + def _calibrate(self, probes: list[Exemplar], max_probes: int = 2000) -> None: + usable = [p for p in probes if p.sku in self.skus] + # A SKU needs a second photo for its probes to be scored without + # their own photo; below a handful of such probes, keep the default. + own: list[float] = [] + by_sku: dict[str, list[float]] = {} + for p in usable[:max_probes]: + i = self.skus.index(p.sku) + if not np.any((self.groups != p.group) & (self.owner == i)): + continue + score = float(self._scores(p.vector, exclude_group=p.group)[i]) + own.append(score) + by_sku.setdefault(p.sku, []).append(score) + if len(own) < 8: + return + low = float(np.percentile(own, 2)) + self.accept = float(np.clip(low - 0.005, 0.5, 0.99)) + # The spread of genuine scores sets how big a lead counts as decisive. + self.margin = float(np.clip((np.percentile(own, 50) - low) / 2, 0.005, 0.1)) + # Per SKU, too: a plain box matches its own photos very closely, so + # an unenrolled look-alike that merely resembles it must clear that + # SKU's own bar, not the loosest bar in the catalog. Measured on a + # look-alike range, this took unenrolled-product rejection from 0% to + # over 90% of sightings, with no wrong acceptances. + for sku, scores in by_sku.items(): + if len(scores) >= 6: + self.accept_by_sku[sku] = float( + np.clip(np.percentile(scores, 5) - 0.005, 0.5, 0.995) + ) + self.calibration = {"source": "probes", "probes": len(own), + "accept": round(self.accept, 4), "margin": round(self.margin, 4), + "per_sku": {k: round(v, 4) for k, v in self.accept_by_sku.items()}} + + def accept_for(self, sku: str) -> float: + return self.accept_by_sku.get(sku, self.accept) + + def well_photographed(self, sku: str) -> bool: + """Enough photos that the product's own bar is not set by one odd view.""" + return self.photos.get(sku, 0) >= MIN_PHOTOS_FOR_OWN_BAR + + +def confidence(matches: list[Match], accept: float, margin: float) -> float: + """How sure a match is: absolute similarity and the lead over the runner-up. + + Both matter: a sighting can look a lot like two SKUs (high score, no + lead), or be closest to one while resembling nothing much (a lead, low + score). Either way a person should look. + """ + if not matches: + return 0.0 + top = matches[0].score + if top < accept: + # Unlike anything enrolled: at most a weak hint, never a decision. + return float(0.3 * np.clip(top / max(accept, 1e-6), 0.0, 1.0)) + second = matches[1].score if len(matches) > 1 else accept - margin + absolute = np.clip((top - accept) / max(1.0 - accept, 1e-6), 0.0, 1.0) + lead = np.clip((top - second) / max(margin, 1e-6), 0.0, 1.0) + return float(np.clip(0.35 + 0.35 * absolute + 0.30 * lead, 0.0, 1.0)) diff --git a/src/countbone/catalog.py b/src/countbone/catalog.py index edc8647..55acbab 100644 --- a/src/countbone/catalog.py +++ b/src/countbone/catalog.py @@ -23,6 +23,8 @@ class SkuEntry: classes: list[str] = field(default_factory=list) # detector class names that map here unit_value: float = 0.0 # used by the tolerance plugin expected: int | None = None + barcodes: list[str] = field(default_factory=list) # EAN/UPC printed on the product + source: str = "config" # config | studio (made in Catalog Studio) def matches_hue(self, hue: float) -> bool: if self.hue is None: @@ -63,6 +65,29 @@ def hue_center_distance(self, hue: float) -> float: @dataclass class Catalog: entries: list[SkuEntry] = field(default_factory=list) + # Enrolled photos, as appearance vectors (see appearance.py). Empty for a + # catalog that identifies by colour or detector class only. + exemplars: list[Any] = field(default_factory=list) + _index: Any = field(default=None, repr=False, compare=False) + + def index(self): + """The appearance index over enrolled photos, built once per catalog.""" + if self._index is None: + from .appearance import ExemplarIndex + + self._index = ExemplarIndex(self.exemplars) + return self._index + + def by_barcode(self, code: str) -> SkuEntry | None: + return next((e for e in self.entries if code in e.barcodes), None) + + def merged(self, extra: list[SkuEntry]) -> Catalog: + """This catalog with `extra` entries added; an extra entry with the + same SKU replaces the configured one (the studio is the newer edit).""" + by_sku = {e.sku: e for e in self.entries} + for e in extra: + by_sku[e.sku] = e + return Catalog(list(by_sku.values()), list(self.exemplars)) @classmethod def load(cls, path: str | Path | None) -> Catalog: @@ -86,6 +111,7 @@ def from_dict(cls, raw: dict[str, Any]) -> Catalog: classes=list(item.get("classes", []) or []), unit_value=float(item.get("unit_value", 0.0)), expected=item.get("expected"), + barcodes=[str(b) for b in item.get("barcodes", []) or []], ) ) return cls(entries) diff --git a/src/countbone/cli.py b/src/countbone/cli.py index c01524b..4cf6ff7 100644 --- a/src/countbone/cli.py +++ b/src/countbone/cli.py @@ -3,6 +3,7 @@ countbone run video.mp4 count a video countbone demo generate synthetic footage and count it countbone serve dashboard + API + countbone user add NAME create an account (also: list, reset-password) countbone plugins what is installed countbone reviews what is waiting for a human countbone history SKU how this SKU has counted over time @@ -13,6 +14,7 @@ import argparse import json import logging +import os import sys from datetime import datetime @@ -77,10 +79,30 @@ def _print_result(result: CountResult, truth: dict[str, int] | None = None) -> N print("\nwrote: " + ", ".join(f"{k} -> {v}" for k, v in outputs.items())) +def _pipeline(cfg: Config) -> Pipeline: + """A pipeline that also writes inspector.json, like one run from the API. + + Without it, a video counted from the CLI appears in the dashboard (same + database) but can never be opened in its frame inspector. There is no one + to report live progress to here, so progress goes nowhere. + """ + from .api import telemetry # no web dependencies: safe without the [api] extra + from .catalog import Catalog + from .ops.catalog import load_catalog + + store = Store(cfg.output.sqlite) if cfg.output.sqlite else None + base = Catalog.load(cfg.identify.catalog) + # With a database, products and photos from Catalog Studio count too. + provider = (lambda: load_catalog(base, store)) if store is not None else None + pipeline = Pipeline(cfg, store=store, catalog_provider=provider) + pipeline.plugins = telemetry.attach(pipeline.plugins, lambda *_, **__: None) + return pipeline + + # -- commands ------------------------------------------------------------- def cmd_run(args: argparse.Namespace) -> int: cfg = _load_config(args) - result = Pipeline(cfg).run(args.video) + result = _pipeline(cfg).run(args.video) if args.json: print(json.dumps(result.to_dict(), indent=2)) else: @@ -95,7 +117,7 @@ def cmd_demo(args: argparse.Namespace) -> int: scene = make_demo_video(args.video or "examples/demo_shelf.mp4", seed=args.seed) print(f"synthetic shelf: {scene.path} ({scene.total} units, {scene.frames} frames)") cfg.count.expected = dict(scene.truth) - result = Pipeline(cfg).run(scene.path) + result = _pipeline(cfg).run(scene.path) _print_result(result, truth=scene.truth) error = abs(result.total - scene.total) print(f"\nabsolute count error: {error} of {scene.total} " @@ -107,9 +129,60 @@ def cmd_serve(args: argparse.Namespace) -> int: import uvicorn from .api.app import create_app + from .api.auth import is_loopback cfg = _load_config(args) - uvicorn.run(create_app(cfg), host=args.host, port=args.port, log_level="info") + if args.no_auth and not is_loopback(args.host): + raise SystemExit("--no-auth is only allowed on a loopback host (127.0.0.1); " + "anyone on the network would be an admin") + app = create_app(cfg, allow_origins=args.allow_origin or (), auth=not args.no_auth, + data_dir=args.data_dir) + # Behind a reverse proxy every request arrives from the proxy's address; + # only a proxy named here is believed about the real client (login + # throttling and the audit trail both depend on it). + forwarded = args.forwarded_allow_ips or os.environ.get("FORWARDED_ALLOW_IPS") or "127.0.0.1" + uvicorn.run(app, host=args.host, port=args.port, log_level="info", + proxy_headers=True, forwarded_allow_ips=forwarded) + return 0 + + +def cmd_user(args: argparse.Namespace) -> int: + import getpass + + from .security import ROLES, check_password_policy, hash_password + + store = Store(args.db or Config().output.sqlite or "countbone.db") + if args.action == "list": + for u in store.list_users(): + state = "disabled" if u["disabled"] else "active" + print(f"{u['username']:<24}{u['role']:<10}{state:<10}{u['display_name'] or ''}") + return 0 + if not args.username: + raise SystemExit("give a username") + password = os.environ.get(args.password_env) if args.password_env else None + if password is None: + password = getpass.getpass(f"password for {args.username}: ") + problem = check_password_policy(password) + if problem: + raise SystemExit(f"password rejected: {problem}") + if args.action == "add": + if args.role not in ROLES: + raise SystemExit(f"role must be one of {', '.join(ROLES)}") + if store.user_by_username(args.username): + raise SystemExit("that username exists; use reset-password") + store.create_user(args.username, hash_password(password), args.role, args.name, + created_by="cli") + store.add_audit("users", "user_created", {"username": args.username, "role": args.role}, + actor="cli") + print(f"created {args.role} {args.username}") + else: # reset-password + user = store.user_by_username(args.username) + if user is None: + raise SystemExit("no such user") + store.update_user(user["user_id"], pw_hash=hash_password(password), disabled=0) + store.delete_user_sessions(user["user_id"]) + store.add_audit("users", "password_reset", {"username": args.username}, actor="cli") + print(f"password reset for {args.username}; their sessions were signed out") return 0 @@ -196,9 +269,34 @@ def common(p: argparse.ArgumentParser) -> None: serve = sub.add_parser("serve", help="run the dashboard and API") serve.add_argument("--host", default="127.0.0.1") serve.add_argument("--port", type=int, default=8000) + serve.add_argument( + "--allow-origin", action="append", metavar="ORIGIN", + help="let a browser app on this origin call the API, e.g. the mobile app's " + "web preview at http://localhost:8081 (repeatable; none by default). " + "Native apps need no origin.", + ) + serve.add_argument("--no-auth", action="store_true", + help="single-user local mode: no sign-in (loopback host only)") + serve.add_argument( + "--forwarded-allow-ips", metavar="IPS", + help="proxies whose X-Forwarded-For is trusted, comma-separated, or '*' when " + "only the proxy can reach this port (default: $FORWARDED_ALLOW_IPS or 127.0.0.1)", + ) + serve.add_argument("--data-dir", help="keys, evidence packs and catalog photos " + "(default: countbone-data beside the database)") common(serve) serve.set_defaults(func=cmd_serve) + user = sub.add_parser("user", help="manage accounts (add, list, reset-password)") + user.add_argument("action", choices=["add", "list", "reset-password"]) + user.add_argument("username", nargs="?") + user.add_argument("--role", default="counter", help="counter | manager | admin") + user.add_argument("--name", help="display name") + user.add_argument("--password-env", metavar="VAR", + help="read the password from this environment variable instead of a prompt") + user.add_argument("--db") + user.set_defaults(func=cmd_user) + plugins = sub.add_parser("plugins", help="list installed plugins") plugins.set_defaults(func=cmd_plugins) diff --git a/src/countbone/config.py b/src/countbone/config.py index 9b033cb..71cc7a5 100644 --- a/src/countbone/config.py +++ b/src/countbone/config.py @@ -18,11 +18,16 @@ @dataclass class CaptureConfig: - every_n_frames: int = 5 # sample rate through the source video - max_frames: int | None = 400 # hard cap so a long video cannot stall a run + every_n_frames: int = 5 # the widest stride; adaptive sampling only ever narrows it + # Hard cap so a runaway source cannot stall a run. Reaching it is flagged + # for review (the rest of the video went uncounted), never silent. + max_frames: int | None = 5000 start_s: float = 0.0 end_s: float | None = None resize_width: int | None = 960 # None keeps native resolution + adaptive: bool = True # closed-loop sampling, see stages.capture.Sampler + target_shift_frac: float = 0.25 # aim for this much scene movement per sample, in object widths + max_shift_frac: float = 0.6 # back-fill skipped frames when a gap moves further than this @dataclass @@ -46,7 +51,8 @@ class DetectConfig: @dataclass class IdentifyConfig: - backend: str = "color" # color | classmap | fixture + backend: str = "auto" # auto (appearance once photos exist, else color) + # | appearance | color | classmap | fixture catalog: str | None = None # path to a SKU catalog YAML min_confidence: float = 0.2 unknown_sku: str = "UNKNOWN" @@ -79,10 +85,16 @@ class PluginSpec: DEFAULT_PLUGINS = [ PluginSpec("quality_gate"), + PluginSpec("location_tag"), PluginSpec("multiframe"), PluginSpec("confidence"), + # On by default: it only acts when a count has an expected number, and a + # count that misses its expected number must never say "no review needed". + PluginSpec("tolerance"), + PluginSpec("shelf_check"), PluginSpec("review_queue"), PluginSpec("exception_report"), + PluginSpec("contact_sheet"), PluginSpec("audit_pack"), ] diff --git a/src/countbone/demo.py b/src/countbone/demo.py index 0f90aec..0956bfc 100644 --- a/src/countbone/demo.py +++ b/src/countbone/demo.py @@ -7,7 +7,10 @@ from __future__ import annotations +import contextlib +import os import random +import sys from dataclasses import dataclass from pathlib import Path @@ -23,6 +26,35 @@ } +# A range the colour identifier cannot separate: four red cartons told apart +# only by their artwork, plus one blue. This is what a real shelf looks like to +# a hue histogram (one brand, one colour, many products). +LOOKALIKE_SKUS: dict[str, dict] = { + "RED-PLAIN": {"label": "Red carton, plain", "color": (40, 40, 205), "art": "plain"}, + "RED-STRIPE": {"label": "Red carton, stripes", "color": (40, 40, 205), "art": "stripes"}, + "RED-DOT": {"label": "Red carton, black dot", "color": (40, 40, 205), "art": "dot"}, + "RED-BAND": {"label": "Red carton, dark band", "color": (40, 40, 205), "art": "band"}, + "BLU-PLAIN": {"label": "Blue carton", "color": (200, 110, 40), "art": "plain"}, +} + + +def draw_carton(board: np.ndarray, x1: int, y1: int, x2: int, y2: int, + color: tuple[int, int, int], art: str = "plain") -> None: + """One carton: body, outline, a white label patch, and its artwork.""" + cv2.rectangle(board, (x1, y1), (x2, y2), color, -1) + cv2.rectangle(board, (x1, y1), (x2, y2), (30, 30, 30), 3) + cv2.rectangle(board, (x1 + 16, y1 + 24), (x2 - 16, y1 + 52), (235, 235, 235), -1) + w, h = x2 - x1, y2 - y1 + if art == "stripes": + for k in range(3): + sx = x1 + int(w * (0.22 + 0.25 * k)) + cv2.rectangle(board, (sx, y1 + 62), (sx + max(4, w // 12), y2 - 8), (235, 235, 235), -1) + elif art == "dot": + cv2.circle(board, (x1 + w // 2, y1 + int(h * 0.68)), max(6, int(w * 0.24)), (25, 25, 25), -1) + elif art == "band": + cv2.rectangle(board, (x1 + 4, y1 + int(h * 0.62)), (x2 - 4, y1 + int(h * 0.78)), (30, 30, 90), -1) + + @dataclass class DemoScene: path: str @@ -30,14 +62,82 @@ class DemoScene: frames: int fps: int shelf_size: tuple[int, int] + # Every carton on the board, for tests that check where things are + # (shelf gaps, cross-video merging): (sku, x1, y1, x2, y2) in board pixels. + layout: list[tuple[str, int, int, int, int]] | None = None + # Empty slots between cartons, same coordinates: (row_top, x1, x2). + empty_slots: list[tuple[int, int, int]] | None = None + view_x: tuple[int, int] = (0, 0) # the board range the pan covered @property def total(self) -> int: return sum(self.truth.values()) -def _shelf(width: int, height: int, rng: random.Random) -> tuple[np.ndarray, dict[str, int]]: - """Paint a shelf wall with evenly spaced cartons.""" +def _lookalike_shelf(width: int, height: int, rng: random.Random): + board = np.full((height, width, 3), 168, dtype=np.uint8) + noise = np.random.default_rng(rng.randint(0, 2**31)).normal(0, 6, board.shape) + board = np.clip(board.astype(np.float64) + noise, 0, 255).astype(np.uint8) + for y in (height // 3, 2 * height // 3): + cv2.rectangle(board, (0, y - 6), (width, y + 6), (120, 120, 120), -1) + truth: dict[str, int] = {} + layout, empty = [], [] + box_w, box_h, gap_x, gap_y = 96, 132, 46, 40 + tops = [20, height // 3 + 20, 2 * height // 3 + 20] + x = 40 + while x + box_w < width - 40: + for top in tops: + if rng.random() < 0.15: + empty.append((top, x, x + box_w)) + continue + sku = rng.choice(list(LOOKALIKE_SKUS)) + spec = LOOKALIKE_SKUS[sku] + j = rng.randint(-6, 6) + x1, y1, x2, y2 = x + j, top, x + j + box_w, top + box_h - gap_y // 2 + draw_carton(board, x1, y1, x2, y2, spec["color"], spec["art"]) + truth[sku] = truth.get(sku, 0) + 1 + layout.append((sku, x1, y1, x2, y2)) + x += box_w + gap_x + return board, truth, layout, empty + + +def product_photos(sku: str, n: int = 5, seed: int = 0) -> list[np.ndarray]: + """What a person enrolling a look-alike SKU would photograph with a phone. + + Close up, a bit rotated, on a random background, with phone-camera + exposure and softness. Deliberately unlike the shelf video, so enrolment + is tested across the domain gap it faces in practice. + """ + spec = LOOKALIKE_SKUS.get(sku) or {"color": SKU_COLORS[sku], "art": "plain"} + rng = np.random.default_rng(seed) + photos = [] + for _ in range(n): + canvas = np.full((460, 380, 3), rng.integers(60, 220, 3), dtype=np.uint8) + canvas = np.clip(canvas + rng.normal(0, 8, canvas.shape), 0, 255).astype(np.uint8) + # The same carton as on the shelf, photographed closer: drawn at shelf + # scale, then enlarged, so its proportions are the product's own. + small = np.full((120, 104, 3), 168, np.uint8) + draw_carton(small, 4, 4, 100, 116, spec["color"], spec["art"]) + art = cv2.resize(small, (250, 340), interpolation=cv2.INTER_CUBIC) + angle = rng.uniform(-6, 6) + m = cv2.getRotationMatrix2D((125, 170), angle, rng.uniform(0.95, 1.05)) + art = cv2.warpAffine(art, m, (250, 340), borderMode=cv2.BORDER_REPLICATE) + ox, oy = int(rng.integers(40, 90)), int(rng.integers(40, 90)) + canvas[oy : oy + 340, ox : ox + 250] = art + gain = rng.uniform(0.8, 1.2) + canvas = np.clip(canvas.astype(np.float32) * gain, 0, 255).astype(np.uint8) + photo = canvas[oy - 12 : oy + 352, ox - 12 : ox + 262] # the user framed it roughly + photos.append(cv2.GaussianBlur(photo, (3, 3), 0)) + return photos + + +def _shelf(width: int, height: int, rng: random.Random): + """Paint a shelf wall with evenly spaced cartons. + + Returns the board, the truth per SKU, every carton's box and every empty + slot. (The random sequence is unchanged from the first version, so a seed + still renders the same shelf.) + """ board = np.full((height, width, 3), 168, dtype=np.uint8) noise = np.random.default_rng(rng.randint(0, 2**31)).normal(0, 6, board.shape) board = np.clip(board.astype(np.float64) + noise, 0, 255).astype(np.uint8) @@ -47,6 +147,8 @@ def _shelf(width: int, height: int, rng: random.Random) -> tuple[np.ndarray, dic cv2.rectangle(board, (0, y - 6), (width, y + 6), (120, 120, 120), -1) truth: dict[str, int] = {} + layout: list[tuple[str, int, int, int, int]] = [] + empty: list[tuple[int, int, int]] = [] box_w, box_h = 96, 132 gap_x, gap_y = 46, 40 top_offsets = [20, height // 3 + 20, 2 * height // 3 + 20] @@ -54,21 +156,18 @@ def _shelf(width: int, height: int, rng: random.Random) -> tuple[np.ndarray, dic while x + box_w < width - 40: for top in top_offsets: if rng.random() < 0.18: # a few empty slots keeps it honest + empty.append((top, x, x + box_w)) continue sku = rng.choice(list(SKU_COLORS)) color = SKU_COLORS[sku] jitter = rng.randint(-6, 6) x1, y1 = x + jitter, top x2, y2 = x1 + box_w, y1 + box_h - gap_y // 2 - cv2.rectangle(board, (x1, y1), (x2, y2), color, -1) - cv2.rectangle(board, (x1, y1), (x2, y2), (30, 30, 30), 3) - # a label patch, so the box is not a flat colour field - cv2.rectangle( - board, (x1 + 16, y1 + 24), (x2 - 16, y1 + 52), (235, 235, 235), -1 - ) + draw_carton(board, x1, y1, x2, y2, color) truth[sku] = truth.get(sku, 0) + 1 + layout.append((sku, x1, y1, x2, y2)) x += box_w + gap_x - return board, truth + return board, truth, layout, empty def make_demo_video( @@ -78,29 +177,53 @@ def make_demo_video( seconds: float = 6.0, view: tuple[int, int] = (960, 540), bad_frame_rate: float = 0.08, + palette: str = "primary", + window: tuple[float, float] = (0.0, 1.0), + shelf_views: float = 2.2, + label: str | None = None, + label_seconds: float = 0.75, ) -> DemoScene: """Render a pan across a synthetic shelf. Returns the ground truth with it. `bad_frame_rate` blurs a fraction of frames, so the quality gate has - something real to reject. + something real to reject. `palette="lookalike"` paints products that differ + only in artwork. `window` pans across only part of the shelf (as fractions + of the full pan): two overlapping windows of the same seed are two videos + of one shelf, filmed by two people or twice. Shorter `seconds` pan faster. """ rng = random.Random(seed) view_w, view_h = view - shelf_w, shelf_h = int(view_w * 2.2), view_h - board, truth = _shelf(shelf_w, shelf_h, rng) + shelf_w, shelf_h = int(view_w * shelf_views), view_h + if palette == "primary": + board, _, layout, empty = _shelf(shelf_w, shelf_h, rng) + elif palette == "lookalike": + board, _, layout, empty = _lookalike_shelf(shelf_w, shelf_h, rng) + else: + raise ValueError(f"unknown palette {palette!r}; choose primary or lookalike") out_path = Path(path) out_path.parent.mkdir(parents=True, exist_ok=True) frames = int(fps * seconds) + travel = shelf_w - view_w + start, end = int(window[0] * travel), int(window[1] * travel) + # What this video can see: cartons wholly inside the covered range count. + seen_lo, seen_hi = start, end + view_w + truth: dict[str, int] = {} + for sku, x1, _, x2, _ in layout: + if x1 >= seen_lo and x2 <= seen_hi: + truth[sku] = truth.get(sku, 0) + 1 writer, out_path = _open_writer(out_path, fps, view_w, view_h) try: - travel = shelf_w - view_w + if label: + # The operator films the bay's label first, then pans the shelf. + for frame in _label_frames(label, view_w, view_h, int(fps * label_seconds)): + writer.write(frame) for i in range(frames): # ease-in-out pan: a human hand does not move linearly t = i / max(frames - 1, 1) eased = 3 * t**2 - 2 * t**3 - x = int(eased * travel) + x = start + int(eased * (end - start)) frame = board[0:view_h, x : x + view_w].copy() if rng.random() < bad_frame_rate: frame = cv2.GaussianBlur(frame, (21, 21), 0) @@ -114,20 +237,80 @@ def make_demo_video( frames=frames, fps=fps, shelf_size=(shelf_w, shelf_h), + layout=[c for c in layout if c[1] >= seen_lo and c[3] <= seen_hi], + empty_slots=empty, + view_x=(seen_lo, seen_hi), ) +def _label_frames(code: str, w: int, h: int, n: int) -> list[np.ndarray]: + """A printed bay label on a wall, held steady for n frames.""" + import segno + + from .plugins.location_tag import label_payload + + # micro=False: segno picks Micro QR for short payloads, which OpenCV cannot read. + qr = segno.make(label_payload(code), error="m", micro=False) + matrix = np.array([[0 if dark else 255 for dark in row] for row in qr.matrix], np.uint8) + size = min(h, w) // 2 + img = cv2.resize(np.pad(matrix, 4, constant_values=255), (size, size), + interpolation=cv2.INTER_NEAREST) + wall = np.full((h, w, 3), 200, np.uint8) + y, x = (h - size) // 2, (w - size) // 2 + wall[y : y + size, x : x + size] = img[:, :, None] + cv2.putText(wall, code, (x, y + size + 40), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (20, 20, 20), 3) + return [wall.copy() for _ in range(n)] + + +@contextlib.contextmanager +def _native_stderr_silenced(): + """Point file descriptor 2 at the null device for the duration. + + Python-level redirection cannot catch output written by C libraries, so + this swaps the descriptor itself, and always restores it. + """ + try: + sys.stderr.flush() + saved = os.dup(2) + except (OSError, ValueError): # no real stderr (e.g. some IDE consoles) + yield + return + devnull = os.open(os.devnull, os.O_WRONLY) + try: + os.dup2(devnull, 2) + yield + finally: + os.dup2(saved, 2) + os.close(saved) + os.close(devnull) + + def _open_writer(path: Path, fps: int, w: int, h: int): - """mp4 where the codec exists, AVI where it does not. Never fail silently.""" - attempts = [(path.with_suffix(".mp4"), "mp4v"), (path.with_suffix(".avi"), "MJPG")] + """A format a browser can play if this OpenCV build can write one. + + The dashboard's frame inspector plays the source video, and no browser + plays OpenCV's usual mp4v. VP8 WebM is written by the pip wheels and + played by every current browser. (H.264 would be nicer, but the wheels + ship no encoder and probing for one prints codec errors on every run.) + mp4v and MJPG remain as last resorts. Never fail silently. + """ + attempts = [ + (path.with_suffix(".webm"), "VP80"), + (path.with_suffix(".mp4"), "mp4v"), + (path.with_suffix(".avi"), "MJPG"), + ] for candidate, fourcc in attempts: - writer = cv2.VideoWriter( - str(candidate), cv2.VideoWriter_fourcc(*fourcc), fps, (w, h) - ) + # FFmpeg prints a harmless "tag VP80 is not supported ... webm" notice + # from native code while it picks the right tag itself; keep it off + # the user's terminal. Failures still surface through isOpened(). + with _native_stderr_silenced(): + writer = cv2.VideoWriter( + str(candidate), cv2.VideoWriter_fourcc(*fourcc), fps, (w, h) + ) if writer.isOpened(): return writer, candidate writer.release() raise RuntimeError( - "OpenCV could not open any video writer (tried mp4v and MJPG); " + "OpenCV could not open any video writer (tried VP80, mp4v and MJPG); " "install a build of opencv with video support" ) diff --git a/src/countbone/integrations/__init__.py b/src/countbone/integrations/__init__.py new file mode 100644 index 0000000..c8fd339 --- /dev/null +++ b/src/countbone/integrations/__init__.py @@ -0,0 +1,48 @@ +"""Connectors to customers' systems of record.""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from .base import Adjustment, Connector, IntegrationError, PostResult, PurchaseOrder +from .netsuite import NetSuiteConnector +from .sap import SapConnector +from .shopify import ShopifyConnector +from .webhook import WebhookConnector + +KINDS: dict[str, type[Connector]] = { + c.kind: c for c in (ShopifyConnector, NetSuiteConnector, SapConnector, WebhookConnector) +} + + +def build(kind: str, settings: dict[str, Any], secrets: dict[str, Any], + client: httpx.Client | None = None) -> Connector: + try: + cls = KINDS[kind] + except KeyError: + raise IntegrationError(f"unknown integration {kind!r}; choose from {sorted(KINDS)}") from None + return cls(settings, secrets, client) + + +def describe() -> list[dict[str, Any]]: + """What each connector can do and what it needs, for the settings UI.""" + return [ + { + "kind": c.kind, + "label": c.label, + "can_pull_expected": c.can_pull_expected, + "can_push_adjustments": c.can_push_adjustments, + "can_pull_purchase_orders": c.can_pull_purchase_orders, + "settings_fields": c.settings_fields, + "secret_fields": c.secret_fields, + } + for c in KINDS.values() + ] + + +__all__ = [ + "Adjustment", "Connector", "IntegrationError", "KINDS", "PostResult", "PurchaseOrder", + "build", "describe", +] diff --git a/src/countbone/integrations/base.py b/src/countbone/integrations/base.py new file mode 100644 index 0000000..e7211dd --- /dev/null +++ b/src/countbone/integrations/base.py @@ -0,0 +1,110 @@ +"""Connector contract: how Countbone talks to a customer's system of record. + +Three things flow across the boundary, and a connector implements the ones +its system supports: + + pull_expected book quantities per location and SKU (what the count is + compared against) + push_adjustments approved differences, written back to the book + pull_purchase_order the lines of a PO, so a delivery can be counted + against it (Receive) + +Connectors are thin, synchronous and use httpx with a caller-supplied +client, so tests replace the network with httpx.MockTransport and assert the +exact requests each system receives. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import httpx + + +class IntegrationError(RuntimeError): + """The other system refused or could not be reached. Message is for people.""" + + +@dataclass +class Adjustment: + adjustment_id: str + location: str | None + sku: str + system_qty: int | None + counted_qty: int + delta: int + note: str = "" + + +@dataclass +class PostResult: + adjustment_id: str + ok: bool + external_ref: str | None = None + error: str | None = None + + +@dataclass +class PurchaseOrder: + po_number: str + supplier: str | None + lines: dict[str, dict[str, Any]] = field(default_factory=dict) # sku -> {qty, unit_cost} + + +class Connector: + kind = "base" + label = "Connector" + can_pull_expected = False + can_push_adjustments = False + can_pull_purchase_orders = False + # Settings (non-secret) and secrets this connector needs, for the UI form. + settings_fields: list[dict[str, str]] = [] + secret_fields: list[dict[str, str]] = [] + + def __init__(self, settings: dict[str, Any], secrets: dict[str, Any], + client: httpx.Client | None = None) -> None: + self.settings = settings or {} + self.secrets = secrets or {} + self.client = client or httpx.Client(timeout=30.0) + missing = [f["key"] for f in self.settings_fields + if f.get("required") and not self.settings.get(f["key"])] + missing += [f["key"] for f in self.secret_fields + if f.get("required") and not self.secrets.get(f["key"])] + if missing: + raise IntegrationError(f"{self.label}: missing {', '.join(missing)}") + + # -- location mapping: Countbone bay codes -> the other system's places ---- + def target_location(self, location: str | None) -> str | None: + """Many bays map to one ERP location (a Shopify location is a whole + store or warehouse). The map is `location_map` in settings, keyed by + bay code or by prefix ending in '*'; `default_location` catches the rest.""" + mapping: dict[str, str] = self.settings.get("location_map") or {} + if location and location in mapping: + return mapping[location] + if location: + for key, value in mapping.items(): + if key.endswith("*") and location.startswith(key[:-1]): + return value + return self.settings.get("default_location") + + def test(self) -> dict[str, Any]: + raise NotImplementedError + + def pull_expected(self, locations: dict[str, list[str]]) -> dict[str, dict[str, int]]: + """{bay_code: [skus]} -> {bay_code: {sku: qty}}.""" + raise IntegrationError(f"{self.label} cannot read stock levels") + + def push_adjustments(self, adjustments: list[Adjustment]) -> list[PostResult]: + raise IntegrationError(f"{self.label} cannot write adjustments") + + def pull_purchase_order(self, po_number: str) -> PurchaseOrder: + raise IntegrationError(f"{self.label} has no purchase orders") + + # -- helpers ----------------------------------------------------------- + @staticmethod + def _raise_for(response: httpx.Response, what: str) -> None: + if response.is_success: + return + detail = response.text[:300].strip() + raise IntegrationError(f"{what} failed: HTTP {response.status_code} {detail}") diff --git a/src/countbone/integrations/csvio.py b/src/countbone/integrations/csvio.py new file mode 100644 index 0000000..8f044eb --- /dev/null +++ b/src/countbone/integrations/csvio.py @@ -0,0 +1,147 @@ +"""CSV in and out: the integration every customer already has. + +Imports are forgiving about headers (case, spaces, common synonyms) and +strict about values: a bad row is reported by line number, never skipped +in silence. +""" + +from __future__ import annotations + +import csv +import io +from typing import Any + +from .base import IntegrationError + +_SYNONYMS = { + "location": {"location", "bay", "bin", "location_code", "loc", "shelf"}, + "sku": {"sku", "item", "itemid", "item_id", "product", "material", "code"}, + "qty": {"qty", "quantity", "on_hand", "onhand", "expected", "count", "units"}, + "unit_cost": {"unit_cost", "cost", "price", "rate", "unit_price"}, + "unit_value": {"unit_value", "value"}, + "label": {"label", "name", "description", "title"}, + "po_number": {"po", "po_number", "purchase_order", "order"}, + "supplier": {"supplier", "vendor"}, + "barcode": {"barcode", "ean", "upc", "gtin"}, +} + + +def _header_map(fieldnames: list[str]) -> dict[str, str]: + out = {} + for raw in fieldnames: + key = raw.strip().lower().replace(" ", "_").replace("-", "_") + for canonical, names in _SYNONYMS.items(): + if key in names and canonical not in out: + out[canonical] = raw + return out + + +def _reader(text: str) -> tuple[csv.DictReader, dict[str, str]]: + text = text.lstrip("") # Excel's BOM + reader = csv.DictReader(io.StringIO(text)) + if not reader.fieldnames: + raise IntegrationError("the file is empty") + return reader, _header_map(reader.fieldnames) + + +def _int(value: str, line: int, column: str) -> int: + try: + f = float(str(value).strip().replace(",", "")) + except ValueError: + raise IntegrationError(f"line {line}: {column} {value!r} is not a number") from None + if f != int(f): + raise IntegrationError(f"line {line}: {column} {value!r} is not a whole number") + return int(f) + + +def parse_expected(text: str) -> dict[str, dict[str, int]]: + """location,sku,qty rows -> {location: {sku: qty}}.""" + reader, cols = _reader(text) + for need in ("location", "sku", "qty"): + if need not in cols: + raise IntegrationError(f"missing a {need} column (found {', '.join(reader.fieldnames or [])})") + out: dict[str, dict[str, int]] = {} + for n, row in enumerate(reader, start=2): + loc, sku = (row.get(cols["location"]) or "").strip(), (row.get(cols["sku"]) or "").strip() + if not loc and not sku: + continue + if not loc or not sku: + raise IntegrationError(f"line {n}: location and sku are both required") + qty = _int(row.get(cols["qty"]) or "", n, "qty") + if qty < 0: + raise IntegrationError(f"line {n}: qty cannot be negative") + out.setdefault(loc, {})[sku] = out.get(loc, {}).get(sku, 0) + qty + return out + + +def parse_po_lines(text: str) -> dict[str, dict[str, Any]]: + """sku,qty[,unit_cost] rows -> {sku: {qty, unit_cost}}.""" + reader, cols = _reader(text) + for need in ("sku", "qty"): + if need not in cols: + raise IntegrationError(f"missing a {need} column") + out: dict[str, dict[str, Any]] = {} + for n, row in enumerate(reader, start=2): + sku = (row.get(cols["sku"]) or "").strip() + if not sku: + continue + qty = _int(row.get(cols["qty"]) or "", n, "qty") + cost = 0.0 + if "unit_cost" in cols and (row.get(cols["unit_cost"]) or "").strip(): + try: + cost = float(str(row[cols["unit_cost"]]).replace(",", "")) + except ValueError: + raise IntegrationError(f"line {n}: unit_cost is not a number") from None + line = out.setdefault(sku, {"qty": 0, "unit_cost": cost}) + line["qty"] += qty + if not out: + raise IntegrationError("no lines found") + return out + + +def parse_skus(text: str) -> list[dict[str, Any]]: + """sku,label[,unit_value][,barcode] rows, for bulk catalog setup.""" + reader, cols = _reader(text) + if "sku" not in cols: + raise IntegrationError("missing a sku column") + out = [] + for n, row in enumerate(reader, start=2): + sku = (row.get(cols["sku"]) or "").strip() + if not sku: + continue + item: dict[str, Any] = {"sku": sku, "label": (row.get(cols.get("label", ""), "") or sku).strip()} + if "unit_value" in cols or "unit_cost" in cols: + raw = row.get(cols.get("unit_value") or cols["unit_cost"]) or "0" + try: + item["unit_value"] = float(str(raw).replace(",", "") or 0) + except ValueError: + raise IntegrationError(f"line {n}: unit value is not a number") from None + if "barcode" in cols and (row.get(cols["barcode"]) or "").strip(): + item["barcodes"] = [row[cols["barcode"]].strip()] + out.append(item) + return out + + +def write_rows(rows: list[dict[str, Any]], columns: list[str]) -> str: + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(columns) + for r in rows: + writer.writerow(["" if r.get(c) is None else _cell(r.get(c)) for c in columns]) + return buf.getvalue() + + +def _cell(value: Any) -> Any: + # Neutralise spreadsheet formula injection: a cell starting with = + - @ + # is executed by Excel when the export is opened. + if isinstance(value, str) and value[:1] in ("=", "+", "-", "@") and not _numeric(value): + return "'" + value + return value + + +def _numeric(value: str) -> bool: + try: + float(value) + return True + except ValueError: + return False diff --git a/src/countbone/integrations/netsuite.py b/src/countbone/integrations/netsuite.py new file mode 100644 index 0000000..1c20772 --- /dev/null +++ b/src/countbone/integrations/netsuite.py @@ -0,0 +1,204 @@ +"""Oracle NetSuite, through SuiteTalk REST web services. + +* Book stock: SuiteQL over InventoryItemLocations (quantityOnHand per item + and location). +* Adjustments: an inventoryAdjustment record, one line per SKU, with + adjustQtyBy = the approved delta, posted to the configured adjustment + account. +* Purchase orders: SuiteQL over the PO's transaction lines. + +Authentication is Token-Based Authentication (OAuth 1.0a, HMAC-SHA256), +which NetSuite requires for integrations; the signing is implemented here +with the standard library so there is no OAuth dependency to keep current. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +import time +from typing import Any +from urllib.parse import parse_qsl, quote, urlsplit + +from .base import Adjustment, Connector, IntegrationError, PostResult, PurchaseOrder + + +def _pct(value: str) -> str: + return quote(str(value), safe="~") + + +def oauth1_header(method: str, url: str, realm: str, consumer_key: str, consumer_secret: str, + token: str, token_secret: str, nonce: str | None = None, + timestamp: str | None = None) -> str: + """The Authorization header for one request (RFC 5849, HMAC-SHA256).""" + oauth = { + "oauth_consumer_key": consumer_key, + "oauth_token": token, + "oauth_signature_method": "HMAC-SHA256", + "oauth_timestamp": timestamp or str(int(time.time())), + "oauth_nonce": nonce or secrets.token_hex(16), + "oauth_version": "1.0", + } + parts = urlsplit(url) + base_url = f"{parts.scheme}://{parts.netloc.lower()}{parts.path}" + params = list(parse_qsl(parts.query, keep_blank_values=True)) + list(oauth.items()) + # Parameters are encoded first and then sorted, as RFC 5849 3.4.1.3.2 requires. + normalized = "&".join( + f"{k}={v}" for k, v in sorted((_pct(k), _pct(v)) for k, v in params) + ) + base = "&".join([method.upper(), _pct(base_url), _pct(normalized)]) + key = f"{_pct(consumer_secret)}&{_pct(token_secret)}" + signature = base64.b64encode( + hmac.new(key.encode(), base.encode(), hashlib.sha256).digest() + ).decode() + oauth["oauth_signature"] = signature + fields = ", ".join(f'{k}="{_pct(v)}"' for k, v in sorted(oauth.items())) + return f'OAuth realm="{realm}", {fields}' + + +def _sql_list(values: list[str]) -> str: + """A SuiteQL IN list. Values are SKUs from our own catalog; quotes are + doubled all the same, since they end up inside a query string.""" + return ", ".join("'" + v.replace("'", "''") + "'" for v in values) + + +class NetSuiteConnector(Connector): + kind = "netsuite" + label = "NetSuite" + can_pull_expected = True + can_push_adjustments = True + can_pull_purchase_orders = True + settings_fields = [ + {"key": "account_id", "label": "Account ID (e.g. 1234567 or 1234567_SB1)", "required": "1"}, + {"key": "adjustment_account", "label": "Adjustment account internal ID", "required": "1"}, + {"key": "subsidiary", "label": "Subsidiary internal ID (OneWorld only)"}, + {"key": "default_location", "label": "Default location internal ID", "required": "1"}, + ] + secret_fields = [ + {"key": "consumer_key", "label": "Consumer key", "required": "1"}, + {"key": "consumer_secret", "label": "Consumer secret", "required": "1"}, + {"key": "token_id", "label": "Token ID", "required": "1"}, + {"key": "token_secret", "label": "Token secret", "required": "1"}, + ] + + @property + def _host(self) -> str: + account = self.settings["account_id"].strip().lower().replace("_", "-") + return f"https://{account}.suitetalk.api.netsuite.com" + + def _headers(self, method: str, url: str) -> dict[str, str]: + return { + "Authorization": oauth1_header( + method, url, self.settings["account_id"].strip().upper().replace("-", "_"), + self.secrets["consumer_key"], self.secrets["consumer_secret"], + self.secrets["token_id"], self.secrets["token_secret"], + ), + "Content-Type": "application/json", + } + + def suiteql(self, query: str) -> list[dict[str, Any]]: + url = f"{self._host}/services/rest/query/v1/suiteql" + rows: list[dict[str, Any]] = [] + offset = 0 + while True: + page = f"{url}?limit=1000&offset={offset}" + headers = {**self._headers("POST", page), "Prefer": "transient"} + response = self.client.post(page, json={"q": query}, headers=headers) + self._raise_for(response, "NetSuite SuiteQL") + body = response.json() + rows.extend(body.get("items", [])) + if not body.get("hasMore"): + return rows + offset += 1000 + + def _item_ids(self, skus: list[str]) -> dict[str, str]: + if not skus: + return {} + rows = self.suiteql(f"SELECT id, itemid FROM item WHERE itemid IN ({_sql_list(skus)})") + return {r["itemid"]: str(r["id"]) for r in rows} + + def test(self) -> dict[str, Any]: + rows = self.suiteql("SELECT id FROM subsidiary WHERE ROWNUM <= 1") + return {"ok": True, "detail": f"SuiteQL reachable ({len(rows)} row)"} + + def pull_expected(self, locations: dict[str, list[str]]) -> dict[str, dict[str, int]]: + all_skus = sorted({s for skus in locations.values() for s in skus}) + ids = self._item_ids(all_skus) + targets = {bay: self.target_location(bay) for bay in locations} + wanted = sorted({t for t in targets.values() if t}) + if not ids or not wanted: + return {} + rows = self.suiteql( + "SELECT item, location, quantityonhand FROM InventoryItemLocations " + f"WHERE item IN ({', '.join(ids.values())}) " + f"AND location IN ({', '.join(str(int(w)) for w in wanted)})" + ) + stock = {(str(r["item"]), str(r["location"])): int(float(r.get("quantityonhand") or 0)) + for r in rows} + out: dict[str, dict[str, int]] = {} + for bay, skus in locations.items(): + loc = targets.get(bay) + if not loc: + continue + out[bay] = {sku: stock.get((ids[sku], str(loc)), 0) for sku in skus if sku in ids} + return out + + def push_adjustments(self, adjustments: list[Adjustment]) -> list[PostResult]: + ids = self._item_ids(sorted({a.sku for a in adjustments})) + results: list[PostResult] = [] + # One adjustment record per NetSuite location keeps each posting + # self-contained and easy to find in NetSuite by its memo. + groups: dict[str, list[Adjustment]] = {} + for adj in adjustments: + loc = self.target_location(adj.location) + if not loc: + results.append(PostResult(adj.adjustment_id, False, + error=f"no NetSuite location mapped for {adj.location}")) + elif adj.sku not in ids: + results.append(PostResult(adj.adjustment_id, False, + error=f"NetSuite has no item {adj.sku}")) + else: + groups.setdefault(loc, []).append(adj) + url = f"{self._host}/services/rest/record/v1/inventoryAdjustment" + for loc, adjs in groups.items(): + body: dict[str, Any] = { + "account": {"id": str(self.settings["adjustment_account"])}, + "adjLocation": {"id": str(loc)}, + "memo": "Countbone cycle count: " + ", ".join(a.adjustment_id for a in adjs)[:900], + "inventory": {"items": [ + {"item": {"id": ids[a.sku]}, "location": {"id": str(loc)}, + "adjustQtyBy": a.delta, "memo": a.note[:200] if a.note else a.adjustment_id} + for a in adjs + ]}, + } + if self.settings.get("subsidiary"): + body["subsidiary"] = {"id": str(self.settings["subsidiary"])} + response = self.client.post(url, json=body, headers=self._headers("POST", url)) + if response.status_code in (200, 201, 204): + # NetSuite answers 204 with the new record's URL in Location. + ref = response.headers.get("Location", "").rsplit("/", 1)[-1] or None + results += [PostResult(a.adjustment_id, True, external_ref=ref) for a in adjs] + else: + err = response.text[:300] + results += [PostResult(a.adjustment_id, False, error=f"HTTP {response.status_code} {err}") + for a in adjs] + return results + + def pull_purchase_order(self, po_number: str) -> PurchaseOrder: + safe = po_number.replace("'", "''") + rows = self.suiteql( + "SELECT t.tranid, BUILTIN.DF(t.entity) AS vendor, i.itemid, tl.quantity, tl.rate " + "FROM transaction t JOIN transactionline tl ON tl.transaction = t.id " + "JOIN item i ON i.id = tl.item " + f"WHERE t.type = 'PurchOrd' AND t.tranid = '{safe}' AND tl.mainline = 'F'" + ) + if not rows: + raise IntegrationError(f"NetSuite has no purchase order {po_number}") + lines: dict[str, dict[str, Any]] = {} + for r in rows: + sku = r["itemid"] + line = lines.setdefault(sku, {"qty": 0, "unit_cost": float(r.get("rate") or 0)}) + line["qty"] += abs(int(float(r.get("quantity") or 0))) + return PurchaseOrder(po_number=po_number, supplier=rows[0].get("vendor"), lines=lines) diff --git a/src/countbone/integrations/sap.py b/src/countbone/integrations/sap.py new file mode 100644 index 0000000..3b575d2 --- /dev/null +++ b/src/countbone/integrations/sap.py @@ -0,0 +1,196 @@ +"""SAP S/4HANA, through its OData v2 APIs. + +* Book stock: API_MATERIAL_STOCK_SRV, entity A_MatlStkInAcctMod, unrestricted + stock per material, plant and storage location. +* Adjustments: the SAP-native physical inventory flow in + API_PHYSICAL_INVENTORY_DOC_SRV: create a physical inventory document for + the materials, enter the *counted* quantity on each item (SAP computes the + difference against its own book at that moment), then post differences. + Countbone sends counts, not deltas, which is what SAP auditors expect to + see on the document. +* Purchase orders: API_PURCHASEORDER_PROCESS_SRV, the PO's items. + +Writes need a CSRF token (fetched with `x-csrf-token: Fetch` and returned +with the session cookies) and item updates need the item's ETag in +If-Match; both are handled here. + +A Countbone bay maps to "PLANT/STORAGE_LOCATION" in `location_map`. +""" + +from __future__ import annotations + +import time +from typing import Any + +import httpx + +from .base import Adjustment, Connector, IntegrationError, PostResult, PurchaseOrder + +PI = "/sap/opu/odata/sap/API_PHYSICAL_INVENTORY_DOC_SRV" +STOCK = "/sap/opu/odata/sap/API_MATERIAL_STOCK_SRV" +PO = "/sap/opu/odata/sap/API_PURCHASEORDER_PROCESS_SRV" + + +def _odata_date(ts: float | None = None) -> str: + return f"/Date({int((ts or time.time()) * 1000)})/" + + +def _q(value: str) -> str: + return value.replace("'", "''") + + +class SapConnector(Connector): + kind = "sap" + label = "SAP S/4HANA" + can_pull_expected = True + can_push_adjustments = True + can_pull_purchase_orders = True + settings_fields = [ + {"key": "base_url", "label": "Host (https://my-s4.example.com)", "required": "1"}, + {"key": "default_location", "label": "Default PLANT/STORAGE_LOCATION, e.g. 1010/0001", + "required": "1"}, + {"key": "unit", "label": "Unit of entry (default PC)"}, + {"key": "sap_client", "label": "SAP client (sap-client), if not the default"}, + ] + secret_fields = [ + {"key": "username", "label": "Communication user", "required": "1"}, + {"key": "password", "label": "Password", "required": "1"}, + ] + + def __init__(self, settings, secrets, client: httpx.Client | None = None) -> None: + super().__init__(settings, secrets, client) + self.client.auth = (self.secrets["username"], self.secrets["password"]) + self._csrf: str | None = None + + def _url(self, path: str) -> str: + url = self.settings["base_url"].rstrip("/") + path + if self.settings.get("sap_client"): + url += ("&" if "?" in url else "?") + f"sap-client={self.settings['sap_client']}" + return url + + def _split(self, location: str | None) -> tuple[str, str]: + target = self.target_location(location) + if not target or "/" not in target: + raise IntegrationError(f"no SAP PLANT/STORAGE_LOCATION mapped for {location}") + plant, sloc = target.split("/", 1) + return plant.strip(), sloc.strip() + + def _get(self, path: str) -> dict[str, Any]: + response = self.client.get(self._url(path), headers={"Accept": "application/json"}) + self._raise_for(response, "SAP read") + return response.json()["d"] + + def _token(self) -> str: + if self._csrf is None: + response = self.client.get(self._url(PI + "/"), + headers={"x-csrf-token": "Fetch", "Accept": "application/json"}) + self._raise_for(response, "SAP CSRF token") + self._csrf = response.headers.get("x-csrf-token") + if not self._csrf: + raise IntegrationError("SAP returned no CSRF token") + return self._csrf + + def _write(self, method: str, path: str, body: dict[str, Any] | None = None, + etag: str | None = None) -> httpx.Response: + headers = {"x-csrf-token": self._token(), "Accept": "application/json", + "Content-Type": "application/json"} + if etag: + headers["If-Match"] = etag + return self.client.request(method, self._url(path), json=body, headers=headers) + + def test(self) -> dict[str, Any]: + self._token() + return {"ok": True, "detail": "authenticated; CSRF token issued"} + + def pull_expected(self, locations: dict[str, list[str]]) -> dict[str, dict[str, int]]: + out: dict[str, dict[str, int]] = {} + for bay, skus in locations.items(): + plant, sloc = self._split(bay) + quantities: dict[str, int] = {} + for sku in skus: + flt = (f"Material eq '{_q(sku)}' and Plant eq '{_q(plant)}' and " + f"StorageLocation eq '{_q(sloc)}' and InventoryStockType eq '01'") + # httpx percent-encodes the query string (spaces, quotes) itself. + data = self._get(f"{STOCK}/A_MatlStkInAcctMod?$filter={flt}") + quantities[sku] = int(sum(float(r.get("MatlWrhsStkQtyInMatlBaseUnit") or 0) + for r in data.get("results", []))) + out[bay] = quantities + return out + + def push_adjustments(self, adjustments: list[Adjustment]) -> list[PostResult]: + results: list[PostResult] = [] + groups: dict[tuple[str, str], list[Adjustment]] = {} + for adj in adjustments: + try: + groups.setdefault(self._split(adj.location), []).append(adj) + except IntegrationError as exc: + results.append(PostResult(adj.adjustment_id, False, error=str(exc))) + unit = self.settings.get("unit") or "PC" + for (plant, sloc), adjs in groups.items(): + try: + results += self._post_document(plant, sloc, adjs, unit) + except IntegrationError as exc: + results += [PostResult(a.adjustment_id, False, error=str(exc)) for a in adjs] + return results + + def _post_document(self, plant: str, sloc: str, adjs: list[Adjustment], + unit: str) -> list[PostResult]: + # 1. A physical inventory document listing the materials. + # Only the core fields: optional header fields differ between S/4 + # releases, and an unknown property fails the whole deep insert. + header = { + "Plant": plant, + "StorageLocation": sloc, + "DocumentDate": _odata_date(), + "to_PhysicalInventoryDocumentItem": {"results": [ + {"Plant": plant, "StorageLocation": sloc, "Material": a.sku} for a in adjs + ]}, + } + created = self._write("POST", f"{PI}/A_PhysInventoryDocHeader", header) + self._raise_for(created, "SAP create physical inventory document") + doc = created.json()["d"] + year, number = doc["FiscalYear"], doc["PhysicalInventoryDocument"] + items = doc.get("to_PhysicalInventoryDocumentItem", {}).get("results", []) + by_material = {i["Material"]: i for i in items} + + # 2. The counted quantity on each item (SAP needs the item's ETag). + for adj in adjs: + item = by_material.get(adj.sku) + if item is None: + raise IntegrationError(f"SAP document {number} has no item for {adj.sku}") + key = (f"(FiscalYear='{year}',PhysicalInventoryDocument='{number}'," + f"PhysicalInventoryDocumentItem='{item['PhysicalInventoryDocumentItem']}')") + current = self.client.get(self._url(f"{PI}/A_PhysInventoryDocItem{key}"), + headers={"Accept": "application/json"}) + self._raise_for(current, "SAP read item") + etag = current.headers.get("etag") or current.json()["d"].get("__metadata", {}).get("etag") + counted = self._write("PATCH", f"{PI}/A_PhysInventoryDocItem{key}", { + "QuantityInUnitOfEntry": str(adj.counted_qty), + "UnitOfEntry": unit, + }, etag=etag) + self._raise_for(counted, f"SAP enter count for {adj.sku}") + + # 3. Post the differences SAP computed. + posted = self._write( + "POST", + f"{PI}/PostDifferences?FiscalYear='{year}'&PhysicalInventoryDocument='{number}'", + ) + self._raise_for(posted, "SAP post differences") + ref = f"{year}/{number}" + return [PostResult(a.adjustment_id, True, external_ref=ref) for a in adjs] + + def pull_purchase_order(self, po_number: str) -> PurchaseOrder: + data = self._get(f"{PO}/A_PurchaseOrder('{_q(po_number)}')?$expand=to_PurchaseOrderItem") + lines: dict[str, dict[str, Any]] = {} + for item in data.get("to_PurchaseOrderItem", {}).get("results", []): + sku = item.get("Material") + if not sku: + continue + qty = int(float(item.get("OrderQuantity") or 0)) + price = float(item.get("NetPriceAmount") or 0) + per = float(item.get("NetPriceQuantity") or 1) or 1 + line = lines.setdefault(sku, {"qty": 0, "unit_cost": price / per}) + line["qty"] += qty + if not lines: + raise IntegrationError(f"SAP purchase order {po_number} has no material lines") + return PurchaseOrder(po_number=po_number, supplier=data.get("Supplier"), lines=lines) diff --git a/src/countbone/integrations/shopify.py b/src/countbone/integrations/shopify.py new file mode 100644 index 0000000..b24e431 --- /dev/null +++ b/src/countbone/integrations/shopify.py @@ -0,0 +1,158 @@ +"""Shopify, through the Admin GraphQL API. + +Reads `available` at a Shopify location and writes counted quantities back +with inventorySetQuantities, reason cycle_count_available. Written against +API version 2026-07, where the mutation requires the @idempotent directive +and uses changeFromQuantity for compare-and-set: if the shelf was sold from +between the count and the post, Shopify rejects the write instead of +overwriting a sale. + +Shopify locations are whole stores or warehouses, not bays, so a bay's +count is posted as a *change* against the location's current quantity: +the adjustment's delta, applied with compare-and-set. Several bays of one +SKU in one store therefore post as several correct deltas, never as one +bay's count overwriting the store total. + +Shopify has no native purchase orders (they live in apps), so Receive +reads POs from CSV or another connector. +""" + +from __future__ import annotations + +from typing import Any + +from .base import Adjustment, Connector, IntegrationError, PostResult + +API_VERSION = "2026-07" + +_VARIANT_QUERY = """ +query Levels($q: String!, $loc: ID!) { + productVariants(first: 5, query: $q) { + nodes { + sku + inventoryItem { + id + inventoryLevel(locationId: $loc) { + quantities(names: ["available"]) { name quantity } + } + } + } + } +} +""" + +_SET_MUTATION = """ +mutation Set($input: InventorySetQuantitiesInput!, $idempotencyKey: String!) { + inventorySetQuantities(input: $input) @idempotent(key: $idempotencyKey) { + inventoryAdjustmentGroup { id } + userErrors { field message code } + } +} +""" + + +class ShopifyConnector(Connector): + kind = "shopify" + label = "Shopify" + can_pull_expected = True + can_push_adjustments = True + settings_fields = [ + {"key": "shop", "label": "Shop domain (name.myshopify.com)", "required": "1"}, + {"key": "default_location", "label": "Location ID (gid://shopify/Location/...)", + "required": "1"}, + ] + secret_fields = [{"key": "access_token", "label": "Admin API access token", "required": "1"}] + + @property + def _url(self) -> str: + shop = self.settings["shop"].strip().removeprefix("https://").rstrip("/") + return f"https://{shop}/admin/api/{API_VERSION}/graphql.json" + + def _gql(self, query: str, variables: dict[str, Any]) -> dict[str, Any]: + response = self.client.post( + self._url, + json={"query": query, "variables": variables}, + headers={"X-Shopify-Access-Token": self.secrets["access_token"], + "Content-Type": "application/json"}, + ) + self._raise_for(response, "Shopify request") + body = response.json() + if body.get("errors"): + raise IntegrationError(f"Shopify: {body['errors']}") + return body["data"] + + def _level(self, sku: str, location_id: str) -> tuple[str, int]: + """(inventory item id, available) for one SKU at one location.""" + # Quoted, so a SKU with spaces or dashes is one search term. + data = self._gql(_VARIANT_QUERY, {"q": f'sku:"{sku}"', "loc": location_id}) + nodes = [n for n in data["productVariants"]["nodes"] if n.get("sku") == sku] + if not nodes: + raise IntegrationError(f"Shopify has no variant with SKU {sku}") + item = nodes[0]["inventoryItem"] + level = item.get("inventoryLevel") + if level is None: + raise IntegrationError(f"SKU {sku} is not stocked at {location_id}") + qty = next((q["quantity"] for q in level["quantities"] if q["name"] == "available"), 0) + return item["id"], int(qty) + + def test(self) -> dict[str, Any]: + data = self._gql("{ shop { name } }", {}) + return {"ok": True, "detail": f"connected to {data['shop']['name']}"} + + def pull_expected(self, locations: dict[str, list[str]]) -> dict[str, dict[str, int]]: + """Shopify only knows a whole location's quantity. That is a bay's + book stock only when the bay *is* the location (map it 1:1); for + several bays per store, load per-bay expectations from CSV and let + Shopify receive the deltas.""" + targets: dict[str, list[str]] = {} + for bay in locations: + if (t := self.target_location(bay)): + targets.setdefault(t, []).append(bay) + shared = sorted(b for bays in targets.values() if len(bays) > 1 for b in bays) + if shared: + raise IntegrationError( + "Shopify stock is per store, but these bays share a Shopify location: " + f"{', '.join(shared[:6])}. Import per-bay quantities from CSV instead." + ) + out: dict[str, dict[str, int]] = {} + for bay, skus in locations.items(): + target = self.target_location(bay) + if not target: + continue + out[bay] = {sku: self._level(sku, target)[1] for sku in skus} + return out + + def push_adjustments(self, adjustments: list[Adjustment]) -> list[PostResult]: + results = [] + for adj in adjustments: + target = self.target_location(adj.location) + if not target: + results.append(PostResult(adj.adjustment_id, False, + error=f"no Shopify location mapped for {adj.location}")) + continue + try: + item_id, current = self._level(adj.sku, target) + data = self._gql(_SET_MUTATION, { + "idempotencyKey": adj.adjustment_id, + "input": { + "name": "available", + "reason": "cycle_count_available", + "referenceDocumentUri": f"countbone://adjustment/{adj.adjustment_id}", + "quantities": [{ + "inventoryItemId": item_id, + "locationId": target, + "quantity": current + adj.delta, + "changeFromQuantity": current, + }], + }, + }) + payload = data["inventorySetQuantities"] + if payload["userErrors"]: + msg = "; ".join(e["message"] for e in payload["userErrors"]) + results.append(PostResult(adj.adjustment_id, False, error=msg)) + else: + group = payload.get("inventoryAdjustmentGroup") or {} + results.append(PostResult(adj.adjustment_id, True, external_ref=group.get("id"))) + except IntegrationError as exc: + results.append(PostResult(adj.adjustment_id, False, error=str(exc))) + return results diff --git a/src/countbone/integrations/webhook.py b/src/countbone/integrations/webhook.py new file mode 100644 index 0000000..6cda044 --- /dev/null +++ b/src/countbone/integrations/webhook.py @@ -0,0 +1,67 @@ +"""Generic webhook: approved adjustments, POSTed as signed JSON. + +For any system without a dedicated connector (a WMS, a custom ERP, Zapier, +a data warehouse). The receiver verifies the body with the shared secret: + + expected = hex(HMAC-SHA256(secret, f"{timestamp}.{raw_body}")) + compare with the X-Countbone-Signature header ("t=,v1=") + +and should reject timestamps more than a few minutes old (replay). +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from typing import Any + +from .base import Adjustment, Connector, PostResult + + +def sign(secret: str, body: bytes, timestamp: int | None = None) -> str: + ts = int(timestamp or time.time()) + mac = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest() + return f"t={ts},v1={mac}" + + +def verify(secret: str, body: bytes, header: str, tolerance_s: int = 300) -> bool: + try: + parts = dict(p.split("=", 1) for p in header.split(",")) + ts = int(parts["t"]) + except (ValueError, KeyError): + return False + if abs(time.time() - ts) > tolerance_s: + return False + return hmac.compare_digest(sign(secret, body, ts), header) + + +class WebhookConnector(Connector): + kind = "webhook" + label = "Webhook" + can_push_adjustments = True + settings_fields = [{"key": "url", "label": "HTTPS endpoint", "required": "1"}] + secret_fields = [{"key": "secret", "label": "Signing secret", "required": "1"}] + + def _send(self, event: str, data: dict[str, Any]) -> tuple[bool, str | None]: + body = json.dumps({"event": event, "sent_at": time.time(), "data": data}, + separators=(",", ":")).encode() + response = self.client.post( + self.settings["url"], + content=body, + headers={"Content-Type": "application/json", + "X-Countbone-Event": event, + "X-Countbone-Signature": sign(self.secrets["secret"], body)}, + ) + if response.is_success: + return True, None + return False, f"HTTP {response.status_code} {response.text[:200]}" + + def test(self) -> dict[str, Any]: + ok, err = self._send("ping", {}) + return {"ok": ok, "detail": err or "endpoint accepted a signed ping"} + + def push_adjustments(self, adjustments: list[Adjustment]) -> list[PostResult]: + ok, err = self._send("adjustments.approved", {"adjustments": [a.__dict__ for a in adjustments]}) + return [PostResult(a.adjustment_id, ok, external_ref=None, error=err) for a in adjustments] diff --git a/src/countbone/ops/__init__.py b/src/countbone/ops/__init__.py new file mode 100644 index 0000000..ab11ae7 --- /dev/null +++ b/src/countbone/ops/__init__.py @@ -0,0 +1,41 @@ +"""Business rules on top of the store: what happens when, and who may do it. + +The API and the job runner call into here; nothing here knows about HTTP. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: # pragma: no cover + from ..catalog import Catalog + from ..security import Keyring + from ..store.db import Store + + +class OpsError(ValueError): + """A request that breaks a business rule. The message is for people.""" + + +class Forbidden(OpsError): + """Allowed in principle, but not for this person's role.""" + + +@dataclass +class Services: + store: Store + keyring: Keyring + data_dir: Path + output_dir: Path + catalog: Any = None # () -> Catalog, the live catalog + http_client: Any = None # httpx.Client for integrations (tests inject a mock) + extra: dict[str, Any] = field(default_factory=dict) + + def current_catalog(self) -> Catalog: + return self.catalog() + + def unit_value(self, sku: str) -> float: + entry = self.current_catalog().by_sku(sku) + return float(entry.unit_value) if entry else 0.0 diff --git a/src/countbone/ops/catalog.py b/src/countbone/ops/catalog.py new file mode 100644 index 0000000..6253187 --- /dev/null +++ b/src/countbone/ops/catalog.py @@ -0,0 +1,287 @@ +"""Catalog Studio: set up products by photographing them. + +Everything a customer needs to add a product without us: create the SKU, +upload about five photos, see whether the system can already tell it from +its look-alikes, and test it on a new photo. Reviewers' corrections feed +back in: a crop a person relabelled becomes an example of the right SKU. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any + +import cv2 +import numpy as np + +from .. import appearance +from ..catalog import Catalog, SkuEntry +from ..security import role_at_least +from ..types import new_id +from . import Forbidden, OpsError, Services + +MAX_PHOTO_BYTES = 12 * 1024 * 1024 +SKU_CHARS = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.:/ ") + + +def estimate_colour(images: list[np.ndarray]) -> dict[str, Any] | None: + """A product's colour band, learned from its photos. + + Colour identification still covers the products nobody photographed, + but only where no photographed product shares their colour (see + identify.AppearanceIdentifier). A photographed product with no known + colour would block colour identification for the whole catalog, so its + colour is measured here: the circular mean hue of the product (vivid + pixels only), as a band of +-12, or achromatic for a grey/black/white box. + """ + from ..stages.identify import circular_hue + + hues, sats = [], [] + for img in images: + crop = appearance.product_crop(img) + hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV) + sats.append(float(np.median(hsv[:, :, 1]))) + vivid = hsv[hsv[:, :, 1] >= 60] + if len(vivid) >= 50: + hues.append(circular_hue(vivid.reshape(-1, 1, 3))) + if not sats: + return None + if float(np.median(sats)) < 60 or not hues: + return {"achromatic": True, "hue": None} + angles = np.asarray(hues) * (2 * np.pi / 180) + centre = float(np.mod(np.arctan2(np.sin(angles).mean(), np.cos(angles).mean()) * 180 / (2 * np.pi), 180)) + return {"achromatic": False, "hue": [int(round(centre - 12)) % 180, int(round(centre + 12)) % 180]} + + +def learn_colour(store, sku: str) -> None: + """Set a Studio product's colour band from its photos, if it has none.""" + row = store.get_sku(sku) + if row is None or row.get("hue") or row.get("achromatic"): + return + images = [img for p in store.list_photos(sku) if p["source"] == "upload" + and (img := cv2.imread(p["path"])) is not None] + if not images: + return + colour = estimate_colour(images) + if colour: + store.upsert_sku(sku, {"hue": colour["hue"], "achromatic": int(colour["achromatic"])}, None) + + +def load_catalog(base: Catalog, store) -> Catalog: + """The configured catalog, plus Studio products and every enrolled photo.""" + # Products photographed before colours were learned get theirs now, once. + for row in store.list_skus(): + if row["photos"] and not row.get("hue") and not row.get("achromatic"): + learn_colour(store, row["sku"]) + studio = [ + SkuEntry( + sku=r["sku"], label=r["label"] or r["sku"], + hue=tuple(r["hue"]) if r.get("hue") else None, # type: ignore[arg-type] + min_saturation=int(r.get("min_saturation") or 60), + achromatic=bool(r.get("achromatic")), + unit_value=float(r.get("unit_value") or 0), barcodes=list(r.get("barcodes") or []), + source="studio", + ) + for r in store.list_skus() + ] + catalog = base.merged(studio) + known = {e.sku for e in catalog.entries} + catalog.exemplars = [ + appearance.Exemplar(r["sku"], r["vector"], r["photo_id"], r["kind"]) + for r in store.all_vectors() + if r["version"] == appearance.EMBED_VERSION and r["sku"] in known + ] + return catalog + + +def validate_sku(sku: str) -> str: + sku = (sku or "").strip() + if not sku or len(sku) > 64 or not set(sku) <= SKU_CHARS: + raise OpsError("a SKU is 1-64 characters: letters, digits, - _ . : / and spaces") + return sku + + +def save_sku(services: Services, sku: str, fields: dict[str, Any], actor: dict[str, Any]) -> dict[str, Any]: + if not role_at_least(actor["role"], "manager"): + raise Forbidden("editing the catalog needs a manager") + sku = validate_sku(sku) + hue = fields.get("hue") + if hue is not None: + if not (isinstance(hue, (list, tuple)) and len(hue) == 2 and all(0 <= int(h) <= 179 for h in hue)): + raise OpsError("hue is two numbers from 0 to 179") + fields["hue"] = [int(h) for h in hue] + if fields.get("unit_value") is not None and float(fields["unit_value"]) < 0: + raise OpsError("unit value cannot be negative") + row = services.store.upsert_sku(sku, fields, actor["user_id"]) + services.store.add_audit(f"sku:{sku}", "sku_saved", {"sku": sku, **fields}, actor=actor["username"]) + return row + + +def _decode(data: bytes) -> np.ndarray: + if len(data) > MAX_PHOTO_BYTES: + raise OpsError("photos must be under 12 MB") + image = cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR) + if image is None: + raise OpsError("that file is not an image OpenCV can read (use JPEG or PNG)") + h, w = image.shape[:2] + if min(h, w) < 48: + raise OpsError("the photo is too small; get closer to the product") + # Keep what is stored and embedded to a sensible size. + if max(h, w) > 1600: + s = 1600 / max(h, w) + image = cv2.resize(image, (int(w * s), int(h * s)), interpolation=cv2.INTER_AREA) + return image + + +def add_photo(services: Services, sku: str, data: bytes, actor: dict[str, Any] | None, + source: str = "upload", run_id: str | None = None, + review_id: str | None = None) -> dict[str, Any]: + store = services.store + if store.get_sku(sku) is None: + entry = services.current_catalog().by_sku(sku) + if entry is None: + raise OpsError(f"no product {sku}; create it first") + # A configured (YAML) product gets a Studio row the first time it is photographed. + store.upsert_sku(sku, {"label": entry.label, "unit_value": entry.unit_value, + "hue": list(entry.hue) if entry.hue else None, + "barcodes": entry.barcodes}, actor["user_id"] if actor else None) + image = _decode(data) + photo_id = new_id("pho") + folder = services.data_dir / "catalog" / "".join(c if c.isalnum() else "_" for c in sku) + folder.mkdir(parents=True, exist_ok=True) + path = folder / f"{photo_id}.jpg" + cv2.imwrite(str(path), image, [cv2.IMWRITE_JPEG_QUALITY, 92]) + vectors = appearance.vectors_for_photo(image, photo_id, sku) + store.add_photo(sku, str(path), source, actor["user_id"] if actor else None, + [(v.kind, v.vector) for v in vectors], appearance.EMBED_VERSION, + run_id=run_id, review_id=review_id, photo_id=photo_id) + store.add_audit(f"sku:{sku}", "sku_photo_added", + {"photo_id": photo_id, "source": source, "run_id": run_id, + "review_id": review_id}, actor=actor["username"] if actor else "system") + if source == "upload": + learn_colour(store, sku) + return store.get_photo(photo_id) # type: ignore[return-value] + + +def learn_from_review(services: Services, review: dict[str, Any], actor: dict[str, Any]) -> str | None: + """A reviewer named what a crop shows: keep it as an example of that SKU. + + Only for decisions that say what the object *is* (accepted as the + model's guess, or corrected to another SKU) on products that are + photo-enrolled, so a colour-only catalog is not silently converted. + """ + if review["status"] not in ("accepted", "corrected") or not review.get("crop_path"): + return None + sku = review.get("resolved_sku") if review["status"] == "corrected" else review["sku"] + unknown = services.extra.get("unknown_sku", "UNKNOWN") + if not sku or sku == unknown: + return None + catalog = services.current_catalog() + if sku not in {e.sku for e in catalog.exemplars}: + return None + path = services.output_dir / review["run_id"] / review["crop_path"] + if not path.is_file(): + return None + photo = add_photo(services, sku, path.read_bytes(), actor, source="review", + run_id=review["run_id"], review_id=review["review_id"]) + return photo["photo_id"] + + +def delete_photo(services: Services, photo_id: str, actor: dict[str, Any]) -> None: + if not role_at_least(actor["role"], "manager"): + raise Forbidden("editing the catalog needs a manager") + photo = services.store.get_photo(photo_id) + if photo is None: + raise OpsError("no such photo") + services.store.delete_photo(photo_id) + Path(photo["path"]).unlink(missing_ok=True) + services.store.add_audit(f"sku:{photo['sku']}", "sku_photo_deleted", {"photo_id": photo_id}, + actor=actor["username"]) + + +def identify_photo(services: Services, data: bytes, k: int = 3) -> dict[str, Any]: + """Try the catalog on a photo: what would a sighting like this be called?""" + catalog = services.current_catalog() + index = catalog.index() + if not len(index): + raise OpsError("no products have photos yet") + product = appearance.product_crop(_decode(data)) + matches = index.match(appearance.embed(product), k=k) + accept = index.accept_for(matches[0].sku) if matches else index.accept + return { + "matches": [{"sku": m.sku, "score": round(m.score, 4), + "label": (catalog.by_sku(m.sku) or SkuEntry(m.sku)).label} for m in matches], + "decision": matches[0].sku if matches and matches[0].score >= accept else None, + "confidence": round(appearance.confidence(matches, accept, index.margin), 4), + "accept": round(accept, 4), + } + + +def quality(services: Services) -> dict[str, Any]: + """How ready each enrolled product is, and what it could be mistaken for. + + For each product: how many photos, how consistently its held-out probes + are recognised as itself (leave-one-photo-out), and the closest other + product. A product with one photo, or one its probes confuse with a + neighbour, needs more (or more varied) photos before it is trusted. + """ + catalog = services.current_catalog() + index = catalog.index() + probes = [e for e in catalog.exemplars if e.kind == "probe"] + photos: dict[str, set[str]] = {} + for e in catalog.exemplars: + photos.setdefault(e.sku, set()).add(e.group) + from ..stages.identify import _bands_overlap, shadowed + + held = {e.sku for e in shadowed(catalog, index) if not index.well_photographed(e.sku)} + per: dict[str, dict[str, Any]] = {} + for sku in index.skus: + own = [p for p in probes if p.sku == sku] + correct = 0 + confusions: dict[str, int] = {} + tested = 0 + for p in own: + if len(photos.get(sku, ())) < 2: + break + scores = index._scores(p.vector, exclude_group=p.group) + best = index.skus[int(np.argmax(scores))] + tested += 1 + if best == sku: + correct += 1 + else: + confusions[best] = confusions.get(best, 0) + 1 + centroid = index.centroids[index.skus.index(sku)] + sims = index.centroids @ centroid + others = [(index.skus[i], float(s)) for i, s in enumerate(sims) if index.skus[i] != sku] + nearest = max(others, key=lambda t: t[1]) if others else (None, None) + n_photos = len(photos.get(sku, ())) + accuracy = correct / tested if tested else None + status = ("needs photos" if n_photos < 3 else + "confusable" if accuracy is not None and accuracy < 0.9 else "ready") + per[sku] = { + "photos": n_photos, "self_recognition": None if accuracy is None else round(accuracy, 3), + "confused_with": confusions, "nearest": nearest[0], + "nearest_similarity": None if nearest[1] is None else round(nearest[1], 4), + # Shares its colour with an unphotographed product, and has too few + # photos for its own bar to be trusted: it is held to a stricter + # one (the bar shown is the one applied), so some genuine sightings + # go to review until it has more. + "accept_threshold": round(max(index.accept_for(sku), appearance.DEFAULT_ACCEPT) + if sku in held else index.accept_for(sku), 4), + "status": status, + "more_photos_advised": sku in held, + "photos_advised": appearance.MIN_PHOTOS_FOR_OWN_BAR, + } + # Colour-only products a photographed look-alike now shadows: sightings + # of these become "unknown" until they are photographed too. + enrolled = [e for e in catalog.entries if e.sku in set(index.skus)] + conflicts = [] + for e in catalog.entries: + if e.sku in set(index.skus): + continue + blockers = [o.sku for o in enrolled if _bands_overlap(e, o)] + if blockers: + conflicts.append({"sku": e.sku, "label": e.label, "shares_colour_with": blockers}) + return {"products": per, "calibration": index.calibration, "colour_conflicts": conflicts, + "enrolled": len(index.skus), "generated_at": time.time()} diff --git a/src/countbone/ops/dataset.py b/src/countbone/ops/dataset.py new file mode 100644 index 0000000..a58d97b --- /dev/null +++ b/src/countbone/ops/dataset.py @@ -0,0 +1,102 @@ +"""Training data from real counts: the data engine behind Count-as-a-Service. + +Every counted video is labelled footage: boxes from the detector, identities +from the identifier, and corrections from people. Exported as COCO (the +format every detection trainer reads), it is what a learned detector and +identifier for real shelves get trained on. + +Only runs whose customer consented (a service job with data consent) are +exported unless an admin explicitly asks for all runs, and a reviewer's +decision always overrides the machine's label. +""" + +from __future__ import annotations + +import io +import json +import zipfile +from typing import Any + +import cv2 + +from ..stages.capture import read_frame +from . import Services +from .final import _meta + + +def _labels_by_track(run: dict[str, Any]) -> dict[int, str | None]: + """Reviewer overrides per track: a SKU, or None for 'not an item'.""" + out: dict[int, str | None] = {} + for r in run.get("reviews") or []: + meta = _meta(r) + if meta.get("scope") != "item" or meta.get("track_id") is None or r["status"] == "pending": + continue + tid = int(meta["track_id"]) + if r["status"] == "rejected": + out[tid] = None + elif r["status"] == "corrected" and r.get("resolved_sku"): + out[tid] = r["resolved_sku"] + elif r["status"] == "accepted": + out[tid] = meta.get("track_sku") or r["sku"] + return out + + +def export_coco(services: Services, run_ids: list[str], frames_per_run: int = 8, + unknown_sku: str = "UNKNOWN") -> bytes: + store = services.store + images, annotations, categories = [], [], {} + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for run_id in run_ids: + run = store.get_run(run_id) + inspector = services.output_dir / run_id / "inspector.json" + if run is None or not inspector.is_file(): + continue + doc = json.loads(inspector.read_text(encoding="utf-8")) + overrides = _labels_by_track(run) + frames = {f["index"]: f for f in doc.get("frames", [])} + by_frame: dict[int, list[dict[str, Any]]] = {} + for b in doc.get("boxes", []): + if not b.get("counted"): + continue + by_frame.setdefault(b["frame"], []).append(b) + # The frames with the most counted objects, spread over the walk. + chosen = sorted(by_frame, key=lambda i: -len(by_frame[i]))[: frames_per_run * 3] + chosen = sorted(chosen)[:: max(1, len(chosen) // max(frames_per_run, 1))][:frames_per_run] + for fi in chosen: + meta = frames.get(fi) + if meta is None: + continue + img = read_frame(run["source"], meta["source_index"], + services.extra.get("resize_width", 960)) + if img is None: + continue + name = f"images/{run_id}_{meta['source_index']:06d}.jpg" + ok, enc = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 92]) + if not ok: + continue + zf.writestr(name, enc.tobytes()) + image_id = len(images) + 1 + images.append({"id": image_id, "file_name": name, "width": img.shape[1], + "height": img.shape[0], "run_id": run_id, + "location": run.get("location")}) + for b in by_frame[fi]: + tid = b.get("track_id") + sku = overrides.get(tid, b.get("track_sku") or b["sku"]) if tid is not None else b["sku"] + if sku is None or sku == unknown_sku: + continue # rejected by a person, or never named + cat = categories.setdefault(sku, {"id": len(categories) + 1, "name": sku}) + x1, y1, x2, y2 = b["bbox"] + annotations.append({ + "id": len(annotations) + 1, "image_id": image_id, "category_id": cat["id"], + "bbox": [round(x1, 1), round(y1, 1), round(x2 - x1, 1), round(y2 - y1, 1)], + "area": round((x2 - x1) * (y2 - y1), 1), "iscrowd": 0, + "track_id": tid, "reviewed": tid in overrides, + }) + coco = { + "info": {"description": "Countbone counted shelves", "version": "1"}, + "images": images, "annotations": annotations, + "categories": list(categories.values()), + } + zf.writestr("annotations.json", json.dumps(coco)) + return buf.getvalue() diff --git a/src/countbone/ops/evidence.py b/src/countbone/ops/evidence.py new file mode 100644 index 0000000..adc2356 --- /dev/null +++ b/src/countbone/ops/evidence.py @@ -0,0 +1,359 @@ +"""Evidence: turn counts into claims a counterparty cannot wave away. + +A claim pack is one zip a supplier, insurer or auditor can check without +access to Countbone: + + report.html the case, readable by a person: what was expected, what + was counted, who filmed, who reviewed, who signed off + runs//... each video's artifacts: its audit pack (source video + hash, configuration, artifact hashes), counts, the + contact sheet of every counted object, review crops + custody.json every recorded event about the claim, its receipt and + its videos, from the hash-chained audit trail + manifest.json the SHA-256 of every file above, plus the case data + signature.json an Ed25519 signature over manifest.json, and the public + key that checks it + verify.py a standalone checker (Python + `cryptography`) + +Changing any file breaks its hash; changing the manifest breaks the +signature; and the signing key never leaves the deployment. What the pack +proves is integrity and origin: these files are what this deployment +recorded. It does not prove the camera told the truth, which is why the +source video's own hash is in the manifest and the video can be included. +""" + +from __future__ import annotations + +import hashlib +import html +import io +import json +import time +import zipfile +from pathlib import Path +from typing import Any + +from ..security import role_at_least, verify_signature +from . import Forbidden, OpsError, Services +from .final import final_counts +from .receive import discrepancies + +FORMAT = "countbone-evidence/1" +CLAIM_STATUSES = ("draft", "sent", "accepted", "rejected", "recovered") +_RUN_FILES = ("audit_pack.json", "result.json", "counts.csv", "contact_sheet.jpg", + "inspector.json", "exceptions.md", "shelf.json") + + +def _canon(obj: Any) -> bytes: + return json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + + +def _sha(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def create_claim(services: Services, actor: dict[str, Any], kind: str, + run_ids: list[str], counterparty: str | None = None, + receipt_id: str | None = None, amount: float = 0.0, + note: str | None = None, currency: str = "USD") -> dict[str, Any]: + if kind not in ("supplier_shortage", "count_variance", "damage", "insurance", "audit"): + raise OpsError("unknown claim kind") + store = services.store + if receipt_id: + receipt = store.get_receipt(receipt_id) + if receipt is None: + raise OpsError("no such receipt") + run_ids = list(dict.fromkeys([*run_ids, *receipt["runs"]])) + counterparty = counterparty or receipt.get("supplier") + missing = [r for r in run_ids if store.get_run(r) is None] + if missing: + raise OpsError(f"unknown run(s): {', '.join(missing)}") + if not run_ids: + raise OpsError("a claim needs at least one counted video") + claim = store.create_claim(kind=kind, counterparty=counterparty, receipt_id=receipt_id, + run_ids=run_ids, amount=amount, note=note, currency=currency, + created_by=actor["user_id"]) + store.add_audit(claim["claim_id"], "claim_created", + {"kind": kind, "runs": run_ids, "receipt_id": receipt_id, "amount": amount}, + actor=actor["username"]) + return claim + + +def update_claim(services: Services, claim_id: str, actor: dict[str, Any], + **fields: Any) -> dict[str, Any]: + store = services.store + claim = store.get_claim(claim_id) + if claim is None: + raise OpsError("no such claim") + if not role_at_least(actor["role"], "manager"): + raise Forbidden("claims are managed by a manager") + status = fields.get("status") + if status is not None and status not in CLAIM_STATUSES: + raise OpsError(f"status must be one of {', '.join(CLAIM_STATUSES)}") + if status == "recovered" and fields.get("recovered_amount") is None and not claim["recovered_amount"]: + raise OpsError("record how much was recovered") + clean = {k: v for k, v in fields.items() if v is not None} + store.update_claim(claim_id, **clean) + store.add_audit(claim_id, "claim_updated", clean, actor=actor["username"]) + return store.get_claim(claim_id) # type: ignore[return-value] + + +# -- building a pack ------------------------------------------------------------ +def _custody(services: Services, subjects: list[str]) -> list[dict[str, Any]]: + rows = [] + for subject in subjects: + for row in services.store.audit_trail(subject): + rows.append({k: row[k] for k in ("id", "run_id", "kind", "payload", "created_at", + "actor", "prev_hash", "row_hash")}) + rows.sort(key=lambda r: r["id"]) + return rows + + +def build_pack(services: Services, claim_id: str, actor: dict[str, Any], + include_video: bool = False) -> dict[str, Any]: + store = services.store + claim = store.get_claim(claim_id) + if claim is None: + raise OpsError("no such claim") + receipt = store.get_receipt(claim["receipt_id"]) if claim.get("receipt_id") else None + files: dict[str, bytes] = {} + run_summaries = [] + for run_id in claim["run_ids"]: + run = store.get_run(run_id) + if run is None: + continue + art = services.output_dir / run_id + for name in _RUN_FILES: + p = art / name + if p.is_file(): + files[f"runs/{run_id}/{name}"] = p.read_bytes() + for sub in ("crops", "gaps"): + d = art / sub + if d.is_dir(): + for p in sorted(d.glob("*.jpg"))[:200]: + files[f"runs/{run_id}/{sub}/{p.name}"] = p.read_bytes() + src = Path(run["source"]) + if include_video and src.is_file(): + files[f"runs/{run_id}/source{src.suffix.lower()}"] = src.read_bytes() + audit = (run.get("meta") or {}).get("audit") or {} + filmed_by = store.get_user(run["created_by"]) if run.get("created_by") else None + run_summaries.append({ + "run_id": run_id, + "kind": run.get("kind"), + "location": run.get("location"), + "filmed_at": run["started_at"], + "filmed_by": filmed_by["display_name"] if filmed_by else None, + "source_name": src.name, + "source_sha256": audit.get("source_sha256"), + "audit_manifest_sha256": audit.get("manifest_sha256"), + "machine_total": run["total"], + "confidence": run["overall_confidence"], + "final_counts": final_counts(run), + }) + subjects = [claim_id, *( [receipt["receipt_id"]] if receipt else []), *claim["run_ids"]] + custody = _custody(services, subjects) + chain = store.verify_audit_chain() + files["custody.json"] = _canon({"events": custody, "chain_at_export": chain}) + lines = discrepancies(receipt) if receipt else [] + files["report.html"] = _report(claim, receipt, lines, run_summaries, custody, chain, + services.keyring.key_id()).encode("utf-8") + files["verify.py"] = VERIFY_SCRIPT.encode("utf-8") + files["README.txt"] = README.encode("utf-8") + + manifest = { + "format": FORMAT, + "generated_at": time.time(), + "generated_by": actor["username"], + "claim": {k: claim[k] for k in ("claim_id", "kind", "counterparty", "status", "amount", + "currency", "note", "created_at", "receipt_id")}, + "receipt": ({"po_number": receipt["po_number"], "supplier": receipt["supplier"], + "dock": receipt["dock"], "lines": receipt["lines"], + "discrepancies": lines} if receipt else None), + "runs": run_summaries, + "audit_chain": {"ok": chain["ok"], "head": chain.get("head"), + "events_included": len(custody)}, + "files": {name: _sha(data) for name, data in sorted(files.items())}, + "signer": {"key_id": services.keyring.key_id(), + "public_key_pem": services.keyring.public_key_pem()}, + } + manifest_bytes = _canon(manifest) + signature = { + "format": FORMAT, + "manifest_sha256": _sha(manifest_bytes), + "algorithm": "Ed25519", + "signature": services.keyring.sign(manifest_bytes), + "key_id": services.keyring.key_id(), + "public_key_pem": services.keyring.public_key_pem(), + } + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for name, data in sorted(files.items()): + zf.writestr(name, data) + zf.writestr("manifest.json", manifest_bytes) + zf.writestr("signature.json", _canon(signature)) + data = buf.getvalue() + out_dir = services.data_dir / "evidence" + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / f"{claim_id}.zip" + path.write_bytes(data) + digest = _sha(data) + store.update_claim(claim_id, pack_path=str(path), pack_sha256=digest) + store.add_audit(claim_id, "claim_pack_generated", + {"pack_sha256": digest, "manifest_sha256": signature["manifest_sha256"], + "files": len(files) + 2, "include_video": include_video}, + actor=actor["username"]) + return {"path": str(path), "sha256": digest, "bytes": len(data), + "manifest_sha256": signature["manifest_sha256"]} + + +def verify_pack(data: bytes, trusted_public_key_pem: str | None = None) -> dict[str, Any]: + """Check a pack: signature over the manifest, then every file's hash.""" + problems: list[str] = [] + try: + zf = zipfile.ZipFile(io.BytesIO(data)) + except zipfile.BadZipFile: + return {"ok": False, "problems": ["not a zip file"]} + with zf: + names = set(zf.namelist()) + if not {"manifest.json", "signature.json"} <= names: + return {"ok": False, "problems": ["manifest.json or signature.json is missing"]} + manifest_bytes = zf.read("manifest.json") + try: + signature = json.loads(zf.read("signature.json")) + manifest = json.loads(manifest_bytes) + except ValueError: + return {"ok": False, "problems": ["manifest or signature is not valid JSON"]} + key = signature.get("public_key_pem", "") + sig_ok = verify_signature(key, manifest_bytes, signature.get("signature", "")) + if not sig_ok: + problems.append("the signature does not match the manifest") + if _sha(manifest_bytes) != signature.get("manifest_sha256"): + problems.append("manifest hash does not match the signed hash") + trusted = None + if trusted_public_key_pem is not None: + trusted = key.strip() == trusted_public_key_pem.strip() + if not trusted: + problems.append("signed by a different key than this deployment's") + listed = manifest.get("files", {}) + for name, digest in listed.items(): + if name not in names: + problems.append(f"missing file: {name}") + elif _sha(zf.read(name)) != digest: + problems.append(f"altered file: {name}") + extra = names - set(listed) - {"manifest.json", "signature.json"} + for name in sorted(extra): + problems.append(f"file not in the manifest: {name}") + return { + "ok": not problems, + "signature_ok": sig_ok, + "trusted_key": trusted, + "key_id": signature.get("key_id"), + "claim_id": (manifest.get("claim") or {}).get("claim_id"), + "files_checked": len(listed), + "problems": problems, + } + + +# -- the human-readable report -------------------------------------------------- +def _fmt_time(ts: float | None) -> str: + return time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(ts)) if ts else "-" + + +def _report(claim, receipt, lines, runs, custody, chain, key_id) -> str: + e = html.escape + rows = "".join( + f"{e(d['sku'])}{d['ordered']}{d['received']}" + f"{d['difference']:+d}" + f"{d['value']:+.2f}" for d in lines) + run_blocks = [] + for r in runs: + fc = r["final_counts"] + count_rows = "".join( + f"{e(x['sku'])}{x['machine']}{x['final']}" + f"{'' if x['expected'] is None else x['expected']}" + f"{e('; '.join(x['changes']))}" for x in fc["rows"]) + run_blocks.append(f""" +

Video {e(r['run_id'])}

+

Filmed {_fmt_time(r['filmed_at'])} by {e(r['filmed_by'] or 'unknown')} + at {e(r['location'] or '-')} · source {e(r['source_name'])}
+ source SHA-256 {e(r['source_sha256'] or '-')}

+ + {count_rows}
SKUMachine countFinalExpectedChanges by reviewers
+ Every counted object in this video +
""") + events = "".join( + f"{_fmt_time(ev['created_at'])}{e(ev['kind'])}" + f"{e(ev['actor'] or '-')}{e((ev['row_hash'] or 'legacy')[:16])}" + for ev in custody) + receipt_block = "" + if receipt: + receipt_block = f"""

Delivery

+

PO {e(receipt['po_number'])} from {e(receipt.get('supplier') or '-')} + at dock {e(receipt.get('dock') or '-')}

+ + {rows or ''}
SKUOrderedReceivedDifferenceValue
No differences
""" + return f""" +Claim {e(claim['claim_id'])} + +

Claim {e(claim['claim_id'])} · {e(claim['kind'].replace('_', ' '))}

+
Counterparty: {e(claim.get('counterparty') or '-')} · +Amount: {claim['amount']:.2f} {e(claim.get('currency') or '')} · +Status: {e(claim['status'])}
{e(claim.get('note') or '')}
+{receipt_block} +

Counted evidence

{''.join(run_blocks)} +

Chain of custody

+

Every event below is from a hash-chained audit trail (chain {'intact' if chain['ok'] else 'BROKEN'} at export).

+{events}
WhenEventByRow hash
+

Checking this pack

+

Run python verify.py in this folder. It checks the Ed25519 signature +(key id {e(key_id)}) over manifest.json and the SHA-256 of every file.

+""" + + +README = """Countbone evidence pack + +Open report.html for the case. To check that nothing in this folder was +changed since it was issued, install Python 3 and the `cryptography` +package, then run: + + python verify.py + +It verifies the Ed25519 signature on manifest.json and the SHA-256 of every +file listed in it. The signing key's public half is in signature.json; ask +the issuer to confirm its key id out of band if you need to know the pack +came from them and not only that it is unaltered. +""" + +VERIFY_SCRIPT = r'''"""Verify a Countbone evidence pack: python verify.py [folder-or-zip]""" +import base64, hashlib, json, os, sys, zipfile + +def main(target="."): + if target.endswith(".zip"): + zf = zipfile.ZipFile(target); read = zf.read; names = set(zf.namelist()) + else: + read = lambda n: open(os.path.join(target, n), "rb").read() + names = {os.path.relpath(os.path.join(r, f), target).replace(os.sep, "/") + for r, _, fs in os.walk(target) for f in fs} + manifest_bytes = read("manifest.json"); sig = json.loads(read("signature.json")) + from cryptography.hazmat.primitives import serialization + key = serialization.load_pem_public_key(sig["public_key_pem"].encode()) + try: + key.verify(base64.b64decode(sig["signature"]), manifest_bytes); print("signature: OK (key id %s)" % sig["key_id"]) + except Exception: + print("signature: INVALID"); return 1 + bad = 0 + for name, digest in json.loads(manifest_bytes)["files"].items(): + if name not in names: print("missing:", name); bad += 1 + elif hashlib.sha256(read(name)).hexdigest() != digest: print("ALTERED:", name); bad += 1 + print("files: %s" % ("all match" if not bad else "%d problem(s)" % bad)) + return 1 if bad else 0 + +if __name__ == "__main__": + sys.exit(main(*(sys.argv[1:] or ["."]))) +''' diff --git a/src/countbone/ops/final.py b/src/countbone/ops/final.py new file mode 100644 index 0000000..47804d1 --- /dev/null +++ b/src/countbone/ops/final.py @@ -0,0 +1,116 @@ +"""The final count: the machine's count with every reviewer's decision applied. + +This is the number the rest of the product acts on (reconciliation, +receiving, evidence), so it is defined once, here, and the machine's own +count is never overwritten: both stay visible, with the reason they differ. + +Decisions and their effect (item reviews are about one tracked object): + + counted object, rejected not a real item: -1 from its SKU + counted object, corrected to X it was an X: -1 from its SKU, +1 to X + counted object, accepted no change + uncounted object (possible miss), + accepted a real item after all: +1 to its SKU + corrected to X a real X: +1 to X + rejected no change + whole-SKU review with a recount the recount replaces that SKU's number + +Reviews without a track id (runs recorded before item reviews carried one) +cannot be mapped to an object, so they inform but do not change the number. +""" + +from __future__ import annotations + +import json +from collections import defaultdict +from typing import Any + + +def _meta(review: dict[str, Any]) -> dict[str, Any]: + meta = review.get("meta") + if isinstance(meta, str): + try: + return json.loads(meta or "{}") + except ValueError: + return {} + return meta or {} + + +def final_counts(run: dict[str, Any]) -> dict[str, Any]: + counts = run.get("counts") or [] + machine = {c["sku"]: int(c["count"]) for c in counts} + labels = {c["sku"]: c.get("label") or c["sku"] for c in counts} + expected = {c["sku"]: c.get("expected") for c in counts} + confidence = {c["sku"]: c.get("confidence") for c in counts} + final: dict[str, int] = defaultdict(int, machine) + why: dict[str, list[str]] = defaultdict(list) + recounts: dict[str, tuple[int, str | None]] = {} + pending: dict[str, int] = defaultdict(int) + seen_tracks: set[int] = set() + + for review in run.get("reviews") or []: + meta = _meta(review) + status = review.get("status") or "pending" + sku = review["sku"] + if status == "pending": + pending[sku] += 1 + continue + if meta.get("scope") == "sku": + if meta.get("resolved_count") is not None: + recounts[sku] = (int(meta["resolved_count"]), review.get("resolved_by")) + continue + track = meta.get("track_id") + if track is None or track in seen_tracks: + continue + seen_tracks.add(track) + own = meta.get("track_sku") or sku + target = review.get("resolved_sku") + counted = bool(meta.get("counted", True)) + who = review.get("resolved_by") or "a reviewer" + if counted: + if status == "rejected": + final[own] -= 1 + why[own].append(f"-1 not an item ({who})") + elif status == "corrected" and target and target != own: + final[own] -= 1 + final[target] += 1 + why[own].append(f"-1 moved to {target} ({who})") + why[target].append(f"+1 moved from {own} ({who})") + else: + if status == "accepted": + final[own] += 1 + why[own].append(f"+1 missed item confirmed ({who})") + elif status == "corrected" and target: + final[target] += 1 + why[target].append(f"+1 missed item confirmed as {target} ({who})") + + for sku, (n, who) in recounts.items(): + final[sku] = n + why[sku].append(f"recounted as {n} ({who or 'a reviewer'})") + + rows = [] + for sku in sorted(set(machine) | set(final)): + value = max(0, final[sku]) + exp = expected.get(sku) + if value == 0 and machine.get(sku, 0) == 0 and exp is None: + continue + rows.append({ + "sku": sku, + "label": labels.get(sku, sku), + "machine": machine.get(sku, 0), + "final": value, + "expected": exp, + "variance": None if exp is None else value - int(exp), + "confidence": confidence.get(sku), + "changes": why.get(sku, []), + "pending_reviews": pending.get(sku, 0), + }) + open_reviews = sum(pending.values()) + return { + "rows": rows, + "total": sum(r["final"] for r in rows), + "machine_total": sum(machine.values()), + # Provisional while anything is still waiting for a person. + "settled": open_reviews == 0, + "open_reviews": open_reviews, + } diff --git a/src/countbone/ops/labels.py b/src/countbone/ops/labels.py new file mode 100644 index 0000000..a7eeaa5 --- /dev/null +++ b/src/countbone/ops/labels.py @@ -0,0 +1,54 @@ +"""Printable bay labels: a QR code per location that the phone and the video read. + +The payload is short and versioned ("CB1:LOC:") so a label printed +today still reads after the format grows, and a random QR on a product box +is never mistaken for a bay. +""" + +from __future__ import annotations + +import html +import io + +import segno + +from ..plugins.location_tag import label_payload + + +def qr_svg(code: str, scale: int = 6) -> str: + qr = segno.make(label_payload(code), error="m", micro=False) + buf = io.BytesIO() + qr.save(buf, kind="svg", scale=scale, border=2, xmldecl=False, svgns=True, dark="#000") + return buf.getvalue().decode("utf-8") + + +def qr_png(code: str, scale: int = 8) -> bytes: + qr = segno.make(label_payload(code), error="m", micro=False) + buf = io.BytesIO() + qr.save(buf, kind="png", scale=scale, border=2) + return buf.getvalue() + + +def sheet_html(locations: list[dict]) -> str: + """An A4 sheet of labels, 3 across, ready to print on adhesive paper.""" + cells = "".join( + f"""
{qr_svg(loc['code'], 5)}
+
{html.escape(loc['code'])}
+
{html.escape(loc.get('name') or '')}
+
Scan before filming
""" + for loc in locations + ) + return f"""Location labels +
{cells}
+""" diff --git a/src/countbone/ops/merge.py b/src/countbone/ops/merge.py new file mode 100644 index 0000000..6eeaf41 --- /dev/null +++ b/src/countbone/ops/merge.py @@ -0,0 +1,245 @@ +"""Count a place once, however many videos cover it. + +Two people film adjacent stretches of one aisle and their videos overlap; +or one person films a bay twice because the first pass was shaky. Adding +the runs double counts everything in the overlap. This merges them. + +Every counted object carries a shelf position (the tracker's world +coordinates: where it sat in the run's first frame, with camera motion +removed). Two videos of one shelf see the same objects in the same +arrangement, just shifted (and scaled, if one was filmed from further +back). So: + +1. Normalise scale by the median object size of each run. +2. Every same-SKU pair across the runs proposes an offset. Score each + offset by the objects it lines up (same SKU, within a third of an object) + minus the conflicts it creates (an object where the other video saw + nothing, or saw a different SKU). The conflict term is what defeats a + repeating shelf: shifting by one facing lines up plenty of objects, but + on a shelf with any variety it also puts SKUs on top of the wrong SKUs. +3. Accept the best offset only if it is clearly supported and clearly + better than the runner-up; a shelf of one identical product is honestly + ambiguous, and is flagged for a person instead of guessed. +4. Pair objects one to one; each pair is one object. The union is the count. +""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + + +@dataclass +class Placed: + sku: str + x: float + y: float + w: float + h: float + run_id: str + track_id: int | None = None + + +@dataclass +class Alignment: + status: str # aligned | disjoint | ambiguous + dx: float = 0.0 + dy: float = 0.0 + scale: float = 1.0 + matched: int = 0 + conflicts: int = 0 + runner_up: float | None = None + detail: str = "" + + +@dataclass +class Merged: + objects: list[Placed] = field(default_factory=list) + alignments: list[dict[str, Any]] = field(default_factory=list) + duplicates: int = 0 + + def counts(self) -> dict[str, int]: + return dict(sorted(Counter(o.sku for o in self.objects).items())) + + +def _median(values: list[float], default: float = 1.0) -> float: + return float(np.median(values)) if values else default + + +def _score(b: np.ndarray, b_sku: list[str], dx: float, dy: float, tx: float, ty: float, + a_xy: np.ndarray, a_sku: np.ndarray, a_lo: float, a_hi: float, + pad: float) -> tuple[int, int, int]: + """(matched, conflicts, objects in the overlap) for one proposed offset. + + Symmetric: inside the stretch both videos cover, every object either + video saw must be explained by the other. An object one saw where the + other saw empty shelf is a conflict, whichever video it is in; checking + only one direction let two different stretches of a regular shelf "line + up" and delete real cartons as duplicates. + """ + shifted = b + np.array([dx, dy]) + # Judge the overlap away from its ragged ends: an object at the very edge + # of one video's coverage was only ever partly in view there, and the + # detector rightly drops partial objects, so its absence proves nothing. + b_lo, b_hi = float(shifted[:, 0].min() + pad), float(shifted[:, 0].max() - pad) + lo, hi = max(a_lo + 2 * pad, b_lo), min(a_hi - 2 * pad, b_hi) + if hi <= lo: + return 0, 0, 0 + matched = conflicts = in_overlap = 0 + for (x, y), sku in zip(shifted, b_sku, strict=True): + if not lo <= x <= hi: + continue + in_overlap += 1 + near = (np.abs(a_xy[:, 0] - x) <= tx) & (np.abs(a_xy[:, 1] - y) <= ty) + if near.any() and (a_sku[near] == sku).any(): + matched += 1 + else: + conflicts += 1 + for (x, y), _ in zip(a_xy, a_sku, strict=True): + if not lo <= x <= hi: + continue + in_overlap += 1 + near = (np.abs(shifted[:, 0] - x) <= tx) & (np.abs(shifted[:, 1] - y) <= ty) + if not near.any(): + conflicts += 1 + return matched, conflicts, in_overlap + + +def align(a: list[Placed], b: list[Placed], max_candidates: int = 4000) -> Alignment: + """Find how `b` sits on `a`'s shelf coordinates.""" + if not a or not b: + return Alignment("disjoint", detail="one of the runs counted nothing") + wa, wb = _median([p.w for p in a]), _median([p.w for p in b]) + ha = _median([p.h for p in a]) + scale = wa / wb if wb > 0 else 1.0 + a_xy = np.array([[p.x, p.y] for p in a], np.float64) + a_sku = np.array([p.sku for p in a]) + b_xy = np.array([[p.x * scale, p.y * scale] for p in b], np.float64) + b_sku = [p.sku for p in b] + tx, ty = 0.35 * wa, 0.35 * ha + a_lo, a_hi = float(a_xy[:, 0].min() - wa / 2), float(a_xy[:, 0].max() + wa / 2) + + cands = [(ax - bx, ay - by) + for (ax, ay), sa in zip(a_xy, a_sku, strict=True) + for (bx, by), sb in zip(b_xy, b_sku, strict=True) if sa == sb] + if not cands: + return Alignment("disjoint", scale=scale, detail="no SKU in common") + if len(cands) > max_candidates: + idx = np.random.default_rng(0).choice(len(cands), max_candidates, replace=False) + cands = [cands[i] for i in idx] + # Offsets within a third of an object are the same proposal; score each once. + buckets: dict[tuple[int, int], tuple[float, float]] = {} + for dx, dy in cands: + buckets.setdefault((round(dx / tx), round(dy / ty)), (dx, dy)) + + scored = [] + for dx, dy in buckets.values(): + m, c, n = _score(b_xy, b_sku, dx, dy, tx, ty, a_xy, a_sku, a_lo, a_hi, wa / 2) + scored.append((m - 2.0 * c, m, c, dx, dy, n)) + scored.sort(key=lambda s: -s[0]) + best = scored[0] + _, matched, conflicts, dx, dy, in_overlap = best + # The runner-up must be a genuinely different placement, not a + # neighbouring bucket of the same one. + runner = next((s for s in scored[1:] + if abs(s[3] - dx) > 0.6 * wa or abs(s[4] - dy) > 0.6 * ha), None) + + # Deleting a real object is worse than keeping a duplicate a person can + # spot, so an overlap must be proven: several objects, nearly all of the + # overlapping stretch explained from both sides, no contradictions to speak of. + if matched < 4: + return Alignment("disjoint", dx, dy, scale, matched, conflicts, + detail="fewer than four objects line up: the videos do not overlap") + if conflicts > max(1, 0.1 * matched) or 2 * matched < 0.85 * in_overlap: + return Alignment("disjoint", dx, dy, scale, matched, conflicts, + detail="the best overlap contradicts itself: treated as separate stretches") + if runner is not None and runner[0] >= 0.8 * best[0] and runner[1] >= 3: + return Alignment("ambiguous", dx, dy, scale, matched, conflicts, runner_up=runner[0], + detail="two placements fit about equally well (a repetitive shelf); " + "a person should confirm the overlap") + # Refine: average displacement of the objects that line up. + shifted = b_xy + np.array([dx, dy]) + diffs = [] + for (x, y), sku in zip(shifted, b_sku, strict=True): + near = (np.abs(a_xy[:, 0] - x) <= tx) & (np.abs(a_xy[:, 1] - y) <= ty) & (a_sku == sku) + if near.any(): + j = int(np.argmin(np.hypot(a_xy[:, 0] - x, a_xy[:, 1] - y) + (~near) * 1e9)) + diffs.append(a_xy[j] - np.array([x, y])) + if diffs: + d = np.mean(diffs, axis=0) + dx, dy = dx + float(d[0]), dy + float(d[1]) + return Alignment("aligned", dx, dy, scale, matched, conflicts, + runner_up=runner[0] if runner else None) + + +def merge(runs: list[tuple[str, list[Placed]]]) -> Merged: + """Merge runs into one set of objects, largest run first as the anchor.""" + out = Merged() + if not runs: + return out + ordered = sorted(runs, key=lambda r: -len(r[1])) + anchor_id, anchor = ordered[0] + placed = list(anchor) + out.alignments.append({"run_id": anchor_id, "status": "anchor", "objects": len(anchor)}) + for run_id, objs in ordered[1:]: + al = align(placed, objs) + entry = {"run_id": run_id, "objects": len(objs), **{ + k: (round(v, 2) if isinstance(v, float) else v) for k, v in al.__dict__.items()}} + if al.status != "aligned": + # Not provably the same stretch: keep every object, and say so. + placed += objs + entry["duplicates"] = 0 + out.alignments.append(entry) + continue + wa = _median([p.w for p in placed]) + ha = _median([p.h for p in placed]) + tx, ty = 0.35 * wa, 0.35 * ha + taken: set[int] = set() + dupes = 0 + additions = [] + for p in objs: + x, y = p.x * al.scale + al.dx, p.y * al.scale + al.dy + best_j, best_d = None, None + for j, q in enumerate(placed): + if j in taken or q.sku != p.sku: + continue + if abs(q.x - x) <= tx and abs(q.y - y) <= ty: + d = (q.x - x) ** 2 + (q.y - y) ** 2 + if best_d is None or d < best_d: + best_j, best_d = j, d + if best_j is not None: + taken.add(best_j) + dupes += 1 + else: + additions.append(Placed(p.sku, x, y, p.w * al.scale, p.h * al.scale, + p.run_id, p.track_id)) + placed += additions + out.duplicates += dupes + entry["duplicates"] = dupes + out.alignments.append(entry) + out.objects = placed + return out + + +def objects_from_run(run: dict[str, Any]) -> list[Placed]: + """A run's counted objects, with reviewers' item decisions applied.""" + from .final import _meta # one definition of how reviews are read + + decisions: dict[int, tuple[str, str | None]] = {} + for r in run.get("reviews") or []: + meta = _meta(r) + if meta.get("scope") == "item" and meta.get("track_id") is not None and r["status"] != "pending": + decisions[int(meta["track_id"])] = (r["status"], r.get("resolved_sku")) + objs = [] + for t in (run.get("meta") or {}).get("tracks_world") or []: + sku = t["sku"] + status, target = decisions.get(int(t["track_id"]), ("accepted", None)) + if status == "rejected": + continue + if status == "corrected" and target: + sku = target + objs.append(Placed(sku, t["x"], t["y"], t["w"], t["h"], run["run_id"], t["track_id"])) + return objs diff --git a/src/countbone/ops/postrun.py b/src/countbone/ops/postrun.py new file mode 100644 index 0000000..9429fc8 --- /dev/null +++ b/src/countbone/ops/postrun.py @@ -0,0 +1,49 @@ +"""What happens after a video is counted, and after a person changes its count. + +One entry point per event, so the job runner and the review endpoint cannot +drift apart in what they trigger: + + after_run(run_id) a run finished: route it by kind + after_review(run_id) a review decision changed a run's final count +""" + +from __future__ import annotations + +import logging +from typing import Any + +from . import Services, receive, reconcile, tasks + +log = logging.getLogger(__name__) + + +def _dispatch(services: Services, run: dict[str, Any], first_time: bool) -> dict[str, Any]: + out: dict[str, Any] = {"kind": run.get("kind")} + kind = run.get("kind") or "count" + if kind == "receive" and run.get("receipt_id"): + out["receipt"] = receive.apply_runs(services, run["receipt_id"])["status"] + elif kind == "recount" and run.get("task_id"): + task = tasks.complete_with_run(services, run) + out["task"] = task["status"] if task else None + else: + out["adjustments"] = len(reconcile.sync_run(services, run["run_id"])) + return out + + +def after_run(services: Services, run_id: str) -> dict[str, Any]: + run = services.store.get_run(run_id) + if run is None: + return {} + try: + return _dispatch(services, run, first_time=True) + except Exception: # noqa: BLE001 - the count is saved; follow-ups must not fail it + log.exception("post-run processing failed for %s", run_id) + services.store.add_audit(run_id, "postrun_failed", {}, actor="system") + return {"error": "post-run processing failed; see the server log"} + + +def after_review(services: Services, run_id: str) -> dict[str, Any]: + run = services.store.get_run(run_id) + if run is None: + return {} + return _dispatch(services, run, first_time=False) diff --git a/src/countbone/ops/receive.py b/src/countbone/ops/receive.py new file mode 100644 index 0000000..75a0fec --- /dev/null +++ b/src/countbone/ops/receive.py @@ -0,0 +1,149 @@ +"""Receive: count a delivery against its purchase order at the dock. + +A receipt is a PO's lines (SKU, ordered quantity, unit cost). Each video +filmed against it (one per pallet, or one walk around the load) is counted +with the PO as its expected numbers. Separate videos are separate pallets, +so their counts add up; a pallet filmed twice belongs in a walk, where the +overlap is merged away. + +When the count disagrees with the PO the receipt is marked discrepancy, and +a shortage opens a draft supplier claim priced at the PO's unit costs, with +the videos attached as evidence, while the truck is still at the dock. +""" + +from __future__ import annotations + +import time +from collections import defaultdict +from typing import Any + +from ..security import role_at_least +from . import Forbidden, OpsError, Services +from .final import final_counts +from .merge import merge, objects_from_run + + +def create(services: Services, po_number: str, lines: dict[str, dict[str, Any]], + actor: dict[str, Any], supplier: str | None = None, dock: str | None = None, + source: str = "manual", note: str | None = None) -> dict[str, Any]: + po_number = (po_number or "").strip() + if not po_number: + raise OpsError("a receipt needs a PO number") + if not lines: + raise OpsError("a receipt needs at least one line") + for sku, line in lines.items(): + if int(line.get("qty", -1)) < 0: + raise OpsError(f"line {sku}: quantity must be zero or more") + receipt = services.store.create_receipt(po_number, lines, supplier, dock, source, + actor["user_id"], note) + services.store.add_audit(receipt["receipt_id"], "receipt_created", + {"po_number": po_number, "lines": lines, "supplier": supplier, + "source": source}, actor=actor["username"]) + return receipt + + +def expected_for(receipt: dict[str, Any]) -> dict[str, int]: + return {line["sku"]: int(line["expected_qty"]) for line in receipt["lines"]} + + +def received_totals(services: Services, receipt: dict[str, Any]) -> tuple[dict[str, int], bool]: + """(units received per SKU across the receipt's videos, all reviews settled).""" + store = services.store + runs = [store.get_run(rid) for rid in receipt["runs"]] + runs = [r for r in runs if r] + totals: dict[str, int] = defaultdict(int) + settled = True + walks: dict[str, list[dict[str, Any]]] = defaultdict(list) + for run in runs: + fc = final_counts(run) + settled = settled and fc["settled"] + if run.get("walk_id"): + walks[run["walk_id"]].append(run) + continue + for row in fc["rows"]: + totals[row["sku"]] += row["final"] + for walk_runs in walks.values(): + merged = merge([(r["run_id"], objects_from_run(r)) for r in walk_runs]) + for sku, n in merged.counts().items(): + totals[sku] += n + return dict(totals), settled + + +def apply_runs(services: Services, receipt_id: str) -> dict[str, Any]: + """Recompute a receipt from its videos (after a run, or a review decision).""" + store = services.store + receipt = store.get_receipt(receipt_id) + if receipt is None: + raise OpsError("no such receipt") + if receipt["status"] == "closed": + return receipt + unknown = services.extra.get("unknown_sku", "UNKNOWN") + totals, settled = received_totals(services, receipt) + ordered = expected_for(receipt) + received = {sku: totals.get(sku, 0) for sku in ordered} + received.update({sku: n for sku, n in totals.items() if sku not in ordered and n and sku != unknown}) + store.set_received(receipt_id, received) + mismatch = any(received.get(s, 0) != q for s, q in ordered.items()) or any( + s not in ordered for s in received) + status = "discrepancy" if mismatch else "counted" + store.update_receipt(receipt_id, status=status, + note=None if settled else "provisional: reviews still open on a video") + receipt = store.get_receipt(receipt_id) + store.add_audit(receipt_id, "receipt_counted", + {"received": received, "status": status, "settled": settled, + "runs": receipt["runs"]}, actor="system") # type: ignore[index] + if status == "discrepancy" and settled: + _draft_claim(services, receipt) # type: ignore[arg-type] + return store.get_receipt(receipt_id) # type: ignore[return-value] + + +def discrepancies(receipt: dict[str, Any]) -> list[dict[str, Any]]: + out = [] + for line in receipt["lines"]: + got = line["received_qty"] if line["received_qty"] is not None else 0 + diff = got - line["expected_qty"] + if diff: + out.append({"sku": line["sku"], "ordered": line["expected_qty"], "received": got, + "difference": diff, "unit_cost": line["unit_cost"], + "value": round(diff * (line["unit_cost"] or 0), 2)}) + return out + + +def _draft_claim(services: Services, receipt: dict[str, Any]) -> None: + shorts = [d for d in discrepancies(receipt) if d["difference"] < 0] + if not shorts: + return + amount = round(-sum(d["value"] for d in shorts), 2) + store = services.store + existing = next((c for c in store.list_claims() if c["receipt_id"] == receipt["receipt_id"] + and c["status"] == "draft"), None) + note = "Short: " + ", ".join(f"{d['sku']} {-d['difference']} unit(s)" for d in shorts) + if existing: + store.update_claim(existing["claim_id"], amount=amount, note=note) + return + claim = store.create_claim(kind="supplier_shortage", counterparty=receipt.get("supplier"), + receipt_id=receipt["receipt_id"], run_ids=receipt["runs"], + amount=amount, note=note, created_by="system") + store.add_audit(claim["claim_id"], "claim_drafted", + {"receipt_id": receipt["receipt_id"], "po_number": receipt["po_number"], + "amount": amount, "shortages": shorts}, actor="system") + + +def close(services: Services, receipt_id: str, actor: dict[str, Any], + note: str | None = None) -> dict[str, Any]: + store = services.store + receipt = store.get_receipt(receipt_id) + if receipt is None: + raise OpsError("no such receipt") + if receipt["status"] == "closed": + raise OpsError("already closed") + if receipt["status"] == "discrepancy" and not role_at_least(actor["role"], "manager"): + raise Forbidden("accepting a delivery with discrepancies needs a manager") + if receipt["status"] == "open" and not receipt["runs"]: + raise OpsError("film the delivery before closing the receipt") + store.update_receipt(receipt_id, status="closed", closed_at=time.time(), + closed_by=actor["user_id"], note=note) + store.add_audit(receipt_id, "receipt_closed", + {"discrepancies": discrepancies(receipt), "note": note}, + actor=actor["username"]) + return store.get_receipt(receipt_id) # type: ignore[return-value] diff --git a/src/countbone/ops/reconcile.py b/src/countbone/ops/reconcile.py new file mode 100644 index 0000000..9b8a6ab --- /dev/null +++ b/src/countbone/ops/reconcile.py @@ -0,0 +1,397 @@ +"""Reconcile: turn counts into signed-off changes to the book. + +The flow, per location and SKU: + + count differs from book ──► outside tolerance? ──► recount task (blocked) + │ │ recount done + ▼ ▼ + adjustment ◄────────────────┘ + │ + rules by value: small ──► auto-approved + medium ─► a manager approves + large ──► an admin (finance) approves + │ + ▼ + posted to the system of record (or exported) + +An adjustment is one proposed change: book quantity, counted quantity, +delta and its value. A newer count of the same place supersedes an older +open adjustment rather than stacking on it, so the inbox only ever shows +the current disagreement. +""" + +from __future__ import annotations + +import time +from collections import defaultdict +from typing import Any + +from .. import integrations +from ..security import role_at_least +from . import Forbidden, OpsError, Services +from .final import final_counts +from .merge import merge, objects_from_run + +DEFAULT_RULES: dict[str, Any] = { + # auto-approve a difference this small (both limits must hold) + "auto_approve_max_value": 25.0, + "auto_approve_max_units": 2, + # up to this value a manager signs off; above it, an admin (finance) + "manager_max_value": 1000.0, + # "all": every mismatch gets a recount task before it can be approved; + # "above_auto": only mismatches too big to auto-approve; "none": never + "recount_policy": "all", + "recount_due_hours": 24, + # push approved adjustments to this integration as soon as they are approved + "post_to": None, + "auto_post": False, + # Separation of duties. A recount is not done by the person whose count is + # in dispute, and an adjustment is not approved by whoever counted or + # recounted it: otherwise one person can film, "correct" and sign off a + # change to the book alone, which is exactly what a stock count exists to + # catch. A one-person shop can turn these off. + "independent_recount": True, + "four_eyes": True, +} + +RULE_ROLE = {"auto": "counter", "manager": "manager", "admin": "admin"} + + +def rules(services: Services) -> dict[str, Any]: + return {**DEFAULT_RULES, **(services.store.get_setting("reconcile_rules") or {})} + + +def set_rules(services: Services, patch: dict[str, Any], actor: dict[str, Any]) -> dict[str, Any]: + unknown = set(patch) - set(DEFAULT_RULES) + if unknown: + raise OpsError(f"unknown rule(s): {', '.join(sorted(unknown))}") + if patch.get("recount_policy") not in (None, "all", "above_auto", "none"): + raise OpsError("recount_policy must be all, above_auto or none") + for key in ("independent_recount", "four_eyes", "auto_post"): + if key in patch and not isinstance(patch[key], bool): + raise OpsError(f"{key} must be true or false") + for key in ("auto_approve_max_value", "manager_max_value", "auto_approve_max_units", + "recount_due_hours"): + if key in patch and (patch[key] is None or float(patch[key]) < 0): + raise OpsError(f"{key} must be zero or more") + merged = {**rules(services), **patch} + services.store.set_setting("reconcile_rules", merged, actor["user_id"]) + services.store.add_audit("settings", "reconcile_rules", merged, actor=actor["username"]) + return merged + + +def classify(delta: int, value: float, r: dict[str, Any]) -> str: + if abs(value) <= float(r["auto_approve_max_value"]) and abs(delta) <= int(r["auto_approve_max_units"]): + return "auto" + if abs(value) <= float(r["manager_max_value"]): + return "manager" + return "admin" + + +def needs_recount(rule: str, r: dict[str, Any]) -> bool: + policy = r["recount_policy"] + return policy == "all" or (policy == "above_auto" and rule != "auto") + + +# -- what was counted ------------------------------------------------------------ +def counted_at(services: Services, run: dict[str, Any]) -> tuple[dict[str, int], dict[str, int], str]: + """(final count per SKU, open reviews per SKU, how it was derived). + + A run in a walk speaks for the whole walk: the walk's videos are merged + so an overlap is counted once. + """ + store = services.store + if run.get("walk_id"): + runs = [store.get_run(r["run_id"]) for r in store.walk_runs(run["walk_id"])] + runs = [r for r in runs if r] + merged = merge([(r["run_id"], objects_from_run(r)) for r in runs]) + pending: dict[str, int] = defaultdict(int) + for r in runs: + for row in final_counts(r)["rows"]: + pending[row["sku"]] += row["pending_reviews"] + return merged.counts(), dict(pending), f"walk {run['walk_id']} ({len(runs)} videos merged)" + fc = final_counts(run) + return ({r["sku"]: r["final"] for r in fc["rows"]}, + {r["sku"]: r["pending_reviews"] for r in fc["rows"]}, f"run {run['run_id']}") + + +# -- keeping adjustments in step with counts ---------------------------------------- +def sync_run(services: Services, run_id: str) -> list[dict[str, Any]]: + """Create or refresh the adjustments (and recount tasks) a run implies. + + Idempotent: call it when a run finishes and again whenever a review + decision changes its final count. + """ + store = services.store + run = store.get_run(run_id) + if run is None or not run.get("location") or run.get("kind") not in ("count", None): + return [] + location = run["location"] + book = store.expected_for(location) + if not book: + return [] # nothing to reconcile against + r = rules(services) + counted, pending, source = counted_at(services, run) + unknown = services_unknown_sku(services) + touched = [] + for sku in sorted(set(book) | set(counted)): + if sku == unknown: + continue # an unnamed object is a review, not a book change + qty = int(counted.get(sku, 0)) + system_qty = int(book.get(sku, 0)) + delta = qty - system_qty + existing = store.open_adjustment_for(location, sku) + if existing is None and store.decided_adjustment_for(location, sku, run_id): + # Signed off already: the book change this run implied is decided, + # and a later review of the same video does not reopen it. + continue + if existing and existing["run_id"] != run_id and existing["run_id"] is not None: + older = store.get_run(existing["run_id"]) + if older and older["started_at"] > run["started_at"]: + continue # a newer count already speaks for this place + if existing and existing["run_id"] == run_id and _recounted(store, existing): + continue # a person recounted this; their number stands over the video's + if delta == 0: + if existing: + store.update_adjustment(existing["adjustment_id"], status="superseded", + note=f"a later count agrees with the book ({source})") + continue + unit_value = services.unit_value(sku) + value = round(delta * unit_value, 2) + rule = classify(delta, value, r) + fields = {"system_qty": system_qty, "counted_qty": qty, "delta": delta, + "unit_value": unit_value, "value": value, "run_id": run_id, "rule": rule} + if existing and existing["run_id"] == run_id: + store.update_adjustment(existing["adjustment_id"], **fields) + adj = store.get_adjustment(existing["adjustment_id"]) + else: + if existing: + store.update_adjustment(existing["adjustment_id"], status="superseded", + note=f"superseded by {source}") + adj = store.create_adjustment(location=location, sku=sku, status="blocked", + note=source, **fields) + store.add_audit(adj["adjustment_id"], "adjustment_proposed", + {**fields, "location": location, "sku": sku, "source": source}, + actor="system") + adj = _route(services, adj, run, pending.get(sku, 0), r) + touched.append(adj) + return touched + + +def services_unknown_sku(services: Services) -> str: + return services.extra.get("unknown_sku", "UNKNOWN") + + +def _route(services: Services, adj: dict[str, Any], run: dict[str, Any], pending_reviews: int, + r: dict[str, Any]) -> dict[str, Any]: + """Decide what an adjustment waits for: reviews, a recount, or approval.""" + store = services.store + if adj["status"] not in ("blocked", "proposed"): + return adj + if pending_reviews: + store.update_adjustment(adj["adjustment_id"], status="blocked", + note=f"waiting for {pending_reviews} review(s) on the count") + return store.get_adjustment(adj["adjustment_id"]) # type: ignore[return-value] + task = store.open_task_for(adj["location"], adj["sku"]) + if task is None and needs_recount(adj["rule"], r) and not _recounted(store, adj): + due = time.time() + float(r["recount_due_hours"]) * 3600 + task = store.create_task( + kind="recount", status="open", location=adj["location"], sku=adj["sku"], + run_id=adj["run_id"], reason=f"counted {adj['counted_qty']}, book says {adj['system_qty']}", + expected=adj["system_qty"], counted=adj["counted_qty"], variance=adj["delta"], + value_at_risk=abs(adj["value"]), + # Unassigned under independent_recount: anyone but the first + # counter takes it (or a manager assigns it). + assignee=None if r.get("independent_recount") else run.get("created_by"), due_at=due, + created_by="system", + ) + store.add_audit(task["task_id"], "task_created", + {"sku": adj["sku"], "location": adj["location"], "variance": adj["delta"], + "assignee": task["assignee"], "adjustment_id": adj["adjustment_id"]}, + actor="system") + if task is not None: + store.update_adjustment(adj["adjustment_id"], status="blocked", task_id=task["task_id"], + note="waiting for a recount") + return store.get_adjustment(adj["adjustment_id"]) # type: ignore[return-value] + return decide(services, adj["adjustment_id"]) + + +def _recounted(store, adj: dict[str, Any]) -> bool: + """Has this disagreement already been recounted? Then do not ask again.""" + if adj.get("task_id"): + task = store.get_task(adj["task_id"]) + return bool(task and task["status"] == "done") + return False + + +def decide(services: Services, adjustment_id: str) -> dict[str, Any]: + """Apply the value rules to an adjustment nobody is blocking.""" + store = services.store + adj = store.get_adjustment(adjustment_id) + if adj is None: + raise OpsError("no such adjustment") + r = rules(services) + rule = classify(adj["delta"], adj["value"], r) + if rule == "auto": + store.update_adjustment(adjustment_id, status="approved", rule=rule, decided_by=None, + decided_at=time.time(), note="auto-approved: within the small-difference rule") + store.add_audit(adjustment_id, "adjustment_approved", + {"rule": "auto", "delta": adj["delta"], "value": adj["value"]}, actor="system") + if r.get("auto_post") and r.get("post_to"): + post(services, [adjustment_id], actor=None) + else: + store.update_adjustment(adjustment_id, status="proposed", rule=rule, + note=f"needs {RULE_ROLE[rule]} approval") + return store.get_adjustment(adjustment_id) # type: ignore[return-value] + + +# -- people deciding --------------------------------------------------------------- +def approve(services: Services, adjustment_id: str, actor: dict[str, Any], + note: str | None = None) -> dict[str, Any]: + store = services.store + adj = store.get_adjustment(adjustment_id) + if adj is None: + raise OpsError("no such adjustment") + if adj["status"] != "proposed": + raise OpsError(f"only a proposed adjustment can be approved (this one is {adj['status']})") + needed = RULE_ROLE.get(adj["rule"] or "admin", "admin") + if not role_at_least(actor["role"], needed): + raise Forbidden(f"a difference worth {abs(adj['value']):.2f} needs {needed} approval") + if rules(services).get("four_eyes") and actor.get("user_id") in _involved(store, adj): + raise Forbidden("you counted or recounted this: someone else has to approve it") + # The old note said what it was waiting for; approval answers that. + store.update_adjustment(adjustment_id, status="approved", decided_by=actor["user_id"], + decided_at=time.time(), note=note or None) + store.add_audit(adjustment_id, "adjustment_approved", + {"rule": adj["rule"], "delta": adj["delta"], "value": adj["value"], + "sku": adj["sku"], "location": adj["location"], "note": note}, + actor=actor["username"]) + r = rules(services) + if r.get("auto_post") and r.get("post_to"): + post(services, [adjustment_id], actor=actor) + return store.get_adjustment(adjustment_id) # type: ignore[return-value] + + +def _involved(store, adj: dict[str, Any]) -> set[str]: + """Who produced the numbers behind an adjustment: its count and its recount.""" + people: set[str] = set() + run = store.get_run(adj["run_id"]) if adj.get("run_id") else None + if run and run.get("created_by"): + people.add(run["created_by"]) + task = store.get_task(adj["task_id"]) if adj.get("task_id") else None + if task and task.get("closed_by"): + people.add(task["closed_by"]) + return people + + +def reject(services: Services, adjustment_id: str, actor: dict[str, Any], note: str) -> dict[str, Any]: + store = services.store + adj = store.get_adjustment(adjustment_id) + if adj is None: + raise OpsError("no such adjustment") + if adj["status"] not in ("proposed", "blocked", "approved"): + raise OpsError(f"a {adj['status']} adjustment cannot be rejected") + if not role_at_least(actor["role"], "manager"): + raise Forbidden("rejecting an adjustment needs a manager") + if not (note or "").strip(): + raise OpsError("say why the adjustment is rejected; it goes on the audit trail") + store.update_adjustment(adjustment_id, status="rejected", decided_by=actor["user_id"], + decided_at=time.time(), note=note.strip()) + store.add_audit(adjustment_id, "adjustment_rejected", + {"note": note.strip(), "sku": adj["sku"], "location": adj["location"], + "delta": adj["delta"]}, actor=actor["username"]) + return store.get_adjustment(adjustment_id) # type: ignore[return-value] + + +def connector_for(services: Services, name: str): + row = services.store.get_integration(name) + if row is None: + raise OpsError(f"no integration named {name!r}") + if not row["enabled"]: + raise OpsError(f"integration {name!r} is disabled") + secrets = services.keyring.unseal(row.get("secrets")) + return integrations.build(row["kind"], row["settings"], secrets, services.http_client) + + +def post(services: Services, adjustment_ids: list[str] | None, actor: dict[str, Any] | None, + integration: str | None = None) -> dict[str, Any]: + """Write approved adjustments to the system of record.""" + store = services.store + r = rules(services) + name = integration or r.get("post_to") + if not name: + raise OpsError("no integration is set to receive adjustments; export CSV instead") + if adjustment_ids is None: + adjs = store.list_adjustments(status="approved") + else: + adjs = [a for a in (store.get_adjustment(i) for i in adjustment_ids) if a] + bad = [a["adjustment_id"] for a in adjs if a["status"] not in ("approved", "failed")] + if bad: + raise OpsError(f"not approved: {', '.join(bad)}") + if not adjs: + return {"posted": 0, "failed": 0, "results": []} + try: + connector = connector_for(services, name) + results = connector.push_adjustments([ + integrations.Adjustment(a["adjustment_id"], a["location"], a["sku"], a["system_qty"], + a["counted_qty"], a["delta"], a.get("note") or "") + for a in adjs + ]) + except integrations.IntegrationError as exc: + store.mark_integration(name, str(exc)) + results = [integrations.PostResult(a["adjustment_id"], False, error=str(exc)) for a in adjs] + ok = failed = 0 + for res in results: + if res.ok: + ok += 1 + store.update_adjustment(res.adjustment_id, status="posted", integration=name, + external_ref=res.external_ref, posted_at=time.time(), + post_error=None) + else: + failed += 1 + store.update_adjustment(res.adjustment_id, status="failed", integration=name, + post_error=res.error) + store.add_audit(res.adjustment_id, "adjustment_posted" if res.ok else "adjustment_post_failed", + {"integration": name, "external_ref": res.external_ref, "error": res.error}, + actor=(actor or {}).get("username", "system")) + if failed == 0: + store.mark_integration(name, None) + return {"posted": ok, "failed": failed, + "results": [res.__dict__ for res in results]} + + +def mark_exported(services: Services, adjustment_ids: list[str], actor: dict[str, Any], + reference: str) -> int: + """For customers who post by file: record that these went out by CSV.""" + if not role_at_least(actor["role"], "manager"): + raise Forbidden("needs a manager") + n = 0 + for adj_id in adjustment_ids: + adj = services.store.get_adjustment(adj_id) + if adj and adj["status"] in ("approved", "failed"): + services.store.update_adjustment(adj_id, status="posted", integration="csv", + external_ref=reference, posted_at=time.time()) + services.store.add_audit(adj_id, "adjustment_posted", + {"integration": "csv", "external_ref": reference}, + actor=actor["username"]) + n += 1 + return n + + +def period_report(services: Services, since: float, until: float) -> dict[str, Any]: + rows = services.store.list_adjustments(since=since, until=until, limit=100000) + by_status: dict[str, dict[str, float]] = defaultdict(lambda: {"count": 0, "units": 0, "value": 0.0}) + for a in rows: + s = by_status[a["status"]] + s["count"] += 1 + s["units"] += a["delta"] + s["value"] = round(s["value"] + (a["value"] or 0), 2) + booked = [a for a in rows if a["status"] == "posted"] + return { + "since": since, "until": until, + "by_status": dict(by_status), + "net_value_posted": round(sum(a["value"] or 0 for a in booked), 2), + "gross_value_posted": round(sum(abs(a["value"] or 0) for a in booked), 2), + "open": sum(1 for a in rows if a["status"] in ("blocked", "proposed", "approved", "failed")), + "adjustments": rows, + } diff --git a/src/countbone/ops/tasks.py b/src/countbone/ops/tasks.py new file mode 100644 index 0000000..4839b83 --- /dev/null +++ b/src/countbone/ops/tasks.py @@ -0,0 +1,138 @@ +"""Recount tasks: a mismatch becomes a job for a named person. + +A task is closed by a recount, either typed in (a person counted by hand) +or filmed (a recount video, run with the task's id). Closing it releases +the blocked adjustment with the recounted number, which then goes through +the approval rules like any other. +""" + +from __future__ import annotations + +import time +from typing import Any + +from ..security import role_at_least +from . import Forbidden, OpsError, Services, reconcile +from .final import final_counts + + +def assign(services: Services, task_id: str, assignee: str | None, actor: dict[str, Any], + due_at: float | None = None) -> dict[str, Any]: + store = services.store + task = store.get_task(task_id) + if task is None: + raise OpsError("no such task") + if not role_at_least(actor["role"], "manager"): + raise Forbidden("assigning tasks needs a manager") + if assignee is not None and store.get_user(assignee) is None: + raise OpsError("no such user") + assign_check(services, task, assignee) + fields: dict[str, Any] = {"assignee": assignee} + if due_at is not None: + fields["due_at"] = due_at + store.update_task(task_id, **fields) + store.add_audit(task_id, "task_assigned", {"assignee": assignee, "due_at": due_at}, + actor=actor["username"]) + return store.get_task(task_id) # type: ignore[return-value] + + +def complete(services: Services, task_id: str, count: int, actor: dict[str, Any] | None, + note: str | None = None, run_id: str | None = None) -> dict[str, Any]: + """Close a task with a recounted quantity.""" + store = services.store + task = store.get_task(task_id) + if task is None: + raise OpsError("no such task") + if task["status"] not in ("open", "escalated"): + raise OpsError(f"this task is already {task['status']}") + if count < 0: + raise OpsError("a count cannot be negative") + if actor is not None and task["assignee"] and task["assignee"] != actor["user_id"] \ + and not role_at_least(actor["role"], "manager"): + raise Forbidden("this recount is assigned to someone else") + if actor is not None and task["kind"] == "recount" and _is_first_counter(services, task, actor) \ + and reconcile.rules(services).get("independent_recount"): + raise Forbidden("a recount has to be done by someone other than the first counter") + who = actor["username"] if actor else "system" + result = {"recount": int(count), "by": who, "note": note, "run_id": run_id, + "method": "video" if run_id else "manual"} + store.update_task(task_id, status="done", result=result, closed_at=time.time(), + closed_by=actor["user_id"] if actor else None) + store.add_audit(task_id, "task_done", {**result, "sku": task["sku"], "location": task["location"], + "first_count": task["counted"]}, actor=who) + + # Release the adjustment this recount was holding, with the recount's number. + adj = store.open_adjustment_for(task["location"], task["sku"]) + if adj is not None: + system_qty = adj["system_qty"] or 0 + delta = int(count) - system_qty + if delta == 0: + store.update_adjustment(adj["adjustment_id"], status="superseded", counted_qty=int(count), + delta=0, value=0.0, note="the recount agrees with the book") + store.add_audit(adj["adjustment_id"], "adjustment_resolved_by_recount", + {"recount": count}, actor=who) + else: + value = round(delta * (adj["unit_value"] or 0), 2) + store.update_adjustment(adj["adjustment_id"], counted_qty=int(count), delta=delta, + value=value, status="proposed", + note=f"recounted as {count} by {who}") + reconcile.decide(services, adj["adjustment_id"]) + return store.get_task(task_id) # type: ignore[return-value] + + +def _is_first_counter(services: Services, task: dict[str, Any], actor: dict[str, Any]) -> bool: + run = services.store.get_run(task["run_id"]) if task.get("run_id") else None + return bool(run and run.get("created_by") and run["created_by"] == actor.get("user_id")) + + +def assign_check(services: Services, task: dict[str, Any], assignee: str | None) -> None: + if assignee and task["kind"] == "recount" and reconcile.rules(services).get("independent_recount") \ + and _is_first_counter(services, task, {"user_id": assignee}): + raise OpsError("assign the recount to someone other than the first counter") + + +def complete_with_run(services: Services, run: dict[str, Any]) -> dict[str, Any] | None: + """A recount video finished: close its task with the video's number.""" + task = services.store.get_task(run["task_id"]) + if task is None or task["status"] not in ("open", "escalated"): + return None + fc = final_counts(run) + if not fc["settled"]: + # A recount that itself needs review is not a recount yet. + services.store.update_task(task["task_id"], result={ + "note": "recount video waiting for review", "run_id": run["run_id"]}) + return services.store.get_task(task["task_id"]) + qty = next((r["final"] for r in fc["rows"] if r["sku"] == task["sku"]), 0) + creator = services.store.get_user(run["created_by"]) if run.get("created_by") else None + try: + return complete(services, task["task_id"], qty, creator, note="recount video", + run_id=run["run_id"]) + except Forbidden as exc: + # The first counter filmed their own recount: the video is kept, but + # the task stays open for someone else. + services.store.update_task(task["task_id"], result={ + "note": f"recount video not accepted: {exc}", "run_id": run["run_id"]}) + services.store.add_audit(task["task_id"], "recount_refused", + {"run_id": run["run_id"], "reason": str(exc)}, + actor=creator["username"] if creator else "system") + return services.store.get_task(task["task_id"]) + + +def cancel(services: Services, task_id: str, actor: dict[str, Any], note: str) -> dict[str, Any]: + store = services.store + task = store.get_task(task_id) + if task is None: + raise OpsError("no such task") + if not role_at_least(actor["role"], "manager"): + raise Forbidden("cancelling tasks needs a manager") + if task["status"] not in ("open", "escalated"): + raise OpsError(f"this task is already {task['status']}") + store.update_task(task_id, status="cancelled", closed_at=time.time(), closed_by=actor["user_id"], + result={"note": note}) + store.add_audit(task_id, "task_cancelled", {"note": note}, actor=actor["username"]) + # The adjustment it held goes to approval on the original count. + adj = store.open_adjustment_for(task["location"], task["sku"]) + if adj is not None and adj["status"] == "blocked": + store.update_adjustment(adj["adjustment_id"], status="proposed", note="recount cancelled") + reconcile.decide(services, adj["adjustment_id"]) + return store.get_task(task_id) # type: ignore[return-value] diff --git a/src/countbone/pipeline.py b/src/countbone/pipeline.py index 254208d..f2d488d 100644 --- a/src/countbone/pipeline.py +++ b/src/countbone/pipeline.py @@ -10,9 +10,13 @@ from __future__ import annotations import logging +import math +import statistics import time -from collections import defaultdict +from collections import defaultdict, deque +from collections.abc import Callable from pathlib import Path +from typing import Any from .catalog import Catalog from .config import Config @@ -24,6 +28,63 @@ log = logging.getLogger(__name__) +CatalogProvider = Callable[[], Catalog] + + +class Continuity: + """How well the tracker could follow the scene between usable frames. + + Confidence used to measure only how sure identification was, so a count + that lost objects between frames was reported as certain. This measures + the thing that actually breaks tracking: how far the scene moved between + two consecutive *kept* frames, in object widths. Dropped frames widen that + gap; the adaptive sampler narrows it; this records what was achieved. + """ + + RISKY = 0.5 # beyond this, association starts to rely on motion compensation + BROKEN = 1.0 # beyond this, an object can pass between kept frames unseen + + def __init__(self) -> None: + self.ratios: list[float] = [] + self.unknown_gaps = 0 + self._last_cum: tuple[float, float] | None = None + self._cum = (0.0, 0.0) + self._unknown = False + + def observe(self, motion_: dict[str, float] | None) -> None: + if motion_ and motion_.get("estimated"): + self._cum = (self._cum[0] + motion_["dx"], self._cum[1] + motion_["dy"]) + elif self._last_cum is not None: + self._unknown = True + + def kept(self, box_px: float | None) -> None: + if self._last_cum is not None and box_px: + if self._unknown: + self.unknown_gaps += 1 + else: + moved = math.hypot(self._cum[0] - self._last_cum[0], self._cum[1] - self._last_cum[1]) + self.ratios.append(moved / box_px) + self._last_cum = self._cum + self._unknown = False + + def summary(self) -> dict[str, Any]: + worst = max(self.ratios, default=0.0) + risky = sum(r > self.RISKY for r in self.ratios) + broken = sum(r > self.BROKEN for r in self.ratios) + # 1.0 while every gap is comfortably followable, falling to 0 as the + # worst gap approaches three object widths. + score = 1.0 if worst <= self.RISKY else max(0.0, 1.0 - (worst - self.RISKY) / 2.5) + if self.unknown_gaps: + score = min(score, 0.8) + return { + "gaps": len(self.ratios), + "max_shift_objects": round(worst, 3), + "risky_gaps": int(risky), + "broken_gaps": int(broken), + "unknown_gaps": self.unknown_gaps, + "score": round(score, 4), + } + class Pipeline: """Runs one video to one result. @@ -32,9 +93,17 @@ class Pipeline: RunContext, not on self. """ - def __init__(self, config: Config | None = None, store: Store | None = None) -> None: + def __init__( + self, + config: Config | None = None, + store: Store | None = None, + catalog_provider: CatalogProvider | None = None, + ) -> None: self.config = config or Config() - self.catalog = Catalog.load(self.config.identify.catalog) + self._catalog_provider = catalog_provider + self.catalog = catalog_provider() if catalog_provider else Catalog.load( + self.config.identify.catalog + ) self.detector = detect.build(self.config.detect) self.identifier = identify.build(self.config.identify, self.catalog) self.plugins = plugin_base.build(self.config.plugins) @@ -44,16 +113,39 @@ def __init__(self, config: Config | None = None, store: Store | None = None) -> log.info( "pipeline ready: detect=%s identify=%s count=%s plugins=[%s]", self.config.detect.backend, - self.config.identify.backend, + self.identifier.name, self.config.count.strategy, ", ".join(p.name for p in self.plugins), ) + def refresh_catalog(self) -> None: + """Pick up catalog edits (new SKUs, enrolled photos) before a run.""" + if self._catalog_provider is None: + return + self.catalog = self._catalog_provider() + self.identifier = identify.build(self.config.identify, self.catalog) + # -- public API ------------------------------------------------------ - def run(self, source: str | int, run_id: str | None = None) -> CountResult: + def run( + self, + source: str | int, + run_id: str | None = None, + expected: dict[str, int] | None = None, + context: dict[str, Any] | None = None, + ) -> CountResult: + """Count one video. + + `expected` replaces the configured expectations for this run (a + location's book stock, a purchase order). `context` describes why the + run exists (location, kind, who filmed it); plugins read and may fill + it, and it is kept on the result. + """ + self.refresh_catalog() ctx = RunContext(config=self.config, source=str(source), store=self.store) if run_id: ctx.run_id = run_id + ctx.state["run_context"] = dict(context or {}) + ctx.state["expected"] = dict(expected) if expected is not None else None result: CountResult | None = None try: result = self._run(ctx, source) @@ -83,18 +175,25 @@ def _run(self, ctx: RunContext, source: str | int) -> CountResult: iou_threshold=cfg.count.track_iou, max_gap=cfg.count.track_max_gap ) camera = motion.MotionEstimator() + sampler = capture.Sampler(cfg.capture) + continuity = Continuity() + widths: deque[float] = deque(maxlen=400) # recent object widths, for scale per_frame: dict[int, list[Item]] = defaultdict(list) # Only the frame-tally strategies need every sighting kept; tracking # does not, and on a long run that is a lot of objects held for nothing. needs_per_frame = cfg.count.strategy in ("peak_frame", "median_frame") - for frame in capture.frames(source, cfg.capture): # 1. Capture + def box_px() -> float | None: + return statistics.median(widths) if widths else None + + for frame in capture.frames(source, cfg.capture, sampler): # 1. Capture result.frames_read += 1 frame = preprocess.apply(frame, cfg.preprocess) # 2. Pre-process # Estimated before the quality gate can drop the frame, so a # dropped frame still contributes its share of camera movement. frame.meta["motion"] = camera.update(frame.image) + continuity.observe(frame.meta["motion"]) # Capture-layer plugins may reject a frame outright. kept = plugin_base.fire( @@ -102,6 +201,9 @@ def _run(self, ctx: RunContext, source: str | int) -> CountResult: ) if kept is None: result.frames_dropped += 1 + # The frame is gone, its camera movement is not (see Tracker). + tracker.observe_motion(frame.meta.get("motion")) + sampler.feedback(kept=False) continue frame = kept result.frames_used += 1 @@ -111,6 +213,7 @@ def _run(self, ctx: RunContext, source: str | int) -> CountResult: self.plugins, "on_detections", ctx, frame, detections, transform=True ) result.detections += len(detections) + widths.extend(d.bbox[2] - d.bbox[0] for d in detections) items = self.identifier.identify(frame, detections) # 4. Identify items = plugin_base.fire( @@ -120,20 +223,53 @@ def _run(self, ctx: RunContext, source: str | int) -> CountResult: if needs_per_frame: per_frame[frame.index] = items tracker.update(frame.index, items, frame.meta.get("motion")) # 5. Count + frame.meta["offset"] = tracker.offset + # Track ids are known now and the pixels are still in hand. + plugin_base.fire(self.plugins, "on_frame_tracked", ctx, frame, items) + continuity.kept(box_px()) + sampler.feedback(kept=True, box_px=box_px()) + + result.meta["sampling"] = {**sampler.stats, "base_step": sampler.base, + "adaptive": sampler.adaptive} + result.meta["continuity"] = continuity.summary() + ctx.state["continuity"] = result.meta["continuity"] + if sampler.truncated: + ctx.warn( + f"video longer than the {cfg.capture.max_frames}-frame cap: the end of " + "it was not counted" + ) + result.needs_review = True + broken = result.meta["continuity"]["broken_gaps"] + if broken: + ctx.warn( + f"camera moved more than an object's width between usable frames " + f"{broken} time(s); items may have been missed (film more slowly)" + ) + result.needs_review = True tracks = plugin_base.fire( self.plugins, "on_tracks", ctx, tracker.tracks, transform=True ) - result = count.finalise(result, tracks, dict(per_frame), cfg.count, self.catalog) + result = count.finalise( + result, tracks, dict(per_frame), cfg.count, self.catalog, + expected=ctx.state.get("expected"), + ) + result.meta["tracks_world"] = [ + {"track_id": t.track_id, "sku": t.sku, "hits": t.hits, **w} + for t in tracks + if t.hits >= cfg.count.min_hits and (w := count.track_world(t)) is not None + ] result = plugin_base.fire( self.plugins, "on_counts", ctx, result, transform=True ) + result.meta["context"] = ctx.state.get("run_context", {}) result.warnings = list(dict.fromkeys(result.warnings + ctx.warnings)) result.finished_at = time.time() result.meta["config_fingerprint"] = cfg.fingerprint() result.meta["plugins"] = [p.name for p in self.plugins] result.meta["artifacts_dir"] = str(ctx.artifacts_dir) + result.meta["identifier"] = self.identifier.name written = output.emit(ctx, result, cfg.output) # 6. Output result.meta["outputs"] = written diff --git a/src/countbone/plugins/base.py b/src/countbone/plugins/base.py index aca41c1..e6da233 100644 --- a/src/countbone/plugins/base.py +++ b/src/countbone/plugins/base.py @@ -10,6 +10,8 @@ on_frame(ctx, frame) -> Frame | None capture layer; None drops the frame on_detections(ctx, f, d) -> list[Detection] on_items(ctx, f, items) -> list[Item] identification layer + on_frame_tracked(ctx, f, items) -> None after the tracker assigned track ids, + while the frame's pixels are in hand on_tracks(ctx, tracks) -> list[Track] after counting has grouped sightings on_counts(ctx, result) -> CountResult count layer on_output(ctx, result) -> None output layer, side effects only @@ -61,6 +63,8 @@ def on_items( ) -> list[Item]: return items + def on_frame_tracked(self, ctx: RunContext, frame: Frame, items: list[Item]) -> None: ... + def on_tracks(self, ctx: RunContext, tracks: list[Track]) -> list[Track]: return tracks @@ -121,10 +125,13 @@ def _load_builtins() -> None: from . import ( # noqa: F401 (imported for the side effect of registering) audit_pack, confidence, + contact_sheet, exception_report, + location_tag, multiframe, quality_gate, review_queue, + shelf_check, tolerance, ) diff --git a/src/countbone/plugins/confidence.py b/src/countbone/plugins/confidence.py index 6a6f9a7..68f6b93 100644 --- a/src/countbone/plugins/confidence.py +++ b/src/countbone/plugins/confidence.py @@ -79,11 +79,11 @@ def _frame_quality(frame: Frame) -> float: # -- per SKU and per run --------------------------------------------- def on_counts(self, ctx: RunContext, result: CountResult) -> CountResult: + capture_term = self._capture_term(ctx) for sku_count in result.counts: # The count stage already scored track quality; fold in how well # the whole capture went so a good count off bad footage is not # reported as certain. - capture_term = self._capture_term(ctx) blended = 0.75 * sku_count.confidence + 0.25 * capture_term sku_count.confidence = float(np.clip(blended, 0.0, 1.0)) sku_count.evidence["below_threshold"] = ( @@ -111,8 +111,15 @@ def _capture_term(ctx: RunContext) -> float: stats = ctx.state.get("quality") or {} scores = stats.get("scores") or [] if not scores: - return 0.7 - sharpness = min(1.0, float(np.mean(scores)) / 150.0) - seen = max(stats.get("seen", 1), 1) - usable = 1.0 - stats.get("dropped", 0) / seen - return float(np.clip(0.6 * sharpness + 0.4 * usable, 0.0, 1.0)) + base = 0.7 + else: + sharpness = min(1.0, float(np.mean(scores)) / 150.0) + seen = max(stats.get("seen", 1), 1) + usable = 1.0 - stats.get("dropped", 0) / seen + base = float(np.clip(0.6 * sharpness + 0.4 * usable, 0.0, 1.0)) + # Tracking continuity caps the whole term: sharp frames are worth + # little if the camera outran the tracker between them. + continuity = (ctx.state.get("continuity") or {}).get("score") + if continuity is not None: + base = min(base, 0.5 + 0.5 * float(continuity)) * (0.6 + 0.4 * float(continuity)) + return float(np.clip(base, 0.0, 1.0)) diff --git a/src/countbone/plugins/contact_sheet.py b/src/countbone/plugins/contact_sheet.py new file mode 100644 index 0000000..422c2c6 --- /dev/null +++ b/src/countbone/plugins/contact_sheet.py @@ -0,0 +1,107 @@ +"""Output-layer plugin: one picture of every object that was counted. + +A count is easiest to trust, and hardest to dispute, when every unit behind +the number can be seen. For each counted object this keeps its clearest +sighting, writes it to tracks/, and lays them out on a contact sheet +grouped by SKU. The sheet goes into the audit pack (it is written before +the pack is hashed) and into evidence packs. +""" + +from __future__ import annotations + +from typing import Any + +import cv2 +import numpy as np + +from ..context import RunContext +from ..types import CountResult +from .base import Plugin, register + +THUMB_H = 96 + + +@register +class ContactSheet(Plugin): + """Save the clearest crop of each counted object and a contact sheet.""" + + name = "contact_sheet" + layer = "output" + priority = 80 # after the review queue and exception report, before the audit pack + + def configure(self, max_objects: int = 600, columns: int = 12, save_tracks: bool = True, + **_: Any) -> None: + self.max_objects = int(max_objects) + self.columns = int(columns) + self.save_tracks = bool(save_tracks) + + def on_frame_tracked(self, ctx: RunContext, frame, items) -> None: + best: dict[int, tuple[float, np.ndarray]] = ctx.setdefault("contact_best", dict) + h, w = frame.image.shape[:2] + for item in items: + tid = item.track_id + if tid is None: + continue + score = item.confidence + if tid in best and best[tid][0] >= score: + continue + x1, y1, x2, y2 = item.detection.bbox + xi1, yi1 = max(0, int(x1)), max(0, int(y1)) + xi2, yi2 = min(w, int(round(x2))), min(h, int(round(y2))) + if xi2 - xi1 < 4 or yi2 - yi1 < 4: + continue + crop = frame.image[yi1:yi2, xi1:xi2] + scale = THUMB_H / crop.shape[0] + thumb = cv2.resize(crop, (max(8, int(crop.shape[1] * scale)), THUMB_H), + interpolation=cv2.INTER_AREA) + best[tid] = (score, thumb) + if len(best) > self.max_objects * 3: + # Bounded: forget the weakest of the uncounted-so-far. + worst = min(best, key=lambda k: best[k][0]) + del best[worst] + + def on_tracks(self, ctx: RunContext, tracks): + ctx.state["contact_counted"] = { + t.track_id: t.sku for t in tracks if t.hits >= ctx.config.count.min_hits + } + return tracks + + def on_output(self, ctx: RunContext, result: CountResult) -> None: + best: dict[int, tuple[float, np.ndarray]] = ctx.state.get("contact_best", {}) + counted: dict[int, str] = ctx.state.get("contact_counted", {}) + rows = [(sku, tid, best[tid][1]) for tid, sku in sorted(counted.items(), key=lambda kv: (kv[1], kv[0])) + if tid in best][: self.max_objects] + if not rows: + return + out = ctx.artifacts_dir + if self.save_tracks: + (out / "tracks").mkdir(exist_ok=True) + for sku, tid, img in rows: + cv2.imwrite(str(out / "tracks" / f"t{tid:05d}_{sku}.jpg"), img) + sheet = self._sheet(rows) + cv2.imwrite(str(out / "contact_sheet.jpg"), sheet, [cv2.IMWRITE_JPEG_QUALITY, 88]) + result.meta.setdefault("outputs", {})["contact_sheet"] = str(out / "contact_sheet.jpg") + result.meta["contact_sheet"] = {"objects": len(rows)} + + def _sheet(self, rows: list[tuple[str, int, np.ndarray]]) -> np.ndarray: + cell_w, label_h, pad = 84, 18, 6 + cells = [] + for sku, tid, img in rows: + cell = np.full((THUMB_H + label_h, cell_w, 3), 245, np.uint8) + im = img[:, : cell_w] if img.shape[1] > cell_w else img + x0 = (cell_w - im.shape[1]) // 2 + cell[:THUMB_H, x0 : x0 + im.shape[1]] = im + text = f"{sku} #{tid}" + cv2.putText(cell, text[:14], (3, THUMB_H + 13), cv2.FONT_HERSHEY_SIMPLEX, 0.36, + (30, 30, 30), 1, cv2.LINE_AA) + cells.append(cell) + cols = min(self.columns, len(cells)) + nrows = (len(cells) + cols - 1) // cols + h = nrows * (THUMB_H + label_h + pad) + pad + w = cols * (cell_w + pad) + pad + sheet = np.full((h, w, 3), 255, np.uint8) + for i, cell in enumerate(cells): + r, c = divmod(i, cols) + y, x = pad + r * (THUMB_H + label_h + pad), pad + c * (cell_w + pad) + sheet[y : y + cell.shape[0], x : x + cell.shape[1]] = cell + return sheet diff --git a/src/countbone/plugins/location_tag.py b/src/countbone/plugins/location_tag.py new file mode 100644 index 0000000..c3aae54 --- /dev/null +++ b/src/countbone/plugins/location_tag.py @@ -0,0 +1,167 @@ +"""Capture-layer plugin: read the bay's QR label from the video itself. + +Each bay carries a printed label (see ops.labels) encoding its location +code. Filming it at the start of a walk ties the count to the place without +typing anything. The phone app scans it before recording; this reads it +from the frames too, so: + +* a video uploaded without a location still lands on the right bay; +* a video filed under one bay that shows another bay's label is flagged, + because counting against the wrong book quantity is a silent error. +""" + +from __future__ import annotations + +from typing import Any + +import cv2 + +from ..context import RunContext +from ..types import CountResult +from .base import Plugin, register + +PREFIX = "CB1:LOC:" + + +def parse_label(text: str) -> str | None: + text = (text or "").strip() + if text.upper().startswith(PREFIX): + code = text[len(PREFIX):].strip() + return code or None + return None + + +def label_payload(code: str) -> str: + return f"{PREFIX}{code}" + + +_detector: cv2.QRCodeDetector | None = None + + +def read_labels_at(image) -> list[tuple[str, tuple[float, float, float, float]]]: + """Location labels in an image, with where each one is (x1, y1, x2, y2).""" + global _detector + if _detector is None: + _detector = cv2.QRCodeDetector() + try: + ok, texts, points, _ = _detector.detectAndDecodeMulti(image) + except cv2.error: + return [] + if not ok or points is None: + return [] + out = [] + for text, quad in zip(texts, points, strict=False): + code = parse_label(text) + if code: + xs, ys = quad[:, 0], quad[:, 1] + out.append((code, (float(xs.min()), float(ys.min()), float(xs.max()), float(ys.max())))) + return out + + +def read_labels(image) -> list[str]: + return [code for code, _ in read_labels_at(image)] + + +def find_location(source: str, seconds: float = 8.0, stride: int = 3, + width: int = 1280) -> str | None: + """The first location label in the opening seconds of a video.""" + from ..stages.capture import _resize, _source_fps, open_source + + cap = open_source(source) + try: + # A browser WebM claims 1000 fps; believing it would read minutes of + # video looking for a label that belongs in the first seconds. + fps = _source_fps(cap) or 30.0 + limit = int(fps * seconds) + for i in range(limit): + ok, image = cap.read() + if not ok: + return None + if i % stride: + continue + codes = read_labels(_resize(image, width)) + if codes: + return codes[0] + finally: + cap.release() + return None + + +@register +class LocationTag(Plugin): + """Read bay QR labels in the video and check them against the run's location.""" + + name = "location_tag" + layer = "capture" + priority = 20 + + def configure(self, search_every: int = 3, watch_every: int = 12, **_: Any) -> None: + self.search_every = max(1, int(search_every)) + self.watch_every = max(1, int(watch_every)) + + def on_run_start(self, ctx: RunContext) -> None: + ctx.state["labels_seen"] = {} + ctx.state["label_in_view"] = False + + def on_frame(self, ctx: RunContext, frame): + seen: dict[str, int] = ctx.state.setdefault("labels_seen", {}) + # While a label is in view, look at every frame: its printed squares + # are box-shaped, and each frame they are not removed from is a frame + # where the detector may take the label for stock. + in_view = ctx.state.get("label_in_view", False) + every = 1 if in_view else (self.watch_every if seen else self.search_every) + if frame.index % every == 0: + found = read_labels_at(frame.image) + ctx.state["label_in_view"] = bool(found) + if found: + frame.meta["label_boxes"] = [box for _, box in found] + for code, _ in found: + seen.setdefault(code, frame.index) + return frame + + def on_detections(self, ctx: RunContext, frame, detections): + boxes = frame.meta.get("label_boxes") + if not boxes: + return detections + keep = [] + for det in detections: + x1, y1, x2, y2 = det.bbox + area = max(1e-6, (x2 - x1) * (y2 - y1)) + inside = False + for bx1, by1, bx2, by2 in boxes: + # The label's area, grown a little to cover its quiet zone and + # the printed code beneath it. + w, h = bx2 - bx1, by2 - by1 + gx1, gy1, gx2, gy2 = bx1 - 0.15 * w, by1 - 0.15 * h, bx2 + 0.15 * w, by2 + 0.45 * h + ix = max(0.0, min(x2, gx2) - max(x1, gx1)) + iy = max(0.0, min(y2, gy2) - max(y1, gy1)) + if ix * iy / area >= 0.5: + inside = True + break + if not inside: + keep.append(det) + return keep + + def on_counts(self, ctx: RunContext, result: CountResult) -> CountResult: + seen: dict[str, int] = ctx.state.get("labels_seen") or {} + run_ctx = ctx.state.setdefault("run_context", {}) + filed = run_ctx.get("location") + codes = sorted(seen, key=seen.get) # in the order they appeared + result.meta["labels_seen"] = codes + if not codes: + return result + if filed and filed not in codes: + result.warnings.append( + f"the video shows the label for {', '.join(codes)} but was filed under {filed}" + ) + result.needs_review = True + elif not filed: + run_ctx["location"] = codes[0] + run_ctx["location_source"] = "label_in_video" + if len(codes) > 1: + result.warnings.append( + f"labels for {len(codes)} locations appear in one video ({', '.join(codes)}); " + "film one bay per video so each is counted against its own stock" + ) + result.needs_review = True + return result diff --git a/src/countbone/plugins/review_queue.py b/src/countbone/plugins/review_queue.py index ec65500..654ac9c 100644 --- a/src/countbone/plugins/review_queue.py +++ b/src/countbone/plugins/review_queue.py @@ -3,6 +3,18 @@ The queue is the honest half of the product. Every item it raises carries the crop the model saw, so the reviewer decides from evidence rather than from trust. + +Three kinds of question are raised: + + low_sku_confidence a whole SKU's number is doubtful (one task, not N) + low_item_confidence one object's identity is doubtful + unidentified an object nobody could name + possible_missed_item an object seen clearly but only once, which the + counter discards as noise; usually a real carton at + the edge of the walk, occasionally a reflection + +Item-level questions are asked once per tracked object, not once per frame, +and carry the track id, so a decision can change the count (ops.final). """ from __future__ import annotations @@ -31,8 +43,14 @@ def configure( max_items: int = 40, save_crops: bool = True, review_unknown: bool = True, + review_single_sightings: bool = True, + single_min_confidence: float = 0.6, + settled_agreement: float = 0.7, **_: Any, ) -> None: + # An object seen 3+ times whose sightings agree at least this much is + # settled by its own vote; its odd doubtful frame raises no question. + self.settled_agreement = float(settled_agreement) self.sku_threshold = float(sku_threshold) self.item_threshold = float(item_threshold) self.max_items = int(max_items) @@ -41,6 +59,8 @@ def configure( self.candidate_cap = max(self.max_items * 2, self.max_items + 20) self.save_crops = bool(save_crops) self.review_unknown = bool(review_unknown) + self.review_single_sightings = bool(review_single_sightings) + self.single_min_confidence = float(single_min_confidence) def on_items(self, ctx: RunContext, frame, items): """Collect low-confidence sightings as they go past. @@ -63,12 +83,59 @@ def on_items(self, ctx: RunContext, frame, items): "index_in_frame": index, "bbox": item.detection.bbox, "crop": self._crop(frame, item) if self.save_crops else None, - "meta": {"id_source": item.id_source, **item.meta}, + # The tracker writes track_id onto this same object later. + "item": item, + "meta": {"id_source": item.id_source, **_public_meta(item.meta)}, } ) self._trim(pending) return items + def on_frame_tracked(self, ctx: RunContext, frame, items) -> None: + """Remember the one sighting of objects seen once so far. + + Most tracks get a second sighting a frame later and are forgotten + here; what is left at the end is exactly the set of objects the + counter will discard for being seen only once. + """ + if not self.review_single_sightings: + return + hits: dict[int, int] = ctx.setdefault("review_track_hits", dict) + singles: dict[int, dict[str, Any]] = ctx.setdefault("review_singles", dict) + for index, item in enumerate(items): + tid = item.track_id + if tid is None: + continue + hits[tid] = hits.get(tid, 0) + 1 + if hits[tid] == 1 and item.confidence >= self.single_min_confidence: + singles[tid] = { + "sku": item.sku, + "reason": "possible_missed_item", + "confidence": item.confidence, + "frame_index": frame.index, + "index_in_frame": index, + "bbox": item.detection.bbox, + "crop": self._crop(frame, item) if self.save_crops else None, + "item": item, + "meta": {"id_source": item.id_source, **_public_meta(item.meta)}, + } + if len(singles) > self.candidate_cap: + # Oldest first: a long-open track is likely to be seen again. + del singles[next(iter(singles))] + elif hits[tid] >= 2: + singles.pop(tid, None) + + def on_tracks(self, ctx: RunContext, tracks): + ctx.state["review_track_index"] = {t.track_id: (t.sku, t.hits) for t in tracks} + # How firmly each object's sightings agree on what it is. One doubtful + # frame of an object seen ten times, nine of them clearly, is settled + # by the vote; asking a person about it is noise. + ctx.state["review_track_agreement"] = { + t.track_id: (sum(i.sku == t.sku for i in t.items) / len(t.items)) if t.items else 0.0 + for t in tracks + } + return tracks + def _trim(self, pending: list[dict[str, Any]]) -> None: """Keep only the least confident candidates. @@ -82,10 +149,55 @@ def _trim(self, pending: list[dict[str, Any]]) -> None: pending.sort(key=lambda c: c["confidence"]) del pending[self.max_items :] + def _one_per_track(self, candidates: list[dict[str, Any]], index) -> list[dict[str, Any]]: + """Ask about each object once. + + The object's least confident sighting ranks it (how doubtful it is), + but the picture shown is its most complete view: the largest box. + The least confident sighting is often a partial or odd detection + (a label patch, an object half out of frame), and a reviewer shown + that would be deciding about the wrong thing. + """ + by_track: dict[int, list[dict[str, Any]]] = {} + out = [] + for cand in candidates: + tid = cand["item"].track_id + if tid is None: + out.append(cand) + else: + by_track.setdefault(tid, []).append(cand) + + def area(c: dict[str, Any]) -> float: + x1, y1, x2, y2 = c["bbox"] + return (x2 - x1) * (y2 - y1) + + for group in by_track.values(): + shown = dict(max(group, key=area)) + shown["confidence"] = min(c["confidence"] for c in group) + out.append(shown) + return sorted(out, key=lambda c: c["confidence"]) + def on_counts(self, ctx: RunContext, result: CountResult) -> CountResult: - candidates = sorted( - ctx.state.get("review_candidates", []), key=lambda c: c["confidence"] + index: dict[int, tuple[str, int]] = ctx.state.get("review_track_index", {}) + agreement: dict[int, float] = ctx.state.get("review_track_agreement", {}) + min_hits = ctx.config.count.min_hits + unknown_sku = ctx.config.identify.unknown_sku + + def settled(cand: dict[str, Any]) -> bool: + tid = cand["item"].track_id + if tid is None or tid not in index: + return False + sku, hits = index[tid] + return (sku != unknown_sku and hits >= max(min_hits, 3) + and agreement.get(tid, 0.0) >= self.settled_agreement) + + candidates = self._one_per_track( + [c for c in ctx.state.get("review_candidates", []) if not settled(c)], index ) + singles = [ + c for tid, c in ctx.state.get("review_singles", {}).items() + if index.get(tid, ("", 0))[1] < min_hits + ] reviews: list[ReviewItem] = [] # A whole SKU whose count is doubtful is one review task, not N. @@ -108,18 +220,32 @@ def on_counts(self, ctx: RunContext, result: CountResult) -> CountResult: ) ) - for cand in candidates[: self.max_items]: + # Unsure identities first (they can move units between SKUs), then + # possible misses, highest-confidence first (most likely real). + ranked = candidates[: self.max_items] + room = max(0, self.max_items - len(ranked)) + ranked += sorted(singles, key=lambda c: -c["confidence"])[:room] + for cand in ranked: + tid = cand["item"].track_id + track_sku, hits = index.get(tid, (None, 0)) if tid is not None else (None, 0) reviews.append( ReviewItem( review_id=new_id("rev"), run_id=result.run_id, - sku=cand["sku"], + sku=track_sku or cand["sku"], reason=cand["reason"], confidence=cand["confidence"], frame_index=cand["frame_index"], bbox=cand["bbox"], crop_path=self._write_crop(ctx, cand), - meta={**cand["meta"], "scope": "item"}, + meta={ + **cand["meta"], + "scope": "item", + "track_id": tid, + "track_sku": track_sku, + "counted": hits >= min_hits, + "sighting_sku": cand["sku"], + }, ) ) @@ -129,7 +255,8 @@ def on_counts(self, ctx: RunContext, result: CountResult) -> CountResult: result.meta["review_queue"] = { "raised": len(reviews), "candidates_seen": len(candidates), - "truncated": max(0, len(candidates) - self.max_items), + "possible_misses": len(singles), + "truncated": max(0, len(candidates) + len(singles) - self.max_items), } return result @@ -161,3 +288,8 @@ def _write_crop(self, ctx: RunContext, cand: dict[str, Any]) -> str | None: path = crops_dir / name cv2.imwrite(str(path), image) return str(path.relative_to(ctx.artifacts_dir)) + + +def _public_meta(meta: dict[str, Any]) -> dict[str, Any]: + """Item metadata worth showing a reviewer (not internal bookkeeping).""" + return {k: v for k, v in meta.items() if k not in ("world", "embedding", "low_confidence")} diff --git a/src/countbone/plugins/shelf_check.py b/src/countbone/plugins/shelf_check.py new file mode 100644 index 0000000..4db6fe5 --- /dev/null +++ b/src/countbone/plugins/shelf_check.py @@ -0,0 +1,206 @@ +"""Analytics-layer plugin: empty shelf space and layout, from the same walk. + +A count says how many; a shelf check says where the holes are. Both come +from one video, so a customer buying counts gets gap detection for no extra +filming. + +Gaps: counted objects are grouped into shelf rows by height, sorted along +the shelf (world coordinates, so camera motion is already removed), and any +stretch between neighbours wider than a typical gap plus most of an object +is empty facing space. Stretches at a row's ends count too when other rows +show the shelf continues there. Each gap gets a photo: the frame where it +was nearest the middle of the view, re-read from the source and marked. + +Layout: if the location has a planogram (the intended SKU order per row, +top row first), each observed row is aligned to its planned row and the +differences named: a missing facing, a wrong product in a slot, an +unexpected extra. +""" + +from __future__ import annotations + +import difflib +import json +from typing import Any + +import cv2 +import numpy as np + +from ..context import RunContext +from ..stages.capture import read_frame +from ..types import CountResult +from .base import Plugin, register + + +def rows_of(objects: list[dict[str, Any]]) -> list[list[dict[str, Any]]]: + """Cluster objects into shelf rows by vertical position, top row first.""" + if not objects: + return [] + med_h = float(np.median([o["h"] for o in objects])) + ordered = sorted(objects, key=lambda o: o["y"]) + rows: list[list[dict[str, Any]]] = [[ordered[0]]] + for o in ordered[1:]: + if o["y"] - np.mean([p["y"] for p in rows[-1]]) > 0.5 * med_h: + rows.append([o]) + else: + rows[-1].append(o) + return [sorted(r, key=lambda o: o["x"]) for r in rows] + + +def find_gaps(objects: list[dict[str, Any]], min_fill: float = 0.8) -> list[dict[str, Any]]: + rows = rows_of(objects) + if not rows or len(objects) < 4: + return [] + med_w = float(np.median([o["w"] for o in objects])) + spacings = [b["x"] - b["w"] / 2 - (a["x"] + a["w"] / 2) + for r in rows for a, b in zip(r, r[1:], strict=False)] + typical = float(np.median(spacings)) if spacings else 0.2 * med_w + typical = max(0.0, typical) + pitch = med_w + typical + threshold = typical + min_fill * med_w + # The shelf's extent, from every row: a short row next to longer ones + # is missing its end facings; a row can only be judged against others. + lo = min(o["x"] - o["w"] / 2 for o in objects) + hi = max(o["x"] + o["w"] / 2 for o in objects) + gaps = [] + for ri, row in enumerate(rows): + y = float(np.median([o["y"] for o in row])) + h = float(np.median([o["h"] for o in row])) + # Sweep left to right. The shelf's ends act as virtual neighbours one + # typical gap beyond the outermost objects seen in any row. + edge, left = lo - typical, None + for right in [*row, None]: + start = hi + typical if right is None else right["x"] - right["w"] / 2 + span = start - edge + if span > threshold and not (left is None and right is None): + gaps.append({ + "row": ri, + "x1": round(edge + (typical if left is None else 0.0), 1), + "x2": round(start - (typical if right is None else 0.0), 1), + "y": round(y, 1), "h": round(h, 1), + "missing_facings": max(1, int(round((span - typical) / pitch))), + "between": [left["sku"] if left else None, right["sku"] if right else None], + # Row-end gaps rest on other rows showing the shelf goes on; + # at a video's edge that is weaker evidence than an interior gap. + "at_row_end": left is None or right is None, + }) + if right is not None: + edge, left = right["x"] + right["w"] / 2, right + return gaps + + +def check_planogram(rows: list[list[dict[str, Any]]], plan: list[list[str]], + gaps: list[dict[str, Any]]) -> dict[str, Any]: + issues = [] + planned = matched = 0 + for ri, planned_row in enumerate(plan): + observed = [o["sku"] for o in rows[ri]] if ri < len(rows) else [] + planned += len(planned_row) + sm = difflib.SequenceMatcher(a=planned_row, b=observed, autojunk=False) + for op, i1, i2, j1, j2 in sm.get_opcodes(): + if op == "equal": + matched += i2 - i1 + elif op == "delete": + for i in range(i1, i2): + issues.append({"row": ri, "position": i, "kind": "missing", + "expected": planned_row[i], "found": None}) + elif op == "insert": + for j in range(j1, j2): + issues.append({"row": ri, "position": i1, "kind": "unexpected", + "expected": None, "found": observed[j]}) + else: # replace + for k in range(max(i2 - i1, j2 - j1)): + exp = planned_row[i1 + k] if i1 + k < i2 else None + got = observed[j1 + k] if j1 + k < j2 else None + kind = "misplaced" if exp and got else ("missing" if exp else "unexpected") + issues.append({"row": ri, "position": i1 + k, "kind": kind, + "expected": exp, "found": got}) + for ri in range(len(plan), len(rows)): + for o in rows[ri]: + issues.append({"row": ri, "position": None, "kind": "unexpected", + "expected": None, "found": o["sku"]}) + return {"planned_facings": planned, "matching": matched, + "compliance": round(matched / planned, 4) if planned else None, "issues": issues} + + +@register +class ShelfCheck(Plugin): + """Find empty facings and planogram differences from the walk video.""" + + name = "shelf_check" + layer = "analytics" + priority = 45 + + def configure(self, min_fill: float = 0.8, photos: bool = True, max_photos: int = 20, + **_: Any) -> None: + self.min_fill = float(min_fill) + self.photos = bool(photos) + self.max_photos = int(max_photos) + + def on_frame_tracked(self, ctx: RunContext, frame, items) -> None: + # Only frames that show the shelf are candidates for a gap photo. The + # opening frames of the bay's QR label would otherwise tie with the + # first shelf frame, and the label's own squares are detected (never + # counted) there, so "has detections" is not enough: which frames show + # counted objects is decided in _photos, once counting is done. + if not items: + return + views: list[tuple[int, float, float, int, int, frozenset]] = ctx.setdefault("shelf_views", list) + ox, oy = frame.meta.get("offset", (0.0, 0.0)) + w, h = frame.shape + tids = frozenset(i.track_id for i in items if i.track_id is not None) + views.append((frame.source_index, float(ox), float(oy), w, h, tids)) + + def on_counts(self, ctx: RunContext, result: CountResult) -> CountResult: + # Every counted object fills its facing, named or not: leaving out the + # unidentified ones reported their places as empty shelf. + objects = result.meta.get("tracks_world") or [] + gaps = find_gaps(objects, self.min_fill) + report: dict[str, Any] = { + "rows": len(rows_of(objects)), + "objects": len(objects), + "gaps": gaps, + "missing_facings": sum(g["missing_facings"] for g in gaps), + } + plan = (ctx.state.get("run_context") or {}).get("planogram") + if plan: + report["planogram"] = check_planogram(rows_of(objects), plan, gaps) + if self.photos and gaps: + self._photos(ctx, gaps, {o["track_id"] for o in objects}) + (ctx.artifacts_dir / "shelf.json").write_text(json.dumps(report, indent=2), encoding="utf-8") + result.meta["shelf"] = { + "gaps": len(gaps), + "missing_facings": report["missing_facings"], + "compliance": (report.get("planogram") or {}).get("compliance"), + } + return result + + def _photos(self, ctx: RunContext, gaps: list[dict[str, Any]], counted: set[int]) -> None: + views = [v for v in ctx.state.get("shelf_views") or [] if v[5] & counted] + if not views: + return + out = ctx.artifacts_dir / "gaps" + out.mkdir(exist_ok=True) + cache: dict[int, np.ndarray | None] = {} + for n, gap in enumerate(gaps[: self.max_photos]): + gx = (gap["x1"] + gap["x2"]) / 2 + # The view whose centre was closest to the gap: world = image - offset. + src, ox, oy, w, h, _ = min(views, key=lambda v: abs((v[3] / 2 - v[1]) - gx)) + if src not in cache: + try: + cache[src] = read_frame(ctx.source, src, ctx.config.capture.resize_width) + except Exception: # noqa: BLE001 - a photo is a nicety, never a failure + cache[src] = None + img = cache[src] + if img is None: + continue + img = img.copy() + x1, x2 = int(gap["x1"] + ox), int(gap["x2"] + ox) + y1, y2 = int(gap["y"] - gap["h"] / 2 + oy), int(gap["y"] + gap["h"] / 2 + oy) + cv2.rectangle(img, (x1, y1), (x2, y2), (40, 40, 230), 3) + cv2.putText(img, f"empty x{gap['missing_facings']}", (max(4, x1), max(18, y1 - 6)), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, (40, 40, 230), 2, cv2.LINE_AA) + name = f"gap_{n + 1:02d}.jpg" + cv2.imwrite(str(out / name), img, [cv2.IMWRITE_JPEG_QUALITY, 85]) + gap["photo"] = f"gaps/{name}" + gap["photo_frame"] = src diff --git a/src/countbone/plugins/tolerance.py b/src/countbone/plugins/tolerance.py index 05e022d..5a59856 100644 --- a/src/countbone/plugins/tolerance.py +++ b/src/countbone/plugins/tolerance.py @@ -14,9 +14,11 @@ from .base import Plugin, register DEFAULT_BANDS = [ - # up to unit_value, allowed absolute units, allowed fraction of expected - {"max_unit_value": 10.0, "abs": 2, "pct": 0.03}, - {"max_unit_value": 100.0, "abs": 1, "pct": 0.01}, + # Out of the box every miss is flagged: a count that disagrees with the + # expected number is exactly what a customer bought this to find. Loosen + # per value band in config once the business has decided what is noise, + # e.g. {"max_unit_value": 10.0, "abs": 2, "pct": 0.03} for cheap cartons. + # (What gets auto-approved is a separate decision: see ops.reconcile.) {"max_unit_value": None, "abs": 0, "pct": 0.0}, ] diff --git a/src/countbone/security.py b/src/countbone/security.py new file mode 100644 index 0000000..ee57294 --- /dev/null +++ b/src/countbone/security.py @@ -0,0 +1,267 @@ +"""Passwords, tokens, secrets at rest, and the evidence signing key. + +Standard library where it is enough (scrypt, secrets, hmac); `cryptography` +for the two things the standard library does not do: authenticated +encryption of integration credentials, and Ed25519 signatures that a third +party can verify with only a public key. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import os +import secrets +import threading +import time +from pathlib import Path +from typing import Any + +ROLES = ("counter", "manager", "admin") # each role can do everything the ones before it can +ROLE_RANK = {r: i for i, r in enumerate(ROLES)} + +MIN_PASSWORD = 10 +SESSION_DAYS = 14 + +# scrypt at n=2**14, r=8: ~16 MiB and a few tens of milliseconds per check. +# Slow enough to make a stolen database expensive to crack, fast enough that +# a login does not feel it. +_SCRYPT = {"n": 2**14, "r": 8, "p": 1} + + +def role_at_least(role: str | None, needed: str) -> bool: + return role in ROLE_RANK and ROLE_RANK[role] >= ROLE_RANK[needed] + + +def check_password_policy(password: str) -> str | None: + """None if acceptable, otherwise the reason it is not.""" + if len(password) < MIN_PASSWORD: + return f"use at least {MIN_PASSWORD} characters" + if len(set(password)) < 4: + return "use a less repetitive password" + return None + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + digest = hashlib.scrypt(password.encode("utf-8"), salt=salt, dklen=32, **_SCRYPT) + return "scrypt${n}${r}${p}${salt}${digest}".format( + **_SCRYPT, + salt=base64.b64encode(salt).decode(), + digest=base64.b64encode(digest).decode(), + ) + + +def verify_password(password: str, stored: str) -> bool: + try: + scheme, n, r, p, salt_b64, digest_b64 = stored.split("$") + if scheme != "scrypt": + return False + digest = hashlib.scrypt( + password.encode("utf-8"), salt=base64.b64decode(salt_b64), dklen=32, + n=int(n), r=int(r), p=int(p), + ) + return hmac.compare_digest(digest, base64.b64decode(digest_b64)) + except (ValueError, TypeError): + return False + + +# A password check that fails fast for unknown users tells an attacker which +# usernames exist. Verifying against this dummy costs the same as a real one. +_DUMMY_HASH = hash_password(secrets.token_urlsafe(16)) + + +def burn_password_check(password: str) -> None: + verify_password(password, _DUMMY_HASH) + + +def new_token(prefix: str = "") -> str: + return prefix + secrets.token_urlsafe(32) + + +def token_hash(token: str) -> str: + """Tokens are stored only as hashes: a leaked database opens no sessions.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +class LoginThrottle: + """Slow down password guessing, per username and per client address. + + Each key gets some free failures, then each further failure doubles a + lockout (capped at 15 minutes). Failures are forgotten after WINDOW_S + without one, so a lockout can never become permanent. + + The client limit is much looser than the username limit on purpose: + one address is often a whole shop behind one router (or, if the proxy + is misconfigured, every user at once), and a stranger's typos must not + lock everyone out. The username limit is what stops guessing one + account; the client limit only stops one address spraying many. + + Memory only, and bounded: a restart forgives, which is fine for a speed + bump whose job is to make online guessing impractical. + """ + + FREE_USER = 5 + FREE_CLIENT = 50 + MAX_LOCK_S = 900 + WINDOW_S = 900 + MAX_KEYS = 10_000 + + def __init__(self) -> None: + self._lock = threading.Lock() + # key -> (failures, locked until, last failure) + self._fails: dict[str, tuple[int, float, float]] = {} + + @staticmethod + def _user_key(username: str) -> str: + return f"u:{username.strip().lower()}" + + def _keys(self, username: str, client: str) -> list[tuple[str, int]]: + return [(self._user_key(username), self.FREE_USER), (f"c:{client}", self.FREE_CLIENT)] + + def _live(self, key: str, now: float) -> tuple[int, float, float] | None: + entry = self._fails.get(key) + if entry is not None and now - entry[2] > self.WINDOW_S and entry[1] <= now: + del self._fails[key] # quiet long enough: forgiven + return None + return entry + + def retry_after(self, username: str, client: str) -> float: + now = time.time() + with self._lock: + waits = [entry[1] - now for k, _ in self._keys(username, client) + if (entry := self._live(k, now)) and entry[1] > now] + return max(waits, default=0.0) + + def failed(self, username: str, client: str) -> None: + now = time.time() + with self._lock: + if len(self._fails) >= self.MAX_KEYS: + self._prune(now) + for k, free in self._keys(username, client): + entry = self._live(k, now) + count = (entry[0] if entry else 0) + 1 + lock = min(self.MAX_LOCK_S, 2 ** (count - free)) if count > free else 0.0 + self._fails[k] = (count, now + lock, now) + + def succeeded(self, username: str, client: str) -> None: + # Only the account is cleared: a guesser with one valid account of + # its own must not be able to wipe its address's record by signing in. + with self._lock: + self._fails.pop(self._user_key(username), None) + + def _prune(self, now: float) -> None: + for k in [k for k, (_, until, last) in self._fails.items() + if until <= now and now - last > self.WINDOW_S]: + del self._fails[k] + if len(self._fails) >= self.MAX_KEYS: + # Still full of live entries (a flood): drop the oldest half. + oldest = sorted(self._fails, key=lambda k: self._fails[k][2]) + for k in oldest[: len(oldest) // 2]: + del self._fails[k] + + +# -- keys on disk -------------------------------------------------------------- +def _write_private(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as fh: + fh.write(data) + + +class Keyring: + """The deployment's secret key and evidence signing key. + + Both live in a directory beside the database (0600 files), created on + first use. COUNTBONE_SECRET_KEY overrides the file so a container can + take it from a secret manager. Back the directory up with the database: + without it, stored integration credentials cannot be decrypted and old + evidence packs can still be verified (the public key is inside them) + but no new ones can be signed with the same identity. + """ + + def __init__(self, directory: str | Path) -> None: + self.dir = Path(directory) + self._fernet = None + self._signer = None + self._lock = threading.Lock() + + # symmetric: integration secrets --------------------------------------------- + def _secret_key(self) -> bytes: + env = os.environ.get("COUNTBONE_SECRET_KEY") + if env: + return env.encode() + path = self.dir / "secret.key" + if not path.exists(): + from cryptography.fernet import Fernet + + try: + _write_private(path, Fernet.generate_key()) + except FileExistsError: # another worker won the race + pass + return path.read_bytes().strip() + + def fernet(self): + with self._lock: + if self._fernet is None: + from cryptography.fernet import Fernet + + self._fernet = Fernet(self._secret_key()) + return self._fernet + + def seal(self, value: dict[str, Any]) -> bytes: + return self.fernet().encrypt(json.dumps(value).encode("utf-8")) + + def unseal(self, blob: bytes | None) -> dict[str, Any]: + if not blob: + return {} + return json.loads(self.fernet().decrypt(blob).decode("utf-8")) + + # asymmetric: evidence signatures ---------------------------------------------- + def signer(self): + with self._lock: + if self._signer is None: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + path = self.dir / "evidence_signing.pem" + if not path.exists(): + key = Ed25519PrivateKey.generate() + pem = key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + try: + _write_private(path, pem) + except FileExistsError: + pass + self._signer = serialization.load_pem_private_key(path.read_bytes(), None) + return self._signer + + def sign(self, data: bytes) -> str: + return base64.b64encode(self.signer().sign(data)).decode() + + def public_key_pem(self) -> str: + from cryptography.hazmat.primitives import serialization + + return self.signer().public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ).decode() + + def key_id(self) -> str: + return hashlib.sha256(self.public_key_pem().encode()).hexdigest()[:16] + + +def verify_signature(public_key_pem: str, data: bytes, signature_b64: str) -> bool: + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives import serialization + + try: + key = serialization.load_pem_public_key(public_key_pem.encode()) + key.verify(base64.b64decode(signature_b64), data) # type: ignore[union-attr] + return True + except (InvalidSignature, ValueError, TypeError): + return False diff --git a/src/countbone/stages/capture.py b/src/countbone/stages/capture.py index ecf024b..2be61d4 100644 --- a/src/countbone/stages/capture.py +++ b/src/countbone/stages/capture.py @@ -7,6 +7,7 @@ from pathlib import Path import cv2 +import numpy as np from ..config import CaptureConfig from ..types import Frame @@ -30,34 +31,234 @@ def open_source(source: str | int) -> cv2.VideoCapture: return cap +def _source_fps(cap: cv2.VideoCapture) -> float: + """The container's frame rate, or 0 when it does not really know. + + Browser recordings (MediaRecorder WebM) carry no rate in their header, + and OpenCV then reports its 1 kHz timebase. No phone films at 1000 fps, + so anything implausible is treated as unknown rather than believed. + """ + fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0) + return fps if 0 < fps <= 240 else 0.0 + + +def _source_frames(cap: cv2.VideoCapture) -> int: + """Frame count, or 0 when unknown (the same WebMs report a negative sentinel).""" + frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + return frames if frames > 0 else 0 + + def probe(source: str | int) -> dict: """Metadata without decoding the whole file.""" cap = open_source(source) try: - fps = cap.get(cv2.CAP_PROP_FPS) or 0.0 - frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + fps = _source_fps(cap) + frames = _source_frames(cap) return { "fps": round(fps, 3), "frame_count": frames, "width": int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), "height": int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), - "duration_s": round(frames / fps, 3) if fps > 0 else None, + "duration_s": round(frames / fps, 3) if fps > 0 and frames > 0 else None, } finally: cap.release() -def frames(source: str | int, cfg: CaptureConfig) -> Iterator[Frame]: +def read_frame(source: str, source_index: int, resize_width: int | None = None) -> np.ndarray | None: + """One frame by its index in the source, at pipeline resolution. + + For after-the-fact evidence (a shelf gap, a dataset image): the pipeline + keeps no pixels once a frame has gone past, and the source is on disk. + Reads forward rather than seeking, because frame-accurate seeking is + unreliable across codecs and a wrong frame would be wrong evidence. + """ + cap = open_source(source) + try: + for _ in range(source_index + 1): + ok, image = cap.read() + if not ok: + return None + return _resize(image, resize_width) + finally: + cap.release() + + +def _timestamp(cap: cv2.VideoCapture, source_index: int, fps: float) -> float: + """When the frame just read is shown, in seconds from the start. + + Phones record variable frame rate, so index / average-fps drifts: on a + VFR clip it was measured over a second out, which puts the frame + inspector's boxes on the wrong carton. The container's own timestamp is + right for both constant and variable rate. Some backends (cameras) report + 0 for every frame; then the old estimate is all there is. + """ + pos = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0 + if pos > 0 or source_index == 0: + return pos + return source_index / fps if fps > 0 else float(source_index) + + +def _resize(image: np.ndarray, width: int | None) -> np.ndarray: + if width and image.shape[1] > width: + scale = width / image.shape[1] + return cv2.resize( + image, + (width, max(1, int(round(image.shape[0] * scale)))), + interpolation=cv2.INTER_AREA, + ) + return image + + +PROBE_WIDTH = 160 # the look-ahead shift check runs on a thumbnail + + +def _thumb(image: np.ndarray) -> np.ndarray: + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image + scale = PROBE_WIDTH / gray.shape[1] + small = cv2.resize( + gray, (PROBE_WIDTH, max(1, int(round(gray.shape[0] * scale)))), + interpolation=cv2.INTER_AREA, + ) + return small.astype(np.float32) + + +class Sampler: + """Decides which source frames become pipeline frames, and adapts. + + A fixed stride is blind to the two things that break counting: the + camera moving further between samples than the tracker can follow, and + the quality gate rejecting a sample, which silently doubles the gap to + the next one. At a fixed stride of 5 both cost real cartons (an item at + the aisle's edge seen in one usable frame is discarded as noise). + + So sampling is closed-loop: + + * look-ahead: before a sample is emitted, a thumbnail phase correlation + measures how far the scene moved since the last emitted frame. If that + is more than `max_shift_frac` of an object's width, the skipped frames + in between are emitted first (they are still in memory), so no gap the + tracker has to bridge is ever that large; + * the stride shrinks toward whatever keeps the shift near `target_shift_frac` + of an object width, and grows back to `every_n_frames` when the camera + slows; + * after the pipeline drops a frame, the very next source frame is taken, + instead of waiting a whole stride. + + Decoding already reads every frame, so this costs thumbnails, not I/O. + """ + + def __init__(self, cfg: CaptureConfig) -> None: + self.base = max(1, int(cfg.every_n_frames)) + self.adaptive = bool(cfg.adaptive) + self.target_shift_frac = float(cfg.target_shift_frac) + self.max_shift_frac = float(cfg.max_shift_frac) + self.step = self.base + self.retry = False + self.box_px: float | None = None # a typical object width, fed back by the pipeline + self.frame_width: int | None = None + self.truncated = False + self.stats = {"emitted": 0, "backfilled": 0, "retries": 0, "min_step": self.base} + self._speed: float | None = None # px per source frame, smoothed + self._window: np.ndarray | None = None + + # -- feedback from the pipeline -------------------------------------- + def feedback(self, kept: bool, box_px: float | None = None) -> None: + if box_px and box_px > 0: + self.box_px = float(box_px) + if not self.adaptive: + return + if not kept: + self.retry = True + self.stats["retries"] += 1 + else: + self.retry = False + + # -- decisions ------------------------------------------------------- + def _object_px(self) -> float: + if self.box_px: + return self.box_px + # Before the first detection: assume objects about a tenth of the frame. + return 0.1 * (self.frame_width or PROBE_WIDTH * 6) + + def due(self, gap: int) -> bool: + """Is the frame `gap` source frames after the last emitted one due?""" + if not self.adaptive: + return gap >= self.base + return self.retry or gap >= self.step + + def shift(self, prev: np.ndarray, curr: np.ndarray, gap: int) -> float | None: + """Scene shift between two thumbnails, in full-resolution pixels. + + None when it cannot be trusted: a weak correlation, or an answer the + recent speed says is implausible (a repeating shelf can alias a big + move into a small one). + """ + if prev.shape != curr.shape: + return None + if self._window is None or self._window.shape != curr.shape: + self._window = cv2.createHanningWindow((curr.shape[1], curr.shape[0]), cv2.CV_32F) + (dx, dy), response = cv2.phaseCorrelate(prev, curr, self._window) + if response < 0.1: + return None + scale = (self.frame_width or PROBE_WIDTH) / PROBE_WIDTH + moved = float(np.hypot(dx, dy)) * scale + if self._speed is not None and gap > 0: + predicted = self._speed * gap + if predicted > 0.5 * self._object_px() and moved < 0.4 * predicted: + return None + return moved + + def observe(self, moved: float | None, gap: int) -> None: + """Adapt the stride from the measured speed.""" + if not self.adaptive or gap <= 0: + return + if moved is None: + self.step = 1 + else: + speed = moved / gap + self._speed = speed if self._speed is None else 0.5 * self._speed + 0.5 * speed + target = self.target_shift_frac * self._object_px() + ideal = int(target / max(self._speed, 1e-6)) + self.step = int(min(self.base, max(1, ideal))) + self.stats["min_step"] = min(self.stats["min_step"], self.step) + + def too_far(self, moved: float | None) -> bool: + return moved is None or moved > self.max_shift_frac * self._object_px() + + +def frames( + source: str | int, cfg: CaptureConfig, sampler: Sampler | None = None +) -> Iterator[Frame]: """Yield sampled, optionally downscaled frames. Sampling is by frame index rather than by time so that a variable-rate - file still gives an even spread of the shelf. + file still gives an even spread of the shelf. Pass a Sampler (and feed it + back) for closed-loop sampling; without one the stride is fixed. """ cap = open_source(source) - fps = cap.get(cv2.CAP_PROP_FPS) or 0.0 - step = max(1, int(cfg.every_n_frames)) + fps = _source_fps(cap) + sampler = sampler or Sampler(CaptureConfig(**{**cfg.__dict__, "adaptive": False})) emitted = 0 source_index = -1 + last_index: int | None = None + last_thumb: np.ndarray | None = None + # Frames skipped since the last emitted one, kept so a gap that turns out + # too wide can be filled in after the fact. Bounded by the stride. + skipped: list[tuple[int, float, np.ndarray]] = [] + + def make(idx: int, ts: float, image: np.ndarray, gap: int, backfill: bool) -> Frame: + nonlocal emitted + frame = Frame( + index=emitted, + source_index=idx, + timestamp_s=round(ts, 4), + image=image, + meta={"fps": round(fps, 3), "gap": gap, "backfill": backfill}, + ) + emitted += 1 + sampler.stats["emitted"] = emitted + return frame try: while True: @@ -65,30 +266,42 @@ def frames(source: str | int, cfg: CaptureConfig) -> Iterator[Frame]: if not ok: break source_index += 1 - ts = source_index / fps if fps > 0 else float(source_index) + ts = _timestamp(cap, source_index, fps) if ts < cfg.start_s: continue if cfg.end_s is not None and ts > cfg.end_s: break - if source_index % step: + image = _resize(image, cfg.resize_width) + if sampler.frame_width is None: + sampler.frame_width = image.shape[1] + + gap = source_index - last_index if last_index is not None else 0 + if last_index is not None and not sampler.due(gap): + skipped.append((source_index, ts, image)) + # Only frames since the last emission are useful for backfill. + if len(skipped) > sampler.base: + skipped.pop(0) continue - if cfg.resize_width and image.shape[1] > cfg.resize_width: - scale = cfg.resize_width / image.shape[1] - image = cv2.resize( - image, - (cfg.resize_width, max(1, int(round(image.shape[0] * scale)))), - interpolation=cv2.INTER_AREA, - ) - yield Frame( - index=emitted, - source_index=source_index, - timestamp_s=round(ts, 4), - image=image, - meta={"fps": round(fps, 3)}, - ) - emitted += 1 - if cfg.max_frames and emitted >= cfg.max_frames: - log.info("capture stopped at max_frames=%d", cfg.max_frames) - break + + thumb = _thumb(image) if sampler.adaptive else None + to_emit: list[tuple[int, float, np.ndarray, bool]] = [] + if last_thumb is not None and thumb is not None: + moved = sampler.shift(last_thumb, thumb, gap) + if skipped and sampler.too_far(moved): + to_emit = [(i, t, img, True) for i, t, img in skipped] + sampler.stats["backfilled"] += len(skipped) + sampler.observe(moved, gap) + to_emit.append((source_index, ts, image, False)) + skipped = [] + + for idx, t, img, backfill in to_emit: + frame_gap = idx - last_index if last_index is not None else 0 + yield make(idx, t, img, frame_gap, backfill) + last_index = idx + if cfg.max_frames and emitted >= cfg.max_frames: + sampler.truncated = True + log.warning("capture stopped at max_frames=%d", cfg.max_frames) + return + last_thumb = thumb finally: cap.release() diff --git a/src/countbone/stages/count.py b/src/countbone/stages/count.py index b745f5b..22b22eb 100644 --- a/src/countbone/stages/count.py +++ b/src/countbone/stages/count.py @@ -45,6 +45,7 @@ def __init__(self, iou_threshold: float = 0.3, max_gap: int = 2) -> None: self._last_cum: dict[int, tuple[float, float]] = {} self._cum = (0.0, 0.0) # running camera displacement since the first frame self._next_id = 1 + self._cut_at = -1 # frame index of the last scene cut def update( self, frame_index: int, items: list[Item], motion: dict[str, float] | None = None @@ -56,11 +57,14 @@ def update( forward by that displacement is what keeps one physical carton as one track instead of one per frame. """ - if motion and motion.get("estimated"): - self._cum = (self._cum[0] + motion["dx"], self._cum[1] + motion["dy"]) + self.observe_motion(motion) + if motion and motion.get("cut"): + # Nothing continues across a scene cut (see motion.py). + self._cut_at = frame_index live = [ - t for t in self.tracks if frame_index - t.last_frame <= self.max_gap + 1 + t for t in self.tracks + if frame_index - t.last_frame <= self.max_gap + 1 and t.last_frame >= self._cut_at ] unmatched = list(items) # Highest-overlap pairs first, so a crowded shelf does not get stolen @@ -105,6 +109,22 @@ def update( self.tracks.append(track) self._attach(track, item, frame_index) + @property + def offset(self) -> tuple[float, float]: + """How far the scene has slid since the first frame (world = image - offset).""" + return self._cum + + def observe_motion(self, motion: dict[str, float] | None) -> None: + """Add one frame's camera displacement to the running total. + + Called for every frame, including ones the quality gate drops: the + camera moved during a dropped frame too. Skip that, and every track's + prediction lags by a whole frame of pan, which at walking speed is + enough to split each object in view into two tracks and count it twice. + """ + if motion and motion.get("estimated"): + self._cum = (self._cum[0] + motion["dx"], self._cum[1] + motion["dy"]) + def _predict( self, track: Track, frame_index: int, motion: dict[str, float] | None ) -> tuple[float, float, float, float]: @@ -117,6 +137,16 @@ def _predict( def _attach(self, track: Track, item: Item, frame_index: int) -> None: item.track_id = track.track_id + # Where the object sits on the shelf, in the first frame's pixel + # coordinates: its image position minus how far the scene has slid. + # Stable across the whole walk, so cross-video merging and shelf-gap + # checks can reason about the shelf rather than the screen. + cx, cy = item.detection.centroid + x1, y1, x2, y2 = item.detection.bbox + item.meta["world"] = ( + round(cx - self._cum[0], 1), round(cy - self._cum[1], 1), + round(x2 - x1, 1), round(y2 - y1, 1), + ) track.items.append(item) track.last_frame = frame_index # A track's SKU is the majority vote of its sightings, not the first @@ -126,6 +156,17 @@ def _attach(self, track: Track, item: Item, frame_index: int) -> None: self._last_cum[track.track_id] = self._cum +def track_world(track: Track) -> dict[str, float] | None: + """A track's shelf position and size: the median over its sightings.""" + pts = [i.meta["world"] for i in track.items if "world" in i.meta] + if not pts: + return None + arr = np.asarray(pts, dtype=np.float64) + x, y, w, h = np.median(arr, axis=0) + return {"x": round(float(x), 1), "y": round(float(y), 1), + "w": round(float(w), 1), "h": round(float(h), 1)} + + def _track_confidence(track: Track, min_hits: int) -> float: """How much we believe this track is one real object.""" if not track.items: @@ -141,8 +182,23 @@ def _track_confidence(track: Track, min_hits: int) -> float: )) +def expected_for(sku: str, cfg: CountConfig, catalog: Catalog, + override: dict[str, int] | None) -> int | None: + """The number this SKU should have come to. + + A per-run expectation (a location's book quantity, a purchase order's + lines) replaces the global ones outright: a bay's stock has nothing to + do with the catalog-wide default. + """ + if override is not None: + return override.get(sku) + entry = catalog.by_sku(sku) + return cfg.expected.get(sku, entry.expected if entry else None) + + def count_from_tracks( - tracks: list[Track], cfg: CountConfig, catalog: Catalog + tracks: list[Track], cfg: CountConfig, catalog: Catalog, + expected: dict[str, int] | None = None, ) -> list[SkuCount]: grouped: dict[str, list[Track]] = defaultdict(list) for track in tracks: @@ -159,7 +215,7 @@ def count_from_tracks( count=len(sku_tracks), label=entry.label if entry else sku, confidence=float(np.mean(confidences)) if confidences else 0.0, - expected=cfg.expected.get(sku, entry.expected if entry else None), + expected=expected_for(sku, cfg, catalog, expected), evidence={ "strategy": "tracking", "tracks": [t.track_id for t in sku_tracks], @@ -172,7 +228,8 @@ def count_from_tracks( def count_per_frame( - per_frame: dict[int, list[Item]], cfg: CountConfig, catalog: Catalog, how: str + per_frame: dict[int, list[Item]], cfg: CountConfig, catalog: Catalog, how: str, + expected: dict[str, int] | None = None, ) -> list[SkuCount]: """peak_frame / median_frame: aggregate the per-frame tallies.""" tallies: dict[str, list[int]] = defaultdict(list) @@ -203,7 +260,7 @@ def count_per_frame( count=value, label=entry.label if entry else sku, confidence=float(np.clip(0.7 * base + 0.3 * stability, 0.0, 1.0)), - expected=cfg.expected.get(sku, entry.expected if entry else None), + expected=expected_for(sku, cfg, catalog, expected), evidence={ "strategy": how, "per_frame_min": min(series), @@ -221,16 +278,40 @@ def finalise( per_frame: dict[int, list[Item]], cfg: CountConfig, catalog: Catalog, + expected: dict[str, int] | None = None, ) -> CountResult: if cfg.strategy == "tracking": - result.counts = count_from_tracks(tracks, cfg, catalog) + result.counts = count_from_tracks(tracks, cfg, catalog, expected) elif cfg.strategy in ("peak_frame", "median_frame"): - result.counts = count_per_frame(per_frame, cfg, catalog, cfg.strategy) + result.counts = count_per_frame(per_frame, cfg, catalog, cfg.strategy, expected) else: raise ValueError( f"unknown count strategy {cfg.strategy!r}; " "choose from tracking, peak_frame, median_frame" ) + # An expected SKU that was never seen is the worst miss there is (the + # shelf is empty), and without a row it would have no variance to flag. + seen = {c.sku for c in result.counts} + wanted = expected if expected is not None else { + **catalog.expected_counts(), **cfg.expected + } + for sku, qty in sorted(wanted.items()): + if sku in seen or qty is None: + continue + entry = catalog.by_sku(sku) + result.counts.append( + SkuCount( + sku=sku, + count=0, + label=entry.label if entry else sku, + # Nothing was seen, so there is no identification to doubt; + # the variance, not the confidence, is what flags this row. + confidence=1.0, + expected=int(qty), + evidence={"strategy": "not_seen"}, + ) + ) + result.counts.sort(key=lambda c: c.sku) result.tracks = len(tracks) if result.counts: total = sum(c.count for c in result.counts) or 1 diff --git a/src/countbone/stages/detect.py b/src/countbone/stages/detect.py index 8e82f9d..00a8bf0 100644 --- a/src/countbone/stages/detect.py +++ b/src/countbone/stages/detect.py @@ -88,9 +88,22 @@ class ContourDetector: def __init__(self, cfg: DetectConfig) -> None: self.cfg = cfg + # Width of the synthetic border added around each frame (see detect()). + PAD = 12 + def detect(self, frame: Frame) -> list[Detection]: image = frame.image gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image + # An object cut off by the frame edge has no edge along the cut, so + # its outline never closes; RETR_EXTERNAL then reports whatever is + # *inside* it (a carton's white label) as an object of its own. A + # border in the frame's median tone closes those outlines against the + # cut, where the plain background, being close to the median, stays + # edge-free. Objects touching the border are then dropped below: + # they are partial, and a pan shows each one whole in another frame. + pad = self.PAD + fill = int(np.median(gray)) + gray = cv2.copyMakeBorder(gray, pad, pad, pad, pad, cv2.BORDER_CONSTANT, value=fill) gray = cv2.GaussianBlur(gray, (5, 5), 0) median = float(np.median(gray)) @@ -102,10 +115,16 @@ def detect(self, frame: Frame) -> list[Detection]: contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) detections: list[Detection] = [] + frame_w, frame_h = frame.shape for contour in contours: x, y, w, h = cv2.boundingRect(contour) if w < 8 or h < 8: continue + # Back to frame coordinates; skip anything reaching the frame edge. + x -= pad + y -= pad + if x <= 0 or y <= 0 or x + w >= frame_w or y + h >= frame_h: + continue rect_area = float(w * h) fill = float(cv2.contourArea(contour)) / rect_area if rect_area else 0.0 aspect = w / h if h else 0.0 diff --git a/src/countbone/stages/identify.py b/src/countbone/stages/identify.py index 7305dde..be0da39 100644 --- a/src/countbone/stages/identify.py +++ b/src/countbone/stages/identify.py @@ -7,7 +7,7 @@ import cv2 import numpy as np -from ..catalog import Catalog +from ..catalog import Catalog, SkuEntry from ..config import IdentifyConfig from ..types import Detection, Frame, Item @@ -32,6 +32,21 @@ def crop(frame: Frame, det: Detection, inset: float = 0.0) -> np.ndarray: return frame.image[yi1:yi2, xi1:xi2] +def circular_hue(hsv: np.ndarray) -> float: + """The typical hue of a patch, on OpenCV's 0-179 wheel. + + Hue wraps: 0 and 179 are both red. A median over a red patch whose + pixels straddle the wrap (sensor noise decides which side each lands on) + averages 0s and 179s into a green or cyan hue, which misfiled real red + cartons as green. The mean direction on the circle, weighted by + saturation so grey pixels (whose hue is noise) barely count, does not. + """ + h = hsv[:, :, 0].astype(np.float64).ravel() * (2.0 * np.pi / 180.0) + w = hsv[:, :, 1].astype(np.float64).ravel() + 1.0 + angle = np.arctan2(float((w * np.sin(h)).sum()), float((w * np.cos(h)).sum())) + return float(np.mod(angle * 180.0 / (2.0 * np.pi), 180.0)) + + class ColorIdentifier: """Match the dominant hue of a detection against the catalog. @@ -50,7 +65,7 @@ def identify(self, frame: Frame, detections: list[Detection]) -> list[Item]: for det in detections: patch = crop(frame, det, inset=0.18) hsv = cv2.cvtColor(patch, cv2.COLOR_BGR2HSV) - hue = float(np.median(hsv[:, :, 0])) + hue = circular_hue(hsv) sat = float(np.median(hsv[:, :, 1])) val = float(np.median(hsv[:, :, 2])) @@ -149,7 +164,148 @@ def identify(self, frame: Frame, detections: list[Detection]) -> list[Item]: ] +def _bands_overlap(a: SkuEntry, b: SkuEntry) -> bool: + """Could a sighting of `b` fall in `a`'s colour band?""" + if a.achromatic or b.achromatic: + return a.achromatic and b.achromatic + if a.hue is None or b.hue is None: + return True + return any(a.matches_hue(h) for h in _band_points(b)) or any( + b.matches_hue(h) for h in _band_points(a)) + + +def _band_points(e: SkuEntry) -> list[float]: + lo, hi = e.hue # type: ignore[misc] + span = (hi - lo) if lo <= hi else (180 - lo + hi) + return [(lo + span * k / 8) % 180 for k in range(9)] + + +class BarcodeReader: + """Read an EAN/UPC off a sighting when one is legible. + + A readable barcode is the strongest identity evidence there is, but most + video crops are too small or soft to decode, so this is tried only on + large crops and only when the catalog lists barcodes at all. + """ + + MIN_WIDTH = 90 # px; below this a 1D code cannot resolve at video quality + + def __init__(self, catalog: Catalog) -> None: + self.catalog = catalog + self.enabled = any(e.barcodes for e in catalog.entries) + self._detector = cv2.barcode.BarcodeDetector() if self.enabled else None + + def read(self, patch: np.ndarray): + if not self.enabled or patch.shape[1] < self.MIN_WIDTH: + return None + try: + text, _, _ = self._detector.detectAndDecode(patch) + except cv2.error: + return None + return self.catalog.by_barcode(text) if text else None + + +class AppearanceIdentifier: + """Identify by artwork, from photos enrolled in Catalog Studio. + + Each sighting is embedded and matched against the enrolled examples + (see appearance.py); a readable barcode overrides the match. SKUs that + were never photographed are still recognised by colour, so a catalog + can move from colour bands to photos one product at a time. + """ + + name = "appearance" + + def __init__(self, cfg: IdentifyConfig, catalog: Catalog) -> None: + from .. import appearance + + self.cfg = cfg + self.catalog = catalog + self.index = catalog.index() + self._embed = appearance.embed + self._confidence = appearance.confidence + self.barcodes = BarcodeReader(catalog) + enrolled = [e for e in catalog.entries if e.sku in set(self.index.skus)] + # Colour may vouch only for products nobody photographed, and only + # when no photographed product shares their colour: a red sighting the + # photos rejected must become "unknown", not the one red product that + # happens to have no photos. If any photographed product has no colour + # band, its colour is unknown and colour vouches for nothing. + if any(e.hue is None and not e.achromatic for e in enrolled): + fallback: list[SkuEntry] = [] + else: + fallback = [ + e for e in catalog.entries + if e.sku not in self.index.skus + and not any(_bands_overlap(e, other) for other in enrolled) + ] + self.colour = ColorIdentifier(cfg, Catalog(fallback)) + # A photographed product sharing its colour with one nobody + # photographed has an invisible rival: the look-alike can never win a + # match, so nothing catches a crop of it that clears the photographed + # product's bar. With few photos that bar is one odd view away from + # letting it through, so it is held to at least the default bar. + self._floor = {e.sku: appearance.DEFAULT_ACCEPT for e in shadowed(catalog, self.index) + if not self.index.well_photographed(e.sku)} + + def identify(self, frame: Frame, detections: list[Detection]) -> list[Item]: + items: list[Item] = [] + for det in detections: + patch = crop(frame, det) + entry = self.barcodes.read(patch) + if entry is not None: + items.append(Item(det, entry.sku, entry.label, 0.98, "barcode", + meta={"candidates": [{"sku": entry.sku, "score": 1.0}]})) + continue + if patch.size < 16 * 3: + items.append(Item(det, self.cfg.unknown_sku, "Unidentified", 0.0, "fallback")) + continue + matches = self.index.match(self._embed(patch)) if len(self.index) else [] + accept = self.index.accept_for(matches[0].sku) if matches else self.index.accept + if matches: + accept = max(accept, self._floor.get(matches[0].sku, 0.0)) + conf = self._confidence(matches, accept, self.index.margin) + candidates = [{"sku": m.sku, "score": round(m.score, 4)} for m in matches] + if matches and matches[0].score >= accept: + entry = self.catalog.by_sku(matches[0].sku) + items.append(Item(det, matches[0].sku, entry.label if entry else matches[0].sku, + conf, "appearance", meta={"candidates": candidates})) + continue + # Not like any photo: perhaps a product that is only colour-banded. + fallback = self.colour.identify(frame, [det])[0] + if fallback.sku != self.cfg.unknown_sku: + fallback.meta["candidates"] = candidates + items.append(fallback) + continue + items.append(Item(det, self.cfg.unknown_sku, "Unidentified", conf, "fallback", + meta={"candidates": candidates, **fallback.meta})) + return items + + +def shadowed(catalog: Catalog, index) -> list[SkuEntry]: + """Photographed products that share their colour with an unphotographed one.""" + enrolled = [e for e in catalog.entries if e.sku in set(index.skus)] + others = [e for e in catalog.entries if e.sku not in set(index.skus)] + return [e for e in enrolled if any(_bands_overlap(e, o) for o in others)] + + +class AutoIdentifier: + """The default: appearance once any product has photos, colour until then. + + Chosen per run (the pipeline rebuilds its identifier when the catalog + changes), so enrolling the first photos switches a deployment over + without a config edit. + """ + + def __new__(cls, cfg: IdentifyConfig, catalog: Catalog): # type: ignore[misc] + if catalog.exemplars: + return AppearanceIdentifier(cfg, catalog) + return ColorIdentifier(cfg, catalog) + + BACKENDS = { + "auto": AutoIdentifier, + "appearance": AppearanceIdentifier, "color": ColorIdentifier, "classmap": ClassMapIdentifier, "fixture": FixtureIdentifier, diff --git a/src/countbone/stages/motion.py b/src/countbone/stages/motion.py index 2de47f6..0171164 100644 --- a/src/countbone/stages/motion.py +++ b/src/countbone/stages/motion.py @@ -37,25 +37,42 @@ class MotionEstimator: def __init__(self, min_response: float = 0.12) -> None: self.min_response = float(min_response) self._prev: np.ndarray | None = None + self._hist: np.ndarray | None = None self._scale: float = 1.0 self._window: np.ndarray | None = None def update(self, image: np.ndarray) -> dict[str, float]: """Return the pixel displacement of scene content since the last frame.""" current, scale = _prepare(image) + # A scene cut (the camera swung from the bay's label to the shelf, or + # was pointed at the floor) is not motion: nothing on one side of it + # is the same object as anything on the other. Grey-level histograms + # of the two frames barely agree across a cut, and stay close under + # any pan, blur or exposure drift. Measured on a tiny, smoothed copy: + # at full detail a motion-blurred frame's histogram differs from a + # sharp one's almost as much as a cut does (0.3 against -0.1 on the + # synthetic shelf); smoothed, blur scores 0.9+ and a cut stays near 0. + gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image + tiny = cv2.GaussianBlur(cv2.resize(gray, (64, 36), interpolation=cv2.INTER_AREA), (9, 9), 0) + hist = cv2.calcHist([tiny], [0], None, [16], [0, 256]) prev, self._prev, self._scale = self._prev, current, scale + prev_hist, self._hist = self._hist, hist if prev is None or prev.shape != current.shape: - return {"dx": 0.0, "dy": 0.0, "response": 0.0, "estimated": False} + return {"dx": 0.0, "dy": 0.0, "response": 0.0, "estimated": False, "cut": False} + + cut = float(cv2.compareHist(prev_hist, hist, cv2.HISTCMP_CORREL)) < 0.5 if self._window is None or self._window.shape != current.shape: self._window = cv2.createHanningWindow( (current.shape[1], current.shape[0]), cv2.CV_32F ) - (dx, dy), response = cv2.phaseCorrelate(prev, current, self._window) - if response < self.min_response: - return {"dx": 0.0, "dy": 0.0, "response": float(response), "estimated": False} + # Copies: the stored frame is the next call's reference and must stay + # exactly as it was prepared. + (dx, dy), response = cv2.phaseCorrelate(prev.copy(), current.copy(), self._window) + if response < self.min_response or cut: + return {"dx": 0.0, "dy": 0.0, "response": float(response), "estimated": False, "cut": cut} # phaseCorrelate gives the shift that maps prev onto current, measured # on the downscaled copy; scale it back to full-resolution pixels. factor = 1.0 / scale if scale else 1.0 @@ -64,4 +81,5 @@ def update(self, image: np.ndarray) -> dict[str, float]: "dy": float(dy * factor), "response": float(response), "estimated": True, + "cut": False, } diff --git a/src/countbone/store/db.py b/src/countbone/store/db.py index 1b4b5bf..5b40a8b 100644 --- a/src/countbone/store/db.py +++ b/src/countbone/store/db.py @@ -1,120 +1,176 @@ -"""Count history. +"""The database. -SQLite, one file, no server. This is the table the analytics-layer plugins -read from: shrinkage, drift per SKU, discrepancy causes. Keeping it boring -means a customer can open it with any tool and audit us. +SQLite, one file, no server. Keeping it boring means a customer can open it +with any tool and audit us. The schema lives in store/schema.py as numbered +migrations; domain tables are reached through the repositories in +store/repos.py, mixed into the one Store class so every caller shares one +connection and one lock. """ from __future__ import annotations +import hashlib import json import sqlite3 import threading import time -from collections.abc import Iterable +from collections.abc import Iterable, Iterator +from contextlib import contextmanager from pathlib import Path from typing import Any from ..types import CountResult, ReviewItem +from .repos import AccessRepo, CatalogRepo, OpsRepo +from .schema import MIGRATIONS -SCHEMA = """ -CREATE TABLE IF NOT EXISTS runs ( - run_id TEXT PRIMARY KEY, - source TEXT NOT NULL, - started_at REAL NOT NULL, - finished_at REAL, - duration_s REAL, - frames_read INTEGER DEFAULT 0, - frames_used INTEGER DEFAULT 0, - frames_dropped INTEGER DEFAULT 0, - detections INTEGER DEFAULT 0, - tracks INTEGER DEFAULT 0, - total INTEGER DEFAULT 0, - overall_confidence REAL DEFAULT 0, - needs_review INTEGER DEFAULT 0, - config_fingerprint TEXT, - meta TEXT -); - -CREATE TABLE IF NOT EXISTS sku_counts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, - sku TEXT NOT NULL, - label TEXT, - count INTEGER NOT NULL, - expected INTEGER, - variance INTEGER, - confidence REAL, - evidence TEXT, - counted_at REAL NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_sku_counts_sku ON sku_counts(sku, counted_at); -CREATE INDEX IF NOT EXISTS idx_sku_counts_run ON sku_counts(run_id); - -CREATE TABLE IF NOT EXISTS reviews ( - review_id TEXT PRIMARY KEY, - run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, - sku TEXT NOT NULL, - reason TEXT, - confidence REAL, - frame_index INTEGER, - bbox TEXT, - crop_path TEXT, - status TEXT DEFAULT 'pending', - resolved_sku TEXT, - resolved_by TEXT, - resolved_at REAL, - created_at REAL NOT NULL, - meta TEXT -); -CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status, created_at); - -CREATE TABLE IF NOT EXISTS audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL, - kind TEXT NOT NULL, - payload TEXT, - created_at REAL NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_audit_run ON audit(run_id, created_at); -""" +GENESIS = "0" * 64 def _json(value: Any) -> str: return json.dumps(value, default=str) -class Store: +def _chain_hash(prev: str, subject: str, kind: str, payload: str, created_at: float, + actor: str | None) -> str: + body = "\x1f".join([prev, subject, kind, payload, repr(float(created_at)), actor or ""]) + return hashlib.sha256(body.encode("utf-8")).hexdigest() + + +class Store(AccessRepo, CatalogRepo, OpsRepo): """Thin, explicit data access. No ORM, no lazy objects.""" def __init__(self, path: str | Path = "countbone.db") -> None: self.path = str(path) if self.path != ":memory:": Path(self.path).parent.mkdir(parents=True, exist_ok=True) - self._lock = threading.Lock() + # Re-entrant: a repository method may call another inside one transaction. + self._lock = threading.RLock() # A single shared connection keeps :memory: databases usable from the - # API's worker threads; the lock serialises writes. - self._conn = sqlite3.connect(self.path, check_same_thread=False) + # API's worker threads; the lock serialises access. + self._conn = sqlite3.connect(self.path, check_same_thread=False, isolation_level=None) self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA journal_mode=WAL") self._conn.execute("PRAGMA foreign_keys=ON") + self._conn.execute("PRAGMA busy_timeout=5000") + self._migrate() + + def _migrate(self) -> None: + with self._lock: + current = self._conn.execute("PRAGMA user_version").fetchone()[0] + for version, script in enumerate(MIGRATIONS, start=1): + if version <= current: + continue + # One transaction per migration: DDL is transactional in + # SQLite, so a failed upgrade leaves the previous version intact. + self._conn.executescript( + f"BEGIN;\n{script}\nPRAGMA user_version = {version};\nCOMMIT;" + ) + + @property + def schema_version(self) -> int: with self._lock: - self._conn.executescript(SCHEMA) - self._conn.commit() + return int(self._conn.execute("PRAGMA user_version").fetchone()[0]) def close(self) -> None: with self._lock: self._conn.close() - # -- writes ---------------------------------------------------------- + # -- low-level helpers shared by the repositories -------------------- + @contextmanager + def _tx(self) -> Iterator[sqlite3.Connection]: + """A write transaction. Nested use joins the outer one.""" + with self._lock: + if self._conn.in_transaction: + yield self._conn + return + self._conn.execute("BEGIN IMMEDIATE") + try: + yield self._conn + except BaseException: + self._conn.execute("ROLLBACK") + raise + self._conn.execute("COMMIT") + + def _rows(self, sql: str, params: Iterable[Any] = ()) -> list[dict[str, Any]]: + with self._lock: + return [dict(r) for r in self._conn.execute(sql, tuple(params)).fetchall()] + + def _one(self, sql: str, params: Iterable[Any] = ()) -> dict[str, Any] | None: + rows = self._rows(sql, params) + return rows[0] if rows else None + + def _exec(self, sql: str, params: Iterable[Any] = ()) -> int: + with self._tx() as conn: + return conn.execute(sql, tuple(params)).rowcount + + # -- audit trail ------------------------------------------------------- + def add_audit(self, run_id: str, kind: str, payload: Any, actor: str | None = None) -> None: + """Append to the hash-chained trail. `run_id` is the subject's id.""" + body = _json(payload) + with self._tx() as conn: + last = conn.execute( + "SELECT row_hash FROM audit WHERE row_hash IS NOT NULL ORDER BY id DESC LIMIT 1" + ).fetchone() + prev = last["row_hash"] if last else GENESIS + now = time.time() + row_hash = _chain_hash(prev, run_id, kind, body, now, actor) + conn.execute( + """INSERT INTO audit (run_id, kind, payload, created_at, actor, prev_hash, row_hash) + VALUES (?,?,?,?,?,?,?)""", + (run_id, kind, body, now, actor, prev, row_hash), + ) + + def verify_audit_chain(self) -> dict[str, Any]: + """Recompute every link. Any edit, deletion or insertion shows up as + the first row whose stored hash no longer matches.""" + rows = self._rows("SELECT * FROM audit ORDER BY id") + prev = GENESIS + checked = legacy = 0 + for row in rows: + if row["row_hash"] is None: + legacy += 1 # written before the chain existed (schema v1) + continue + if row["prev_hash"] != prev: + return {"ok": False, "checked": checked, "legacy_rows": legacy, + "broken_at": row["id"], "problem": "a row before this one was removed or altered"} + expected = _chain_hash(prev, row["run_id"], row["kind"], row["payload"] or "null", + row["created_at"], row["actor"]) + if expected != row["row_hash"]: + return {"ok": False, "checked": checked, "legacy_rows": legacy, + "broken_at": row["id"], "problem": "this row was altered"} + prev = row["row_hash"] + checked += 1 + return {"ok": True, "checked": checked, "legacy_rows": legacy, "head": prev} + + def audit_trail(self, run_id: str) -> list[dict[str, Any]]: + rows = self._rows( + "SELECT * FROM audit WHERE run_id = ? ORDER BY id", (run_id,) + ) + for row in rows: + row["payload"] = json.loads(row["payload"] or "null") + return rows + + def recent_audit(self, limit: int = 100, kind: str | None = None) -> list[dict[str, Any]]: + if kind: + rows = self._rows("SELECT * FROM audit WHERE kind = ? ORDER BY id DESC LIMIT ?", + (kind, limit)) + else: + rows = self._rows("SELECT * FROM audit ORDER BY id DESC LIMIT ?", (limit,)) + for row in rows: + row["payload"] = json.loads(row["payload"] or "null") + return rows + + # -- runs -------------------------------------------------------------- def save_run(self, result: CountResult, config_fingerprint: str = "") -> None: now = time.time() - with self._lock: - self._conn.execute( + context = result.meta.get("context") or {} + with self._tx() as conn: + conn.execute( """INSERT OR REPLACE INTO runs (run_id, source, started_at, finished_at, duration_s, frames_read, frames_used, frames_dropped, detections, - tracks, total, overall_confidence, needs_review, config_fingerprint, meta) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + tracks, total, overall_confidence, needs_review, config_fingerprint, meta, + location, kind, created_by, walk_id, receipt_id, task_id, job_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( result.run_id, result.source, @@ -131,10 +187,17 @@ def save_run(self, result: CountResult, config_fingerprint: str = "") -> None: int(result.needs_review), config_fingerprint, _json({"warnings": result.warnings, **result.meta}), + context.get("location"), + context.get("kind") or "count", + context.get("created_by"), + context.get("walk_id"), + context.get("receipt_id"), + context.get("task_id"), + context.get("job_id"), ), ) - self._conn.execute("DELETE FROM sku_counts WHERE run_id = ?", (result.run_id,)) - self._conn.executemany( + conn.execute("DELETE FROM sku_counts WHERE run_id = ?", (result.run_id,)) + conn.executemany( """INSERT INTO sku_counts (run_id, sku, label, count, expected, variance, confidence, evidence, counted_at) VALUES (?,?,?,?,?,?,?,?,?)""", @@ -153,7 +216,7 @@ def save_run(self, result: CountResult, config_fingerprint: str = "") -> None: for c in result.counts ], ) - self._conn.executemany( + conn.executemany( """INSERT OR REPLACE INTO reviews (review_id, run_id, sku, reason, confidence, frame_index, bbox, crop_path, status, created_at, meta) VALUES (?,?,?,?,?,?,?,?,?,?,?)""", @@ -174,46 +237,76 @@ def save_run(self, result: CountResult, config_fingerprint: str = "") -> None: for r in result.reviews ], ) - self._conn.commit() - - def add_audit(self, run_id: str, kind: str, payload: Any) -> None: - with self._lock: - self._conn.execute( - "INSERT INTO audit (run_id, kind, payload, created_at) VALUES (?,?,?,?)", - (run_id, kind, _json(payload), time.time()), - ) - self._conn.commit() def resolve_review( - self, review_id: str, status: str, resolved_sku: str | None, resolved_by: str + self, + review_id: str, + status: str, + resolved_sku: str | None, + resolved_by: str, + resolved_count: int | None = None, ) -> bool: + """Record a reviewer's decision. + + A recount goes into the review's meta rather than over the run's + count: the machine's answer and the human's correction are both + evidence, and the audit trail needs to show which was which. + """ if status not in {"accepted", "rejected", "corrected", "pending"}: raise ValueError(f"invalid review status {status!r}") - with self._lock: - cur = self._conn.execute( + now = time.time() + with self._tx() as conn: + row = conn.execute( + "SELECT run_id, sku, meta FROM reviews WHERE review_id = ?", (review_id,) + ).fetchone() + if row is None: + return False + meta = json.loads(row["meta"] or "{}") + if resolved_count is not None: + meta["resolved_count"] = int(resolved_count) + else: + meta.pop("resolved_count", None) + conn.execute( """UPDATE reviews - SET status = ?, resolved_sku = ?, resolved_by = ?, resolved_at = ? + SET status = ?, resolved_sku = ?, resolved_by = ?, resolved_at = ?, + meta = ? WHERE review_id = ?""", - (status, resolved_sku, resolved_by, time.time(), review_id), + (status, resolved_sku, resolved_by, now, _json(meta), review_id), ) - self._conn.commit() - return cur.rowcount > 0 - - # -- reads ----------------------------------------------------------- - def _rows(self, sql: str, params: Iterable[Any] = ()) -> list[dict[str, Any]]: - with self._lock: - return [dict(r) for r in self._conn.execute(sql, tuple(params)).fetchall()] + self.add_audit( + row["run_id"], + "review_decision", + { + "review_id": review_id, + "sku": row["sku"], + "status": status, + "resolved_sku": resolved_sku, + "resolved_count": resolved_count, + "resolved_by": resolved_by, + }, + actor=resolved_by, + ) + return True - def list_runs(self, limit: int = 50) -> list[dict[str, Any]]: + # -- reads ------------------------------------------------------------- + def list_runs(self, limit: int = 50, location: str | None = None, + kind: str | None = None) -> list[dict[str, Any]]: + clauses, params = [], [] + if location: + clauses.append("location = ?") + params.append(location) + if kind: + clauses.append("kind = ?") + params.append(kind) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" return self._rows( - "SELECT * FROM runs ORDER BY started_at DESC LIMIT ?", (limit,) + f"SELECT * FROM runs {where} ORDER BY started_at DESC LIMIT ?", (*params, limit) ) def get_run(self, run_id: str) -> dict[str, Any] | None: - rows = self._rows("SELECT * FROM runs WHERE run_id = ?", (run_id,)) - if not rows: + run = self._one("SELECT * FROM runs WHERE run_id = ?", (run_id,)) + if run is None: return None - run = rows[0] run["meta"] = json.loads(run["meta"] or "{}") run["counts"] = self._rows( "SELECT * FROM sku_counts WHERE run_id = ? ORDER BY sku", (run_id,) @@ -233,21 +326,22 @@ def reviews(self, status: str | None = "pending", limit: int = 200) -> list[dict ) return self._rows("SELECT * FROM reviews ORDER BY created_at DESC LIMIT ?", (limit,)) + def review(self, review_id: str) -> dict[str, Any] | None: + row = self._one("SELECT * FROM reviews WHERE review_id = ?", (review_id,)) + if row: + row["meta"] = json.loads(row["meta"] or "{}") + row["bbox"] = json.loads(row["bbox"] or "null") + return row + def sku_history(self, sku: str, limit: int = 100) -> list[dict[str, Any]]: return self._rows( - """SELECT run_id, count, expected, variance, confidence, counted_at - FROM sku_counts WHERE sku = ? ORDER BY counted_at DESC LIMIT ?""", + """SELECT sc.run_id, sc.count, sc.expected, sc.variance, sc.confidence, + sc.counted_at, r.location + FROM sku_counts sc JOIN runs r ON r.run_id = sc.run_id + WHERE sc.sku = ? ORDER BY sc.counted_at DESC LIMIT ?""", (sku, limit), ) - def audit_trail(self, run_id: str) -> list[dict[str, Any]]: - rows = self._rows( - "SELECT * FROM audit WHERE run_id = ? ORDER BY created_at", (run_id,) - ) - for row in rows: - row["payload"] = json.loads(row["payload"] or "null") - return rows - def review_items(self, run_id: str) -> list[ReviewItem]: out = [] for row in self._rows("SELECT * FROM reviews WHERE run_id = ?", (run_id,)): @@ -264,6 +358,7 @@ def review_items(self, run_id: str) -> list[ReviewItem]: crop_path=row["crop_path"], status=row["status"], resolved_sku=row["resolved_sku"], + meta=json.loads(row["meta"] or "{}"), ) ) return out diff --git a/src/countbone/store/repos.py b/src/countbone/store/repos.py new file mode 100644 index 0000000..2668a0b --- /dev/null +++ b/src/countbone/store/repos.py @@ -0,0 +1,771 @@ +"""Repositories: the platform's tables, one mixin per area. + +Mixed into store.db.Store, which supplies the connection, the lock and the +helpers (_tx, _rows, _one, _exec). Methods here are plain data access; the +rules about what may happen when live in countbone.ops. +""" + +from __future__ import annotations + +import json +import time +from typing import TYPE_CHECKING, Any + +import numpy as np + +from ..types import new_id + +if TYPE_CHECKING: # pragma: no cover + import sqlite3 + from collections.abc import Iterable + from contextlib import AbstractContextManager + + +def _j(value: Any) -> str | None: + return None if value is None else json.dumps(value, default=str) + + +def _unj(value: Any, default: Any = None) -> Any: + if value is None or value == "": + return default + try: + return json.loads(value) + except (TypeError, ValueError): + return default + + +class _Base: + """What the mixins expect from Store (declared for readers and linters).""" + + if TYPE_CHECKING: # pragma: no cover + def _tx(self) -> AbstractContextManager[sqlite3.Connection]: ... + def _rows(self, sql: str, params: Iterable[Any] = ()) -> list[dict[str, Any]]: ... + def _one(self, sql: str, params: Iterable[Any] = ()) -> dict[str, Any] | None: ... + def _exec(self, sql: str, params: Iterable[Any] = ()) -> int: ... + def add_audit(self, run_id: str, kind: str, payload: Any, actor: str | None = None) -> None: ... + + def _update(self, table: str, key_col: str, key: str, fields: dict[str, Any], + allowed: set[str], json_cols: set[str] = frozenset()) -> bool: + fields = {k: v for k, v in fields.items() if k in allowed} + if not fields: + return self._one(f"SELECT 1 FROM {table} WHERE {key_col} = ?", (key,)) is not None + cols = ", ".join(f"{k} = ?" for k in fields) + values = [_j(v) if k in json_cols else v for k, v in fields.items()] + return self._exec(f"UPDATE {table} SET {cols} WHERE {key_col} = ?", (*values, key)) > 0 + + +# -- people and access ----------------------------------------------------- +class AccessRepo(_Base): + def count_users(self) -> int: + row = self._one("SELECT COUNT(*) AS n FROM users") + return int(row["n"]) if row else 0 + + def create_user(self, username: str, pw_hash: str, role: str, + display_name: str | None = None, created_by: str | None = None) -> dict[str, Any]: + user_id = new_id("usr") + self._exec( + """INSERT INTO users (user_id, username, display_name, role, pw_hash, created_at, created_by) + VALUES (?,?,?,?,?,?,?)""", + (user_id, username.strip(), display_name or username.strip(), role, pw_hash, + time.time(), created_by), + ) + return self.get_user(user_id) # type: ignore[return-value] + + def get_user(self, user_id: str) -> dict[str, Any] | None: + return self._one("SELECT * FROM users WHERE user_id = ?", (user_id,)) + + def user_by_username(self, username: str) -> dict[str, Any] | None: + return self._one("SELECT * FROM users WHERE username = ?", (username.strip(),)) + + def list_users(self) -> list[dict[str, Any]]: + return self._rows("SELECT * FROM users ORDER BY disabled, username") + + def update_user(self, user_id: str, **fields: Any) -> bool: + return self._update("users", "user_id", user_id, fields, + {"display_name", "role", "pw_hash", "disabled", "last_login_at"}) + + def create_session(self, token_hash: str, user_id: str, expires_at: float, + client: str | None = None) -> None: + now = time.time() + self._exec( + """INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at, last_seen_at, client) + VALUES (?,?,?,?,?,?)""", + (token_hash, user_id, now, expires_at, now, (client or "")[:200]), + ) + + def session_user(self, token_hash: str) -> dict[str, Any] | None: + row = self._one( + """SELECT u.*, s.expires_at AS session_expires_at, s.last_seen_at AS session_seen_at + FROM auth_sessions s JOIN users u ON u.user_id = s.user_id + WHERE s.token_hash = ?""", + (token_hash,), + ) + now = time.time() + if row is None or row["session_expires_at"] < now or row["disabled"]: + return None + # Touch at most once a minute: every request would be a write otherwise. + if now - (row["session_seen_at"] or 0) > 60: + self._exec("UPDATE auth_sessions SET last_seen_at = ? WHERE token_hash = ?", + (now, token_hash)) + return row + + def delete_session(self, token_hash: str) -> None: + self._exec("DELETE FROM auth_sessions WHERE token_hash = ?", (token_hash,)) + + def delete_user_sessions(self, user_id: str) -> None: + self._exec("DELETE FROM auth_sessions WHERE user_id = ?", (user_id,)) + + def purge_expired_sessions(self) -> int: + return self._exec("DELETE FROM auth_sessions WHERE expires_at < ?", (time.time(),)) + + def create_api_key(self, name: str, key_hash: str, role: str, + created_by: str | None) -> dict[str, Any]: + key_id = new_id("key") + self._exec( + """INSERT INTO api_keys (key_id, name, key_hash, role, created_by, created_at) + VALUES (?,?,?,?,?,?)""", + (key_id, name, key_hash, role, created_by, time.time()), + ) + return self._one("SELECT key_id, name, role, created_by, created_at, revoked FROM api_keys " + "WHERE key_id = ?", (key_id,)) # type: ignore[return-value] + + def api_key_by_hash(self, key_hash: str) -> dict[str, Any] | None: + row = self._one("SELECT * FROM api_keys WHERE key_hash = ? AND revoked = 0", (key_hash,)) + if row and time.time() - (row["last_used_at"] or 0) > 60: + self._exec("UPDATE api_keys SET last_used_at = ? WHERE key_id = ?", + (time.time(), row["key_id"])) + return row + + def list_api_keys(self) -> list[dict[str, Any]]: + return self._rows("SELECT key_id, name, role, created_by, created_at, last_used_at, revoked " + "FROM api_keys ORDER BY revoked, created_at DESC") + + def revoke_api_key(self, key_id: str) -> bool: + return self._exec("UPDATE api_keys SET revoked = 1 WHERE key_id = ?", (key_id,)) > 0 + + +# -- product catalog --------------------------------------------------------- +class CatalogRepo(_Base): + def list_skus(self, include_archived: bool = False) -> list[dict[str, Any]]: + where = "" if include_archived else "WHERE s.archived = 0" + rows = self._rows( + f"""SELECT s.*, (SELECT COUNT(*) FROM sku_photos p WHERE p.sku = s.sku) AS photos + FROM skus s {where} ORDER BY s.sku""" + ) + for r in rows: + r["hue"] = _unj(r["hue"]) + r["barcodes"] = _unj(r["barcodes"], []) + return rows + + def get_sku(self, sku: str) -> dict[str, Any] | None: + row = self._one("SELECT * FROM skus WHERE sku = ?", (sku,)) + if row: + row["hue"] = _unj(row["hue"]) + row["barcodes"] = _unj(row["barcodes"], []) + return row + + def upsert_sku(self, sku: str, fields: dict[str, Any], actor: str | None) -> dict[str, Any]: + existing = self.get_sku(sku) + with self._tx(): + if existing is None: + self._exec( + """INSERT INTO skus (sku, label, unit_value, hue, achromatic, min_saturation, + barcodes, created_at, created_by) + VALUES (?,?,?,?,?,?,?,?,?)""", + (sku, fields.get("label") or sku, float(fields.get("unit_value") or 0), + _j(fields.get("hue")), int(bool(fields.get("achromatic"))), + int(fields.get("min_saturation") or 60), _j(fields.get("barcodes") or []), + time.time(), actor), + ) + else: + self._update("skus", "sku", sku, fields, + {"label", "unit_value", "hue", "achromatic", "min_saturation", + "barcodes", "archived"}, json_cols={"hue", "barcodes"}) + return self.get_sku(sku) # type: ignore[return-value] + + def add_photo(self, sku: str, path: str, source: str, created_by: str | None, + vectors: list[tuple[str, np.ndarray]], version: int, + run_id: str | None = None, review_id: str | None = None, + photo_id: str | None = None) -> str: + photo_id = photo_id or new_id("pho") + with self._tx() as conn: + conn.execute( + """INSERT INTO sku_photos (photo_id, sku, path, source, run_id, review_id, + created_at, created_by) VALUES (?,?,?,?,?,?,?,?)""", + (photo_id, sku, path, source, run_id, review_id, time.time(), created_by), + ) + conn.executemany( + "INSERT INTO sku_vectors (photo_id, sku, kind, version, vector) VALUES (?,?,?,?,?)", + [(photo_id, sku, kind, version, np.asarray(v, np.float32).tobytes()) + for kind, v in vectors], + ) + return photo_id + + def list_photos(self, sku: str | None = None) -> list[dict[str, Any]]: + if sku: + return self._rows("SELECT * FROM sku_photos WHERE sku = ? ORDER BY created_at", (sku,)) + return self._rows("SELECT * FROM sku_photos ORDER BY sku, created_at") + + def get_photo(self, photo_id: str) -> dict[str, Any] | None: + return self._one("SELECT * FROM sku_photos WHERE photo_id = ?", (photo_id,)) + + def delete_photo(self, photo_id: str) -> bool: + return self._exec("DELETE FROM sku_photos WHERE photo_id = ?", (photo_id,)) > 0 + + def all_vectors(self) -> list[dict[str, Any]]: + rows = self._rows( + """SELECT v.photo_id, v.sku, v.kind, v.version, v.vector + FROM sku_vectors v JOIN skus s ON s.sku = v.sku + WHERE s.archived = 0 ORDER BY v.id""" + ) + for r in rows: + r["vector"] = np.frombuffer(r["vector"], dtype=np.float32) + return rows + + def replace_vectors(self, photo_id: str, sku: str, + vectors: list[tuple[str, np.ndarray]], version: int) -> None: + with self._tx() as conn: + conn.execute("DELETE FROM sku_vectors WHERE photo_id = ?", (photo_id,)) + conn.executemany( + "INSERT INTO sku_vectors (photo_id, sku, kind, version, vector) VALUES (?,?,?,?,?)", + [(photo_id, sku, kind, version, np.asarray(v, np.float32).tobytes()) + for kind, v in vectors], + ) + + def stale_photo_ids(self, version: int) -> list[str]: + return [r["photo_id"] for r in self._rows( + "SELECT DISTINCT photo_id FROM sku_vectors WHERE version != ?", (version,))] + + +# -- operations -------------------------------------------------------------- +_TASK_COLS = {"status", "assignee", "due_at", "result", "counted", "variance", "value_at_risk", + "updated_at", "closed_at", "closed_by", "reason", "expected"} +_ADJ_COLS = {"status", "rule", "decided_by", "decided_at", "note", "integration", "external_ref", + "post_error", "posted_at", "counted_qty", "delta", "value", "system_qty", "task_id", + "run_id", "updated_at", "unit_value"} + + +class OpsRepo(_Base): + # settings ------------------------------------------------------------ + def get_setting(self, key: str, default: Any = None) -> Any: + row = self._one("SELECT value FROM settings WHERE key = ?", (key,)) + return _unj(row["value"], default) if row else default + + def set_setting(self, key: str, value: Any, actor: str | None) -> None: + self._exec( + """INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, + updated_at = excluded.updated_at, updated_by = excluded.updated_by""", + (key, _j(value), time.time(), actor), + ) + + # sites ----------------------------------------------------------------- + def create_site(self, name: str, customer: str | None = None, address: str | None = None, + timezone: str | None = None) -> dict[str, Any]: + site_id = new_id("site") + self._exec("INSERT INTO sites (site_id, name, customer, address, timezone, created_at) " + "VALUES (?,?,?,?,?,?)", (site_id, name, customer, address, timezone, time.time())) + return self.get_site(site_id) # type: ignore[return-value] + + def get_site(self, site_id: str) -> dict[str, Any] | None: + return self._one("SELECT * FROM sites WHERE site_id = ?", (site_id,)) + + def list_sites(self) -> list[dict[str, Any]]: + return self._rows("SELECT * FROM sites ORDER BY name") + + # locations --------------------------------------------------------------- + def upsert_location(self, code: str, name: str | None = None, site_id: str | None = None, + zone: str | None = None, actor: str | None = None) -> dict[str, Any]: + self._exec( + """INSERT INTO locations (code, name, site_id, zone, created_at, created_by) + VALUES (?,?,?,?,?,?) + ON CONFLICT(code) DO UPDATE SET + name = COALESCE(excluded.name, locations.name), + site_id = COALESCE(excluded.site_id, locations.site_id), + zone = COALESCE(excluded.zone, locations.zone), + archived = 0""", + (code, name, site_id, zone, time.time(), actor), + ) + return self.get_location(code) # type: ignore[return-value] + + def get_location(self, code: str) -> dict[str, Any] | None: + return self._one("SELECT * FROM locations WHERE code = ?", (code,)) + + def list_locations(self, site_id: str | None = None, + include_archived: bool = False) -> list[dict[str, Any]]: + clauses, params = [], [] + if site_id: + clauses.append("l.site_id = ?") + params.append(site_id) + if not include_archived: + clauses.append("l.archived = 0") + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + return self._rows( + f"""SELECT l.*, + (SELECT COUNT(*) FROM expected_stock e WHERE e.location = l.code) AS skus_expected, + (SELECT MAX(started_at) FROM runs r WHERE r.location = l.code) AS last_counted_at, + (SELECT run_id FROM runs r WHERE r.location = l.code + ORDER BY started_at DESC LIMIT 1) AS last_run_id + FROM locations l {where} ORDER BY l.code""", + params, + ) + + def archive_location(self, code: str) -> bool: + return self._exec("UPDATE locations SET archived = 1 WHERE code = ?", (code,)) > 0 + + # book stock --------------------------------------------------------------- + def set_expected(self, location: str, quantities: dict[str, int], source: str, + actor: str | None, replace: bool = False) -> None: + now = time.time() + with self._tx() as conn: + if replace: + conn.execute("DELETE FROM expected_stock WHERE location = ?", (location,)) + conn.executemany( + """INSERT INTO expected_stock (location, sku, qty, source, updated_at, updated_by) + VALUES (?,?,?,?,?,?) + ON CONFLICT(location, sku) DO UPDATE SET qty = excluded.qty, + source = excluded.source, updated_at = excluded.updated_at, + updated_by = excluded.updated_by""", + [(location, sku, int(q), source, now, actor) for sku, q in quantities.items()], + ) + + def expected_for(self, location: str) -> dict[str, int]: + return {r["sku"]: int(r["qty"]) for r in self._rows( + "SELECT sku, qty FROM expected_stock WHERE location = ?", (location,))} + + def expected_rows(self, location: str | None = None) -> list[dict[str, Any]]: + if location: + return self._rows("SELECT * FROM expected_stock WHERE location = ? ORDER BY sku", + (location,)) + return self._rows("SELECT * FROM expected_stock ORDER BY location, sku") + + # planograms --------------------------------------------------------------- + def set_planogram(self, location: str, rows: list[list[str]], actor: str | None) -> None: + self._exec( + """INSERT INTO planograms (location, rows, updated_at, updated_by) VALUES (?,?,?,?) + ON CONFLICT(location) DO UPDATE SET rows = excluded.rows, + updated_at = excluded.updated_at, updated_by = excluded.updated_by""", + (location, _j(rows), time.time(), actor), + ) + + def get_planogram(self, location: str) -> list[list[str]] | None: + row = self._one("SELECT rows FROM planograms WHERE location = ?", (location,)) + return _unj(row["rows"]) if row else None + + # durable job queue ---------------------------------------------------------- + def enqueue_job(self, run_id: str, source: str, params: dict[str, Any], + filename: str | None, created_by: str | None) -> None: + now = time.time() + self._exec( + """INSERT INTO jobs (run_id, source, params, status, filename, created_by, + created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)""", + (run_id, source, _j(params), "queued", filename, created_by, now, now), + ) + + def set_job(self, run_id: str, status: str, error: str | None = None, + bump_attempts: bool = False) -> None: + self._exec( + f"""UPDATE jobs SET status = ?, error = ?, updated_at = ? + {", attempts = attempts + 1" if bump_attempts else ""} + WHERE run_id = ?""", + (status, error, time.time(), run_id), + ) + + def get_job(self, run_id: str) -> dict[str, Any] | None: + row = self._one("SELECT * FROM jobs WHERE run_id = ?", (run_id,)) + if row: + row["params"] = _unj(row["params"], {}) + return row + + def unfinished_jobs(self) -> list[dict[str, Any]]: + rows = self._rows("SELECT * FROM jobs WHERE status IN ('queued', 'running') " + "ORDER BY created_at") + for r in rows: + r["params"] = _unj(r["params"], {}) + return rows + + def list_jobs(self, limit: int = 50) -> list[dict[str, Any]]: + rows = self._rows("SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?", (limit,)) + for r in rows: + r["params"] = _unj(r["params"], {}) + return rows + + # resumable uploads ------------------------------------------------------------ + def create_upload(self, client_id: str | None, filename: str | None, size: int, + sha256: str | None, params: dict[str, Any], path: str, + created_by: str | None) -> dict[str, Any]: + upload_id = new_id("upl") + now = time.time() + self._exec( + """INSERT INTO uploads (upload_id, client_id, filename, size, received, sha256, params, + path, status, created_by, created_at, updated_at) + VALUES (?,?,?,?,0,?,?,?,'open',?,?,?)""", + (upload_id, client_id, filename, size, sha256, _j(params), path, created_by, now, now), + ) + return self.get_upload(upload_id) # type: ignore[return-value] + + def get_upload(self, upload_id: str) -> dict[str, Any] | None: + row = self._one("SELECT * FROM uploads WHERE upload_id = ?", (upload_id,)) + if row: + row["params"] = _unj(row["params"], {}) + return row + + def upload_by_client(self, client_id: str) -> dict[str, Any] | None: + row = self._one("SELECT * FROM uploads WHERE client_id = ?", (client_id,)) + if row: + row["params"] = _unj(row["params"], {}) + return row + + def set_upload(self, upload_id: str, **fields: Any) -> bool: + fields["updated_at"] = time.time() + return self._update("uploads", "upload_id", upload_id, fields, + {"received", "status", "run_id", "updated_at"}) + + def stale_uploads(self, before: float) -> list[dict[str, Any]]: + """Open uploads nobody has added to since `before`: abandoned.""" + return self._rows("SELECT * FROM uploads WHERE status = 'open' AND updated_at < ?", (before,)) + + # walks: several videos of one place ---------------------------------------------- + def create_walk(self, location: str | None, name: str | None, + actor: str | None) -> dict[str, Any]: + walk_id = new_id("walk") + self._exec("INSERT INTO walks (walk_id, location, name, created_by, created_at) " + "VALUES (?,?,?,?,?)", (walk_id, location, name, actor, time.time())) + return self.get_walk(walk_id) # type: ignore[return-value] + + def get_walk(self, walk_id: str) -> dict[str, Any] | None: + return self._one("SELECT * FROM walks WHERE walk_id = ?", (walk_id,)) + + def list_walks(self, location: str | None = None, limit: int = 50) -> list[dict[str, Any]]: + if location: + return self._rows( + """SELECT w.*, (SELECT COUNT(*) FROM runs r WHERE r.walk_id = w.walk_id) AS runs + FROM walks w WHERE location = ? ORDER BY created_at DESC LIMIT ?""", + (location, limit)) + return self._rows( + """SELECT w.*, (SELECT COUNT(*) FROM runs r WHERE r.walk_id = w.walk_id) AS runs + FROM walks w ORDER BY created_at DESC LIMIT ?""", (limit,)) + + def close_walk(self, walk_id: str) -> bool: + return self._exec("UPDATE walks SET status = 'closed', closed_at = ? WHERE walk_id = ?", + (time.time(), walk_id)) > 0 + + def walk_runs(self, walk_id: str) -> list[dict[str, Any]]: + return self._rows("SELECT run_id FROM runs WHERE walk_id = ? ORDER BY started_at", + (walk_id,)) + + # tasks --------------------------------------------------------------------------- + def create_task(self, **fields: Any) -> dict[str, Any]: + task_id = new_id("task") + now = time.time() + self._exec( + """INSERT INTO tasks (task_id, kind, status, location, sku, run_id, reason, expected, + counted, variance, value_at_risk, assignee, due_at, created_at, created_by, + updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (task_id, fields.get("kind", "recount"), fields.get("status", "open"), + fields.get("location"), fields.get("sku"), fields.get("run_id"), + fields.get("reason"), fields.get("expected"), fields.get("counted"), + fields.get("variance"), fields.get("value_at_risk"), fields.get("assignee"), + fields.get("due_at"), now, fields.get("created_by"), now), + ) + return self.get_task(task_id) # type: ignore[return-value] + + def get_task(self, task_id: str) -> dict[str, Any] | None: + row = self._one( + """SELECT t.*, u.display_name AS assignee_name FROM tasks t + LEFT JOIN users u ON u.user_id = t.assignee WHERE task_id = ?""", (task_id,)) + if row: + row["result"] = _unj(row["result"]) + return row + + def list_tasks(self, status: str | None = None, assignee: str | None = None, + location: str | None = None, limit: int = 200, + for_user: str | None = None) -> list[dict[str, Any]]: + """`for_user`: what that person can pick up: tasks assigned to them, and + unassigned ones that are not a recount of their own run.""" + clauses, params = [], [] + if status: + clauses.append("t.status = ?") + params.append(status) + if assignee: + clauses.append("t.assignee = ?") + params.append(assignee) + if for_user: + clauses.append("(t.assignee = ? OR (t.assignee IS NULL AND NOT EXISTS (" + "SELECT 1 FROM runs r WHERE r.run_id = t.run_id AND r.created_by = ?)))") + params.extend([for_user, for_user]) + if location: + clauses.append("t.location = ?") + params.append(location) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + rows = self._rows( + f"""SELECT t.*, u.display_name AS assignee_name FROM tasks t + LEFT JOIN users u ON u.user_id = t.assignee {where} + ORDER BY CASE t.status WHEN 'open' THEN 0 WHEN 'escalated' THEN 1 ELSE 2 END, + COALESCE(t.due_at, 9e18), t.created_at DESC LIMIT ?""", + (*params, limit), + ) + for r in rows: + r["result"] = _unj(r["result"]) + return rows + + def open_task_for(self, location: str | None, sku: str) -> dict[str, Any] | None: + row = self._one( + """SELECT task_id FROM tasks WHERE status IN ('open', 'escalated') + AND sku = ? AND COALESCE(location, '') = COALESCE(?, '') + ORDER BY created_at DESC LIMIT 1""", + (sku, location)) + return self.get_task(row["task_id"]) if row else None + + def update_task(self, task_id: str, **fields: Any) -> bool: + fields["updated_at"] = time.time() + return self._update("tasks", "task_id", task_id, fields, _TASK_COLS, json_cols={"result"}) + + # adjustments --------------------------------------------------------------------- + def create_adjustment(self, **fields: Any) -> dict[str, Any]: + adjustment_id = new_id("adj") + now = time.time() + self._exec( + """INSERT INTO adjustments (adjustment_id, location, sku, system_qty, counted_qty, delta, + unit_value, value, run_id, task_id, status, rule, note, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + (adjustment_id, fields.get("location"), fields["sku"], fields.get("system_qty"), + fields["counted_qty"], fields["delta"], fields.get("unit_value", 0.0), + fields.get("value", 0.0), fields.get("run_id"), fields.get("task_id"), + fields.get("status", "proposed"), fields.get("rule"), fields.get("note"), now, now), + ) + return self.get_adjustment(adjustment_id) # type: ignore[return-value] + + def get_adjustment(self, adjustment_id: str) -> dict[str, Any] | None: + return self._one( + """SELECT a.*, u.display_name AS decided_by_name FROM adjustments a + LEFT JOIN users u ON u.user_id = a.decided_by WHERE adjustment_id = ?""", + (adjustment_id,)) + + def list_adjustments(self, status: str | None = None, location: str | None = None, + since: float | None = None, until: float | None = None, + limit: int = 500) -> list[dict[str, Any]]: + clauses, params = [], [] + if status: + statuses = status.split(",") + clauses.append(f"a.status IN ({','.join('?' * len(statuses))})") + params.extend(statuses) + if location: + clauses.append("a.location = ?") + params.append(location) + if since is not None: + clauses.append("a.created_at >= ?") + params.append(since) + if until is not None: + clauses.append("a.created_at < ?") + params.append(until) + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + return self._rows( + f"""SELECT a.*, u.display_name AS decided_by_name FROM adjustments a + LEFT JOIN users u ON u.user_id = a.decided_by {where} + ORDER BY ABS(a.value) DESC, a.created_at DESC LIMIT ?""", + (*params, limit), + ) + + def open_adjustment_for(self, location: str | None, sku: str) -> dict[str, Any] | None: + return self._one( + """SELECT * FROM adjustments WHERE status IN ('blocked', 'proposed') + AND sku = ? AND COALESCE(location, '') = COALESCE(?, '') + ORDER BY created_at DESC LIMIT 1""", + (sku, location)) + + def decided_adjustment_for(self, location: str | None, sku: str, + run_id: str) -> dict[str, Any] | None: + return self._one( + """SELECT * FROM adjustments WHERE status IN ('approved', 'posted', 'failed', 'rejected') + AND sku = ? AND COALESCE(location, '') = COALESCE(?, '') AND run_id = ? + LIMIT 1""", + (sku, location, run_id)) + + def update_adjustment(self, adjustment_id: str, **fields: Any) -> bool: + fields["updated_at"] = time.time() + return self._update("adjustments", "adjustment_id", adjustment_id, fields, _ADJ_COLS) + + # receipts ------------------------------------------------------------------------ + def create_receipt(self, po_number: str, lines: dict[str, dict[str, Any]], + supplier: str | None, dock: str | None, source: str, + actor: str | None, note: str | None = None) -> dict[str, Any]: + receipt_id = new_id("rcpt") + now = time.time() + with self._tx() as conn: + conn.execute( + """INSERT INTO receipts (receipt_id, po_number, supplier, dock, status, source, note, + created_by, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)""", + (receipt_id, po_number, supplier, dock, "open", source, note, actor, now, now), + ) + conn.executemany( + """INSERT INTO receipt_lines (receipt_id, sku, expected_qty, unit_cost) + VALUES (?,?,?,?)""", + [(receipt_id, sku, int(v["qty"]), float(v.get("unit_cost") or 0)) + for sku, v in lines.items()], + ) + return self.get_receipt(receipt_id) # type: ignore[return-value] + + def get_receipt(self, receipt_id: str) -> dict[str, Any] | None: + row = self._one("SELECT * FROM receipts WHERE receipt_id = ?", (receipt_id,)) + if row is None: + return None + row["lines"] = self._rows( + "SELECT * FROM receipt_lines WHERE receipt_id = ? ORDER BY sku", (receipt_id,)) + row["runs"] = [r["run_id"] for r in self._rows( + "SELECT run_id FROM runs WHERE receipt_id = ? ORDER BY started_at", (receipt_id,))] + return row + + def list_receipts(self, status: str | None = None, limit: int = 100) -> list[dict[str, Any]]: + where, params = ("WHERE status = ?", [status]) if status else ("", []) + return self._rows( + f"""SELECT r.*, + (SELECT COALESCE(SUM(expected_qty), 0) FROM receipt_lines l + WHERE l.receipt_id = r.receipt_id) AS expected_units, + (SELECT SUM(received_qty) FROM receipt_lines l + WHERE l.receipt_id = r.receipt_id) AS received_units + FROM receipts r {where} ORDER BY created_at DESC LIMIT ?""", + (*params, limit)) + + def update_receipt(self, receipt_id: str, **fields: Any) -> bool: + fields["updated_at"] = time.time() + return self._update("receipts", "receipt_id", receipt_id, fields, + {"status", "note", "closed_at", "closed_by", "updated_at", "dock", + "supplier"}) + + def set_received(self, receipt_id: str, received: dict[str, int]) -> None: + with self._tx() as conn: + for sku, qty in received.items(): + cur = conn.execute( + "UPDATE receipt_lines SET received_qty = ? WHERE receipt_id = ? AND sku = ?", + (int(qty), receipt_id, sku)) + if cur.rowcount == 0: + # Delivered but never ordered: a line of its own. + conn.execute( + """INSERT INTO receipt_lines (receipt_id, sku, expected_qty, received_qty) + VALUES (?,?,0,?)""", (receipt_id, sku, int(qty))) + conn.execute( + "UPDATE receipt_lines SET received_qty = 0 WHERE receipt_id = ? AND received_qty IS NULL", + (receipt_id,)) + + # claims -------------------------------------------------------------------------- + def create_claim(self, **fields: Any) -> dict[str, Any]: + claim_id = new_id("clm") + now = time.time() + self._exec( + """INSERT INTO claims (claim_id, kind, counterparty, status, receipt_id, run_ids, amount, + currency, note, created_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + (claim_id, fields["kind"], fields.get("counterparty"), "draft", + fields.get("receipt_id"), _j(fields.get("run_ids") or []), + float(fields.get("amount") or 0), fields.get("currency") or "USD", + fields.get("note"), fields.get("created_by"), now, now), + ) + return self.get_claim(claim_id) # type: ignore[return-value] + + def get_claim(self, claim_id: str) -> dict[str, Any] | None: + row = self._one("SELECT * FROM claims WHERE claim_id = ?", (claim_id,)) + if row: + row["run_ids"] = _unj(row["run_ids"], []) + return row + + def list_claims(self, status: str | None = None, limit: int = 100) -> list[dict[str, Any]]: + where, params = ("WHERE status = ?", [status]) if status else ("", []) + rows = self._rows(f"SELECT * FROM claims {where} ORDER BY created_at DESC LIMIT ?", + (*params, limit)) + for r in rows: + r["run_ids"] = _unj(r["run_ids"], []) + return rows + + def update_claim(self, claim_id: str, **fields: Any) -> bool: + fields["updated_at"] = time.time() + return self._update("claims", "claim_id", claim_id, fields, + {"status", "counterparty", "amount", "recovered_amount", "note", + "pack_path", "pack_sha256", "updated_at", "currency"}) + + # service jobs ---------------------------------------------------------------------- + def create_service_job(self, **fields: Any) -> dict[str, Any]: + job_id = new_id("svc") + now = time.time() + self._exec( + """INSERT INTO service_jobs (job_id, site_id, title, scheduled_for, crew, locations, + status, data_consent, notes, created_by, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + (job_id, fields.get("site_id"), fields.get("title"), fields.get("scheduled_for"), + _j(fields.get("crew") or []), _j(fields.get("locations") or []), "planned", + int(bool(fields.get("data_consent"))), fields.get("notes"), + fields.get("created_by"), now, now), + ) + return self.get_service_job(job_id) # type: ignore[return-value] + + def get_service_job(self, job_id: str) -> dict[str, Any] | None: + row = self._one( + """SELECT j.*, s.name AS site_name, s.customer AS customer FROM service_jobs j + LEFT JOIN sites s ON s.site_id = j.site_id WHERE job_id = ?""", (job_id,)) + if row: + row["crew"] = _unj(row["crew"], []) + row["locations"] = _unj(row["locations"], []) + row["runs"] = [r["run_id"] for r in self._rows( + "SELECT run_id FROM runs WHERE job_id = ? ORDER BY started_at", (job_id,))] + return row + + def list_service_jobs(self, status: str | None = None, + crew_member: str | None = None) -> list[dict[str, Any]]: + where, params = ("WHERE j.status = ?", [status]) if status else ("", []) + rows = self._rows( + f"""SELECT j.*, s.name AS site_name, s.customer AS customer, + (SELECT COUNT(*) FROM runs r WHERE r.job_id = j.job_id) AS run_count + FROM service_jobs j LEFT JOIN sites s ON s.site_id = j.site_id {where} + ORDER BY COALESCE(j.scheduled_for, j.created_at) DESC""", params) + for r in rows: + r["crew"] = _unj(r["crew"], []) + r["locations"] = _unj(r["locations"], []) + if crew_member: + rows = [r for r in rows if crew_member in r["crew"]] + return rows + + def update_service_job(self, job_id: str, **fields: Any) -> bool: + fields["updated_at"] = time.time() + return self._update("service_jobs", "job_id", job_id, fields, + {"status", "title", "scheduled_for", "crew", "locations", + "data_consent", "notes", "completed_at", "updated_at", "site_id"}, + json_cols={"crew", "locations"}) + + def consented_runs(self) -> list[str]: + return [r["run_id"] for r in self._rows( + """SELECT r.run_id FROM runs r JOIN service_jobs j ON j.job_id = r.job_id + WHERE j.data_consent = 1 ORDER BY r.started_at""")] + + # integrations ------------------------------------------------------------------------ + def upsert_integration(self, name: str, kind: str, settings: dict[str, Any], + secrets: bytes | None, enabled: bool, actor: str | None) -> None: + self._exec( + """INSERT INTO integrations (name, kind, settings, secrets, enabled, updated_at, updated_by) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(name) DO UPDATE SET kind = excluded.kind, settings = excluded.settings, + secrets = COALESCE(excluded.secrets, integrations.secrets), + enabled = excluded.enabled, updated_at = excluded.updated_at, + updated_by = excluded.updated_by""", + (name, kind, _j(settings), secrets, int(enabled), time.time(), actor), + ) + + def get_integration(self, name: str) -> dict[str, Any] | None: + row = self._one("SELECT * FROM integrations WHERE name = ?", (name,)) + if row: + row["settings"] = _unj(row["settings"], {}) + return row + + def list_integrations(self) -> list[dict[str, Any]]: + rows = self._rows("SELECT * FROM integrations ORDER BY name") + for r in rows: + r["settings"] = _unj(r["settings"], {}) + r["has_secrets"] = bool(r.pop("secrets")) + return rows + + def mark_integration(self, name: str, error: str | None) -> None: + self._exec("UPDATE integrations SET last_sync_at = ?, last_error = ? WHERE name = ?", + (time.time(), error, name)) + + def delete_integration(self, name: str) -> bool: + return self._exec("DELETE FROM integrations WHERE name = ?", (name,)) > 0 diff --git a/src/countbone/store/schema.py b/src/countbone/store/schema.py new file mode 100644 index 0000000..54eb60f --- /dev/null +++ b/src/countbone/store/schema.py @@ -0,0 +1,378 @@ +"""Database schema, as numbered migrations. + +A deployed database is upgraded in place on start: each migration runs once, +in order, and PRAGMA user_version records how far it has got. Never edit a +migration that has shipped; add the next one. + +Version 1 is the original schema (runs, counts, reviews, audit). Version 2 +adds the platform: people and access, locations, the product catalog, the +durable job queue, resumable uploads, recount tasks, reconciliation, +receiving, evidence claims, service operations and integrations. +""" + +from __future__ import annotations + +V1 = """ +CREATE TABLE IF NOT EXISTS runs ( + run_id TEXT PRIMARY KEY, + source TEXT NOT NULL, + started_at REAL NOT NULL, + finished_at REAL, + duration_s REAL, + frames_read INTEGER DEFAULT 0, + frames_used INTEGER DEFAULT 0, + frames_dropped INTEGER DEFAULT 0, + detections INTEGER DEFAULT 0, + tracks INTEGER DEFAULT 0, + total INTEGER DEFAULT 0, + overall_confidence REAL DEFAULT 0, + needs_review INTEGER DEFAULT 0, + config_fingerprint TEXT, + meta TEXT +); + +CREATE TABLE IF NOT EXISTS sku_counts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, + sku TEXT NOT NULL, + label TEXT, + count INTEGER NOT NULL, + expected INTEGER, + variance INTEGER, + confidence REAL, + evidence TEXT, + counted_at REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sku_counts_sku ON sku_counts(sku, counted_at); +CREATE INDEX IF NOT EXISTS idx_sku_counts_run ON sku_counts(run_id); + +CREATE TABLE IF NOT EXISTS reviews ( + review_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE, + sku TEXT NOT NULL, + reason TEXT, + confidence REAL, + frame_index INTEGER, + bbox TEXT, + crop_path TEXT, + status TEXT DEFAULT 'pending', + resolved_sku TEXT, + resolved_by TEXT, + resolved_at REAL, + created_at REAL NOT NULL, + meta TEXT +); +CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status, created_at); + +CREATE TABLE IF NOT EXISTS audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload TEXT, + created_at REAL NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_audit_run ON audit(run_id, created_at); +""" + +V2 = """ +-- What a run was for. `location` is a bay/shelf code; `kind` is count, +-- receive or recount; the *_id columns link it to what asked for it. +ALTER TABLE runs ADD COLUMN location TEXT; +ALTER TABLE runs ADD COLUMN kind TEXT DEFAULT 'count'; +ALTER TABLE runs ADD COLUMN created_by TEXT; +ALTER TABLE runs ADD COLUMN walk_id TEXT; +ALTER TABLE runs ADD COLUMN receipt_id TEXT; +ALTER TABLE runs ADD COLUMN task_id TEXT; +ALTER TABLE runs ADD COLUMN job_id TEXT; +CREATE INDEX IF NOT EXISTS idx_runs_location ON runs(location, started_at); + +-- The audit table becomes the trail for everything, not just runs: `run_id` +-- is read as "subject" (a run, task, adjustment, claim ...). Rows are hash +-- chained, so an edited or deleted row breaks every hash after it. +ALTER TABLE audit ADD COLUMN actor TEXT; +ALTER TABLE audit ADD COLUMN prev_hash TEXT; +ALTER TABLE audit ADD COLUMN row_hash TEXT; +CREATE INDEX IF NOT EXISTS idx_audit_kind ON audit(kind, created_at); + +-- People and access ------------------------------------------------------ +CREATE TABLE users ( + user_id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + display_name TEXT, + role TEXT NOT NULL, -- admin | manager | counter + pw_hash TEXT NOT NULL, + disabled INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL, + created_by TEXT, + last_login_at REAL +); + +CREATE TABLE auth_sessions ( + token_hash TEXT PRIMARY KEY, -- the token itself is never stored + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at REAL NOT NULL, + expires_at REAL NOT NULL, + last_seen_at REAL, + client TEXT +); + +CREATE TABLE api_keys ( + key_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + role TEXT NOT NULL, + created_by TEXT, + created_at REAL NOT NULL, + last_used_at REAL, + revoked INTEGER NOT NULL DEFAULT 0 +); + +-- Where stock lives ------------------------------------------------------ +CREATE TABLE sites ( + site_id TEXT PRIMARY KEY, + name TEXT NOT NULL, + customer TEXT, + address TEXT, + timezone TEXT, + created_at REAL NOT NULL +); + +CREATE TABLE locations ( + code TEXT PRIMARY KEY, -- printed on the bay's QR label + name TEXT, + site_id TEXT, + zone TEXT, + created_at REAL NOT NULL, + created_by TEXT, + archived INTEGER NOT NULL DEFAULT 0 +); + +-- The book quantity: what the customer's system says is at a location. +CREATE TABLE expected_stock ( + location TEXT NOT NULL, + sku TEXT NOT NULL, + qty INTEGER NOT NULL, + source TEXT, -- manual | csv | shopify | netsuite | sap ... + updated_at REAL NOT NULL, + updated_by TEXT, + PRIMARY KEY (location, sku) +); + +CREATE TABLE planograms ( + location TEXT PRIMARY KEY, + rows TEXT NOT NULL, -- JSON: [[sku, sku, ...], ...] top row first + updated_at REAL NOT NULL, + updated_by TEXT +); + +-- Product catalog made in Catalog Studio --------------------------------- +CREATE TABLE skus ( + sku TEXT PRIMARY KEY, + label TEXT, + unit_value REAL DEFAULT 0, + hue TEXT, -- JSON [lo, hi] or null + achromatic INTEGER DEFAULT 0, + min_saturation INTEGER DEFAULT 60, + barcodes TEXT, -- JSON list + created_at REAL NOT NULL, + created_by TEXT, + archived INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE sku_photos ( + photo_id TEXT PRIMARY KEY, + sku TEXT NOT NULL, + path TEXT NOT NULL, + source TEXT NOT NULL, -- upload | review (a reviewer's correction) + run_id TEXT, + review_id TEXT, + created_at REAL NOT NULL, + created_by TEXT +); +CREATE INDEX idx_sku_photos_sku ON sku_photos(sku); + +CREATE TABLE sku_vectors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + photo_id TEXT NOT NULL REFERENCES sku_photos(photo_id) ON DELETE CASCADE, + sku TEXT NOT NULL, + kind TEXT NOT NULL, -- train | probe + version INTEGER NOT NULL, + vector BLOB NOT NULL +); +CREATE INDEX idx_sku_vectors_sku ON sku_vectors(sku); + +-- Work that must survive a restart --------------------------------------- +CREATE TABLE jobs ( + run_id TEXT PRIMARY KEY, + source TEXT NOT NULL, + params TEXT, -- JSON: expected, context + status TEXT NOT NULL, -- queued | running | done | failed + attempts INTEGER NOT NULL DEFAULT 0, + error TEXT, + filename TEXT, + created_by TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL +); +CREATE INDEX idx_jobs_status ON jobs(status, created_at); + +CREATE TABLE uploads ( + upload_id TEXT PRIMARY KEY, + client_id TEXT UNIQUE, -- the phone's own id: retries are idempotent + filename TEXT, + size INTEGER NOT NULL, + received INTEGER NOT NULL DEFAULT 0, + sha256 TEXT, + params TEXT, + path TEXT NOT NULL, + status TEXT NOT NULL, -- open | complete + run_id TEXT, + created_by TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL +); + +-- Several videos of one place, merged without double counting ------------ +CREATE TABLE walks ( + walk_id TEXT PRIMARY KEY, + location TEXT, + name TEXT, + status TEXT NOT NULL DEFAULT 'open', -- open | closed + created_by TEXT, + created_at REAL NOT NULL, + closed_at REAL +); + +-- Recount tasks ---------------------------------------------------------- +CREATE TABLE tasks ( + task_id TEXT PRIMARY KEY, + kind TEXT NOT NULL DEFAULT 'recount', + status TEXT NOT NULL, -- open | done | escalated | cancelled + location TEXT, + sku TEXT, + run_id TEXT, + reason TEXT, + expected INTEGER, + counted INTEGER, + variance INTEGER, + value_at_risk REAL, + assignee TEXT, -- user_id + due_at REAL, + result TEXT, -- JSON: recount, run, note + created_at REAL NOT NULL, + created_by TEXT, + updated_at REAL NOT NULL, + closed_at REAL, + closed_by TEXT +); +CREATE INDEX idx_tasks_status ON tasks(status, assignee); + +-- Reconcile: proposed changes to the book, and who signed them off ------- +CREATE TABLE adjustments ( + adjustment_id TEXT PRIMARY KEY, + location TEXT, + sku TEXT NOT NULL, + system_qty INTEGER, + counted_qty INTEGER NOT NULL, + delta INTEGER NOT NULL, + unit_value REAL DEFAULT 0, + value REAL DEFAULT 0, + run_id TEXT, + task_id TEXT, + status TEXT NOT NULL, -- blocked | proposed | approved | rejected | posted | failed | superseded + rule TEXT, + decided_by TEXT, + decided_at REAL, + note TEXT, + integration TEXT, + external_ref TEXT, + post_error TEXT, + posted_at REAL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL +); +CREATE INDEX idx_adjustments_status ON adjustments(status, created_at); + +-- Receive --------------------------------------------------------------- +CREATE TABLE receipts ( + receipt_id TEXT PRIMARY KEY, + po_number TEXT NOT NULL, + supplier TEXT, + dock TEXT, + status TEXT NOT NULL, -- open | counted | discrepancy | closed + source TEXT, -- manual | csv | shopify | netsuite | sap + note TEXT, + created_by TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + closed_at REAL, + closed_by TEXT +); + +CREATE TABLE receipt_lines ( + receipt_id TEXT NOT NULL REFERENCES receipts(receipt_id) ON DELETE CASCADE, + sku TEXT NOT NULL, + expected_qty INTEGER NOT NULL, + received_qty INTEGER, + unit_cost REAL DEFAULT 0, + PRIMARY KEY (receipt_id, sku) +); + +-- Evidence -------------------------------------------------------------- +CREATE TABLE claims ( + claim_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, -- supplier_shortage | count_variance | damage | insurance | audit + counterparty TEXT, + status TEXT NOT NULL, -- draft | sent | accepted | rejected | recovered + receipt_id TEXT, + run_ids TEXT, -- JSON list + amount REAL DEFAULT 0, + recovered_amount REAL DEFAULT 0, + currency TEXT DEFAULT 'USD', + note TEXT, + pack_path TEXT, + pack_sha256 TEXT, + created_by TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL +); + +-- Count-as-a-Service ---------------------------------------------------- +CREATE TABLE service_jobs ( + job_id TEXT PRIMARY KEY, + site_id TEXT, + title TEXT, + scheduled_for REAL, + crew TEXT, -- JSON list of user_ids + locations TEXT, -- JSON list of location codes + status TEXT NOT NULL, -- planned | in_progress | done | cancelled + data_consent INTEGER NOT NULL DEFAULT 0, + notes TEXT, + created_by TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + completed_at REAL +); + +-- Integrations and settings --------------------------------------------- +CREATE TABLE integrations ( + name TEXT PRIMARY KEY, + kind TEXT NOT NULL, + settings TEXT, -- JSON, non-secret + secrets BLOB, -- encrypted JSON + enabled INTEGER NOT NULL DEFAULT 1, + updated_at REAL NOT NULL, + updated_by TEXT, + last_sync_at REAL, + last_error TEXT +); + +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at REAL NOT NULL, + updated_by TEXT +); +""" + +MIGRATIONS: list[str] = [V1, V2] diff --git a/tests/test_accuracy_features.py b/tests/test_accuracy_features.py new file mode 100644 index 0000000..18be6f1 --- /dev/null +++ b/tests/test_accuracy_features.py @@ -0,0 +1,374 @@ +"""Counting features, scored end to end on rendered footage with known truth: +adaptive sampling, continuity, photo-enrolled identity, cross-video merging, +shelf gaps, bay labels, and the final count after review.""" + +from __future__ import annotations + +import logging + +import numpy as np +import pytest + +from countbone import appearance, demo +from countbone.catalog import Catalog, SkuEntry +from countbone.config import Config +from countbone.ops.final import final_counts +from countbone.ops.merge import Placed, align, merge, objects_from_run +from countbone.pipeline import Pipeline +from countbone.plugins.shelf_check import check_planogram, find_gaps, rows_of + + +def _cfg(tmp_path, **capture) -> Config: + cfg = Config() + cfg.output.sqlite = None + cfg.output.dir = str(tmp_path / "runs") + for k, v in capture.items(): + setattr(cfg.capture, k, v) + return cfg + + +@pytest.fixture(scope="module") +def media(tmp_path_factory): + return tmp_path_factory.mktemp("accuracy") + + +# -- sampling and continuity --------------------------------------------------------- +def test_adaptive_sampling_recovers_what_a_fixed_stride_misses(media, tmp_path): + """Seed 21 lost its left-edge cartons at a fixed stride of 5: the blurred + samples left them one usable sighting each.""" + scene = demo.make_demo_video(media / "s21.webm", seed=21) + fixed = Pipeline(_cfg(tmp_path, adaptive=False)).run(scene.path) + adaptive = Pipeline(_cfg(tmp_path)).run(scene.path) + assert fixed.total < scene.total + assert fixed.needs_review, "a wrong count must never pass as clean" + assert adaptive.total == scene.total + assert {c.sku: c.count for c in adaptive.counts} == scene.truth + assert adaptive.meta["sampling"]["retries"] > 0 + assert adaptive.meta["continuity"]["broken_gaps"] == 0 + + +@pytest.mark.parametrize("seed", [2, 5, 9, 14]) +def test_adaptive_sampling_is_exact_across_seeds(media, tmp_path, seed): + scene = demo.make_demo_video(media / f"seed{seed}.webm", seed=seed) + result = Pipeline(_cfg(tmp_path)).run(scene.path) + assert {c.sku: c.count for c in result.counts} == scene.truth + + +def test_a_fast_pan_is_followed_by_narrowing_the_stride(media, tmp_path): + scene = demo.make_demo_video(media / "fast.webm", seed=4, seconds=2.5) + result = Pipeline(_cfg(tmp_path)).run(scene.path) + assert result.meta["sampling"]["min_step"] < 5 + assert result.total == scene.total + + +def test_continuity_flags_a_camera_that_outran_the_tracker(media, tmp_path): + scene = demo.make_demo_video(media / "veryfast.webm", seed=6, seconds=2.0) + result = Pipeline(_cfg(tmp_path, adaptive=False, every_n_frames=10)).run(scene.path) + assert result.meta["continuity"]["broken_gaps"] > 0 + assert result.needs_review + assert any("film more slowly" in w for w in result.warnings) + + +def test_reaching_the_frame_cap_is_flagged(media, tmp_path): + scene = demo.make_demo_video(media / "cap.webm", seed=3) + result = Pipeline(_cfg(tmp_path, max_frames=10)).run(scene.path) + assert result.needs_review + assert any("not counted" in w for w in result.warnings) + + +def test_an_expected_sku_that_was_never_seen_is_a_zero_row(media, tmp_path): + scene = demo.make_demo_video(media / "zero.webm", seed=3) + expected = {**scene.truth, "SKU-GHOST": 4} + result = Pipeline(_cfg(tmp_path)).run(scene.path, expected=expected) + ghost = next(c for c in result.counts if c.sku == "SKU-GHOST") + assert ghost.count == 0 and ghost.variance == -4 + assert result.needs_review + assert any(e["sku"] == "SKU-GHOST" for e in result.meta["tolerance_exceptions"]) + + +# -- identity ------------------------------------------------------------------------ +def _lookalike_catalog(skip: str | None = None) -> Catalog: + entries = [SkuEntry(s, v["label"], hue=(170, 10) if s.startswith("RED") else (95, 130)) + for s, v in demo.LOOKALIKE_SKUS.items()] + exemplars = [] + for i, sku in enumerate(demo.LOOKALIKE_SKUS): + if sku == skip: + continue + for j, photo in enumerate(demo.product_photos(sku, 5, seed=100 + i)): + exemplars += appearance.vectors_for_photo(photo, f"{sku}:{j}", sku) + return Catalog(entries, exemplars) + + +def test_photos_tell_apart_products_colour_cannot(media, tmp_path): + scene = demo.make_demo_video(media / "look.webm", seed=5, palette="lookalike") + colour = Pipeline(_cfg(tmp_path), catalog_provider=lambda: _lookalike_catalog()).run + photos_catalog = _lookalike_catalog() + with_photos = Pipeline(_cfg(tmp_path), catalog_provider=lambda: photos_catalog) + assert with_photos.identifier.name == "appearance" + result = with_photos.run(scene.path) + assert {c.sku: c.count for c in result.counts if c.count} == scene.truth + + cfg = _cfg(tmp_path) + cfg.identify.backend = "color" + colour_only = Pipeline(cfg, catalog_provider=lambda: _lookalike_catalog()).run(scene.path) + colour_counts = {c.sku: c.count for c in colour_only.counts if c.count} + assert colour_counts != scene.truth, "the look-alike range should defeat colour matching" + del colour + + +def test_an_unenrolled_look_alike_is_flagged_not_misfiled(media, tmp_path): + scene = demo.make_demo_video(media / "look2.webm", seed=3, palette="lookalike") + catalog = _lookalike_catalog(skip="RED-BAND") + result = Pipeline(_cfg(tmp_path), catalog_provider=lambda: catalog).run(scene.path) + counts = {c.sku: c.count for c in result.counts} + assert scene.truth.get("RED-BAND", 0) > 0 + # Its units are not silently added to the look-alike it resembles most... + assert counts.get("RED-PLAIN", 0) == scene.truth.get("RED-PLAIN", 0) + # ...they surface as unknown, for a person. + assert counts.get("UNKNOWN", 0) > 0 + assert result.needs_review + + +def test_index_calibrates_itself_from_enrolment_photos(): + index = _lookalike_catalog().index() + assert index.calibration["source"] == "probes" + assert 0.5 < index.accept < 0.99 + assert set(index.accept_by_sku) == set(demo.LOOKALIKE_SKUS) + + +def test_product_crop_removes_the_background(): + photo = demo.product_photos("RED-DOT", 1, seed=2)[0] + crop = appearance.product_crop(photo) + assert crop.shape[0] < photo.shape[0] and crop.shape[1] < photo.shape[1] + # most of what is left is the red carton, not the backdrop + red = (crop[:, :, 2] > 150) & (crop[:, :, 1] < 90) + assert red.mean() > 0.3 + + +def test_embedding_is_deterministic_and_unit_length(): + img = demo.product_photos("RED-STRIPE", 1, seed=0)[0] + a, b = appearance.embed(img), appearance.embed(img.copy()) + assert np.allclose(a, b) + assert abs(float(np.linalg.norm(a)) - 1.0) < 1e-5 + + +# -- merging videos of one place --------------------------------------------------------- +def _objects(tmp_path, path) -> tuple[str, list[Placed]]: + result = Pipeline(_cfg(tmp_path)).run(path) + return result.run_id, objects_from_run({"run_id": result.run_id, "meta": result.meta, + "reviews": []}) + + +def test_overlapping_videos_are_counted_once(media, tmp_path): + full = demo.make_demo_video(media / "m_full.webm", seed=8) + a = demo.make_demo_video(media / "m_a.webm", seed=8, window=(0.0, 0.6), seconds=4.0) + b = demo.make_demo_video(media / "m_b.webm", seed=8, window=(0.4, 1.0), seconds=4.0) + runs = [_objects(tmp_path, a.path), _objects(tmp_path, b.path)] + naive = sum(len(objs) for _, objs in runs) + merged = merge(runs) + assert naive > full.total + assert merged.counts() == full.truth + assert merged.alignments[1]["status"] == "aligned" + + +def test_videos_that_do_not_overlap_are_simply_added(media, tmp_path): + a = demo.make_demo_video(media / "d_a.webm", seed=8, window=(0.0, 0.2), seconds=2.5) + b = demo.make_demo_video(media / "d_b.webm", seed=8, window=(0.85, 1.0), seconds=2.5) + runs = [_objects(tmp_path, a.path), _objects(tmp_path, b.path)] + merged = merge(runs) + assert sum(merged.counts().values()) == sum(len(o) for _, o in runs) + assert merged.duplicates == 0 + + +def test_a_uniform_shelf_is_ambiguous_not_guessed(): + grid = [Placed("SKU-A", x * 140.0, y * 180.0, 96, 112, "a") for x in range(12) for y in range(3)] + shifted = [Placed("SKU-A", p.x - 420.0, p.y, 96, 112, "b") for p in grid if p.x >= 420] + assert align(grid, shifted).status == "ambiguous" + + +# -- shelf gaps and planograms ------------------------------------------------------------ +def test_every_empty_slot_is_found(media, tmp_path): + import json + + scene = demo.make_demo_video(media / "gaps.webm", seed=21) + result = Pipeline(_cfg(tmp_path)).run(scene.path) + report = json.loads((tmp_path / "runs" / result.run_id / "shelf.json").read_text()) + assert report["missing_facings"] == len(scene.empty_slots) + assert all("photo" in g for g in report["gaps"]) + assert (tmp_path / "runs" / result.run_id / report["gaps"][0]["photo"]).is_file() + + +def test_an_unidentified_object_still_fills_its_facing(media, tmp_path): + """Regression: objects nobody could name were left out of the shelf check, + so their places were reported as empty shelf.""" + import json + + scene = demo.make_demo_video(media / "gaps_unknown.webm", seed=21) + # A catalog that knows only blue: every other carton is unidentified. + catalog = Catalog([SkuEntry("SKU-BLU", "Blue", hue=(95, 130))]) + result = Pipeline(_cfg(tmp_path), catalog_provider=lambda: catalog).run(scene.path) + report = json.loads((tmp_path / "runs" / result.run_id / "shelf.json").read_text()) + assert report["missing_facings"] == len(scene.empty_slots) + + +def test_gap_finder_on_a_hand_made_row(): + row = [{"sku": "A", "x": x, "y": 50, "w": 100, "h": 100} for x in (50, 200, 500, 650)] + other = [{"sku": "B", "x": x, "y": 250, "w": 100, "h": 100} for x in (50, 200, 350, 500, 650)] + gaps = find_gaps(row + other) + assert len(gaps) == 1 and gaps[0]["missing_facings"] == 1 and gaps[0]["row"] == 0 + + +def test_planogram_names_what_is_wrong(): + objs = [{"sku": s, "x": i * 150.0, "y": 50, "w": 100, "h": 100} + for i, s in enumerate(["A", "B", "X", "D"])] + report = check_planogram(rows_of(objs), [["A", "B", "C", "D", "E"]], []) + kinds = sorted(i["kind"] for i in report["issues"]) + assert kinds == ["misplaced", "missing"] + assert report["compliance"] == 0.6 + + +def test_contact_sheet_shows_every_counted_object(media, tmp_path): + scene = demo.make_demo_video(media / "sheet.webm", seed=12) + result = Pipeline(_cfg(tmp_path)).run(scene.path) + assert result.meta["contact_sheet"]["objects"] == result.total + assert (tmp_path / "runs" / result.run_id / "contact_sheet.jpg").is_file() + + +# -- bay labels in the video ---------------------------------------------------------------- +def test_a_label_in_the_video_files_the_run_under_that_bay(media, tmp_path): + scene = demo.make_demo_video(media / "label.webm", seed=3, label="A07-B03") + result = Pipeline(_cfg(tmp_path)).run(scene.path) + assert result.meta["context"]["location"] == "A07-B03" + assert result.meta["labels_seen"] == ["A07-B03"] + assert {c.sku: c.count for c in result.counts if c.sku != "UNKNOWN"} == scene.truth + + +def test_gap_photos_show_the_shelf_not_the_label(media, tmp_path): + """Regression: the opening label frames tied with the first shelf frame + and were chosen as the photo of a shelf gap.""" + import json + + scene = demo.make_demo_video(media / "label_gaps.webm", seed=12, label="A07-B03") + result = Pipeline(_cfg(tmp_path)).run(scene.path) + gaps = json.loads((tmp_path / "runs" / result.run_id / "shelf.json").read_text())["gaps"] + label_frames = int(scene.fps * 0.75) + assert gaps and all(g["photo_frame"] >= label_frames for g in gaps) + + +def test_a_label_for_another_bay_is_flagged(media, tmp_path): + scene = demo.make_demo_video(media / "label2.webm", seed=3, label="A07-B03") + result = Pipeline(_cfg(tmp_path)).run(scene.path, context={"location": "Z99"}) + assert result.needs_review + assert any("filed under Z99" in w for w in result.warnings) + + +def test_scene_cuts_are_detected_and_blur_is_not(): + import random + + import cv2 + + from countbone.stages.motion import MotionEstimator + + board, *_ = demo._shelf(2112, 540, random.Random(3)) + shelf = board[:, 0:960].copy() + moved = board[:, 12:972].copy() + blurred = cv2.GaussianBlur(moved, (21, 21), 0) + label = demo._label_frames("A1", 960, 540, 1)[0] + m = MotionEstimator() + m.update(shelf) + step = m.update(moved) + assert step["estimated"] and not step["cut"] and abs(step["dx"] + 12) < 2 + assert not m.update(blurred)["cut"] + assert m.update(label)["cut"] + # The reference frame is not modified by phase correlation (OpenCV 5 + # windows its inputs in place): the same frame twice is zero motion. + m2 = MotionEstimator() + m2.update(shelf) + again = m2.update(shelf.copy()) + assert again["estimated"] and abs(again["dx"]) < 0.5 and abs(again["dy"]) < 0.5 + + +def test_find_location_prescan(media): + from countbone.plugins.location_tag import find_location + + scene = demo.make_demo_video(media / "label3.webm", seed=3, label="DOCK-2") + assert find_location(scene.path) == "DOCK-2" + plain = demo.make_demo_video(media / "nolabel.webm", seed=3) + assert find_location(plain.path) is None + + +# -- the final count ------------------------------------------------------------------------------ +def _run(counts, reviews): + return {"counts": [{"sku": s, "count": n, "expected": e, "label": s, "confidence": 0.9} + for s, n, e in counts], "reviews": reviews} + + +def _review(status, scope="item", **meta): + return {"sku": meta.pop("sku", "A"), "status": status, "resolved_sku": meta.pop("to", None), + "resolved_by": "ann", "meta": {"scope": scope, **meta}} + + +def test_final_count_applies_decisions_per_object(): + run = _run([("A", 10, 10), ("B", 5, 6)], [ + _review("rejected", track_id=1, track_sku="A", counted=True), # A -1 + _review("corrected", track_id=2, track_sku="A", counted=True, to="B"), # A -1, B +1 + _review("accepted", track_id=3, track_sku="B", counted=False, sku="B"), # B +1 (a miss) + _review("rejected", track_id=4, track_sku="B", counted=False, sku="B"), # nothing + _review("accepted", track_id=5, track_sku="A", counted=True), # nothing + ]) + fc = final_counts(run) + rows = {r["sku"]: r for r in fc["rows"]} + assert rows["A"]["machine"] == 10 and rows["A"]["final"] == 8 + assert rows["B"]["final"] == 7 and rows["B"]["variance"] == 1 + assert fc["settled"] and fc["total"] == 15 + + +def test_a_recount_overrides_and_pending_reviews_keep_it_provisional(): + run = _run([("A", 10, 12)], [ + _review("corrected", scope="sku", sku="A", resolved_count=12), + {"sku": "A", "status": "pending", "meta": {"scope": "item", "track_id": 9}}, + ]) + fc = final_counts(run) + assert fc["rows"][0]["final"] == 12 + assert not fc["settled"] and fc["open_reviews"] == 1 + + +def test_reviews_without_a_track_do_not_move_the_number(): + run = _run([("A", 4, None)], [_review("rejected", track_id=None)]) + assert final_counts(run)["rows"][0]["final"] == 4 + + +@pytest.fixture(autouse=True) +def _quiet(): + logging.disable(logging.WARNING) + yield + logging.disable(logging.NOTSET) + + +def test_few_photos_and_an_unphotographed_look_alike_hold_the_bar_up(): + """With five photos a product's own bar is set by one or two odd views + (it ranged 0.914-0.968 over photo sets), and a colour look-alike nobody + photographed can never win a match to catch a wrong one. Such products + are held to at least the default bar until they have enough photos.""" + from countbone.config import IdentifyConfig + from countbone.stages.identify import AppearanceIdentifier + + def catalog(n_photos: int, seed: int) -> Catalog: + entries = [SkuEntry("RED-DOT", "Red, dot", hue=(170, 10)), + SkuEntry("SKU-RED", "Plain red, no photos", hue=(170, 10))] + exemplars = [] + for j, photo in enumerate(demo.product_photos("RED-DOT", n_photos, seed=seed)): + exemplars += appearance.vectors_for_photo(photo, f"RED-DOT:{j}", "RED-DOT") + return Catalog(entries, exemplars) + + few = catalog(5, seed=5) # the loosest five-photo set measured + ident = AppearanceIdentifier(IdentifyConfig(), few) + assert ident.index.accept_for("RED-DOT") < appearance.DEFAULT_ACCEPT + assert ident._floor == {"RED-DOT": appearance.DEFAULT_ACCEPT} + + many = catalog(appearance.MIN_PHOTOS_FOR_OWN_BAR, seed=5) + assert AppearanceIdentifier(IdentifyConfig(), many)._floor == {} + + # No look-alike without photos: nothing to guard against. + alone = Catalog([SkuEntry("RED-DOT", "Red, dot", hue=(170, 10))], few.exemplars) + assert AppearanceIdentifier(IdentifyConfig(), alone)._floor == {} diff --git a/tests/test_api.py b/tests/test_api.py index 8f43c53..49aef5f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import time from pathlib import Path @@ -13,12 +14,28 @@ from countbone.api.app import create_app # noqa: E402 from countbone.store.db import Store # noqa: E402 +ADMIN_PASSWORD = "correct horse battery" + + +def signed_in(app, store, username="admin", role="admin", password=ADMIN_PASSWORD): + """A TestClient signed in as a new user of `role` (Bearer, like the phone app).""" + from countbone.security import hash_password + + if store.user_by_username(username) is None: + store.create_user(username, hash_password(password), role, username.title()) + c = TestClient(app) + token = c.post("/api/auth/login", json={"username": username, "password": password}).json()["token"] + c.headers["Authorization"] = f"Bearer {token}" + return c + @pytest.fixture def client(config, tmp_path): store = Store(tmp_path / "api.db") - with TestClient(create_app(config, store=store)) as c: + app = create_app(config, store=store, data_dir=tmp_path / "data") + with signed_in(app, store) as c: c.store = store + c.app_ = app yield c store.close() @@ -123,7 +140,168 @@ def test_one_run_cannot_read_another_runs_artifacts(client, config): assert client.get("/api/runs/run_10/artifacts/secret.txt").status_code == 200 +def test_a_run_reports_progress_and_writes_the_inspector(client, demo_scene): + run_id = client.post("/api/runs", json={"path": demo_scene.path}).json()["run_id"] + run = wait_for(client, run_id) + + live = run["live"] + assert live["phase"] == "done" + t = live["telemetry"] + assert t["frames_read"] == run["frames_read"] + assert t["frames_kept"] == run["frames_used"] + assert t["detections"] == run["detections"] + + doc = client.get(f"/api/runs/{run_id}/artifacts/inspector.json").json() + assert len(doc["frames"]) == run["frames_read"] + assert sum(f["kept"] for f in doc["frames"]) == run["frames_used"] + assert doc["boxes"] and all(len(b["bbox"]) == 4 for b in doc["boxes"]) + # Every counted object is exactly one surviving track. + counted = {b["track_id"] for b in doc["boxes"] if b["counted"]} + assert len(counted) == run["tracks"] + # Boxes carry the identity the object was counted as, one per track. + per_track = {} + for b in doc["boxes"]: + if b["counted"]: + per_track.setdefault(b["track_id"], set()).add(b["track_sku"]) + assert all(len(s) == 1 and None not in s for s in per_track.values()) + by_sku = {} + for (sku,) in per_track.values(): + by_sku[sku] = by_sku.get(sku, 0) + 1 + assert by_sku == {c["sku"]: c["count"] for c in run["counts"]} + # Hashed into the audit pack like any other artifact. + manifest = client.get(f"/api/runs/{run_id}/artifacts/audit_pack.json").json() + assert "inspector.json" in manifest["manifest"]["artifacts"] + + +def test_the_source_video_is_streamed_with_range_support(client, demo_scene): + run_id = client.post("/api/runs", json={"path": demo_scene.path}).json()["run_id"] + wait_for(client, run_id) + res = client.get(f"/api/runs/{run_id}/video", headers={"Range": "bytes=0-99"}) + assert res.status_code == 206 + assert len(res.content) == 100 + assert client.get("/api/runs/nope/video").status_code == 404 + + +def test_video_endpoint_never_serves_a_non_video_source(client, tmp_path): + """A run records whatever path it was given; that must not become a file read.""" + from countbone.types import CountResult + + secret = tmp_path / "secret.txt" + secret.write_text("not for you", encoding="utf-8") + client.store.save_run(CountResult(run_id="run_sneaky", source=str(secret))) + assert client.get("/api/runs/run_sneaky/video").status_code == 404 + + # Nor the source of a run that failed (it never reached the store). + failed = client.post("/api/runs", json={"path": str(secret)}).json()["run_id"] + deadline = time.time() + 30 + while client.get(f"/api/runs/{failed}").json().get("pending", {}).get("status") != "failed": + assert time.time() < deadline, "run should have failed" + time.sleep(0.1) + assert client.get(f"/api/runs/{failed}/video").status_code == 404 + + +def test_a_failed_run_reports_failed_phase(client, tmp_path): + bad = tmp_path / "broken.mp4" + bad.write_bytes(b"this is not a video") + run_id = client.post("/api/runs", json={"path": str(bad)}).json()["run_id"] + deadline = time.time() + 30 + while True: + live = client.get(f"/api/runs/{run_id}").json()["pending"] + if live["status"] == "failed": + break + assert time.time() < deadline + time.sleep(0.1) + assert live["error"] + # Nothing half-written reached the store. + assert run_id not in [r["run_id"] for r in client.get("/api/runs").json()["runs"]] + + +def test_a_decision_can_be_reopened(client): + from countbone.types import CountResult, ReviewItem, SkuCount + + client.store.save_run( + CountResult( + run_id="run_undo", + source="x.mp4", + counts=[SkuCount("SKU-A", 3)], + reviews=[ReviewItem("rev_undo", "run_undo", "SKU-A", "unidentified", 0.2, 1)], + ) + ) + client.post("/api/reviews/rev_undo", json={"status": "rejected"}) + client.post("/api/reviews/rev_undo", json={"status": "pending"}) + run = client.get("/api/runs/run_undo").json() + assert run["reviews"][0]["status"] == "pending" + kinds = [a["payload"]["status"] for a in run["audit"] if a["kind"] == "review_decision"] + assert kinds == ["rejected", "pending"] + + +def test_upload_keeps_a_safe_original_name(client, demo_scene): + with open(demo_scene.path, "rb") as fh: + res = client.post( + "/api/runs/upload", files={"file": ("../../Aisle 7 (east).mp4", fh, "video/mp4")} + ) + source = Path(res.json()["source"]) + assert source.name.endswith("__Aisle_7_east.mp4") + assert source.parent.name == "_uploads" # the ../ went nowhere + wait_for(client, res.json()["run_id"]) + + +def test_uploads_must_be_video(client): + res = client.post("/api/runs/upload", files={"file": ("notes.txt", b"hi", "text/plain")}) + assert res.status_code == 415 + + +def test_catalog_gives_each_sku_a_swatch(client): + rows = {r["sku"]: r for r in client.get("/api/catalog").json()} + assert rows["SKU-RED"]["swatch"].startswith("#") + red = rows["SKU-RED"]["swatch"] + assert int(red[1:3], 16) > int(red[3:5], 16) # red hue band renders red + + +def test_a_recount_is_kept_beside_the_machine_count(client): + from countbone.types import CountResult, ReviewItem, SkuCount + + client.store.save_run( + CountResult( + run_id="run_rc", + source="x.mp4", + counts=[SkuCount("SKU-A", 3)], + reviews=[ReviewItem("rev_rc", "run_rc", "SKU-A", "low_sku_confidence", 0.4, -1)], + ) + ) + res = client.post( + "/api/reviews/rev_rc", + json={"status": "corrected", "resolved_sku": "SKU-A", "resolved_count": 5}, + ) + assert res.status_code == 200 + run = client.get("/api/runs/run_rc").json() + assert run["counts"][0]["count"] == 3 # the machine's answer is untouched + review = run["reviews"][0] + assert json.loads(review["meta"])["resolved_count"] == 5 + decisions = [a for a in run["audit"] if a["kind"] == "review_decision"] + assert decisions[0]["payload"]["resolved_count"] == 5 + + bad = client.post("/api/reviews/rev_rc", json={"status": "accepted", "resolved_count": -1}) + assert bad.status_code == 422 + + +def test_cors_is_off_unless_origins_are_named(client, config, tmp_path): + origin = {"Origin": "http://localhost:8081"} + assert "access-control-allow-origin" not in client.get("/api/health", headers=origin).headers + + store = Store(tmp_path / "cors.db") + with TestClient(create_app(config, store=store, allow_origins=["http://localhost:8081"], + data_dir=tmp_path / "cors-data")) as c: + allowed = c.get("/api/health", headers=origin).headers + other = c.get("/api/health", headers={"Origin": "http://evil.example"}).headers + store.close() + assert allowed["access-control-allow-origin"] == "http://localhost:8081" + assert "access-control-allow-origin" not in other + + def test_dashboard_is_served(client): res = client.get("/") assert res.status_code == 200 assert "countbone" in res.text + # index.html names the current asset fingerprints: never serve it stale. + assert res.headers["cache-control"] == "no-cache" diff --git a/tests/test_cli.py b/tests/test_cli.py index 20b7a5e..a24e6ea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from pathlib import Path import pytest @@ -29,6 +30,17 @@ def test_run_prints_a_table(demo_scene, config, tmp_path, capsys): assert "wrote:" in out +def test_a_cli_run_can_be_inspected_in_the_dashboard(demo_scene, config, tmp_path, capsys): + """Same database as the dashboard, so it must leave the inspector file too.""" + main(["run", demo_scene.path, "--json", *base_args(config, tmp_path)]) + payload = json.loads(capsys.readouterr().out) + doc = json.loads( + (Path(config.output.dir) / payload["run_id"] / "inspector.json").read_text(encoding="utf-8") + ) + assert len(doc["frames"]) == payload["frames_read"] + assert doc["boxes"] + + def test_run_json_output_is_parseable(demo_scene, config, tmp_path, capsys): main(["run", demo_scene.path, "--json", *base_args(config, tmp_path)]) payload = json.loads(capsys.readouterr().out) diff --git a/tests/test_counting.py b/tests/test_counting.py index b425e67..c394e84 100644 --- a/tests/test_counting.py +++ b/tests/test_counting.py @@ -40,6 +40,23 @@ def test_a_pan_without_motion_compensation_fragments_tracks(): assert len(tracker.tracks) > 1 +def test_a_dropped_frame_still_moves_the_camera(): + """Regression: motion during a frame the quality gate dropped was lost, so + every track lagged a frame of pan and split in two (a double count).""" + tracker = Tracker() + motion = {"dx": -30.0, "dy": 0.0, "response": 0.9, "estimated": True} + x = 200.0 + for f in range(6): + if f: + x -= 30 + if f == 3: # dropped by the quality gate: no items, but the camera moved + tracker.observe_motion(motion) + continue + tracker.update(f, [item(x, frame=f)], motion if f else None) + assert len(tracker.tracks) == 1 + assert tracker.tracks[0].hits == 5 + + def test_motion_compensation_keeps_a_panning_object_as_one_track(): tracker = Tracker() motion = {"dx": -45.0, "dy": 0.0, "response": 0.9, "estimated": True} diff --git a/tests/test_detect_identify.py b/tests/test_detect_identify.py index 29e1eb8..1d88df2 100644 --- a/tests/test_detect_identify.py +++ b/tests/test_detect_identify.py @@ -62,6 +62,27 @@ def test_contour_detector_finds_separated_boxes(): assert all(d.score > 0.5 for d in dets) +def test_a_box_cut_by_the_frame_edge_does_not_leak_its_label(): + """Regression: a carton cut off by the frame edge has an open outline, so + its white label was reported as an object of its own (an UNKNOWN count and + a review item). The partial carton is skipped; the label must be too.""" + image = np.full((480, 640, 3), 170, dtype=np.uint8) + # whole carton with a label, for contrast + cv2.rectangle(image, (100, 120), (200, 300), (40, 40, 205), -1) + cv2.rectangle(image, (100, 120), (200, 300), (30, 30, 30), 3) + cv2.rectangle(image, (116, 150), (184, 180), (235, 235, 235), -1) + # carton running off the right edge, label fully inside the frame + cv2.rectangle(image, (560, 120), (700, 300), (40, 40, 205), -1) + cv2.rectangle(image, (560, 120), (700, 300), (30, 30, 30), 3) + cv2.rectangle(image, (576, 150), (630, 180), (235, 235, 235), -1) + frame = Frame(index=0, source_index=0, timestamp_s=0.0, image=image) + + dets = detect.build(DetectConfig()).detect(frame) + assert len(dets) == 1 # only the whole carton: no partial, no label + x1, y1, x2, y2 = dets[0].bbox + assert x1 <= 100 and y1 <= 120 and x2 >= 200 and y2 >= 300 and x2 < 260 + + def test_unknown_detector_backend_fails_loudly(): with pytest.raises(ValueError, match="unknown detect backend"): detect.build(DetectConfig(backend="magic")) diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..b157429 --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,212 @@ +"""Connectors, against mock servers that check the requests each system +documents: Shopify Admin GraphQL, NetSuite REST (OAuth 1.0a TBA), SAP +S/4HANA OData, the signed webhook, and CSV import/export.""" + +from __future__ import annotations + +import json + +import httpx +import pytest + +from countbone.integrations import Adjustment, IntegrationError, build, csvio +from countbone.integrations.netsuite import oauth1_header +from countbone.integrations.webhook import sign, verify + + +def client(handler) -> httpx.Client: + return httpx.Client(transport=httpx.MockTransport(handler)) + + +# -- Shopify ------------------------------------------------------------------------------------ +def test_shopify_posts_a_delta_with_compare_and_set_and_an_idempotency_key(): + calls = [] + + def handler(req: httpx.Request) -> httpx.Response: + assert req.url.path == "/admin/api/2026-07/graphql.json" + assert req.headers["X-Shopify-Access-Token"] == "shpat_x" + body = json.loads(req.content) + calls.append(body) + if "productVariants" in body["query"]: + assert body["variables"]["q"] == 'sku:"SKU-RED"' + return httpx.Response(200, json={"data": {"productVariants": {"nodes": [{ + "sku": "SKU-RED", "inventoryItem": {"id": "gid://shopify/InventoryItem/1", + "inventoryLevel": {"quantities": [{"name": "available", "quantity": 40}]}}}]}}}) + assert "@idempotent(key: $idempotencyKey)" in body["query"] + return httpx.Response(200, json={"data": {"inventorySetQuantities": { + "inventoryAdjustmentGroup": {"id": "gid://shopify/InventoryAdjustmentGroup/9"}, + "userErrors": []}}}) + + shop = build("shopify", {"shop": "acme.myshopify.com", + "default_location": "gid://shopify/Location/5"}, + {"access_token": "shpat_x"}, client(handler)) + [res] = shop.push_adjustments([Adjustment("adj_1", "A1", "SKU-RED", 43, 40, -3)]) + assert res.ok and res.external_ref.endswith("/9") + mutation = calls[-1] + assert mutation["variables"]["idempotencyKey"] == "adj_1" + qty = mutation["variables"]["input"]["quantities"][0] + # The bay's delta is applied to the store's live number, guarded by it. + assert qty == {"inventoryItemId": "gid://shopify/InventoryItem/1", + "locationId": "gid://shopify/Location/5", "quantity": 37, + "changeFromQuantity": 40} + assert mutation["variables"]["input"]["reason"] == "cycle_count_available" + + +def test_shopify_user_errors_fail_the_adjustment_not_the_batch(): + def handler(req): + body = json.loads(req.content) + if "productVariants" in body["query"]: + return httpx.Response(200, json={"data": {"productVariants": {"nodes": []}}}) + raise AssertionError("no mutation without a variant") + + shop = build("shopify", {"shop": "a.myshopify.com", "default_location": "L"}, + {"access_token": "t"}, client(handler)) + [res] = shop.push_adjustments([Adjustment("adj_2", None, "NOPE", 1, 0, -1)]) + assert not res.ok and "no variant" in res.error + + +def test_shopify_refuses_per_bay_book_stock_for_shared_locations(): + shop = build("shopify", {"shop": "a.myshopify.com", "default_location": "L"}, + {"access_token": "t"}, client(lambda r: httpx.Response(500))) + with pytest.raises(IntegrationError, match="share a Shopify location"): + shop.pull_expected({"A1": ["X"], "A2": ["X"]}) + + +def test_missing_settings_are_reported_by_name(): + with pytest.raises(IntegrationError, match="access_token"): + build("shopify", {"shop": "a", "default_location": "L"}, {}) + + +# -- NetSuite ---------------------------------------------------------------------------------- +def test_netsuite_oauth_signature_matches_the_reference_implementation(): + oauthlib = pytest.importorskip("oauthlib.oauth1") + url = "https://1234567-sb1.suitetalk.api.netsuite.com/services/rest/query/v1/suiteql?limit=1000&offset=0" + ours = oauth1_header("POST", url, "1234567_SB1", "ck", "cs", "tk", "ts", + nonce="abc123", timestamp="1700000000") + ref = oauthlib.Client("ck", client_secret="cs", resource_owner_key="tk", + resource_owner_secret="ts", signature_method="HMAC-SHA256", + realm="1234567_SB1", nonce="abc123", timestamp="1700000000") + _, headers, _ = ref.sign(url, http_method="POST") + + def sig(h: str) -> str: + return h.split('oauth_signature="')[1].split('"')[0] + + assert sig(ours) == sig(headers["Authorization"]) + + +def test_netsuite_posts_one_adjustment_record_per_location(): + posted = [] + + def handler(req: httpx.Request) -> httpx.Response: + assert req.headers["Authorization"].startswith('OAuth realm="1234567_SB1"') + if req.url.path.endswith("/suiteql"): + assert req.headers["Prefer"] == "transient" + q = json.loads(req.content)["q"] + assert "FROM item WHERE itemid IN ('SKU-RED', 'SKU-YEL')" in q + return httpx.Response(200, json={"items": [{"id": 11, "itemid": "SKU-RED"}, + {"id": 12, "itemid": "SKU-YEL"}], + "hasMore": False}) + assert req.url.path == "/services/rest/record/v1/inventoryAdjustment" + posted.append(json.loads(req.content)) + return httpx.Response(204, headers={ + "Location": "https://x/services/rest/record/v1/inventoryAdjustment/555"}) + + ns = build("netsuite", {"account_id": "1234567_SB1", "adjustment_account": "300", + "default_location": "7", "subsidiary": "1"}, + {"consumer_key": "ck", "consumer_secret": "cs", "token_id": "tk", + "token_secret": "ts"}, client(handler)) + res = ns.push_adjustments([Adjustment("adj_a", "A1", "SKU-RED", 10, 8, -2), + Adjustment("adj_b", "A2", "SKU-YEL", 3, 4, 1)]) + assert all(r.ok and r.external_ref == "555" for r in res) + [record] = posted + assert record["account"] == {"id": "300"} and record["adjLocation"] == {"id": "7"} + assert record["subsidiary"] == {"id": "1"} + lines = {i["item"]["id"]: i["adjustQtyBy"] for i in record["inventory"]["items"]} + assert lines == {"11": -2, "12": 1} + + +def test_netsuite_reads_a_purchase_order(): + def handler(req): + q = json.loads(req.content)["q"] + assert "t.type = 'PurchOrd'" in q and "t.tranid = 'PO''7'" in q # quote escaped + return httpx.Response(200, json={"items": [ + {"tranid": "PO'7", "vendor": "Acme", "itemid": "SKU-RED", "quantity": "10", "rate": "4.5"}, + {"tranid": "PO'7", "vendor": "Acme", "itemid": "SKU-RED", "quantity": "2", "rate": "4.5"}, + ], "hasMore": False}) + + ns = build("netsuite", {"account_id": "1", "adjustment_account": "3", "default_location": "7"}, + {"consumer_key": "a", "consumer_secret": "b", "token_id": "c", "token_secret": "d"}, + client(handler)) + po = ns.pull_purchase_order("PO'7") + assert po.supplier == "Acme" and po.lines == {"SKU-RED": {"qty": 12, "unit_cost": 4.5}} + + +# -- SAP --------------------------------------------------------------------------------------- +def test_sap_creates_counts_and_posts_a_physical_inventory_document(): + seen = [] + + def handler(req: httpx.Request) -> httpx.Response: + seen.append((req.method, req.url.path, dict(req.headers))) + path = req.url.path + if req.method == "GET" and req.headers.get("x-csrf-token") == "Fetch": + return httpx.Response(200, headers={"x-csrf-token": "tok123"}) + if req.method == "POST" and path.endswith("/A_PhysInventoryDocHeader"): + assert req.headers["x-csrf-token"] == "tok123" + body = json.loads(req.content) + assert body["Plant"] == "1010" and body["StorageLocation"] == "0001" + mats = [i["Material"] for i in body["to_PhysicalInventoryDocumentItem"]["results"]] + assert mats == ["SKU-RED"] + return httpx.Response(201, json={"d": {"FiscalYear": "2026", + "PhysicalInventoryDocument": "100000123", + "to_PhysicalInventoryDocumentItem": {"results": [ + {"Material": "SKU-RED", + "PhysicalInventoryDocumentItem": "1"}]}}}) + if req.method == "GET" and "A_PhysInventoryDocItem" in path: + return httpx.Response(200, headers={"etag": 'W/"x1"'}, json={"d": {}}) + if req.method == "PATCH": + assert req.headers["If-Match"] == 'W/"x1"' + body = json.loads(req.content) + assert body == {"QuantityInUnitOfEntry": "8", "UnitOfEntry": "PC"} # the count, not the delta + return httpx.Response(204) + if req.method == "POST" and path.endswith("/PostDifferences"): + assert req.url.params["PhysicalInventoryDocument"] == "'100000123'" + return httpx.Response(200, json={"d": {}}) + raise AssertionError(f"unexpected {req.method} {req.url}") + + sap = build("sap", {"base_url": "https://s4.example.com", "default_location": "1010/0001"}, + {"username": "u", "password": "p"}, client(handler)) + [res] = sap.push_adjustments([Adjustment("adj_s", "A1", "SKU-RED", 10, 8, -2)]) + assert res.ok and res.external_ref == "2026/100000123" + assert [m for m, _, _ in seen] == ["GET", "POST", "GET", "PATCH", "POST"] + + +def test_sap_without_a_mapping_fails_that_adjustment(): + sap = build("sap", {"base_url": "https://s4", "default_location": "bad"}, + {"username": "u", "password": "p"}, client(lambda r: httpx.Response(500))) + [res] = sap.push_adjustments([Adjustment("adj_x", "A1", "M", 1, 0, -1)]) + assert not res.ok and "PLANT/STORAGE_LOCATION" in res.error + + +# -- webhook and CSV ------------------------------------------------------------------------------ +def test_webhook_signatures_verify_and_expire(): + body = b'{"event":"x"}' + header = sign("secret", body) + assert verify("secret", body, header) + assert not verify("other", body, header) + assert not verify("secret", body + b" ", header) + old = sign("secret", body, timestamp=1_000_000) + assert not verify("secret", body, old) + + +def test_csv_import_is_forgiving_about_headers_and_strict_about_values(): + text = "Bay,Item ID,On Hand\nA1,SKU-RED,10\nA1,SKU-RED,2\nA2,SKU-BLU,5\n" + assert csvio.parse_expected(text) == {"A1": {"SKU-RED": 12}, "A2": {"SKU-BLU": 5}} + with pytest.raises(IntegrationError, match="line 2"): + csvio.parse_expected("location,sku,qty\nA1,X,2.5\n") + with pytest.raises(IntegrationError, match="missing a qty column"): + csvio.parse_expected("location,sku\nA1,X\n") + + +def test_csv_export_neutralises_spreadsheet_formulas(): + out = csvio.write_rows([{"note": "=HYPERLINK(\"http://evil\")", "delta": -3}], ["note", "delta"]) + assert "'=HYPERLINK" in out and ",-3" in out diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 88cf727..b3cae45 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -5,6 +5,7 @@ import json from pathlib import Path +import cv2 import pytest from countbone.config import Config @@ -27,6 +28,23 @@ def test_counts_synthetic_shelf_within_tolerance(demo_scene, config): assert {c.sku for c in result.counts} <= set(demo_scene.truth) | {"UNKNOWN"} +@pytest.mark.parametrize("seed", [0, 3, 9]) +def test_other_shelves_count_exactly(seed, config, tmp_path): + """Regression: these seeds over-counted by +9 to +13 (up to 36%) while the + single default seed looked fine, because motion during dropped frames was + lost and every object in view split into two tracks. The number of + physical objects must now be exact. Which SKU each one is may still be off + by one (colour identification, a separate and weaker stage).""" + from countbone.demo import make_demo_video + + scene = make_demo_video(tmp_path / f"shelf_{seed}.mp4", seed=seed) + result = Pipeline(config).run(scene.path) + counted = {c.sku: c.count for c in result.counts} + assert sum(counted.get(s, 0) for s in scene.truth) == scene.total + for sku, truth in scene.truth.items(): + assert abs(counted.get(sku, 0) - truth) <= 1, f"{sku}: {counted.get(sku, 0)} vs {truth}" + + def test_per_sku_counts_are_close(demo_scene, config): result = Pipeline(config).run(demo_scene.path) counted = {c.sku: c.count for c in result.counts} @@ -109,6 +127,36 @@ def test_missing_video_is_a_clear_error(config): Pipeline(config).run("does-not-exist.mp4") +def test_frame_timestamps_follow_the_video_clock(demo_scene, config): + """Constant-rate footage: timestamps are exactly frame / fps.""" + stamps = [(f.source_index, f.timestamp_s) for f in capture.frames(demo_scene.path, config.capture)] + fps = capture.probe(demo_scene.path)["fps"] + assert stamps and all(abs(t - i / fps) < 1e-3 for i, t in stamps) + + +def test_variable_frame_rate_timestamps_do_not_drift(tmp_path, config): + """Regression: index / average-fps drifted over a second on VFR phone + footage, putting the inspector's boxes on the wrong frame.""" + imageio_ffmpeg = pytest.importorskip("imageio_ffmpeg") + import subprocess + + from countbone.demo import make_demo_video + + src = make_demo_video(tmp_path / "cfr.webm", seed=1).path + vfr = tmp_path / "vfr.mkv" + # 30 fps for 60 frames, then 15 fps: frame 60 is at exactly 2.000 s. + subprocess.run( + [imageio_ffmpeg.get_ffmpeg_exe(), "-y", "-loglevel", "error", "-i", src, + "-vf", "setpts='if(lt(N,60),N/(30*TB),(2+(N-60)/15)/TB)'", + "-fps_mode", "vfr", "-c:v", "libvpx", str(vfr)], + check=True, + ) + config.capture.every_n_frames = 1 + stamps = {f.source_index: f.timestamp_s for f in capture.frames(str(vfr), config.capture)} + assert stamps[60] == pytest.approx(2.0, abs=0.02) + assert stamps[90] == pytest.approx(4.0, abs=0.02) + + def test_run_video_helper(demo_scene, tmp_path): cfg = Config() cfg.output.dir = str(tmp_path / "runs") @@ -116,3 +164,21 @@ def test_run_video_helper(demo_scene, tmp_path): result = run_video(demo_scene.path, cfg, run_id="run_fixed") assert result.run_id == "run_fixed" assert result.total > 0 + + +def test_implausible_container_rates_are_treated_as_unknown(): + """Browser (MediaRecorder) WebM reports OpenCV's 1 kHz timebase and a + negative frame-count sentinel; neither may reach the run as fact.""" + from countbone.stages.capture import _source_fps, _source_frames + + class FakeCap: + def __init__(self, fps, frames): + self.v = {cv2.CAP_PROP_FPS: fps, cv2.CAP_PROP_FRAME_COUNT: frames} + + def get(self, prop): + return self.v[prop] + + assert _source_fps(FakeCap(1000.0, 0)) == 0.0 + assert _source_frames(FakeCap(1000.0, -9.2e18)) == 0 + assert _source_fps(FakeCap(29.97, 300)) == 29.97 + assert _source_frames(FakeCap(29.97, 300)) == 300 diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..c8e98d9 --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,683 @@ +"""The product around the pipeline, through its HTTP API: accounts and roles, +resumable uploads, locations, reconcile, receive, evidence, the catalog +studio, service jobs, and durability across a restart.""" + +from __future__ import annotations + +import hashlib +import io +import json +import time +import zipfile +from pathlib import Path + +import cv2 +import httpx +import numpy as np +import pytest + +pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +from countbone import demo # noqa: E402 +from countbone.api.app import create_app # noqa: E402 +from countbone.security import hash_password # noqa: E402 +from countbone.store.db import Store # noqa: E402 + +PW = "correct horse battery" + + +def make_app(config, tmp_path, store=None, **kw): + store = store or Store(tmp_path / "p.db") + return create_app(config, store=store, data_dir=tmp_path / "data", **kw), store + + +def login(app, store, username, role, password=PW) -> TestClient: + if store.user_by_username(username) is None: + store.create_user(username, hash_password(password), role, username.title()) + c = TestClient(app) + res = c.post("/api/auth/login", json={"username": username, "password": password}) + assert res.status_code == 200, res.text + c.headers["Authorization"] = f"Bearer {res.json()['token']}" + return c + + +def wait_run(c: TestClient, run_id: str, timeout: float = 120) -> dict: + deadline = time.time() + timeout + while time.time() < deadline: + body = c.get(f"/api/runs/{run_id}").json() + if "pending" not in body: + return body + assert body["pending"]["status"] != "failed", body["pending"].get("error") + time.sleep(0.2) + pytest.fail("run did not finish") + + +@pytest.fixture +def env(config, tmp_path): + app, store = make_app(config, tmp_path) + with TestClient(app): # runs the lifespan + admin = login(app, store, "admin", "admin") + manager = login(app, store, "maria", "manager") + counter = login(app, store, "carl", "counter") + yield {"app": app, "store": store, "admin": admin, "manager": manager, + "counter": counter, "tmp": tmp_path} + store.close() + + +@pytest.fixture(scope="module") +def scene(tmp_path_factory): + return demo.make_demo_video(tmp_path_factory.mktemp("plat") / "shelf.webm", seed=12) + + +def upload(c: TestClient, path: str, **form) -> str: + with open(path, "rb") as fh: + res = c.post("/api/runs/upload", files={"file": ("walk.webm", fh, "video/webm")}, + data={k: v for k, v in form.items() if v is not None}) + assert res.status_code == 202, res.text + return res.json()["run_id"] + + +# -- accounts ---------------------------------------------------------------------------------- +def test_first_run_setup_needs_the_console_code(config, tmp_path): + app, store = make_app(config, tmp_path) + c = TestClient(app) + assert c.get("/api/auth/status").json()["setup_needed"] is True + assert c.get("/api/runs").status_code == 401 + bad = c.post("/api/auth/setup", json={"setup_code": "nope", "username": "boss", "password": PW}) + assert bad.status_code == 403 + code = app.state.ctx.setup_token + ok = c.post("/api/auth/setup", json={"setup_code": code, "username": "boss", "password": PW, + "organisation": "Acme Foods"}) + assert ok.status_code == 200 and ok.json()["user"]["role"] == "admin" + # The cookie now signs the browser in. + assert c.get("/api/auth/me").json()["username"] == "boss" + again = c.post("/api/auth/setup", json={"setup_code": code, "username": "second", "password": PW}) + assert again.status_code == 409 + assert c.get("/api/auth/status").json()["organisation"] == "Acme Foods" + + +def test_cookie_writes_need_the_csrf_header(env): + app = env["app"] + c = TestClient(app) + c.post("/api/auth/login", json={"username": "maria", "password": PW}) + assert c.get("/api/locations").status_code == 200 # reads are fine + body = {"code": "A1"} + assert c.post("/api/locations", json=body).status_code == 403 + assert c.post("/api/locations", json=body, headers={"X-Countbone": "1"}).status_code == 201 + + +def test_wrong_passwords_are_throttled_and_do_not_reveal_usernames(env): + c = TestClient(env["app"]) + for _ in range(5): + assert c.post("/api/auth/login", json={"username": "carl", "password": "wrong"}).status_code == 401 + assert c.post("/api/auth/login", json={"username": "carl", "password": "wrong"}).status_code == 401 + locked = c.post("/api/auth/login", json={"username": "carl", "password": PW}) + assert locked.status_code == 429 + # The lock covers the client address too (guessing across usernames), so + # the unknown-user check starts from a clean slate. + from countbone.security import LoginThrottle + env["app"].state.ctx.throttle = LoginThrottle() + unknown = TestClient(env["app"]).post("/api/auth/login", json={"username": "nobody", "password": "x"}) + assert unknown.status_code == 401 and unknown.json()["detail"] == "wrong username or password" + + +def test_other_peoples_failures_do_not_lock_out_a_shared_address(): + """Behind a proxy or a shop router everyone shares one address: a stranger's + wrong passwords must not lock a real user out.""" + from countbone.security import LoginThrottle + + t = LoginThrottle() + shared = "172.18.0.3" + for i in range(t.FREE_CLIENT): + t.failed(f"stranger{i}", shared) + assert t.retry_after("alice", shared) == 0 + t.failed("stranger-last", shared) # past the address's allowance: now it bites + assert t.retry_after("alice", shared) > 0 + + +def test_throttle_forgets_old_failures_and_stays_bounded(monkeypatch): + import countbone.security as sec + + now = [1000.0] + monkeypatch.setattr(sec.time, "time", lambda: now[0]) + t = sec.LoginThrottle() + for _ in range(t.FREE_USER + 3): + t.failed("carl", "1.2.3.4") + assert t.retry_after("carl", "1.2.3.4") > 0 + now[0] += t.MAX_LOCK_S + t.WINDOW_S + 1 + assert t.retry_after("carl", "1.2.3.4") == 0 + t.failed("carl", "1.2.3.4") # counting starts again, not from where it left off + assert t.retry_after("carl", "1.2.3.4") == 0 + + t.MAX_KEYS = 50 + for i in range(500): + t.failed(f"u{i}", f"10.0.{i // 250}.{i % 250}") + assert len(t._fails) <= t.MAX_KEYS + + +def test_serve_trusts_only_the_named_proxy(monkeypatch): + import uvicorn + + from countbone import cli + + seen = {} + monkeypatch.setattr(uvicorn, "run", lambda app, **kw: seen.update(kw)) + monkeypatch.setattr("countbone.api.app.create_app", lambda *a, **k: object()) + monkeypatch.delenv("FORWARDED_ALLOW_IPS", raising=False) + cli.main(["serve"]) + assert seen["proxy_headers"] is True and seen["forwarded_allow_ips"] == "127.0.0.1" + cli.main(["serve", "--forwarded-allow-ips", "10.0.0.5"]) + assert seen["forwarded_allow_ips"] == "10.0.0.5" + monkeypatch.setenv("FORWARDED_ALLOW_IPS", "*") + cli.main(["serve"]) + assert seen["forwarded_allow_ips"] == "*" + + +def test_counters_see_names_not_account_details(env): + counter_view = env["counter"].get("/api/users").json() + assert counter_view and all(set(u) == {"user_id", "username", "display_name", "role"} for u in counter_view) + assert "last_login_at" in env["manager"].get("/api/users").json()[0] + + +def test_finished_runs_do_not_pile_up_in_memory(): + from countbone.api.jobs import RunTracker + + t = RunTracker() + t.KEEP_FINISHED = 3 + for i in range(10): + t.set(f"run_{i}", status="queued") + t.set(f"run_{i}", status="done") + t.set("run_live", status="running") + ids = {r["run_id"] for r in t.all()} + assert ids == {"run_7", "run_8", "run_9", "run_live"} + assert "_touched" not in t.get("run_live") + + +def test_roles_are_enforced(env): + counter, manager = env["counter"], env["manager"] + assert counter.post("/api/users", json={"username": "z", "password": PW}).status_code == 403 + assert counter.post("/api/locations", json={"code": "B1"}).status_code == 403 + assert manager.post("/api/locations", json={"code": "B1"}).status_code == 201 + assert manager.put("/api/reconcile/rules", json={"auto_approve_max_value": 5}).status_code == 403 + assert counter.post("/api/runs", json={"path": "x.mp4"}).status_code == 403 # server paths: admin + + +def test_disabling_a_user_ends_their_sessions_and_the_last_admin_stays(env): + admin, store = env["admin"], env["store"] + carl = store.user_by_username("carl") + assert env["counter"].get("/api/auth/me").status_code == 200 + admin.patch(f"/api/users/{carl['user_id']}", json={"disabled": True}) + assert env["counter"].get("/api/auth/me").status_code == 401 + me = store.user_by_username("admin") + res = admin.patch(f"/api/users/{me['user_id']}", json={"role": "manager"}) + assert res.status_code == 409 + + +def test_api_keys_authenticate_machines_and_can_be_revoked(env): + admin = env["admin"] + made = admin.post("/api/api-keys", json={"name": "erp-sync", "role": "manager"}).json() + key = made["key"] + assert key.startswith("cbk_") + bot = TestClient(env["app"], headers={"Authorization": f"Bearer {key}"}) + assert bot.get("/api/locations").status_code == 200 + admin.delete(f"/api/api-keys/{made['key_id']}") + assert bot.get("/api/locations").status_code == 401 + assert key not in json.dumps(admin.get("/api/api-keys").json()) + + +def test_reviews_are_signed_by_the_signed_in_user(env, scene): + counter, store = env["counter"], env["store"] + run = wait_run(counter, upload(counter, scene.path)) + assert run["created_by_name"] == "Carl" + # A reviewer field in the body is ignored: the account decides. + from countbone.types import CountResult, ReviewItem, SkuCount + store.save_run(CountResult(run_id="run_r", source="x.mp4", counts=[SkuCount("SKU-RED", 1)], + reviews=[ReviewItem("rev_r", "run_r", "SKU-RED", "unidentified", 0.2, 1)])) + counter.post("/api/reviews/rev_r", json={"status": "accepted", "reviewer": "someone-else"}) + assert store.review("rev_r")["resolved_by"] == "carl" + trail = [a for a in store.audit_trail("run_r") if a["kind"] == "review_decision"] + assert trail[0]["actor"] == "carl" + assert store.verify_audit_chain()["ok"] + + +# -- resumable uploads --------------------------------------------------------------------------- +def test_a_resumable_upload_survives_retries_and_duplicates(env, scene): + c = env["counter"] + data = open(scene.path, "rb").read() + digest = hashlib.sha256(data).hexdigest() + start = c.post("/api/uploads", json={"filename": "aisle.webm", "size": len(data), + "sha256": digest, "client_id": "phone-1-rec-42"}).json() + uid = start["upload_id"] + half = len(data) // 2 + assert c.put(f"/api/uploads/{uid}", content=data[:half], headers={"Upload-Offset": "0"}).status_code == 204 + # The phone lost the reply and resends the same chunk: acknowledged, not doubled. + again = c.put(f"/api/uploads/{uid}", content=data[:half], headers={"Upload-Offset": "0"}) + assert again.headers["Upload-Offset"] == str(half) + # The app restarts and asks where it was, by its own id. + resumed = c.post("/api/uploads", json={"filename": "aisle.webm", "size": len(data), + "client_id": "phone-1-rec-42"}).json() + assert resumed["upload_id"] == uid and resumed["offset"] == half + # A gap is refused with where to resume from. + gap = c.put(f"/api/uploads/{uid}", content=data[-10:], headers={"Upload-Offset": str(len(data) - 10)}) + assert gap.status_code == 409 and gap.headers["Upload-Offset"] == str(half) + c.put(f"/api/uploads/{uid}", content=data[half:], headers={"Upload-Offset": str(half)}) + done = c.post(f"/api/uploads/{uid}/complete").json() + twice = c.post(f"/api/uploads/{uid}/complete").json() + assert twice["run_id"] == done["run_id"] and twice["duplicate"] + run = wait_run(c, done["run_id"]) + assert run["total"] == scene.total + + +def test_a_corrupted_upload_is_rejected_and_restarts(env, scene): + c = env["counter"] + data = open(scene.path, "rb").read() + uid = c.post("/api/uploads", json={"filename": "a.webm", "size": len(data), + "sha256": "0" * 64}).json()["upload_id"] + c.put(f"/api/uploads/{uid}", content=data, headers={"Upload-Offset": "0"}) + res = c.post(f"/api/uploads/{uid}/complete") + assert res.status_code == 422 + assert c.get(f"/api/uploads/{uid}").json()["offset"] == 0 + + +def test_an_oversized_chunk_is_refused_without_being_kept(env, monkeypatch): + from countbone.api import routes_runs + + monkeypatch.setattr(routes_runs, "CHUNK_LIMIT", 1024) + c = env["counter"] + uid = c.post("/api/uploads", json={"filename": "a.webm", "size": 4096}).json()["upload_id"] + res = c.put(f"/api/uploads/{uid}", content=b"x" * 2048, headers={"Upload-Offset": "0"}) + assert res.status_code == 413 + assert c.get(f"/api/uploads/{uid}").json()["offset"] == 0 + + +def test_a_recording_id_of_another_user_is_refused_for_good(env): + env["counter"].post("/api/uploads", json={"filename": "a.webm", "size": 10, "client_id": "rec-x"}) + res = env["manager"].post("/api/uploads", json={"filename": "a.webm", "size": 10, "client_id": "rec-x"}) + assert res.status_code == 403 # the phone must not retry this forever + + +def test_abandoned_uploads_are_cleared_and_can_start_again(env): + c, store, app = env["counter"], env["store"], env["app"] + start = c.post("/api/uploads", json={"filename": "a.webm", "size": 8, "client_id": "rec-old"}).json() + uid = start["upload_id"] + c.put(f"/api/uploads/{uid}", content=b"abcd", headers={"Upload-Offset": "0"}) + path = Path(store.get_upload(uid)["path"]) + store._exec("UPDATE uploads SET updated_at = 0 WHERE upload_id = ?", (uid,)) + app.state.ctx.extra["expire_stale_uploads"](force=True) + assert not path.exists() and store.get_upload(uid)["status"] == "expired" + assert c.post(f"/api/uploads/{uid}/complete").status_code == 409 + again = c.post("/api/uploads", json={"filename": "a.webm", "size": 8, "client_id": "rec-old"}).json() + assert again["upload_id"] == uid and again["offset"] == 0 and again["status"] == "open" + assert c.put(f"/api/uploads/{uid}", content=b"abcdefgh", headers={"Upload-Offset": "0"}).status_code == 204 + + +def test_the_checksum_can_arrive_with_the_last_chunk(env): + c = env["counter"] + data = b"not really a video, but bytes all the same" + uid = c.post("/api/uploads", json={"filename": "a.webm", "size": len(data)}).json()["upload_id"] + c.put(f"/api/uploads/{uid}", content=data, headers={"Upload-Offset": "0"}) + bad = c.post(f"/api/uploads/{uid}/complete", json={"sha256": "0" * 64}) + assert bad.status_code == 422 and c.get(f"/api/uploads/{uid}").json()["offset"] == 0 + c.put(f"/api/uploads/{uid}", content=data, headers={"Upload-Offset": "0"}) + ok = c.post(f"/api/uploads/{uid}/complete", json={"sha256": hashlib.sha256(data).hexdigest()}) + assert ok.status_code == 202 + + +def test_someone_elses_upload_is_invisible(env, scene): + uid = env["counter"].post("/api/uploads", json={"filename": "a.webm", "size": 10}).json()["upload_id"] + assert env["manager"].get(f"/api/uploads/{uid}").status_code == 404 + + +# -- locations, labels, reconcile ---------------------------------------------------------------- +def _book(env, code, truth, delta_sku=None, delta=0): + m = env["manager"] + m.post("/api/locations", json={"code": code, "name": f"Bay {code}"}) + book = dict(truth) + if delta_sku: + book[delta_sku] = book.get(delta_sku, 0) + delta + m.put(f"/api/locations/{code}/expected", json={"quantities": book, "replace": True}) + return book + + +def test_labels_print_and_decode(env): + m = env["manager"] + m.post("/api/locations", json={"code": "A07-B03", "name": "Aisle 7 bay 3"}) + svg = m.get("/api/labels/A07-B03.svg") + assert svg.status_code == 200 and svg.text.lstrip().startswith(" 2. + assert adj["status"] == "proposed" and adj["rule"] == "manager" + assert counter.post(f"/api/adjustments/{adj['adjustment_id']}/approve", json={}).status_code == 403 + ok = manager.post(f"/api/adjustments/{adj['adjustment_id']}/approve", json={"note": "ok"}) + assert ok.json()["status"] == "approved" and ok.json()["decided_by_name"] == "Maria" + csv = manager.get("/api/adjustments/export.csv").text + assert "SKU-RED" in csv and "-3" in csv + report = manager.get("/api/reconcile/report").json() + assert report["by_status"]["approved"]["count"] == 1 + assert store.verify_audit_chain()["ok"] + + +def test_nobody_approves_a_change_they_counted_themselves(env, scene): + manager, admin, store = env["manager"], env["admin"], env["store"] + _book(env, "A9", scene.truth, "SKU-RED", +3) + run = wait_run(manager, upload(manager, scene.path, location="A9")) # the manager films it + time.sleep(0.2) + task_id = manager.get("/api/tasks?location=A9").json()[0]["task_id"] + cara = login(env["app"], store, "cara", "counter") + cara.post(f"/api/tasks/{task_id}/complete", json={"count": scene.truth["SKU-RED"]}) + adj = next(a for a in admin.get("/api/adjustments").json() if a["location"] == "A9") + assert adj["status"] == "proposed" and adj["run_id"] == run["run_id"] + own = manager.post(f"/api/adjustments/{adj['adjustment_id']}/approve", json={}) + assert own.status_code == 403 and "someone else" in own.json()["detail"] + assert admin.post(f"/api/adjustments/{adj['adjustment_id']}/approve", json={}).json()["status"] == "approved" + # A one-person shop can switch the rule off. + assert admin.put("/api/reconcile/rules", json={"four_eyes": False}).status_code == 200 + assert admin.put("/api/reconcile/rules", json={"four_eyes": "yes"}).status_code == 400 + + +def test_a_recount_video_by_the_first_counter_leaves_the_task_open(env, scene): + counter, store = env["counter"], env["store"] + _book(env, "A8", scene.truth, "SKU-RED", +3) + wait_run(counter, upload(counter, scene.path, location="A8")) + time.sleep(0.2) + task = counter.get("/api/tasks?location=A8").json()[0] + wait_run(counter, upload(counter, scene.path, location="A8", kind="recount", task_id=task["task_id"])) + time.sleep(0.3) + after = store.get_task(task["task_id"]) + assert after["status"] == "open" and "not accepted" in after["result"]["note"] + assert any(e["kind"] == "recount_refused" for e in store.audit_trail(task["task_id"])) + + +def test_small_differences_approve_themselves_under_the_rules(env, scene): + counter, admin = env["counter"], env["admin"] + admin.put("/api/reconcile/rules", json={"recount_policy": "above_auto"}) + _book(env, "A2", scene.truth, "SKU-YEL", +1) # 1 unit x 3.20 = 3.20 + wait_run(counter, upload(counter, scene.path, location="A2")) + time.sleep(0.2) + adj = [a for a in counter.get("/api/adjustments").json() if a["location"] == "A2"] + assert len(adj) == 1 and adj[0]["status"] == "approved" and adj[0]["rule"] == "auto" + assert not counter.get("/api/tasks?location=A2").json() + + +def test_a_count_matching_the_book_creates_nothing(env, scene): + counter = env["counter"] + _book(env, "A3", scene.truth) + wait_run(counter, upload(counter, scene.path, location="A3")) + time.sleep(0.2) + assert not [a for a in counter.get("/api/adjustments").json() if a["location"] == "A3"] + + +def test_approved_adjustments_post_through_a_webhook(config, tmp_path, scene): + received = [] + + def handler(request: httpx.Request) -> httpx.Response: + from countbone.integrations.webhook import verify + assert verify("s3cret", request.content, request.headers["X-Countbone-Signature"]) + received.append(json.loads(request.content)) + return httpx.Response(200) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + app, store = make_app(config, tmp_path, http_client=client) + with TestClient(app): + admin = login(app, store, "admin", "admin") + admin.put("/api/integrations", json={"name": "erp", "kind": "webhook", + "settings": {"url": "https://erp.example/hook"}, + "secrets": {"secret": "s3cret"}}) + assert admin.post("/api/integrations/erp/test").json()["ok"] + admin.put("/api/reconcile/rules", json={"recount_policy": "none", "post_to": "erp", + "auto_post": True}) + admin.post("/api/locations", json={"code": "W1"}) + book = dict(scene.truth) + book["SKU-GRN"] = book.get("SKU-GRN", 0) + 1 + admin.put("/api/locations/W1/expected", json={"quantities": book}) + wait_run(admin, upload(admin, scene.path, location="W1")) + time.sleep(0.3) + adj = [a for a in admin.get("/api/adjustments").json() if a["location"] == "W1"] + assert adj[0]["status"] == "posted" and adj[0]["integration"] == "erp" + events = [r for r in received if r["event"] == "adjustments.approved"] + assert events[0]["data"]["adjustments"][0]["delta"] == -1 + # Credentials are stored sealed, never returned. + listed = admin.get("/api/integrations").json()[0] + assert "secrets" not in listed and listed["has_secrets"] + raw = store.get_integration("erp")["secrets"] + assert b"s3cret" not in raw + store.close() + + +# -- receive and evidence ------------------------------------------------------------------------ +def test_a_short_delivery_drafts_a_claim_with_a_verifiable_pack(env, scene): + manager, counter, store = env["manager"], env["counter"], env["store"] + lines = {sku: {"qty": n, "unit_cost": 2.0} for sku, n in scene.truth.items()} + lines["SKU-BLU"]["qty"] += 2 # ordered two more than arrived + receipt = manager.post("/api/receipts", json={"po_number": "PO-1001", "supplier": "Acme", + "lines": lines}).json() + run_id = upload(counter, scene.path, kind="receive", receipt_id=receipt["receipt_id"]) + wait_run(counter, run_id) + time.sleep(0.3) + got = manager.get(f"/api/receipts/{receipt['receipt_id']}").json() + assert got["status"] == "discrepancy" + assert got["discrepancies"] == [{"sku": "SKU-BLU", "ordered": lines["SKU-BLU"]["qty"], + "received": scene.truth["SKU-BLU"], "difference": -2, + "unit_cost": 2.0, "value": -4.0}] + claim = got["claims"][0] + assert claim["kind"] == "supplier_shortage" and claim["amount"] == 4.0 + assert claim["counterparty"] == "Acme" + + built = manager.post(f"/api/claims/{claim['claim_id']}/pack").json() + data = manager.get(f"/api/claims/{claim['claim_id']}/pack.zip").content + assert hashlib.sha256(data).hexdigest() == built["sha256"] + names = zipfile.ZipFile(io.BytesIO(data)).namelist() + assert {"report.html", "manifest.json", "signature.json", "verify.py", "custody.json"} <= set(names) + assert f"runs/{run_id}/contact_sheet.jpg" in names + + ok = manager.post("/api/evidence/verify", files={"file": ("p.zip", data)}).json() + assert ok["ok"] and ok["signature_ok"] and ok["trusted_key"] + + # Change one byte of one file: the pack no longer verifies. + src = zipfile.ZipFile(io.BytesIO(data)) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as out: + for n in src.namelist(): + body = src.read(n) + if n == "report.html": + body = body.replace(b"Acme", b"Acmf") + out.writestr(n, body) + bad = manager.post("/api/evidence/verify", files={"file": ("p.zip", buf.getvalue())}).json() + assert not bad["ok"] and "altered file: report.html" in bad["problems"] + + # The standalone checker agrees. + import subprocess + import sys + pack = env["tmp"] / "pack.zip" + pack.write_bytes(data) + script = env["tmp"] / "verify.py" + script.write_bytes(src.read("verify.py")) + out = subprocess.run([sys.executable, str(script), str(pack)], capture_output=True, text=True) + assert out.returncode == 0 and "signature: OK" in out.stdout + + # Closing a delivery with discrepancies is a manager's call. + assert counter.post(f"/api/receipts/{receipt['receipt_id']}/close", json={}).status_code == 403 + assert manager.post(f"/api/receipts/{receipt['receipt_id']}/close", json={}).json()["status"] == "closed" + manager.patch(f"/api/claims/{claim['claim_id']}", json={"status": "recovered", "recovered_amount": 4.0}) + assert store.get_claim(claim["claim_id"])["recovered_amount"] == 4.0 + + +def test_a_receipt_can_be_imported_from_csv(env): + m = env["manager"] + csv = "Item,Quantity,Unit Price\nSKU-RED,10,4.50\nSKU-BLU,5,5.10\n" + res = m.post("/api/receipts/import", data={"po_number": "PO-7", "supplier": "Z"}, + files={"file": ("po.csv", csv.encode())}) + assert res.status_code == 201 + lines = {x["sku"]: x for x in res.json()["lines"]} + assert lines["SKU-RED"]["expected_qty"] == 10 and lines["SKU-BLU"]["unit_cost"] == 5.1 + bad = m.post("/api/receipts/import", data={"po_number": "PO-8"}, + files={"file": ("po.csv", b"sku,qty\nA,ten\n")}) + assert bad.status_code == 400 and "line 2" in bad.json()["detail"] + + +# -- walks: two videos of one place --------------------------------------------------------------- +def test_a_walk_merges_its_videos(env, tmp_path): + c = env["counter"] + env["manager"].post("/api/locations", json={"code": "M1"}) + full = demo.make_demo_video(tmp_path / "full.webm", seed=8) + a = demo.make_demo_video(tmp_path / "a.webm", seed=8, window=(0.0, 0.6), seconds=4.0) + b = demo.make_demo_video(tmp_path / "b.webm", seed=8, window=(0.4, 1.0), seconds=4.0) + walk = c.post("/api/walks", json={"location": "M1"}).json() + for v in (a, b): + wait_run(c, upload(c, v.path, walk_id=walk["walk_id"])) + merged = c.get(f"/api/walks/{walk['walk_id']}").json() + assert merged["total"] == full.total and merged["counts"] == full.truth + assert merged["naive_total"] > full.total and merged["duplicates_removed"] > 0 + + +# -- catalog studio ------------------------------------------------------------------------------ +def test_studio_enrols_photos_and_identifies_a_new_one(env): + m = env["manager"] + for sku, spec in demo.LOOKALIKE_SKUS.items(): + assert m.post("/api/studio/skus", json={"sku": sku, "label": spec["label"], + "unit_value": 3.0}).status_code == 201 + files = [] + for j, photo in enumerate(demo.product_photos(sku, 5, seed=40)): + ok, enc = cv2.imencode(".jpg", photo) + files.append(("files", (f"{sku}{j}.jpg", enc.tobytes(), "image/jpeg"))) + res = m.post(f"/api/studio/skus/{sku}/photos", files=files) + assert res.status_code == 201 and len(res.json()["added"]) == 5 + quality = m.get("/api/studio/quality").json() + assert quality["enrolled"] == len(demo.LOOKALIKE_SKUS) + products = quality["products"] + assert all(p["photos"] == 5 and p["status"] != "needs photos" for p in products.values()) + # Plain red against red-with-a-dark-band is a genuinely close pair (their + # self-recognition sits near the 90% bar and lands either side of it on + # different OpenCV builds). When the studio calls one confusable, it + # must name a real look-alike from the same red family. + for sku, p in products.items(): + if p["status"] == "confusable": + assert sku.startswith("RED") and all(o.startswith("RED") for o in p["confused_with"]) + assert products["BLU-PLAIN"]["status"] == "ready" + probe = demo.product_photos("RED-DOT", 1, seed=999)[0] + ok, enc = cv2.imencode(".jpg", probe) + guess = m.post("/api/studio/identify", files={"file": ("p.jpg", enc.tobytes())}).json() + assert guess["decision"] == "RED-DOT" + assert m.get("/api/health").json()["identify"] in ("appearance", "color") + cat = {e["sku"]: e for e in m.get("/api/catalog").json()} + assert cat["RED-DOT"]["enrolled"] is True + bad = m.post("/api/studio/skus/RED-DOT/photos", files={"files": ("x.jpg", b"not an image")}) + assert bad.status_code == 400 + + +def test_photographing_one_product_does_not_break_colour_counting(env, scene): + """Regression (found in the phone preview): enrolling a single product by + photo, with no colour band, turned every colour-identified product into + UNKNOWN. Its colour is now learned from its photos, so only the products + it actually resembles are held back, and the studio names them.""" + m, store = env["manager"], env["store"] + m.post("/api/studio/skus", json={"sku": "RED-DOT", "label": "Red carton, black dot"}) + files = [] + for j, photo in enumerate(demo.product_photos("RED-DOT", 5, seed=7)): + ok, enc = cv2.imencode(".jpg", photo) + files.append(("files", (f"d{j}.jpg", enc.tobytes(), "image/jpeg"))) + m.post("/api/studio/skus/RED-DOT/photos", files=files) + assert store.get_sku("RED-DOT")["hue"] is not None # learned, not left blank + + run = wait_run(m, upload(m, scene.path)) + counts = {c["sku"]: c["count"] for c in run["counts"]} + for sku in ("SKU-BLU", "SKU-GRN", "SKU-YEL"): + assert counts.get(sku) == scene.truth[sku], (sku, counts) + # Plain red cartons are not filed as the red look-alike... + assert counts.get("RED-DOT", 0) == 0 + # ...and the studio says why they went to review. + quality = m.get("/api/studio/quality").json() + conflicts = quality["colour_conflicts"] + assert any(c["sku"] == "SKU-RED" and "RED-DOT" in c["shares_colour_with"] for c in conflicts) + # Five photos and an unphotographed look-alike: the studio asks for more. + assert quality["products"]["RED-DOT"]["more_photos_advised"] is True + + +# -- service jobs and the data engine -------------------------------------------------------------- +def test_consented_service_runs_export_as_coco(env, scene): + m, c, store = env["manager"], env["counter"], env["store"] + site = m.post("/api/sites", json={"name": "DC North", "customer": "Acme"}).json() + carl = store.user_by_username("carl")["user_id"] + job = m.post("/api/service-jobs", json={"site_id": site["site_id"], "title": "Monthly count", + "crew": [carl], "data_consent": True}).json() + assert [j["job_id"] for j in c.get("/api/service-jobs?mine=true").json()] == [job["job_id"]] + c.patch(f"/api/service-jobs/{job['job_id']}", json={"status": "in_progress"}) + assert c.patch(f"/api/service-jobs/{job['job_id']}", json={"title": "x"}).status_code == 403 + wait_run(c, upload(c, scene.path, job_id=job["job_id"])) + data = env["admin"].get("/api/datasets/coco.zip?frames_per_run=2").content + zf = zipfile.ZipFile(io.BytesIO(data)) + coco = json.loads(zf.read("annotations.json")) + assert len(coco["images"]) == 2 and coco["annotations"] + assert {c_["name"] for c_ in coco["categories"]} <= set(scene.truth) + assert zf.read(coco["images"][0]["file_name"])[:2] == b"\xff\xd8" # a JPEG + + +# -- durability ------------------------------------------------------------------------------------ +def test_queued_runs_survive_a_restart(config, tmp_path, scene): + store = Store(tmp_path / "d.db") + store.create_user("admin", hash_password(PW), "admin") + # A run was queued when the server stopped. + store.enqueue_job("run_before_restart", scene.path, {"kind": "count"}, "shelf.webm", None) + app, _ = make_app(config, tmp_path, store=store) + with TestClient(app): + c = login(app, store, "admin", "admin") + run = wait_run(c, "run_before_restart") + assert run["total"] == scene.total + assert store.get_job("run_before_restart")["status"] == "done" + store.close() + + +def test_a_v1_database_upgrades_in_place(tmp_path): + import sqlite3 + + from countbone.store.schema import V1 + + path = tmp_path / "old.db" + con = sqlite3.connect(path) + con.executescript(V1) + con.execute("INSERT INTO runs (run_id, source, started_at) VALUES ('run_old', 'x.mp4', 1)") + con.execute("INSERT INTO audit (run_id, kind, payload, created_at) VALUES ('run_old', 'x', '{}', 1)") + con.commit() + con.close() + store = Store(path) + assert store.schema_version == 2 + assert store.get_run("run_old")["kind"] == "count" + store.add_audit("run_old", "after_upgrade", {}) + chain = store.verify_audit_chain() + assert chain["ok"] and chain["legacy_rows"] == 1 and chain["checked"] == 1 + store.close() diff --git a/tests/test_plugins.py b/tests/test_plugins.py index a3cbed2..f151e68 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -141,13 +141,32 @@ def test_quality_gate_warns_when_most_footage_is_unusable(): # -- analytics layer ------------------------------------------------------- -def test_tolerance_bands_scale_with_unit_value(): +def test_tolerance_flags_every_miss_by_default(): rules = ToleranceRules() + assert rules.band_for(2.0)["abs"] == 0 + assert rules.band_for(5000.0)["abs"] == 0 + + +def test_tolerance_bands_scale_with_unit_value(): + rules = ToleranceRules(bands=[ + {"max_unit_value": 10.0, "abs": 2, "pct": 0.03}, + {"max_unit_value": 100.0, "abs": 1, "pct": 0.01}, + {"max_unit_value": None, "abs": 0, "pct": 0.0}, + ]) assert rules.band_for(2.0)["abs"] == 2 # cheap stock, slack allowed assert rules.band_for(50.0)["abs"] == 1 assert rules.band_for(5000.0)["abs"] == 0 # expensive stock, none allowed +def test_a_cheap_one_unit_miss_is_flagged_by_default(): + from countbone.catalog import Catalog, SkuEntry + + ctx = RunContext(config=Config(), source="x") + ctx.state["catalog"] = Catalog([SkuEntry("SKU-C", "Carton", unit_value=2.0)]) + result = CountResult(run_id="r", source="x", counts=[SkuCount("SKU-C", count=9, expected=10)]) + assert ToleranceRules().on_counts(ctx, result).needs_review + + def test_tolerance_flags_a_breach_and_prices_it(): from countbone.catalog import Catalog, SkuEntry @@ -177,6 +196,12 @@ def test_tolerance_ignores_skus_with_no_expected_count(): def test_every_review_item_gets_its_own_crop(demo_scene, config): """Regression: identical cartons in one frame overwrote each other's crop, so a reviewer was shown evidence belonging to a different item.""" + # Review every sighting: the demo is now clean enough to raise none by + # itself, and this test is about crops, not about what gets flagged. + for spec in config.plugins: + if spec.name == "review_queue": + spec.options = {**(spec.options or {}), "item_threshold": 1.01, + "settled_agreement": 1.01} result = Pipeline(config).run(demo_scene.path) crops = [r.crop_path for r in result.reviews if r.crop_path] diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..d1694e0 --- /dev/null +++ b/web/README.md @@ -0,0 +1,50 @@ +# countbone dashboard + +React + TypeScript + Tailwind v4, built with Vite into `src/countbone/api/static/`. +`countbone serve` serves that build, so **running the dashboard never needs Node**. +You need Node only to change it. + +```bash +cd web +npm install +npm run dev # http://localhost:5173, proxies /api to countbone serve on :8000 +npm run build # typecheck, then write the build into the Python package +``` + +Run `countbone serve` alongside `npm run dev`. Commit the rebuilt `static/` with any change +to `web/src`, because that build is what ships. + +## Layout + +``` +src/ + lib/ api client and types, queries, routing, status tiers, export + components/ ui primitives (Button, Badge, ConfidenceBar, …) and the sidebar + features/ + upload/ dropzone, preview, upload with progress + pipeline/ live six-stage tracker and telemetry readouts + results/ metric cards, counts table, export + review/ "Items to check" drawer: crop vs candidate SKUs, recounts + inspector/ video player with box overlay and frame timeline + run/ the run page that composes the above +``` + +## Where the data comes from + +Everything shown comes from the pipeline. Nothing is simulated. + +| UI | Source | +| --- | --- | +| Live stage progress, FPS, blur, lighting | `api/telemetry.py`: two plugins the API attaches, published through `GET /api/runs/{id}` while the run is pending | +| Box overlay, frame timeline | `inspector.json`, written per run by the same telemetry and hashed into the audit pack | +| SKU swatches, candidate ranking | `GET /api/catalog`: each SKU's hue band, the same evidence the colour identifier uses | +| Recounts | `POST /api/reviews/{id}` with `resolved_count`. Stored beside the machine count, never over it, and logged to the audit trail | + +## Conventions + +- Colours come only from the tokens in `src/index.css`. Every text/background pair is measured + at 4.5:1 or better, so check the numbers there before adding a colour. +- Status is never shown by colour alone: tiers carry a label and confidence carries a number. +- Every action has a keyboard path. The review drawer is built to be driven with `J`/`K`, `1`–`9`, + `A`, `R` and `Z`. +- Animation respects `prefers-reduced-motion` (see `MotionConfig` in `main.tsx`). diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..cf92269 --- /dev/null +++ b/web/index.html @@ -0,0 +1,16 @@ + + + + + + + + + countbone + + +
+ + + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..1e077a5 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2090 @@ +{ + "name": "countbone-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "countbone-web", + "version": "0.1.0", + "dependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@tanstack/react-query": "^5.104.0", + "lucide-react": "^1.48.0", + "motion": "^13.4.4", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.3.1" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "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.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.104.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.0.tgz", + "integrity": "sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.0.tgz", + "integrity": "sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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/framer-motion": { + "version": "13.4.4", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.4.4.tgz", + "integrity": "sha512-lbsZO95NGbulo6apz70zEt6Vxf/anoRwQI2ECEc/EQYWO5CGbITtl7plDW8P/VXgXw6e8wrbhH4a9i+ygke0Mg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^13.4.4", + "motion-utils": "^13.3.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "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/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/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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, + "libc": [ + "glibc" + ], + "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, + "libc": [ + "musl" + ], + "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/lucide-react": { + "version": "1.48.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.48.0.tgz", + "integrity": "sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/motion": { + "version": "13.4.4", + "resolved": "https://registry.npmjs.org/motion/-/motion-13.4.4.tgz", + "integrity": "sha512-lyX5kpAum2MmigecKWrFdIj3Sjn7pHek2EUrXY0WGZbOjJ1f5NfX0g4KQAb2Bp2EqL0a6dUzse11s1n7OYr5jg==", + "license": "MIT", + "dependencies": { + "framer-motion": "^13.4.4", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "13.4.4", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.4.4.tgz", + "integrity": "sha512-z2qN3RUABSci4G7cr5aHTFhqPNCWJsEMMfRlzxqtANQsSCIbVJmHvMV288m5x7doEQBTYbRTWbKdztmquCn4Sw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^13.3.0" + } + }, + "node_modules/motion-utils": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.3.0.tgz", + "integrity": "sha512-sgSschQp7EseHInIlR7hBbMuvet3RA0bs28KPZAXJcGKGdxHGvh1ogpYDilY3bOMtl73EqPNmp75sAKHYPU5sg==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "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/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.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "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.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/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/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "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.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..4679fd7 --- /dev/null +++ b/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "countbone-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "typecheck": "tsc -b", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@tanstack/react-query": "^5.104.0", + "lucide-react": "^1.48.0", + "motion": "^13.4.4", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.3.1" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..341fdf6 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,122 @@ +import { lazy, Suspense, useEffect, useState } from "react"; +import { AnimatePresence, motion } from "motion/react"; +import { Menu, X } from "lucide-react"; +import { Sidebar } from "@/components/Sidebar"; +import { IconButton } from "@/components/ui"; +import { AuthGate } from "@/features/auth/AuthGate"; +import { UploadPanel } from "@/features/upload/UploadPanel"; +import { RunView } from "@/features/run/RunView"; +import { navigate, useRoute, type Route } from "@/lib/route"; +import { Skeleton } from "@/components/ui"; + +// Each area loads when first opened: someone filming a count never downloads Reconcile or Settings. +const RunsPage = lazy(() => import("@/features/runs/RunsPage").then((m) => ({ default: m.RunsPage }))); +const TasksPage = lazy(() => import("@/features/tasks/TasksPage").then((m) => ({ default: m.TasksPage }))); +const ReconcilePage = lazy(() => import("@/features/reconcile/ReconcilePage").then((m) => ({ default: m.ReconcilePage }))); +const ReceivePage = lazy(() => import("@/features/receive/ReceivePage").then((m) => ({ default: m.ReceivePage }))); +const EvidencePage = lazy(() => import("@/features/evidence/EvidencePage").then((m) => ({ default: m.EvidencePage }))); +const LocationsPage = lazy(() => import("@/features/locations/LocationsPage").then((m) => ({ default: m.LocationsPage }))); +const WalksPage = lazy(() => import("@/features/walks/WalksPage").then((m) => ({ default: m.WalksPage }))); +const StudioPage = lazy(() => import("@/features/studio/StudioPage").then((m) => ({ default: m.StudioPage }))); +const ServicePage = lazy(() => import("@/features/service/ServicePage").then((m) => ({ default: m.ServicePage }))); +const SettingsPage = lazy(() => import("@/features/settings/SettingsPage").then((m) => ({ default: m.SettingsPage }))); + +function Content({ route }: { route: Route }) { + if (route.view === "new") return ; + if (route.view === "run") return ; + const { page, id, tab } = route; + switch (page) { + case "runs": + return ; + case "tasks": + return ; + case "reconcile": + return ; + case "receive": + return ; + case "evidence": + return ; + case "locations": + return ; + case "walks": + return ; + case "studio": + return ; + case "service": + return ; + case "settings": + return ; + } +} + +export function App() { + const route = useRoute(); + const [navOpen, setNavOpen] = useState(false); + + // Close the mobile nav whenever the route changes. + useEffect(() => setNavOpen(false), [route]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const t = e.target as HTMLElement; + if (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA") return; + if (e.metaKey || e.ctrlKey || e.altKey || document.querySelector('[role="dialog"]')) return; + if (e.key.toLowerCase() === "n") navigate({ view: "new" }); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + return ( + +
+ + Skip to content + + +
+ +
+ + + {navOpen && ( + <> + setNavOpen(false)} + /> + + + setNavOpen(false)} /> + + + )} + + +
+
+ setNavOpen(true)} /> + countbone +
+
+
}> + + + +
+ +
+ ); +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx new file mode 100644 index 0000000..35a8760 --- /dev/null +++ b/web/src/components/Sidebar.tsx @@ -0,0 +1,251 @@ +import { + AlertTriangle, + Boxes, + Camera, + CheckCircle2, + ClipboardList, + FileCheck2, + Footprints, + History, + Loader2, + LogOut, + MapPin, + Plus, + Scale, + ScanLine, + Settings as SettingsIcon, + Truck, + Users, + XCircle, + type LucideIcon, +} from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, type LiveRun, type Role, type RunSummary } from "@/lib/api"; +import { cn } from "@/lib/cn"; +import { ago, runTitle } from "@/lib/format"; +import { keys, useAuth, useRuns } from "@/lib/queries"; +import { pageHref, runHref, type Page, type Route } from "@/lib/route"; +import { Kbd } from "./ui"; + +function Logo({ org }: { org: string | null }) { + return ( + + + + + + countbone + {org && {org}} + + + ); +} + +interface NavItem { + page: Page; + label: string; + icon: LucideIcon; + role?: Role; + badge?: number; + tone?: "warn" | "accent"; +} + +const RANK: Record = { counter: 0, manager: 1, admin: 2 }; + +function NavLink({ item, active }: { item: NavItem; active: boolean }) { + return ( + + + {item.label} + {!!item.badge && ( + + {item.badge} + + )} + + ); +} + +interface Entry { + runId: string; + title: string; + sub: string; + state: "live" | "failed" | "review" | "clean"; +} + +function toEntries(inFlight: LiveRun[], runs: RunSummary[]): Entry[] { + const done = new Set(runs.map((r) => r.run_id)); + const live: Entry[] = inFlight + .filter((r) => !done.has(r.run_id) && r.status !== "done") + .map((r) => ({ + runId: r.run_id, + title: runTitle(r.run_id, r.source, r.filename), + sub: r.status === "failed" ? "Failed" : r.status === "queued" ? "Queued" : "Counting…", + state: r.status === "failed" ? "failed" : "live", + })); + const names = new Map(inFlight.map((r) => [r.run_id, r.filename])); + const finished: Entry[] = runs.map((r) => ({ + runId: r.run_id, + title: r.location ? `${r.location} · ${runTitle(r.run_id, r.source, names.get(r.run_id))}` : runTitle(r.run_id, r.source, names.get(r.run_id)), + sub: `${r.total} units · ${ago(r.started_at)}`, + state: r.needs_review ? "review" : "clean", + })); + return [...live.reverse(), ...finished]; +} + +const STATE_ICON = { + live: , + failed: , + review: , + clean: , +}; + +export function Sidebar({ route }: { route: Route }) { + const qc = useQueryClient(); + const auth = useAuth(); + const me = auth.data?.user; + const role = me?.role ?? "counter"; + const runs = useRuns(); + const myTasks = useQuery({ queryKey: keys.tasks({ status: "open", mine: true }), queryFn: () => api.tasks({ status: "open", mine: true }), refetchInterval: 15_000 }); + const summary = useQuery({ queryKey: keys.adjSummary, queryFn: api.adjustmentSummary, refetchInterval: 15_000 }); + const entries = runs.data ? toEntries(runs.data.inFlight, runs.data.runs).slice(0, 8) : []; + const activeRun = route.view === "run" ? route.runId : null; + const activePage = route.view === "page" ? route.page : route.view === "run" ? "runs" : null; + const proposed = summary.data?.by_status.proposed?.count ?? 0; + + const groups: { title: string; items: NavItem[] }[] = [ + { + title: "Count", + items: [ + { page: "runs", label: "Counts", icon: History }, + { page: "walks", label: "Walks", icon: Footprints }, + { page: "tasks", label: "Recount tasks", icon: ClipboardList, badge: myTasks.data?.length, tone: "warn" }, + ], + }, + { + title: "Operate", + items: [ + { page: "receive", label: "Receive", icon: Truck }, + { page: "locations", label: "Locations", icon: MapPin }, + { page: "studio", label: "Catalog studio", icon: Camera }, + ], + }, + { + title: "Finance", + items: [ + { page: "reconcile", label: "Reconcile", icon: Scale, badge: proposed, tone: "accent" }, + { page: "evidence", label: "Evidence & claims", icon: FileCheck2 }, + ], + }, + { + title: "Admin", + items: [ + { page: "service", label: "Service jobs", icon: Users }, + { page: "settings", label: "Settings", icon: SettingsIcon, role: "admin" }, + ], + }, + ]; + + const signOut = async () => { + await api.logout().catch(() => undefined); + qc.clear(); + qc.invalidateQueries({ queryKey: keys.auth }); + }; + + return ( + + ); +} diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx new file mode 100644 index 0000000..d08834e --- /dev/null +++ b/web/src/components/ui.tsx @@ -0,0 +1,451 @@ +import { + forwardRef, + type ButtonHTMLAttributes, + type InputHTMLAttributes, + type ReactNode, + type SelectHTMLAttributes, + type TextareaHTMLAttributes, +} from "react"; +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/cn"; +import { pct } from "@/lib/format"; +import { TIER_LABEL, tierOf, type Tier } from "@/lib/status"; + +/* ------------------------------------------------------------------ Button */ + +type Variant = "primary" | "secondary" | "ghost" | "danger" | "success"; +type Size = "sm" | "md"; + +const VARIANTS: Record = { + primary: "bg-accent text-accent-fg hover:brightness-110 font-semibold", + secondary: "bg-raised text-fg border border-line hover:bg-hover hover:border-line-strong", + ghost: "text-muted hover:text-fg hover:bg-hover", + danger: "bg-raised text-bad border border-line hover:border-bad/60 hover:bg-bad/10", + success: "bg-ok text-accent-fg hover:brightness-110 font-semibold", +}; + +const SIZES: Record = { + // 32px visual height on desktop; the hit area is padded out to 44px on touch. + sm: "h-7 px-2.5 text-xs gap-1.5", + md: "h-8 px-3 text-[13px] gap-2", +}; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; + size?: Size; + icon?: LucideIcon; + loading?: boolean; + kbd?: string; +} + +export const Button = forwardRef(function Button( + { variant = "secondary", size = "md", icon: Icon, loading, kbd, className, children, disabled, ...rest }, + ref, +) { + return ( + + ); +}); + +export function IconButton({ + icon: Icon, + label, + className, + active, + ...rest +}: ButtonHTMLAttributes & { icon: LucideIcon; label: string; active?: boolean }) { + return ( + + ); +} + +export const Spinner = ({ className }: { className?: string }) => ( + +); + +/* -------------------------------------------------------------------- Kbd */ + +export const Kbd = ({ children, className }: { children: ReactNode; className?: string }) => ( + + {children} + +); + +/* ------------------------------------------------------------------ Badge */ + +type Tone = "neutral" | "ok" | "warn" | "bad" | "accent" | "info"; +const TONES: Record = { + neutral: "text-muted bg-raised border-line", + ok: "text-ok bg-ok/10 border-ok/25", + warn: "text-warn bg-warn/10 border-warn/25", + bad: "text-bad bg-bad/10 border-bad/25", + accent: "text-accent bg-accent/10 border-accent/25", + info: "text-info bg-info/10 border-info/25", +}; + +export const Badge = ({ + tone = "neutral", + dot, + children, + className, +}: { + tone?: Tone; + dot?: boolean; + children: ReactNode; + className?: string; +}) => ( + + {dot && } + {children} + +); + +export const TIER_TONE: Record = { high: "ok", medium: "info", review: "warn" }; + +export const TierBadge = ({ tier }: { tier: Tier }) => ( + + {TIER_LABEL[tier]} + +); + +/* --------------------------------------------------------- ConfidenceBar */ + +const BAR_FILL: Record = { high: "bg-ok", medium: "bg-info", review: "bg-warn" }; + +/** Always paired with the number: colour alone never carries the meaning. */ +export function ConfidenceBar({ value, className }: { value: number | null; className?: string }) { + const tier = tierOf(value); + const w = Math.max(0, Math.min(1, value ?? 0)) * 100; + return ( +
+
+
+
+ {pct(value)} +
+ ); +} + +/* ----------------------------------------------------------------- Swatch */ + +export function Swatch({ color, size = 20, label }: { color: string | null; size?: number; label?: string }) { + return ( + + ); +} + +/* ------------------------------------------------------------------- Card */ + +export const Card = ({ className, children }: { className?: string; children: ReactNode }) => ( +
+ {children} +
+); + +export const CardHeader = ({ + title, + icon: Icon, + children, +}: { + title: string; + icon?: LucideIcon; + children?: ReactNode; +}) => ( +
+ {Icon && } +

{title}

+
{children}
+
+); + +/* ------------------------------------------------------------ Empty state */ + +export function Empty({ + icon: Icon, + title, + children, + className, +}: { + icon: LucideIcon; + title: string; + children?: ReactNode; + className?: string; +}) { + return ( +
+
+ +
+

{title}

+ {children &&
{children}
} +
+ ); +} + +export const Skeleton = ({ className }: { className?: string }) => ( +
+); + +/* ------------------------------------------------------------------ Forms */ + +const FIELD = + "h-8 w-full min-w-0 rounded-md border border-line bg-bg px-2.5 text-[13px] text-fg placeholder:text-subtle " + + "focus:border-accent/60 focus:outline-none disabled:opacity-50 pointer-coarse:h-11"; + +export const Input = forwardRef>(function Input( + { className, ...rest }, + ref, +) { + return ; +}); + +export const Select = forwardRef>(function Select( + { className, children, ...rest }, + ref, +) { + return ( + + ); +}); + +export const Textarea = forwardRef>( + function Textarea({ className, ...rest }, ref) { + return