Merge sort_name into name_sort_key with custom-override flag

Collapse the separate `sort_name` column into `name_sort_key`, which is now
the single user-settable sort field: always normalized and indexed for fast
ordering, derived from `name` by default, and overridable. A new
`name_sort_key_custom` boolean marks user/metadata overrides so they survive
renames and rescans.

- Drop the `roms.sort_name` column; repurpose migration 0085 to add
  `name_sort_key_custom`.
- Derive the key via `@validates("name")` unless pinned custom; the edit
  dialog, unmatch flow, and ES-DE gamelist <sortname> set custom keys.
- update_rom / scan_rom keep the columns in sync explicitly (bulk update and
  construction bypass / reorder the validator).
- Frontend: edit field drives name_sort_key (empty when auto), api sends the
  override only when custom, regenerated types updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Georges-Antoine Assi
2026-06-18 10:34:21 -04:00
parent e7d46ee8c4
commit cd19d723fa
18 changed files with 185 additions and 101 deletions

View File

@@ -1010,8 +1010,8 @@ class DBRomsHandler(DBBaseHandler):
order_attr = Rom.name
# Use indexed `name_sort_key` to have fast access to names without
# articles (the, a, an) and leading digits. The key already folds in
# `sort_name` (falling back to `name`) at write time.
# articles (the, a, an) and leading digits. The key is derived from
# `name` at write time, or holds a custom override when one is set.
if order_attr is Rom.name:
order_attr = Rom.name_sort_key
@@ -1206,19 +1206,16 @@ class DBRomsHandler(DBBaseHandler):
) -> Rom:
# Bulk update() bypasses the ORM @validates hooks, so keep the
# columns derived from name / fs_name in sync explicitly.
if "name" in data or "sort_name" in data:
if "name" in data and "sort_name" in data:
effective = data["sort_name"] or data["name"]
else:
# Only one of the two changed; read the other from the row so
# the key reflects the effective `sort_name or name`.
existing = session.query(Rom).filter_by(id=id).one()
name = data["name"] if "name" in data else existing.name
sort_name = (
data["sort_name"] if "sort_name" in data else existing.sort_name
)
effective = sort_name or name
data = {**data, "name_sort_key": compute_name_sort_key(effective)}
if "name_sort_key" in data:
# An explicit sort key was supplied (custom override or a revert to
# the derived value). Trust it; mark it custom unless told otherwise.
data = {"name_sort_key_custom": True, **data}
elif "name" in data:
# Name changed without an explicit key: recompute the derived key,
# but never clobber a row pinned to a custom sort key.
existing = session.query(Rom).filter_by(id=id).one()
if not existing.name_sort_key_custom:
data = {**data, "name_sort_key": compute_name_sort_key(data["name"])}
if "fs_name" in data:
parts = compute_file_name_parts(data["fs_name"])

View File

@@ -52,7 +52,8 @@ MULTIPLE_SPACE_PATTERN = re.compile(r"\s+")
class BaseRom(TypedDict):
name: NotRequired[str]
sort_name: NotRequired[str | None]
name_sort_key: NotRequired[str | None]
name_sort_key_custom: NotRequired[bool]
summary: NotRequired[str]
url_cover: NotRequired[str]
url_screenshots: NotRequired[list[str]]

View File

@@ -14,7 +14,7 @@ from config.config_manager import config_manager as cm
from handler.filesystem import fs_platform_handler, fs_resource_handler
from logger.logger import log
from models.platform import Platform
from models.rom import Rom
from models.rom import Rom, compute_name_sort_key
from .base_handler import BaseRom, MetadataHandler
@@ -420,7 +420,12 @@ class GamelistHandler(MetadataHandler):
rom_data = GamelistRom(
gamelist_id=str(uuid.uuid4()),
name=name,
sort_name=sort_name or name,
# A gamelist <sortname> tag becomes a custom sort key; the
# derived-from-name default is used when it is absent.
name_sort_key=(
compute_name_sort_key(sort_name) if sort_name else None
),
name_sort_key_custom=bool(sort_name),
summary=summary,
regions=regions,
languages=languages,

View File

@@ -310,6 +310,19 @@ async def scan_firmware(
return Firmware(**firmware_attrs)
def _build_rom(rom_attrs: dict[str, Any]) -> Rom:
"""Construct a Rom, applying a custom ``name_sort_key`` after construction so
the override survives the ``@validates('name')`` derivation regardless of the
order in which ``name`` and the sort-key fields are passed."""
name_sort_key = rom_attrs.pop("name_sort_key", None)
name_sort_key_custom = rom_attrs.pop("name_sort_key_custom", False)
rom = Rom(**rom_attrs)
if name_sort_key_custom and name_sort_key:
rom.name_sort_key = name_sort_key
rom.name_sort_key_custom = True
return rom
async def scan_rom(
scan_type: ScanType,
platform: Platform,
@@ -335,7 +348,6 @@ async def scan_rom(
"sha1_hash": rom.sha1_hash,
"ra_hash": rom.ra_hash,
"fs_size_bytes": rom.fs_size_bytes,
"sort_name": None,
}
# Check if files have been parsed and hashed
@@ -356,7 +368,8 @@ async def scan_rom(
rom_attrs.update(
{
"name": rom.name,
"sort_name": rom.sort_name,
"name_sort_key": rom.name_sort_key,
"name_sort_key_custom": rom.name_sort_key_custom,
"slug": rom.slug,
"summary": rom.summary,
"url_cover": rom.url_cover,
@@ -1003,7 +1016,7 @@ async def scan_rom(
f"{hl(rom_attrs['fs_name'])} not identified {emoji.EMOJI_CROSS_MARK}",
extra=LOGGER_MODULE_NAME,
)
return Rom(**rom_attrs)
return _build_rom(rom_attrs)
async def fetch_sgdb_details(playmatch_rom: PlaymatchRomMatch) -> SGDBRom:
"""Fetch SteamGridDB details for the ROM."""
@@ -1079,7 +1092,7 @@ async def scan_rom(
)
rom_attrs["missing_from_fs"] = False
return Rom(**rom_attrs)
return _build_rom(rom_attrs)
async def _scan_asset(file_name: str, asset_path: str, should_hash: bool = False):