Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
063f2d2
fix(frontend): send passwordConfirm from profile change-password (#793)
ajslater Jul 3, 2026
3020d67
update deps and version to v2.1.1. bump news
ajslater Jul 3, 2026
27783ee
test(frontend): work around vitest/valid-expect false positive on exp…
ajslater Jul 4, 2026
4963ad9
update deps
ajslater Jul 4, 2026
2462a41
fix(onlinetag): drop dead effort option; model Metron's fewer requests
ajslater Jul 4, 2026
8245efa
trim news
ajslater Jul 4, 2026
6591384
Merge branch 'main' into develop
ajslater Jul 4, 2026
a1845af
refactor(onlinetag): derive source list + issue-id parser from comicbox
ajslater Jul 4, 2026
cd84ed9
fix(onlinetag): scope the match-mode request-count hint to Comic Vine
ajslater Jul 4, 2026
9980c04
fix(fs): don't let one unreadable folder crash the library scan
ajslater Jul 4, 2026
42b7fe9
update deps
ajslater Jul 4, 2026
dfbf390
update version to 2.1.2
ajslater Jul 4, 2026
1d06607
fix typechecking
ajslater Jul 4, 2026
07ffa2a
news for v2.1.2
ajslater Jul 4, 2026
ed38cb5
format
ajslater Jul 4, 2026
a41eede
Squashed commit of the following:
ajslater Jul 4, 2026
8ebc051
update deps
ajslater Jul 4, 2026
08eadba
refactor(onlinetag): extract resume-param sanitizer to cut complexity
ajslater Jul 4, 2026
227cea4
fix(onlinetag): reference defined constant in match-mode hint
ajslater Jul 4, 2026
76a7d3b
Merge branch 'main' into develop
ajslater Jul 4, 2026
97c62aa
update deps
ajslater Jul 7, 2026
f7abf7d
fix(settings): nest comicbox loglevel/delete_keys under general section
ajslater Jul 11, 2026
da257b0
fix(onlinetag): resolve prompts against current DB path, surface appl…
ajslater Jul 11, 2026
a5f37d8
update deps and format
ajslater Jul 11, 2026
e45ef90
Native OIDC single sign-on (Admin Auth tab) (#798)
ajslater Jul 13, 2026
d03666d
bump version 2.2.0
ajslater Jul 13, 2026
698164c
bump news
ajslater Jul 13, 2026
2de8473
update deps and comicbox
ajslater Jul 13, 2026
9243b5e
adapt ComicVine credential check to simyan v3 (comicbox 4.1.1)
ajslater Jul 13, 2026
4c97679
update deps
ajslater Jul 13, 2026
e6bb026
test(onlinetag): assert merge flag is forwarded, not comicbox's arith…
ajslater Jul 13, 2026
19f7b1b
Merge branch 'main' into develop
ajslater Jul 13, 2026
c7dc81f
update deps
ajslater Jul 19, 2026
cb98ea0
v2.2.1: show Metron account rate limits live (comicbox 4.3.0 / mokkar…
ajslater Jul 19, 2026
b3ec559
update deps
ajslater Jul 22, 2026
86b8403
v2.2.1: community ratings replace critical rating (comicbox 4.4.0)
ajslater Jul 22, 2026
04a7b2d
Merge branch 'main' into develop
ajslater Jul 22, 2026
4f7e516
update deps
ajslater Jul 24, 2026
f7ceb6c
v2.2.2: fix rotated pdf page serving (comicbox-pdffile 0.6.3)
ajslater Jul 24, 2026
8086c4c
update deps, including comicbox 4.5.0
ajslater Jul 24, 2026
00c3a99
Fix silent no-op when clearing tag editor fields
ajslater Jul 22, 2026
172a8c9
Fix lint errors from the ruff 0.16 upgrade
ajslater Jul 24, 2026
f1c8405
update comicbox
ajslater Jul 24, 2026
8be22e5
Clear monochrome by deleting the tag, not writing false
ajslater Jul 24, 2026
6b29b63
Merge branch 'main' into develop
ajslater Jul 24, 2026
1acc942
update devenv and deps
ajslater Jul 25, 2026
d358c22
update claude rules about telemetry
ajslater Jul 25, 2026
10d87b0
Fix anonymous stats sending and report what codex grew into
ajslater Jul 26, 2026
9b102cd
update deps
ajslater Jul 26, 2026
a92236d
Support comicbox 4.6.0 series alternative names
ajslater Jul 26, 2026
265f7fa
update deps and devenv
ajslater Jul 26, 2026
5d5b3bd
bump news for comicbox 4.6.1
ajslater Jul 26, 2026
3b500ae
update devenv
ajslater Jul 26, 2026
646fe38
fix cron double enqueue of telemeter task
ajslater Jul 27, 2026
c014acb
test crond double enqueu fix
ajslater Jul 27, 2026
7c65e0d
test telmemeter logging fixes
ajslater Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ through a responsive web UI. A background librarian daemon watches the
filesystem for changes and manages metadata import, cover generation, and search
indexing.

## Hard Rules

@.claude/rules/telemetry-privacy.md — **No personally identifying information,
comic/series/folder/imprint/publisher names, tag values, or admin-flag strings
may ever be collected by or transmitted from `codex/librarian/telemeter/`.**
Telemetry is counts, booleans, and closed-enum keys only. This rule is not
negotiable and not waivable for debugging.

## Commands

Commands are from @\~/.claude/rules/devenv.md
Expand Down
27 changes: 27 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@ width: 128px;
border-radius: 128px;
" />

## v2.2.3

- Features
- Alternate series names: the localized and variant series titles comics
carry in MetronInfo AlternativeNames and Reprints tags (and that Metron
and Comic Vine online tagging now record) are imported, shown in the
metadata panel, editable in the tag editor, and browsable — sort, table
column, filter, and `alternate_series:` search (comicbox 4.6.0). Comics
imported or tagged online from now on pick them up automatically;
already-imported comics need a Force Update Tags.
- Online tagging matches a comic filed under a localized or variant series
title to the right volume, and the match prompt shows each candidate's
other known names (comicbox 4.6.0).

- Fixes
- Identifier links point at the right page. Hand-tagged keys with a type
prefix like `series:178012`, Comic Vine long codes like `4050-160294`, and
`source:type:key` strings made broken issue-shaped URLs for every source
(comicbox 4.6.1).
- Every identifier URN in a comic's notes is imported; only the first was
read before (comicbox 4.6.1).
- Story arcs tagged online from Metron or Comic Vine get web links (comicbox
4.6.1). These identifier fixes reach already-imported comics on their next
re-import, or use Force Update Tags.
- User data backups no longer skip every saved filter set after an upgrade
that adds a new filter.

## v2.2.2

- Fixes
Expand Down
10 changes: 10 additions & 0 deletions bin/fix-django.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Fix django template lint errors
set -euxo pipefail

mapfile -t templates < <(find . -mindepth 1 -name '.*' -prune -o -path '*/templates/*' -name '*.html' -print)
if [ ${#templates[@]} -eq 0 ]; then
echo "No django template files found. Nothing fixed."
exit 0
fi
uv run --group lint djlint --reformat "${templates[@]}"
10 changes: 10 additions & 0 deletions bin/lint-django.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Lint django templates
set -euxo pipefail

mapfile -t templates < <(find . -mindepth 1 -name '.*' -prune -o -path '*/templates/*' -name '*.html' -print)
if [ ${#templates[@]} -eq 0 ]; then
echo "No django template files found. Nothing linted."
exit 0
fi
uv run --group lint djlint --lint "${templates[@]}"
43 changes: 42 additions & 1 deletion bin/manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,52 @@

import os
import sys
import tomllib
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
PRUNE = frozenset({"build", "dist", "docs", "node_modules", "test", "tests"})


def _from_pyproject() -> str | None:
"""Read an explicitly configured settings module."""
pyproject = ROOT / "pyproject.toml"
if not pyproject.is_file():
return None
with pyproject.open("rb") as f:
config = tomllib.load(f)
return config.get("tool", {}).get("devenv", {}).get("django-settings-module")


def _is_settings_package(pkg: Path) -> bool:
"""Test if a dir is a package that holds a settings module or package."""
if (
not pkg.is_dir()
or pkg.name.startswith((".", "_"))
or pkg.name in PRUNE
or not (pkg / "__init__.py").is_file()
):
return False
return (pkg / "settings.py").is_file() or (
pkg / "settings" / "__init__.py"
).is_file()


def _discover() -> str:
"""Find the sole <package>/settings module or package in the project root."""
candidates = sorted(pkg.name for pkg in ROOT.iterdir() if _is_settings_package(pkg))
if len(candidates) == 1:
return f"{candidates[0]}.settings"
reason = (
f"Found {len(candidates)} django settings modules in {ROOT}: {candidates}. "
"Set [tool.devenv] django-settings-module in pyproject.toml."
)
raise RuntimeError(reason)


def main():
"""Run the server."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "codex.settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", _from_pyproject() or _discover())
try:
from django.core.management import (
execute_from_command_line,
Expand Down
54 changes: 37 additions & 17 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions cfg/django.mk
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ export DEVENV_DJANGO
## Fix django lint errors in templates
## @category Fix
fix::
uv run --group lint djlint --reformat **/templates/**/*.html
bin/fix-django.sh

.PHONY: lint
## Lint django templates
## @category Lint
lint::
uv run --group lint djlint --lint **/templates/**/*.html
bin/lint-django.sh

.PHONY: django-check
## Django check
Expand Down
15 changes: 14 additions & 1 deletion codex/choices/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
{
"created_at": "Added Time",
"age_rating": "Age Rating",
"reprints": "Alternate Series",
"characters": "Characters",
"child_count": "Child Count",
"community_rating": "Community Rating",
Expand Down Expand Up @@ -528,6 +529,17 @@ def admin_default_route_for(top_collection: str) -> dict:
"editable": False,
"edit_widget": None,
},
"reprints": {
# Alternate & localized series names (comicbox ``reprints``).
# "Reprints" reads as reprint editions to users, so the
# column, order-by entry and filter all say "Alternate
# Series"; only the ORM key stays ``reprints``.
"label": "Alternate Series",
"sort_key": "reprints",
"m2m": True,
"editable": False,
"edit_widget": None,
},
"series_groups": {
"label": "Series Groups",
"sort_key": "series_groups",
Expand Down Expand Up @@ -602,7 +614,7 @@ def admin_default_route_for(top_collection: str) -> dict:
# shape). Display is one query for the whole page; sort scales
# with the filtered collection count when the user clicks the header.
# - ``high``: composite-M2M columns (``credits`` / ``identifiers``
# / ``universes`` / ``story_arcs``). Display issues its own
# / ``reprints`` / ``universes`` / ``story_arcs``). Display issues its own
# per-column query (composite display strings can't share the
# simple-M2M union shape); sort runs a per-outer-row correlated
# subquery that JOINs the bespoke composite expression. Both
Expand All @@ -624,6 +636,7 @@ def admin_default_route_for(top_collection: str) -> dict:
# Composite M2M — display + sort both per-column / per-row.
"credits": "high",
"identifiers": "high",
"reprints": "high",
"story_arcs": "high",
"universes": "high",
}
Expand Down
3 changes: 3 additions & 0 deletions codex/choices/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ def _get_fieldmap_values(*args) -> tuple:
"age", "age_rating", "age_rating_metron"
),
"age_rating_tagged": (),
"alternate_series": _get_fieldmap_values(
"alternate_series", "alt_series", "reprints"
),
"characters": _get_fieldmap_values("category", "categories", "characters"),
"collection_title": ("collection",),
"country": (),
Expand Down
19 changes: 15 additions & 4 deletions codex/choices/tagging.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ def _vuetify_choices(pairs: Iterable[tuple[str, str]]) -> tuple[MappingProxyType
# most fields; the splits/renames are codex's relational model (issue split into
# number + suffix so issues sort numerically, comicbox "arcs" stored as
# story_arcs, ComicInfo's "manga"/"title" surfaced as reading_direction/stories).
# Canonical keys absent here (bookmark, date, pages, reprints, page_count,
# prices, updated_at, ...) are not tag-editor fields and are dropped.
# Canonical keys absent here (bookmark, date, pages, page_count, prices,
# updated_at, ...) are not tag-editor fields and are dropped.
_CANONICAL_TO_EDITOR: MappingProxyType[str, tuple[str, ...]] = MappingProxyType(
{
"publisher": ("publisher",),
Expand Down Expand Up @@ -113,6 +113,9 @@ def _vuetify_choices(pairs: Iterable[tuple[str, str]]) -> tuple[MappingProxyType
"community_rating": ("community_rating",),
"protagonist": ("protagonist",),
"identifiers": ("identifiers",),
# The series name and issue only; the volume number and language are
# MetronInfo-exclusive — see _EXTRA_FORMAT_FIELDS.
"reprints": ("reprints",),
"country": ("country",),
"universes": ("universes",),
}
Expand All @@ -121,10 +124,18 @@ def _vuetify_choices(pairs: Iterable[tuple[str, str]]) -> tuple[MappingProxyType
# Editor sub-fields comicbox stores only for some formats and which its
# transform specs don't surface at the SPECS_TO top level (the
# hand-maintained support data): MetronInfo's Series carries a volume
# count, and only MetronInfo's CommunityRating persists a rating count.
# count, only MetronInfo's CommunityRating persists a rating count, and
# only MetronInfo encodes a reprint's volume number (in Reprints) and
# language (in Series/AlternativeNames) — ComicInfo's AlternateSeries /
# AlternateNumber / AlternateCount carry neither.
_EXTRA_FORMAT_FIELDS: MappingProxyType[str, tuple[str, ...]] = MappingProxyType(
{
"METRON_INFO": ("volume_count", "community_rating_count"),
"METRON_INFO": (
"volume_count",
"community_rating_count",
"reprint_volume",
"reprint_language",
),
}
)

Expand Down
79 changes: 62 additions & 17 deletions codex/librarian/cron/crond.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,42 @@
"""Perform maintenance tasks."""

from collections.abc import Callable
from datetime import datetime
from threading import Condition, Event
from time import sleep
from types import MappingProxyType
from typing import override
from typing import NamedTuple, override

from django.db import connections
from django.utils import timezone as django_timezone
from loguru._logger import Logger

from codex.librarian.scribe.janitor.scheduled_time import get_janitor_time
from codex.librarian.scribe.janitor.tasks import JanitorNightlyTask
from codex.librarian.telemeter.scheduled_time import get_telemeter_time
from codex.librarian.telemeter.scheduled_time import (
get_telemeter_time,
mark_telemeter_attempt,
)
from codex.librarian.telemeter.tasks import TelemeterTask
from codex.librarian.threads import NamedThread

_TASK_TIME_FUNCTION_MAP = MappingProxyType(

class _CronJob(NamedTuple):
"""When a recurring task runs next, and how to spend the slot it runs in."""

get_time: Callable[[Logger], datetime | None]
# Called just before the task is queued, for jobs whose next time is
# read back out of state the job itself writes. ``None`` for jobs
# whose schedule is pure clock arithmetic and so advances on its own.
claim: Callable[[], None] | None = None


_CRON_JOBS: MappingProxyType[type, _CronJob] = MappingProxyType(
{
JanitorNightlyTask: get_janitor_time,
TelemeterTask: get_telemeter_time,
# Always the next midnight, so queueing it moves the schedule.
JanitorNightlyTask: _CronJob(get_janitor_time),
# Read out of the telemeter Timestamp, which the send writes on a
# thread this one never joins. See ``mark_telemeter_attempt``.
TelemeterTask: _CronJob(get_telemeter_time, mark_telemeter_attempt),
}
)

Expand All @@ -34,8 +53,8 @@ def __init__(self, *args, **kwargs) -> None:

def _create_task_times(self) -> None:
task_times = {}
for task_class, func in _TASK_TIME_FUNCTION_MAP.items():
if dttm := func(self.log):
for task_class, job in _CRON_JOBS.items():
if dttm := job.get_time(self.log):
task_times[dttm] = task_class

self._task_times = tuple(sorted(task_times.items()))
Expand All @@ -51,25 +70,43 @@ def _get_timeout(self) -> int:
self.log.debug(f"Next scheduled job at {next_time} in {delta}.")
return max(0, int(delta.total_seconds()))

def _run_expired_jobs(self) -> None:
def _enqueue_job(self, task_class: type) -> None:
"""Spend the job's slot before queueing it, never after."""
claim = _CRON_JOBS[task_class].claim
if claim is not None:
claim()
self.librarian_queue.put(task_class())

def _run_expired_jobs(self, *, timed_out: bool) -> None:
"""
Queue every job whose scheduled time has arrived.

``timed_out`` means the wait ran its full course instead of being
cut short by ``end_timeout``, so the job at the head of the
schedule is due even if the clock reads a hair short of its time
— ``_get_timeout`` truncates the delta to whole seconds, so the
wait always ends slightly early. Missing that job cost more than
a late run: ``_create_task_times`` would immediately push the
nightly janitor out to the *following* midnight and skip a night.
That is what ``sleep(2) # fix time rounding problems`` was for.
"""
now = django_timezone.now()
if timed_out and self._task_times:
now = max(now, self._task_times[0][0])
for dttm, task_class in self._task_times:
if dttm < now:
self.librarian_queue.put(task_class())
else:
if dttm > now:
# Times are always ordered so stop checking at the first future job.
break
self._enqueue_job(task_class)

@override
def run(self) -> None:
"""Cron loop."""
try:
self.run_start()
with self._cond:
self._create_task_times()
while not self._stop_event.is_set():
self._run_expired_jobs()
self._create_task_times()
sleep(2) # try to fix double jobs
timeout = self._get_timeout()
# Idle gaps between scheduled tasks are typically
# hours-to-days. Release the conn so the next
Expand All @@ -78,10 +115,18 @@ def run(self) -> None:
# whole window. Reopen on the next query is
# ~5-20 ms, invisible against the wait.
connections.close_all()
self._cond.wait(timeout=timeout)
# ``Condition.wait`` returns False only when the whole
# timeout elapsed; True means ``end_timeout`` notified.
timed_out = not self._cond.wait(timeout=timeout)
if self._stop_event.is_set():
break
sleep(2) # fix time rounding problems
self._run_expired_jobs(timed_out=timed_out)
# Recompute *after* queueing, so the new schedule sees
# the slots those jobs just claimed. Recomputing first
# re-read the telemeter's unchanged send time and put
# the same task on the queue again, once per pass, for
# as long as the offloaded send took to finish.
self._create_task_times()
except Exception:
self.log.exception(f"In {self.__class__.__name__}")
self.log.debug(f"Stopped {self.__class__.__name__}.")
Expand Down
1 change: 0 additions & 1 deletion codex/librarian/onlinetag/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,6 @@ def run_session(self, task: BulkOnlineTagTask) -> None:
"sources": list(task.sources),
"mode": task.mode,
"prompts_mode": task.prompts_mode,
"auto_threshold": task.auto_threshold,
"delete_original": task.delete_original,
"merge_all_sources": task.merge_all_sources,
"rename": task.rename,
Expand Down
6 changes: 6 additions & 0 deletions codex/librarian/onlinetag/session_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,13 @@ def serialize_candidate(c) -> dict[str, Any]:
"year": getattr(summary, "year", None),
"publisher": getattr(summary, "publisher", ""),
"cover_url": getattr(summary, "cover_url", ""),
# Alternative series names comicbox scored this candidate on.
# Empty for sources whose search results don't carry them.
"alt_series": list(getattr(summary, "alt_series", ())),
},
"score": c.score,
"url": getattr(c, "url", ""),
# The candidate's parent container id (CV volume, Metron series).
# None for sources that don't expose it.
"volume_id": getattr(c, "volume_id", None),
}
1 change: 0 additions & 1 deletion codex/librarian/onlinetag/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ class BulkOnlineTagTask(OnlineTagTask):
sources: tuple[str, ...] = SOURCE_NAMES
mode: str = "auto"
prompts_mode: str = "ask"
auto_threshold: float = 0.85
delete_original: bool = False
dry_run: bool = False
# Query every source per comic and merge (comicbox first_wins=False)
Expand Down
Loading