Conversation
Three items `make complexity` flagged, all pure refactors. AdminOnlineTagStartView.post (CC 11 -> C): three near-identical "request value or stored default" blocks collapse into a _FLAG_DEFAULT_FIELDS mapping plus a _resolve_flags helper. test_telemeter_privacy._vocabularies (CC 12 -> C): split by vocabulary source into _choice_vocabularies, _identifier_bucket_vocabulary and _tagging_vocabularies, with the literal and OIDC sets hoisted to module constants. The same string set is checked; the walk is not widened. test_onlinetag_session_manager (MI 17.37 -> B): 916 lines in one module, every function already rank A. Split along its seams into the scan pass, prompt resolution and credentials, with the doubles and the comic factory moved to tests/onlinetag_session_fakes.py. All 31 tests come across unchanged; the move dedupes the BulkTagWriteTask filter into write_tasks() and the prompt dict into a _prompt() factory. test_onlinetag_tag_pass now imports the fakes from their new home. make complexity, lint, ty clean; 815 pytest + 371 vitest pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A move whose destination path already belonged to another comic row violated the (library, path) unique constraint inside bulk_update. The IntegrityError aborted the whole import before the delete phase could clear the stale row, so the poller rebuilt the same task and crashed on every subsequent scan. Mirror the folder guard for comics and custom covers: drop moves onto paths an existing row holds, and moves that two sources in one batch claim, which files_moved can express because it is a plain dict rather than a bidict. Skipping converges. The destination row's stat refreshes, the next scan sees two rows sharing an inode and suppresses the bogus move, and the stale source falls through to the delete phase. Also contain any move phase failure in all three move steps so a bad batch degrades to a skipped phase the next scan reconciles instead of aborting the import. This covers the same class of crash in the folder step, where converting dirs_moved to a bidict raises on the duplicate destinations the watcher can emit. Fixes #807 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The self-update was not flaky, it was inert. Nothing ever asked for an automatic update. update_latest_version() had an `update` hook that queues JanitorCodexUpdateTask and no caller passed it, and the update task was not in the nightly fan-out, so the Auto Update flag promised a daily upgrade that could never happen. The nightly check now forces a fetch and chains into the update when the flag is on. The flag gate moves to that scheduling point, so the admin Jobs tab button updates whether or not the flag is set -- it used to silently no-op, since the flag defaults off and the button sends force=False. The installer ran `sys.executable -m pip install --upgrade codex` with no timeout, no captured output, and a blanket except that swallowed every failure while still logging "updated to the same version". uv and pipx environments have no pip and the docker image uninstalls it, so those installs always failed invisibly. Now: pip if importable, else `uv pip install --python <sys.executable>`, else an ERROR naming both and pointing docker at a new image. Failures log the installer's own stderr, a hung installer times out instead of wedging the scribe queue behind it, and the restart only fires when the install really happened. Restarting exec'd __file__, which relied on run.py's executable bit and its `#!/usr/bin/env python3` shebang resolving to a python that has codex installed. Under macOS, pipx, uv, systemd and launchd that is frequently a different interpreter and the server never came back. Exec `sys.executable -m codex.run` instead. _is_outdated() called Version() on unvalidated strings, raising on a fresh install's empty cache and on a source checkout's "test" version. Version comparison is now codex.version.is_outdated(), total by construction, shared by the janitor and the version view. The browser could not announce anything: semverGreaterThan(a > b) passed one boolean into a two-argument function so `outdated` was always false, and the comparison read 1.0.9 as newer than 1.1.0. The server ships `outdated` and `docker` in the version payload and the javascript comparison is gone. The footer renders a codex orange "upgrade to codex vX.Y.Z" link to the Update Codex job, or to the image repo in docker, where codex cannot install over itself. The Jobs tab grows section anchors, names the version the job would install, and explains the docker case. While the cache is empty every /api/v4/version request queued a fetch task, each on its own thread with a 5 second PyPI call. Add a process wide in-flight lock and a cooldown after a failed fetch. Tests cover the version comparison, the payload, installer selection and failure paths, the flag gate and fetch guards, the nightly wiring, and both frontend surfaces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nginx hides the upstream Server header by default (along with Date,
X-Pad and X-Accel-*) and substitutes its own, so codex/<version> never
reaches clients behind a reverse proxy. Granian passes the header set by
CodexMiddleware through untouched; nginx is the only clobberer.
Add proxy_pass_header Server to the README's example location, plus a
subsection covering the curl verification, why server_tokens off is not
a substitute, and the array-directive inheritance trap (a location that
declares any proxy_pass_header drops the one inherited from server{}).
Set the same directive in the test-proxy harness so the documented
config is the one actually exercised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OPDS responses were cached without varying on User-Agent while the body depends on it, so a client could be served a variant rendered for a different one until the entry expired. UserAgentNames switches facet emission (FACET_SUPPORT), download mime types (SIMPLE_DOWNLOAD_MIME_TYPES), order facet suppression (CLIENT_REORDERS) and absolute hrefs (REQUIRE_ABSOLUTE_URL), and cache_page keys only on the URL plus the headers named in Vary. Add User-Agent to the existing vary_on_headers in opds_cached, which covers every wrapped v1 and v2 feed, start, manifest and opensearch route. Vary already includes Cookie, so per-session keys existed anyway and this barely fragments the cache further; it also corrects what intermediary caches are told. Cover routes keep the narrower vary since covers don't depend on the client, and the static authentication document is left alone. Adds tests/test_opds_cache.py asserting the Vary header names User-Agent and that a feed primed by one client isn't replayed to another. Both fail without the fix. Fixes #811 Claude-Session: https://claude.ai/code/session_01MAYYLr3w4xZJA1StYH2WwN Co-authored-by: Claude <noreply@anthropic.com>
) OPDS1TemplateEntrySerializer declared the field as `credits`, but the entry object exposes `contributors` and the template iterates `entry.contributors`. The template renders serialized data, so the mismatch meant DRF looked for a `credits` attribute on the entry, found none, and skipped the field: `credits` is read_only and not required, so get_attribute raises SkipField and the key is omitted with no error. `contributors` was never declared and so never serialized, leaving the template's contributor loop iterating nothing. The result is that comic credits which are not writing credits -- artists, colorists, letterers, everyone outside AUTHOR_ROLES -- have never appeared in a v1 feed. `<author>` was unaffected because all three layers agree on that name. Atom allows zero or more `atom:contributor` in an entry, so the schema tests could not catch it either. Rename the field to match the entry property and the template. The payload shape was already correct: get_credit_people and its batched variant return objects with .name and .url, exactly what OPDS1CreditSerializer expects. Adds tests/test_opds_contributors.py, which seeds a Writer and a Colorist and asserts each lands in its own element. Without the rename the author assertion still passes and the contributor one fails, which is the shape of the bug. Claude-Session: https://claude.ai/code/session_01MAYYLr3w4xZJA1StYH2WwN Co-authored-by: Claude <noreply@anthropic.com>
Panels on iOS added OPDS facet and sort support in 3.13.0, so add it to UserAgentNames.FACET_SUPPORT. Its CFNetwork style UA parses to "Panels" through get_user_agent_name, so the existing exact-name match works. Two more fixes came out of testing that allowlist. Facet capable clients received the facets twice. The gate in the v1 feed `entries` property had been commented out, so facets() ran unconditionally and its OPDS1Link objects were appended to the entries list alongside the real facet links from _links_facets. OPDS1TemplateEntrySerializer drops every field those links don't have, so each one rendered as a dead entry: empty id, no links, unclickable. Restore the gate so the fake navigation folders are emitted only for clients that can't read facets. The opds:facetGroup attribute carried internal query parameter names, and clients like Panels show them verbatim as filter menu headings. Add a display_name to the FacetGroup dataclass and emit that instead, so the headings read "Order By", "Order Direction" and "Views". The query param still drives hrefs and active-facet detection. facet_group also becomes a CharField; it was typed as a collection-name ChoiceField whose choices never included any value actually assigned to it. Adds tests/test_opds_user_agent.py covering both facet variants, the display names, and User-Agent parsing. Each test fails without its corresponding fix. Fixes #810 Claude-Session: https://claude.ai/code/session_01MAYYLr3w4xZJA1StYH2WwN Co-authored-by: Claude <noreply@anthropic.com>
Panels' facet support is per platform and the platforms share one UA name: the macOS build (951) does not render OPDS facets while iOS builds (952 and later) do. Matching FACET_SUPPORT on the name alone sent macOS Panels facet links it can't render, and with no fake nav folders either it had no sort UI at all. get_user_agent_name now also returns a build number, parsed from the token right after the first slash for clients listed in _BUILD_UA_NAMES (Panels only today). The auth mixin memoizes the pair and exposes it as user_agent_name and user_agent_build, so existing name consumers are unchanged. use_facets requires the client's build to meet UserAgentNames.FACET_SUPPORT_MIN_BUILD (Panels: 952) in addition to name membership; a missing or unparseable build fails the floor, falling back to the fake nav folder sort that works on every client. Clients without a floor, like kybooks, are unaffected. No cache change needed: the response cache already varies on the full User-Agent header, so builds 951 and 952 key separately. Updates tests/test_opds_user_agent.py: the iOS constant moves to build 952, a new test pins that build 951 keeps the nav folder sort and gets no facet links (it fails against the name-only gate), and the parse unit tests cover the (name, build) pair including unparseable builds. Claude-Session: https://claude.ai/code/session_01MAYYLr3w4xZJA1StYH2WwN Co-authored-by: Claude <noreply@anthropic.com>
Django 6.1 deprecates the discrete EMAIL_* connection settings (RemovedInDjango70Warning at django.setup()), and defining MAILERS makes reading the old names an AttributeError. - Replace the eight deprecated settings with an EMAIL_CONNECTION_OPTIONS dict (TOML/env layer, keyed by EmailBackend constructor kwarg) plus a MAILERS declaration pointing at the DB-aware DBEmailBackend. - get_email_connection_kwargs / get_email_from_address coalesce the EmailSettings DB row over EMAIL_CONNECTION_OPTIONS instead of the removed settings. - DBEmailBackend defaults its mailer alias so direct construction never hits the SMTP parent's pre-MAILERS settings fallback. - The admin test-send view builds the backend directly and calls send_messages(), dropping deprecated get_connection() and EmailMessage(connection=...). - Tests override MAILERS + EMAIL_CONNECTION_OPTIONS instead of EMAIL_BACKEND/EMAIL_HOST. Claude-Session: https://claude.ai/code/session_01Bqa2ULBSaSVDwDSMEni1hf Co-authored-by: Claude <noreply@anthropic.com>
#817) The build >= 952 floor was built on a wrong premise: Panels build numbers interleave across platforms. Real iOS builds run lower than the macOS build - 942 (reported in the field) and 950 (issue #810's reporter) both render facets natively, while macOS 951 does not - so no floor can separate them, and 952 shut real iOS users out of facets, handing them the fake nav folder sort instead. Replace FACET_SUPPORT_MIN_BUILD with FACET_BLIND_BUILDS, a per-client frozenset of known facet-blind builds (Panels: {951}). use_facets now refuses only those builds; every other build - unknown and unparseable ones included - gets facets, which is the pre-gate behavior that worked on iOS. A future facet-blind macOS build must be added to the set as discovered; until then it receives facets it ignores, the pre-gate status quo. The iOS test constant moves to the field-reported build 942, an unparseable-build UA joins the facet-capable cases to pin the facets-by-default behavior, and the macOS 951 test still asserts the nav folder fallback. The 942 and unparseable cases fail under the old floor gate and pass under the denylist. Claude-Session: https://claude.ai/code/session_01MAYYLr3w4xZJA1StYH2WwN Co-authored-by: Claude <noreply@anthropic.com>
FIT_TO_CLASSES mapped "O" to "Original", so the Original Size setting at the default zoom produced fitToOriginal / fitToOriginalTwo / fitToOriginalVertical while page-img.vue and pdf-doc.vue only style fitToOrig*. The zoomed-in path already used "Orig". Map "O" to "Orig" and have the zoom path read the same entry so the two can't drift. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
None of pdf-doc.vue's four canvas rules had matched anything since the Vuetify 3 port (Dec 2022). That port moved them from an unscoped block into top-level :deep() selectors, which compile to `[data-v-x] .vue-pdf-embed.X > div > canvas` and need a scoped ancestor. The embed is the component's root, so it has none. vue-pdf-embed 2.x also nests the canvas one div deeper than `> div > canvas`. With the selectors fixed (`.pdfDoc.X :deep(canvas)`), each rule was checked in a browser with Force vector at DPR 2: - Fit to Height/Screen two-page `width: inherit` and Fit to Width two-page `height: inherit` did nothing. vue-pdf-embed already gives the canvas an aspect-correct inline size. Deleted. - Original two-page `width/height: inherit` resolved to auto, so each canvas showed at its bitmap size, 2x on HiDPI (1023x1581 instead of 512x790). Zoom uses these classes too. Deleted. - Fit to Screen `object-fit: contain` did nothing, because the box already matched the bitmap. Kept, and paired with max-width 100vw/50vw as page-img.vue does for images. Wide pages now fit the screen instead of overflowing it: a spread plus a page in two-page mode at 1024px went from a 1491px scroll width to 1024px, and a wide page on a 390px phone from 1092px to 390px. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…errors (#952) loadTagWriteErrors had the race loadTable had before #949. The Tagging tab and the settings button read the list unforced when they mount, and the WebSocket forces a reload whenever it changes. If a slow mount read landed after the forced reload, it put the older list back and stamped it when it arrived, so every unforced read for the next 5 s was served the stale list. clearTagWriteErrors wrote [] directly, so a read that went out before the clear could put the cleared errors back the same way. Both now use loadTable's request counter. #949's landing check moves into a claimLanding(key, request) helper that all three share, keyed like timestamps. The tag-write reads take a number when they go out, land only if no later request has landed, and stamp the time they went out. The clear takes a number when its DELETE goes out, so an older read can't undo it, and a read that went out after it and already landed isn't wiped by it. The singleton settings loads (tagging defaults, email, OIDC, site defaults, throttling) are unchanged. Nothing forces them, and their only writer is the same tab's own Save. That can't realistically fire before the tab's mount read comes back. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore: update devenv - Retire the dead .circleci ignore lines (devenv's new retirement lists). - eslint base config: presets apply again and Markdown code blocks are linted, which reformats the compose YAML example in docs/DOCKER.md. - pyproject template: T201 exemption for bin/release_info.py. - eslint-plugin-package-json bump. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: compose CI from devenv's building blocks ci.yml is now a caller of devenv's CI building blocks (copied in by `make update-devenv`) plus codex's own image jobs: - ci: devenv-check.yml with ci-target codex-ci and the same four checks. Its gate runs the Release Preflight and, on a main push, reuses the python-dist of an earlier run that passed on the same git tree (keyed by tree hash, replacing the "last merged PR" lookup). One image job builds codex-ci and pushes it by digest; each check pulls it instead of building and loading its own. The required check becomes "CI / Lint, Test & Build Dist". - build, deploy, deploy-hub: unchanged in substance, but they read deploy/version/final from ci's outputs instead of testing the event or grepping pyproject.toml. deploy publishes to PyPI through devenv-pypi (uv publish --check-url) after the manifest. deploy-hub now skips at the job level for alphas. - release: devenv-release.yml (tag, GitHub Release, merge-back), after deploy and deploy-hub. Removes .github/actions/ci-container (now devenv-ci-container) and the release-engine tests, which devenv owns now; codex keeps its golden NEWS.md tests. tests/test_ci_workflow.py pins how codex wires the blocks. CLAUDE.md describes the new graph and the current Dockerfile stages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * update deps * fix(make): build the choices JSON before lint and fix Since the ESLint preset fix (#931, #932), import-x/no-unresolved resolves the frontend's `@/choices/*.json` imports, which bin/build-choices.sh generates and git ignores. A checkout that never built them, such as CI's fresh codex-ci container, fails `make lint` and `make fix` with 19 unresolved-import errors. Give lint and fix the same build-choices prerequisite test-frontend and build already have. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…955) * feat(browser): mark finished items with a check cap, not a thick bar Issue 856 feedback: the 6px grey finished bar was hard to tell from the 3px reading bar when scanning a grid, and only really read when two cards sat side by side. The dimmed caption was a second cue that made read titles harder to read. Every bar is now 3px. A finished comic or all-read collection ends its bar in a 10px grey check circle, the same grey as the fill. The bar runs under the cap to its centre so the two join with no gap. The check is stroked in the page background instead of cut out, because a cut-out would show the track through it. The fill is still the real bookmark position, so a comic marked read but never opened is an empty track and its cap. Read titles and subtitles keep their normal colour. The bar is now one component, ReadStateBar, used by both the browser card and the metadata dialog. They had duplicated its CSS, and the cap would have been a third copy to keep in sync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(metadata): draw the read state under the cover, not behind it The read state bar in the metadata dialog has been invisible since the read state landed in v2.4.0. It sat 15px up over the cover's bottom edge, where the old v-progress-linear used to be. That component is positioned, so it painted over the cover. The plain div that replaced it is not, and the absolutely positioned cover image painted over the bar. It now sits 1px under the cover at every breakpoint, as on the browser card, which moves the dialog's buttons down about 16px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s bare root (#957) A route whose collection pk resolves to nothing redirected to that collection's own bare root. Under a Publishers top collection that is `/series` (or `/volumes`) with no parent ids: the backend sends only the root crumb, the client hides it as the current view, and `/` resumes the saved last route, so the user is stranded until they switch top collection. A tag write that moves a series' or volume's comics elsewhere triggers it on the next refresh: the ACL resolves through `comic__`, so the emptied group no longer resolves. Reuse `_get_up_page_redirect`, the out-of-bounds page redirect's target: the top collection's root, or the folder / story arc root. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ands the browser (#958) A bare nav-collection root below the top collection (e.g. /series under Publishers) has no breadcrumbs, and "/" resumes it. Only a search belongs there. Three frontend paths left the browser there after the search ended: - "Clear Filters and Search" (clearFilters(true)) assigned the reset search straight into state, skipping _validateSearch, so the redirect made on entering the search was never undone. - loadSavedSettings discarded the redirect _validateAndSaveSettings returned. A saved view without a search stayed at the search root. One with another top collection stayed on the old route, where the server's _validate_top_collection rewrote the view's top collection: a Folders view loaded at the Publishers root came back as Publishers. - _validateSearch only undid the redirect at the current lowestShownCollection. Showing a deeper level mid-search, or loading a saved search that shows one, left the old search root behind. clearFilters(true) now takes its redirect from _validateSearch, loadSavedSettings goes through setSettings, and clearing a search leaves any bare imprints/series/volumes root. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The saved-settings DELETE endpoint and API client already existed, but nothing in the UI called them, so a saved view could never be removed. Each saved view in the settings drawer's "Load Saved View" menu now has a trash icon. It opens a "Delete View" confirm dialog naming the view; the click is stopped so it doesn't also load the view. The dialog lives outside the combobox so the menu closing can't unmount it. The item slot keeps Vuetify's role="option" on each entry. The new deleteSavedSettings store action reloads the list even when the delete fails, so a view already deleted in another tab drops out. Adds the first tests for the DELETE endpoint: it removes the view with its filters and last-route rows, and 404s for another user's view, the unnamed current-settings row, and an unknown pk. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
After a saved view was picked from the settings drawer's "Load Saved View" combobox, the view loaded but the menu popped back open with focus still in the input. Vuetify closes the menu on select and keeps focus in the combobox. When the view's settings land, the component's deep settings watcher clears the combobox model. VCombobox's model watcher then sets its search text to '', and its search watcher opens the menu whenever the field is focused and the menu is closed. The watcher now blurs the combobox before clearing a picked view, so the name still clears and the menu stays shut. It only blurs when the model holds a picked view (an object with a pk). Every browse page load re-sets settings.breadcrumbs, which fires the same watcher. An unconditional blur closed a menu the user had opened while a page was still loading. Checked in a real browser: mouse and keyboard picks, a view whose settings match the current ones, and opening the menu mid page load. Also checked merged with the unpushed per-view trash icon branch: a pick from its item slot, and its Delete View dialog. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- Add alt text to the logo images in README and NEWS. - Point in-page links at the ids readthedocs generates and add matching <a name> anchors so the same links work on GitHub, whose emoji-heading slugs differ. - Link the Windows doc as docs/WINDOWS.md so it resolves on GitHub too. - Drop the Troubleshooting heading's self-link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* update devenv Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: let the deploy job publish to PyPI with trusted publishing Grant id-token: write to the deploy job, which runs devenv-pypi after the image manifest. devenv-pypi now publishes with PyPI trusted publishing once the PYPI_TOKEN secret is deleted, and that needs the job to be able to get a GitHub identity token. While the secret exists it still publishes with the token. A new test pins the permission. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* update comicbox * fix(reader): 404 a page past the end instead of caching an empty 200 comicbox 5.2.2 made get_page_by_index() return None for an index past the last page; before, it raised StopIteration, which surfaced as a 500. The page view turned that None into b"" and served it as a 200 image/jpeg, and both page routes (v4 reader and OPDS) mark 2xx responses public for a week, so clients kept the empty image. Raise NotFound on None instead. cache_control_2xx leaves 404s uncached. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
of a list with no way back.
way back, and saved views keep their top collection.