diff --git a/backend/app/utils/tag_prefix.py b/backend/app/utils/tag_prefix.py new file mode 100644 index 0000000..67ad90b --- /dev/null +++ b/backend/app/utils/tag_prefix.py @@ -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() diff --git a/tests/test_tag_prefix.py b/tests/test_tag_prefix.py new file mode 100644 index 0000000..0547386 --- /dev/null +++ b/tests/test_tag_prefix.py @@ -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")