From 19f4958fb5fda058f35730aa22fd4f34cb0ae702 Mon Sep 17 00:00:00 2001 From: Roland Date: Mon, 20 Jul 2026 18:24:03 -0600 Subject: [PATCH] zim: ship a default fallback Zim and honor the configured Zim directory Two related gaps in how WROLPi serves Kiwix content: 1. A fresh WROLPi (or one whose zims are all corrupt/absent) showed a blank Kiwix. Every image now ships a small built-in default Zim that Kiwix falls back to, telling the user to download real Zim files. 2. The startup scripts hardcoded the `/zims` directory, ignoring wrolpi.yaml's `zims_destination` (which the Python side already honors). Changes: - New wrolpi/scripts/read_config_value.sh: a zero-dependency (awk) reader so non-Python scripts can reliably read top-level wrolpi.yaml scalars. Also a config_value() wrapper in wrolpi/scripts/lib.sh. - New wrolpi/scripts/default_zim/: HTML source + a reproducible builder (ghcr.io/openzim/zim-tools) that outputs the committed wrolpi/blobs/default.zim. - New wrolpi/blobs/: any committed blob here is copied to /opt/wrolpi-blobs by the pi-gen and debian-live builds; the Docker zim image bakes it in. - start_kiwix_serve.sh honors zims_destination and falls back to the default Zim when no functioning zim is found. - docker/zim/entrypoint.sh gains the same default-Zim fallback. It needs no config read: it already scans the whole mounted media dir, and zims_destination is always a subdirectory of that mount. - Changing zims_destination via the settings API now restarts Kiwix so it serves the new directory without a reboot. Tests: new test_read_config_value.py, extended test_start_kiwix_serve.py (configured dir + default-Zim fallback), and a root_api test for the restart. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hooks/normal/9999-wrolpi.hook.chroot | 4 + docker/zim/Dockerfile | 5 ++ docker/zim/entrypoint.sh | 36 ++++++++- modules/zim/test/test_read_config_value.py | 69 ++++++++++++++++++ modules/zim/test/test_start_kiwix_serve.py | 58 +++++++++++++-- pi-gen/stage2/04-wrolpi/04-run-chroot.sh | 4 + scripts/start_kiwix_serve.sh | 24 +++++- wrolpi/blobs/default.zim | Bin 0 -> 71053 bytes wrolpi/root_api.py | 8 ++ .../scripts/default_zim/build_default_zim.sh | 31 ++++++++ wrolpi/scripts/default_zim/src/favicon.png | Bin 0 -> 4928 bytes wrolpi/scripts/default_zim/src/index.html | 60 +++++++++++++++ wrolpi/scripts/lib.sh | 5 ++ wrolpi/scripts/read_config_value.sh | 52 +++++++++++++ wrolpi/test/test_root_api.py | 20 +++++ 15 files changed, 362 insertions(+), 14 deletions(-) create mode 100644 modules/zim/test/test_read_config_value.py create mode 100644 wrolpi/blobs/default.zim create mode 100755 wrolpi/scripts/default_zim/build_default_zim.sh create mode 100644 wrolpi/scripts/default_zim/src/favicon.png create mode 100644 wrolpi/scripts/default_zim/src/index.html create mode 100755 wrolpi/scripts/read_config_value.sh diff --git a/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot b/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot index 8518c4fc..afd3c2e5 100644 --- a/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot +++ b/debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot @@ -132,6 +132,10 @@ mkdir /media/wrolpi [[ -f /opt/wrolpi-blobs/map-overview.pmtiles && -s /opt/wrolpi-blobs/map-overview.pmtiles ]] || \ (echo "ERROR: map-overview.pmtiles missing from /opt/wrolpi-blobs!" && exit 1) +# Ship committed blobs (e.g. the default Zim served by Kiwix when the media dir +# has no working zims) into /opt/wrolpi-blobs. +cp /opt/wrolpi/wrolpi/blobs/* /opt/wrolpi-blobs/ + # Bake in the map toolchain (pmtiles CLI + tippecanoe) so first boot does not # need internet. Shared with pi-gen and on-device upgrades via a single script # so versions/pins live in one place. It is safe in the chroot: unlike diff --git a/docker/zim/Dockerfile b/docker/zim/Dockerfile index b390bac7..426d6b95 100644 --- a/docker/zim/Dockerfile +++ b/docker/zim/Dockerfile @@ -2,6 +2,11 @@ FROM debian:13 ENV DOCKER=true RUN apt update && apt-get install -y kiwix kiwix-tools + +# Committed blobs (e.g. the default Zim served when the media directory has no +# functioning zim files). +COPY wrolpi/blobs/ /opt/wrolpi-blobs/ + COPY docker/zim/* / ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/docker/zim/entrypoint.sh b/docker/zim/entrypoint.sh index e0df9a03..2e622bb4 100755 --- a/docker/zim/entrypoint.sh +++ b/docker/zim/entrypoint.sh @@ -1,10 +1,38 @@ #! /usr/bin/env bash -rm -f /library.xml +set -u -# Find all Zim files and import them. -find /media/wrolpi -iname '*.zim' -exec /import_zim.sh {} \; +LIBRARY=/library.xml +# Default Zim baked into the image; served when no real zim is available. +DEFAULT_ZIM="${DEFAULT_ZIM:-/opt/wrolpi-blobs/default.zim}" + +rm -f "${LIBRARY}" + +# Import every *.zim under the mounted media directory. A corrupt or partial +# zim is rejected by kiwix-manage; skip it so the remaining valid zims are +# still served. We scan the whole media mount, so any configured +# `zims_destination` subdirectory is covered automatically -- no config read. +added_zim=false +while IFS= read -r -d '' zim; do + if /import_zim.sh "${zim}"; then + added_zim=true + else + echo "Skipping zim which could not be added (corrupt or incomplete?): ${zim}" >&2 + fi +done < <(find /media/wrolpi -iname '*.zim' -print0) + +# When no functioning zim was added, fall back to the default Zim so the user +# sees a helpful placeholder instead of a blank Kiwix. +if [ "${added_zim}" = false ] && [ -f "${DEFAULT_ZIM}" ]; then + echo "No usable Zim files found; serving the default WROLPi Zim: ${DEFAULT_ZIM}" >&2 + /import_zim.sh "${DEFAULT_ZIM}" || echo "Failed to add the default Zim" >&2 +fi + +# Ensure a library file always exists so kiwix-serve can start regardless. +if [ ! -f "${LIBRARY}" ]; then + printf '\n\n\n' > "${LIBRARY}" +fi # Bind all interfaces by omitting --address: kiwix-serve defaults to all # interfaces, and trixie's kiwix-tools 3.7.0 rejects the literal # "--address 0.0.0.0" ("IP address is not available on this system"). -kiwix-serve --monitorLibrary --library /library.xml --port 80 +kiwix-serve --monitorLibrary --library "${LIBRARY}" --port 80 diff --git a/modules/zim/test/test_read_config_value.py b/modules/zim/test/test_read_config_value.py new file mode 100644 index 00000000..41ce7f44 --- /dev/null +++ b/modules/zim/test/test_read_config_value.py @@ -0,0 +1,69 @@ +"""Tests for wrolpi/scripts/read_config_value.sh. + +The reader extracts a top-level scalar from wrolpi.yaml with zero dependencies +so non-Python scripts (e.g. start_kiwix_serve.sh, the Docker Zim entrypoint) can +read config values. Exercised directly via bash. +""" +import os +import pathlib +import subprocess + +import pytest + +REPO_ROOT = pathlib.Path(__file__).parents[3] +READER = REPO_ROOT / 'wrolpi' / 'scripts' / 'read_config_value.sh' + + +def _read(media: pathlib.Path, key: str, default: str = '') -> str: + env = {**os.environ, 'MEDIA_DIRECTORY': str(media)} + result = subprocess.run(['bash', str(READER), key, default], env=env, + capture_output=True, text=True, timeout=30) + assert result.returncode == 0, result.stderr + return result.stdout.strip('\n') + + +@pytest.fixture +def media_with_config(tmp_path): + media = tmp_path / 'media' + (media / 'config').mkdir(parents=True) + + def write(text: str): + (media / 'config' / 'wrolpi.yaml').write_text(text) + + return media, write + + +def test_reads_scalar_value(media_with_config): + media, write = media_with_config + write('zims_destination: mycustomzims\nvideos_destination: videos\n') + assert _read(media, 'zims_destination', 'zims') == 'mycustomzims' + + +def test_strips_surrounding_quotes(media_with_config): + media, write = media_with_config + write('videos_destination: "quoted videos"\n') + assert _read(media, 'videos_destination', 'x') == 'quoted videos' + + +def test_missing_key_returns_default(media_with_config): + media, write = media_with_config + write('videos_destination: videos\n') + assert _read(media, 'zims_destination', 'zims') == 'zims' + + +def test_empty_value_returns_default(media_with_config): + media, write = media_with_config + write('zims_destination:\n') + assert _read(media, 'zims_destination', 'zims') == 'zims' + + +def test_missing_config_file_returns_default(tmp_path): + # No config directory/file at all (fresh install) -> default, no error. + assert _read(tmp_path / 'media', 'zims_destination', 'zims') == 'zims' + + +def test_ignores_indented_nested_keys(media_with_config): + media, write = media_with_config + # A nested key with the same name must not be matched at the top level. + write('some_section:\n zims_destination: nested\ntop: value\n') + assert _read(media, 'zims_destination', 'zims') == 'zims' diff --git a/modules/zim/test/test_start_kiwix_serve.py b/modules/zim/test/test_start_kiwix_serve.py index 4999e8d5..c9d13d67 100644 --- a/modules/zim/test/test_start_kiwix_serve.py +++ b/modules/zim/test/test_start_kiwix_serve.py @@ -3,9 +3,12 @@ The script imports every *.zim in the Zim directory into a Kiwix library, then starts kiwix-serve. These tests exercise the script directly with fake `kiwix-manage`/`kiwix-serve` binaries on PATH so they need no real Kiwix -installation. The key invariant: a single corrupt/incomplete zim must never -prevent the remaining valid zims from being served, nor stop the server from -starting. +installation. Key invariants: + +- A single corrupt/incomplete zim must never prevent the remaining valid zims + from being served, nor stop the server from starting. +- The configured Zim directory (wrolpi.yaml `zims_destination`) is honored. +- When no functioning zim is found, the default Zim is served as a fallback. """ import os import pathlib @@ -14,7 +17,8 @@ import subprocess import pytest -SCRIPT = pathlib.Path(__file__).parents[3] / 'scripts' / 'start_kiwix_serve.sh' +REPO_ROOT = pathlib.Path(__file__).parents[3] +SCRIPT = REPO_ROOT / 'scripts' / 'start_kiwix_serve.sh' # Fake kiwix-manage: refuses any zim whose contents contain "CORRUPT" (mimicking # kiwix-manage's "Cannot add zim ..." rejection of partial/corrupt files), @@ -55,6 +59,7 @@ def kiwix_env(tmp_path): media = tmp_path / 'media' zims = media / 'zims' zims.mkdir(parents=True) + (media / 'config').mkdir() bin_dir = tmp_path / 'bin' bin_dir.mkdir() @@ -64,20 +69,34 @@ def kiwix_env(tmp_path): p.chmod(p.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) started_marker = tmp_path / 'started' + # Points at a file that does not exist by default (so the fallback is a + # no-op); the fallback test creates it. + default_zim = tmp_path / 'blobs' / 'default.zim' + + def write_config(text: str): + (media / 'config' / 'wrolpi.yaml').write_text(text) def run(): env = { **os.environ, 'MEDIA_DIRECTORY': str(media), + 'PROJECT_DIR': str(REPO_ROOT), + 'DEFAULT_ZIM': str(default_zim), 'PATH': f'{bin_dir}:{os.environ["PATH"]}', 'STARTED_MARKER': str(started_marker), } return subprocess.run(['bash', str(SCRIPT)], env=env, capture_output=True, text=True, timeout=60) - return dict(media=media, zims=zims, library=zims / 'library.xml', started_marker=started_marker, run=run) + def library_text(): + lib = zims / 'library.xml' + return lib.read_text() if lib.exists() else '' + + return dict(media=media, zims=zims, library=zims / 'library.xml', started_marker=started_marker, + default_zim=default_zim, write_config=write_config, library_text=library_text, run=run) def _write_zim(path: pathlib.Path, corrupt: bool = False): + path.parent.mkdir(parents=True, exist_ok=True) path.write_text('CORRUPT' if corrupt else 'ZIMDATA') @@ -107,7 +126,7 @@ def test_corrupt_zim_does_not_prevent_serving_valid_zims(kiwix_env): def test_only_corrupt_zim_still_starts_server(kiwix_env): - """Even when the only zim is corrupt, kiwix must still start (empty library).""" + """Even when the only zim is corrupt, kiwix must still start.""" _write_zim(kiwix_env['zims'] / 'sudan_broken.zim', corrupt=True) result = kiwix_env['run']() @@ -123,3 +142,30 @@ def test_no_zims_still_starts_server(kiwix_env): assert result.returncode == 0, result.stderr assert kiwix_env['started_marker'].exists(), 'kiwix-serve was not started' + + +def test_honors_configured_zims_destination(kiwix_env): + """A custom zims_destination in wrolpi.yaml is used instead of the default `zims`.""" + kiwix_env['write_config']('zims_destination: mycustomzims\n') + _write_zim(kiwix_env['media'] / 'mycustomzims' / 'wikipedia.zim') + + result = kiwix_env['run']() + + assert result.returncode == 0, result.stderr + assert kiwix_env['started_marker'].exists(), 'kiwix-serve was not started' + # The library lives in the configured directory and references the zim there. + library = kiwix_env['media'] / 'mycustomzims' / 'library.xml' + assert library.exists() + assert 'mycustomzims/wikipedia.zim' in library.read_text() + + +def test_falls_back_to_default_zim_when_no_usable_zim(kiwix_env): + """When no functioning zim is present, the default Zim is served.""" + # No zims in the directory; create the default Zim the script should fall back to. + _write_zim(kiwix_env['default_zim']) + + result = kiwix_env['run']() + + assert result.returncode == 0, result.stderr + assert kiwix_env['started_marker'].exists(), 'kiwix-serve was not started' + assert 'default.zim' in kiwix_env['library'].read_text() diff --git a/pi-gen/stage2/04-wrolpi/04-run-chroot.sh b/pi-gen/stage2/04-wrolpi/04-run-chroot.sh index 6c5dbd8d..c1ca9a39 100755 --- a/pi-gen/stage2/04-wrolpi/04-run-chroot.sh +++ b/pi-gen/stage2/04-wrolpi/04-run-chroot.sh @@ -18,6 +18,10 @@ cp /opt/wrolpi/etc/raspberrypios/*desktop /etc/skel/Desktop [[ -f /opt/wrolpi-blobs/map-overview.pmtiles && -s /opt/wrolpi-blobs/map-overview.pmtiles ]] || \ (echo "ERROR: map-overview.pmtiles missing from /opt/wrolpi-blobs!" && exit 1) +# Ship committed blobs (e.g. the default Zim served by Kiwix when the media dir +# has no working zims) into /opt/wrolpi-blobs. +cp /opt/wrolpi/wrolpi/blobs/* /opt/wrolpi-blobs/ + # Install WROLPi Help. /opt/wrolpi/scripts/install_help_service.sh diff --git a/scripts/start_kiwix_serve.sh b/scripts/start_kiwix_serve.sh index 092b048a..b837d1e9 100755 --- a/scripts/start_kiwix_serve.sh +++ b/scripts/start_kiwix_serve.sh @@ -2,12 +2,19 @@ # This file imports all *.zim files in the Zim media directory, then starts kiwix-serve. set -u -# The media directory is overridable for testing; defaults to the production mount. +# The media directory and project directory are overridable for testing; +# they default to the production locations. MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-/media/wrolpi}" +PROJECT_DIR="${PROJECT_DIR:-/opt/wrolpi}" +# Default Zim shipped in /opt/wrolpi-blobs; served when no real zim is available. +DEFAULT_ZIM="${DEFAULT_ZIM:-/opt/wrolpi-blobs/default.zim}" [ ! -d "${MEDIA_DIRECTORY}" ] && echo "Cannot start kiwix without media directory mounted" && exit 1 -ZIM_DIRECTORY="${MEDIA_DIRECTORY}/zims" +# Honor the configured Zim directory (wrolpi.yaml `zims_destination`, always +# relative to the media directory), defaulting to `zims`. +ZIMS_DEST="$(MEDIA_DIRECTORY="${MEDIA_DIRECTORY}" "${PROJECT_DIR}/wrolpi/scripts/read_config_value.sh" zims_destination zims)" +ZIM_DIRECTORY="${MEDIA_DIRECTORY}/${ZIMS_DEST}" LIBRARY="${ZIM_DIRECTORY}/library.xml" # Wait up to 30 seconds for the zims directory to be mounted. @@ -44,11 +51,20 @@ done < <(find "${ZIM_DIRECTORY}" -iname '*.zim' -print0) if [ "${found_zim}" = false ]; then echo "Could not find any Zim files to import" elif [ "${added_zim}" = false ]; then - echo "Found Zim files but none could be added (all corrupt or incomplete?); starting Kiwix with an empty library" >&2 + echo "Found Zim files but none could be added (all corrupt or incomplete?)" >&2 +fi + +# When no functioning zim was added, fall back to the default Zim shipped in +# /opt/wrolpi-blobs so the user sees a helpful placeholder ("download some Zim +# files") instead of a blank Kiwix. A single corrupt zim must never take down +# the server. +if [ "${added_zim}" = false ] && [ -f "${DEFAULT_ZIM}" ]; then + echo "No usable Zim files found; serving the default WROLPi Zim: ${DEFAULT_ZIM}" >&2 + kiwix-manage "${LIBRARY}" add "${DEFAULT_ZIM}" || echo "Failed to add the default Zim" >&2 fi # Ensure a library file always exists so kiwix-serve can start even when there -# are no valid zims. A single corrupt zim must never take down the server. +# are no valid zims and the default Zim is missing. if [ ! -f "${LIBRARY}" ]; then printf '\n\n\n' > "${LIBRARY}" fi diff --git a/wrolpi/blobs/default.zim b/wrolpi/blobs/default.zim new file mode 100644 index 0000000000000000000000000000000000000000..6d00174f3b58af4ed62387160fdc960e0e0e0965 GIT binary patch literal 71053 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 literal 0 HcmV?d00001 diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 631638be..55650815 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -223,6 +223,7 @@ async def update_settings(_: Request, body: schema.SettingsRequest): # Remove any keys with None values, then save the config. new_config = {k: v for k, v in body.__dict__.items() if v is not None} wrolpi_config = get_wrolpi_config() + old_zims_destination = wrolpi_config.zims_destination if not new_config: raise InvalidConfig() @@ -300,6 +301,13 @@ async def update_settings(_: Request, body: schema.SettingsRequest): # Save config settings (hotspot control is now through Controller endpoints) wrolpi_config.update(new_config) + # If the Zim directory changed, restart Kiwix so it serves the new directory + # without waiting for a reboot. Local import avoids a circular import + # (modules depend on wrolpi, not the reverse). + if wrolpi_config.zims_destination != old_zims_destination: + from modules.zim.lib import restart_kiwix_handler + restart_kiwix_handler.activate_switch() + # Apply timezone to the system via Controller (native deployments only). if body.timezone is not None and not DOCKERIZED: timezone_value = new_config.get('timezone') diff --git a/wrolpi/scripts/default_zim/build_default_zim.sh b/wrolpi/scripts/default_zim/build_default_zim.sh new file mode 100755 index 00000000..2de45c60 --- /dev/null +++ b/wrolpi/scripts/default_zim/build_default_zim.sh @@ -0,0 +1,31 @@ +#! /usr/bin/env bash +# Build the default fallback Zim that Kiwix serves when a WROLPi has no +# functioning Zim files. The output is committed to wrolpi/blobs/default.zim, +# which every build copies into /opt/wrolpi-blobs. +# +# Reproducible via the openzim/zim-tools Docker image, so no host zim-tools +# installation is required. Re-run this only when src/ changes. +set -euo pipefail + +HERE="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &>/dev/null && pwd )" +BLOBS="$( cd -- "${HERE}/../../blobs" &>/dev/null && pwd )" +IMAGE="ghcr.io/openzim/zim-tools:latest" + +echo "Building default.zim from ${HERE}/src -> ${BLOBS}/default.zim ..." +rm -f "${BLOBS}/default.zim" # zimwriterfs refuses to overwrite an existing file. +docker run --rm -v "${HERE}:/work" -v "${BLOBS}:/out" -w /work "${IMAGE}" \ + zimwriterfs \ + --welcome=index.html \ + --illustration=favicon.png \ + --language=eng \ + --title="WROLPi" \ + --description="Default WROLPi Zim. Download Zim files to replace this placeholder." \ + --creator="WROLPi" \ + --publisher="WROLPi" \ + --name="wrolpi.default" \ + src /out/default.zim + +echo "Validating default.zim ..." +docker run --rm -v "${BLOBS}:/out" -w /out "${IMAGE}" zimcheck default.zim + +echo "Done: ${BLOBS}/default.zim" diff --git a/wrolpi/scripts/default_zim/src/favicon.png b/wrolpi/scripts/default_zim/src/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..d6c8a169380a6e3d79d724b24cbdc04166e217ef GIT binary patch literal 4928 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 Kszk#s + + + + + WROLPi — No Zim files yet + + + + +

This is the default WROLPi Zim

+ +

+ You are seeing this page because your WROLPi has no Zim files yet + (or the Zim files it found could not be read). This built-in placeholder keeps the + Kiwix viewer running until you download real content. +

+ +
+ To add encyclopedias and reference libraries, open Zim in the WROLPi + menu and download what you want — Wikipedia, Wiktionary, StackExchange, iFixit, + medical and survival references, and more. +
+ +

+ Downloaded Zim files are stored in your Zim directory (by default + zims inside your media directory). Once at least one working Zim file is + present, Kiwix will serve it here automatically and this page will disappear. +

+ + + + diff --git a/wrolpi/scripts/lib.sh b/wrolpi/scripts/lib.sh index 4630bd7b..13edc248 100755 --- a/wrolpi/scripts/lib.sh +++ b/wrolpi/scripts/lib.sh @@ -16,6 +16,11 @@ PROJECT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) PROJECT_DIR=$(dirname "${PROJECT_DIR}") PROJECT_DIR=$(dirname "${PROJECT_DIR}") +# Read a top-level scalar value from wrolpi.yaml. Usage: config_value KEY [DEFAULT] +function config_value { + "${PROJECT_DIR}/wrolpi/scripts/read_config_value.sh" "$@" +} + rpi=false debian12=false if (grep 'Raspberry Pi' /proc/cpuinfo >/dev/null); then diff --git a/wrolpi/scripts/read_config_value.sh b/wrolpi/scripts/read_config_value.sh new file mode 100755 index 00000000..57dfd60d --- /dev/null +++ b/wrolpi/scripts/read_config_value.sh @@ -0,0 +1,52 @@ +#! /usr/bin/env bash +# Read a single top-level scalar value from WROLPi's config (wrolpi.yaml). +# +# wrolpi.yaml is the source of truth for configuration, but scripts that run +# outside the Python API (e.g. start_kiwix_serve.sh, or a minimal Docker +# container) still need to read config values. This reader has zero +# dependencies -- no Python, no jq/yq -- so it runs identically everywhere. +# +# Usage: read_config_value.sh KEY [DEFAULT] +# Prints the value of the top-level scalar KEY, or DEFAULT (empty if omitted) +# when the config file or the key is missing/empty. +# +# Limitation: only top-level scalar keys are supported (sufficient for the +# *_destination keys). Nested keys, lists, and multi-line values are not +# parsed. wrolpi.yaml is written by PyYAML and is regular, so this is reliable +# for the keys it is used with. +set -u + +KEY="${1:?Usage: read_config_value.sh KEY [DEFAULT]}" +DEFAULT="${2:-}" + +MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-/media/wrolpi}" +CONFIG="${MEDIA_DIRECTORY}/config/wrolpi.yaml" + +value="" +if [ -f "${CONFIG}" ]; then + value=$(awk -v key="${KEY}" ' + # Only consider unindented (top-level) "key:" lines. + /^[A-Za-z0-9_]+[[:space:]]*:/ { + idx = index($0, ":") + k = substr($0, 1, idx - 1) + gsub(/[[:space:]]+$/, "", k) + if (k == key) { + v = substr($0, idx + 1) + sub(/^[[:space:]]+/, "", v) # leading whitespace + sub(/[[:space:]]+#.*$/, "", v) # trailing inline comment + sub(/[[:space:]]+$/, "", v) # trailing whitespace + # Strip a matching pair of surrounding quotes. + if (v ~ /^".*"$/ || v ~ /^'\''.*'\''$/) + v = substr(v, 2, length(v) - 2) + print v + exit + } + } + ' "${CONFIG}") +fi + +if [ -z "${value}" ]; then + printf '%s\n' "${DEFAULT}" +else + printf '%s\n' "${value}" +fi diff --git a/wrolpi/test/test_root_api.py b/wrolpi/test/test_root_api.py index 1f22c275..25b7ebdf 100644 --- a/wrolpi/test/test_root_api.py +++ b/wrolpi/test/test_root_api.py @@ -84,6 +84,26 @@ async def test_daily_download_limit_settings(test_session, async_client): assert response.status_code == HTTPStatus.BAD_REQUEST +@pytest.mark.asyncio +async def test_changing_zims_destination_restarts_kiwix(test_session, async_client): + """Changing zims_destination should activate the restart_kiwix switch so Kiwix serves the new + directory; an unrelated settings change should not.""" + from modules.zim import lib as zim_lib + + with mock.patch.object(zim_lib.restart_kiwix_handler, 'activate_switch') as mock_activate: + # An unrelated change does not restart Kiwix. + request, response = await async_client.patch('/api/settings', content=json.dumps({'log_level': 20})) + assert response.status_code == HTTPStatus.NO_CONTENT + mock_activate.assert_not_called() + + # Changing the Zim directory does. + data = {'zims_destination': 'my_zims'} + request, response = await async_client.patch('/api/settings', content=json.dumps(data)) + assert response.status_code == HTTPStatus.NO_CONTENT + assert get_wrolpi_config().zims_destination == 'my_zims' + mock_activate.assert_called_once() + + @pytest.mark.asyncio async def test_log_level(async_client): """Log level must be between 0 and 40."""