feat(tag-prefix): parse_kind_prefix util — IR-style \kind:name\ parser at the input boundary; KNOWN_KINDS = artist/character/fandom/series/meta/rating (excludes default \general\ and system-managed archive/post)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 20:51:49 -04:00
parent 0316f92e8b
commit ccee344099
2 changed files with 87 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
"""Parse the user-facing `kind:name` shortcut used by the add-tag input.
Mirrors IR's app/utils/tag_prefix.py. Tag.name in FC is stored bare;
the `kind:` prefix only exists as an input convention at user-facing
places (image-modal add-tag input, future bulk-add forms). The parser
is the single owner of the kind-string list — anything not in
KNOWN_KINDS keeps its colon as literal text.
"""
from __future__ import annotations
# Kinds the user can type as a prefix at the input boundary.
# `general` is intentionally excluded — it's the default for un-prefixed
# input. `archive` and `post` are system-managed and never user-typed.
KNOWN_KINDS: frozenset[str] = frozenset({
"artist",
"character",
"fandom",
"series",
"meta",
"rating",
})
def parse_kind_prefix(raw: str) -> tuple[str | None, str]:
"""Split a raw user-typed tag string into (kind, name).
Returns (kind, name) where kind is lowercase canonical and in
KNOWN_KINDS, or (None, raw.strip()) if no recognized prefix is
present. `name` is always whitespace-stripped.
Examples:
parse_kind_prefix("character:Saber") -> ("character", "Saber")
parse_kind_prefix("Character:Saber") -> ("character", "Saber")
parse_kind_prefix("sunset") -> (None, "sunset")
parse_kind_prefix("http://example") -> (None, "http://example")
parse_kind_prefix("artist: Eric ") -> ("artist", "Eric")
"""
if ":" in raw:
prefix, rest = raw.split(":", 1)
if prefix.lower() in KNOWN_KINDS:
return prefix.lower(), rest.strip()
return None, raw.strip()
+44
View File
@@ -0,0 +1,44 @@
"""parse_kind_prefix — IR-style `kind:name` shortcut at the input boundary."""
from backend.app.utils.tag_prefix import KNOWN_KINDS, parse_kind_prefix
def test_recognized_kinds_match_user_input_set():
# System-managed kinds (archive, post) and the default (general)
# are intentionally excluded — the user can't type those at the
# input boundary.
assert KNOWN_KINDS == frozenset({
"artist", "character", "fandom", "series", "meta", "rating",
})
def test_character_prefix_parsed():
assert parse_kind_prefix("character:Saber") == ("character", "Saber")
def test_case_insensitive_prefix():
assert parse_kind_prefix("Character:Saber") == ("character", "Saber")
assert parse_kind_prefix("CHARACTER:Saber") == ("character", "Saber")
def test_no_prefix_returns_none_kind():
assert parse_kind_prefix("sunset") == (None, "sunset")
def test_unknown_prefix_kept_as_literal():
# 'http' is not a known kind — preserve the literal text.
assert parse_kind_prefix("http://example.com") == (None, "http://example.com")
def test_whitespace_stripped():
assert parse_kind_prefix("artist: Eric ") == ("artist", "Eric")
assert parse_kind_prefix(" sunset ") == (None, "sunset")
def test_empty_string():
assert parse_kind_prefix("") == (None, "")
def test_just_colon():
# Empty prefix → "" not in KNOWN_KINDS → falls through to (None, "...")
assert parse_kind_prefix(":foo") == (None, ":foo")