mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
967 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
10372d4c1f
|
build(deps): bump the npm-dependencies group in /app with 7 updates
Bumps the npm-dependencies group in /app with 7 updates: | Package | From | To | | --- | --- | --- | | [@tabler/icons-react](https://github.com/tabler/tabler-icons/tree/HEAD/packages/icons-react) | `3.46.0` | `3.48.0` | | [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.6` | `14.6.7` | | [esptool-js](https://github.com/espressif/esptool-js) | `0.6.1` | `0.7.0` | | [react-colorful](https://github.com/omgovich/react-colorful) | `5.8.0` | `5.8.1` | | [three](https://github.com/mrdoob/three.js) | `0.185.1` | `0.186.1` | | [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.4.0` | `26.6.3` | Updates `@tabler/icons-react` from 3.46.0 to 3.48.0 - [Release notes](https://github.com/tabler/tabler-icons/releases) - [Commits](https://github.com/tabler/tabler-icons/commits/v3.48.0/packages/icons-react) Updates `@testing-library/user-event` from 14.6.6 to 14.6.7 - [Release notes](https://github.com/testing-library/user-event/releases) - [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/user-event/compare/v14.6.6...v14.6.7) Updates `esptool-js` from 0.6.1 to 0.7.0 - [Release notes](https://github.com/espressif/esptool-js/releases) - [Changelog](https://github.com/espressif/esptool-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/espressif/esptool-js/compare/v0.6.1...v0.7.0) Updates `react-colorful` from 5.8.0 to 5.8.1 - [Release notes](https://github.com/omgovich/react-colorful/releases) - [Changelog](https://github.com/omgovich/react-colorful/blob/master/CHANGELOG.md) - [Commits](https://github.com/omgovich/react-colorful/commits) Updates `three` from 0.185.1 to 0.186.1 - [Release notes](https://github.com/mrdoob/three.js/releases) - [Commits](https://github.com/mrdoob/three.js/commits) Updates `@testing-library/dom` from 10.4.1 to 10.4.2 - [Release notes](https://github.com/testing-library/dom-testing-library/releases) - [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2) Updates `@types/node` from 26.4.0 to 26.6.3 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@tabler/icons-react" dependency-version: 3.48.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: "@testing-library/user-event" dependency-version: 14.6.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-dependencies - dependency-name: esptool-js dependency-version: 0.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: react-colorful dependency-version: 5.8.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-dependencies - dependency-name: three dependency-version: 0.186.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: "@testing-library/dom" dependency-version: 10.4.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-dependencies - dependency-name: "@types/node" dependency-version: 26.6.3 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-dependencies ... Signed-off-by: dependabot[bot] <support@github.com> |
||
|
|
4fea50943b
|
map: clicking a POI shows its kind, detail, elevation and coordinates (#738)
The Protomaps basemap keeps a few attributes per POI beyond the name the map already labels: kind, kind_detail and elevation. Clicking a POI now opens a popup with those, plus the feature's coordinates (or the click location when the feature has no point). The same POI appears at several min_zoom levels within one tile, so features are deduped by name and kind. Map popups (this one and the existing pin popup) now follow the active theme's tokens instead of MapLibre's fixed white, covering light, dark, night and amber. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
15d3792b0e
|
videos: transcode can remove the audio stream (silent video) (#737)
A video's audio stream can be dropped the same way its video stream could already be: a new REMOVE_AUDIO audio target maps only the video stream with -an, needs a video container, and is refused for audio files. Output verification insists the result has no audio stream. Removing both streams is refused by the validator, the API, resolve_output, and the modal: choosing Remove for one stream resets the other to Keep and withdraws the other's Remove option. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
63ddad69e8
|
app: api helpers report failure instead of returning an empty value (#736)
Six helpers turned a non-OK response into an empty value, so their
consumers rendered a server error as "nothing here":
* getConfigBackups -> {dates: []}: "No backups available for this
config." A thrown request also left "Loading backups..." forever.
* getInventoryBackups -> []: "No backups yet."
* getDownloaders -> {downloaders: []} on a throw, the error body on a
non-OK response (which then crashed the .map): the RSS form's
Downloader select sat empty.
* searchMap -> {results: []}: "No map locations found".
* getMapSearchStatus -> {indexed: [], missing: []}: "No maps are
installed".
* flasherSearch -> [null, null], then `|| []`: "No .bin firmware
found".
Invariants now enforced and tested:
* On a non-OK response each helper returns undefined; an empty value
only ever comes from the server.
* Each consumer shows an error for a failed or thrown fetch and keeps
its "none" message for a confirmed empty result. The Downloader
select is disabled and says it is loading or could not fetch. A
failed map status is unknown, never "no maps are installed".
* The map search tab loads from the first render, and a query shorter
than the two characters the request needs now says so instead of
"No map locations found".
Archive browsers and recent tags are left as they are: a failed fetch
there hides an optional picker or suggestion list without claiming
anything.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
20541d7184
|
app: number fields no longer throw "e.preventDefault is not a function" (#735)
`useForm`'s input handlers called `e.preventDefault()` unconditionally, but the
Mantine-backed `NumberField` (Sleep between requests, Depth, Maximum Pages)
hands them a bare `{target}` object because NumberInput emits a value, not an
event. Every keystroke in those fields threw.
`handleInputEvent` now only calls `preventDefault` when the argument actually
has one, and `getInputProps`' wrapper no longer repeats the call. Audited every
other `preventDefault` call site: all are bound to real DOM/React events or a
guarded no-argument call.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
eead78007d
|
app: status and settings contexts say whether their first poll landed (#734)
Both contexts start as {} and nothing distinguished that from a
server that reported nothing, so for up to one poll interval after a
page load consumers rendered the negative case as fact: the Settings
form appeared with blank values and an "undefined/config/wrolpi.yaml"
path (the ready check `settings ? true : undefined` was always true),
the Downloads toggle was clickable and read "Downloading Disabled",
the upgrade panel said "up to date, vundefined", and the five hardware
toggles on the Controller page claimed "not supported on this server".
Invariants now enforced and tested:
* StatusContext.loaded is true once a poll returned a real status;
the API being down does not count. SettingsContext.loaded is true
once settings were fetched, and `failed` when a fetch returned
nothing before that; a failed refresh after a load keeps the last
known values and `loaded`.
* The Settings page shows its loader until loaded and its error only
when failed. The upgrade panel shows a loader until status loaded.
The Downloads toggle is disabled with a neutral label until then.
* A subsystem toggle is disabled without claiming "unsupported" while
status is unknown (undefined); "unsupported" (null) is reserved for
a loaded status that lacks the field. A call in flight is unknown,
not unsupported.
The context fixtures declare loaded so existing specs render as
before.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
306386e801
|
app: Flasher saved configs and Statistics distinguish pending from empty (#733)
The Flasher's saved configurations started as [] with no loading
flag, and the Saved Firmwares tab is the default tab, so every load
painted "No saved configurations yet." until the fetch returned. The
api helper returned [] on a non-OK response too, so a failed fetch
read as an empty list.
useStatistics used useState() with no initial value, so the
Statistics page's first render saw undefined and flashed "Failed to
fetch statistics" for one frame before the effect set {} and the
spinner appeared.
Invariants now enforced and tested:
* null = pending, undefined = fetch failed, [] / object = loaded. The
api helper returns undefined (and toasts) on a non-OK response.
* The Saved Firmwares tab shows a placeholder while pending, an error
on failure, and "No saved configurations yet." only once the server
confirmed there are none. Only the newest configs request may write
state, since save and delete refetch.
* The Statistics page shows its loader while pending and its error
only for a failed fetch.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
6e251a009a
|
app: map pins and subscriptions tables distinguish pending from empty (#732)
* app: map pins and subscriptions tables distinguish pending from empty Both lists were initialised to [], which SortableTable renders as "loaded and empty". The Pins tab therefore showed "No pins. Right-click on the map to add one." until the pins arrived, and the Manage tab's subscriptions table showed "No results" until the catalog did. A non-OK response (the api helpers return undefined) or a thrown request left the same messages up permanently. Invariants now enforced and tested: * null = pending, undefined = fetch failed, [] = confirmed empty, so SortableTable shows its placeholder while pending and each empty message appears only once the server confirmed the list is empty. * A failed fetch renders an error in place of the table. * Only the newest request for each list may write state, since edits, deletes, subscribes and unsubscribes refetch while the mount request may still be in flight. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * app: the regions preview waits for the catalog "View All Regions" stayed enabled while the catalog was pending or failed, and the modal received a fresh `catalog || []` on every render. Its map effect depends on that array, and ManageMap re-renders on every status poll, so an open preview tore its WebGL map down and rebuilt it every three seconds, and a failed catalog was previewed as zero regions over the error message. The button is disabled until the catalog is an array, and the modal is mounted only then, so it receives the state array itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
ae745f1a18
|
app: search dropdown offers "Channels: No results" only after a search (#730)
useSuggestions started with channels: [] and reset to that on every keystroke, and the normalizer treats an empty list as "the server found none". So the global search dropdown showed a "Channels: No results" row on focus before anything was typed, and on every keystroke through the debounce and the request. Because that row counts as a result, the SearchBox's own "Searching…" state never appeared. The channel, domain, author and subject groups now start as null, meaning not fetched; an empty list only ever comes from the server. The count summary tolerates the null. Tests render the hook and check no Channels group exists before a search or while the request is pending, and that the "No results" row and real rows still appear after the response. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
86d4bbab98
|
Bookmarks: a nav menu of user links, stored in bookmarks.yaml (#729)
* bookmarks: user bookmarks in the navigation bar, stored in bookmarks.yaml A "Bookmarks" tab between Calculators and Flasher opens on hover or click and lists the user's bookmarks, with directories as flyout submenus (Menu.Sub). The mobile hamburger shows directories as labelled groups instead. The tab always shows, with an "Edit bookmarks" item, so the feature can be found before any bookmark exists. Bookmarks live in a new config-only bookmarks.yaml: a nested tree where a node with `children` is a directory and a node with `url` is a bookmark. Every node carries an integer id so the API can rename, move and delete without relying on names. A URL may be a path on this WROLPi, a port on this host (`:8096/` or `http://:8096/`, resolved against whatever hostname the browser used, so it works over LAN IP, mDNS and the hotspot alike), or an absolute URL. Each bookmark chooses whether to open in a new tab. The editor at /more/bookmarks follows the playlist items table: numbered rows, up/down/edit/delete, and inline "Add a bookmark" / "Add a directory" forms with a Location picker. Deleting a directory asks first, since its contents go with it. The menu and the editor are in the component gallery. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * bookmarks: mark More current when the editor is folded inside it; rem indents in the mobile menu Found on the QA Pi at a width where Bookmarks overflows into More: the More button carried no mark on /more/bookmarks, because the bookmarks link has no `to` for isLinkActive to match. isLinkActive now knows the bookmarks link owns its editor page, and the Bookmarks tab uses the same rule. The mobile menu's directory labels sat a few pixels left of the labels around them: the indent was in em, and a Menu.Label is set smaller than a Menu.Item. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * bookmarks: parse URLs instead of matching a prefix; validate the whole tree on every write The URL contract was checked by prefix on both sides, and a prefix is not a form: `//evil.com` starts like a path and `:8096@evil.com` starts like a port, and a browser takes both off this host. A path is now a single leading slash that resolves to this origin, a port form is an optional scheme, `:`, digits and then only a path, query or fragment (no `@`, no backslash, no control characters), and anything else must parse as http(s). The same rules live in wrolpi/bookmarks.py and BookmarksContext.js, with the same bypass strings in both test suites. The config's dataclass validator only saw that `bookmarks` was a list, so a hand-edited file or the generic config API could store a `javascript:` URL that the bookmarks API would refuse, and the nav rendered it. The config now validates the tree itself on every write and import: URL forms, exactly one of url/children, unique integer ids, boolean new_tab, and no cycles (a YAML anchor can build one). An invalid file is not imported and, since it was never successfully imported, is never overwritten. On the page, a stored URL that does not resolve is rendered as text, never as an href. A refused add keeps what was typed instead of clearing the form, and an edit whose move is refused still refreshes the nav (the rename was committed) and keeps the modal open. An IPv6 host is bracketed before a port. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * help: document Bookmarks Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
9caa140fb6
|
app: search Other tab distinguishes pending channels from none (#728)
* app: search Other tab distinguishes pending channels from none useSearchChannels initialised channels to [] and loading to false, and only set loading inside its mount effect, so the Other tab's first render painted "No Channels" with a count of 0 before the search had run. A failed search was rendered the same way, since the hook had no catch and the API helper returns undefined on a non-OK response. Invariants now enforced and tested: * null = pending, undefined = fetch failed, [] = no channels; loading is true from the first render because the search starts on mount. * Changing tags resets to pending, and only the newest request may write state, so a slow response for previous tags is dropped. * The tab shows a loader while pending, an error on failure, and "No Channels" only once the server confirmed there are none. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * app: useSearchChannels follows its caller's tags The hook read its tag argument only as the initial state, so the Other tab never refetched when the filter modal changed the URL's tags while the tab stayed mounted; the list, including a stale "No Channels", was whatever the mount fetched. The internal tag state now follows the argument's contents, keyed on the serialized list because the caller builds a new array every render. Tests change the hook argument after a loaded result and expect a second search and a reset to pending, check that a new array with the same tags does not refetch, and keep the slow-response drop. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
83362a65c1
|
app: video comments and description distinguish pending from empty (#727)
* app: video comments and description distinguish pending from empty useVideoExtras initialised both values to null and the page treated null the same as "none", so every video open (and every prev/next) showed "No comments have been downloaded. Refresh the video?" with a live Refresh button until the comments arrived. The Description tab showed "No description available." the same way. A failed fetch was also rendered as "none", and the previous video's data lingered until the next video's responses arrived. Invariants now enforced and tested: * null = pending, undefined = fetch failed, [] / '' = loaded and empty. The API serialises a missing info_json field as null, so the hook normalises that to empty; consumers never see null for "none". * Switching video resets both to pending, and a slow response for the previous video is dropped. * The page renders a placeholder while pending, an error message on failure, and offers Refresh only once the server confirmed there are no comments. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * app: useVideoExtras drops any request that is not the newest Matching on the video id let a stale request land when the user left and returned to the same video, or saved an edit while the page's first description request was still in flight. Each fetch now takes a generation number and only the newest generation may write state, per value, so a description refetch never invalidates a comments request. The edit dialog adopts a description that arrives while it is open only if the user has not typed, so a draft written over the placeholder is not wiped by the empty or failed value that follows. Tests: 7 -> none -> 8 -> 7 with the first request rejecting last; a save refetch beating the initial request; the draft surviving; the pending placeholder asserted; the console spy restored in finally. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
25e00f75fd
|
app: useLatestRequest reports loading before the debounce (#725)
`loading` only became true once the debounce timer fired, so for the first 300-500ms after every sendRequest the hook reported data=null, loading=false. Consumers branch on `loading` to choose between a spinner and an empty state, so the global search dropdown, the Zim search tab, and the map search rendered "No results" (or "No Zims have been indexed") for a search that had not run yet. Invariants now enforced and tested: * loading is true from sendRequest until the latest request settles. * The latest-request id is claimed synchronously in sendRequest, so a fetch already in flight is stale the moment a newer request is sent and cannot write data or clear loading during the new debounce. * A stale request that rejects late no longer nulls newer data. * A pending debounce is cancelled on unmount, and any in-flight fetch is treated as stale. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
5358c75a2b
|
Jobs queue, video Edit dropdown: transcode/remux, edit title & description (#711)
* Jobs queue and video transcode from the video page Add an in-memory FIFO Job queue (wrolpi/jobs.py): functions register by name, callers enqueue by name with JSON kwargs, and the perpetual-tasks owner runs them one at a time. Logging emitted inside a Job and the output of its subprocesses are captured into a per-Job log; Jobs report progress and can be cancelled (a running Job's subprocess is killed). Jobs are not persisted; an API restart forgets them. Add a transcode Job and POST /api/videos/<id>/transcode. ffmpeg reports progress via -progress pipe:1. The existing FileGroup is pointed at the new file before the refresh so tags and history survive a container change. Replace the video page Refresh button with an Edit dropdown (Refresh, Transcode). The Transcode modal pre-fills from the preferred codecs in the Videos settings and shows the Job's progress, log tail and Cancel. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Video page: move Delete into the Edit dropdown The Edit dropdown now holds Refresh, Transcode and Delete, so the action row is Download / Edit / Add to Playlist. Delete keeps its confirmation (a Confirm dialog) and the tagged-files prompt, and is disabled in WROL Mode like the other items. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Video page: Edit > Edit to change a video's title PUT /api/videos/<file_group_id> writes the title (and fulltitle) into the Video's info json, creating the file when the video has none, then validates the Video again so its title and search text follow. Files stay the source of truth: the edit survives a refresh, and a re-download of the metadata replaces it (the modal says so). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Video page: edit the description in Edit > Edit PUT /api/videos/<file_group_id> now takes optional title and description and changes only the fields given; an empty description clears it. The modal sends only what changed and refetches the description after saving. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Transcode modal: allow a container-only remux Keeping both streams is no longer refused: ffmpeg copies them into the chosen container with fast start. The container select starts at the file's own container, the hint explains what a remux does, and the button reads Remux when nothing is re-encoded. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * WROL Mode: refuse video edits and transcodes past the API The routes already returned 403, but a transcode Job queued before WROL Mode was enabled still ran, update_video could be called from anywhere, and the Edit modal's Save button stayed live. The Job now fails before ffmpeg starts, update_video carries wrol_mode_check, and the modal disables Save with a notice. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Transcode: verify the output before replacing the original After ffmpeg exits, the output is probed and compared with the source (container, presence of video/audio streams, codec per stream, duration within 1s or 1%) and its last 30 seconds are decoded to a null output. Any failure removes the output and keeps the original. The tail decode replaces the download-time completeness check, which grabbed a single frame near the end: one ffmpeg_video_complete (sync for the downloader, async for Jobs) now serves both, and is stricter, since every frame of the tail plus the audio must decode. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Jobs: claim atomically, cap pending, own-session subprocesses; retarget before delete Review fixes. The worker read a Job's status and wrote RUNNING as two steps, so a cancel landing between them was overwritten and the Job ran; _claim_job now does both under the lock. enqueue_job refuses past JOB_PENDING_LIMIT pending Jobs. JobContext.run_command starts the subprocess in its own session and raises CancelledError when the command was killed, so a handler cannot mistake it for a normal exit. The transcode Job now retargets the FileGroup (committed) while the original file still exists, and deletes the original afterwards (transcode_video_file gained keep_original). A crash in between leaves both files with the database pointing at a real one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Video page: the Edit menu owns the transcode Job; polling survives Close and errors Review fixes. The transcode Job's id and polling (useJob) move from the modal to VideoEditMenu, so closing the modal no longer stops the page from learning the Job finished (and refetching the video, whose path may have changed). While a Job runs the menu shows Transcoding..., reopening shows its progress instead of the form, and Delete waits. A failed poll request is retried instead of ending the polling. getJobs checks the response like its siblings. Design narration trimmed from comments. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Transcode: audio files, and Remove video to turn a video into an audio file video_codec 'none' (REMOVE_VIDEO) drops the video stream (-vn, audio required); an audio-only source is audio-only output whatever the video target says, and a video target on it is refused. Audio output gets an audio container (m4a/ogg/mp3) checked against the codec it will hold, and a video container asked for audio output is replaced by the codec's natural one. faststart only for mp4/m4a. Verification learned the audio-only shape and ffprobe's container names (mkv reports matroska). The modal offers 'Remove video (audio only)', switches to audio containers and follows the audio codec, reads Extract Audio, and opens for audio files (no video codec select). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Video page: yellow Edit button Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Transcode: remove the temp output on cancel; tell the user when a closed-modal Job fails or is lost Review fixes. The temp output was only removed on Exception, but a cancelled Job's run_command raises CancelledError (a BaseException), so a cancel left the partial file for a refresh to index. Closing the modal during a Job that then failed showed nothing; it now toasts the error (and a cancellation). Polling gives up on a 404 or five failures in a row (the API restarted and forgot its Jobs), warning the user and unlocking Transcode and Delete instead of reading Transcoding... forever. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Jobs: _claim_job docstring states the invariant, not the old race Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Transcode: fragmented mp4 option for very long videos A whole-file mp4 index grows with the frame count; for a many-hour video it reaches tens of MB, which a phone must fetch and parse before the first frame, and every seek walks it. `fragmented` writes the file with -movflags +frag_keyframe+empty_moov+default_base_moof+global_sidx and 10s fragments: a ~1 KB moov plus a global seek index, so playback starts and seeks quickly. mp4/m4a only; a remux, so it takes seconds per GB. Measured on an 11-hour h264/aac file: moov 40 MB -> 1.4 KB, seek index 2 x 120 KB, 10k fragments, duration and codecs unchanged, remux in 25s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Transcode modal: the Fragmented label says audio for audio output Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * Video edits keep the original: custom_title/custom_description in the info json's wrolpi section Editing a title or description no longer overwrites yt-dlp's keys. The values go into the info json's `wrolpi` section (custom_title, custom_description); the modeler prefers them when deriving the Video, so a refresh keeps the edit while the original title and description stay in the file. An empty custom description means none. Both re-download paths carry the wrolpi section over: the metadata-only refresh merges it into the fresh info json, and a full re-download snapshots it before yt-dlp overwrites the file and restores it (then re-validates) in finalize_download. FileGroup.get_wrolpi_json reads it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
d701846254
|
Hotspot captive portal: welcome page with the WROLPi's address (#709)
* Hotspot captive portal: welcome page with the WROLPi's address
Devices that join the hotspot probe a fixed plain-HTTP URL to check for
Internet access. NetworkManager's shared-mode dnsmasq now resolves those
probe hostnames (and `wrolpi`) to the hotspot address via an include the
Controller writes when the hotspot starts, the probes land on the Controller
(port 80), and are redirected to /portal: "This is your WROLPi, its address is
10.42.0.1, open WROLPi / open the Controller".
Only the probe hostnames are hijacked so the hotspot keeps forwarding
everything else when the WROLPi has an uplink. The include is corrected and
the connection re-activated once if NetworkManager picked a non-default
subnet. Writing the include is best-effort; the hotspot starts regardless.
`hotspot.captive_portal` (controller.yaml, default true) toggles the probe
redirects and the include; like the other hotspot settings it applies on the
next hotspot start, so saving while the hotspot is up reports
restart_required and both Controller UIs say so. The status endpoint gains
the hotspot address and portal URL, shown in both UIs. repair.sh only
creates the include directory so a disabled setting survives upgrades.
Native only: Docker has no hotspot.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* captive portal: address review
* React: re-read the hotspot address whenever the HotspotToggle starts or
stops the hotspot, matching the fallback UI's refresh-on-status.
* A WPA3 start that reverts to WPA2 leaves the hotspot up, so it now gets
the captive portal address correction too.
* The corrective `nmcli connection up` logs a warning on a non-zero exit;
dnsmasq would otherwise keep the old address silently.
* HotspotStatusResponse.captive_portal defaults to true like everywhere else.
* Trim design narrative from the module docstring and React comments.
* The include-before-activate test now observes the include at the moment
of the `nmcli ... hotspot` call instead of inferring from call order.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* captive portal: never forward any record type for probe hosts
`address=` only synthesizes A/AAAA answers. iOS also asks for the HTTPS
record type; dnsmasq forwarded that upstream, Apple's answer aliased to
captive.g.aaplimg.com, and the phone fetched the genuine "Success" from
Apple instead of the portal. Each probe host now also gets `local=`, so
dnsmasq answers every type itself, and the CDN alias is a probe host too.
Observed with dnsmasq query logging on a hotspot with an eth0 uplink.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* captive portal: let acknowledged clients finish so the OS offers "Done"
A phone's sign-in sheet only shows "Done" once the OS re-probes and gets
the genuine success reply. The Controller now remembers each client (by
hotspot IP) that has loaded the portal page and answers its later probes
with the reply each OS expects (Apple "Success" HTML, Android 204, Windows
"Microsoft Connect Test", ...). The set is cleared when the hotspot
starts so a new session shows the page again.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* captive portal: acknowledge on Continue, not on view
iOS fetches the portal page in the background before opening the sheet,
then re-probes after the sheet's own navigation. Acknowledging on view
made that re-probe return "Success" as the sheet's content. Phones only
re-check the network (and offer "Done") after a full-page navigation, so
the welcome page now has a Continue button leading to /portal/continue,
which is the only place a client is acknowledged. The Continue page says
to tap Done and links to WROLPi and the Controller.
Also hijack clients3.google.com (older Android probe host).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* help: bump wrolpi-help to the captive portal and Speed Test docs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* captive portal: comments describe the Continue design; clear acks on stop
Three comments still described acknowledging on view, which
|
||
|
|
91f92683ba
|
Speed Test calculator: ping, download, and upload against the API (#708)
* Speed Test calculator: ping, download, and upload against the API Users asking whether the hotspot is fast enough, or how many people can stream video at once, had no objective tool that works offline. This adds one under Calculators > Tools & Reference. Backend (wrolpi/speedtest.py, /api/speedtest): * ping: tiny no-store JSON the browser times for round trip latency. * download: streams slices of a pre-built random buffer for a clamped number of seconds (0.1-15). Random so no proxy or compression can flatter the result; slices so no bytes are generated per request. * upload: streaming-body handler that reads and discards the body and reports bytes and elapsed time. Requests over 8 MiB are refused. * info: the client address Caddy forwarded plus how many tests are in flight. Sanic now trusts one proxy hop (PROXIES_COUNT = 1) so request.remote_addr is the browser, not Caddy. Nothing else read that field before. There is deliberately no one-test-at-a-time lock. Several people testing at once is how a user learns how the link shares between streamers, so the server only counts in-flight tests (a shared_ctx Value created pre-fork so every worker sees the same number) and the UI reports how many others ran alongside. Both Caddyfiles route /api/speedtest/* unbuffered, as the SSE chat route already is, so throughput is measured live rather than in bursts. Frontend: * speedtest.js holds the logic with no React in it (bucketed throughput, steady-state average that drops the first second, ping stats, stream-capacity tiers, hotspot address check) so Jest drives it with fake fetch and streams. * SpeedTestCalculator runs 20 pings, a 10 s download, and a 10 s upload with a live figure and rolling trace, then shows Mbps and MB/s, ping verdict, the client address with a hotspot badge, and a table of how many 480p..4K streams the download speed supports. Upload is sequential POSTs of one 4 MiB Blob because Safari cannot stream a request body. Nothing is saved. * Sparkline is a new inline-SVG ui component (no chart dependency), added to /theme-sample and checked in all four themes. A missing sample is a gap, not a plunge to zero. The page states that it measures the network path, not the drive that actually serves video files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * speedtest: bound uploads in time, close the Start race, address review Review on #708 found three bugs and some polish: * Start could be clicked again while the first fetchInfo() was still pending, because the phase only left `idle` after that await. A second click made a second AbortController and a second concurrent run, and Cancel only reached the later one. start() now refuses when a controller already exists and enters a `starting` phase synchronously, so the button is Cancel before the first await. * The upload phase could overrun the advertised 10 s by however long the in-flight 4 MiB POST took: on a slow hotspot, the very link this tool exists for, that is tens of seconds. runUpload() now aborts the in-flight request at the phase deadline (counting only completed requests) and uses 1 MiB bodies so a few-Mbps link still completes several inside the phase. * The server upload sink was capped by size only, so a slow or paused body could hold a worker until 8 MiB eventually arrived. It now also enforces MAX_UPLOAD_SECONDS (15 s, matching download) and answers 408 when exceeded. Both caps together are what "cannot hold a worker" actually requires. Also: a NaN `seconds` now gets a NaN message instead of "must not be negative"; the module, Sparkline, and gallery docstrings are trimmed to purpose; and the upload counter is covered for the mid-stream 413, the 408, and a broken body. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
e0fdb762fc
|
zim: recompute the outdated-Zims flag after startup and on Delete (#706)
* zim: recompute the outdated-Zims flag after startup and on Delete The Dashboard shows the "Outdated Zim Files" banner from the persisted `outdated_zims` flag, but the modal lists files from a live scan. The two disagreed: the flag was restored from the DB on startup and never recomputed once the outdated file had been removed, so the banner showed with nothing to delete, and the modal displayed an endless placeholder. - The startup recompute now runs after the DB is up and configs are imported, so it overrides the value `init_flags` restored from the DB. Previously it ran before the DB was up, and the restored value won. - DELETE /api/zim/outdated always recomputes the flag, even when nothing was deleted, so clicking Delete clears a stale banner. - The modal re-scans when opened and says when nothing was found instead of showing a placeholder. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * zim: enforce restore-then-scan ordering; no Kiwix restart on empty delete - Startup calls flags.init_flags() then flag_outdated_zim_files() right after db_up, before the DB config import, so the disk scan always overwrites the DB-restored value in one local sequence. - DELETE /api/zim/outdated recomputes the flag in a finally. - remove_outdated_zim_files skips refresh and the Kiwix restart when no file was deleted. - Trim narrative comments to the constraint they enforce. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
8008cd1fd8
|
search: don't COUNT(*) OVER() the page query (#702)
COUNT(*) OVER() forced SQLite to visit every matching file_group before LIMIT, which is a full table scan of the (large) file_group table — ~22s cold on a Pi for videos ordered by download date. Count separately and cache for 60s by filters so indexed orders can stop after one page. Past-the-end pages now report the true total. Videos/archives/docs pagers keep totalPages across page changes. |
||
|
|
0b61d3ab08
|
videos: codec download preferences with transcode and strict-fail options (#701)
* videos: codec download preferences with transcode and strict-fail options
Add video_codecs/audio_codecs ordered preference lists to the video,
channel, and RSS downloaders, alongside the existing resolution
selector. Codec preference is codec-major: a preferred codec at a
lower resolution beats another codec at a higher resolution.
When 'transcode' is enabled, a downloaded file whose streams do not
match the preferences is re-encoded with ffmpeg after download (only
the mismatching stream; targets are extensible maps, currently h264
and aac/opus/mp3). When 'strict_codecs' is enabled and transcode is
not, a download with no matching format fails permanently instead of
falling back.
Empty codec lists produce a byte-identical yt-dlp command to before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: honor strict_codecs when transcode cannot produce the preference
Review fixes:
- strict_codecs is now suppressed only when transcoding can produce a
preferred codec for every non-empty preference list (new
transcode_can_satisfy_codecs); av1-only preferences with transcode
enabled fail under strict instead of silently keeping the file.
- No partial transcode: an unfixable video mismatch blocks an
audio-only conversion (and vice versa); the mismatch falls through
to strict/keep.
- fsync the transcode output before rename, and the directory after,
so power loss on a Pi cannot leave a truncated final file.
- mp3 yt-dlp pattern matches mp4a.6B as well as mp4a.6b.
- ffmpeg maps use 0:V:0 (excludes attached pictures) and 0🅰️0?
(tolerates missing audio).
- Strict toggle is no longer disabled by transcode in the UI; popup
wording corrected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: serialize transcodes machine-wide via shared_ctx lock
Sanic runs multiple worker processes, so an asyncio primitive cannot
serialize transcodes across download workers. A multiprocessing.Lock
in shared_ctx now guarantees only one ffmpeg transcode runs at a time
on the machine; waiting workers poll the lock asynchronously so the
event loop stays responsive while a transcode holds it for hours.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* videos: transcode-lock review fixes
- Document that a crashed lock holder requires an API restart; this is
accepted rather than adding owner-death recovery.
- Move the 'Transcoding' user notification to after the lock is
acquired, so it fires when work begins rather than before a
potentially hours-long wait.
- Soften the lock test docstring to what it measures (in-process
wait/release); trim the acquire docstring to the polling rationale.
- Give transcode_lock its own section header in contexts.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
e0ae71048a
|
build(deps): bump the npm-dependencies group in /app with 7 updates (#700)
Bumps the npm-dependencies group with 7 updates in the /app directory: | Package | From | To | | --- | --- | --- | | [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.1` | `14.6.6` | | [cypress](https://github.com/cypress-io/cypress) | `15.20.0` | `15.21.1` | | [esptool-js](https://github.com/espressif/esptool-js) | `0.6.0` | `0.6.1` | | [pmtiles](https://github.com/protomaps/pmtiles) | `4.4.1` | `4.5.0` | | [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) | `7.0.0` | `7.0.1` | | [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash) | `4.17.24` | `4.17.25` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.1.2` | `26.4.0` | Updates `@testing-library/user-event` from 14.6.1 to 14.6.6 - [Release notes](https://github.com/testing-library/user-event/releases) - [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/user-event/compare/v14.6.1...v14.6.6) Updates `cypress` from 15.20.0 to 15.21.1 - [Release notes](https://github.com/cypress-io/cypress/releases) - [Changelog](https://github.com/cypress-io/cypress/blob/develop/CHANGELOG.md) - [Commits](https://github.com/cypress-io/cypress/compare/v15.20.0...v15.21.1) Updates `esptool-js` from 0.6.0 to 0.6.1 - [Release notes](https://github.com/espressif/esptool-js/releases) - [Changelog](https://github.com/espressif/esptool-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/espressif/esptool-js/compare/v0.6.0...v0.6.1) Updates `pmtiles` from 4.4.1 to 4.5.0 - [Commits](https://github.com/protomaps/pmtiles/commits) Updates `@testing-library/jest-dom` from 7.0.0 to 7.0.1 - [Release notes](https://github.com/testing-library/jest-dom/releases) - [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/jest-dom/compare/v7.0.0...v7.0.1) Updates `@types/lodash` from 4.17.24 to 4.17.25 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash) Updates `@types/node` from 26.1.2 to 26.4.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@testing-library/user-event" dependency-version: 14.6.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-dependencies - dependency-name: cypress dependency-version: 15.21.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: esptool-js dependency-version: 0.6.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-dependencies - dependency-name: pmtiles dependency-version: 4.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-dependencies - dependency-name: "@testing-library/jest-dom" dependency-version: 7.0.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-dependencies - dependency-name: "@types/lodash" dependency-version: 4.17.25 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-dependencies - dependency-name: "@types/node" dependency-version: 26.4.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-dependencies ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> |
||
|
|
1cd47e4afd
|
build(deps): bump browserslist from 4.28.1 to 4.28.8 in /app (#695)
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.1 to 4.28.8. - [Release notes](https://github.com/browserslist/browserslist/releases) - [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md) - [Commits](https://github.com/browserslist/browserslist/compare/4.28.1...4.28.8) --- updated-dependencies: - dependency-name: browserslist dependency-version: 4.28.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> |
||
|
|
3842d6436f
|
build(deps): bump decode-uri-component and query-string in /app (#694)
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) to 0.5.0 and updates ancestor dependency [query-string](https://github.com/sindresorhus/query-string). These dependencies need to be updated together. Updates `decode-uri-component` from 0.4.1 to 0.5.0 - [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases) - [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.4.1...v0.5.0) Updates `query-string` from 9.4.1 to 9.5.1 - [Release notes](https://github.com/sindresorhus/query-string/releases) - [Commits](https://github.com/sindresorhus/query-string/compare/v9.4.1...v9.5.1) --- updated-dependencies: - dependency-name: decode-uri-component dependency-version: 0.5.0 dependency-type: indirect - dependency-name: query-string dependency-version: 9.5.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> |
||
|
|
61deb0a1ee
|
build(deps): bump fflate in /app (#696)
Bumps and [fflate](https://github.com/101arrowz/fflate). These dependencies needed to be updated together. Updates `fflate` from 0.8.2 to 0.8.3 - [Release notes](https://github.com/101arrowz/fflate/releases) - [Changelog](https://github.com/101arrowz/fflate/blob/master/CHANGELOG.md) - [Commits](https://github.com/101arrowz/fflate/compare/v0.8.2...v0.8.3) Updates `fflate` from 0.6.10 to 0.6.11 - [Release notes](https://github.com/101arrowz/fflate/releases) - [Changelog](https://github.com/101arrowz/fflate/blob/master/CHANGELOG.md) - [Commits](https://github.com/101arrowz/fflate/compare/v0.8.2...v0.8.3) --- updated-dependencies: - dependency-name: fflate dependency-version: 0.8.3 dependency-type: indirect - dependency-name: fflate dependency-version: 0.6.11 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> |
||
|
|
22cf798052
|
build(deps): bump fast-uri from 3.1.5 to 3.1.7 in /app (#693)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.7. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lrnselfreliance <33263708+lrnselfreliance@users.noreply.github.com> |
||
|
|
88d5d55283
|
files: left-align the preview toolbar (#698)
* files: left-align the preview toolbar, Open joins the button row The modal actions row packs its children to the right, which suits a confirming button but stranded the preview's toolbar. The toolbar now fills the row so its buttons read from the left, with Open first in the same row as Download. On phones the tags selector always takes its own line instead of squeezing in beside the buttons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * tags: the add-tag button is violet like every other tag button Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * share: a real button everywhere but the navbar corner The share trigger was a bare anchor-wrapped glyph, which matched the navbar's other corner icons but looked out of place beside real buttons in the file preview toolbar. It is an IconButton by default now; the navbar keeps the bare glyph via asIcon. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9b8ebdc9b3
|
files: readable text previews in dark themes (#697)
* files: readable text previews in dark themes A same-origin text document inherits the page's color-scheme, so dark themes rendered the browser's default light text on the preview's forced white background. Force a light color-scheme on the preview iframe so text is always black on white. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * files: text previews follow the theme Instead of pinning the preview iframe to a light scheme, follow the theme: dark, night, and amber render the document light-on-dark against the theme's panel color; light stays black-on-white. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
97a6566e3d
|
Fix API stalls: non-blocking switch worker, threaded searches, indexed Videos page (#678)
* Fix API stalls: non-blocking switch worker, threaded searches, indexed Videos page Three response-time fixes found by benchmarking the read-only API endpoints hot and cold on real libraries: * `switch_worker` runs on the Sanic worker's event loop every 0.1s and called the blocking `multiprocessing.Event.wait(timeout=1)`. With no switch pending (the normal state) this froze the loop for a second at a time; about a third of all requests, including trivial ones, took ~1,000 ms. It now polls `is_set()` and returns immediately. * The search handlers (videos, files, archives, docs, zims) and `/api/statistics` ran synchronous SQLite queries on the event loop, so one slow search stalled every other request on that worker. They now run via `asyncio.to_thread`. * The unfiltered Videos page (`POST /api/videos/search` with only an order) used `mimetype LIKE 'video/%' OR ...` plus `COUNT(*) OVER()`, which visits every video's index entry before LIMIT applies: seconds cold on a large library on a spinning disk, and ~10 s hot for a deep page. It now seeks each exact mimetype on the (mimetype, effective_datetime) index for `limit + offset` rows and merges them, with the total from a separate count cached for 60 s. Results are identical to the generic query. Also adds `scripts/api_perf.py` (times every read-only endpoint against a host, optionally restarting the API over SSH for cold numbers) and `scripts/api_perf_compare.py` (merges per-host results). Host profiles live in a gitignored JSON file; an example is included. Measured on an 8-core docker host with a 10 GB library: median hot request 1,007 ms -> 22 ms; Videos page 1 hot 287 -> 108 ms, page 50 1,345 -> 120 ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serve the video description from its own endpoint instead of Video.__json__ `Video.__json__` computed `description` as `c_text or get_video_description()`, where the fallback opens and parses the video's info json from disk. The list endpoints serialize every result with `__json__`, so any page containing videos without an indexed description (common: many videos have none) read their info json files on every request, cold, for a field the list never displays. The description is not part of search either: search runs against the FTS copy of `c_text`, so the fallback never fed the index. Drop `description` from `__json__` and serve it from `GET /api/videos/<id>/description`, like comments and captions. The endpoint returns the indexed `c_text`, falling back to the info json only there, for a row indexed before descriptions were stored. The player page fetches it through `useVideoExtras` alongside the comments. Tests assert the list and detail responses never touch the info json, and cover the endpoint's indexed, fallback, and not-found cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review: thread-local zim sessions, fast-path equivalence test, comments * Zim search, single-zim search, and search estimates run libzim off the event loop, each opening its own session inside the worker thread rather than passing the request's SQLAlchemy Session across threads. * The Videos fast-path test now runs the same requests through the generic query and asserts identical rows and paging for both date orders. The one intentional difference is documented: past the end, the fast path still reports the true total. * switch_worker returns before its try block so the empty path does not clear the event; its comment states the invariant only. The copied "synchronous SQLite" comments are reduced to one line in one place. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e66e1e9309
|
app: the navbar corner wrapped onto a second row on small phones (#672)
* app: the navbar corner wrapped onto a second row on small phones Every status-icon slot carried a 1.5em separator margin -- ~80px across three slots -- which wrapped the mobile bar's whole corner onto a second row on any screen narrower than about 380px. The margin is now 0.25em on both bars: the corner's gap and the slots' own padding still space the icons, and on desktop the freed room just goes back to the links that useOverflowNav folds into "More". The mobile bar is extracted as MobileNav (mirroring DesktopNav) so a Cypress suite can mount it and hold it to one row at phone widths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review: prove 320px with a faithful fixture, trim the CSS narration The suite claimed 360px covered narrower devices because the fixture was wider than production, but the fixture also lacked the real theme toggle's 0.8em right margin, so the safety margin was overstated. The fixture now matches a phone -- 32px logo home link, the toggle's margin, and a coarse pointer so the search link drops its keyboard hint, with a test asserting the hint is really absent -- and 320px is measured directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9278103554
|
e2e: exercise the FileBrowser footer buttons against real files (#670)
* e2e: exercise the FileBrowser footer buttons against real files Sixteen Cypress tests covering Rename, Move, Delete (including the tagged-file force-delete flow), Ignore/Unignore, Upload, and scoped Refresh, with no API mocking: fb:* tasks seed a cypress-fb/ sandbox inside the media directory, the UI mutates it through the real API, and assertions check the disk as well as the table. Skipped in CI, which runs only the React dev server. Background jobs (move, refresh) only queue work and worker_status reads idle while jobs wait, so cy.refreshFiles / cy.waitForEvent poll the events feed for completion events newer than the server's clock, and fail loudly when one never arrives. Every suite cleans up unconditionally: sandbox files, DB rows via a final refresh, test tags and their tags/ directories, and any leaked ignored-directory config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * e2e: act on review — drain stragglers, teardown that cannot be skipped Events carry no job id, so waitForEvent cannot prove WHICH job finished. The rename tests now drain the parent-directory refresh their rename queues before ending, so the next test's refresh wait cannot be satisfied by a straggler while the DB still reflects the old tree; move waits match the destination in the completion message. Teardown must always reach the disk wipe: a failed command aborts the rest of its hook, so the tag cleanup and the DB-purge refresh are now best-effort (failOnStatusCode: false, no assertions), and the unignore runs after every test instead of once per spec. History and forward-looking product notes trimmed from comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b46faa005a
|
ui: a download form that emptied itself after every submit (#665)
* ui: a download form that emptied itself after every submit A successful submit reset the whole form, so adding a second download meant re-choosing the tags, the destination and the video settings that the first one had already been given. It now empties only the URLs. Everything else stays for the next download, and a Clear button beside Download puts the form back to its defaults when the next one is unrelated. `useForm` gains `clearPathsOnSuccess`; `clearOnSuccess` is untouched for the forms that want the whole reset. * review: the one download form that kept its URLs Scrape took a `clearOnSuccess` prop that no caller passed, so mapping it to the URL paths left create-mode scrape clearing nothing. It follows the same rule as Videos and Archive now: the edit form supplies `onSuccess`, and only that keeps the form as it was. `clearPaths` also wrote every path it was given, so a `urls` form gained a `url: ''` it never declared -- which the comment beside it and the test named for it both said it would not do. It skips a path neither the defaults nor the form has, and the test asserts the key is absent. |
||
|
|
4c71e84696
|
ui: two upload dialogs that had drifted apart (#664)
* ui: two upload dialogs that had drifted apart The dashboard and the file browser each had their own upload component: two dropzones, two overwrite toggles, two copies of the progress-bar rendering, against the same `useUploadFile` state. The file browser's dialog was made fullscreen with a drop target that grows; the dashboard's stayed a `large` dialog with a strip to drop onto, because it was a different file. They differ in one thing that is real: the file browser knows the destination because the user selected it, and the dashboard has to ask. That is now the `destination` prop -- `undefined` asks, a string uploads there, and '' is the media directory rather than a missing destination. Everything else is one component, so the next change to either entry point lands on both. * review: an upload dialog that looped on the media directory Pressing Upload in the file browser with nothing selected passes `''` -- the media directory. Three checks along that path read it as "no destination": * The clearing effect ran, and `doClear` is a new function every render whose own setState scheduled the next one. React ended it with "Maximum update depth exceeded", 16 warnings deep, on the default Upload path. The effect now clears only while asking, and `doClear` is stable. * The title read `Upload to: /media/wrolpi/`. * `doUpload` bailed, so a drop on the media root uploaded nothing. All three predate this branch; moving the dialog is what put them behind one component. Tests cover the title, what the hook is told, and that a fixed destination is never the thing that empties the queue. |
||
|
|
82fdf9dd92
|
ui: an upload target smaller than the dialog around it (#663)
The upload dialog was 682x367 on a 1440x900 desktop -- 47% of the width, 41% of the height -- and the drop target inside it was a strip about 90px tall. A small thing to drag a file onto, with the file browser it came from covering most of the screen behind. Upload and Move ask for `fullScreen`, which is edge to edge on a phone and 90% above the tablet breakpoint: filling a desktop reads as the app having been replaced rather than as something opened over it. Move holds a DirectorySearch above a row per path being moved, which grows with the selection; 620px showed a handful and scrolled the rest. Two things had to follow. Mantine sizes a fullscreen dialog's content to the viewport but leaves the body at its content height, so the footer landed two thirds up the screen with a quarter of the viewport empty beneath it -- the body takes the leftover now and the actions ride to the bottom of it. And the drop target itself grows into the room, with a floor so it stays large once progress bars claim their own space. Rename and the confirmations keep their sizes: a fullscreen dialog for one text field or one sentence reads as broken. New Folder keeps `large` too -- it is a text field and a path preview, the same shape as rename, not the directory picker its name suggests. `size='fullscreen'` is a DIFFERENT thing -- full width, height from the content, which is what a log or a preview wants. Both are documented where the sizes are declared, and the gallery grows a fullscreen example so the difference is visible in all four themes. 339 component tests pass, 69 suites / 1214 jest tests pass, npm run build is clean. |
||
|
|
67c3afbec9
|
review: the 375px phone this fix had not been measured on (#662)
* review: the 375px phone this fix had not been measured on Answers the five findings on #660. * Recurring's four fixed columns wanted 416px between them, so at 375px the URL column got exactly 0 and the table overflowed again -- the same bug as before, moved down from 540px. Widths are phone-first now, in col-* classes, widening at the tablet breakpoint. * The in-progress button in Completed At carried "5 MBps / 3m44s" -- 169-188px measured, against 124px of column. It shows the percent; the modal it opens already lists speed, ETA and bytes, and the full text is on the button's title. * The select column was 2.5em: 16.5px of content for a 22px checkbox, which hung into the URL column. 3em holds the checkbox and its padding. * The Cypress fixtures were a raw <table>, so tableLayout would read 'fixed' even with the selector that loses to Mantine -- the test could not fail for the reason it claimed. They mount SortableTable, Table.HeaderCell onSort and a real Checkbox now, and the header check measures the text rather than skipping cells with no element child. * Comments trimmed to what cannot be recovered from the code. Fixing the first surfaced one more: once URL became the small column, its own header overran -- "URL" and its arrow need 72px against 58 -- and a single word cannot wrap. A header is clipped by its column as a last resort. Measured at 375px: once 46/58/123/146, recurring 44/100/104/57/69, nothing overflowing. 69 suites / 1214 jest tests pass. The Cypress tests here are NOT verified: the component runner does not start in this repo (see the separate cypress investigation), so every layout claim above rests on direct measurement in Chrome, not on that suite. * review: the 375px phone this fix had not been measured on Answers the five findings on #660, and now runs against a component suite that can actually execute (#661). * Recurring's four fixed columns wanted 416px between them, so at 375px the URL got exactly 0 and the table overflowed -- the same bug as before, moved down from 540px. Widths are phone-first now, in col-* classes, widening at the tablet breakpoint. * The in-progress button in Completed At carried "5 MBps / 3m44s", 169-188px against 124px of column. It shows the percent; the modal it opens already lists speed, ETA and bytes, and the full text is on the button's title. * The select column was 2.5em: 16.5px of content for a 22px checkbox, which hung into the URL column. 3em holds the checkbox and its padding. * The fixtures were a raw <table>, so tableLayout read 'fixed' even with the selector that loses to Mantine -- the test could not fail for the reason it claimed. They mount SortableTable, Table.HeaderCell onSort and a real Checkbox now. * Comments trimmed to what cannot be recovered from the code. Two defects the restored suite caught, neither of them findable before it ran: * The elastic URL column ends up narrower than its own header -- "URL" and its arrow need 72px against 58 -- and one word cannot wrap, so the column clips. A sized column must fit its label; only the elastic one may clip. That is what the header test asserts, per kind of column, rather than measuring text and calling it the label: the first version measured the text alone, missed the sort arrow, and passed while the button overran by 14px. * Recurring's Control column was 4.5em, 70px of content for a 78px "Control". Sized for the word rather than the button it holds: 5.2em. At 375px: once 46/58/123/146, recurring 33/100/104/57/80, nothing overflowing and no sized header clipped. The URL column there is 33px -- the accepted trade for keeping every column on a phone. 336 component tests pass, 69 suites / 1214 jest tests pass, npm run build is clean. * review: a header assertion that stopped at the padding box Three findings on #662. * The sized-header check compared scrollWidth to clientWidth, which is the padding box, so a label could overflow its content box into the padding and pass -- the same class of miss the 4.5em Control failure was meant to close. It measures the label's longest word (plus the sort arrow, where there is one) against the content box now. That found two more columns tuned to their exact limit and clipping by a pixel: Download Frequency wanted 79.4px of a 78.1px box, Completed At 82.3 of 81.9. Every sized column now has margin over its own label. * SortableTable stopped applying `width`, leaving dead keys at the Tags and Zim call sites. Removed, so a column is sized one way. CollectionTable's `width` is NOT dead -- it converts sixteenths to percentages -- so Channels, Docs, Playlists and Archive keep theirs. * Comments trimmed again: the percent button, the header clip, the ButtonGroup rule and the Cypress helper. 336 component tests pass, 69 suites / 1214 jest tests pass. * ui: a phone header short enough to leave room for the URL Five full-length headers do not fit a phone. Sized so each held its own label, the recurring URL column came out 20px -- narrower than one character and its ellipsis, a column present but carrying nothing. A long header carries both forms and `.label-*` shows one: Freq and Done below the tablet breakpoint, Download Frequency and Completed At above it, where there is room. The mobile widths are sized for the short word. At 375px the URL column goes from 20px to 105px on the recurring table and 54px to 97px on the once table. Nothing overflows, nothing clips, and every column stays. 336 component tests pass, 69 suites / 1214 jest tests pass, npm run build is clean. |
||
|
|
1867382d7b
|
test: the component suite has not run since cypress 15, on any machine (#661)
* test: the component suite has not run since cypress 15, on any machine Six component spec files -- 334 tests, including the 4,300-line layout suite -- could not start. Cypress exited before printing anything, and under its default Electron browser it did not even do that: it hung at 0% CPU until killed. Chrome prints the error in seconds, which is the only reason this was findable. Three things broke, all of them from the 13.17.0 -> 15.20.0 bump. `baseUrl: "src"`. Cypress 15 loads its config with tsx, and tsx honours tsconfig path resolution. Cypress requires Node's core `events` module; with that baseUrl the bare specifier resolves against `src`, and on a case-insensitive filesystem it matches `src/Events.js`. That file imports the app, so the whole component tree was required in Node: JSX in `.js` files, which esbuild will not parse. This is why CI stayed green while every local run died. Linux is case-sensitive, `events` never matched `Events.js`, and it resolved to the built-in. The baseUrl buys nothing -- there is not one absolute import in the codebase -- so it goes. `framework: "create-react-app"`. Cypress 14 removed that dev server; the handler is in the 13.17.0 binary and absent from 15. The webpack config is handed over explicitly now, taken from react-scripts so components compile exactly as `npm start` compiles them. Two things follow from that: `webpack-dev-server` 5 is a devDependency because Cypress requires 5 and react-scripts pins 4 (npm nests react-scripts' copy, and the production build is unchanged), and babel-loader is widened to `cypress/`, which react-scripts does not compile -- the support file is JSX and was reaching babel with no preset. The suite ran nowhere. `cypress run` means e2e -- its specPattern is `cypress/e2e/**` -- and these specs sit beside the components in `src`, so no job has ever executed them. A `cypress-component-tests` job runs them now; without it this breaks again in silence. One test fails the moment the suite can run, and it is mine, from #660: it asserted the URL column is the widest in the row. It is not, and never was -- at that width the fixed columns take ~307px of 400, leaving the URL ~93 against Control's 146. It shipped green because nothing could run it. It asserts a usable remainder instead. 334 component tests pass, 69 suites / 1214 jest tests pass, `npm run build` is clean. * review: an assertion with a floor, and comments that told the story instead of the rule The leftover-width test had a lower bound and nothing else, so it passed on any column wide enough to have padding -- including one that had grown, which is what losing table-layout: fixed does. It measures the leftover now: the sized columns are held to the widths they declare, and the truncating column to exactly what they did not take. Comments in the config, the CI job and the spec cut back to the rule a reader needs. The version history and the measurements are in the PR. |
||
|
|
0ae8bd5442
|
ui: a downloads table wider than the phone showing it (#660)
The two downloads tables came out 676px and 727px in a 540px window, so Completed At and
Control sat off the right edge and their buttons could not be reached without scrolling
sideways.
`.table-ellipsis` was trying to truncate under `table-layout: auto`, where a table is as
wide as its columns want to be and the container simply overflows. The rule leaned on
`max-width: 0` to force an ellipsis and `min-width: 25em` to keep the text column readable
-- but 25em is 385px, and a floor wider than the screen makes the overflow certain.
Fixed layout inverts it: the columns divide the table's width and the column marked
`column-ellipsis` gives up what it cannot show. Neither the max-width trick nor the floor
is needed then. Columns that must fit something exact -- a checkbox, a group of buttons --
state a width; the rest is the truncating column's.
`table.table-ellipsis`, not the bare class: Mantine sets `table-layout` on its own
`.mantine-Table-table`, the same specificity and later in the cascade, so the first version
of this lost silently. Every other property in the rule applied, which made the table look
fixed while it stayed auto -- the recurring table got *wider* than before, not narrower.
Three things the rework then had to answer for, each of them measured in the browser:
* A header overran its column: "Completed At" printed on top of "Control", because
`.wrolpi-th-sort` is nowrap so a label never splits from its arrow. It wraps here, and
needs `min-width: 0` as well -- the label is an anonymous flex item whose automatic
minimum is its longest word, so without it the label still refuses to go below
"Completed" and still overruns.
* A grouped button was squeezed: `ButtonGroup` is a nowrap flex row whose children shrink,
and the Stop button on a pending download came out 23px against its neighbour's 40 --
clipped, and too small to hit. The columns are sized for the widest group a row can
have, so the buttons no longer give.
* Status's Top Processes table had the same bug. It already asked for `.table-ellipsis`
and already marked its Command cell, so it overflowed a phone too (548px into 503px).
The same rule fixes it, with widths for the three short readings.
A wrapped header is the fallback, not the layout, so every column is sized to hold its own
label on one line -- including on a phone. "Download Frequency" is the exception at 11.7em,
which a phone cannot spare beside four other columns, so it takes a class that widens at the
tablet breakpoint and wraps below it.
Measured, nothing overflowing, no header overrun, no clipped cell, no shrunken button:
540px once 40 / 208 / 146 / 146 recurring 123 / 108 / 131 / 62 / 116
1400px once 40 / 1033 / 146 / 146 recurring 871 / 185 / 131 / 62 / 116
Desktop gains from it as well: the checkbox column drops from 90px to 40 and Completed At
from 291 to 146, so the URL column picks up about 255px.
Covered by five tests in the Cypress layout suite. None of this is checkable in jsdom --
it is all resolved widths, and the central question is whether our `table-layout` beats the
one Mantine sets, which is exactly the part that failed silently.
69 suites / 1214 jest tests pass.
|
||
|
|
ebdf9dcc4b
|
Map previews that never drew, and a Docker map with no world under it (#658)
* ui: a preview map that waited for a container it could never see
Both map preview modals -- the per-region eye button and "View All Regions"
-- opened to an empty box. Each renders a div into a Modal and hands it to
MapLibre from an effect gated on `open`, reading the node out of a `useRef`:
if (!open || !mapContainer.current) return;
Mantine's Modal mounts its body one commit after `opened` flips true, so on
the render that opens the modal the ref is still null. The effect returns,
nothing it depends on changes again, and it never runs a second time. No
console error, no rejected promise, no canvas -- just an empty div.
Semantic UI's Portal mounted the body in the same commit that opened it, so
the ref was attached by the time effects ran. The effect bodies are
byte-identical across that migration; the modal library is the only variable,
confirmed against a device still running the older build where both previews
render.
A callback ref instead, so the effect re-runs when the node arrives and this
stops depending on any modal's mounting schedule. The async source lookup
also gets a cancelled flag: it could resolve after the cleanup ran, building
a map nothing would ever remove and leaking a WebGL context per open.
The tests drive the real page and assert a map was built against a node that
is in the document -- the outcome, rather than the mechanism that quietly
changed underneath it. Seven of the eight fail on the code before this.
* build: docker had no world underneath its map regions
Caddy serves /blobs/map-overview.pmtiles out of /opt/wrolpi-blobs, and that
blob is the world at zoom 0-6 -- what a regional extract sits on top of.
Without it a user who subscribes to a few regions gets those regions floating
on an empty background everywhere their data does not reach. Only a full
planet file, at 134GB, makes it unnecessary.
Raspberry Pi and Debian installs get the blob from pi-gen, the live-build
config, and upgrade.sh. Docker got nothing: the compose file bind-mounted a
git-ignored host directory over /opt/wrolpi-blobs, and a bind mount replaces
the image's directory rather than merging with it, so the path existed and was
empty on every Docker WROLPi. Verified against a running deployment, where
that URL returned 404 while the same URL on hardware returned 206.
So the web image fetches the blob at build time, as the other three builders
do, and the mount that would hide it is gone. `curl -f` so an HTTP error
fails the layer -- an image that builds without the blob is this same bug,
except silent at build time too.
The tests read the deployment files rather than a built container: building
to check costs a CDN download and several minutes, and both things that broke
-- nothing fetches it, a mount hides it -- are visible in the source.
* ui: cancel the load handler too, and make the cancel test falsifiable
Review follow-ups on the preview modals.
The `cancelled` flag was checked before constructing the map but not inside
the `load` handler it installs, so cleanup landing between the two left that
body drawing into a map `remove()` had already torn down. Guarded, mirroring
the `destroyed` pattern in MapViewer.js. The source promise also gets a
`.catch`: the helper cannot reject today, but nothing says it never will, and
an unhandled rejection here would be as quiet as the bug this all started as.
Two test corrections, both cases of an assertion claiming more than it read:
The region-drawing test spied on `addSource` AFTER construction and then
re-fired `load` by hand, so its count came entirely from that manual call and
said nothing about the production path. FakeMap now records the sources it is
given, and the assertion reads what the component's own `load` drew -- each
region once, in order, and none for the entry with no bbox.
The new cancel test passed with the guard deleted, which is worse than not
having it: Mantine had not mounted the modal body when it closed, so the
effect had never run and there was nothing to cancel. It now waits for the
effect to reach the source lookup before closing, and ships beside a control
that releases the same held promise WITHOUT closing and requires a map. The
control is what makes the zero meaningful; verified that removing the guard
fails the cancel test while the control still passes.
|
||
|
|
6d0f920c2a
|
ui: an icon button that was given a color, and never used it (#659)
`IconButton` resolved `variant={props.variant ?? fromRole?.variant ?? 'default'}`, so any
call site that named a color without also naming a role or a variant landed on Mantine's
`default` variant. That variant draws its background, hover, text and border from
`--mantine-color-default*` alone and never reads `color`, so the button rendered
byte-identical to one with no props at all. The color was accepted, stored, and dropped.
`Button` was never affected: it leaves `variant` undefined when there is no role and
Mantine fills in `filled`, which is why the same `color='violet'` works there. Match it --
`filled` when a color is named, `default` when one is not, which is most of them.
Eleven call sites were passing a color that could not reach a pixel:
DonatePage:19, Files:421 the color the USER picked, thrown away
Downloads:241,404,427,460 danger and blue on the download controls
ControllerPage:326,408,1591 the service Open buttons
FilePreview:71,541
The fallback is last, behind both `variant` and `role`, so `role='danger'` keeps its
dashed night outline even when a color is also named; there is a test for that.
Verified in the browser across all four themes: each icon button now matches the labelled
button of the same color exactly, and an icon button with no color keeps the neutral
default. In night and amber the hues collapse to brightnesses, as those themes intend.
The gallery grows a color pair per hue, so a regression here is visible rather than silent.
68 suites / 1204 tests pass.
|
||
|
|
69aaf64165
|
ui: the Open button rejoins the service's other actions (#657)
On the Control tab, each service's actions -- start/stop, restart, logs -- sit in a `.wrolpi-button-row`, a wrapping flex row. Open sat outside it, after the logs modal, so it was a block-level sibling of the row rather than a member of it: it landed on its own line under the others, on both the mobile and the desktop row. Move it inside the row, after logs. Nothing else changes -- same icon, same link, same order left to right -- and the row already wraps, so a narrow Actions column breaks it where it needs to instead of hard-coding a break at Open. The two row components are now exported so the test can mount them: `Media` renders neither branch under jsdom, where no media query matches, so `ServicesSection` yields no rows there at all and the buttons were untestable through the page. The test asserts that Open shares a `.wrolpi-button-row` with stop, restart and logs, and it fails on the old layout. 68 suites / 1202 tests pass; `npm run build` is clean. |
||
|
|
e3fa68a53c
|
ui: a PDF asks before it lights up a night-adapted eye (#654)
* ui: a PDF asks before it lights up a night-adapted eye Night and amber pass media through an SVG color matrix so nothing a user opens costs them their dark adaptation. It reaches everything drawn in our own document -- images, video, canvases, and same-origin iframes like the EPUB viewer. It does not reach a PDF, and the miss is not subtle. Chrome draws PDFs in its own out-of-process viewer, which the compositor paints outside the parent's filter: the computed style on the iframe really does say `filter: url(#wrolpi-night-red)` and the page still arrives in full color. Measured in night mode on a device, a PDF covered 55% of the doc page in non-red pixels -- a full-brightness photograph of a blue sky -- while the chrome around it was correctly red. The preview modal measured 39.8%. Tinting from outside was tried and does not work. A red `mix-blend-mode: multiply` overlay above the iframe cannot see the plugin's pixels, so it stops blending and paints flat opaque red: zero non-red pixels by measurement, document entirely obliterated. So the PDF is withheld and the reader chooses. Nothing renders until they do; hiding it with CSS would still load and paint the viewer, which is the flash being avoided. Gated on whether the theme is CURRENTLY filtering, not on its name. A reader who turns night's filter off has said they do not want this, and amber with its filter on has the same unreachable PDF and does. Only PDFs: text and the EPUB viewer are documents of our own and tint correctly, so gating them would hide something for no reason. The gallery paragraph claiming the matrix remaps PDFs is corrected -- it was the one place we told users something this measurement disproves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * review: the reveal that outlived its document, and a test that proved nothing Five review comments, all correct. The bug: MediaGate was not keyed in FilePreview. The effect that rebuilds a preview sets the modal to null and then to the new content in one pass, so React 18 batches both and reconciles instead of unmounting -- the fiber survives, and a reader who revealed PDF A would get PDF B in full color with no prompt. Keyed by url, which is what Docs already did with activePath. The test named "re-gates when filtering is turned back on" never revealed anything first, so it went from gated=false to gated=true with `revealed` already false. That path never reaches the reset effect: the test passed with the effect deleted. It now clicks Show first, and fails without the effect. The hide control moved above the media. A preview fills `calc(100dvh - 18.75rem)`, a height calibrated with no room for a trailing button row, so a control after it lands below the fold of a full-screen modal -- leaving the reader who just lit up their eyes scrolling to find the way back. Docs' embed -- the surface measured at 55% non-red -- had no contract test where FilePreview had one. It has one now, asserting the gate wraps the iframe, keys per document, and reads mediaFilterEnabled rather than the theme's name. Gallery copy said "night and amber withhold it"; amber's filter is off unless asked for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c560ba6fa5
|
build: put the @babel pins back, and make them fail loudly if dropped again (#653)
The pins that fixed the blank React /map were removed as collateral when the Mantine commit rewrote `overrides` for TypeScript 5. Nothing failed, so nothing objected: the map kept working only because package-lock.json still happened to resolve @babel/core to 7.20.12, and devices run `npm install`, not `npm ci`. Verified on this tree rather than assumed. Raising @babel/core to 7.29.7 and reinstalling floats the whole pipeline (traverse/generator/types to 7.29.8) and `npm run build` then fails outright: "'opera_mobile' is not a valid target", from a helper-compilation-targets that floated away from the browserslist data it was pinned against. With the pins restored the build compiles, all 141 resolved copies match, and /map renders from the built bundle. The test walks every path in the lockfile, not just node_modules/<name>: npm nests a second copy whenever versions conflict, and a nested copy that escaped the override would put the new pipeline back into the build while the top-level entry still read correctly. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f1bc52d9c6
|
test: user-event that matches the DOM library it drives
Rebasing onto master brought @testing-library/dom 8.20.0 -> 10.4.1, while user-event stayed at 13.5.0. v13 targets dom v8 and reaches into internals v10 moved, so its keyboard simulation silently stopped dispatching: two SearchBox tests failed with `Number of calls: 0` -- the arrow keys and Enter did nothing. Test-harness only. @testing-library/dom never reaches a build, and no product code changed in the rebase; the combination was simply never installed together until master's bump met this branch. v14's direct API is call-compatible with what the ten suites already do, so no test needed rewriting -- the calls were already awaited. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
24e2dd8b9b
|
ui: the two icon names that were only ever defaults (#652)
InfoPopup and HelpPopup default to `icon = 'info circle'` and `icon = 'help circle'`. Neither name was in iconNameAliases, so both fell through to the fallback glyph and logged an error -- and being defaults, they fired on every use of either helper. Opening Settings on a device logged 43 of them. The test that should have caught this was called "maps every icon name the app uses" and checked a list of twenty names written by hand; these two were not on it. It now reads the icon defaults off the source -- 85 of them -- and names the file it found an unmapped one in. Scanning `name='...'` as well was tried and reverted: `name` is overwhelmingly a form-field attribute, so it reported 27 field names as unmapped icons and failed on correct code. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7e63bbb3e2
|
ui: the Files filter's clear button rejoins its input (#650)
The clear button sat detached from the filter field and 11px below it, and it
dragged the collapse-all button out of line with both.
One stale rule did all of that. `.search-clear { margin: 0.625rem }` is left
over from the Semantic-era filter, where the clear button was a sibling of the
input and needed the gap. It is now ActionInput's action, which joins the two
by dropping the input's right border -- so the margin reopened the seam the
component exists to close, pushed the button down by its own top margin, and,
because a flex line is as tall as its tallest item plus that item's margins,
stretched the wrapper to 62px around a 40px field. The collapse-all button
beside it was centred in that 62px, which is why it looked wrong too.
Removing the rule fixes all three: the button abuts the field and shares its
outline, like the filters on Videos and Archives, and the row is one height.
The class had exactly one user left, so the rule goes with it.
The test asserts the button is inside ActionInput's wrapper, and that no rule
reintroduces a margin on it -- jsdom applies no stylesheet, so the geometry
itself cannot be measured there. Confirmed it fails when the rule is put back.
|
||
|
|
eaa8b83e54
|
ui: the navbar shows which section you are in (#649)
* ui: the navbar shows which section you are in
Clicking Videos opened the videos page and left the bar looking untouched.
react-router was doing its part the whole time -- the tab carried `active` and
`aria-current="page"` on every one of those pages. Nothing in the stylesheet
drew that class, so all eleven tabs rendered identically and the mark was
visible only to a screen reader.
The current tab now takes a deepened version of the hover wash plus an
underline. The underline is a pseudo-element in `currentColor` rather than a
border, for two reasons: `currentColor` is the foreground the bar already
computed for whichever of the fourteen navbar colors the user picked, so one
rule covers every color in every theme without naming one; and a pseudo-element
adds no height, where a border would jog the whole row as the mark moved
between tabs. The overflow "More" menu and the mobile hamburger mark their
copy of the tab too -- on a phone that menu is where most tabs live.
Calculators and Statistics also lose `end: true`. A tab is meant to stay
current for the pages beneath it, and `end` restricts the match to the exact
path, so either one would have dropped its mark the moment it grew a sub-page.
The other nine links never had it, which is why /videos/1234 already kept
Videos marked.
The test renders the real bar, which needed two pieces of browser jsdom does
not have: fresnel renders no navbar at all unless a breakpoint query matches,
and every width is 0, which convinces the overflow logic that nothing fits and
sweeps all eleven tabs into the closed "More" menu -- where every assertion
about them would have passed vacuously. Both are stubbed, and the styling
itself is asserted against the stylesheet, since jsdom applies none.
* review: the mark follows the tab into the overflow menu
Six review points, no bugs among them. The substantive one: when the window
narrows enough to fold the current tab into "More", the bar went blank again --
the trigger is a plain button, not a NavLink, so nothing was marked until the
user opened the menu to look, which is exactly when they least need telling.
"More" now takes the mark when it holds the current section.
That needs the matching rule outside a NavLink, where the hooks that answer it
cannot be called in a loop, so `isLinkActive` states it once: a link owns the
pages beneath it unless it asks for an exact match. The trigger gets the class
only -- the real NavLink inside the menu already carries `aria-current`, and a
second one on the button that opens the menu announces the same page twice.
The other five, all fair:
* The mobile hamburger and the overflow menu had no test at all, so
`.mantine-Menu-item.active` -- the whole of the mark on a phone -- was
never exercised. Both now are, which meant splitting the viewport and the
bar width out of the fixture so a test can ask for a narrow bar or a phone.
The mobile case also needs the real getBoundingClientRect back: floating-ui
positions the menu's portal and crashes the worker on a stub DOMRect.
* The CSS guard matched the selector as a substring, which still passed with
the rule body emptied or the underline deleted -- and the underline is the
half that survives on a dark bar. It now reads the declarations.
* The table cases used `toContain`, which a regression leaving Home
permanently marked would satisfy on every row. `toEqual([text])`.
* A comment claimed the tab "only matched its own exact path", which is the
root cause the PR description explicitly rules out -- Videos never carried
`end`. Corrected to what those cases actually guard.
* The navbar offers twelve colors, not fourteen. I had carried over
Semantic's palette count, which includes black and white.
Writing the tightened height assertion I reached for `(?!0)`, which calls
0.1875rem zero. It reads the number instead.
|
||
|
|
499ef435f8
|
ui: sample data names Blender and WROLPi, not real channels (#647)
* ui: sample data names Blender, not somebody's channel The gallery, the card samples, and the search fixtures used a real YouTube channel and one of its video titles as demo content. Sample data ships to users and appears in a page linked from Settings; naming a real third party in it puts words in their mouth -- the delete-confirmation sample in particular read "The channel Wranglerstar and its download rules will be removed." Blender fits better anyway: the repo's own test media is Big Buck Bunny, so the sample library now refers to something it actually contains, and to a project that publishes its films for exactly this kind of use. * ui: the second sample channel is WROLPi's own Same reason as the last commit: the remaining sample channel and its video title belonged to a real person. The pair now reads as Blender and WROLPi, which are the two things this project can name freely. |
||
|
|
5873e65e8c
|
review: the color-scheme half of this was already there
Acting on review of #644. The largest finding came out of a suggestion rather than being in it. The reviewer asked for coverage of the `color-scheme: dark` rule, on the grounds that dropping it would leave night's track wrong with no failing test. Written and passing -- then mutated, by pointing the selector at a type that does not exist, and the tests stayed green. Mantine's own stylesheet puts `color-scheme: var(--mantine-color-scheme)` on `:root`, and ThemeProvider drives it with `forceColorScheme`. The property is inherited, so a range slider on a dark theme has had a dark track all along and the rule added here changed no pixel. It is deleted, and the claim about "the UA's pale slab" is out of the comments and the gallery copy with it. The tests stay, re-aimed at what actually supplies it: breaking `forceColorScheme` fails all three, which is worth guarding because it lives in another module and every accent assertion passes without it. The other suggestions: - The gallery slider misrepresented the control it stands for -- it invented an "Error" step, dropped Trace and ran 0..4, where Settings maps 1..5 onto Critical / Warning / Info / Debug / Trace. A gallery whose purpose is showing the real control is worse than useless when it shows a different one. Keyed to match `levelNameMap` exactly, and it starts at Info so both halves of the track are visible. - The "never leaves it at the UA default" test restated the four above it: `toRgb` throws on anything unresolved, and the computed value of `accent-color: auto` is the keyword, so those already fail on an unstyled slider. Replaced with a constraint they do not cover -- `accent-color` is inherited, so a text field beside the slider must still read `auto`, which is what says the accent comes from this rule and not from a tinted document. - The comment implied native checkboxes and radios are on screen the way sliders are. They are not: those are Mantine's and Progress is a div, so three of the four selectors are a guard rather than a fix. Said so. Nits: four product sliders, not five -- the fifth is the sample itself. And the progressive-enhancement limit is now stated where the rule is, including what a complete fix would take. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e15e2fd993
|
ui: a range slider is painted by the theme, not by the browser
A native `<input type="range">` is drawn by the UA, and with nothing said about it the UA uses its own accent -- a fixed blue no theme can reach. Five are on screen: the log level on Settings, and the duration sliders in the food, water and ration calculators. None had ever been themed; `accent-color` appeared nowhere in the app. Night is where that is not merely inconsistent. The theme exists so a user reading in the dark keeps their adaptation and every token is a red, so the slider was the brightest non-red thing on the page -- on the one control a user drags back and forth while watching it. `accent-color: var(--blue)` for the same reason links take that token: it remaps per theme, so night gets the dim red (#8a2828) and amber the amber (#a86618) with no second rule. Checkboxes, radios and `<progress>` are included because they are painted from the same accent and would have been the next report. `accent-color` reaches the thumb and the FILLED half of the track and nothing else, so on a dark ground the rest stayed the UA's pale slab. `color-scheme: dark` fixes that, scoped to these controls rather than declared on `html` -- on `html` it also governs scrollbars, spinners and every other piece of browser chrome, which is a larger change than this one and worth making deliberately. Tests assert the accent equals `--blue` in each of the four themes, plus a negative: in the monochrome themes it is a resolved color and never `auto`. Without that second one the first four would also pass on an unstyled slider whose accent happened to stringify the same way. Added to the gallery, which is how the monochrome themes get looked at. Its slider is stateful and starts mid-track: the accent only shows on the filled half, so one parked at either end would show almost none of the color the section exists to check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6115c7e3ec
|
ui: the dashboard's status panel links without looking like a link
The whole panel is wrapped in a `<Link to='/admin/status'>`, so the block is one anchor -- and `a` takes `color: var(--blue)` with an underline on hover from tokens.css. `.wrolpi-statistic-value` sets no color of its own, so the three load figures inherited the link color: blue numbers on a panel where nothing else is blue, underlining as a group whenever the pointer crossed any part of it. The Downloading figure below the divider had it too. `no-link-underscore card-link` is the pair already used wherever a link IS the element's own text rather than a link through it -- every card title and meta line, via `cardLinkClass` in Common.js. Reused rather than adding a third class for the same idea. The pointer cursor is untouched, which is the point: it comes from the UA stylesheet for `a[href]`, and the affordance is the only reason to wrap a panel in a link at all. A test asserts it so a later change cannot take it away while tidying the rest. DashboardStatus is exported so its call site can be rendered directly; the colors are asserted in cypress, since jsdom drops `color: var(--text)` as an invalid declaration and cannot see this at all. Each new assertion has a paired negative: the same panel WITHOUT the classes comes out blue, and has no rule protecting it from an underline. The first draft of the underline test checked only that no matching hover rule was `!important`, which was true before the fix as well -- it would have passed on a panel that still underlined. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2c6b5ae10e
|
test: the component suite's twelve failures, none of them about the component
The cypress component suite has been failing 12 of 311 for long enough to read
as background noise. Every one of them was the test's own fault, and four
distinct causes account for all twelve.
Dead selectors. `div.ui.label.large`, `div.divider.text`, `.item` and
`have.attr 'error'` all named markup the old library rendered. None of them
started failing when the library was replaced -- they went on matching nothing,
or looking for an attribute no control carries, which is the failure mode that
makes a test look broken rather than the code. A tag chip is `.wrolpi-tag`, a
Select is a real input whose value can be read, an option carries the ARIA role,
and an invalid field says `aria-invalid`. Both directions of that last one are
asserted by value: a valid field says `aria-invalid="false"` rather than
dropping the attribute, so `not.have.attr` would have passed on absence.
Missing context. `useFileWorkerStatus` throws rather than defaulting when its
provider is absent, so the channel edit page died on a modal buried three levels
down while every other page mounted fine. App.js wraps the whole app in that
provider; the mount helper now does too, with the poll slowed to a minute.
Unstubbed fetches. The inventory page loads the food catalog from its own
endpoint, and the RSS edit form fetched downloaders before its intercept was
declared. Both rejected with "Failed to fetch" out of an effect, which Cypress
counts as an uncaught application error and fails the running test on -- so
three inventory tests and one download test died for a reason unrelated to
anything they assert.
Assertions reading the wrong thing. `cy.contains('Food Storage')` found the
hidden `.inventory-print` copy, since contains takes the deepest match and the
print sheet is mounted at all times. And the PUT body arrives as a JSON string,
so `req.body.items` was undefined both in the reply the interceptor built and in
the assertion waiting on it. The RSS submit test deep-equalled one exact JSON
string and broke the moment the video downloader gained eight settings the form
fills in by default; the envelope is still matched exactly and the settings are
checked for the values the fixture set. It also never waited on its own alias,
so an interceptor that never ran would have passed in silence.
Separately, the navbar's shrink test read its link count in a bare `.then()`
right after the corner finished growing -- before the recalculation that growth
triggers had run, so `shrunken` was sometimes already the full count and the
assertion came out "expected 10 to be above 10" under load. It now waits for
the bar to settle to one row, which is the same signal the test above it
asserts, and requires the "More" trigger to exist so it cannot pass having
measured a bar that never overflowed.
New: two tests for the file browser filter's width, desktop and phone, which is
what caught the dead selector behind it.
311 passing, 0 failing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e00f0439da
|
ui: the last of Semantic UI, in name and in dead CSS
The library has been uninstalled for some time, but the codebase still read as though it were there: identifiers named after it, rules written against its class names, and about 180 comments explaining a rule in terms of a thing that is no longer installed. Renamed, keeping the vocabulary the app actually uses: semanticUIColorMap -> navColorHexMap, semanticColorNames -> paletteColorNames, semanticNames -> iconNameAliases, semanticSizes -> sizeAliases, and SemanticUIColors -> NavColors in wrolpi/schema.py, which is what validates nav_color on the way in. ColorToSemanticHexColor had no callers at all and is gone. The baseline ratchet has done its job -- the list reached zero, so the JSON is deleted and the guard keeps only the checks with teeth: no package, no import, no `className="ui "`, no toast library. Its Theme.tsx check went with the wrappers it was watching for. It also asserts its own walk found files, so a clean result cannot mean it scanned nothing. Deleted from App.css: every rule requiring `.ui`, `.inverted`, `.segment` or `.modal`, none of which any component has set since the stylesheet stopped loading, plus the form skeletons, ui-alerts and progress-text rules that nothing renders. Two of them were doing harm rather than nothing: the drag tints had a dark-theme variant under `.inverted` that stopped applying, leaving 15% of a fixed blue over a dark row, and now mix a token instead. Two selectors naming markup that no longer exists were live bugs. The file browser's filter had `.ui.input.file-browser-filter`, so it rendered at the browser default width on a bar laid out to give it 34em -- now qualified by its container, because `.wrolpi-action-input` sets `width: 100%` at the same specificity and is loaded later. And SearchBox takes named props rather than a rest spread, so the `id` DestinationForm hands it was dropped in silence and the label it renders pointed at nothing; the download forms' Destination field has had no associated label since the migration. "Semantic roles" survives in four places, where it means what it says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |