mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
feat: one page to manage the bot, and the tagger folded into it
./web.sh serves health, the configuration and the photo album on 127.0.0.1:8140 -- the port tool/tagger.py vacates. React 19, Vite 8, TypeScript 6.0.3, ESLint 10 with strictTypeChecked + stylisticTypeChecked + unicorn + sonarjs + perfectionist, Vitest 4 at 100% on all four metrics. No coverage exclude list, including for main.tsx. This repository treats a coverage omit as a suppression, and "the DOM bootstrap is awkward to assert" is the same sentence as "this code is untested" -- so the bootstrap is a shim thin enough to test in jsdom, and the album view holds its file list apart from the album so the update path stopped carrying a "what if it is null" branch that cannot happen. An untestable branch is an untested one. TypeScript stays at 6.0.3: typescript-eslint 8.68.0 declares typescript >=4.8.4 <6.1.0, and the fleet-wide allowlist entry says the compiler waits for the linter. The freshness gate then caught two pins the reference repo was carrying stale -- eslint-plugin-unicorn 73 -> 74 and a floating node-version: "24" in CI -- both bumped rather than allowlisted. Traps the gates found, all of them real: - .gitignore has a bare lib/, which matches at any depth, so web/src/lib/ would have been silently untracked. The source lives in src/api/. - CI's shellcheck glob was `run.sh scripts/*.sh`, so tag.sh was unlinted in CI for its whole life and web.sh would have inherited that. Now ./*.sh. - tsconfig.json is strict JSON, not JSONC: check-json is generic and tsconfig being commentable is a convention, not a requirement. - The personal-data gate refused an invented phone number in a test, which is exactly what it is for. The album's warning that names a command moved with the command, and every path that pointed at tool/tagger.py now points at ./web.sh. The historical mentions stay: they explain why the code is shaped as it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
This commit is contained in:
parent
0b539fc327
commit
a24d0d5905
4
.binary-allowlist
Normal file
4
.binary-allowlist
Normal file
@ -0,0 +1,4 @@
|
||||
# The management page's only non-text asset. An SVG so it stays reviewable
|
||||
# in a diff: the gate blocks the extension, not the content, and a PNG would
|
||||
# need a negation in .gitignore as well as an entry here.
|
||||
web/public/*.svg
|
||||
@ -74,7 +74,7 @@ SELFTEST_GROUPS=
|
||||
# Optional, and private. The album the per-person photo commands draw
|
||||
# from: a directory holding people.json, tags.json and media/. Everything
|
||||
# identifying lives in there and nothing about it is in this repository --
|
||||
# see DOCS-photo-pack.md, and tool/tagger.py for how it gets filled in.
|
||||
# see DOCS-photo-pack.md, and ./web.sh for how it gets filled in.
|
||||
#
|
||||
# Leave it empty and the bot looks in $STATE_DIRECTORY/pack (systemd) and
|
||||
# then $XDG_DATA_HOME/signal-bot/pack. Not finding one is normal: the
|
||||
|
||||
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
@ -54,7 +54,7 @@ jobs:
|
||||
- name: shellcheck
|
||||
# --severity=style is the floor CI enforces; the local pre-commit hook
|
||||
# uses the same flag so a version skew cannot pass locally and fail here.
|
||||
run: shellcheck -x --severity=style run.sh scripts/*.sh
|
||||
run: shellcheck -x --severity=style ./*.sh scripts/*.sh
|
||||
|
||||
- name: pytest (100% branch coverage)
|
||||
run: .venv/bin/python -m pytest -q
|
||||
@ -68,3 +68,24 @@ jobs:
|
||||
# for eight hours on 2026-08-30.
|
||||
- name: cold start
|
||||
run: ./scripts/cold_start.sh
|
||||
|
||||
# The management page. Its own job rather than more steps on `check`: it
|
||||
# needs a Node toolchain that the Python job has no use for, and the two
|
||||
# halves fail for unrelated reasons.
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.20.0"
|
||||
- run: corepack enable
|
||||
- name: install (exact pins, from the lockfile)
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: tsc --noEmit and eslint
|
||||
run: pnpm lint
|
||||
- name: vitest (100% coverage, no excludes)
|
||||
run: pnpm coverage
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -194,3 +194,9 @@ adds.json
|
||||
|
||||
# Wine/Bottles scratch dir, never part of the bot.
|
||||
bottles/
|
||||
|
||||
# The management page's build inputs and reports. dist/ is already covered by
|
||||
# the bare entry above, which matches at any depth.
|
||||
node_modules/
|
||||
web/coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
@ -104,7 +104,7 @@ answers that directly. Do not collapse the two jobs back together.
|
||||
still reporting one has a metadata track the re-mux should have removed, and
|
||||
refusing it is the correct outcome.
|
||||
|
||||
**Both entry points obey this.** `tool/tagger.py --import` used to be a
|
||||
**Both entry points obey this.** `./web.sh --import` used to be a
|
||||
verbatim `shutil.copy2`; since 2026-08-31 it runs the same stripper, so the
|
||||
guarantee on this page covers the whole album and not just the chat path.
|
||||
|
||||
|
||||
@ -175,13 +175,13 @@ and retrievable immediately. That path has its own document, because the
|
||||
metadata handling and the privacy invariants it has to keep are longer than
|
||||
the album format itself: **`DOCS-add-photo.md`**.
|
||||
|
||||
## Filling it in: tool/tagger.py
|
||||
## Filling it in: ./web.sh
|
||||
|
||||
```sh
|
||||
./tag.sh --import ~/photos # dedup, strip, import, then tag
|
||||
./tag.sh --import ~/photos --dry-run # the report only
|
||||
./tag.sh --untagged # only items with no tags yet
|
||||
./tag.sh # carry on where you left off
|
||||
./web.sh --import ~/photos # dedup, strip, import, then tag
|
||||
./web.sh --import ~/photos --dry-run # the report only
|
||||
./web.sh --untagged # only items with no tags yet
|
||||
./web.sh # carry on where you left off
|
||||
```
|
||||
|
||||
`tag.sh` sets up `.venv` on first use, so this works on a fresh clone with
|
||||
|
||||
152
DOCS-web-ui.md
Normal file
152
DOCS-web-ui.md
Normal file
@ -0,0 +1,152 @@
|
||||
# The management page
|
||||
|
||||
`./web.sh` serves one page on `127.0.0.1:8140`: whether the bot is alive, what
|
||||
it is configured with, and the photo album's tagger. It replaces
|
||||
`tool/tagger.py`, which served the album half of this on the same port.
|
||||
|
||||
```sh
|
||||
./web.sh # the configured pack
|
||||
./web.sh --dir ~/pack --port 8140
|
||||
./web.sh --import ~/photos # dedup, strip and import, then serve
|
||||
./web.sh --import ~/photos --dry-run # the report, changing nothing
|
||||
./web.sh --untagged # only items with no tags yet
|
||||
```
|
||||
|
||||
## Why it is started by hand
|
||||
|
||||
A systemd unit would be more convenient and is the wrong shape. This page can
|
||||
rewrite `.env` — the file that decides which groups the bot answers in and who
|
||||
may add it to one — and a unit would keep that writable on loopback all day
|
||||
for the sake of a bookmark. Started by hand, the window is the few minutes
|
||||
somebody is actually looking at it.
|
||||
|
||||
It is also a **second process**, not more routes on the bot. The bot's own
|
||||
port already serves `POST /_selftest`, which posts for real into a real group;
|
||||
putting a configuration writer beside it would put both behind one mistake.
|
||||
|
||||
## Why loopback is not the whole defence
|
||||
|
||||
Binding to `127.0.0.1` stops the network. It does not stop the browser already
|
||||
running on this machine, and two attacks reach a loopback port from an
|
||||
ordinary web page. `signal_bot/manage/guard.py` closes both, in about forty
|
||||
lines, with nothing to configure and no session to keep:
|
||||
|
||||
- **Cross-origin form posts.** Any site can POST a `<form>` at
|
||||
`127.0.0.1:8140` without reading the reply. A form can only send
|
||||
`application/x-www-form-urlencoded`, `multipart/form-data` or `text/plain`,
|
||||
so every mutating request must declare `application/json` — which a form
|
||||
cannot. Anything else is **415**.
|
||||
- **DNS rebinding.** A hostname the attacker controls is re-pointed at
|
||||
`127.0.0.1` after the page loads, which makes the browser treat this app as
|
||||
same-origin. The rebound request still carries the attacker's hostname, so
|
||||
a `Host` that is not loopback is **421**.
|
||||
|
||||
Neither is authentication, and neither pretends to be. They are the two
|
||||
cheapest things that make "only this machine" mean it.
|
||||
|
||||
## Editing .env
|
||||
|
||||
Three refusals, in this order, and the order is the point.
|
||||
|
||||
1. **A key that may not be written is refused before anything is parsed**, so
|
||||
a read-only key cannot be smuggled past by making the rest of the file
|
||||
invalid. `403`, naming the key.
|
||||
2. **The whole candidate file is validated by the function startup uses** —
|
||||
`load_config(candidate, use_dotenv=False)`. An edit this bot would refuse
|
||||
at boot is refused at the form instead, with the journal's own sentence.
|
||||
`400`.
|
||||
3. **Only then is anything written.** A rejected edit leaves `.env`
|
||||
byte-identical.
|
||||
|
||||
The write itself is line-by-line: only the `KEY=` line of a key that actually
|
||||
changed is replaced, so the paragraphs explaining each setting survive an edit
|
||||
made in a browser. A key the file never had is appended under a marker rather
|
||||
than dropped among comments that explain something else. Temp-then-replace
|
||||
with mode `0600` reapplied before the swap, because this file holds the
|
||||
account's number and is edited from a tab that can be closed mid-save.
|
||||
|
||||
### What is read-only, and why
|
||||
|
||||
| Key | Why the page will not write it |
|
||||
|---|---|
|
||||
| `PHONE_NUMBER` | Identifies the account; the container holds the matching registration. |
|
||||
| `API_BASE_URL` | Repointing it sends every outgoing message and attachment fetch elsewhere at the next restart. Strictly worse than changing the number. |
|
||||
| `DATABASE_PATH` | Repointing it silently orphans the whole statistical history. |
|
||||
| `PHOTO_PACK_DIR` | **Cannot be validated.** `_photo_pack_dirs()` reads `os.environ` directly rather than the mapping it is handed — deliberately, since that is what keeps the test suite away from a real album. |
|
||||
| `BIND_HOST` / `BIND_PORT` | Moving the bot's port from a page that proxies to it. |
|
||||
|
||||
### Group settings need a group listing
|
||||
|
||||
`GROUP_IDS` and `SELFTEST_GROUPS` are editable, but only while `/api/groups`
|
||||
has actually returned something. `parse_group_ids` checks *syntax*; startup
|
||||
additionally requires each listed group to be one the bot really joined,
|
||||
judged against a listing that was really read. So with the container down, a
|
||||
syntactically valid write can name a group the next startup rejects — turning
|
||||
a scoping mistake into a unit that will not start. The page says so and leaves
|
||||
the other keys editable.
|
||||
|
||||
Group ids are **picked, never typed**, for the same reason the tagger picked
|
||||
them: an id is 44 characters of base64.
|
||||
|
||||
> `SELFTEST_GROUPS` is editable and worth understanding before you widen it.
|
||||
> It is what keeps `POST /_selftest` fail-closed; every group named in it is a
|
||||
> group the bot can be made to post arbitrary text into.
|
||||
|
||||
## Routes
|
||||
|
||||
Everything under `/api`. The page itself is served from `web/dist` at every
|
||||
other path, so a reload on a sub-view works.
|
||||
|
||||
| Method | Path | What it does |
|
||||
|---|---|---|
|
||||
| GET | `/api/status` | Proxies the bot's `/healthz`. Unreachable is a state, never an error. |
|
||||
| GET | `/api/groups` | The live listing, or `[]` when the container is down. |
|
||||
| GET | `/api/config` | The resolved config. The number is cut to its last four digits. |
|
||||
| GET / PUT | `/api/env` | Read and write `.env`, per the rules above. |
|
||||
| POST | `/api/selftest` | Proxied to the bot verbatim, including its 403. |
|
||||
| POST | `/api/restart-container` | `docker restart`, through `signal_bot/external.py`. |
|
||||
| GET | `/api/album/state` | The album, the roster and the groups to pick from. |
|
||||
| POST | `/api/album/tag` | Record who is in one file. |
|
||||
| GET | `/api/album/summary` | What each command would answer with. |
|
||||
| POST | `/api/album/setup` | Write the roster, then prove the bot could load it. |
|
||||
| POST | `/api/album/person` | Add one person mid-session. |
|
||||
| GET | `/api/album/media/{index}` | One item, **by position**. |
|
||||
|
||||
That last one is not a style choice. A filename supplied by a client is a
|
||||
path, and this directory holds private photographs; the route takes an `int`,
|
||||
so FastAPI refuses a name with **422** before any handler code runs.
|
||||
|
||||
## Building it
|
||||
|
||||
`dist/` is a build output and this repository does not commit those.
|
||||
`scripts/install_service.sh` builds it, and verifies the artefact rather than
|
||||
trusting the exit code. Until it has been built the page answers **503** with
|
||||
the command to run, because `StaticFiles` raises at construction on a missing
|
||||
directory — mounting it unconditionally would take the app down on a fresh
|
||||
clone, and in CI, where pytest never runs `pnpm build`.
|
||||
|
||||
```sh
|
||||
pnpm --dir web install --frozen-lockfile
|
||||
pnpm --dir web build
|
||||
pnpm --dir web lint # tsc --noEmit + eslint strictTypeChecked
|
||||
pnpm --dir web coverage # vitest, 100% with no exclude list
|
||||
```
|
||||
|
||||
React 19, Vite 8, TypeScript **6.0.3**, ESLint 10 with `strictTypeChecked` +
|
||||
`stylisticTypeChecked` + unicorn + sonarjs + perfectionist, Vitest 4 at 100%
|
||||
on all four metrics. TypeScript stays on 6.0.3 because
|
||||
`typescript-eslint@8.68.0` declares `typescript: ">=4.8.4 <6.1.0"`; the pin is
|
||||
fleet-wide in `~/utils/dependency-freshness.allowlist.yaml` and clears itself
|
||||
when the linter catches up. The compiler waits for the linter.
|
||||
|
||||
`web/tsconfig.json` is strict JSON rather than the usual JSONC: this
|
||||
repository's `check-json` hook is generic and tsconfig being commentable is a
|
||||
convention, not a requirement. The two things the comments used to say: `lib`
|
||||
is `ESNext` while `target` stays `ES2023`, so iterator helpers are declared
|
||||
without being emitted for; and every strictness flag is on, including
|
||||
`noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`.
|
||||
|
||||
There is **no coverage exclude list**, including for `src/main.tsx`. A
|
||||
coverage omit is a suppression, and "the DOM bootstrap is awkward to assert"
|
||||
is the same sentence as "this code is untested" — so the bootstrap is a shim
|
||||
thin enough to test in jsdom instead.
|
||||
28
README.md
28
README.md
@ -170,18 +170,12 @@ its own number and no access to your chats at all.
|
||||
|
||||
### Why not Shelter / a second Signal app on the phone
|
||||
|
||||
Two independent reasons, both verified rather than assumed:
|
||||
|
||||
1. **A second app instance is not a second account.** Every Signal account
|
||||
needs its own phone number, so Shelter never removed the actual blocker.
|
||||
Signal's phone-number-free "Signal Login" is announced but not shipped.
|
||||
2. **It cannot be installed anyway** on a phone with a Device Owner. Setting a
|
||||
Device Owner makes Android enable `no_add_managed_profile`,
|
||||
`no_add_private_profile` and `no_add_clone_profile` on every user — killing
|
||||
Shelter, Island, Private Space and app cloning alike. Verified on-device:
|
||||
`pm create-user --profileOf 0 --managed` returns
|
||||
`Cannot add user: no_add_managed_profile is enabled`. This is the Android 11
|
||||
COMP deprecation and is unchanged through Android 16.
|
||||
Two reasons, both verified rather than assumed. **A second app instance is not
|
||||
a second account** — every Signal account needs its own phone number, so
|
||||
Shelter never removed the actual blocker. And **it cannot be installed anyway**
|
||||
on a phone with a Device Owner: Android then enables `no_add_managed_profile`
|
||||
and its two siblings on every user, killing Shelter, Island, Private Space and
|
||||
cloning alike. Unchanged through Android 16.
|
||||
|
||||
## When it cannot hear, and who may add it
|
||||
|
||||
@ -231,10 +225,16 @@ at 0, up to three highlights. Sundays close a week, month ends a month. Ask it
|
||||
things: `!staty` `!wczoraj` `!tydzień` `!miesiąc` `!rok` `!rekordy` `!korona`
|
||||
`!dni` `!ja`. Full behaviour: `DOCS-statistics.md`.
|
||||
|
||||
## Managing it
|
||||
|
||||
`./web.sh` serves one page on `127.0.0.1:8140`: health, the configuration
|
||||
(editable) and the album's tagger. Started by hand and stopped again, because
|
||||
it can rewrite `.env`: [DOCS-web-ui.md](DOCS-web-ui.md).
|
||||
|
||||
## Develop
|
||||
|
||||
```sh
|
||||
pytest # 708 tests, 100% branch coverage, no network
|
||||
pytest # 100% branch coverage, no network
|
||||
pre-commit run --all-files # ruff ALL + mypy strict + 250-line cap + gates
|
||||
./scripts/e2e.sh # the real bot against the fake server
|
||||
```
|
||||
@ -243,7 +243,7 @@ No `# noqa`, no `# type: ignore`, no coverage omits. Never bump `SCHEMA_VERSION`
|
||||
read `signal_bot/schema.py` first; a bump DROPs every table and throws the live tallies away, while
|
||||
`CREATE TABLE IF NOT EXISTS` is additive and safe. Wire format: [DOCS-signal-envelopes.md](DOCS-signal-envelopes.md).
|
||||
Releases are named after the popes, in order and in Latin — 0.1.4 is «Eleutherius» — and a bump publishes to the group as the pope's portrait, name and article: [DOCS-changelog.md](DOCS-changelog.md). Latency: [DOCS-latency.md](DOCS-latency.md).
|
||||
Signal never delivers an account its own sends: `scripts/selftest.sh testbot '!papież' 3` drives the live dispatcher instead, [DOCS-selftest.md](DOCS-selftest.md).
|
||||
Signal never delivers an account its own sends: `scripts/selftest.sh testbot '!papież' 3` drives the live dispatcher instead, [DOCS-selftest.md](DOCS-selftest.md). The page has its own gates: `pnpm --dir web lint` and `pnpm --dir web coverage`, both at 100% with no excludes.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@ -87,10 +87,30 @@ ensure_ffmpeg() {
|
||||
fi
|
||||
}
|
||||
|
||||
# The management page is built, not committed: dist/ is a build output and
|
||||
# this repository does not carry those. Verified by looking at the artefact
|
||||
# rather than trusting the exit code, the same way exiftool is.
|
||||
build_web() {
|
||||
local web="${REPO_ROOT}/web"
|
||||
if ! command -v pnpm >/dev/null; then
|
||||
echo "Installing pnpm (the management page is built with it)..."
|
||||
if ! sudo pacman -S --needed --noconfirm pnpm; then
|
||||
die "could not install pnpm; run: sudo pacman -S pnpm"
|
||||
fi
|
||||
fi
|
||||
command -v node >/dev/null || die "node not found; the page needs node >= 24"
|
||||
echo "Building the management page..."
|
||||
(cd "$web" && pnpm install --frozen-lockfile && pnpm build) ||
|
||||
die "pnpm build failed in ${web}"
|
||||
[[ -f "${web}/dist/index.html" ]] ||
|
||||
die "pnpm build reported success but ${web}/dist/index.html is not there"
|
||||
}
|
||||
|
||||
validate_requirements() {
|
||||
command -v systemctl >/dev/null || die "systemctl not found; this needs systemd"
|
||||
ensure_exiftool
|
||||
ensure_ffmpeg
|
||||
build_web
|
||||
[[ -f "${REPO_ROOT}/.env" ]] ||
|
||||
die "no ${REPO_ROOT}/.env -- run scripts/register_bot.sh first"
|
||||
[[ -x "${REPO_ROOT}/run.sh" ]] || die "no executable ${REPO_ROOT}/run.sh"
|
||||
|
||||
@ -8,7 +8,7 @@ people and custom tags in different files is what makes that guarantee cheap:
|
||||
``people.json`` is edited by the tagger and defines commands, ``custom.json``
|
||||
is appended to from the chat and defines no commands at all.
|
||||
|
||||
Written atomically, temp-then-replace, exactly as ``tool/tagger.py::Album.write``
|
||||
Written atomically, temp-then-replace, exactly as ``pack_album.Album.write``
|
||||
already does, because the alternative is a half-written manifest that fails the
|
||||
next load and takes startup down.
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"""The one mutable box the pack lives in, so a new photo is visible at once.
|
||||
|
||||
``pack_command.pack_handler`` used to close over a frozen ``Pack``. That was
|
||||
right while the only writer was ``tool/tagger.py`` and a restart followed
|
||||
right while the only writer was the tagging page and a restart followed
|
||||
every change; with ``!add_photo`` it means a photo somebody just added stays
|
||||
invisible to their own command until the unit is restarted. Every pack
|
||||
handler therefore reads ``holder.pack`` at call time instead of capturing it.
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Importing a folder of photos into the album without importing it twice.
|
||||
|
||||
``tool/tagger.py --import`` used to be a filename check and ``shutil.copy2``.
|
||||
``./web.sh --import`` used to be a filename check and ``shutil.copy2``.
|
||||
Pointed at a phone export that overlaps what is already tagged, that copies
|
||||
the same photo in again under whatever name the exporter gave it that time,
|
||||
and copies the metadata in with it. Both are fixed here:
|
||||
|
||||
@ -225,7 +225,7 @@ def _tags(directory: Path, people: set[str]) -> dict[str, frozenset[str]]:
|
||||
"""
|
||||
path = directory / TAGS_FILE
|
||||
if not path.exists():
|
||||
logger.warning("photo pack %s has no %s yet; run tool/tagger.py", directory, TAGS_FILE)
|
||||
logger.warning("photo pack %s has no %s yet; run ./web.sh", directory, TAGS_FILE)
|
||||
return {}
|
||||
document = _document(path)
|
||||
tags: dict[str, frozenset[str]] = {}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"""Putting a file into the album, and taking it back out when the swap fails.
|
||||
|
||||
Every write here is temp-then-``Path.replace``, the pattern
|
||||
``tool/tagger.py::Album.write`` already uses, because a manifest that is half
|
||||
``pack_album.Album.write`` already uses, because a manifest that is half
|
||||
written is a manifest that fails the next load -- and with ``Restart=always``
|
||||
a startup-time ConfigError is a loop, not an error message.
|
||||
|
||||
|
||||
@ -151,10 +151,11 @@ def test_one_entry_may_spell_itself_two_ways_that_fold_the_same(tmp_path: Path)
|
||||
|
||||
|
||||
def test_an_untagged_pack_loads_and_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Not an error: that is an album waiting for the tagger, not a broken one."""
|
||||
"""Not an error: that is an album waiting to be tagged, not a broken one."""
|
||||
pack = _load(tmp_path, write_tags=False)
|
||||
assert pack.tags == {}
|
||||
assert "tagger" in caplog.text
|
||||
# The warning names the command to run, so it has to move with it.
|
||||
assert "./web.sh" in caplog.text
|
||||
|
||||
|
||||
def test_a_tag_naming_a_file_that_is_gone_is_refused(tmp_path: Path) -> None:
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Every script under tool/ imports, and the tagger answers --help.
|
||||
"""Every script under tool/ imports, and the launcher answers --help.
|
||||
|
||||
This exists because ``tool/tagger.py`` shipped an unconditional ImportError
|
||||
and survived two releases with it: commit 48957a1 renamed
|
||||
@ -66,11 +66,11 @@ def test_the_module_imports(path: Path) -> None:
|
||||
sys.modules.pop(spec.name, None)
|
||||
|
||||
|
||||
def test_the_tagger_answers_help() -> None:
|
||||
def test_the_launcher_answers_help() -> None:
|
||||
# Through signal_bot.external, like every other subprocess here: S603 is
|
||||
# suppressed for exactly one file and widening it is what that entry
|
||||
# exists to prevent.
|
||||
result = run([sys.executable, str(TOOL / "tagger.py"), "--help"])
|
||||
result = run([sys.executable, str(TOOL / "web.py"), "--help"])
|
||||
assert result.ok, result.stderr
|
||||
for flag in ("--import", "--untagged", "--dry-run"):
|
||||
assert flag in result.stdout
|
||||
|
||||
327
tool/tagger.html
327
tool/tagger.html
@ -1,327 +0,0 @@
|
||||
<!doctype html>
|
||||
<!-- Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
The tagger's whole interface. Served only on 127.0.0.1, by tool/tagger.py.
|
||||
No framework and no network calls but its own: this page shows private
|
||||
photos, and the only thing it may talk to is the process that opened it. -->
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Tagowanie zdjęć</title>
|
||||
<style>
|
||||
:root { --bg: #1b1d21; --fg: #f2f3f5; --dim: #9aa0a6; --accent: #7aa2f7; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--fg);
|
||||
font: 16px/1.5 system-ui, sans-serif; }
|
||||
main { max-width: 900px; margin: 0 auto; padding: 24px; }
|
||||
h1 { font-size: 20px; margin: 0 0 16px; }
|
||||
.stage { display: flex; align-items: center; justify-content: center;
|
||||
height: 62vh; background: #101214; border-radius: 12px; }
|
||||
.stage img, .stage video { max-width: 100%; max-height: 62vh; border-radius: 12px; }
|
||||
.people { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }
|
||||
.person { border: 1px solid #33363d; border-radius: 999px; padding: 8px 14px;
|
||||
cursor: pointer; user-select: none; background: #24262b; }
|
||||
.person.on { background: var(--accent); color: #10131a; border-color: var(--accent); }
|
||||
.bar { display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 16px; color: var(--dim); }
|
||||
button { background: #24262b; color: var(--fg); border: 1px solid #33363d;
|
||||
border-radius: 8px; padding: 8px 14px; cursor: pointer; }
|
||||
textarea { width: 100%; min-height: 140px; background: #101214; color: var(--fg);
|
||||
border: 1px solid #33363d; border-radius: 8px; padding: 12px;
|
||||
font-family: ui-monospace, monospace; }
|
||||
.hint { color: var(--dim); font-size: 14px; }
|
||||
.addperson { display: flex; gap: 8px; align-items: center; margin: 4px 0 16px; }
|
||||
.addperson input { background: #10131a; color: inherit; border: 1px solid #33363d;
|
||||
border-radius: 8px; padding: 8px 12px; font: inherit; }
|
||||
.err { color: #f7768e; }
|
||||
label.group { display: block; padding: 4px 0; }
|
||||
pre.sample { background: #101214; border: 1px solid #33363d; border-radius: 8px;
|
||||
padding: 12px; overflow-x: auto; }
|
||||
.dim { color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div id="setup" hidden>
|
||||
<h1>Kto jest w tej paczce?</h1>
|
||||
<p class="hint">
|
||||
<b>Jedna osoba na linię</b> — Enter po każdej. Bez wykrzyknika.
|
||||
Przecinek to <b>inna nazwa tej samej osoby</b>, nie następna osoba.
|
||||
</p>
|
||||
<pre class="hint sample">ala
|
||||
basia
|
||||
celina, cela <span class="dim">← jedna osoba, dwie nazwy</span>
|
||||
darek
|
||||
oboje = ala + basia <span class="dim">← zdjęcie, na którym są obie</span>
|
||||
ktokolwiek = ala | basia <span class="dim">← losowo jedna z nich</span></pre>
|
||||
<textarea id="names" spellcheck="false"></textarea>
|
||||
<h1>W której grupie działają?</h1>
|
||||
<div id="groups" class="hint">szukam grup…</div>
|
||||
<p><button id="save">Zapisz i zacznij tagowanie</button></p>
|
||||
<p id="error" class="err"></p>
|
||||
</div>
|
||||
|
||||
<div id="done" hidden>
|
||||
<h1>Gotowe.</h1>
|
||||
<p class="hint">Wszystko zapisane. Komendy i ile mają zdjęć:</p>
|
||||
<pre class="hint sample" id="counts"></pre>
|
||||
<p><button id="again">← wróć do zdjęć</button></p>
|
||||
</div>
|
||||
|
||||
<div id="tagger" hidden>
|
||||
<div class="bar">
|
||||
<h1 id="progress"></h1>
|
||||
<span class="hint">1–9 (0 = dziesiąta) = osoba, → dalej, ← wstecz</span>
|
||||
</div>
|
||||
<div class="stage" id="stage"></div>
|
||||
<div class="people" id="people"></div>
|
||||
<div class="addperson">
|
||||
<input id="newperson" placeholder="nowa osoba: imie, ksywka" autocomplete="off">
|
||||
<button id="addperson">dodaj osobę</button>
|
||||
<span id="addederror" class="hint"></span>
|
||||
</div>
|
||||
<div class="bar">
|
||||
<button id="prev">← wstecz</button>
|
||||
<span id="saved" class="hint"></span>
|
||||
<button id="next">dalej →</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const api = (path, body) => fetch(path, body ? {
|
||||
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body),
|
||||
} : undefined).then(async (r) => {
|
||||
if (!r.ok) { throw new Error((await r.json()).detail || r.statusText); }
|
||||
return r.json();
|
||||
});
|
||||
|
||||
let state = null;
|
||||
let at = 0;
|
||||
|
||||
function parseSetup(text) {
|
||||
const people = [], combos = [];
|
||||
const byName = new Map();
|
||||
for (const raw of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
|
||||
if (raw.includes("=")) { continue; }
|
||||
const triggers = raw.split(",")
|
||||
.map((t) => "!" + t.trim().replace(/^!/, ""))
|
||||
.filter((t) => t.length > 1);
|
||||
if (!triggers.length) { continue; }
|
||||
// Reuse the key this person already has. Handing out p{length+1} on
|
||||
// every save renumbers everyone after an insertion, and every tags.json
|
||||
// entry then points at the wrong person, silently.
|
||||
const existing = (state.people || [])
|
||||
.find((q) => q.triggers.some((t) => triggers.includes(t)));
|
||||
const k = existing ? existing.key : `p${nextKey(people)}`;
|
||||
people.push({key: k, triggers});
|
||||
// Every alias, not just the first: a combination may name a person by
|
||||
// whichever spelling its author remembers.
|
||||
for (const trigger of triggers) { byName.set(trigger.slice(1), k); }
|
||||
}
|
||||
let n = 1;
|
||||
for (const raw of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
|
||||
if (!raw.includes("=")) { continue; }
|
||||
const [left, right] = raw.split("=");
|
||||
const every = right.includes("+");
|
||||
const parts = right.split(every ? "+" : "|").map((p) => p.trim().replace(/^!/, ""));
|
||||
const unknown = parts.filter((p) => !byName.has(p));
|
||||
if (unknown.length) { throw new Error(`nie znam: ${unknown.join(", ")}`); }
|
||||
const keys = parts.map((p) => byName.get(p));
|
||||
const triggers = left.split(",")
|
||||
.map((t) => "!" + t.trim().replace(/^!/, ""))
|
||||
.filter((t) => t.length > 1);
|
||||
combos.push(every
|
||||
? {key: `c${n}`, triggers, all_of: keys}
|
||||
: {key: `c${n}`, triggers, any_of: keys});
|
||||
n += 1;
|
||||
}
|
||||
return {people, combos};
|
||||
}
|
||||
|
||||
// One past the highest number in use, never the count: deleting p3 by hand
|
||||
// must not let a later add reuse the key and inherit its photos. Mirrors
|
||||
// pack_people.next_key on the server, which is the authority.
|
||||
function nextKey(pending) {
|
||||
const used = [...(state.people || []), ...pending]
|
||||
.map((p) => Number(String(p.key || "").replace(/^p/, "")))
|
||||
.filter((n) => Number.isInteger(n) && n > 0);
|
||||
return Math.max(0, ...used) + 1;
|
||||
}
|
||||
|
||||
function drawGroups(known, chosen) {
|
||||
const box = document.getElementById("groups");
|
||||
if (!known.length) {
|
||||
box.innerHTML = '<input id="rawgroup" style="width:100%" placeholder="group.…=">';
|
||||
return;
|
||||
}
|
||||
// Nothing is pre-selected. The first group in the listing is whichever
|
||||
// one the account happens to return first -- checking it by default is a
|
||||
// one-click way to scope a private album to the wrong chat.
|
||||
box.innerHTML = known.map((g) => `<label class="group">
|
||||
<input type="radio" name="group" value="${g.id}" ${chosen.includes(g.id) ? "checked" : ""}>
|
||||
${g.name || "(bez nazwy)"} <span class="hint">${g.id.slice(0, 18)}…</span></label>`).join("");
|
||||
}
|
||||
|
||||
function chosenGroups() {
|
||||
const raw = document.getElementById("rawgroup");
|
||||
if (raw) { return [raw.value.trim()]; }
|
||||
const picked = document.querySelector('input[name="group"]:checked');
|
||||
return picked ? [picked.value] : [];
|
||||
}
|
||||
|
||||
function entries() {
|
||||
return [...state.people];
|
||||
}
|
||||
|
||||
// One expression, used by both the chip label and the key handler. Those
|
||||
// used to be two independent expressions -- "i === 9 ? 0 : i + 1" here and
|
||||
// "key === '0' ? 9 : Number(key) - 1" in the handler -- encoding one mapping
|
||||
// twice. They agreed, and would not have survived an edit.
|
||||
const HOTKEYS = [..."1234567890", ..."qwertyuiop"];
|
||||
|
||||
// .mp4 alone left .mov/.m4v/.webm rendering as a broken <img>.
|
||||
const VIDEO = [".mp4", ".mov", ".m4v", ".webm"];
|
||||
|
||||
function isVideo(name) {
|
||||
const lower = name.toLowerCase();
|
||||
return VIDEO.some((suffix) => lower.endsWith(suffix));
|
||||
}
|
||||
|
||||
// Which items this session presents. The server never filters: /media/{index}
|
||||
// and /api/tag both index the full list, so a shorter list here would show
|
||||
// item i of the album while tagging item i of the filtered set.
|
||||
// Anything seen this session stays navigable even once it has tags. Without
|
||||
// this, tagging an item removes it from the set and the left arrow jumps
|
||||
// straight over your own work -- so a mis-tag is unreachable and the only way
|
||||
// back is to restart without --untagged.
|
||||
const visited = new Set();
|
||||
|
||||
function shown(index) {
|
||||
return !state.untagged_only || !state.files[index].keys.length || visited.has(index);
|
||||
}
|
||||
|
||||
function step(from, direction) {
|
||||
for (let i = from + direction; i >= 0 && i < state.files.length; i += direction) {
|
||||
if (shown(i)) { return i; }
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
visited.add(at);
|
||||
const item = state.files[at];
|
||||
document.getElementById("progress").textContent =
|
||||
`${at + 1} / ${state.files.length} — ${item.name}`;
|
||||
const stage = document.getElementById("stage");
|
||||
stage.innerHTML = isVideo(item.name)
|
||||
? `<video src="/media/${at}" controls autoplay muted loop></video>`
|
||||
: `<img src="/media/${at}" alt="">`;
|
||||
document.getElementById("people").innerHTML = entries().map((p, i) => `
|
||||
<div class="person ${item.keys.includes(p.key) ? "on" : ""}" data-key="${p.key}">
|
||||
${HOTKEYS[i] ? HOTKEYS[i] + ". " : ""}${p.triggers[0].slice(1)}
|
||||
</div>`).join("");
|
||||
for (const node of document.querySelectorAll(".person")) {
|
||||
node.onclick = () => toggle(node.dataset.key);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(k) {
|
||||
const item = state.files[at];
|
||||
item.keys = item.keys.includes(k) ? item.keys.filter((x) => x !== k) : [...item.keys, k];
|
||||
draw();
|
||||
await api("/api/tag", {file: item.name, keys: item.keys});
|
||||
document.getElementById("saved").textContent = "zapisano";
|
||||
setTimeout(() => { document.getElementById("saved").textContent = ""; }, 800);
|
||||
}
|
||||
|
||||
async function move(direction) {
|
||||
const next = step(at, direction);
|
||||
if (next < 0) {
|
||||
if (direction < 0) { return; }
|
||||
// The end of the album is a screen, not a button that stops working.
|
||||
await finish();
|
||||
return;
|
||||
}
|
||||
at = next;
|
||||
draw();
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
const {lines} = await api("/api/summary");
|
||||
document.getElementById("counts").textContent = lines.join("\n");
|
||||
document.getElementById("tagger").hidden = true;
|
||||
document.getElementById("done").hidden = false;
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (document.getElementById("tagger").hidden) { return; }
|
||||
if (event.key === "ArrowRight" || event.key === "Enter") { move(1); }
|
||||
else if (event.key === "ArrowLeft") { move(-1); }
|
||||
else if (event.target.tagName !== "INPUT") {
|
||||
const index = HOTKEYS.indexOf(event.key);
|
||||
const person = index < 0 ? null : entries()[index];
|
||||
if (person) { toggle(person.key); }
|
||||
}
|
||||
});
|
||||
|
||||
async function start() {
|
||||
state = await api("/api/state");
|
||||
if (state.configured) {
|
||||
document.getElementById("tagger").hidden = false;
|
||||
at = Math.max(0, state.files.findIndex((f) => !f.keys.length));
|
||||
if (!shown(at)) {
|
||||
const first = step(-1, 1);
|
||||
if (first < 0) { await finish(); return; }
|
||||
at = first;
|
||||
}
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
document.getElementById("setup").hidden = false;
|
||||
drawGroups(state.known_groups, state.groups);
|
||||
document.getElementById("save").onclick = async () => {
|
||||
try {
|
||||
const {people, combos} = parseSetup(document.getElementById("names").value);
|
||||
if (!people.length) { throw new Error("dodaj przynajmniej jedną osobę"); }
|
||||
if (!chosenGroups().filter(Boolean).length) { throw new Error("wybierz grupę"); }
|
||||
await api("/api/setup", {groups: chosenGroups(), people, combos});
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
document.getElementById("error").textContent = error.message;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function addPerson() {
|
||||
const field = document.getElementById("newperson");
|
||||
const error = document.getElementById("addederror");
|
||||
error.textContent = "";
|
||||
const triggers = field.value.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
if (!triggers.length) { return; }
|
||||
try {
|
||||
await api("/api/person", {triggers});
|
||||
// Re-read rather than patch in place: the server assigns the key, and
|
||||
// guessing it here is how the two ends drift apart.
|
||||
const fresh = await api("/api/state");
|
||||
state.people = fresh.people;
|
||||
field.value = "";
|
||||
draw();
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("addperson").onclick = addPerson;
|
||||
document.getElementById("newperson").addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") { event.stopPropagation(); addPerson(); }
|
||||
});
|
||||
document.getElementById("prev").onclick = () => move(-1);
|
||||
document.getElementById("next").onclick = () => move(1);
|
||||
document.getElementById("again").onclick = () => {
|
||||
document.getElementById("done").hidden = true;
|
||||
document.getElementById("tagger").hidden = false;
|
||||
draw();
|
||||
};
|
||||
start();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
201
tool/tagger.py
201
tool/tagger.py
@ -1,201 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Tag a private photo album, in a browser, on this machine only.
|
||||
|
||||
Sixty-odd photos and seven people is not a hand-edited JSON file. This serves
|
||||
one page on 127.0.0.1 that shows each item in turn with a numbered checkbox
|
||||
per person, saves on every keystroke, and never leaves the machine.
|
||||
|
||||
Names are typed *into the page*, never as arguments: an argument lands in the
|
||||
shell history, and the whole design of the pack is that the names exist in
|
||||
one directory and nowhere else.
|
||||
|
||||
Either entry point sets up ``.venv`` on first use -- the dependencies are not
|
||||
in the system interpreter, and a script that tells you to go and install them
|
||||
yourself is a script that does not own its dependencies.
|
||||
|
||||
python tool/tagger.py --untagged # bootstraps, then runs
|
||||
./tag.sh # the configured pack
|
||||
./tag.sh --dir ~/pack --port 8140
|
||||
./tag.sh --import ~/photos # dedup, strip and import, then tag
|
||||
./tag.sh --import ~/photos --dry-run # the report, changing nothing
|
||||
./tag.sh --untagged # only items with no tags yet
|
||||
|
||||
It prints a summary when it stops: how many items are untagged, and how many
|
||||
photos each command would have. A command with zero is dead on arrival, and
|
||||
this is where that shows up rather than in the group.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Importing this sets up .venv if the interpreter running us cannot import
|
||||
# what follows. It has to come first, and it is an import rather than a call
|
||||
# so that nothing here needs an E402 suppression. "python tool/tagger.py"
|
||||
# puts tool/ on sys.path, which is what makes the plain name resolve.
|
||||
from venv_bootstrap import REPO_ROOT
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
|
||||
from signal_bot.config_env import load_config
|
||||
from signal_bot.groups import ConfigError
|
||||
from signal_bot.media_strip import ExiftoolStripper
|
||||
from signal_bot.pack import MEDIA_DIR, default_dirs
|
||||
from signal_bot.pack_album import Album
|
||||
from signal_bot.pack_import import plan_import, run_import
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.pack_report import ImportReport
|
||||
|
||||
PAGE: Final = REPO_ROOT / "tool" / "tagger.html"
|
||||
DEFAULT_PORT: Final = 8140
|
||||
|
||||
|
||||
def live_groups() -> list[dict[str, str]]:
|
||||
"""The groups this account is in, so an id is picked and never typed."""
|
||||
try:
|
||||
config = load_config()
|
||||
response = httpx.get(config.groups_url, timeout=5.0)
|
||||
response.raise_for_status()
|
||||
listing = response.json()
|
||||
except ConfigError, httpx.HTTPError, ValueError:
|
||||
# No container, no .env, no network: none of them stop the tagging,
|
||||
# they only mean the group id has to be pasted instead of picked.
|
||||
return []
|
||||
return [
|
||||
{"id": str(entry.get("id", "")), "name": str(entry.get("name", ""))}
|
||||
for entry in listing
|
||||
if entry.get("id")
|
||||
]
|
||||
|
||||
|
||||
def roster_routes(app: FastAPI, album: Album) -> None:
|
||||
"""The two endpoints that write people.json.
|
||||
|
||||
Split from build_app to keep it under the complexity gate, along the
|
||||
seam that matters: everything here can refuse, and refusing has to reach
|
||||
the page rather than the next restart.
|
||||
"""
|
||||
|
||||
@app.post("/api/setup")
|
||||
async def setup(payload: dict[str, Any]) -> dict[str, str]:
|
||||
try:
|
||||
album.setup(
|
||||
list(payload.get("groups", [])),
|
||||
list(payload.get("people", [])),
|
||||
list(payload.get("combos", [])),
|
||||
)
|
||||
except ConfigError as error:
|
||||
# Straight into the page: a manifest the bot would refuse should
|
||||
# be fixed here, in the form, not found at the next restart.
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/api/person")
|
||||
async def person(payload: dict[str, Any]) -> dict[str, str]:
|
||||
# Mid-session, not only at setup: the reason to add somebody is
|
||||
# usually a photo of them already on the screen.
|
||||
try:
|
||||
key = album.add_person([str(t) for t in payload.get("triggers", [])])
|
||||
except ConfigError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
return {"status": "ok", "key": key}
|
||||
|
||||
|
||||
def build_app(album: Album) -> FastAPI:
|
||||
"""The one-page application, bound to one album."""
|
||||
app = FastAPI()
|
||||
roster_routes(app, album)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def page() -> str:
|
||||
return PAGE.read_text(encoding="utf-8")
|
||||
|
||||
@app.get("/api/state")
|
||||
async def state() -> dict[str, Any]:
|
||||
return {**album.state(), "known_groups": live_groups()}
|
||||
|
||||
@app.post("/api/tag")
|
||||
async def tag(payload: dict[str, Any]) -> dict[str, str]:
|
||||
try:
|
||||
album.tag(str(payload["file"]), [str(key) for key in payload.get("keys", [])])
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/api/summary")
|
||||
async def summary() -> dict[str, list[str]]:
|
||||
# The same lines the tagger prints when it stops. Shown in the page
|
||||
# too, because "I tagged everything, did it save?" is a question the
|
||||
# last photo used to answer by doing nothing at all.
|
||||
return {"lines": album.summary()}
|
||||
|
||||
@app.get("/media/{index}")
|
||||
async def media(index: int) -> FileResponse:
|
||||
# By index, never by a name the page sends back: a filename from a
|
||||
# client is a path, and portraits.path_for exists for the same reason.
|
||||
files = album.files
|
||||
if not 0 <= index < len(files):
|
||||
raise HTTPException(status_code=404, detail="no such item")
|
||||
return FileResponse(album.directory / MEDIA_DIR / files[index])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Serve the tagger until it is stopped, then print the summary."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument("--dir", type=Path, default=None, help="the pack directory")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
parser.add_argument(
|
||||
"--import", dest="source", type=Path, default=None, help="import media from here first"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="report what --import would do, change nothing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--untagged", action="store_true", help="present only items with no tags yet"
|
||||
)
|
||||
parser.add_argument("--no-open", action="store_true", help="do not open a browser")
|
||||
arguments = parser.parse_args()
|
||||
|
||||
directory = arguments.dir or default_dirs()[0][0]
|
||||
if arguments.source is not None:
|
||||
report = _import(arguments.source, directory, dry_run=arguments.dry_run)
|
||||
sys.stdout.write("\n".join(report.lines()) + "\n\n")
|
||||
if arguments.dry_run:
|
||||
# Nothing was written, so there is nothing to tag and no reason to
|
||||
# open a browser at a server this run is not going to start.
|
||||
return 0
|
||||
|
||||
album = Album(directory, untagged_only=arguments.untagged)
|
||||
sys.stdout.write(f"== tagging {len(album.files)} item(s) in {directory}\n")
|
||||
url = f"http://127.0.0.1:{arguments.port}/"
|
||||
sys.stdout.write(f"== open {url} -- ctrl-c here when you are done\n")
|
||||
if not arguments.no_open:
|
||||
webbrowser.open(url)
|
||||
uvicorn.run(build_app(album), host="127.0.0.1", port=arguments.port, log_level="warning")
|
||||
sys.stdout.write("\n".join(album.summary()) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _import(source: Path, directory: Path, *, dry_run: bool) -> ImportReport:
|
||||
"""Import a folder, or work out what importing it would do."""
|
||||
stripper = ExiftoolStripper()
|
||||
if dry_run:
|
||||
return plan_import(source, directory, stripper)
|
||||
return run_import(source, directory, stripper)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -2,13 +2,13 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Make a script work with whatever interpreter you started it with.
|
||||
|
||||
``python tool/tagger.py`` failed with ``ModuleNotFoundError: fastapi`` because
|
||||
``python tool/web.py`` failed with ``ModuleNotFoundError: fastapi`` because
|
||||
the dependencies live in ``.venv`` and nothing said so. So instead of reporting
|
||||
that, this fixes it: if the running interpreter cannot import what the script
|
||||
needs, it creates ``.venv`` if there is none, installs the project into it, and
|
||||
re-executes the same command line through it.
|
||||
|
||||
``tag.sh`` and ``run.sh`` get there through ``scripts/ensure_venv.sh``. This
|
||||
``web.sh`` and ``run.sh`` get there through ``scripts/ensure_venv.sh``. This
|
||||
module is the same job for the case where somebody runs the ``.py`` directly,
|
||||
which is what the file's own docstring used to tell them to do.
|
||||
|
||||
@ -108,7 +108,7 @@ def _venv_creator() -> list[str]:
|
||||
|
||||
|
||||
def _install() -> bool:
|
||||
"""Install the project editable, so a later git pull reaches the tagger."""
|
||||
"""Install the project editable, so a later git pull reaches the page."""
|
||||
uv = shutil.which("uv")
|
||||
target = f"{REPO_ROOT}[dev]"
|
||||
if uv is not None:
|
||||
|
||||
123
tool/web.py
Executable file
123
tool/web.py
Executable file
@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Manage the bot, in a browser, on this machine only.
|
||||
|
||||
Health, configuration and the photo album in one page. It replaces the
|
||||
tagger, which served the album half of this on the same port; everything it
|
||||
did is still here, and the routes it did it with now live in
|
||||
``signal_bot/manage/`` where the coverage gate can see them.
|
||||
|
||||
Started by hand and stopped again, deliberately. This page can rewrite
|
||||
``.env`` -- the file that decides which groups the bot answers in and who may
|
||||
add it to one -- and a unit would keep that writable on loopback all day for
|
||||
the sake of a bookmark.
|
||||
|
||||
./web.sh # the configured pack
|
||||
./web.sh --dir ~/pack --port 8140
|
||||
./web.sh --import ~/photos # dedup, strip and import, then serve
|
||||
./web.sh --import ~/photos --dry-run # the report, changing nothing
|
||||
./web.sh --untagged # only items with no tags yet
|
||||
|
||||
It prints the album summary when it stops: how many items are untagged, and
|
||||
how many photos each command would have. A command with zero is dead on
|
||||
arrival, and this is where that shows up rather than in the group.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Importing this sets up .venv if the interpreter running us cannot import
|
||||
# what follows. It has to come first, and it is an import rather than a call
|
||||
# so that nothing here needs an E402 suppression.
|
||||
from venv_bootstrap import REPO_ROOT
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import httpx
|
||||
import uvicorn
|
||||
|
||||
from signal_bot.config_env import load_config
|
||||
from signal_bot.manage.app import build_app
|
||||
from signal_bot.manage.deps import ManageDeps
|
||||
from signal_bot.media_strip import ExiftoolStripper
|
||||
from signal_bot.pack import default_dirs
|
||||
from signal_bot.pack_album import Album
|
||||
from signal_bot.pack_import import plan_import, run_import
|
||||
from signal_bot.portcheck import assert_port_free
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.pack_report import ImportReport
|
||||
|
||||
DEFAULT_PORT: Final = 8140
|
||||
DIST: Final = REPO_ROOT / "web" / "dist"
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
"""What the launcher was asked to do."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
parser.add_argument("--dir", type=Path, default=None, help="the pack directory")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
parser.add_argument(
|
||||
"--import", dest="source", type=Path, default=None, help="import media from here first"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="report what --import would do, change nothing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--untagged", action="store_true", help="present only items with no tags yet"
|
||||
)
|
||||
parser.add_argument("--no-open", action="store_true", help="do not open a browser")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Serve the management page until it is stopped, then print the summary."""
|
||||
arguments = _arguments()
|
||||
directory = arguments.dir or default_dirs()[0][0]
|
||||
if arguments.source is not None:
|
||||
report = _import(arguments.source, directory, dry_run=arguments.dry_run)
|
||||
sys.stdout.write("\n".join(report.lines()) + "\n\n")
|
||||
if arguments.dry_run:
|
||||
# Nothing was written, so there is nothing to serve and no reason to
|
||||
# open a browser at a server this run is not going to start.
|
||||
return 0
|
||||
|
||||
# Before the browser opens, not after: a stale instance on this port
|
||||
# should be a refusal naming the process, not a tab showing somebody
|
||||
# else's album. signal_bot/__main__.py does the same for the bot.
|
||||
assert_port_free("127.0.0.1", arguments.port)
|
||||
|
||||
album = Album(directory, untagged_only=arguments.untagged)
|
||||
deps = ManageDeps(
|
||||
config=load_config(),
|
||||
env_path=REPO_ROOT / ".env",
|
||||
dist_dir=DIST,
|
||||
album=album,
|
||||
client=httpx.AsyncClient(),
|
||||
port=arguments.port,
|
||||
)
|
||||
url = f"http://127.0.0.1:{arguments.port}/"
|
||||
sys.stdout.write(f"== {len(album.files)} item(s) in {directory}\n")
|
||||
sys.stdout.write(f"== open {url} -- ctrl-c here when you are done\n")
|
||||
if not arguments.no_open:
|
||||
webbrowser.open(url)
|
||||
uvicorn.run(build_app(deps), host="127.0.0.1", port=arguments.port, log_level="warning")
|
||||
sys.stdout.write("\n".join(album.summary()) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _import(source: Path, directory: Path, *, dry_run: bool) -> ImportReport:
|
||||
"""Import a folder, or work out what importing it would do."""
|
||||
stripper = ExiftoolStripper()
|
||||
if dry_run:
|
||||
return plan_import(source, directory, stripper)
|
||||
return run_import(source, directory, stripper)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -1,11 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Tag the photo album, in a browser.
|
||||
# Manage the bot, in a browser: health, configuration and the album.
|
||||
#
|
||||
# ./tag.sh --untagged # only items with no tags yet
|
||||
# ./tag.sh --import ~/photos --untagged # import that folder first
|
||||
# ./tag.sh --import ~/photos --dry-run # the report, changing nothing
|
||||
# ./web.sh --untagged # only items with no tags yet
|
||||
# ./web.sh --import ~/photos --untagged # import that folder first
|
||||
# ./web.sh --import ~/photos --dry-run # the report, changing nothing
|
||||
#
|
||||
# Sets up .venv on first run, so this works on a clone with nothing installed.
|
||||
# ============================================================================
|
||||
@ -20,7 +20,7 @@ source "${REPO_ROOT}/scripts/ensure_venv.sh"
|
||||
|
||||
main() {
|
||||
ensure_venv "$REPO_ROOT"
|
||||
exec "${REPO_ROOT}/.venv/bin/python" "${REPO_ROOT}/tool/tagger.py" "$@"
|
||||
exec "${REPO_ROOT}/.venv/bin/python" "${REPO_ROOT}/tool/web.py" "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
183
web/eslint.config.js
Normal file
183
web/eslint.config.js
Normal file
@ -0,0 +1,183 @@
|
||||
// Maximally aggressive, type-aware flat config.
|
||||
//
|
||||
// Baseline is typescript-eslint's *type-checked* strict + stylistic presets, so
|
||||
// anything they flag is an error, not a warning. On top of that: unicorn (all),
|
||||
// sonarjs (cognitive complexity / duplicate logic), perfectionist (sorting), and
|
||||
// react-hooks. Unused disable directives are themselves errors, so every
|
||||
// `eslint-disable` in this repo must be load-bearing.
|
||||
//
|
||||
// Deliberately absent:
|
||||
// - eslint-plugin-react : 7.37.x still calls context.getFilename(), removed in
|
||||
// ESLint 10, so it crashes the run.
|
||||
// - eslint-plugin-jsx-a11y: 6.10.2 declares peer ESLint "^3 || ... || ^9" — no
|
||||
// ESLint 10 support yet. a11y is enforced by review +
|
||||
// testing-library queries (getByLabelText) instead.
|
||||
//
|
||||
// TypeScript is pinned to 6.0.x, not 7.x: typescript-eslint requires
|
||||
// "typescript >=4.8.4 <6.1.0", and losing every type-aware rule would be a far
|
||||
// bigger regression than gaining the Go-native compiler.
|
||||
import js from "@eslint/js";
|
||||
import perfectionist from "eslint-plugin-perfectionist";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import sonarjs from "eslint-plugin-sonarjs";
|
||||
import unicorn from "eslint-plugin-unicorn";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "coverage"] },
|
||||
{ linterOptions: { reportUnusedDisableDirectives: "error" } },
|
||||
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
unicorn.configs.all,
|
||||
sonarjs.configs.recommended,
|
||||
perfectionist.configs["recommended-natural"],
|
||||
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: { "react-hooks": reactHooks },
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
|
||||
// Numbers in template literals are unambiguous and pervasive here
|
||||
// (counts, star totals, grades).
|
||||
"@typescript-eslint/restrict-template-expressions": [
|
||||
"error",
|
||||
{ allowNumber: true },
|
||||
],
|
||||
|
||||
// Keep the rule, widen its vocabulary. `passesFilters(server, filter)`
|
||||
// and `matchesQuery(...)` are predicates that read as English; forcing
|
||||
// them to `isPassingFilters` would be worse prose in service of a prefix
|
||||
// list. `passes`/`matches` are added, nothing is removed.
|
||||
"unicorn/consistent-boolean-name": [
|
||||
"error",
|
||||
{ prefixes: { matches: true, passes: true } },
|
||||
],
|
||||
|
||||
// Kebab-case files exporting PascalCase components: house convention.
|
||||
"unicorn/filename-case": ["error", { case: "kebabCase" }],
|
||||
|
||||
// Keep the rule, drop one replacement: `props` is React's own word.
|
||||
// `interface FilterBarProps` is the convention every React codebase and
|
||||
// every reader expects; `FilterBarProperties` would be unidiomatic in
|
||||
// service of a generic dictionary. Every other replacement stays on.
|
||||
"unicorn/name-replacements": ["error", { replacements: { props: false } }],
|
||||
},
|
||||
},
|
||||
|
||||
// House overrides, applied to every file including this config itself.
|
||||
//
|
||||
// Each rule below is switched off deliberately: these are the cases where
|
||||
// "more aggressive" makes the code worse rather than safer. A rule you must
|
||||
// disable at every use site is worse than no rule at all, because
|
||||
// reportUnusedDisableDirectives then turns the disables into their own debt.
|
||||
{
|
||||
rules: {
|
||||
// Directly contradicts unicorn/consistent-class-member-order: one wants
|
||||
// a private field before the getter that reads it, the other wants the
|
||||
// getter first, and satisfying either re-triggers the other. unicorn's
|
||||
// wins because "declare the field before the code that uses it" is a
|
||||
// reason, whereas alphabetical order within a class is not.
|
||||
"perfectionist/sort-classes": "off",
|
||||
|
||||
// Sorts Map entries by key. The marker table in parse-readme.ts is keyed
|
||||
// by emoji codepoint, so "sorted" would mean ordered by
|
||||
// U+1F396 < U+1F3CE < ... — meaningless to a reader, and it would shred
|
||||
// the legend's own grouping (official, languages, scope, OS) into noise.
|
||||
"perfectionist/sort-maps": "off",
|
||||
|
||||
// Alphabetises declarations within a file, which actively fights reading
|
||||
// order: it would scatter `GRADES -> gradeRank -> isTripleA` into
|
||||
// `compositeRank -> GRADES -> gradeCoverage`. Alphabetical order is not
|
||||
// narrative order. Every *other* perfectionist rule stays on — sorting
|
||||
// imports, union members and object keys destroys no narrative.
|
||||
"perfectionist/sort-modules": "off",
|
||||
|
||||
// Wants JSDoc blocks written without leading `*`, which is neither the
|
||||
// TypeScript convention nor what editors/typedoc render.
|
||||
"unicorn/no-asterisk-prefix-in-documentation-comments": "off",
|
||||
|
||||
// Flags hand-wrapped comment prose. Comments here are wrapped to 80
|
||||
// columns on purpose; the rule would force one long line per paragraph.
|
||||
"unicorn/no-manually-wrapped-comments": "off",
|
||||
|
||||
// `null` is load-bearing here and cannot be replaced by `undefined`:
|
||||
// 1. The pipeline's output is JSON, and JSON has no `undefined`. A
|
||||
// 2. The two values mean different things in this domain — `null` is
|
||||
// "Glama never indexed this server", `undefined` is "indexed, but
|
||||
// this axis was not graded". Collapsing them would lose information
|
||||
// the UI displays differently (not-indexed vs graded-partial).
|
||||
"unicorn/no-null": "off",
|
||||
|
||||
// `Temporal` does not exist in this project's runtime: Node 26 reports
|
||||
// `typeof Temporal === "undefined"`. The rule is unfollowable until it
|
||||
// ships. The inputs here are GitHub's ISO-8601 timestamps, which
|
||||
// Date.parse handles identically across engines — the cross-engine
|
||||
// inconsistency the rule warns about is for non-ISO strings.
|
||||
"unicorn/prefer-temporal": "off",
|
||||
|
||||
// The domain vocabulary is abbreviated on purpose: `os`, `spdx`, `repo`,
|
||||
// `env` are the names the source data uses, and expanding them to
|
||||
// `operatingSystem` / `repository` would obscure the mapping.
|
||||
"unicorn/prevent-abbreviations": "off",
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
rules: {
|
||||
// Caps a try block at ONE statement. Unfollowable where the whole point
|
||||
// is that several steps can throw: loadPresets must wrap getItem (throws
|
||||
// in private browsing), JSON.parse (throws on junk) and the shape check
|
||||
// together, because any of them failing means the same thing — fall back
|
||||
// to no presets. Splitting them into three try blocks would be strictly
|
||||
// worse code. The rule has no maximum option, so it is all or nothing.
|
||||
"unicorn/try-complexity": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// Tests: relax the rules that fight with mocking and fixtures.
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/unbound-method": "off",
|
||||
"sonarjs/no-duplicate-string": "off",
|
||||
// Test helpers live beside the tests that use them; hoisting every
|
||||
// fixture factory to module scope to satisfy a scoping rule would
|
||||
// scatter each test's setup away from the test.
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
// A no-op stub is the point of a stub.
|
||||
"unicorn/no-empty-file": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// The vitest setup file exists *to* perform top-level side effects: it patches
|
||||
// globals (ResizeObserver, layout metrics, pointer capture) that jsdom lacks,
|
||||
// before any test imports run. These two rules cannot be satisfied by a file
|
||||
// whose entire contract is "mutate the environment on import".
|
||||
{
|
||||
files: ["src/test/setup.ts"],
|
||||
rules: {
|
||||
// The stubs are property getters installed on DOM prototypes, so `this`
|
||||
// is the element being measured — there is no class to put them in.
|
||||
"unicorn/no-this-outside-of-class": "off",
|
||||
"unicorn/no-top-level-assignment-in-function": "off",
|
||||
"unicorn/no-top-level-side-effects": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// Plain JS config files get no type-aware linting.
|
||||
{
|
||||
extends: [tseslint.configs.disableTypeChecked],
|
||||
files: ["**/*.{js,mjs,cjs}"],
|
||||
},
|
||||
);
|
||||
20
web/index.html
Normal file
20
web/index.html
Normal file
@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- No remote anything. This page shows private photographs and the
|
||||
bot's configuration, and the only thing it may talk to is the
|
||||
process that served it -- the same rule tool/tagger.html carried. -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>signal-bot</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
41
web/package.json
Normal file
41
web/package.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "signal-bot-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@11.24.0",
|
||||
"engines": { "node": ">=24", "pnpm": ">=10" },
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit && eslint .",
|
||||
"test": "vitest run",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.3.1&path:/web_ui",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@testing-library/jest-dom": "7.0.1",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@testing-library/user-event": "14.6.6",
|
||||
"@types/node": "26.4.0",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/react-dom": "19.2.5",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"eslint": "10.9.1",
|
||||
"eslint-plugin-perfectionist": "5.10.1",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-sonarjs": "4.2.0",
|
||||
"eslint-plugin-unicorn": "74.0.0",
|
||||
"jsdom": "30.0.1",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.68.0",
|
||||
"vite": "8.2.2",
|
||||
"vitest": "4.1.11"
|
||||
}
|
||||
}
|
||||
2862
web/pnpm-lock.yaml
Normal file
2862
web/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
6
web/public/favicon.svg
Normal file
6
web/public/favicon.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="signal-bot">
|
||||
<rect width="32" height="32" rx="7" fill="#1b1d21"/>
|
||||
<path d="M16 6a10 10 0 0 0-8.7 14.9L6 26l5.3-1.3A10 10 0 1 0 16 6Z"
|
||||
fill="none" stroke="#7aa2f7" stroke-width="2.2" stroke-linejoin="round"/>
|
||||
<circle cx="16" cy="16" r="2.4" fill="#7aa2f7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 361 B |
61
web/src/api/client.test.ts
Normal file
61
web/src/api/client.test.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { serve } from "../test/http.ts";
|
||||
import { api, ApiError } from "./client.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("the api client", () => {
|
||||
it("returns the body of a successful read", async () => {
|
||||
serve({ "/api/status": { body: { bot: {}, reachable: true } } });
|
||||
await expect(api.status()).resolves.toEqual({ bot: {}, reachable: true });
|
||||
});
|
||||
|
||||
it("raises the server's own sentence, not one of its own", async () => {
|
||||
// The detail is usually load_config's message -- what the journal would
|
||||
// have shown at the next restart. Rewording it here would make two
|
||||
// different sentences for one rule.
|
||||
serve({
|
||||
"/api/env": { body: { detail: "REPORT_HOUR out of range" }, status: 400 },
|
||||
});
|
||||
await expect(api.saveEnv({ REPORT_HOUR: "99" })).rejects.toThrow(
|
||||
"REPORT_HOUR out of range",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the status text when there is no detail", async () => {
|
||||
serve({ "/api/status": { body: { nothing: true }, status: 500 } });
|
||||
await expect(api.status()).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
|
||||
it("treats a path the server does not serve as a 404", async () => {
|
||||
serve({});
|
||||
await expect(api.status()).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it("carries the status code so a caller can tell 403 from 400", async () => {
|
||||
serve({ "/api/env": { body: { detail: "nope" }, status: 403 } });
|
||||
await expect(api.saveEnv({})).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it("declares json on every write, which is what refuses a form post", async () => {
|
||||
const fetcher = serve({ "/api/album/tag": { body: { status: "ok" } } });
|
||||
await api.tag("one.png", ["p1"]);
|
||||
const [, init] = fetcher.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.headers).toMatchObject({ "content-type": "application/json" });
|
||||
expect(init.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("reads the album, the groups and the restart through the same wrapper", async () => {
|
||||
serve({
|
||||
"/api/album/state": { body: { files: [] } },
|
||||
"/api/groups": { body: { groups: [] } },
|
||||
"/api/restart-container": { body: { ok: true } },
|
||||
});
|
||||
await expect(api.album()).resolves.toEqual({ files: [] });
|
||||
await expect(api.groups()).resolves.toEqual({ groups: [] });
|
||||
await expect(api.restart()).resolves.toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
59
web/src/api/client.ts
Normal file
59
web/src/api/client.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type { AlbumState, EnvironmentView, Group, Restart, Status } from "./types.ts";
|
||||
|
||||
// Every mutating call sends application/json, and it is not decoration: the
|
||||
// server refuses anything else, because a cross-origin <form> cannot set
|
||||
// this header and that is what stops a web page from writing .env.
|
||||
const JSON_HEADERS = { "content-type": "application/json" };
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(message: string, options: ErrorOptions & { status: number }) {
|
||||
super(message, options);
|
||||
this.name = "ApiError";
|
||||
this.status = options.status;
|
||||
}
|
||||
}
|
||||
|
||||
async function unwrap<T>(response: Response): Promise<T> {
|
||||
const body: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
// FastAPI puts the reason in `detail`, and it is usually load_config's
|
||||
// own message -- the sentence the journal would have shown at the next
|
||||
// restart. Showing anything else would be inventing a second wording.
|
||||
const detail =
|
||||
typeof body === "object" && body !== null && "detail" in body
|
||||
? String((body).detail)
|
||||
: response.statusText;
|
||||
throw new ApiError(detail, { status: response.status });
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
return unwrap<T>(await fetch(path));
|
||||
}
|
||||
|
||||
async function send<T>(path: string, method: string, body: unknown): Promise<T> {
|
||||
return unwrap<T>(
|
||||
await fetch(path, {
|
||||
body: JSON.stringify(body),
|
||||
headers: JSON_HEADERS,
|
||||
method,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
album: async (): Promise<AlbumState> => get<AlbumState>("/api/album/state"),
|
||||
env: async (): Promise<EnvironmentView> => get<EnvironmentView>("/api/env"),
|
||||
groups: async (): Promise<{ groups: Group[] }> =>
|
||||
get<{ groups: Group[] }>("/api/groups"),
|
||||
restart: async (): Promise<Restart> =>
|
||||
send<Restart>("/api/restart-container", "POST", {}),
|
||||
saveEnv: async (values: Record<string, string>): Promise<unknown> =>
|
||||
send("/api/env", "PUT", { values }),
|
||||
status: async (): Promise<Status> => get<Status>("/api/status"),
|
||||
tag: async (file: string, keys: string[]): Promise<unknown> =>
|
||||
send("/api/album/tag", "POST", { file, keys }),
|
||||
};
|
||||
57
web/src/api/types.ts
Normal file
57
web/src/api/types.ts
Normal file
@ -0,0 +1,57 @@
|
||||
// What the management app answers with. Hand-written rather than generated:
|
||||
// the backend is eleven small modules in this same repository, and a
|
||||
// generator would be a build step to keep working for no new information.
|
||||
|
||||
export interface BotHealth {
|
||||
papacy?: string;
|
||||
pope?: string;
|
||||
reason?: string;
|
||||
status?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
bot: BotHealth;
|
||||
reachable: boolean;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentKey {
|
||||
editable: boolean;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentView {
|
||||
groups_known: boolean;
|
||||
keys: EnvironmentKey[];
|
||||
needs_groups: string[];
|
||||
}
|
||||
|
||||
export interface AlbumFile {
|
||||
keys: string[];
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
key: string;
|
||||
triggers: string[];
|
||||
}
|
||||
|
||||
export interface AlbumState {
|
||||
combos: Person[];
|
||||
configured: boolean;
|
||||
files: AlbumFile[];
|
||||
known_groups: Group[];
|
||||
people: Person[];
|
||||
}
|
||||
|
||||
export interface Restart {
|
||||
ok: boolean;
|
||||
stderr: string;
|
||||
stdout: string;
|
||||
}
|
||||
45
web/src/app.test.tsx
Normal file
45
web/src/app.test.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { App } from "./app.tsx";
|
||||
import { ALBUM, ENV, HEALTHY } from "./test/fixtures.ts";
|
||||
import { serve } from "./test/http.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function everythingAnswers(): void {
|
||||
serve({
|
||||
"/api/album/state": { body: ALBUM },
|
||||
"/api/env": { body: ENV },
|
||||
"/api/status": { body: HEALTHY },
|
||||
});
|
||||
}
|
||||
|
||||
it("opens on health, because that is the question being asked", async () => {
|
||||
everythingAnswers();
|
||||
render(<App />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("reachable")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Health" })).toHaveAttribute(
|
||||
"aria-current",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("switches to settings and to the photos", async () => {
|
||||
everythingAnswers();
|
||||
render(<App />);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Settings" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("COOLDOWN_SECONDS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Photos" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("img")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
42
web/src/app.tsx
Normal file
42
web/src/app.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { Album } from "./views/album.tsx";
|
||||
import { Health } from "./views/health.tsx";
|
||||
import { Settings } from "./views/settings.tsx";
|
||||
|
||||
const VIEWS = {
|
||||
Health: Health,
|
||||
Photos: Album,
|
||||
Settings: Settings,
|
||||
} as const;
|
||||
|
||||
type ViewName = keyof typeof VIEWS;
|
||||
|
||||
const NAMES = Object.keys(VIEWS) as ViewName[];
|
||||
|
||||
export function App(): React.JSX.Element {
|
||||
const [view, setView] = useState<ViewName>("Health");
|
||||
const Current = VIEWS[view];
|
||||
return (
|
||||
<main>
|
||||
<h1>signal-bot</h1>
|
||||
<nav>
|
||||
{NAMES.map((name) => {
|
||||
return (
|
||||
<button
|
||||
aria-current={name === view}
|
||||
key={name}
|
||||
onClick={() => {
|
||||
setView(name);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<Current />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
30
web/src/main.test.tsx
Normal file
30
web/src/main.test.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { HEALTHY } from "./test/fixtures.ts";
|
||||
import { serve } from "./test/http.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("mounts the app into the root the page provides", async () => {
|
||||
serve({ "/api/status": { body: HEALTHY } });
|
||||
const root = document.createElement("div");
|
||||
root.id = "root";
|
||||
document.body.replaceChildren(root);
|
||||
await import("./main.tsx");
|
||||
await vi.waitFor(() => {
|
||||
expect(document.querySelector("h1")).toHaveTextContent("signal-bot");
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing at all when there is no root to mount into", async () => {
|
||||
// Not a hypothetical: this module is imported by its own test file, and a
|
||||
// bootstrap that assumed the element existed would throw during collection
|
||||
// rather than at run time.
|
||||
serve({ "/api/status": { body: HEALTHY } });
|
||||
await import("./main.tsx");
|
||||
expect(document.querySelector("h1")).toBeNull();
|
||||
});
|
||||
15
web/src/main.tsx
Normal file
15
web/src/main.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { App } from "./app.tsx";
|
||||
|
||||
// Thin enough to test rather than exclude. This repository treats a coverage
|
||||
// omit as a suppression, and "the DOM bootstrap is awkward to assert" is the
|
||||
// same sentence as "this code is untested".
|
||||
export function mount(container: Element): void {
|
||||
createRoot(container).render(<App />);
|
||||
}
|
||||
|
||||
const root = document.querySelector("#root");
|
||||
if (root !== null) {
|
||||
mount(root);
|
||||
}
|
||||
51
web/src/test/fixtures.ts
Normal file
51
web/src/test/fixtures.ts
Normal file
@ -0,0 +1,51 @@
|
||||
// Every value here is invented. Two constraints beyond "do not use real
|
||||
// data": the repository's personal-data gate refuses any file containing a
|
||||
// string matching group.<16+ base64 chars>, so the fixture group ids are
|
||||
// short and obviously fake; and the real album's people are private, so the
|
||||
// names here are letters of the NATO alphabet, as tests/pack_fixture.py does.
|
||||
import type { AlbumState, EnvironmentView as EnvironmentView, Status } from "../api/types.ts";
|
||||
|
||||
export const GROUP = { id: "group.FIXTURE-ALPHA", name: "alfa" };
|
||||
|
||||
export const HEALTHY: Status = {
|
||||
bot: { papacy: "nr 1", pope: "Petrus", status: "ok", version: "0.0.0" },
|
||||
reachable: true,
|
||||
};
|
||||
|
||||
export const DEAF: Status = {
|
||||
bot: { reason: "no frame in 120s", status: "deaf" },
|
||||
reachable: true,
|
||||
};
|
||||
|
||||
export const UNREACHABLE: Status = { bot: {}, reachable: false };
|
||||
|
||||
// Answering, but with nothing to say for itself. Older releases predate the
|
||||
// version/pope fields, so the page must not render "undefined" at somebody.
|
||||
export const TERSE: Status = { bot: {}, reachable: true };
|
||||
|
||||
export const ENV: EnvironmentView = {
|
||||
groups_known: true,
|
||||
keys: [
|
||||
{ editable: true, key: "GROUP_IDS", value: "" },
|
||||
{ editable: true, key: "COOLDOWN_SECONDS", value: "10" },
|
||||
{ editable: false, key: "PHONE_NUMBER", value: "+15550000000" },
|
||||
],
|
||||
needs_groups: ["GROUP_IDS"],
|
||||
};
|
||||
|
||||
export const ALBUM: AlbumState = {
|
||||
combos: [],
|
||||
configured: true,
|
||||
files: [
|
||||
{ keys: ["p1"], name: "one.png" },
|
||||
{ keys: [], name: "two.png" },
|
||||
],
|
||||
known_groups: [GROUP],
|
||||
people: [
|
||||
{ key: "p1", triggers: ["!alfa"] },
|
||||
{ key: "p2", triggers: ["!beta"] },
|
||||
// Half-added, which the tagger allows: add_person writes the key first
|
||||
// and the page has to show something for it.
|
||||
{ key: "p3", triggers: [] },
|
||||
],
|
||||
};
|
||||
25
web/src/test/http.ts
Normal file
25
web/src/test/http.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export interface Reply {
|
||||
body: unknown;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
Answer each request by path, so a test says what the server is doing.
|
||||
*/
|
||||
export function serve(routes: Record<string, Reply>): ReturnType<typeof vi.fn> {
|
||||
const fetcher = vi.fn((input: string) => {
|
||||
const { pathname: path } = new URL(input, "http://127.0.0.1:8140");
|
||||
const reply = routes[path] ?? { body: {}, status: 404 };
|
||||
const status = reply.status ?? 200;
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve(reply.body),
|
||||
ok: status < 400,
|
||||
status,
|
||||
statusText: `status ${status}`,
|
||||
} as Response);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return fetcher;
|
||||
}
|
||||
1
web/src/test/setup.ts
Normal file
1
web/src/test/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
103
web/src/views/album.test.tsx
Normal file
103
web/src/views/album.test.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { ALBUM } from "../test/fixtures.ts";
|
||||
import { serve } from "../test/http.ts";
|
||||
import { Album } from "./album.tsx";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("says it is opening before the album arrives", () => {
|
||||
serve({ "/api/album/state": { body: ALBUM } });
|
||||
render(<Album />);
|
||||
expect(screen.getByText("Opening the album…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when there is nothing to tag yet", async () => {
|
||||
serve({ "/api/album/state": { body: { ...ALBUM, files: [] } } });
|
||||
render(<Album />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("empty")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("asks for media by position, never by name", async () => {
|
||||
// The server takes an int for exactly this reason: a filename from a
|
||||
// client is a path, and this directory holds private photographs.
|
||||
serve({ "/api/album/state": { body: ALBUM } });
|
||||
render(<Album />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/0");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows who is already tagged on the current item", async () => {
|
||||
serve({ "/api/album/state": { body: ALBUM } });
|
||||
render(<Album />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("1. !alfa")).toBeChecked();
|
||||
});
|
||||
expect(screen.getByLabelText("2. !beta")).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("shows a half-added person by key rather than by nothing", async () => {
|
||||
serve({ "/api/album/state": { body: ALBUM } });
|
||||
render(<Album />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("3. p3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("saves on the click rather than on a done button", async () => {
|
||||
// The tab gets closed. A "done" button is a way to lose an evening's work.
|
||||
const fetcher = serve({
|
||||
"/api/album/state": { body: ALBUM },
|
||||
"/api/album/tag": { body: { status: "ok" } },
|
||||
});
|
||||
render(<Album />);
|
||||
await userEvent.click(await screen.findByLabelText("2. !beta"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("2. !beta")).toBeChecked();
|
||||
});
|
||||
const [path, init] = fetcher.mock.calls.at(-1) as [string, { body: string }];
|
||||
expect(path).toBe("/api/album/tag");
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
file: "one.png",
|
||||
keys: ["p1", "p2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("untags somebody who was tagged by mistake", async () => {
|
||||
const fetcher = serve({
|
||||
"/api/album/state": { body: ALBUM },
|
||||
"/api/album/tag": { body: { status: "ok" } },
|
||||
});
|
||||
render(<Album />);
|
||||
await userEvent.click(await screen.findByLabelText("1. !alfa"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("1. !alfa")).not.toBeChecked();
|
||||
});
|
||||
const [, init] = fetcher.mock.calls.at(-1) as [string, { body: string }];
|
||||
expect(JSON.parse(init.body)).toEqual({ file: "one.png", keys: [] });
|
||||
});
|
||||
|
||||
it("walks the album forwards and back", async () => {
|
||||
serve({ "/api/album/state": { body: ALBUM } });
|
||||
render(<Album />);
|
||||
const previous = await screen.findByRole("button", { name: "Previous" });
|
||||
expect(previous).toBeDisabled();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Next" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/1");
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Previous" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/0");
|
||||
});
|
||||
});
|
||||
93
web/src/views/album.tsx
Normal file
93
web/src/views/album.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { AlbumFile, Person } from "../api/types.ts";
|
||||
|
||||
import { api } from "../api/client.ts";
|
||||
|
||||
// The file list is held apart from the rest of the album state so that
|
||||
// recording a tag is a plain list update. Folding it back into a nullable
|
||||
// object would add a "what if it is null" branch that cannot happen -- this
|
||||
// only renders once the album has loaded -- and an untestable branch is the
|
||||
// same thing as an untested one.
|
||||
function tagged(files: AlbumFile[], name: string, keys: string[]): AlbumFile[] {
|
||||
return files.map((entry) => (entry.name === name ? { ...entry, keys } : entry));
|
||||
}
|
||||
|
||||
export function Album(): React.JSX.Element {
|
||||
const [people, setPeople] = useState<null | Person[]>(null);
|
||||
const [files, setFiles] = useState<AlbumFile[]>([]);
|
||||
const [at, setAt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
void api.album().then((state) => {
|
||||
setPeople(state.people);
|
||||
setFiles(state.files);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback((file: AlbumFile, personKey: string) => {
|
||||
const keys = file.keys.includes(personKey)
|
||||
? file.keys.filter((held) => held !== personKey)
|
||||
: [...file.keys, personKey];
|
||||
// Saved on the click, not on a "done" button: the tab gets closed, and
|
||||
// a "done" button is a way to lose an evening's work.
|
||||
void api.tag(file.name, keys).then(() => {
|
||||
setFiles((current) => tagged(current, file.name, keys));
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (people === null) {
|
||||
return <p>Opening the album…</p>;
|
||||
}
|
||||
// One list, one index space. The server serves media by position and tags
|
||||
// by name against the same ordering, so filtering here would show item i
|
||||
// of the album while tagging item i of a shorter list.
|
||||
const file = files[at];
|
||||
if (file === undefined) {
|
||||
return <p data-testid="empty">This album has no items yet.</p>;
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<h2>
|
||||
Photos — {at + 1} of {files.length}
|
||||
</h2>
|
||||
<img alt={`album item ${at + 1}`} src={`/api/album/media/${at}`} />
|
||||
<ul>
|
||||
{people.map((person, index) => {
|
||||
return (
|
||||
<li key={person.key}>
|
||||
<label>
|
||||
<input
|
||||
checked={file.keys.includes(person.key)}
|
||||
onChange={() => {
|
||||
toggle(file, person.key);
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
{index + 1}. {person.triggers[0] ?? person.key}
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button
|
||||
disabled={at === 0}
|
||||
onClick={() => {
|
||||
setAt((current) => current - 1);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
disabled={at >= files.length - 1}
|
||||
onClick={() => {
|
||||
setAt((current) => current + 1);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
79
web/src/views/health.test.tsx
Normal file
79
web/src/views/health.test.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEAF, HEALTHY, TERSE, UNREACHABLE } from "../test/fixtures.ts";
|
||||
import { serve } from "../test/http.ts";
|
||||
import { Health } from "./health.tsx";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("says it is asking before the answer arrives", () => {
|
||||
serve({ "/api/status": { body: HEALTHY } });
|
||||
render(<Health />);
|
||||
expect(screen.getByText("Asking the bot…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the release the bot is actually running", async () => {
|
||||
serve({ "/api/status": { body: HEALTHY } });
|
||||
render(<Health />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("reachable")).toHaveTextContent("0.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
it("never renders undefined at somebody", async () => {
|
||||
// A release older than the version/pope fields still answers /healthz.
|
||||
serve({ "/api/status": { body: TERSE } });
|
||||
render(<Health />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("reachable")).toHaveTextContent("?");
|
||||
});
|
||||
expect(screen.getByTestId("reachable")).not.toHaveTextContent("undefined");
|
||||
});
|
||||
|
||||
it("says plainly when the bot is not answering", async () => {
|
||||
// The single most important thing this page can tell you, so it must not
|
||||
// be the thing that stops the page rendering.
|
||||
serve({ "/api/status": { body: UNREACHABLE } });
|
||||
render(<Health />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("reachable")).toHaveTextContent("not answering");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows why a bot that is up has gone deaf", async () => {
|
||||
serve({ "/api/status": { body: DEAF } });
|
||||
render(<Health />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("reason")).toHaveTextContent("no frame in 120s");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a restart that worked", async () => {
|
||||
serve({
|
||||
"/api/restart-container": { body: { ok: true, stderr: "", stdout: "" } },
|
||||
"/api/status": { body: HEALTHY },
|
||||
});
|
||||
render(<Health />);
|
||||
await userEvent.click(await screen.findByRole("button"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("restart")).toHaveTextContent("restarted");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a restart that failed look like success", async () => {
|
||||
serve({
|
||||
"/api/restart-container": {
|
||||
body: { ok: false, stderr: "no such container", stdout: "" },
|
||||
},
|
||||
"/api/status": { body: HEALTHY },
|
||||
});
|
||||
render(<Health />);
|
||||
await userEvent.click(await screen.findByRole("button"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("restart")).toHaveTextContent("no such container");
|
||||
});
|
||||
});
|
||||
51
web/src/views/health.tsx
Normal file
51
web/src/views/health.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { Restart, Status } from "../api/types.ts";
|
||||
|
||||
import { api } from "../api/client.ts";
|
||||
|
||||
// The page is most useful exactly when something is down, so every read here
|
||||
// degrades to a state rather than an error.
|
||||
export function Health(): React.JSX.Element {
|
||||
const [status, setStatus] = useState<null | Status>(null);
|
||||
const [restart, setRestart] = useState<null | Restart>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void api.status().then(setStatus);
|
||||
}, []);
|
||||
|
||||
useEffect(refresh, [refresh]);
|
||||
|
||||
const doRestart = useCallback(() => {
|
||||
setBusy(true);
|
||||
void api.restart().then((result) => {
|
||||
setRestart(result);
|
||||
setBusy(false);
|
||||
refresh();
|
||||
});
|
||||
}, [refresh]);
|
||||
|
||||
if (status === null) {
|
||||
return <p>Asking the bot…</p>;
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<h2>Health</h2>
|
||||
<p data-testid="reachable">
|
||||
{status.reachable
|
||||
? `${status.bot.status ?? "?"} — ${status.bot.version ?? "?"} «${status.bot.pope ?? "?"}»`
|
||||
: "the bot is not answering on its loopback port"}
|
||||
</p>
|
||||
{status.bot.reason !== undefined && <p data-testid="reason">{status.bot.reason}</p>}
|
||||
<button disabled={busy} onClick={doRestart} type="button">
|
||||
Restart signal-api
|
||||
</button>
|
||||
{restart !== null && (
|
||||
<p data-testid="restart">
|
||||
{restart.ok ? "restarted" : `failed: ${restart.stderr}`}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
82
web/src/views/settings.test.tsx
Normal file
82
web/src/views/settings.test.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { ENV } from "../test/fixtures.ts";
|
||||
import { serve } from "../test/http.ts";
|
||||
import { Settings } from "./settings.tsx";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("says it is reading before the answer arrives", () => {
|
||||
serve({ "/api/env": { body: ENV } });
|
||||
render(<Settings />);
|
||||
expect(screen.getByText("Reading .env…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("will not let a key the server refuses even be typed into", async () => {
|
||||
serve({ "/api/env": { body: ENV } });
|
||||
render(<Settings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("PHONE_NUMBER")).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByLabelText("COOLDOWN_SECONDS")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("locks the group fields while no listing has been read", async () => {
|
||||
// parse_group_ids only checks syntax. Writing one blind with the container
|
||||
// down turns a scoping mistake into a unit that will not start.
|
||||
serve({ "/api/env": { body: { ...ENV, groups_known: false } } });
|
||||
render(<Settings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("groups-locked")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText("GROUP_IDS")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("asks for a restart once something has been written", async () => {
|
||||
serve({ "/api/env": { body: ENV } });
|
||||
render(<Settings />);
|
||||
await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("note")).toHaveTextContent("Restart the bot");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the server's refusal verbatim", async () => {
|
||||
serve({ "/api/env": { body: ENV } });
|
||||
render(<Settings />);
|
||||
await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
|
||||
serve({
|
||||
"/api/env": { body: { detail: "COOLDOWN_SECONDS must be an integer" }, status: 400 },
|
||||
});
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("note")).toHaveTextContent("must be an integer");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a failure that is not the server's own sentence", async () => {
|
||||
serve({ "/api/env": { body: ENV } });
|
||||
render(<Settings />);
|
||||
await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.reject(new Error("network is down"))),
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("note")).toHaveTextContent("could not save");
|
||||
});
|
||||
});
|
||||
|
||||
it("has nothing to save until something is edited", async () => {
|
||||
serve({ "/api/env": { body: ENV } });
|
||||
render(<Settings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
74
web/src/views/settings.tsx
Normal file
74
web/src/views/settings.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { EnvironmentView as EnvironmentView } from "../api/types.ts";
|
||||
|
||||
import { api, ApiError } from "../api/client.ts";
|
||||
|
||||
// Editing .env from a browser is the whole reason the server refuses
|
||||
// cross-origin forms. The form's own job is smaller: never offer a key the
|
||||
// server would refuse, so a refusal is a bug rather than a normal outcome.
|
||||
export function Settings(): React.JSX.Element {
|
||||
const [view, setView] = useState<EnvironmentView | null>(null);
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void api.env().then(setView);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
void api
|
||||
.saveEnv(edits)
|
||||
.then(() => {
|
||||
// The running process loaded the old values and keeps using them.
|
||||
// Saying so is the whole contract; restarting is done from the
|
||||
// terminal this page was started in.
|
||||
setNote("Written. Restart the bot for it to take effect.");
|
||||
setEdits({});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setNote(error instanceof ApiError ? error.message : "could not save");
|
||||
});
|
||||
}, [edits]);
|
||||
|
||||
if (view === null) {
|
||||
return <p>Reading .env…</p>;
|
||||
}
|
||||
return (
|
||||
<section>
|
||||
<h2>Settings</h2>
|
||||
{!view.groups_known && (
|
||||
<p data-testid="groups-locked">
|
||||
The container is not reachable, so group settings are read-only: a
|
||||
group id can only be checked against a listing that was really read.
|
||||
</p>
|
||||
)}
|
||||
{view.keys.map((entry) => {
|
||||
const isLocked =
|
||||
!entry.editable ||
|
||||
(view.needs_groups.includes(entry.key) && !view.groups_known);
|
||||
return (
|
||||
<label key={entry.key}>
|
||||
{entry.key}
|
||||
<input
|
||||
disabled={isLocked}
|
||||
onChange={(event) => {
|
||||
setEdits((current) => {
|
||||
return {
|
||||
...current,
|
||||
[entry.key]: event.target.value,
|
||||
};
|
||||
});
|
||||
}}
|
||||
value={edits[entry.key] ?? entry.value}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<button disabled={Object.keys(edits).length === 0} onClick={save} type="button">
|
||||
Save
|
||||
</button>
|
||||
{note !== "" && <p data-testid="note">{note}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
39
web/tsconfig.json
Normal file
39
web/tsconfig.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"node"
|
||||
],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"useUnknownInCatchVariables": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"*.ts",
|
||||
"*.config.ts"
|
||||
]
|
||||
}
|
||||
35
web/vite.config.ts
Normal file
35
web/vite.config.ts
Normal file
@ -0,0 +1,35 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// The page is served from the root of its own loopback port, and from
|
||||
// nowhere else, so there is no subpath to be agnostic about.
|
||||
export default defineConfig({
|
||||
build: { outDir: "dist" },
|
||||
plugins: [react()],
|
||||
server: {
|
||||
// `pnpm dev` talks to the real management app rather than a mock: the
|
||||
// guard refuses anything whose Host is not loopback, so the proxy has
|
||||
// to preserve it.
|
||||
proxy: { "/api": "http://127.0.0.1:8140" },
|
||||
},
|
||||
test: {
|
||||
coverage: {
|
||||
// No exclude list. This repository's rule is that coverage omits are
|
||||
// suppressions, and "the bootstrap is hard to test" is the same
|
||||
// sentence as "this line is untested" -- so main.tsx is a shim thin
|
||||
// enough to test in jsdom instead.
|
||||
include: ["src/**/*.{ts,tsx}"],
|
||||
provider: "v8",
|
||||
thresholds: {
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
statements: 100,
|
||||
},
|
||||
},
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user