diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 00000000..f79823f0
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,253 @@
+# Use the latest 2.1 version of CircleCI pipeline process engine.
+# See: https://circleci.com/docs/2.0/configuration-reference
+version: 2.1
+
+# Define a job to be invoked later in a workflow.
+# See: https://circleci.com/docs/2.0/configuration-reference/#jobs
+jobs:
+ api-tests:
+ docker:
+ - image: cimg/python:3.13
+ # `large` (4 vCPU, 8 GB) so pytest-xdist's `-nauto` has room to scale.
+ resource_class: large
+ steps:
+ - checkout
+ - run: sudo apt-get update
+ - run: sudo apt-get install -y ffmpeg catdoc
+ - restore_cache:
+ key: deps-3.13-{{ checksum "requirements.txt" }}-6
+ - run:
+ name: Install Requirements
+ command: |
+ # A restored venv points at the exact interpreter that built it. CircleCI's
+ # cimg/python:3.13 gets patch bumps, and this cache key does not name the
+ # patch, so a venv cached before a bump has a dangling bin/python3 -- and
+ # `python3 -m venv` over it fails with `[Errno 2]` instead of repairing it,
+ # which broke every Python job. Rebuild when it cannot run: that costs a
+ # reinstall only on the first run after a bump.
+ if ! ./venv/bin/python3 --version >/dev/null 2>&1; then rm -rf venv; fi
+ python3 -m venv venv
+ . venv/bin/activate
+ pip install -r requirements.txt
+ - save_cache:
+ key: deps-3.13-{{ checksum "requirements.txt" }}-6
+ paths:
+ - "venv"
+ # `-nauto` parallelizes across all available cores via pytest-xdist.
+ # `-svv` was dumping the full test stdout to CircleCI logs on every run,
+ # which inflated wall time and produced multi-MB log artifacts; dropped.
+ - run:
+ command: './venv/bin/pytest -nauto --junitxml=test-results.xml'
+ - store_test_results:
+ path: test-results.xml
+ # Do the API and the frontend's test fixtures still agree?
+ #
+ # `api-tests` runs these too, as part of the whole suite. This job exists for the
+ # signal: fixture drift is silent by nature -- the frontend suite passes against a body the
+ # API stopped sending -- so it is worth a check whose name says what broke, failing in under a
+ # minute rather than somewhere inside seventeen hundred results.
+ #
+ # It needs no ffmpeg or catdoc (verified), and shares the dependency cache key with the other
+ # Python jobs, so it costs a venv restore and one test file.
+ api-contract-tests:
+ docker:
+ - image: cimg/python:3.13
+ resource_class: medium
+ steps:
+ - checkout
+ - restore_cache:
+ key: deps-3.13-{{ checksum "requirements.txt" }}-6
+ - run:
+ name: Install Requirements
+ command: |
+ # A restored venv points at the exact interpreter that built it. CircleCI's
+ # cimg/python:3.13 gets patch bumps, and this cache key does not name the
+ # patch, so a venv cached before a bump has a dangling bin/python3 -- and
+ # `python3 -m venv` over it fails with `[Errno 2]` instead of repairing it,
+ # which broke every Python job. Rebuild when it cannot run: that costs a
+ # reinstall only on the first run after a bump.
+ if ! ./venv/bin/python3 --version >/dev/null 2>&1; then rm -rf venv; fi
+ python3 -m venv venv
+ . venv/bin/activate
+ pip install -r requirements.txt
+ - run:
+ name: Fixtures match the OpenAPI spec, and the spec matches the handlers
+ command: './venv/bin/pytest wrolpi/test/test_api_fixtures.py -v'
+
+ api-alembic-migrations:
+ docker:
+ - image: cimg/python:3.13
+ resource_class: medium
+ steps:
+ - checkout
+ - restore_cache:
+ key: deps-3.13-{{ checksum "requirements.txt" }}-6
+ - run:
+ name: Install Requirements
+ command: |
+ # A restored venv points at the exact interpreter that built it. CircleCI's
+ # cimg/python:3.13 gets patch bumps, and this cache key does not name the
+ # patch, so a venv cached before a bump has a dangling bin/python3 -- and
+ # `python3 -m venv` over it fails with `[Errno 2]` instead of repairing it,
+ # which broke every Python job. Rebuild when it cannot run: that costs a
+ # reinstall only on the first run after a bump.
+ if ! ./venv/bin/python3 --version >/dev/null 2>&1; then rm -rf venv; fi
+ python3 -m venv venv
+ . venv/bin/activate
+ pip install -r requirements.txt
+ - run:
+ name: Create Media Directory
+ command: mkdir -p /tmp/wrolpi-media/config
+ - run:
+ name: Run Alembic Migrations
+ command: MEDIA_DIRECTORY=/tmp/wrolpi-media ./venv/bin/python ./main.py db upgrade
+ - run:
+ name: Check for Missing Migrations
+ command: MEDIA_DIRECTORY=/tmp/wrolpi-media ./venv/bin/python ./main.py db check
+ sanic-api-start:
+ docker:
+ - image: cimg/python:3.13
+ steps:
+ - checkout
+ - restore_cache:
+ key: deps-3.13-{{ checksum "requirements.txt" }}-6
+ - run:
+ name: Install dependencies
+ command: |
+ # A restored venv points at the exact interpreter that built it. CircleCI's
+ # cimg/python:3.13 gets patch bumps, and this cache key does not name the
+ # patch, so a venv cached before a bump has a dangling bin/python3 -- and
+ # `python3 -m venv` over it fails with `[Errno 2]` instead of repairing it,
+ # which broke every Python job. Rebuild when it cannot run: that costs a
+ # reinstall only on the first run after a bump.
+ if ! ./venv/bin/python3 --version >/dev/null 2>&1; then rm -rf venv; fi
+ python -m venv venv
+ . venv/bin/activate
+ pip install -r requirements.txt
+ sudo mkdir -p /media/wrolpi
+ - run:
+ name: Start Sanic API
+ command: |
+ . venv/bin/activate
+ sanic main:api_app -H 0.0.0.0 -p 8081
+ background: true
+ - run:
+ name: Test Sanic API
+ command: |
+ sleep 20
+ curl http://0.0.0.0:8081/api/echo
+ - run:
+ name: Stop Sanic API
+ command: pkill -f sanic
+ react-app-start:
+ docker:
+ - image: cimg/node:20.19
+ resource_class: large
+ steps:
+ - checkout
+ - restore_cache:
+ key: app-{{ checksum "app/package-lock.json" }}
+ - run: cd app && npm install
+ - save_cache:
+ key: app-{{ checksum "app/package-lock.json" }}
+ paths:
+ - ./app/node_modules
+ - run:
+ name: Build React App
+ command: cd app && DISABLE_ESLINT_PLUGIN=true npm run build
+ - run:
+ name: Start React App
+ command: cd app && DISABLE_ESLINT_PLUGIN=true npm run start
+ background: true
+ - run:
+ name: Test React App
+ command: |
+ sleep 20
+ curl http://0.0.0.0:3000/manifest.json
+ - run:
+ name: Stop React App
+ command: pkill -f npm
+ # Jest tests moved to GitHub Actions (.github/workflows/jest-tests.yml)
+ # Component specs live under `src/`, so they need `cypress run --component`; the e2e job's
+ # `cypress run` will not match them. Cypress starts its own dev server, so no `npm start`.
+ cypress-component-tests:
+ docker:
+ - image: cimg/node:20.19-browsers
+ resource_class: large
+ steps:
+ - checkout
+ - restore_cache:
+ key: app-browsers-{{ checksum "app/package-lock.json" }}
+ - run:
+ name: Install Dependencies
+ command: cd app && npm install
+ - run:
+ name: Install Cypress Binary
+ command: cd app && npx cypress install
+ - run:
+ name: Run Cypress Component Tests
+ command: cd app && CI=true npx cypress run --component
+ - store_artifacts:
+ path: app/cypress/screenshots
+ cypress-e2e-tests:
+ docker:
+ - image: cimg/node:20.19-browsers
+ resource_class: large
+ steps:
+ - checkout
+ - restore_cache:
+ key: app-browsers-{{ checksum "app/package-lock.json" }}
+ - run:
+ name: Install Dependencies
+ command: cd app && npm install
+ # The Cypress binary lives outside node_modules, so caching only
+ # node_modules lets the two drift apart: a restored cache satisfies npm,
+ # npm skips the postinstall download, and the binary for the new version
+ # is never fetched. Cache both, and install explicitly (a no-op when the
+ # binary is already present).
+ - run:
+ name: Install Cypress Binary
+ command: cd app && npx cypress install
+ - save_cache:
+ key: app-browsers-{{ checksum "app/package-lock.json" }}
+ paths:
+ - ./app/node_modules
+ - ~/.cache/Cypress
+ - run:
+ name: Start React App in Background
+ command: cd app && DISABLE_ESLINT_PLUGIN=true npm start
+ background: true
+ - run:
+ name: Wait for React App
+ command: |
+ for i in {1..60}; do
+ if curl -s http://localhost:3000 > /dev/null; then
+ echo "React app is ready!"
+ exit 0
+ fi
+ echo "Waiting for React app... ($i/60)"
+ sleep 2
+ done
+ echo "React app failed to start"
+ exit 1
+ - run:
+ name: Run Cypress Tests
+ command: cd app && CI=true npx cypress run
+ - store_artifacts:
+ path: app/cypress/videos
+ - store_artifacts:
+ path: app/cypress/screenshots
+
+workflows:
+ wrolpi-api-tests:
+ jobs:
+ - sanic-api-start
+ - api-tests
+ - api-contract-tests
+ - api-alembic-migrations
+ wrolpi-app-test:
+ jobs:
+ - react-app-start
+ # jest-tests moved to GitHub Actions
+ - cypress-e2e-tests
+ - cypress-component-tests
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 29480843..e1969755 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -7,11 +7,6 @@ updates:
interval: "monthly"
target-branch: "master"
open-pull-requests-limit: 5
- ignore:
- # SQLAlchemy 2.1 is a minor release with its own deprecations; upgrade it
- # deliberately rather than by an automatic bump.
- - dependency-name: "SQLAlchemy"
- versions: [">=2.1"]
groups:
python-dependencies:
patterns:
@@ -42,42 +37,6 @@ updates:
interval: "monthly"
target-branch: "master"
open-pull-requests-limit: 5
- ignore:
- # Major upgrades below need their own migration branch, not a bump PR.
- # maplibre-gl 6: /map build depends on pinned babel/terser handling.
- - dependency-name: "maplibre-gl"
- versions: [">=6"]
- # react-dropzone 15+ changed the accept API; 20 requires Node >=22.
- - dependency-name: "react-dropzone"
- versions: [">=15"]
- # React 19: @react-three/fiber 8 pins react <19.
- - dependency-name: "react"
- update-types: ["version-update:semver-major"]
- - dependency-name: "react-dom"
- update-types: ["version-update:semver-major"]
- - dependency-name: "@types/react"
- update-types: ["version-update:semver-major"]
- - dependency-name: "@types/react-dom"
- update-types: ["version-update:semver-major"]
- # TypeScript 6+/7 breaks the react-scripts toolchain.
- - dependency-name: "typescript"
- versions: [">=6"]
- # Mantine 9: all @mantine packages move together with app code changes.
- - dependency-name: "@mantine/*"
- update-types: ["version-update:semver-major"]
- # Cypress 16 requires Node >=22; CI and the app image run Node 20.
- - dependency-name: "cypress"
- versions: [">=16"]
- # react-router 8 is a routing migration across ~40 files.
- - dependency-name: "react-router"
- update-types: ["version-update:semver-major"]
- # @testing-library/react 14+ async act semantics break menu/popover tests.
- - dependency-name: "@testing-library/react"
- update-types: ["version-update:semver-major"]
- # Cypress component testing resolves the top-level webpack-dev-server and only
- # accepts major 5; 6 crashes cypress.config.js ("Unexpected major version").
- - dependency-name: "webpack-dev-server"
- versions: [">=6"]
groups:
npm-dependencies:
patterns:
diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml
deleted file mode 100644
index 05cec023..00000000
--- a/.github/workflows/backend.yml
+++ /dev/null
@@ -1,145 +0,0 @@
-name: Backend Tests
-
-# Same triggers as jest-tests.yml: `pull_request` is unfiltered so pull requests into feature
-# branches are checked too, not only those into master.
-on:
- push:
- branches: [master, feature/*]
- pull_request:
-
-# A new push to a pull request makes the run for the previous push worthless; cancel it so it
-# does not hold runners (the free plan allows 20 concurrent jobs). Runs on master always finish.
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-jobs:
- # The suite is split by directory so it finishes in a few minutes, and so a check's name says
- # which area broke. The groups are balanced by test time; `rest` runs everything the others do
- # not, so a new directory is tested before anyone assigns it. A path added to any other group
- # must also be added to `rest` as an --ignore, or its tests run twice.
- api-tests:
- name: api-tests (${{ matrix.name }})
- runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- include:
- - name: core
- paths: wrolpi/test
- - name: media
- paths: modules/videos modules/zim modules/docs modules/map modules/inventory modules/flasher
- - name: files
- paths: wrolpi/files wrolpi/collections
- - name: rest
- paths: >-
- --ignore=wrolpi/test
- --ignore=modules/videos --ignore=modules/zim --ignore=modules/docs --ignore=modules/map
- --ignore=modules/inventory --ignore=modules/flasher
- --ignore=wrolpi/files --ignore=wrolpi/collections
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Install system packages
- run: sudo apt-get update && sudo apt-get install -y ffmpeg catdoc
-
- - name: Setup Python
- uses: actions/setup-python@v5
- with:
- # Trixie ships 3.13.
- python-version: '3.13'
- cache: 'pip'
- cache-dependency-path: requirements.txt
-
- - name: Install dependencies
- run: pip install -r requirements.txt
-
- # `logical`, not `auto`: with psutil installed, `auto` counts physical cores, which is two on
- # the four-vCPU runner. No `-v`: the full test output inflates wall time and logs.
- - name: Run pytest
- env:
- PATHS: ${{ matrix.paths }}
- run: pytest -n logical $PATHS --junitxml=test-results.xml
-
- - name: Upload test results
- if: ${{ !cancelled() }}
- uses: actions/upload-artifact@v4
- with:
- name: api-test-results-${{ matrix.name }}
- path: test-results.xml
- retention-days: 30
-
- # Do the API and the frontend's test fixtures still agree?
- #
- # `api-tests` runs these too, as part of the whole suite. This job exists for the signal:
- # fixture drift is silent by nature -- the frontend suite passes against a body the API stopped
- # sending -- so it is worth a check whose name says what broke, failing in under a minute rather
- # than somewhere inside seventeen hundred results. It needs no ffmpeg or catdoc.
- api-contract-tests:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Setup Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.13'
- cache: 'pip'
- cache-dependency-path: requirements.txt
-
- - name: Install dependencies
- run: pip install -r requirements.txt
-
- - name: Fixtures match the OpenAPI spec, and the spec matches the handlers
- run: pytest wrolpi/test/test_api_fixtures.py -v
-
- # The migrations apply to an empty database, the models match them, and the API starts.
- api-migrations-and-start:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Setup Python
- uses: actions/setup-python@v5
- with:
- python-version: '3.13'
- cache: 'pip'
- cache-dependency-path: requirements.txt
-
- - name: Install dependencies
- run: pip install -r requirements.txt
-
- - name: Run Alembic migrations
- env:
- MEDIA_DIRECTORY: ${{ runner.temp }}/wrolpi-media
- run: |
- mkdir -p "$MEDIA_DIRECTORY/config"
- python ./main.py db upgrade
-
- - name: Check for missing migrations
- env:
- MEDIA_DIRECTORY: ${{ runner.temp }}/wrolpi-media
- run: python ./main.py db check
-
- - name: Start Sanic API
- run: |
- sudo mkdir -p /media/wrolpi
- nohup sanic main:api_app -H 0.0.0.0 -p 8081 > sanic.log 2>&1 &
- for i in $(seq 1 60); do
- if curl -sf http://localhost:8081/api/echo; then
- echo
- echo "Sanic API is up"
- exit 0
- fi
- sleep 2
- done
- echo "Sanic API failed to start"
- cat sanic.log
- exit 1
-
- - name: Stop Sanic API
- if: ${{ always() }}
- run: pkill -f sanic || true
diff --git a/.github/workflows/cypress.yml b/.github/workflows/cypress.yml
deleted file mode 100644
index efb01e97..00000000
--- a/.github/workflows/cypress.yml
+++ /dev/null
@@ -1,121 +0,0 @@
-name: Cypress Tests
-
-on:
- push:
- branches: [master, feature/*]
- pull_request:
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-jobs:
- # Component specs live under `src/`, so they need `cypress run --component`; the e2e job's
- # `cypress run` will not match them. Cypress starts its own dev server, so no `npm start`.
- cypress-component-tests:
- runs-on: ubuntu-latest
-
- defaults:
- run:
- working-directory: app
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20.19'
- cache: 'npm'
- cache-dependency-path: app/package-lock.json
-
- # The Cypress binary lives outside node_modules, and setup-node only caches ~/.npm. Key it
- # on the lockfile so a Cypress bump fetches the matching binary.
- - name: Cache Cypress binary
- uses: actions/cache@v4
- with:
- path: ~/.cache/Cypress
- key: cypress-${{ runner.os }}-${{ hashFiles('app/package-lock.json') }}
-
- - name: Install dependencies
- run: npm install
-
- # A no-op when the cached binary matches.
- - name: Install Cypress binary
- run: npx cypress install
-
- - name: Run Cypress component tests
- run: npx cypress run --component
-
- - name: Upload screenshots
- if: ${{ failure() }}
- uses: actions/upload-artifact@v4
- with:
- name: cypress-component-screenshots
- path: app/cypress/screenshots
- retention-days: 14
-
- # Waiting for `npm start` to serve the app also checks that the dev server starts.
- cypress-e2e-tests:
- runs-on: ubuntu-latest
-
- defaults:
- run:
- working-directory: app
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '20.19'
- cache: 'npm'
- cache-dependency-path: app/package-lock.json
-
- - name: Cache Cypress binary
- uses: actions/cache@v4
- with:
- path: ~/.cache/Cypress
- key: cypress-${{ runner.os }}-${{ hashFiles('app/package-lock.json') }}
-
- - name: Install dependencies
- run: npm install
-
- - name: Install Cypress binary
- run: npx cypress install
-
- # GitHub Actions sets `CI=true`, which makes cypress.config.js use http://localhost:3000 as
- # the base URL.
- - name: Start React app
- env:
- DISABLE_ESLINT_PLUGIN: 'true'
- BROWSER: none
- run: |
- nohup npm start > react.log 2>&1 &
- for i in $(seq 1 90); do
- if curl -sf http://localhost:3000/manifest.json > /dev/null; then
- echo "React app is ready"
- exit 0
- fi
- sleep 2
- done
- echo "React app failed to start"
- cat react.log
- exit 1
-
- - name: Run Cypress e2e tests
- run: npx cypress run
-
- - name: Upload videos and screenshots
- if: ${{ failure() }}
- uses: actions/upload-artifact@v4
- with:
- name: cypress-e2e-media
- path: |
- app/cypress/videos
- app/cypress/screenshots
- retention-days: 14
- if-no-files-found: ignore
diff --git a/.gitignore b/.gitignore
index a3ab9bc5..655c907f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -150,6 +150,3 @@ app/cypress/videos
release/latest.json
perf_results/
scripts/api_perf_hosts.json
-
-# Tile cache for scripts/build_solar_climatology.py
-/scripts/.solar_climatology_cache/
diff --git a/INSTALL.md b/INSTALL.md
index cf93f460..1fc77ee8 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -3,10 +3,8 @@
There are several ways to run WROLPi. Pick the one that matches your hardware:
* [WROLPi Portable (Live USB)](#wrolpi-portable-live-usb) — boot a full WROLPi from a USB stick on any x86 PC or laptop, no installation required.
-* [Debian 13 Install](#debian-13-install) — install WROLPi onto an x86 PC or laptop.
+* [Debian 12 Install](#debian-12-install) — install WROLPi onto an x86 PC or laptop.
* [Raspberry Pi Install](#raspberry-pi-install) — install WROLPi onto a Raspberry Pi with an external drive.
-
-After installing, follow [First Boot](#first-boot) to set up your drive.
* [Docker](docker/README.md) — run the WROLPi containers on an existing Linux host (best for trying it out or development).
See [UPGRADE.md](UPGRADE.md) for upgrading an existing WROLPi.
@@ -38,7 +36,6 @@ Steps:
minute or two; progress is shown on screen.
5. Once the desktop loads, browse to https://wrolpi.local or the IP address of
your WROLPi. Wi-Fi hotspot and all other features work as usual.
-6. To use a terminal remotely: `ssh user@wrolpi.local`, password `live`.
Notes:
@@ -55,50 +52,30 @@ Notes:
* **Upgrading.** See [Upgrading a WROLPi Portable USB](UPGRADE.md#upgrading-a-wrolpi-portable-usb)
to move to a newer ISO without losing your library.
-## Debian 13 Install
+## Debian 12 Install
-Install WROLPi onto the internal disk of an x86 PC or laptop. This uses the same
-`WROLPi-v*-amd64.iso` as WROLPi Portable.
+Steps necessary to initialize your WROLPi after installing the Debian image from wrolpi.org
-1. Download the latest `WROLPi-v*-amd64.iso` from [wrolpi.org](https://wrolpi.org) and flash it to a USB drive
- (see [WROLPi Portable](#wrolpi-portable-live-usb) step 2).
-2. Boot the PC from the USB drive. At the boot menu, choose **Install** or **Graphical Install**.
-3. Install Debian 13 as you would like.
- 1. It is recommended to use the hostname **wrolpi**.
- 2. WROLPi is installed along with Debian; there is nothing extra to select.
-4. Unplug the USB drive after the installation has completed, and reboot.
-5. Continue with [First Boot](#first-boot).
+1. Download and copy a pre-built Debian image from https://wrolpi.org onto a USB thumb-drive (USB 2 recommended).
+2. Insert the thumb-drive into the laptop, boot to the thumb-drive.
+ 1. Select "Start Installer".
+ 2. Install Debian 12 as you would like.
+ 1. It is recommended to use the hostname **wrolpi**.
+ 2. (WROLPi will be installed during the installation without your intervention.)
+3. Unplug the thumb-drive after the installation has completed.
+4. Login as the user _you_ created during installation.
+5. Initialize the WROLPi databases using the repair script: `/opt/wrolpi/repair.sh`
+6. Reboot: `reboot`
+7. Browse to https://wrolpi.local or the IP address of your WROLPi!
## Raspberry Pi Install
-Install WROLPi onto a Raspberry Pi. An external drive is recommended for your library.
+Steps necessary to initialize your WROLPi after installing the Raspberry Pi image from wrolpi.org
-1. Download the latest `WROLPi-v*-aarch64-desktop.img.xz` from [wrolpi.org](https://wrolpi.org).
-2. Flash it to an SD card using Raspberry Pi Imager.
-3. Plug in your external drive, insert the SD card, and boot the Raspberry Pi.
-4. On the first boot, Raspberry Pi OS asks you to create your own user.
-5. Continue with [First Boot](#first-boot).
-
-## First Boot
-
-These steps are the same for every install.
-
-1. Join the WROLPi hotspot (network **WROLPi**, password **wrolpi hotspot**; it starts on every boot), or connect
- your WROLPi to your network, then browse to https://wrolpi.local (or the IP address of your WROLPi).
-2. **Choose your drive.**
- * **Raspberry Pi and Debian:** until a drive is set up, you will see the **Controller** with a *Welcome to WROLPi*
- wizard (also at http://wrolpi.local). Select the drive which will hold your library; WROLPi mounts it
- at `/media/wrolpi`, adds it to `/etc/fstab`, and runs the repair script for you. If the drive already holds a
- WROLPi library (from another WROLPi, or a backup), its configuration is used. No external drive? Choose **Run
- repair without external drive** to keep your library on the internal disk or SD card.
- * **WROLPi Portable:** nothing to do; your library lives in the persistence partition on the USB drive.
-3. **Trust the certificate.** Your browser will warn about the HTTPS certificate because it was made by your WROLPi,
- not by a public authority. Download https://wrolpi.local/ca.crt and trust it on each device you use; see
- *Help > Certificates* on your WROLPi for per-device instructions.
-4. **Wait for the first refresh.** WROLPi refreshes all your files when it first starts so it can find anything already
- on the drive. Your library looks empty, and Download and Upload are disabled, until the refresh completes.
-5. Start adding content: download a video channel, archive a web page, or add a Zim (Wikipedia) file. The *Help* page
- on your WROLPi explains each module.
-
-You do not need to edit `/etc/fstab` or run `repair.sh` by hand. `sudo /opt/wrolpi/repair.sh` is still available if
-your WROLPi ever needs repairing.
+1. Download and copy a pre-built image from https://wrolpi.org onto an SD card.
+2. Boot the Raspberry Pi, login with username pi and password wrolpi.
+3. Modify fstab to mount your external drive to /media/wrolpi (modify this command to match your system).
+ * `echo '/dev/sda1 /media/wrolpi auto defaults,nofail 0 0' | sudo tee -a /etc/fstab`
+4. Initialize the WROLPi databases using the repair script: `sudo /opt/wrolpi/repair.sh`
+5. Reboot `sudo reboot`
+6. Join the Hotspot or browse to https://wrolpi.local or the IP address of your WROLPi!
diff --git a/README.md b/README.md
index 291d2507..dfe580fd 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# WROLPi
-[](https://github.com/lrnselfreliance/wrolpi/actions/workflows/backend.yml?query=branch%3Amaster)
+[](https://app.circleci.com/pipelines/github/lrnselfreliance/wrolpi?branch=release&filter=all)
[](https://github.com/lrnselfreliance/wrolpi/blob/master/LICENSE)
[](https://discord.gg/HrwFk7nqA2)
[](https://gitpod.io/#https://github.com/lrnselfreliance/wrolpi)
@@ -93,7 +93,7 @@ More Docker documentation is available in [docker/README.md](docker/README.md)
Full installation instructions are in [INSTALL.md](INSTALL.md). Pick the option that matches your hardware:
* **[WROLPi Portable (Live USB)](INSTALL.md#wrolpi-portable-live-usb)** — boot a complete WROLPi from a USB stick on any x86 PC or laptop. Nothing is installed to the computer; the first boot creates a persistence partition on the USB so your library, database, and configuration survive reboots.
-* **[Debian 13](INSTALL.md#debian-13-install)** — install WROLPi onto an x86 PC or laptop.
+* **[Debian 12](INSTALL.md#debian-12-install)** — install WROLPi onto an x86 PC or laptop.
* **[Raspberry Pi](INSTALL.md#raspberry-pi-install)** — install WROLPi onto a Raspberry Pi with an external drive.
* **[Docker](docker/README.md)** — run the WROLPi containers on an existing Linux host.
diff --git a/UPGRADE.md b/UPGRADE.md
index 7029bb37..0b3449af 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -3,7 +3,7 @@
# Table of Contents
* [Upgrading Docker containers](#upgrading-docker-containers)
-* [Upgrading Raspberry Pi or Debian Installation](#upgrading-raspberry-pi-or-debian-installation)
+* [Upgrading Raspberry Pi or Debian 12 Installation](#upgrading-raspberry-pi-or-debian-12-installation)
* [Upgrading a WROLPi Portable USB](#upgrading-a-wrolpi-portable-usb)
## Upgrading Docker containers
@@ -24,9 +24,7 @@
7. Start all docker containers
* `docker-compose up -d`
-## Upgrading Raspberry Pi or Debian Installation
-
-A Debian 12 (bookworm) WROLPi stays on the final version for Debian 12; reinstall on Debian 13 to get newer versions.
+## Upgrading Raspberry Pi or Debian 12 Installation
1. Run the upgrade script
* `sudo /bin/bash /opt/wrolpi/upgrade.sh`
diff --git a/alembic/env.py b/alembic/env.py
index 1fc7bd9c..9aeeb116 100644
--- a/alembic/env.py
+++ b/alembic/env.py
@@ -6,10 +6,9 @@ from alembic import context
# access to the values within the .ini file in use.
config = context.config
-# Configure logging only for the alembic command line. WROLPi also migrates in-process at startup
-# (db_bootstrap), where fileConfig would replace the API's handlers and disable its existing loggers.
-if config.cmd_opts is not None:
- fileConfig(config.config_file_name)
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
@@ -24,7 +23,6 @@ from wrolpi.collections.models import Collection, CollectionItem # noqa: F401
from modules.videos.models import Video, Channel # noqa: F401
from modules.archive.models import Archive # noqa: F401
from modules.zim.models import Zim, TagZimEntry, ZimSubscription # noqa: F401
-from modules.repos.models import Repository # noqa: F401
target_metadata = Base.metadata
@@ -98,10 +96,7 @@ def run_migrations_online():
connectable = create_wrolpi_engine(get_url())
- # Autocommit: each migration statement is its own transaction. A migration may call WROLPi
- # functions which write on their own connection; a migration connection holding an open read
- # transaction could not then upgrade to a writer ("database is locked").
- with connectable.connect().execution_options(isolation_level='AUTOCOMMIT') as connection:
+ with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
diff --git a/alembic/versions/2026_09_27_1200_domain_collection_summary.py b/alembic/versions/2026_09_27_1200_domain_collection_summary.py
deleted file mode 100644
index 405dbfe1..00000000
--- a/alembic/versions/2026_09_27_1200_domain_collection_summary.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Maintain collection.item_count/total_size for Domain Collections with triggers.
-
-Listing Domain Collections summed file_group.size through an archive join on every request,
-one random page read per archive. The new triggers keep the summary columns current
-(as the channel triggers already do for Channels); this migration installs them and
-backfills existing Domain Collections.
-
-Revision ID: 2026_09_27_1200
-Revises: 2026_09_20_1200
-"""
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision = '2026_09_27_1200'
-down_revision = '2026_09_20_1200'
-branch_labels = None
-depends_on = None
-
-TRIGGERS = (
- 'archive_insert_collection_summary',
- 'archive_delete_collection_summary',
- 'archive_update_collection_summary',
- 'file_group_size_collection_summary',
-)
-
-
-def upgrade():
- from wrolpi.schema_ddl import install_raw_ddl
-
- # Idempotent: only the new triggers are created.
- install_raw_ddl(op.get_bind())
-
- op.execute('''
- UPDATE collection SET
- item_count = (SELECT COUNT(*) FROM archive WHERE collection_id = collection.id),
- total_size = (SELECT COALESCE(SUM(fg.size), 0) FROM archive a
- LEFT JOIN file_group fg ON a.file_group_id = fg.id
- WHERE a.collection_id = collection.id)
- WHERE kind = 'domain'
- ''')
-
-
-def downgrade():
- for name in TRIGGERS:
- op.execute(f'DROP TRIGGER IF EXISTS {name}')
diff --git a/alembic/versions/2026_09_28_1200_collection_listing_indexes.py b/alembic/versions/2026_09_28_1200_collection_listing_indexes.py
deleted file mode 100644
index d8ca602b..00000000
--- a/alembic/versions/2026_09_28_1200_collection_listing_indexes.py
+++ /dev/null
@@ -1,29 +0,0 @@
-"""Covering indexes for the Collection listing.
-
-Listing Collections read every channel row (for video_count/total_size) and every download
-row of those Collections (for the minimum frequency). Both tables store yt-dlp info_json,
-so their rows are tens of KB and each sits on its own scattered page; from a cold disk cache
-that was 10+ seconds. These indexes answer both queries without touching a row.
-
-Revision ID: 2026_09_28_1200
-Revises: 2026_09_27_1200
-"""
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision = '2026_09_28_1200'
-down_revision = '2026_09_27_1200'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
- op.execute('CREATE INDEX IF NOT EXISTS channel_collection_summary_idx'
- ' ON channel (collection_id, video_count, total_size)')
- op.execute('CREATE INDEX IF NOT EXISTS download_collection_frequency_idx'
- ' ON download (collection_id, frequency)')
-
-
-def downgrade():
- op.execute('DROP INDEX IF EXISTS channel_collection_summary_idx')
- op.execute('DROP INDEX IF EXISTS download_collection_frequency_idx')
diff --git a/alembic/versions/2026_10_05_1200_markdown_mimetype.py b/alembic/versions/2026_10_05_1200_markdown_mimetype.py
deleted file mode 100644
index 02300ea4..00000000
--- a/alembic/versions/2026_10_05_1200_markdown_mimetype.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""Retag existing markdown file groups as text/markdown.
-
-A refresh skips a group whose files have not changed, so markdown indexed before text/markdown
-existed would keep the type libmagic gave it (text/plain, or a programming language when the file
-holds code) and the indexer that type chose. Marking the rows unindexed lets apply_indexers
-re-index them with TextIndexer. SQLite's LIKE is case-insensitive for ASCII, so `.MD` matches.
-
-Revision ID: 2026_10_05_1200
-Revises: 2026_09_28_1200
-"""
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision = '2026_10_05_1200'
-down_revision = '2026_09_28_1200'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
- op.execute("""
- UPDATE file_group SET mimetype = 'text/markdown', "indexed" = 0
- WHERE (primary_path LIKE '%.md' OR primary_path LIKE '%.markdown')
- AND mimetype LIKE 'text/%'
- AND mimetype != 'text/markdown'
- """)
-
-
-def downgrade():
- # The original types are not recorded; text/plain is what libmagic reports for most markdown.
- op.execute("""UPDATE file_group SET mimetype = 'text/plain', "indexed" = 0 WHERE mimetype = 'text/markdown'""")
diff --git a/alembic/versions/2026_10_06_1200_repository.py b/alembic/versions/2026_10_06_1200_repository.py
deleted file mode 100644
index e4537267..00000000
--- a/alembic/versions/2026_10_06_1200_repository.py
+++ /dev/null
@@ -1,43 +0,0 @@
-"""Add the repository table for git Repos (Collections with kind='repo').
-
-Revision ID: 2026_10_06_1200
-Revises: 2026_10_05_1200
-"""
-import sqlalchemy as sa
-from alembic import op
-
-import wrolpi.dates
-
-# revision identifiers, used by Alembic.
-revision = '2026_10_06_1200'
-down_revision = '2026_10_05_1200'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
- op.create_table(
- 'repository',
- sa.Column('id', sa.Integer(), nullable=False),
- sa.Column('url', sa.String(), nullable=False),
- sa.Column('host', sa.String(), nullable=True),
- sa.Column('owner', sa.String(), nullable=True),
- sa.Column('mode', sa.String(), nullable=False),
- sa.Column('branch', sa.String(), nullable=True),
- sa.Column('default_branch', sa.String(), nullable=True),
- sa.Column('head_sha', sa.String(), nullable=True),
- sa.Column('head_date', wrolpi.dates.TZDateTime(), nullable=True),
- sa.Column('head_message', sa.Text(), nullable=True),
- sa.Column('last_fetch', wrolpi.dates.TZDateTime(), nullable=True),
- sa.Column('size', sa.BigInteger(), nullable=True),
- sa.Column('readme_path', sa.String(), nullable=True),
- sa.Column('collection_id', sa.Integer(), nullable=False),
- sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ondelete='CASCADE'),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('collection_id'),
- sa.UniqueConstraint('url'),
- )
-
-
-def downgrade():
- op.drop_table('repository')
diff --git a/alembic/versions/2026_10_06_1300_repository_search.py b/alembic/versions/2026_10_06_1300_repository_search.py
deleted file mode 100644
index b4fd7abc..00000000
--- a/alembic/versions/2026_10_06_1300_repository_search.py
+++ /dev/null
@@ -1,39 +0,0 @@
-"""Search git Repos by name and README: repository.search_name/readme_text and repository_fts.
-
-Revision ID: 2026_10_06_1300
-Revises: 2026_10_06_1200
-"""
-import sqlalchemy as sa
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision = '2026_10_06_1300'
-down_revision = '2026_10_06_1200'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
- from wrolpi.schema_ddl import install_raw_ddl
-
- op.add_column('repository', sa.Column('search_name', sa.String(), nullable=True))
- op.add_column('repository', sa.Column('readme_text', sa.Text(), nullable=True))
- op.execute('''
- UPDATE repository SET search_name = TRIM(
- COALESCE((SELECT name FROM collection WHERE collection.id = repository.collection_id), '')
- || ' ' || COALESCE(owner, ''))
- ''')
-
- # Creates repository_fts and its triggers now that the columns exist. The README text is filled by the next
- # update of each Repo.
- install_raw_ddl(op.get_bind())
- op.execute("INSERT INTO repository_fts(repository_fts) VALUES('rebuild')")
-
-
-def downgrade():
- for name in ('repository_fts_ai', 'repository_fts_ad', 'repository_fts_au'):
- op.execute(f'DROP TRIGGER IF EXISTS {name}')
- op.execute('DROP TABLE IF EXISTS repository_fts')
- with op.batch_alter_table('repository') as batch_op:
- batch_op.drop_column('readme_text')
- batch_op.drop_column('search_name')
diff --git a/alembic/versions/2026_10_06_1400_repository_submodules.py b/alembic/versions/2026_10_06_1400_repository_submodules.py
deleted file mode 100644
index 123ba97d..00000000
--- a/alembic/versions/2026_10_06_1400_repository_submodules.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""A git Repo can also download its submodules: repository.submodules.
-
-Revision ID: 2026_10_06_1400
-Revises: 2026_10_06_1300
-"""
-import sqlalchemy as sa
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision = '2026_10_06_1400'
-down_revision = '2026_10_06_1300'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
- op.add_column('repository', sa.Column('submodules', sa.Boolean(), server_default=sa.false(), nullable=False))
-
-
-def downgrade():
- with op.batch_alter_table('repository') as batch_op:
- batch_op.drop_column('submodules')
diff --git a/alembic/versions/2026_10_06_1500_repository_clone_token.py b/alembic/versions/2026_10_06_1500_repository_clone_token.py
deleted file mode 100644
index 61d936af..00000000
--- a/alembic/versions/2026_10_06_1500_repository_clone_token.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""A git Repo's clone holds a random token: repository.clone_token.
-
-Revision ID: 2026_10_06_1500
-Revises: 2026_10_06_1400
-"""
-import sqlalchemy as sa
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision = '2026_10_06_1500'
-down_revision = '2026_10_06_1400'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
- op.add_column('repository', sa.Column('clone_token', sa.String(), nullable=True))
-
-
-def downgrade():
- with op.batch_alter_table('repository') as batch_op:
- batch_op.drop_column('clone_token')
diff --git a/alembic/versions/2026_10_08_1200_file_group_progress.py b/alembic/versions/2026_10_08_1200_file_group_progress.py
deleted file mode 100644
index 5742beee..00000000
--- a/alembic/versions/2026_10_08_1200_file_group_progress.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""A FileGroup records how far the user is through it: file_group.progress and file_group.position.
-
-Revision ID: 2026_10_08_1200
-Revises: 2026_10_06_1500
-"""
-import sqlalchemy as sa
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision = '2026_10_08_1200'
-down_revision = '2026_10_06_1500'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
- op.add_column('file_group', sa.Column('progress', sa.Float(), nullable=True))
- op.add_column('file_group', sa.Column('position', sa.JSON(), nullable=True))
-
-
-def downgrade():
- # Not batch_alter_table: rebuilding file_group drops its FTS triggers (see 2026_09_20_1200).
- op.execute('ALTER TABLE file_group DROP COLUMN position')
- op.execute('ALTER TABLE file_group DROP COLUMN progress')
diff --git a/alembic/versions/2026_10_24_1200_config_version.py b/alembic/versions/2026_10_24_1200_config_version.py
deleted file mode 100644
index bf202b50..00000000
--- a/alembic/versions/2026_10_24_1200_config_version.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Each database config's version, so an import can tell whether the database or the file is newer
-(see wrolpi.config_version).
-
-Revision ID: 2026_10_24_1200
-Revises: 2026_10_08_1200
-"""
-import sqlalchemy as sa
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision = '2026_10_24_1200'
-down_revision = '2026_10_08_1200'
-branch_labels = None
-depends_on = None
-
-
-def upgrade():
- # No rows: a config's next import (without deletes) records its version.
- op.create_table(
- 'config_version',
- sa.Column('name', sa.String(), primary_key=True),
- sa.Column('version', sa.Integer(), nullable=False),
- )
-
-
-def downgrade():
- op.drop_table('config_version')
diff --git a/alembic/versions/2026_10_25_1200_strip_tag_names.py b/alembic/versions/2026_10_25_1200_strip_tag_names.py
deleted file mode 100644
index a076bbf8..00000000
--- a/alembic/versions/2026_10_25_1200_strip_tag_names.py
+++ /dev/null
@@ -1,175 +0,0 @@
-"""Tag names have no leading or trailing whitespace.
-
-A Tag whose name had whitespace is renamed. Tags whose names are the same once stripped are merged into one: it
-keeps every tagged file, Zim entry and Collection, and the first color (by Tag id) of the merged Tags. Downloads
-and the config files that name Tags are rewritten the same way, so the next import finds them in step with the
-database (an import of the old names would create the padded Tags again, or delete the stripped ones).
-
-Revision ID: 2026_10_25_1200
-Revises: 2026_10_24_1200
-"""
-import json
-import logging
-
-import yaml
-from alembic import op
-
-from wrolpi.common import get_media_directory, write_config_data
-
-# revision identifiers, used by Alembic.
-revision = '2026_10_25_1200'
-down_revision = '2026_10_24_1200'
-branch_labels = None
-depends_on = None
-
-logger = logging.getLogger(__name__)
-
-# The configs which name Tags.
-CONFIG_FILES = ('tags.yaml', 'download_manager.yaml', 'channels.yaml', 'domains.yaml', 'playlists.yaml',
- 'repos.yaml', 'videos_downloader.yaml', 'archives_downloader.yaml')
-
-
-def _strip(name):
- """A Tag name without its whitespace; a name that would be empty is kept (it cannot be renamed)."""
- if isinstance(name, str) and name.strip():
- return name.strip()
- return name
-
-
-def _strip_names(names: list) -> list:
- stripped = list()
- for name in map(_strip, names):
- if name not in stripped:
- stripped.append(name)
- return stripped
-
-
-def _strip_nested(data):
- """Strip the Tag names in a config's (or a Download's settings') `tag_name`, `tag_names` and
- `channel_tag_name` values, at any depth."""
- if isinstance(data, list):
- return [_strip_nested(i) for i in data]
- if not isinstance(data, dict):
- return data
- result = dict()
- for key, value in data.items():
- if key == 'tag_name':
- value = _strip(value)
- elif key in ('tag_names', 'channel_tag_name') and isinstance(value, list):
- value = _strip_names(value)
- else:
- value = _strip_nested(value)
- result[key] = value
- return result
-
-
-def _strip_rows(rows: list, key_length: int) -> list:
- """tags.yaml's tag_files or tag_zims, each row keyed by its first `key_length` values (the Tag name first)."""
- result, seen = list(), set()
- for row in rows:
- if isinstance(row, list) and row:
- row = [_strip(row[0]), *row[1:]]
- key = json.dumps(row[:key_length], default=str)
- if key in seen:
- continue
- seen.add(key)
- result.append(row)
- return result
-
-
-def _strip_tags_config(config: dict, colors: dict) -> dict:
- """`colors`: each renamed Tag's color in the database, which the file must match (an import copies the file's
- color into the database)."""
- config = dict(config)
- if isinstance(config.get('tags'), dict):
- tags = dict()
- for name, attrs in config['tags'].items():
- stripped = _strip(name)
- if stripped not in tags:
- tags[stripped] = attrs
- elif isinstance(attrs, dict) and attrs.get('color') and not (tags[stripped] or dict()).get('color'):
- # The first color of the merged Tags.
- tags[stripped] = {**(tags[stripped] or dict()), 'color': attrs['color']}
- for name in tags.keys() & colors.keys():
- tags[name] = {**(tags[name] or dict()), 'color': colors[name]}
- config['tags'] = tags
- if isinstance(config.get('tag_files'), list):
- config['tag_files'] = _strip_rows(config['tag_files'], 2)
- if isinstance(config.get('tag_zims'), list):
- config['tag_zims'] = _strip_rows(config['tag_zims'], 3)
- return config
-
-
-def strip_tag_names_in_configs(colors: dict):
- config_directory = get_media_directory() / 'config'
- for file_name in CONFIG_FILES:
- file = config_directory / file_name
- if not file.is_file():
- continue
- try:
- with file.open('rt') as fh:
- config = yaml.load(fh, Loader=yaml.CSafeLoader)
- except Exception as e:
- logger.error(f'Cannot strip Tag names of invalid config: {file}', exc_info=e)
- continue
- if not isinstance(config, dict):
- continue
- stripped = _strip_tags_config(config, colors) if file_name == 'tags.yaml' else config
- stripped = _strip_nested(stripped)
- if stripped != config:
- logger.warning(f'Stripping whitespace from Tag names in {file}')
- write_config_data(stripped, file, width=500 if file_name == 'tags.yaml' else 90)
-
-
-def strip_tag_names_in_db(connection) -> dict:
- """Returns the color of each renamed Tag, by its new name."""
- groups, colors = dict(), dict()
- for id_, name, color in connection.exec_driver_sql('SELECT id, name, color FROM tag ORDER BY id').fetchall():
- groups.setdefault(_strip(name), list()).append((id_, name, color))
-
- for stripped, tags in groups.items():
- if all(name == stripped for _, name, _ in tags):
- continue
- # Keep the Tag already named this, if any, otherwise the oldest.
- survivor_id = next((id_ for id_, name, _ in tags if name == stripped), tags[0][0])
- color = colors[stripped] = next((color for _, _, color in tags if color), None)
- for id_, name, _ in tags:
- if id_ == survivor_id:
- continue
- logger.warning(f'Merging Tag {name!r} into {stripped!r}')
- connection.exec_driver_sql(
- 'INSERT OR IGNORE INTO tag_file (tag_id, file_group_id, created_at)'
- ' SELECT ?, file_group_id, created_at FROM tag_file WHERE tag_id = ?', (survivor_id, id_))
- connection.exec_driver_sql(
- 'INSERT OR IGNORE INTO tag_zim (tag_id, zim_id, zim_entry, created_at)'
- ' SELECT ?, zim_id, zim_entry, created_at FROM tag_zim WHERE tag_id = ?', (survivor_id, id_))
- connection.exec_driver_sql('DELETE FROM tag_file WHERE tag_id = ?', (id_,))
- connection.exec_driver_sql('DELETE FROM tag_zim WHERE tag_id = ?', (id_,))
- connection.exec_driver_sql('UPDATE collection SET tag_id = ? WHERE tag_id = ?', (survivor_id, id_))
- connection.exec_driver_sql('DELETE FROM tag WHERE id = ?', (id_,))
- logger.warning(f'Renaming Tag to {stripped!r}')
- connection.exec_driver_sql('UPDATE tag SET name = ?, color = ? WHERE id = ?', (stripped, color, survivor_id))
-
- # The Downloads which apply them (download_manager.yaml is stripped the same way).
- downloads = connection.exec_driver_sql(
- 'SELECT id, tag_names, settings FROM download WHERE tag_names IS NOT NULL OR settings IS NOT NULL').fetchall()
- for id_, tag_names, settings in downloads:
- tag_names, settings = json.loads(tag_names) if tag_names else None, json.loads(settings) if settings else None
- new_tag_names = _strip_names(tag_names) if isinstance(tag_names, list) else tag_names
- new_settings = _strip_nested(settings)
- if new_tag_names != tag_names or new_settings != settings:
- connection.exec_driver_sql(
- 'UPDATE download SET tag_names = ?, settings = ? WHERE id = ?',
- (json.dumps(new_tag_names) if new_tag_names is not None else None,
- json.dumps(new_settings) if new_settings is not None else None, id_))
- return colors
-
-
-def upgrade():
- colors = strip_tag_names_in_db(op.get_bind())
- strip_tag_names_in_configs(colors)
-
-
-def downgrade():
- # Whitespace that was stripped is not restored.
- pass
diff --git a/app/cypress/e2e/settings/special-directories.cy.js b/app/cypress/e2e/settings/special-directories.cy.js
index 021df88d..0a9e248d 100644
--- a/app/cypress/e2e/settings/special-directories.cy.js
+++ b/app/cypress/e2e/settings/special-directories.cy.js
@@ -1,5 +1,5 @@
/*
- * The six Special Directories fields on /admin/settings.
+ * The five Special Directories fields on /admin/settings.
*
* These are the fields that shipped printing their own prefix over their own value, and the
* ones whose visible labels were never associated with their inputs. Both faults were
@@ -19,7 +19,6 @@ describe('Special Directories settings', () => {
['Map Directory', 'map'],
['Zims Directory', 'zims'],
['Playlists Directory', 'playlists'],
- ['Repos Directory', 'repos/%(repo_tag)s/%(repo_name)s'],
];
beforeEach(() => {
@@ -39,8 +38,8 @@ describe('Special Directories settings', () => {
it('names every directory field, so they can be told apart', () => {
/*
- * All six fields sit in one grid, hold similar-looking relative paths, and share the
- * same prefix. Without a name attached to the input, a screen reader user hears six
+ * All five fields sit in one grid, hold similar-looking relative paths, and share the
+ * same prefix. Without a name attached to the input, a screen reader user hears five
* fields described only as "/media/wrolpi/" and cannot tell which one saves videos.
*/
directories.forEach(([label, value]) => {
diff --git a/app/cypress/support/component.js b/app/cypress/support/component.js
index fc66e133..e6d426e8 100644
--- a/app/cypress/support/component.js
+++ b/app/cypress/support/component.js
@@ -41,7 +41,7 @@ import React from "react";
Cypress.on('uncaught:exception', (err, runnable) => {
if (err.message.includes('ChunkLoadError')) {
- return false; // Ignore this error which happens after CI tests.
+ return false; // Ignore this error which happens after CircleCI tests.
}
// Otherwise, fail like normal
return true;
diff --git a/app/package-lock.json b/app/package-lock.json
index 94743961..0ff57ad5 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -14,36 +14,34 @@
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
- "@tabler/icons-react": "^3.49.0",
+ "@tabler/icons-react": "^3.46.0",
"@testing-library/react": "^13.4.0",
- "@testing-library/user-event": "^14.6.7",
+ "@testing-library/user-event": "^14.6.6",
"cypress": "^15.21.1",
- "dompurify": "^3.4.17",
- "esptool-js": "^0.7.0",
+ "esptool-js": "^0.6.1",
"lodash": "^4.18.1",
"maplibre-gl": "^5.24.0",
- "markdown-it": "^15.0.3",
"mathjs": "^15.2.0",
"pmtiles": "^4.5.0",
"protomaps-themes-base": "^4.5.0",
"query-string": "^9.5.1",
- "react": "^18.3.1",
- "react-colorful": "^5.8.1",
- "react-dom": "^18.3.1",
- "react-dropzone": "^14.4.1",
+ "react": "^18.2.0",
+ "react-colorful": "^5.8.0",
+ "react-dom": "^18.2.0",
+ "react-dropzone": "^14.2.3",
"react-hotkeys-hook": "^5.3.3",
"react-qr-code": "^2.2.0",
- "react-router": "^7.18.4",
+ "react-router": "^7.18.2",
"react-scripts": "5.0.1",
"react-stl-viewer": "^2.5.0",
- "three": "^0.186",
- "web-vitals": "^6.2.3"
+ "three": "^0.185",
+ "web-vitals": "^5.3.0"
},
"devDependencies": {
- "@testing-library/dom": "^10.4.2",
+ "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@types/lodash": "^4.17.25",
- "@types/node": "^26.6.4",
+ "@types/node": "^26.4.0",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"typescript": "^5.9.3",
@@ -3712,9 +3710,9 @@
}
},
"node_modules/@eslint/eslintrc/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==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+ "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"funding": [
{
"type": "github",
@@ -5977,9 +5975,9 @@
}
},
"node_modules/@tabler/icons": {
- "version": "3.49.0",
- "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.49.0.tgz",
- "integrity": "sha512-l+GnSkFETx0U26icxv1VUFnxxiTqXfCcVRiRAnyMwibupM2S2L/hSaZ9gmSgz01rkrsH/93NvLsxykqdanc6lw==",
+ "version": "3.46.0",
+ "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz",
+ "integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5987,12 +5985,12 @@
}
},
"node_modules/@tabler/icons-react": {
- "version": "3.49.0",
- "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.49.0.tgz",
- "integrity": "sha512-Um59pm5RVNUaQjFUtG0r4oYJbZuBS3tGugAA2tgBg0WufMk34Eio3nsO30RUkLuL2giYPQUNdY9HvqltLLYyNA==",
+ "version": "3.46.0",
+ "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz",
+ "integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==",
"license": "MIT",
"dependencies": {
- "@tabler/icons": "3.49.0"
+ "@tabler/icons": "3.46.0"
},
"funding": {
"type": "github",
@@ -6003,9 +6001,9 @@
}
},
"node_modules/@testing-library/dom": {
- "version": "10.4.2",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
- "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
@@ -6165,9 +6163,9 @@
}
},
"node_modules/@testing-library/user-event": {
- "version": "14.6.7",
- "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz",
- "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==",
+ "version": "14.6.6",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz",
+ "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==",
"license": "MIT",
"engines": {
"node": ">=12",
@@ -6468,12 +6466,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "26.6.4",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
- "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
+ "version": "26.4.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
+ "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
"license": "MIT",
"dependencies": {
- "undici-types": "~8.9.0"
+ "undici-types": "~8.3.0"
}
},
"node_modules/@types/node-forge": {
@@ -7485,10 +7483,9 @@
"license": "MIT"
},
"node_modules/attr-accept": {
- "version": "2.2.5",
- "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
- "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
- "license": "MIT",
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
+ "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==",
"engines": {
"node": ">=4"
}
@@ -8078,10 +8075,9 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
},
"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==",
- "license": "MIT",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -8631,10 +8627,9 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
},
"node_modules/colord": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz",
- "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==",
- "license": "MIT"
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
+ "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
},
"node_modules/colorette": {
"version": "2.0.20",
@@ -8706,15 +8701,13 @@
}
},
"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",
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+ "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"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",
@@ -8722,10 +8715,6 @@
},
"engines": {
"node": ">= 0.8.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
}
},
"node_modules/compression/node_modules/debug": {
@@ -9937,15 +9926,6 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
- "node_modules/dompurify": {
- "version": "3.4.17",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
- "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
- "license": "(MPL-2.0 OR Apache-2.0)",
- "optionalDependencies": {
- "@types/trusted-types": "^2.0.7"
- }
- },
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -11028,13 +11008,14 @@
}
},
"node_modules/esptool-js": {
- "version": "0.7.0",
- "resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.7.0.tgz",
- "integrity": "sha512-9wHuW/s9t4Vs6HKcr3pra3gbRVWwaN0eabMWodWH4KEJHEYZxgmWwZ3s0F4xcWVCZOc/L98bGZ4uWoZvR8KBKA==",
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.6.1.tgz",
+ "integrity": "sha512-WNgQTfaEIgHyEiT56pI5v7Tq6Pzjc2XaibLxAtWY4v3zE2Ofk5ImkJY5foEUr0JrdkfHWf6rNizAewN4/kSpHw==",
"license": "Apache-2.0",
"dependencies": {
"atob-lite": "^2.0.0",
- "pako": "^2.1.0"
+ "pako": "^2.1.0",
+ "tslib": "^2.4.1"
}
},
"node_modules/esquery": {
@@ -11163,9 +11144,9 @@
}
},
"node_modules/express": {
- "version": "4.22.3",
- "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
- "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
@@ -11187,9 +11168,9 @@
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
- "path-to-regexp": "~0.1.13",
+ "path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
- "qs": "~6.16.0",
+ "qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -11226,22 +11207,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
- "node_modules/express/node_modules/qs": {
- "version": "6.16.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
- "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "es-define-property": "^1.0.1",
- "side-channel": "^1.1.1"
- },
- "engines": {
- "node": ">=0.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -11297,9 +11262,9 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
},
"node_modules/fast-uri": {
- "version": "3.1.8",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
- "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [
{
"type": "github",
@@ -11376,12 +11341,11 @@
}
},
"node_modules/file-selector": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
- "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
- "license": "MIT",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
+ "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
"dependencies": {
- "tslib": "^2.7.0"
+ "tslib": "^2.4.0"
},
"engines": {
"node": ">= 12"
@@ -11397,10 +11361,9 @@
}
},
"node_modules/filelist/node_modules/brace-expansion": {
- "version": "2.1.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
- "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
- "license": "MIT",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -11526,16 +11489,15 @@
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="
},
"node_modules/follow-redirects": {
- "version": "1.16.1",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
- "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
- "license": "MIT",
"engines": {
"node": ">=4.0"
},
@@ -15313,9 +15275,9 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
- "version": "3.15.2",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
- "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
+ "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
@@ -15522,13 +15484,13 @@
}
},
"node_modules/launch-editor": {
- "version": "2.14.2",
- "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.2.tgz",
- "integrity": "sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==",
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz",
+ "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==",
"license": "MIT",
"dependencies": {
"picocolors": "^1.1.1",
- "shell-quote": "^1.10.0"
+ "shell-quote": "^1.8.4"
}
},
"node_modules/leven": {
@@ -15564,25 +15526,6 @@
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
},
- "node_modules/linkify-it": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
- "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/puzrin"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/markdown-it"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "uc.micro": "^3.0.0"
- }
- },
"node_modules/listr2": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
@@ -16078,61 +16021,6 @@
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
- "node_modules/markdown-it": {
- "version": "15.0.3",
- "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.3.tgz",
- "integrity": "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/puzrin"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/markdown-it"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "argparse": "^3.0.3",
- "entities": "^8.1.0",
- "linkify-it": "^6.1.0",
- "mdurl": "^2.1.0",
- "punycode.js": "^2.3.1",
- "uc.micro": "^3.0.0"
- },
- "bin": {
- "markdown-it": "bin/markdown-it.mjs"
- }
- },
- "node_modules/markdown-it/node_modules/argparse": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.3.tgz",
- "integrity": "sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/puzrin"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/nodeca"
- }
- ],
- "license": "PSF-2.0"
- },
- "node_modules/markdown-it/node_modules/entities": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
- "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=20.19.0"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
- },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -16180,12 +16068,6 @@
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
"integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA=="
},
- "node_modules/mdurl": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
- "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==",
- "license": "MIT"
- },
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -16437,9 +16319,9 @@
"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==",
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
@@ -18487,20 +18369,15 @@
"license": "BSD-3-Clause"
},
"node_modules/proxy-addr": {
- "version": "2.0.8",
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
- "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
- "license": "MIT",
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr/node_modules/ipaddr.js": {
@@ -18538,15 +18415,6 @@
"node": ">=6"
}
},
- "node_modules/punycode.js": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
- "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
@@ -18734,10 +18602,9 @@
}
},
"node_modules/react": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
- "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
- "license": "MIT",
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
+ "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -18762,10 +18629,9 @@
}
},
"node_modules/react-colorful": {
- "version": "5.8.1",
- "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.1.tgz",
- "integrity": "sha512-oz68bhsnFWnpDf1ZR8daiQbYpXUnM2h2J6hl9Zg2rTpM/DU6vCqe1E+CpqmqLnJucMZetHZeifSAfJ+geN9lcA==",
- "license": "MIT",
+ "version": "5.8.0",
+ "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.0.tgz",
+ "integrity": "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng==",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
@@ -18889,26 +18755,24 @@
}
},
"node_modules/react-dom": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
- "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
- "license": "MIT",
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+ "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"dependencies": {
"loose-envify": "^1.1.0",
- "scheduler": "^0.23.2"
+ "scheduler": "^0.23.0"
},
"peerDependencies": {
- "react": "^18.3.1"
+ "react": "^18.2.0"
}
},
"node_modules/react-dropzone": {
- "version": "14.4.1",
- "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz",
- "integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==",
- "license": "MIT",
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
+ "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==",
"dependencies": {
- "attr-accept": "^2.2.4",
- "file-selector": "^2.1.0",
+ "attr-accept": "^2.2.2",
+ "file-selector": "^0.6.0",
"prop-types": "^15.8.1"
},
"engines": {
@@ -19038,10 +18902,9 @@
}
},
"node_modules/react-router": {
- "version": "7.18.4",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
- "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
- "license": "MIT",
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
+ "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
@@ -20019,10 +19882,9 @@
}
},
"node_modules/scheduler": {
- "version": "0.23.2",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
- "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
- "license": "MIT",
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
+ "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
"dependencies": {
"loose-envify": "^1.1.0"
}
@@ -20287,9 +20149,9 @@
}
},
"node_modules/shell-quote": {
- "version": "1.12.0",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
- "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
+ "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"
@@ -20450,9 +20312,9 @@
}
},
"node_modules/source-map-js": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
- "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "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"
@@ -21284,10 +21146,9 @@
}
},
"node_modules/three": {
- "version": "0.186.1",
- "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
- "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
- "license": "MIT"
+ "version": "0.185.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
+ "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="
},
"node_modules/three-stdlib": {
"version": "2.17.2",
@@ -21630,12 +21491,6 @@
"node": ">=14.17"
}
},
- "node_modules/uc.micro": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz",
- "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==",
- "license": "MIT"
- },
"node_modules/unbox-primitive": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
@@ -21651,10 +21506,9 @@
}
},
"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"
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.0",
@@ -22006,10 +21860,9 @@
}
},
"node_modules/web-vitals": {
- "version": "6.2.3",
- "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.3.tgz",
- "integrity": "sha512-Dmy+vdO1UPMqXiAtZSNayhl4f794Bxfd94W4HIj2HlYtczEQfEOon969memlcpcHrTuOKLa5baFjX7WbgmOq+A==",
- "license": "Apache-2.0"
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
+ "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="
},
"node_modules/webidl-conversions": {
"version": "6.1.0",
@@ -25658,9 +25511,9 @@
}
},
"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==",
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+ "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"requires": {
"argparse": "^2.0.1"
}
@@ -27167,22 +27020,22 @@
}
},
"@tabler/icons": {
- "version": "3.49.0",
- "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.49.0.tgz",
- "integrity": "sha512-l+GnSkFETx0U26icxv1VUFnxxiTqXfCcVRiRAnyMwibupM2S2L/hSaZ9gmSgz01rkrsH/93NvLsxykqdanc6lw=="
+ "version": "3.46.0",
+ "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz",
+ "integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A=="
},
"@tabler/icons-react": {
- "version": "3.49.0",
- "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.49.0.tgz",
- "integrity": "sha512-Um59pm5RVNUaQjFUtG0r4oYJbZuBS3tGugAA2tgBg0WufMk34Eio3nsO30RUkLuL2giYPQUNdY9HvqltLLYyNA==",
+ "version": "3.46.0",
+ "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz",
+ "integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==",
"requires": {
- "@tabler/icons": "3.49.0"
+ "@tabler/icons": "3.46.0"
}
},
"@testing-library/dom": {
- "version": "10.4.2",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
- "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"requires": {
"@babel/code-frame": "7.29.0",
"@babel/runtime": "^7.12.5",
@@ -27295,9 +27148,9 @@
}
},
"@testing-library/user-event": {
- "version": "14.6.7",
- "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz",
- "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==",
+ "version": "14.6.6",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz",
+ "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==",
"requires": {}
},
"@tootallnate/once": {
@@ -27559,11 +27412,11 @@
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
},
"@types/node": {
- "version": "26.6.4",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
- "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
+ "version": "26.4.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
+ "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
"requires": {
- "undici-types": "~8.9.0"
+ "undici-types": "~8.3.0"
}
},
"@types/node-forge": {
@@ -28324,9 +28177,9 @@
"integrity": "sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw=="
},
"attr-accept": {
- "version": "2.2.5",
- "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
- "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="
+ "version": "2.2.2",
+ "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
+ "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg=="
},
"autoprefixer": {
"version": "10.4.13",
@@ -28748,9 +28601,9 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
},
"brace-expansion": {
- "version": "1.1.21",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
- "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -29118,9 +28971,9 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
},
"colord": {
- "version": "2.10.0",
- "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz",
- "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw=="
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
+ "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
},
"colorette": {
"version": "2.0.20",
@@ -29170,14 +29023,13 @@
}
},
"compression": {
- "version": "1.8.2",
- "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
- "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+ "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"requires": {
"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",
@@ -30026,14 +29878,6 @@
"domelementtype": "^2.2.0"
}
},
- "dompurify": {
- "version": "3.4.17",
- "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
- "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
- "requires": {
- "@types/trusted-types": "^2.0.7"
- }
- },
"domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -30817,12 +30661,13 @@
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"esptool-js": {
- "version": "0.7.0",
- "resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.7.0.tgz",
- "integrity": "sha512-9wHuW/s9t4Vs6HKcr3pra3gbRVWwaN0eabMWodWH4KEJHEYZxgmWwZ3s0F4xcWVCZOc/L98bGZ4uWoZvR8KBKA==",
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.6.1.tgz",
+ "integrity": "sha512-WNgQTfaEIgHyEiT56pI5v7Tq6Pzjc2XaibLxAtWY4v3zE2Ofk5ImkJY5foEUr0JrdkfHWf6rNizAewN4/kSpHw==",
"requires": {
"atob-lite": "^2.0.0",
- "pako": "^2.1.0"
+ "pako": "^2.1.0",
+ "tslib": "^2.4.1"
}
},
"esquery": {
@@ -30917,9 +30762,9 @@
}
},
"express": {
- "version": "4.22.3",
- "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
- "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"requires": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -30940,9 +30785,9 @@
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
- "path-to-regexp": "~0.1.13",
+ "path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
- "qs": "~6.16.0",
+ "qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -30971,15 +30816,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
- },
- "qs": {
- "version": "6.16.0",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
- "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
- "requires": {
- "es-define-property": "^1.0.1",
- "side-channel": "^1.1.1"
- }
}
}
},
@@ -31031,9 +30867,9 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
},
"fast-uri": {
- "version": "3.1.8",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
- "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="
},
"fastq": {
"version": "1.15.0",
@@ -31082,11 +30918,11 @@
}
},
"file-selector": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
- "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
+ "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
"requires": {
- "tslib": "^2.7.0"
+ "tslib": "^2.4.0"
}
},
"filelist": {
@@ -31098,9 +30934,9 @@
},
"dependencies": {
"brace-expansion": {
- "version": "2.1.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
- "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"requires": {
"balanced-match": "^1.0.0"
}
@@ -31196,9 +31032,9 @@
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="
},
"follow-redirects": {
- "version": "1.16.1",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
- "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="
},
"for-each": {
"version": "0.3.3",
@@ -33845,9 +33681,9 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"js-yaml": {
- "version": "3.15.2",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
- "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
+ "version": "3.15.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
+ "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"requires": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
@@ -34005,12 +33841,12 @@
}
},
"launch-editor": {
- "version": "2.14.2",
- "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.2.tgz",
- "integrity": "sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==",
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz",
+ "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==",
"requires": {
"picocolors": "^1.1.1",
- "shell-quote": "^1.10.0"
+ "shell-quote": "^1.8.4"
}
},
"leven": {
@@ -34037,14 +33873,6 @@
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
},
- "linkify-it": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
- "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==",
- "requires": {
- "uc.micro": "^3.0.0"
- }
- },
"listr2": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
@@ -34395,31 +34223,6 @@
}
}
},
- "markdown-it": {
- "version": "15.0.3",
- "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.3.tgz",
- "integrity": "sha512-+Az7Ojz3iRGu8KYMvJ6xVhvrxP3eE+vEtJX75Ow88XBpcQNV/0lGX5ZynIIBftREo4DC2jsveAMDaeltmm8QvQ==",
- "requires": {
- "argparse": "^3.0.3",
- "entities": "^8.1.0",
- "linkify-it": "^6.1.0",
- "mdurl": "^2.1.0",
- "punycode.js": "^2.3.1",
- "uc.micro": "^3.0.0"
- },
- "dependencies": {
- "argparse": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.3.tgz",
- "integrity": "sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w=="
- },
- "entities": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
- "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="
- }
- }
- },
"math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -34453,11 +34256,6 @@
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz",
"integrity": "sha512-iV3XNKw06j5Q7mi6h+9vbx23Tv7JkjEVgKHW4pimwyDGWm0OIQntJJ+u1C6mg6mK1EaTv42XQ7w76yuzH7M2cA=="
},
- "mdurl": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz",
- "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg=="
- },
"media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -34630,9 +34428,9 @@
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="
},
"nanoid": {
- "version": "3.3.19",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
- "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="
},
"natural-compare": {
"version": "1.4.0",
@@ -35902,9 +35700,9 @@
"integrity": "sha512-z5A6oXAXNi/CHEIbCfzZ/6uVDgnv3pO4C3LFvomxjUFXwyqWZ7HJdfPoNJSIlsvaQubqsaxCbamEzqBr/W7vkg=="
},
"proxy-addr": {
- "version": "2.0.8",
- "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
- "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"requires": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
@@ -35941,11 +35739,6 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz",
"integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA=="
},
- "punycode.js": {
- "version": "2.3.1",
- "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
- "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="
- },
"pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
@@ -36070,9 +35863,9 @@
}
},
"react": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
- "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
+ "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"requires": {
"loose-envify": "^1.1.0"
}
@@ -36091,9 +35884,9 @@
}
},
"react-colorful": {
- "version": "5.8.1",
- "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.1.tgz",
- "integrity": "sha512-oz68bhsnFWnpDf1ZR8daiQbYpXUnM2h2J6hl9Zg2rTpM/DU6vCqe1E+CpqmqLnJucMZetHZeifSAfJ+geN9lcA==",
+ "version": "5.8.0",
+ "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.0.tgz",
+ "integrity": "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng==",
"requires": {}
},
"react-dev-utils": {
@@ -36183,21 +35976,21 @@
}
},
"react-dom": {
- "version": "18.3.1",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
- "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
+ "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"requires": {
"loose-envify": "^1.1.0",
- "scheduler": "^0.23.2"
+ "scheduler": "^0.23.0"
}
},
"react-dropzone": {
- "version": "14.4.1",
- "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz",
- "integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==",
+ "version": "14.2.3",
+ "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
+ "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==",
"requires": {
- "attr-accept": "^2.2.4",
- "file-selector": "^2.1.0",
+ "attr-accept": "^2.2.2",
+ "file-selector": "^0.6.0",
"prop-types": "^15.8.1"
}
},
@@ -36278,9 +36071,9 @@
}
},
"react-router": {
- "version": "7.18.4",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
- "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
+ "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
"requires": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
@@ -36914,9 +36707,9 @@
}
},
"scheduler": {
- "version": "0.23.2",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
- "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
+ "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
"requires": {
"loose-envify": "^1.1.0"
}
@@ -37137,9 +36930,9 @@
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
},
"shell-quote": {
- "version": "1.12.0",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
- "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q=="
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
+ "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="
},
"side-channel": {
"version": "1.1.1",
@@ -37245,9 +37038,9 @@
"integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="
},
"source-map-js": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
- "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="
+ "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=="
},
"source-map-loader": {
"version": "3.0.2",
@@ -37815,9 +37608,9 @@
"requires": {}
},
"three": {
- "version": "0.186.1",
- "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
- "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA=="
+ "version": "0.185.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
+ "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="
},
"three-stdlib": {
"version": "2.17.2",
@@ -38077,11 +37870,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="
},
- "uc.micro": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz",
- "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw=="
- },
"unbox-primitive": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz",
@@ -38094,9 +37882,9 @@
}
},
"undici-types": {
- "version": "8.9.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
- "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="
},
"unicode-canonical-property-names-ecmascript": {
"version": "2.0.0",
@@ -38323,9 +38111,9 @@
}
},
"web-vitals": {
- "version": "6.2.3",
- "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.3.tgz",
- "integrity": "sha512-Dmy+vdO1UPMqXiAtZSNayhl4f794Bxfd94W4HIj2HlYtczEQfEOon969memlcpcHrTuOKLa5baFjX7WbgmOq+A=="
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
+ "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="
},
"webidl-conversions": {
"version": "6.1.0",
diff --git a/app/package.json b/app/package.json
index 8dd5dd31..aadddfc5 100644
--- a/app/package.json
+++ b/app/package.json
@@ -9,30 +9,28 @@
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
- "@tabler/icons-react": "^3.49.0",
+ "@tabler/icons-react": "^3.46.0",
"@testing-library/react": "^13.4.0",
- "@testing-library/user-event": "^14.6.7",
+ "@testing-library/user-event": "^14.6.6",
"cypress": "^15.21.1",
- "dompurify": "^3.4.17",
- "esptool-js": "^0.7.0",
+ "esptool-js": "^0.6.1",
"lodash": "^4.18.1",
"maplibre-gl": "^5.24.0",
- "markdown-it": "^15.0.3",
"mathjs": "^15.2.0",
"pmtiles": "^4.5.0",
"protomaps-themes-base": "^4.5.0",
"query-string": "^9.5.1",
- "react": "^18.3.1",
- "react-colorful": "^5.8.1",
- "react-dom": "^18.3.1",
- "react-dropzone": "^14.4.1",
+ "react": "^18.2.0",
+ "react-colorful": "^5.8.0",
+ "react-dom": "^18.2.0",
+ "react-dropzone": "^14.2.3",
"react-hotkeys-hook": "^5.3.3",
"react-qr-code": "^2.2.0",
- "react-router": "^7.18.4",
+ "react-router": "^7.18.2",
"react-scripts": "5.0.1",
"react-stl-viewer": "^2.5.0",
- "three": "^0.186",
- "web-vitals": "^6.2.3"
+ "three": "^0.185",
+ "web-vitals": "^5.3.0"
},
"scripts": {
"start": "react-scripts start",
@@ -60,10 +58,10 @@
]
},
"devDependencies": {
- "@testing-library/dom": "^10.4.2",
+ "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@types/lodash": "^4.17.25",
- "@types/node": "^26.6.4",
+ "@types/node": "^26.4.0",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"typescript": "^5.9.3",
diff --git a/app/public/data/solar-climatology.LICENSE.txt b/app/public/data/solar-climatology.LICENSE.txt
deleted file mode 100644
index 4290e795..00000000
--- a/app/public/data/solar-climatology.LICENSE.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-Solar climatology for the WROLPi Solar calculator
-
-Source: NASA POWER, https://power.larc.nasa.gov/
-Data: 2001-2020 monthly climatology of ALLSKY_SFC_SW_DWN, ALLSKY_SFC_SW_DIFF, ALLSKY_SRF_ALB, T2M, on a 1° grid.
-The data was obtained from the POWER Project's POWER Climatology API v2.10.0 version on 2026/10/04.
-
-The data was obtained from National Aeronautics and Space Administration (NASA) Langley Research Center's Prediction Of Worldwide Energy Resources (POWER) project funded through the NASA Earth Science Division.
-
-License: Creative Commons Attribution 4.0 International (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/
-
-Changes: WROLPi modified the data: values are rounded to fit one byte each (0.04 kWh/m²/day for irradiance, 0.004 for albedo, 0.5 °C for temperature), temperatures are averaged from NASA's 0.5° x 0.625° grid into 1° cells, and the calculator interpolates between cells.
diff --git a/app/public/data/solar-climatology.bin b/app/public/data/solar-climatology.bin
deleted file mode 100644
index 9a5bcb68..00000000
Binary files a/app/public/data/solar-climatology.bin and /dev/null differ
diff --git a/app/public/epub/epub.html b/app/public/epub/epub.html
index 73f1289a..3a1ab60e 100644
--- a/app/public/epub/epub.html
+++ b/app/public/epub/epub.html
@@ -242,8 +242,6 @@ either expressed or implied, of the FreeBSD Project. -->
// Optional search term passed in from a search-result deep link. If present we'll
// search the spine item at `loc` and jump to + highlight the first match.
let searchQuery = (params && params.get("q")) ? params.get("q") : null;
- // Where the reader left off (an EPUB CFI). A search result's `loc` wins over it.
- let startCfi = (params && params.get("cfi")) ? params.get("cfi") : null;
let book = ePub(url);
let rendition = book.renderTo("viewer", {
@@ -252,33 +250,7 @@ either expressed or implied, of the FreeBSD Project. -->
height: "100%"
});
- rendition.display(currentSectionIndex !== undefined ? currentSectionIndex : (startCfi || undefined));
-
- // How far through the book `location` is, from its chapter and its page within that chapter. Cheaper
- // than `book.locations.generate()`, which stalls a Raspberry Pi on a large book.
- function readingProgress(location) {
- if (location.atEnd) {
- return 1;
- }
- const start = location.start;
- const sections = book.spine.length || 1;
- const displayed = start.displayed || {page: 1, total: 1};
- const withinSection = (displayed.page - 1) / Math.max(displayed.total, 1);
- return Math.min((start.index + withinSection) / sections, 1);
- }
-
- // Tell the WROLPi page that embeds this viewer where the reader is, so it can save their place. Nothing is
- // posted at the start of the book: that is where a finished book opens, and saving it would un-finish it.
- function postLocation(location) {
- if (window.parent === window || !location || !location.start || location.atStart) {
- return;
- }
- window.parent.postMessage({
- type: "wrolpi:epub-location",
- cfi: location.start.cfi,
- progress: readingProgress(location),
- }, window.location.origin);
- }
+ rendition.display(currentSectionIndex);
// --- In-book search ---------------------------------------------------
// State is module-level so the toolbar buttons can drive it. `hits` is the
@@ -544,7 +516,6 @@ either expressed or implied, of the FreeBSD Project. -->
prev.style.visibility = "visible";
}
- postLocation(location);
});
rendition.on("layout", function (layout) {
diff --git a/app/src/App.css b/app/src/App.css
index 6b423219..d0f46346 100644
--- a/app/src/App.css
+++ b/app/src/App.css
@@ -316,90 +316,6 @@ pre.wrap-text {
height: 100%;
}
-/* A markdown file preview, formatted or raw. Scrolls rather than fitting like .preview-fit, because
- it is a document to read, not media to frame. Colors come from the theme tokens. */
-.markdown-preview {
- height: calc(100dvh - 21rem);
- min-height: 15rem;
- overflow: auto;
- margin-top: 0.5em;
- padding: 0 0.25em;
-}
-
-/* A repo's README is part of its page, not a modal; it grows with its content. */
-.repo-readme .markdown-preview {
- height: auto;
- min-height: 0;
- overflow: visible;
-}
-
-.markdown-raw {
- margin: 0;
- white-space: pre-wrap;
- overflow-wrap: anywhere;
-}
-
-.markdown-body {
- max-width: 50rem;
- line-height: 1.6;
- overflow-wrap: break-word;
-}
-
-.markdown-body > :first-child {
- margin-top: 0;
-}
-
-.markdown-body h1, .markdown-body h2 {
- padding-bottom: 0.25em;
- border-bottom: 1px solid var(--table-line);
-}
-
-.markdown-body img {
- max-width: 100%;
-}
-
-.markdown-body blockquote {
- margin: 0 0 1em;
- padding: 0 1em;
- color: var(--muted);
- border-left: 0.25em solid var(--border);
-}
-
-.markdown-body code {
- padding: 0.1em 0.3em;
- border-radius: 0.25em;
- background-color: var(--head);
-}
-
-.markdown-body pre {
- padding: 0.75em 1em;
- overflow-x: auto;
- border-radius: 0.25em;
- background-color: var(--head);
-}
-
-.markdown-body pre code {
- padding: 0;
- background-color: transparent;
-}
-
-.markdown-body table {
- display: block;
- max-width: 100%;
- overflow-x: auto;
- border-collapse: collapse;
-}
-
-.markdown-body th, .markdown-body td {
- padding: 0.3em 0.75em;
- border: 1px solid var(--table-line);
-}
-
-.markdown-body hr {
- border: none;
- border-top: 1px solid var(--border);
-}
-
td.file-path {
cursor: pointer;
}
@@ -809,35 +725,3 @@ tr.drag-deselecting td {
display: block;
}
}
-
-/* Map popups (POI details and pins) follow the active theme's tokens. */
-/* Scoped under .maplibregl-popup to outrank maplibre-gl.css, which loads after this file. */
-.maplibregl-popup .maplibregl-popup-content {
- background: var(--panel);
- color: var(--text);
- border: 1px solid var(--border);
- border-radius: 0.25rem;
-}
-.maplibregl-popup .maplibregl-popup-close-button {
- color: var(--muted);
-}
-.maplibregl-popup .maplibregl-popup-close-button:hover {
- background: var(--head);
- color: var(--text);
-}
-.maplibregl-popup.maplibregl-popup-anchor-top .maplibregl-popup-tip,
-.maplibregl-popup.maplibregl-popup-anchor-top-left .maplibregl-popup-tip,
-.maplibregl-popup.maplibregl-popup-anchor-top-right .maplibregl-popup-tip {
- border-bottom-color: var(--panel);
-}
-.maplibregl-popup.maplibregl-popup-anchor-bottom .maplibregl-popup-tip,
-.maplibregl-popup.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip,
-.maplibregl-popup.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip {
- border-top-color: var(--panel);
-}
-.maplibregl-popup.maplibregl-popup-anchor-left .maplibregl-popup-tip {
- border-right-color: var(--panel);
-}
-.maplibregl-popup.maplibregl-popup-anchor-right .maplibregl-popup-tip {
- border-left-color: var(--panel);
-}
diff --git a/app/src/App.js b/app/src/App.js
index 088cfa95..a9881881 100644
--- a/app/src/App.js
+++ b/app/src/App.js
@@ -17,7 +17,6 @@ import {ArchiveRoute} from "./components/Archive";
import {FilesRoute} from "./components/Files";
import {QueryProvider, StatusProvider} from "./hooks/customHooks";
import {FileWorkerStatusProvider} from "./contexts/FileWorkerStatusContext";
-import {BookmarksProvider} from "./contexts/BookmarksContext";
import {ChannelEditPage, ChannelNewPage, ChannelsPage} from "./components/Channels";
import {MapRoute} from "./components/Map";
import {MediaContextProvider, mediaStyles, StatusContext} from "./contexts/contexts";
@@ -30,9 +29,9 @@ import {FilePreviewProvider} from "./components/FilePreview";
import {TagsProvider} from "./Tags";
import {ZimRoute} from "./components/Zim";
import {PlaylistsRoute} from "./components/Playlists";
-import {ReposRoute} from "./components/Repos";
import {DocsRoute} from "./components/Docs";
import {FlasherRoute} from "./components/Flasher";
+import {AiRoute} from "./components/Ai";
import ErrorBoundary from "./components/ErrorBoundary";
import {KeyboardShortcutsProvider} from "./components/KeyboardShortcutsProvider";
@@ -71,15 +70,13 @@ function Root() {
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
@@ -87,12 +84,10 @@ function Root() {
}
-export const routes = createRoutesFromElements(}
- // Root (and its ThemeProvider) is not rendered when Root itself fails, so the error page
- // brings its own theme; Mantine components cannot render without one.
- errorElement={}
+ errorElement={}
>
}/>
}/>
@@ -120,14 +115,10 @@ export const routes = createRoutesFromElements(}/>
}/>
}/>
- }/>
}/>
}/>
- {/* Unknown URLs render inside Root, with the navigation bar and theme. */}
- }/>
-);
-
-const router = createBrowserRouter(routes);
+ }/>
+));
export default function App() {
useEventsInterval();
diff --git a/app/src/App.test.js b/app/src/App.test.js
deleted file mode 100644
index f7e78352..00000000
--- a/app/src/App.test.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import React from 'react';
-import {render, screen} from '@testing-library/react';
-import {createMemoryRouter, RouterProvider} from 'react-router';
-import {routes} from './App';
-import {StatusProvider} from './hooks/customHooks';
-import {FileWorkerStatusProvider} from './contexts/FileWorkerStatusContext';
-import {MediaContextProvider} from './contexts/contexts';
-
-// Jest does not transform CSS inside node_modules (see transformIgnorePatterns).
-jest.mock('@mantine/core/styles.css', () => ({}));
-jest.mock('@mantine/notifications/styles.css', () => ({}));
-// Routes this test does not visit, whose libraries jsdom/Jest cannot load (maplibre needs
-// browser APIs; esptool-js is ESM-only).
-jest.mock('./components/Map', () => ({MapRoute: () => null}));
-jest.mock('./components/Flasher', () => ({FlasherRoute: () => null}));
-
-describe('unknown routes', () => {
- beforeEach(() => {
- // resetMocks clears setupTests' implementation; the theme and media providers read it.
- window.matchMedia.mockImplementation(query => ({
- matches: false,
- media: query,
- addListener: jest.fn(),
- removeListener: jest.fn(),
- addEventListener: jest.fn(),
- removeEventListener: jest.fn(),
- }));
- global.fetch = jest.fn(() => Promise.resolve({
- ok: true,
- status: 200,
- headers: {get: () => 'application/json'},
- json: () => Promise.resolve({tags: []}),
- text: () => Promise.resolve('{}'),
- }));
- });
-
- it('renders Page Not Found inside the app layout', async () => {
- const router = createMemoryRouter(routes, {initialEntries: ['/no-such-page']});
- // The providers App wraps around the router.
- render(
-
-
-
-
-
- );
-
- expect(await screen.findByText('Page Not Found!')).toBeInTheDocument();
- // Rendered inside Root's layout (the footer comes from Root), not as a bare error page.
- expect(screen.getByText('Donate')).toBeInTheDocument();
- });
-});
diff --git a/app/src/DashboardPage.js b/app/src/DashboardPage.js
index 1ab785ab..3faf7a5e 100644
--- a/app/src/DashboardPage.js
+++ b/app/src/DashboardPage.js
@@ -5,7 +5,6 @@ import {
PageContainer,
RefreshHeader,
SearchResultsInput,
- useLocalStorage,
useTitle
} from "./components/Common";
import React, {useContext, useState} from "react";
@@ -15,7 +14,6 @@ import {DownloadMenu} from "./components/Download";
import {
ActionInput,
Button,
- ButtonGroup,
Divider,
Header,
Icon,
@@ -252,8 +250,6 @@ export function Getters() {
Or
- {status?.flags && gettersDisabled &&
-
Download and Upload are available after your files are refreshed.
}
;
let getterModal;
@@ -355,14 +351,7 @@ export function DashboardStatus() {
}
function DashboardRecentFiles() {
- // Show only the files the user is part way through.
- const [continueOnly, setContinueOnly] = useLocalStorage('dashboard_recent_continue', false);
- const {searchFiles, loading, fetchFiles} = useSearchRecentFiles(continueOnly);
-
- let files = ;
- if (continueOnly && searchFiles && searchFiles.length === 0) {
- files =
Nothing in progress. A video, audio file, ebook or comic you stop part way through will be here.
+
}
diff --git a/app/src/DashboardPage.test.js b/app/src/DashboardPage.test.js
index 26d5bdb7..1d75414c 100644
--- a/app/src/DashboardPage.test.js
+++ b/app/src/DashboardPage.test.js
@@ -21,11 +21,11 @@ jest.mock('./hooks/customHooks', () => ({
useWROLMode: () => false,
}));
-function renderAtUrl(url, flags = {refresh_complete: true}) {
+function renderAtUrl(url) {
// test-utils' render() wraps content in a BrowserRouter (not MemoryRouter),
// so point the jsdom location at the desired URL before rendering.
window.history.pushState({}, '', url);
- const status = {flags};
+ const status = {flags: {refresh_complete: true}};
return render(
@@ -67,25 +67,6 @@ describe('DashboardPage Getters deep-link', () => {
});
});
-describe('DashboardPage Getters before the first refresh', () => {
- const note = /available after your files are refreshed/i;
-
- it('explains why Download and Upload are disabled', () => {
- renderAtUrl('/', {refresh_complete: false});
- expect(screen.getByText(note)).toBeInTheDocument();
- });
-
- it('says nothing once the refresh has completed', () => {
- renderAtUrl('/');
- expect(screen.queryByText(note)).toBeNull();
- });
-
- it('says nothing before the status has loaded', () => {
- renderAtUrl('/', null);
- expect(screen.queryByText(note)).toBeNull();
- });
-});
-
// The DownloadMenu's seed-object construction (joining initialUrls with
// newlines into the `download` prop's `urls` field) is exercised in the
// browser via manual verification, not unit-tested here — reaching into the
diff --git a/app/src/Events.js b/app/src/Events.js
index a9959b44..3d21622b 100644
--- a/app/src/Events.js
+++ b/app/src/Events.js
@@ -9,7 +9,7 @@ function eventToast(title, description, type = 'info', time = 5000, onClick = nu
toast({type: type, title: title, description: description, time: time, onClick: onClick});
}
-export function handleEvents(events) {
+function handleEvents(events) {
const newestEvents = {};
events.forEach(e => {
@@ -63,17 +63,13 @@ export function handleEvents(events) {
}
if (event === 'user_notify_message') {
- // Only a shared page carries a URL; other notifications (jobs, transcodes) are text.
- if (url) {
- eventToast(
- message,
- 'Click here to view the shared page',
- 'info',
- 10_000,
- () => window.open(url, '_self'));
- } else {
- eventToast(message, null, 'info', 10_000);
- }
+ console.log(message, url);
+ eventToast(
+ message,
+ 'Click here to view the shared page',
+ 'info',
+ 10_000,
+ () => window.open(url, '_self'));
}
if (event === 'deleted') {
diff --git a/app/src/Events.test.js b/app/src/Events.test.js
deleted file mode 100644
index d6644fb4..00000000
--- a/app/src/Events.test.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import {handleEvents} from './Events';
-import {toast} from './components/ui';
-
-jest.mock('./components/ui', () => ({toast: jest.fn()}));
-
-const notify = (url) => ({event: 'user_notify_message', message: 'Transcoding video.mp4', dt: '2026-10-12', url});
-
-afterEach(() => {
- toast.mockClear();
-});
-
-describe('user_notify_message', () => {
- it('a notification without a URL is plain text, not a link', () => {
- handleEvents([notify(null)]);
-
- expect(toast).toHaveBeenCalledTimes(1);
- const options = toast.mock.calls[0][0];
- expect(options.title).toBe('Transcoding video.mp4');
- expect(options.description).toBeFalsy();
- expect(options.onClick).toBeFalsy();
- });
-
- it('a shared page opens its URL when clicked', () => {
- const open = jest.spyOn(window, 'open').mockImplementation(() => null);
- handleEvents([notify('/archive/1')]);
-
- const options = toast.mock.calls[0][0];
- expect(options.description).toBe('Click here to view the shared page');
- options.onClick();
- expect(open).toHaveBeenCalledWith('/archive/1', '_self');
- open.mockRestore();
- });
-});
diff --git a/app/src/api.errorAsEmpty.test.js b/app/src/api.errorAsEmpty.test.js
deleted file mode 100644
index cb7c693c..00000000
--- a/app/src/api.errorAsEmpty.test.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import {
- flasherSearch, getConfigBackups, getDownloaders, getInventoryBackups, getMapSearchStatus, searchMap,
-} from './api';
-
-// A helper that fails must say so. These used to turn a non-OK response into an empty value
-// ([], {dates: []}, {results: []}, ...), so every consumer rendered "nothing here" for a server
-// error. They now return undefined; an empty value only ever comes from the server.
-
-jest.mock('./components/ui', () => ({
- ...jest.requireActual('./components/ui'),
- toast: jest.fn(),
-}));
-
-const response = (ok, body = {}) => {
- const r = {
- ok,
- status: ok ? 200 : 500,
- json: async () => body,
- text: async () => JSON.stringify(body),
- };
- r.clone = () => r;
- return r;
-};
-
-let originalFetch;
-beforeEach(() => {
- originalFetch = global.fetch;
-});
-afterEach(() => {
- global.fetch = originalFetch;
-});
-
-const failWith = () => {
- global.fetch = jest.fn(() => Promise.resolve(response(false, {error: 'boom'})));
-};
-const succeedWith = (body) => {
- global.fetch = jest.fn(() => Promise.resolve(response(true, body)));
-};
-
-describe('on a non-OK response, the helper returns undefined', () => {
- test.each([
- ['getConfigBackups', () => getConfigBackups('wrolpi.yaml')],
- ['getInventoryBackups', () => getInventoryBackups('food')],
- ['getDownloaders', () => getDownloaders()],
- ['searchMap', () => searchMap('camp')],
- ['getMapSearchStatus', () => getMapSearchStatus()],
- ['flasherSearch', () => flasherSearch(null, null)],
- ])('%s', async (_name, call) => {
- failWith();
- expect(await call()).toBeUndefined();
- });
-});
-
-describe('a confirmed empty result is still empty', () => {
- test('getConfigBackups', async () => {
- succeedWith({dates: []});
- expect(await getConfigBackups('wrolpi.yaml')).toEqual({dates: []});
- });
-
- test('getInventoryBackups', async () => {
- succeedWith({dates: []});
- expect(await getInventoryBackups('food')).toEqual([]);
- });
-
- test('getDownloaders', async () => {
- succeedWith({downloaders: []});
- expect(await getDownloaders()).toEqual({downloaders: []});
- });
-
- test('searchMap', async () => {
- succeedWith({results: [], total: 0});
- expect(await searchMap('camp')).toEqual({results: [], total: 0});
- });
-
- test('getMapSearchStatus', async () => {
- succeedWith({indexed: [], missing: []});
- expect(await getMapSearchStatus()).toEqual({indexed: [], missing: []});
- });
-
- test('flasherSearch', async () => {
- succeedWith({file_groups: [], totals: {file_groups: 0}});
- expect(await flasherSearch(null, null)).toEqual([[], 0]);
- });
-});
diff --git a/app/src/api.js b/app/src/api.js
index 527ad696..ddb05a2b 100644
--- a/app/src/api.js
+++ b/app/src/api.js
@@ -7,7 +7,6 @@ import {
COLLECTIONS_API,
DEFAULT_LIMIT,
Downloaders,
- REPOS_API,
VIDEOS_API,
ZIM_API
} from "./components/Vars";
@@ -407,7 +406,6 @@ export async function getSettings() {
nav_color: content.nav_color,
media_directory: content.media_directory,
playlists_destination: content.playlists_destination,
- repos_destination: content.repos_destination,
require_cookies_unlocked: content.require_cookies_unlocked,
require_media_mounted: content.require_media_mounted,
save_ffprobe_json: content.save_ffprobe_json,
@@ -524,7 +522,7 @@ export async function getConfigBackups(fileName) {
if (response.ok) {
return await response.json();
}
- // Undefined, not {dates: []}, so the modal can tell a failed fetch from having no backups.
+ return {dates: []};
}
export async function postConfigBackupPreview(fileName, backupDate, mode) {
@@ -722,7 +720,7 @@ export async function getInventoryBackups(slug) {
if (response.ok) {
return (await response.json())['dates'];
}
- // Undefined, not [], so the modal can tell a failed fetch from having no backups.
+ return [];
}
export async function postInventoryRestorePreview(slug, backupDate, mode) {
@@ -929,21 +927,6 @@ export async function getCollectionTagInfo(collectionId, tagName) {
}
}
-// Tag any Collection (and optionally move its directory).
-export async function tagCollection(collectionId, tagName, directory) {
- const body = {tag_name: tagName};
- if (directory) {
- body['directory'] = directory;
- }
- const response = await apiPost(`${COLLECTIONS_API}/${collectionId}/tag`, body);
- if (response.ok) {
- return await response.json();
- }
- const message = await getErrorMessage(response, 'Unable to tag. See server logs.');
- toast({type: 'error', title: 'Tag Error', description: message, time: 5000});
- throw new Error(message);
-}
-
export async function tagDomain(domainId, tagName, directory) {
const body = {
tag_name: tagName,
@@ -1276,15 +1259,12 @@ export async function startDownloads() {
}
export async function getDownloaders() {
- // Not toasting because this will happen often. Undefined on any failure, not an empty list, so
- // the form can say the downloaders could not be fetched.
try {
const response = await apiGet(`${API_URI}/downloaders`);
- if (response.ok) {
- return await response.json();
- }
+ // Not toasting because this will happen often.
+ return await response.json();
} catch (e) {
- // The API is down; reported as undefined below.
+ return {downloaders: []};
}
}
@@ -1305,7 +1285,7 @@ export async function deleteDownload(downloadId) {
}
}
-export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep, inProgress) {
+export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep) {
const body = {search_str: searchStr, offset: parseInt(offset), limit: parseInt(limit), any_tag: anyTag};
if (suffix) {
body['suffix'] = suffix;
@@ -1340,9 +1320,6 @@ export async function filesSearch(offset, limit, searchStr, mimetypes, model, ta
if (deep) {
body['deep'] = true;
}
- if (inProgress) {
- body['in_progress'] = true;
- }
console.info('searching files', body);
const response = await apiPost(`${API_URI}/files/search`, body);
@@ -1374,7 +1351,7 @@ export async function flasherSearch(chip, path) {
}
const message = await getErrorMessage(response, 'Cannot search firmware. See server logs.');
toast({type: 'error', title: 'Unable to search firmware', description: message, time: 5000});
- // Undefined, not [null, null], so the picker can tell a failed search from finding nothing.
+ return [null, null];
}
export async function getFlasherConfigs() {
@@ -1384,9 +1361,7 @@ export async function getFlasherConfigs() {
const data = await response.json();
return data['configurations'] || [];
}
- // Undefined, not [], so the page can tell a failed fetch from an empty list.
- const message = await getErrorMessage(response, 'Could not get saved firmware configurations');
- toast({type: 'error', title: 'Flasher Error', description: message, time: 5000});
+ return [];
}
export async function saveFlasherConfig(name, files, eraseAll) {
@@ -1681,8 +1656,7 @@ export async function searchMap(query, limit = 12, offset = 0, lat = null, lon =
}
const response = await apiGet(url);
if (!response.ok) {
- // Undefined, not {results: []}, so a failed search is not shown as finding nothing.
- return undefined;
+ return {results: [], total: 0};
}
return await response.json();
}
@@ -1690,8 +1664,7 @@ export async function searchMap(query, limit = 12, offset = 0, lat = null, lon =
export async function getMapSearchStatus() {
const response = await apiGet(`${API_URI}/map/search/status`);
if (!response.ok) {
- // Undefined, not {indexed: [], missing: []}, which read as "no maps are installed".
- return undefined;
+ return {indexed: [], missing: []};
}
return await response.json();
}
@@ -2071,7 +2044,6 @@ export async function searchSuggestions(search_str) {
domains: content.domains,
authors: content.authors || [],
subjects: content.subjects || [],
- repos: content.repos || [],
}
} else {
console.error('Failed to get file search suggestions!');
@@ -2110,8 +2082,8 @@ export async function searchEstimateZims(search_str, tagNames) {
}
}
-export async function searchEstimateOthers(tagNames, searchStr) {
- const body = {tag_names: tagNames, search_str: searchStr || null};
+export async function searchEstimateOthers(tagNames) {
+ const body = {tag_names: tagNames};
const response = await apiPost(`${API_URI}/search_other_estimates`, body);
if (response.ok) {
const content = await response.json();
@@ -2121,8 +2093,8 @@ export async function searchEstimateOthers(tagNames, searchStr) {
}
}
-export async function searchChannels(tagNames, searchStr) {
- const body = {tag_names: tagNames, search_str: searchStr};
+export async function searchChannels(tagNames) {
+ const body = {tag_names: tagNames};
const response = await apiPost(`${API_URI}/videos/channels/search`, body);
if (response.ok) {
const content = await response.json();
@@ -2132,16 +2104,6 @@ export async function searchChannels(tagNames, searchStr) {
}
}
-// Collections of `kind` whose name matches `searchStr` and/or which have any of the tags.
-export async function searchCollections(kind, tagNames, searchStr) {
- const body = {kind, tag_names: tagNames, search_str: searchStr};
- const response = await apiPost(`${COLLECTIONS_API}/search`, body);
- if (response.ok) {
- const content = await response.json();
- return {collections: content.collections};
- }
-}
-
export async function getOutdatedZims() {
const response = await apiGet(`${API_URI}/zim/outdated`);
if (response.status === 200) {
@@ -2477,154 +2439,32 @@ export async function setPlaylistTag(playlistId, tagName, directory) {
throw Error(message);
}
-// Bookmarks: a tree of nodes, a node with `children` being a directory.
-export async function getBookmarks() {
- const response = await apiGet(`${API_URI}/bookmarks`);
+// --- AI assistant ---
+
+export async function getAiCatalog() {
+ const response = await apiGet(`${API_URI}/ai/manage/catalog`);
if (response.ok) {
- return (await response.json())['bookmarks'];
+ return await response.json();
}
- throw new Error('Could not fetch bookmarks');
+ const message = await getErrorMessage(response, 'Failed to fetch AI catalog.');
+ toast({type: 'error', title: 'Error', description: message, time: 5000});
}
-async function bookmarksModify(request, fallbackMessage) {
- const response = await request;
+export async function postAiSettings(settings) {
+ const response = await apiPost(`${API_URI}/ai/manage/settings`, settings);
if (response.ok) {
- return response.status === 204 ? null : await response.json();
+ return await response.json();
}
- const message = await getErrorMessage(response, fallbackMessage);
- toast({type: 'error', title: 'Bookmarks Error', description: message, time: 5000});
- throw new Error(message);
-}
-
-export async function addBookmark({name, url, new_tab = false, parent_id = null, position = null}) {
- const body = {name, url, new_tab, parent_id, position};
- return await bookmarksModify(apiPost(`${API_URI}/bookmarks`, body), 'Could not add bookmark');
-}
-
-export async function addBookmarkDirectory({name, parent_id = null, position = null}) {
- const body = {name, parent_id, position};
- return await bookmarksModify(apiPost(`${API_URI}/bookmarks/directory`, body), 'Could not add directory');
-}
-
-export async function updateBookmark(nodeId, {name, url, new_tab}) {
- const body = {name, url, new_tab};
- return await bookmarksModify(apiPut(`${API_URI}/bookmarks/${nodeId}`, body), 'Could not update bookmark');
-}
-
-export async function moveBookmark(nodeId, parent_id, position = null) {
- const body = {parent_id, position};
- return await bookmarksModify(apiPost(`${API_URI}/bookmarks/${nodeId}/move`, body), 'Could not move bookmark');
-}
-
-export async function deleteBookmark(nodeId) {
- return await bookmarksModify(apiDelete(`${API_URI}/bookmarks/${nodeId}`), 'Could not delete bookmark');
-}
-
-async function reposError(response, fallbackMessage) {
- const message = await getErrorMessage(response, fallbackMessage);
- toast({type: 'error', title: 'Repos Error', description: message, time: 5000});
+ const message = await getErrorMessage(response, 'Failed to save AI settings.');
+ toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
-export async function fetchRepos() {
- const response = await apiGet(REPOS_API);
+export async function getChatModes() {
+ const response = await apiGet(`${API_URI}/chat/modes`);
if (response.ok) {
- return (await response.json())['repos'];
+ return (await response.json())['modes'];
}
- await reposError(response, 'Failed to fetch repos.');
-}
-
-export async function getRepo(repoId) {
- const response = await apiGet(`${REPOS_API}/${repoId}`);
- if (response.ok) {
- return (await response.json())['repo'];
- }
- await reposError(response, 'Failed to fetch repo.');
-}
-
-// `repo`: {url, directory?, name?, tag_name?, frequency?, mode?, branch?, description?}
-export async function createRepo(repo) {
- const response = await apiPost(REPOS_API, repo);
- if (response.ok) {
- return (await response.json())['repo'];
- }
- await reposError(response, 'Failed to add repo.');
-}
-
-// `changes`: {description?, frequency?, mode?, branch?}. An empty branch follows the remote's default branch.
-export async function updateRepo(repoId, changes) {
- const response = await apiPut(`${REPOS_API}/${repoId}`, changes);
- if (response.ok) {
- return (await response.json())['repo'];
- }
- await reposError(response, 'Failed to update repo.');
-}
-
-export async function deleteRepo(repoId, deleteFiles = false) {
- const response = await apiDelete(`${REPOS_API}/${repoId}?delete_files=${deleteFiles ? 'true' : 'false'}`);
- if (!response.ok) {
- await reposError(response, 'Failed to delete repo.');
- }
-}
-
-// Download the repo's updates now.
-export async function updateRepoNow(repoId) {
- const response = await apiPost(`${REPOS_API}/${repoId}/update`);
- if (!response.ok) {
- await reposError(response, 'Failed to start the repo update.');
- }
-}
-
-// One directory of a repo's files: {path, entries: [{name, path, is_dir, size}], readme_path}.
-export async function getRepoTree(repoId, path) {
- const query = path ? `?path=${encodeURIComponent(path)}` : '';
- const response = await apiGet(`${REPOS_API}/${repoId}/tree${query}`);
- if (response.ok) {
- return await response.json();
- }
- await reposError(response, 'Failed to list the repo files.');
-}
-
-// Repos matching the search (name and README) and/or the tags. Each has an escaped `readme_headline`.
-export async function searchRepos(searchStr, tagNames) {
- const body = {search_str: searchStr || null, tag_names: tagNames || []};
- const response = await apiPost(`${REPOS_API}/search`, body);
- if (response.ok) {
- const content = await response.json();
- return {repos: content.repos, total: content.totals.repos};
- }
- await reposError(response, 'Failed to search repos.');
-}
-
-// The commits of a repo's checked out branch, newest first: {commits: [{sha, author, date, message}], total}.
-export async function getRepoLog(repoId, offset = 0, limit = 50) {
- const response = await apiGet(`${REPOS_API}/${repoId}/log?limit=${limit}&offset=${offset}`);
- if (response.ok) {
- return await response.json();
- }
- await reposError(response, 'Failed to fetch the repo history.');
-}
-
-// A ZIP of a repo's checked out files; the browser downloads it.
-export function repoArchiveURL(repoId) {
- return `${REPOS_API}/${repoId}/archive.zip`;
-}
-
-// What importing the git clone in `directory` (relative to the media directory) would do. Changes nothing.
-export async function inspectRepoImport(directory, name, tagName, url) {
- const body = {directory, name: name || null, tag_name: tagName || null, url: url || null};
- const response = await apiPost(`${REPOS_API}/import/inspect`, body);
- if (response.ok) {
- return {inspection: (await response.json())['inspection']};
- }
- return {error: await getErrorMessage(response, 'Cannot import this directory.')};
-}
-
-// Import an existing git clone as a Repo; it stays where it is. `repo`: {directory, confirm, url?, name?, ...}
-export async function importRepo(repo) {
- const response = await apiPost(`${REPOS_API}/import`, repo);
- if (response.ok) {
- return (await response.json())['repo'];
- }
- await reposError(response, 'Failed to import the repo.');
+ const message = await getErrorMessage(response, 'Failed to fetch chat modes.');
+ toast({type: 'error', title: 'Error', description: message, time: 5000});
}
diff --git a/app/src/components/Ai.js b/app/src/components/Ai.js
new file mode 100644
index 00000000..0da4c9fd
--- /dev/null
+++ b/app/src/components/Ai.js
@@ -0,0 +1,216 @@
+import React, {useContext, useEffect, useState} from 'react';
+import {Route, Routes} from 'react-router';
+
+import {getAiCatalog, postAiSettings, postDownload} from '../api';
+import {getServiceStatus, startService, stopService} from '../api/controller';
+import {AiChatProvider} from '../contexts/AiChatContext';
+import {StatusContext} from '../contexts/contexts';
+import {AiChat} from './AiChat';
+import {APIButton, humanFileSize, InfoMessage, PageContainer, TabLinks, useTitle, WROLModeMessage} from './Common';
+import {SortableTable} from './SortableTable';
+import {Button, Divider, Group, Header, NumberInput, Select, Table, Text, Toggle, toast} from './ui';
+import {Downloaders} from './Vars';
+
+// The Controller's name for llama-server: the compose service in docker, the systemd unit natively.
+export const llmServiceName = (dockerized) => dockerized ? 'llm' : 'wrolpi-llm';
+
+export function AiModelRow({model, onDownload}) {
+ const {name, tier, size, description, downloaded, active} = model;
+
+ return
+
+ {name}{active ? ' (active)' : ''}
+ {description && {description}}
+
+ {tier}
+ {humanFileSize(size)}
+
+ {downloaded
+ ?
+ : onDownload(model)}>
+ Download
+ }
+
+ ;
+}
+
+export function ManageAi() {
+ const [catalog, setCatalog] = useState(null);
+ const [serviceStatus, setServiceStatus] = useState(null);
+ const [pendingSave, setPendingSave] = useState(false);
+ const {status} = useContext(StatusContext);
+ const serviceName = llmServiceName(status?.dockerized);
+
+ const fetchCatalog = async () => {
+ try {
+ const data = await getAiCatalog();
+ if (data) {
+ setCatalog(data);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ };
+
+ const fetchService = async () => {
+ try {
+ setServiceStatus(await getServiceStatus(serviceName));
+ } catch (e) {
+ // The service is unknown until AI is installed; not an error worth toasting.
+ setServiceStatus(null);
+ }
+ };
+
+ useEffect(() => {
+ fetchCatalog();
+ fetchService();
+ const interval = setInterval(() => {
+ fetchCatalog();
+ fetchService();
+ }, 30000);
+ return () => clearInterval(interval);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [serviceName]);
+
+ const handleDownload = async (model) => {
+ await postDownload({
+ urls: [model.url],
+ downloader: Downloaders.AiModel,
+ destination: 'ai/models',
+ });
+ toast({
+ type: 'success',
+ title: 'Download started',
+ description: 'Monitor progress on the Downloads page.',
+ time: 5000,
+ });
+ };
+
+ const saveSettings = async (settings) => {
+ setPendingSave(true);
+ try {
+ await postAiSettings(settings);
+ await fetchCatalog();
+ } finally {
+ setPendingSave(false);
+ }
+ };
+
+ const handleServiceButton = async () => {
+ if (serviceStatus?.status === 'running') {
+ await stopService(serviceName);
+ } else {
+ await startService(serviceName);
+ }
+ await fetchService();
+ };
+
+ if (!catalog) {
+ return ;
+ }
+
+ const {models, recommended_tier, total_ram, slow_hardware, disk_usage, enabled, active_model} = catalog;
+ const downloadedModels = models.filter(i => i.downloaded);
+
+ const modelHeaders = [
+ {key: 'name', text: 'Model', sortBy: i => i.name.toLowerCase()},
+ {key: 'tier', text: 'Tier', sortBy: i => i.tier},
+ {key: 'size', text: 'Size', sortBy: i => i.size || 0},
+ {key: 'actions', text: '', sortBy: null},
+ ];
+
+ return <>
+
+
+ {slow_hardware &&
+ This hardware will run the assistant slowly. Answers may take minutes; the smallest
+ model is recommended.
+ }
+
+ AI Assistant
+
+ saveSettings({enabled: e.target.checked})}
+ />
+
+
+ {enabled &&
+ Service: {serviceStatus?.status || 'unknown'}
+
+ {serviceStatus?.status === 'running' ? 'Stop' : 'Start'}
+
+ }
+
+
+
+ Model Catalog
+
+ This device has {humanFileSize(total_ram)} of RAM;
+ the {recommended_tier} tier is recommended.
+ Models use {humanFileSize(disk_usage)} in ai/models/.
+
+ }
+ rowKey='name'
+ defaultSortColumn='size'
+ />
+
+
+
The AI assistant runs entirely on this WROLPi — no internet is needed after a model
+ is downloaded. Download a model, select it, then enable the assistant. Monitor
+ download progress on the Downloads page.
+
You can also copy your own GGUF file into ai/models/; it will appear here as a
+ custom model the next time this page loads. Use a plain ASCII file name without
+ #, quotes, or ... The model must support tool calling (Qwen3, Llama 3.x,
+ and Mistral instruct models do). Selecting a custom model resets the context size to the
+ default; set context_size in ai.yaml to override it.
+
+
+ >}
+ >;
+}
diff --git a/app/src/components/AiChat.test.js b/app/src/components/AiChat.test.js
new file mode 100644
index 00000000..d47488a4
--- /dev/null
+++ b/app/src/components/AiChat.test.js
@@ -0,0 +1,121 @@
+import React from 'react';
+import {fireEvent, screen, waitFor} from '@testing-library/react';
+
+import {render} from '../test-utils';
+import {AiChatProvider} from '../contexts/AiChatContext';
+import {parseSSEBlock} from '../sse';
+import {AiChat, LinkifiedText} from './AiChat';
+
+jest.mock('../api', () => ({
+ getChatModes: jest.fn().mockResolvedValue([
+ {name: 'help', suggestions: ['How do I download videos?']},
+ {name: 'research', suggestions: ['Find videos about canning']},
+ {name: 'system', suggestions: ['Why is my drive full?']},
+ ]),
+ getAiCatalog: jest.fn().mockResolvedValue({
+ enabled: true, active_model: 'test.gguf', slow_hardware: false,
+ }),
+}));
+jest.mock('../sse', () => ({
+ ...jest.requireActual('../sse'),
+ streamChat: jest.fn(),
+}));
+
+const {getAiCatalog, getChatModes} = require('../api');
+const {streamChat} = require('../sse');
+
+const renderChat = () => render();
+
+describe('parseSSEBlock', () => {
+ it('parses an event and its JSON data', () => {
+ expect(parseSSEBlock('event: token\ndata: {"content": "hi"}'))
+ .toEqual({event: 'token', data: {content: 'hi'}});
+ expect(parseSSEBlock('')).toBeNull();
+ });
+});
+
+describe('LinkifiedText', () => {
+ it('makes relative WROLPi links clickable', () => {
+ render();
+ const link = screen.getByRole('link', {name: '/videos/123'});
+ expect(link).toHaveAttribute('href', '/videos/123');
+ });
+});
+
+describe('AiChat', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ getChatModes.mockResolvedValue([
+ {name: 'help', suggestions: ['How do I download videos?']},
+ {name: 'research', suggestions: ['Find videos about canning']},
+ {name: 'system', suggestions: ['Why is my drive full?']},
+ ]);
+ getAiCatalog.mockResolvedValue({enabled: true, active_model: 'test.gguf', slow_hardware: false});
+ });
+
+ it('starts with the mode picker; picking a mode shows its suggestion chips', async () => {
+ renderChat();
+ await waitFor(() => expect(screen.getByRole('button', {name: 'Research'})).toBeInTheDocument());
+ expect(screen.getByText(/Pick a mode/)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', {name: 'Research'}));
+ await waitFor(() => expect(screen.getByText('Find videos about canning')).toBeInTheDocument());
+ });
+
+ it('streams tokens and tool activity into the conversation', async () => {
+ streamChat.mockImplementation(async ({mode, messages}, onEvent) => {
+ expect(mode).toBe('research');
+ expect(messages[messages.length - 1]).toEqual({role: 'user', content: 'find canning'});
+ onEvent('tool_call', {tool: 'search_files', args: {search_str: 'canning', kind: 'video'}});
+ onEvent('tool_result', {tool: 'search_files', success: true});
+ onEvent('token', {content: 'One video: '});
+ onEvent('token', {content: '/videos/123'});
+ onEvent('done', {content: 'One video: /videos/123'});
+ });
+
+ renderChat();
+ await waitFor(() => expect(screen.getByRole('button', {name: 'Research'})).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('button', {name: 'Research'}));
+
+ fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'find canning'}});
+ fireEvent.click(screen.getByText('Send'));
+
+ await waitFor(() => expect(screen.getByText(/using search files/)).toBeInTheDocument());
+ await waitFor(() => expect(screen.getByRole('link', {name: '/videos/123'})).toBeInTheDocument());
+ expect(streamChat).toHaveBeenCalledTimes(1);
+ });
+
+ it('switching modes clears the conversation', async () => {
+ streamChat.mockImplementation(async (_, onEvent) => {
+ onEvent('done', {content: 'An answer.'});
+ });
+ renderChat();
+ await waitFor(() => expect(screen.getByRole('button', {name: 'Help'})).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('button', {name: 'Help'}));
+ fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'hello'}});
+ fireEvent.click(screen.getByText('Send'));
+ await waitFor(() => expect(screen.getByText('An answer.')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', {name: 'System'}));
+ await waitFor(() => expect(screen.queryByText('An answer.')).not.toBeInTheDocument());
+ });
+
+ it('shows an error event without crashing the conversation', async () => {
+ streamChat.mockImplementation(async (_, onEvent) => {
+ onEvent('error', {message: 'The AI failed to answer.'});
+ });
+ renderChat();
+ await waitFor(() => expect(screen.getByRole('button', {name: 'Help'})).toBeInTheDocument());
+ fireEvent.click(screen.getByRole('button', {name: 'Help'}));
+ fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'hello'}});
+ fireEvent.click(screen.getByText('Send'));
+ await waitFor(() => expect(screen.getByText('The AI failed to answer.')).toBeInTheDocument());
+ });
+
+ it('points at the Manage tab when AI is not enabled', async () => {
+ getAiCatalog.mockResolvedValue({enabled: false, active_model: '', slow_hardware: false});
+ renderChat();
+ await waitFor(() => expect(screen.getByText(/not ready/)).toBeInTheDocument());
+ expect(screen.getByRole('link', {name: 'Manage'})).toHaveAttribute('href', '/ai/manage');
+ });
+});
diff --git a/app/src/components/Apps.js b/app/src/components/Apps.js
index 9e547f3c..724ae665 100644
--- a/app/src/components/Apps.js
+++ b/app/src/components/Apps.js
@@ -12,10 +12,8 @@ import {
import {Header, Loading, Panel, Statistic, StatisticGroup} from "./ui";
import {useStatistics} from "../hooks/customHooks";
import {CalculatorsPage} from "./Calculators";
-import {BookmarksPage} from "./Bookmarks";
-// `statistics` follows useStatistics: null = pending, undefined = fetch failed.
-export function StatisticsPage() {
+function StatisticsPage() {
useTitle('Statistics');
const {statistics} = useStatistics();
@@ -27,7 +25,7 @@ export function StatisticsPage() {
>
}
- if (statistics && statistics['global_statistics']) {
+ if (statistics['global_statistics']) {
let {global_statistics, file_statistics} = statistics;
const {
archive_count,
@@ -106,7 +104,6 @@ export function MoreRoute(props) {
}/>
}/>
- }/>
}
diff --git a/app/src/components/Bookmarks.js b/app/src/components/Bookmarks.js
deleted file mode 100644
index 270d0aef..00000000
--- a/app/src/components/Bookmarks.js
+++ /dev/null
@@ -1,453 +0,0 @@
-import React, {useState} from 'react';
-import {Link} from 'react-router';
-import {IconBookmark} from '@tabler/icons-react';
-import {Button, Group, Header, Icon, Menu, Message, Modal, Panel, Select, Table, Text, TextInput, Toggle} from './ui';
-import {APIButton, PageContainer, useTitle} from './Common';
-import {isBookmarkDirectory, resolveBookmarkUrl, useBookmarks} from '../contexts/BookmarksContext';
-import {addBookmark, addBookmarkDirectory, deleteBookmark, moveBookmark, updateBookmark} from '../api';
-
-export const EDIT_BOOKMARKS_PATH = '/more/bookmarks';
-
-/**
- * The props that make a Menu.Item open a bookmark: an in-app Link for a path, a plain
- * anchor for everything else, and a new tab when the bookmark asks for one. A URL that
- * does not resolve (see resolveBookmarkUrl) gets a disabled item, never a raw href.
- */
-function bookmarkItemProps(node) {
- const {internal, href} = resolveBookmarkUrl(node.url);
- if (!href) {
- return {disabled: true};
- }
- const target = node.new_tab ? {target: '_blank', rel: 'noopener noreferrer'} : {};
- if (internal) {
- return {component: Link, to: href, ...target};
- }
- return {component: 'a', href, ...target};
-}
-
-/**
- * The items of a bookmarks menu.
- *
- * `nested` draws a directory as a flyout (Menu.Sub), which opens beside the menu on hover.
- * Without it a directory is a labelled, indented run of items instead: the mobile
- * hamburger is the one place bookmarks are shown where a flyout cannot be hovered. The
- * indent is in rem: a label is set smaller than an item, so an em indent would put a
- * directory's label a few pixels left of the items beside it.
- */
-export function BookmarkMenuItems({nodes, nested = true, depth = 0}) {
- if (!nodes || nodes.length === 0) {
- return null;
- }
- return nodes.map(node => {
- if (isBookmarkDirectory(node)) {
- if (nested) {
- return
-
- }>{node.name}
-
-
- {node.children.length > 0
- ?
- : Empty}
-
- ;
- }
- return
- {node.name}
-
- ;
- }
- return : undefined}
- >
- {node.name}
- ;
- });
-}
-
-/**
- * Everything inside a bookmarks dropdown: the bookmarks, then the link to edit them. The
- * edit link is always there, so a user with no bookmarks yet can find where to add one.
- */
-export function BookmarksMenuContents({nested = true}) {
- const {bookmarks} = useBookmarks();
- const nodes = bookmarks || [];
- return <>
-
- {nodes.length > 0 && }
- }>
- Edit bookmarks
-
- >;
-}
-
-/** Every directory in the tree with its full path, for the "Location" picker. */
-export function directoryOptions(nodes, prefix = '') {
- const options = [];
- for (const node of nodes || []) {
- if (isBookmarkDirectory(node)) {
- const label = `${prefix}${node.name}`;
- options.push({value: String(node.id), label, id: node.id});
- options.push(...directoryOptions(node.children, `${label} / `));
- }
- }
- return options;
-}
-
-/** The ids of `node` and everything beneath it. */
-const subtreeIds = (node) => {
- const ids = [node.id];
- if (isBookmarkDirectory(node)) {
- node.children.forEach(child => ids.push(...subtreeIds(child)));
- }
- return ids;
-};
-
-const ROOT = 'root';
-
-/** The places a node may live: the top level, and every directory not inside `node`. */
-export function locationOptions(nodes, node = null) {
- // A directory cannot be moved into itself or its own children.
- const excluded = node ? new Set(subtreeIds(node)) : new Set();
- return [
- {value: ROOT, label: 'Top level'},
- ...directoryOptions(nodes).filter(i => !excluded.has(i.id)).map(({value, label}) => ({value, label})),
- ];
-}
-
-/**
- * The form for one node. `node` is null when adding; `directory` says which kind.
- * `parentId` is where a new node goes, or where an edited one currently lives.
- */
-export function BookmarkNodeModal({open, node, directory, parentId, nodes, onSave, onClose}) {
- const [name, setName] = useState(node?.name || '');
- const [url, setUrl] = useState(node?.url || '');
- const [newTab, setNewTab] = useState(Boolean(node?.new_tab));
- const [location, setLocation] = useState(parentId === null || parentId === undefined ? ROOT : String(parentId));
- const [saving, setSaving] = useState(false);
-
- const locations = locationOptions(nodes, node);
-
- const valid = name.trim() && (directory || url.trim());
- const title = node ? (directory ? 'Edit directory' : 'Edit bookmark') : (directory ? 'New directory' : 'New bookmark');
-
- const handleSave = async () => {
- if (!valid) return;
- setSaving(true);
- try {
- await onSave({
- name: name.trim(),
- url: url.trim(),
- new_tab: newTab,
- parent_id: location === ROOT ? null : Number(location),
- });
- } finally {
- setSaving(false);
- }
- };
-
- return
- {title}
-
-
-
-
-
-
-
- ;
-}
-
-/** The rows of the editor, one per node, children beneath their directory and indented. */
-function BookmarkTableRows({nodes, parentId, depth, editable, actions}) {
- return nodes.map((node, index) => {
- const directory = isBookmarkDirectory(node);
- const {href} = directory ? {} : resolveBookmarkUrl(node.url);
- return
-
- {String(index + 1).padStart(2, '0')}
-
- {directory
- ?
- : }
-
-
- {directory && {node.name}}
- {!directory && (href
- ?
- {node.name}
-
- : {node.name})}
- {!directory && {node.url}}
-
- {editable &&
-
-
- }
-
- {directory && }
- ;
- });
-}
-
-/**
- * The bookmarks table, in the shape of the playlist items table. `actions` carries
- * edit/move/remove so the gallery can render it with none of them wired.
- */
-export function BookmarkTable({nodes, editable = true, actions}) {
- return
;
-}
-
-/** The "Add a bookmark" and "Add a directory" forms beneath the table. */
-export function BookmarkAddForms({nodes, onAddBookmark, onAddDirectory}) {
- const [url, setUrl] = useState('');
- const [name, setName] = useState('');
- const [newTab, setNewTab] = useState(false);
- const [location, setLocation] = useState(ROOT);
- const [directoryName, setDirectoryName] = useState('');
- const [directoryLocation, setDirectoryLocation] = useState(ROOT);
- const locations = locationOptions(nodes);
- const parentOf = (value) => value === ROOT ? null : Number(value);
-
- // The form is cleared only once the server has accepted the node. A refused add has
- // already shown its toast; the typed URL stays so the user can correct it.
- const handleAddBookmark = async () => {
- if (!url.trim() || !name.trim()) return;
- try {
- await onAddBookmark({name: name.trim(), url: url.trim(), new_tab: newTab, parent_id: parentOf(location)});
- } catch (e) {
- return;
- }
- setUrl('');
- setName('');
- };
-
- const handleAddDirectory = async () => {
- if (!directoryName.trim()) return;
- try {
- await onAddDirectory({name: directoryName.trim(), parent_id: parentOf(directoryLocation)});
- } catch (e) {
- return;
- }
- setDirectoryName('');
- };
-
- return <>
- Add a bookmark
-
-
-
- A URL is a path on this WROLPi (/videos), a port on this host (:8096/ or http://:8096/) that
- works whichever address you reach it by, or a full URL.
-
-
-
- Add a directory
-
- >;
-}
-
-export function BookmarksPage() {
- useTitle('Bookmarks');
- const {bookmarks, refresh} = useBookmarks();
- // {node, directory, parentId} while the edit modal is open; a `key` remounts the form fresh.
- const [modal, setModal] = useState(null);
- const [modalKey, setModalKey] = useState(0);
- const nodes = bookmarks || [];
-
- const actions = {
- edit: (node, parentId) => {
- setModalKey(k => k + 1);
- setModal({node, directory: isBookmarkDirectory(node), parentId});
- },
- move: async (node, parentId, position) => {
- try {
- await moveBookmark(node.id, parentId, position);
- await refresh();
- } catch (e) {
- // Error toast already shown by the API client.
- }
- },
- remove: async (node) => {
- try {
- await deleteBookmark(node.id);
- await refresh();
- } catch (e) {
- }
- },
- };
-
- // These reject on failure (the API client has already shown the toast) so the add
- // forms know to keep what was typed.
- const handleAddBookmark = async (bookmark) => {
- await addBookmark(bookmark);
- await refresh();
- };
-
- const handleAddDirectory = async (directory) => {
- await addBookmarkDirectory(directory);
- await refresh();
- };
-
- const handleEditSave = async ({name, url, new_tab, parent_id}) => {
- const {node, directory, parentId} = modal;
- try {
- await updateBookmark(node.id, directory ? {name} : {name, url, new_tab});
- } catch (e) {
- return; // Nothing changed; the modal stays open with the toast beside it.
- }
- // The rename is committed even if the move is refused, so the nav must show it
- // either way; only a fully successful edit closes the modal.
- let moved = true;
- if (parent_id !== parentId) {
- try {
- await moveBookmark(node.id, parent_id);
- } catch (e) {
- moved = false;
- }
- }
- await refresh();
- if (moved) {
- setModal(null);
- }
- };
-
- return
-
- Bookmarks
-
- Bookmarks appear in the Bookmarks menu of the navigation bar. They can point at pages on this
- WROLPi, at other services running beside it, or anywhere on the web.
-
-
- {nodes.length === 0
- ? Add a bookmark below.
- : }
-
-
-
- {modal && setModal(null)}
- />}
- ;
-}
diff --git a/app/src/components/Bookmarks.test.js b/app/src/components/Bookmarks.test.js
deleted file mode 100644
index 60b12b3a..00000000
--- a/app/src/components/Bookmarks.test.js
+++ /dev/null
@@ -1,199 +0,0 @@
-import React from 'react';
-import {screen} from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import {render} from '../test-utils';
-import {BookmarkAddForms, BookmarkNodeModal, BookmarkTable, directoryOptions, locationOptions} from './Bookmarks';
-import {resolveBookmarkUrl} from '../contexts/BookmarksContext';
-
-const tree = [
- {id: 1, name: 'Channels', url: '/videos/channel', new_tab: false},
- {
- id: 2, name: 'Services', children: [
- {id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true},
- {id: 4, name: 'Radio', children: []},
- ]
- },
-];
-
-describe('resolveBookmarkUrl', () => {
- const location = {protocol: 'https:', hostname: '10.0.0.2', host: '10.0.0.2'};
-
- it.each([
- ['/videos', {internal: true, href: '/videos'}],
- [' /videos/channel/3 ', {internal: true, href: '/videos/channel/3'}],
- // A port on this host takes the page's scheme, or its own when it names one.
- [':8096/web', {internal: false, href: 'https://10.0.0.2:8096/web'}],
- [':8096', {internal: false, href: 'https://10.0.0.2:8096'}],
- ['http://:8096/web', {internal: false, href: 'http://10.0.0.2:8096/web'}],
- ['HTTPS://:8085', {internal: false, href: 'https://10.0.0.2:8085'}],
- ['https://example.com/a?b=c', {internal: false, href: 'https://example.com/a?b=c'}],
- ])('resolves %s', (url, expected) => {
- expect(resolveBookmarkUrl(url, location)).toEqual(expected);
- });
-
- it.each([
- // Paths a browser takes off this host: network paths, backslashes, and control
- // characters a URL parser strips before deciding.
- '//evil.com', '///evil.com', '/\\evil.com', '/\\@evil.com', '/\t/evil.com', '/\n/evil.com',
- // Port forms where the host would become credentials.
- ':8096@evil.com', 'http://:8096@evil.com', ':8096\t@evil.com', ':8096/x@evil.com', ':8096\\evil.com',
- // Not http(s).
- 'javascript:alert(1)', 'data:text/html,hi', 'ftp://example.com', 'videos', '',
- ])('refuses %j rather than guessing', (url) => {
- expect(resolveBookmarkUrl(url, location)).toEqual({internal: false, href: null});
- });
-
- it('brackets an IPv6 host for a port bookmark', () => {
- // The browser reports an IPv6 hostname bracketed already.
- const v6 = {protocol: 'https:', hostname: '[::1]', host: '[::1]'};
- expect(resolveBookmarkUrl(':8096/web', v6)).toEqual({internal: false, href: 'https://[::1]:8096/web'});
- expect(resolveBookmarkUrl(':8096', {protocol: 'http:', hostname: '::1', host: '[::1]'}))
- .toEqual({internal: false, href: 'http://[::1]:8096'});
- });
-});
-
-describe('directoryOptions', () => {
- it('lists every directory with its path, and no bookmarks', () => {
- expect(directoryOptions(tree)).toEqual([
- {value: '2', label: 'Services', id: 2},
- {value: '4', label: 'Services / Radio', id: 4},
- ]);
- });
-});
-
-describe('BookmarkTable', () => {
- const actions = () => ({edit: jest.fn(), move: jest.fn(), remove: jest.fn()});
-
- it('renders the tree with resolved links, numbered within each directory', () => {
- render();
- const channels = screen.getByRole('link', {name: 'Channels'});
- expect(channels).toHaveAttribute('href', '/videos/channel');
- expect(channels).not.toHaveAttribute('target');
- const jellyfin = screen.getByRole('link', {name: 'Jellyfin'});
- expect(jellyfin).toHaveAttribute('href', `${window.location.protocol}//${window.location.hostname}:8096/`);
- expect(jellyfin).toHaveAttribute('target', '_blank');
- expect(screen.getByText('Radio')).toBeInTheDocument();
- // Channels, Services, then Services' two children.
- const numbers = screen.getAllByRole('row').slice(1).map(row => row.firstChild.textContent);
- expect(numbers).toEqual(['01', '02', '01', '02']);
- });
-
- it('moves a node among its siblings and only within bounds', async () => {
- const a = actions();
- render();
- // The first top-level node cannot move up; the last cannot move down.
- expect(screen.getByRole('button', {name: 'Move Channels up'})).toBeDisabled();
- expect(screen.getByRole('button', {name: 'Move Services down'})).toBeDisabled();
-
- await userEvent.click(screen.getByRole('button', {name: 'Move Channels down'}));
- expect(a.move).toHaveBeenCalledWith(tree[0], null, 1);
- // A child moves within its directory: parent 2, from index 1 to 0.
- await userEvent.click(screen.getByRole('button', {name: 'Move Radio up'}));
- expect(a.move).toHaveBeenCalledWith(tree[1].children[1], 2, 0);
- });
-
- it('shows a bookmark whose URL does not resolve as text, not a link', () => {
- const nodes = [{id: 1, name: 'Bad', url: '//evil.com', new_tab: false}];
- render();
- expect(screen.queryByRole('link', {name: 'Bad'})).not.toBeInTheDocument();
- expect(screen.getByText('Bad')).toBeInTheDocument();
- });
-
- it('edits with the parent the node lives in, and deletes a bookmark without asking', async () => {
- const a = actions();
- render();
- await userEvent.click(screen.getByRole('button', {name: 'Edit Jellyfin'}));
- expect(a.edit).toHaveBeenCalledWith(tree[1].children[0], 2);
- await userEvent.click(screen.getByRole('button', {name: 'Delete Channels'}));
- expect(a.remove).toHaveBeenCalledWith(tree[0]);
- });
-
- it('asks before deleting a directory, since its contents go with it', async () => {
- const a = actions();
- render();
- await userEvent.click(screen.getByRole('button', {name: 'Delete Services'}));
- expect(a.remove).not.toHaveBeenCalled();
- await userEvent.click(await screen.findByRole('button', {name: 'Delete'}));
- expect(a.remove).toHaveBeenCalledWith(tree[1]);
- });
-});
-
-describe('BookmarkAddForms', () => {
- it('adds a bookmark at the top level and clears the form', async () => {
- const onAddBookmark = jest.fn();
- render();
- const adds = screen.getAllByRole('button', {name: 'Add'});
- expect(adds[0]).toBeDisabled();
-
- await userEvent.type(screen.getByLabelText('Bookmark URL'), ':8123/');
- expect(adds[0]).toBeDisabled();
- await userEvent.type(screen.getByLabelText('Bookmark name'), 'Home Assistant');
- await userEvent.click(screen.getByLabelText('New tab'));
- await userEvent.click(adds[0]);
-
- expect(onAddBookmark).toHaveBeenCalledWith({name: 'Home Assistant', url: ':8123/', new_tab: true, parent_id: null});
- expect(screen.getByLabelText('Bookmark URL')).toHaveValue('');
- });
-
- it('keeps what was typed when the server refuses the bookmark', async () => {
- const onAddBookmark = jest.fn().mockRejectedValue(new Error('refused'));
- render();
- await userEvent.type(screen.getByLabelText('Bookmark URL'), '//evil.com');
- await userEvent.type(screen.getByLabelText('Bookmark name'), 'Oops');
- await userEvent.click(screen.getAllByRole('button', {name: 'Add'})[0]);
-
- expect(onAddBookmark).toHaveBeenCalled();
- expect(screen.getByLabelText('Bookmark URL')).toHaveValue('//evil.com');
- expect(screen.getByLabelText('Bookmark name')).toHaveValue('Oops');
- });
-
- it('adds a directory', async () => {
- const onAddDirectory = jest.fn();
- render();
- await userEvent.type(screen.getByLabelText('Directory name'), 'Tools');
- await userEvent.click(screen.getAllByRole('button', {name: 'Add'})[1]);
- expect(onAddDirectory).toHaveBeenCalledWith({name: 'Tools', parent_id: null});
- });
-});
-
-describe('BookmarkNodeModal', () => {
- // Mantine's Select scrolls the highlighted option into view; jsdom has no layout.
- beforeAll(() => {
- Element.prototype.scrollIntoView = jest.fn();
- });
-
- it('saves a new bookmark with its location and new-tab choice', async () => {
- const onSave = jest.fn();
- render();
- const save = screen.getByRole('button', {name: 'Save'});
- expect(save).toBeDisabled();
-
- await userEvent.type(screen.getByLabelText('Name'), 'Home Assistant');
- expect(save).toBeDisabled();
- await userEvent.type(screen.getByLabelText('URL'), ':8123/');
- await userEvent.click(screen.getByLabelText('Open in a new tab'));
- await userEvent.click(save);
-
- expect(onSave).toHaveBeenCalledWith({name: 'Home Assistant', url: ':8123/', new_tab: true, parent_id: 2});
- });
-
- it('a directory has no URL or new-tab choice', () => {
- render();
- expect(screen.getByLabelText('Name')).toHaveValue('Services');
- expect(screen.queryByLabelText('URL')).not.toBeInTheDocument();
- expect(screen.queryByLabelText('Open in a new tab')).not.toBeInTheDocument();
- });
-});
-
-describe('locationOptions', () => {
- it('offers the top level and every directory to a new node', () => {
- expect(locationOptions(tree).map(o => o.label)).toEqual(['Top level', 'Services', 'Services / Radio']);
- });
-
- it('never offers a directory itself, or its children', () => {
- expect(locationOptions(tree, tree[1]).map(o => o.label)).toEqual(['Top level']);
- expect(locationOptions(tree, tree[1].children[1]).map(o => o.label)).toEqual(['Top level', 'Services']);
- });
-});
diff --git a/app/src/components/Calculators.js b/app/src/components/Calculators.js
index da82243f..99878cde 100644
--- a/app/src/components/Calculators.js
+++ b/app/src/components/Calculators.js
@@ -14,7 +14,6 @@ import {FoodStorageCalculator} from "./calculators/FoodStorageCalculator";
import {OneTimePadCalculator} from "./calculators/OneTimePadCalculator";
import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator";
import {SpeedTestCalculator} from "./calculators/SpeedTestCalculator";
-import {SolarCalculator} from "./calculators/SolarCalculator";
// Calculators are shown in these titled groups, in this order. Each calculator carries a
// `group` matching one of these names; any calculator whose group is missing/unknown falls
@@ -36,7 +35,6 @@ export const useCalculators = () => {
{key: 'ratio', icon: 'th large', button: 'Ratio', group: 'Engineering', contents: },
{key: 'electrical', icon: 'lightning', button: 'Electrical', group: 'Engineering', contents: },
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', group: 'Engineering', contents: },
- {key: 'solar', icon: 'sun', button: 'Solar', group: 'Engineering', contents: },
{key: 'water', icon: 'tint', button: 'Water Storage', group: 'Preparedness & Storage', contents: },
{key: 'ration', icon: 'food', button: 'Food Storage', group: 'Preparedness & Storage', contents: },
{key: 'antenna', icon: 'signal', button: 'Antenna', group: 'Radio & Communications', contents: },
diff --git a/app/src/components/CbzViewer.js b/app/src/components/CbzViewer.js
index 3b3da07d..9e3feaa4 100644
--- a/app/src/components/CbzViewer.js
+++ b/app/src/components/CbzViewer.js
@@ -3,7 +3,6 @@ import {IconArrowLeft, IconArrowRight} from '@tabler/icons-react';
import {getArchiveContents, getArchiveMemberUrl} from '../api';
import {IconButton, Label, Loading, Message} from './ui';
import {useLocalStorage} from './Common';
-import {usePageProgress} from '../hooks/useFileProgress';
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i;
@@ -23,20 +22,9 @@ function flattenEntries(entries, prefix = '') {
return paths;
}
-/**
- * @param path The comic book file.
- * @param progressPath The FileGroup's primary path, to resume and save the reader's page; omit to not track it.
- * @param fileGroup The FileGroup JSON, which holds the saved page.
- */
-export function CbzViewer({path, progressPath = null, fileGroup = null}) {
+export function CbzViewer({path}) {
const [pages, setPages] = useState(null);
const [currentPage, setCurrentPage] = useState(0);
- const {initialPage, onPageChange} = usePageProgress(progressPath, fileGroup, {enabled: !!progressPath});
- // Read when the pages load; a later save must not move the reader.
- const initialPageRef = useRef(initialPage);
- initialPageRef.current = initialPage;
- // The page shown on opening is where the reader already is (or the cover of a finished comic); it is not saved.
- const justOpenedRef = useRef(true);
const [rtl, setRtl] = useLocalStorage('cbzViewerRtl', false);
const [fullscreen, setFullscreen] = useState(false);
const [error, setError] = useState(null);
@@ -53,9 +41,8 @@ export function CbzViewer({path, progressPath = null, fileGroup = null}) {
.filter(name => IMAGE_EXTENSIONS.test(name))
.sort();
const urls = images.map(member => getArchiveMemberUrl(path, member));
- justOpenedRef.current = true;
setPages(urls);
- setCurrentPage(Math.min(initialPageRef.current, Math.max(urls.length - 1, 0)));
+ setCurrentPage(0);
} catch (e) {
setError(e.message || 'Could not read archive contents.');
}
@@ -63,17 +50,6 @@ export function CbzViewer({path, progressPath = null, fileGroup = null}) {
load();
}, [path]);
- useEffect(() => {
- if (!pages || pages.length === 0) {
- return;
- }
- if (justOpenedRef.current) {
- justOpenedRef.current = false;
- return;
- }
- onPageChange(currentPage, pages.length);
- }, [currentPage, pages, onPageChange]);
-
const goNext = useCallback(() => {
setCurrentPage(p => Math.min(p + 1, (pages?.length || 1) - 1));
}, [pages]);
diff --git a/app/src/components/CbzViewer.test.js b/app/src/components/CbzViewer.test.js
deleted file mode 100644
index 61ba6576..00000000
--- a/app/src/components/CbzViewer.test.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import React from 'react';
-import {act, fireEvent, screen} from '@testing-library/react';
-import {renderWithProviders} from '../test-utils';
-import {CbzViewer} from './CbzViewer';
-import {getArchiveContents} from '../api';
-
-jest.mock('../api', () => ({
- ...jest.requireActual('../api'),
- getArchiveContents: jest.fn(),
- getArchiveMemberUrl: (path, member) => `/member/${member}`,
-}));
-
-const PATH = 'comics/issue.cbz';
-const PAGES = ['01.jpg', '02.jpg', '03.jpg', '04.jpg'];
-
-const {fetch: realFetch} = global;
-const realSendBeacon = navigator.sendBeacon;
-
-beforeEach(() => {
- window.sessionStorage.clear();
- getArchiveContents.mockResolvedValue({entries: PAGES.map(name => ({name, path: name, is_dir: false}))});
- navigator.sendBeacon = jest.fn(() => false);
- global.fetch = jest.fn(() => Promise.resolve({ok: true}));
-});
-
-afterEach(() => {
- global.fetch = realFetch;
- navigator.sendBeacon = realSendBeacon;
-});
-
-const saves = () => global.fetch.mock.calls.map(([, init]) => JSON.parse(init.body));
-
-describe('CbzViewer progress', () => {
- test('opens at the saved page, and saves the page the reader leaves on', async () => {
- const fileGroup = {progress: 0.75, position: {kind: 'page', page: 2, updated_at: 1000}};
- const {unmount} = renderWithProviders(
- );
-
- expect(await screen.findByText('3 / 4')).toBeInTheDocument();
- expect(screen.getByAltText('Page 3')).toHaveAttribute('src', '/member/03.jpg');
-
- fireEvent.click(screen.getByLabelText('Next page'));
- expect(screen.getByText('4 / 4')).toBeInTheDocument();
-
- unmount();
- expect(saves()).toEqual([expect.objectContaining({
- file: PATH, progress: 1, final: true, position: expect.objectContaining({kind: 'page', page: 3}),
- })]);
- });
-
- test('a saved page past the end opens the last page', async () => {
- const fileGroup = {progress: 0.5, position: {kind: 'page', page: 40, updated_at: 1000}};
- renderWithProviders();
- expect(await screen.findByText('4 / 4')).toBeInTheDocument();
- });
-
- test('without a progress path it opens at the first page and saves nothing', async () => {
- const fileGroup = {progress: 0.75, position: {kind: 'page', page: 2, updated_at: 1000}};
- const {unmount} = renderWithProviders();
- expect(await screen.findByText('1 / 4')).toBeInTheDocument();
- await act(async () => fireEvent.click(screen.getByLabelText('Next page')));
- unmount();
- expect(saves()).toEqual([]);
- });
-
- test('opening a comic saves nothing, so a finished comic stays finished', async () => {
- const fileGroup = {progress: 1, position: null, viewed: new Date(9000).toISOString()};
- const {unmount} = renderWithProviders();
- expect(await screen.findByText('1 / 4')).toBeInTheDocument();
- unmount();
- expect(saves()).toEqual([]);
- });
-
- test('peeking at the cover keeps the reader\'s place', async () => {
- const fileGroup = {progress: 0.75, position: {kind: 'page', page: 2, updated_at: 1000}};
- const {unmount} = renderWithProviders();
- expect(await screen.findByText('3 / 4')).toBeInTheDocument();
- fireEvent.click(screen.getByLabelText('Previous page'));
- fireEvent.click(screen.getByLabelText('Previous page'));
- expect(screen.getByText('1 / 4')).toBeInTheDocument();
- unmount();
- expect(saves()).toEqual([expect.objectContaining({position: expect.objectContaining({page: 1})})]);
- });
-});
diff --git a/app/src/components/Channels.cy.js b/app/src/components/Channels.cy.js
index 15be46a9..34fc5994 100644
--- a/app/src/components/Channels.cy.js
+++ b/app/src/components/Channels.cy.js
@@ -49,7 +49,7 @@ describe('Channels Page', () => {
{initialEntries: ['/videos/channel/1/edit']}
);
cy.wait('@channelFetch');
- cy.wait('@searchDirectories'); // Needed in CI.
+ cy.wait('@searchDirectories'); // Needed for CircleCi.
cy.wait(500);
cy.get('input[name="name"]').should('be.visible');
cy.get('input[name="name"]').should('have.value', 'Editing Channel');
diff --git a/app/src/components/Common.js b/app/src/components/Common.js
index 118ce30e..9e93e039 100644
--- a/app/src/components/Common.js
+++ b/app/src/components/Common.js
@@ -757,18 +757,6 @@ export function findPosterPath(file) {
}
}
-/** How far the user is through a file, as a bar along the bottom of its poster. A full bar is finished. */
-export function ViewProgress({progress}) {
- if (!(progress > 0)) {
- return null;
- }
- const percent = Math.round(Math.min(progress, 1) * 100);
- return
@@ -815,7 +802,6 @@ export function CardPoster({to, file, overlay}) {
{imageLabel}
-
} else if (!posterPath && to) {
@@ -824,7 +810,6 @@ export function CardPoster({to, file, overlay}) {
navigate(to)}>
{imageLabel}
-
}
@@ -943,10 +928,7 @@ export function HelpHeader({
// why it cannot be used. `confirmStop` asks before switching off.
function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null, disabled = null, confirmStop = null}) {
const [confirmOpen, setConfirmOpen] = React.useState(false);
- // null = the status has loaded without this subsystem; undefined = the status has not loaded
- // yet (or a call is in flight). Both disable the toggle; only the first claims unsupported.
const unsupported = on === null;
- const unknown = on === undefined;
const handleChange = (checked) => {
if (!checked && confirmStop) {
@@ -979,11 +961,11 @@ function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null,
}
- {popup && }
+ {popup && }
;
}
@@ -1083,10 +1065,9 @@ export function Toggle({label, checked, disabled, onChange, icon, popupContent =
export function DisableDownloadsToggle() {
const [pending, setPending] = React.useState(false);
- const {status, fetchStatus, loaded} = React.useContext(StatusContext);
+ const {status, fetchStatus} = React.useContext(StatusContext);
- // Until the first status poll lands there is no downloads state to show, only a disabled toggle.
- const downloads = loaded ? status?.downloads : null;
+ const {downloads} = status ? status : {downloads: null};
const wrolModeEnabled = useWROLMode();
const setDownloads = async (enable) => {
@@ -1101,9 +1082,8 @@ export function DisableDownloadsToggle() {
}
const on = downloads && downloads['disabled'] === false && downloads['stopped'] === false;
- const label = downloads === null ? 'Downloading' : on === true ? 'Downloading Enabled' : 'Downloading Disabled';
return {
* the two had been inline before.
*
* A row fixes it without touching the stack: the row is the stretched child, and it lays
- * its buttons out at their own width. Thirteen sites, so this scan rather than thirteen
+ * its buttons out at their own width. Nine sites, so this scan rather than nine
* assertions -- and a scan because a stretched button is still a working button.
*/
const offenders = [];
@@ -1140,8 +1140,8 @@ describe('a row of buttons', () => {
}
}
- // Thirteen renders. Common.js declares it, and a declaration is not a `;
}
-/**
- * The embedded viewer (the EPUB viewer, or the browser's PDF viewer). An EPUB opens where the user left off,
- * unless a search result says where (`deepLinked`), and saves their place as they read.
- *
- * @param epubProgressPath The FileGroup's primary path when an EPUB is shown and tracked, otherwise null.
- */
-function DocViewerFrame({url, epubProgressPath, fileGroup, deepLinked, title}) {
- const {iframeRef, resumeCfi} = useEpubProgress(epubProgressPath, fileGroup, {enabled: !!epubProgressPath});
- const src = resumeCfi && !deepLinked ? `${url}&cfi=${encodeURIComponent(resumeCfi)}` : url;
- return
-}
-
function DocPage() {
const {fileGroupId} = useParams();
const navigate = useNavigate();
@@ -263,8 +245,6 @@ function DocPage() {
? `/media/${encodeMediaPath(activePath)}${pageParam ? `#page=${encodeURIComponent(pageParam)}` : ''}`
: null;
const embedUrl = isEpub ? viewerUrl : pdfSrc;
- // Progress belongs to the FileGroup; another format of the same book (a PDF beside the EPUB) is not tracked.
- const trackedPath = String(activePath) === String(docFile.primary_path) ? docFile.primary_path : null;
const handleDelete = async (force = false) => {
if (doc && doc.id) {
@@ -374,7 +354,7 @@ function DocPage() {
return <>
{/*
@@ -384,8 +364,11 @@ function DocPage() {
* re-gates rather than inheriting the last one's reveal.
*/}
-
+
}
diff --git a/app/src/components/Docs.test.js b/app/src/components/Docs.test.js
index 6f757b1f..147ea885 100644
--- a/app/src/components/Docs.test.js
+++ b/app/src/components/Docs.test.js
@@ -19,15 +19,10 @@ const source = fs.readFileSync(path.join(__dirname, 'Docs.js'), 'utf8');
describe('DocPage embed gating (source contract)', () => {
it('wraps the embedded viewer in a MediaGate', () => {
- // The iframe must not be reachable without passing through the gate. The gate's only child is the
- // viewer frame, and the frame is just the iframe.
- const embed = source.match(/]*>\s*]*>\s*