You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
680 lines
25 KiB
680 lines
25 KiB
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, Any, Optional, List, Tuple
|
|
|
|
SUPPORTED_LANGS = ("ru", "en", "ar")
|
|
|
|
# Default templates matching the specification in Коллекции дат.md
|
|
# Tuple structure: (display_template, display_template_negative, display_template_age)
|
|
DEFAULT_TEMPLATES: Dict[str, Dict[str, Tuple[Optional[str], Optional[str], Optional[str]]]] = {
|
|
"exact": {
|
|
"ar": ("{value} هـ", "{abs_value} ق.هـ", "{value} سنة"),
|
|
"ru": ("{value} г.", "{abs_value} г. до хиджры", "{value} г."),
|
|
"en": ("{value} AH", "{abs_value} BH", "{value} years"),
|
|
},
|
|
"range": {
|
|
"ar": ("{min}–{max} هـ", "{abs_min} ق.هـ – {max} هـ", "{min}–{max} سنة"),
|
|
"ru": ("{min}–{max} гг.", "{abs_min} г. до хиджры – {max} г.", "{min}–{max} лет"),
|
|
"en": ("{min}–{max} AH", "{abs_min} BH – {max} AH", "{min}–{max} years"),
|
|
},
|
|
"circa": {
|
|
"ar": ("حوالي {value} هـ", "حوالي {abs_value} ق.هـ", "حوالي {value} سنة"),
|
|
"ru": ("около {value} г.", "около {abs_value} г. до хиджры", "около {value} лет"),
|
|
"en": ("circa {value} AH", "circa {abs_value} BH", "circa {value} years"),
|
|
},
|
|
"greater_than": {
|
|
"ar": ("بعد {min} هـ", "بعد {abs_min} ق.هـ", "أكثر من {min} سنة"),
|
|
"ru": ("после {min} г.", "после {abs_min} г. до хиджры", "более {min} лет"),
|
|
"en": ("after {min} AH", "after {abs_min} BH", "more than {min} years"),
|
|
},
|
|
"less_than": {
|
|
"ar": ("قبل {max} هـ", "قبل {abs_max} ق.هـ", "أقل من {max} سنة"),
|
|
"ru": ("до {max} г.", "до {abs_max} г. до хиджры", "менее {max} лет"),
|
|
"en": ("before {max} AH", "before {abs_max} BH", "less than {max} years"),
|
|
},
|
|
"alternatives": {
|
|
"ar": ("{min} أو {max}", None, "{min} أو {max} سنة"),
|
|
"ru": ("{min} или {max}", None, "{min} или {max} лет"),
|
|
"en": ("{min} or {max}", None, "{min} or {max} years"),
|
|
},
|
|
"alternatives_range": {
|
|
"ar": ("{range1} أو {range2}", None, "{range1} أو {range2} سنة"),
|
|
"ru": ("{range1} или {range2}", None, "{range1} или {range2} лет"),
|
|
"en": ("{range1} or {range2}", None, "{range1} or {range2} years"),
|
|
},
|
|
"context": {
|
|
"ar": ("", "", ""),
|
|
"ru": ("", "", ""),
|
|
"en": ("", "", ""),
|
|
},
|
|
}
|
|
|
|
DEFAULT_CONTEXT_TAGS: Dict[str, Dict[str, str]] = {
|
|
"#hijra_habasha": {
|
|
"ar": "وُلِدَ فِي الْحَبَشَةِ فِي فَتْرَةِ الْهِجْرَةِ الْأُولَى",
|
|
"ru": "Родился в Абиссинии во время первого переселения",
|
|
"en": "Born in Abyssinia during the first migration",
|
|
},
|
|
"#early_islam": {
|
|
"ar": "صدر الإسلام",
|
|
"ru": "Ранний ислам",
|
|
"en": "Early Islam",
|
|
},
|
|
"#umayyad_era": {
|
|
"ar": "العصر الأموي",
|
|
"ru": "Эпоха Омейядов",
|
|
"en": "Umayyad era",
|
|
},
|
|
}
|
|
|
|
# Regex patterns for strict validation
|
|
RE_EXACT = re.compile(r"^-?\d+$")
|
|
RE_RANGE = re.compile(r"^(-?\d+)\.\.(-?\d+)$")
|
|
RE_CIRCA = re.compile(r"^~(-?\d+)$")
|
|
RE_GREATER = re.compile(r"^>(-?\d+)$")
|
|
RE_LESS = re.compile(r"^<(-?\d+)$")
|
|
RE_ALT_EXACT = re.compile(r"^-?\d+(?:;-?\d+)+$")
|
|
RE_ALT_RANGE = re.compile(r"^-?\d+\.\.-?\d+(?:;-?\d+\.\.-?\d+)+$")
|
|
RE_CONTEXT = re.compile(r"^#([a-zA-Z0-9_]+)$")
|
|
|
|
|
|
class CanonicalDateValidationError(ValueError):
|
|
def __init__(self, message: str, suggestion: Optional[str] = None):
|
|
super().__init__(message)
|
|
self.message = message
|
|
self.suggestion = suggestion
|
|
|
|
|
|
@dataclass
|
|
class ParsedCanonicalDate:
|
|
raw_value: str
|
|
is_valid: bool = True
|
|
parse_type: str = "exact"
|
|
value: Optional[int] = None
|
|
value_min: Optional[int] = None
|
|
value_max: Optional[int] = None
|
|
values_list: List[int] = field(default_factory=list)
|
|
abs_value: Optional[int] = None
|
|
is_bh: bool = False
|
|
is_mixed: bool = False
|
|
context_key: Optional[str] = None
|
|
error: Optional[str] = None
|
|
suggestion: Optional[str] = None
|
|
translations: List[Dict[str, Any]] = field(default_factory=list)
|
|
_threshold: Optional[int] = None
|
|
_parts: List[int] = field(default_factory=list)
|
|
_ranges: List[Tuple[int, int]] = field(default_factory=list)
|
|
|
|
@property
|
|
def canonical(self) -> str:
|
|
return self.raw_value
|
|
|
|
@property
|
|
def type(self) -> str:
|
|
return self.parse_type
|
|
|
|
def get(self, key: str, default: Any = None) -> Any:
|
|
return getattr(self, key, default)
|
|
|
|
def __getitem__(self, key: str) -> Any:
|
|
return getattr(self, key)
|
|
|
|
|
|
def normalize_lang_code(lang: Optional[str]) -> str:
|
|
if not lang:
|
|
return "en"
|
|
code = lang.split(",")[0].split(";")[0].split("-")[0].split("_")[0].strip().lower()
|
|
if code in SUPPORTED_LANGS:
|
|
return code
|
|
return "en"
|
|
|
|
|
|
def suggest_canonical_fix(raw: str) -> Optional[str]:
|
|
"""
|
|
Attempts to auto-fix common operator mistakes described in the standard:
|
|
- '170-193' or '170 – 193' or '170...193' -> '170..193'
|
|
- '±100' or '≈100' or '~ 100' -> '~100'
|
|
- '> 80' -> '>80', '< 150' -> '<150'
|
|
- '131, 140' or '131; 140' or '131 или 140' or '131 or 140' -> '131;140'
|
|
"""
|
|
if not raw or not isinstance(raw, str):
|
|
return None
|
|
s = raw.strip()
|
|
if not s:
|
|
return None
|
|
|
|
# Context tag cleanup
|
|
if s.startswith("#"):
|
|
cleaned_key = re.sub(r"[^a-zA-Z0-9_]", "_", s[1:].strip()).strip("_").lower()
|
|
return f"#{cleaned_key}" if cleaned_key else None
|
|
|
|
# Replace approximate symbols
|
|
if s.startswith(("±", "≈")):
|
|
s = "~" + s[1:].strip()
|
|
elif s.startswith("~"):
|
|
s = "~" + s[1:].strip()
|
|
|
|
# Replace greater/less spacing or words
|
|
s = re.sub(r"^(?:больше|more\s+than|after|بعد)\s*", ">", s, flags=re.IGNORECASE)
|
|
s = re.sub(r"^(?:меньше|до|less\s+than|before|قبل)\s*", "<", s, flags=re.IGNORECASE)
|
|
s = re.sub(r"([><~])\s+(?=-?\d)", r"\1", s)
|
|
|
|
# Replace alternative separators ('или', 'or', 'أو', '∨', ',') with ';'
|
|
s = re.sub(r"\s*(?:или|or|أو|∨|,|،|;)\s*", ";", s, flags=re.IGNORECASE)
|
|
|
|
# Replace range separators ('...', '–', '—', or '-' between two positive numbers)
|
|
s = re.sub(r"\s*\.\.\.+\s*", "..", s)
|
|
s = re.sub(r"\s*[–—]\s*", "..", s)
|
|
s = re.sub(r"(?<=\d)\s*-\s*(?=\d)", "..", s)
|
|
s = re.sub(r"\s*\.\.\s*", "..", s)
|
|
|
|
return s if s != raw.strip() else None
|
|
|
|
|
|
def _join_multi_items(items: List[str], lang: str) -> str:
|
|
if not items:
|
|
return ""
|
|
if len(items) == 1:
|
|
return items[0]
|
|
if lang == "ar":
|
|
if len(items) == 2:
|
|
return f"{items[0]} أو {items[1]}"
|
|
return "، ".join(items[:-1]) + f" أو {items[-1]}"
|
|
elif lang == "ru":
|
|
if len(items) == 2:
|
|
return f"{items[0]} или {items[1]}"
|
|
return ", ".join(items[:-1]) + f" или {items[-1]}"
|
|
else:
|
|
if len(items) == 2:
|
|
return f"{items[0]} or {items[1]}"
|
|
return ", ".join(items[:-1]) + f" or {items[-1]}"
|
|
|
|
|
|
def _format_single_range(r_min: int, r_max: int, lang: str, for_age: bool = False, with_unit: bool = True) -> str:
|
|
if for_age:
|
|
base = f"{r_min}–{r_max}"
|
|
if not with_unit:
|
|
return base
|
|
unit = {"ar": "سنة", "ru": "лет", "en": "years"}.get(lang, "years")
|
|
return f"{base} {unit}"
|
|
|
|
# Hijri year range
|
|
if r_min < 0 and r_max >= 0:
|
|
abs_min = abs(r_min)
|
|
if lang == "ar":
|
|
return f"{abs_min} ق.هـ – {r_max} هـ"
|
|
elif lang == "ru":
|
|
return f"{abs_min} г. до хиджры – {r_max} г."
|
|
else:
|
|
return f"{abs_min} BH – {r_max} AH"
|
|
elif r_min < 0 and r_max < 0:
|
|
abs_min = abs(r_min)
|
|
abs_max = abs(r_max)
|
|
if not with_unit:
|
|
return f"{abs_min}–{abs_max}"
|
|
if lang == "ar":
|
|
return f"{abs_min}–{abs_max} ق.هـ"
|
|
elif lang == "ru":
|
|
return f"{abs_min}–{abs_max} гг. до хиджры"
|
|
else:
|
|
return f"{abs_min}–{abs_max} BH"
|
|
else:
|
|
base = f"{r_min}–{r_max}"
|
|
if not with_unit:
|
|
return base
|
|
if lang == "ar":
|
|
return f"{base} هـ"
|
|
elif lang == "ru":
|
|
return f"{base} гг."
|
|
else:
|
|
return f"{base} AH"
|
|
|
|
|
|
def canonical_to_parts(canonical: str) -> List[int]:
|
|
return [int(p) for p in canonical.split(";") if p.strip()]
|
|
|
|
|
|
def canonical_to_ranges(canonical: str) -> List[Tuple[int, int]]:
|
|
res: List[Tuple[int, int]] = []
|
|
for chunk in canonical.split(";"):
|
|
if ".." in chunk:
|
|
a, b = chunk.split("..", 1)
|
|
res.append((int(a), int(b)))
|
|
return res
|
|
|
|
|
|
def parse_canonical_date(
|
|
raw_value: Any,
|
|
category: Optional[str] = None,
|
|
date_type: Optional[str] = None,
|
|
raise_on_error: bool = False,
|
|
) -> ParsedCanonicalDate:
|
|
"""
|
|
Validates and parses a canonical date/age string according to the Russian specification.
|
|
Returns a ParsedCanonicalDate object with `is_valid`, `raw_value`, `parse_type`, `value`,
|
|
`value_min`, `value_max`, `values_list`, `error`, `suggestion`, and `translations`.
|
|
"""
|
|
cat = category or date_type or "birth"
|
|
is_age = (cat == "age")
|
|
|
|
if raw_value is None:
|
|
return ParsedCanonicalDate(raw_value="", is_valid=False, parse_type="unparsed", error="Empty value")
|
|
|
|
canonical = str(raw_value).strip()
|
|
if not canonical or canonical.lower() in ("null", "none", "undefined"):
|
|
return ParsedCanonicalDate(raw_value="", is_valid=False, parse_type="unparsed", error="Empty value")
|
|
|
|
suggestion = suggest_canonical_fix(canonical)
|
|
|
|
def _invalid(err_msg: str, custom_suggestion: Optional[str] = None) -> ParsedCanonicalDate:
|
|
sug = custom_suggestion if custom_suggestion is not None else suggestion
|
|
if seg_msg := (f"{err_msg} (Did you mean '{sug}'?)" if sug and sug != canonical else err_msg):
|
|
pass
|
|
if raise_on_error:
|
|
raise CanonicalDateValidationError(seg_msg, suggestion=sug)
|
|
return ParsedCanonicalDate(
|
|
raw_value=canonical,
|
|
is_valid=False,
|
|
parse_type="unparsed",
|
|
error=seg_msg,
|
|
suggestion=sug,
|
|
)
|
|
|
|
# 1. Exact value (e.g. "143", "-5", "0")
|
|
if RE_EXACT.match(canonical):
|
|
val = int(canonical)
|
|
if is_age and val < 0:
|
|
return _invalid("Negative values (Before Hijra) are not allowed for age.", str(abs(val)))
|
|
is_bh = val < 0
|
|
parsed = ParsedCanonicalDate(
|
|
raw_value=canonical,
|
|
is_valid=True,
|
|
parse_type="exact",
|
|
value=val,
|
|
value_min=val,
|
|
value_max=val,
|
|
values_list=[val],
|
|
abs_value=abs(val),
|
|
is_bh=is_bh,
|
|
is_mixed=False,
|
|
context_key=None,
|
|
)
|
|
parsed.translations = build_translations_for_parsed(parsed, category=cat)
|
|
return parsed
|
|
|
|
# 2. Range (e.g. "170..193", "-3..5")
|
|
m_range = RE_RANGE.match(canonical)
|
|
if m_range:
|
|
v_min = int(m_range.group(1))
|
|
v_max = int(m_range.group(2))
|
|
if is_age and (v_min < 0 or v_max < 0):
|
|
return _invalid("Negative values are not allowed for age ranges.", f"{abs(v_min)}..{abs(v_max)}")
|
|
if v_min > v_max:
|
|
return _invalid(
|
|
f"Range minimum ({v_min}) cannot be greater than maximum ({v_max}).",
|
|
f"{v_max}..{v_min}",
|
|
)
|
|
is_mixed = (v_min < 0 and v_max >= 0)
|
|
is_bh = (v_min < 0 and not is_mixed)
|
|
parsed = ParsedCanonicalDate(
|
|
raw_value=canonical,
|
|
is_valid=True,
|
|
parse_type="range",
|
|
value=None,
|
|
value_min=v_min,
|
|
value_max=v_max,
|
|
values_list=[v_min, v_max],
|
|
abs_value=None,
|
|
is_bh=is_bh,
|
|
is_mixed=is_mixed,
|
|
context_key=None,
|
|
)
|
|
parsed.translations = build_translations_for_parsed(parsed, category=cat)
|
|
return parsed
|
|
|
|
# 3. Circa / Approximate (e.g. "~100", "~-10")
|
|
m_circa = RE_CIRCA.match(canonical)
|
|
if m_circa:
|
|
val = int(m_circa.group(1))
|
|
if is_age and val < 0:
|
|
return _invalid("Negative values are not allowed for age.", f"~{abs(val)}")
|
|
parsed = ParsedCanonicalDate(
|
|
raw_value=canonical,
|
|
is_valid=True,
|
|
parse_type="circa",
|
|
value=val,
|
|
value_min=val,
|
|
value_max=val,
|
|
values_list=[val],
|
|
abs_value=abs(val),
|
|
is_bh=val < 0,
|
|
is_mixed=False,
|
|
context_key=None,
|
|
)
|
|
parsed.translations = build_translations_for_parsed(parsed, category=cat)
|
|
return parsed
|
|
|
|
# 4. Greater than (e.g. ">80", ">-5")
|
|
m_gt = RE_GREATER.match(canonical)
|
|
if m_gt:
|
|
val = int(m_gt.group(1))
|
|
if is_age and val < 0:
|
|
return _invalid("Negative values are not allowed for age.", f">{abs(val)}")
|
|
parsed = ParsedCanonicalDate(
|
|
raw_value=canonical,
|
|
is_valid=True,
|
|
parse_type="greater_than",
|
|
value=val,
|
|
value_min=val + 1,
|
|
value_max=None,
|
|
values_list=[val],
|
|
abs_value=abs(val),
|
|
is_bh=val < 0,
|
|
is_mixed=False,
|
|
context_key=None,
|
|
_threshold=val,
|
|
)
|
|
parsed.translations = build_translations_for_parsed(parsed, category=cat)
|
|
return parsed
|
|
|
|
# 5. Less than (e.g. "<150", "<-10")
|
|
m_lt = RE_LESS.match(canonical)
|
|
if m_lt:
|
|
val = int(m_lt.group(1))
|
|
if is_age and val < 0:
|
|
return _invalid("Negative values are not allowed for age.", f"<{abs(val)}")
|
|
parsed = ParsedCanonicalDate(
|
|
raw_value=canonical,
|
|
is_valid=True,
|
|
parse_type="less_than",
|
|
value=val,
|
|
value_min=None,
|
|
value_max=val - 1,
|
|
values_list=[val],
|
|
abs_value=abs(val),
|
|
is_bh=val < 0,
|
|
is_mixed=False,
|
|
context_key=None,
|
|
_threshold=val,
|
|
)
|
|
parsed.translations = build_translations_for_parsed(parsed, category=cat)
|
|
return parsed
|
|
|
|
# 6. Alternatives (2 or 3+ exact numbers separated by ';', e.g. "131;140" or "131;140;142")
|
|
if RE_ALT_EXACT.match(canonical):
|
|
parts = [int(p) for p in canonical.split(";")]
|
|
if is_age and any(p < 0 for p in parts):
|
|
return _invalid(
|
|
"Negative values are not allowed for age alternatives.",
|
|
";".join(str(abs(p)) for p in parts),
|
|
)
|
|
v_type = "alternatives" if len(parts) == 2 else "alternatives_multi"
|
|
parsed = ParsedCanonicalDate(
|
|
raw_value=canonical,
|
|
is_valid=True,
|
|
parse_type=v_type,
|
|
value=None,
|
|
value_min=min(parts),
|
|
value_max=max(parts),
|
|
values_list=parts,
|
|
abs_value=None,
|
|
is_bh=min(parts) < 0,
|
|
is_mixed=False,
|
|
context_key=None,
|
|
_parts=parts,
|
|
)
|
|
parsed.translations = build_translations_for_parsed(parsed, category=cat)
|
|
return parsed
|
|
|
|
# 7. Alternatives Range (2 or 3+ ranges separated by ';', e.g. "75..76;68..69")
|
|
if RE_ALT_RANGE.match(canonical):
|
|
raw_ranges = canonical.split(";")
|
|
ranges: List[Tuple[int, int]] = []
|
|
all_nums: List[int] = []
|
|
for r_str in raw_ranges:
|
|
a_str, b_str = r_str.split("..")
|
|
a, b = int(a_str), int(b_str)
|
|
if is_age and (a < 0 or b < 0):
|
|
return _invalid("Negative values are not allowed for age ranges.")
|
|
if a > b:
|
|
return _invalid(f"Range minimum ({a}) cannot be greater than maximum ({b}) in '{r_str}'.")
|
|
ranges.append((a, b))
|
|
all_nums.extend([a, b])
|
|
|
|
v_type = "alternatives_range" if len(ranges) == 2 else "alternatives_range_multi"
|
|
parsed = ParsedCanonicalDate(
|
|
raw_value=canonical,
|
|
is_valid=True,
|
|
parse_type=v_type,
|
|
value=None,
|
|
value_min=min(all_nums),
|
|
value_max=max(all_nums),
|
|
values_list=all_nums,
|
|
abs_value=None,
|
|
is_bh=min(all_nums) < 0,
|
|
is_mixed=False,
|
|
context_key=None,
|
|
_ranges=ranges,
|
|
)
|
|
parsed.translations = build_translations_for_parsed(parsed, category=cat)
|
|
return parsed
|
|
|
|
# 8. Context tag (e.g. "#hijra_habasha")
|
|
m_ctx = RE_CONTEXT.match(canonical)
|
|
if m_ctx:
|
|
if is_age:
|
|
return _invalid("Context tags (#key) are only allowed for birth/death years, not for age.")
|
|
ctx_key = m_ctx.group(1).lower()
|
|
normalized_tag = f"#{ctx_key}"
|
|
parsed = ParsedCanonicalDate(
|
|
raw_value=normalized_tag,
|
|
is_valid=True,
|
|
parse_type="context",
|
|
value=None,
|
|
value_min=None,
|
|
value_max=None,
|
|
values_list=[],
|
|
abs_value=None,
|
|
is_bh=False,
|
|
is_mixed=False,
|
|
context_key=ctx_key,
|
|
)
|
|
parsed.translations = build_translations_for_parsed(parsed, category=cat)
|
|
return parsed
|
|
|
|
msg = (
|
|
f"Invalid canonical format '{canonical}'. "
|
|
"Allowed formats: exact (143, -5), range (170..193), circa (~100), "
|
|
"greater/less (>80, <150), alternatives (131;140), range alternatives (75..76;68..69), "
|
|
"or context (#hijra_habasha)."
|
|
)
|
|
return _invalid(msg)
|
|
|
|
|
|
def render_date_from_parsed(
|
|
parsed: Any,
|
|
date_type: str = "birth",
|
|
lang: str = "en",
|
|
translations_override: Optional[List[Dict[str, Any]]] = None,
|
|
) -> str:
|
|
"""
|
|
Renders the human-readable localized date/age string for a given parsed canonical object,
|
|
`date_type` ('birth', 'death', or 'age'), and language ('ru', 'en', 'ar').
|
|
"""
|
|
lang_code = normalize_lang_code(lang)
|
|
v_type = parsed["parse_type"] if isinstance(parsed, dict) and "parse_type" in parsed else parsed.get("type")
|
|
canonical_str = parsed.get("raw_value") or parsed.get("canonical") or ""
|
|
|
|
if translations_override:
|
|
for t in translations_override:
|
|
if isinstance(t, dict) and t.get("language_code") == lang_code:
|
|
txt = t.get("text") or t.get("title") or t.get("context_translation")
|
|
if txt:
|
|
return str(txt)
|
|
|
|
# 1. Context tag
|
|
if v_type == "context":
|
|
tag_key = canonical_str if canonical_str.startswith("#") else f"#{parsed.get('context_key', '')}"
|
|
default_ctx = DEFAULT_CONTEXT_TAGS.get(tag_key, {})
|
|
return default_ctx.get(lang_code, tag_key)
|
|
|
|
is_age = (date_type == "age")
|
|
|
|
# 2. Multi-item ready strings (3+ alternatives)
|
|
if v_type == "alternatives_multi":
|
|
parts = [str(p) for p in (parsed.get("_parts") or canonical_to_parts(canonical_str))]
|
|
joined = _join_multi_items(parts, lang_code)
|
|
if is_age:
|
|
age_unit = {"ar": "سنة", "ru": "лет", "en": "years"}[lang_code]
|
|
return f"{joined} {age_unit}"
|
|
return joined
|
|
|
|
if v_type == "alternatives_range_multi":
|
|
ranges = parsed.get("_ranges") or canonical_to_ranges(canonical_str)
|
|
if is_age:
|
|
age_items = [_format_single_range(a, b, lang_code, for_age=True, with_unit=False) for a, b in ranges]
|
|
joined_age = _join_multi_items(age_items, lang_code)
|
|
age_unit = {"ar": "سنة", "ru": "лет", "en": "years"}[lang_code]
|
|
return f"{joined_age} {age_unit}"
|
|
hijri_items = [_format_single_range(a, b, lang_code, for_age=False, with_unit=False) for a, b in ranges]
|
|
return _join_multi_items(hijri_items, lang_code)
|
|
|
|
# 3. Standard template rendering
|
|
tpl_tuple = DEFAULT_TEMPLATES.get(v_type, DEFAULT_TEMPLATES["exact"]).get(lang_code)
|
|
disp, disp_neg, disp_age = tpl_tuple if tpl_tuple else ("{value}", None, "{value}")
|
|
|
|
is_neg_or_mixed = bool(parsed.get("is_bh") or parsed.get("is_mixed"))
|
|
if is_age:
|
|
template = disp_age
|
|
elif is_neg_or_mixed:
|
|
template = disp_neg or disp
|
|
else:
|
|
template = disp
|
|
|
|
if not template:
|
|
return canonical_str
|
|
|
|
# Special case: range where both min and max are negative (e.g. -10..-5)
|
|
v_min = parsed.get("value_min")
|
|
v_max = parsed.get("value_max")
|
|
if v_type == "range" and not is_age and v_min is not None and v_max is not None and v_min < 0 and v_max < 0:
|
|
return _format_single_range(v_min, v_max, lang_code, for_age=False, with_unit=True)
|
|
|
|
abs_val = parsed.get("abs_value")
|
|
if v_type == "greater_than":
|
|
threshold = parsed.get("_threshold")
|
|
if threshold is None:
|
|
threshold = (v_min - 1) if v_min is not None else 0
|
|
sub_min = threshold
|
|
sub_abs_min = abs(threshold)
|
|
sub_max = v_max
|
|
sub_abs_max = abs(v_max) if v_max is not None else None
|
|
sub_val = threshold
|
|
elif v_type == "less_than":
|
|
threshold = parsed.get("_threshold")
|
|
if threshold is None:
|
|
threshold = (v_max + 1) if v_max is not None else 0
|
|
sub_max = threshold
|
|
sub_abs_max = abs(threshold)
|
|
sub_min = v_min
|
|
sub_abs_min = abs(v_min) if v_min is not None else None
|
|
sub_val = threshold
|
|
elif v_type == "alternatives":
|
|
parts = parsed.get("_parts") or canonical_to_parts(canonical_str)
|
|
sub_min = parts[0] if len(parts) > 0 else v_min
|
|
sub_max = parts[1] if len(parts) > 1 else v_max
|
|
sub_abs_min = abs(sub_min) if sub_min is not None else None
|
|
sub_abs_max = abs(sub_max) if sub_max is not None else None
|
|
sub_val = sub_min
|
|
else:
|
|
sub_min = v_min
|
|
sub_max = v_max
|
|
sub_abs_min = abs(v_min) if v_min is not None else None
|
|
sub_abs_max = abs(v_max) if v_max is not None else None
|
|
sub_val = v_min if v_min is not None else v_max
|
|
|
|
range1_str = ""
|
|
range2_str = ""
|
|
if v_type == "alternatives_range":
|
|
ranges = parsed.get("_ranges") or canonical_to_ranges(canonical_str)
|
|
if len(ranges) >= 2:
|
|
if is_age:
|
|
range1_str = _format_single_range(ranges[0][0], ranges[0][1], lang_code, for_age=True, with_unit=False)
|
|
range2_str = _format_single_range(ranges[1][0], ranges[1][1], lang_code, for_age=True, with_unit=False)
|
|
else:
|
|
range1_str = _format_single_range(ranges[0][0], ranges[0][1], lang_code, for_age=False, with_unit=True)
|
|
range2_str = _format_single_range(ranges[1][0], ranges[1][1], lang_code, for_age=False, with_unit=True)
|
|
|
|
return template.format(
|
|
value=sub_val if sub_val is not None else "",
|
|
abs_value=abs_val if abs_val is not None else (abs(sub_val) if sub_val is not None else ""),
|
|
min=sub_min if sub_min is not None else "",
|
|
max=sub_max if sub_max is not None else "",
|
|
abs_min=sub_abs_min if sub_abs_min is not None else "",
|
|
abs_max=sub_abs_max if sub_abs_max is not None else "",
|
|
range1=range1_str,
|
|
range2=range2_str,
|
|
)
|
|
|
|
|
|
def build_translations_for_parsed(
|
|
parsed: Any,
|
|
category: str = "birth",
|
|
custom_context_translations: Optional[Dict[str, str]] = None,
|
|
) -> List[Dict[str, str]]:
|
|
"""
|
|
Generates the 3 language translation objects (ru, en, ar) in standard
|
|
`[{"language_code": "ru", "text": "..."}, ...]` format stored on `YearValue.translations`.
|
|
"""
|
|
translations: List[Dict[str, str]] = []
|
|
v_type = parsed.get("parse_type") or parsed.get("type")
|
|
for lang in SUPPORTED_LANGS:
|
|
if v_type == "context" and custom_context_translations and custom_context_translations.get(lang):
|
|
txt = custom_context_translations[lang]
|
|
else:
|
|
txt = render_date_from_parsed(parsed, date_type=category, lang=lang)
|
|
translations.append({
|
|
"language_code": lang,
|
|
"text": txt,
|
|
})
|
|
return translations
|
|
|
|
|
|
def format_canonical_date(
|
|
raw_value: Any,
|
|
category: str = "birth",
|
|
lang: str = "ru",
|
|
date_type: Optional[str] = None,
|
|
) -> Optional[str]:
|
|
"""
|
|
Formats a canonical date/age string into the requested language ('ru', 'en', 'ar').
|
|
Returns raw string as fallback if unparseable.
|
|
"""
|
|
if raw_value is None or str(raw_value).strip() == "":
|
|
return None
|
|
cat = date_type or category or "birth"
|
|
raw_str = str(raw_value).strip()
|
|
parsed = parse_canonical_date(raw_str, category=cat)
|
|
if not parsed.is_valid:
|
|
return raw_str
|
|
return render_date_from_parsed(parsed, date_type=cat, lang=lang)
|
|
|
|
|
|
def format_canonical_for_all_langs(
|
|
raw_value: Any,
|
|
date_type: str = "birth",
|
|
translations_override: Optional[List[Dict[str, Any]]] = None,
|
|
) -> Dict[str, str]:
|
|
"""
|
|
Returns a dictionary of formatted strings for all supported languages: {'ru': ..., 'en': ..., 'ar': ...}.
|
|
"""
|
|
if raw_value is None or str(raw_value).strip() == "":
|
|
return {}
|
|
raw_str = str(raw_value).strip()
|
|
parsed = parse_canonical_date(raw_str, category=date_type)
|
|
if not parsed.is_valid:
|
|
return {lang: raw_str for lang in SUPPORTED_LANGS}
|
|
return {
|
|
lang: render_date_from_parsed(parsed, date_type=date_type, lang=lang, translations_override=translations_override)
|
|
for lang in SUPPORTED_LANGS
|
|
}
|