diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index f79823f0..00000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,253 +0,0 @@
-# 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 e1969755..29480843 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -7,6 +7,11 @@ 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:
@@ -37,6 +42,42 @@ 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
new file mode 100644
index 00000000..05cec023
--- /dev/null
+++ b/.github/workflows/backend.yml
@@ -0,0 +1,145 @@
+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
new file mode 100644
index 00000000..efb01e97
--- /dev/null
+++ b/.github/workflows/cypress.yml
@@ -0,0 +1,121 @@
+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 655c907f..a3ab9bc5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -150,3 +150,6 @@ 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 1fc77ee8..cf93f460 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -3,8 +3,10 @@
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 12 Install](#debian-12-install) — install WROLPi onto an x86 PC or laptop.
+* [Debian 13 Install](#debian-13-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.
@@ -36,6 +38,7 @@ 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:
@@ -52,30 +55,50 @@ 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 12 Install
+## Debian 13 Install
-Steps necessary to initialize your WROLPi after installing the Debian image from wrolpi.org
+Install WROLPi onto the internal disk of an x86 PC or laptop. This uses the same
+`WROLPi-v*-amd64.iso` as WROLPi Portable.
-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!
+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).
## Raspberry Pi Install
-Steps necessary to initialize your WROLPi after installing the Raspberry Pi image from wrolpi.org
+Install WROLPi onto a Raspberry Pi. An external drive is recommended for your library.
-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!
+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.
diff --git a/README.md b/README.md
index dfe580fd..291d2507 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# WROLPi
-[](https://app.circleci.com/pipelines/github/lrnselfreliance/wrolpi?branch=release&filter=all)
+[](https://github.com/lrnselfreliance/wrolpi/actions/workflows/backend.yml?query=branch%3Amaster)
[](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 12](INSTALL.md#debian-12-install)** — install WROLPi onto an x86 PC or laptop.
+* **[Debian 13](INSTALL.md#debian-13-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 0b3449af..7029bb37 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 12 Installation](#upgrading-raspberry-pi-or-debian-12-installation)
+* [Upgrading Raspberry Pi or Debian Installation](#upgrading-raspberry-pi-or-debian-installation)
* [Upgrading a WROLPi Portable USB](#upgrading-a-wrolpi-portable-usb)
## Upgrading Docker containers
@@ -24,7 +24,9 @@
7. Start all docker containers
* `docker-compose up -d`
-## Upgrading Raspberry Pi or Debian 12 Installation
+## 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.
1. Run the upgrade script
* `sudo /bin/bash /opt/wrolpi/upgrade.sh`
diff --git a/alembic/env.py b/alembic/env.py
index 9aeeb116..1fc7bd9c 100644
--- a/alembic/env.py
+++ b/alembic/env.py
@@ -6,9 +6,10 @@ from alembic import context
# access to the values within the .ini file in use.
config = context.config
-# Interpret the config file for Python logging.
-# This line sets up loggers basically.
-fileConfig(config.config_file_name)
+# 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)
# add your model's MetaData object here
# for 'autogenerate' support
@@ -23,6 +24,7 @@ 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
@@ -96,7 +98,10 @@ def run_migrations_online():
connectable = create_wrolpi_engine(get_url())
- with connectable.connect() as connection:
+ # 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:
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
new file mode 100644
index 00000000..405dbfe1
--- /dev/null
+++ b/alembic/versions/2026_09_27_1200_domain_collection_summary.py
@@ -0,0 +1,45 @@
+"""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
new file mode 100644
index 00000000..d8ca602b
--- /dev/null
+++ b/alembic/versions/2026_09_28_1200_collection_listing_indexes.py
@@ -0,0 +1,29 @@
+"""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
new file mode 100644
index 00000000..02300ea4
--- /dev/null
+++ b/alembic/versions/2026_10_05_1200_markdown_mimetype.py
@@ -0,0 +1,31 @@
+"""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
new file mode 100644
index 00000000..e4537267
--- /dev/null
+++ b/alembic/versions/2026_10_06_1200_repository.py
@@ -0,0 +1,43 @@
+"""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
new file mode 100644
index 00000000..b4fd7abc
--- /dev/null
+++ b/alembic/versions/2026_10_06_1300_repository_search.py
@@ -0,0 +1,39 @@
+"""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
new file mode 100644
index 00000000..123ba97d
--- /dev/null
+++ b/alembic/versions/2026_10_06_1400_repository_submodules.py
@@ -0,0 +1,22 @@
+"""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
new file mode 100644
index 00000000..61d936af
--- /dev/null
+++ b/alembic/versions/2026_10_06_1500_repository_clone_token.py
@@ -0,0 +1,22 @@
+"""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
new file mode 100644
index 00000000..5742beee
--- /dev/null
+++ b/alembic/versions/2026_10_08_1200_file_group_progress.py
@@ -0,0 +1,24 @@
+"""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
new file mode 100644
index 00000000..bf202b50
--- /dev/null
+++ b/alembic/versions/2026_10_24_1200_config_version.py
@@ -0,0 +1,27 @@
+"""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
new file mode 100644
index 00000000..a076bbf8
--- /dev/null
+++ b/alembic/versions/2026_10_25_1200_strip_tag_names.py
@@ -0,0 +1,175 @@
+"""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 0a9e248d..021df88d 100644
--- a/app/cypress/e2e/settings/special-directories.cy.js
+++ b/app/cypress/e2e/settings/special-directories.cy.js
@@ -1,5 +1,5 @@
/*
- * The five Special Directories fields on /admin/settings.
+ * The six 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,6 +19,7 @@ describe('Special Directories settings', () => {
['Map Directory', 'map'],
['Zims Directory', 'zims'],
['Playlists Directory', 'playlists'],
+ ['Repos Directory', 'repos/%(repo_tag)s/%(repo_name)s'],
];
beforeEach(() => {
@@ -38,8 +39,8 @@ describe('Special Directories settings', () => {
it('names every directory field, so they can be told apart', () => {
/*
- * 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
+ * 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
* 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 e6d426e8..fc66e133 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 CircleCI tests.
+ return false; // Ignore this error which happens after CI tests.
}
// Otherwise, fail like normal
return true;
diff --git a/app/package-lock.json b/app/package-lock.json
index 0ff57ad5..94743961 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -14,34 +14,36 @@
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
- "@tabler/icons-react": "^3.46.0",
+ "@tabler/icons-react": "^3.49.0",
"@testing-library/react": "^13.4.0",
- "@testing-library/user-event": "^14.6.6",
+ "@testing-library/user-event": "^14.6.7",
"cypress": "^15.21.1",
- "esptool-js": "^0.6.1",
+ "dompurify": "^3.4.17",
+ "esptool-js": "^0.7.0",
"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.2.0",
- "react-colorful": "^5.8.0",
- "react-dom": "^18.2.0",
- "react-dropzone": "^14.2.3",
+ "react": "^18.3.1",
+ "react-colorful": "^5.8.1",
+ "react-dom": "^18.3.1",
+ "react-dropzone": "^14.4.1",
"react-hotkeys-hook": "^5.3.3",
"react-qr-code": "^2.2.0",
- "react-router": "^7.18.2",
+ "react-router": "^7.18.4",
"react-scripts": "5.0.1",
"react-stl-viewer": "^2.5.0",
- "three": "^0.185",
- "web-vitals": "^5.3.0"
+ "three": "^0.186",
+ "web-vitals": "^6.2.3"
},
"devDependencies": {
- "@testing-library/dom": "^10.4.1",
+ "@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@types/lodash": "^4.17.25",
- "@types/node": "^26.4.0",
+ "@types/node": "^26.6.4",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"typescript": "^5.9.3",
@@ -3710,9 +3712,9 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/js-yaml": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
- "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",
@@ -5975,9 +5977,9 @@
}
},
"node_modules/@tabler/icons": {
- "version": "3.46.0",
- "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz",
- "integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A==",
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.49.0.tgz",
+ "integrity": "sha512-l+GnSkFETx0U26icxv1VUFnxxiTqXfCcVRiRAnyMwibupM2S2L/hSaZ9gmSgz01rkrsH/93NvLsxykqdanc6lw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5985,12 +5987,12 @@
}
},
"node_modules/@tabler/icons-react": {
- "version": "3.46.0",
- "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz",
- "integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==",
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.49.0.tgz",
+ "integrity": "sha512-Um59pm5RVNUaQjFUtG0r4oYJbZuBS3tGugAA2tgBg0WufMk34Eio3nsO30RUkLuL2giYPQUNdY9HvqltLLYyNA==",
"license": "MIT",
"dependencies": {
- "@tabler/icons": "3.46.0"
+ "@tabler/icons": "3.49.0"
},
"funding": {
"type": "github",
@@ -6001,9 +6003,9 @@
}
},
"node_modules/@testing-library/dom": {
- "version": "10.4.1",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
- "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "version": "10.4.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
+ "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
@@ -6163,9 +6165,9 @@
}
},
"node_modules/@testing-library/user-event": {
- "version": "14.6.6",
- "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz",
- "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==",
+ "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==",
"license": "MIT",
"engines": {
"node": ">=12",
@@ -6466,12 +6468,12 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "26.4.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
- "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
+ "version": "26.6.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
+ "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
"license": "MIT",
"dependencies": {
- "undici-types": "~8.3.0"
+ "undici-types": "~8.9.0"
}
},
"node_modules/@types/node-forge": {
@@ -7483,9 +7485,10 @@
"license": "MIT"
},
"node_modules/attr-accept": {
- "version": "2.2.2",
- "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
- "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==",
+ "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",
"engines": {
"node": ">=4"
}
@@ -8075,9 +8078,10 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
},
"node_modules/brace-expansion": {
- "version": "1.1.18",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
- "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -8627,9 +8631,10 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
},
"node_modules/colord": {
- "version": "2.9.3",
- "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
- "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz",
+ "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==",
+ "license": "MIT"
},
"node_modules/colorette": {
"version": "2.0.20",
@@ -8701,13 +8706,15 @@
}
},
"node_modules/compression": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
- "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
+ "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
+ "license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
+ "destroy": "1.2.0",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
@@ -8715,6 +8722,10 @@
},
"engines": {
"node": ">= 0.8.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/compression/node_modules/debug": {
@@ -9926,6 +9937,15 @@
"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",
@@ -11008,14 +11028,13 @@
}
},
"node_modules/esptool-js": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.6.1.tgz",
- "integrity": "sha512-WNgQTfaEIgHyEiT56pI5v7Tq6Pzjc2XaibLxAtWY4v3zE2Ofk5ImkJY5foEUr0JrdkfHWf6rNizAewN4/kSpHw==",
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.7.0.tgz",
+ "integrity": "sha512-9wHuW/s9t4Vs6HKcr3pra3gbRVWwaN0eabMWodWH4KEJHEYZxgmWwZ3s0F4xcWVCZOc/L98bGZ4uWoZvR8KBKA==",
"license": "Apache-2.0",
"dependencies": {
"atob-lite": "^2.0.0",
- "pako": "^2.1.0",
- "tslib": "^2.4.1"
+ "pako": "^2.1.0"
}
},
"node_modules/esquery": {
@@ -11144,9 +11163,9 @@
}
},
"node_modules/express": {
- "version": "4.22.2",
- "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
- "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
@@ -11168,9 +11187,9 @@
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
- "path-to-regexp": "~0.1.12",
+ "path-to-regexp": "~0.1.13",
"proxy-addr": "~2.0.7",
- "qs": "~6.15.1",
+ "qs": "~6.16.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -11207,6 +11226,22 @@
"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",
@@ -11262,9 +11297,9 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
},
"node_modules/fast-uri": {
- "version": "3.1.7",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
- "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
@@ -11341,11 +11376,12 @@
}
},
"node_modules/file-selector": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
- "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
+ "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",
"dependencies": {
- "tslib": "^2.4.0"
+ "tslib": "^2.7.0"
},
"engines": {
"node": ">= 12"
@@ -11361,9 +11397,10 @@
}
},
"node_modules/filelist/node_modules/brace-expansion": {
- "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==",
+ "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",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -11489,15 +11526,16 @@
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="
},
"node_modules/follow-redirects": {
- "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==",
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
+ "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
+ "license": "MIT",
"engines": {
"node": ">=4.0"
},
@@ -15275,9 +15313,9 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
- "version": "3.15.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
- "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
+ "version": "3.15.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
+ "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
@@ -15484,13 +15522,13 @@
}
},
"node_modules/launch-editor": {
- "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==",
+ "version": "2.14.2",
+ "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.2.tgz",
+ "integrity": "sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==",
"license": "MIT",
"dependencies": {
"picocolors": "^1.1.1",
- "shell-quote": "^1.8.4"
+ "shell-quote": "^1.10.0"
}
},
"node_modules/leven": {
@@ -15526,6 +15564,25 @@
"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",
@@ -16021,6 +16078,61 @@
"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",
@@ -16068,6 +16180,12 @@
"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",
@@ -16319,9 +16437,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.16",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
- "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"funding": [
{
"type": "github",
@@ -18369,15 +18487,20 @@
"license": "BSD-3-Clause"
},
"node_modules/proxy-addr": {
- "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==",
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
"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": {
@@ -18415,6 +18538,15 @@
"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",
@@ -18602,9 +18734,10 @@
}
},
"node_modules/react": {
- "version": "18.2.0",
- "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
- "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
+ "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",
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -18629,9 +18762,10 @@
}
},
"node_modules/react-colorful": {
- "version": "5.8.0",
- "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.0.tgz",
- "integrity": "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng==",
+ "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",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
@@ -18755,24 +18889,26 @@
}
},
"node_modules/react-dom": {
- "version": "18.2.0",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
- "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
+ "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",
"dependencies": {
"loose-envify": "^1.1.0",
- "scheduler": "^0.23.0"
+ "scheduler": "^0.23.2"
},
"peerDependencies": {
- "react": "^18.2.0"
+ "react": "^18.3.1"
}
},
"node_modules/react-dropzone": {
- "version": "14.2.3",
- "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
- "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==",
+ "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",
"dependencies": {
- "attr-accept": "^2.2.2",
- "file-selector": "^0.6.0",
+ "attr-accept": "^2.2.4",
+ "file-selector": "^2.1.0",
"prop-types": "^15.8.1"
},
"engines": {
@@ -18902,9 +19038,10 @@
}
},
"node_modules/react-router": {
- "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==",
+ "version": "7.18.4",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
+ "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
+ "license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
@@ -19882,9 +20019,10 @@
}
},
"node_modules/scheduler": {
- "version": "0.23.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
- "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
@@ -20149,9 +20287,9 @@
}
},
"node_modules/shell-quote": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
- "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==",
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
+ "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -20312,9 +20450,9 @@
}
},
"node_modules/source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "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==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -21146,9 +21284,10 @@
}
},
"node_modules/three": {
- "version": "0.185.1",
- "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
- "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="
+ "version": "0.186.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
+ "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==",
+ "license": "MIT"
},
"node_modules/three-stdlib": {
"version": "2.17.2",
@@ -21491,6 +21630,12 @@
"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",
@@ -21506,9 +21651,10 @@
}
},
"node_modules/undici-types": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
- "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.0",
@@ -21860,9 +22006,10 @@
}
},
"node_modules/web-vitals": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
- "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="
+ "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"
},
"node_modules/webidl-conversions": {
"version": "6.1.0",
@@ -25511,9 +25658,9 @@
}
},
"js-yaml": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
- "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "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==",
"requires": {
"argparse": "^2.0.1"
}
@@ -27020,22 +27167,22 @@
}
},
"@tabler/icons": {
- "version": "3.46.0",
- "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz",
- "integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A=="
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.49.0.tgz",
+ "integrity": "sha512-l+GnSkFETx0U26icxv1VUFnxxiTqXfCcVRiRAnyMwibupM2S2L/hSaZ9gmSgz01rkrsH/93NvLsxykqdanc6lw=="
},
"@tabler/icons-react": {
- "version": "3.46.0",
- "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz",
- "integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==",
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.49.0.tgz",
+ "integrity": "sha512-Um59pm5RVNUaQjFUtG0r4oYJbZuBS3tGugAA2tgBg0WufMk34Eio3nsO30RUkLuL2giYPQUNdY9HvqltLLYyNA==",
"requires": {
- "@tabler/icons": "3.46.0"
+ "@tabler/icons": "3.49.0"
}
},
"@testing-library/dom": {
- "version": "10.4.1",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
- "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "version": "10.4.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz",
+ "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==",
"requires": {
"@babel/code-frame": "7.29.0",
"@babel/runtime": "^7.12.5",
@@ -27148,9 +27295,9 @@
}
},
"@testing-library/user-event": {
- "version": "14.6.6",
- "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz",
- "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==",
+ "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==",
"requires": {}
},
"@tootallnate/once": {
@@ -27412,11 +27559,11 @@
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="
},
"@types/node": {
- "version": "26.4.0",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz",
- "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==",
+ "version": "26.6.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
+ "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
"requires": {
- "undici-types": "~8.3.0"
+ "undici-types": "~8.9.0"
}
},
"@types/node-forge": {
@@ -28177,9 +28324,9 @@
"integrity": "sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw=="
},
"attr-accept": {
- "version": "2.2.2",
- "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
- "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg=="
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
+ "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="
},
"autoprefixer": {
"version": "10.4.13",
@@ -28601,9 +28748,9 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
},
"brace-expansion": {
- "version": "1.1.18",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
- "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -28971,9 +29118,9 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
},
"colord": {
- "version": "2.9.3",
- "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
- "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz",
+ "integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw=="
},
"colorette": {
"version": "2.0.20",
@@ -29023,13 +29170,14 @@
}
},
"compression": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
- "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
+ "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
"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",
@@ -29878,6 +30026,14 @@
"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",
@@ -30661,13 +30817,12 @@
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"esptool-js": {
- "version": "0.6.1",
- "resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.6.1.tgz",
- "integrity": "sha512-WNgQTfaEIgHyEiT56pI5v7Tq6Pzjc2XaibLxAtWY4v3zE2Ofk5ImkJY5foEUr0JrdkfHWf6rNizAewN4/kSpHw==",
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/esptool-js/-/esptool-js-0.7.0.tgz",
+ "integrity": "sha512-9wHuW/s9t4Vs6HKcr3pra3gbRVWwaN0eabMWodWH4KEJHEYZxgmWwZ3s0F4xcWVCZOc/L98bGZ4uWoZvR8KBKA==",
"requires": {
"atob-lite": "^2.0.0",
- "pako": "^2.1.0",
- "tslib": "^2.4.1"
+ "pako": "^2.1.0"
}
},
"esquery": {
@@ -30762,9 +30917,9 @@
}
},
"express": {
- "version": "4.22.2",
- "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
- "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
"requires": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -30785,9 +30940,9 @@
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
- "path-to-regexp": "~0.1.12",
+ "path-to-regexp": "~0.1.13",
"proxy-addr": "~2.0.7",
- "qs": "~6.15.1",
+ "qs": "~6.16.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -30816,6 +30971,15 @@
"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"
+ }
}
}
},
@@ -30867,9 +31031,9 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
},
"fast-uri": {
- "version": "3.1.7",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
- "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="
},
"fastq": {
"version": "1.15.0",
@@ -30918,11 +31082,11 @@
}
},
"file-selector": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
- "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
+ "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
"requires": {
- "tslib": "^2.4.0"
+ "tslib": "^2.7.0"
}
},
"filelist": {
@@ -30934,9 +31098,9 @@
},
"dependencies": {
"brace-expansion": {
- "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==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
+ "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"requires": {
"balanced-match": "^1.0.0"
}
@@ -31032,9 +31196,9 @@
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="
},
"follow-redirects": {
- "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=="
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
+ "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="
},
"for-each": {
"version": "0.3.3",
@@ -33681,9 +33845,9 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"js-yaml": {
- "version": "3.15.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
- "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
+ "version": "3.15.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
+ "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
"requires": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
@@ -33841,12 +34005,12 @@
}
},
"launch-editor": {
- "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==",
+ "version": "2.14.2",
+ "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.2.tgz",
+ "integrity": "sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==",
"requires": {
"picocolors": "^1.1.1",
- "shell-quote": "^1.8.4"
+ "shell-quote": "^1.10.0"
}
},
"leven": {
@@ -33873,6 +34037,14 @@
"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",
@@ -34223,6 +34395,31 @@
}
}
},
+ "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",
@@ -34256,6 +34453,11 @@
"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",
@@ -34428,9 +34630,9 @@
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="
},
"nanoid": {
- "version": "3.3.16",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
- "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="
+ "version": "3.3.19",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
+ "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="
},
"natural-compare": {
"version": "1.4.0",
@@ -35700,9 +35902,9 @@
"integrity": "sha512-z5A6oXAXNi/CHEIbCfzZ/6uVDgnv3pO4C3LFvomxjUFXwyqWZ7HJdfPoNJSIlsvaQubqsaxCbamEzqBr/W7vkg=="
},
"proxy-addr": {
- "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==",
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"requires": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
@@ -35739,6 +35941,11 @@
"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",
@@ -35863,9 +36070,9 @@
}
},
"react": {
- "version": "18.2.0",
- "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
- "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
+ "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==",
"requires": {
"loose-envify": "^1.1.0"
}
@@ -35884,9 +36091,9 @@
}
},
"react-colorful": {
- "version": "5.8.0",
- "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.0.tgz",
- "integrity": "sha512-Wy9OzPfjSN9bF12OB8N7UQvlsZ0I+7wHxpN+bV5BjNQGxOj6IiwkRjevJK9yOBjJWGQvAaf1OXtn8rUeEatAng==",
+ "version": "5.8.1",
+ "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.8.1.tgz",
+ "integrity": "sha512-oz68bhsnFWnpDf1ZR8daiQbYpXUnM2h2J6hl9Zg2rTpM/DU6vCqe1E+CpqmqLnJucMZetHZeifSAfJ+geN9lcA==",
"requires": {}
},
"react-dev-utils": {
@@ -35976,21 +36183,21 @@
}
},
"react-dom": {
- "version": "18.2.0",
- "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
- "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"requires": {
"loose-envify": "^1.1.0",
- "scheduler": "^0.23.0"
+ "scheduler": "^0.23.2"
}
},
"react-dropzone": {
- "version": "14.2.3",
- "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz",
- "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==",
+ "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==",
"requires": {
- "attr-accept": "^2.2.2",
- "file-selector": "^0.6.0",
+ "attr-accept": "^2.2.4",
+ "file-selector": "^2.1.0",
"prop-types": "^15.8.1"
}
},
@@ -36071,9 +36278,9 @@
}
},
"react-router": {
- "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==",
+ "version": "7.18.4",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
+ "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
"requires": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
@@ -36707,9 +36914,9 @@
}
},
"scheduler": {
- "version": "0.23.0",
- "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
- "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"requires": {
"loose-envify": "^1.1.0"
}
@@ -36930,9 +37137,9 @@
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
},
"shell-quote": {
- "version": "1.10.0",
- "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz",
- "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
+ "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q=="
},
"side-channel": {
"version": "1.1.1",
@@ -37038,9 +37245,9 @@
"integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="
},
"source-map-js": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
- "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
+ "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=="
},
"source-map-loader": {
"version": "3.0.2",
@@ -37608,9 +37815,9 @@
"requires": {}
},
"three": {
- "version": "0.185.1",
- "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
- "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg=="
+ "version": "0.186.1",
+ "resolved": "https://registry.npmjs.org/three/-/three-0.186.1.tgz",
+ "integrity": "sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA=="
},
"three-stdlib": {
"version": "2.17.2",
@@ -37870,6 +38077,11 @@
"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",
@@ -37882,9 +38094,9 @@
}
},
"undici-types": {
- "version": "8.3.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
- "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="
},
"unicode-canonical-property-names-ecmascript": {
"version": "2.0.0",
@@ -38111,9 +38323,9 @@
}
},
"web-vitals": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
- "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.3.tgz",
+ "integrity": "sha512-Dmy+vdO1UPMqXiAtZSNayhl4f794Bxfd94W4HIj2HlYtczEQfEOon969memlcpcHrTuOKLa5baFjX7WbgmOq+A=="
},
"webidl-conversions": {
"version": "6.1.0",
diff --git a/app/package.json b/app/package.json
index aadddfc5..8dd5dd31 100644
--- a/app/package.json
+++ b/app/package.json
@@ -9,28 +9,30 @@
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
- "@tabler/icons-react": "^3.46.0",
+ "@tabler/icons-react": "^3.49.0",
"@testing-library/react": "^13.4.0",
- "@testing-library/user-event": "^14.6.6",
+ "@testing-library/user-event": "^14.6.7",
"cypress": "^15.21.1",
- "esptool-js": "^0.6.1",
+ "dompurify": "^3.4.17",
+ "esptool-js": "^0.7.0",
"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.2.0",
- "react-colorful": "^5.8.0",
- "react-dom": "^18.2.0",
- "react-dropzone": "^14.2.3",
+ "react": "^18.3.1",
+ "react-colorful": "^5.8.1",
+ "react-dom": "^18.3.1",
+ "react-dropzone": "^14.4.1",
"react-hotkeys-hook": "^5.3.3",
"react-qr-code": "^2.2.0",
- "react-router": "^7.18.2",
+ "react-router": "^7.18.4",
"react-scripts": "5.0.1",
"react-stl-viewer": "^2.5.0",
- "three": "^0.185",
- "web-vitals": "^5.3.0"
+ "three": "^0.186",
+ "web-vitals": "^6.2.3"
},
"scripts": {
"start": "react-scripts start",
@@ -58,10 +60,10 @@
]
},
"devDependencies": {
- "@testing-library/dom": "^10.4.1",
+ "@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@types/lodash": "^4.17.25",
- "@types/node": "^26.4.0",
+ "@types/node": "^26.6.4",
"@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
new file mode 100644
index 00000000..4290e795
--- /dev/null
+++ b/app/public/data/solar-climatology.LICENSE.txt
@@ -0,0 +1,11 @@
+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
new file mode 100644
index 00000000..9a5bcb68
Binary files /dev/null and b/app/public/data/solar-climatology.bin differ
diff --git a/app/public/epub/epub.html b/app/public/epub/epub.html
index 3a1ab60e..73f1289a 100644
--- a/app/public/epub/epub.html
+++ b/app/public/epub/epub.html
@@ -242,6 +242,8 @@ 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", {
@@ -250,7 +252,33 @@ either expressed or implied, of the FreeBSD Project. -->
height: "100%"
});
- rendition.display(currentSectionIndex);
+ 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);
+ }
// --- In-book search ---------------------------------------------------
// State is module-level so the toolbar buttons can drive it. `hits` is the
@@ -516,6 +544,7 @@ 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 d0f46346..6b423219 100644
--- a/app/src/App.css
+++ b/app/src/App.css
@@ -316,6 +316,90 @@ 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;
}
@@ -725,3 +809,35 @@ 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 1e372a53..088cfa95 100644
--- a/app/src/App.js
+++ b/app/src/App.js
@@ -17,6 +17,7 @@ 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";
@@ -29,6 +30,7 @@ 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 ErrorBoundary from "./components/ErrorBoundary";
@@ -69,13 +71,15 @@ function Root() {
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -83,10 +87,12 @@ function Root() {
}
-const router = createBrowserRouter(createRoutesFromElements(}
- errorElement={}
+ // 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={}
>
}/>
}/>
@@ -114,9 +120,14 @@ const router = createBrowserRouter(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
new file mode 100644
index 00000000..f7e78352
--- /dev/null
+++ b/app/src/App.test.js
@@ -0,0 +1,52 @@
+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 3faf7a5e..1ab785ab 100644
--- a/app/src/DashboardPage.js
+++ b/app/src/DashboardPage.js
@@ -5,6 +5,7 @@ import {
PageContainer,
RefreshHeader,
SearchResultsInput,
+ useLocalStorage,
useTitle
} from "./components/Common";
import React, {useContext, useState} from "react";
@@ -14,6 +15,7 @@ import {DownloadMenu} from "./components/Download";
import {
ActionInput,
Button,
+ ButtonGroup,
Divider,
Header,
Icon,
@@ -250,6 +252,8 @@ export function Getters() {
Or
+ {status?.flags && gettersDisabled &&
+
Download and Upload are available after your files are refreshed.
}
;
let getterModal;
@@ -351,7 +355,14 @@ export function DashboardStatus() {
}
function DashboardRecentFiles() {
- const {searchFiles, loading, fetchFiles} = useSearchRecentFiles();
+ // 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.
;
+ }
return
-
+
+
+
+
+
{files}
}
diff --git a/app/src/DashboardPage.test.js b/app/src/DashboardPage.test.js
index 1d75414c..26d5bdb7 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) {
+function renderAtUrl(url, flags = {refresh_complete: true}) {
// 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: {refresh_complete: true}};
+ const status = {flags};
return render(
@@ -67,6 +67,25 @@ 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 3d21622b..a9959b44 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});
}
-function handleEvents(events) {
+export function handleEvents(events) {
const newestEvents = {};
events.forEach(e => {
@@ -63,13 +63,17 @@ function handleEvents(events) {
}
if (event === 'user_notify_message') {
- console.log(message, url);
- eventToast(
- message,
- 'Click here to view the shared page',
- 'info',
- 10_000,
- () => window.open(url, '_self'));
+ // 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);
+ }
}
if (event === 'deleted') {
diff --git a/app/src/Events.test.js b/app/src/Events.test.js
new file mode 100644
index 00000000..d6644fb4
--- /dev/null
+++ b/app/src/Events.test.js
@@ -0,0 +1,33 @@
+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
new file mode 100644
index 00000000..cb7c693c
--- /dev/null
+++ b/app/src/api.errorAsEmpty.test.js
@@ -0,0 +1,84 @@
+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 b06ef557..527ad696 100644
--- a/app/src/api.js
+++ b/app/src/api.js
@@ -7,6 +7,7 @@ import {
COLLECTIONS_API,
DEFAULT_LIMIT,
Downloaders,
+ REPOS_API,
VIDEOS_API,
ZIM_API
} from "./components/Vars";
@@ -406,6 +407,7 @@ 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,
@@ -522,7 +524,7 @@ export async function getConfigBackups(fileName) {
if (response.ok) {
return await response.json();
}
- return {dates: []};
+ // Undefined, not {dates: []}, so the modal can tell a failed fetch from having no backups.
}
export async function postConfigBackupPreview(fileName, backupDate, mode) {
@@ -720,7 +722,7 @@ export async function getInventoryBackups(slug) {
if (response.ok) {
return (await response.json())['dates'];
}
- return [];
+ // Undefined, not [], so the modal can tell a failed fetch from having no backups.
}
export async function postInventoryRestorePreview(slug, backupDate, mode) {
@@ -927,6 +929,21 @@ 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,
@@ -1259,12 +1276,15 @@ 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`);
- // Not toasting because this will happen often.
- return await response.json();
+ if (response.ok) {
+ return await response.json();
+ }
} catch (e) {
- return {downloaders: []};
+ // The API is down; reported as undefined below.
}
}
@@ -1285,7 +1305,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) {
+export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep, inProgress) {
const body = {search_str: searchStr, offset: parseInt(offset), limit: parseInt(limit), any_tag: anyTag};
if (suffix) {
body['suffix'] = suffix;
@@ -1320,6 +1340,9 @@ 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);
@@ -1351,7 +1374,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});
- return [null, null];
+ // Undefined, not [null, null], so the picker can tell a failed search from finding nothing.
}
export async function getFlasherConfigs() {
@@ -1361,7 +1384,9 @@ export async function getFlasherConfigs() {
const data = await response.json();
return data['configurations'] || [];
}
- return [];
+ // 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});
}
export async function saveFlasherConfig(name, files, eraseAll) {
@@ -1656,7 +1681,8 @@ export async function searchMap(query, limit = 12, offset = 0, lat = null, lon =
}
const response = await apiGet(url);
if (!response.ok) {
- return {results: [], total: 0};
+ // Undefined, not {results: []}, so a failed search is not shown as finding nothing.
+ return undefined;
}
return await response.json();
}
@@ -1664,7 +1690,8 @@ 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) {
- return {indexed: [], missing: []};
+ // Undefined, not {indexed: [], missing: []}, which read as "no maps are installed".
+ return undefined;
}
return await response.json();
}
@@ -2044,6 +2071,7 @@ 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!');
@@ -2082,8 +2110,8 @@ export async function searchEstimateZims(search_str, tagNames) {
}
}
-export async function searchEstimateOthers(tagNames) {
- const body = {tag_names: tagNames};
+export async function searchEstimateOthers(tagNames, searchStr) {
+ const body = {tag_names: tagNames, search_str: searchStr || null};
const response = await apiPost(`${API_URI}/search_other_estimates`, body);
if (response.ok) {
const content = await response.json();
@@ -2093,8 +2121,8 @@ export async function searchEstimateOthers(tagNames) {
}
}
-export async function searchChannels(tagNames) {
- const body = {tag_names: tagNames};
+export async function searchChannels(tagNames, searchStr) {
+ const body = {tag_names: tagNames, search_str: searchStr};
const response = await apiPost(`${API_URI}/videos/channels/search`, body);
if (response.ok) {
const content = await response.json();
@@ -2104,6 +2132,16 @@ export async function searchChannels(tagNames) {
}
}
+// 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) {
@@ -2438,3 +2476,155 @@ export async function setPlaylistTag(playlistId, tagName, directory) {
toast({type: 'error', title: 'Error', description: message, time: 5000});
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`);
+ if (response.ok) {
+ return (await response.json())['bookmarks'];
+ }
+ throw new Error('Could not fetch bookmarks');
+}
+
+async function bookmarksModify(request, fallbackMessage) {
+ const response = await request;
+ if (response.ok) {
+ return response.status === 204 ? null : 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});
+ throw Error(message);
+}
+
+export async function fetchRepos() {
+ const response = await apiGet(REPOS_API);
+ if (response.ok) {
+ return (await response.json())['repos'];
+ }
+ 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.');
+}
diff --git a/app/src/components/Apps.js b/app/src/components/Apps.js
index 724ae665..9e547f3c 100644
--- a/app/src/components/Apps.js
+++ b/app/src/components/Apps.js
@@ -12,8 +12,10 @@ import {
import {Header, Loading, Panel, Statistic, StatisticGroup} from "./ui";
import {useStatistics} from "../hooks/customHooks";
import {CalculatorsPage} from "./Calculators";
+import {BookmarksPage} from "./Bookmarks";
-function StatisticsPage() {
+// `statistics` follows useStatistics: null = pending, undefined = fetch failed.
+export function StatisticsPage() {
useTitle('Statistics');
const {statistics} = useStatistics();
@@ -25,7 +27,7 @@ function StatisticsPage() {
>
}
- if (statistics['global_statistics']) {
+ if (statistics && statistics['global_statistics']) {
let {global_statistics, file_statistics} = statistics;
const {
archive_count,
@@ -104,6 +106,7 @@ export function MoreRoute(props) {
}/>
}/>
+ }/>
}
diff --git a/app/src/components/Bookmarks.js b/app/src/components/Bookmarks.js
new file mode 100644
index 00000000..270d0aef
--- /dev/null
+++ b/app/src/components/Bookmarks.js
@@ -0,0 +1,453 @@
+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
new file mode 100644
index 00000000..60b12b3a
--- /dev/null
+++ b/app/src/components/Bookmarks.test.js
@@ -0,0 +1,199 @@
+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 99878cde..da82243f 100644
--- a/app/src/components/Calculators.js
+++ b/app/src/components/Calculators.js
@@ -14,6 +14,7 @@ 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
@@ -35,6 +36,7 @@ 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 9e3feaa4..3b3da07d 100644
--- a/app/src/components/CbzViewer.js
+++ b/app/src/components/CbzViewer.js
@@ -3,6 +3,7 @@ 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;
@@ -22,9 +23,20 @@ function flattenEntries(entries, prefix = '') {
return paths;
}
-export function CbzViewer({path}) {
+/**
+ * @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}) {
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);
@@ -41,8 +53,9 @@ export function CbzViewer({path}) {
.filter(name => IMAGE_EXTENSIONS.test(name))
.sort();
const urls = images.map(member => getArchiveMemberUrl(path, member));
+ justOpenedRef.current = true;
setPages(urls);
- setCurrentPage(0);
+ setCurrentPage(Math.min(initialPageRef.current, Math.max(urls.length - 1, 0)));
} catch (e) {
setError(e.message || 'Could not read archive contents.');
}
@@ -50,6 +63,17 @@ export function CbzViewer({path}) {
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
new file mode 100644
index 00000000..61ba6576
--- /dev/null
+++ b/app/src/components/CbzViewer.test.js
@@ -0,0 +1,84 @@
+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 34fc5994..15be46a9 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 for CircleCi.
+ cy.wait('@searchDirectories'); // Needed in CI.
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 9e93e039..118ce30e 100644
--- a/app/src/components/Common.js
+++ b/app/src/components/Common.js
@@ -757,6 +757,18 @@ 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
@@ -802,6 +815,7 @@ export function CardPoster({to, file, overlay}) {
{imageLabel}
+
} else if (!posterPath && to) {
@@ -810,6 +824,7 @@ export function CardPoster({to, file, overlay}) {
navigate(to)}>
{imageLabel}
+
}
@@ -928,7 +943,10 @@ 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) {
@@ -961,11 +979,11 @@ function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null,
}
- {popup && }
+ {popup && }
;
}
@@ -1065,9 +1083,10 @@ export function Toggle({label, checked, disabled, onChange, icon, popupContent =
export function DisableDownloadsToggle() {
const [pending, setPending] = React.useState(false);
- const {status, fetchStatus} = React.useContext(StatusContext);
+ const {status, fetchStatus, loaded} = React.useContext(StatusContext);
- const {downloads} = status ? status : {downloads: null};
+ // Until the first status poll lands there is no downloads state to show, only a disabled toggle.
+ const downloads = loaded ? status?.downloads : null;
const wrolModeEnabled = useWROLMode();
const setDownloads = async (enable) => {
@@ -1082,8 +1101,9 @@ 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. Nine sites, so this scan rather than nine
+ * its buttons out at their own width. Thirteen sites, so this scan rather than thirteen
* assertions -- and a scan because a stretched button is still a working button.
*/
const offenders = [];
@@ -1140,8 +1140,8 @@ describe('a row of buttons', () => {
}
}
- // Nine 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();
@@ -245,6 +263,8 @@ 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) {
@@ -354,7 +374,7 @@ function DocPage() {
return <>