Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ width: 128px;
border-radius: 128px;
" />

## v2.4.0

- Features
- Each top collection remembers the sort it was last browsed with. Sort
Issues by added time and Publishers alphabetically, and switching between
them keeps each one's sort instead of carrying one sort everywhere. A
collection you haven't sorted yet keeps whatever sort you arrive with.
- Clearing a search puts back the sort the search replaced.

- Fixes
- Saving browser settings sent an empty request that stored nothing. The
settings were persisted by the page request that followed, so nothing was
lost, but the save request itself did nothing.

## v2.3.0

- Features
Expand Down
1 change: 1 addition & 0 deletions codex/choices/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,7 @@ def admin_default_route_for(top_collection: str) -> dict:
"order_by": "sort_name",
"order_reverse": False,
"order_extra_keys": (),
"collection_order_memory": MappingProxyType({}),
"search": "",
"show": _DEFAULT_SHOW,
"top_collection": "publishers",
Expand Down
29 changes: 29 additions & 0 deletions codex/migrations/0054_settingsbrowser_collection_order_memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Generated by Django 6.0.7 on 2026-08-30 12:00."""

from django.db import migrations, models


class Migration(migrations.Migration):
"""
Remember the sort each top collection was last browsed with.

Switching top collections used to drag one global sort along with it, so
sorting issues by added time and then looking at publishers left the
publisher list in added-time order too.

The new map is empty for every existing row, which reads as "no collection
has been customized yet" and keeps the current carry-the-sort-over
behavior until someone changes a sort. Nothing to backfill.
"""

dependencies = [
("codex", "0053_reprint_issue_number"),
]

operations = [
migrations.AddField(
model_name="settingsbrowser",
name="collection_order_memory",
field=models.JSONField(default=dict),
),
]
10 changes: 10 additions & 0 deletions codex/models/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,15 @@ class SettingsBrowser(SettingsBase):
# Empty list means single-column sort (today's behavior). The
# frontend table view adds entries via shift-click on a header.
order_extra_keys = JSONField(default=list)
# The sort each top collection was last browsed with, so switching
# between them restores the sort that collection was left in instead
# of dragging one global sort everywhere. Keyed by
# ``BROWSER_TOP_COLLECTION_CHOICES`` key; each value is
# ``{"order_by": <key>, "order_reverse": <bool>, "order_extra_keys": [...]}``.
# A missing key means "never customized" and the current sort carries
# over. ``search_score`` is never stored: it only exists while a
# search is active.
collection_order_memory = JSONField(default=dict)
search = CharField(max_length=4095, default="", blank=True)

# Display preferences
Expand Down Expand Up @@ -387,6 +396,7 @@ class SettingsBrowser(SettingsBase):
"order_by",
"order_reverse",
"order_extra_keys",
"collection_order_memory",
"search",
"custom_covers",
"dynamic_covers",
Expand Down
77 changes: 76 additions & 1 deletion codex/serializers/browser/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
)

from codex.choices.browser import (
BROWSER_EXTRA_SORT_UNSUPPORTED_KEYS,
BROWSER_ORDER_BY_CHOICES,
BROWSER_TABLE_COLUMNS,
BROWSER_TABLE_COVER_SIZE_CHOICES,
Expand All @@ -31,6 +32,45 @@
from codex.serializers.route import SimpleRouteSerializer
from codex.serializers.settings import SettingsInputSerializer

# Sorts worth remembering per top collection. ``search_score`` only means
# anything while a search is running, so it never enters the memory.
_MEMORABLE_ORDER_BY_KEYS = frozenset(BROWSER_ORDER_BY_CHOICES.keys()) - {"search_score"}
# Extra (secondary) sort keys the order pipeline can resolve anywhere.
_MEMORABLE_EXTRA_KEYS = (
frozenset(BROWSER_ORDER_BY_CHOICES.keys()) - BROWSER_EXTRA_SORT_UNSUPPORTED_KEYS
)


def _clean_remembered_extra_keys(value) -> list[dict]:
"""Keep the well formed, sortable, non duplicate extra sort entries."""
cleaned: list[dict] = []
if not isinstance(value, list | tuple):
return cleaned
seen: set[str] = set()
for entry in value:
if not isinstance(entry, dict):
continue
key = entry.get("key")
if key not in _MEMORABLE_EXTRA_KEYS or key in seen:
continue
seen.add(key) # pyright: ignore[reportArgumentType]
cleaned.append({"key": key, "reverse": bool(entry.get("reverse", False))})
return cleaned


def _clean_remembered_order(value) -> dict | None:
"""Coerce one remembered sort, or None when it can't be salvaged."""
if not isinstance(value, dict):
return None
order_by = value.get("order_by")
if order_by not in _MEMORABLE_ORDER_BY_KEYS:
return None
return {
"order_by": order_by,
"order_reverse": bool(value.get("order_reverse", False)),
"order_extra_keys": _clean_remembered_extra_keys(value.get("order_extra_keys")),
}


class BrowserSettingsShowCollectionFlagsSerializer(Serializer):
"""Show Collection Flags (collection vocabulary)."""
Expand Down Expand Up @@ -127,7 +167,7 @@ class BrowserSettingsSerializer(BrowserSettingsSerializerBase):

JSON_FIELDS = frozenset(
BrowserSettingsSerializerBase.JSON_FIELDS
| {"table_columns", "order_extra_keys"}
| {"table_columns", "order_extra_keys", "collection_order_memory"}
)

mtime = TimestampField(read_only=True)
Expand All @@ -154,6 +194,14 @@ class BrowserSettingsSerializer(BrowserSettingsSerializerBase):
required=False,
allow_empty=True,
)
# The sort each top collection was last browsed with, keyed by
# top_collection. Cleaned in ``validate_collection_order_memory``;
# see the model field for the stored shape.
collection_order_memory = DictField(
child=DictField(),
required=False,
allow_empty=True,
)

def validate_table_columns(self, value):
"""
Expand Down Expand Up @@ -183,6 +231,33 @@ def validate_table_columns(self, value):
cleaned[top_collection] = [c for c in columns if c in valid_columns]
return cleaned

def validate_collection_order_memory(self, value):
"""
Drop unknown top collections and unusable remembered sorts.

Like ``table_columns`` this round-trips out of stored settings, so a
stale client must not hard-400 the whole browse page. An entry naming
a sort that no longer exists is dropped with a warning; the collection
then just keeps whatever sort it is browsed with next.
"""
cleaned: dict[str, dict] = {}
for top_collection, order in value.items():
if top_collection not in BROWSER_TOP_COLLECTION_CHOICES:
logger.warning(
"Dropping unknown collection_order_memory top_collection "
f"{top_collection!r}"
)
continue
remembered_order = _clean_remembered_order(order)
if remembered_order is None:
logger.warning(
"Dropping unusable collection_order_memory order for "
f"{top_collection!r}"
)
continue
cleaned[top_collection] = remembered_order
return cleaned

def validate_order_extra_keys(self, value):
"""
Reject malformed entries; coerce to the canonical shape.
Expand Down
12 changes: 12 additions & 0 deletions codex/user_data/restore.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,15 @@ def _resolve_filter_column(row_keys, column: str) -> str | None:
return None


def _row_column(row, column: str):
"""Read a column a sidecar written by an older codex may not carry."""
try:
return row[column]
except (IndexError, KeyError):
# sqlite3.Row raises IndexError, a plain mapping raises KeyError.
return None


def _build_browser_defaults(row, show) -> dict[str, Any]:
"""Map a sidecar settings_browser row to ``update_or_create`` defaults."""
order_by = row["order_by"] or ""
Expand All @@ -582,6 +591,9 @@ def _build_browser_defaults(row, show) -> dict[str, Any]:
"order_by": order_by,
"order_reverse": bool(row["order_reverse"]),
"order_extra_keys": json.loads(row["order_extra_keys"] or "[]"),
"collection_order_memory": json.loads(
_row_column(row, "collection_order_memory") or "{}"
),
"search": row["search"] or "",
"custom_covers": bool(row["custom_covers"]),
"dynamic_covers": bool(row["dynamic_covers"]),
Expand Down
1 change: 1 addition & 0 deletions codex/user_data/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ CREATE TABLE IF NOT EXISTS settings_browser (
order_by TEXT NOT NULL DEFAULT '',
order_reverse INTEGER NOT NULL DEFAULT 0,
order_extra_keys TEXT NOT NULL DEFAULT '[]',
collection_order_memory TEXT NOT NULL DEFAULT '{}',
search TEXT NOT NULL DEFAULT '',
custom_covers INTEGER NOT NULL DEFAULT 1,
dynamic_covers INTEGER NOT NULL DEFAULT 1,
Expand Down
3 changes: 3 additions & 0 deletions codex/user_data/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ def serialize_settings_browser(
"order_extra_keys": json.dumps(
browser.order_extra_keys, separators=(",", ":")
),
"collection_order_memory": json.dumps(
browser.collection_order_memory, separators=(",", ":")
),
"search": browser.search,
"custom_covers": int(browser.custom_covers),
"dynamic_covers": int(browser.dynamic_covers),
Expand Down
35 changes: 35 additions & 0 deletions codex/views/browser/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,40 @@
# Collections whose nav owns a dedicated route (folders / story arcs); for these
# the URL collection becomes the top_collection directly during validation.
_OWN_ROUTE_COLLECTIONS = frozenset({FOLDER_COLLECTION, STORY_ARC_COLLECTION})
_SEARCH_ORDER_BY = "search_score"


def apply_collection_order_memory(
params: MutableMapping, old_top_collection: str, new_top_collection: str
) -> None:
"""
Carry the remembered sort across a top collection change.

Files the sort the old collection is leaving with, then restores the sort
the new collection was last browsed with. The browser store does this for
collection switches the user makes; this covers the ones the server makes
on its own when a url or a settings change forces a different top
collection, which would otherwise leak one collection's sort into another.
"""
if old_top_collection == new_top_collection:
return
memory = dict(params.get("collection_order_memory") or {})
order_by = params.get("order_by")
if old_top_collection and order_by and order_by != _SEARCH_ORDER_BY:
memory[old_top_collection] = {
"order_by": order_by,
"order_reverse": bool(params.get("order_reverse")),
"order_extra_keys": list(params.get("order_extra_keys") or ()),
}
params["collection_order_memory"] = memory

remembered = memory.get(new_top_collection)
if not remembered or params.get("search"):
# An active search owns the sort until it's cleared.
return
params["order_by"] = remembered["order_by"]
params["order_reverse"] = remembered["order_reverse"]
params["order_extra_keys"] = list(remembered.get("order_extra_keys") or ())


class BrowserSettingsBaseView(SettingsBaseView):
Expand Down Expand Up @@ -145,6 +179,7 @@ def _validate_settings_get(self, validated_data, params: dict) -> dict:
else Collection.ROOT
)
self._validate_top_collection(params, collection, top_collection)
apply_collection_order_memory(params, top_collection, params["top_collection"])
self.set_order_by_default(params)
return params

Expand Down
6 changes: 6 additions & 0 deletions codex/views/browser/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from codex.models.collections import BrowserCollectionModel
from codex.util import mapping_to_dict
from codex.views.browser.filters.search.parse import SearchFilterView
from codex.views.browser.settings import apply_collection_order_memory
from codex.views.const import (
COLLECTION_MODEL_MAP,
COMIC_COLLECTION,
Expand Down Expand Up @@ -77,7 +78,12 @@ def raise_redirect(
route["params"].update(route_mask)
settings = cast("dict[str, Any]", deepcopy(mapping_to_dict(self.params)))
if settings_mask:
old_top_collection = settings.get("top_collection", "")
settings.update(settings_mask)
# A redirect that moves the top collection moves its sort too.
apply_collection_order_memory(
settings, old_top_collection, settings.get("top_collection", "")
)
detail = {"route": route, "settings": settings, "reason": reason}
raise SeeOtherRedirectError(detail=detail)

Expand Down
16 changes: 0 additions & 16 deletions codex/views/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
SettingsReader,
)
from codex.views.auth import AuthFilterGenericAPIView
from codex.views.const import FOLDER_COLLECTION, STORY_ARC_COLLECTION

# Fallback top-collection when the BG flag row is missing, off, or holds
# an invalid value. Mirrors ``SettingsBrowser.top_collection``'s model
Expand Down Expand Up @@ -405,21 +404,6 @@ def load_params_from_settings(self, only: Sequence[str] | None = None) -> dict:

# ── Save (write) ────────────────────────────────────────────────

def _get_browser_order_defaults(self) -> dict:
if collection := self.kwargs.get("collection"):
# order_by has a dynamic collection based default
order_by = (
"filename"
if collection == FOLDER_COLLECTION
else "story_arc_number"
if collection == STORY_ARC_COLLECTION
else "sort_name"
)
order_defaults = {"order_by": order_by}
else:
order_defaults = {}
return order_defaults

@staticmethod
def _save_browser_show(instance: SettingsBrowser, show_data: dict) -> bool:
"""
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/api/v4/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,10 @@ export const getSettings = (data) => {

export const updateSettings = (settings) => {
const params = serializeParams(settings, undefined, false);
return HTTP.patch(_collectionSettingsBase(settings?.collection), { params });
// The settings go in the request body, not a `params` wrapper: the
// endpoint validates the body's top-level keys, so a wrapped object
// validates as empty and saves nothing.
return HTTP.patch(_collectionSettingsBase(settings?.collection), params);
};

export const resetSettings = (settings) =>
Expand Down
Loading