diff --git a/NEWS.md b/NEWS.md index f9d551099..273e4ec9d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,11 @@ width: 128px; border-radius: 128px; " /> +## v.1.12.2 + +- Fix + - Ordering in browser card mode was broken for groups + ## v1.12.1 - Features diff --git a/README.md b/README.md index 8f010cba2..be2cae27d 100644 --- a/README.md +++ b/README.md @@ -561,7 +561,7 @@ can chmod, forward to a SIEM, or retain on its own schedule. Each line looks like: -``` +```log 2026-05-10 12:34:56 | Failed login from 192.168.1.42 user=alice ``` diff --git a/codex/views/browser/annotate/order.py b/codex/views/browser/annotate/order.py index 5c05981ff..783b5b221 100644 --- a/codex/views/browser/annotate/order.py +++ b/codex/views/browser/annotate/order.py @@ -288,23 +288,32 @@ def _comic_order_value(self): def _group_m2m_order_value(self, qs): """Group-row M2M order_value, falling back to ``sort_name``.""" - isort_expr = m2m_intersection_sort_expr(qs.model, self.order_key) - if isort_expr is not None: - return qs, isort_expr + # Intersection sort matches the table-view cell display. Cover + # view's caption can't render M2M intersection and the rule + # would leave most cards with a NULL sort key, so card mode + # falls back to sort_name. + if self.params.get("view_mode") == "table": + isort_expr = m2m_intersection_sort_expr(qs.model, self.order_key) + if isort_expr is not None: + return qs, isort_expr if qs.model is Volume: qs = qs.alias(sort_name=F("name")) return qs, F("sort_name") def _group_scalar_order_value(self, qs): - """Group-row scalar / FK-name order_value (intersection-aware).""" - # Display uses intersection — a value renders only when every - # child comic agrees — so the sort key matches that rule. - # Falls back to the legacy aggregate path when the column / - # group model isn't wired so adding a new registry scalar - # doesn't silently regress its sort. - isort_expr = scalar_intersection_sort_expr(qs.model, self.order_key) - if isort_expr is not None: - return isort_expr + """Group-row scalar / FK-name order_value.""" + # Table view uses intersection so the sort key matches the + # displayed cell (blank when children disagree). Cover view's + # card caption shows order_value directly — intersection there + # would blank the caption for any group with mixed children, + # which is the regression we're avoiding. Falls back to the + # legacy aggregate path when the column / group model isn't + # wired so adding a new registry scalar doesn't silently + # regress its sort. + if self.params.get("view_mode") == "table": + isort_expr = scalar_intersection_sort_expr(qs.model, self.order_key) + if isort_expr is not None: + return isort_expr agg_func = _ORDER_AGGREGATE_FUNCS[self.order_key] agg_func = self.order_agg_func if agg_func == Min else agg_func field = self.rel_prefix + comic_order_path(self.order_key) diff --git a/frontend/package.json b/frontend/package.json index dbedea9a5..d55f96b17 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "codex", - "version": "1.12.1", + "version": "1.12.2", "private": true, "description": "ui for codex api", "type": "module", diff --git a/pyproject.toml b/pyproject.toml index 767eacc25..5fcd131ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ readme = "README.md" requires-python = ">=3.12" license = "GPL-3.0-only" name = "codex" -version = "1.12.1" +version = "1.12.2" [[project.authors]] name = "AJ Slater" email = "aj@slater.net" diff --git a/tests/test_browser_ordering.py b/tests/test_browser_ordering.py index cc48d8740..b04423d9c 100644 --- a/tests/test_browser_ordering.py +++ b/tests/test_browser_ordering.py @@ -657,6 +657,72 @@ def test_year_sort_matches_intersection_display(self) -> None: # intersection-sort key is NULL. assert names == ["Beta", "Alpha", "Mixed"], names + def test_cover_mode_group_order_value_uses_aggregate(self) -> None: + """ + Cover view's group order_value is an aggregate, not an intersection. + + Regression: the table-view PR routed group-row scalar sort + through ``scalar_intersection_sort_expr`` for every view mode. + That's correct for table view (sort matches the intersection + cell display) but blanks the order_value caption beneath + cover-mode cards for any group whose children disagree on + the sorted field. Series with mixed years rendered the card + caption as null; the user reported the same for Publish Date + across publishers, series, and folders. + + Setup matches ``test_year_sort_matches_intersection_display`` + — Alpha (clean 2020), Beta (single 2021), Mixed (2018 + 2024) + — but the request uses ``viewMode=cover``. Every group row + must come back with a non-null ``orderValue`` so the card + caption renders. + """ + library = Library.objects.first() + publisher = Publisher.objects.get(name="ZZ Press") + imprint = Imprint.objects.get(name="ZZ Imprint") + series_mixed = Series.objects.create( + name="Mixed", imprint=imprint, publisher=publisher + ) + volume_mixed = Volume.objects.create( + name="2030", + series=series_mixed, + imprint=imprint, + publisher=publisher, + ) + for issue, year in ((1, 2018), (2, 2024)): + path = TMP_DIR / f"mixed-{issue}.cbz" + path.touch() + Comic.objects.create( + library=library, + path=path, + issue_number=issue, + name=f"mixed-{issue}", + publisher=publisher, + imprint=imprint, + series=series_mixed, + volume=volume_mixed, + size=100 + issue, + year=year, + page_count=10 + issue, + ) + + url = f"/api/v3/p/{publisher.pk}/1" + self._patch_settings( + {"viewMode": "cover", "orderBy": "year", "orderReverse": False} + ) + response = self.client.get(url) + assert response.status_code == _HTTP_OK, response.content + groups = response.json().get("groups", []) + by_name = {g["name"]: g for g in groups} + # Aggregate (Min year asc): Mixed=2018, Alpha=2020, Beta=2021. + # None must be null — the user-visible regression was a null + # caption on Mixed (children disagree). The serializer emits + # ``order_value`` as a CharField so values come back stringified. + assert by_name["Alpha"]["orderValue"] == "2020" + assert by_name["Beta"]["orderValue"] == "2021" + assert by_name["Mixed"]["orderValue"] == "2018", ( + f"Mixed orderValue should be Min(year)=2018, got {by_name['Mixed']['orderValue']!r}" + ) + def test_primary_sort_by_year_on_group_rows(self) -> None: """Min aggregate over a direct integer field (``year``) sorts correctly.""" # Alpha has two comics, both year=2020 → Min year = 2020. diff --git a/uv.lock b/uv.lock index ef01fdb2d..abecc7ee5 100644 --- a/uv.lock +++ b/uv.lock @@ -464,7 +464,7 @@ wheels = [ [[package]] name = "codex" -version = "1.12.1" +version = "1.12.2" source = { editable = "." } dependencies = [ { name = "adrf" },