""" Controlled category taxonomy for game-library. Single source of truth for activity categories. The DB stores the *slug*; the UI displays the Romanian name. `category` (thematic domain) and `content_type` (form of the content) are INDEPENDENT axes — see plan §2. """ import unicodedata import re from typing import Dict, List # --- Categories (thematic domain) -------------------------------------------- # slug -> Romanian display name. ~16 fixed slugs; `altele` is the mandatory # fallback and MUST always be present. CATEGORIES: Dict[str, str] = { "jocuri-cercetasesti": "Jocuri cercetășești", "team-building": "Team-building", "icebreakers": "Icebreakers / spargerea gheții", "camp-outdoor": "Tabără și activități în aer liber", "wide-games": "Wide games / jocuri de teren", "orientare": "Orientare", "prim-ajutor": "Prim ajutor", "escape-room-puzzle": "Escape room și puzzle", "creative-stem": "Creativitate și STEM", "sports-active": "Sport și activități fizice", "cantece-ceremonii": "Cântece și ceremonii", "retete": "Rețete", "supravietuire": "Supraviețuire", "integrare-incluziune": "Integrare și incluziune", "conflict-empatie": "Conflict și empatie", "altele": "Altele", } # Mandatory fallback slug. FALLBACK_CATEGORY = "altele" # Ordered list of valid slugs. CATEGORY_SLUGS: List[str] = list(CATEGORIES.keys()) # --- Content type (form of the content) -------------------------------------- # Independent axis from `category`. The UI default search excludes the # non-game content types (see plan §6). CONTENT_TYPES: Dict[str, str] = { "joc": "Joc", "activitate": "Activitate", "reteta": "Rețetă", "cantec": "Cântec", "ceremonie": "Ceremonie", } CONTENT_TYPE_SLUGS: List[str] = list(CONTENT_TYPES.keys()) # Content types considered "non-game" — excluded from the default UI search. NON_GAME_CONTENT_TYPES: List[str] = ["reteta", "cantec", "ceremonie"] DEFAULT_CONTENT_TYPE = "activitate" # --- Aliases ----------------------------------------------------------------- # Map of normalized arbitrary strings -> canonical slug. Keys are already # diacritic-stripped, lowercased and hyphenated (see _slugify). This catches # legacy / messy values from the old DB and common English/Romanian variants. _CATEGORY_ALIASES: Dict[str, str] = { # legacy junk "general-activity": "altele", "general": "altele", "educational": "creative-stem", "d": "altele", "a": "altele", "b": "altele", "c": "altele", # scouting "cercetasie": "jocuri-cercetasesti", "cercetasesti": "jocuri-cercetasesti", "scout": "jocuri-cercetasesti", "scouting": "jocuri-cercetasesti", "scout-games": "jocuri-cercetasesti", "jocuri-cercetasesti": "jocuri-cercetasesti", # team building "teambuilding": "team-building", "team": "team-building", "cooperare": "team-building", # icebreakers "icebreaker": "icebreakers", "spargerea-ghetii": "icebreakers", "cunoastere": "icebreakers", "energizers": "icebreakers", "energizer": "icebreakers", # camp / outdoor "camp": "camp-outdoor", "tabara": "camp-outdoor", "outdoor": "camp-outdoor", "aer-liber": "camp-outdoor", # wide games "wide-game": "wide-games", "jocuri-de-teren": "wide-games", "joc-de-teren": "wide-games", "big-games": "wide-games", # orientare "orienteering": "orientare", "navigatie": "orientare", # prim ajutor "first-aid": "prim-ajutor", "primul-ajutor": "prim-ajutor", # escape room / puzzle "escape-room": "escape-room-puzzle", "escaperoom": "escape-room-puzzle", "puzzle": "escape-room-puzzle", "puzzles": "escape-room-puzzle", "ghicitori": "escape-room-puzzle", # creative / stem "creative": "creative-stem", "creativitate": "creative-stem", "stem": "creative-stem", "arts-and-crafts": "creative-stem", "craft": "creative-stem", "crafts": "creative-stem", "stiinta": "creative-stem", # sports "sport": "sports-active", "sports": "sports-active", "sportive": "sports-active", "active": "sports-active", "miscare": "sports-active", "physical": "sports-active", # songs / ceremonies "cantece": "cantece-ceremonii", "cantec": "cantece-ceremonii", "songs": "cantece-ceremonii", "ceremonii": "cantece-ceremonii", "ceremonie": "cantece-ceremonii", "ceremony": "cantece-ceremonii", # recipes "reteta": "retete", "recipe": "retete", "recipes": "retete", "cooking": "retete", "gatit": "retete", # survival "survival": "supravietuire", "supravietuire": "supravietuire", # inclusion "integrare": "integrare-incluziune", "incluziune": "integrare-incluziune", "inclusion": "integrare-incluziune", # conflict / empathy "conflict": "conflict-empatie", "empatie": "conflict-empatie", "empathy": "conflict-empatie", "rezolvarea-conflictelor": "conflict-empatie", # fallback "altele": "altele", "other": "altele", "others": "altele", "misc": "altele", } def _slugify(value: str) -> str: """Lowercase, strip diacritics, collapse non-alphanumerics to hyphens.""" if not value: return "" # Decompose accents (ă -> a, ș -> s, ț -> t, etc.) decomposed = unicodedata.normalize("NFKD", value) ascii_str = "".join(c for c in decomposed if not unicodedata.combining(c)) ascii_str = ascii_str.lower().strip() ascii_str = re.sub(r"[^a-z0-9]+", "-", ascii_str) return ascii_str.strip("-") def normalize_category(value: str) -> str: """Map an arbitrary string to a valid category slug. Returns one of CATEGORY_SLUGS, falling back to `altele` for anything unrecognised or empty. """ if not value: return FALLBACK_CATEGORY slug = _slugify(str(value)) if not slug: return FALLBACK_CATEGORY # Exact slug match. if slug in CATEGORIES: return slug # Alias match. if slug in _CATEGORY_ALIASES: return _CATEGORY_ALIASES[slug] return FALLBACK_CATEGORY def normalize_content_type(value: str) -> str: """Map an arbitrary string to a valid content_type slug. Returns one of CONTENT_TYPE_SLUGS, falling back to `activitate`. """ if not value: return DEFAULT_CONTENT_TYPE slug = _slugify(str(value)) if slug in CONTENT_TYPES: return slug # Light alias handling for plural / English forms. aliases = { "jocuri": "joc", "game": "joc", "games": "joc", "activitati": "activitate", "activity": "activitate", "retete": "reteta", "recipe": "reteta", "cantece": "cantec", "song": "cantec", "ceremonii": "ceremonie", "ceremony": "ceremonie", } return aliases.get(slug, DEFAULT_CONTENT_TYPE) def is_valid_category(slug: str) -> bool: """True if `slug` is a valid category slug.""" return slug in CATEGORIES def category_display_name(slug: str) -> str: """Romanian display name for a slug (fallback to the slug itself).""" return CATEGORIES.get(slug, slug) def content_type_display_name(slug: str) -> str: """Romanian display name for a content_type slug.""" return CONTENT_TYPES.get(slug, slug)