Repository: BoboTiG/ebook-reader-dict

Issue:
[EN] Parse gender string
Many templates, including doublet and l have a "g" parameter containing a gender and number string.
See https://en.wiktionary.org/wiki/Module:gender_and_number

We need function to parse such a string and send back a meaningful string

<!-- POLAR PLEDGE BADGE START -->
## Upvote & Fund

- We're using [Polar.sh](https://polar.sh/tiger-222) so you can upvote and help fund this issue.
- We receive the funding once the issue is completed & confirmed by you.
- Thank you in advance for helping prioritize & fund our backlog.

<a href="https://polar.sh/BoboTiG/ebook-reader-dict/issues/377">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/BoboTiG/ebook-reader-dict/issues/377/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/BoboTiG/ebook-reader-dict/issues/377/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->


Discussion:

Patch:
diff --git a/wikidict/lang/en/template_handlers.py b/wikidict/lang/en/template_handlers.py
index 5d86eb2c..96612f22 100644
--- a/wikidict/lang/en/template_handlers.py
+++ b/wikidict/lang/en/template_handlers.py
@@ -26,6 +26,85 @@
 from .si_unit import prefix_to_exp, prefix_to_symbol, unit_to_symbol, unit_to_type
 
 
+def gender_number_specs(parts: str) -> str:
+    """
+    Source: https://en.wiktionary.org/wiki/Module:gender_and_number
+
+    >>> gender_number_specs("m")
+    '<i>m</i>'
+    >>> gender_number_specs("m-p")
+    '<i>m pl</i>'
+    >>> gender_number_specs("m-an-p")
+    '<i>m anim pl</i>'
+    >>> gender_number_specs("?-p")
+    '<i>? pl</i>'
+    >>> gender_number_specs("?!-an-s")
+    '<i>gender unattested anim sg</i>'
+    >>> gender_number_specs("mfbysense-p")
+    '<i>m pl or f pl by sense</i>'
+    >>> gender_number_specs("mfequiv-s")
+    '<i>m sg or f sg same meaning</i>'
+    >>> gender_number_specs("mfequiv")
+    '<i>m or f same meaning</i>'
+    >>> gender_number_specs("biasp-s")
+    '<i>impf sg or pf sg</i>'
+    """
+    specs = {
+        # Genders
+        "m": "m",
+        "n": "n",
+        "f": "f",
+        "gneut": "gender-neutral",
+        "g!": "gender unattested",
+        "c": "c",
+        # Numbers
+        "s": "sg",
+        "d": "du",
+        "num!": "number unattested",
+        "p": "pl",
+        # Animacy
+        "an": "anim",
+        "in": "inan",
+        "an!": "animacy unattested",
+        "pr": "pers",
+        "anml": "animal",
+        "np": "npers",
+        # Virility
+        "vr": "vir",
+        "nv": "nvir",
+        # Aspect
+        "pf": "pf",
+        "impf": "impf",
+        "asp!": "aspect unattested",
+        # Other
+        "?": "?",
+        "?!": "gender unattested",
+    }
+    specs_combined = {
+        "biasp": [specs["impf"], specs["pf"]],
+        "mf": [specs["m"], specs["f"]],
+        "mfbysense": [specs["m"], specs["f"]],
+        "mfequiv": [specs["m"], specs["f"]],
+    }
+    result = []
+
+    for part in parts.split("-"):
+        if part in specs_combined:
+            combinations = specs_combined[parts.split("-")[0]]
+            spec = specs[parts.split("-")[1]] if "-" in parts else ""
+            res = " or ".join(f"{a} {b}".strip() for a, b in zip(combinations, [spec] * len(combinations), strict=True))
+            if "sense" in part:
+                res += " by sense"
+            elif "equiv" in part:
+                res += " same meaning"
+            result.append(res)
+            return italic(" or ".join(result))
+        else:
+            result.append(specs[part])
+
+    return italic(" ".join(result))
+
+
 def join_names(
     data: defaultdict[str, str],
     key: str,
@@ -468,7 +547,7 @@ def render_foreign_derivation(tpl: str, parts: list[str], data: defaultdict[str,
     elif word:
         phrase += f" {italic(word)}"
     if data["g"]:
-        phrase += f' {italic(data["g"])}'
+        phrase += f' {gender_number_specs(data["g"])}'
     trans = "" if data["tr"] else transliterate(dst_locale, word)
     if parts:
         gloss = parts.pop(0)  # 5, t=, gloss=
@@ -896,7 +975,7 @@ def add_dash(tpl: str, index: int, parts_count: int, chunk: str) -> str:
         chunk = chunk.split("#")[0] if chunk else ""
         chunk = data[f"alt{si}"] or chunk
         p_dic["chunk"] = chunk
-        p_dic["g"] = data[f"g{si}"]
+        p_dic["g"] = gender_number_specs(gender) if (gender := data[f"g{si}"]) else ""
         p_dic["tr"] = data[f"tr{si}"]
         p_dic["t"] = data[f"t{si}"] or data[f"gloss{si}"]
         p_dic["pos"] = data[f"pos{si}"]
@@ -916,7 +995,7 @@ def add_dash(tpl: str, index: int, parts_count: int, chunk: str) -> str:
         if chunk:
             chunk = italic(chunk)
         if c["g"]:
-            chunk += " " + italic(c["g"])
+            chunk += " " + c["g"]
         local_phrase = []
         if c["tr"]:
             result = c["tr"]


Test patch:
diff --git a/tests/test_en.py b/tests/test_en.py
index 1e237f1f..55f5d4a4 100644
--- a/tests/test_en.py
+++ b/tests/test_en.py
@@ -152,7 +152,7 @@
             ["/ði/", "/ðə/", "/ðɪ/", "/ˈðiː/", "/ˈðʌ/"],
             [
                 "From Middle English <i>þe</i>, from Old English <i>þē</i> <i>m</i> (“the, that”, demonstrative pronoun), a late variant of <i>sē</i>, the <i>s-</i> (which occurred in the masculine and feminine nominative singular only) having been replaced by the <i>þ-</i> from the oblique stem.",  # noqa
-                "Originally neutral nominative, in Middle English it superseded all previous Old English nominative forms (<i>sē</i> <i>m</i>, <i>sēo</i> <i>f</i>, <i>þæt</i> <i>n</i>, <i>þā</i> <i>p</i>); <i>sē</i> is from Proto-West Germanic <i>*siz</i>, from Proto-Germanic <i>*sa</i>, ultimately from Proto-Indo-European <i>*só</i>.",  # noqa
+                "Originally neutral nominative, in Middle English it superseded all previous Old English nominative forms (<i>sē</i> <i>m</i>, <i>sēo</i> <i>f</i>, <i>þæt</i> <i>n</i>, <i>þā</i> <i>pl</i>); <i>sē</i> is from Proto-West Germanic <i>*siz</i>, from Proto-Germanic <i>*sa</i>, ultimately from Proto-Indo-European <i>*só</i>.",  # noqa
                 "Cognate with Saterland Frisian <i>die</i> (“the”), West Frisian <i>de</i> (“the”), Dutch <i>de</i> (“the”), German Low German <i>de</i> (“the”), German <i>der</i> (“the”), Danish <i>de</i> (“the”), Swedish <i>de</i> (“the”), Icelandic <i>sá</i> (“that”) within Germanic and with Sanskrit <i>sá</i> (“the, that”), Ancient Greek <i>ὁ</i> (“the”), Tocharian B <i>se</i> (“this”) among other Indo-European languages.",  # noqa
             ],
             [