forked from Yara724/api
197 lines
4.9 KiB
TypeScript
197 lines
4.9 KiB
TypeScript
/**
|
||
* Converts an ISO date string or Date to Jalali (Persian) date and time.
|
||
* @param input - ISO date string (e.g. 2026-02-08T13:51:20.747+00:00) or Date
|
||
* @returns Tuple [dateStr, timeStr] e.g. ['1404/11/24', '13:37']
|
||
*/
|
||
export function toJalaliDateAndTime(input: string | Date): [string, string] {
|
||
const d = new Date(input);
|
||
const dateStr = toJalaliDate(d);
|
||
const timeStr = toJalaliTime(d);
|
||
return [dateStr, timeStr];
|
||
}
|
||
|
||
/**
|
||
* Converts a Jalali (Persian) date in any of the common shapes used across
|
||
* this codebase to a Gregorian `YYYY-MM-DD` string.
|
||
*
|
||
* Accepted input shapes:
|
||
* - number `13770624` (packed `YYYYMMDD`)
|
||
* - string `"13770624"` / `"1377-06-24"` / `"1377/06/24"` / `"1377/6/24"`
|
||
* - already-Gregorian `YYYY-MM-DD` strings are returned untouched as long as
|
||
* they parse (we treat them as Gregorian only when the year is clearly
|
||
* Gregorian, i.e. >= 1900). Otherwise the year is treated as Jalali.
|
||
*
|
||
* Returns `null` when the input cannot be parsed into a valid Jalali date.
|
||
*/
|
||
export function jalaliToGregorianDate(
|
||
input: string | number | null | undefined,
|
||
): string | null {
|
||
if (input === null || input === undefined) return null;
|
||
|
||
const raw = typeof input === "number" ? String(input) : String(input).trim();
|
||
if (!raw) return null;
|
||
|
||
let year = 0;
|
||
let month = 0;
|
||
let day = 0;
|
||
|
||
const separated = raw.match(/^(\d{4})[\-/](\d{1,2})[\-/](\d{1,2})$/);
|
||
if (separated) {
|
||
year = parseInt(separated[1], 10);
|
||
month = parseInt(separated[2], 10);
|
||
day = parseInt(separated[3], 10);
|
||
} else {
|
||
const digits = raw.replace(/\D/g, "");
|
||
if (digits.length === 8) {
|
||
year = parseInt(digits.slice(0, 4), 10);
|
||
month = parseInt(digits.slice(4, 6), 10);
|
||
day = parseInt(digits.slice(6, 8), 10);
|
||
} else {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
if (!year || !month || !day) return null;
|
||
|
||
// If the year already looks Gregorian (>= 1900), just normalise formatting.
|
||
if (year >= 1900) {
|
||
const mm = String(month).padStart(2, "0");
|
||
const dd = String(day).padStart(2, "0");
|
||
const d = new Date(`${year}-${mm}-${dd}`);
|
||
if (isNaN(d.getTime())) return null;
|
||
return `${year}-${mm}-${dd}`;
|
||
}
|
||
|
||
return jalaliPartsToGregorian(year, month, day);
|
||
}
|
||
|
||
/**
|
||
* Converts a Date to Jalali date string (e.g. 1404/11/24).
|
||
*/
|
||
export function toJalaliDate(d: Date): string {
|
||
const persianDate = d.toLocaleDateString("fa-IR", {
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
});
|
||
return persianDigitsToEnglish(persianDate, "/");
|
||
}
|
||
|
||
/**
|
||
* Converts a Date to 24h time string (e.g. 13:37).
|
||
*/
|
||
export function toJalaliTime(d: Date): string {
|
||
const hours = d.getHours().toString().padStart(2, "0");
|
||
const minutes = d.getMinutes().toString().padStart(2, "0");
|
||
return `${hours}:${minutes}`;
|
||
}
|
||
|
||
/**
|
||
* Converts Jalali year/month/day parts to a Gregorian `YYYY-MM-DD` string
|
||
* using the algorithmic conversion (no external deps).
|
||
* Returns `null` if the resulting Gregorian date is invalid.
|
||
*/
|
||
function jalaliPartsToGregorian(
|
||
jYear: number,
|
||
jMonth: number,
|
||
jDay: number,
|
||
): string | null {
|
||
const jy = jYear - 979;
|
||
const jm = jMonth - 1;
|
||
const jd = jDay - 1;
|
||
|
||
// Jalali day number — correct leap cycle is every 33 years with 8 leaps
|
||
let jDayNo =
|
||
365 * jy + Math.floor(jy / 33) * 8 + Math.floor(((jy % 33) + 3) / 4);
|
||
|
||
const jalaliMonthDays = [31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29];
|
||
for (let i = 0; i < jm; i++) {
|
||
jDayNo += jalaliMonthDays[i];
|
||
}
|
||
jDayNo += jd;
|
||
|
||
// Offset to Gregorian day number anchored at 1600
|
||
let gDayNo = jDayNo + 79;
|
||
|
||
let gy = 1600 + 400 * Math.floor(gDayNo / 146097);
|
||
gDayNo %= 146097;
|
||
|
||
let leap = true;
|
||
if (gDayNo >= 36525) {
|
||
gDayNo--;
|
||
gy += 100 * Math.floor(gDayNo / 36524);
|
||
gDayNo %= 36524;
|
||
if (gDayNo >= 365) gDayNo++;
|
||
else leap = false;
|
||
}
|
||
|
||
gy += 4 * Math.floor(gDayNo / 1461);
|
||
gDayNo %= 1461;
|
||
|
||
if (gDayNo >= 366) {
|
||
leap = false;
|
||
gDayNo--;
|
||
gy += Math.floor(gDayNo / 365);
|
||
gDayNo %= 365;
|
||
}
|
||
|
||
const gregorianMonthDays = [
|
||
31,
|
||
leap ? 29 : 28,
|
||
31,
|
||
30,
|
||
31,
|
||
30,
|
||
31,
|
||
31,
|
||
30,
|
||
31,
|
||
30,
|
||
31,
|
||
];
|
||
|
||
let gm = 0;
|
||
for (let i = 0; i < 12; i++) {
|
||
if (gDayNo < gregorianMonthDays[i]) {
|
||
gm = i + 1;
|
||
break;
|
||
}
|
||
gDayNo -= gregorianMonthDays[i];
|
||
}
|
||
const gd = gDayNo + 1;
|
||
|
||
const mm = String(gm).padStart(2, "0");
|
||
const dd = String(gd).padStart(2, "0");
|
||
const result = `${gy}-${mm}-${dd}`;
|
||
|
||
const check = new Date(result);
|
||
if (isNaN(check.getTime())) return null;
|
||
|
||
return result;
|
||
}
|
||
|
||
const PERSIAN_TO_ENGLISH: Record<string, string> = {
|
||
"۰": "0",
|
||
"۱": "1",
|
||
"۲": "2",
|
||
"۳": "3",
|
||
"۴": "4",
|
||
"۵": "5",
|
||
"۶": "6",
|
||
"۷": "7",
|
||
"۸": "8",
|
||
"۹": "9",
|
||
};
|
||
|
||
function persianDigitsToEnglish(str: string, separator: string): string {
|
||
return str
|
||
.split(separator)
|
||
.map((part) =>
|
||
part
|
||
.split("")
|
||
.map((char) => PERSIAN_TO_ENGLISH[char] ?? char)
|
||
.join(""),
|
||
)
|
||
.join("/");
|
||
}
|