. */ declare(strict_types=1); namespace Fisharebest\Webtrees\Date; use Fisharebest\ExtCalendar\CalendarInterface; use Fisharebest\ExtCalendar\JewishCalendar; use Fisharebest\Webtrees\Carbon; use Fisharebest\Webtrees\I18N; use Fisharebest\Webtrees\Tree; use InvalidArgumentException; use function array_key_exists; use function array_search; use function get_class; use function intdiv; use function is_array; use function is_int; use function max; use function preg_match; use function preg_match_all; use function preg_replace; use function route; use function sprintf; use function str_replace; use function strpbrk; use function trim; use function view; /** * Classes for Gedcom Date/Calendar functionality. * * CalendarDate is a base class for classes such as GregorianDate, etc. * + All supported calendars have non-zero days/months/years. * + We store dates as both Y/M/D and Julian Days. * + For imprecise dates such as "JAN 2000" we store the start/end julian day. * * NOTE: Since different calendars start their days at different times, (civil * midnight, solar midnight, sunset, sunrise, etc.), we convert on the basis of * midday. */ abstract class AbstractCalendarDate { // GEDCOM calendar escape public const ESCAPE = '@#DUNKNOWN@'; // Convert GEDCOM month names to month numbers. protected const MONTH_ABBREVIATIONS = []; /** @var CalendarInterface The calendar system used to represent this date */ protected $calendar; /** @var int Year number */ public $year; /** @var int Month number */ public $month; /** @var int Day number */ public $day; /** @var int Earliest Julian day number (start of month/year for imprecise dates) */ private $minimum_julian_day; /** @var int Latest Julian day number (end of month/year for imprecise dates) */ private $maximum_julian_day; /** * Create a date from either: * a Julian day number * day/month/year strings from a GEDCOM date * another CalendarDate object * * @param array|int|AbstractCalendarDate $date */ protected function __construct($date) { // Construct from an integer (a julian day number) if (is_int($date)) { $this->minimum_julian_day = $date; $this->maximum_julian_day = $date; [$this->year, $this->month, $this->day] = $this->calendar->jdToYmd($date); return; } // Construct from an array (of three gedcom-style strings: "1900", "FEB", "4") if (is_array($date)) { $this->day = (int) $date[2]; if (array_key_exists($date[1], static::MONTH_ABBREVIATIONS)) { $this->month = static::MONTH_ABBREVIATIONS[$date[1]]; } else { $this->month = 0; $this->day = 0; } $this->year = $this->extractYear($date[0]); // Our simple lookup table above does not take into account Adar and leap-years. if ($this->month === 6 && $this->calendar instanceof JewishCalendar && !$this->calendar->isLeapYear($this->year)) { $this->month = 7; } $this->setJdFromYmd(); return; } // Contruct from a CalendarDate $this->minimum_julian_day = $date->minimum_julian_day; $this->maximum_julian_day = $date->maximum_julian_day; // Construct from an equivalent xxxxDate object if (get_class($this) === get_class($date)) { $this->year = $date->year; $this->month = $date->month; $this->day = $date->day; return; } // Not all dates can be converted if (!$this->inValidRange()) { $this->year = 0; $this->month = 0; $this->day = 0; return; } // ...else construct an inequivalent xxxxDate object if ($date->year === 0) { // Incomplete date - convert on basis of anniversary in current year $today = $date->calendar->jdToYmd(Carbon::now()->julianDay()); $jd = $date->calendar->ymdToJd($today[0], $date->month, $date->day === 0 ? $today[2] : $date->day); } else { // Complete date $jd = intdiv($date->maximum_julian_day + $date->minimum_julian_day, 2); } [$this->year, $this->month, $this->day] = $this->calendar->jdToYmd($jd); // New date has same precision as original date if ($date->year === 0) { $this->year = 0; } if ($date->month === 0) { $this->month = 0; } if ($date->day === 0) { $this->day = 0; } $this->setJdFromYmd(); } /** * @return int */ public function maximumJulianDay(): int { return $this->maximum_julian_day; } /** * @return int */ public function year(): int { return $this->year; } /** * @return int */ public function month(): int { return $this->month; } /** * @return int */ public function day(): int { return $this->day; } /** * @return int */ public function minimumJulianDay(): int { return $this->minimum_julian_day; } /** * Is the current year a leap year? * * @return bool */ public function isLeapYear(): bool { return $this->calendar->isLeapYear($this->year); } /** * Set the object’s Julian day number from a potentially incomplete year/month/day * * @return void */ public function setJdFromYmd(): void { if ($this->year === 0) { $this->minimum_julian_day = 0; $this->maximum_julian_day = 0; } elseif ($this->month === 0) { $this->minimum_julian_day = $this->calendar->ymdToJd($this->year, 1, 1); $this->maximum_julian_day = $this->calendar->ymdToJd($this->nextYear($this->year), 1, 1) - 1; } elseif ($this->day === 0) { [$ny, $nm] = $this->nextMonth(); $this->minimum_julian_day = $this->calendar->ymdToJd($this->year, $this->month, 1); $this->maximum_julian_day = $this->calendar->ymdToJd($ny, $nm, 1) - 1; } else { $this->minimum_julian_day = $this->calendar->ymdToJd($this->year, $this->month, $this->day); $this->maximum_julian_day = $this->minimum_julian_day; } } /** * Full day of the week * * @param int $day_number * * @return string */ public function dayNames(int $day_number): string { static $translated_day_names; if ($translated_day_names === null) { $translated_day_names = [ 0 => I18N::translate('Monday'), 1 => I18N::translate('Tuesday'), 2 => I18N::translate('Wednesday'), 3 => I18N::translate('Thursday'), 4 => I18N::translate('Friday'), 5 => I18N::translate('Saturday'), 6 => I18N::translate('Sunday'), ]; } return $translated_day_names[$day_number]; } /** * Abbreviated day of the week * * @param int $day_number * * @return string */ protected function dayNamesAbbreviated(int $day_number): string { static $translated_day_names; if ($translated_day_names === null) { $translated_day_names = [ /* I18N: abbreviation for Monday */ 0 => I18N::translate('Mon'), /* I18N: abbreviation for Tuesday */ 1 => I18N::translate('Tue'), /* I18N: abbreviation for Wednesday */ 2 => I18N::translate('Wed'), /* I18N: abbreviation for Thursday */ 3 => I18N::translate('Thu'), /* I18N: abbreviation for Friday */ 4 => I18N::translate('Fri'), /* I18N: abbreviation for Saturday */ 5 => I18N::translate('Sat'), /* I18N: abbreviation for Sunday */ 6 => I18N::translate('Sun'), ]; } return $translated_day_names[$day_number]; } /** * Most years are 1 more than the previous, but not always (e.g. 1BC->1AD) * * @param int $year * * @return int */ protected function nextYear(int $year): int { return $year + 1; } /** * Calendars that use suffixes, etc. (e.g. “B.C.”) or OS/NS notation should redefine this. * * @param string $year * * @return int */ protected function extractYear(string $year): int { return (int) $year; } /** * Compare two dates, for sorting * * @param AbstractCalendarDate $d1 * @param AbstractCalendarDate $d2 * * @return int */ public static function compare(AbstractCalendarDate $d1, AbstractCalendarDate $d2): int { if ($d1->maximum_julian_day < $d2->minimum_julian_day) { return -1; } if ($d2->maximum_julian_day < $d1->minimum_julian_day) { return 1; } return 0; } /** * Calculate the years/months/days between this date and another date. * Results assume you add the days first, then the months. * 4 February -> 3 July is 27 days (3 March) and 4 months. * It is not 4 months (4 June) and 29 days. * * @param AbstractCalendarDate $date * * @return int[] Age in years/months/days */ public function ageDifference(AbstractCalendarDate $date): array { // Incomplete dates if ($this->year === 0 || $date->year === 0) { return [-1, -1, -1]; } // Overlapping dates if (self::compare($this, $date) === 0) { return [0, 0, 0]; } // Perform all calculations using the calendar of the first date [$year1, $month1, $day1] = $this->calendar->jdToYmd($this->minimum_julian_day); [$year2, $month2, $day2] = $this->calendar->jdToYmd($date->minimum_julian_day); $years = $year2 - $year1; $months = $month2 - $month1; $days = $day2 - $day1; if ($days < 0) { $days += $this->calendar->daysInMonth($year1, $month1); $months--; } if ($months < 0) { $months += $this->calendar->monthsInYear($year2); $years--; } return [$years, $months, $days]; } /** * How long between an event and a given julian day * Return result as a number of years. * * @param int $jd date for calculation * * @return int */ public function getAge(int $jd): int { if ($this->year === 0 || $jd === 0) { return 0; } if ($this->minimum_julian_day < $jd && $this->maximum_julian_day > $jd) { return 0; } if ($this->minimum_julian_day === $jd) { return 0; } [$y, $m, $d] = $this->calendar->jdToYmd($jd); $dy = $y - $this->year; $dm = $m - max($this->month, 1); $dd = $d - max($this->day, 1); if ($dd < 0) { $dm--; } if ($dm < 0) { $dy--; } // Not a full age? Then just the years return $dy; } /** * How long between an event and a given julian day * Return result as a gedcom-style age string. * * @param int $jd date for calculation * * @return string */ public function getAgeFull(int $jd): string { if ($this->year === 0 || $jd === 0) { return ''; } if ($this->minimum_julian_day < $jd && $this->maximum_julian_day > $jd) { return ''; } if ($this->minimum_julian_day === $jd) { return ''; } if ($jd < $this->minimum_julian_day) { return view('icons/warning'); } [$y, $m, $d] = $this->calendar->jdToYmd($jd); $dy = $y - $this->year; $dm = $m - max($this->month, 1); $dd = $d - max($this->day, 1); if ($dd < 0) { $dm--; } if ($dm < 0) { $dm += $this->calendar->monthsInYear(); $dy--; } // Age in years? if ($dy > 1) { return $dy . 'y'; } $dm += $dy * $this->calendar->monthsInYear(); // Age in months? if ($dm > 1) { return $dm . 'm'; } // Age in days? return ($jd - $this->minimum_julian_day) . 'd'; } /** * Convert a date from one calendar to another. * * @param string $calendar * * @return AbstractCalendarDate */ public function convertToCalendar(string $calendar): AbstractCalendarDate { switch ($calendar) { case 'gregorian': return new GregorianDate($this); case 'julian': return new JulianDate($this); case 'jewish': return new JewishDate($this); case 'french': return new FrenchDate($this); case 'hijri': return new HijriDate($this); case 'jalali': return new JalaliDate($this); default: return $this; } } /** * Is this date within the valid range of the calendar * * @return bool */ public function inValidRange(): bool { return $this->minimum_julian_day >= $this->calendar->jdStart() && $this->maximum_julian_day <= $this->calendar->jdEnd(); } /** * How many months in a year * * @return int */ public function monthsInYear(): int { return $this->calendar->monthsInYear(); } /** * How many days in the current month * * @return int */ public function daysInMonth(): int { try { return $this->calendar->daysInMonth($this->year, $this->month); } catch (InvalidArgumentException $ex) { // calendar.php calls this with "DD MMM" dates, for which we cannot calculate // the length of a month. Should we validate this before calling this function? return 0; } } /** * How many days in the current week * * @return int */ public function daysInWeek(): int { return $this->calendar->daysInWeek(); } /** * Format a date, using similar codes to the PHP date() function. * * @param string $format See http://php.net/date * @param string $qualifier GEDCOM qualifier, so we can choose the right case for the month name. * * @return string */ public function format(string $format, string $qualifier = ''): string { // Dates can include additional punctuation and symbols. // e.g. "%Y年 %n月 %j日", "Y.M.D" and "M D, Y". // The logic here is inflexible, and should be replaced with // specific translations for each abbreviated format. // Don’t show exact details for inexact dates if (!$this->day) { $format = preg_replace('/%[djlDNSwz][,日]?/u', '', $format); $format = str_replace(['%d,', '%j日', '%j,', '%j', '%l', '%D', '%N', '%S', '%w', '%z'], '', $format); } if (!$this->month) { $format = str_replace(['%F', '%m', '%M', '年 %n月', '%n', '%t'], '', $format); } if (!$this->year) { $format = str_replace(['%t', '%L', '%G', '%y', '%Y年', '%Y'], '', $format); } // If we’ve trimmed the format, also trim the punctuation if (!$this->day || !$this->month || !$this->year) { $format = trim($format, ',. ;/-'); } if ($this->day && preg_match('/%[djlDNSwz]/', $format)) { // If we have a day-number *and* we are being asked to display it, then genitive $case = 'GENITIVE'; } else { switch ($qualifier) { case 'TO': case 'ABT': case 'FROM': $case = 'GENITIVE'; break; case 'AFT': $case = 'LOCATIVE'; break; case 'BEF': case 'BET': case 'AND': $case = 'INSTRUMENTAL'; break; case '': case 'INT': case 'EST': case 'CAL': default: // There shouldn't be any other options... $case = 'NOMINATIVE'; break; } } // Build up the formatted date, character at a time preg_match_all('/%[^%]/', $format, $matches); foreach ($matches[0] as $match) { switch ($match) { case '%d': $format = str_replace($match, $this->formatDayZeros(), $format); break; case '%j': $format = str_replace($match, $this->formatDay(), $format); break; case '%l': $format = str_replace($match, $this->formatLongWeekday(), $format); break; case '%D': $format = str_replace($match, $this->formatShortWeekday(), $format); break; case '%N': $format = str_replace($match, $this->formatIsoWeekday(), $format); break; case '%w': $format = str_replace($match, $this->formatNumericWeekday(), $format); break; case '%z': $format = str_replace($match, $this->formatDayOfYear(), $format); break; case '%F': $format = str_replace($match, $this->formatLongMonth($case), $format); break; case '%m': $format = str_replace($match, $this->formatMonthZeros(), $format); break; case '%M': $format = str_replace($match, $this->formatShortMonth(), $format); break; case '%n': $format = str_replace($match, $this->formatMonth(), $format); break; case '%t': $format = str_replace($match, (string) $this->daysInMonth(), $format); break; case '%L': $format = str_replace($match, $this->isLeapYear() ? '1' : '0', $format); break; case '%Y': $format = str_replace($match, $this->formatLongYear(), $format); break; case '%y': $format = str_replace($match, $this->formatShortYear(), $format); break; // These 4 extensions are useful for re-formatting gedcom dates. case '%@': $format = str_replace($match, $this->formatGedcomCalendarEscape(), $format); break; case '%A': $format = str_replace($match, $this->formatGedcomDay(), $format); break; case '%O': $format = str_replace($match, $this->formatGedcomMonth(), $format); break; case '%E': $format = str_replace($match, $this->formatGedcomYear(), $format); break; } } return $format; } /** * Generate the %d format for a date. * * @return string */ protected function formatDayZeros(): string { if ($this->day > 9) { return I18N::digits($this->day); } return I18N::digits('0' . $this->day); } /** * Generate the %j format for a date. * * @return string */ protected function formatDay(): string { return I18N::digits($this->day); } /** * Generate the %l format for a date. * * @return string */ protected function formatLongWeekday(): string { return $this->dayNames($this->minimum_julian_day % $this->calendar->daysInWeek()); } /** * Generate the %D format for a date. * * @return string */ protected function formatShortWeekday(): string { return $this->dayNamesAbbreviated($this->minimum_julian_day % $this->calendar->daysInWeek()); } /** * Generate the %N format for a date. * * @return string */ protected function formatIsoWeekday(): string { return I18N::digits($this->minimum_julian_day % 7 + 1); } /** * Generate the %w format for a date. * * @return string */ protected function formatNumericWeekday(): string { return I18N::digits(($this->minimum_julian_day + 1) % $this->calendar->daysInWeek()); } /** * Generate the %z format for a date. * * @return string */ protected function formatDayOfYear(): string { return I18N::digits($this->minimum_julian_day - $this->calendar->ymdToJd($this->year, 1, 1)); } /** * Generate the %n format for a date. * * @return string */ protected function formatMonth(): string { return I18N::digits($this->month); } /** * Generate the %m format for a date. * * @return string */ protected function formatMonthZeros(): string { if ($this->month > 9) { return I18N::digits($this->month); } return I18N::digits('0' . $this->month); } /** * Generate the %F format for a date. * * @param string $case Which grammatical case shall we use * * @return string */ protected function formatLongMonth($case = 'NOMINATIVE'): string { switch ($case) { case 'GENITIVE': return $this->monthNameGenitiveCase($this->month, $this->isLeapYear()); case 'NOMINATIVE': return $this->monthNameNominativeCase($this->month, $this->isLeapYear()); case 'LOCATIVE': return $this->monthNameLocativeCase($this->month, $this->isLeapYear()); case 'INSTRUMENTAL': return $this->monthNameInstrumentalCase($this->month, $this->isLeapYear()); default: throw new InvalidArgumentException($case); } } /** * Full month name in genitive case. * * @param int $month * @param bool $leap_year Some calendars use leap months * * @return string */ abstract protected function monthNameGenitiveCase(int $month, bool $leap_year): string; /** * Full month name in nominative case. * * @param int $month * @param bool $leap_year Some calendars use leap months * * @return string */ abstract protected function monthNameNominativeCase(int $month, bool $leap_year): string; /** * Full month name in locative case. * * @param int $month * @param bool $leap_year Some calendars use leap months * * @return string */ abstract protected function monthNameLocativeCase(int $month, bool $leap_year): string; /** * Full month name in instrumental case. * * @param int $month * @param bool $leap_year Some calendars use leap months * * @return string */ abstract protected function monthNameInstrumentalCase(int $month, bool $leap_year): string; /** * Abbreviated month name * * @param int $month * @param bool $leap_year Some calendars use leap months * * @return string */ abstract protected function monthNameAbbreviated(int $month, bool $leap_year): string; /** * Generate the %M format for a date. * * @return string */ protected function formatShortMonth(): string { return $this->monthNameAbbreviated($this->month, $this->isLeapYear()); } /** * Generate the %y format for a date. * NOTE Short year is NOT a 2-digit year. It is for calendars such as hebrew * which have a 3-digit form of 4-digit years. * * @return string */ protected function formatShortYear(): string { return $this->formatLongYear(); } /** * Generate the %A format for a date. * * @return string */ protected function formatGedcomDay(): string { if ($this->day === 0) { return ''; } return sprintf('%02d', $this->day); } /** * Generate the %O format for a date. * * @return string */ protected function formatGedcomMonth(): string { // Our simple lookup table doesn't work correctly for Adar on leap years if ($this->month === 7 && $this->calendar instanceof JewishCalendar && !$this->calendar->isLeapYear($this->year)) { return 'ADR'; } return array_search($this->month, static::MONTH_ABBREVIATIONS, true); } /** * Generate the %E format for a date. * * @return string */ protected function formatGedcomYear(): string { if ($this->year === 0) { return ''; } return sprintf('%04d', $this->year); } /** * Generate the %@ format for a calendar escape. * * @return string */ protected function formatGedcomCalendarEscape(): string { return static::ESCAPE; } /** * Generate the %Y format for a date. * * @return string */ protected function formatLongYear(): string { return I18N::digits($this->year); } /** * Which months follows this one? Calendars with leap-months should provide their own implementation. * * @return int[] */ protected function nextMonth(): array { return [ $this->month === $this->calendar->monthsInYear() ? $this->nextYear($this->year) : $this->year, ($this->month % $this->calendar->monthsInYear()) + 1, ]; } /** * Get today’s date in the current calendar. * * @return int[] */ public function todayYmd(): array { return $this->calendar->jdToYmd(Carbon::now()->julianDay()); } /** * Convert to today’s date. * * @return AbstractCalendarDate */ public function today(): AbstractCalendarDate { $tmp = clone $this; $ymd = $tmp->todayYmd(); $tmp->year = $ymd[0]; $tmp->month = $ymd[1]; $tmp->day = $ymd[2]; $tmp->setJdFromYmd(); return $tmp; } /** * Create a URL that links this date to the WT calendar * * @param string $date_format * @param Tree $tree * * @return string */ public function calendarUrl(string $date_format, Tree $tree): string { if ($this->day !== 0 && strpbrk($date_format, 'dDj')) { // If the format includes a day, and the date also includes a day, then use the day view $view = 'day'; } elseif ($this->month !== 0 && strpbrk($date_format, 'FMmn')) { // If the format includes a month, and the date also includes a month, then use the month view $view = 'month'; } else { // Use the year view $view = 'year'; } return route('calendar', [ 'cal' => $this->calendar->gedcomCalendarEscape(), 'year' => $this->formatGedcomYear(), 'month' => $this->formatGedcomMonth(), 'day' => $this->formatGedcomDay(), 'view' => $view, 'tree' => $tree->name(), ]); } }