Commit graph

19 commits

Author SHA1 Message Date
lrnselfreliance
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>
2026-08-11 18:57:15 -06:00
Roland
4a51662714
review: the scan the first two commits needed
Five findings, all valid.  The substantive one: the video page's Download /
Delete / Refresh / AddToPlaylist strip was still four bare buttons in a `<p>` --
the direct analogue of the archive row this PR started with -- and the scan that
was supposed to catch exactly that reported the app clean.

It only ever saw `{fooButton}` interpolations, and most of the app writes its
buttons out in place.  Scanning for adjacent button ELEMENTS finds twelve more
rows in the same state: reorder icons in the playlist and field-schema tables,
both service tables on the controller page, its hotspot Save/QR pair and its
Restart/Shutdown pair, the downloads footer, the one-time pad's Generate/Cheat
Sheet, and the ration calculator's CSV/Print.  All rowed.

Two holes had to be closed before that scan could be trusted, because a scan
that reports noise is worse than one with a stated limit:

  - `tagsIn` never captured the tag NAME, so `ROW_COMPONENTS.has(undefined)` was
    false for every tag and the list of components that lay out their own
    children -- Group, ButtonGroup, Modal.Actions -- silently accepted none of
    them.  56 false positives, every modal footer in the app among them.
  - A row whose style is a const (`style={seasonRowStyle}`) says nothing in its
    own attributes.  Resolved now; the dates selector's seasons row is a
    four-column grid with a gap and was reported three times.

Also from review: the row body is found by walking to the MATCHING close rather
than the first `</div>`, which one nested div would have truncated -- and the
collection form now nests one, so this was about to matter.  Cancel and Save
travel together in a row of their own pushed to the end; `margin-left: auto` on
Save alone was right for a row that could not wrap, and would now drop Save onto
a second line by itself with Cancel left among the action buttons.  The doc
page's comment no longer implies the picker's 1em separation was not a change.

One more found on the way: the file browser's filter row is flex with no gap, and
its collapse-all button carried `margin-left: 0.5em` for it.  The gap is the
container's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:53 -06:00
Roland
076e982b69
ui: a Back button is a row of one, not a stretched flex child
Reported: the Back button fills the page width on the domain and channel edit
pages.  It fills it on all nine pages that have one, and this is mine from the
last PR.

`.wrolpi-stack` is a flex column at `align-items: stretch`, deliberately -- a
panel was full-width as a block element and has to stay that way.  A BARE button
as a direct child of that stack is stretched too, and every page's Back button
was one.  On the domain, channel and playlist edit pages it also pushed the
button beside it onto its own line, where the two had been inline.

Wrapping each in a `.wrolpi-button-row` fixes both without touching the stack:
the row is the stretched child, and it lays its buttons out at their own width.
The three pages that have a second button get one row holding both, which is how
they read before.

Also the Videos, Archives and Documents select bars, whose Tag/Delete/Invert/
Clear were four touching buttons in a bare div.  They are inline elements rather
than interpolations, which is the shape the bare-siblings scan does not see.

Guarded twice: the row keeps its buttons their own width inside a stack while
itself stretching (both halves, since a row that refused to stretch would pass a
test that only looked at the button), and every `<BackButton/>` in the app is
inside a row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:52 -06:00
Roland
7741ee1f12
ui: a row of buttons is spaced by the row
The archive page's six actions shared edges, and Update sat lower than the
rest.  Both come from the same thing: Semantic's Button carried
`margin: 0 .25em .25em 0`, so a page could list buttons as bare siblings and
they arrived spaced -- and separated from the line below when the row wrapped.
Nothing replaced that margin.

Two of those six answered with `marginTop: 0.5em`, which is the wrong axis: a
Button is `display: inline-flex; vertical-align: middle`, and `vertical-align`
is measured on the MARGIN box, so half of any top margin shows up as the
visible button dropping.  It was covering the wrapped-row case in the only axis
a margin can, and paying for it in the other one.

`.wrolpi-button-row` is a flex row with a gap, so both axes are covered at once
and no button needs a margin of its own -- the same rule as `.wrolpi-stack`,
along the other axis.  Applied to the five rows in the app that had no gap: the
archive page, the shared collection edit form (which three pages hand action
buttons to, and where nine buttons plus Cancel carried `margin-top: 1em` each
while Save carried none), the doc page's two responsive branches, and the
downloads table's edit/restart pair.

`.action-button-spacing` goes with them; it was that same margin, on Cancel.

Guarded four ways.  ui-layout.cy.js measures that the row really spaces what it
holds, wraps rather than overflows, centres its buttons against a taller item
beside them, and counts a confirmed APIButton as one item rather than two --
that one returns a fragment holding a `<Confirm/>`, which would open a hole in
the row if it did not portal.  Common.test.js scans for the two ways this comes
back: a button in a row stamping a vertical margin, and a row of buttons that is
not a row.  Neither throws, which is how the app got here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:52 -06:00
Roland
4e60309794
review: finish the sweep the first pass claimed
The review was right that completeness was not done.  Every finding held up.

Three more pages mounted a `PageContainer` inside a route that already
provides page chrome -- `ManageMap`, `MapPins`, `ManageZim` -- so they sat a
further 1em down and 1em in from the tab bar than the viewers beside them.
The same defect as ArchiveSettings, in a shape the scan could not see: it
keyed on `PageContainer` route wrappers, and Map and Zim wrap their content
in a `wrolpi-stack` div instead.  It now locates routers first and asks
whether the enclosing return provides chrome either way, resolves
`element={<ErrorBoundary><Page/></ErrorBoundary>}` as well as the bare form,
and knows about `class` components -- `ManageZim` is one, and a scan that
only knew functions reported it clean.

Five top-level blocks still added their own margin to the stack's gap: the
dashboard search row (52.8px against 17.6px, on the most-looked-at page in
the app), the Playlists filter row, both Docs filter rows, and the Map pins
filter.  The extension page's hint panel had the same compound against a
Mantine Stack's gap.

ControllerPage held its four panel sections in a plain wrapper, so the
admin page's stack saw one child and the panels kept sharing edges -- the
exact hole Settings had.

The dashboard was reported clean in the first pass and was not.  The sweep
measured wrapper-to-wrapper gaps, and that row's margin is INSIDE a Media
wrapper, so it never appeared: the measurement, not the page, was at fault.

Cypress gap assertions now derive from the stack's own `row-gap` instead of a
12-28px range that assumed the default interface scale and would have
false-failed a correct layout at a larger one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:05:52 -06:00
Roland
016a47ad64
ui: migrate Docs.js and Zim.js off Semantic UI React
Replace semantic-ui-react and ./Theme imports with ./ui across both
files: Grid -> Group/flexbox (two-up rows), Loader -> Loading, Segment
-> Panel, Statistic/StatisticLabel/StatisticValue -> Statistic +
StatisticGroup, Tab -> Tabs, Accordion -> Mantine Accordion.Item with
controlled string value, Dropdown -> Select, Message/Label/Placeholder
-> ui equivalents. Buttons take role (primary/save/retry/danger/cancel)
instead of Semantic color names where a role fits.

Zim unsubscribe now goes through a destructive Confirm (via APIButton's
confirmContent) instead of firing immediately, matching the
Confirm+destructive rule for irreversible actions; the outdated-Zims
delete flow keeps its rich custom Modal (dynamic file lists) but its
trigger and confirm buttons are styled destructive and labeled "Delete".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:04:01 -06:00
Roland
f8bef9fc23
ui: keep the unmigrated pages' notifications working
Greptile P1, and a real one: App.js migrated and took `<SemanticToastContainer/>`
with it, but seven pages -- Archive, Channels, Docs, FilePreview, Playlists,
Zim, ControllerPage -- still raised their toasts through the Semantic library.
With no container mounted those notifications went nowhere.  No error, no
warning; a user deletes a document and is simply never told whether it worked.

Fixed by pointing those seven at the library's `toast`, which takes the same
options, rather than by mounting a second toast system.  Nothing calls the
Semantic toast library now, so a test asserts that at zero: a file that goes
back to it would raise notifications that silently never appear, and that is
not something a reviewer would spot.

Also reverted Archive.js to its last good state.  An interrupted agent had
migrated part of it -- Panel, Tabs, and Mantine's Grid.Col -- while the rest of
the file still used Semantic's Segment, Tab and Grid.Row, so a single `Grid`
identifier had to be two different components at once.  A half-migrated
1400-line file is worse than an unmigrated one; it goes back on the list and
gets done in one piece.

That break was masked: react-scripts caches ESLint results per file, so the
earlier build passed while Archive.js sat on a stale pass.  Editing its import
invalidated the entry and the errors appeared.  Verified with the cache cleared
this time, which is the only way that check means anything after a wide change.
2026-08-08 18:04:01 -06:00
Roland
73fafcd645
Search: fast ABC mode by default, opt-in deep search
The file_group.textsearch tsvector indexes all four text columns;
d_text (captions, document text, zip contents) is ~90% of the data,
making ts_rank searches take seconds on large libraries.  Add a
textsearch_abc generated column (title/author/description only, ~8x
smaller) and default all searches to it, with deep=true opting into the
full column.

Database:
- Migration adds the generated column and a GIN index (rewrites the
  table; a few minutes on large libraries).

Backend:
- FilesSearchRequest, VideoSearchRequest, ArchiveSearchRequest, and
  DocSearchRequest accept `deep`; each search function picks the column.
- /api/search_file_estimates returns both counts in one index-bound
  query (COUNT FILTER on the abc subset): `file_groups` (fast) and
  `file_groups_deep`.

UI (one coherent story across the count surfaces):
- The typing dropdown always suggests the deep count (what is findable).
- The tab badge shows the active mode's count so it matches the result
  list.
- The gap is reconciled by a hint offering deep search: count-aware on
  the global search ("No results in fast search. N results available in
  deep search." / "N more results available..."), generic on the module
  pages (no deep estimate there).  The hint resets pagination.
- "Search Depth" toggle in the search filter modals, persisted as
  ?deep=true.

Tests that relied on d_text matches now pass deep=True, with fast-mode
assertions alongside.  Also skip flaky test_download_channel on
CircleCI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 10:32:16 -06:00
lrnselfreliance
264cc8cd58
Consolidate search options into a single Filter modal (#497)
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
* Consolidate search options into a single Filter modal

Reworks the file-search UI on the Dashboard, Videos, Archives, and Docs
pages. The crowded row of controls (sort, tags, limit, file-type filter,
date selector) is replaced with a single "Filter" button that opens one
comprehensive modal.

- Add SearchFilterModal + SearchFilterButton (violet + count badge when
  active). Sections (Sort, File Type, Tags, Results Per Page, Published
  Date) are shown per-page via props; layout uses a Semantic Grid.
- Selections are drafted locally and applied in a single updateQuery only
  on close/Done, so the search isn't re-run on every field change.
- Results Per Page and Sort use buttons (fewer clicks, no clipped dropdown).
- Extract shared SearchControlBar (view toggle + input + Filter) for the
  three results pages; move the global-search view toggle onto the tab row.
- Rework the Dashboard search line into one adaptive flex row; clear button
  only shows when there's an active search.
- Remove now-dead FilesView limit/tag returns and SearchLimitDropdown.

* Address review: reset limit on Clear All; use replace over replaceAll

- handleClearAll now resets Results Per Page to the default page size
  (DEFAULT_SEARCH_LIMIT = 24) so "Clear All" actually clears every setting.
- Replace start-anchored replaceAll(/^-/g) with replace(/^-/) for the sort key.
- Add a test covering the Clear All limit reset.

* Remove now-unused Media import from Files.js

The FilesSearchView refactor dropped the last <Media> usage in this file,
leaving an unused import (a build warning). Remove it.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 19:49:25 -06:00
lrnselfreliance
9a5d1f1630
Add ordered collections (playlists): polymorphic items + item API (#475)
* Add Playlists: ordered, mixed-type collections

A playlist (Collection kind='playlist') is a manually-curated, ordered list that
mixes item types. Squash of the playlists feature branch (24 commits).

Data model & API:
- CollectionItem is polymorphic via item_kind: 'file' (FileGroup), 'zim'
  (zim_id + zim_entry), or 'url' (link to anything the WROLPi browser opens,
  e.g. /map?lat=&lon=&z=). CHECK constraint enforces exactly one kind; per-kind
  unique constraints; migration a7b1c2d3e4f5.
- Endpoints: create playlist; add/remove/reorder items (idempotent adds: 201 new,
  200 existing); GET returns the ordered items. All mutations wrol_mode_check'd.

Config (source of truth):
- playlists.yaml (PlaylistsConfig) stores each playlist's metadata AND ordered
  items (file by relative path, zim by path+entry, url inline); round-trips a
  DB wipe. Supports backup import (DB_CONFIG_FILE_NAMES).
- The redundant generic CollectionsConfig (collections.yaml) is deleted:
  channels/domains own their configs, authors/subjects are re-derived.

On-disk sync (sync_playlists_directory switch):
- Each playlist materializes into the configurable Playlists Directory
  (playlists_destination, Settings UI input, excluded from indexing) with
  position-prefixed entries: NNNN_<file> hard links (copy fallback across
  filesystems) and NNNN_<title>.html stubs for zim/url items.
- Tagged playlists are namespaced like channels: <playlists>/<tag>/<name>;
  a tag change moves the directory and prunes the empty old tag dir.
- Deletion safety: managed files are only removed when they are our marked
  stubs or still have another hard link (st_nlink > 1) -- never a last copy.
  Stray loose files at the playlists root are removed; stub URLs are
  scheme-allowlisted (no javascript:/data:).

Frontend:
- /playlists list page (CollectionTable, name filter, New Playlist modal, Tag
  column) between Files and Zim in the nav.
- View-only detail page (Name link) and edit page (Edit button): themed table
  with model icons/colors + poster thumbnails, items link to their model page
  (video/archive/doc) or preview, up/down reorder, remove, add-URL form,
  single-tag selector; all editing disabled in WROL Mode.
- Reusable green "Add to Playlist" button on Video/Archive/Doc detail pages,
  the FilePreview toolbar, Zim search results, and Map pins (added as a /map
  URL item with a map-marker icon).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Playlist edit page parity: rename, custom directory, tag modal move suggestion

Match the ChannelEditPage/DomainEditPage UX on the playlist edit page:
Back/View buttons, an edit form (name, description, directory), and the
shared CollectionTagModal with the violet "Tag" button.  The tag modal now
suggests moving the playlist to its managed tag location
(playlists/<tag>/<name>); accepting the suggestion stores directory=None so
the sync keeps auto-managing the location.  Tag buttons on the Channel and
Domain edit pages are now violet to match.

Backend: update_collection learns rename (with per-kind validation and
duplicate checks) and playlist custom directories (cleanup of the abandoned
directory, config round-trip, commit-before-switch to avoid the background
sync racing pre-commit state).  tag_collection's duplicated untag/tag
branches are merged, and the per-kind config-save dispatch is consolidated
into _activate_config_save().

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

* Deduplicate Collection error handling and Tag creation

Delete the duplicate UnknownCollection in wrolpi/collections/errors.py and
re-export the canonical one from wrolpi/errors.py, which carries
status_code=NOT_FOUND (the local copy inherited 500).  Replace the repeated
query/raise pattern in reorganize.py with Collection.find_by_id.

Add Tag.get_or_create_tag and use it for collection tagging; flush the new
Tag before returning so callers can read .id immediately -- previously
tagging a collection with a brand-new tag name set tag_id=None.  Regression
test included.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 19:46:34 -06:00
Roland
ba63170e55
Consolidating deletion endpoints.
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
2026-05-03 19:14:30 -06:00
Roland
09a967535b Models like Archive/Video/Doc can be deleted, even if they are tagged. User is shown a warning and must confirm deletion of tagged files. 2026-05-03 19:14:15 -06:00
Roland
66cecce5e6
Adding Doc filter button so users can search just PDFs/EPUB/etc. 2026-04-14 15:35:35 -06:00
Roland
8a75cf9057 Linking to specific page or section of an EPUB/PDF when user searches. 2026-04-13 16:47:02 -06:00
Roland
1294b14c10
Adding button to bulk tag file groups for collection items. Always displaying selection column on collection page tables.
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run
2026-04-09 16:18:26 -06:00
Roland
bf5b787fb3
Unifying Files tab appearance for model pages.
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
2026-03-28 14:19:09 -06:00
Roland
2943b2a9c8
Adding buttons to DocPage so that user can select which version of the same document to view, for example a PDF or EPUB of the same document. 2026-03-28 13:52:01 -06:00
Roland
1c79c1c495
Refactoring docs to match new url scheme like videos and archives. 2026-03-20 15:53:21 -06:00
Roland
aefc7b1def
Add Docs module for searching and organizing PDFs and EPUBs
Create a new Docs module that consolidates PDF and eBook handling into a
unified system with subjects and authors support. This replaces the
separate PDF/eBook modelers with a single Doc model backed by an
alembic migration (rename ebook to doc).

Key changes:
- New modules/docs package with models, API, extractors, and lib
- Subject and author metadata extraction with case-insensitive
  normalization for cleaner PDF metadata
- Frontend Docs pages with AuthorsPage, SubjectsPage using
  CollectionTable, and doc card deletion
- Global search integration for subjects and authors
- Generic collection item count support
- Frontend file card metadata adjusted to match collection sort
- Bump yt-dlp version
2026-03-20 14:17:06 -06:00