"""Jalali (Shamsi) ↔ Gregorian conversion — the ONLY date-conversion module. Ported from the well-known `jalaali-js` algorithm (MIT), dependency-free. Design notes: - Conversions work on plain (year, month, day) integers — no timezone or clock involvement at all, which is what makes date-only fields safe (no off-by-one-day bugs). - PM stores standard ISO dates (Gregorian) and converts only at the boundaries: parsing user input (Jalali → ISO) and rendering responses (`*_jalali` convenience fields). The frontend has its own mirrored copy of this algorithm in `frontend/src/lib/jalali.ts` for pickers/display. """ from __future__ import annotations import datetime as _dt import re _BREAKS = [ -61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178, ] def _div(a: int, b: int) -> int: """Truncating integer division (matches the JS reference implementation).""" q = abs(a) // abs(b) return q if (a >= 0) == (b >= 0) else -q def _mod(a: int, b: int) -> int: """Remainder with the sign of the dividend (JS `%` semantics).""" return a - _div(a, b) * b def _jal_cal(jy: int, without_leap: bool = False) -> tuple[int, int, int]: """(leap, gy, march) for a Jalali year.""" bl = len(_BREAKS) gy = jy + 621 leap_j = -14 jp = _BREAKS[0] jump = 0 if jy < jp or jy >= _BREAKS[bl - 1]: raise ValueError(f"سال جلالی نامعتبر: {jy}") for i in range(1, bl): jm = _BREAKS[i] jump = jm - jp if jy < jm: break leap_j += _div(jump, 33) * 8 + _div(_mod(jump, 33), 4) jp = jm n = jy - jp leap_j += _div(n, 33) * 8 + _div(_mod(n, 33) + 3, 4) if _mod(jump, 33) == 4 and jump - n == 4: leap_j += 1 leap_g = _div(gy, 4) - _div((_div(gy, 100) + 1) * 3, 4) - 150 march = 20 + leap_j - leap_g leap = 0 if not without_leap: if jump - n < 6: n = n - jump + _div(jump + 4, 33) * 33 leap = _mod(_mod(n + 1, 33) - 1, 4) if leap == -1: leap = 4 return leap, gy, march def _g2d(gy: int, gm: int, gd: int) -> int: """Gregorian date → Julian Day Number.""" d = ( _div((gy + _div(gm - 8, 6) + 100100) * 1461, 4) + _div(153 * _mod(gm + 9, 12) + 2, 5) + gd - 34840408 ) d = d - _div(_div(gy + 100100 + _div(gm - 8, 6), 100) * 3, 4) + 752 return d def _d2g(jdn: int) -> tuple[int, int, int]: """Julian Day Number → Gregorian date.""" j = 4 * jdn + 139361631 j = j + _div(_div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908 i = _div(_mod(j, 1461), 4) * 5 + 308 gd = _div(_mod(i, 153), 5) + 1 gm = _mod(_div(i, 153), 12) + 1 gy = _div(j, 1461) - 100100 + _div(8 - gm, 6) return gy, gm, gd def _j2d(jy: int, jm: int, jd: int) -> int: """Jalali date → Julian Day Number.""" _, gy, march = _jal_cal(jy, True) return _g2d(gy, 3, march) + (jm - 1) * 31 - _div(jm, 7) * (jm - 7) + jd - 1 def _d2j(jdn: int) -> tuple[int, int, int]: """Julian Day Number → Jalali date.""" gy, _, _ = _d2g(jdn) jy = gy - 621 leap, _, march = _jal_cal(jy, False) jdn1f = _g2d(gy, 3, march) k = jdn - jdn1f if k >= 0: if k <= 185: return jy, 1 + _div(k, 31), _mod(k, 31) + 1 k -= 186 else: jy -= 1 k += 179 if leap == 1: k += 1 return jy, 7 + _div(k, 30), _mod(k, 30) + 1 # --- public API --------------------------------------------------------------- def gregorian_to_jalali(value: _dt.date) -> tuple[int, int, int]: """date(2026, 9, 14) → (1405, 6, 23)""" return _d2j(_g2d(value.year, value.month, value.day)) def is_leap_jalali(jy: int) -> bool: return _jal_cal(jy, False)[0] == 0 def days_in_month(jy: int, jm: int) -> int: if jm <= 6: return 31 if jm <= 11: return 30 return 30 if is_leap_jalali(jy) else 29 def jalali_to_gregorian(jy: int, jm: int, jd: int) -> _dt.date: """(1405, 6, 23) → date(2026, 9, 14)""" if not 1 <= jm <= 12: raise ValueError(f"ماه جلالی نامعتبر است: {jm}") if not 1 <= jd <= days_in_month(jy, jm): raise ValueError(f"روز جلالی نامعتبر است: {jy}/{jm:02d}/{jd:02d}") gy, gm, gd = _d2g(_j2d(jy, jm, jd)) return _dt.date(gy, gm, gd) def to_jalali_str(value: _dt.date | None, sep: str = "/") -> str | None: """date → «1405/06/23» (Latin digits, zero-padded).""" if value is None: return None jy, jm, jd = gregorian_to_jalali(value) return f"{jy:04d}{sep}{jm:02d}{sep}{jd:02d}" _ISO_RE = re.compile(r"^(\d{4})-(\d{1,2})-(\d{1,2})$") _JALALI_RE = re.compile(r"^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$") _JALALI_COMPACT_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})$") # Gregorian years PM can plausibly see; anything lower is treated as Jalali. _GREGORIAN_MIN_YEAR = 1700 def parse_flexible_date(value: str | None) -> _dt.date | None: """Accept ISO (`2026-09-14`), Jalali (`1405/06/23`, `1405-6-23`) or Jalali compact (`14050623`) and return a Gregorian date. Disambiguation: a 4-digit year >= 1700 is Gregorian, otherwise Jalali. ISO datetime strings (`2026-09-14T10:00:00Z`) are truncated to the date. """ if value is None: return None text = str(value).strip() if not text: return None # ISO datetime → date part if "T" in text: text = text.split("T", 1)[0] match = _ISO_RE.match(text) if match: year, month, day = (int(g) for g in match.groups()) if year >= _GREGORIAN_MIN_YEAR: return _dt.date(year, month, day) match = _JALALI_RE.match(text) or _JALALI_COMPACT_RE.match(text) if match: jy, jm, jd = (int(g) for g in match.groups()) if jy >= _GREGORIAN_MIN_YEAR: # e.g. 2026/09/14 written with slashes return _dt.date(jy, jm, jd) return jalali_to_gregorian(jy, jm, jd) try: return _dt.date.fromisoformat(text) except ValueError as exc: raise ValueError(f"قالب تاریخ نامعتبر است: {value}") from exc