196 lines
6.1 KiB
Python
196 lines
6.1 KiB
Python
"""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
|