fix(tags): Title-Case operator-entered tags at create endpoint only (plan #701)
normalize_tag_name (per-word capitalize + whitespace collapse) is applied in the POST /api/tags handler so operator-entered tags get clean display casing. It is NOT applied in the shared find_or_create / rename paths — those are used by the ML tagger and allowlist matching, which must preserve the booru vocabulary's original casing (Title-Casing it broke apply_allowlist matching). find_or_create / rename keep case-insensitive lookup + clash detection so a differently-cased entry dedups onto the existing tag instead of forking. Tests updated to expect Title-Cased create output (sunset→Sunset, character:Saber→Character:saber, http://example.com→Http://example.com) and a dedicated normalize_tag_name unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ from ..services.tag_service import (
|
||||
TagMergeConflict,
|
||||
TagService,
|
||||
TagValidationError,
|
||||
normalize_tag_name,
|
||||
)
|
||||
from ..utils.tag_prefix import parse_kind_prefix
|
||||
|
||||
@@ -141,6 +142,11 @@ async def create_tag():
|
||||
|
||||
fandom_id = body.get("fandom_id")
|
||||
|
||||
# #701: Title-Case operator-entered tags. Only here (the explicit create
|
||||
# endpoint), NOT in the shared find_or_create — the ML tagger uses that path
|
||||
# and must keep the booru vocabulary's casing for allowlist matching.
|
||||
name = normalize_tag_name(name)
|
||||
|
||||
async with get_session() as session:
|
||||
svc = TagService(session)
|
||||
try:
|
||||
|
||||
@@ -84,7 +84,11 @@ class TagService:
|
||||
- if fandom_id is set, the referenced tag must exist and have
|
||||
kind == TagKind.fandom
|
||||
"""
|
||||
name = normalize_tag_name(name)
|
||||
# NOTE: case is NOT normalized here — find_or_create is the shared path
|
||||
# the ML tagger / allowlist also use, and Title-Casing the booru
|
||||
# vocabulary breaks allowlist matching. Display-casing (Title Case) is
|
||||
# applied at the user-entry layer (api/tags create_tag) only (#701).
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise TagValidationError("Tag name cannot be empty")
|
||||
|
||||
@@ -259,14 +263,14 @@ class TagService:
|
||||
with an existing tag of the same (kind, fandom_id) — the API turns
|
||||
that into a 409 merge hint.
|
||||
"""
|
||||
new_name = normalize_tag_name(new_name)
|
||||
new_name = new_name.strip()
|
||||
if not new_name:
|
||||
raise TagValidationError("Tag name cannot be empty")
|
||||
tag = await self.session.get(Tag, tag_id)
|
||||
if tag is None:
|
||||
raise TagValidationError(f"Tag {tag_id} not found")
|
||||
|
||||
# Case-insensitive clash (#701) — colliding with a differently-cased tag
|
||||
# Case-insensitive clash (#701) — renaming onto a differently-cased tag
|
||||
# is still a merge, not a silent fork.
|
||||
clash_stmt = (
|
||||
select(Tag)
|
||||
|
||||
Reference in New Issue
Block a user