Compare commits

..

26 commits

Author SHA1 Message Date
lrnselfreliance
4b3c6510b4
ai: list custom GGUF files in the Manage tab (#720)
* ai: list custom GGUF files in the Manage tab

A GGUF copied into ai/models/ by hand could be activated by editing
ai.yaml, but never appeared in the Manage tab because the catalog
endpoint only returned CDN/bundled models. The Active Model dropdown
builds from downloaded models, so a custom file was unselectable.

The catalog endpoint now merges any on-disk *.gguf that is not in the
catalog as a `custom` tier model with its real size and no URL. It is
discovered on every request, so a file appears the next time the page
loads; the file refresh is not involved and does not index models.

Catalog models are never duplicated and in-progress .tmp downloads are
ignored. Custom rows read "On disk" and the hint explains the copy-in
path, the tool-calling requirement, and the context_size fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ai: only list custom GGUFs llama-server can load; reset context on custom select

Review found two gaps in the custom-model listing.

Any *.gguf basename was listed and accepted, but start_llama_server.sh
reads active_model back out of ai.yaml through read_config_value.sh.
PyYAML dumps non-ASCII as escapes the reader prints literally, the
reader strips " #..." before unquoting, and the start script's
traversal guard rejects any ".." run. Those names got a 200 from the
API and then "Model file does not exist" at startup. A shared
is_loadable_model_name check now filters the catalog loop and rejects
the name in manage_settings, so the UI never shows an activation that
cannot start. Directories named *.gguf are skipped too.

Selecting a custom model after a catalog model kept the previous
model's explicit context_size (e.g. 16384 from the 4B) because the
adopt branch was skipped when there was no catalog default. The
default is now always written on model selection, so a custom model
resets to null and the conservative default applies. The hint text
says so instead of claiming the default is used unconditionally.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
168f194512
ai: page list_collections
A library with a few hundred channels returned 137k characters from one
list_collections call, far past any tool-result budget.  The endpoint
now takes limit/offset (clamped like the other AI listings) and a
search_str name filter, and reports total and next_offset; the MCP tool
passes them through and tells the caller how to continue.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
1b172ddda5
ai: wrolpi_mcp exposes only the compact tool set
The per-kind MCP wrappers (search/search_videos/search_archives/
search_docs, get_video/get_archive/get_doc, get_video_captions/
get_video_comments/get_archive_text) duplicated search_files, get_file,
and read_content and cost an external client ten redundant definitions
on every request.  They are gone.  search_default_zims folds into
search_zim (zim_id optional), get_inventory_items into get_inventory
(slug optional), and read_file is exposed for the first time.  16 tools,
matching what the built-in assistant sees plus the read-only listings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
e4d996013a
ai: research mode runs on the compact tool catalog
Research mode switches to nine kind-generic tools: search_files,
get_file, read_content, search_zims, get_zim_entry, get_inventory,
list_files, read_file, search_places.  Tool definitions drop from 20
tools / 8,947 chars (~2,240 tokens) to 9 / 3,881 (~970) on every
request, a sixth of the 8k context the small tier runs with instead of
a quarter.  The remaining descriptions are trimmed toward 300 chars.

The replaced per-kind endpoints are removed (four searches, three
getters, three readers, search_suggestions, the slug inventory route);
their tests move onto the generic routes.  The research prompt, its
worked example, and the empty-search hint no longer tell the model to
list collections first: channels resolve by name from the library map
that is already in the prompt.  list_collections, list_tags, and
get_map_overview stay as endpoints for the MCP wrappers but leave the
chat allowlist; the MCP search_suggestions tool goes since search_files
returns the same matches inline.

scripts/ai_eval.py expects search_files and treats "what channels do I
have" as answerable from context.  On the 1.7B model against the dev
library both catalogs score 14/18, but the compact one answers with one
tool call where the old one needed up to five, no longer invents the
example's channel, and keeps kinds straight when asked for videos.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
85ce1d0172
ai: kind-generic search_files / get_file / read_content
Small models carry every tool definition on every request, and the
per-kind trios (four searches, three getters, three readers) cost a
quarter of an 8k context while giving the model near-duplicate choices.
Three generic endpoints replace them:

- POST files/search: one search over videos, archives, and docs with an
  optional kind; channel (name or id), domain, author, subject each
  imply their kind and are ignored for other kinds.  When the term also
  names channels/domains/authors, they are returned as `matches` so the
  model can narrow without a second tool.  Channels resolve by name, so
  the library map already in the prompt is enough: no list call first.
- GET files/{id}: detail by id whatever the kind; videos now report
  has_captions/has_comments and the full description, archives keep
  their snapshot history.
- GET files/{id}/content?part=text|comments: captions, page text, or
  comments, paged; a wrong part for the kind says what the file is.

Inventories take an optional slug on GET /inventories (one tool lists
and fetches).  Listings are slimmed: no derivable captions/text links,
no size/mimetype, duration as H:MM:SS, published as a date.  The
library map gains the tag names.

wrolpi_mcp keeps every existing tool name; the wrappers now call the
generic routes, and search_files/get_file/read_content are exposed too.
The old per-kind endpoints remain until the modes switch over.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
c5ca3dae78
ai: tags, downloads, map, and search-preview tools
Five new read-only /api/ai endpoints, each with a matching wrolpi_mcp
proxy tool: list_tags (tags with what they are applied to, plus recent
names), search_suggestions (matching channels/domains/authors/subjects
and file/Zim result estimates), list_downloads (queue summary and lean
recurring/one-time entries with relative destinations; status filter;
errors keep their tail, which holds the exception message),
get_map_overview (map files, search-index state, subscriptions, pins
with map links), and search_places (place search with map links and
optional proximity ranking).

Research mode allowlists the library-facing four.  Nothing here can
start, stop, or retry a download.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
6cbc48c78e
ai: list_files tool — browse the media directory tree
New read-only GET /api/ai/files/list lists one directory of the media
directory (directories first, then files with size and guessed
mimetype), paged at 100 entries.  Every entry carries its relative
path for list_files or read_file.  Same guards as read_file via a
shared resolver: no escaping the media directory, and the config
directory is refused and hidden from the root listing.

The agent loop picks the operation up from the spec; library mode
allowlists it.  wrolpi_mcp gains the matching list_files proxy tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
6b6f057c86
ai: system mode never answers state from memory
The re-run showed "Is everything running?" parroting the example's
all-clear without checking.  The system prompt now states that state is
never known from memory or examples — every health answer starts with a
fresh get_system_status — and the example's answer cites its own
example data (service names, version) so a verbatim parrot is
self-evidently wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:56 -06:00
Roland
6e8e11b2b9
ai: no invented failures in the system-mode example
The eval's two remaining System misses were the model investigating a
fictional failed wrolpi-kiwix — planted by the few-shot example itself.
The example now shows a healthy status and an all-clear answer; it
still demonstrates status-first without seeding a fake diagnosis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
40b63b6a28
ai: eval findings — truthful help example, links in list answers
The first scored eval run (39/45) caught two example defects: a user
asking about the help example's own topic got the example's invented
UI detail parroted back without a search, so the example now carries
true content from the real certificates doc on a topic users rarely
lead with; and list-style answers dropped WROLPi links because the
research example only showed links in prose, so its answer now models
a numbered list with a link per item.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
ced78e181f
ai: ground answers in a live library map, budget the context
Response-quality work, all aimed at giving the model enough context to
answer accurately:

- Every chat's system prompt now carries a live library map (today's
  date, counts, and the exact names of channels, domains, zims, and
  inventories, capped and cached 60s).  Exact spellings are in context
  before the first token, and the list_collections round-trip — minutes
  on slow hardware — is usually unnecessary.
- The request is budgeted against the model's real context: oldest
  turns are trimmed first, the current question is never dropped, and
  an over-budget turn gets a clear "start a new conversation" error
  instead of a silently truncated prompt.  cache_prompt reuses the KV
  cache for the stable prefix, which is most of the latency on a Pi.
- context_size plumbing fixed: selecting a model adopts its catalog
  default (the 4B was running at 8k instead of 16k), and llama-server
  runs --parallel 1 so the single user gets the whole context.
- temperature is configurable in ai.yaml (default 0.3 — small models
  call tools far more reliably near 0.2-0.3 than at server defaults).
- One worked tool-use example per mode is prepended to every
  conversation; small models imitate far better than they obey.
- scripts/ai_eval.py scores the assistant against a live WROLPi on
  mechanical checks (finished, called the right tool first, included a
  WROLPi link, no external URLs) so prompt changes stop being validated
  by anecdote.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
82e11cbaba
ai: keep slow-CPU chat streams alive
On the FX-8320 the first token took over a minute, and Sanic's default
60-second RESPONSE_TIMEOUT killed the stream before anything was sent.
Raise the timeout above the chat loop's own 600s hard ceiling, and emit
a status event before every model call so the stream is never silent —
for proxies and for the user staring at a slow machine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
642cfa29ee
ai: build every x86 CPU variant of llama.cpp with runtime dispatch
The portable (GGML_NATIVE=OFF) x86-64 build still assumes AVX2, which
SIGILLs on older CPUs — found deploying to an AMD FX-8320.  x86_64 now
builds all CPU-variant backends with runtime dispatch (GGML_BACKEND_DL
+ GGML_CPU_ALL_VARIANTS), the same scheme as llama.cpp's official
release binaries; arm64 keeps the baseline build, which runs on both
Pi 4 and Pi 5.  The binary and its dlopen'd backends install together
in /opt/llama-cpp (the loader scans the executable's directory) with
$ORIGIN rpath, symlinked into /usr/local/bin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
6ca2fc954c
ai: ground the librarian — collection links, retry hints, no source URLs
Live testing surfaced three ways small models went wrong summarizing a
library: they gave up after one empty search (misspelled channel names
found nothing), so zero-result searches now carry a retry hint and the
Research prompt requires list_collections before channel/site questions
and search_videos(channel_id) before claiming a channel is summarized;
they presented the familiar YouTube source URL instead of the WROLPi
link, so listings omit the source URL (archive detail keeps it); and
they invented links for channels, so channel collections now carry
their real /videos/channel page link.  Search descriptions state that
search_str is optional for browsing the newest items.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
9cb1414d7a
ai: live-chat fixes from first real-model run
Three findings from running Qwen3-1.7B against the docker stack:
the llm image lost libgomp when the build tools were removed
(llama-server needs the OpenMP runtime); the Help prompt let the
small model answer from memory instead of the docs, so it now
requires search_help before any answer; and help search results
called the slug "doc" while get_help_doc's argument is "slug", so
the model invented slugs — the names now match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:55 -06:00
Roland
bf52b24b98
ai: publish tooling + real model hashes in the bundled catalog
publish_ai_models.py produces the CDN artifacts for every AI_MODELS
entry — GPG-signed .meta4 sidecars (reusing the map trust chain) and
the signed ai/models.json manifest — and uploads them with s3cmd.
The bundled catalog now carries the real sha256/size of the two Qwen
GGUFs, which match Hugging Face's LFS records for the Apache 2.0
unsloth quants, and whose signed meta4s verify against the committed
public key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:54 -06:00
Roland
ebbea75e26
ai: wrolpi_mcp becomes a thin proxy over /api/ai
Every MCP tool is now one /api/ai call with minimal text rendering;
the formatting, link building, and truncation logic it used to carry
untested lives (tested) in modules/ai.  Tool names are unchanged for
external clients; a source-scan test guards the names and that the
proxy only reaches /api/ai.  New lean blueprint endpoints back the two
tools that had no equivalent: GET archives/{id} (detail + snapshot
history) and GET videos/{id}/comments (paged author lines).  Paged
content is read up to the old 50k-char bound.  get_inventory_items now
takes the inventory slug (inventories are config-only; the old
numeric-id endpoint no longer exists).  This also brings wrolpi_mcp
into version control (venv/ is already gitignored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:54 -06:00
Roland
4a37d95fab
ai: the Chat tab — SSE streaming chat with modes
The app's first streaming client: sse.js reads the /api/chat POST body
with fetch + a stream reader (apiCall's 60s JSON path cannot stream),
parsing SSE events with abort support.  AiChatContext holds the
conversation (browser-only, per design) so it survives visiting the
Manage tab; switching modes starts a fresh conversation.  The Chat tab
has the three-button mode picker, per-mode suggestion chips, streamed
answers with clickable relative WROLPi links, tool-activity indicators,
a slow-hardware banner, and a "not ready" state pointing at Manage.
ChatMessage states are in the theme-sample gallery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:54 -06:00
Roland
0347c2901b
ai: the AI nav page — Manage tab (model catalog, settings, service)
New AI nav item and /ai route with Chat and Manage tabs (Chat is a
placeholder until the SSE chat lands).  The Manage tab mirrors the
Map/Zim download UX: the model catalog with the RAM-based tier
recommendation and a Pi 4 performance hint, downloads through the
download manager (progress on the Downloads page), active-model
select, enable toggle, idle-unload minutes, and llama-server
start/stop through the Controller (compose service name in docker,
systemd unit natively).  AiModelRow is in the theme-sample gallery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:54 -06:00
Roland
f86058ee8a
ai: the /api/chat agent loop — SSE streaming, modes, tool execution
Tool definitions are generated in-process from the /api/ai blueprint's
own OpenAPI operations (operationId = tool name, description = tool
description, dataclass bodies + declared parameters = one flat JSON
schema) — the spec IS the catalog, nothing to drift.  Manage endpoints
never become tools.  A mode is a server-enforced system prompt + tool
allowlist (Help / Research / System per ai.md); the executor can only
reach allowlisted /api/ai endpoints, path parameters are encoded and
traversal-rejected so a crafted argument cannot escape an endpoint.

POST /api/chat streams SSE (status/token/tool_call/tool_result/done/
error), starts llama-server on demand, validates tool args server-side
with instructive errors the model can retry on, caps tool results with
a paging hint, and bounds the loop by ai.yaml max_tool_calls plus a
hard timeout.  Caddy passes the stream unbuffered (flush_interval -1)
in both deployments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:54 -06:00
Roland
485a9703eb
ai: package llama-server for all three deployments, with idle unload
install_llama_cpp.sh builds llama-server from the SHA-pinned v0.3.0
source tarball (single source of truth: on-device upgrade, Pi image,
Live ISO, and the new docker/llm container all call it; version-marker
makes re-runs no-ops).  start_llama_server.sh refuses to start unless
ai.yaml enables AI and the active model file exists — which also covers
unmounted media.  wrolpi-llm.service is installed but deliberately
never enabled at boot: the Controller starts it on demand (registered
in managed_services; the llm compose service follows the samba
pattern), and the API's perpetual idle-unload worker asks the
Controller to stop it after the configured idle minutes.  llama-server
is localhost-only natively, internal-network-only in docker, and never
proxied by Caddy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:54 -06:00
Roland
41bd8f7c45
ai: register AIConfig in get_all_configs so it imports on startup
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:54 -06:00
Roland
c6e0f18d5e
ai: ai.yaml config, model catalog, and the GGUF downloader
ai.yaml is the source of truth (opt-in: enabled=False by default) with
active model, idle-unload minutes, context override, and tool-call
bound.  The model catalog follows the map pattern: a bundled constant
keeps the Manage tab working offline, the CDN manifest (ai/models.json
+ detached GPG signature) wins when reachable.  GGUF downloads clone
the map-extract flow — the per-file meta4 is GPG-verified once and the
same bytes handed to aria2c, so the verified hash is the enforced one;
unverified downloads are refused.  Models land in <media>/ai/models/
(gated by the existing media_mounted download rules).  Manage endpoints
(catalog with RAM-tier recommendation, settings) live under
/api/ai/manage/ which the future agent loop excludes from its tool
catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:53 -06:00
Roland
85604c80fe
ai: help-docs search — Help mode tools
Search the wrolpi-help markdown in-process: files are parsed on demand
and cached by mtime; title matches outrank headings outrank body.  No
database table — the corpus is dozens of small files.  Pages are read
as paged markdown.  The docker api container gets a read-only bind
mount of the help docs at the native /opt/wrolpi-help location; a
missing help directory degrades to empty results with a message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:53 -06:00
Roland
b07672b976
ai: system endpoints — aggregate status, services, logs, disks
Proxy the Controller under /api/ai so the model sees one API: an
aggregate /status to start every diagnosis, lean service list, recent
service logs (lines clamped), and disks + SMART.  One controller_client
helper resolves the Controller base URL (docker service name vs
localhost).  Every proxy degrades to an `errors` entry when the
Controller is unreachable — never a 500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:53 -06:00
Roland
d77eaa9ecd
ai: add the /api/ai blueprint, the read-only LLM tool catalog
Lean, flat, LLM-shaped read-only endpoints over the library: global,
video, archive, and doc search; paged captions, archive text, and zim
entries; collections, inventories, and a media-directory text reader.
Links are server-computed; big content is paged, never truncated.
This ports the formatting layer from wrolpi_mcp (untouched for now)
into tested code.  First phase of ai.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-24 14:09:52 -06:00
380 changed files with 10818 additions and 32453 deletions

253
.circleci/config.yml Normal file
View file

@ -0,0 +1,253 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/2.0/configuration-reference
version: 2.1
# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/2.0/configuration-reference/#jobs
jobs:
api-tests:
docker:
- image: cimg/python:3.13
# `large` (4 vCPU, 8 GB) so pytest-xdist's `-nauto` has room to scale.
resource_class: large
steps:
- checkout
- run: sudo apt-get update
- run: sudo apt-get install -y ffmpeg catdoc
- restore_cache:
key: deps-3.13-{{ checksum "requirements.txt" }}-6
- run:
name: Install Requirements
command: |
# A restored venv points at the exact interpreter that built it. CircleCI's
# cimg/python:3.13 gets patch bumps, and this cache key does not name the
# patch, so a venv cached before a bump has a dangling bin/python3 -- and
# `python3 -m venv` over it fails with `[Errno 2]` instead of repairing it,
# which broke every Python job. Rebuild when it cannot run: that costs a
# reinstall only on the first run after a bump.
if ! ./venv/bin/python3 --version >/dev/null 2>&1; then rm -rf venv; fi
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
- save_cache:
key: deps-3.13-{{ checksum "requirements.txt" }}-6
paths:
- "venv"
# `-nauto` parallelizes across all available cores via pytest-xdist.
# `-svv` was dumping the full test stdout to CircleCI logs on every run,
# which inflated wall time and produced multi-MB log artifacts; dropped.
- run:
command: './venv/bin/pytest -nauto --junitxml=test-results.xml'
- store_test_results:
path: test-results.xml
# Do the API and the frontend's test fixtures still agree?
#
# `api-tests` runs these too, as part of the whole suite. This job exists for the
# signal: fixture drift is silent by nature -- the frontend suite passes against a body the
# API stopped sending -- so it is worth a check whose name says what broke, failing in under a
# minute rather than somewhere inside seventeen hundred results.
#
# It needs no ffmpeg or catdoc (verified), and shares the dependency cache key with the other
# Python jobs, so it costs a venv restore and one test file.
api-contract-tests:
docker:
- image: cimg/python:3.13
resource_class: medium
steps:
- checkout
- restore_cache:
key: deps-3.13-{{ checksum "requirements.txt" }}-6
- run:
name: Install Requirements
command: |
# A restored venv points at the exact interpreter that built it. CircleCI's
# cimg/python:3.13 gets patch bumps, and this cache key does not name the
# patch, so a venv cached before a bump has a dangling bin/python3 -- and
# `python3 -m venv` over it fails with `[Errno 2]` instead of repairing it,
# which broke every Python job. Rebuild when it cannot run: that costs a
# reinstall only on the first run after a bump.
if ! ./venv/bin/python3 --version >/dev/null 2>&1; then rm -rf venv; fi
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
- run:
name: Fixtures match the OpenAPI spec, and the spec matches the handlers
command: './venv/bin/pytest wrolpi/test/test_api_fixtures.py -v'
api-alembic-migrations:
docker:
- image: cimg/python:3.13
resource_class: medium
steps:
- checkout
- restore_cache:
key: deps-3.13-{{ checksum "requirements.txt" }}-6
- run:
name: Install Requirements
command: |
# A restored venv points at the exact interpreter that built it. CircleCI's
# cimg/python:3.13 gets patch bumps, and this cache key does not name the
# patch, so a venv cached before a bump has a dangling bin/python3 -- and
# `python3 -m venv` over it fails with `[Errno 2]` instead of repairing it,
# which broke every Python job. Rebuild when it cannot run: that costs a
# reinstall only on the first run after a bump.
if ! ./venv/bin/python3 --version >/dev/null 2>&1; then rm -rf venv; fi
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
- run:
name: Create Media Directory
command: mkdir -p /tmp/wrolpi-media/config
- run:
name: Run Alembic Migrations
command: MEDIA_DIRECTORY=/tmp/wrolpi-media ./venv/bin/python ./main.py db upgrade
- run:
name: Check for Missing Migrations
command: MEDIA_DIRECTORY=/tmp/wrolpi-media ./venv/bin/python ./main.py db check
sanic-api-start:
docker:
- image: cimg/python:3.13
steps:
- checkout
- restore_cache:
key: deps-3.13-{{ checksum "requirements.txt" }}-6
- run:
name: Install dependencies
command: |
# A restored venv points at the exact interpreter that built it. CircleCI's
# cimg/python:3.13 gets patch bumps, and this cache key does not name the
# patch, so a venv cached before a bump has a dangling bin/python3 -- and
# `python3 -m venv` over it fails with `[Errno 2]` instead of repairing it,
# which broke every Python job. Rebuild when it cannot run: that costs a
# reinstall only on the first run after a bump.
if ! ./venv/bin/python3 --version >/dev/null 2>&1; then rm -rf venv; fi
python -m venv venv
. venv/bin/activate
pip install -r requirements.txt
sudo mkdir -p /media/wrolpi
- run:
name: Start Sanic API
command: |
. venv/bin/activate
sanic main:api_app -H 0.0.0.0 -p 8081
background: true
- run:
name: Test Sanic API
command: |
sleep 20
curl http://0.0.0.0:8081/api/echo
- run:
name: Stop Sanic API
command: pkill -f sanic
react-app-start:
docker:
- image: cimg/node:20.19
resource_class: large
steps:
- checkout
- restore_cache:
key: app-{{ checksum "app/package-lock.json" }}
- run: cd app && npm install
- save_cache:
key: app-{{ checksum "app/package-lock.json" }}
paths:
- ./app/node_modules
- run:
name: Build React App
command: cd app && DISABLE_ESLINT_PLUGIN=true npm run build
- run:
name: Start React App
command: cd app && DISABLE_ESLINT_PLUGIN=true npm run start
background: true
- run:
name: Test React App
command: |
sleep 20
curl http://0.0.0.0:3000/manifest.json
- run:
name: Stop React App
command: pkill -f npm
# Jest tests moved to GitHub Actions (.github/workflows/jest-tests.yml)
# Component specs live under `src/`, so they need `cypress run --component`; the e2e job's
# `cypress run` will not match them. Cypress starts its own dev server, so no `npm start`.
cypress-component-tests:
docker:
- image: cimg/node:20.19-browsers
resource_class: large
steps:
- checkout
- restore_cache:
key: app-browsers-{{ checksum "app/package-lock.json" }}
- run:
name: Install Dependencies
command: cd app && npm install
- run:
name: Install Cypress Binary
command: cd app && npx cypress install
- run:
name: Run Cypress Component Tests
command: cd app && CI=true npx cypress run --component
- store_artifacts:
path: app/cypress/screenshots
cypress-e2e-tests:
docker:
- image: cimg/node:20.19-browsers
resource_class: large
steps:
- checkout
- restore_cache:
key: app-browsers-{{ checksum "app/package-lock.json" }}
- run:
name: Install Dependencies
command: cd app && npm install
# The Cypress binary lives outside node_modules, so caching only
# node_modules lets the two drift apart: a restored cache satisfies npm,
# npm skips the postinstall download, and the binary for the new version
# is never fetched. Cache both, and install explicitly (a no-op when the
# binary is already present).
- run:
name: Install Cypress Binary
command: cd app && npx cypress install
- save_cache:
key: app-browsers-{{ checksum "app/package-lock.json" }}
paths:
- ./app/node_modules
- ~/.cache/Cypress
- run:
name: Start React App in Background
command: cd app && DISABLE_ESLINT_PLUGIN=true npm start
background: true
- run:
name: Wait for React App
command: |
for i in {1..60}; do
if curl -s http://localhost:3000 > /dev/null; then
echo "React app is ready!"
exit 0
fi
echo "Waiting for React app... ($i/60)"
sleep 2
done
echo "React app failed to start"
exit 1
- run:
name: Run Cypress Tests
command: cd app && CI=true npx cypress run
- store_artifacts:
path: app/cypress/videos
- store_artifacts:
path: app/cypress/screenshots
workflows:
wrolpi-api-tests:
jobs:
- sanic-api-start
- api-tests
- api-contract-tests
- api-alembic-migrations
wrolpi-app-test:
jobs:
- react-app-start
# jest-tests moved to GitHub Actions
- cypress-e2e-tests
- cypress-component-tests

View file

@ -7,11 +7,6 @@ updates:
interval: "monthly"
target-branch: "master"
open-pull-requests-limit: 5
ignore:
# SQLAlchemy 2.1 is a minor release with its own deprecations; upgrade it
# deliberately rather than by an automatic bump.
- dependency-name: "SQLAlchemy"
versions: [">=2.1"]
groups:
python-dependencies:
patterns:
@ -42,42 +37,6 @@ updates:
interval: "monthly"
target-branch: "master"
open-pull-requests-limit: 5
ignore:
# Major upgrades below need their own migration branch, not a bump PR.
# maplibre-gl 6: /map build depends on pinned babel/terser handling.
- dependency-name: "maplibre-gl"
versions: [">=6"]
# react-dropzone 15+ changed the accept API; 20 requires Node >=22.
- dependency-name: "react-dropzone"
versions: [">=15"]
# React 19: @react-three/fiber 8 pins react <19.
- dependency-name: "react"
update-types: ["version-update:semver-major"]
- dependency-name: "react-dom"
update-types: ["version-update:semver-major"]
- dependency-name: "@types/react"
update-types: ["version-update:semver-major"]
- dependency-name: "@types/react-dom"
update-types: ["version-update:semver-major"]
# TypeScript 6+/7 breaks the react-scripts toolchain.
- dependency-name: "typescript"
versions: [">=6"]
# Mantine 9: all @mantine packages move together with app code changes.
- dependency-name: "@mantine/*"
update-types: ["version-update:semver-major"]
# Cypress 16 requires Node >=22; CI and the app image run Node 20.
- dependency-name: "cypress"
versions: [">=16"]
# react-router 8 is a routing migration across ~40 files.
- dependency-name: "react-router"
update-types: ["version-update:semver-major"]
# @testing-library/react 14+ async act semantics break menu/popover tests.
- dependency-name: "@testing-library/react"
update-types: ["version-update:semver-major"]
# Cypress component testing resolves the top-level webpack-dev-server and only
# accepts major 5; 6 crashes cypress.config.js ("Unexpected major version").
- dependency-name: "webpack-dev-server"
versions: [">=6"]
groups:
npm-dependencies:
patterns:

View file

@ -1,145 +0,0 @@
name: Backend Tests
# Same triggers as jest-tests.yml: `pull_request` is unfiltered so pull requests into feature
# branches are checked too, not only those into master.
on:
push:
branches: [master, feature/*]
pull_request:
# A new push to a pull request makes the run for the previous push worthless; cancel it so it
# does not hold runners (the free plan allows 20 concurrent jobs). Runs on master always finish.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# The suite is split by directory so it finishes in a few minutes, and so a check's name says
# which area broke. The groups are balanced by test time; `rest` runs everything the others do
# not, so a new directory is tested before anyone assigns it. A path added to any other group
# must also be added to `rest` as an --ignore, or its tests run twice.
api-tests:
name: api-tests (${{ matrix.name }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: core
paths: wrolpi/test
- name: media
paths: modules/videos modules/zim modules/docs modules/map modules/inventory modules/flasher
- name: files
paths: wrolpi/files wrolpi/collections
- name: rest
paths: >-
--ignore=wrolpi/test
--ignore=modules/videos --ignore=modules/zim --ignore=modules/docs --ignore=modules/map
--ignore=modules/inventory --ignore=modules/flasher
--ignore=wrolpi/files --ignore=wrolpi/collections
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install system packages
run: sudo apt-get update && sudo apt-get install -y ffmpeg catdoc
- name: Setup Python
uses: actions/setup-python@v5
with:
# Trixie ships 3.13.
python-version: '3.13'
cache: 'pip'
cache-dependency-path: requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt
# `logical`, not `auto`: with psutil installed, `auto` counts physical cores, which is two on
# the four-vCPU runner. No `-v`: the full test output inflates wall time and logs.
- name: Run pytest
env:
PATHS: ${{ matrix.paths }}
run: pytest -n logical $PATHS --junitxml=test-results.xml
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: api-test-results-${{ matrix.name }}
path: test-results.xml
retention-days: 30
# Do the API and the frontend's test fixtures still agree?
#
# `api-tests` runs these too, as part of the whole suite. This job exists for the signal:
# fixture drift is silent by nature -- the frontend suite passes against a body the API stopped
# sending -- so it is worth a check whose name says what broke, failing in under a minute rather
# than somewhere inside seventeen hundred results. It needs no ffmpeg or catdoc.
api-contract-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: 'pip'
cache-dependency-path: requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt
- name: Fixtures match the OpenAPI spec, and the spec matches the handlers
run: pytest wrolpi/test/test_api_fixtures.py -v
# The migrations apply to an empty database, the models match them, and the API starts.
api-migrations-and-start:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
cache: 'pip'
cache-dependency-path: requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run Alembic migrations
env:
MEDIA_DIRECTORY: ${{ runner.temp }}/wrolpi-media
run: |
mkdir -p "$MEDIA_DIRECTORY/config"
python ./main.py db upgrade
- name: Check for missing migrations
env:
MEDIA_DIRECTORY: ${{ runner.temp }}/wrolpi-media
run: python ./main.py db check
- name: Start Sanic API
run: |
sudo mkdir -p /media/wrolpi
nohup sanic main:api_app -H 0.0.0.0 -p 8081 > sanic.log 2>&1 &
for i in $(seq 1 60); do
if curl -sf http://localhost:8081/api/echo; then
echo
echo "Sanic API is up"
exit 0
fi
sleep 2
done
echo "Sanic API failed to start"
cat sanic.log
exit 1
- name: Stop Sanic API
if: ${{ always() }}
run: pkill -f sanic || true

View file

@ -1,121 +0,0 @@
name: Cypress Tests
on:
push:
branches: [master, feature/*]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Component specs live under `src/`, so they need `cypress run --component`; the e2e job's
# `cypress run` will not match them. Cypress starts its own dev server, so no `npm start`.
cypress-component-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: app
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19'
cache: 'npm'
cache-dependency-path: app/package-lock.json
# The Cypress binary lives outside node_modules, and setup-node only caches ~/.npm. Key it
# on the lockfile so a Cypress bump fetches the matching binary.
- name: Cache Cypress binary
uses: actions/cache@v4
with:
path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('app/package-lock.json') }}
- name: Install dependencies
run: npm install
# A no-op when the cached binary matches.
- name: Install Cypress binary
run: npx cypress install
- name: Run Cypress component tests
run: npx cypress run --component
- name: Upload screenshots
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: cypress-component-screenshots
path: app/cypress/screenshots
retention-days: 14
# Waiting for `npm start` to serve the app also checks that the dev server starts.
cypress-e2e-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: app
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19'
cache: 'npm'
cache-dependency-path: app/package-lock.json
- name: Cache Cypress binary
uses: actions/cache@v4
with:
path: ~/.cache/Cypress
key: cypress-${{ runner.os }}-${{ hashFiles('app/package-lock.json') }}
- name: Install dependencies
run: npm install
- name: Install Cypress binary
run: npx cypress install
# GitHub Actions sets `CI=true`, which makes cypress.config.js use http://localhost:3000 as
# the base URL.
- name: Start React app
env:
DISABLE_ESLINT_PLUGIN: 'true'
BROWSER: none
run: |
nohup npm start > react.log 2>&1 &
for i in $(seq 1 90); do
if curl -sf http://localhost:3000/manifest.json > /dev/null; then
echo "React app is ready"
exit 0
fi
sleep 2
done
echo "React app failed to start"
cat react.log
exit 1
- name: Run Cypress e2e tests
run: npx cypress run
- name: Upload videos and screenshots
if: ${{ failure() }}
uses: actions/upload-artifact@v4
with:
name: cypress-e2e-media
path: |
app/cypress/videos
app/cypress/screenshots
retention-days: 14
if-no-files-found: ignore

3
.gitignore vendored
View file

@ -150,6 +150,3 @@ app/cypress/videos
release/latest.json
perf_results/
scripts/api_perf_hosts.json
# Tile cache for scripts/build_solar_climatology.py
/scripts/.solar_climatology_cache/

View file

@ -3,10 +3,8 @@
There are several ways to run WROLPi. Pick the one that matches your hardware:
* [WROLPi Portable (Live USB)](#wrolpi-portable-live-usb) — boot a full WROLPi from a USB stick on any x86 PC or laptop, no installation required.
* [Debian 13 Install](#debian-13-install) — install WROLPi onto an x86 PC or laptop.
* [Debian 12 Install](#debian-12-install) — install WROLPi onto an x86 PC or laptop.
* [Raspberry Pi Install](#raspberry-pi-install) — install WROLPi onto a Raspberry Pi with an external drive.
After installing, follow [First Boot](#first-boot) to set up your drive.
* [Docker](docker/README.md) — run the WROLPi containers on an existing Linux host (best for trying it out or development).
See [UPGRADE.md](UPGRADE.md) for upgrading an existing WROLPi.
@ -38,7 +36,6 @@ Steps:
minute or two; progress is shown on screen.
5. Once the desktop loads, browse to https://wrolpi.local or the IP address of
your WROLPi. Wi-Fi hotspot and all other features work as usual.
6. To use a terminal remotely: `ssh user@wrolpi.local`, password `live`.
Notes:
@ -55,50 +52,30 @@ Notes:
* **Upgrading.** See [Upgrading a WROLPi Portable USB](UPGRADE.md#upgrading-a-wrolpi-portable-usb)
to move to a newer ISO without losing your library.
## Debian 13 Install
## Debian 12 Install
Install WROLPi onto the internal disk of an x86 PC or laptop. This uses the same
`WROLPi-v*-amd64.iso` as WROLPi Portable.
Steps necessary to initialize your WROLPi after installing the Debian image from wrolpi.org
1. Download the latest `WROLPi-v*-amd64.iso` from [wrolpi.org](https://wrolpi.org) and flash it to a USB drive
(see [WROLPi Portable](#wrolpi-portable-live-usb) step 2).
2. Boot the PC from the USB drive. At the boot menu, choose **Install** or **Graphical Install**.
3. Install Debian 13 as you would like.
1. It is recommended to use the hostname **wrolpi**.
2. WROLPi is installed along with Debian; there is nothing extra to select.
4. Unplug the USB drive after the installation has completed, and reboot.
5. Continue with [First Boot](#first-boot).
1. Download and copy a pre-built Debian image from https://wrolpi.org onto a USB thumb-drive (USB 2 recommended).
2. Insert the thumb-drive into the laptop, boot to the thumb-drive.
1. Select "Start Installer".
2. Install Debian 12 as you would like.
1. It is recommended to use the hostname **wrolpi**.
2. (WROLPi will be installed during the installation without your intervention.)
3. Unplug the thumb-drive after the installation has completed.
4. Login as the user _you_ created during installation.
5. Initialize the WROLPi databases using the repair script: `/opt/wrolpi/repair.sh`
6. Reboot: `reboot`
7. Browse to https://wrolpi.local or the IP address of your WROLPi!
## Raspberry Pi Install
Install WROLPi onto a Raspberry Pi. An external drive is recommended for your library.
Steps necessary to initialize your WROLPi after installing the Raspberry Pi image from wrolpi.org
1. Download the latest `WROLPi-v*-aarch64-desktop.img.xz` from [wrolpi.org](https://wrolpi.org).
2. Flash it to an SD card using Raspberry Pi Imager.
3. Plug in your external drive, insert the SD card, and boot the Raspberry Pi.
4. On the first boot, Raspberry Pi OS asks you to create your own user.
5. Continue with [First Boot](#first-boot).
## First Boot
These steps are the same for every install.
1. Join the WROLPi hotspot (network **WROLPi**, password **wrolpi hotspot**; it starts on every boot), or connect
your WROLPi to your network, then browse to https://wrolpi.local (or the IP address of your WROLPi).
2. **Choose your drive.**
* **Raspberry Pi and Debian:** until a drive is set up, you will see the **Controller** with a *Welcome to WROLPi*
wizard (also at http://wrolpi.local). Select the drive which will hold your library; WROLPi mounts it
at `/media/wrolpi`, adds it to `/etc/fstab`, and runs the repair script for you. If the drive already holds a
WROLPi library (from another WROLPi, or a backup), its configuration is used. No external drive? Choose **Run
repair without external drive** to keep your library on the internal disk or SD card.
* **WROLPi Portable:** nothing to do; your library lives in the persistence partition on the USB drive.
3. **Trust the certificate.** Your browser will warn about the HTTPS certificate because it was made by your WROLPi,
not by a public authority. Download https://wrolpi.local/ca.crt and trust it on each device you use; see
*Help > Certificates* on your WROLPi for per-device instructions.
4. **Wait for the first refresh.** WROLPi refreshes all your files when it first starts so it can find anything already
on the drive. Your library looks empty, and Download and Upload are disabled, until the refresh completes.
5. Start adding content: download a video channel, archive a web page, or add a Zim (Wikipedia) file. The *Help* page
on your WROLPi explains each module.
You do not need to edit `/etc/fstab` or run `repair.sh` by hand. `sudo /opt/wrolpi/repair.sh` is still available if
your WROLPi ever needs repairing.
1. Download and copy a pre-built image from https://wrolpi.org onto an SD card.
2. Boot the Raspberry Pi, login with username pi and password wrolpi.
3. Modify fstab to mount your external drive to /media/wrolpi (modify this command to match your system).
* `echo '/dev/sda1 /media/wrolpi auto defaults,nofail 0 0' | sudo tee -a /etc/fstab`
4. Initialize the WROLPi databases using the repair script: `sudo /opt/wrolpi/repair.sh`
5. Reboot `sudo reboot`
6. Join the Hotspot or browse to https://wrolpi.local or the IP address of your WROLPi!

View file

@ -1,6 +1,6 @@
# WROLPi
[![Backend Tests](https://github.com/lrnselfreliance/wrolpi/actions/workflows/backend.yml/badge.svg?branch=master)](https://github.com/lrnselfreliance/wrolpi/actions/workflows/backend.yml?query=branch%3Amaster)
[![Build Status](https://img.shields.io/circleci/build/github/lrnselfreliance/wrolpi/release?label=release%20build)](https://app.circleci.com/pipelines/github/lrnselfreliance/wrolpi?branch=release&filter=all)
[![License: GPL v3](https://img.shields.io/github/license/lrnselfreliance/wrolpi?style=flat-square)](https://github.com/lrnselfreliance/wrolpi/blob/master/LICENSE)
[![Discord](https://img.shields.io/discord/900430987987681330?label=Discord&logo=discord)](https://discord.gg/HrwFk7nqA2)
[![Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/lrnselfreliance/wrolpi)
@ -93,7 +93,7 @@ More Docker documentation is available in [docker/README.md](docker/README.md)
Full installation instructions are in [INSTALL.md](INSTALL.md). Pick the option that matches your hardware:
* **[WROLPi Portable (Live USB)](INSTALL.md#wrolpi-portable-live-usb)** — boot a complete WROLPi from a USB stick on any x86 PC or laptop. Nothing is installed to the computer; the first boot creates a persistence partition on the USB so your library, database, and configuration survive reboots.
* **[Debian 13](INSTALL.md#debian-13-install)** — install WROLPi onto an x86 PC or laptop.
* **[Debian 12](INSTALL.md#debian-12-install)** — install WROLPi onto an x86 PC or laptop.
* **[Raspberry Pi](INSTALL.md#raspberry-pi-install)** — install WROLPi onto a Raspberry Pi with an external drive.
* **[Docker](docker/README.md)** — run the WROLPi containers on an existing Linux host.

View file

@ -3,7 +3,7 @@
# Table of Contents
* [Upgrading Docker containers](#upgrading-docker-containers)
* [Upgrading Raspberry Pi or Debian Installation](#upgrading-raspberry-pi-or-debian-installation)
* [Upgrading Raspberry Pi or Debian 12 Installation](#upgrading-raspberry-pi-or-debian-12-installation)
* [Upgrading a WROLPi Portable USB](#upgrading-a-wrolpi-portable-usb)
## Upgrading Docker containers
@ -24,9 +24,7 @@
7. Start all docker containers
* `docker-compose up -d`
## Upgrading Raspberry Pi or Debian Installation
A Debian 12 (bookworm) WROLPi stays on the final version for Debian 12; reinstall on Debian 13 to get newer versions.
## Upgrading Raspberry Pi or Debian 12 Installation
1. Run the upgrade script
* `sudo /bin/bash /opt/wrolpi/upgrade.sh`

View file

@ -6,10 +6,9 @@ from alembic import context
# access to the values within the .ini file in use.
config = context.config
# Configure logging only for the alembic command line. WROLPi also migrates in-process at startup
# (db_bootstrap), where fileConfig would replace the API's handlers and disable its existing loggers.
if config.cmd_opts is not None:
fileConfig(config.config_file_name)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
@ -24,7 +23,6 @@ from wrolpi.collections.models import Collection, CollectionItem # noqa: F401
from modules.videos.models import Video, Channel # noqa: F401
from modules.archive.models import Archive # noqa: F401
from modules.zim.models import Zim, TagZimEntry, ZimSubscription # noqa: F401
from modules.repos.models import Repository # noqa: F401
target_metadata = Base.metadata
@ -98,10 +96,7 @@ def run_migrations_online():
connectable = create_wrolpi_engine(get_url())
# Autocommit: each migration statement is its own transaction. A migration may call WROLPi
# functions which write on their own connection; a migration connection holding an open read
# transaction could not then upgrade to a writer ("database is locked").
with connectable.connect().execution_options(isolation_level='AUTOCOMMIT') as connection:
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,

View file

@ -1,45 +0,0 @@
"""Maintain collection.item_count/total_size for Domain Collections with triggers.
Listing Domain Collections summed file_group.size through an archive join on every request,
one random page read per archive. The new triggers keep the summary columns current
(as the channel triggers already do for Channels); this migration installs them and
backfills existing Domain Collections.
Revision ID: 2026_09_27_1200
Revises: 2026_09_20_1200
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = '2026_09_27_1200'
down_revision = '2026_09_20_1200'
branch_labels = None
depends_on = None
TRIGGERS = (
'archive_insert_collection_summary',
'archive_delete_collection_summary',
'archive_update_collection_summary',
'file_group_size_collection_summary',
)
def upgrade():
from wrolpi.schema_ddl import install_raw_ddl
# Idempotent: only the new triggers are created.
install_raw_ddl(op.get_bind())
op.execute('''
UPDATE collection SET
item_count = (SELECT COUNT(*) FROM archive WHERE collection_id = collection.id),
total_size = (SELECT COALESCE(SUM(fg.size), 0) FROM archive a
LEFT JOIN file_group fg ON a.file_group_id = fg.id
WHERE a.collection_id = collection.id)
WHERE kind = 'domain'
''')
def downgrade():
for name in TRIGGERS:
op.execute(f'DROP TRIGGER IF EXISTS {name}')

View file

@ -1,29 +0,0 @@
"""Covering indexes for the Collection listing.
Listing Collections read every channel row (for video_count/total_size) and every download
row of those Collections (for the minimum frequency). Both tables store yt-dlp info_json,
so their rows are tens of KB and each sits on its own scattered page; from a cold disk cache
that was 10+ seconds. These indexes answer both queries without touching a row.
Revision ID: 2026_09_28_1200
Revises: 2026_09_27_1200
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = '2026_09_28_1200'
down_revision = '2026_09_27_1200'
branch_labels = None
depends_on = None
def upgrade():
op.execute('CREATE INDEX IF NOT EXISTS channel_collection_summary_idx'
' ON channel (collection_id, video_count, total_size)')
op.execute('CREATE INDEX IF NOT EXISTS download_collection_frequency_idx'
' ON download (collection_id, frequency)')
def downgrade():
op.execute('DROP INDEX IF EXISTS channel_collection_summary_idx')
op.execute('DROP INDEX IF EXISTS download_collection_frequency_idx')

View file

@ -1,31 +0,0 @@
"""Retag existing markdown file groups as text/markdown.
A refresh skips a group whose files have not changed, so markdown indexed before text/markdown
existed would keep the type libmagic gave it (text/plain, or a programming language when the file
holds code) and the indexer that type chose. Marking the rows unindexed lets apply_indexers
re-index them with TextIndexer. SQLite's LIKE is case-insensitive for ASCII, so `.MD` matches.
Revision ID: 2026_10_05_1200
Revises: 2026_09_28_1200
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = '2026_10_05_1200'
down_revision = '2026_09_28_1200'
branch_labels = None
depends_on = None
def upgrade():
op.execute("""
UPDATE file_group SET mimetype = 'text/markdown', "indexed" = 0
WHERE (primary_path LIKE '%.md' OR primary_path LIKE '%.markdown')
AND mimetype LIKE 'text/%'
AND mimetype != 'text/markdown'
""")
def downgrade():
# The original types are not recorded; text/plain is what libmagic reports for most markdown.
op.execute("""UPDATE file_group SET mimetype = 'text/plain', "indexed" = 0 WHERE mimetype = 'text/markdown'""")

View file

@ -1,43 +0,0 @@
"""Add the repository table for git Repos (Collections with kind='repo').
Revision ID: 2026_10_06_1200
Revises: 2026_10_05_1200
"""
import sqlalchemy as sa
from alembic import op
import wrolpi.dates
# revision identifiers, used by Alembic.
revision = '2026_10_06_1200'
down_revision = '2026_10_05_1200'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'repository',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('url', sa.String(), nullable=False),
sa.Column('host', sa.String(), nullable=True),
sa.Column('owner', sa.String(), nullable=True),
sa.Column('mode', sa.String(), nullable=False),
sa.Column('branch', sa.String(), nullable=True),
sa.Column('default_branch', sa.String(), nullable=True),
sa.Column('head_sha', sa.String(), nullable=True),
sa.Column('head_date', wrolpi.dates.TZDateTime(), nullable=True),
sa.Column('head_message', sa.Text(), nullable=True),
sa.Column('last_fetch', wrolpi.dates.TZDateTime(), nullable=True),
sa.Column('size', sa.BigInteger(), nullable=True),
sa.Column('readme_path', sa.String(), nullable=True),
sa.Column('collection_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('collection_id'),
sa.UniqueConstraint('url'),
)
def downgrade():
op.drop_table('repository')

View file

@ -1,39 +0,0 @@
"""Search git Repos by name and README: repository.search_name/readme_text and repository_fts.
Revision ID: 2026_10_06_1300
Revises: 2026_10_06_1200
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '2026_10_06_1300'
down_revision = '2026_10_06_1200'
branch_labels = None
depends_on = None
def upgrade():
from wrolpi.schema_ddl import install_raw_ddl
op.add_column('repository', sa.Column('search_name', sa.String(), nullable=True))
op.add_column('repository', sa.Column('readme_text', sa.Text(), nullable=True))
op.execute('''
UPDATE repository SET search_name = TRIM(
COALESCE((SELECT name FROM collection WHERE collection.id = repository.collection_id), '')
|| ' ' || COALESCE(owner, ''))
''')
# Creates repository_fts and its triggers now that the columns exist. The README text is filled by the next
# update of each Repo.
install_raw_ddl(op.get_bind())
op.execute("INSERT INTO repository_fts(repository_fts) VALUES('rebuild')")
def downgrade():
for name in ('repository_fts_ai', 'repository_fts_ad', 'repository_fts_au'):
op.execute(f'DROP TRIGGER IF EXISTS {name}')
op.execute('DROP TABLE IF EXISTS repository_fts')
with op.batch_alter_table('repository') as batch_op:
batch_op.drop_column('readme_text')
batch_op.drop_column('search_name')

View file

@ -1,22 +0,0 @@
"""A git Repo can also download its submodules: repository.submodules.
Revision ID: 2026_10_06_1400
Revises: 2026_10_06_1300
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '2026_10_06_1400'
down_revision = '2026_10_06_1300'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('repository', sa.Column('submodules', sa.Boolean(), server_default=sa.false(), nullable=False))
def downgrade():
with op.batch_alter_table('repository') as batch_op:
batch_op.drop_column('submodules')

View file

@ -1,22 +0,0 @@
"""A git Repo's clone holds a random token: repository.clone_token.
Revision ID: 2026_10_06_1500
Revises: 2026_10_06_1400
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '2026_10_06_1500'
down_revision = '2026_10_06_1400'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('repository', sa.Column('clone_token', sa.String(), nullable=True))
def downgrade():
with op.batch_alter_table('repository') as batch_op:
batch_op.drop_column('clone_token')

View file

@ -1,24 +0,0 @@
"""A FileGroup records how far the user is through it: file_group.progress and file_group.position.
Revision ID: 2026_10_08_1200
Revises: 2026_10_06_1500
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '2026_10_08_1200'
down_revision = '2026_10_06_1500'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('file_group', sa.Column('progress', sa.Float(), nullable=True))
op.add_column('file_group', sa.Column('position', sa.JSON(), nullable=True))
def downgrade():
# Not batch_alter_table: rebuilding file_group drops its FTS triggers (see 2026_09_20_1200).
op.execute('ALTER TABLE file_group DROP COLUMN position')
op.execute('ALTER TABLE file_group DROP COLUMN progress')

View file

@ -1,27 +0,0 @@
"""Each database config's version, so an import can tell whether the database or the file is newer
(see wrolpi.config_version).
Revision ID: 2026_10_24_1200
Revises: 2026_10_08_1200
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '2026_10_24_1200'
down_revision = '2026_10_08_1200'
branch_labels = None
depends_on = None
def upgrade():
# No rows: a config's next import (without deletes) records its version.
op.create_table(
'config_version',
sa.Column('name', sa.String(), primary_key=True),
sa.Column('version', sa.Integer(), nullable=False),
)
def downgrade():
op.drop_table('config_version')

View file

@ -1,175 +0,0 @@
"""Tag names have no leading or trailing whitespace.
A Tag whose name had whitespace is renamed. Tags whose names are the same once stripped are merged into one: it
keeps every tagged file, Zim entry and Collection, and the first color (by Tag id) of the merged Tags. Downloads
and the config files that name Tags are rewritten the same way, so the next import finds them in step with the
database (an import of the old names would create the padded Tags again, or delete the stripped ones).
Revision ID: 2026_10_25_1200
Revises: 2026_10_24_1200
"""
import json
import logging
import yaml
from alembic import op
from wrolpi.common import get_media_directory, write_config_data
# revision identifiers, used by Alembic.
revision = '2026_10_25_1200'
down_revision = '2026_10_24_1200'
branch_labels = None
depends_on = None
logger = logging.getLogger(__name__)
# The configs which name Tags.
CONFIG_FILES = ('tags.yaml', 'download_manager.yaml', 'channels.yaml', 'domains.yaml', 'playlists.yaml',
'repos.yaml', 'videos_downloader.yaml', 'archives_downloader.yaml')
def _strip(name):
"""A Tag name without its whitespace; a name that would be empty is kept (it cannot be renamed)."""
if isinstance(name, str) and name.strip():
return name.strip()
return name
def _strip_names(names: list) -> list:
stripped = list()
for name in map(_strip, names):
if name not in stripped:
stripped.append(name)
return stripped
def _strip_nested(data):
"""Strip the Tag names in a config's (or a Download's settings') `tag_name`, `tag_names` and
`channel_tag_name` values, at any depth."""
if isinstance(data, list):
return [_strip_nested(i) for i in data]
if not isinstance(data, dict):
return data
result = dict()
for key, value in data.items():
if key == 'tag_name':
value = _strip(value)
elif key in ('tag_names', 'channel_tag_name') and isinstance(value, list):
value = _strip_names(value)
else:
value = _strip_nested(value)
result[key] = value
return result
def _strip_rows(rows: list, key_length: int) -> list:
"""tags.yaml's tag_files or tag_zims, each row keyed by its first `key_length` values (the Tag name first)."""
result, seen = list(), set()
for row in rows:
if isinstance(row, list) and row:
row = [_strip(row[0]), *row[1:]]
key = json.dumps(row[:key_length], default=str)
if key in seen:
continue
seen.add(key)
result.append(row)
return result
def _strip_tags_config(config: dict, colors: dict) -> dict:
"""`colors`: each renamed Tag's color in the database, which the file must match (an import copies the file's
color into the database)."""
config = dict(config)
if isinstance(config.get('tags'), dict):
tags = dict()
for name, attrs in config['tags'].items():
stripped = _strip(name)
if stripped not in tags:
tags[stripped] = attrs
elif isinstance(attrs, dict) and attrs.get('color') and not (tags[stripped] or dict()).get('color'):
# The first color of the merged Tags.
tags[stripped] = {**(tags[stripped] or dict()), 'color': attrs['color']}
for name in tags.keys() & colors.keys():
tags[name] = {**(tags[name] or dict()), 'color': colors[name]}
config['tags'] = tags
if isinstance(config.get('tag_files'), list):
config['tag_files'] = _strip_rows(config['tag_files'], 2)
if isinstance(config.get('tag_zims'), list):
config['tag_zims'] = _strip_rows(config['tag_zims'], 3)
return config
def strip_tag_names_in_configs(colors: dict):
config_directory = get_media_directory() / 'config'
for file_name in CONFIG_FILES:
file = config_directory / file_name
if not file.is_file():
continue
try:
with file.open('rt') as fh:
config = yaml.load(fh, Loader=yaml.CSafeLoader)
except Exception as e:
logger.error(f'Cannot strip Tag names of invalid config: {file}', exc_info=e)
continue
if not isinstance(config, dict):
continue
stripped = _strip_tags_config(config, colors) if file_name == 'tags.yaml' else config
stripped = _strip_nested(stripped)
if stripped != config:
logger.warning(f'Stripping whitespace from Tag names in {file}')
write_config_data(stripped, file, width=500 if file_name == 'tags.yaml' else 90)
def strip_tag_names_in_db(connection) -> dict:
"""Returns the color of each renamed Tag, by its new name."""
groups, colors = dict(), dict()
for id_, name, color in connection.exec_driver_sql('SELECT id, name, color FROM tag ORDER BY id').fetchall():
groups.setdefault(_strip(name), list()).append((id_, name, color))
for stripped, tags in groups.items():
if all(name == stripped for _, name, _ in tags):
continue
# Keep the Tag already named this, if any, otherwise the oldest.
survivor_id = next((id_ for id_, name, _ in tags if name == stripped), tags[0][0])
color = colors[stripped] = next((color for _, _, color in tags if color), None)
for id_, name, _ in tags:
if id_ == survivor_id:
continue
logger.warning(f'Merging Tag {name!r} into {stripped!r}')
connection.exec_driver_sql(
'INSERT OR IGNORE INTO tag_file (tag_id, file_group_id, created_at)'
' SELECT ?, file_group_id, created_at FROM tag_file WHERE tag_id = ?', (survivor_id, id_))
connection.exec_driver_sql(
'INSERT OR IGNORE INTO tag_zim (tag_id, zim_id, zim_entry, created_at)'
' SELECT ?, zim_id, zim_entry, created_at FROM tag_zim WHERE tag_id = ?', (survivor_id, id_))
connection.exec_driver_sql('DELETE FROM tag_file WHERE tag_id = ?', (id_,))
connection.exec_driver_sql('DELETE FROM tag_zim WHERE tag_id = ?', (id_,))
connection.exec_driver_sql('UPDATE collection SET tag_id = ? WHERE tag_id = ?', (survivor_id, id_))
connection.exec_driver_sql('DELETE FROM tag WHERE id = ?', (id_,))
logger.warning(f'Renaming Tag to {stripped!r}')
connection.exec_driver_sql('UPDATE tag SET name = ?, color = ? WHERE id = ?', (stripped, color, survivor_id))
# The Downloads which apply them (download_manager.yaml is stripped the same way).
downloads = connection.exec_driver_sql(
'SELECT id, tag_names, settings FROM download WHERE tag_names IS NOT NULL OR settings IS NOT NULL').fetchall()
for id_, tag_names, settings in downloads:
tag_names, settings = json.loads(tag_names) if tag_names else None, json.loads(settings) if settings else None
new_tag_names = _strip_names(tag_names) if isinstance(tag_names, list) else tag_names
new_settings = _strip_nested(settings)
if new_tag_names != tag_names or new_settings != settings:
connection.exec_driver_sql(
'UPDATE download SET tag_names = ?, settings = ? WHERE id = ?',
(json.dumps(new_tag_names) if new_tag_names is not None else None,
json.dumps(new_settings) if new_settings is not None else None, id_))
return colors
def upgrade():
colors = strip_tag_names_in_db(op.get_bind())
strip_tag_names_in_configs(colors)
def downgrade():
# Whitespace that was stripped is not restored.
pass

View file

@ -1,5 +1,5 @@
/*
* The six Special Directories fields on /admin/settings.
* The five Special Directories fields on /admin/settings.
*
* These are the fields that shipped printing their own prefix over their own value, and the
* ones whose visible labels were never associated with their inputs. Both faults were
@ -19,7 +19,6 @@ describe('Special Directories settings', () => {
['Map Directory', 'map'],
['Zims Directory', 'zims'],
['Playlists Directory', 'playlists'],
['Repos Directory', 'repos/%(repo_tag)s/%(repo_name)s'],
];
beforeEach(() => {
@ -39,8 +38,8 @@ describe('Special Directories settings', () => {
it('names every directory field, so they can be told apart', () => {
/*
* All six fields sit in one grid, hold similar-looking relative paths, and share the
* same prefix. Without a name attached to the input, a screen reader user hears six
* All five fields sit in one grid, hold similar-looking relative paths, and share the
* same prefix. Without a name attached to the input, a screen reader user hears five
* fields described only as "/media/wrolpi/" and cannot tell which one saves videos.
*/
directories.forEach(([label, value]) => {

View file

@ -41,7 +41,7 @@ import React from "react";
Cypress.on('uncaught:exception', (err, runnable) => {
if (err.message.includes('ChunkLoadError')) {
return false; // Ignore this error which happens after CI tests.
return false; // Ignore this error which happens after CircleCI tests.
}
// Otherwise, fail like normal
return true;

654
app/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,30 +9,28 @@
"@mantine/core": "^8.3.18",
"@mantine/hooks": "^8.3.18",
"@mantine/notifications": "^8.3.18",
"@tabler/icons-react": "^3.49.0",
"@tabler/icons-react": "^3.46.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.6.7",
"@testing-library/user-event": "^14.6.6",
"cypress": "^15.21.1",
"dompurify": "^3.4.17",
"esptool-js": "^0.7.0",
"esptool-js": "^0.6.1",
"lodash": "^4.18.1",
"maplibre-gl": "^5.24.0",
"markdown-it": "^15.0.3",
"mathjs": "^15.2.0",
"pmtiles": "^4.5.0",
"protomaps-themes-base": "^4.5.0",
"query-string": "^9.5.1",
"react": "^18.3.1",
"react-colorful": "^5.8.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.4.1",
"react": "^18.2.0",
"react-colorful": "^5.8.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-hotkeys-hook": "^5.3.3",
"react-qr-code": "^2.2.0",
"react-router": "^7.18.4",
"react-router": "^7.18.2",
"react-scripts": "5.0.1",
"react-stl-viewer": "^2.5.0",
"three": "^0.186",
"web-vitals": "^6.2.3"
"three": "^0.185",
"web-vitals": "^5.3.0"
},
"scripts": {
"start": "react-scripts start",
@ -60,10 +58,10 @@
]
},
"devDependencies": {
"@testing-library/dom": "^10.4.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@types/lodash": "^4.17.25",
"@types/node": "^26.6.4",
"@types/node": "^26.4.0",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"typescript": "^5.9.3",

View file

@ -1,11 +0,0 @@
Solar climatology for the WROLPi Solar calculator
Source: NASA POWER, https://power.larc.nasa.gov/
Data: 2001-2020 monthly climatology of ALLSKY_SFC_SW_DWN, ALLSKY_SFC_SW_DIFF, ALLSKY_SRF_ALB, T2M, on a 1° grid.
The data was obtained from the POWER Project's POWER Climatology API v2.10.0 version on 2026/10/04.
The data was obtained from National Aeronautics and Space Administration (NASA) Langley Research Center's Prediction Of Worldwide Energy Resources (POWER) project funded through the NASA Earth Science Division.
License: Creative Commons Attribution 4.0 International (CC BY 4.0), https://creativecommons.org/licenses/by/4.0/
Changes: WROLPi modified the data: values are rounded to fit one byte each (0.04 kWh/m²/day for irradiance, 0.004 for albedo, 0.5 °C for temperature), temperatures are averaged from NASA's 0.5° x 0.625° grid into 1° cells, and the calculator interpolates between cells.

View file

@ -242,8 +242,6 @@ either expressed or implied, of the FreeBSD Project. -->
// Optional search term passed in from a search-result deep link. If present we'll
// search the spine item at `loc` and jump to + highlight the first match.
let searchQuery = (params && params.get("q")) ? params.get("q") : null;
// Where the reader left off (an EPUB CFI). A search result's `loc` wins over it.
let startCfi = (params && params.get("cfi")) ? params.get("cfi") : null;
let book = ePub(url);
let rendition = book.renderTo("viewer", {
@ -252,33 +250,7 @@ either expressed or implied, of the FreeBSD Project. -->
height: "100%"
});
rendition.display(currentSectionIndex !== undefined ? currentSectionIndex : (startCfi || undefined));
// How far through the book `location` is, from its chapter and its page within that chapter. Cheaper
// than `book.locations.generate()`, which stalls a Raspberry Pi on a large book.
function readingProgress(location) {
if (location.atEnd) {
return 1;
}
const start = location.start;
const sections = book.spine.length || 1;
const displayed = start.displayed || {page: 1, total: 1};
const withinSection = (displayed.page - 1) / Math.max(displayed.total, 1);
return Math.min((start.index + withinSection) / sections, 1);
}
// Tell the WROLPi page that embeds this viewer where the reader is, so it can save their place. Nothing is
// posted at the start of the book: that is where a finished book opens, and saving it would un-finish it.
function postLocation(location) {
if (window.parent === window || !location || !location.start || location.atStart) {
return;
}
window.parent.postMessage({
type: "wrolpi:epub-location",
cfi: location.start.cfi,
progress: readingProgress(location),
}, window.location.origin);
}
rendition.display(currentSectionIndex);
// --- In-book search ---------------------------------------------------
// State is module-level so the toolbar buttons can drive it. `hits` is the
@ -544,7 +516,6 @@ either expressed or implied, of the FreeBSD Project. -->
prev.style.visibility = "visible";
}
postLocation(location);
});
rendition.on("layout", function (layout) {

View file

@ -316,90 +316,6 @@ pre.wrap-text {
height: 100%;
}
/* A markdown file preview, formatted or raw. Scrolls rather than fitting like .preview-fit, because
it is a document to read, not media to frame. Colors come from the theme tokens. */
.markdown-preview {
height: calc(100dvh - 21rem);
min-height: 15rem;
overflow: auto;
margin-top: 0.5em;
padding: 0 0.25em;
}
/* A repo's README is part of its page, not a modal; it grows with its content. */
.repo-readme .markdown-preview {
height: auto;
min-height: 0;
overflow: visible;
}
.markdown-raw {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.markdown-body {
max-width: 50rem;
line-height: 1.6;
overflow-wrap: break-word;
}
.markdown-body > :first-child {
margin-top: 0;
}
.markdown-body h1, .markdown-body h2 {
padding-bottom: 0.25em;
border-bottom: 1px solid var(--table-line);
}
.markdown-body img {
max-width: 100%;
}
.markdown-body blockquote {
margin: 0 0 1em;
padding: 0 1em;
color: var(--muted);
border-left: 0.25em solid var(--border);
}
.markdown-body code {
padding: 0.1em 0.3em;
border-radius: 0.25em;
background-color: var(--head);
}
.markdown-body pre {
padding: 0.75em 1em;
overflow-x: auto;
border-radius: 0.25em;
background-color: var(--head);
}
.markdown-body pre code {
padding: 0;
background-color: transparent;
}
.markdown-body table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
.markdown-body th, .markdown-body td {
padding: 0.3em 0.75em;
border: 1px solid var(--table-line);
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--border);
}
td.file-path {
cursor: pointer;
}
@ -809,35 +725,3 @@ tr.drag-deselecting td {
display: block;
}
}
/* Map popups (POI details and pins) follow the active theme's tokens. */
/* Scoped under .maplibregl-popup to outrank maplibre-gl.css, which loads after this file. */
.maplibregl-popup .maplibregl-popup-content {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 0.25rem;
}
.maplibregl-popup .maplibregl-popup-close-button {
color: var(--muted);
}
.maplibregl-popup .maplibregl-popup-close-button:hover {
background: var(--head);
color: var(--text);
}
.maplibregl-popup.maplibregl-popup-anchor-top .maplibregl-popup-tip,
.maplibregl-popup.maplibregl-popup-anchor-top-left .maplibregl-popup-tip,
.maplibregl-popup.maplibregl-popup-anchor-top-right .maplibregl-popup-tip {
border-bottom-color: var(--panel);
}
.maplibregl-popup.maplibregl-popup-anchor-bottom .maplibregl-popup-tip,
.maplibregl-popup.maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip,
.maplibregl-popup.maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip {
border-top-color: var(--panel);
}
.maplibregl-popup.maplibregl-popup-anchor-left .maplibregl-popup-tip {
border-right-color: var(--panel);
}
.maplibregl-popup.maplibregl-popup-anchor-right .maplibregl-popup-tip {
border-left-color: var(--panel);
}

View file

@ -17,7 +17,6 @@ import {ArchiveRoute} from "./components/Archive";
import {FilesRoute} from "./components/Files";
import {QueryProvider, StatusProvider} from "./hooks/customHooks";
import {FileWorkerStatusProvider} from "./contexts/FileWorkerStatusContext";
import {BookmarksProvider} from "./contexts/BookmarksContext";
import {ChannelEditPage, ChannelNewPage, ChannelsPage} from "./components/Channels";
import {MapRoute} from "./components/Map";
import {MediaContextProvider, mediaStyles, StatusContext} from "./contexts/contexts";
@ -30,9 +29,9 @@ import {FilePreviewProvider} from "./components/FilePreview";
import {TagsProvider} from "./Tags";
import {ZimRoute} from "./components/Zim";
import {PlaylistsRoute} from "./components/Playlists";
import {ReposRoute} from "./components/Repos";
import {DocsRoute} from "./components/Docs";
import {FlasherRoute} from "./components/Flasher";
import {AiRoute} from "./components/Ai";
import ErrorBoundary from "./components/ErrorBoundary";
import {KeyboardShortcutsProvider} from "./components/KeyboardShortcutsProvider";
@ -71,15 +70,13 @@ function Root() {
<TagsProvider>
<KeyboardShortcutsProvider>
<FilePreviewProvider>
<BookmarksProvider>
<header>
<NavBar/>
</header>
<ErrorBoundary>
<Outlet/>
</ErrorBoundary>
<Footer/>
</BookmarksProvider>
<header>
<NavBar/>
</header>
<ErrorBoundary>
<Outlet/>
</ErrorBoundary>
<Footer/>
</FilePreviewProvider>
</KeyboardShortcutsProvider>
</TagsProvider>
@ -87,12 +84,10 @@ function Root() {
</QueryProvider>
}
export const routes = createRoutesFromElements(<Route
const router = createBrowserRouter(createRoutesFromElements(<Route
path='/'
element={<Root/>}
// 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={<ThemeProvider><PageNotFound/></ThemeProvider>}
errorElement={<PageNotFound/>}
>
<Route index element={<ErrorBoundary><DashboardPage/></ErrorBoundary>}/>
<Route path='search/*' element={<ErrorBoundary><DashboardPage/></ErrorBoundary>}/>
@ -120,14 +115,10 @@ export const routes = createRoutesFromElements(<Route
<Route path='map/*' element={<ErrorBoundary><MapRoute/></ErrorBoundary>}/>
<Route path='zim/*' element={<ErrorBoundary><ZimRoute/></ErrorBoundary>}/>
<Route path='playlists/*' element={<ErrorBoundary><PlaylistsRoute/></ErrorBoundary>}/>
<Route path='repos/*' element={<ErrorBoundary><ReposRoute/></ErrorBoundary>}/>
<Route path='files/*' element={<ErrorBoundary><FilesRoute/></ErrorBoundary>}/>
<Route path='flasher/*' element={<ErrorBoundary><FlasherRoute/></ErrorBoundary>}/>
{/* Unknown URLs render inside Root, with the navigation bar and theme. */}
<Route path='*' element={<PageNotFound/>}/>
</Route>);
const router = createBrowserRouter(routes);
<Route path='ai/*' element={<ErrorBoundary><AiRoute/></ErrorBoundary>}/>
</Route>));
export default function App() {
useEventsInterval();

View file

@ -1,52 +0,0 @@
import React from 'react';
import {render, screen} from '@testing-library/react';
import {createMemoryRouter, RouterProvider} from 'react-router';
import {routes} from './App';
import {StatusProvider} from './hooks/customHooks';
import {FileWorkerStatusProvider} from './contexts/FileWorkerStatusContext';
import {MediaContextProvider} from './contexts/contexts';
// Jest does not transform CSS inside node_modules (see transformIgnorePatterns).
jest.mock('@mantine/core/styles.css', () => ({}));
jest.mock('@mantine/notifications/styles.css', () => ({}));
// Routes this test does not visit, whose libraries jsdom/Jest cannot load (maplibre needs
// browser APIs; esptool-js is ESM-only).
jest.mock('./components/Map', () => ({MapRoute: () => null}));
jest.mock('./components/Flasher', () => ({FlasherRoute: () => null}));
describe('unknown routes', () => {
beforeEach(() => {
// resetMocks clears setupTests' implementation; the theme and media providers read it.
window.matchMedia.mockImplementation(query => ({
matches: false,
media: query,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
global.fetch = jest.fn(() => Promise.resolve({
ok: true,
status: 200,
headers: {get: () => 'application/json'},
json: () => Promise.resolve({tags: []}),
text: () => Promise.resolve('{}'),
}));
});
it('renders Page Not Found inside the app layout', async () => {
const router = createMemoryRouter(routes, {initialEntries: ['/no-such-page']});
// The providers App wraps around the router.
render(<StatusProvider>
<FileWorkerStatusProvider>
<MediaContextProvider>
<RouterProvider router={router}/>
</MediaContextProvider>
</FileWorkerStatusProvider>
</StatusProvider>);
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();
});
});

View file

@ -5,7 +5,6 @@ import {
PageContainer,
RefreshHeader,
SearchResultsInput,
useLocalStorage,
useTitle
} from "./components/Common";
import React, {useContext, useState} from "react";
@ -15,7 +14,6 @@ import {DownloadMenu} from "./components/Download";
import {
ActionInput,
Button,
ButtonGroup,
Divider,
Header,
Icon,
@ -252,8 +250,6 @@ export function Getters() {
</div>
<span className='wrolpi-or-label'>Or</span>
</div>
{status?.flags && gettersDisabled &&
<p>Download and Upload are available after your files are refreshed.</p>}
</Panel>;
let getterModal;
@ -355,14 +351,7 @@ export function DashboardStatus() {
}
function DashboardRecentFiles() {
// Show only the files the user is part way through.
const [continueOnly, setContinueOnly] = useLocalStorage('dashboard_recent_continue', false);
const {searchFiles, loading, fetchFiles} = useSearchRecentFiles(continueOnly);
let files = <FileCards files={searchFiles} loading={loading}/>;
if (continueOnly && searchFiles && searchFiles.length === 0) {
files = <p>Nothing in progress. A video, audio file, ebook or comic you stop part way through will be here.</p>;
}
const {searchFiles, loading, fetchFiles} = useSearchRecentFiles();
return <Panel>
<RefreshHeader
@ -370,15 +359,7 @@ function DashboardRecentFiles() {
popupContents='Fetch the most recent files again'
onRefresh={fetchFiles}
/>
<ButtonGroup>
<Button size='small' variant={continueOnly ? 'default' : 'filled'} onClick={() => setContinueOnly(false)}>
All
</Button>
<Button size='small' variant={continueOnly ? 'filled' : 'default'} onClick={() => setContinueOnly(true)}>
Continue
</Button>
</ButtonGroup>
<div style={{marginTop: '1em'}}>{files}</div>
<FileCards files={searchFiles} loading={loading}/>
</Panel>
}

View file

@ -21,11 +21,11 @@ jest.mock('./hooks/customHooks', () => ({
useWROLMode: () => false,
}));
function renderAtUrl(url, flags = {refresh_complete: true}) {
function renderAtUrl(url) {
// test-utils' render() wraps content in a BrowserRouter (not MemoryRouter),
// so point the jsdom location at the desired URL before rendering.
window.history.pushState({}, '', url);
const status = {flags};
const status = {flags: {refresh_complete: true}};
return render(
<StatusContext.Provider value={{status}}>
<Getters/>
@ -67,25 +67,6 @@ describe('DashboardPage Getters deep-link', () => {
});
});
describe('DashboardPage Getters before the first refresh', () => {
const note = /available after your files are refreshed/i;
it('explains why Download and Upload are disabled', () => {
renderAtUrl('/', {refresh_complete: false});
expect(screen.getByText(note)).toBeInTheDocument();
});
it('says nothing once the refresh has completed', () => {
renderAtUrl('/');
expect(screen.queryByText(note)).toBeNull();
});
it('says nothing before the status has loaded', () => {
renderAtUrl('/', null);
expect(screen.queryByText(note)).toBeNull();
});
});
// The DownloadMenu's seed-object construction (joining initialUrls with
// newlines into the `download` prop's `urls` field) is exercised in the
// browser via manual verification, not unit-tested here — reaching into the

View file

@ -9,7 +9,7 @@ function eventToast(title, description, type = 'info', time = 5000, onClick = nu
toast({type: type, title: title, description: description, time: time, onClick: onClick});
}
export function handleEvents(events) {
function handleEvents(events) {
const newestEvents = {};
events.forEach(e => {
@ -63,17 +63,13 @@ export function handleEvents(events) {
}
if (event === 'user_notify_message') {
// Only a shared page carries a URL; other notifications (jobs, transcodes) are text.
if (url) {
eventToast(
message,
'Click here to view the shared page',
'info',
10_000,
() => window.open(url, '_self'));
} else {
eventToast(message, null, 'info', 10_000);
}
console.log(message, url);
eventToast(
message,
'Click here to view the shared page',
'info',
10_000,
() => window.open(url, '_self'));
}
if (event === 'deleted') {

View file

@ -1,33 +0,0 @@
import {handleEvents} from './Events';
import {toast} from './components/ui';
jest.mock('./components/ui', () => ({toast: jest.fn()}));
const notify = (url) => ({event: 'user_notify_message', message: 'Transcoding video.mp4', dt: '2026-10-12', url});
afterEach(() => {
toast.mockClear();
});
describe('user_notify_message', () => {
it('a notification without a URL is plain text, not a link', () => {
handleEvents([notify(null)]);
expect(toast).toHaveBeenCalledTimes(1);
const options = toast.mock.calls[0][0];
expect(options.title).toBe('Transcoding video.mp4');
expect(options.description).toBeFalsy();
expect(options.onClick).toBeFalsy();
});
it('a shared page opens its URL when clicked', () => {
const open = jest.spyOn(window, 'open').mockImplementation(() => null);
handleEvents([notify('/archive/1')]);
const options = toast.mock.calls[0][0];
expect(options.description).toBe('Click here to view the shared page');
options.onClick();
expect(open).toHaveBeenCalledWith('/archive/1', '_self');
open.mockRestore();
});
});

View file

@ -1,84 +0,0 @@
import {
flasherSearch, getConfigBackups, getDownloaders, getInventoryBackups, getMapSearchStatus, searchMap,
} from './api';
// A helper that fails must say so. These used to turn a non-OK response into an empty value
// ([], {dates: []}, {results: []}, ...), so every consumer rendered "nothing here" for a server
// error. They now return undefined; an empty value only ever comes from the server.
jest.mock('./components/ui', () => ({
...jest.requireActual('./components/ui'),
toast: jest.fn(),
}));
const response = (ok, body = {}) => {
const r = {
ok,
status: ok ? 200 : 500,
json: async () => body,
text: async () => JSON.stringify(body),
};
r.clone = () => r;
return r;
};
let originalFetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
});
const failWith = () => {
global.fetch = jest.fn(() => Promise.resolve(response(false, {error: 'boom'})));
};
const succeedWith = (body) => {
global.fetch = jest.fn(() => Promise.resolve(response(true, body)));
};
describe('on a non-OK response, the helper returns undefined', () => {
test.each([
['getConfigBackups', () => getConfigBackups('wrolpi.yaml')],
['getInventoryBackups', () => getInventoryBackups('food')],
['getDownloaders', () => getDownloaders()],
['searchMap', () => searchMap('camp')],
['getMapSearchStatus', () => getMapSearchStatus()],
['flasherSearch', () => flasherSearch(null, null)],
])('%s', async (_name, call) => {
failWith();
expect(await call()).toBeUndefined();
});
});
describe('a confirmed empty result is still empty', () => {
test('getConfigBackups', async () => {
succeedWith({dates: []});
expect(await getConfigBackups('wrolpi.yaml')).toEqual({dates: []});
});
test('getInventoryBackups', async () => {
succeedWith({dates: []});
expect(await getInventoryBackups('food')).toEqual([]);
});
test('getDownloaders', async () => {
succeedWith({downloaders: []});
expect(await getDownloaders()).toEqual({downloaders: []});
});
test('searchMap', async () => {
succeedWith({results: [], total: 0});
expect(await searchMap('camp')).toEqual({results: [], total: 0});
});
test('getMapSearchStatus', async () => {
succeedWith({indexed: [], missing: []});
expect(await getMapSearchStatus()).toEqual({indexed: [], missing: []});
});
test('flasherSearch', async () => {
succeedWith({file_groups: [], totals: {file_groups: 0}});
expect(await flasherSearch(null, null)).toEqual([[], 0]);
});
});

View file

@ -7,7 +7,6 @@ import {
COLLECTIONS_API,
DEFAULT_LIMIT,
Downloaders,
REPOS_API,
VIDEOS_API,
ZIM_API
} from "./components/Vars";
@ -407,7 +406,6 @@ export async function getSettings() {
nav_color: content.nav_color,
media_directory: content.media_directory,
playlists_destination: content.playlists_destination,
repos_destination: content.repos_destination,
require_cookies_unlocked: content.require_cookies_unlocked,
require_media_mounted: content.require_media_mounted,
save_ffprobe_json: content.save_ffprobe_json,
@ -524,7 +522,7 @@ export async function getConfigBackups(fileName) {
if (response.ok) {
return await response.json();
}
// Undefined, not {dates: []}, so the modal can tell a failed fetch from having no backups.
return {dates: []};
}
export async function postConfigBackupPreview(fileName, backupDate, mode) {
@ -722,7 +720,7 @@ export async function getInventoryBackups(slug) {
if (response.ok) {
return (await response.json())['dates'];
}
// Undefined, not [], so the modal can tell a failed fetch from having no backups.
return [];
}
export async function postInventoryRestorePreview(slug, backupDate, mode) {
@ -929,21 +927,6 @@ export async function getCollectionTagInfo(collectionId, tagName) {
}
}
// Tag any Collection (and optionally move its directory).
export async function tagCollection(collectionId, tagName, directory) {
const body = {tag_name: tagName};
if (directory) {
body['directory'] = directory;
}
const response = await apiPost(`${COLLECTIONS_API}/${collectionId}/tag`, body);
if (response.ok) {
return await response.json();
}
const message = await getErrorMessage(response, 'Unable to tag. See server logs.');
toast({type: 'error', title: 'Tag Error', description: message, time: 5000});
throw new Error(message);
}
export async function tagDomain(domainId, tagName, directory) {
const body = {
tag_name: tagName,
@ -1276,15 +1259,12 @@ export async function startDownloads() {
}
export async function getDownloaders() {
// Not toasting because this will happen often. Undefined on any failure, not an empty list, so
// the form can say the downloaders could not be fetched.
try {
const response = await apiGet(`${API_URI}/downloaders`);
if (response.ok) {
return await response.json();
}
// Not toasting because this will happen often.
return await response.json();
} catch (e) {
// The API is down; reported as undefined below.
return {downloaders: []};
}
}
@ -1305,7 +1285,7 @@ export async function deleteDownload(downloadId) {
}
}
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep, inProgress) {
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep) {
const body = {search_str: searchStr, offset: parseInt(offset), limit: parseInt(limit), any_tag: anyTag};
if (suffix) {
body['suffix'] = suffix;
@ -1340,9 +1320,6 @@ export async function filesSearch(offset, limit, searchStr, mimetypes, model, ta
if (deep) {
body['deep'] = true;
}
if (inProgress) {
body['in_progress'] = true;
}
console.info('searching files', body);
const response = await apiPost(`${API_URI}/files/search`, body);
@ -1374,7 +1351,7 @@ export async function flasherSearch(chip, path) {
}
const message = await getErrorMessage(response, 'Cannot search firmware. See server logs.');
toast({type: 'error', title: 'Unable to search firmware', description: message, time: 5000});
// Undefined, not [null, null], so the picker can tell a failed search from finding nothing.
return [null, null];
}
export async function getFlasherConfigs() {
@ -1384,9 +1361,7 @@ export async function getFlasherConfigs() {
const data = await response.json();
return data['configurations'] || [];
}
// Undefined, not [], so the page can tell a failed fetch from an empty list.
const message = await getErrorMessage(response, 'Could not get saved firmware configurations');
toast({type: 'error', title: 'Flasher Error', description: message, time: 5000});
return [];
}
export async function saveFlasherConfig(name, files, eraseAll) {
@ -1681,8 +1656,7 @@ export async function searchMap(query, limit = 12, offset = 0, lat = null, lon =
}
const response = await apiGet(url);
if (!response.ok) {
// Undefined, not {results: []}, so a failed search is not shown as finding nothing.
return undefined;
return {results: [], total: 0};
}
return await response.json();
}
@ -1690,8 +1664,7 @@ export async function searchMap(query, limit = 12, offset = 0, lat = null, lon =
export async function getMapSearchStatus() {
const response = await apiGet(`${API_URI}/map/search/status`);
if (!response.ok) {
// Undefined, not {indexed: [], missing: []}, which read as "no maps are installed".
return undefined;
return {indexed: [], missing: []};
}
return await response.json();
}
@ -2071,7 +2044,6 @@ export async function searchSuggestions(search_str) {
domains: content.domains,
authors: content.authors || [],
subjects: content.subjects || [],
repos: content.repos || [],
}
} else {
console.error('Failed to get file search suggestions!');
@ -2110,8 +2082,8 @@ export async function searchEstimateZims(search_str, tagNames) {
}
}
export async function searchEstimateOthers(tagNames, searchStr) {
const body = {tag_names: tagNames, search_str: searchStr || null};
export async function searchEstimateOthers(tagNames) {
const body = {tag_names: tagNames};
const response = await apiPost(`${API_URI}/search_other_estimates`, body);
if (response.ok) {
const content = await response.json();
@ -2121,8 +2093,8 @@ export async function searchEstimateOthers(tagNames, searchStr) {
}
}
export async function searchChannels(tagNames, searchStr) {
const body = {tag_names: tagNames, search_str: searchStr};
export async function searchChannels(tagNames) {
const body = {tag_names: tagNames};
const response = await apiPost(`${API_URI}/videos/channels/search`, body);
if (response.ok) {
const content = await response.json();
@ -2132,16 +2104,6 @@ export async function searchChannels(tagNames, searchStr) {
}
}
// Collections of `kind` whose name matches `searchStr` and/or which have any of the tags.
export async function searchCollections(kind, tagNames, searchStr) {
const body = {kind, tag_names: tagNames, search_str: searchStr};
const response = await apiPost(`${COLLECTIONS_API}/search`, body);
if (response.ok) {
const content = await response.json();
return {collections: content.collections};
}
}
export async function getOutdatedZims() {
const response = await apiGet(`${API_URI}/zim/outdated`);
if (response.status === 200) {
@ -2477,154 +2439,32 @@ export async function setPlaylistTag(playlistId, tagName, directory) {
throw Error(message);
}
// Bookmarks: a tree of nodes, a node with `children` being a directory.
export async function getBookmarks() {
const response = await apiGet(`${API_URI}/bookmarks`);
// --- AI assistant ---
export async function getAiCatalog() {
const response = await apiGet(`${API_URI}/ai/manage/catalog`);
if (response.ok) {
return (await response.json())['bookmarks'];
return await response.json();
}
throw new Error('Could not fetch bookmarks');
const message = await getErrorMessage(response, 'Failed to fetch AI catalog.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
async function bookmarksModify(request, fallbackMessage) {
const response = await request;
export async function postAiSettings(settings) {
const response = await apiPost(`${API_URI}/ai/manage/settings`, settings);
if (response.ok) {
return response.status === 204 ? null : await response.json();
return await response.json();
}
const message = await getErrorMessage(response, fallbackMessage);
toast({type: 'error', title: 'Bookmarks Error', description: message, time: 5000});
throw new Error(message);
}
export async function addBookmark({name, url, new_tab = false, parent_id = null, position = null}) {
const body = {name, url, new_tab, parent_id, position};
return await bookmarksModify(apiPost(`${API_URI}/bookmarks`, body), 'Could not add bookmark');
}
export async function addBookmarkDirectory({name, parent_id = null, position = null}) {
const body = {name, parent_id, position};
return await bookmarksModify(apiPost(`${API_URI}/bookmarks/directory`, body), 'Could not add directory');
}
export async function updateBookmark(nodeId, {name, url, new_tab}) {
const body = {name, url, new_tab};
return await bookmarksModify(apiPut(`${API_URI}/bookmarks/${nodeId}`, body), 'Could not update bookmark');
}
export async function moveBookmark(nodeId, parent_id, position = null) {
const body = {parent_id, position};
return await bookmarksModify(apiPost(`${API_URI}/bookmarks/${nodeId}/move`, body), 'Could not move bookmark');
}
export async function deleteBookmark(nodeId) {
return await bookmarksModify(apiDelete(`${API_URI}/bookmarks/${nodeId}`), 'Could not delete bookmark');
}
async function reposError(response, fallbackMessage) {
const message = await getErrorMessage(response, fallbackMessage);
toast({type: 'error', title: 'Repos Error', description: message, time: 5000});
const message = await getErrorMessage(response, 'Failed to save AI settings.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
export async function fetchRepos() {
const response = await apiGet(REPOS_API);
export async function getChatModes() {
const response = await apiGet(`${API_URI}/chat/modes`);
if (response.ok) {
return (await response.json())['repos'];
return (await response.json())['modes'];
}
await reposError(response, 'Failed to fetch repos.');
}
export async function getRepo(repoId) {
const response = await apiGet(`${REPOS_API}/${repoId}`);
if (response.ok) {
return (await response.json())['repo'];
}
await reposError(response, 'Failed to fetch repo.');
}
// `repo`: {url, directory?, name?, tag_name?, frequency?, mode?, branch?, description?}
export async function createRepo(repo) {
const response = await apiPost(REPOS_API, repo);
if (response.ok) {
return (await response.json())['repo'];
}
await reposError(response, 'Failed to add repo.');
}
// `changes`: {description?, frequency?, mode?, branch?}. An empty branch follows the remote's default branch.
export async function updateRepo(repoId, changes) {
const response = await apiPut(`${REPOS_API}/${repoId}`, changes);
if (response.ok) {
return (await response.json())['repo'];
}
await reposError(response, 'Failed to update repo.');
}
export async function deleteRepo(repoId, deleteFiles = false) {
const response = await apiDelete(`${REPOS_API}/${repoId}?delete_files=${deleteFiles ? 'true' : 'false'}`);
if (!response.ok) {
await reposError(response, 'Failed to delete repo.');
}
}
// Download the repo's updates now.
export async function updateRepoNow(repoId) {
const response = await apiPost(`${REPOS_API}/${repoId}/update`);
if (!response.ok) {
await reposError(response, 'Failed to start the repo update.');
}
}
// One directory of a repo's files: {path, entries: [{name, path, is_dir, size}], readme_path}.
export async function getRepoTree(repoId, path) {
const query = path ? `?path=${encodeURIComponent(path)}` : '';
const response = await apiGet(`${REPOS_API}/${repoId}/tree${query}`);
if (response.ok) {
return await response.json();
}
await reposError(response, 'Failed to list the repo files.');
}
// Repos matching the search (name and README) and/or the tags. Each has an escaped `readme_headline`.
export async function searchRepos(searchStr, tagNames) {
const body = {search_str: searchStr || null, tag_names: tagNames || []};
const response = await apiPost(`${REPOS_API}/search`, body);
if (response.ok) {
const content = await response.json();
return {repos: content.repos, total: content.totals.repos};
}
await reposError(response, 'Failed to search repos.');
}
// The commits of a repo's checked out branch, newest first: {commits: [{sha, author, date, message}], total}.
export async function getRepoLog(repoId, offset = 0, limit = 50) {
const response = await apiGet(`${REPOS_API}/${repoId}/log?limit=${limit}&offset=${offset}`);
if (response.ok) {
return await response.json();
}
await reposError(response, 'Failed to fetch the repo history.');
}
// A ZIP of a repo's checked out files; the browser downloads it.
export function repoArchiveURL(repoId) {
return `${REPOS_API}/${repoId}/archive.zip`;
}
// What importing the git clone in `directory` (relative to the media directory) would do. Changes nothing.
export async function inspectRepoImport(directory, name, tagName, url) {
const body = {directory, name: name || null, tag_name: tagName || null, url: url || null};
const response = await apiPost(`${REPOS_API}/import/inspect`, body);
if (response.ok) {
return {inspection: (await response.json())['inspection']};
}
return {error: await getErrorMessage(response, 'Cannot import this directory.')};
}
// Import an existing git clone as a Repo; it stays where it is. `repo`: {directory, confirm, url?, name?, ...}
export async function importRepo(repo) {
const response = await apiPost(`${REPOS_API}/import`, repo);
if (response.ok) {
return (await response.json())['repo'];
}
await reposError(response, 'Failed to import the repo.');
const message = await getErrorMessage(response, 'Failed to fetch chat modes.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}

216
app/src/components/Ai.js Normal file
View file

@ -0,0 +1,216 @@
import React, {useContext, useEffect, useState} from 'react';
import {Route, Routes} from 'react-router';
import {getAiCatalog, postAiSettings, postDownload} from '../api';
import {getServiceStatus, startService, stopService} from '../api/controller';
import {AiChatProvider} from '../contexts/AiChatContext';
import {StatusContext} from '../contexts/contexts';
import {AiChat} from './AiChat';
import {APIButton, humanFileSize, InfoMessage, PageContainer, TabLinks, useTitle, WROLModeMessage} from './Common';
import {SortableTable} from './SortableTable';
import {Button, Divider, Group, Header, NumberInput, Select, Table, Text, Toggle, toast} from './ui';
import {Downloaders} from './Vars';
// The Controller's name for llama-server: the compose service in docker, the systemd unit natively.
export const llmServiceName = (dockerized) => dockerized ? 'llm' : 'wrolpi-llm';
export function AiModelRow({model, onDownload}) {
const {name, tier, size, description, downloaded, active} = model;
return <Table.Row>
<Table.Cell>
<Text fw={active ? 700 : undefined}>{name}{active ? ' (active)' : ''}</Text>
{description && <Text size='sm' c='dimmed'>{description}</Text>}
</Table.Cell>
<Table.Cell>{tier}</Table.Cell>
<Table.Cell>{humanFileSize(size)}</Table.Cell>
<Table.Cell>
{downloaded
? <Button size='xs' icon='check' disabled role='save'>
{tier === 'custom' ? 'On disk' : 'Downloaded'}
</Button>
: <APIButton size='xs' icon='download' color='violet' onClick={() => onDownload(model)}>
Download
</APIButton>}
</Table.Cell>
</Table.Row>;
}
export function ManageAi() {
const [catalog, setCatalog] = useState(null);
const [serviceStatus, setServiceStatus] = useState(null);
const [pendingSave, setPendingSave] = useState(false);
const {status} = useContext(StatusContext);
const serviceName = llmServiceName(status?.dockerized);
const fetchCatalog = async () => {
try {
const data = await getAiCatalog();
if (data) {
setCatalog(data);
}
} catch (e) {
console.error(e);
}
};
const fetchService = async () => {
try {
setServiceStatus(await getServiceStatus(serviceName));
} catch (e) {
// The service is unknown until AI is installed; not an error worth toasting.
setServiceStatus(null);
}
};
useEffect(() => {
fetchCatalog();
fetchService();
const interval = setInterval(() => {
fetchCatalog();
fetchService();
}, 30000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serviceName]);
const handleDownload = async (model) => {
await postDownload({
urls: [model.url],
downloader: Downloaders.AiModel,
destination: 'ai/models',
});
toast({
type: 'success',
title: 'Download started',
description: 'Monitor progress on the Downloads page.',
time: 5000,
});
};
const saveSettings = async (settings) => {
setPendingSave(true);
try {
await postAiSettings(settings);
await fetchCatalog();
} finally {
setPendingSave(false);
}
};
const handleServiceButton = async () => {
if (serviceStatus?.status === 'running') {
await stopService(serviceName);
} else {
await startService(serviceName);
}
await fetchService();
};
if (!catalog) {
return <WROLModeMessage content='Cannot modify AI'/>;
}
const {models, recommended_tier, total_ram, slow_hardware, disk_usage, enabled, active_model} = catalog;
const downloadedModels = models.filter(i => i.downloaded);
const modelHeaders = [
{key: 'name', text: 'Model', sortBy: i => i.name.toLowerCase()},
{key: 'tier', text: 'Tier', sortBy: i => i.tier},
{key: 'size', text: 'Size', sortBy: i => i.size || 0},
{key: 'actions', text: '', sortBy: null},
];
return <>
<WROLModeMessage content='Cannot modify AI'/>
{slow_hardware && <InfoMessage storageName='hint_ai_slow_hardware'>
This hardware will run the assistant slowly. Answers may take minutes; the smallest
model is recommended.
</InfoMessage>}
<Header as='h3'>AI Assistant</Header>
<Group gap='lg'>
<Toggle
label='Enabled'
checked={enabled === true}
disabled={pendingSave || !active_model}
onChange={e => saveSettings({enabled: e.target.checked})}
/>
<Select
label='Active Model'
data={downloadedModels.map(i => ({value: i.name, label: i.name}))}
value={active_model || null}
placeholder={downloadedModels.length ? 'Select a model' : 'Download a model below'}
disabled={pendingSave || !downloadedModels.length}
onChange={value => value && saveSettings({active_model: value})}
/>
<NumberInput
label='Idle unload (minutes)'
min={1}
value={catalog.idle_unload_minutes}
disabled={pendingSave}
onChange={value => value && saveSettings({idle_unload_minutes: value})}
/>
</Group>
{enabled && <Group gap='xs' style={{marginTop: '1em'}}>
<Text>Service: {serviceStatus?.status || 'unknown'}</Text>
<APIButton
size='xs'
color={serviceStatus?.status === 'running' ? undefined : 'violet'}
role={serviceStatus?.status === 'running' ? 'danger' : undefined}
onClick={handleServiceButton}
>
{serviceStatus?.status === 'running' ? 'Stop' : 'Start'}
</APIButton>
</Group>}
<Divider/>
<Header as='h3'>Model Catalog</Header>
<Text size='sm'>
This device has {humanFileSize(total_ram)} of RAM;
the <b>{recommended_tier}</b> tier is recommended.
Models use {humanFileSize(disk_usage)} in <b>ai/models/</b>.
</Text>
<SortableTable
tableHeaders={modelHeaders}
data={models}
rowFunc={(model) => <AiModelRow key={model.name} model={model} onDownload={handleDownload}/>}
rowKey='name'
defaultSortColumn='size'
/>
<InfoMessage storageName='hint_ai_models'>
<p>The AI assistant runs entirely on this WROLPi — no internet is needed after a model
is downloaded. Download a model, select it, then enable the assistant. Monitor
download progress on the <b>Downloads</b> page.</p>
<p>You can also copy your own GGUF file into <b>ai/models/</b>; it will appear here as a
<b>custom</b> model the next time this page loads. Use a plain ASCII file name without
<b>#</b>, quotes, or <b>..</b>. The model must support tool calling (Qwen3, Llama 3.x,
and Mistral instruct models do). Selecting a custom model resets the context size to the
default; set <b>context_size</b> in <b>ai.yaml</b> to override it.</p>
</InfoMessage>
</>;
}
export function AiRoute() {
useTitle('AI');
const links = [
{text: 'Chat', to: '/ai', key: 'chat', end: true},
{text: 'Manage', to: '/ai/manage', key: 'manage'},
];
// The provider wraps both tabs so the conversation survives visiting Manage.
return <AiChatProvider>
<PageContainer>
<TabLinks links={links}/>
<Routes>
<Route path='/' exact element={<AiChat/>}/>
<Route path='manage' exact element={<ManageAi/>}/>
</Routes>
</PageContainer>
</AiChatProvider>;
}

View file

@ -0,0 +1,130 @@
import React from 'react';
import {fireEvent, screen, waitFor} from '@testing-library/react';
import {render} from '../test-utils';
import {AiModelRow, ManageAi, llmServiceName} from './Ai';
import {Table} from './ui';
jest.mock('../api', () => ({
getAiCatalog: jest.fn(),
postAiSettings: jest.fn().mockResolvedValue({}),
postDownload: jest.fn().mockResolvedValue({}),
}));
jest.mock('../api/controller', () => ({
getServiceStatus: jest.fn().mockResolvedValue({name: 'wrolpi-llm', status: 'stopped'}),
startService: jest.fn().mockResolvedValue({}),
stopService: jest.fn().mockResolvedValue({}),
}));
const {getAiCatalog, postAiSettings, postDownload} = require('../api');
const catalogFixture = (overrides = {}) => ({
models: [
{
name: 'Qwen3-1.7B-Q4_K_M.gguf', tier: 'small', size: 1_200_000_000,
url: 'https://cdn.example.com/ai/Qwen3-1.7B-Q4_K_M.gguf',
description: 'Small model.', downloaded: true, active: true,
},
{
name: 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf', tier: 'medium', size: 2_600_000_000,
url: 'https://cdn.example.com/ai/Qwen3-4B-Instruct-2507-Q4_K_M.gguf',
description: 'Medium model.', downloaded: false, active: false,
},
],
catalog_source: 'bundled',
total_ram: 8 * 1024 ** 3,
recommended_tier: 'medium',
slow_hardware: false,
models_directory: '/media/wrolpi/ai/models',
disk_usage: 1_200_000_000,
enabled: false,
active_model: 'Qwen3-1.7B-Q4_K_M.gguf',
idle_unload_minutes: 15,
context_size: null,
...overrides,
});
describe('llmServiceName', () => {
it('is the compose service in docker, the systemd unit natively', () => {
expect(llmServiceName(true)).toBe('llm');
expect(llmServiceName(false)).toBe('wrolpi-llm');
});
});
describe('AiModelRow', () => {
const renderRow = (model, onDownload = jest.fn()) => render(
<Table><Table.Body><AiModelRow model={model} onDownload={onDownload}/></Table.Body></Table>);
it('shows a downloaded, active model without a download button', () => {
renderRow(catalogFixture().models[0]);
expect(screen.getByText(/Qwen3-1.7B-Q4_K_M.gguf \(active\)/)).toBeInTheDocument();
expect(screen.getByText('Downloaded')).toBeInTheDocument();
expect(screen.queryByText('Download')).not.toBeInTheDocument();
});
it('offers a download for a model that is not downloaded', async () => {
const onDownload = jest.fn();
renderRow(catalogFixture().models[1], onDownload);
fireEvent.click(screen.getByText('Download'));
await waitFor(() => expect(onDownload).toHaveBeenCalled());
expect(onDownload.mock.calls[0][0].name).toBe('Qwen3-4B-Instruct-2507-Q4_K_M.gguf');
});
});
describe('ManageAi', () => {
beforeEach(() => {
jest.clearAllMocks();
getAiCatalog.mockResolvedValue(catalogFixture());
});
it('renders the catalog with the RAM recommendation', async () => {
render(<ManageAi/>);
await waitFor(() => expect(screen.getByText(/tier is recommended/)).toBeInTheDocument());
// The active model appears in both the Select and its catalog row.
expect(screen.getAllByText(/Qwen3-1.7B-Q4_K_M.gguf/).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/of RAM/)).toBeInTheDocument();
});
it('starts a model download with the ai_model downloader', async () => {
render(<ManageAi/>);
await waitFor(() => expect(screen.getByText('Download')).toBeInTheDocument());
fireEvent.click(screen.getByText('Download'));
await waitFor(() => expect(postDownload).toHaveBeenCalled());
expect(postDownload).toHaveBeenCalledWith({
urls: ['https://cdn.example.com/ai/Qwen3-4B-Instruct-2507-Q4_K_M.gguf'],
downloader: 'ai_model',
destination: 'ai/models',
});
});
it('saves the enabled toggle', async () => {
render(<ManageAi/>);
await waitFor(() => expect(screen.getByLabelText('Enabled')).toBeInTheDocument());
fireEvent.click(screen.getByLabelText('Enabled'));
await waitFor(() => expect(postAiSettings).toHaveBeenCalledWith({enabled: true}));
});
it('lists a custom GGUF from ai/models in the Active Model dropdown', async () => {
const custom = {
name: 'My-Custom-Model-Q8_0.gguf', tier: 'custom', size: 3_000_000_000, url: null,
description: 'Custom model found in ai/models/. Not from the WROLPi catalog.',
downloaded: true, active: false,
};
const fixture = catalogFixture();
getAiCatalog.mockResolvedValue({...fixture, models: [...fixture.models, custom]});
render(<ManageAi/>);
await waitFor(() => expect(screen.getByText('On disk')).toBeInTheDocument());
expect(screen.getByRole('cell', {name: 'custom'})).toBeInTheDocument();
fireEvent.click(screen.getByRole('textbox', {name: 'Active Model'}));
const option = await screen.findByRole('option', {name: 'My-Custom-Model-Q8_0.gguf'});
fireEvent.click(option);
await waitFor(() => expect(postAiSettings).toHaveBeenCalledWith({active_model: 'My-Custom-Model-Q8_0.gguf'}));
});
it('shows the slow-hardware warning on a Pi 4', async () => {
getAiCatalog.mockResolvedValue(catalogFixture({slow_hardware: true}));
render(<ManageAi/>);
await waitFor(() => expect(screen.getByText(/run the assistant slowly/)).toBeInTheDocument());
});
});

View file

@ -0,0 +1,154 @@
import React, {useEffect, useRef, useState} from 'react';
import {Link} from 'react-router';
import {getAiCatalog, getChatModes} from '../api';
import {useAiChat} from '../contexts/AiChatContext';
import {InfoMessage} from './Common';
import {Button, Group, Icon, Loader, Text, TextInput} from './ui';
const MODE_LABELS = {help: 'Help', research: 'Research', system: 'System'};
const MODE_DESCRIPTIONS = {
help: 'Learn how to use WROLPi',
research: 'Find and summarize library content',
system: 'Troubleshoot this WROLPi',
};
// Render an assistant message with WROLPi links clickable. The model only ever returns
// relative links (/videos/123), which the server computed — never external URLs.
export function LinkifiedText({content}) {
const parts = String(content || '').split(/(\/[a-zA-Z0-9_\-./?=&%#]+)/g);
return <span style={{whiteSpace: 'pre-wrap', overflowWrap: 'anywhere'}}>
{parts.map((part, i) => part.startsWith('/') && part.length > 1
? <Link key={i} to={part}>{part}</Link>
: part)}
</span>;
}
function ToolActivity({activity}) {
if (!activity || !activity.length) {
return null;
}
return <div>
{activity.map((item, i) => <Text key={i} size='sm' c='dimmed'>
{item.pending
? <Loader size='xs'/>
: <Icon name={item.success ? 'check' : 'warning circle'}/>}
{' '}using {String(item.tool || '').replace(/_/g, ' ')}
</Text>)}
</div>;
}
export function ChatMessage({message}) {
if (message.role === 'user') {
return <div style={{textAlign: 'right', margin: '0.5em 0'}}>
<span style={{
display: 'inline-block', padding: '0.5em 0.8em', borderRadius: 8,
background: 'var(--panel)', maxWidth: '85%', textAlign: 'left',
}}>{message.content}</span>
</div>;
}
return <div style={{margin: '0.5em 0'}}>
{message.status && <Text size='sm' c='dimmed'>{message.status}</Text>}
<ToolActivity activity={message.activity}/>
{message.content && <LinkifiedText content={message.content}/>}
{message.streaming && !message.content && !message.activity?.length && <Loader size='xs'/>}
{message.error && <Text style={{color: 'var(--danger)'}}>{message.error}</Text>}
</div>;
}
export function AiChat() {
const {mode, setMode, messages, sending, send, stop, disabledCode} = useAiChat();
const [modes, setModes] = useState([]);
const [slowHardware, setSlowHardware] = useState(false);
const [aiEnabled, setAiEnabled] = useState(null);
const [input, setInput] = useState('');
const bottomRef = useRef(null);
useEffect(() => {
getChatModes().then(i => i && setModes(i)).catch(console.error);
getAiCatalog().then(catalog => {
if (catalog) {
setSlowHardware(catalog.slow_hardware === true);
setAiEnabled(Boolean(catalog.enabled && catalog.active_model));
}
}).catch(console.error);
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView?.({behavior: 'smooth'});
}, [messages]);
const handleSend = async (content) => {
setInput('');
await send(content);
};
const notReady = aiEnabled === false || disabledCode === 'AI_DISABLED';
if (notReady) {
return <InfoMessage>
The AI assistant is not ready. Download a model and enable it on
the <Link to='/ai/manage'>Manage</Link> tab.
</InfoMessage>;
}
const suggestions = (modes.find(i => i.name === mode) || {}).suggestions || [];
return <>
{slowHardware && <InfoMessage storageName='hint_ai_chat_slow'>
This hardware runs the assistant slowly — answers can take minutes.
</InfoMessage>}
<Group gap='xs'>
{(modes.length ? modes.map(i => i.name) : Object.keys(MODE_LABELS)).map(name =>
<Button
key={name}
size='sm'
color={mode === name ? 'violet' : undefined}
aria-pressed={mode === name}
onClick={() => setMode(name)}
>
{MODE_LABELS[name] || name}
</Button>)}
{mode && <Text size='sm' c='dimmed'>{MODE_DESCRIPTIONS[mode]}</Text>}
</Group>
{!mode && <Text style={{marginTop: '1em'}}>
Pick a mode to start: <b>Help</b> teaches WROLPi, <b>Research</b> searches your
library, <b>System</b> troubleshoots this device.
</Text>}
{mode && <>
<div style={{minHeight: '40vh', margin: '1em 0'}}>
{messages.length === 0 && <Group gap='xs'>
{suggestions.map(suggestion =>
<Button key={suggestion} size='xs' onClick={() => handleSend(suggestion)}>
{suggestion}
</Button>)}
</Group>}
{messages.map((message, i) => <ChatMessage key={i} message={message}/>)}
<div ref={bottomRef}/>
</div>
<form onSubmit={e => {
e.preventDefault();
if (!sending) {
handleSend(input.trim());
}
}}>
<Group gap='xs' wrap='nowrap'>
<TextInput
style={{flexGrow: 1}}
placeholder='Ask the assistant…'
value={input}
disabled={sending}
onChange={e => setInput(e.target.value)}
aria-label='Chat message'
/>
{sending
? <Button role='cancel' onClick={stop}>Stop</Button>
: <Button type='submit' color='violet' disabled={!input.trim()}>Send</Button>}
</Group>
</form>
</>}
</>;
}

View file

@ -0,0 +1,121 @@
import React from 'react';
import {fireEvent, screen, waitFor} from '@testing-library/react';
import {render} from '../test-utils';
import {AiChatProvider} from '../contexts/AiChatContext';
import {parseSSEBlock} from '../sse';
import {AiChat, LinkifiedText} from './AiChat';
jest.mock('../api', () => ({
getChatModes: jest.fn().mockResolvedValue([
{name: 'help', suggestions: ['How do I download videos?']},
{name: 'research', suggestions: ['Find videos about canning']},
{name: 'system', suggestions: ['Why is my drive full?']},
]),
getAiCatalog: jest.fn().mockResolvedValue({
enabled: true, active_model: 'test.gguf', slow_hardware: false,
}),
}));
jest.mock('../sse', () => ({
...jest.requireActual('../sse'),
streamChat: jest.fn(),
}));
const {getAiCatalog, getChatModes} = require('../api');
const {streamChat} = require('../sse');
const renderChat = () => render(<AiChatProvider><AiChat/></AiChatProvider>);
describe('parseSSEBlock', () => {
it('parses an event and its JSON data', () => {
expect(parseSSEBlock('event: token\ndata: {"content": "hi"}'))
.toEqual({event: 'token', data: {content: 'hi'}});
expect(parseSSEBlock('')).toBeNull();
});
});
describe('LinkifiedText', () => {
it('makes relative WROLPi links clickable', () => {
render(<LinkifiedText content='See /videos/123 for details'/>);
const link = screen.getByRole('link', {name: '/videos/123'});
expect(link).toHaveAttribute('href', '/videos/123');
});
});
describe('AiChat', () => {
beforeEach(() => {
jest.clearAllMocks();
getChatModes.mockResolvedValue([
{name: 'help', suggestions: ['How do I download videos?']},
{name: 'research', suggestions: ['Find videos about canning']},
{name: 'system', suggestions: ['Why is my drive full?']},
]);
getAiCatalog.mockResolvedValue({enabled: true, active_model: 'test.gguf', slow_hardware: false});
});
it('starts with the mode picker; picking a mode shows its suggestion chips', async () => {
renderChat();
await waitFor(() => expect(screen.getByRole('button', {name: 'Research'})).toBeInTheDocument());
expect(screen.getByText(/Pick a mode/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: 'Research'}));
await waitFor(() => expect(screen.getByText('Find videos about canning')).toBeInTheDocument());
});
it('streams tokens and tool activity into the conversation', async () => {
streamChat.mockImplementation(async ({mode, messages}, onEvent) => {
expect(mode).toBe('research');
expect(messages[messages.length - 1]).toEqual({role: 'user', content: 'find canning'});
onEvent('tool_call', {tool: 'search_files', args: {search_str: 'canning', kind: 'video'}});
onEvent('tool_result', {tool: 'search_files', success: true});
onEvent('token', {content: 'One video: '});
onEvent('token', {content: '/videos/123'});
onEvent('done', {content: 'One video: /videos/123'});
});
renderChat();
await waitFor(() => expect(screen.getByRole('button', {name: 'Research'})).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', {name: 'Research'}));
fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'find canning'}});
fireEvent.click(screen.getByText('Send'));
await waitFor(() => expect(screen.getByText(/using search files/)).toBeInTheDocument());
await waitFor(() => expect(screen.getByRole('link', {name: '/videos/123'})).toBeInTheDocument());
expect(streamChat).toHaveBeenCalledTimes(1);
});
it('switching modes clears the conversation', async () => {
streamChat.mockImplementation(async (_, onEvent) => {
onEvent('done', {content: 'An answer.'});
});
renderChat();
await waitFor(() => expect(screen.getByRole('button', {name: 'Help'})).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', {name: 'Help'}));
fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'hello'}});
fireEvent.click(screen.getByText('Send'));
await waitFor(() => expect(screen.getByText('An answer.')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', {name: 'System'}));
await waitFor(() => expect(screen.queryByText('An answer.')).not.toBeInTheDocument());
});
it('shows an error event without crashing the conversation', async () => {
streamChat.mockImplementation(async (_, onEvent) => {
onEvent('error', {message: 'The AI failed to answer.'});
});
renderChat();
await waitFor(() => expect(screen.getByRole('button', {name: 'Help'})).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', {name: 'Help'}));
fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'hello'}});
fireEvent.click(screen.getByText('Send'));
await waitFor(() => expect(screen.getByText('The AI failed to answer.')).toBeInTheDocument());
});
it('points at the Manage tab when AI is not enabled', async () => {
getAiCatalog.mockResolvedValue({enabled: false, active_model: '', slow_hardware: false});
renderChat();
await waitFor(() => expect(screen.getByText(/not ready/)).toBeInTheDocument());
expect(screen.getByRole('link', {name: 'Manage'})).toHaveAttribute('href', '/ai/manage');
});
});

View file

@ -12,10 +12,8 @@ import {
import {Header, Loading, Panel, Statistic, StatisticGroup} from "./ui";
import {useStatistics} from "../hooks/customHooks";
import {CalculatorsPage} from "./Calculators";
import {BookmarksPage} from "./Bookmarks";
// `statistics` follows useStatistics: null = pending, undefined = fetch failed.
export function StatisticsPage() {
function StatisticsPage() {
useTitle('Statistics');
const {statistics} = useStatistics();
@ -27,7 +25,7 @@ export function StatisticsPage() {
</>
}
if (statistics && statistics['global_statistics']) {
if (statistics['global_statistics']) {
let {global_statistics, file_statistics} = statistics;
const {
archive_count,
@ -106,7 +104,6 @@ export function MoreRoute(props) {
<Routes>
<Route path='calculators' element={<CalculatorsPage/>}/>
<Route path='statistics' exact element={<StatisticsPage/>}/>
<Route path='bookmarks' element={<BookmarksPage/>}/>
</Routes>
</PageContainer>
}

View file

@ -1,453 +0,0 @@
import React, {useState} from 'react';
import {Link} from 'react-router';
import {IconBookmark} from '@tabler/icons-react';
import {Button, Group, Header, Icon, Menu, Message, Modal, Panel, Select, Table, Text, TextInput, Toggle} from './ui';
import {APIButton, PageContainer, useTitle} from './Common';
import {isBookmarkDirectory, resolveBookmarkUrl, useBookmarks} from '../contexts/BookmarksContext';
import {addBookmark, addBookmarkDirectory, deleteBookmark, moveBookmark, updateBookmark} from '../api';
export const EDIT_BOOKMARKS_PATH = '/more/bookmarks';
/**
* The props that make a Menu.Item open a bookmark: an in-app Link for a path, a plain
* anchor for everything else, and a new tab when the bookmark asks for one. A URL that
* does not resolve (see resolveBookmarkUrl) gets a disabled item, never a raw href.
*/
function bookmarkItemProps(node) {
const {internal, href} = resolveBookmarkUrl(node.url);
if (!href) {
return {disabled: true};
}
const target = node.new_tab ? {target: '_blank', rel: 'noopener noreferrer'} : {};
if (internal) {
return {component: Link, to: href, ...target};
}
return {component: 'a', href, ...target};
}
/**
* The items of a bookmarks menu.
*
* `nested` draws a directory as a flyout (Menu.Sub), which opens beside the menu on hover.
* Without it a directory is a labelled, indented run of items instead: the mobile
* hamburger is the one place bookmarks are shown where a flyout cannot be hovered. The
* indent is in rem: a label is set smaller than an item, so an em indent would put a
* directory's label a few pixels left of the items beside it.
*/
export function BookmarkMenuItems({nodes, nested = true, depth = 0}) {
if (!nodes || nodes.length === 0) {
return null;
}
return nodes.map(node => {
if (isBookmarkDirectory(node)) {
if (nested) {
return <Menu.Sub key={node.id}>
<Menu.Sub.Target>
<Menu.Sub.Item leftSection={<Icon name='folder' size='small'/>}>{node.name}</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
{node.children.length > 0
? <BookmarkMenuItems nodes={node.children} nested={nested}/>
: <Menu.Item disabled>Empty</Menu.Item>}
</Menu.Sub.Dropdown>
</Menu.Sub>;
}
return <React.Fragment key={node.id}>
<Menu.Label style={{paddingLeft: `${0.75 + depth}rem`}}>{node.name}</Menu.Label>
<BookmarkMenuItems nodes={node.children} nested={false} depth={depth + 1}/>
</React.Fragment>;
}
return <Menu.Item
key={node.id}
{...bookmarkItemProps(node)}
style={nested ? undefined : {paddingLeft: `${0.75 + depth}rem`}}
rightSection={node.new_tab ? <Icon name='external' size='small'/> : undefined}
>
{node.name}
</Menu.Item>;
});
}
/**
* 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 <>
<BookmarkMenuItems nodes={nodes} nested={nested}/>
{nodes.length > 0 && <Menu.Divider/>}
<Menu.Item component={Link} to={EDIT_BOOKMARKS_PATH} leftSection={<Icon name='edit' size='small'/>}>
Edit bookmarks
</Menu.Item>
</>;
}
/** 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 <Modal open={open} onClose={onClose} size='tiny'>
<Modal.Header>{title}</Modal.Header>
<Modal.Content>
<form onSubmit={e => {
e.preventDefault();
handleSave();
}}>
<TextInput
autoFocus
label='Name'
value={name}
onChange={e => setName(e.currentTarget.value)}
/>
{!directory && <TextInput
label='URL'
style={{marginTop: '1em'}}
value={url}
onChange={e => setUrl(e.currentTarget.value)}
placeholder='/videos, :8096/, or https://example.com'
description='A path on this WROLPi (/videos), a port on this host (:8096/ or http://:8096/), or a full URL.'
/>}
<Select
label='Location'
style={{marginTop: '1em'}}
data={locations}
value={location}
onChange={value => setLocation(value || ROOT)}
allowDeselect={false}
/>
{!directory && <Toggle
style={{marginTop: '1em'}}
label='Open in a new tab'
checked={newTab}
onChange={e => setNewTab(e.currentTarget.checked)}
/>}
</form>
</Modal.Content>
<Modal.Actions>
<Button role='cancel' onClick={onClose}>Cancel</Button>
<Button role='save' disabled={!valid || saving} onClick={handleSave}>Save</Button>
</Modal.Actions>
</Modal>;
}
/** 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 <React.Fragment key={node.id}>
<Table.Row>
<Table.Cell>{String(index + 1).padStart(2, '0')}</Table.Cell>
<Table.Cell style={{width: '90px', textAlign: 'center'}}>
{directory
? <Icon name='folder' size='large'/>
: <Icon name={node.new_tab ? 'external' : 'linkify'} size='large'/>}
</Table.Cell>
<Table.Cell style={{paddingLeft: depth ? `calc(0.75em + ${depth * 1.5}em)` : undefined}}>
{directory && <b>{node.name}</b>}
{!directory && (href
? <a href={href} target={node.new_tab ? '_blank' : undefined} rel='noopener noreferrer'>
{node.name}
</a>
: <span>{node.name}</span>)}
{!directory && <Text size='xs' c='dimmed' truncate='end'>{node.url}</Text>}
</Table.Cell>
{editable && <Table.Cell>
<div className='wrolpi-button-row' style={{justifyContent: 'flex-end'}}>
<Button icon='arrow up' size='xs' aria-label={`Move ${node.name} up`} disabled={index === 0}
onClick={() => actions.move(node, parentId, index - 1)}/>
<Button icon='arrow down' size='xs' aria-label={`Move ${node.name} down`}
disabled={index === nodes.length - 1}
onClick={() => actions.move(node, parentId, index + 1)}/>
<Button icon='edit' size='xs' aria-label={`Edit ${node.name}`}
onClick={() => actions.edit(node, parentId)}/>
{directory
? <APIButton
role='danger' icon='trash' size='xs' aria-label={`Delete ${node.name}`}
confirmContent={`Delete the directory "${node.name}" and everything in it?`}
confirmButton='Delete'
onClick={() => actions.remove(node)}/>
: <Button role='danger' icon='trash' size='xs' aria-label={`Delete ${node.name}`}
onClick={() => actions.remove(node)}/>}
</div>
</Table.Cell>}
</Table.Row>
{directory && <BookmarkTableRows nodes={node.children} parentId={node.id} depth={depth + 1}
editable={editable} actions={actions}/>}
</React.Fragment>;
});
}
/**
* 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 <Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>#</Table.HeaderCell>
<Table.HeaderCell>Kind</Table.HeaderCell>
<Table.HeaderCell>Bookmark</Table.HeaderCell>
{editable && <Table.HeaderCell/>}
</Table.Row>
</Table.Header>
<Table.Body>
<BookmarkTableRows nodes={nodes} parentId={null} depth={0} editable={editable} actions={actions}/>
</Table.Body>
</Table>;
}
/** 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 <>
<Header as='h4'>Add a bookmark</Header>
<form onSubmit={e => {
e.preventDefault();
handleAddBookmark();
}}>
<Group align='flex-end' wrap='wrap'>
<TextInput
style={{flex: '2 1 300px'}}
aria-label='Bookmark URL'
placeholder='URL (e.g. /videos, :8096/, or https://example.com)'
value={url}
onChange={e => setUrl(e.currentTarget.value)}/>
<TextInput
style={{flex: '1 1 200px'}}
aria-label='Bookmark name'
placeholder='Name'
value={name}
onChange={e => setName(e.currentTarget.value)}/>
<Select
style={{flex: '1 1 160px'}}
aria-label='Bookmark location'
data={locations}
value={location}
onChange={value => setLocation(value || ROOT)}
allowDeselect={false}/>
<Toggle
label='New tab'
checked={newTab}
onChange={e => setNewTab(e.currentTarget.checked)}/>
<Button role='primary' type='submit' disabled={!url.trim() || !name.trim()}>Add</Button>
</Group>
</form>
<p style={{marginTop: '0.5em'}}>
<Text size='xs' c='dimmed' component='span'>
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.
</Text>
</p>
<Header as='h4'>Add a directory</Header>
<form onSubmit={e => {
e.preventDefault();
handleAddDirectory();
}}>
<Group align='flex-end' wrap='wrap'>
<TextInput
style={{flex: '2 1 300px'}}
aria-label='Directory name'
placeholder='Directory name'
value={directoryName}
onChange={e => setDirectoryName(e.currentTarget.value)}/>
<Select
style={{flex: '1 1 160px'}}
aria-label='Directory location'
data={locations}
value={directoryLocation}
onChange={value => setDirectoryLocation(value || ROOT)}
allowDeselect={false}/>
<Button role='primary' type='submit' disabled={!directoryName.trim()}>Add</Button>
</Group>
</form>
</>;
}
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 <PageContainer>
<Panel>
<Header as='h1' icon={IconBookmark}>Bookmarks</Header>
<p>
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.
</p>
{nodes.length === 0
? <Message title='There are no bookmarks yet'>Add a bookmark below.</Message>
: <BookmarkTable nodes={nodes} actions={actions}/>}
<BookmarkAddForms nodes={nodes} onAddBookmark={handleAddBookmark} onAddDirectory={handleAddDirectory}/>
</Panel>
{modal && <BookmarkNodeModal
key={modalKey}
open
node={modal.node}
directory={modal.directory}
parentId={modal.parentId}
nodes={nodes}
onSave={handleEditSave}
onClose={() => setModal(null)}
/>}
</PageContainer>;
}

View file

@ -1,199 +0,0 @@
import React from 'react';
import {screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {render} from '../test-utils';
import {BookmarkAddForms, BookmarkNodeModal, BookmarkTable, directoryOptions, locationOptions} from './Bookmarks';
import {resolveBookmarkUrl} from '../contexts/BookmarksContext';
const tree = [
{id: 1, name: 'Channels', url: '/videos/channel', new_tab: false},
{
id: 2, name: 'Services', children: [
{id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true},
{id: 4, name: 'Radio', children: []},
]
},
];
describe('resolveBookmarkUrl', () => {
const location = {protocol: 'https:', hostname: '10.0.0.2', host: '10.0.0.2'};
it.each([
['/videos', {internal: true, href: '/videos'}],
[' /videos/channel/3 ', {internal: true, href: '/videos/channel/3'}],
// A port on this host takes the page's scheme, or its own when it names one.
[':8096/web', {internal: false, href: 'https://10.0.0.2:8096/web'}],
[':8096', {internal: false, href: 'https://10.0.0.2:8096'}],
['http://:8096/web', {internal: false, href: 'http://10.0.0.2:8096/web'}],
['HTTPS://:8085', {internal: false, href: 'https://10.0.0.2:8085'}],
['https://example.com/a?b=c', {internal: false, href: 'https://example.com/a?b=c'}],
])('resolves %s', (url, expected) => {
expect(resolveBookmarkUrl(url, location)).toEqual(expected);
});
it.each([
// Paths a browser takes off this host: network paths, backslashes, and control
// characters a URL parser strips before deciding.
'//evil.com', '///evil.com', '/\\evil.com', '/\\@evil.com', '/\t/evil.com', '/\n/evil.com',
// Port forms where the host would become credentials.
':8096@evil.com', 'http://:8096@evil.com', ':8096\t@evil.com', ':8096/x@evil.com', ':8096\\evil.com',
// Not http(s).
'javascript:alert(1)', 'data:text/html,hi', 'ftp://example.com', 'videos', '',
])('refuses %j rather than guessing', (url) => {
expect(resolveBookmarkUrl(url, location)).toEqual({internal: false, href: null});
});
it('brackets an IPv6 host for a port bookmark', () => {
// The browser reports an IPv6 hostname bracketed already.
const v6 = {protocol: 'https:', hostname: '[::1]', host: '[::1]'};
expect(resolveBookmarkUrl(':8096/web', v6)).toEqual({internal: false, href: 'https://[::1]:8096/web'});
expect(resolveBookmarkUrl(':8096', {protocol: 'http:', hostname: '::1', host: '[::1]'}))
.toEqual({internal: false, href: 'http://[::1]:8096'});
});
});
describe('directoryOptions', () => {
it('lists every directory with its path, and no bookmarks', () => {
expect(directoryOptions(tree)).toEqual([
{value: '2', label: 'Services', id: 2},
{value: '4', label: 'Services / Radio', id: 4},
]);
});
});
describe('BookmarkTable', () => {
const actions = () => ({edit: jest.fn(), move: jest.fn(), remove: jest.fn()});
it('renders the tree with resolved links, numbered within each directory', () => {
render(<BookmarkTable nodes={tree} actions={actions()}/>);
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(<BookmarkTable nodes={tree} actions={a}/>);
// 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(<BookmarkTable nodes={nodes} actions={actions()}/>);
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(<BookmarkTable nodes={tree} actions={a}/>);
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(<BookmarkTable nodes={tree} actions={a}/>);
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(<BookmarkAddForms nodes={tree} onAddBookmark={onAddBookmark} onAddDirectory={jest.fn()}/>);
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(<BookmarkAddForms nodes={tree} onAddBookmark={onAddBookmark} onAddDirectory={jest.fn()}/>);
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(<BookmarkAddForms nodes={tree} onAddBookmark={jest.fn()} onAddDirectory={onAddDirectory}/>);
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(<BookmarkNodeModal open node={null} directory={false} parentId={2} nodes={tree}
onSave={onSave} onClose={jest.fn()}/>);
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(<BookmarkNodeModal open node={tree[1]} directory parentId={null} nodes={tree}
onSave={jest.fn()} onClose={jest.fn()}/>);
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']);
});
});

View file

@ -14,7 +14,6 @@ import {FoodStorageCalculator} from "./calculators/FoodStorageCalculator";
import {OneTimePadCalculator} from "./calculators/OneTimePadCalculator";
import {VinDecoderCalculator} from "./calculators/VinDecoderCalculator";
import {SpeedTestCalculator} from "./calculators/SpeedTestCalculator";
import {SolarCalculator} from "./calculators/SolarCalculator";
// Calculators are shown in these titled groups, in this order. Each calculator carries a
// `group` matching one of these names; any calculator whose group is missing/unknown falls
@ -36,7 +35,6 @@ export const useCalculators = () => {
{key: 'ratio', icon: 'th large', button: 'Ratio', group: 'Engineering', contents: <RatioCalculators/>},
{key: 'electrical', icon: 'lightning', button: 'Electrical', group: 'Engineering', contents: <ElectricalCalculators/>},
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', group: 'Engineering', contents: <DriveCalculator/>},
{key: 'solar', icon: 'sun', button: 'Solar', group: 'Engineering', contents: <SolarCalculator/>},
{key: 'water', icon: 'tint', button: 'Water Storage', group: 'Preparedness & Storage', contents: <WaterCalculator/>},
{key: 'ration', icon: 'food', button: 'Food Storage', group: 'Preparedness & Storage', contents: <FoodStorageCalculator/>},
{key: 'antenna', icon: 'signal', button: 'Antenna', group: 'Radio & Communications', contents: <DipoleAntennaCalculator/>},

View file

@ -3,7 +3,6 @@ import {IconArrowLeft, IconArrowRight} from '@tabler/icons-react';
import {getArchiveContents, getArchiveMemberUrl} from '../api';
import {IconButton, Label, Loading, Message} from './ui';
import {useLocalStorage} from './Common';
import {usePageProgress} from '../hooks/useFileProgress';
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i;
@ -23,20 +22,9 @@ function flattenEntries(entries, prefix = '') {
return paths;
}
/**
* @param path The comic book file.
* @param progressPath The FileGroup's primary path, to resume and save the reader's page; omit to not track it.
* @param fileGroup The FileGroup JSON, which holds the saved page.
*/
export function CbzViewer({path, progressPath = null, fileGroup = null}) {
export function CbzViewer({path}) {
const [pages, setPages] = useState(null);
const [currentPage, setCurrentPage] = useState(0);
const {initialPage, onPageChange} = usePageProgress(progressPath, fileGroup, {enabled: !!progressPath});
// Read when the pages load; a later save must not move the reader.
const initialPageRef = useRef(initialPage);
initialPageRef.current = initialPage;
// The page shown on opening is where the reader already is (or the cover of a finished comic); it is not saved.
const justOpenedRef = useRef(true);
const [rtl, setRtl] = useLocalStorage('cbzViewerRtl', false);
const [fullscreen, setFullscreen] = useState(false);
const [error, setError] = useState(null);
@ -53,9 +41,8 @@ export function CbzViewer({path, progressPath = null, fileGroup = null}) {
.filter(name => IMAGE_EXTENSIONS.test(name))
.sort();
const urls = images.map(member => getArchiveMemberUrl(path, member));
justOpenedRef.current = true;
setPages(urls);
setCurrentPage(Math.min(initialPageRef.current, Math.max(urls.length - 1, 0)));
setCurrentPage(0);
} catch (e) {
setError(e.message || 'Could not read archive contents.');
}
@ -63,17 +50,6 @@ export function CbzViewer({path, progressPath = null, fileGroup = null}) {
load();
}, [path]);
useEffect(() => {
if (!pages || pages.length === 0) {
return;
}
if (justOpenedRef.current) {
justOpenedRef.current = false;
return;
}
onPageChange(currentPage, pages.length);
}, [currentPage, pages, onPageChange]);
const goNext = useCallback(() => {
setCurrentPage(p => Math.min(p + 1, (pages?.length || 1) - 1));
}, [pages]);

View file

@ -1,84 +0,0 @@
import React from 'react';
import {act, fireEvent, screen} from '@testing-library/react';
import {renderWithProviders} from '../test-utils';
import {CbzViewer} from './CbzViewer';
import {getArchiveContents} from '../api';
jest.mock('../api', () => ({
...jest.requireActual('../api'),
getArchiveContents: jest.fn(),
getArchiveMemberUrl: (path, member) => `/member/${member}`,
}));
const PATH = 'comics/issue.cbz';
const PAGES = ['01.jpg', '02.jpg', '03.jpg', '04.jpg'];
const {fetch: realFetch} = global;
const realSendBeacon = navigator.sendBeacon;
beforeEach(() => {
window.sessionStorage.clear();
getArchiveContents.mockResolvedValue({entries: PAGES.map(name => ({name, path: name, is_dir: false}))});
navigator.sendBeacon = jest.fn(() => false);
global.fetch = jest.fn(() => Promise.resolve({ok: true}));
});
afterEach(() => {
global.fetch = realFetch;
navigator.sendBeacon = realSendBeacon;
});
const saves = () => global.fetch.mock.calls.map(([, init]) => JSON.parse(init.body));
describe('CbzViewer progress', () => {
test('opens at the saved page, and saves the page the reader leaves on', async () => {
const fileGroup = {progress: 0.75, position: {kind: 'page', page: 2, updated_at: 1000}};
const {unmount} = renderWithProviders(
<CbzViewer path={PATH} progressPath={PATH} fileGroup={fileGroup}/>);
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(<CbzViewer path={PATH} progressPath={PATH} fileGroup={fileGroup}/>);
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(<CbzViewer path={PATH} fileGroup={fileGroup}/>);
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(<CbzViewer path={PATH} progressPath={PATH} fileGroup={fileGroup}/>);
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(<CbzViewer path={PATH} progressPath={PATH} fileGroup={fileGroup}/>);
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})})]);
});
});

View file

@ -49,7 +49,7 @@ describe('Channels Page', () => {
{initialEntries: ['/videos/channel/1/edit']}
);
cy.wait('@channelFetch');
cy.wait('@searchDirectories'); // Needed in CI.
cy.wait('@searchDirectories'); // Needed for CircleCi.
cy.wait(500);
cy.get('input[name="name"]').should('be.visible');
cy.get('input[name="name"]').should('have.value', 'Editing Channel');

View file

@ -757,18 +757,6 @@ export function findPosterPath(file) {
}
}
/** How far the user is through a file, as a bar along the bottom of its poster. A full bar is finished. */
export function ViewProgress({progress}) {
if (!(progress > 0)) {
return null;
}
const percent = Math.round(Math.min(progress, 1) * 100);
return <div className='wrolpi-view-progress' role='progressbar' aria-label='Viewing progress'
aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent}>
<div className='wrolpi-view-progress-bar' style={{width: `${percent}%`}}/>
</div>
}
export function CardPoster({to, file, overlay}) {
const navigate = useNavigate();
@ -797,7 +785,6 @@ export function CardPoster({to, file, overlay}) {
{imageLabel}
<img alt='poster' src={posterPath}/>
{overlay}
<ViewProgress progress={file.progress}/>
</>;
return <div className='wrolpi-card-poster'>
@ -815,7 +802,6 @@ export function CardPoster({to, file, overlay}) {
<div className='wrolpi-card-icon'>
{imageLabel}
<FileIcon file={file}/>
<ViewProgress progress={file.progress}/>
</div>
</PreviewLink>
} else if (!posterPath && to) {
@ -824,7 +810,6 @@ export function CardPoster({to, file, overlay}) {
<div className='wrolpi-card-icon' onClick={() => navigate(to)}>
{imageLabel}
<FileIcon file={file}/>
<ViewProgress progress={file.progress}/>
</div>
</Link>
}
@ -943,10 +928,7 @@ export function HelpHeader({
// why it cannot be used. `confirmStop` asks before switching off.
function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null, disabled = null, confirmStop = null}) {
const [confirmOpen, setConfirmOpen] = React.useState(false);
// null = the status has loaded without this subsystem; undefined = the status has not loaded
// yet (or a call is in flight). Both disable the toggle; only the first claims unsupported.
const unsupported = on === null;
const unknown = on === undefined;
const handleChange = (checked) => {
if (!checked && confirmStop) {
@ -979,11 +961,11 @@ function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null,
</Confirm>}
<Toggle
label={label}
disabled={disabled === null ? (unsupported || unknown) : disabled}
disabled={disabled === null ? unsupported : disabled}
checked={on === true}
onChange={handleChange}
/>
{popup && <span data-testid='subsystem-popup'><InfoPopup content={popup}/></span>}
{popup && <InfoPopup content={popup}/>}
</div>;
}
@ -1083,10 +1065,9 @@ export function Toggle({label, checked, disabled, onChange, icon, popupContent =
export function DisableDownloadsToggle() {
const [pending, setPending] = React.useState(false);
const {status, fetchStatus, loaded} = React.useContext(StatusContext);
const {status, fetchStatus} = React.useContext(StatusContext);
// Until the first status poll lands there is no downloads state to show, only a disabled toggle.
const downloads = loaded ? status?.downloads : null;
const {downloads} = status ? status : {downloads: null};
const wrolModeEnabled = useWROLMode();
const setDownloads = async (enable) => {
@ -1101,9 +1082,8 @@ export function DisableDownloadsToggle() {
}
const on = downloads && downloads['disabled'] === false && downloads['stopped'] === false;
const label = downloads === null ? 'Downloading' : on === true ? 'Downloading Enabled' : 'Downloading Disabled';
return <Toggle
label={label}
label={on === true ? 'Downloading Enabled' : 'Downloading Disabled'}
disabled={wrolModeEnabled || pending || downloads === null}
checked={on === true}
onChange={setDownloads}
@ -1180,7 +1160,7 @@ export function mimetypeIconName(mimetype, lowerPath = '') {
return 'file code';
} else if (mimetype.startsWith('application/pdf')) {
return 'file pdf';
} else if (mimetype.startsWith('text/plain') || mimetype.startsWith('text/markdown')) {
} else if (mimetype.startsWith('text/plain')) {
return 'file text';
} else if (mimetype.startsWith('image/')) {
return 'image';

View file

@ -1121,7 +1121,7 @@ describe('a row of buttons', () => {
* the two had been inline before.
*
* A row fixes it without touching the stack: the row is the stretched child, and it lays
* its buttons out at their own width. Thirteen sites, so this scan rather than thirteen
* its buttons out at their own width. Nine sites, so this scan rather than nine
* assertions -- and a scan because a stretched button is still a working button.
*/
const offenders = [];
@ -1140,8 +1140,8 @@ describe('a row of buttons', () => {
}
}
// Thirteen renders. Common.js declares it, and a declaration is not a `<BackButton`.
expect(found).toBe(13);
// Nine renders. Common.js declares it, and a declaration is not a `<BackButton`.
expect(found).toBe(9);
expect(offenders).toEqual([]);
});

View file

@ -38,7 +38,6 @@ import {toast} from "./ui";
import {CollectionTable} from "./collections/CollectionTable";
import {AddToPlaylistButton} from "./AddToPlaylist";
import {CbzViewer} from "./CbzViewer";
import {useEpubProgress} from "../hooks/useFileProgress";
import _ from "lodash";
function DocsPage() {
@ -166,23 +165,6 @@ function DocsPage() {
</>;
}
/**
* 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 <iframe
ref={iframeRef}
src={src}
title={title}
style={{width: '100%', height: '80vh', border: '1px solid var(--border)'}}
/>
}
function DocPage() {
const {fileGroupId} = useParams();
const navigate = useNavigate();
@ -263,8 +245,6 @@ function DocPage() {
? `/media/${encodeMediaPath(activePath)}${pageParam ? `#page=${encodeURIComponent(pageParam)}` : ''}`
: null;
const embedUrl = isEpub ? viewerUrl : pdfSrc;
// Progress belongs to the FileGroup; another format of the same book (a PDF beside the EPUB) is not tracked.
const trackedPath = String(activePath) === String(docFile.primary_path) ? docFile.primary_path : null;
const handleDelete = async (force = false) => {
if (doc && doc.id) {
@ -374,7 +354,7 @@ function DocPage() {
return <>
<div className='wrolpi-button-row'><BackButton/></div>
{isCbz && activePath && <CbzViewer path={activePath} progressPath={trackedPath} fileGroup={docFile}/>}
{isCbz && activePath && <CbzViewer path={activePath}/>}
{canEmbed && embedUrl && !isCbz && <div style={{marginBottom: '1em'}}>
{/*
@ -384,8 +364,11 @@ function DocPage() {
* re-gates rather than inheriting the last one's reveal.
*/}
<MediaGate key={activePath} gated={isPdf && mediaFilterEnabled}>
<DocViewerFrame url={embedUrl} epubProgressPath={isEpub ? trackedPath : null} fileGroup={docFile}
deepLinked={!!locParam} title={docFile.title || docFile.name}/>
<iframe
src={embedUrl}
title={docFile.title || docFile.name}
style={{width: '100%', height: '80vh', border: '1px solid var(--border)'}}
/>
</MediaGate>
</div>}

View file

@ -19,15 +19,10 @@ const source = fs.readFileSync(path.join(__dirname, 'Docs.js'), 'utf8');
describe('DocPage embed gating (source contract)', () => {
it('wraps the embedded viewer in a MediaGate', () => {
// The iframe must not be reachable without passing through the gate. The gate's only child is the
// viewer frame, and the frame is just the iframe.
const embed = source.match(/<MediaGate[^>]*>\s*<DocViewerFrame/);
expect(embed).not.toBeNull();
// The iframe must not be reachable without passing through the gate.
const embed = source.match(/<MediaGate[^>]*>\s*<iframe/);
const frame = source.match(/function DocViewerFrame[\s\S]*?\n}\n/);
expect(frame).not.toBeNull();
expect(frame[0]).toMatch(/return <iframe/);
expect(source.match(/<iframe/g)).toHaveLength(1);
expect(embed).not.toBeNull();
});
it('gates on the live filter state, not on the theme name', () => {

View file

@ -127,15 +127,6 @@ describe('<ChannelDownloadForm/>', () => {
shouldBeInvalid('#url_input');
});
it('can be submitted with a frequency of Once', () => {
cy.get('#url_input').type('https://example.com/playlist').wait(500);
cy.get('#frequency_select').click();
cy.contains('[role="option"]:visible', 'Once').should('be.visible').click({force: true});
cy.get('#frequency_select').should('have.value', 'Once');
cy.get('#download_form_download_button').should('not.be.disabled');
});
it('can choose different options', () => {
/*
* `#download_frequency_selector` and `.item` named the old dropdown and its menu rows.

View file

@ -247,38 +247,28 @@ export function DownloadFrequencySelector({
}
export function DownloaderSelector({form, name = 'sub_downloader', path = 'sub_downloader'}) {
// null = pending, undefined = could not be fetched, a list = loaded. Until loaded, the select is
// disabled and says why, rather than sitting empty as though there were no downloaders.
const [downloaders, setDownloaders] = React.useState(null);
const [downloaders, setDownloaders] = React.useState([]);
const fetchDownloaders = async () => {
const result = await getDownloaders();
if (!result || !Array.isArray(result.downloaders)) {
setDownloaders(undefined);
return;
}
setDownloaders(result.downloaders.map((i) => {
let {downloaders: downloaders_} = await getDownloaders();
downloaders_ = downloaders_.map((i) => {
return {key: i.name, text: i.pretty_name || i.name, value: i.name}
}));
})
setDownloaders(downloaders_);
}
React.useEffect(() => {
fetchDownloaders();
}, []);
const placeholder = downloaders === null ? 'Loading downloaders…'
: downloaders === undefined ? 'Could not fetch downloaders'
: 'Select a downloader';
return <SelectField
form={form}
name={name}
path={path}
required
label='Downloader'
placeholder={placeholder}
options={downloaders || []}
disabled={!Array.isArray(downloaders)}
placeholder='Select a downloader'
options={downloaders}
/>
}

View file

@ -1,9 +1,8 @@
import React from 'react';
import {render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {DepthInputForm, DestinationForm} from './Download';
import {useForm} from '../hooks/useForm';
import {createTestForm, renderWithProviders} from '../test-utils';
import {DestinationForm} from './Download';
import {createTestForm} from '../test-utils';
// Mock DirectorySearch component - simplified to avoid useState/useEffect warnings
jest.mock('./Common', () => {
@ -252,26 +251,3 @@ describe('DestinationForm', () => {
});
});
});
describe('NumberField', () => {
// Renders a NumberField against a real useForm: the Mantine NumberInput passes a value (not an
// event) to onChange, which used to reach `e.preventDefault()` and throw while typing.
function DepthHarness({onData}) {
const form = useForm({defaultFormData: {settings: {depth: ''}}, submitter: jest.fn()});
onData(form.formData);
return <DepthInputForm form={form}/>;
}
it('typing into a NumberField updates the form without throwing', async () => {
let latest = null;
renderWithProviders(<DepthHarness onData={(d) => latest = d}/>);
const input = screen.getByRole('textbox', {name: /Depth/});
await userEvent.type(input, '3');
await waitFor(() => {
expect(latest.settings.depth).toBe(3);
});
expect(input).toHaveValue('3');
});
});

View file

@ -1,43 +0,0 @@
import React from 'react';
import {act, render, screen} from '../test-utils';
import {createTestForm} from '../test-utils';
import {DownloaderSelector} from './Download';
import {getDownloaders} from '../api';
// The RSS form's Downloader select is filled from the API. Until the list arrives, or if it could
// not be fetched, the select must say so rather than sit empty as though there were no downloaders.
jest.mock('../api', () => ({
...jest.requireActual('../api'),
getDownloaders: jest.fn(),
}));
const makeForm = () => createTestForm({}, {
overrides: {getSelectionProps: () => [{value: null, onChange: jest.fn(), disabled: false, error: null}]},
});
beforeEach(() => getDownloaders.mockReset());
test('pending: the select says it is loading and is disabled', () => {
getDownloaders.mockReturnValue(new Promise(() => {
}));
render(<DownloaderSelector form={makeForm()}/>);
const input = screen.getByPlaceholderText('Loading downloaders…');
expect(input).toBeDisabled();
});
test('failed: the select says the downloaders could not be fetched', async () => {
getDownloaders.mockResolvedValue(undefined);
render(<DownloaderSelector form={makeForm()}/>);
await act(async () => {
});
expect(screen.getByPlaceholderText('Could not fetch downloaders')).toBeDisabled();
});
test('loaded: the select offers the downloaders', async () => {
getDownloaders.mockResolvedValue({downloaders: [{name: 'video', pretty_name: 'Videos'}]});
render(<DownloaderSelector form={makeForm()}/>);
await act(async () => {
});
expect(screen.getByPlaceholderText('Select a downloader')).toBeEnabled();
});

View file

@ -8,14 +8,12 @@ import {CbzViewer} from "./CbzViewer";
import {StlViewer} from "react-stl-viewer";
import {Button, ButtonGroup, Header, Icon, IconButton, MediaGate, Menu, Modal, toast} from "./ui";
import {useOneQuery} from "../hooks/customHooks";
import {useEpubProgress, useMediaProgress} from "../hooks/useFileProgress";
import {ShareButton} from "./Share";
import {AddToPlaylistButton} from "./AddToPlaylist";
import {pathDirectory} from "./FileBrowser";
import {InlineErrorBoundary} from "./ErrorBoundary";
import {useLocation} from "react-router";
import {ThemeContext} from "../contexts/contexts";
import {isMarkdownFile, MarkdownPreview} from "./MarkdownPreview";
// Routes where file views should not be tracked
const EXCLUDED_TRACKING_ROUTES = [
@ -102,7 +100,7 @@ function getEpubViewerURL(previewFile) {
* A component rather than part of `getIframePreviewModal` because it reads the theme: the
* modal builders are plain functions called from an event handler, where hooks cannot run.
*/
export function IframePreview({url, gatePdf, iframeRef}) {
export function IframePreview({url, gatePdf}) {
const {isDark, mediaFilterEnabled} = React.useContext(ThemeContext);
/*
@ -118,7 +116,7 @@ export function IframePreview({url, gatePdf, iframeRef}) {
*/
return <MediaGate key={url} gated={!!gatePdf && !!mediaFilterEnabled}>
<div className='preview-fit'>
<iframe title='textModal' src={url} ref={iframeRef}
<iframe title='textModal' src={url}
style={{
height: '100%', width: '100%', border: 'none',
// A same-origin document has no styles of its own; it renders with the
@ -139,19 +137,6 @@ function getIframePreviewModal(previewFile, url, {gatePdf = false} = {}) {
</Modal.Content>
}
/** The EPUB viewer, opened where the user left off. */
function EpubPreview({previewFile}) {
const path = previewFile.primary_path ?? previewFile.path;
const {iframeRef, resumeCfi} = useEpubProgress(path, previewFile);
let url = getEpubViewerURL(previewFile);
if (resumeCfi) {
url = `${url}&cfi=${encodeURIComponent(resumeCfi)}`;
}
return <Modal.Content>
<IframePreview url={url} iframeRef={iframeRef}/>
</Modal.Content>
}
function getImagePreviewModal(previewFile) {
const path = previewFile.primary_path ?? previewFile.path;
const url = getMediaPathURL(previewFile);
@ -171,14 +156,6 @@ function getImagePreviewModal(previewFile) {
</React.Fragment>
}
/** A <video> or <audio> which resumes where the user left off, and saves how far they got. */
function PreviewMediaPlayer({as: Element, previewFile, path, style, children}) {
const progressHandlers = useMediaProgress(path, previewFile);
return <Element controls autoPlay={true} id="player" playsInline={true} style={style} {...progressHandlers}>
{children}
</Element>
}
function getVideoPreviewModal(previewFile) {
const url = getMediaPathURL(previewFile);
const path = previewFile.primary_path ?? previewFile.path;
@ -190,10 +167,14 @@ function getVideoPreviewModal(previewFile) {
<Modal.Content>
<Header as='h5'>{path['path']}</Header>
<div className='preview-fit'>
<PreviewMediaPlayer as='video' previewFile={previewFile} path={path}
style={{maxHeight: '100%', maxWidth: '100%'}}>
<video controls
autoPlay={true}
id="player"
playsInline={true}
style={{maxHeight: '100%', maxWidth: '100%'}}
>
<source src={url}/>
</PreviewMediaPlayer>
</video>
</div>
</Modal.Content>
</React.Fragment>
@ -210,10 +191,14 @@ function getAudioPreviewModal(previewFile) {
{name}
</Modal.Header>
<Modal.Content>
<PreviewMediaPlayer as='audio' previewFile={previewFile} path={path}
style={{width: '90%', maxWidth: '95%'}}>
<audio controls
autoPlay={true}
id="player"
playsInline={true}
style={{width: '90%', maxWidth: '95%'}}
>
<source src={url} type={type}/>
</PreviewMediaPlayer>
</audio>
</Modal.Content>
</React.Fragment>
}
@ -456,9 +441,6 @@ export function FilePreviewProvider({children}) {
const [previewQuery, setPreviewQuery] = useOneQuery('preview');
const [errorModalOpen, setErrorModalOpen] = React.useState(false);
const location = useLocation();
// The path of the open preview that has been marked viewed. One opening is one view, however
// many times previewFile is replaced while it is open (re-fetching after tagging, for example).
const trackedPathRef = React.useRef(null);
// Determine if tracking should be skipped based on current route
const skipTracking = shouldSkipTracking(location.pathname);
@ -488,8 +470,7 @@ export function FilePreviewProvider({children}) {
const initPreviewFile = async () => {
// Get simple information about the file, preview the file.
try {
// Not a view; the preview effect marks the file viewed once it is displayed.
const file = await getFile(previewQuery, true);
const file = await getFile(previewQuery, skipTracking);
setPreviewFile(file);
} catch (e) {
console.error(e);
@ -514,7 +495,7 @@ export function FilePreviewProvider({children}) {
// Get the file again with its Tags.
const {path, primary_path} = previewFile;
try {
const file = await getFile(primary_path ?? path, true);
const file = await getFile(primary_path ?? path, skipTracking);
setPreviewFile(file);
} finally {
await handleCallbacks();
@ -604,9 +585,6 @@ export function FilePreviewProvider({children}) {
React.useEffect(() => {
setPreviewModal(null);
if (_.isEmpty(previewFile)) {
trackedPathRef.current = null;
}
if (previewFile && !_.isObject(previewFile)) {
console.error(`Unknown previewFile type: ${typeof previewFile}`);
toast({
@ -629,9 +607,6 @@ export function FilePreviewProvider({children}) {
if (mimetype.startsWith('text/') && size > MAXIMUM_TEXT_SIZE) {
// Large text files should be downloaded.
window.open(downloadURL);
} else if (isMarkdownFile(mimetype, lowerPath)) {
setModalContent(<Modal.Content><MarkdownPreview url={url}/></Modal.Content>, url, downloadURL, path,
taggable);
} else if (mimetype.startsWith('text/') || mimetype.startsWith('application/json')) {
setModalContent(getIframePreviewModal(previewFile), url, downloadURL, path, taggable);
} else if (mimetype.startsWith('video/')) {
@ -640,7 +615,7 @@ export function FilePreviewProvider({children}) {
setModalContent(getAudioPreviewModal(previewFile), url, downloadURL, path, taggable);
} else if (mimetype.startsWith('application/epub')) {
const viewerURL = getEpubViewerURL(previewFile);
setModalContent(<EpubPreview previewFile={previewFile}/>, viewerURL, downloadURL, path, taggable);
setModalContent(getIframePreviewModal(previewFile, viewerURL), viewerURL, downloadURL, path, taggable);
} else if (mimetype.startsWith('application/pdf')) {
setModalContent(getIframePreviewModal(previewFile, null, {gatePdf: true}), url, downloadURL, path, taggable);
} else if (mimetype.startsWith('image/')) {
@ -662,9 +637,7 @@ export function FilePreviewProvider({children}) {
<OpenInSlicerButton previewFile={previewFile}/>);
} else if (mimetype.includes('cbz') || mimetype.includes('cbr') || mimetype.includes('comicbook+zip') || mimetype.includes('comicbook-rar')
|| lowerPath.endsWith('.cbz') || lowerPath.endsWith('.cbr') || lowerPath.endsWith('.cbt') || lowerPath.endsWith('.cb7')) {
setModalContent(<Modal.Content>
<CbzViewer path={path} progressPath={path} fileGroup={previewFile}/>
</Modal.Content>, null, downloadURL, path, taggable);
setModalContent(<Modal.Content><CbzViewer path={path}/></Modal.Content>, null, downloadURL, path, taggable);
} else if (isSupportedArchive(mimetype, lowerPath)) {
setModalContent(<ArchivePreviewContent previewFile={previewFile}/>, null, downloadURL, path, taggable);
} else {
@ -673,8 +646,7 @@ export function FilePreviewProvider({children}) {
}
// Trigger tracking for the file view (unless on an excluded route)
if (!skipTracking && trackedPathRef.current !== path) {
trackedPathRef.current = path;
if (!skipTracking) {
getFile(path).catch(e => {
console.error(e);
console.error('Failed to get file to set FileGroup.viewed');

View file

@ -101,26 +101,6 @@ describe('preview gate remounting (source contract)', () => {
});
});
describe('markdown preview routing (source contract)', () => {
/*
* Markdown is text/*, so the generic text branch would also accept it and show it raw in an
* iframe. The markdown branch has to be tested first for the formatted view to be reachable.
*/
const fs = require('fs');
const path = require('path');
const source = fs.readFileSync(path.join(__dirname, 'FilePreview.js'), 'utf8');
test('markdown is routed to MarkdownPreview before the generic text preview', () => {
const markdownBranch = source.indexOf('} else if (isMarkdownFile(mimetype, lowerPath)) {');
const textBranch = source.indexOf("} else if (mimetype.startsWith('text/') || mimetype.startsWith('application/json')) {");
expect(markdownBranch).toBeGreaterThan(-1);
expect(textBranch).toBeGreaterThan(-1);
expect(markdownBranch).toBeLessThan(textBranch);
expect(source.slice(markdownBranch, textBranch)).toContain('<MarkdownPreview url={url}/>');
});
});
describe('IframePreview color scheme', () => {
/*
* The previewed file is a bare document with no styles of its own, so it renders with the

View file

@ -1,80 +0,0 @@
import React from 'react';
import {act, waitFor} from '@testing-library/react';
import {renderWithProviders} from '../test-utils';
import {FilePreviewContext, FilePreviewProvider} from './FilePreview';
import {getFile} from '../api';
jest.mock('../api', () => ({
...jest.requireActual('../api'),
getFile: jest.fn(),
tagFileGroup: jest.fn(),
untagFileGroup: jest.fn(),
}));
const imageFile = {primary_path: 'pictures/cat.png', mimetype: 'image/png', size: 100, taggable: true, tags: []};
/** getFile calls that would mark the file viewed (skipTracking not set). */
const trackedCalls = () => getFile.mock.calls.filter(([, skipTracking]) => !skipTracking);
/** Exposes the provider's context so a test can open a preview the way PreviewLink does. */
let context;
const ContextSpy = () => {
context = React.useContext(FilePreviewContext);
return null;
};
const renderProvider = (preview = null) => renderWithProviders(
<FilePreviewProvider><ContextSpy/></FilePreviewProvider>,
{
route: '/files',
query: {searchParams: new URLSearchParams(preview ? {preview} : {}), updateQuery: jest.fn()},
},
);
describe('FilePreview view tracking', () => {
beforeEach(() => {
getFile.mockReset();
getFile.mockResolvedValue(imageFile);
context = null;
});
test('opening a preview from the URL marks the file viewed once', async () => {
renderProvider(imageFile.primary_path);
await waitFor(() => expect(getFile).toHaveBeenCalled());
await waitFor(() => expect(context.previewModal).not.toBeNull());
expect(trackedCalls()).toHaveLength(1);
});
test('opening a preview from a link marks the file viewed once', async () => {
renderProvider();
act(() => context.setPreviewFile(imageFile));
await waitFor(() => expect(trackedCalls()).toHaveLength(1));
expect(getFile).toHaveBeenCalledTimes(1);
});
test('refreshing the open file (after tagging) is not another view', async () => {
renderProvider();
act(() => context.setPreviewFile(imageFile));
await waitFor(() => expect(trackedCalls()).toHaveLength(1));
// localFetchFile replaces previewFile with the re-fetched file after a tag change.
act(() => context.setPreviewFile({...imageFile, tags: ['animals']}));
await waitFor(() => expect(context.previewFile.tags).toEqual(['animals']));
expect(trackedCalls()).toHaveLength(1);
});
test('closing and reopening the same file is another view', async () => {
renderProvider();
act(() => context.setPreviewFile(imageFile));
await waitFor(() => expect(trackedCalls()).toHaveLength(1));
act(() => context.setPreviewFile(null));
act(() => context.setPreviewFile(imageFile));
await waitFor(() => expect(trackedCalls()).toHaveLength(2));
});
});

View file

@ -3,7 +3,7 @@ import {Route, Routes} from "react-router";
import {ESPLoader, Transport} from "esptool-js";
import {useDropzone} from "react-dropzone";
import _ from "lodash";
import {encodeMediaPath, ErrorMessage, humanFileSize, PageContainer, useTitle} from "./Common";
import {encodeMediaPath, humanFileSize, PageContainer, useTitle} from "./Common";
import {
Button,
Checkbox,
@ -17,7 +17,6 @@ import {
Message,
Modal,
Panel,
Placeholder,
Progress,
Select,
Stack,
@ -270,8 +269,7 @@ export function FlasherPage() {
// Media-directory firmware picker.
const [mediaFilter, setMediaFilter] = useState('');
// null = not searched yet, undefined = the search failed, a list = the server's results.
const [mediaResults, setMediaResults] = useState(null);
const [mediaResults, setMediaResults] = useState([]);
const [mediaLoading, setMediaLoading] = useState(false);
// When set (e.g. "ESP32-S2"), the picker only shows firmware for that chip (detected from the device).
const [deviceChip, setDeviceChip] = useState(null);
@ -299,12 +297,8 @@ export function FlasherPage() {
// Which firmware-source tab is active (controlled so a file drop can switch to "Add from computer").
const [activeTab, setActiveTab] = useState(TAB_SAVED);
// Saved firmware configurations (flasher.yaml). null = pending, undefined = fetch failed,
// [] = none saved; the "No saved configurations yet." message is only for the last.
const [savedConfigs, setSavedConfigs] = useState(null);
// Only the newest configs request may write state: a save or delete refetches while the mount
// request may still be in flight.
const savedConfigsGen = useRef(0);
// Saved firmware configurations (flasher.yaml).
const [savedConfigs, setSavedConfigs] = useState([]);
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [saveName, setSaveName] = useState('');
@ -394,15 +388,14 @@ export function FlasherPage() {
const fetchMediaFirmware = React.useCallback(async (pathFilter, chip) => {
setMediaLoading(true);
try {
const result = await flasherSearch(chip || null, pathFilter || null);
// The api helper returns undefined (and toasts) on a non-OK response.
setMediaResults(result ? result[0] || [] : undefined);
const [fileGroups] = await flasherSearch(chip || null, pathFilter || null);
setMediaResults(fileGroups || []);
} catch (e) {
// Surface connectivity errors instead of showing the empty "no firmware found" state, which would
// be indistinguishable from genuinely having no firmware.
setError(e && e.message ? e.message : String(e));
setBootHint(false);
setMediaResults(undefined);
setMediaResults([]);
} finally {
setMediaLoading(false);
}
@ -431,19 +424,7 @@ export function FlasherPage() {
// Saved firmware configurations.
const fetchSavedConfigs = React.useCallback(async () => {
const gen = ++savedConfigsGen.current;
try {
const configs = await getFlasherConfigs();
if (gen === savedConfigsGen.current) {
// The api helper returns undefined on a non-OK response.
setSavedConfigs(configs ?? undefined);
}
} catch (e) {
console.error(e);
if (gen === savedConfigsGen.current) {
setSavedConfigs(undefined);
}
}
setSavedConfigs(await getFlasherConfigs());
}, []);
useEffect(() => {
@ -769,10 +750,8 @@ export function FlasherPage() {
onChange={(e) => handleMediaFilterChange(e.currentTarget.value)}
/>
<div style={{marginTop: '1em', overflowX: 'auto'}}>
{mediaLoading || mediaResults === null
{mediaLoading
? <Loading/>
: mediaResults === undefined
? <ErrorMessage>Could not search firmware.</ErrorMessage>
: (mediaResults.length === 0
? <p style={{opacity: 0.7}}>
No <code>.bin</code> firmware found
@ -833,9 +812,7 @@ export function FlasherPage() {
Configure the firmware files and offsets, then save them as a named set to re-flash later
(e.g. a Meshtastic T-Deck: firmware at <code>0x0</code>, littlefs at <code>0xc90000</code>).
</p>
{savedConfigs === null && <Placeholder lines={2}/>}
{savedConfigs === undefined && <ErrorMessage>Could not fetch saved configurations.</ErrorMessage>}
{Array.isArray(savedConfigs) && (savedConfigs.length > 0
{savedConfigs.length > 0
? <Panel style={{padding: 0}}>
{savedConfigs.map((configuration, index) => <div
key={configuration.name}
@ -869,7 +846,7 @@ export function FlasherPage() {
onClick={() => handleDeleteConfig(configuration.name)}/>
</div>)}
</Panel>
: <p style={{opacity: 0.7}}>No saved configurations yet.</p>)}
: <p style={{opacity: 0.7}}>No saved configurations yet.</p>}
</>;
return <div {...getRootProps()}>

View file

@ -265,43 +265,6 @@ describe('FlasherPage', () => {
}
});
// The Saved Firmwares tab is the default tab. "No saved configurations yet." may only appear once the
// server confirmed there are none; a pending or failed fetch must not read as an empty list.
it('shows a placeholder, not "no saved configurations", while the configs load', () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
getFlasherConfigs.mockReturnValue(new Promise(() => {
}));
const {container} = render(<FlasherPage/>);
expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument();
expect(screen.queryAllByText('No saved configurations yet.')).toHaveLength(0);
});
it('shows an error, not "no saved configurations", when the configs fetch failed', async () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
getFlasherConfigs.mockResolvedValue(undefined);
render(<FlasherPage/>);
expect((await screen.findAllByText(/Could not fetch saved configurations/)).length).toBeGreaterThan(0);
expect(screen.queryAllByText('No saved configurations yet.')).toHaveLength(0);
});
it('shows "no saved configurations" once the server confirmed there are none', async () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
render(<FlasherPage/>);
expect((await screen.findAllByText('No saved configurations yet.')).length).toBeGreaterThan(0);
});
// "No .bin firmware found" may only appear once the server searched and found none.
it('shows an error, not "no firmware found", when the firmware search failed', async () => {
Object.defineProperty(global.navigator, 'serial', {value: {}, configurable: true});
// The api helper returns undefined on a non-OK response.
flasherSearch.mockResolvedValue(undefined);
render(<FlasherPage/>);
await waitFor(() => expect(flasherSearch).toHaveBeenCalled());
switchTab('Choose from your WROLPi');
expect((await screen.findAllByText(/Could not search firmware/)).length).toBeGreaterThan(0);
expect(screen.queryAllByText(/firmware found/)).toHaveLength(0);
});
// PageContainer renders both the mobile and desktop breakpoints (fresnel Media), so each label appears twice.
it('warns when Web Serial is unavailable', () => {
delete global.navigator.serial;

View file

@ -1,4 +1,4 @@
import React, {useEffect, useRef, useState} from "react";
import React, {useEffect, useState} from "react";
import {
APIButton,
ErrorMessage,
@ -20,7 +20,6 @@ import {Media, StatusContext} from "../contexts/contexts";
import {MAP_VIEWER_URI} from "./Vars";
import MapViewer from "./MapViewer";
import {AddToPlaylistButton} from "./AddToPlaylist";
import {solarCalculatorPath} from "./calculators/solar";
import maplibregl from "maplibre-gl";
import {Protocol} from "pmtiles";
import layers from "protomaps-themes-base";
@ -285,15 +284,11 @@ function MapCatalogRow({item, subscribedRegions, fetchData}) {
function ManageMap() {
const [files, setFiles] = useState(null);
// null = pending, undefined = fetch failed, [] = no regions in the catalog.
const [catalog, setCatalog] = useState(null);
const [catalog, setCatalog] = useState([]);
const [subscriptions, setSubscriptions] = useState([]);
const [allRegionsOpen, setAllRegionsOpen] = useState(false);
const {status} = React.useContext(StatusContext);
const searchBuilding = status?.flags?.map_search_building;
// Only the newest subscriptions request may write state: a subscribe or unsubscribe refetches
// while the mount request may still be in flight.
const subscriptionsGen = useRef(0);
const fetchFiles = async () => {
try {
@ -308,24 +303,14 @@ function ManageMap() {
};
const fetchSubscriptionData = async () => {
const gen = ++subscriptionsGen.current;
try {
const data = await fetchMapSubscriptions();
if (gen !== subscriptionsGen.current) {
return;
}
if (data) {
setCatalog(data.catalog || []);
setSubscriptions(data.subscriptions || []);
} else {
// The api helper already toasted the non-OK response.
setCatalog(undefined);
}
} catch (e) {
console.error(e);
if (gen === subscriptionsGen.current) {
setCatalog(undefined);
}
}
};
@ -412,8 +397,6 @@ function ManageMap() {
</>;
}
const catalogLoaded = Array.isArray(catalog);
/*
* A fragment, not a `PageContainer`. `MapRoute` already provides the page chrome -- a
* `wrolpi-stack` wrapper with its own top margin -- so a second one put this page a further
@ -436,26 +419,18 @@ function ManageMap() {
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5em'}}>
<Header as='h3'>Map Subscriptions</Header>
{/* A pending or failed catalog is not "zero regions": the preview waits for one. The
modal is mounted only then, so it receives the state array itself and its map is
not rebuilt by a fresh `[]` on every status tick. */}
<Button size='xs' icon='globe' disabled={!catalogLoaded} onClick={() => setAllRegionsOpen(true)}>
View All Regions
</Button>
<Button size='xs' icon='globe' onClick={() => setAllRegionsOpen(true)}>View All Regions</Button>
</div>
{catalogLoaded && <AllRegionsPreviewModal catalog={catalog} open={allRegionsOpen}
onClose={() => setAllRegionsOpen(false)}/>}
<AllRegionsPreviewModal catalog={catalog} open={allRegionsOpen} onClose={() => setAllRegionsOpen(false)}/>
{catalog === undefined
? <ErrorMessage>Could not fetch map subscriptions</ErrorMessage>
: <SortableTable
tableHeaders={catalogHeaders}
data={catalog}
rowFunc={catalogRowFunc}
rowKey='region'
defaultSortColumn='name'
/>}
<SortableTable
tableHeaders={catalogHeaders}
data={catalog}
rowFunc={catalogRowFunc}
rowKey='region'
defaultSortColumn='name'
/>
<InfoMessage storageName='hint_map_subscribe'>
<div style={{fontWeight: 600, marginBottom: 4}}>How to get map files</div>
@ -522,29 +497,17 @@ function PinEditRow({pin, onSave, onCancel}) {
}
function MapPins() {
// null = pending, undefined = fetch failed, [] = no pins. The empty row invites the user to
// add one, so it may only appear for a confirmed empty list.
const [pins, setPins] = useState(null);
const [pins, setPins] = useState([]);
const [filter, setFilter] = useState('');
const [editingId, setEditingId] = useState(null);
const navigate = useNavigate();
// Only the newest pins request may write state: an edit or delete refetches while the mount
// request may still be in flight.
const pinsGen = useRef(0);
const fetchPins = async () => {
const gen = ++pinsGen.current;
try {
const data = await getMapPins();
if (gen === pinsGen.current) {
// The api helper returns undefined on a non-OK response.
setPins(data ? data.pins || [] : undefined);
}
if (data) setPins(data.pins || []);
} catch (e) {
console.error(e);
if (gen === pinsGen.current) {
setPins(undefined);
}
}
};
@ -568,9 +531,6 @@ function MapPins() {
navigate(`/map?lat=${pin.lat}&lon=${pin.lon}&z=14`);
};
const solarButton = (pin) => <Button size='xs' icon='sun' aria-label={`Solar estimate for ${pin.label}`}
onClick={() => navigate(solarCalculatorPath(pin.lat, pin.lon))}/>;
// --- Desktop headers and row ---
const fullHeaders = [
{key: 'color', text: '', sortBy: i => i.color},
@ -604,11 +564,10 @@ function MapPins() {
</Table.Cell>
<Table.Cell>{pin.created}</Table.Cell>
<Table.Cell>
{/* See PinEditRow: a Group so the controls line up and are spaced. */}
{/* See PinEditRow: a Group so the three controls line up and are spaced. */}
<Group gap='xs' wrap='nowrap'>
<Button size='xs' icon='edit' aria-label={`Edit ${pin.label}`}
onClick={() => setEditingId(pin.id)}/>
{solarButton(pin)}
<AddToPlaylistButton
size='xs'
icon='list'
@ -651,18 +610,15 @@ function MapPins() {
<div style={{fontSize: '0.6875rem', color: 'var(--muted)'}}>{pin.lat.toFixed(4)}, {pin.lon.toFixed(4)}</div>
</Table.Cell>
<Table.Cell>
<Group gap='xs' wrap='nowrap'>
{solarButton(pin)}
<APIButton
size='xs'
role='danger'
icon='trash'
aria-label={`Delete ${pin.label}`}
confirmContent='Delete this pin?'
confirmButton='Delete'
onClick={() => handleDelete(pin.id)}
/>
</Group>
<APIButton
size='xs'
role='danger'
icon='trash'
aria-label={`Delete ${pin.label}`}
confirmContent='Delete this pin?'
confirmButton='Delete'
onClick={() => handleDelete(pin.id)}
/>
</Table.Cell>
</Table.Row>;
};
@ -671,13 +627,8 @@ function MapPins() {
<Table.Cell colSpan={5}>No pins. Right-click on the map to add one.</Table.Cell>
</Table.Row>;
if (pins === undefined) {
return <ErrorMessage>Could not fetch map pins</ErrorMessage>;
}
const lowerFilter = filter.toLowerCase();
// Pending (null) passes through so SortableTable shows its placeholder.
const filteredPins = lowerFilter && pins
const filteredPins = lowerFilter
? pins.filter(p =>
(p.label || '').toLowerCase().includes(lowerFilter) ||
`${p.lat.toFixed(4)}, ${p.lon.toFixed(4)}`.includes(lowerFilter)

View file

@ -1,63 +0,0 @@
import {poiPopupHtml} from './mapPoiPopup';
// Clicking a POI on the map opens a popup built from attributes already in the pmtiles
// file: the name, its category (kind), sub-category (kind_detail), and elevation.
const feature = (properties) => ({properties});
test('name, kind, detail and elevation are all shown, humanized', () => {
const html = poiPopupHtml([feature({
name: 'Flattop Mountain', kind: 'peak', kind_detail: 'volcano', elevation: 1070,
})]);
expect(html).toContain('Flattop Mountain');
expect(html).toContain('Peak');
expect(html).toContain('Volcano');
expect(html).toContain('1070 m');
expect(html).toContain('3510 ft');
});
test('missing attributes are left out rather than shown as undefined', () => {
const html = poiPopupHtml([feature({name: 'Franz', kind: 'bakery'})]);
expect(html).toContain('Franz');
expect(html).toContain('Bakery');
expect(html).not.toContain('undefined');
expect(html).not.toContain('Elevation');
});
test('kind_detail of "none" is not shown', () => {
const html = poiPopupHtml([feature({name: 'West High', kind: 'school', kind_detail: 'none'})]);
expect(html).not.toContain('none');
expect(html).not.toContain('None');
});
test('the same feature at several min_zoom levels appears once', () => {
const html = poiPopupHtml([
feature({name: 'Westchester Lagoon', kind: 'water', min_zoom: 12}),
feature({name: 'Westchester Lagoon', kind: 'water', min_zoom: 14}),
]);
expect(html.match(/Westchester Lagoon/g)).toHaveLength(1);
});
test('feature properties are HTML escaped', () => {
const html = poiPopupHtml([feature({name: '<img src=x onerror=alert(1)>', kind: 'a&b'})]);
expect(html).not.toContain('<img');
expect(html).toContain('&lt;img');
expect(html).toContain('A&amp;b');
});
test('a point feature shows its own coordinates, lat then lon', () => {
const html = poiPopupHtml([{
properties: {name: 'Peak Two', kind: 'peak'},
geometry: {type: 'Point', coordinates: [-149.67301, 61.08571]},
}], {lng: -149.5, lat: 61.0});
expect(html).toContain('61.08571, -149.67301');
});
test('a feature without a point falls back to the clicked location', () => {
const html = poiPopupHtml([feature({name: 'Lagoon', kind: 'water'})], {lng: -149.5, lat: 61.0});
expect(html).toContain('61.00000, -149.50000');
});
test('no features gives no popup', () => {
expect(poiPopupHtml([])).toBeNull();
});

View file

@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react";
import {Link, useNavigate} from "react-router";
import {getMapSearchStatus, searchMap} from "../api";
import {Card, CardGroup, Header, Loading, Panel} from "./ui";
import {ErrorMessage, Paginator} from "./Common";
import {Paginator} from "./Common";
import {useLatestRequest, usePages} from "../hooks/customHooks";
import {QueryContext} from "../contexts/contexts";
import {MAP_VIEWER_URI} from "./Vars";
@ -68,24 +68,16 @@ export function MapSearchView() {
const {offset, limit, activePage, setPage, totalPages, setTotal} = usePages(12);
// useLatestRequest discards stale responses and catches errors, so rapid pagination
// can't show out-of-order results and a network failure can't leave the spinner stuck.
// Loading from the first render: a query of two or more characters always sends a request (below),
// so this settles, and the shorter-query branches render before the loading check.
const {data, sendRequest, loading} = useLatestRequest(300, true);
// null = unknown (pending, or the status could not be fetched), otherwise whether any map is
// installed. Only a real status may claim that no maps are installed.
const {data, sendRequest, loading} = useLatestRequest(300);
const [hasMaps, setHasMaps] = useState(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const status = await getMapSearchStatus();
if (!cancelled && status) {
const installed = (status.indexed || []).length + (status.missing || []).length;
setHasMaps(installed > 0);
}
} catch (e) {
console.error(e);
const status = await getMapSearchStatus();
if (!cancelled) {
const installed = (status.indexed || []).length + (status.missing || []).length;
setHasMaps(installed > 0);
}
})();
return () => {
@ -114,22 +106,12 @@ export function MapSearchView() {
</Panel>;
}
if (!searchStr) {
return <Panel><Header as="h4">Enter a search query to find map locations.</Header></Panel>;
}
if (searchStr.length < 2) {
return <Panel><Header as="h4">Enter at least two characters to find map locations.</Header></Panel>;
}
if (loading) {
return <Panel><Loading/></Panel>;
}
// Settled with no data: the request failed (the api helper returns undefined on a non-OK
// response, and useLatestRequest leaves null when it throws).
if (data == null) {
return <Panel><ErrorMessage>Could not search the map.</ErrorMessage></Panel>;
if (!searchStr) {
return <Panel><Header as="h4">Enter a search query to find map locations.</Header></Panel>;
}
if (results.length === 0) {

View file

@ -1,61 +0,0 @@
import React from 'react';
import {render, screen} from '../test-utils';
import {MapSearchView} from './MapSearchView';
import {getMapSearchStatus, searchMap} from '../api';
// The map search tab says "No map locations found" only when the server searched and found none,
// and "No maps are installed" only when the server said so. A failed request is an error.
jest.mock('../api', () => ({
...jest.requireActual('../api'),
getMapSearchStatus: jest.fn(),
searchMap: jest.fn(),
}));
const NOT_FOUND = /No map locations found/;
const NO_MAPS = /No maps are installed/;
const FAILED = /Could not search the map/;
const renderAt = (q) => render(<MapSearchView/>, {query: {searchParams: new URLSearchParams({q})}});
beforeEach(() => {
getMapSearchStatus.mockReset();
searchMap.mockReset();
getMapSearchStatus.mockResolvedValue({indexed: ['utah'], missing: []});
});
test('found nothing: says so', async () => {
searchMap.mockResolvedValue({results: [], total: 0});
renderAt('camp');
expect(await screen.findByText(NOT_FOUND)).toBeInTheDocument();
});
test('a failed search shows an error, not "No map locations found"', async () => {
searchMap.mockResolvedValue(undefined);
renderAt('camp');
expect(await screen.findByText(FAILED)).toBeInTheDocument();
expect(screen.queryByText(NOT_FOUND)).not.toBeInTheDocument();
});
test('a failed status check does not claim no maps are installed', async () => {
getMapSearchStatus.mockResolvedValue(undefined);
searchMap.mockResolvedValue({results: [], total: 0});
renderAt('camp');
expect(await screen.findByText(NOT_FOUND)).toBeInTheDocument();
expect(screen.queryByText(NO_MAPS)).not.toBeInTheDocument();
});
test('a thrown status check does not claim no maps are installed', async () => {
getMapSearchStatus.mockRejectedValue(new Error('down'));
searchMap.mockResolvedValue({results: [], total: 0});
renderAt('camp');
expect(await screen.findByText(NOT_FOUND)).toBeInTheDocument();
expect(screen.queryByText(NO_MAPS)).not.toBeInTheDocument();
});
test('the server saying no maps are installed is shown', async () => {
getMapSearchStatus.mockResolvedValue({indexed: [], missing: []});
searchMap.mockResolvedValue({results: [], total: 0});
renderAt('camp');
expect(await screen.findByText(NO_MAPS)).toBeInTheDocument();
});

View file

@ -1,158 +0,0 @@
import React from 'react';
import {act, fireEvent, screen} from '@testing-library/react';
import {Route, Routes} from 'react-router';
import {renderWithProviders as render} from '../test-utils';
import {MapRoute} from './Map';
// What the map's Pins and Manage tabs show while their lists are still loading, when the
// fetch failed, and when the server confirmed the list is empty. The empty messages invite
// the user to act ("Right-click on the map to add one"), so they may only appear for a
// confirmed empty list.
jest.mock('maplibre-gl', () => ({
__esModule: true,
default: {Map: class {
}, addProtocol: jest.fn(), Marker: class {
}},
}));
jest.mock('pmtiles', () => ({
__esModule: true, Protocol: class {
tile = jest.fn();
},
}));
jest.mock('protomaps-themes-base', () => ({__esModule: true, default: () => []}));
jest.mock('./MapViewer', () => ({__esModule: true, default: () => null}));
jest.mock('../api', () => ({
...jest.requireActual('../api'),
getMapFiles: jest.fn(),
fetchMapSubscriptions: jest.fn(),
getMapPins: jest.fn(),
}));
const api = require('../api');
const pending = () => new Promise(() => {
});
const NO_PINS = /No pins\. Right-click/;
const NO_RESULTS = /^No results$/;
// The pins page renders one table per breakpoint through `Media`, so the real media provider is
// needed; the queries below tolerate either variant being in the tree.
const renderAt = (route) => render(
<Routes><Route path='/map/*' element={<MapRoute/>}/></Routes>,
{route, withMedia: true},
);
beforeEach(() => {
jest.clearAllMocks();
// The media provider reads matchMedia; the global stub's implementation is reset between
// tests, so give it one here. Only the computer breakpoint (1024px) matches, so the
// provider reports a desktop viewport and the pins page renders its desktop table.
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: query.includes('1024'), media: query, onchange: null,
addListener: jest.fn(), removeListener: jest.fn(),
addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(),
}));
api.getMapFiles.mockResolvedValue({files: []});
api.fetchMapSubscriptions.mockResolvedValue({catalog: [], subscriptions: []});
api.getMapPins.mockResolvedValue({pins: []});
});
describe('the Pins tab', () => {
test('pending: shows a placeholder, not "No pins"', () => {
api.getMapPins.mockReturnValue(pending());
const {container} = renderAt('/map/pins');
expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument();
expect(screen.queryAllByText(NO_PINS)).toHaveLength(0);
});
test('failed: shows an error, not "No pins"', async () => {
// The api helper returns undefined on a non-OK response.
api.getMapPins.mockResolvedValue(undefined);
renderAt('/map/pins');
await act(async () => {
});
expect(screen.getByText(/Could not fetch map pins/)).toBeInTheDocument();
expect(screen.queryAllByText(NO_PINS)).toHaveLength(0);
});
test('empty: shows "No pins"', async () => {
renderAt('/map/pins');
await act(async () => {
});
expect(screen.getAllByText(NO_PINS).length).toBeGreaterThan(0);
});
test('loaded: lists the pins', async () => {
api.getMapPins.mockResolvedValue({pins: [{id: 1, lat: 40.1, lon: -111.2, label: 'Camp', color: '#f00'}]});
renderAt('/map/pins');
await act(async () => {
});
expect(screen.getAllByText('Camp').length).toBeGreaterThan(0);
expect(screen.queryAllByText(NO_PINS)).toHaveLength(0);
});
test('a pin opens the Solar calculator at its location', async () => {
api.getMapPins.mockResolvedValue({pins: [{id: 1, lat: 40.12346, lon: -111.2, label: 'Camp', color: '#f00'}]});
renderAt('/map/pins');
await act(async () => {
});
fireEvent.click(screen.getAllByRole('button', {name: 'Solar estimate for Camp'})[0]);
expect(window.location.pathname).toBe('/more/calculators');
expect(window.location.search).toBe('?calc=solar&lat=40.1235&lon=-111.2');
});
});
describe('the Manage tab subscriptions table', () => {
test('pending: shows a placeholder, not "No results"', async () => {
api.fetchMapSubscriptions.mockReturnValue(pending());
const {container} = renderAt('/map/manage');
await act(async () => {
});
// The files table has loaded (empty); the subscriptions table is still pending.
expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument();
expect(screen.queryByText(NO_RESULTS)).not.toBeInTheDocument();
});
test('failed: shows an error, not "No results"', async () => {
api.fetchMapSubscriptions.mockResolvedValue(undefined);
renderAt('/map/manage');
await act(async () => {
});
expect(screen.getByText(/Could not fetch map subscriptions/)).toBeInTheDocument();
expect(screen.queryByText(NO_RESULTS)).not.toBeInTheDocument();
});
test('empty: shows "No results"', async () => {
renderAt('/map/manage');
await act(async () => {
});
expect(screen.getByText(NO_RESULTS)).toBeInTheDocument();
});
// The "View All Regions" preview draws the catalog; a pending or failed catalog must not be
// previewed as zero regions.
test('pending: the regions preview is disabled', async () => {
api.fetchMapSubscriptions.mockReturnValue(pending());
renderAt('/map/manage');
await act(async () => {
});
expect(screen.getByRole('button', {name: 'View All Regions'})).toBeDisabled();
});
test('failed: the regions preview is disabled', async () => {
api.fetchMapSubscriptions.mockResolvedValue(undefined);
renderAt('/map/manage');
await act(async () => {
});
expect(screen.getByRole('button', {name: 'View All Regions'})).toBeDisabled();
});
test('loaded: the regions preview is enabled', async () => {
renderAt('/map/manage');
await act(async () => {
});
expect(screen.getByRole('button', {name: 'View All Regions'})).toBeEnabled();
});
});

View file

@ -1,5 +1,5 @@
import React, {useCallback, useContext, useEffect, useRef, useState} from "react";
import {useNavigate, useSearchParams} from "react-router";
import {useSearchParams} from "react-router";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import {Protocol} from "pmtiles";
@ -10,8 +10,6 @@ import {MAP_VIEWER_URI} from "./Vars";
import {Button, Checkbox, Header, Icon, Panel, TextInput, Toggle} from "./ui";
import {SettingsContext, ThemeContext} from "../contexts/contexts";
import {mapFlavor, mapSprite} from "../themes/names";
import {poiPopupHtml} from "./mapPoiPopup";
import {solarCalculatorPath} from "./calculators/solar";
// Terrain DEM file prefix for hillshade and contours.
const TERRAIN_PREFIX = "terrain-";
@ -423,13 +421,10 @@ function MapSearch({map}) {
lon = center.lng;
}
// A failed search (undefined) shows no dropdown, same as finding nothing: this is an
// as-you-type suggestion list, not a results page.
searchMap(q, 8, 0, lat, lon).then(data => {
const found = data?.results || [];
setResults(found);
setShowResults(found.length > 0);
}).catch(e => console.error(e));
setResults(data.results || []);
setShowResults((data.results || []).length > 0);
});
}, [map]);
const handleInput = useCallback((e) => {
@ -532,7 +527,6 @@ export default function MapViewer() {
const [mapReady, setMapReady] = useState(false);
const [error, setError] = useState(null);
const [contextMenu, setContextMenu] = useState(null);
const navigate = useNavigate();
const [addingPin, setAddingPin] = useState(null); // {lat, lon} when pin dialog is open
const markersRef = useRef([]);
const pinsVisibleRef = useRef(true);
@ -678,29 +672,6 @@ export default function MapViewer() {
map.on("click", () => setContextMenu(null));
map.on("movestart", () => setContextMenu(null));
// Click a POI to see the details the pmtiles file already has (kind, detail, elevation).
const poiLayerIds = () => (map.getStyle()?.layers || [])
.map(l => l.id)
.filter(id => id === "pois" || id.endsWith(":pois"))
.filter(id => map.getLayer(id));
map.on("click", (e) => {
if (destroyed) return;
const layers = poiLayerIds();
if (!layers.length) return;
const html = poiPopupHtml(map.queryRenderedFeatures(e.point, {layers}), e.lngLat);
if (!html) return;
new maplibregl.Popup({offset: 8, maxWidth: "260px", className: "wrolpi-map-popup"})
.setLngLat(e.lngLat)
.setHTML(html)
.addTo(map);
});
map.on("mousemove", (e) => {
if (destroyed) return;
const layers = poiLayerIds();
const hit = layers.length && map.queryRenderedFeatures(e.point, {layers}).length;
map.getCanvas().style.cursor = hit ? "pointer" : "";
});
// Long-press context menu (mobile).
let longPressTimer = null;
let touchStartPos = null;
@ -774,16 +745,12 @@ export default function MapViewer() {
}
mapRef.current = null;
setMapReady(false);
// Clean up map params from URL so they don't leak to other pages. Only while the URL is
// still a map page: after navigating away, lat/lon belong to the new page (the Solar
// calculator, for one).
// Clean up map params from URL so they don't leak to other pages.
const url = new URL(window.location);
if (url.pathname.startsWith("/map")) {
url.searchParams.delete("lat");
url.searchParams.delete("lon");
url.searchParams.delete("z");
window.history.replaceState(null, '', url.pathname + url.search);
}
url.searchParams.delete("lat");
url.searchParams.delete("lon");
url.searchParams.delete("z");
window.history.replaceState(null, '', url.pathname + url.search);
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
@ -929,12 +896,6 @@ export default function MapViewer() {
setContextMenu(null);
}, [contextMenu]);
const handleSolar = useCallback(() => {
if (!contextMenu) return;
setContextMenu(null);
navigate(solarCalculatorPath(contextMenu.lat, contextMenu.lon));
}, [contextMenu, navigate]);
const handleSetDefaultLocation = useCallback(async () => {
const map = mapRef.current;
if (!map) return;
@ -1011,14 +972,6 @@ export default function MapViewer() {
>
Add Pin Here
</div>
<div
style={{padding: "0.5rem 0.75rem", cursor: "pointer", userSelect: "none"}}
onMouseEnter={e => e.target.style.background = "#f0f0f0"}
onMouseLeave={e => e.target.style.background = "transparent"}
onClick={handleSolar}
>
Calculate Solar Performance
</div>
<div
style={{padding: "0.5rem 0.75rem", cursor: "pointer", userSelect: "none"}}
onMouseEnter={e => e.target.style.background = "#f0f0f0"}

View file

@ -1,45 +0,0 @@
import React from 'react';
import {renderWithProviders as render} from '../test-utils';
import MapViewer from './MapViewer';
// MapLibre needs WebGL, which jsdom lacks; these tests only cover what the viewer does to the URL.
jest.mock('maplibre-gl', () => ({
__esModule: true,
default: {Map: class {
}, addProtocol: jest.fn(), Marker: class {
}, Popup: class {
}},
}));
jest.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
jest.mock('pmtiles', () => ({
__esModule: true, Protocol: class {
tile = jest.fn();
},
}));
jest.mock('protomaps-themes-base', () => ({__esModule: true, default: () => []}));
jest.mock('@acalcutt/maplibre-contour-pmtiles', () => ({__esModule: true, default: {}}));
jest.mock('../api', () => ({
...jest.requireActual('../api'),
// Never resolves: the map never initializes, which these tests do not need.
getMapFiles: jest.fn(() => new Promise(() => {
})),
getMapPins: jest.fn(() => new Promise(() => {
})),
}));
describe('the map position in the URL', () => {
test('is removed when the map closes', () => {
const {unmount} = render(<MapViewer/>, {route: '/map?lat=40&lon=-105&z=8&layer=a'});
unmount();
expect(window.location.pathname).toBe('/map');
expect(window.location.search).toBe('?layer=a');
});
test('is not removed from the page the map navigated to', () => {
// "Calculate Solar Performance" navigates to a page whose own lat/lon must survive the map closing.
const {unmount} = render(<MapViewer/>, {route: '/map?lat=40&lon=-105&z=8'});
window.history.pushState({}, '', '/more/calculators?calc=solar&lat=40.0154&lon=-105.2705');
unmount();
expect(window.location.search).toBe('?calc=solar&lat=40.0154&lon=-105.2705');
});
});

View file

@ -1,170 +0,0 @@
import React from "react";
import MarkdownIt from "markdown-it";
import DOMPurify from "dompurify";
import {useLocalStorage} from "./Common";
import {Button, ButtonGroup, Loading, Message} from "./ui";
/*
* Raw HTML in the file is escaped, not parsed: markdown files arrive with downloads, and the preview
* renders on the API's origin. `validateLink` (on by default) refuses javascript:, vbscript:, file:,
* and non-image data: URLs. DOMPurify runs anyway, so neither guard is the only one.
*/
const markdown = new MarkdownIt({html: false, linkify: true});
// A URL with a scheme (https:, mailto:, ...) points somewhere other than the media directory.
const URL_WITH_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
/**
* Resolve a URL written in a markdown file against the file's own media URL, the way a browser
* would resolve it against a document in that directory.
*
* Returns the URL unchanged if it is not relative, or null if it climbs out of the media directory;
* only media is served relative to a file.
*/
function resolveRelativeURL(url, fileURL) {
if (!url || URL_WITH_SCHEME.test(url) || url.startsWith('/') || url.startsWith('#')) {
return url;
}
const resolved = new URL(url, new URL(fileURL, window.location.origin));
if (!resolved.pathname.startsWith('/media/')) {
return null;
}
return resolved.pathname + resolved.search + resolved.hash;
}
function rewriteURL(token, attr, fileURL) {
const url = token.attrGet(attr);
if (url === null) {
return;
}
const resolved = resolveRelativeURL(url, fileURL);
if (resolved === null) {
token.attrs = token.attrs.filter(([name]) => name !== attr);
} else {
token.attrSet(attr, resolved);
}
}
function openInNewTab(token) {
const href = token.attrGet('href');
// Following a link would close the preview; a fragment stays within the document.
if (href && !href.startsWith('#')) {
token.attrSet('target', '_blank');
token.attrSet('rel', 'noopener noreferrer');
}
}
/** An image the preview may load: one served by this WROLPi, or embedded in the file. */
function isLocalImage(src) {
return !src || src.startsWith('data:') || (src.startsWith('/') && !src.startsWith('//'));
}
/**
* Replace an image from another site with a link to it. Loading it would tell that site who is
* reading the file and when (a tracking pixel), so the reader chooses whether to follow it.
*
* Inside a link (a README badge) the image becomes the outer link's text; links cannot nest.
*/
function replaceRemoteImage(state, image, insideLink) {
const src = image.attrGet('src');
const text = new state.Token('text', '', 0);
text.content = image.content || src;
if (insideLink) {
return [text];
}
const linkOpen = new state.Token('link_open', 'a', 1);
linkOpen.attrSet('href', src);
openInNewTab(linkOpen);
return [linkOpen, text, new state.Token('link_close', 'a', -1)];
}
markdown.core.ruler.push('wrolpi_media_urls', (state) => {
const {fileURL} = state.env;
for (const token of state.tokens) {
if (!token.children) {
continue;
}
let insideLink = false;
token.children = token.children.flatMap(child => {
if (child.type === 'image') {
rewriteURL(child, 'src', fileURL);
return isLocalImage(child.attrGet('src')) ? [child] : replaceRemoteImage(state, child, insideLink);
} else if (child.type === 'link_open') {
insideLink = true;
rewriteURL(child, 'href', fileURL);
openInNewTab(child);
} else if (child.type === 'link_close') {
insideLink = false;
}
return [child];
});
}
});
/** Render markdown to sanitized HTML. Relative URLs resolve against `fileURL`, the file's /media/ URL. */
export function renderMarkdown(text, fileURL) {
const html = markdown.render(text, {fileURL});
return DOMPurify.sanitize(html, {ADD_ATTR: ['target']});
}
export function isMarkdownFile(mimetype, lowerPath) {
// By suffix too, so a markdown file whose stored type is still text/plain (or a language libmagic
// guessed) is not shown raw; the 2026_10_05_1200 migration retags those rows, a refresh does not.
return mimetype.startsWith('text/markdown')
|| (mimetype.startsWith('text/') && (lowerPath.endsWith('.md') || lowerPath.endsWith('.markdown')));
}
/** Preview a markdown file, formatted or as its raw source. */
export function MarkdownPreview({url}) {
const [formatted, setFormatted] = useLocalStorage('markdownPreviewFormatted', true);
const [text, setText] = React.useState(null);
const [error, setError] = React.useState(null);
React.useEffect(() => {
const controller = new AbortController();
setText(null);
setError(null);
fetch(url, {signal: controller.signal})
.then(response => {
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.text();
})
.then(setText)
.catch(e => {
if (e.name !== 'AbortError') {
console.error(e);
setError(e.message);
}
});
return () => controller.abort();
}, [url]);
const html = React.useMemo(() => text === null ? null : renderMarkdown(text, url), [text, url]);
let body;
if (error) {
body = <Message kind='error' title='Could not load file'>{error}</Message>;
} else if (text === null) {
body = <Loading/>;
} else if (formatted) {
body = <div className='markdown-body' dangerouslySetInnerHTML={{__html: html}}/>;
} else {
body = <pre className='markdown-raw' data-testid='markdown-raw'>{text}</pre>;
}
return <>
<ButtonGroup>
<Button size='small' variant={formatted ? 'filled' : 'default'} onClick={() => setFormatted(true)}>
Formatted
</Button>
<Button size='small' variant={formatted ? 'default' : 'filled'} onClick={() => setFormatted(false)}>
Raw
</Button>
</ButtonGroup>
<div className='markdown-preview'>
{body}
</div>
</>
}

View file

@ -1,198 +0,0 @@
import React from 'react';
import {fireEvent, screen, waitFor} from '@testing-library/react';
import {isMarkdownFile, MarkdownPreview, renderMarkdown} from './MarkdownPreview';
import {renderUI} from '../test-utils';
const FILE_URL = '/media/notes/water/storage.md';
/** Render markdown into a detached element so assertions read the DOM, not the HTML string. */
const renderToDOM = (text, fileURL = FILE_URL) => {
const div = document.createElement('div');
div.innerHTML = renderMarkdown(text, fileURL);
return div;
};
describe('renderMarkdown', () => {
test('formats headings, emphasis, lists, code, and tables', () => {
const div = renderToDOM([
'# Water',
'',
'Rotate *every* barrel.',
'',
'- one',
'- two',
'',
'```',
'boil 1 minute',
'```',
'',
'| a | b |',
'|---|---|',
'| 1 | 2 |',
].join('\n'));
expect(div.querySelector('h1').textContent).toBe('Water');
expect(div.querySelector('em').textContent).toBe('every');
expect(div.querySelectorAll('ul li')).toHaveLength(2);
expect(div.querySelector('pre code').textContent).toBe('boil 1 minute\n');
expect(div.querySelectorAll('table td')).toHaveLength(2);
});
test('raw HTML in the file is shown as text, never parsed', () => {
const div = renderToDOM('<script>alert(1)</script>\n\n<img src=x onerror="alert(2)">');
expect(div.querySelector('script')).toBeNull();
expect(div.querySelector('img')).toBeNull();
expect(div.textContent).toContain('<script>alert(1)</script>');
});
test('script URLs are not turned into links', () => {
const div = renderToDOM('[click](javascript:alert(1))');
expect(div.querySelector('a')).toBeNull();
});
test('relative images resolve against the markdown file directory', () => {
const div = renderToDOM('![pump](images/hand%20pump.png)\n\n![tank](../tank.jpg)');
const [pump, tank] = div.querySelectorAll('img');
expect(pump.getAttribute('src')).toBe('/media/notes/water/images/hand%20pump.png');
expect(tank.getAttribute('src')).toBe('/media/notes/tank.jpg');
});
test('relative links resolve against the markdown file directory and open a new tab', () => {
const a = renderToDOM('[filters](filters.md#ceramic)').querySelector('a');
expect(a.getAttribute('href')).toBe('/media/notes/water/filters.md#ceramic');
expect(a.getAttribute('target')).toBe('_blank');
});
test('a relative URL that climbs out of the media directory is dropped', () => {
const div = renderToDOM('[api](../../../api/status)\n\n![x](../../../../x.png)');
expect(div.querySelector('a').hasAttribute('href')).toBe(false);
expect(div.querySelector('img').hasAttribute('src')).toBe(false);
});
test.each([
['https://tracker.example/pixel.gif'],
['http://tracker.example/pixel.gif'],
// Protocol-relative: starts with a slash, but loads from another host.
['//tracker.example/pixel.gif'],
])('an image from another site (%s) is not loaded, it becomes a link', (src) => {
const div = renderToDOM(`![pixel](${src})`);
expect(div.querySelector('img')).toBeNull();
const a = div.querySelector('a');
expect(a.getAttribute('href')).toBe(src);
expect(a.textContent).toBe('pixel');
expect(a.getAttribute('target')).toBe('_blank');
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
});
test('an image from another site with no alt text is labeled by its URL', () => {
const a = renderToDOM('![](https://example.com/a.png)').querySelector('a');
expect(a.textContent).toBe('https://example.com/a.png');
});
test('an image from another site inside a link becomes the link text, not a nested link', () => {
// The badge pattern common in READMEs.
const div = renderToDOM('[![build status](https://ci.example/badge.svg)](https://ci.example/project)');
expect(div.querySelector('img')).toBeNull();
const links = div.querySelectorAll('a');
expect(links).toHaveLength(1);
expect(links[0].getAttribute('href')).toBe('https://ci.example/project');
expect(links[0].textContent).toBe('build status');
});
test('media and embedded data images still load', () => {
const [media, data] = renderToDOM('![a](/media/x/a.png)\n\n![b](data:image/png;base64,AAAA)')
.querySelectorAll('img');
expect(media.getAttribute('src')).toBe('/media/x/a.png');
expect(data.getAttribute('src')).toBe('data:image/png;base64,AAAA');
});
test('external links open in a new tab without an opener', () => {
const a = renderToDOM('<https://example.com/page>').querySelector('a');
expect(a.getAttribute('href')).toBe('https://example.com/page');
expect(a.getAttribute('target')).toBe('_blank');
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
});
test('absolute and fragment URLs are left alone', () => {
const [root, fragment] = renderToDOM('[root](/files)\n\n[top](#top)').querySelectorAll('a');
expect(root.getAttribute('href')).toBe('/files');
expect(fragment.getAttribute('href')).toBe('#top');
expect(fragment.hasAttribute('target')).toBe(false);
});
});
describe('MarkdownPreview', () => {
const SOURCE = '# Water storage\n\nRotate *every* six months.\n';
beforeEach(() => {
window.localStorage.clear();
global.fetch = jest.fn(() => Promise.resolve({
ok: true,
text: () => Promise.resolve(SOURCE),
}));
});
afterEach(() => {
delete global.fetch;
});
test('fetches the file and shows it formatted', async () => {
renderUI(<MarkdownPreview url={FILE_URL}/>);
const heading = await screen.findByRole('heading', {name: 'Water storage'});
expect(heading.tagName).toBe('H1');
expect(global.fetch).toHaveBeenCalledWith(FILE_URL, expect.anything());
});
test('Raw shows the unformatted source, and the choice is remembered', async () => {
const {unmount} = renderUI(<MarkdownPreview url={FILE_URL}/>);
await screen.findByRole('heading', {name: 'Water storage'});
fireEvent.click(screen.getByRole('button', {name: 'Raw'}));
expect(screen.queryByRole('heading')).toBeNull();
expect(screen.getByTestId('markdown-raw').textContent).toBe(SOURCE);
// The next preview opens in the mode the reader chose last.
unmount();
renderUI(<MarkdownPreview url={FILE_URL}/>);
await waitFor(() => expect(screen.getByTestId('markdown-raw').textContent).toBe(SOURCE));
expect(screen.queryByRole('heading')).toBeNull();
fireEvent.click(screen.getByRole('button', {name: 'Formatted'}));
expect(screen.getByRole('heading', {name: 'Water storage'})).toBeTruthy();
});
test('a failed fetch shows an error instead of loading forever', async () => {
global.fetch = jest.fn(() => Promise.resolve({ok: false, status: 404, statusText: 'Not Found'}));
renderUI(<MarkdownPreview url={FILE_URL}/>);
expect(await screen.findByText(/404 Not Found/)).toBeTruthy();
});
});
describe('isMarkdownFile', () => {
test.each([
['text/markdown', 'notes/a.md', true],
// A stored type from before text/markdown existed; refresh does not retag unchanged files.
['text/plain', 'notes/a.md', true],
['text/plain', 'notes/a.markdown', true],
['text/plain', 'notes/a.txt', false],
// A suffix alone is not enough; a binary file named .md is not markdown.
['application/octet-stream', 'notes/a.md', false],
])('%s %s -> %s', (mimetype, lowerPath, expected) => {
expect(isMarkdownFile(mimetype, lowerPath)).toBe(expected);
});
});

View file

@ -21,7 +21,6 @@ import {HELP_VIEWER_URI, NAME, navColorHexMap} from "./Vars";
import {useOverflowNav} from "../hooks/useOverflowNav";
import {defaultNavColor, navBarStyle, useNavColors} from "../themes/navColors";
import _ from "lodash";
import {BookmarksMenuContents, EDIT_BOOKMARKS_PATH} from "./Bookmarks";
function updateFavicon(colorName) {
// Fall back to violet if invalid color
@ -61,23 +60,17 @@ const allLinks = [
{text: 'Files', to: '/files', key: 'files'},
{text: 'Playlists', to: '/playlists', key: 'playlists'},
{text: 'Zim', to: '/zim', key: 'zim'},
{text: 'Repos', to: '/repos', key: 'repos'},
{text: 'Inventory', to: '/inventory', key: 'inventory'},
{text: 'AI', to: '/ai', key: 'ai'},
// No `end` on these two: a tab stays current for the pages beneath it, and `end` would
// drop the mark the moment either grows a sub-page.
{to: '/more/calculators', text: 'Calculators', key: 'calculators'},
// The user's bookmarks, read from BookmarksContext when rendered. No `to`: it is a
// menu, and its items are not sections of the app.
{text: 'Bookmarks', key: 'bookmarks', bookmarks: true},
{text: 'Flasher', to: '/flasher', key: 'flasher'},
{to: '/more/statistics', text: 'Statistics', key: 'statistics'},
];
function MenuLink({link}) {
// A top-level tab rendered directly in the bar (not inside a dropdown).
if (link.bookmarks) {
return <BookmarksDropdown link={link}/>
}
if (link.links) {
return <DropdownLinks link={link}/>
}
@ -93,31 +86,13 @@ function MenuLink({link}) {
</NavLink>
}
function DropdownMenuItem({link, nested = true}) {
function DropdownMenuItem({link}) {
// An item inside a Menu.Dropdown: the mobile hamburger menu, or the desktop
// "More" overflow menu. `nested` allows flyout submenus, which the hamburger
// declines: a flyout opens on hover, and a phone has none.
if (link.bookmarks) {
if (nested) {
return <Menu.Sub>
<Menu.Sub.Target>
<Menu.Sub.Item>{link.text}</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
<BookmarksMenuContents nested/>
</Menu.Sub.Dropdown>
</Menu.Sub>
}
return <React.Fragment>
<Menu.Divider/>
<Menu.Label>{link.text}</Menu.Label>
<BookmarksMenuContents nested={false}/>
<Menu.Divider/>
</React.Fragment>
}
// "More" overflow menu.
if (link.links) {
// A labelled, indented group stands in for a submenu here. No current data uses
// this path (no allLinks entry has its own `links`).
// Mantine's Menu does not nest submenus without extra plumbing; a labelled,
// indented group stands in for one. No current data uses this path (no
// allLinks entry has its own `links`).
return <React.Fragment>
<Menu.Label>{link.text}</Menu.Label>
{link.links.map(l => <DropdownMenuItem key={l.key} link={l}/>)}
@ -167,11 +142,6 @@ NavDropdownTrigger.displayName = 'NavDropdownTrigger';
* the hooks that would answer it cannot be called in a loop.
*/
export const isLinkActive = (pathname, link) => {
// The Bookmarks menu owns only its editor page: a bookmark pointing at /videos does
// not make the menu own the Videos section.
if (link.bookmarks) {
return pathname === EDIT_BOOKMARKS_PATH || pathname.startsWith(`${EDIT_BOOKMARKS_PATH}/`);
}
// An off-site link (Help) is never the current section.
if (!link.to || /^[a-z]+:\/\//i.test(link.to)) return false;
const to = link.to.length > 1 ? link.to.replace(/\/+$/, '') : link.to;
@ -206,24 +176,6 @@ function DropdownLinks({link}) {
</Menu>
}
/**
* The Bookmarks tab: a dropdown that opens on hover as well as on click, holding the
* user's bookmarks and the link to edit them. Marked current on the editor page only
* (see isLinkActive).
*/
function BookmarksDropdown({link}) {
const {pathname} = useLocation();
const holdsCurrent = isLinkActive(pathname, link);
return <Menu position='bottom-start' withinPortal trigger='click-hover' openDelay={100} closeDelay={200}>
<Menu.Target>
<NavDropdownTrigger text={link.text} className={holdsCurrent ? 'active' : undefined}/>
</Menu.Target>
<Menu.Dropdown>
<BookmarksMenuContents nested/>
</Menu.Dropdown>
</Menu>
}
function MobileMenu({links}) {
// The mobile hamburger menu: an icon-only trigger holding every link.
return <Menu position='bottom-end' withinPortal>
@ -231,7 +183,7 @@ function MobileMenu({links}) {
<IconButton icon={IconMenu2} label='Menu' variant='subtle'/>
</Menu.Target>
<Menu.Dropdown>
{links.map(link => <DropdownMenuItem key={link.key} link={link} nested={false}/>)}
{links.map(link => <DropdownMenuItem key={link.key} link={link}/>)}
</Menu.Dropdown>
</Menu>
}

View file

@ -231,91 +231,3 @@ describe('NavBar current tab', () => {
expect(ruleBody('\\.mantine-Menu-item\\.active')).toMatch(/background:|font-weight:/);
});
});
describe('NavBar bookmarks', () => {
const {BookmarksContext} = require('../contexts/BookmarksContext');
const bookmarks = [
{id: 1, name: 'Channels', url: '/videos/channel', new_tab: false},
{id: 2, name: 'Services', children: [{id: 3, name: 'Jellyfin', url: ':8096/', new_tab: true}]},
];
const renderWithBookmarks = (route, nodes) => render(<NavBar/>, {
route,
withMedia: true,
contexts: [
[FileWorkerStatusContext, fileWorkerStatus],
[BookmarksContext, {bookmarks: nodes, error: null, refresh: jest.fn()}],
],
});
it('sits between Calculators and Flasher', () => {
renderNav('/');
const texts = [...document.querySelectorAll('#global_navbar .wrolpi-navbar-link')]
.map(el => el.textContent.trim());
const at = texts.indexOf('Bookmarks');
expect(at).toBeGreaterThan(0);
expect(texts[at - 1]).toBe('Calculators');
expect(texts[at + 1]).toBe('Flasher');
});
it('opens on click with the bookmarks, a directory flyout, and the edit link', async () => {
useRealRects();
renderWithBookmarks('/', bookmarks);
await userEvent.click(screen.getByRole('button', {name: /Bookmarks/}));
const channels = await screen.findByRole('menuitem', {name: 'Channels'});
expect(channels).toHaveAttribute('href', '/videos/channel');
expect(channels).not.toHaveAttribute('target');
// A directory is a submenu; its bookmarks appear once it is hovered.
expect(screen.queryByRole('menuitem', {name: 'Jellyfin'})).not.toBeInTheDocument();
await userEvent.hover(screen.getByRole('menuitem', {name: 'Services'}));
// `hidden`: the flyout is placed by floating-ui, which jsdom cannot lay out, so it
// stays hidden to accessibility queries even though it has rendered.
const jellyfin = await screen.findByRole('menuitem', {name: 'Jellyfin', hidden: true});
expect(jellyfin).toHaveAttribute('href', `${window.location.protocol}//${window.location.hostname}:8096/`);
expect(jellyfin).toHaveAttribute('target', '_blank');
expect(screen.getByRole('menuitem', {name: 'Edit bookmarks', hidden: true})).toHaveAttribute('href', '/more/bookmarks');
});
it('offers the edit link even with no bookmarks, so the feature can be found', async () => {
useRealRects();
renderWithBookmarks('/', []);
await userEvent.click(screen.getByRole('button', {name: /Bookmarks/}));
expect(await screen.findByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument();
expect(screen.getAllByRole('menuitem')).toHaveLength(1);
});
it('is marked current on the editor page, and never for a bookmark pointing at a section', () => {
renderWithBookmarks('/more/bookmarks', bookmarks);
expect(screen.getByRole('button', {name: /Bookmarks/})).toHaveClass('active');
expect(currentTab()).toEqual([]);
});
it('marks More when the editor page is folded inside it', () => {
useBarWidth(200);
renderWithBookmarks('/more/bookmarks', bookmarks);
expect(screen.getByRole('button', {name: /More/})).toHaveClass('active');
});
it('does not own the section a bookmark points at', () => {
renderWithBookmarks('/videos/channel', bookmarks);
expect(screen.getByRole('button', {name: /Bookmarks/})).not.toHaveClass('active');
expect(currentTab()).toEqual(['Videos']);
});
it('lists bookmarks as a labelled group in the mobile menu', async () => {
useViewport('mobile');
useRealRects();
renderWithBookmarks('/', bookmarks);
await userEvent.click(screen.getByRole('button', {name: 'Menu'}));
expect(await screen.findByRole('menuitem', {name: 'Channels'})).toBeInTheDocument();
// No flyout on a phone: the directory's bookmarks are listed directly.
expect(screen.getByRole('menuitem', {name: 'Jellyfin'})).toBeInTheDocument();
expect(screen.getByText('Services')).toBeInTheDocument();
expect(screen.getByRole('menuitem', {name: 'Edit bookmarks'})).toBeInTheDocument();
});
});

View file

@ -1,174 +0,0 @@
import React from 'react';
import {act, render, screen} from '../test-utils';
import {OtherSearchView} from './Search';
import {searchChannels, searchCollections, searchRepos} from '../api';
// What the search page's Other tab shows for each state of useSearchChannels. "No Channels"
// must only appear once the server has confirmed there are none.
jest.mock('../api', () => ({
...jest.requireActual('../api'),
searchChannels: jest.fn(),
searchCollections: jest.fn(),
searchRepos: jest.fn(),
}));
const NO_CHANNELS = /No Channels/;
beforeEach(() => {
searchChannels.mockReset();
searchRepos.mockReset();
searchRepos.mockResolvedValue({repos: [], total: 0});
searchCollections.mockReset();
searchCollections.mockResolvedValue({collections: []});
});
test('pending: shows a loader, not "No Channels"', () => {
searchChannels.mockReturnValue(new Promise(() => {
}));
const {container} = render(<OtherSearchView loading={false}/>);
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
});
test('failed: shows an error, not "No Channels"', async () => {
searchChannels.mockResolvedValue(undefined);
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
});
try {
render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText(/Could not fetch the channels/)).toBeInTheDocument();
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
} finally {
spy.mockRestore();
}
});
test('empty: shows "No Channels"', async () => {
searchChannels.mockResolvedValue({channels: []});
render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText(NO_CHANNELS)).toBeInTheDocument();
});
test('loaded: lists the channels', async () => {
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText('Cooking')).toBeInTheDocument();
expect(screen.queryByText(NO_CHANNELS)).not.toBeInTheDocument();
});
describe('Repos', () => {
test('pending: the Repos section loads while Channels are shown', async () => {
searchChannels.mockResolvedValue({channels: []});
searchRepos.mockReturnValue(new Promise(() => {
}));
const {container} = render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText(NO_CHANNELS)).toBeInTheDocument();
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
expect(screen.queryByText('No Repos')).not.toBeInTheDocument();
});
test('empty: shows "No Repos"', async () => {
searchChannels.mockResolvedValue({channels: []});
render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText('No Repos')).toBeInTheDocument();
});
test('failed: shows an error only in the Repos section', async () => {
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
searchRepos.mockRejectedValue(new Error('boom'));
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
});
try {
render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText('Cooking')).toBeInTheDocument();
expect(screen.getByText(/Could not fetch the repos/)).toBeInTheDocument();
} finally {
spy.mockRestore();
}
});
test('loaded: lists repos by the search, with their README headline', async () => {
searchChannels.mockResolvedValue({channels: []});
searchRepos.mockResolvedValue({
total: 1, repos: [{
id: 7, name: 'kiwix-tools', tag_name: null,
// The server escapes the README; only the highlight is markup.
readme_headline: 'Serve <b>Zim</b> files &lt;script&gt;',
}],
});
const searchParams = new URLSearchParams('q=zim&tag=Software');
const {container} = render(<OtherSearchView loading={false}/>, {query: {searchParams}});
await act(async () => {
});
expect(searchRepos).toHaveBeenCalledWith('zim', ['Software']);
expect(screen.getByText('kiwix-tools').closest('a')).toHaveAttribute('href', '/repos/7');
expect(container.querySelector('u').textContent).toBe('Zim');
expect(container.querySelector('script')).toBeNull();
expect(screen.getByText(/files <script>/)).toBeInTheDocument();
});
});
// Domains and Playlists are Collections searched by kind.
describe.each([
['domain', 'Domains', 'example.com', '/archives?domain=example.com'],
['playlist', 'Playlists', 'Road Trip', '/playlists/3'],
])('%s Collections', (kind, title, name, href) => {
const collectionsOf = (wanted, collections) => (k) => Promise.resolve({collections: k === wanted ? collections : []});
test(`empty: shows "No ${title}"`, async () => {
searchChannels.mockResolvedValue({channels: []});
render(<OtherSearchView loading={false}/>, {query: {searchParams: new URLSearchParams('q=nothing')}});
await act(async () => {
});
expect(screen.getByText(`No ${title}`)).toBeInTheDocument();
});
test('nothing to search by: shows none without fetching', async () => {
searchChannels.mockResolvedValue({channels: []});
render(<OtherSearchView loading={false}/>);
await act(async () => {
});
expect(screen.getByText(`No ${title}`)).toBeInTheDocument();
expect(searchCollections).not.toHaveBeenCalled();
});
test(`failed: shows an error only in the ${title} section`, async () => {
searchChannels.mockResolvedValue({channels: [{id: 1, name: 'Cooking'}]});
searchCollections.mockImplementation(k => k === kind ? Promise.reject(new Error('boom')) : Promise.resolve({collections: []}));
const spy = jest.spyOn(console, 'error').mockImplementation(() => {
});
try {
render(<OtherSearchView loading={false}/>, {query: {searchParams: new URLSearchParams('q=x')}});
await act(async () => {
});
expect(screen.getByText('Cooking')).toBeInTheDocument();
expect(screen.getByText(new RegExp(`Could not fetch the ${title.toLowerCase()}`))).toBeInTheDocument();
} finally {
spy.mockRestore();
}
});
test('loaded: lists them by the search and tags, linked', async () => {
searchChannels.mockResolvedValue({channels: []});
searchCollections.mockImplementation(collectionsOf(kind, [{id: 3, name, kind, tag_name: null}]));
const searchParams = new URLSearchParams('q=road&tag=News');
render(<OtherSearchView loading={false}/>, {query: {searchParams}});
await act(async () => {
});
expect(searchCollections).toHaveBeenCalledWith(kind, ['News'], 'road');
expect(screen.getByText(name).closest('a')).toHaveAttribute('href', href);
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,394 +0,0 @@
import React from 'react';
import {Route, Routes} from 'react-router';
import {act, fireEvent, render, screen, waitFor, within} from '../test-utils';
import {
RepoEditPage,
RepoImportPage,
repoNameFromURL,
RepoNewPage,
ReposPage,
RepoViewPage,
safeRepoHref,
} from './Repos';
import {
createRepo, deleteRepo, fetchRepos, getFile, getRepo, getRepoLog, getRepoTree, importRepo, inspectRepoImport,
updateRepo,
} from '../api';
import {FilePreviewContext} from './FilePreview';
jest.mock('../api', () => ({
...jest.requireActual('../api'),
createRepo: jest.fn(),
deleteRepo: jest.fn(),
fetchRepos: jest.fn(),
getFile: jest.fn(),
getRepo: jest.fn(),
getRepoLog: jest.fn(),
getRepoTree: jest.fn(),
importRepo: jest.fn(),
inspectRepoImport: jest.fn(),
updateRepo: jest.fn(),
}));
// The directory picker searches the server; picking a directory is all these specs need.
jest.mock('./Common', () => ({
...jest.requireActual('./Common'),
DirectorySearch: ({onSelect}) => <button type='button' onClick={() => onSelect('code/kiwix-tools')}>
Pick directory
</button>,
}));
// The README is fetched from the media server; its rendering is MarkdownPreview's own spec.
jest.mock('./MarkdownPreview', () => ({
...jest.requireActual('./MarkdownPreview'),
MarkdownPreview: ({url}) => <div data-testid='markdown-preview'>{url}</div>,
}));
const repoFixture = (overrides = {}) => ({
id: 1, collection_id: 10, name: 'kiwix-tools', tag_name: null, description: null,
url: 'https://github.com/kiwix/kiwix-tools', mode: 'full', branch: null, default_branch: 'main',
head_sha: '3f9c2a1e7b', head_message: 'Fix the mimetype', head_date: '2026-09-30T14:02:00Z',
size: 1000, directory: 'repos/kiwix-tools', frequency: 604800, download_error: null,
...overrides,
});
const treeFixture = {
path: '',
readme_path: 'README.md',
entries: [
{name: 'src', path: 'src', is_dir: true, size: null},
{name: 'README.md', path: 'README.md', is_dir: false, size: 120},
],
};
const flush = () => act(async () => {
});
beforeEach(() => jest.clearAllMocks());
describe('safeRepoHref', () => {
it('only links https URLs', () => {
expect(safeRepoHref('https://github.com/a/b')).toBe('https://github.com/a/b');
expect(safeRepoHref('javascript:alert(1)')).toBeNull();
expect(safeRepoHref('http://github.com/a/b')).toBeNull();
expect(safeRepoHref('file:///etc/passwd')).toBeNull();
expect(safeRepoHref('not a url')).toBeNull();
});
});
describe('repoNameFromURL', () => {
it('is the last path segment, without .git or a branch', () => {
expect(repoNameFromURL('https://github.com/kiwix/kiwix-tools.git')).toBe('kiwix-tools');
expect(repoNameFromURL('https://github.com/kiwix/kiwix-tools/tree/dev')).toBe('kiwix-tools');
expect(repoNameFromURL('https://gitlab.com/group/sub/project/')).toBe('project');
expect(repoNameFromURL('nope')).toBe('');
});
});
describe('ReposPage', () => {
it('lists repos, linking each to its page', async () => {
fetchRepos.mockResolvedValue([
repoFixture(),
repoFixture({id: 2, name: 'gone', download_error: 'git ls-remote failed', head_sha: null}),
]);
render(<ReposPage/>, {route: '/repos'});
await flush();
expect(screen.getAllByText('kiwix-tools')[0].closest('a')).toHaveAttribute('href', '/repos/1');
expect(screen.getAllByText('gone')[0].closest('a')).toHaveAttribute('href', '/repos/2');
expect(screen.getByText('New Repo').closest('a')).toHaveAttribute('href', '/repos/new');
});
it('shows a repo which is never updated', async () => {
fetchRepos.mockResolvedValue([repoFixture({frequency: 0})]);
render(<ReposPage/>, {route: '/repos'});
await flush();
expect(screen.getAllByText('Never').length).toBeGreaterThan(0);
});
it('explains an empty list', async () => {
fetchRepos.mockResolvedValue([]);
render(<ReposPage/>, {route: '/repos'});
await flush();
expect(screen.getByText(/No repos yet/)).toBeInTheDocument();
});
});
function renderViewPage({repo, tree = treeFixture, query = {}, setPreviewFile = jest.fn()} = {}) {
getRepo.mockResolvedValue(repo);
getRepoTree.mockResolvedValue(tree);
return render(<Routes><Route path='/repos/:repoId' element={<RepoViewPage/>}/></Routes>, {
route: '/repos/1',
query,
contexts: [[FilePreviewContext, {setPreviewFile}]],
});
}
describe('RepoViewPage', () => {
it('shows the latest commit, the files, and the README', async () => {
renderViewPage({repo: repoFixture()});
await flush();
expect(getRepoTree).toHaveBeenCalledWith('1', '');
expect(screen.getByText('3f9c2a1')).toBeInTheDocument();
expect(screen.getByText('Fix the mimetype')).toBeInTheDocument();
expect(screen.getByText('main')).toBeInTheDocument();
expect(screen.getByText('(default)')).toBeInTheDocument();
expect(screen.getByText('https://github.com/kiwix/kiwix-tools').closest('a'))
.toHaveAttribute('href', 'https://github.com/kiwix/kiwix-tools');
expect(screen.getByText('src')).toBeInTheDocument();
expect(screen.getByTestId('markdown-preview')).toHaveTextContent('/media/repos/kiwix-tools/README.md');
});
it('opens a directory through the URL, and a file in the preview', async () => {
const updateQuery = jest.fn();
const setPreviewFile = jest.fn();
const file = {path: 'repos/kiwix-tools/README.md', mimetype: 'text/markdown', size: 120};
getFile.mockResolvedValue(file);
renderViewPage({repo: repoFixture(), query: {updateQuery}, setPreviewFile});
await flush();
fireEvent.click(screen.getByText('src'));
expect(updateQuery).toHaveBeenCalledWith({path: 'src'}, false);
fireEvent.click(screen.getAllByText('README.md')[0]);
await flush();
expect(getFile).toHaveBeenCalledWith('repos/kiwix-tools/README.md', true);
expect(setPreviewFile).toHaveBeenCalledWith(file);
});
it('lists the directory in the URL, with a way back up', async () => {
const tree = {path: 'src/lib', readme_path: null, entries: [{name: 'a.c', path: 'src/lib/a.c', is_dir: false}]};
const updateQuery = jest.fn();
renderViewPage({repo: repoFixture(), tree, query: {searchParams: new URLSearchParams('path=src/lib'), updateQuery}});
await flush();
expect(getRepoTree).toHaveBeenCalledWith('1', 'src/lib');
expect(screen.queryByTestId('markdown-preview')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('..'));
expect(updateQuery).toHaveBeenCalledWith({path: 'src'}, false);
// The breadcrumb's root.
fireEvent.click(screen.getAllByText('kiwix-tools').find(i => i.tagName === 'BUTTON'));
expect(updateQuery).toHaveBeenCalledWith({path: ''}, false);
});
it('offers a ZIP of the files', async () => {
renderViewPage({repo: repoFixture()});
await flush();
const link = screen.getByText('Download ZIP').closest('a');
expect(link).toHaveAttribute('href', expect.stringMatching(/\/api\/repos\/1\/archive\.zip$/));
expect(link).toHaveAttribute('download');
});
it('shows the history instead of the files', async () => {
getRepoLog.mockResolvedValue({
total: 51,
commits: [{sha: 'abcdef1234', author: 'Ada', date: '2026-09-30T14:02:00Z', message: 'Fix the mimetype'}],
});
const updateQuery = jest.fn();
renderViewPage({repo: repoFixture(), query: {searchParams: new URLSearchParams('view=history'), updateQuery}});
await flush();
expect(getRepoLog).toHaveBeenCalledWith(1, 0, 50);
expect(getRepoTree).not.toHaveBeenCalled();
expect(screen.getByText('51 commits')).toBeInTheDocument();
expect(screen.getByText('abcdef1')).toBeInTheDocument();
expect(screen.getByText('Ada')).toBeInTheDocument();
// The next page is appended.
getRepoLog.mockResolvedValue({
total: 51,
commits: [{sha: '0123456789', author: 'Ben', date: '2026-09-01T00:00:00Z', message: 'Older'}],
});
fireEvent.click(screen.getByText('Load more'));
await flush();
expect(getRepoLog).toHaveBeenLastCalledWith(1, 1, 50);
expect(screen.getByText('abcdef1')).toBeInTheDocument();
expect(screen.getByText('0123456')).toBeInTheDocument();
fireEvent.click(screen.getByText('Files'));
expect(updateQuery).toHaveBeenCalledWith({view: null}, false);
});
it('explains a repo that is not downloaded yet', async () => {
renderViewPage({repo: repoFixture({head_sha: null, head_message: null, head_date: null})});
await flush();
expect(screen.getByText('Not downloaded yet')).toBeInTheDocument();
expect(getRepoTree).not.toHaveBeenCalled();
expect(screen.queryByText('Download ZIP')).not.toBeInTheDocument();
expect(screen.queryByText('History')).not.toBeInTheDocument();
});
it('keeps showing the files when the last update failed', async () => {
renderViewPage({repo: repoFixture({download_error: 'Traceback...\nDownloadError: git ls-remote failed (128)'})});
await flush();
expect(screen.getByText('The last update failed')).toBeInTheDocument();
expect(screen.getByText(/git ls-remote failed/)).toBeInTheDocument();
expect(screen.getByText('src')).toBeInTheDocument();
});
it('says a repo which is never updated is only updated on request', async () => {
renderViewPage({repo: repoFixture({frequency: 0, download_error: 'git ls-remote failed (128)'})});
await flush();
expect(screen.getByText('The last update failed')).toBeInTheDocument();
expect(screen.getByText(/never updated automatically/)).toBeInTheDocument();
expect(screen.queryByText(/will be tried again/)).not.toBeInTheDocument();
});
it('never links an upstream that is not https', async () => {
renderViewPage({repo: repoFixture({url: 'javascript:alert(1)'})});
await flush();
expect(screen.queryByText('javascript:alert(1)')).not.toBeInTheDocument();
});
});
describe('RepoNewPage', () => {
it('adds a repo, then opens it', async () => {
createRepo.mockResolvedValue(repoFixture({id: 5}));
render(<Routes>
<Route path='/repos/new' element={<RepoNewPage/>}/>
<Route path='/repos/:repoId' element={<div>repo page</div>}/>
</Routes>, {route: '/repos/new'});
const add = screen.getByText('Add Repo').closest('button');
expect(add).toBeDisabled();
fireEvent.change(screen.getByLabelText(/URL/), {target: {value: 'git@github.com:a/b'}});
expect(add).toBeDisabled();
expect(screen.getByText('Must be an https:// URL')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/URL/), {target: {value: 'https://github.com/kiwix/kiwix-tools'}});
expect(screen.getByPlaceholderText('kiwix-tools')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Branch'), {target: {value: ' dev '}});
fireEvent.click(screen.getByText('Pick directory'));
fireEvent.click(add);
await flush();
expect(createRepo).toHaveBeenCalledWith({
url: 'https://github.com/kiwix/kiwix-tools', name: null, tag_name: null, frequency: 604800,
mode: 'full', branch: 'dev', submodules: false, description: null, directory: 'code/kiwix-tools',
});
expect(screen.getByText('repo page')).toBeInTheDocument();
});
});
describe('RepoEditPage', () => {
function renderEditPage(repo) {
getRepo.mockResolvedValue(repo);
return render(<Routes>
<Route path='/repos/:repoId/edit' element={<RepoEditPage/>}/>
<Route path='/repos' element={<div>repos page</div>}/>
</Routes>, {route: '/repos/1/edit'});
}
it('saves the settings', async () => {
updateRepo.mockResolvedValue(repoFixture());
renderEditPage(repoFixture({branch: 'dev', description: 'Tools'}));
await flush();
expect(screen.getByLabelText('Branch')).toHaveValue('dev');
expect(screen.getByLabelText('Include submodules')).not.toBeChecked();
fireEvent.change(screen.getByLabelText('Branch'), {target: {value: ''}});
fireEvent.click(screen.getByLabelText('Include submodules'));
fireEvent.click(screen.getByText('Save'));
await flush();
expect(updateRepo).toHaveBeenCalledWith('1', {
description: 'Tools', frequency: 604800, mode: 'full', branch: '', submodules: true,
});
});
it('keeps a repo which is never updated', async () => {
updateRepo.mockResolvedValue(repoFixture({frequency: 0}));
renderEditPage(repoFixture({frequency: 0}));
await flush();
expect(screen.getByDisplayValue('Never')).toBeInTheDocument();
fireEvent.click(screen.getByText('Save'));
await flush();
expect(updateRepo).toHaveBeenCalledWith('1', expect.objectContaining({frequency: 0}));
});
it('deletes the repo, and its files only when asked', async () => {
deleteRepo.mockResolvedValue(undefined);
renderEditPage(repoFixture());
await flush();
fireEvent.click(screen.getByText('Delete'));
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByLabelText(/Also delete its files/));
fireEvent.click(within(dialog).getByText('Delete'));
await flush();
expect(deleteRepo).toHaveBeenCalledWith('1', true);
expect(screen.getByText('repos page')).toBeInTheDocument();
});
});
describe('RepoImportPage', () => {
const inspection = (overrides = {}) => ({
directory: 'code/kiwix-tools', origin: 'git@github.com:kiwix/kiwix-tools.git',
url: 'https://github.com/kiwix/kiwix-tools', name: 'kiwix-tools', branch: 'main',
head_sha: 'abcdef1234', head_message: 'Local work', head_date: '2026-09-30T14:02:00Z', local_commits: 2,
shallow: false, ignored: false,
...overrides,
});
function renderImportPage() {
return render(<Routes>
<Route path='/repos/import' element={<RepoImportPage/>}/>
<Route path='/repos/:repoId' element={<div>repo page</div>}/>
</Routes>, {route: '/repos/import'});
}
it('inspects the directory, warns about local commits, and imports once confirmed', async () => {
inspectRepoImport.mockResolvedValue({inspection: inspection()});
importRepo.mockResolvedValue(repoFixture({id: 9}));
renderImportPage();
fireEvent.click(screen.getByText('Pick directory'));
expect(await screen.findByText('2 commits are not on the remote')).toBeInTheDocument();
expect(inspectRepoImport).toHaveBeenCalledWith('code/kiwix-tools', '', null, '');
// Inspected again with the URL the import will use.
await waitFor(() => expect(inspectRepoImport).toHaveBeenCalledWith('code/kiwix-tools', '', null,
'https://github.com/kiwix/kiwix-tools'));
expect(screen.getByText('git@github.com:kiwix/kiwix-tools.git')).toBeInTheDocument();
expect(screen.getByText(/Its files are indexed/)).toBeInTheDocument();
// The URL comes from the origin.
expect(screen.getByLabelText(/^URL/)).toHaveValue('https://github.com/kiwix/kiwix-tools');
const button = screen.getByText('Import').closest('button');
expect(button).toBeDisabled();
fireEvent.click(screen.getByLabelText(/I understand/));
expect(button).toBeEnabled();
fireEvent.click(button);
await flush();
expect(importRepo).toHaveBeenCalledWith({
directory: 'code/kiwix-tools', confirm: true, url: 'https://github.com/kiwix/kiwix-tools', name: null,
tag_name: null, frequency: 604800, mode: 'full', branch: null, submodules: false,
});
expect(screen.getByText('repo page')).toBeInTheDocument();
});
it('asks for the URL when the clone has no https origin', async () => {
inspectRepoImport.mockResolvedValue({inspection: inspection({origin: null, url: null, local_commits: 0})});
renderImportPage();
fireEvent.click(screen.getByText('Pick directory'));
expect(await screen.findByText(/no https:\/\/ origin/)).toBeInTheDocument();
fireEvent.click(screen.getByLabelText(/I understand/));
expect(screen.getByText('Import').closest('button')).toBeDisabled();
fireEvent.change(screen.getByLabelText(/^URL/), {target: {value: 'https://github.com/kiwix/kiwix-tools'}});
expect(screen.getByText('Import').closest('button')).toBeEnabled();
});
it('explains a directory which cannot be imported', async () => {
inspectRepoImport.mockResolvedValue({error: 'code/kiwix-tools is not a git clone'});
renderImportPage();
fireEvent.click(screen.getByText('Pick directory'));
expect(await screen.findByText('code/kiwix-tools is not a git clone')).toBeInTheDocument();
expect(screen.queryByText('Import')).not.toBeInTheDocument();
});
it('says when the files are not indexed', async () => {
inspectRepoImport.mockResolvedValue({inspection: inspection({ignored: true})});
renderImportPage();
fireEvent.click(screen.getByText('Pick directory'));
expect(await screen.findByText(/Its files are not indexed/)).toBeInTheDocument();
});
});

View file

@ -1,27 +1,17 @@
import React, {useState} from "react";
import {Link, Route, Routes, useLocation, useNavigate} from "react-router";
import {FilesSearchView, SearchViewButton} from "./Files";
import {
useLatestRequest,
usePages,
useSearchChannels,
useSearchDate,
useSearchDeep,
useSearchFilter,
useSearchCollections,
useSearchRepos,
} from "../hooks/customHooks";
import {useLatestRequest, usePages, useSearchChannels, useSearchDate, useSearchDeep, useSearchFilter} from "../hooks/customHooks";
import {ShortcutHint} from "./ShortcutHint";
import {ZimSearchView} from "./Zim";
import {MapSearchView} from "./MapSearchView";
import {searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEstimateZims, searchSuggestions} from "../api";
import {ErrorMessage, filterToMimetypes, fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common";
import {filterToMimetypes, fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common";
import _ from "lodash";
import {TagsContext} from "../Tags";
import {Accordion, Header, Icon, Label, Loading, Panel, Stack} from "./ui";
import {QueryContext} from "../contexts/contexts";
import {KeyboardShortcutsContext} from "../contexts/KeyboardShortcutsContext";
import {HeadlineText} from "./Headline";
const SUGGESTED_APPS = [
{location: '/admin', title: 'Downloads', description: 'View and control your downloads'},
@ -141,15 +131,12 @@ export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false
}
export function useSuggestions(searchStr, tagNames, filter, anyTag) {
// The general groups are null until the suggestion request returns: an empty list means the
// server found none, and the dropdown may say "No results" only for that.
const defaultSuggestions = {
fileGroups: [],
channels: null,
domains: null,
authors: null,
subjects: null,
repos: null,
channels: [],
domains: [],
authors: [],
subjects: [],
zimsEstimates: [],
}
const [suggestions, setSuggestions] = React.useState(defaultSuggestions);
@ -174,7 +161,7 @@ export function useSuggestions(searchStr, tagNames, filter, anyTag) {
// The files estimate always uses deep search server-side, so users know results are possible.
sendFilesRequest(async () => await searchEstimateFiles(searchStr, tagNames, mimetypes, months, dateRange, anyTag));
sendZimRequest(async () => await searchEstimateZims(searchStr, tagNames));
sendOtherRequest(async () => await searchEstimateOthers(tagNames, searchStr));
sendOtherRequest(async () => await searchEstimateOthers(tagNames));
}
}, [
searchStr,
@ -196,7 +183,6 @@ export function useSuggestions(searchStr, tagNames, filter, anyTag) {
domains: generalData.domains,
authors: generalData.authors,
subjects: generalData.subjects,
repos: generalData.repos,
}
});
}
@ -324,13 +310,6 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
})
}
}
if (newSuggestions.repos && newSuggestions.repos.length > 0) {
results.repos = {
name: 'Repos', results: newSuggestions.repos.map(i => {
return {type: 'repo', title: i.name, id: i.id, location: `/repos/${i.id}`}
})
}
}
if (newSuggestions.authors && newSuggestions.authors.length > 0) {
results.authors = {
name: 'Authors', results: newSuggestions.authors.map(i => {
@ -422,11 +401,10 @@ export function useSearchSuggestions(defaultSearchStr, defaultTagNames, anyTag)
zims: zimSum,
otherSum: otherSum,
mapPlaces: newSuggestions.mapPlaces || 0,
channels: newSuggestions.channels?.length,
domains: newSuggestions.domains?.length,
channels: newSuggestions.channels.length,
domains: newSuggestions.domains.length,
authors: newSuggestions.authors?.length,
subjects: newSuggestions.subjects?.length,
repos: newSuggestions.repos?.length,
tags: matchingTags?.length,
apps: matchingApps?.length,
});
@ -563,99 +541,31 @@ function SearchChannelPreview({channel}) {
</div>
}
function SearchCollectionPreview({collection, to}) {
const {SingleTag} = React.useContext(TagsContext);
return <div>
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap'}}>
<Link to={to}>{collection.name}</Link>
{collection.tag_name && <SingleTag name={collection.tag_name}/>}
</span>
</div>
}
function SearchRepoPreview({repo}) {
const {SingleTag} = React.useContext(TagsContext);
return <div>
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap'}}>
<Link to={`/repos/${repo.id}`}>{repo.name}</Link>
{repo.tag_name && <SingleTag name={repo.tag_name}/>}
</span>
{/* The server escapes the README; only its <b> highlights are markup. */}
{repo.readme_headline && <div><small><HeadlineText headline={repo.readme_headline}/></small></div>}
</div>
}
function OtherSearchSection({value, title, items, preview, emptyText, errorText}) {
let body;
if (items === null) {
body = <Loading/>;
} else if (items === undefined) {
body = <ErrorMessage>{errorText}</ErrorMessage>;
} else {
body = <Stack gap='xs'>{!_.isEmpty(items) ? items.map(preview) : emptyText}</Stack>;
}
return <Accordion.Item value={value}>
<Accordion.Control>
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8}}>
<Header as='h3' style={{margin: 0}}>{title}</Header>
{Array.isArray(items) && <Label>{normalizeEstimate(items.length)}</Label>}
</span>
</Accordion.Control>
<Accordion.Panel>{body}</Accordion.Panel>
</Accordion.Item>
}
// Each section's items follow useSearchChannels, useSearchCollections, or useSearchRepos: null = pending,
// undefined = fetch failed, [] = none.
export function OtherSearchView({loading}) {
function OtherSearchView({loading}) {
const {searchParams} = React.useContext(QueryContext);
const [activeValues, setActiveValues] = React.useState(['channels', 'domains', 'playlists', 'repos']);
const [activeValue, setActiveValue] = React.useState('channels');
const activeTags = searchParams.getAll('tag');
const searchStr = searchParams.get('q') || '';
const {channels, loading: channelsLoading} = useSearchChannels(activeTags, searchStr);
const {collections: domains} = useSearchCollections('domain', searchStr, activeTags);
const {collections: playlists} = useSearchCollections('playlist', searchStr, activeTags);
const {repos} = useSearchRepos(searchStr, activeTags);
const {channels, loading: channelsLoading} = useSearchChannels(activeTags);
if (loading || channelsLoading || channels === null) {
if (loading || channelsLoading) {
return <Panel><Loading/></Panel>
} else if (channels === undefined) {
return <Panel><ErrorMessage>Could not fetch the channels.</ErrorMessage></Panel>
}
return <Accordion multiple value={activeValues} onChange={setActiveValues}>
<OtherSearchSection
value='channels'
title='Channels'
items={channels}
preview={i => <SearchChannelPreview key={i.id} channel={i}/>}
emptyText='No Channels'
errorText='Could not fetch the channels.'
/>
<OtherSearchSection
value='domains'
title='Domains'
items={domains}
preview={i => <SearchCollectionPreview key={i.id} collection={i}
to={`/archives?domain=${encodeURIComponent(i.name)}`}/>}
emptyText='No Domains'
errorText='Could not fetch the domains.'
/>
<OtherSearchSection
value='playlists'
title='Playlists'
items={playlists}
preview={i => <SearchCollectionPreview key={i.id} collection={i} to={`/playlists/${i.id}`}/>}
emptyText='No Playlists'
errorText='Could not fetch the playlists.'
/>
<OtherSearchSection
value='repos'
title='Repos'
items={repos}
preview={i => <SearchRepoPreview key={i.id} repo={i}/>}
emptyText='No Repos'
errorText='Could not fetch the repos.'
/>
return <Accordion value={activeValue} onChange={setActiveValue}>
<Accordion.Item value='channels'>
<Accordion.Control>
<span style={{display: 'inline-flex', alignItems: 'center', gap: 8}}>
<Header as='h3' style={{margin: 0}}>Channels</Header>
<Label>{normalizeEstimate(channels.length)}</Label>
</span>
</Accordion.Control>
<Accordion.Panel>
<Stack gap='xs'>
{!_.isEmpty(channels) ?
channels.map(i => <SearchChannelPreview key={i.id} channel={i}/>)
: 'No Channels'}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
}

View file

@ -1,139 +0,0 @@
import React from 'react';
import {act, render, screen} from '../test-utils';
import {useSearchSuggestions} from './Search';
import {TagsContext} from '../Tags';
import {tagsContextFixture} from '../test-fixtures';
import {
searchEstimateFiles, searchEstimateMap, searchEstimateOthers, searchEstimateZims, searchSuggestions,
} from '../api';
// The global search dropdown's groups are built from useSuggestions. A "Channels: No results" row
// may only appear once the suggestion request has come back empty, never before the user has
// typed, and never while the request is pending.
jest.mock('../api', () => ({
...jest.requireActual('../api'),
searchSuggestions: jest.fn(),
searchEstimateFiles: jest.fn(),
searchEstimateMap: jest.fn(),
searchEstimateOthers: jest.fn(),
searchEstimateZims: jest.fn(),
}));
const DEBOUNCE = 500;
const pending = () => new Promise(() => {
});
let hook;
function Probe() {
hook = useSearchSuggestions('', [], false);
return <pre data-testid='results'>{JSON.stringify(hook.suggestionsResults)}</pre>;
}
const results = () => JSON.parse(screen.getByTestId('results').textContent);
const renderProbe = () => render(<Probe/>, {
contexts: [[TagsContext, tagsContextFixture({fuzzyMatchTagsByName: () => []})]],
});
beforeEach(() => {
jest.useFakeTimers();
searchEstimateFiles.mockReturnValue(pending());
searchEstimateMap.mockReturnValue(pending());
searchEstimateOthers.mockReturnValue(pending());
searchEstimateZims.mockReturnValue(pending());
});
afterEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
});
test('no Channels group before anything has been searched', () => {
searchSuggestions.mockReturnValue(pending());
renderProbe();
expect(results().channels).toBeUndefined();
});
test('no Channels group while the suggestion request is pending', () => {
searchSuggestions.mockReturnValue(pending());
renderProbe();
act(() => {
hook.setSearchStr('foo');
});
expect(results().channels).toBeUndefined();
act(() => {
jest.advanceTimersByTime(DEBOUNCE);
});
expect(searchSuggestions).toHaveBeenCalledWith('foo');
expect(results().channels).toBeUndefined();
expect(hook.loading).toBe(true);
});
test('a Channels "No results" row once the server said there are none', async () => {
let resolve;
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
renderProbe();
act(() => {
hook.setSearchStr('foo');
});
act(() => {
jest.advanceTimersByTime(DEBOUNCE);
});
await act(async () => {
resolve({channels: [], domains: [], authors: [], subjects: []});
});
expect(results().channels.results).toEqual([{title: 'No results'}]);
});
test('Channels rows once the server found some', async () => {
let resolve;
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
renderProbe();
act(() => {
hook.setSearchStr('foo');
});
act(() => {
jest.advanceTimersByTime(DEBOUNCE);
});
await act(async () => {
resolve({channels: [{id: 3, name: 'Food'}], domains: [], authors: [], subjects: []});
});
expect(results().channels.results.map(i => i.title)).toEqual(['Food']);
});
test('Repos rows link to each repo, and the Other estimate gets the search', async () => {
let resolve;
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
renderProbe();
act(() => {
hook.setSearchStr('kiwix');
});
act(() => {
jest.advanceTimersByTime(DEBOUNCE);
});
await act(async () => {
resolve({channels: [], domains: [], authors: [], subjects: [], repos: [{id: 4, name: 'kiwix-tools'}]});
});
expect(results().repos.results).toEqual([{type: 'repo', title: 'kiwix-tools', id: 4, location: '/repos/4'}]);
// Repos count toward the Other tab by their name and README, not only by tag.
expect(searchEstimateOthers).toHaveBeenCalledWith([], 'kiwix');
});
test('no Repos group when none match', async () => {
let resolve;
searchSuggestions.mockReturnValue(new Promise(res => resolve = res));
renderProbe();
act(() => {
hook.setSearchStr('foo');
});
act(() => {
jest.advanceTimersByTime(DEBOUNCE);
});
await act(async () => {
resolve({channels: [], domains: [], authors: [], subjects: [], repos: []});
});
expect(results().repos).toBeUndefined();
});

View file

@ -1,49 +0,0 @@
import React from 'react';
import {act, render, screen} from '../test-utils';
import {StatisticsPage} from './Apps';
import {getStatistics} from '../api';
// What the Statistics page shows while the numbers are still loading, when the fetch failed, and
// once they arrive. "Failed to fetch statistics" may only appear for a failed fetch.
jest.mock('../api', () => ({
...jest.requireActual('../api'),
getStatistics: jest.fn(),
}));
const FAILED = /Failed to fetch statistics/;
const STATS = {
global_statistics: {db_size: 1024},
file_statistics: {
archive_count: 1, audio_count: 2, ebook_count: 3, image_count: 4, pdf_count: 5, total_count: 21,
video_count: 6, zip_count: 0, total_size: 4096, tagged_files: 7, tagged_zims: 0, tags_count: 2,
},
};
beforeEach(() => getStatistics.mockReset());
test('pending: shows a loader, not the error', () => {
getStatistics.mockReturnValue(new Promise(() => {
}));
const {container} = render(<StatisticsPage/>);
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
expect(screen.queryByText(FAILED)).not.toBeInTheDocument();
});
test('failed: shows the error', async () => {
// The api helper toasts and returns undefined on a non-OK response.
getStatistics.mockResolvedValue(undefined);
render(<StatisticsPage/>);
await act(async () => {
});
expect(screen.getByText(FAILED)).toBeInTheDocument();
});
test('loaded: shows the numbers', async () => {
getStatistics.mockResolvedValue(STATS);
render(<StatisticsPage/>);
await act(async () => {
});
expect(screen.getByText('All Files')).toBeInTheDocument();
expect(screen.queryByText(FAILED)).not.toBeInTheDocument();
});

View file

@ -1,69 +0,0 @@
import React from 'react';
import {render, screen} from '../test-utils';
import {DisableDownloadsToggle, HotspotToggle} from './Common';
import {SettingsPage, UpgradeSegment} from './admin/Settings';
// The status and settings contexts start empty and are filled by the first poll. Until then a
// consumer must not claim a negative ("Downloading Disabled", "not supported", "up to date") or
// render a form over blank values; `loaded` on each context says whether the first poll landed.
jest.mock('../api', () => ({
...jest.requireActual('../api'),
getConfigs: jest.fn(() => new Promise(() => {
})),
}));
const unloadedStatus = {status: {}, loaded: false};
describe('DisableDownloadsToggle before status has loaded', () => {
test('is disabled and does not claim downloading is disabled', () => {
render(<DisableDownloadsToggle/>, {status: unloadedStatus});
expect(screen.getByTestId('toggle')).toBeDisabled();
expect(screen.queryByText('Downloading Disabled')).not.toBeInTheDocument();
});
test('once loaded, reads the downloads state', () => {
render(<DisableDownloadsToggle/>, {status: {status: {downloads: {disabled: true, stopped: false}}, loaded: true}});
expect(screen.getByText('Downloading Disabled')).toBeInTheDocument();
expect(screen.getByTestId('toggle')).toBeEnabled();
});
});
describe('HotspotToggle before status has loaded', () => {
test('is disabled without claiming the hotspot is unsupported', () => {
render(<HotspotToggle/>, {status: unloadedStatus});
expect(screen.getByTestId('toggle')).toBeDisabled();
expect(screen.queryByTestId('subsystem-popup')).not.toBeInTheDocument();
});
test('once loaded without a hotspot field, is unsupported', () => {
render(<HotspotToggle/>, {status: {status: {}, loaded: true}});
expect(screen.getByTestId('toggle')).toBeDisabled();
expect(screen.getByTestId('subsystem-popup')).toBeInTheDocument();
});
});
describe('UpgradeSegment before status has loaded', () => {
test('does not claim the WROLPi is up to date', () => {
render(<UpgradeSegment/>, {status: unloadedStatus});
expect(screen.queryByText(/up to date/)).not.toBeInTheDocument();
});
test('once loaded with no update, says so', () => {
render(<UpgradeSegment/>, {status: {status: {update_available: false, version: '1.0'}, loaded: true}});
expect(screen.getByText(/up to date/)).toBeInTheDocument();
});
});
describe('SettingsPage before settings have loaded', () => {
test('shows a loader, not a form over blank values', () => {
const {container} = render(<SettingsPage/>, {settings: {settings: {}, loaded: false, failed: false}});
expect(container.querySelector('.wrolpi-loading')).toBeInTheDocument();
expect(screen.queryByText(/Any changes will be written/)).not.toBeInTheDocument();
});
test('shows an error when the settings could not be fetched', () => {
render(<SettingsPage/>, {settings: {settings: {}, loaded: false, failed: true}});
expect(screen.getByText('Unable to fetch settings')).toBeInTheDocument();
});
});

View file

@ -45,13 +45,11 @@ import {
toast,
} from './ui';
import {paletteColorNames} from '../themes/mantine';
import {contrastingColor, ViewProgress} from './Common';
import {contrastingColor} from './Common';
import {AiModelRow} from './Ai';
import {ChatMessage} from './AiChat';
import {navBarStyle, navColorNames, useNavColors} from '../themes/navColors';
import {IconMenu2} from '@tabler/icons-react';
import {BookmarkAddForms, BookmarkMenuItems, BookmarkTable} from './Bookmarks';
import {
RepoBreadcrumb, RepoHistoryTable, RepoImportSummary, RepoStatusIcon, RepoSummary, RepoTreeTable,
} from './Repos';
/*
* A gallery of every component in the library, in the current theme.
@ -160,41 +158,6 @@ export const NavBarSample = ({color}) => {
// press adds a toast rather than replacing the first. Module scope, so it survives re-renders.
let toastCounter = 0;
// Sample data for the bookmarks section; the editor's actions do nothing here.
const sampleBookmarks = [
{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: [
{id: 5, name: 'OpenWebRX', url: 'http://:8073/', new_tab: true},
]},
]},
{id: 6, name: 'WROLPi.org', url: 'https://wrolpi.org', new_tab: true},
];
const noBookmarkActions = {edit: () => null, move: () => null, remove: () => null};
const sampleRepo = {
id: 1, name: 'kiwix-tools', tag_name: null, url: 'https://github.com/kiwix/kiwix-tools',
description: 'Command line tools for Kiwix and Zim files.', mode: 'full', branch: null, default_branch: 'main',
head_sha: '3f9c2a1e7b', head_message: 'Serve Zim files with the correct mimetype', head_date: '2026-09-30T14:02:00Z',
size: 18_400_000, directory: 'repos/kiwix-tools', download_error: null,
};
const sampleFailedRepo = {
...sampleRepo, name: 'gone-upstream', branch: 'release', mode: 'snapshot',
download_error: "git ls-remote failed (128): remote: Repository not found.\nfatal: repository " +
"'https://github.com/example/gone-upstream/' not found",
};
const sampleRepoCommits = [
{sha: '3f9c2a1e7b', author: 'Ada Example', date: '2026-09-30T14:02:00Z',
message: 'Serve Zim files with the correct mimetype'},
{sha: '91b0d44c02', author: 'Ben Example', date: '2026-09-12T09:15:00Z', message: 'Merge pull request #42'},
];
const sampleRepoEntries = [
{name: 'docs', path: 'src/docs', is_dir: true, size: null},
{name: 'kiwix-serve.cpp', path: 'src/kiwix-serve.cpp', is_dir: false, size: 48_210},
{name: 'README.md', path: 'src/README.md', is_dir: false, size: 2_104},
];
export function ThemeSamplePage() {
const {theme, mediaFilterEnabled} = useContext(ThemeContext);
const [confirmOpen, setConfirmOpen] = useState(false);
@ -764,6 +727,51 @@ export function ThemeSamplePage() {
</Table>
</Section>
<Section label='AI model catalog'>
{/* The real AiModelRow from the AI page's Manage tab, in both of its states. */}
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Model</Table.HeaderCell>
<Table.HeaderCell>Tier</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
<Table.HeaderCell/>
</Table.Row>
</Table.Header>
<Table.Body>
<AiModelRow model={{
name: 'Qwen3-1.7B-Q4_K_M.gguf', tier: 'small', size: 1_200_000_000,
description: 'The default assistant for 4GB devices (Pi 4/5).',
downloaded: true, active: true,
}} onDownload={() => {}}/>
<AiModelRow model={{
name: 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf', tier: 'medium', size: 2_600_000_000,
description: 'The default assistant for 8GB+ devices.',
downloaded: false, active: false,
}} onDownload={() => {}}/>
</Table.Body>
</Table>
</Section>
<Section label='AI chat'>
{/* The real ChatMessage from the AI Chat tab, in each of its states. */}
<ChatMessage message={{role: 'user', content: 'Find videos about canning'}}/>
<ChatMessage message={{
role: 'assistant',
content: 'I found one video: Pressure Canning Basics — /videos/123',
activity: [{tool: 'search_files', pending: false, success: true}],
}}/>
<ChatMessage message={{
role: 'assistant', content: '',
activity: [{tool: 'search_files', pending: true}], streaming: true,
}}/>
<ChatMessage message={{
role: 'assistant', content: '',
activity: [{tool: 'read_content', pending: false, success: false}],
error: 'The AI failed to answer. Check the logs.',
}}/>
</Section>
<Section label='Form controls'>
<Panel>
<div style={{display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: 12}}>
@ -1032,31 +1040,6 @@ export function ThemeSamplePage() {
rather than upscaled.
</p>
<Header as='h4'>Viewing progress</Header>
<CardGroup>
{[
['Part way through', 0.4],
['Nearly done', 0.9],
['Finished', 1],
['Not started', null],
].map(([label, progress]) => <Card
key={label}
title={label}
meta={progress ? `${Math.round(progress * 100)}%` : 'no bar'}
color='blue'
media={<div className='wrolpi-card-poster'>
<div className='wrolpi-card-poster-frame'>
<img alt='' src={samplePoster(1280, 720)}/>
<ViewProgress progress={progress}/>
</div>
</div>}
/>)}
</CardGroup>
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginTop: 10}}>
How far the user is through a video, audio file, ebook or comic, along the bottom
of its poster. A full bar is finished; a file not yet started has none.
</p>
<Header as='h4'>Meta, body and actions</Header>
<CardGroup>
{[
@ -1142,71 +1125,6 @@ export function ThemeSamplePage() {
</Panel>
</Section>
<Section label='Bookmarks'>
<Panel>
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginTop: 0}}>
The Bookmarks menu in the navigation bar, and the editor under More. A
directory is a flyout that opens beside the menu. In the mobile menu it is a
labelled, indented run of items instead.
</p>
<Row>
<Menu position='bottom-start' withinPortal trigger='click-hover'>
<Menu.Target>
<Button iconAfter='dropdown'>Bookmarks</Button>
</Menu.Target>
<Menu.Dropdown>
<BookmarkMenuItems nodes={sampleBookmarks}/>
<Menu.Divider/>
<Menu.Item leftSection={<Icon name='edit' size='small'/>}>Edit bookmarks</Menu.Item>
</Menu.Dropdown>
</Menu>
<Menu position='bottom-start' withinPortal>
<Menu.Target>
<Button iconAfter='dropdown'>As the mobile menu shows it</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Bookmarks</Menu.Label>
<BookmarkMenuItems nodes={sampleBookmarks} nested={false}/>
</Menu.Dropdown>
</Menu>
</Row>
<div style={{marginTop: 12}}>
<BookmarkTable nodes={sampleBookmarks} actions={noBookmarkActions}/>
<BookmarkAddForms nodes={sampleBookmarks} onAddBookmark={() => null} onAddDirectory={() => null}/>
</div>
</Panel>
</Section>
<Section label='Repos'>
<Panel>
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginTop: 0}}>
A git repo's page: its summary, then its files. A repo whose last update failed keeps
its files and explains why. The status icons appear beside a repo's name in the Repos
table: <RepoStatusIcon repo={sampleFailedRepo}/> failed,{' '}
<RepoStatusIcon repo={{...sampleRepo, head_sha: null}}/> not downloaded yet.
</p>
<RepoSummary repo={sampleRepo}/>
<div style={{marginTop: 12}}>
<RepoBreadcrumb name={sampleRepo.name} path='src' onNavigate={() => null}/>
<RepoTreeTable path='src' entries={sampleRepoEntries} onOpenDirectory={() => null}
onOpenFile={() => null}/>
</div>
<div style={{marginTop: 12}}>
<RepoHistoryTable commits={sampleRepoCommits}/>
</div>
<div style={{marginTop: 12}}>
<RepoImportSummary inspection={{
origin: 'git@github.com:kiwix/kiwix-tools.git', branch: 'main', head_sha: '3f9c2a1e7b',
head_message: 'Local work', head_date: '2026-09-30T14:02:00Z', local_commits: 2,
shallow: true, directory: 'code/kiwix-tools', ignored: false,
}}/>
</div>
<div style={{marginTop: 12}}>
<RepoSummary repo={sampleFailedRepo}/>
</div>
</Panel>
</Section>
<Section label='Danger zone'>
<Panel danger>
<h2 style={{color: 'var(--danger)', fontSize: '0.875rem', margin: '0 0 4px'}}>Danger zone</h2>

View file

@ -3,7 +3,6 @@ export const VIDEOS_API = `${API_URI}/videos`;
export const ARCHIVES_API = `${API_URI}/archive`;
export const COLLECTIONS_API = `${API_URI}/collections`;
export const ZIM_API = `${API_URI}/zim`;
export const REPOS_API = `${API_URI}/repos`;
export const DEFAULT_LIMIT = 20;
export const NAME = process.env && process.env.REACT_APP_NAME ? process.env.REACT_APP_NAME : null;
@ -169,14 +168,11 @@ export const transcodeVideoCodecOptions = [
{value: 'h264', label: 'h264 (avc1)'},
{value: TRANSCODE_REMOVE_VIDEO, label: 'Remove video (audio only)'},
];
// REMOVE_AUDIO in modules/videos/transcode.py: drop the audio stream, the result is a silent video.
export const TRANSCODE_REMOVE_AUDIO = 'none';
export const transcodeAudioCodecOptions = [
{value: TRANSCODE_COPY, label: 'Keep (copy)'},
{value: 'aac', label: 'aac'},
{value: 'opus', label: 'opus'},
{value: 'mp3', label: 'mp3'},
{value: TRANSCODE_REMOVE_AUDIO, label: 'Remove audio (silent video)'},
];
// Containers for output with a video stream ...
export const transcodeContainerOptions = [
@ -198,6 +194,7 @@ export function audioContainerForCodec(codec) {
}
export const Downloaders = {
AiModel: 'ai_model',
Archive: 'archive',
File: 'file',
KiwixCatalog: 'kiwix_catalog',

View file

@ -8,7 +8,6 @@ import {
import {
audioContainerForCodec,
TRANSCODE_COPY,
TRANSCODE_REMOVE_AUDIO,
TRANSCODE_REMOVE_VIDEO,
transcodeAudioCodecOptions,
transcodeAudioContainerOptions,
@ -69,41 +68,6 @@ export function containerFor({audioOutput, audioCodec, video}) {
return transcodeContainerOptions.some(i => i.value === current) ? current : 'mp4';
}
/**
* The video codec choices: Remove video is withdrawn once the audio is being removed (nothing
* would remain).
*/
export function videoCodecOptionsFor({removeAudio}) {
return removeAudio ? transcodeVideoCodecOptions.filter(i => i.value !== TRANSCODE_REMOVE_VIDEO)
: transcodeVideoCodecOptions;
}
/**
* The audio codec choices: Remove audio is only for a video whose video stream is kept; an audio
* file has nothing else, and removing the video already chose the audio as the result.
*/
export function audioCodecOptionsFor({audioOnly, removeVideo}) {
return (audioOnly || removeVideo) ? transcodeAudioCodecOptions.filter(i => i.value !== TRANSCODE_REMOVE_AUDIO)
: transcodeAudioCodecOptions;
}
/**
* The codec pair after one select changes. Both streams can never be removed (nothing would
* remain): choosing Remove for one stream puts the other back to Keep.
*/
export function chooseCodecs({videoCodec, audioCodec}, {video, audio}) {
let nextVideo = video !== undefined ? (video || TRANSCODE_COPY) : videoCodec;
let nextAudio = audio !== undefined ? (audio || TRANSCODE_COPY) : audioCodec;
if (nextVideo === TRANSCODE_REMOVE_VIDEO && nextAudio === TRANSCODE_REMOVE_AUDIO) {
if (video !== undefined) {
nextAudio = TRANSCODE_COPY;
} else {
nextVideo = TRANSCODE_COPY;
}
}
return {videoCodec: nextVideo, audioCodec: nextAudio};
}
const statusKind = (status) => {
if (status === 'complete') return 'complete';
if (status === 'running') return 'active';
@ -250,7 +214,6 @@ export function TranscodeModal({
const current = currentCodecs(video);
const removeVideo = videoCodec === TRANSCODE_REMOVE_VIDEO;
const removeAudio = !audioOnly && !removeVideo && audioCodec === TRANSCODE_REMOVE_AUDIO;
// An audio file has no video stream to keep or remove; removing the video makes an audio file.
const audioOutput = audioOnly || removeVideo;
@ -287,20 +250,18 @@ export function TranscodeModal({
};
}, [open]);
// `chooseCodecs` keeps Remove video and Remove audio from ever being chosen together; the
// other select also withdraws its Remove option (see `videoCodecOptionsFor`/`audioCodecOptionsFor`).
const applyCodecs = (change) => {
const next = chooseCodecs({videoCodec, audioCodec}, change);
setVideoCodec(next.videoCodec);
setAudioCodec(next.audioCodec);
setContainer(containerFor({
audioOutput: audioOnly || next.videoCodec === TRANSCODE_REMOVE_VIDEO, audioCodec: next.audioCodec, video,
}));
const changeVideoCodec = (value) => {
const next = value || TRANSCODE_COPY;
setVideoCodec(next);
setContainer(containerFor({audioOutput: audioOnly || next === TRANSCODE_REMOVE_VIDEO, audioCodec, video}));
};
const changeAudioCodec = (value) => {
const next = value || TRANSCODE_COPY;
setAudioCodec(next);
setContainer(containerFor({audioOutput, audioCodec: next, video}));
};
const changeVideoCodec = (value) => applyCodecs({video: value});
const changeAudioCodec = (value) => applyCodecs({audio: value});
const remuxOnly = !removeVideo && !removeAudio && videoCodec === TRANSCODE_COPY && audioCodec === TRANSCODE_COPY;
const remuxOnly = !removeVideo && videoCodec === TRANSCODE_COPY && audioCodec === TRANSCODE_COPY;
const sameContainer = container === currentContainer(video);
const containerOptions = audioOutput ? transcodeAudioContainerOptions : transcodeContainerOptions;
// Fragments are an mp4 concept (m4a is an mp4).
@ -311,8 +272,7 @@ export function TranscodeModal({
try {
const id = await transcodeVideo(fileGroupId, {
video_codec: videoCodec === TRANSCODE_COPY ? null : videoCodec,
audio_codec: audioCodec === TRANSCODE_COPY || (removeVideo && audioCodec === TRANSCODE_REMOVE_AUDIO)
? null : audioCodec,
audio_codec: audioCodec === TRANSCODE_COPY ? null : audioCodec,
container,
fragmented: canFragment && fragmented,
});
@ -332,9 +292,6 @@ export function TranscodeModal({
if (defaultsLoaded && removeVideo) {
hint = `The video stream is removed; the result is an audio file (.${container}). `
+ (audioCodec === TRANSCODE_COPY ? 'The audio is copied without re-encoding.' : `The audio is converted to ${audioCodec}.`);
} else if (defaultsLoaded && removeAudio) {
hint = `The audio stream is removed; the result is a silent video (.${container}). `
+ (videoCodec === TRANSCODE_COPY ? 'The video is copied without re-encoding.' : `The video is converted to ${videoCodec}.`);
} else if (defaultsLoaded && remuxOnly) {
const kept = audioOnly ? 'The audio is kept' : 'Both streams are kept';
if (sameContainer && canFragment && fragmented) {
@ -346,7 +303,7 @@ export function TranscodeModal({
hint = `${kept}: only the container changes to ${container} (a quick remux, no quality loss).`;
}
}
const buttonLabel = removeVideo ? 'Extract Audio' : removeAudio ? 'Remove Audio' : remuxOnly ? 'Remux' : 'Transcode';
const buttonLabel = removeVideo ? 'Extract Audio' : remuxOnly ? 'Remux' : 'Transcode';
return <Modal open={open} onClose={onClose} size='small'>
<Modal.Header>{audioOnly ? 'Transcode Audio' : 'Transcode Video'}</Modal.Header>
@ -362,7 +319,7 @@ export function TranscodeModal({
{!showJob && <>
{!audioOnly && <Select
label='Video codec'
data={videoCodecOptionsFor({removeAudio})}
data={transcodeVideoCodecOptions}
value={videoCodec}
onChange={changeVideoCodec}
disabled={!defaultsLoaded}
@ -370,7 +327,7 @@ export function TranscodeModal({
/>}
<Select
label='Audio codec'
data={audioCodecOptionsFor({audioOnly, removeVideo})}
data={transcodeAudioCodecOptions}
value={audioCodec}
onChange={changeAudioCodec}
disabled={!defaultsLoaded}
@ -422,27 +379,14 @@ export function EditVideoModal({open, onClose, fileGroupId, videoFile, descripti
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [saving, setSaving] = useState(false);
// The description last copied into the textarea; the user has typed when the field differs.
const syncedDescription = useRef('');
useEffect(() => {
if (open) {
// Start from the current details on every open, discarding an abandoned edit.
setTitle(videoFile?.title || '');
setDescription(currentDescription || '');
syncedDescription.current = currentDescription || '';
}
}, [open, videoFile?.title]);
useEffect(() => {
// The description may still be loading when the dialog opens (null), and it is refetched
// after a save. Adopt what arrives only while the user has not typed, so a draft written
// over the placeholder is never wiped by the empty or failed value that follows it.
if (open && currentDescription != null && description === syncedDescription.current) {
setDescription(currentDescription);
syncedDescription.current = currentDescription;
}
}, [currentDescription]);
}, [open, videoFile?.title, currentDescription]);
const trimmed = title.trim();
const titleChanged = trimmed !== (videoFile?.title || '');

View file

@ -2,11 +2,11 @@ import React from 'react';
import {act, fireEvent, screen, waitFor} from '@testing-library/react';
import {renderWithProviders} from '../test-utils';
import {
audioCodecOptionsFor, chooseCodecs, containerFor, currentCodecs, currentContainer, EditVideoModal, JobProgress, preferredTarget,
TranscodeModal, useJob, VideoEditMenu, videoCodecOptionsFor,
containerFor, currentCodecs, currentContainer, EditVideoModal, JobProgress, preferredTarget, TranscodeModal,
useJob, VideoEditMenu,
} from './VideoEditMenu';
import {
audioContainerForCodec, TRANSCODE_COPY, TRANSCODE_REMOVE_AUDIO, TRANSCODE_REMOVE_VIDEO, transcodeAudioCodecOptions,
audioContainerForCodec, TRANSCODE_COPY, TRANSCODE_REMOVE_VIDEO, transcodeAudioCodecOptions,
transcodeVideoCodecOptions,
} from './Vars';
@ -221,35 +221,6 @@ describe('TranscodeModal', () => {
// Selecting it is exercised through containerFor (Mantine's dropdown is not driveable in jsdom).
});
test('the Remove audio option is offered for videos, not audio files, and never with Remove video', () => {
expect(transcodeAudioCodecOptions.some(i => i.value === TRANSCODE_REMOVE_AUDIO)).toBe(true);
const has = (options, value) => options.some(i => i.value === value);
expect(has(audioCodecOptionsFor({audioOnly: false, removeVideo: false}), TRANSCODE_REMOVE_AUDIO)).toBe(true);
expect(has(audioCodecOptionsFor({audioOnly: true, removeVideo: false}), TRANSCODE_REMOVE_AUDIO)).toBe(false);
expect(has(audioCodecOptionsFor({audioOnly: false, removeVideo: true}), TRANSCODE_REMOVE_AUDIO)).toBe(false);
// The real codecs and Keep are never withdrawn.
expect(audioCodecOptionsFor({audioOnly: true, removeVideo: false}).map(i => i.value))
.toEqual([TRANSCODE_COPY, 'aac', 'opus', 'mp3']);
expect(has(videoCodecOptionsFor({removeAudio: false}), TRANSCODE_REMOVE_VIDEO)).toBe(true);
expect(has(videoCodecOptionsFor({removeAudio: true}), TRANSCODE_REMOVE_VIDEO)).toBe(false);
expect(has(videoCodecOptionsFor({removeAudio: true}), 'h264')).toBe(true);
});
test('Remove video and Remove audio can never be chosen together', () => {
const keepBoth = {videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_COPY};
// Removing one stream after the other was chosen puts the other back to Keep.
expect(chooseCodecs({videoCodec: TRANSCODE_REMOVE_VIDEO, audioCodec: 'aac'}, {audio: TRANSCODE_REMOVE_AUDIO}))
.toEqual({videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_REMOVE_AUDIO});
expect(chooseCodecs({videoCodec: 'h264', audioCodec: TRANSCODE_REMOVE_AUDIO}, {video: TRANSCODE_REMOVE_VIDEO}))
.toEqual({videoCodec: TRANSCODE_REMOVE_VIDEO, audioCodec: TRANSCODE_COPY});
// Ordinary choices are untouched; a cleared select means Keep.
expect(chooseCodecs(keepBoth, {video: 'h264'})).toEqual({videoCodec: 'h264', audioCodec: TRANSCODE_COPY});
expect(chooseCodecs({videoCodec: 'h264', audioCodec: 'aac'}, {audio: null}))
.toEqual({videoCodec: 'h264', audioCodec: TRANSCODE_COPY});
expect(chooseCodecs(keepBoth, {audio: TRANSCODE_REMOVE_AUDIO}))
.toEqual({videoCodec: TRANSCODE_COPY, audioCodec: TRANSCODE_REMOVE_AUDIO});
});
test('WROL Mode disables starting a transcode', async () => {
renderWithProviders(
<TranscodeModal open={true} onClose={jest.fn()} fileGroupId={7} video={video}/>,
@ -298,26 +269,6 @@ describe('EditVideoModal', () => {
await waitFor(() => expect(updateVideo).toHaveBeenCalledWith(7, {description: ''}));
});
test('a draft typed while the description was still loading survives its arrival', () => {
// The page passes null while the description is pending, then '' when the server has none.
const {rerender} = renderWithProviders(
<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile} description={null}/>,
);
fireEvent.change(screen.getByLabelText('Description'), {target: {value: 'my new words'}});
rerender(<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile} description=''/>);
expect(screen.getByLabelText('Description')).toHaveValue('my new words');
});
test('a description that arrives while the field is untouched is adopted', () => {
const {rerender} = renderWithProviders(
<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile} description={null}/>,
);
expect(screen.getByLabelText('Description')).toHaveValue('');
rerender(<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile}
description='From the server'/>);
expect(screen.getByLabelText('Description')).toHaveValue('From the server');
});
test('WROL Mode disables saving', async () => {
renderWithProviders(
<EditVideoModal open={true} onClose={jest.fn()} fileGroupId={7} videoFile={videoFile}/>,

View file

@ -1,66 +0,0 @@
import React from 'react';
import {render, screen} from '../test-utils';
import {Comments, VideoDescription} from './VideoPlayer';
// What the video page shows for each state of useVideoExtras. The states are null (pending),
// undefined (fetch failed), and an empty value (loaded, nothing to show). "No comments have been
// downloaded" carries a Refresh button that re-downloads the video, so it must only appear once
// the server has confirmed there are none.
const video = {id: 7, url: 'https://example.com/v'};
const NO_COMMENTS = /No comments have been/;
const NO_DESCRIPTION = /No description available/;
describe('Comments', () => {
test('pending: shows a placeholder, not "no comments" or an error', () => {
const {container} = render(<Comments comments={null} video={video}/>);
expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument();
expect(screen.queryByText(NO_COMMENTS)).not.toBeInTheDocument();
expect(screen.queryByText(/Could not/)).not.toBeInTheDocument();
expect(screen.queryByRole('button', {name: 'Refresh'})).not.toBeInTheDocument();
});
test('failed: shows an error, not "no comments"', () => {
render(<Comments comments={undefined} video={video}/>);
expect(screen.getByText(/Could not fetch the comments/)).toBeInTheDocument();
expect(screen.queryByText(NO_COMMENTS)).not.toBeInTheDocument();
});
test('empty: shows "no comments" with the Refresh button', () => {
render(<Comments comments={[]} video={video}/>);
expect(screen.getByText(NO_COMMENTS)).toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Refresh'})).toBeInTheDocument();
});
test('loaded: renders the comments', () => {
render(<Comments comments={[{id: 'c1', parent: 'root', text: 'first!', author: 'a'}]} video={video}/>);
expect(screen.getByText('first!')).toBeInTheDocument();
expect(screen.queryByText(NO_COMMENTS)).not.toBeInTheDocument();
});
});
describe('VideoDescription', () => {
test('pending: shows a placeholder, not "no description" or an error', () => {
const {container} = render(<VideoDescription description={null} setVideoTime={() => null}/>);
expect(container.querySelector('.wrolpi-placeholder')).toBeInTheDocument();
expect(screen.queryByText(NO_DESCRIPTION)).not.toBeInTheDocument();
expect(screen.queryByText(/Could not/)).not.toBeInTheDocument();
});
test('failed: shows an error, not "no description"', () => {
render(<VideoDescription description={undefined} setVideoTime={() => null}/>);
expect(screen.getByText(/Could not fetch the description/)).toBeInTheDocument();
expect(screen.queryByText(NO_DESCRIPTION)).not.toBeInTheDocument();
});
test('empty: shows "no description"', () => {
render(<VideoDescription description='' setVideoTime={() => null}/>);
expect(screen.getByText(NO_DESCRIPTION)).toBeInTheDocument();
});
test('loaded: renders the description', () => {
render(<VideoDescription description='A fine video.' setVideoTime={() => null}/>);
expect(screen.getByText('A fine video.')).toBeInTheDocument();
expect(screen.queryByText(NO_DESCRIPTION)).not.toBeInTheDocument();
});
});

View file

@ -8,7 +8,6 @@ import {
BackButton,
DirectoryLink,
encodeMediaPath,
ErrorMessage,
humanFileSize,
humanNumber,
isoDatetimeToAgoPopup,
@ -19,9 +18,8 @@ import {
useTitle
} from "./Common";
import {VideoPlaceholder} from "./Placeholder";
import {useChannel, useOneQuery, useVideoCaptions, useVideoExtras} from "../hooks/customHooks";
import {useMediaProgress} from "../hooks/useFileProgress";
import {Button, Grid, Header, Icon, Label, Loading, Panel, Placeholder, Tabs, TextInput} from "./ui";
import {useChannel, useVideoCaptions, useVideoExtras} from "../hooks/customHooks";
import {Button, Grid, Header, Icon, Label, Loading, Panel, Tabs, TextInput} from "./ui";
import {VideoCard} from "./Videos";
import {TagsSelector} from "../Tags";
import {AddToPlaylistButton} from "./AddToPlaylist";
@ -172,14 +170,8 @@ const VideoComment = ({comment, children}) => {
}
// `comments` follows useVideoExtras: null = pending, undefined = fetch failed, [] = none downloaded.
// Only the last of those may offer the Refresh button.
export const Comments = ({comments, video}) => {
if (comments === null) {
return <Placeholder lines={3}/>
} else if (comments === undefined) {
return <ErrorMessage>Could not fetch the comments.</ErrorMessage>
} else if (_.isEmpty(comments)) {
const Comments = ({comments, video}) => {
if (!comments || _.isEmpty(comments)) {
return <NoComments video={video}/>
}
@ -231,12 +223,6 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
video = videoFile.video;
}
// `?t=120` starts at 2:00, rather than where the user left off.
const [startTimeQuery] = useOneQuery('t');
const startSeconds = startTimeQuery && Number.isFinite(parseFloat(startTimeQuery))
? parseFloat(startTimeQuery) : null;
const progressHandlers = useMediaProgress(videoFile?.primary_path, videoFile, {startSeconds});
// Seeks to the `seconds` on video player.
const setVideoTime = (seconds) => {
videoRef.current.currentTime = seconds;
@ -357,6 +343,12 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
caption_files.filter(i => i['mimetype'] === 'text/vtt').map(i => `${MEDIA_PATH}/${encodeMediaPath(i['path'])}`)
: [];
let description = 'No description available.';
if (videoDescription) {
// Only replace empty description if there is one available.
description = formatVideoDescription(videoDescription, setVideoTime)
}
const getFile = (suffix) => videoFile.files.find(i => i.path.toLowerCase().endsWith(suffix));
const fileSize = (file) => file && file.size
? <span style={{marginLeft: '1em', color: 'var(--muted)'}}>({humanFileSize(file.size)})</span>
@ -463,7 +455,6 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
id="player"
style={{width: '100%', marginTop: posterUrl ? '0.5em' : 0}}
ref={videoRef}
{...progressHandlers}
>
{videoSource}
</audio>
@ -475,7 +466,6 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
playsInline={true}
style={{maxWidth: '100%'}}
ref={videoRef}
{...progressHandlers}
>
{videoSource}
{/* Only WebVTT captions can be displayed. */}
@ -560,7 +550,9 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
</Tabs.Panel>
<Tabs.Panel value='description' style={{paddingTop: '1em'}}>
<VideoDescription description={videoDescription} setVideoTime={setVideoTime}/>
<pre className="wrap-text">
{description}
</pre>
</Tabs.Panel>
<Tabs.Panel value='files' style={{paddingTop: '1em'}}>
@ -725,18 +717,6 @@ export default VideoPage;
const chapterRegex = new RegExp('^(\\(?(?:((\\d?\\d):)?(?:(\\d?\\d):(\\d\\d)))\\)?)\.?\\s+(.*)$', 'i');
// `description` follows useVideoExtras: null = pending, undefined = fetch failed, '' = none.
export const VideoDescription = ({description, setVideoTime}) => {
if (description === null) {
return <Placeholder lines={3}/>
} else if (description === undefined) {
return <ErrorMessage>Could not fetch the description.</ErrorMessage>
}
return <pre className="wrap-text">
{description ? formatVideoDescription(description, setVideoTime) : 'No description available.'}
</pre>
}
function formatVideoDescription(description, setVideoTime) {
// Convert timestamps to links which change the video's playback location. Change hashtags to search links.
if (description && description.length > 0) {

View file

@ -1,43 +0,0 @@
import React from 'react';
import {render, screen} from '../../test-utils';
import {BackupsModal} from './Configs';
import {getConfigBackups} from '../../api';
// The config backups modal says "No backups available" only once the server confirmed there are
// none; a failed fetch is an error, and a thrown one must not leave the loader up forever.
jest.mock('../../api', () => ({
...jest.requireActual('../../api'),
getConfigBackups: jest.fn(),
postConfigBackupPreview: jest.fn(() => new Promise(() => {
})),
}));
const NONE = /No backups available/;
const FAILED = /Could not fetch backups/;
const renderModal = () => render(
<BackupsModal open fileName='wrolpi.yaml' onClose={jest.fn()} fetchConfigs={jest.fn()}/>,
);
beforeEach(() => getConfigBackups.mockReset());
test('empty: says there are no backups', async () => {
getConfigBackups.mockResolvedValue({dates: []});
renderModal();
expect(await screen.findByText(NONE)).toBeInTheDocument();
});
test('failed: shows an error, not "No backups available"', async () => {
getConfigBackups.mockResolvedValue(undefined);
renderModal();
expect(await screen.findByText(FAILED)).toBeInTheDocument();
expect(screen.queryByText(NONE)).not.toBeInTheDocument();
});
test('thrown: shows an error, not a loader forever', async () => {
getConfigBackups.mockRejectedValue(new Error('down'));
renderModal();
expect(await screen.findByText(FAILED)).toBeInTheDocument();
expect(screen.queryByText(/Loading backups/)).not.toBeInTheDocument();
});

View file

@ -1,4 +1,4 @@
import {APIButton, ErrorMessage} from "../Common";
import {APIButton} from "../Common";
import {Button, Header, Icon, IconButton, Loader, Loading, Modal, Placeholder, Table, Tooltip} from "../ui";
import React, {useEffect, useState} from "react";
import {getConfigText, getConfigBackups, postConfigBackupImport, postConfigBackupPreview} from "../../api";
@ -117,11 +117,9 @@ function BackupDateRow({date, previews, previewsLoaded, onSelect}) {
</Table.Row>;
}
export function BackupsModal({open, onClose, fileName, fetchConfigs}) {
function BackupsModal({open, onClose, fileName, fetchConfigs}) {
const [dates, setDates] = useState([]);
const [datesLoading, setDatesLoading] = useState(true);
// The backups could not be fetched; distinct from the server confirming there are none.
const [datesFailed, setDatesFailed] = useState(false);
const [previews, setPreviews] = useState({});
const [previewsLoaded, setPreviewsLoaded] = useState({});
const [selectedAction, setSelectedAction] = useState(null); // {date, mode, preview}
@ -132,30 +130,17 @@ export function BackupsModal({open, onClose, fileName, fetchConfigs}) {
if (!open) return;
let cancelled = false;
setDatesLoading(true);
setDatesFailed(false);
setDates([]);
setPreviews({});
setPreviewsLoaded({});
setSelectedAction(null);
setApplying(false);
getConfigBackups(fileName)
.then(result => {
if (cancelled) return;
// The api helper returns undefined on a non-OK response.
if (result) {
setDates(result.dates || []);
} else {
setDatesFailed(true);
}
})
.catch(e => {
console.error(e);
if (!cancelled) setDatesFailed(true);
})
.finally(() => {
if (!cancelled) setDatesLoading(false);
});
getConfigBackups(fileName).then(result => {
if (cancelled) return;
setDates(result?.dates || []);
setDatesLoading(false);
});
return () => { cancelled = true; };
}, [open, fileName]);
@ -268,8 +253,6 @@ export function BackupsModal({open, onClose, fileName, fetchConfigs}) {
<Modal.Content>
{datesLoading
? <Loading>Loading backups...</Loading>
: datesFailed
? <ErrorMessage>Could not fetch backups for this config.</ErrorMessage>
: dates.length === 0
? <p>No backups available for this config.</p>
: <div style={{maxHeight: '400px', overflowY: 'auto'}}>

View file

@ -101,8 +101,8 @@ export function ShutdownButton() {
</APIButton>
}
export function UpgradeSegment() {
const {status, fetchStatus, loaded} = React.useContext(StatusContext);
function UpgradeSegment() {
const {status, fetchStatus} = React.useContext(StatusContext);
const dockerized = useDockerized();
const [upgrading, setUpgrading] = React.useState(false);
const [checking, setChecking] = React.useState(false);
@ -144,14 +144,6 @@ export function UpgradeSegment() {
</Panel>;
}
// Nothing is known until the first status poll lands; "up to date" is a claim about the server.
if (!loaded) {
return <Panel id='upgrade'>
<Header as='h3'>System Upgrade</Header>
<Loading/>
</Panel>;
}
// No update available
if (!status?.update_available) {
return <Panel id='upgrade'>
@ -333,9 +325,7 @@ export function SettingsPage() {
const [isDevMode, setIsDevMode] = React.useState(false);
// Get current settings from the API.
const {
settings, saveSettings, fetchSettings, loaded: settingsLoaded, failed: settingsFailed,
} = React.useContext(SettingsContext);
const {settings, saveSettings, fetchSettings} = React.useContext(SettingsContext);
const {status} = React.useContext(StatusContext);
// Used to track changes to the settings form between saves/loads.
const [state, setState] = React.useState({});
@ -473,8 +463,7 @@ export function SettingsPage() {
React.useEffect(() => {
console.debug('settings changed, replacing state...');
// true = show the form, false = still loading, undefined = could not fetch.
setReady(settingsLoaded ? true : settingsFailed ? undefined : false);
setReady(settings ? true : undefined);
setState({
// Only these settings can be changed on the SettingsPage.
archive_destination: settings.archive_destination,
@ -499,10 +488,9 @@ export function SettingsPage() {
videos_destination: settings.videos_destination,
zims_destination: settings.zims_destination,
playlists_destination: settings.playlists_destination,
repos_destination: settings.repos_destination,
save_ffprobe_json: settings.save_ffprobe_json,
});
}, [JSON.stringify(settings), settingsLoaded, settingsFailed]);
}, [JSON.stringify(settings)]);
const handleInputChange = async (name, value) => {
setState({...state, [name]: value});
@ -850,32 +838,6 @@ export function SettingsPage() {
onChange={e => handleInputChange('playlists_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<PathInput
label={<>
Repos Directory
<InfoPopup
content={<>
<p>Variables:</p>
<ul>
<li><code>%(repo_name)s</code> - Repo name</li>
<li><code>%(repo_tag)s</code> - Tag name for the repo (empty if no tag)
</li>
<li><code>%(repo_owner)s</code> - Owner from the repo URL</li>
<li><code>%(repo_host)s</code> - Host from the repo URL</li>
</ul>
<p>Must start with a fixed directory (like <code>repos/</code>), which
is never indexed, and contain <code>%(repo_name)s</code>.</p>
</>}
position='top'
/>
</>}
prefix={mediaDirectoryLabel}
value={state.repos_destination}
disabled={!editSpecialDirectories}
onChange={e => handleInputChange('repos_destination', e.currentTarget.value)}
/>
</Grid.Col>
<Grid.Col span={12}>
<Toggle
label='Edit Directories'

View file

@ -1,625 +0,0 @@
import React from "react";
import {useSearchParams} from "react-router";
import {
Accordion,
Button,
Grid,
Group,
Header,
IconButton,
Loader,
Message,
NumberInput,
Panel,
Select,
Statistic,
StatisticGroup,
Table,
Tabs,
TextInput,
Toggle,
} from "../ui";
import {InfoPopup, roundDigits} from "../Common";
import {
DEFAULT_LOSSES,
defaultAzimuth,
estimateProduction,
formatMonthly,
INVERTER_NOMINAL_EFFICIENCY,
LOSS_CATEGORIES,
MODULE_TYPES,
MONTH_NAMES,
MOUNTINGS,
optimalTilt,
parseCoordinate,
parseMonthly,
totalLoss,
TRACKING,
} from "./solar";
import {
CLIMATOLOGY_ACKNOWLEDGMENT,
CLIMATOLOGY_CHANGES,
CLIMATOLOGY_DATA_REFERENCE,
CLIMATOLOGY_LICENSE,
lookupClimatology,
useClimatology,
} from "./solarData";
import {SolarOffGrid} from "./SolarOffGrid";
// Every input lives in the URL query so the Share button (which shares window.location.href) shares
// the whole configuration. Values equal to their default are left out to keep the URL, and its QR
// code, small. Keys are short and must stay stable; shared links depend on them.
const DEFAULT_PANEL_WATTS = 400;
const DEFAULT_PANEL_COUNT = 1;
const COMPASS = [
{value: '0', label: 'N'}, {value: '45', label: 'NE'}, {value: '90', label: 'E'}, {value: '135', label: 'SE'},
{value: '180', label: 'S'}, {value: '225', label: 'SW'}, {value: '270', label: 'W'}, {value: '315', label: 'NW'},
];
const fToC = f => (f - 32) * 5 / 9;
const cToF = c => c * 9 / 5 + 32;
const numberOr = (value, fallback) => {
if (value === null || value === undefined || value === '') {
return fallback;
}
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
};
const fmt = (value, digits = 2) => (Number.isFinite(value) ? `${roundDigits(value, digits)}` : '—');
// Read and write the calculator's query parameters. Writes replace the history entry, so typing does
// not fill the Back button with one entry per keystroke.
export function useSolarParams() {
const [searchParams, setSearchParams] = useSearchParams();
const get = key => searchParams.get(key);
// `updates` maps keys to values; null, undefined and '' remove the key.
const set = updates => setSearchParams(prev => {
const next = new URLSearchParams(prev);
Object.entries(updates).forEach(([key, value]) => {
if (value === null || value === undefined || value === '') {
next.delete(key);
} else {
next.set(key, `${value}`);
}
});
return next;
}, {replace: true});
// Store a number, or remove it when it equals the default.
const setNumber = (key, value, fallback) =>
set({[key]: value === '' || Number(value) === fallback ? null : value});
return {searchParams, get, set, setNumber};
}
// Turn the query parameters into the model's inputs. Invalid values fall back to defaults, so a
// hand-edited or truncated link never crashes the page.
export function systemFromParams(get) {
const latitude = parseCoordinate(get('lat'), 'lat');
const longitude = parseCoordinate(get('lon'), 'lon');
const fahrenheit = get('tu') === 'f';
// Own sunlight data replaces the NASA data when present.
const ownData = get('ghi') !== null;
const ghi = parseMonthly(get('ghi')).map(v => (v !== null && v >= 0 ? v : null));
const temperatureInput = parseMonthly(get('temp'));
const temperature = temperatureInput.map(v => (v === null ? null : (fahrenheit ? fToC(v) : v)));
const panelWatts = Math.max(0, numberOr(get('pw'), DEFAULT_PANEL_WATTS));
const panelCount = Math.max(0, Math.round(numberOr(get('pn'), DEFAULT_PANEL_COUNT)));
const kwp = panelWatts * panelCount / 1000;
const tracking = TRACKING[get('track')] ? get('track') : 'fixed';
const mounting = MOUNTINGS[get('mount')] ? get('mount') : 'open';
const gamma = numberOr(get('gamma'), MODULE_TYPES.standard.gamma);
const defaultTilt = latitude === null ? 0 : Math.round(Math.abs(latitude));
const tilt = Math.min(90, Math.max(0, numberOr(get('tilt'), defaultTilt)));
const azimuth = numberOr(get('az'), defaultAzimuth(latitude ?? 0));
// Null means "use the monthly albedo from the data", which includes seasonal snow.
const albedoInput = numberOr(get('alb'), null);
const albedo = albedoInput === null ? null : Math.min(1, Math.max(0, albedoInput));
const lossValues = parseMonthly(get('losses'));
const losses = LOSS_CATEGORIES.map((c, i) => {
const v = lossValues[i];
return v !== null && v >= 0 && v <= 100 ? v : c.default;
});
const inverterOn = get('inv') === '1';
const acRating = numberOr(get('iac'), roundDigits(kwp / 1.2, 2));
const efficiency = numberOr(get('ieff'), INVERTER_NOMINAL_EFFICIENCY);
return {
latitude, longitude, fahrenheit, ownData, ghi, temperatureInput, temperature, panelWatts, panelCount,
kwp, tracking, mounting, gamma, defaultTilt, tilt, azimuth, albedo, losses, inverterOn, acRating,
efficiency,
};
}
// The sunlight inputs for the model: the user's own values, or the NASA data at the location.
export function sunlightFor(s, grid) {
if (s.ownData) {
return {ghi: s.ghi, diffuse: null, temperature: s.temperature, albedo: null};
}
if (!grid || s.latitude === null || s.longitude === null) {
return null;
}
return lookupClimatology(grid, s.latitude, s.longitude);
}
// What stops an estimate, or an empty list when it can run.
export function missingInputs(s, sunlight) {
const missing = [];
if (s.latitude === null) {
missing.push('a latitude');
}
if (!s.ownData && s.longitude === null) {
missing.push('a longitude');
}
if (s.ownData && s.ghi.some(v => v === null)) {
missing.push('sunlight for all 12 months');
}
if (!(s.kwp > 0)) {
missing.push('panel watts and count');
}
if (missing.length === 0 && (!sunlight || sunlight.ghi.some(v => v === null))) {
missing.push('a location with sunlight data');
}
return missing;
}
const modelInputs = (s, sunlight) => ({
latitude: s.latitude,
ghi: sunlight.ghi,
diffuse: sunlight.diffuse,
temperature: sunlight.temperature,
albedo: s.albedo ?? sunlight.albedo ?? undefined,
kwp: s.kwp,
tilt: s.tilt,
azimuth: s.azimuth,
tracking: s.tracking,
mounting: s.mounting,
gamma: s.gamma,
losses: s.losses,
inverter: s.inverterOn ? {acRating: s.acRating, efficiency: s.efficiency} : null,
});
const compassLabel = azimuth => {
const exact = COMPASS.find(c => Number(c.value) === azimuth);
return exact ? exact.label : `${azimuth}°`;
};
// Small explanatory text at the top of a collapsed section.
const Hint = ({children}) => <p style={{fontSize: '0.9em', opacity: 0.75, marginTop: 0}}>{children}</p>;
// NASA POWER's requested acknowledgment, the license, and how WROLPi changed the data (CC BY 4.0).
function DataAttribution() {
return <section aria-labelledby='solar-data-attribution'
style={{marginTop: '2em', fontSize: '0.85em', opacity: 0.75}}>
<Header as='h4' id='solar-data-attribution'>Data attribution</Header>
<p>{CLIMATOLOGY_ACKNOWLEDGMENT} {CLIMATOLOGY_DATA_REFERENCE}</p>
<p>License: {CLIMATOLOGY_LICENSE}</p>
<p>{CLIMATOLOGY_CHANGES}</p>
</section>;
}
const ESTIMATE_INFO = 'A typical-year estimate from monthly averages. Real years vary; expect roughly ±10–15% '
+ 'over a year and more in any single month. Shading from trees and buildings is not modeled beyond the '
+ 'Shading loss.';
// Browsers only offer geolocation to pages served over HTTPS.
export const geolocationAvailable = () => Boolean(window.isSecureContext && navigator.geolocation);
const GEOLOCATION_ERRORS = {
1: 'Location permission was denied. Allow it in your browser\'s settings for this site.',
2: 'Your device could not find its location. Phones with GPS work offline; most computers need the Internet '
+ 'for this.',
3: 'Finding your location took too long. Phones with GPS work offline; most computers need the Internet for '
+ 'this.',
};
function LocationSection({get, set, s, climatology}) {
const [locating, setLocating] = React.useState(false);
const [geoError, setGeoError] = React.useState(null);
const latError = get('lat') && s.latitude === null ? 'Latitude must be between -90 and 90' : null;
const lonError = get('lon') && s.longitude === null ? 'Longitude must be between -180 and 180' : null;
const locate = () => {
setLocating(true);
setGeoError(null);
navigator.geolocation.getCurrentPosition(
({coords}) => {
setLocating(false);
// 4 decimals (about 11 m) is far finer than the 1° data and keeps the shared URL short.
set({lat: Number(coords.latitude.toFixed(4)), lon: Number(coords.longitude.toFixed(4))});
},
error => {
setLocating(false);
setGeoError(GEOLOCATION_ERRORS[error.code] || error.message);
},
{enableHighAccuracy: false, timeout: 20000, maximumAge: 10 * 60 * 1000},
);
};
// The data's attribution is at the bottom of the page; this line only reports the data's state.
let source = null;
if (s.ownData) {
source = 'Using your own sunlight data.';
} else if (climatology.loading) {
source = <Loader size='xs' label='Loading sunlight data'/>;
} else if (climatology.error) {
source = <Message kind='warning' title='Sunlight data could not be loaded'>
Enter your own monthly sunlight under "Use my own sunlight data" below.
</Message>;
}
return <>
<Header as='h3'>1. Location</Header>
<Grid align='flex-end'>
<Grid.Col span='content'>
<IconButton icon='current location' label='Use my current location' size='input-sm'
loading={locating} disabled={!geolocationAvailable()} onClick={locate}/>
</Grid.Col>
<Grid.Col span='auto'>
<TextInput label='Latitude' placeholder='40.015 or 40°0′54″N' name='lat'
value={get('lat') || ''} error={latError}
onChange={e => set({lat: e.target.value})}/>
</Grid.Col>
<Grid.Col span={{base: 12, xs: 'auto'}}>
<TextInput label='Longitude' placeholder='-105.27 or 105°16′12″W' name='lon'
value={get('lon') || ''} error={lonError}
onChange={e => set({lon: e.target.value})}/>
</Grid.Col>
</Grid>
{geoError && <Message kind='warning' title='Could not get your location'>{geoError}</Message>}
{source && <div style={{fontSize: '0.85em', opacity: 0.75, marginTop: '0.5em'}}>{source}</div>}
</>;
}
function PanelsSection({setNumber, s}) {
return <>
<Header as='h3' style={{marginTop: '1em'}}>2. Your panels</Header>
<Grid>
<Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label='Watts per panel' name='pw' value={s.panelWatts} min={0} step={10}
onChange={v => setNumber('pw', v, DEFAULT_PANEL_WATTS)}/>
</Grid.Col>
<Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label='Panels' name='pn' value={s.panelCount} min={0} step={1}
onChange={v => setNumber('pn', v, DEFAULT_PANEL_COUNT)}/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}} style={{display: 'flex', alignItems: 'flex-end'}}>
<span style={{opacity: 0.75, paddingBottom: '0.5em'}}>
{fmt(s.kwp * 1000, 0)} W total ({fmt(s.kwp)} kW)
</span>
</Grid.Col>
</Grid>
</>;
}
function Results({result, s}) {
const unit = s.inverterOn ? 'kWh AC' : 'kWh DC';
const rows = result.months.map(m => <Table.Row key={m.month}>
<Table.Cell>{m.month}</Table.Cell>
<Table.Cell>{fmt(m.energyDaily)}</Table.Cell>
<Table.Cell>{fmt(m.energy, 0)}</Table.Cell>
<Table.Cell>{fmt(m.poa)}</Table.Cell>
<Table.Cell>{fmt(m.dayLength, 1)} h</Table.Cell>
</Table.Row>);
return <Panel style={{marginTop: '1em'}}>
<Header as='h3'>Estimate <InfoPopup content={ESTIMATE_INFO}/></Header>
<StatisticGroup>
<Statistic value={fmt(result.annual, 0)} label={`${unit} per year`}/>
<Statistic value={fmt(result.annual / 365, 1)} label={`${unit} per day, average`}/>
<Statistic value={fmt(result.worst.energyDaily, 1)} label={`${unit} per day in ${result.worst.month}`}/>
<Statistic value={fmt(result.specificYield, 0)} label='kWh per year per kW of panels'/>
</StatisticGroup>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Month</Table.HeaderCell>
<Table.HeaderCell>{unit}/day</Table.HeaderCell>
<Table.HeaderCell>{unit}/month</Table.HeaderCell>
<Table.HeaderCell>Sun hours on panel</Table.HeaderCell>
<Table.HeaderCell>Day length</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>{rows}</Table.Body>
</Table>
</Panel>;
}
function AngleSection({setNumber, s, sunlight, ready}) {
const compass = COMPASS.find(c => Number(c.value) === s.azimuth)?.value || null;
const applyOptimal = goal => setNumber('tilt', optimalTilt(modelInputs(s, sunlight), goal), s.defaultTilt);
const azimuthDefault = defaultAzimuth(s.latitude ?? 0);
if (s.tracking !== 'fixed') {
return <Hint>
A tracking array turns to follow the sun, so it has no fixed angle. Change Tracking under Panel details
to set one.
</Hint>;
}
return <>
<Hint>
Leave these alone unless your panels are already mounted. The default (tilted to your latitude, facing
the equator) is close to the best for the year. "Best for winter" suits off-grid systems that must get
through the darkest month.
</Hint>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<NumberInput label='Tilt (degrees from flat)' name='tilt' value={s.tilt} min={0} max={90} step={1}
onChange={v => setNumber('tilt', v, s.defaultTilt)}/>
<Group gap='xs' style={{marginTop: '0.25em'}}>
<Button type='button' size='small' disabled={!ready} onClick={() => applyOptimal('annual')}>
Best for the year
</Button>
<Button type='button' size='small' disabled={!ready} onClick={() => applyOptimal('winter')}>
Best for winter
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<Group gap='xs' wrap='nowrap' align='flex-end'>
<NumberInput label='Facing (compass degrees)' name='az' value={s.azimuth} min={0} max={359}
step={5} style={{flex: 1}} onChange={v => setNumber('az', v, azimuthDefault)}/>
<Select aria-label='Compass direction' data={COMPASS} value={compass} placeholder='—'
style={{width: '5.5em'}} allowDeselect={false}
onChange={v => setNumber('az', v, azimuthDefault)}/>
</Group>
</Grid.Col>
</Grid>
</>;
}
function DetailsSection({set, setNumber, s}) {
const moduleType = Object.entries(MODULE_TYPES).find(([, m]) => m.gamma === s.gamma)?.[0] || 'custom';
return <>
<Hint>
The defaults suit a typical ground or pole mounted array of ordinary panels. The temperature coefficient
is on your panel's datasheet; panels lose this much power for each °C they run above 25 °C.
</Hint>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<Select label='Panel type'
data={[
...Object.entries(MODULE_TYPES).map(([value, m]) => ({value, label: m.label})),
{value: 'custom', label: 'Custom (from datasheet)'},
]}
value={moduleType} allowDeselect={false}
onChange={v => v !== 'custom' && setNumber('gamma', MODULE_TYPES[v].gamma,
MODULE_TYPES.standard.gamma)}/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<NumberInput label='Temperature coefficient of power (%/°C)' name='gamma' value={s.gamma}
max={0} step={0.01} decimalScale={3}
onChange={v => setNumber('gamma', v, MODULE_TYPES.standard.gamma)}/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<Select label='Mounting' data={Object.entries(MOUNTINGS).map(([value, m]) => ({value, label: m.label}))}
value={s.mounting} allowDeselect={false}
onChange={v => set({mount: v === 'open' ? null : v})}/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<Select label='Tracking' data={Object.entries(TRACKING).map(([value, t]) => ({value, label: t.label}))}
value={s.tracking} allowDeselect={false}
onChange={v => set({track: v === 'fixed' ? null : v})}/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<NumberInput label='Ground reflectance (albedo)' name='alb' value={s.albedo ?? ''}
placeholder='From the data (monthly, includes snow)' min={0} max={1} step={0.05}
decimalScale={2} onChange={v => set({alb: v === '' ? null : v})}/>
</Grid.Col>
</Grid>
</>;
}
function LossesSection({set, s}) {
const setLoss = (index, value) => {
const next = [...s.losses];
next[index] = value === '' ? LOSS_CATEGORIES[index].default : Number(value);
const isDefault = next.every((v, i) => v === DEFAULT_LOSSES[i]);
set({losses: isDefault ? null : formatMonthly(next)});
};
return <>
<Hint>
Real systems lose some power to dirt, wiring, shading and more. These are PVWatts' defaults. Raise Shading
if trees or buildings shade your panels, or Snow if snow sits on them for weeks.
</Hint>
<Grid>
{LOSS_CATEGORIES.map((c, i) => <Grid.Col key={c.key} span={{base: 6, sm: 4}}>
<NumberInput label={`${c.label} (%)`} name={`loss-${c.key}`} value={s.losses[i]}
min={0} max={100} step={0.5} onChange={v => setLoss(i, v)}/>
</Grid.Col>)}
</Grid>
<Button type='button' size='small' style={{marginTop: '0.5em'}} onClick={() => set({losses: null})}>
Reset to PVWatts defaults
</Button>
</>;
}
function InverterSection({set, setNumber, s}) {
return <>
<Hint>
Leave this off if your panels charge batteries through a charge controller. Turn it on to see AC output
from a grid-tie or hybrid inverter.
</Hint>
<Toggle label='Include an inverter (AC output)' checked={s.inverterOn}
onChange={e => set({inv: e.currentTarget.checked ? '1' : null})}/>
{s.inverterOn && <Grid style={{marginTop: '0.5em'}}>
<Grid.Col span={{base: 6}}>
<NumberInput label='AC rating (kW)' name='iac' value={s.acRating} min={0} step={0.1} decimalScale={2}
onChange={v => setNumber('iac', v, roundDigits(s.kwp / 1.2, 2))}/>
</Grid.Col>
<Grid.Col span={{base: 6}}>
<NumberInput label='Efficiency (%)' name='ieff' value={s.efficiency} min={50} max={100} step={0.5}
onChange={v => setNumber('ieff', v, INVERTER_NOMINAL_EFFICIENCY)}/>
</Grid.Col>
</Grid>}
</>;
}
function OwnDataSection({set, s, nasa}) {
const unit = s.fahrenheit ? '°F' : '°C';
// Start from the NASA values, when available, so the user edits rather than types all 24.
const enable = on => {
if (!on) {
set({ghi: null, temp: null});
return;
}
const ghi = nasa ? nasa.ghi.map(v => (v === null ? '' : roundDigits(v, 2))) : Array(12).fill('');
const temp = nasa
? nasa.temperature.map(v => (v === null ? '' : roundDigits(s.fahrenheit ? cToF(v) : v, 1)))
: Array(12).fill('');
// An all-blank list is still written for sunlight, so the section stays on for typing.
set({ghi: formatMonthly(ghi), temp: nasa ? formatMonthly(temp) : null});
};
// Convert the temperatures with the unit, so they stay the same physical temperatures.
const switchUnits = toFahrenheit => {
const temps = s.temperatureInput.map(v => (v === null ? null
: roundDigits(toFahrenheit ? cToF(v) : fToC(v), 1)));
set({tu: toFahrenheit ? 'f' : null, temp: temps.some(v => v !== null) ? formatMonthly(temps) : null});
};
const setMonth = (key, values, index, value) => {
const next = [...values];
next[index] = value === '' ? null : value;
set({[key]: formatMonthly(next)});
};
const rows = MONTH_NAMES.map((name, i) => <Table.Row key={name}>
<Table.Cell>{name}</Table.Cell>
<Table.Cell>
<NumberInput name={`ghi-${i}`} aria-label={`${name} sunlight`} value={s.ghi[i] ?? ''} min={0}
step={0.1} hideControls onChange={v => setMonth('ghi', s.ghi, i, v)}/>
</Table.Cell>
<Table.Cell>
<NumberInput name={`temp-${i}`} aria-label={`${name} temperature`}
placeholder={s.fahrenheit ? '68' : '20'} value={s.temperatureInput[i] ?? ''} step={1}
hideControls onChange={v => setMonth('temp', s.temperatureInput, i, v)}/>
</Table.Cell>
</Table.Row>);
return <>
<Hint>
Only needed if you have better local measurements than the built-in data, for example in mountains, where
a 1° grid cell (about 69 miles across) can average very different terrain. Sunlight is global horizontal
irradiance in kWh/m²/day, the same as "peak sun hours" on flat ground. Blank temperatures use 20 °C.
</Hint>
<Group gap='lg'>
<Toggle label='Use my own sunlight data' checked={s.ownData}
onChange={e => enable(e.currentTarget.checked)}/>
{s.ownData && <Toggle label='Fahrenheit' checked={s.fahrenheit}
onChange={e => switchUnits(e.currentTarget.checked)}/>}
</Group>
{s.ownData && <Table style={{marginTop: '0.5em'}}>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Month</Table.HeaderCell>
<Table.HeaderCell>Sunlight (kWh/m²/day)</Table.HeaderCell>
<Table.HeaderCell>Avg. temp ({unit})</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>{rows}</Table.Body>
</Table>}
</>;
}
export function SolarCalculator() {
const {searchParams, get, set, setNumber} = useSolarParams();
const climatology = useClimatology();
const queryString = searchParams.toString();
// eslint-disable-next-line react-hooks/exhaustive-deps
const s = React.useMemo(() => systemFromParams(get), [queryString]);
const nasa = React.useMemo(
() => (climatology.grid && s.latitude !== null && s.longitude !== null
? lookupClimatology(climatology.grid, s.latitude, s.longitude) : null),
[climatology.grid, s.latitude, s.longitude]);
const sunlight = s.ownData ? sunlightFor(s, null) : nasa;
const missing = missingInputs(s, sunlight);
const ready = missing.length === 0;
const result = React.useMemo(
() => (ready ? estimateProduction(modelInputs(s, sunlight)) : null),
// eslint-disable-next-line react-hooks/exhaustive-deps
[s, sunlight, ready]);
// Off-grid sizing works from DC production per kW of panels, whatever the inverter setting.
const worstPerKwp = React.useMemo(
() => (ready ? estimateProduction({...modelInputs(s, sunlight), kwp: 1, inverter: null}).worst : null),
// eslint-disable-next-line react-hooks/exhaustive-deps
[s, sunlight, ready]);
const tab = get('tab') === 'offgrid' ? 'offgrid' : 'estimate';
const angleTitle = s.tracking === 'fixed'
? `Panel angle: ${s.tilt}° tilt, facing ${compassLabel(s.azimuth)}`
: 'Panel angle: follows the sun';
const detailsTitle = `Panel details: ${TRACKING[s.tracking].label.toLowerCase()}, `
+ `${MOUNTINGS[s.mounting].label.toLowerCase()}`;
const waitingForData = climatology.loading && !s.ownData;
return <div>
<Header as='h1'>Solar</Header>
<p>
Estimate what solar panels will produce over a typical year. Enter a location and your panels; everything
else has sensible defaults. Settings are saved in the page address, so you can bookmark or share them.
</p>
<LocationSection get={get} set={set} s={s} climatology={climatology}/>
<PanelsSection setNumber={setNumber} s={s}/>
<Tabs value={tab} onChange={v => set({tab: v === 'offgrid' ? v : null})} keepMounted={false}
style={{marginTop: '1.5em'}}>
<Tabs.List>
<Tabs.Tab value='estimate'>Estimate</Tabs.Tab>
<Tabs.Tab value='offgrid'>Off-grid sizing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value='estimate'>
{ready && <Results result={result} s={s}/>}
{!ready && !waitingForData && <div style={{marginTop: '1em'}}>
<Message kind='info' title='Almost there'>Enter {missing.join(', ')} to see an estimate.</Message>
</div>}
</Tabs.Panel>
<Tabs.Panel value='offgrid'>
<SolarOffGrid get={get} set={set} setNumber={setNumber} s={s} worst={worstPerKwp}
temperatures={sunlight?.temperature} loading={waitingForData}/>
</Tabs.Panel>
</Tabs>
<Header as='h3' style={{marginTop: '1.5em'}}>Fine-tune (optional)</Header>
<Accordion multiple>
<Accordion.Item value='angle'>
<Accordion.Control>{angleTitle}</Accordion.Control>
<Accordion.Panel>
<AngleSection setNumber={setNumber} s={s} sunlight={sunlight} ready={ready}/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='details'>
<Accordion.Control>{detailsTitle}</Accordion.Control>
<Accordion.Panel><DetailsSection set={set} setNumber={setNumber} s={s}/></Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='losses'>
<Accordion.Control>System losses: {fmt(totalLoss(s.losses))}%</Accordion.Control>
<Accordion.Panel><LossesSection set={set} s={s}/></Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='inverter'>
<Accordion.Control>Inverter: {s.inverterOn ? 'on (AC output)' : 'off (DC output)'}</Accordion.Control>
<Accordion.Panel><InverterSection set={set} setNumber={setNumber} s={s}/></Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='own'>
<Accordion.Control>Use my own sunlight data: {s.ownData ? 'on' : 'off'}</Accordion.Control>
<Accordion.Panel><OwnDataSection set={set} s={s} nasa={nasa}/></Accordion.Panel>
</Accordion.Item>
</Accordion>
<DataAttribution/>
</div>;
}

View file

@ -1,256 +0,0 @@
import React from "react";
import {fireEvent, screen, within} from "@testing-library/react";
import {renderWithProviders} from "../../test-utils";
import {missingInputs, SolarCalculator, sunlightFor, systemFromParams} from "./SolarCalculator";
import {CalculatorsPage} from "../Calculators";
import {DEFAULT_LOSSES, estimateProduction, MODULE_TYPES} from "./solar";
import {decodeClimatology, forgetClimatology, lookupClimatology} from "./solarData";
import {mockClimatologyFetch, SUNNY_GHI, uniformClimatology} from "./solarData.fixtures";
import {roundDigits} from "../Common";
const BASE = '/more/calculators?calc=solar';
const DENVER = `${BASE}&lat=40&lon=-105`;
const params = query => {
const searchParams = new URLSearchParams(query);
return key => searchParams.get(key);
};
const currentParams = () => new URLSearchParams(window.location.search);
// The estimate appears once the data file has loaded and decoded, which can be slow on a busy machine.
const findEstimate = (unit = 'DC') => screen.findByText(`kWh ${unit} per year`, {}, {timeout: 5000});
// The annual estimate the page should show for a location, from the same uniform test data.
const expectedAnnual = (overrides = {}) => {
const grid = decodeClimatology(uniformClimatology());
const sunlight = lookupClimatology(grid, 40, -105);
return estimateProduction({latitude: 40, kwp: 0.4, tilt: 40, azimuth: 180, ...sunlight, ...overrides}).annual;
};
beforeEach(() => {
forgetClimatology();
mockClimatologyFetch();
});
describe('systemFromParams', () => {
test('defaults when the URL is empty', () => {
const s = systemFromParams(params(''));
expect(s.latitude).toBeNull();
expect(s.kwp).toBeCloseTo(0.4, 6);
expect(s.tracking).toBe('fixed');
expect(s.mounting).toBe('open');
expect(s.gamma).toBe(MODULE_TYPES.standard.gamma);
expect(s.losses).toEqual(DEFAULT_LOSSES);
expect(s.inverterOn).toBe(false);
expect(s.ownData).toBe(false);
expect(s.albedo).toBeNull();
});
test('tilt and azimuth default from the latitude', () => {
expect(systemFromParams(params('lat=40.4')).tilt).toBe(40);
expect(systemFromParams(params('lat=40.4')).azimuth).toBe(180);
expect(systemFromParams(params('lat=-33.9')).tilt).toBe(34);
expect(systemFromParams(params('lat=-33.9')).azimuth).toBe(0);
});
test('invalid values fall back instead of crashing', () => {
const s = systemFromParams(params('lat=999&track=bogus&mount=x&tilt=abc&losses=1,2,abc&alb=7&pn=-3'));
expect(s.latitude).toBeNull();
expect(s.tracking).toBe('fixed');
expect(s.mounting).toBe('open');
expect(s.tilt).toBe(0);
expect(s.losses.slice(0, 3)).toEqual([1, 2, DEFAULT_LOSSES[2]]);
expect(s.albedo).toBe(1);
expect(s.kwp).toBe(0);
});
test('own Fahrenheit temperatures are converted to Celsius', () => {
const s = systemFromParams(params('ghi=5&tu=f&temp=32,212'));
expect(s.ownData).toBe(true);
expect(s.temperature[0]).toBeCloseTo(0, 6);
expect(s.temperature[1]).toBeCloseTo(100, 6);
expect(s.temperatureInput[0]).toBe(32);
});
});
describe('sunlight and missing inputs', () => {
const grid = decodeClimatology(uniformClimatology());
test('the NASA data is used unless the user gave their own', () => {
const nasa = sunlightFor(systemFromParams(params('lat=40&lon=-105')), grid);
expect(nasa.ghi[5]).toBeCloseTo(SUNNY_GHI[5], 1);
expect(nasa.diffuse[5]).toBeCloseTo(SUNNY_GHI[5] * 0.3, 1);
const own = sunlightFor(systemFromParams(params(`lat=40&lon=-105&ghi=${Array(12).fill(1).join(',')}`)), grid);
expect(own.ghi).toEqual(Array(12).fill(1));
expect(own.diffuse).toBeNull();
});
test('a location is enough with the NASA data', () => {
const s = systemFromParams(params('lat=40&lon=-105'));
expect(missingInputs(s, sunlightFor(s, grid))).toEqual([]);
const noLon = systemFromParams(params('lat=40'));
expect(missingInputs(noLon, sunlightFor(noLon, grid))).toEqual(['a longitude']);
});
test('own data needs all 12 months but no longitude', () => {
const s = systemFromParams(params('lat=40&ghi=1,2,3'));
expect(missingInputs(s, sunlightFor(s, grid))).toEqual(['sunlight for all 12 months']);
});
});
describe('SolarCalculator', () => {
test('is linked from the Engineering group of the calculators page', () => {
renderWithProviders(<CalculatorsPage/>, {route: '/more/calculators'});
const engineering = screen.getByRole('heading', {name: 'Engineering'}).closest('.wrolpi-header').parentElement;
const link = within(engineering).getByRole('link', {name: /Solar/});
expect(link).toHaveAttribute('href', '/more/calculators?calc=solar');
});
test('asks for a location', async () => {
renderWithProviders(<SolarCalculator/>, {route: BASE});
expect(await screen.findByText('Enter a latitude, a longitude to see an estimate.', {}, {timeout: 5000}))
.toBeInTheDocument();
});
test('a location alone gives an estimate from the built-in data', async () => {
renderWithProviders(<SolarCalculator/>, {route: DENVER});
expect(await findEstimate()).toBeInTheDocument();
expect(screen.getByText(`${roundDigits(expectedAnnual(), 0)}`)).toBeInTheDocument();
// The attribution is only at the bottom of the page.
expect(screen.queryByText(/Sunlight and temperature/)).not.toBeInTheDocument();
expect(global.fetch).toHaveBeenCalledWith(expect.stringMatching(/\/data\/solar-climatology\.bin$/));
});
test('the data attribution is at the bottom of the page', async () => {
renderWithProviders(<SolarCalculator/>, {route: DENVER});
await findEstimate();
const heading = screen.getByRole('heading', {name: 'Data attribution'});
const section = screen.getByRole('region', {name: 'Data attribution'});
expect(within(section).getByText(/Prediction Of Worldwide Energy Resources/)).toBeInTheDocument();
expect(within(section).getByText(/WROLPi modified the data/)).toBeInTheDocument();
expect(within(section).getByText(/CC BY 4\.0/)).toBeInTheDocument();
// After everything else, including the last Fine-tune section.
const lastControl = screen.getByText('Use my own sunlight data: off');
expect(lastControl.compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
test('typing writes the URL and leaves defaults out', async () => {
renderWithProviders(<SolarCalculator/>, {route: BASE});
fireEvent.change(screen.getByLabelText('Latitude'), {target: {value: '40°0\'54"N'}});
expect(currentParams().get('lat')).toBe('40°0\'54"N');
expect(currentParams().get('calc')).toBe('solar');
fireEvent.change(screen.getByLabelText('Panels'), {target: {value: '4'}});
expect(currentParams().get('pn')).toBe('4');
fireEvent.change(screen.getByLabelText('Panels'), {target: {value: '1'}});
expect(currentParams().has('pn')).toBe(false);
});
describe('use my current location', () => {
const withGeolocation = getCurrentPosition => {
Object.defineProperty(window, 'isSecureContext', {value: true, configurable: true});
Object.defineProperty(navigator, 'geolocation', {value: {getCurrentPosition}, configurable: true});
};
afterEach(() => {
delete window.isSecureContext;
delete navigator.geolocation;
});
test('fills in the location, rounded to 4 decimals', async () => {
withGeolocation(success => success({coords: {latitude: 39.7392358, longitude: -104.990251}}));
renderWithProviders(<SolarCalculator/>, {route: BASE});
fireEvent.click(screen.getByRole('button', {name: 'Use my current location'}));
expect(currentParams().get('lat')).toBe('39.7392');
expect(currentParams().get('lon')).toBe('-104.9903');
expect(screen.getByLabelText('Latitude')).toHaveValue('39.7392');
expect(await findEstimate()).toBeInTheDocument();
});
test('explains a denied permission', () => {
withGeolocation((success, failure) => failure({code: 1, message: 'User denied Geolocation'}));
renderWithProviders(<SolarCalculator/>, {route: BASE});
fireEvent.click(screen.getByRole('button', {name: 'Use my current location'}));
expect(screen.getByText('Could not get your location')).toBeInTheDocument();
expect(screen.getByText(/Location permission was denied/)).toBeInTheDocument();
expect(currentParams().has('lat')).toBe(false);
});
test('is disabled without HTTPS', () => {
Object.defineProperty(window, 'isSecureContext', {value: false, configurable: true});
renderWithProviders(<SolarCalculator/>, {route: BASE});
expect(screen.getByRole('button', {name: 'Use my current location'})).toBeDisabled();
});
});
test('an invalid latitude is flagged', () => {
renderWithProviders(<SolarCalculator/>, {route: `${BASE}&lat=95`});
expect(screen.getByText('Latitude must be between -90 and 90')).toBeInTheDocument();
});
test('the inverter switches the results to AC', async () => {
renderWithProviders(<SolarCalculator/>, {route: DENVER});
await findEstimate();
fireEvent.click(screen.getByText('Inverter: off (DC output)'));
fireEvent.click(screen.getByLabelText('Include an inverter (AC output)'));
expect(currentParams().get('inv')).toBe('1');
expect(screen.getByText('kWh AC per year')).toBeInTheDocument();
});
test('best-for-the-year replaces a poor tilt', async () => {
renderWithProviders(<SolarCalculator/>, {route: `${DENVER}&tilt=10`});
await findEstimate();
expect(screen.getByText('Panel angle: 10° tilt, facing S')).toBeInTheDocument();
fireEvent.click(screen.getByText('Panel angle: 10° tilt, facing S'));
fireEvent.click(await screen.findByRole('button', {name: 'Best for the year'}));
// A result of 40 equals the default (the latitude), so it is left out of the URL.
const tilt = Number(currentParams().get('tilt') ?? 40);
expect(tilt).toBeGreaterThanOrEqual(30);
expect(tilt).toBeLessThanOrEqual(44);
});
test('own sunlight data starts from the built-in values and replaces them', async () => {
renderWithProviders(<SolarCalculator/>, {route: DENVER});
await findEstimate();
fireEvent.click(screen.getByText('Use my own sunlight data: off'));
fireEvent.click(screen.getByLabelText('Use my own sunlight data'));
const ghi = currentParams().get('ghi').split(',').map(Number);
ghi.forEach((v, i) => expect(v).toBeCloseTo(SUNNY_GHI[i], 1));
expect(screen.getByText('Using your own sunlight data.')).toBeInTheDocument();
expect(screen.getByLabelText('Jun sunlight')).toHaveValue(`${ghi[5]}`);
// Halving June's sunlight lowers the estimate.
fireEvent.change(screen.getByLabelText('Jun sunlight'), {target: {value: `${ghi[5] / 2}`}});
expect(currentParams().get('ghi').split(',')[5]).toBe(`${ghi[5] / 2}`);
fireEvent.click(screen.getByLabelText('Use my own sunlight data'));
expect(currentParams().has('ghi')).toBe(false);
expect(currentParams().has('temp')).toBe(false);
});
test('switching to Fahrenheit converts your temperatures instead of relabeling them', async () => {
const temps = '0,10,,,,,,,,,,';
renderWithProviders(<SolarCalculator/>,
{route: `${DENVER}&ghi=${SUNNY_GHI.join(',')}&temp=${temps}`});
await findEstimate();
fireEvent.click(screen.getByText('Use my own sunlight data: on'));
fireEvent.click(screen.getByLabelText('Fahrenheit'));
expect(currentParams().get('tu')).toBe('f');
expect(currentParams().get('temp')).toBe('32,50,,,,,,,,,,');
expect(screen.getByLabelText('Jan temperature')).toHaveValue('32');
fireEvent.click(screen.getByLabelText('Fahrenheit'));
expect(currentParams().has('tu')).toBe(false);
expect(currentParams().get('temp')).toBe(temps);
});
test('a failed download explains how to continue', async () => {
forgetClimatology();
global.fetch = jest.fn(() => Promise.resolve({ok: false, status: 404}));
renderWithProviders(<SolarCalculator/>, {route: DENVER});
expect(await screen.findByText('Sunlight data could not be loaded', {}, {timeout: 5000})).toBeInTheDocument();
expect(screen.getByText('Enter a location with sunlight data to see an estimate.')).toBeInTheDocument();
});
});

View file

@ -1,350 +0,0 @@
import React from "react";
import {Button, Grid, Group, Header, IconButton, Loader, Message, NumberInput, Panel, Select, Statistic,
StatisticGroup, Table, TextInput, Toggle} from "../ui";
import {roundDigits} from "../Common";
import {
BATTERIES,
batteryWarnings,
CONTROLLERS,
controllerSizing,
dailyLoadWh,
defaultColdest,
formatLoads,
leadAcidCapacityFactor,
loadWh,
parseLoads,
requiredKwp,
sizeBattery,
SYSTEM_VOLTAGES,
} from "./offgrid";
// Defaults are typical of a 400 W residential panel; the user should copy their panel's label.
const PANEL_DEFAULTS = {voc: 37, vmp: 31, isc: 13.8, bvoc: -0.27};
const DEFAULT_LOAD_INVERTER_EFFICIENCY = 90;
const DEFAULT_DAYS = 2;
const DEFAULT_CONTROLLER_MAX_VOLTAGE = 100;
// Above this, a higher system voltage needs a much smaller controller and thinner wire.
const HIGH_CURRENT = 60;
const numberOr = (value, fallback) => {
if (value === null || value === undefined || value === '') {
return fallback;
}
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
};
const fmt = (value, digits = 0) => (Number.isFinite(value) ? `${roundDigits(value, digits)}` : '—');
const Hint = ({children}) => <p style={{fontSize: '0.9em', opacity: 0.75, marginTop: 0}}>{children}</p>;
// The off-grid inputs, read from the URL like the rest of the calculator. Invalid values fall back.
export function offGridFromParams(get, monthlyTemperatures) {
const battery = BATTERIES[get('bat')] ? get('bat') : 'lfp';
const systemVoltage = SYSTEM_VOLTAGES.includes(Number(get('sv'))) ? Number(get('sv')) : 12;
const controller = CONTROLLERS[get('ctl')] ? get('ctl') : 'mppt';
const coldestMonthly = (monthlyTemperatures || []).filter(Number.isFinite).length
? Math.min(...monthlyTemperatures.filter(Number.isFinite)) : null;
const defaultTmin = defaultColdest(monthlyTemperatures);
return {
loads: parseLoads(get('load')),
acLoads: get('ac') !== '0',
loadInverterEfficiency: numberOr(get('leff'), DEFAULT_LOAD_INVERTER_EFFICIENCY),
battery,
systemVoltage,
dod: numberOr(get('dod'), BATTERIES[battery].dod),
days: Math.max(0, numberOr(get('aut'), DEFAULT_DAYS)),
outdoors: get('bout') === '1',
controller,
voc: numberOr(get('voc'), PANEL_DEFAULTS.voc),
vmp: numberOr(get('vmp'), PANEL_DEFAULTS.vmp),
isc: numberOr(get('isc'), PANEL_DEFAULTS.isc),
// Always negative for silicon panels; a missing minus sign must not hide the cold-weather rise.
betaVoc: -Math.abs(numberOr(get('bvoc'), PANEL_DEFAULTS.bvoc)),
series: Math.max(1, Math.round(numberOr(get('ns'), 1))),
controllerMaxVoltage: numberOr(get('cmax'), DEFAULT_CONTROLLER_MAX_VOLTAGE),
coldestMonthly,
defaultTmin,
coldest: numberOr(get('tmin'), defaultTmin),
};
}
/**
* Everything the off-grid tab shows, from its inputs.
*
* @param {Object} o offGridFromParams()
* @param {Object} worst the worst month for a 1 kW array: {month, energyDaily}
* @param {number} panelWatts
* @param {number} panelCount
*/
export function offGridResults(o, worst, panelWatts, panelCount) {
const devicesWh = dailyLoadWh(o.loads);
// AC devices draw through an inverter, so the batteries supply more than the devices use.
const batteryLoadWh = o.acLoads && o.loadInverterEfficiency > 0
? devicesWh / (o.loadInverterEfficiency / 100) : devicesWh;
const capacityFactor = o.outdoors && o.battery === 'lead' ? leadAcidCapacityFactor(o.coldestMonthly) : 1;
const bank = sizeBattery({
loadWh: batteryLoadWh, days: o.days, dod: o.dod, voltage: o.systemVoltage, capacityFactor,
});
const sizing = controllerSizing({
panelCount, panelWatts, series: o.series, voc: o.voc, vmp: o.vmp, isc: o.isc, betaVoc: o.betaVoc,
coldest: o.coldest, controller: o.controller, controllerMaxVoltage: o.controllerMaxVoltage,
systemVoltage: o.systemVoltage,
});
// A PWM controller wastes part of every panel's output, so more panels are needed.
const worstPerKwp = worst ? worst.energyDaily * sizing.pwmFraction : null;
const kwp = requiredKwp(batteryLoadWh, worstPerKwp, BATTERIES[o.battery].efficiency);
const panelsNeeded = kwp && panelWatts > 0 ? Math.ceil(kwp * 1000 / panelWatts) : null;
const yourArrayWh = worstPerKwp !== null
? worstPerKwp * (panelCount * panelWatts / 1000) * 1000 * BATTERIES[o.battery].efficiency / 100 : null;
return {
devicesWh, batteryLoadWh, bank, kwp, panelsNeeded, sizing,
coverage: batteryLoadWh > 0 && yourArrayWh !== null ? yourArrayWh / batteryLoadWh : null,
warnings: batteryWarnings({battery: o.battery, outdoors: o.outdoors, coldestMonthly: o.coldestMonthly}),
};
}
function LoadsSection({o, r, set, setNumber}) {
const rows = o.loads.length ? o.loads : [{name: '', watts: '', hours: ''}];
const write = next => set({load: next.some(row => row.name || row.watts || row.hours) ? formatLoads(next) : null});
const update = (index, field, value) => write(rows.map((row, i) => (i === index ? {...row, [field]: value} : row)));
const remove = index => write(rows.filter((_, i) => i !== index));
return <>
<Header as='h3'>Your devices</Header>
<Hint>
List what you will run and for how long each day. The watts are on the device's label or power supply;
a refrigerator cycles on and off, so use about a third of its rating for 24 hours.
</Hint>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Device</Table.HeaderCell>
<Table.HeaderCell>Watts</Table.HeaderCell>
<Table.HeaderCell>Hours per day</Table.HeaderCell>
<Table.HeaderCell>Wh per day</Table.HeaderCell>
<Table.HeaderCell/>
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map((row, i) => <Table.Row key={i}>
<Table.Cell>
<TextInput aria-label={`Device ${i + 1} name`} placeholder='Refrigerator' value={row.name}
onChange={e => update(i, 'name', e.target.value)}/>
</Table.Cell>
<Table.Cell>
<NumberInput aria-label={`Device ${i + 1} watts`} placeholder='50' value={row.watts} min={0}
hideControls onChange={v => update(i, 'watts', v)}/>
</Table.Cell>
<Table.Cell>
<NumberInput aria-label={`Device ${i + 1} hours`} placeholder='24' value={row.hours} min={0}
max={24} hideControls onChange={v => update(i, 'hours', v)}/>
</Table.Cell>
<Table.Cell>{fmt(loadWh(row))}</Table.Cell>
<Table.Cell>
<IconButton icon='trash' label={`Remove device ${i + 1}`} onClick={() => remove(i)}/>
</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>
<Group gap='md' style={{marginTop: '0.5em'}}>
<Button type='button' size='small' icon='add'
onClick={() => write([...rows, {name: '', watts: '', hours: ''}])}>
Add device
</Button>
<span><b>{fmt(r.devicesWh)} Wh</b> per day</span>
</Group>
<Group gap='lg' align='flex-end' style={{marginTop: '0.75em'}}>
<Toggle label='Devices run on AC, through an inverter' checked={o.acLoads}
onChange={e => set({ac: e.currentTarget.checked ? null : '0'})}/>
{o.acLoads && <NumberInput label='Inverter efficiency (%)' name='leff' value={o.loadInverterEfficiency}
min={50} max={100} step={1} style={{width: '12em'}}
onChange={v => setNumber('leff', v, DEFAULT_LOAD_INVERTER_EFFICIENCY)}/>}
</Group>
</>;
}
function BatterySection({o, set, setNumber}) {
return <>
<Header as='h3' style={{marginTop: '1.5em'}}>Batteries</Header>
<Hint>
Days without sun is how long the batteries must carry your devices through cloudy weather. Two or three
days is typical. Larger systems (over about 1,500 W of panels) usually use 24 or 48 volts.
</Hint>
<Grid>
<Grid.Col span={{base: 12, sm: 6}}>
<Select label='Battery type'
data={Object.entries(BATTERIES).map(([value, b]) => ({value, label: b.label}))}
value={o.battery} allowDeselect={false}
onChange={v => set({bat: v === 'lfp' ? null : v, dod: null})}/>
</Grid.Col>
<Grid.Col span={{base: 6, sm: 3}}>
<Select label='System voltage' data={SYSTEM_VOLTAGES.map(v => ({value: `${v}`, label: `${v} V`}))}
value={`${o.systemVoltage}`} allowDeselect={false}
onChange={v => set({sv: v === '12' ? null : v})}/>
</Grid.Col>
<Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label='Days without sun' name='aut' value={o.days} min={0} step={0.5}
onChange={v => setNumber('aut', v, DEFAULT_DAYS)}/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}}>
<NumberInput label='Usable depth of discharge (%)' name='dod' value={o.dod} min={10} max={100} step={5}
onChange={v => setNumber('dod', v, BATTERIES[o.battery].dod)}/>
</Grid.Col>
<Grid.Col span={{base: 12, sm: 6}} style={{display: 'flex', alignItems: 'flex-end'}}>
<Toggle label='Batteries are outdoors or unheated' checked={o.outdoors}
onChange={e => set({bout: e.currentTarget.checked ? '1' : null})}/>
</Grid.Col>
</Grid>
</>;
}
function NeedsSection({o, r, worst, s, setNumber}) {
if (!(r.devicesWh > 0)) {
return <div style={{marginTop: '1em'}}>
<Message kind='info' title='Add your devices'>List your devices above to size the system.</Message>
</div>;
}
const coverage = r.coverage;
return <Panel style={{marginTop: '1.5em'}}>
<Header as='h3'>What you need</Header>
<StatisticGroup>
<Statistic value={fmt(r.panelsNeeded)} label={`${s.panelWatts} W panels for ${worst.month}`}/>
<Statistic value={fmt(r.bank?.ah)} label={`Ah of ${o.systemVoltage} V battery`}/>
<Statistic value={fmt(r.bank ? r.bank.nominalWh / 1000 : null, 1)} label='kWh of battery'/>
<Statistic value={fmt(r.batteryLoadWh)} label='Wh per day from the batteries'/>
</StatisticGroup>
<p>
Sized for {worst.month}, your darkest month, when each kW of panels makes about{' '}
{fmt(worst.energyDaily, 1)} kWh a day at the angle chosen under Fine-tune.
{o.controller === 'pwm' && r.sizing.pwmFraction > 0 && ` The PWM controller uses only `
+ `${fmt(r.sizing.pwmFraction * 100)}% of the panels' power, so more panels are needed.`}
{o.controller === 'pwm' && r.sizing.pwmFraction === 0 && ' These panels cannot charge this battery '
+ 'through a PWM controller; see the warning below.'}
</p>
{coverage !== null && <p>
Your {s.panelCount} panel{s.panelCount === 1 ? '' : 's'} would supply about{' '}
<b>{fmt(Math.min(coverage, 9.99) * 100)}%</b> of your use in {worst.month}.{' '}
{r.panelsNeeded !== s.panelCount && r.panelsNeeded > 0 &&
<Button type='button' size='small' onClick={() => setNumber('pn', r.panelsNeeded, 1)}>
Use {r.panelsNeeded} panels
</Button>}
</p>}
{r.warnings.map(w => <Message key={w} kind='warning' title='Battery'>{w}</Message>)}
</Panel>;
}
function ControllerSection({o, r, set, setNumber, temperaturesKnown}) {
const z = r.sizing;
const panelField = (key, label, value, fallback, step, max) => <Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label={label} name={key} value={value} step={step} decimalScale={3} max={max}
onChange={v => setNumber(key, v, fallback)}/>
</Grid.Col>;
return <>
<Header as='h3' style={{marginTop: '1.5em'}}>Charge controller and wiring</Header>
<Hint>
Copy these from the label on the back of your panel. Panels make a higher voltage when cold; too many in
series can exceed the controller's limit on a cold morning and destroy it. The coldest morning starts
25 °C below your coldest monthly average, because averages hide cold snaps; enter your area's record
low if you know it.
</Hint>
<Grid>
{panelField('voc', 'Open-circuit voltage, Voc (V)', o.voc, PANEL_DEFAULTS.voc, 0.1)}
{panelField('vmp', 'Max-power voltage, Vmp (V)', o.vmp, PANEL_DEFAULTS.vmp, 0.1)}
{panelField('isc', 'Short-circuit current, Isc (A)', o.isc, PANEL_DEFAULTS.isc, 0.1)}
{panelField('bvoc', 'Voc temperature coefficient (%/°C)', o.betaVoc, PANEL_DEFAULTS.bvoc, 0.01, 0)}
<Grid.Col span={{base: 6, sm: 3}}>
<Select label='Controller type'
data={Object.entries(CONTROLLERS).map(([value, c]) => ({value, label: c.label}))}
value={o.controller} allowDeselect={false}
onChange={v => set({ctl: v === 'mppt' ? null : v})}/>
</Grid.Col>
<Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label='Controller max PV voltage (V)' name='cmax' value={o.controllerMaxVoltage} min={0}
step={10} onChange={v => setNumber('cmax', v, DEFAULT_CONTROLLER_MAX_VOLTAGE)}/>
</Grid.Col>
<Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label='Panels in series' name='ns' value={o.series} min={1} step={1}
onChange={v => setNumber('ns', v, 1)}/>
</Grid.Col>
<Grid.Col span={{base: 6, sm: 3}}>
<NumberInput label='Coldest morning (°C)' name='tmin' value={o.coldest} step={1}
onChange={v => setNumber('tmin', v, o.defaultTmin)}/>
</Grid.Col>
</Grid>
{/* The cold-morning limit depends on the site's temperatures; do not show a placeholder's verdict. */}
{!temperaturesKnown && <div style={{marginTop: '1em'}}><Loader size='xs' label='Loading temperatures'/></div>}
{temperaturesKnown && <>
<Table style={{marginTop: '1em'}}>
<Table.Body>
<Table.Row>
<Table.Cell>Strings</Table.Cell>
<Table.Cell>{z.strings} of {o.series} panel{o.series === 1 ? '' : 's'} in series</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>String voltage on the coldest morning</Table.Cell>
<Table.Cell>{fmt(z.stringColdVoc, 1)} V (most panels in series: {z.maxSeries})</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Controller current rating, at least</Table.Cell>
<Table.Cell>{fmt(z.current)} A</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
{z.overVoltage && <Message kind='error' title='Too much voltage for the controller'>
{o.series} panels in series reach {fmt(z.stringColdVoc, 1)} V on a {o.coldest} °C morning,
over the controller's {o.controllerMaxVoltage} V limit. Use at most {z.maxSeries} in series, or a
controller rated for more voltage.
</Message>}
{z.lowVoltage && <Message kind='warning' title='Too little voltage to charge'>
{o.controller === 'pwm' && z.pwmFraction === 0
? `${o.series} panel${o.series === 1 ? '' : 's'} in series cannot reach the ${o.systemVoltage} V `
+ 'battery\'s charging voltage, so a PWM controller cannot charge it at all.'
: `On a hot day ${o.series} panel${o.series === 1 ? '' : 's'} in series may not stay far enough `
+ 'above the battery\'s charging voltage for the controller to charge well.'}
{' '}Put more panels in series, or use a lower system voltage.
</Message>}
{z.unevenStrings && <Message kind='warning' title='Uneven strings'>
Your panel count does not divide evenly into strings of {o.series}. Every string should have the same
number of panels.
</Message>}
{o.controller === 'mppt' && z.current > HIGH_CURRENT && o.systemVoltage < 48 &&
<Message kind='info' title='High current'>
{fmt(z.current)} A needs a large controller and thick wire. A higher system voltage would cut the
current.
</Message>}
</>}
</>;
}
/**
* The off-grid sizing tab.
*
* @param {Object} worst the worst month for a 1 kW DC array, or null when there is no estimate
* @param {number[]} temperatures monthly average temperatures at the site, °C
* @param {boolean} loading the built-in sunlight and temperature data is still downloading
*/
export function SolarOffGrid({get, set, setNumber, s, worst, temperatures, loading}) {
const o = offGridFromParams(get, temperatures);
const r = offGridResults(o, worst, s.panelWatts, s.panelCount);
return <div style={{marginTop: '1em'}}>
<Hint>
Size a battery system to run your devices through the darkest month. Production here is DC; the
Inverter section under Fine-tune is for grid-tie systems and does not apply.
</Hint>
<LoadsSection o={o} r={r} set={set} setNumber={setNumber}/>
<BatterySection o={o} set={set} setNumber={setNumber}/>
{worst && <NeedsSection o={o} r={r} worst={worst} s={s} setNumber={setNumber}/>}
{!worst && loading && <div style={{marginTop: '1em'}}><Loader size='xs' label='Loading sunlight data'/></div>}
{!worst && !loading && <div style={{marginTop: '1em'}}>
<Message kind='info' title='Almost there'>Enter a location above to size the system.</Message>
</div>}
<ControllerSection o={o} r={r} set={set} setNumber={setNumber}
temperaturesKnown={!loading || get('tmin') !== null}/>
</div>;
}

View file

@ -1,173 +0,0 @@
import React from "react";
import {fireEvent, screen} from "@testing-library/react";
import {renderWithProviders} from "../../test-utils";
import {offGridFromParams, offGridResults} from "./SolarOffGrid";
import {SolarCalculator} from "./SolarCalculator";
import {forgetClimatology} from "./solarData";
import {mockClimatologyFetch} from "./solarData.fixtures";
const params = query => {
const searchParams = new URLSearchParams(query);
return key => searchParams.get(key);
};
const currentParams = () => new URLSearchParams(window.location.search);
const TEMPS = [-1, 0, 5, 10, 15, 20, 24, 22, 18, 10, 4, -1];
const DEC = {month: 'Dec', energyDaily: 3};
describe('offGridFromParams', () => {
test('defaults', () => {
const o = offGridFromParams(params(''), TEMPS);
expect(o.loads).toEqual([]);
expect(o.acLoads).toBe(true);
expect(o.battery).toBe('lfp');
expect(o.dod).toBe(80);
expect(o.systemVoltage).toBe(12);
expect(o.days).toBe(2);
expect(o.controller).toBe('mppt');
expect(o.series).toBe(1);
expect(o.coldestMonthly).toBe(-1);
expect(o.coldest).toBe(-26);
});
test('the depth of discharge follows the battery type unless set', () => {
expect(offGridFromParams(params('bat=lead'), TEMPS).dod).toBe(50);
expect(offGridFromParams(params('bat=lead&dod=60'), TEMPS).dod).toBe(60);
});
test('a Voc coefficient without its minus sign is read as negative, even from a shared link', () => {
expect(offGridFromParams(params('bvoc=0.27'), TEMPS).betaVoc).toBe(-0.27);
expect(offGridFromParams(params('bvoc=-0.3'), TEMPS).betaVoc).toBe(-0.3);
});
test('invalid values fall back', () => {
const o = offGridFromParams(params('bat=x&sv=13&ctl=y&ns=0&aut=-1'), null);
expect(o.battery).toBe('lfp');
expect(o.systemVoltage).toBe(12);
expect(o.controller).toBe('mppt');
expect(o.series).toBe(1);
expect(o.days).toBe(0);
expect(o.coldest).toBe(-20);
});
});
describe('offGridResults', () => {
// 1000 Wh/day of AC devices through a 90% inverter, LiFePO4 at 80%, 2 days, 12 V.
const o = offGridFromParams(params('load=Stuff~100~10'), TEMPS);
test('battery and panels for the worst month', () => {
const r = offGridResults(o, DEC, 400, 1);
expect(r.devicesWh).toBe(1000);
expect(r.batteryLoadWh).toBeCloseTo(1111.1, 1);
expect(r.bank.nominalWh).toBeCloseTo(2777.8, 1);
expect(r.bank.ah).toBeCloseTo(231.5, 1);
// 1.111 kWh / (3 kWh/kWp x 95%) = 0.390 kW: one 400 W panel.
expect(r.kwp).toBeCloseTo(0.3899, 4);
expect(r.panelsNeeded).toBe(1);
// One 400 W panel: 3 x 0.4 x 95% = 1.14 kWh for 1.111 kWh of use.
expect(r.coverage).toBeCloseTo(1.026, 3);
});
test('DC devices skip the inverter loss', () => {
const dc = offGridResults(offGridFromParams(params('load=Stuff~100~10&ac=0'), TEMPS), DEC, 400, 1);
expect(dc.batteryLoadWh).toBe(1000);
});
test('a PWM controller needs more panels', () => {
const pwm = offGridResults(offGridFromParams(params('load=Stuff~100~10&ctl=pwm'), TEMPS), DEC, 400, 1);
// Only 14.4 / 31 of each panel is used: 0.390 / 0.4645 = 0.839 kW, three panels.
expect(pwm.panelsNeeded).toBe(3);
});
test('an outdoor lead-acid bank is enlarged for the cold', () => {
const indoor = offGridResults(offGridFromParams(params('load=Stuff~100~10&bat=lead'), TEMPS), DEC, 400, 1);
const outdoor = offGridResults(offGridFromParams(params('load=Stuff~100~10&bat=lead&bout=1'), TEMPS),
DEC, 400, 1);
// Coldest month -1 °C: 79% of rated capacity.
expect(outdoor.bank.nominalWh).toBeCloseTo(indoor.bank.nominalWh / 0.79, 1);
expect(outdoor.warnings).toHaveLength(1);
});
test('nothing to size without devices or an estimate', () => {
const none = offGridResults(offGridFromParams(params(''), TEMPS), DEC, 400, 1);
expect(none.bank).toBeNull();
expect(none.panelsNeeded).toBeNull();
expect(offGridResults(o, null, 400, 1).panelsNeeded).toBeNull();
});
});
describe('the off-grid tab', () => {
const OFFGRID = '/more/calculators?calc=solar&lat=40&lon=-105&tab=offgrid';
beforeEach(() => {
forgetClimatology();
mockClimatologyFetch();
});
const findNeeds = () => screen.findByText('What you need', {}, {timeout: 5000});
test('devices are added to the URL and the system is sized', async () => {
renderWithProviders(<SolarCalculator/>, {route: OFFGRID});
expect(await screen.findByText('Add your devices', {}, {timeout: 5000})).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Device 1 name'), {target: {value: 'Fridge'}});
fireEvent.change(screen.getByLabelText('Device 1 watts'), {target: {value: '50'}});
fireEvent.change(screen.getByLabelText('Device 1 hours'), {target: {value: '24'}});
expect(currentParams().get('load')).toBe('Fridge~50~24');
expect(await findNeeds()).toBeInTheDocument();
expect(screen.getByText('1200 Wh')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: 'Add device'}));
expect(screen.getByLabelText('Device 2 name')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: 'Remove device 2'}));
expect(screen.queryByLabelText('Device 2 name')).not.toBeInTheDocument();
});
test('the suggested panel count can be applied', async () => {
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Heater~500~10`});
await findNeeds();
const button = screen.getByRole('button', {name: /^Use \d+ panels$/});
const suggested = button.textContent.match(/\d+/)[0];
fireEvent.click(button);
expect(currentParams().get('pn')).toBe(suggested);
});
test('too many panels in series warns before it destroys the controller', async () => {
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24&ns=3&pn=3`});
await findNeeds();
expect(screen.getByText('Too much voltage for the controller')).toBeInTheDocument();
});
test('PWM panels that cannot reach a 48 V battery say so instead of sizing a system', async () => {
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24&ctl=pwm&sv=48`});
await findNeeds();
expect(screen.getByText('Too little voltage to charge')).toBeInTheDocument();
expect(screen.getByText(/a PWM controller cannot charge it at all/)).toBeInTheDocument();
expect(screen.queryByRole('button', {name: /^Use \d+ panels$/})).not.toBeInTheDocument();
});
test('while the data downloads, it shows a loader and no cold-morning verdict', async () => {
forgetClimatology();
global.fetch = jest.fn(() => new Promise(() => {
}));
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24`});
// One beside the location, one in place of the sizing.
expect(screen.getAllByLabelText('Loading sunlight data')).toHaveLength(2);
expect(screen.queryByText('Almost there')).not.toBeInTheDocument();
expect(screen.queryByText('String voltage on the coldest morning')).not.toBeInTheDocument();
});
test('a coldest morning the user typed is checked even while the data downloads', async () => {
forgetClimatology();
global.fetch = jest.fn(() => new Promise(() => {
}));
renderWithProviders(<SolarCalculator/>, {route: `${OFFGRID}&load=Fridge~50~24&tmin=-30`});
expect(screen.getByText('String voltage on the coldest morning')).toBeInTheDocument();
});
test('the tab is remembered in the URL', async () => {
renderWithProviders(<SolarCalculator/>, {route: '/more/calculators?calc=solar&lat=40&lon=-105'});
fireEvent.click(screen.getByRole('tab', {name: 'Off-grid sizing'}));
expect(currentParams().get('tab')).toBe('offgrid');
fireEvent.click(screen.getByRole('tab', {name: 'Estimate'}));
expect(currentParams().has('tab')).toBe(false);
});
});

View file

@ -1,210 +0,0 @@
// Pure calculation functions for the Solar calculator's off-grid sizing. No React here.
//
// Off-grid systems are sized for the worst month, not the yearly average: the battery has to carry the
// loads through a run of cloudy days, and the panels have to refill it in the darkest month.
//
// Energy is Wh, power W, voltage V, current A, temperature °C, percentages 0-100.
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
// ---------------------------------------------------------------------------
// Loads
// ---------------------------------------------------------------------------
// Devices are stored in the URL as "name~watts~hours" joined by "|".
const ROW_SEPARATOR = '|';
const FIELD_SEPARATOR = '~';
export function parseLoads(text) {
if (!text) {
return [];
}
return text.split(ROW_SEPARATOR).map(row => {
const [name = '', watts = '', hours = ''] = row.split(FIELD_SEPARATOR);
return {name, watts, hours};
});
}
export function formatLoads(rows) {
// The separators cannot appear inside a name.
const clean = value => `${value ?? ''}`.replace(/[|~]/g, ' ');
return rows.map(r => [clean(r.name), clean(r.watts), clean(r.hours)].join(FIELD_SEPARATOR)).join(ROW_SEPARATOR);
}
// Watt-hours a device uses per day; blank or invalid values count as nothing.
export function loadWh({watts, hours}) {
const w = Number(watts), h = Number(hours);
return w > 0 && h > 0 ? w * Math.min(h, 24) : 0;
}
export function dailyLoadWh(rows) {
return rows.reduce((acc, row) => acc + loadWh(row), 0);
}
// ---------------------------------------------------------------------------
// Battery
// ---------------------------------------------------------------------------
export const BATTERIES = {
lfp: {
label: 'Lithium iron phosphate (LiFePO4)',
// Usable depth of discharge, and round-trip efficiency.
dod: 80, efficiency: 95,
},
lead: {
label: 'Lead-acid (flooded or AGM)',
// Discharging below half shortens a lead-acid battery's life sharply.
dod: 50, efficiency: 85,
},
};
export const SYSTEM_VOLTAGES = [12, 24, 48];
// Charging voltage of a battery bank, used to judge PWM controllers and MPPT headroom.
export const chargeVoltage = systemVoltage => systemVoltage * 1.2;
// Share of rated capacity a cold lead-acid battery delivers: about 80% at 0 °C and 60% at -20 °C, as on
// typical manufacturer curves. Lithium batteries are not derated here; they must not be CHARGED below
// 0 °C, which is a warning instead (see batteryWarnings).
export function leadAcidCapacityFactor(temperature) {
if (!Number.isFinite(temperature) || temperature >= 20) {
return 1;
}
return clamp(1 - 0.01 * (20 - temperature), 0.5, 1);
}
/**
* Battery bank for `days` of the daily load with no sun.
*
* @returns {{usableWh: number, nominalWh: number, ah: number}}
*/
export function sizeBattery({loadWh: daily, days, dod, voltage, capacityFactor = 1}) {
if (!(daily > 0) || !(days > 0) || !(dod > 0) || !(voltage > 0)) {
return null;
}
const usableWh = daily * days;
const nominalWh = usableWh / (dod / 100) / capacityFactor;
return {usableWh, nominalWh, ah: nominalWh / voltage};
}
// ---------------------------------------------------------------------------
// Array
// ---------------------------------------------------------------------------
/**
* kW of panels needed to supply the daily load in the worst month.
*
* @param {number} loadWh daily energy the battery bank must supply
* @param {number} worstDailyPerKwp DC kWh per day per kW of panels in the worst month
* @param {number} batteryEfficiency round-trip efficiency, %; conservatively applied to all energy
*/
export function requiredKwp(loadWh, worstDailyPerKwp, batteryEfficiency) {
if (!(loadWh > 0) || !(worstDailyPerKwp > 0) || !(batteryEfficiency > 0)) {
return null;
}
return (loadWh / 1000) / (worstDailyPerKwp * batteryEfficiency / 100);
}
// ---------------------------------------------------------------------------
// Charge controller and strings
// ---------------------------------------------------------------------------
export const CONTROLLERS = {
mppt: {label: 'MPPT'},
pwm: {label: 'PWM'},
};
// A PWM controller connects a string of `series` panels straight to the battery, holding it at the battery's
// charging voltage instead of its best voltage (Vmp), so only this share of its power is used:
// - charging voltage at or above the string's Voc: no current flows, nothing is delivered;
// - between Vmp and Voc: the current falls from its maximum toward zero at Voc (taken as linear);
// - below Vmp: full current at the lower voltage, charging voltage / Vmp of the power.
export function pwmUsableFraction(vmp, voc, series, systemVoltage) {
const stringVmp = vmp * series, stringVoc = voc * series;
if (!(stringVmp > 0) || !(stringVoc > 0)) {
return 1;
}
const charge = chargeVoltage(systemVoltage);
if (charge >= stringVoc) {
return 0;
}
if (charge > stringVmp) {
return (charge / stringVmp) * (stringVoc - charge) / (stringVoc - stringVmp);
}
return charge / stringVmp;
}
// Panel open-circuit voltage rises as it gets colder. `betaVoc` is its temperature coefficient, %/°C, which is
// negative for every silicon panel; a coefficient typed without its minus sign is treated as negative, because
// a positive one would hide the cold-weather rise that destroys controllers.
export function coldVoc(voc, betaVoc, temperature) {
return voc * (1 - (Math.abs(betaVoc) / 100) * (temperature - 25));
}
/**
* Wiring and controller checks for an array of `panelCount` panels, `series` of them per string.
*/
export function controllerSizing({
panelCount, panelWatts, series, voc, vmp, isc, betaVoc, coldest,
controller, controllerMaxVoltage, systemVoltage,
}) {
const strings = series > 0 ? Math.ceil(panelCount / series) : 0;
const arrayWatts = panelCount * panelWatts;
const stringColdVoc = coldVoc(voc, betaVoc, coldest) * series;
const maxSeries = Math.floor(controllerMaxVoltage / coldVoc(voc, betaVoc, coldest));
let current;
if (controller === 'pwm') {
// Panel current passes straight through, plus the usual 25% margin.
current = isc * strings * 1.25;
} else {
current = arrayWatts / systemVoltage * 1.25;
}
return {
strings,
arrayWatts,
stringColdVoc,
maxSeries,
current,
overVoltage: stringColdVoc > controllerMaxVoltage,
// An MPPT controller needs the string's voltage, which sags about 15% in summer heat, to stay above the
// battery's charging voltage with some headroom. A PWM controller loses power as soon as the string's
// Vmp is below the charging voltage, and all of it once its Voc is.
lowVoltage: controller === 'mppt'
? vmp * series * 0.85 < chargeVoltage(systemVoltage) + 5
: vmp * series < chargeVoltage(systemVoltage),
unevenStrings: series > 0 && panelCount % series !== 0,
pwmFraction: controller === 'pwm' ? pwmUsableFraction(vmp, voc, series, systemVoltage) : 1,
};
}
// Default for the coldest morning when the user has not entered one: well below the coldest monthly
// average, because monthly averages hide cold snaps. Errs cold, since underestimating the cold is what
// destroys controllers.
export const COLD_MARGIN = 25;
export function defaultColdest(monthlyTemperatures) {
const known = (monthlyTemperatures || []).filter(Number.isFinite);
if (known.length === 0) {
return -20;
}
return Math.round(Math.min(...known) - COLD_MARGIN);
}
// Advice about the battery for the site's climate. `coldestMonthly` is the coldest monthly average, °C.
export function batteryWarnings({battery, outdoors, coldestMonthly}) {
const warnings = [];
if (!outdoors || !Number.isFinite(coldestMonthly)) {
return warnings;
}
if (battery === 'lfp' && coldestMonthly < 5) {
warnings.push('Lithium (LiFePO4) batteries are damaged by charging below 0 °C. Keep them indoors, or buy '
+ 'batteries with built-in heaters or low-temperature charge protection.');
}
if (battery === 'lead' && coldestMonthly < 20) {
warnings.push(`Cold lead-acid batteries hold less: the bank is enlarged to deliver ` +
`${Math.round(leadAcidCapacityFactor(coldestMonthly) * 100)}% of its rating in the coldest month. ` +
'Discharged lead-acid batteries can also freeze and crack; keep them charged.');
}
return warnings;
}

View file

@ -1,174 +0,0 @@
import {
BATTERIES,
batteryWarnings,
chargeVoltage,
coldVoc,
controllerSizing,
dailyLoadWh,
defaultColdest,
formatLoads,
leadAcidCapacityFactor,
loadWh,
parseLoads,
pwmUsableFraction,
requiredKwp,
sizeBattery,
} from "./offgrid";
describe('loads', () => {
test('round-trip through the URL format', () => {
const rows = [{name: 'Fridge', watts: '60', hours: '24'}, {name: 'Lights', watts: '20', hours: '5'}];
expect(formatLoads(rows)).toBe('Fridge~60~24|Lights~20~5');
expect(parseLoads(formatLoads(rows))).toEqual(rows);
expect(parseLoads('')).toEqual([]);
expect(parseLoads(null)).toEqual([]);
});
test('separators are removed from names', () => {
expect(parseLoads(formatLoads([{name: 'A|B~C', watts: '1', hours: '1'}]))[0].name).toBe('A B C');
});
test('daily energy is watts times hours, ignoring blanks and capping at 24 hours', () => {
expect(loadWh({watts: '60', hours: '24'})).toBe(1440);
expect(loadWh({watts: '60', hours: '30'})).toBe(1440);
expect(loadWh({watts: '', hours: '5'})).toBe(0);
expect(loadWh({watts: '-5', hours: '5'})).toBe(0);
expect(dailyLoadWh(parseLoads('Fridge~60~24|Lights~20~5|~~'))).toBe(1540);
});
});
describe('battery', () => {
test('a bank for 2 days of 1 kWh at 50% depth of discharge holds 4 kWh', () => {
const bank = sizeBattery({loadWh: 1000, days: 2, dod: 50, voltage: 12});
expect(bank.usableWh).toBe(2000);
expect(bank.nominalWh).toBe(4000);
expect(bank.ah).toBeCloseTo(333.3, 1);
});
test('a cold lead-acid bank must be bigger', () => {
expect(leadAcidCapacityFactor(25)).toBe(1);
expect(leadAcidCapacityFactor(0)).toBeCloseTo(0.8, 6);
expect(leadAcidCapacityFactor(-20)).toBeCloseTo(0.6, 6);
expect(leadAcidCapacityFactor(-60)).toBe(0.5);
const warm = sizeBattery({loadWh: 1000, days: 1, dod: 50, voltage: 12});
const cold = sizeBattery({loadWh: 1000, days: 1, dod: 50, voltage: 12, capacityFactor: 0.8});
expect(cold.nominalWh).toBeCloseTo(warm.nominalWh / 0.8, 6);
});
test('lithium needs a smaller bank than lead-acid for the same load', () => {
const lfp = sizeBattery({loadWh: 1000, days: 2, dod: BATTERIES.lfp.dod, voltage: 24});
const lead = sizeBattery({loadWh: 1000, days: 2, dod: BATTERIES.lead.dod, voltage: 24});
expect(lfp.nominalWh).toBeLessThan(lead.nominalWh);
});
test('no bank without a load', () => {
expect(sizeBattery({loadWh: 0, days: 2, dod: 50, voltage: 12})).toBeNull();
});
});
describe('array', () => {
test('panels needed for the worst month', () => {
// 2 kWh/day, 3 kWh/kWp/day in the worst month, 95% battery efficiency: 2 / 2.85 = 0.702 kW.
expect(requiredKwp(2000, 3, 95)).toBeCloseTo(0.7018, 4);
expect(requiredKwp(2000, 0, 95)).toBeNull();
});
});
describe('charge controller', () => {
test('a PWM controller wastes most of a high-voltage panel on a 12 V battery', () => {
expect(chargeVoltage(12)).toBeCloseTo(14.4, 6);
// 14.4 V / 31 V: under half the panel's power.
expect(pwmUsableFraction(31, 37, 1, 12)).toBeCloseTo(0.4645, 3);
// A "12 V" panel (Vmp ~18 V) suits PWM much better.
expect(pwmUsableFraction(18, 22, 1, 12)).toBeCloseTo(0.8, 6);
});
test('PWM uses the string voltage, so panels in series waste even more', () => {
// Two 31 V panels make a 62 V string: 14.4 / 62.
expect(pwmUsableFraction(31, 37, 2, 12)).toBeCloseTo(0.2323, 3);
});
test('PWM delivers nothing when the string cannot reach the charging voltage', () => {
// One 37 Voc panel against a 48 V bank's 57.6 V.
expect(pwmUsableFraction(31, 37, 1, 48)).toBe(0);
expect(pwmUsableFraction(10, 12, 1, 12)).toBe(0);
});
test('PWM between Vmp and Voc delivers part of the power as current falls toward Voc', () => {
// 14.4 V sits just above a 14 V Vmp: (14.4 / 14) x (21 - 14.4) / (21 - 14).
expect(pwmUsableFraction(14, 21, 1, 12)).toBeCloseTo(0.9698, 3);
});
test('open-circuit voltage rises in the cold', () => {
// -0.27 %/°C at -25 °C (50 °C below the 25 °C rating): +13.5%.
expect(coldVoc(37, -0.27, -25)).toBeCloseTo(41.995, 3);
expect(coldVoc(37, -0.27, 25)).toBe(37);
});
test('a Voc coefficient typed without its minus sign still errs safe', () => {
expect(coldVoc(37, 0.27, -25)).toBeCloseTo(coldVoc(37, -0.27, -25), 9);
const sizing = controllerSizing({...typical, series: 3, betaVoc: 0.27});
expect(sizing.overVoltage).toBe(true);
expect(sizing.maxSeries).toBe(2);
});
const typical = {
panelCount: 4, panelWatts: 400, series: 2, voc: 37, vmp: 31, isc: 13.8, betaVoc: -0.27, coldest: -25,
controller: 'mppt', controllerMaxVoltage: 100, systemVoltage: 24,
};
test('two 400 W panels in series on a 100 V MPPT controller are safe', () => {
const result = controllerSizing(typical);
expect(result.strings).toBe(2);
expect(result.stringColdVoc).toBeCloseTo(83.99, 2);
expect(result.overVoltage).toBe(false);
expect(result.maxSeries).toBe(2);
// 1600 W / 24 V x 1.25.
expect(result.current).toBeCloseTo(83.33, 2);
expect(result.lowVoltage).toBe(false);
expect(result.unevenStrings).toBe(false);
});
test('three in series exceed 100 V on a cold morning', () => {
const result = controllerSizing({...typical, series: 3});
expect(result.overVoltage).toBe(true);
expect(result.unevenStrings).toBe(true);
});
test('one panel per string is too little voltage to charge 48 V through MPPT', () => {
expect(controllerSizing({...typical, series: 1, systemVoltage: 48}).lowVoltage).toBe(true);
});
test('PWM current is the panels\' short-circuit current plus 25%', () => {
const result = controllerSizing({...typical, controller: 'pwm', series: 1, systemVoltage: 12});
expect(result.current).toBeCloseTo(13.8 * 4 * 1.25, 6);
expect(result.pwmFraction).toBeCloseTo(0.4645, 3);
expect(result.lowVoltage).toBe(false);
});
test('PWM on a battery above the string\'s voltage warns and delivers nothing', () => {
const result = controllerSizing({...typical, controller: 'pwm', series: 1, systemVoltage: 48});
expect(result.pwmFraction).toBe(0);
expect(result.lowVoltage).toBe(true);
});
});
describe('defaultColdest', () => {
test('is well below the coldest monthly average', () => {
expect(defaultColdest([-1, 0, 5, 10, 15, 20, 24, 22, 18, 10, 4, -1])).toBe(-26);
expect(defaultColdest([null, null])).toBe(-20);
expect(defaultColdest(null)).toBe(-20);
});
});
describe('batteryWarnings', () => {
test('warn about charging lithium in the cold, only when the batteries are outdoors', () => {
expect(batteryWarnings({battery: 'lfp', outdoors: true, coldestMonthly: -5})[0]).toMatch(/below 0 °C/);
expect(batteryWarnings({battery: 'lfp', outdoors: false, coldestMonthly: -5})).toEqual([]);
expect(batteryWarnings({battery: 'lfp', outdoors: true, coldestMonthly: 15})).toEqual([]);
});
test('explain the cold lead-acid enlargement', () => {
expect(batteryWarnings({battery: 'lead', outdoors: true, coldestMonthly: 0})[0]).toMatch(/80% of its rating/);
});
});

View file

@ -1,481 +0,0 @@
// Pure calculation functions for the Solar calculator. No React here; SolarCalculator.js is the UI.
//
// The model estimates a typical year from 12 monthly averages, the standard approach when hourly
// typical-year weather files are not available:
//
// 1. Sun geometry for Klein's representative day of each month (Duffie & Beckman ch. 1).
// 2. Monthly global horizontal irradiation (GHI) is split into diffuse and beam (Erbs monthly
// correlation), then spread across the day (Collares-Pereira & Rabl for total, Liu & Jordan
// for diffuse).
// 3. Each step is transposed onto the panel with the HDKR sky model, for a fixed, single-axis or
// dual-axis array.
// 4. PVWatts' models turn plane-of-array irradiance into energy: ASHRAE angle-of-incidence loss,
// SAPM cell temperature, linear temperature-corrected DC power, multiplicative system losses,
// and an optional inverter efficiency curve with clipping.
//
// Angles are in degrees at the API boundary. Azimuths are compass bearings (0 = north, 90 = east,
// 180 = south, 270 = west); internally the Duffie & Beckman convention is used (0 = south, east
// negative, west positive). Energy is kWh, irradiation kWh/m², irradiance W/m², power kW.
export const SOLAR_CONSTANT = 1361; // W/m²
const RAD = Math.PI / 180;
const sin = deg => Math.sin(deg * RAD);
const cos = deg => Math.cos(deg * RAD);
const tan = deg => Math.tan(deg * RAD);
const acos = x => Math.acos(Math.min(1, Math.max(-1, x))) / RAD;
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
export const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
// Klein's recommended average day of each month: the day whose extraterrestrial radiation is
// closest to the month's mean. Day of the year, 1-365.
export const KLEIN_DAYS = [17, 47, 75, 105, 135, 162, 198, 228, 258, 288, 318, 344];
// ---------------------------------------------------------------------------
// Sun geometry
// ---------------------------------------------------------------------------
// Solar declination (Cooper), degrees.
export function declination(dayOfYear) {
return 23.45 * sin(360 * (284 + dayOfYear) / 365);
}
// Sunset hour angle on a horizontal surface, degrees. 0 during polar night, 180 during polar day.
export function sunsetHourAngle(latitude, decl) {
return acos(-tan(latitude) * tan(decl));
}
// Orbit eccentricity correction to the solar constant.
export function eccentricity(dayOfYear) {
return 1 + 0.033 * cos(360 * dayOfYear / 365);
}
// Daily extraterrestrial irradiation on a horizontal surface, kWh/m²/day (D&B eq. 1.10.3).
export function extraterrestrialDaily(latitude, dayOfYear) {
const decl = declination(dayOfYear);
const ws = sunsetHourAngle(latitude, decl);
const wh = (24 / Math.PI) * SOLAR_CONSTANT * eccentricity(dayOfYear)
* (cos(latitude) * cos(decl) * sin(ws) + (Math.PI * ws / 180) * sin(latitude) * sin(decl));
return Math.max(0, wh / 1000);
}
// Day length in hours.
export function dayLength(latitude, decl) {
return 2 * sunsetHourAngle(latitude, decl) / 15;
}
// Sun elevation at solar noon, degrees. Negative means the sun does not rise.
export function noonElevation(latitude, decl) {
return 90 - Math.abs(latitude - decl);
}
// Sun position for an hour angle (degrees, 15° per hour from solar noon, afternoon positive).
// Returns cosine of the zenith angle and the azimuth in the D&B convention (0 = south).
export function sunPosition(latitude, decl, hourAngle) {
const cosZenith = cos(latitude) * cos(decl) * cos(hourAngle) + sin(latitude) * sin(decl);
const sinZenith = Math.sqrt(Math.max(0, 1 - cosZenith * cosZenith));
let azimuth = 0;
if (sinZenith > 1e-9 && Math.abs(cos(latitude)) > 1e-9) {
azimuth = acos((cosZenith * sin(latitude) - sin(decl)) / (sinZenith * cos(latitude)));
azimuth = hourAngle < 0 ? -azimuth : azimuth;
}
return {cosZenith, zenith: acos(cosZenith), azimuth};
}
// Cosine of the angle of incidence on a surface of tilt `tilt` and D&B azimuth `surfaceAzimuth`.
export function cosIncidence(zenith, sunAzimuth, tilt, surfaceAzimuth) {
return cos(zenith) * cos(tilt) + sin(zenith) * sin(tilt) * cos(sunAzimuth - surfaceAzimuth);
}
// Compass bearing (0 = north) to the D&B azimuth convention (0 = south, east negative).
export function compassToSolarAzimuth(compass) {
let a = ((compass % 360) + 360) % 360 - 180;
return a === -180 ? 180 : a;
}
// Default panel azimuth: face the equator.
export function defaultAzimuth(latitude) {
return latitude < 0 ? 0 : 180;
}
// ---------------------------------------------------------------------------
// Monthly irradiation to sub-daily steps
// ---------------------------------------------------------------------------
// Monthly average diffuse fraction Hd/H from the clearness index (Erbs, Klein & Duffie 1982).
// The correlation was fit for 0.3 <= KT <= 0.8, so KT is clamped to that range.
export function erbsMonthlyDiffuseFraction(kt, sunsetAngle) {
const k = clamp(kt, 0.3, 0.8);
if (sunsetAngle <= 81.4) {
return 1.391 - 3.560 * k + 4.189 * k * k - 2.137 * k * k * k;
}
return 1.311 - 3.022 * k + 3.427 * k * k - 1.821 * k * k * k;
}
// Ratio of hourly to daily total irradiation at hour angle `w` (Collares-Pereira & Rabl).
export function totalHourlyRatio(w, sunsetAngle) {
if (Math.abs(w) >= sunsetAngle) {
return 0;
}
const a = 0.409 + 0.5016 * sin(sunsetAngle - 60);
const b = 0.6609 - 0.4767 * sin(sunsetAngle - 60);
return diffuseHourlyRatio(w, sunsetAngle) * (a + b * cos(w));
}
// Ratio of hourly to daily diffuse irradiation at hour angle `w` (Liu & Jordan).
export function diffuseHourlyRatio(w, sunsetAngle) {
if (Math.abs(w) >= sunsetAngle) {
return 0;
}
const denominator = sin(sunsetAngle) - (Math.PI * sunsetAngle / 180) * cos(sunsetAngle);
return (Math.PI / 24) * (cos(w) - cos(sunsetAngle)) / denominator;
}
// Number of evenly spaced steps between sunrise and sunset. Steps adapt to the day length, so a
// short polar-edge day is still sampled.
export const STEPS_PER_DAY = 48;
// The average day of a month as a list of steps, each with its hour angle, duration (hours), and
// the horizontal irradiances (W/m²) for global, beam and diffuse. The two hourly ratios are
// normalized over the day so the steps add back up to exactly the monthly GHI and diffuse given.
export function averageDaySteps(latitude, monthIndex, ghi, diffuse = null) {
const n = KLEIN_DAYS[monthIndex];
const decl = declination(n);
const ws = sunsetHourAngle(latitude, decl);
const h0 = extraterrestrialDaily(latitude, n);
if (!(ghi > 0) || !(ws > 0) || !(h0 > 0)) {
return {decl, sunsetAngle: ws, kt: null, diffuseFraction: null, steps: []};
}
const kt = ghi / h0;
const hd = diffuse != null && diffuse >= 0
? Math.min(diffuse, ghi)
: ghi * erbsMonthlyDiffuseFraction(kt, ws);
const dw = 2 * ws / STEPS_PER_DAY;
const dt = dw / 15;
const raw = [];
let totalSum = 0, diffuseSum = 0;
for (let i = 0; i < STEPS_PER_DAY; i++) {
const w = -ws + (i + 0.5) * dw;
const rt = totalHourlyRatio(w, ws);
const rd = diffuseHourlyRatio(w, ws);
raw.push({w, rt, rd});
totalSum += rt * dt;
diffuseSum += rd * dt;
}
const steps = raw.map(({w, rt, rd}) => {
// kWh/m² per hour is numerically kW/m², so x1000 gives the step's mean irradiance in W/m².
const global = totalSum > 0 ? (rt / totalSum) * ghi * 1000 : 0;
const diff = Math.min(global, diffuseSum > 0 ? (rd / diffuseSum) * hd * 1000 : 0);
return {hourAngle: w, hours: dt, global, diffuse: diff, beam: global - diff};
});
return {decl, sunsetAngle: ws, kt, diffuseFraction: hd / ghi, steps};
}
// ---------------------------------------------------------------------------
// Panel orientation and plane-of-array irradiance
// ---------------------------------------------------------------------------
export const TRACKING = {
fixed: {label: 'Fixed'},
single: {label: 'Single-axis (N–S axis)'},
dual: {label: 'Dual-axis'},
};
// Single-axis trackers are limited to this rotation either side of flat.
export const SINGLE_AXIS_LIMIT = 60;
// Panel tilt and D&B azimuth for a step. `tilt`/`azimuth` are only used for fixed arrays.
export function panelOrientation(tracking, sun, tilt, azimuth) {
if (tracking === 'dual') {
return {tilt: sun.zenith, azimuth: sun.azimuth};
}
if (tracking === 'single') {
// Horizontal north-south axis rotating east-west (Marion & Dobos ideal rotation).
const rotation = clamp(
Math.atan(Math.tan(sun.zenith * RAD) * Math.sin(sun.azimuth * RAD)) / RAD,
-SINGLE_AXIS_LIMIT, SINGLE_AXIS_LIMIT);
return {tilt: Math.abs(rotation), azimuth: rotation >= 0 ? 90 : -90};
}
return {tilt, azimuth};
}
// Plane-of-array irradiance for one step with the HDKR (Hay-Davies-Klucher-Reindl) sky model.
// Returns the beam (direct plus circumsolar), sky diffuse and ground-reflected parts in W/m², and
// the angle of incidence.
export function poaIrradiance({global, beam, diffuse}, sun, orientation, albedo, dayOfYear) {
const {tilt, azimuth} = orientation;
const cosTheta = cosIncidence(sun.zenith, sun.azimuth, tilt, azimuth);
// Beam normal irradiance, capped at the extraterrestrial value; near sunrise the division by a
// tiny cos(zenith) would otherwise blow up.
const extraNormal = SOLAR_CONSTANT * eccentricity(dayOfYear);
const cosZ = Math.max(sun.cosZenith, 0.01745);
const dni = Math.min(beam / cosZ, extraNormal);
const rb = Math.max(0, cosTheta) / cosZ;
const anisotropy = clamp(dni / extraNormal, 0, 1);
const horizonFactor = global > 0 ? Math.sqrt(beam / global) : 0;
const beamPoa = dni * Math.max(0, cosTheta) + diffuse * anisotropy * rb;
const skyPoa = diffuse * (1 - anisotropy) * ((1 + cos(tilt)) / 2)
* (1 + horizonFactor * Math.pow(sin(tilt / 2), 3));
const groundPoa = global * albedo * (1 - cos(tilt)) / 2;
return {beam: beamPoa, sky: skyPoa, ground: groundPoa, total: beamPoa + skyPoa + groundPoa, cosTheta};
}
// ASHRAE incidence angle modifier for the glass cover; applied to the beam part.
export function ashraeIam(cosTheta, b0 = 0.05) {
if (cosTheta <= 0) {
return 0;
}
return clamp(1 - b0 * (1 / cosTheta - 1), 0, 1);
}
// ---------------------------------------------------------------------------
// Temperature, DC power, losses, inverter
// ---------------------------------------------------------------------------
// Sandia (SAPM) cell temperature coefficients by mounting.
export const MOUNTINGS = {
open: {label: 'Open rack (ground or pole)', a: -3.56, b: -0.075, deltaT: 3},
roof: {label: 'Close roof mount', a: -2.98, b: -0.0471, deltaT: 1},
flush: {label: 'Insulated back / flush', a: -2.81, b: -0.0455, deltaT: 0},
};
// PVWatts v8 temperature coefficients of power, %/°C.
export const MODULE_TYPES = {
standard: {label: 'Standard (crystalline)', gamma: -0.37},
premium: {label: 'Premium (high efficiency)', gamma: -0.35},
thinFilm: {label: 'Thin film', gamma: -0.32},
};
export const DEFAULT_WIND_SPEED = 1; // m/s; deliberately low, so cell temperatures are not optimistic.
// SAPM cell temperature, °C.
export function cellTemperature(poa, ambient, mounting = 'open', windSpeed = DEFAULT_WIND_SPEED) {
const m = MOUNTINGS[mounting] || MOUNTINGS.open;
const moduleTemp = poa * Math.exp(m.a + m.b * windSpeed) + ambient;
return moduleTemp + (poa / 1000) * m.deltaT;
}
// Daily air temperature swing, used when only a monthly mean is known: the mean +/- this amount.
export const DEFAULT_DAILY_SWING = 5;
// Air temperature at an hour angle, peaking mid-afternoon (3 pm).
export function ambientTemperature(mean, hourAngle, swing = DEFAULT_DAILY_SWING) {
return mean + swing * cos(hourAngle - 45);
}
// DC power, kW, for an array of `kwp` kilowatts (STC rating) and temperature coefficient `gamma` (%/°C).
export function dcPower(effectiveIrradiance, cellTemp, kwp, gamma) {
return Math.max(0, kwp * (effectiveIrradiance / 1000) * (1 + (gamma / 100) * (cellTemp - 25)));
}
// PVWatts system loss categories and their default percentages.
export const LOSS_CATEGORIES = [
{key: 'soiling', label: 'Soiling', default: 2},
{key: 'shading', label: 'Shading', default: 3},
{key: 'snow', label: 'Snow', default: 0},
{key: 'mismatch', label: 'Mismatch', default: 2},
{key: 'wiring', label: 'Wiring', default: 2},
{key: 'connections', label: 'Connections', default: 0.5},
{key: 'lid', label: 'Light-induced degradation', default: 1.5},
{key: 'nameplate', label: 'Nameplate rating', default: 1},
{key: 'age', label: 'Age', default: 0},
{key: 'availability', label: 'Availability', default: 3},
];
export const DEFAULT_LOSSES = LOSS_CATEGORIES.map(c => c.default);
// Combine loss percentages multiplicatively, as PVWatts does. Returns a percentage.
export function totalLoss(percentages) {
const kept = percentages.reduce((acc, p) => acc * (1 - clamp(Number(p) || 0, 0, 100) / 100), 1);
return 100 * (1 - kept);
}
export const INVERTER_NOMINAL_EFFICIENCY = 96;
const INVERTER_REFERENCE_EFFICIENCY = 0.9637;
// AC power, kW, from the PVWatts inverter model: part-load efficiency curve, clipped at the AC rating.
export function inverterOutput(pdc, acRating, nominalEfficiency = INVERTER_NOMINAL_EFFICIENCY) {
if (!(pdc > 0) || !(acRating > 0)) {
return 0;
}
const etaNom = nominalEfficiency / 100;
const pdc0 = acRating / etaNom;
const zeta = pdc / pdc0;
const eta = (etaNom / INVERTER_REFERENCE_EFFICIENCY) * (-0.0162 * zeta - 0.0059 / zeta + 0.9858);
return Math.min(Math.max(0, eta * pdc), acRating);
}
// ---------------------------------------------------------------------------
// The whole model
// ---------------------------------------------------------------------------
export const DEFAULT_ALBEDO = 0.2;
/**
* Estimate monthly and annual production.
*
* @param {Object} system
* @param {number} system.latitude degrees, north positive
* @param {number[]} system.ghi 12 monthly average GHI values, kWh/m²/day
* @param {number[]} [system.diffuse] 12 monthly diffuse values, kWh/m²/day; estimated when absent
* @param {number[]} [system.temperature] 12 monthly mean air temperatures, °C; 20 when absent
* @param {number[]} [system.albedo] ground reflectance, one value or 12 monthly values
* @param {number} system.kwp array STC rating, kW
* @param {number} [system.tilt] degrees from horizontal (fixed arrays)
* @param {number} [system.azimuth] compass degrees (fixed arrays)
* @param {string} [system.tracking] 'fixed' | 'single' | 'dual'
* @param {string} [system.mounting] key of MOUNTINGS
* @param {number} [system.gamma] temperature coefficient of power, %/°C
* @param {number[]} [system.losses] system loss percentages, see LOSS_CATEGORIES
* @param {Object} [system.inverter] {acRating (kW), efficiency (%)}; null for DC output
*/
export function estimateProduction({
latitude,
ghi,
diffuse = null,
temperature = null,
albedo = DEFAULT_ALBEDO,
kwp,
tilt = 0,
azimuth = defaultAzimuth(latitude),
tracking = 'fixed',
mounting = 'open',
gamma = MODULE_TYPES.standard.gamma,
losses = DEFAULT_LOSSES,
inverter = null,
}) {
const lossFactor = 1 - totalLoss(losses) / 100;
const surfaceAzimuth = compassToSolarAzimuth(azimuth);
const months = MONTH_NAMES.map((name, m) => {
const n = KLEIN_DAYS[m];
const givenAlbedo = Array.isArray(albedo) ? albedo[m] : albedo;
const monthAlbedo = Number.isFinite(givenAlbedo) ? givenAlbedo : DEFAULT_ALBEDO;
const meanTemp = temperature && Number.isFinite(temperature[m]) ? temperature[m] : 20;
const day = averageDaySteps(latitude, m, ghi[m], diffuse ? diffuse[m] : null);
let poaDaily = 0, energyDaily = 0;
for (const step of day.steps) {
const sun = sunPosition(latitude, day.decl, step.hourAngle);
if (sun.cosZenith <= 0) {
continue;
}
const orientation = panelOrientation(tracking, sun, tilt, surfaceAzimuth);
const poa = poaIrradiance(step, sun, orientation, monthAlbedo, n);
const effective = poa.beam * ashraeIam(poa.cosTheta) + poa.sky + poa.ground;
const tc = cellTemperature(poa.total, ambientTemperature(meanTemp, step.hourAngle), mounting);
let power = dcPower(effective, tc, kwp, gamma) * lossFactor;
if (inverter) {
power = inverterOutput(power, inverter.acRating, inverter.efficiency);
}
poaDaily += poa.total * step.hours / 1000;
energyDaily += power * step.hours;
}
return {
month: name,
ghi: ghi[m],
kt: day.kt,
diffuseFraction: day.diffuseFraction,
// Plane-of-array kWh/m²/day: the honest "peak sun hours" for this panel.
poa: poaDaily,
energyDaily,
energy: energyDaily * DAYS_IN_MONTH[m],
dayLength: dayLength(latitude, day.decl),
noonElevation: noonElevation(latitude, day.decl),
};
});
const annual = months.reduce((acc, m) => acc + m.energy, 0);
const worst = months.reduce((a, b) => (b.energyDaily < a.energyDaily ? b : a));
const best = months.reduce((a, b) => (b.energyDaily > a.energyDaily ? b : a));
return {
months,
annual,
specificYield: kwp > 0 ? annual / kwp : null,
capacityFactor: kwp > 0 ? annual / (kwp * 8760) : null,
worst,
best,
totalLoss: totalLoss(losses),
};
}
// Sweep fixed tilts 0-90° at the given azimuth and return the best one. `goal` is 'annual' (most
// energy in a year) or 'winter' (most energy in the worst month, which sizes off-grid systems).
export function optimalTilt(system, goal = 'annual') {
let best = {tilt: 0, value: -1};
for (let tilt = 0; tilt <= 90; tilt++) {
const result = estimateProduction({...system, tilt, tracking: 'fixed'});
const value = goal === 'winter' ? result.worst.energyDaily : result.annual;
if (value > best.value + 1e-9) {
best = {tilt, value};
}
}
return best.tilt;
}
// ---------------------------------------------------------------------------
// Input parsing
// ---------------------------------------------------------------------------
// Parse latitude/longitude text: decimal ("40.015", "-105.27") or degrees-minutes-seconds
// ("40°0'54\"N", "105 16 12 W"). Returns null when unparseable or out of range.
export function parseCoordinate(text, kind = 'lat') {
if (text === null || text === undefined) {
return null;
}
const s = `${text}`.trim().toUpperCase();
if (!s) {
return null;
}
const hemisphere = s.match(/[NSEW]/);
const parts = s.replace(/[NSEW]/g, ' ').match(/-?\d+(\.\d+)?/g);
if (!parts || parts.length > 3) {
return null;
}
const [d, m = 0, sec = 0] = parts.map(Number);
if (m < 0 || m >= 60 || sec < 0 || sec >= 60) {
return null;
}
let value = Math.abs(d) + m / 60 + sec / 3600;
if (d < 0 || s.trim().startsWith('-')) {
value = -value;
}
if (hemisphere) {
const h = hemisphere[0];
if ((kind === 'lat' && (h === 'E' || h === 'W')) || (kind === 'lon' && (h === 'N' || h === 'S'))) {
return null;
}
if (h === 'S' || h === 'W') {
value = -Math.abs(value);
}
}
const limit = kind === 'lat' ? 90 : 180;
return Math.abs(value) <= limit ? value : null;
}
// Parse a comma separated list of 12 monthly numbers. Blank entries become null.
export function parseMonthly(text) {
const values = (text || '').split(',').map(v => (v.trim() === '' ? null : Number(v)));
return MONTH_NAMES.map((_, i) => (Number.isFinite(values[i]) ? values[i] : null));
}
export function formatMonthly(values) {
return values.map(v => (v === null || v === undefined || v === '' ? '' : `${v}`)).join(',');
}
// Link to the Solar calculator for a location, e.g. from the map. 4 decimals (about 11 m) is far finer
// than the 1° data and keeps the shared URL short. MapLibre reports longitudes past ±180° once the map
// has been panned around the world, so those are wrapped back.
export function solarCalculatorPath(latitude, longitude) {
const lon = longitude >= -180 && longitude <= 180 ? longitude : ((longitude + 180) % 360 + 360) % 360 - 180;
const round = value => Number(value.toFixed(4));
return `/more/calculators?calc=solar&lat=${round(latitude)}&lon=${round(lon)}`;
}

View file

@ -1,324 +0,0 @@
import {
ambientTemperature,
ashraeIam,
averageDaySteps,
cellTemperature,
compassToSolarAzimuth,
DAYS_IN_MONTH,
dcPower,
declination,
DEFAULT_LOSSES,
defaultAzimuth,
diffuseHourlyRatio,
erbsMonthlyDiffuseFraction,
estimateProduction,
extraterrestrialDaily,
formatMonthly,
inverterOutput,
KLEIN_DAYS,
optimalTilt,
parseCoordinate,
parseMonthly,
poaIrradiance,
solarCalculatorPath,
sunPosition,
sunsetHourAngle,
totalHourlyRatio,
totalLoss,
} from "./solar";
// A sunny 40°N GHI profile, kWh/m²/day, with a clearness index of about 0.6 year-round (Denver-like).
const GHI_40N = [2.6, 3.5, 4.7, 5.8, 6.5, 7.2, 7.0, 6.2, 5.2, 4.0, 2.9, 2.3];
const TEMP_40N = [0, 2, 6, 10, 15, 21, 24, 23, 18, 11, 4, 0];
const system = (overrides = {}) => ({
latitude: 40,
ghi: GHI_40N,
temperature: TEMP_40N,
kwp: 1,
tilt: 40,
azimuth: 180,
...overrides,
});
const integrate = (ratio, ws) => {
// Midpoint rule over the day, ratios are per hour.
const n = 2000, dw = 2 * ws / n;
let sum = 0;
for (let i = 0; i < n; i++) {
sum += ratio(-ws + (i + 0.5) * dw, ws) * dw / 15;
}
return sum;
};
describe('sun geometry', () => {
test('declination matches Cooper at the solstices and equinox', () => {
expect(declination(172)).toBeCloseTo(23.45, 1); // June 21
expect(declination(355)).toBeCloseTo(-23.45, 1); // December 21
expect(Math.abs(declination(81))).toBeLessThan(0.5); // March 22
// Klein's February day (D&B Table 1.6.1 lists -13.0).
expect(declination(KLEIN_DAYS[1])).toBeCloseTo(-12.95, 1);
});
test('sunset hour angle is 90° at the equator and handles polar day and night', () => {
expect(sunsetHourAngle(0, 20)).toBeCloseTo(90, 5);
expect(sunsetHourAngle(40, 0)).toBeCloseTo(90, 5);
expect(sunsetHourAngle(80, 20)).toBe(180);
expect(sunsetHourAngle(80, -20)).toBe(0);
});
test('extraterrestrial daily irradiation matches Duffie & Beckman Example 1.10.1', () => {
// 43°N on April 15: H0 = 33.8 MJ/m² = 9.39 kWh/m².
expect(extraterrestrialDaily(43, 105)).toBeCloseTo(33.8 / 3.6, 1);
expect(extraterrestrialDaily(80, 355)).toBe(0); // polar night
});
test('sun is due south at solar noon and east in the morning (northern hemisphere)', () => {
const noon = sunPosition(40, 0, 0);
expect(noon.azimuth).toBeCloseTo(0, 5);
expect(noon.zenith).toBeCloseTo(40, 5);
expect(sunPosition(40, 0, -45).azimuth).toBeLessThan(0);
expect(sunPosition(40, 0, 45).azimuth).toBeGreaterThan(0);
});
test('compass azimuth converts to the Duffie & Beckman convention', () => {
expect(compassToSolarAzimuth(180)).toBe(0);
expect(compassToSolarAzimuth(90)).toBe(-90);
expect(compassToSolarAzimuth(270)).toBe(90);
expect(compassToSolarAzimuth(0)).toBe(180);
expect(compassToSolarAzimuth(360)).toBe(180);
expect(defaultAzimuth(40)).toBe(180);
expect(defaultAzimuth(-33)).toBe(0);
});
});
describe('monthly irradiation to steps', () => {
test('Erbs monthly diffuse fraction follows both branches and clamps KT', () => {
expect(erbsMonthlyDiffuseFraction(0.5, 80)).toBeCloseTo(0.3911, 4);
expect(erbsMonthlyDiffuseFraction(0.5, 100)).toBeCloseTo(1.311 - 1.511 + 0.85675 - 0.227625, 4);
expect(erbsMonthlyDiffuseFraction(0.95, 80)).toBeCloseTo(erbsMonthlyDiffuseFraction(0.8, 80), 10);
expect(erbsMonthlyDiffuseFraction(0.1, 80)).toBeCloseTo(erbsMonthlyDiffuseFraction(0.3, 80), 10);
});
test('Liu & Jordan diffuse ratio integrates to one day', () => {
for (const ws of [60, 90, 120, 180]) {
expect(integrate(diffuseHourlyRatio, ws)).toBeCloseTo(1, 3);
}
});
test('Collares-Pereira & Rabl total ratio integrates to about one day', () => {
// The correlation is empirical, so it only approximately conserves the daily total; the
// model normalizes it, but a large error here would mean a transcription mistake.
for (const ws of [60, 90, 120]) {
expect(Math.abs(integrate(totalHourlyRatio, ws) - 1)).toBeLessThan(0.03);
}
});
test('average day steps add back up to the monthly GHI and diffuse', () => {
const day = averageDaySteps(40, 5, 7.2);
const sum = key => day.steps.reduce((acc, s) => acc + s[key] * s.hours, 0) / 1000;
expect(sum('global')).toBeCloseTo(7.2, 6);
expect(sum('diffuse')).toBeCloseTo(7.2 * day.diffuseFraction, 6);
expect(day.steps.every(s => s.beam >= 0 && s.diffuse >= 0)).toBe(true);
// Peak at solar noon.
const peak = day.steps.reduce((a, b) => (b.global > a.global ? b : a));
expect(Math.abs(peak.hourAngle)).toBeLessThan(5);
});
test('a supplied diffuse value is used instead of the Erbs estimate', () => {
expect(averageDaySteps(40, 5, 7.2, 1.8).diffuseFraction).toBeCloseTo(0.25, 6);
});
test('polar night and missing GHI produce no steps', () => {
expect(averageDaySteps(80, 11, 0.1).steps).toEqual([]);
expect(averageDaySteps(40, 5, null).steps).toEqual([]);
});
});
describe('plane of array', () => {
test('a flat panel receives exactly the horizontal irradiance', () => {
const day = averageDaySteps(40, 5, 7.2);
const decl = day.decl;
for (const step of day.steps) {
const sun = sunPosition(40, decl, step.hourAngle);
if (sun.zenith > 85) continue; // the DNI cap applies right at sunrise
const poa = poaIrradiance(step, sun, {tilt: 0, azimuth: 0}, 0.2, KLEIN_DAYS[5]);
expect(poa.total).toBeCloseTo(step.global, 6);
}
});
test('a vertical panel sees half the ground', () => {
const step = {global: 500, beam: 0, diffuse: 500};
const sun = sunPosition(40, 0, 0);
const poa = poaIrradiance(step, sun, {tilt: 90, azimuth: 0}, 0.2, 81);
expect(poa.ground).toBeCloseTo(500 * 0.2 / 2, 6);
});
test('ASHRAE incidence angle modifier', () => {
expect(ashraeIam(1)).toBe(1);
expect(ashraeIam(0.5)).toBeCloseTo(0.95, 6); // 60°
expect(ashraeIam(0)).toBe(0);
expect(ashraeIam(0.01)).toBe(0); // never negative
});
});
describe('temperature, power, losses, inverter', () => {
test('SAPM cell temperature, open rack glass/polymer', () => {
// 1000 * exp(-3.56 - 0.075) + 25 = 51.38, plus deltaT 3.
expect(cellTemperature(1000, 25, 'open', 1)).toBeCloseTo(54.38, 2);
expect(cellTemperature(1000, 25, 'flush', 1)).toBeGreaterThan(cellTemperature(1000, 25, 'open', 1));
expect(cellTemperature(0, 10, 'open')).toBe(10);
});
test('air temperature peaks at 3 pm and averages to the mean', () => {
expect(ambientTemperature(20, 45)).toBeCloseTo(25, 6);
expect(ambientTemperature(20, -135)).toBeCloseTo(15, 6);
});
test('DC power scales with irradiance and loses power when hot', () => {
expect(dcPower(1000, 25, 1, -0.37)).toBeCloseTo(1, 6);
expect(dcPower(500, 25, 2, -0.37)).toBeCloseTo(1, 6);
expect(dcPower(1000, 35, 1, -0.37)).toBeCloseTo(0.963, 6);
expect(dcPower(0, 25, 1, -0.37)).toBe(0);
});
test('PVWatts default losses combine to 14.08%', () => {
expect(totalLoss(DEFAULT_LOSSES)).toBeCloseTo(14.08, 2);
expect(totalLoss([])).toBe(0);
expect(totalLoss([50, 50])).toBeCloseTo(75, 6);
});
test('PVWatts inverter is at nominal efficiency at full load and clips', () => {
// At zeta = 1 the curve evaluates to exactly the nominal efficiency.
const ac = 1, pdc0 = ac / 0.96;
expect(inverterOutput(pdc0, ac) / pdc0).toBeCloseTo(0.96, 4);
expect(inverterOutput(5, ac)).toBe(ac);
expect(inverterOutput(0, ac)).toBe(0);
// Part load is less efficient.
expect(inverterOutput(pdc0 * 0.05, ac) / (pdc0 * 0.05)).toBeLessThan(0.96);
});
});
describe('estimateProduction', () => {
test('a 1 kWp south-facing array at 40°N gives a plausible annual yield', () => {
const result = estimateProduction(system());
// A clear 40°N site yields roughly 1500-1900 kWh/kWp DC after 14% losses.
expect(result.annual).toBeGreaterThan(1500);
expect(result.annual).toBeLessThan(1900);
// Performance ratio (energy over plane-of-array sun-hours): losses plus temperature.
const poaAnnual = result.months.reduce((acc, m, i) => acc + m.poa * DAYS_IN_MONTH[i], 0);
expect(result.annual / poaAnnual).toBeGreaterThan(0.75);
expect(result.annual / poaAnnual).toBeLessThan(0.88);
expect(result.specificYield).toBeCloseTo(result.annual, 6);
expect(result.months).toHaveLength(12);
expect(result.totalLoss).toBeCloseTo(14.08, 2);
});
test('tilting toward the equator gains in winter and loses in summer', () => {
const flat = estimateProduction(system({tilt: 0}));
const tilted = estimateProduction(system({tilt: 40}));
expect(tilted.months[11].poa).toBeGreaterThan(flat.months[11].poa * 1.5);
expect(tilted.months[5].poa).toBeLessThan(flat.months[5].poa);
expect(flat.months[5].poa).toBeCloseTo(GHI_40N[5], 1);
});
test('tracking beats fixed, and dual-axis beats single-axis', () => {
const fixed = estimateProduction(system()).annual;
const single = estimateProduction(system({tracking: 'single'})).annual;
const dual = estimateProduction(system({tracking: 'dual'})).annual;
expect(single).toBeGreaterThan(fixed);
expect(dual).toBeGreaterThan(single);
// Dual-axis is commonly 30-45% over a well-tilted fixed array.
expect(dual / fixed).toBeLessThan(1.6);
});
test('facing away from the equator produces less', () => {
const south = estimateProduction(system()).annual;
const north = estimateProduction(system({azimuth: 0})).annual;
expect(north).toBeLessThan(south * 0.7);
});
test('the southern hemisphere mirrors the northern', () => {
// Same GHI shifted 6 months, panel facing north.
const shifted = [...GHI_40N.slice(6), ...GHI_40N.slice(0, 6)];
const north = estimateProduction(system({temperature: null}));
const south = estimateProduction(system({latitude: -40, ghi: shifted, azimuth: 0, temperature: null}));
expect(south.annual / north.annual).toBeGreaterThan(0.97);
expect(south.annual / north.annual).toBeLessThan(1.03);
});
test('hot weather reduces output', () => {
const cool = estimateProduction(system({temperature: Array(12).fill(0)})).annual;
const hot = estimateProduction(system({temperature: Array(12).fill(35)})).annual;
expect(hot).toBeLessThan(cool * 0.95);
});
test('the inverter costs a few percent and clips an oversized array', () => {
const dc = estimateProduction(system()).annual;
const ac = estimateProduction(system({inverter: {acRating: 1, efficiency: 96}})).annual;
expect(ac / dc).toBeGreaterThan(0.93);
expect(ac / dc).toBeLessThan(0.97);
const clipped = estimateProduction(system({inverter: {acRating: 0.3, efficiency: 96}})).annual;
expect(clipped).toBeLessThan(ac * 0.7);
});
test('polar night months produce nothing without errors', () => {
const result = estimateProduction(system({latitude: 75, tilt: 75}));
expect(result.months[11].energy).toBe(0);
expect(Number.isFinite(result.annual)).toBe(true);
});
test('missing months count as zero', () => {
const ghi = [...GHI_40N];
ghi[0] = null;
expect(estimateProduction(system({ghi})).months[0].energy).toBe(0);
});
test('the best annual tilt is near the latitude; the best winter tilt is steeper', () => {
const annual = optimalTilt(system());
const winter = optimalTilt(system(), 'winter');
expect(annual).toBeGreaterThanOrEqual(30);
expect(annual).toBeLessThanOrEqual(44);
expect(winter).toBeGreaterThan(annual + 8);
});
});
describe('input parsing', () => {
test('decimal and DMS coordinates', () => {
expect(parseCoordinate('40.015')).toBeCloseTo(40.015, 6);
expect(parseCoordinate('-105.27', 'lon')).toBeCloseTo(-105.27, 6);
expect(parseCoordinate('40°0\'54"N')).toBeCloseTo(40.015, 6);
expect(parseCoordinate('105 16 12 W', 'lon')).toBeCloseTo(-105.27, 6);
expect(parseCoordinate('33.9 S')).toBeCloseTo(-33.9, 6);
expect(parseCoordinate('-0.5')).toBeCloseTo(-0.5, 6);
});
test('invalid coordinates are null', () => {
expect(parseCoordinate('')).toBeNull();
expect(parseCoordinate(null)).toBeNull();
expect(parseCoordinate('91')).toBeNull();
expect(parseCoordinate('181', 'lon')).toBeNull();
expect(parseCoordinate('40 E')).toBeNull();
expect(parseCoordinate('abc')).toBeNull();
expect(parseCoordinate('40 75 0')).toBeNull();
});
test('monthly lists round-trip and tolerate blanks', () => {
expect(parseMonthly('1,2,,4')).toEqual([1, 2, null, 4, null, null, null, null, null, null, null, null]);
expect(parseMonthly(null)).toHaveLength(12);
expect(parseMonthly(formatMonthly(GHI_40N))).toEqual(GHI_40N);
});
});
describe('solarCalculatorPath', () => {
test('links to the calculator with the location rounded to 4 decimals', () => {
expect(solarCalculatorPath(40.0153812, -105.2705456))
.toBe('/more/calculators?calc=solar&lat=40.0154&lon=-105.2705');
});
test('wraps a longitude from a map that has been panned around the world', () => {
expect(solarCalculatorPath(10, 190)).toBe('/more/calculators?calc=solar&lat=10&lon=-170');
expect(solarCalculatorPath(10, -540.5)).toBe('/more/calculators?calc=solar&lat=10&lon=179.5');
expect(solarCalculatorPath(10, 180)).toBe('/more/calculators?calc=solar&lat=10&lon=180');
});
});

View file

@ -1,48 +0,0 @@
import {cellIndex, COLUMNS, PARAMETERS, ROWS} from "./solarData";
// Build a climatology file for tests, the same format scripts/build_solar_climatology.py writes.
// `valueAt(key, month, latitude, longitude)` returns the value for a cell center, or null for no data.
export function encodeClimatology(valueAt) {
const header = 8;
const bytes = new Uint8Array(header + PARAMETERS.length * 12 * ROWS * COLUMNS);
bytes.set([...'WSOL'].map(c => c.charCodeAt(0)), 0);
bytes[4] = 1;
bytes[5] = PARAMETERS.length;
PARAMETERS.forEach(({key, offset, step}, p) => {
for (let month = 0; month < 12; month++) {
for (let row = 0; row < ROWS; row++) {
for (let column = 0; column < COLUMNS; column++) {
const value = valueAt(key, month, row - 89.5, column - 179.5);
bytes[header + cellIndex(p, month, row, column)] = value === null
? 255
: Math.max(0, Math.min(254, Math.round((value + offset) / step)));
}
}
}
});
return bytes.buffer;
}
// A plain climatology: the same sunny 40°N profile everywhere.
export const SUNNY_GHI = [2.6, 3.5, 4.7, 5.8, 6.5, 7.2, 7.0, 6.2, 5.2, 4.0, 2.9, 2.3];
export const MILD_TEMPERATURE = [0, 2, 6, 10, 15, 21, 24, 23, 18, 11, 4, 0];
// Encoding the 3 MB file is slow, so it is built once and shared; nothing writes to it.
let uniform = null;
export const uniformClimatology = () => {
if (!uniform) {
uniform = encodeClimatology((key, month) => ({
ghi: SUNNY_GHI[month],
diffuse: SUNNY_GHI[month] * 0.3,
albedo: 0.2,
temperature: MILD_TEMPERATURE[month],
})[key]);
}
return uniform;
};
// Make `fetch` return a climatology file.
export function mockClimatologyFetch(buffer = uniformClimatology()) {
global.fetch = jest.fn(() => Promise.resolve({ok: true, arrayBuffer: () => Promise.resolve(buffer)}));
return global.fetch;
}

View file

@ -1,146 +0,0 @@
import React from "react";
// Offline solar climatology for the Solar calculator: NASA POWER's 2001-2020 monthly averages on a
// 1° grid, built by scripts/build_solar_climatology.py. The format is described in that script and
// must match PARAMETERS below. The file is only fetched when the calculator opens.
export const CLIMATOLOGY_URL = `${process.env.PUBLIC_URL || ''}/data/solar-climatology.bin`;
// Attribution, as NASA asks (https://power.larc.nasa.gov/docs/referencing/) and CC BY 4.0 requires. The
// build script writes the same text, with the API version and download date, to
// public/data/solar-climatology.LICENSE.txt; a test keeps the two in step.
export const CLIMATOLOGY_ACKNOWLEDGMENT = '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.';
export const CLIMATOLOGY_DATA_REFERENCE = 'The data was obtained from the POWER Project\'s POWER Climatology API '
+ 'v2.10.0 version on 2026/10/04.';
export const CLIMATOLOGY_LICENSE = 'Creative Commons Attribution 4.0 International (CC BY 4.0), '
+ 'https://creativecommons.org/licenses/by/4.0/';
export const CLIMATOLOGY_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.';
const MAGIC = 'WSOL';
const VERSION = 1;
const HEADER_BYTES = 8;
export const ROWS = 180;
export const COLUMNS = 360;
const MISSING = 255;
// Stored as round((value + offset) / step), in this order.
export const PARAMETERS = [
{key: 'ghi', offset: 0, step: 0.04},
{key: 'diffuse', offset: 0, step: 0.04},
{key: 'albedo', offset: 0, step: 0.004},
{key: 'temperature', offset: 70, step: 0.5},
];
const PARAMETER_BYTES = 12 * ROWS * COLUMNS;
// Validate a downloaded file and return its value grid.
export function decodeClimatology(buffer) {
const bytes = new Uint8Array(buffer);
const magic = String.fromCharCode(...bytes.slice(0, 4));
if (magic !== MAGIC) {
throw new Error('Not a solar climatology file');
}
if (bytes[4] !== VERSION || bytes[5] !== PARAMETERS.length) {
throw new Error(`Unsupported solar climatology version ${bytes[4]}`);
}
if (bytes.length !== HEADER_BYTES + PARAMETERS.length * PARAMETER_BYTES) {
throw new Error('Solar climatology file is truncated');
}
return bytes.subarray(HEADER_BYTES);
}
// Index of a stored value.
export const cellIndex = (parameter, month, row, column) =>
parameter * PARAMETER_BYTES + (month * ROWS + row) * COLUMNS + column;
/**
* Monthly averages at a location, bilinearly interpolated between the four surrounding cell centers.
* Cells with no data are skipped. Longitude wraps across the antimeridian; latitude is clamped at the
* outermost cell centers.
*
* @returns {{ghi: number[], diffuse: number[], albedo: number[], temperature: number[]}} 12 values each,
* null for a month with no data
*/
export function lookupClimatology(grid, latitude, longitude) {
const y = Math.min(ROWS - 1, Math.max(0, latitude + 90 - 0.5));
const row0 = Math.floor(y);
const row1 = Math.min(ROWS - 1, row0 + 1);
const fy = y - row0;
const x = longitude + 180 - 0.5;
const xFloor = Math.floor(x);
const fx = x - xFloor;
const column0 = ((xFloor % COLUMNS) + COLUMNS) % COLUMNS;
const column1 = (column0 + 1) % COLUMNS;
const corners = [
[row0, column0, (1 - fy) * (1 - fx)],
[row0, column1, (1 - fy) * fx],
[row1, column0, fy * (1 - fx)],
[row1, column1, fy * fx],
];
const result = {};
PARAMETERS.forEach(({key, offset, step}, p) => {
result[key] = Array.from({length: 12}, (_, month) => {
let sum = 0, weight = 0;
for (const [row, column, w] of corners) {
const raw = grid[cellIndex(p, month, row, column)];
if (raw !== MISSING && w > 0) {
sum += (raw * step - offset) * w;
weight += w;
}
}
return weight > 0 ? sum / weight : null;
});
});
return result;
}
let climatologyPromise = null;
// Fetch and decode the file once per page load. A failed fetch is not cached, so it can be retried.
export function loadClimatology() {
if (!climatologyPromise) {
climatologyPromise = fetch(CLIMATOLOGY_URL)
.then(response => {
if (!response.ok) {
throw new Error(`Could not load solar data (${response.status})`);
}
return response.arrayBuffer();
})
.then(decodeClimatology)
.catch(error => {
climatologyPromise = null;
throw error;
});
}
return climatologyPromise;
}
// Drop the loaded file, so the next load fetches again. Tests use this to simulate a failed download.
export function forgetClimatology() {
climatologyPromise = null;
}
// The decoded grid, or null while loading. `error` is set when the file could not be loaded.
export function useClimatology() {
const [grid, setGrid] = React.useState(null);
const [error, setError] = React.useState(null);
React.useEffect(() => {
let cancelled = false;
loadClimatology()
.then(g => !cancelled && setGrid(g))
.catch(e => !cancelled && setError(e));
return () => {
cancelled = true;
};
}, []);
return {grid, error, loading: !grid && !error};
}

View file

@ -1,167 +0,0 @@
import fs from "fs";
import path from "path";
import {
CLIMATOLOGY_ACKNOWLEDGMENT,
CLIMATOLOGY_CHANGES,
CLIMATOLOGY_DATA_REFERENCE,
CLIMATOLOGY_LICENSE,
decodeClimatology,
lookupClimatology,
} from "./solarData";
import {estimateProduction} from "./solar";
import {encodeClimatology, uniformClimatology} from "./solarData.fixtures";
describe('decodeClimatology', () => {
test('accepts a valid file', () => {
expect(decodeClimatology(uniformClimatology()).length).toBe(4 * 12 * 180 * 360);
});
test('rejects a file that is not ours, a newer version, or truncated', () => {
const good = new Uint8Array(uniformClimatology());
const wrongMagic = good.slice();
wrongMagic[0] = 'X'.charCodeAt(0);
expect(() => decodeClimatology(wrongMagic.buffer)).toThrow('Not a solar climatology file');
const newer = good.slice();
newer[4] = 2;
expect(() => decodeClimatology(newer.buffer)).toThrow('Unsupported');
expect(() => decodeClimatology(good.slice(0, 1000).buffer)).toThrow('truncated');
});
});
describe('lookupClimatology', () => {
// A field that varies with longitude, so interpolation is visible: GHI = 3 + lon/100.
const sloped = decodeClimatology(encodeClimatology((key, month, lat, lon) => ({
ghi: 3 + lon / 100,
diffuse: 1,
albedo: lat > 60 ? 0.8 : 0.2,
temperature: -10 + month,
})[key]));
test('returns a cell center value exactly', () => {
const values = lookupClimatology(sloped, 40.5, -104.5);
expect(values.ghi[0]).toBeCloseTo(3 - 1.045, 1);
expect(values.temperature.map(v => Math.round(v))).toEqual([-10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1]);
expect(values.diffuse[6]).toBeCloseTo(1, 1);
});
test('interpolates between cell centers', () => {
// Halfway between the cells centered on -105.5 and -104.5.
const mid = lookupClimatology(sloped, 40.5, -105).ghi[0];
const west = lookupClimatology(sloped, 40.5, -105.5).ghi[0];
const east = lookupClimatology(sloped, 40.5, -104.5).ghi[0];
expect(mid).toBeCloseTo((west + east) / 2, 6);
// Interpolates in latitude too: halfway between albedo 0.2 and 0.8 cells.
expect(lookupClimatology(sloped, 60, 0).albedo[0]).toBeCloseTo(0.5, 2);
});
test('wraps across the antimeridian', () => {
// Between the cells centered on 179.5 (ghi 4.795) and -179.5 (ghi 1.205).
expect(lookupClimatology(sloped, 0.5, 180).ghi[0]).toBeCloseTo((4.795 + 1.205) / 2, 1);
expect(lookupClimatology(sloped, 0.5, -180).ghi[0]).toBeCloseTo((4.795 + 1.205) / 2, 1);
});
test('clamps at the poles', () => {
expect(lookupClimatology(sloped, 90, 0.5).ghi[0]).toBeCloseTo(3.005, 1);
expect(lookupClimatology(sloped, -90, 0.5).ghi[0]).toBeCloseTo(3.005, 1);
});
test('skips cells with no data and returns null when all are missing', () => {
const holes = decodeClimatology(encodeClimatology((key, month, lat, lon) =>
(lon > 0 ? null : {ghi: 5, diffuse: 1, albedo: 0.2, temperature: 10}[key])));
// Straddling lon 0: only the western cells have data.
expect(lookupClimatology(holes, 10.5, 0).ghi[0]).toBeCloseTo(5, 1);
expect(lookupClimatology(holes, 10.5, 90).ghi[0]).toBeNull();
});
});
// The real data file, when it has been built. The expected values come from NASA POWER's point API
// (2001-2020 climatology), so these confirm the build script and decoder agree end to end.
const REAL_FILE = path.join(__dirname, '../../../public/data/solar-climatology.bin');
const describeReal = fs.existsSync(REAL_FILE) ? describe : describe.skip;
describeReal('the built climatology file', () => {
let grid;
beforeAll(() => {
grid = decodeClimatology(fs.readFileSync(REAL_FILE).buffer.slice(0));
});
// NASA POWER's monthly GHI for two cells, copied from its API (2001-2020 climatology). A cell
// center is returned exactly, give or take the file's 0.04 kWh/m²/day step.
const expectCell = (actual, expected) => actual.forEach((v, i) => expect(Math.abs(v - expected[i]))
.toBeLessThanOrEqual(0.021));
test('matches NASA POWER at the cell near Denver', () => {
const values = lookupClimatology(grid, 40.5, -104.5);
expectCell(values.ghi,
[2.4089, 3.2969, 4.6241, 5.6225, 6.3463, 7.2588, 7.0428, 6.2326, 5.2351, 3.7486, 2.6717, 2.0921]);
expect(values.diffuse.every((v, i) => v > 0 && v < values.ghi[i])).toBe(true);
expect(values.temperature[6]).toBeGreaterThan(values.temperature[0] + 15);
});
test('matches NASA POWER at the cell near Sydney, with the seasons reversed', () => {
const values = lookupClimatology(grid, -33.5, 151.5);
expectCell(values.ghi,
[6.4908, 5.6611, 4.8137, 3.8191, 2.9803, 2.357, 2.772, 3.6725, 4.8242, 5.6698, 6.2074, 6.5594]);
});
// PVWatts v8 results for a 1 kW standard-module, open-rack, fixed array with the default 14.08%
// losses, DC output in kWh, fetched from developer.nlr.gov on 2026-10-04. PVWatts uses NREL's NSRDB
// typical-year weather, not NASA POWER, so the two agree only within the data sets' differences.
const PVWATTS = {
Phoenix: {
lat: 33.45, lon: -112.07, tilt: 33, azimuth: 180,
monthly: [139.7, 139.1, 166.3, 175.4, 175.8, 165.2, 152.4, 155.2, 157.6, 157.8, 144.8, 140.3],
},
Seattle: {
lat: 47.61, lon: -122.33, tilt: 48, azimuth: 180,
monthly: [51.9, 67.3, 89.6, 115.5, 127.1, 116.7, 130.3, 141.0, 111.9, 82.0, 48.6, 42.8],
},
Anchorage: {
lat: 61.22, lon: -149.90, tilt: 61, azimuth: 180,
monthly: [19.6, 45.0, 119.2, 117.4, 124.2, 118.0, 109.4, 109.4, 87.8, 52.0, 23.3, 12.6],
},
};
test.each(Object.entries(PVWATTS))('agrees with PVWatts in %s', (name, site) => {
const sunlight = lookupClimatology(grid, site.lat, site.lon);
const result = estimateProduction({
latitude: site.lat, kwp: 1, tilt: site.tilt, azimuth: site.azimuth, ...sunlight,
});
const annual = site.monthly.reduce((a, b) => a + b, 0);
expect(result.annual / annual).toBeGreaterThan(0.9);
expect(result.annual / annual).toBeLessThan(1.1);
// Dark months are small and the two data sets disagree most there; check the months that matter.
const meanMonth = annual / 12;
result.months.forEach((m, i) => {
if (site.monthly[i] >= 0.6 * meanMonth) {
expect(m.energy / site.monthly[i]).toBeGreaterThan(0.75);
expect(m.energy / site.monthly[i]).toBeLessThan(1.25);
}
});
});
test('has polar night', () => {
expect(lookupClimatology(grid, 80, 15).ghi[11]).toBeLessThan(0.05);
});
});
describe('attribution', () => {
// The build script writes this notice beside the data; the page shows the same text.
const notice = fs.readFileSync(path.join(__dirname, '../../../public/data/solar-climatology.LICENSE.txt'), 'utf8');
test('the notice and the page credit NASA identically', () => {
expect(notice).toContain(CLIMATOLOGY_ACKNOWLEDGMENT);
expect(notice).toContain(CLIMATOLOGY_DATA_REFERENCE);
expect(notice).toContain(CLIMATOLOGY_LICENSE);
expect(notice).toContain(CLIMATOLOGY_CHANGES);
});
test('the acknowledgment is NASA POWER\'s requested wording', () => {
// https://power.larc.nasa.gov/docs/referencing/
expect(CLIMATOLOGY_ACKNOWLEDGMENT).toBe('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.');
});
});

View file

@ -77,9 +77,7 @@ function renderRow(collection, columns, routes, SingleTag, onRowClick) {
}
// Format the value based on column configuration
if (col.render) {
value = col.render(collection);
} else if (col.format === 'bytes') {
if (col.format === 'bytes') {
value = humanFileSize(value);
} else if (col.format === 'frequency') {
value = formatFrequency(value);
@ -136,9 +134,7 @@ function MobileCollectionRow({collection, mobileColumns, routes}) {
.filter(col => col.type !== 'actions' && col.key !== primaryColumn.key && col.key !== 'tag_name')
.map(col => {
let value = collection[col.key];
if (col.render) {
value = col.render(collection);
} else if (col.format === 'bytes') {
if (col.format === 'bytes') {
value = humanFileSize(value);
} else if (col.format === 'frequency') {
value = formatFrequency(value);
@ -161,8 +157,7 @@ function MobileCollectionRow({collection, mobileColumns, routes}) {
* Reusable table component for displaying collections (Domains, Channels, etc).
*
* @param {Array} collections - Array of collection objects
* @param {Array} columns - Column configurations for the table. A column's optional `render(collection)` displays
* the cell; the column still sorts by `collection[key]`.
* @param {Array} columns - Column configurations for the table
* @param {Object} routes - Routes configuration for navigation (edit, search, etc.)
* @param {String} searchStr - Search filter string (managed by parent)
* @param {Function} onRowClick - Optional callback when a row is clicked

View file

@ -68,27 +68,6 @@ const DOMAIN_ROUTES = {
describe('CollectionTable', () => {
const mockCollections = createMockDomains(3);
describe('Column render', () => {
it('displays what a column renders, in the desktop and mobile rows', () => {
const columns = [
{key: 'name', label: 'Name', render: (c) => <em data-testid='rendered-name'>{c.name}!</em>},
{key: 'branch', label: 'Branch', render: (c) => c.branch || c.default_branch},
];
render(<CollectionTable
collections={[{id: 1, name: 'kiwix-tools', branch: null, default_branch: 'main'}]}
columns={columns}
routes={{search: '/repos/:id', id_field: 'id'}}
/>);
// Desktop: the rendered name is still the link to the collection.
expect(screen.getByTestId('rendered-name')).toHaveTextContent('kiwix-tools!');
expect(screen.getByTestId('rendered-name').closest('a')).toHaveAttribute('href', '/repos/1');
expect(screen.getByText('main')).toBeInTheDocument();
// Mobile lists the other columns by label.
expect(screen.getByText('Branch: main')).toBeInTheDocument();
});
});
describe('Loading and Error States', () => {
it('renders loading placeholder when collections is null', () => {
const {container} = render(

View file

@ -25,7 +25,7 @@ function ItemNameList({items}) {
* `onChanged` refreshes the page's inventory state after a successful action.
*/
export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
const [dates, setDates] = useState(null); // null while loading, undefined if the fetch failed
const [dates, setDates] = useState(null); // null while loading
const [reimporting, setReimporting] = useState(false);
const [selected, setSelected] = useState(null); // {date, mode, preview} confirmation view
const [previewing, setPreviewing] = useState(null); // `${date}:${mode}` currently loading a preview
@ -41,19 +41,11 @@ export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
setReimporting(false);
setApplying(false);
setPreviewing(null);
getInventoryBackups(slug)
.then(result => {
if (!cancelled) {
// The api helper returns undefined on a non-OK response.
setDates(result === undefined ? undefined : result || []);
}
})
.catch(e => {
console.error(e);
if (!cancelled) {
setDates(undefined);
}
});
getInventoryBackups(slug).then(result => {
if (!cancelled) {
setDates(result || []);
}
});
return () => {
cancelled = true;
};
@ -161,8 +153,6 @@ export function InventoryImportModal({open, onClose, slug, name, onChanged}) {
</p>
{dates === null
? <Loading>Loading backups…</Loading>
: dates === undefined
? <p style={{color: 'var(--danger)'}}>Could not fetch backups for this inventory.</p>
: dates.length === 0
? <p>No backups yet. A backup is saved automatically whenever this inventory changes.</p>
: <div style={{maxHeight: '320px', overflowY: 'auto'}}>

Some files were not shown because too many files have changed in this diff Show more