Skip to content

v2.6.0 - Tag Editor Suggestions - #1010

Merged
ajslater merged 440 commits into
mainfrom
develop
Nov 7, 2026
Merged

ajslater merged 440 commits into
mainfrom
develop

Conversation

@ajslater

@ajslater ajslater commented Nov 7, 2026

Copy link
Copy Markdown
Owner

✨ Added

  • The tag editor suggests the library's existing publishers, imprints, series,
    volumes, credits, story arcs, universes and tags as you type, so a correction
    can pick the spelling already in use instead of creating a near-duplicate.
    Imprint, series and volume suggestions narrow to the publisher, imprint and
    series chosen above them. New names can still be typed in.
  • New Add Library server folder picker component.

♻️ Changed

  • Renamed files carry the full date, like (1950-11-01), and the volume count
    (comicbox 5.6.0).

🐛 Fixed

  • Filenames supply the month, day, volume count and publisher; (Digital) or
    (DC) alone no longer read as the title (comicbox 5.6.0).
  • A new visitor's first page load no longer sometimes fails.
  • Unchecking every default metadata format no longer breaks online tagging.
  • Unreadable folders in Add Library say "Permission denied" instead of "Server
    Error".

ajslater and others added 30 commits September 19, 2026 17:48
Overnight, an entire publisher's comics would lose their read state.
The files were fine; the rows were destroyed and re-imported with new
pks, and Bookmark.comic cascades. Three layers in sequence each read
"I could not look" as "it is not there", and a fourth turned one failed
stat into a whole publisher.

The disk walk drops an unreadable subtree with no record that it did —
silently, in the per-entry branch. The diff is a bare set subtraction,
so every database path under that subtree reads as deleted. The delete
backstop confirms the lie, because Path.exists() swallows every OSError
and answers False for a file it merely could not reach. And the folder
delete cascades its comics away without a stat of its own.

Only ENOENT and ENOTDIR now mean gone. Every other error means the
filesystem could not answer, which is not the same thing:

- Snapshot carries an `unreadable` set. Both walk failure sites record
  into it, and the silent one finally logs.
- SnapshotDiff withholds deletes for those paths and everything under
  them, and says how many. A withheld row still points at a real file,
  so the next healthy poll reconciles it. A file carried out of the
  subtree can still pair as a move, which is what keeps its bookmarks.
- split_extant stats instead of Path.exists() and fails closed, naming
  the errors it could not check so a recurrence is diagnosable.
- bulk_folders_deleted probes the comics its cascade would take, drops
  every folder above one that is still on disk, and calls the
  mass-delete warning that was written but never wired to it. Every
  cascade now logs how many comics it is about to remove.

What remains unhandled is an outage that answers ENOENT for files that
exist. The new logging is how that would be recognized.

Tests mock scandir and stat rather than using chmod: CI runs as root,
where mode bits are ignored, and a 0o000 directory still stats from its
parent, so a permissions test can only reach one of the two routes.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(opds): restore library browsing on the v1.2 start page

Panels on macOS showed Keep Reading, Latest Unread and Oldest Unread
and no way into the library (#855). The four browse views — Publishers,
Series, Folder, Story Arc — were emitted as entries only when the
client was judged facet-blind, and as feed-level facet links otherwise.
macOS Panels ignores facet links, so its start feed was a dead end.

Two faults, fixed separately.

Navigation was folded into the facet mechanism. A facet refines the
entries of the feed it appears on, and the start page has none by
construction, so "Views" refines nothing: it is navigation wearing a
facet costume. Failing to render facets may cost a client its sort UI,
which is cosmetic. It must never cost the only way into the library.
``facets()`` now returns sort facets only and ``nav_views()`` emits the
Views group as entries for every client, called where the old
facet-entry call sat so the bytes are unchanged for clients that were
already getting entries. Facet-aware clients lose "Views" from their
native filter menu and gain four navigation rows, which is strictly
more discoverable.

The Panels facet gate could not work in either direction. #817 replaced
a build floor with a denylist of facet-blind builds, guessing 951 for
macOS; the real builds are macOS 957 and iOS 956, one apart and
interleaved, with identical Darwin tokens. No floor separates them, the
guess was wrong, and a denylist or allowlist grows by one entry per
Panels release forever. So codex stops choosing: Panels gets the facet
links *and* the navigation folder sort. iOS users see five sort rows
they do not need; macOS users get a sort instead of none. Nothing reads
a build number any more, so the parse goes too.

Also records, in the code, why the Folder and Story Arc entries
advertise kind=acquisition and why that was left alone after checking
Panels 957 — it has now been re-derived twice from scratch.

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

* fix(opds): make the OPDS urls dialog show something, always

Second half of #855: "when I click on the OPDS link in the browser the
window goes inactive but no new window appears."

The dialog body was a bare div, so the overlay was a shrink-to-fit box,
and the only thing in it while the urls load is a placeholder that
hard-codes height and width at 50%. Percentages against a
shrink-to-fit parent resolve to nothing, so the dialog opened as an
invisible sliver behind the scrim — inactive window, no visible window.
A v-card gives the body an intrinsic size and the placeholder is sized
explicitly at this one call site, which is the only bare usage in the
tree.

A failed request was also unrecoverable: loadOPDSURLs ended in
.catch(console.error), leaving opdsURLs undefined forever with nothing
to render and no way to retry. It now records why and the dialog offers
a retry.

Clipboard rows were dead on every plain-http LAN deployment for a
related reason: navigator.clipboard is undefined on insecure origins,
so useClipboard reported unsupported, which hid the icon, suppressed
the tooltip and made the click a no-op. The legacy fallback restores
all three.

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

* news: opds browsing, panels sort, and the opds dialog

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Every guard in #858 keys on the filesystem raising an error. A share
that answers "no such file" for a file that exists defeats all of them,
because not-found is exactly what a real deletion looks like. A CIFS
reconnect window does that for a few seconds at a time, and so does an
external rm + mv caught mid-swap.

One observation cannot tell those apart from a deletion. Two can, for
the short shape: the delete phase now probes the paths a scan reported
missing, waits five seconds, and re-probes only the ones that came back
gone. Anything that answers the second time was never gone, and is
dropped from the batch with a warning naming the paths — a flapping
share is worth saying out loud.

A scan that found nothing missing never waits, which is the common
case. The wait is spent on abort_event.wait, so an admin abort cuts it
short; daemon shutdown does not set that event, so a shutdown landing
inside the wait costs at most one delay. Covers are excluded because
they never come from a scan.

This does not cover a sustained outage, where both probes answer gone.
That needs confirmation across polls and stays unbuilt.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…me (#862)

An inode is identity, so it pairs a move outright. Plenty of moves do
not keep one: a copy-then-delete, a cross-device mv, a remount that
rotates the inode space. The poller's own docstring admitted the cost —
"legitimate cross-remount renames still degrade to delete+add (and the
user loses the comic.pk's bookmarks)" — because the delete cascades
Bookmark.comic away and the add re-imports the same file as a fresh,
unread comic. The existence backstop cannot help: the old path really is
gone.

Move detection now has a second tier. What the inode loops leave
unpaired is matched on a signature of filename, size and mtime, and only
when that signature is unique on both sides, so a wrong pair is
impossible by construction rather than unlikely. The filename carries
most of that weight: a bulk copy hands many files the same size and
mtime, and without the name they would pair with each other. The cost of
keeping it is that this pairs a file that moved, not one renamed to a
different name; that case has nothing left to match on.

Files only. A directory's size is meaningless and its mtime moves with
any child change, so children pair one at a time and the importer
creates the destination folders. Withheld paths are not indexed either:
an inode may still pair one because an inode is identity, but a
signature is a guess and an unreadable subtree may simply come back.
mtime compares exactly, as _is_stats_equal does, so a filesystem that
truncates it across a copy falls back to today's behavior.

Fixed alongside, because every paired row would have hit it: the
stale-stat refresh stored the mtime as an int. list(os.stat_result)
takes the sequence form, whose mtime is truncated, while set_stat stores
the float — and that model method warns about this exact trap in a
comment. A refreshed row therefore disagreed with its own disk snapshot
on the next poll and was re-imported once for nothing.

The watcher pairs by inode only as well. Porting this there is a
separate change: an add inside a 60s watcher batch may be an in-flight
copy whose size and mtime are not final.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The scanner matches comic suffixes with re.IGNORECASE and the importer
stores the path verbatim, so Foo.CBZ is a perfectly ordinary comic. The
nightly integrity check compared those same suffixes case-sensitively.

An uppercase comic therefore contributed no folder to the set the check
considers needed, and was skipped by the repoint step as well. The prune
then deleted its folder as empty, with no existence probe of its own,
and Folder.parent_folder, Comic.parent_folder and Bookmark.comic all
cascade: the folder took the comic, and the comic took the read
progress. A library whose comics all carry uppercase suffixes loses its
root folder row and everything under it, every night.

The suffix set now derives from comicbox's FileTypeEnum instead of a
hand-written tuple, so a new archive format cannot be left behind here
again, and the comparison folds case. Not filters.match_comic, which
consults unrar and pdf support: on a host without unrar every .cbr row
would drop out and be pruned, the same bug from the other side.

The prune also gets a backstop that does not depend on that rule being
right: a folder any comic still calls its parent is never stale. The
comment claiming the repoint step guaranteed as much was false for
exactly the rows this fixes; now it is true.

Installs that already ran migration 0043 with uppercase comics lost
those bookmarks then. A user-data sidecar from before the loss may still
hold them; restoring one is manual and not part of this.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
**The blind choice.** Two candidates can read identically — same series,
issue and year — and the dialog showed nothing else. The reporter's
screenshot has 92% and 84% with no way to tell which is which. Each row
now carries the cover, a link to the source's own page, the volume
ordinal where the source has one, and what the blended score is made of:
"Match 100% · Cover 60%" against "Match 100% · Cover 20%" says the cover
comparison *is* the whole difference.

Covers load straight from the two CDNs. An <img> is exempt from CORS, so
nothing on their side had to cooperate; codex's own img-src was the only
thing in the way, and this file already relaxes it for unpkg's pdfjs
images a few lines up. Two path-scoped prefixes is the whole backend
change — no proxy view, no cache, no SSRF ladder to contain a
server-side fetch of a url that arrived in a third-party API response.

**The clipped Pause button.** Vuetify's .v-card-title is nowrap with
overflow:hidden, so as a flex container it clips its last child: the
buttons cannot shrink below their content and the title will not yield.
Two utility classes fix it — the title truncates, the buttons do not
shrink — with no override and no layer question.

**"already exists" on a converted CBR.** Writing tags to a CBR repacks
it as a CBZ. With delete-original off the CBR stays beside its own
output, and every later write collides with it. comicbox refused, but
only after opening, merging and serializing, and its message named a
filename with no hint what to do; because tag-write errors dedupe by
path, that vaguer text also replaced the clearer one the planner had
recorded. Codex now refuses first, so there is one message per path and
it says to edit the twin instead. With rename on it checks the scheme
name too: the kept conversion is renamed after the write, which freed
the plain destination and let every retry mint another copy.

Which archives convert now comes from each row's file_type — comicbox's
own sniff, recorded at import — rather than the filename, so a CBZ
misnamed .cbr is written in place instead of mis-planned. One constant
serves the preflight view and the writer.

**The missing Failed Imports link.** It was gated on *unseen* failures,
so "Clear Warning" hid the only way back to a table that was still
there. It now persists while failures exist and is colored only while
unseen; the hamburger dot stays on unseen, because a dot is a
notification. The Tag Write Errors hint says a damaged archive is listed
there, and that Failed Imports is a different set — a file that imported
fine and later failed a write will not be in it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Its docstring says "Delete bookmarks without users or sessions", and it
has never deleted one. The filter also required comic to be NULL, and
Bookmark.comic is a non-nullable foreign key, so the query matched
nothing for its whole life.

The comic term was never needed either: that FK cascades, so a deleted
comic takes its bookmarks with it and cannot leave one behind. What can
be left behind is a row owned by neither a user nor a session —
invisible to every reader and returned by no query. No ORM path creates
one: the auth filter is exclusive and non-null, restore forces a user,
and cascade_if_user_null deletes session-owned rows whose user is NULL
while nulling the session on the rest. So a both-NULL row comes from raw
SQL, a migration or legacy data, which is what a backstop sweep is for.

The correct filter is the one cleanup_orphan_settings two lines below
already uses; this was a copy of it with one relation too many.

Because the sweep is about to delete rows for the first time on every
install that has ever run it, it now names the pks it is about to remove
before removing them.

Checked against two real databases first: the reporter's copy has 756
bookmarks and zero orphans, and this dev database has none at all.
Turning the sweep live is a no-op on both.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Writing tags to a CBR repacks it as a CBZ at a new path. With "delete
original" off the CBR stays beside its own conversion, and every later
write is refused. The message says to edit that comic instead, but left
the admin to go find it.

The refusal already queries the twin row to know it exists; take its pk
and library path from the same query and carry them into the tag-write
error record, so the Tagging panel can link to the comic and the log
line can name it. The two keys are optional and written only when there
is a twin, so every other failure keeps the shape it has today and
records already sitting in the filesystem cache still read.

Also adds the NEWS lines that #861, #862, #864 and the fixes half of
#863 shipped without.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
``Bookmark.unique_together = (user, session, comic)`` never enforced
anything. Both owner columns are nullable, and SQL treats NULLs in a
unique index as distinct, so (user=1, session=NULL, comic=5) could be
stored any number of times. The writer selects before it inserts, but
nothing serializes those two steps: a queued page turn and a synchronous
mark-read for the same comic both find nothing and both insert.

The duplicate is not cosmetic. Comic cards sum page and finished over
the rows, so a collection can report more finished children than it has;
the reader takes whichever row it read last; and OPDS progression and
the settings restore both fetch a single row and raise
MultipleObjectsReturned, which surfaces as a 500.

Two partial unique constraints say what was meant, each conditioned on
its own owner being present. Migration 0054 merges the rows that already
violate them first: the most recently updated row of a group survives
and takes the group's progress with it. sqlmigrate shows DROP INDEX plus
two CREATE UNIQUE INDEX ... WHERE, so no table is rebuilt.

The write path now runs both passes in one transaction and catches the
IntegrityError the loser of the race gets, re-running the update pass
for the row the winner just inserted. Without it the bookmark thread
would swallow the error and lose the page write.

Verified against a copy of the reporter's database: 756 bookmarks, one
duplicate group, 755 after, the surviving row still finished.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The register form read `this.MIN_PASSWORD_LEN`, and the auth store has
only `MIN_PASSWORD_LENGTH`. The read was `undefined`, `v.length <
undefined` is always false, and the whole branch had never run: any
password of one character registered. Had it run, its message would have
read "Password must be undefined characters long".

Fix the identifier in both the mapState key and the rule, and use the
wording the profile and reset-password dialogs already use.

The new spec seeds the store with a minimum of 6, not the default 4, so
neither a misspelled store key nor a hardcoded literal can pass it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Vue 3 does not invoke a mixin's setup(), so the global keyup listener in
auth-form-mixin.js has never executed in any of its four consumers.

Making it run would be worse than deleting it. These dialogs mount
whenever their parent renders, not only when they are open — auth-menu
renders ProfileDialog or AuthLoginDialog unconditionally, and
login-dialog supplies its own activator — so a component-lifetime
listener calling stopImmediatePropagation would kill keyup app-wide for
the whole session.

The protection it was meant to provide already exists and works: all
three reader keyup handlers bail on the auth store's isAuthDialogOpen
getter. Verified in the browser — `m` opens the metadata dialog from the
reader, and typing "mmm" into the login dialog opened from the reader
does not.

@vueuse/core stays in package.json; the reader components, the socket
store and clipboard.vue still use it.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The edit panel binds :rules to five fields, but grep -c v-form found
zero: nothing ever called validate(), so canSave ignored every rule, and
TagWriteRequestSerializer.patch is an opaque CharField so the backend
never saw them either. buildPatch clamped out-of-range values on the way
out — the author left a comment saying exactly that — so a year of
10000 was silently written as 9999 and the user was never told.

Follow the admin tabs' precedent: a <v-form ref="form"> root and a
validate() early return at the top of preSave. The Save button stays
enabled; clicking it validates, renders the messages and aborts.
@submit.prevent guards against VForm's own onSubmit calling the native
form.submit(), which would be a full page navigation.

VForm.validate() iterates every registered input and never checks
disabled, so a stale value in a field the selected formats cannot write
— a rating count with no average under COMIC_INFO — would block an
unrelated save with an error on a field the user cannot reach.
activeRules() gives a disabled field no rules.

The root element is replaced rather than wrapped: wrapping re-indents
1370 template lines. #editPanel's styles have no child selectors and
nothing else references it; #editToolbar's sticky containing block is
still the same content box.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…870)

* refactor(rules): hoist static validation rules to module constants

Nine rule arrays were computeds that never read `this`. Frozen module
constants exposed through data() discharge the "no computed for static
values" convention and shrink the plugin conversion that follows to one
line per rule. Vue leaves a frozen array raw, so binding it from data()
costs nothing.

No template changes: each data() key keeps the name the template already
binds. auth-tab keeps URL_REGEX — canEnableOidc uses it independently —
and profile-dialog keeps passwordRules a computed, since it reads
passwordSectionActive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(rules): install Vuetify's rules plugin with Codex's aliases

createVuetify() does not install the rules plugin — it is a separate Vue
plugin that needs the locale instance createVuetify() returns. One
composite default export keeps `app.use(vuetify)` the single install
point for main.js and for all 41 specs that pass this export in
global.plugins, with no second install path to forget.

That matters because an unresolved ["$alias", …] array fails *open*:
Vuetify warns and skips it, and the field validates with no rules at
all. rules-plugin.test.js pins that behavior from both directions — no
plugin, and a misspelled alias — and scans every $alias used under
src/components against the registry.

Three aliases: $intRange and $numRange share one range builder,
differing only in Number.isInteger vs Number.isFinite, and treat blank
as unset because every numeric field in Codex is optional. $notIn
unwraps a getter at validation time, so a set that reloads on a
WebSocket event while the dialog is open is read fresh — Vuetify does
not re-validate merely because the rules array changed.

throttling-section is the canary; its message is already the alias
default, so it needs no message argument.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(rules): express integer ranges through the $intRange alias

Five hand-written range closures become alias descriptors: the three
publish-date parts, the SMTP port and timeout, and the custom-cover max
upload. DATE_PART_BOUNDS and DATE_PARTS stay — buildPatch clamps with
the bounds independently, and edit-panel-date.test.js asserts that
clamp.

custom-covers is the one site with no blank guard: Number(null) and
Number("") are 0, which is below the minimum, so blank fails today with
"Must be 1–2048". $intRange alone would pass blank, so $required runs
first carrying the same message. Every input gives the same result as
before, including 0, which $required lets through by its own carve-out
and $intRange then rejects.

"the rules bound each part" called each rule as a function; a descriptor
is not callable. It now drives the real fields through validate(), which
is what the descriptors mean anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(rules): express rating range and name uniqueness through aliases

The community rating becomes $numRange — same builder as $intRange with
Number.isFinite instead of Number.isInteger — and the two "already used"
checks become $notIn with a getter.

The getter is required, not stylistic: usernames and names read admin
store tables that reload on WebSocket events while the dialog is open,
and Vuetify does not re-validate because a rules array changed. Passing
the Set directly would freeze whatever it held when the dialog opened.

Two deliberate deltas, both invisible: blank now passes $notIn, with the
required rule ahead of it rendering the same message, and nameSet()
returning false for a non-admin no longer throws a TypeError inside
validation.

communityRatingCountRules stays a closure — it reads the sibling
community_rating out of patch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(style): correct two rbg( typos that silently voided a colour

An invalid function name makes the whole declaration invalid, so both
sites have been falling back to the inherited colour. Neither is a
visible change — the fallback happened to be close enough that nobody
noticed — but the declarations now do what they say.

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

* fix(browser): show the card hover border that never rendered

`border: solid thin` on the line above has always had its colour
discarded, because `rbg(` is not a function and an invalid value voids
the declaration. The border fell back to `currentColor`.

Unlike the other two typos this one is visible: hovering a card now
draws the orange border its author intended.

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

* chore(style): delete dead theme config, dead CSS and a dead dependency

- `options: { customProperties: true }` is a Vuetify 2 key; Vuetify 3
  emits the --v-theme-* variables unconditionally.
- `new createVuetify(...)` — createVuetify is a plain factory. `new` on
  a function returning an object discards the constructed `this`.
- The CSP comment named `theme.isDisabled`, which does not exist (the
  real switch is `theme: false`), and its goal is unreachable anyway:
  Django already ships style-src 'self' 'unsafe-inline' for Vue's
  scoped styles. Rewritten to say that.
- anchors.scss's commented `a:visited` block references
  --v-theme-secondary, a colour the palette does not define.
- pager-vertical.vue's debug CSS comment.
- browser-table.vue hardcoded the 0.12 that Vuetify emits as
  --v-border-opacity; same value, now sourced from the theme.
- typeface-roboto was in package.json with no import anywhere.

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

* test(theme): pin the theme and style contract

Nothing about the theme was tested. A rename, a stray hex in `defaults`
or a typo'd colour function changed rendering with no signal — which is
how `rbg(` survived in three files, one of them hiding a card hover
border for years.

Two contracts, no mounting and no new dependencies: what
src/plugins/vuetify.js declares, and what the style blocks may say. The
scan asserts it found files, so an empty list can never make the rest
vacuous.

Verified it bites: altering one colour hex and typing `rbg(` into a
component each fail their test by name.

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

* refactor(theme): pass theme tokens to defaults, not resolved hex

Ten component defaults handed Vuetify `codexTheme.colors.primary`, the
literal "#CC7B19". A hex lands as an inline style on the element; a
token resolves to a theme class at render time, which is what every
other colour in the app does and what keeps a colour change in one
place. reader-toolbar-nav's :track-color and the edit panel's disabled
Save button did the same thing by other routes.

The contract test gains the assertion, so a hex cannot come back in.

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

* refactor(theme): kebab-case the eight custom colour tokens

Vuetify's own tokens are kebab-case (surface-light, primary-darken-1,
on-surface); Codex's eight were camelCase, so every style block mixed
two spellings of the same idea. Values are unchanged — the four text
greys keep their exact hexes and DESIGN.md §3 survives verbatim, only
the names in the table move.

The auto-generated utility classes become .text-text-secondary and the
like. Nothing uses them, and the edit panel's disabled Save button emits
text-text-disabled, which is fine.

The contract test gains a kebab-case assertion: a reintroduced camelCase
variable resolves to nothing and the declaration is dropped in silence.

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

* fix(admin): give the tagging validation chip a real icon, drop @mdi/font

The chip passed the class names "mdi-check-circle" / "mdi-alert-circle"
to :prepend-icon. Codex runs the mdi-svg icon set, which resolves an
icon name to an SVG path; a font class name is not one, so the chip has
rendered an empty icon slot. It now passes the paths from @mdi/js, as
every other icon in the app does.

That was the last consumer of the webfont, which main.js imported
globally. All three greps are empty — no `class="... mdi ..."`, no
"mdi-name" strings under src, nothing in the Django templates — so the
import and the dependency go.

main-*.css drops from 508 KB to 192 KB, and no materialdesignicons
webfont files ship at all.

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

* fix(pwa): correct the status-bar meta and the manifest theme colour

apple-mobile-web-app-status-bar-style accepts only default, black or
black-translucent. It held "#2A2A2A", which iOS ignores, so the setting
has never applied. black-translucent gives the dark chrome the hex was
reaching for and lets the app paint under it.

The manifest's theme_color was the codex orange. theme_color is the app
bar, not an accent, so an installed Codex had an orange bar over a dark
app. It is surface-light now, matching the theme-color meta and the
background_color beside it.

Both sites carry a comment naming src/plugins/vuetify.js as the source.

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

* refactor(browser): sum the browsePane toolbar offsets instead of enumerating them

Three toolbars can appear above the grid independently, so the padding
had eight cases and seven modifier classes spelling out every
combination. Because #browsePane is an ID (1,0,0) and a class is
(0,1,0), not one of them could win without !important — seven of this
file's nine, and seven of the repo's 76.

Each modifier now sets one custom property and the ID rule sums them.
Three classes instead of seven, no !important, and a fourth toolbar
would be one line rather than eight.

The 0px defaults deliberately live in the var() fallbacks, not on
#browsePane: custom properties obey specificity, so an ID-level default
would beat every class-level override.

Verified in the browser across all eight states — 134 / 174 / 166 / 206
/ 154 / 194 / 186 / 226 px — each identical to the class it replaces,
and the live search toggle lands browsePaneSearch and 166px.

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

* refactor(style): one global stylesheet, zero unscoped style blocks

Four components carried an unscoped <style> block, each with an
eslint-disable above it, and two of them held byte-for-byte copies of
the same .coverPopup rules. An unscoped block in a component is a
global rule filed under whichever component happened to need it first,
and .settingsSubHeader proved it: six components across the browser and
reader drawers used a class defined in settings-drawer.vue, with
comments pointing back at it.

src/styles/global.scss holds all of it, imported once from main.js
after vuetify/styles. The three back-pointing comments now name the new
file. The !importants stay for now; they come out with the cascade
layers.

Verified in the browser: all nine rules present once in the document,
.settingsSubHeader still #808080 / bolder / 10px in the drawer, and
every .coverPopup declaration resolving to its old value — 70vh, 60vw,
4px, the same shadow.

Also adds the NEWS lines for the card hover border, the tagging chip
icon and the PWA app bar colour.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(tagging): release the pooled Metron connections after each task

Since mokkari 4.8.0 every session keeps one pooled TLS connection, and
comicbox 5.2.0 releases it only when asked. Nothing in codex owns all of
them: the scan builds one OnlineSession, the prompt applier builds its
own per answer, and the explicit-id fetch lets comicbox build a third.
So the release is process-wide, which is safe because every Metron path
in codex runs on the one OnlineTagThread.

run_session releases as the last statement of its finally, after the
deferred applies — those run on this same thread and open their own
sessions, so releasing before them would only force a reconnect.
resolve_prompt releases in a finally around the apply, and not on the
skip branch, which makes no request at all.

The credential validator builds a raw mokkari Session per check in the
web process and never closed it; now it does, on every path.

A reconnect per answered prompt is the cost, and it is exactly what
every request cost before mokkari 4.8.0. A scan still keeps its pool for
the whole run.

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

* feat(tagging): link an in-batch destination collision to its rival

Two archives in one directory whose names differ only by suffix both
convert to the same CBZ. Codex's own preflight passes them: it models
the database, and no row or file holds that name yet. comicbox 5.2.0
finds it — its claim map refuses the second before any archive is
opened — and now says which archive it collided with, as a typed
DestinationOccupiedError carrying the occupant.

That occupant is the one signal codex could not have produced itself,
so the write-error record turns it into the same twin link #865 added
for the database case. The destination is a fallback: a row may have
appeared there between codex's check and the write.

comicbox's own message stands — it already names both archives — and
the batch still queues one errors-changed task rather than one per
error.

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

* docs(tagging): correct two docstrings comicbox 5.2.0 invalidated

Both said comicbox refuses an occupied destination "only after opening
the archive, merging the metadata and serializing it". 5.2.0 refuses
before reading any metadata, and bulk_write sniffs every destination
before the write pool starts.

So the reason codex keeps its own check is not timing. It is the
database: which comic holds the twin, so the error can link it, and the
scheme-name twin a rename-on write would mint, which no filesystem
check can predict. The one-message-per-path reasoning is unchanged and
stays.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…nent (#874)

The browser table's cover cell and the admin custom-covers grid each
hand-rolled the same v-menu, with byte-identical popup rules that #871
had already deduped into the global stylesheet. Both now use
cover-popup.vue, which #872 landed.

Both gain what neither had: hover-open, focus-open and keyboard access.
An <img> is not focusable, so the only way to enlarge a cover was a
mouse click. They also lose their hand-rolled @Mouseleave — Vuetify's
own hover handling covers the content too, with open and close delays.

The browser cell's popupOpen state goes with it; CoverPopup is keyed on
row.pk, so a reused cell instance gets a fresh popup rather than one
pinned to the previous row.

The .coverPopup block leaves global.scss, which is the hand-off #871's
WS8 left for this PR. Nothing references the class now: it was never
global styling, only a component nobody had written yet.

Two details the move forced:

Each site's crop and radius go in as a style object rather than a class.
CoverPopup's menu branch is a VMenu fragment, and a scoped parent class
cannot reach a multi-root child — it would land without the data-v
attribute and match nothing.

CoverPopup gains a `loading` prop, defaulting to the lazy it already
used. The table thumb is sized `width: auto`, so before its image loads
it has zero width; a zero-area element never satisfies the browser's
in-viewport check, and the load never starts. Caught in the browser: 0
of 17 covers loaded, against 17 of 17 on develop. It passes "eager".

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(style): declare the cascade-layer order

A browser fixes a layer's position the first time it sees the name, and
django-vite links imported chunks' CSS before the entry's own — the
built manifest has three Vuetify chunks whose CSS opens with
`@layer vuetify-components`. So production already establishes
vuetify-components before vuetify-core, and any declaration shipped
through main.js would arrive second.

Inline in the Django template is the only placement that cannot be out
of order and cannot 404.

The order is the whole point. codex-base and codex-components sit after
vuetify-components, so a codex rule beats component CSS with no
!important, and before vuetify-utilities, so a `color` prop still wins.
codex-components after codex-base lets a component's own rule beat the
global `a` rule. codex-trumps is last, the named escape hatch;
vuetify-final holds forced-colors accessibility fixes and is never
overridden.

Nothing is in any layer yet, so this commit changes no rendering.

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

* feat(style): put the global stylesheet in the codex-base layer

With the order declared, the snackbar carve-out can go. A snackbar's
`color` prop paints .v-overlay__content through a `bg-*` utility class;
an unlayered background-color beat that class no matter how the snackbar
asked, which is why the rule had to exempt .v-snackbar__wrapper by name.
In codex-base the utility wins on layer order, so the rule addresses
every overlay and says what it means. That is the root cause of #845
rather than the per-instance patch it shipped with.

The file's three !importants go with it: each existed to beat Vuetify's
own layered component CSS, which codex-base now beats by position.

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

* feat(style): the five shared partials declare their own layer

A partial has to wrap itself. `@use` emits its CSS at the top of the
consuming stylesheet, outside any @layer the consumer writes, so a
consumer-side wrap would silently leave the shared rules unlayered —
and unlayered CSS beats all layered CSS, so they would win over
everything. `@use` inside @layer is a Sass error, so there is no third
option.

Verified against the built CSS: the layer survives @use and @forward,
Vue's scoping lands the [data-v-*] attribute inside the block, and
:deep() still compiles to a descendant selector within it.

anchors.scss drops its two !importants with the move — they existed to
beat Vuetify's own anchor styling, which codex-components now beats by
position. That ends the anchor war: the four components whose own
!important existed only to outrank anchors.scss drop theirs too. Their
scoped blocks are unlayered, so they win outright.

76 !important declarations at the start of this plan, 62 now.

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

* feat(style): layer the settings area's scoped blocks

First of the six areas, smallest blast radius first.

Three colour/background !importants go: two backgrounds that beat
Vuetify's navigation-drawer CSS, and the :deep() list-item icon tint.
All three lose to codex-components' position now, and the icon tint
keeps winning over admin-menu's semantic colours the same way it did —
that rule is more specific, and specificity still decides inside a
layer.

The `position: relative !important` stays: it is layout, not colour, and
this audit is scoped to colour.

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

* feat(style): layer the metadata area's scoped blocks

No colour !importants to drop here — metadata-text's went with the
anchor war in the partials commit. The two that remain set font-size
and min-height, which this audit does not touch.

Audit (b): edit-panel is one of only two files in the wrap set that
combine a Vuetify spacing utility with scoped rules. Its `mt-2`
checkbox is matched by no scoped margin rule, so nothing changes hands.

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

* feat(style): layer the root components' scoped blocks

Ten files. The audit turned up the first rule that genuinely has to beat
a utility class: VEmptyState hands its action button
`color: surface-variant`, which arrives as a bg-* class in
vuetify-utilities, and utilities deliberately outrank codex-components.

That is what codex-trumps exists for, so the rule moves there and drops
its !important. Passing `color="primary"` to the empty state instead
would let Vuetify derive on-primary as white and flip the button's black
label — a look-at-it decision the theme plan deliberately deferred.

The other !importants in this area set margins, position and opacity,
which this audit does not touch.

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

* feat(style): layer the admin area's scoped blocks

Seven files, and the clearest payoff in the plan: admin-menu's
notification icons needed `!important` purely to outrank
settings-drawer's blanket icon tint, which itself needed `!important` to
outrank Vuetify. Both rules are in codex-components now, so the extra
class here wins on specificity alone and neither side carries
!important. Three of them gone, and a comment that used to explain an
arms race now explains ordinary CSS.

library-table's `.disabled` goes with them. It is not in the wrap set —
its !important fought the global anchor rule, which is layered below it
now.

Audit (b): tagging-status-table is the other file combining Vuetify
spacing utilities with scoped rules. Its three `mr-1` elements are
matched by no scoped margin rule.

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

* feat(style): layer the reader area's scoped blocks

Eight files, two colour !importants dropped: the arc select's selection
text and the page-error empty state's icon and headline. Both fought
Vuetify's own component CSS, which codex-components beats by position.
Neither element carries a utility class — VEmptyState only colours its
icon when a caller passes `color`, and nothing here does.

The remaining !importants in this area set height and width on the PDF
canvas and the page tracker, which this audit does not touch.

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

* feat(style): layer the browser area's scoped blocks

Nine files, the largest blast radius, which is why it went last. Three
colour !importants drop: the card control buttons' colour and hover
colour, and the transparent v-btn overlay. Neither button sets a colour
prop and VBtn is not in codex's defaults, so no utility class competes —
all three only ever fought Vuetify's own component CSS.

76 !important declarations when this plan started, 50 now. The rest set
geometry, size and opacity; this audit was colour only.

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

* docs(design): record app-wide colour and the cascade

DESIGN.md described the admin area's visual language. Colour and the
cascade are not admin-only, so the scope line now says which sections
apply where, and two new sections record what the code actually does.

§9 Colour: the two ways to reach a theme colour and the four ways not
to, with the contract test named as the thing that fails.

§10 Cascade layers: the order, why it lives in the Django template and
nowhere else, which layer each kind of stylesheet belongs in, the two
Sass rules that make self-layering partials load-bearing, and that
!important is no longer the tool for beating Vuetify.

Every claim here was checked against the tree it describes.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The SMTP host field required a dot, so `localhost`, `mailhog` and every
Compose service name could not be typed into it even though the server
accepts them -- `host` is a plain CharField with no format check. Drop
the regex rule rather than loosening it; the server enforces length
only, and a blank host is the documented "email is off" state.

The change-password dialog read MIN_PASSWORD_LENGTH off the component
while mapping only showChangePasswordDialog, so the comparison ran
against `undefined` and the minimum-length check never fired. Same bug
class as #867 in login-dialog.vue; fixed the same way. Had it fired the
message would have read "Password must be undefined characters long".

Its wording is unified with the other four password rules while here.
The new vitest seeds a minimum of 6 so a missing mapping, a misspelled
store key or a hardcoded 4 fails.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A scan that cannot find a file infers a deletion, and the delete
cascades the comic's bookmarks away. The reporter lost a publisher's
read progress twice to a share unreachable for 41-50 minutes, far
longer than the five-second second look covers.

This is the state that makes retention expressible. Nothing reads or
writes the column yet, so the change is inert: no behaviour moves until
the hiding, stamping and revival PRs land on top of it.

Two things here are easy to get silently wrong, and both are pinned by
tests that fail when reverted:

A subclass Meta's `indexes` REPLACES the base's rather than merging, so
declaring the partial index on `WatchedPath.Meta` would hand it to
Folder, FailedImport and CustomCover while silently skipping Comic --
the model that most needs it -- and would drop Comic's existing ACL
index at the same time. Both concrete models declare their own.

`BULK_UPDATE_COMIC_FIELDS` is derived from `Comic._meta.get_fields()`,
so the column enrols itself and `bulk_update` would write a freshly
constructed Comic's None over a live stamp on any unrelated re-import.
It joins `metadata_imported_at` in the exclusion set. That also keeps it
out of `BULK_CREATE_COMIC_FIELDS`, which is built from the same tuple.

The index is partial on purpose. The visibility filter is
`missing_since IS NULL`, which matches ~every row and which no index can
serve; the reap's `missing_since < cutoff` matches ~none. Precedent is
LibrarianStatus's partial index on its nullable datetime markers.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Three sites painted the orange themselves and set a literal black
label, while every `bg-primary` element took Vuetify's derived
`on-primary` -- which `genOnColors` picks as WHITE for this orange
(verified by running hasLightForeground against #CC7B19). So the app
had two different foregrounds on the same accent.

Declare `on-primary: #000000` and point the three literal blacks at
the token. Black everywhere: the three sites are unchanged, and six
elevated/flat `bg-primary` buttons flip from white to black -- the SSO
login button, the browser-settings Save, the table column-picker Save,
the online-tag launcher, and the metadata edit-panel confirm and
add-URL dialogs. Tonal buttons, chips and progress bars are unaffected;
they use `text-primary` or carry no text.

The three rules are replaced, not deleted: deleting them would leave
the empty-state label on `on-surface-variant` and the list items on
their inherited colour. empty.vue's comment about the derived white is
now stale, so it records the real reason not to pass `color="primary"`
there instead -- VEmptyState also hands `props.color` to the icon.

Measured in the running app: `--v-theme-on-primary` resolves to 0,0,0;
`.clearFilter` renders rgb(0,0,0) on rgb(204,123,25); the column-picker
Save button renders rgb(0,0,0) where it was white.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Every validation number the admin client enforces is also enforced on
the server, and the client re-typed them. That is how #867 happened --
a minimum-length rule reading a store key nobody had mapped, comparing
against `undefined` and never firing -- and how it happened a second
time in the change-password dialog, which this repo fixed last week.

`codex/choices/limits.py` types each limit once and generates the
client's copy, so a component cannot drift from the server and a
misspelled key is undefined rather than stale. It deliberately imports
nothing from Django or comicbox: `codex.settings` imports it, so it has
to stay importable before the app registry is ready. A test asserts
that.

The password minimum is the proof case and the first consumer. Server
enforcement is exclusively Django's `validate_password()` and no DRF
serializer carries `min_length`, so OPTIONS metadata could never expose
it -- a generated constant is the only transport that can. All five
password sites now read `LIMITS.passwordMinLength` directly and the
store key is deleted.

The three field widths move here and `codex.models.base` imports them
back, and the OIDC url literal is replaced by the constant of the same
value, so no schema moves. The three widths Django owns are mirrored
with pytest tripwires rather than definitions.

The two dialog vitests change contract with it: seeding a store value
no longer means anything, so they read the generated file and assert it
is a real number -- which is what catches a typo'd key, the failure
mode that started this.

The remaining constants have no consumers yet; the bounds and length
rules that use them land in the follow-ups.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Three holes where a direct API call could save a value the app cannot
survive. The client's rules were the only thing in the way, and a rule
in a browser is not validation.

A saved page size of 0 is a live 500: `get_browser_max_obj_per_page`
honours "0" -- only an empty or unparseable value falls back -- and
`ceil(count / 0)` raises ZeroDivisionError, so browsing stops working
entirely until an admin fixes the flag from a UI they can no longer
reach. A saved upload cap of -1 makes every custom-cover upload fail
with "Upload exceeds -1 MB limit". Neither value is rejected anywhere
today.

Both live in a text column shared with every other flag, so a
MinValueValidator cannot go on the field; `AdminFlagSerializer.validate`
is the only place the bound can be expressed. It grows a per-key range
map beside the existing per-key branch, and `validate` splits into two
named helpers rather than growing a third arm inline.

The SMTP port and timeout are not literally unbounded -- Django derives
a range from the integer type and DRF lifts it into the field -- but
the derived range is (0, 9223372036854775807), measured here, so port 0
and port 70000 both save while negatives are refused. Explicit
serializer-level bounds, not model `validators=[...]`: validators
deconstruct, so those would cost an AlterField migration for no schema
change.

On the client, `flag-card.vue` has no v-form and PATCHes on every
keystroke, so a rule displays a message without stopping the write. The
write itself is gated. Both client controls now read their bounds from
the generated limits file rather than retyping them.

Each test fails against the unbounded code.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`from_address` is the envelope sender of every outbound message and was
a CharField with client-only validation, so anything at all could be
saved through a direct API call and a malformed one failed at send time
instead of at save time. It becomes an EmailField on both email
serializers rather than on the model: the model type would be truthier,
but an AlterField migration buys nothing the serializer cannot enforce,
and `put()` is the only admin write path. `max_length` stays explicit
so it tracks the column rather than EmailField's own default of 254.
The test-send serializer's `host` and `subject_prefix` gain the widths
the save path already enforces and they carried not at all.

D6 suggested consuming DRF's OPTIONS metadata for the client's length
rules instead of new constants. It cannot work here: SimpleMetadata
emits field info only for `{PUT, POST} & allowed_methods` on views with
`get_serializer`, and in this codebase the detail routes are PATCH-only
while the email, OIDC and throttle singletons are plain AdminAPIViews
with no serializer hook. It would cover three of the six fields, miss
the SMTP host and the OIDC url entirely, cost a round trip per dialog,
and return snake_case under the JSON:API renderer. The generated
constants cover all six with no new alias -- Vuetify already ships
`$maxLength`.

No frontend rule enforced any length before this, on any field.

The SMTP host gets a rule again, the honest one: length only. The regex
removed last week refused `localhost` and every Compose service name;
the column width is the one thing the server actually checks there.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
D6 called this "a deletion, not an addition" -- stop scanning locally
and surface the server's error. But there was no server-field-error
plumbing to surface it into, on either of the two renderers, and
neither shape is the one a reader would expect.

Admin resource viewsets render through AdminJSONAPIRenderer, whose
`format_errors` is literally `{"errors": data}` -- so a 400 body is
`{"errors": {"username": [...]}}`, a dict rather than the JSON:API error
list, because DJA's own exception handler is not installed. The
interceptor only wraps when `errors` is an array, so it stays a plain
xior error, and `getErrors` scanned its known keys at the TOP level and
fell through to `errors = [data]`. SubmitFooter then rendered an object,
which Vue stringified as JSON.

Envelope endpoints emit `{"errors": [{status, title, detail}]}`, which
DOES wrap into an APIError -- and APIError has no `.response`, so the
same parser logged "Unable to parse error" and returned ["Unknown
error"]. The email tab showed no save errors at all.

Both shapes are pinned by a pytest written before any of this, and both
now normalize into one `{field: [messages]}` map that inputs bind
per-field. Keys arrive camelCased, matching the client's model keys.

`flag-card.setError` read `Reflect.get(this.formErrors[0], field)`
against a list of message strings, so it was always undefined and a
flag error could never render at all.

The client uniqueness scans stay as a fast pre-check rather than being
deleted -- #870 made them getter-based so they re-read after a
websocket reload -- but they were never the authority and are now
documented as such: `loadTable` reads only `body.results` and never
follows `next`, so past the first page of 200 they cannot see the
duplicate the server will reject. `library-create-update-inputs.vue`
never had an equality check at all, so it gains correct behaviour it
never had.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ajslater and others added 29 commits October 26, 2026 15:34
…996)

A library change (e.g. Mylar importing into the series being read) reloads
the open book through loadMtimes -> loadBooks. loadBooks stepped the prev/next
page routes from params.page, but the reader route is /read/:pk and keeps the
page in the query, so both routes came back false. The next-page link then
resolved to /read/:pk with no page (the cover) and the arrow keys fell
through to the book-change prompt, so a second press opened the next book.
An arc switch mid-book did the same. loadBooks now steps from the store's
page when reloading the open book (the query lags in cacheBook mode) and from
the query's page otherwise.

On a keyboard book change, the book-change flag pre-mounts the target book's
pager, and its $route watcher called setActivePage before loadBooks swapped
the books. setActivePage acts on the current book, so the book being left got
the new book's page and bookmark: going forward reset it to page 0, going
back marked an unread book finished. The pager now sets the page only once
its book is the current book.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ty 0.0.92 resolves Django foreign-key ``_id`` attributes, so 17
``# ty: ignore[unresolved-attribute]`` comments went unused. Drop the ty
part and keep each line's pyright ignore, which basedpyright still needs.

It also rejects an in-method ``self.request: Request`` declaration as an
incompatible override of Django's ``View.request``. AuthMixin,
BookmarkFilterMixin and UserActiveMixin now declare it at class level, as
APIView does; ``self.request`` still resolves to DRF's Request.

SettingsReader.comic_id is now ``int | None`` (global rows have no comic).
The reader's batch lookup filters on ``comic_id__in``, so a walrus guard
narrows the key instead of an ignore.

``comic.pk = None`` is Django's copy idiom, but django-stubs types pk as
never None, so that test line gets a narrow ty ignore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…500 (#998)

AdminUserViewSet's guards against deleting or deactivating the logged in
user raised django.core.exceptions.ValidationError, which DRF's exception
handler does not convert, so both escaped as a 500. In the admin UI,
unticking your own Is Staff or Is Active and saving showed an unknown
error instead of the reason. Delete is already hidden on your own row,
but the API now answers it the same way.

Raise DRF's ValidationError keyed by ``detail``. The JSON:API renderer
wraps it as {"errors": {"detail": "..."}}, which the admin form shows as
a plain sentence.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Adapt to comicbox 5.5.0: MetronInfo series fields, key-only Metron, BCP 47

comicbox 5.5.0 writes MetronInfo v1.2, which carries a series' EndYear,
Status and Summary. The tag editor now writes them into MetronInfo like
Start Year, gated to that format, and the re-read imports them. The
database-only path that stood in for a format goes: POST
/admin/series-attrs, its serializer, the "saved in Codex only" hint, and
the ImportTask.series_attrs side channel that carried a tag write's or
online match's series values past the file. A ComicInfo-only write now
stores none of them, like every other MetronInfo-only field. Clearing one
empties the file but, as for Start Year, leaves the Series row.

MetronInfo refuses an EndYear on an Ongoing series or before StartYear,
and comicbox drops it with only a log line, so End Year gets a rule that
blocks the save with that reason.

Metron is API-token only in comicbox 5.5.0 / mokkari 5.0: the credential
dataclasses lost user/password and raise TypeError on them. Migration
0059 drops the stored login, every credential builder and "configured"
check is key-only, the deprecation UI goes, and backups stop carrying the
columns (old sidecars' are ignored). Telemetry keeps the
has_metron_credentials key, now from the key alone.

BCP 47 language tags like pt-BR now survive comicbox. They read as
"Portuguese (BR)" in the serializer, browser table and search index
instead of the raw tag plus a warning per row, and the editor maps the
name back to the tag.

The pyproject pin and lock wait for the comicbox 5.5.0 release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* update deps

* Adapt to djangorestframework-jsonapi 9.0: own the admin error body

Codex renders admin resource viewsets with DJA's JSONRenderer but not
DJA's exception handler, so a 4xx reaches render_errors as DRF's raw
error body. Through 8.1, format_errors wrapped any body as
{"errors": data}. In 9.0 it passes a dict through bare and sorts a list
as error objects. A duplicate username came back as {"username": [...]},
and a list of message strings would crash the sort and turn the 400
into a 500.

AdminJSONAPIRenderer.render_errors now builds {"errors": data} itself,
so the wire shape the frontend and tests/test_admin_error_shapes.py pin
stays put whatever DJA's helper does. A new test covers the list shape.

Nothing else in 9.0 applies: IncludedResourcesFilter only concerns
DJA's own views and serializers and ?include=, which codex doesn't use,
and the polymorphic removals don't touch it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…1000)

A new visitor's first page load and its API calls arrive together on one
fresh session. Each looked the browser settings row up, missed, and
inserted; the loser hit unique_settingsbrowser_session and answered 500.

The browser row, its filters and its last route are now created in one
atomic block, so a loser rolls back cleanly and no request sees a row
without its related rows. On IntegrityError the lookups run again and the
winner's row is used; with no row found the error is re-raised. The reader
path already absorbed the same race through get_or_create.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(frontend): register Vuetify labs components

Auto-import the labs components (VCheckboxGroup, VSplitBtn). A checkbox
group passes its own color to its boxes even when unset, so give groups
the primary color checkboxes already default to, and line groups up with
a lone checkbox: drop the labs CSS's 16px label and 6px box indents and
space inline boxes apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(choices): derive the tag write formats

Add the write formats, titled by comicbox, to tagging-choices.json so the
tag editor and the admin Tagging tab stop hand-writing the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(admin): require at least one default tag write format

comicbox refuses a write with no formats, so an empty default made every
file in an online-tagging session fail. Reject an empty list, a non-list
and unknown format ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(tag-editor): formats as a checkbox group

Replace the Formats chip select with checkboxes. The last checked format
is disabled and the group says why, which replaces enforceMinFormat and
its undeclared lastFormats. The hint sits beside the boxes on a wide
toolbar and under them, with the toolbar wrapping, on a narrow one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(admin): tagging Metadata Formats as a checkbox group

The last checked format is disabled and explained, so auto-save can't
write an empty list. A row already saved empty shows an error instead of
auto-saving a default on open. The docs links stay in the field's hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(online-tag): sources as a checkbox group

A source without an API key is disabled with a hint pointing to Admin >
Tagging, and an empty selection explains why Search is disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(browser): column picker categories as checkbox groups

Each category is a group titled by its label. A click still goes through
toggleColumn, so a new column lands in canonical position, not at the end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(browser): Show Collection Levels as a checkbox group

A click still sends one setSettings call with only the flag it flipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(admin): Defaults Show Collections as a checkbox group

The locked top collection's reason becomes the group's hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(frontend): register Vuetify labs components

Auto-import the labs components (VCheckboxGroup, VSplitBtn).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(admin): library Poll / Force Update as a split button

Experiment. Poll is the button and Force Update its one menu item. Both
confirmations sit outside the menu, which unmounts its content when it
closes, so ConfirmDialog gains a controlled mode: bound to a modelValue
it renders no activator and reports every close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(admin): folder list reports unlistable folders and its parent

A folder the server can see but not list answered "Server Error" with a
logged traceback; it is now a 400 "Permission denied" keyed to the path.
Folders sort ignoring case, so dc sorts next to DC, and the listing names
its parent folder (null at a filesystem root). ".." stays for the current
picker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(admin): folder tree picker for Add Library

Experiment. A VTreeview rooted at the server's default folder replaces
the combobox picker: one click chooses and opens a folder, a Parent
folder button climbs while keeping what was loaded, Enter on a typed
path re-roots there, and Show Hidden Folders refetches. Folders an
existing library covers are disabled and say so. Nothing is chosen on
open, since the default folder is the newest library's parent. Past 200
children the rest collapse into a "type the path" leaf, which keeps a
5,000-folder directory opening in about 0.6s. The old picker stays until
the go/no-go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(admin): Show Hidden Folders keeps the folder tree in place

Toggling it re-rooted the tree at the chosen folder, collapsing
everything above and beside it. It now refetches the root and each open
folder with the new setting and keeps them open; a closed folder lists
with the new setting when it is opened. Hiding never hides the way to
the chosen folder, so unchecking it deep inside .claude keeps the open
branch down to the choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(admin): remove the old folder picker

The folder tree replaces it. With it go the store's folderPicker state,
loadFolders and clearFolders, and the server's ".." entry: the tree
climbs with parentFolder instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* docs(news): Keep a Changelog sections under gitmoji headings

Every version's legacy `- Features` style list labels become
`### ✨ Added` style headings in a fixed order: Breaking Changes, Added,
Changed, Deprecated, Removed, Fixed, Security, Developer. About 25 legacy
spellings fold into those; status-only versions (yanked, broken) move
their note into the version title.

cfg/eslint.config.base.js takes devenv's checkSiblingsOnly change so a
section heading may repeat under each version but not within one. The
v2.4.1 golden release body follows the new format; GitHub Releases
already published keep their old bodies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* restore some sections from old news.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* update deps

* chore(deps): bump eslint_d and eslint-plugin-yml

From the bun update that make update-devenv runs: eslint_d 15.1.0,
eslint-plugin-yml 3.10.0 and their lockfile transitive updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(devenv): update to 0.3.1

Raw make update-devenv output against devenv 0.3.1, plus uv lock for
the new docs group.

- mkdocs.yml, docs/requirements.txt and docs/style.css retired
- zensical.toml created from the devenv template
- .readthedocs.yaml builds through build.jobs with uv
- docs group: mkdocs + mkdocs-minify-plugin -> zensical~=0.1
- source-include: mkdocs and docs/style*.css entries out, zensical.toml in
- cfg/docs.mk runs zensical build --strict / zensical serve --open
- cfg/eslint.config.base.js ignores site/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: Zensical project settings, drop the mkdocs theme stylesheets

zensical.toml [project] takes site_name, site_author, site_description
and nav from the old mkdocs.yml, plus site_url for Read the Docs and the
codex/img/logo.svg theme logo. The style.{material,mkdocs,readthedocs}.css
variants only existed for docs/style.css, which devenv retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(build): sort source-include, drop entries docs/** covers

update-devenv appended docs/** and zensical.toml after the sorted list.
The explicit docs/ files are all inside docs/**; the sdist file list is
unchanged (2219 files, compared before and after).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix docs orange

* chore(deps): bun update transitive lint deps

From the bun update that make update-devenv runs: mdast-util-* and
vue-eslint-parser patch releases. package.json is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(devenv): update to 0.3.2

Raw make update-devenv output against devenv 0.3.2: the docs palette
primary moves from deep purple to indigo (accent orange stays), and
zensical.toml enables admonition, pymdownx.details, pymdownx.superfences
and pymdownx.quotes callouts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(devenv): update to 0.3.3

Raw make update-devenv output against devenv 0.3.3: Markdown lint
accepts GitHub callouts (no-missing-label-refs allowLabels for !NOTE,
!TIP, !IMPORTANT, !WARNING and !CAUTION). The bun update and uv lock
changed nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: GitHub-style warning callouts for the deprecation and caveat notes

The Docker Hub move note, the three config deprecation notes and the
read-only /comics mount note become > [!WARNING] callouts, which GitHub
renders natively and Zensical renders as admonitions through
pymdownx.quotes callouts. The bare > line after each marker keeps
Prettier's proseWrap from joining the marker onto the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: note that a correct proxy needs no CSRF trusted origins

The CSRF 403 section now ends with a callout: in general a correctly
configured reverse proxy shouldn't need csrf_trusted_origins or
CODEX_CSRF_TRUSTED_ORIGINS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: lead the CSRF 403 section with its note

The note that a correctly configured reverse proxy needs no CSRF
trusted origins moves from the end of the section to the top, and the
first paragraph drops the clause that said the same thing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Wrap every 💥 Breaking Changes section in a > [!WARNING] callout and give
each yanked or broken release a > [!CAUTION] body naming the release to use
instead, via devenv's news_normalize.py. Also point v2.3.1 at the
💥 Breaking Changes sections instead of the long-gone Upgrading notes, and
normalize two sub-group labels in v1.2.4.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore(deps): comicbox 5.6.0

comicbox 5.6.0 changes only the filename format: filenames now read the
month, day, volume count and publisher, renames write the full date and
volume count but never the publisher, and `(Digital)` / `(DC)` alone no
longer parse as the title. comicfn2dict 1.1 and comicbox-pdffile 1.1.2
come along.

codex needs no code change. None of the new filename keys is a new
top-level comicbox field, so the derived delete_keys list is unchanged
and `remainders` is still dropped before a rename. The rename preview
goes through predict_filename, so it shows the new names as is.
Checked on metadata-less CBZs through COMICBOX_CONFIG: publisher, full
date and original_format are imported, and the predicted names match
comicbox's new format.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(ty): rename unresolved-import ignores to import-not-found

ty 0.0.99, already locked on develop, renamed the rule. The old name
now raises renamed-rule-in-ignore-comment, which fails `make ty`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* comicbox 5.6.1.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The tag editor's publisher, imprint, series, volume, credit, story arc,
universe and tag inputs only offered the opened group's own values, so
correcting a name meant retyping the library's spelling exactly; a typo
silently created a new row.

New admin endpoint GET /api/v4/admin/tag-names/<field>?q= returns at most
100 existing names containing q, prefix matches first, with a `complete`
flag. Imprint, series and volume narrow to the form's parent names.

A TagNameCombobox wrapper fetches only when its menu opens or the user
types with it open (debounced, aborting the stale request). Results cache
per field, scope and query in the metadata store until the dialog closes,
and a complete result for a shorter prefix answers longer queries without
another request. Free text is still accepted.


Claude-Session: https://claude.ai/code/session_01UgiA5Rda3grk8WEu1YhY4S

Co-authored-by: Claude <noreply@anthropic.com>
* feat(tag-editor): suggest existing names as you type

The tag editor's publisher, imprint, series, volume, credit, story arc,
universe and tag inputs only offered the opened group's own values, so
correcting a name meant retyping the library's spelling exactly; a typo
silently created a new row.

New admin endpoint GET /api/v4/admin/tag-names/<field>?q= returns at most
100 existing names containing q, prefix matches first, with a `complete`
flag. Imprint, series and volume narrow to the form's parent names.

A TagNameCombobox wrapper fetches only when its menu opens or the user
types with it open (debounced, aborting the stale request). Results cache
per field, scope and query in the metadata store until the dialog closes,
and a complete result for a shorter prefix answers longer queries without
another request. Free text is still accepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgiA5Rda3grk8WEu1YhY4S

* chore(release): v2.6.0 - Tag Editor Suggestions

Bump to 2.6.0 from the unreleased 2.5.8 and retitle its NEWS section,
leading with the tag editor's name suggestions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgiA5Rda3grk8WEu1YhY4S

---------

Co-authored-by: Claude <noreply@anthropic.com>
@ajslater
ajslater merged commit 9857d9a into main Nov 7, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant