1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2023 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Services; 21 22use Fisharebest\ExtCalendar\PersianCalendar; 23use Fisharebest\Webtrees\Date; 24use Fisharebest\Webtrees\Date\AbstractCalendarDate; 25use Fisharebest\Webtrees\Date\FrenchDate; 26use Fisharebest\Webtrees\Date\GregorianDate; 27use Fisharebest\Webtrees\Date\HijriDate; 28use Fisharebest\Webtrees\Date\JalaliDate; 29use Fisharebest\Webtrees\Date\JewishDate; 30use Fisharebest\Webtrees\Date\JulianDate; 31use Fisharebest\Webtrees\DB; 32use Fisharebest\Webtrees\Fact; 33use Fisharebest\Webtrees\Family; 34use Fisharebest\Webtrees\GedcomRecord; 35use Fisharebest\Webtrees\Individual; 36use Fisharebest\Webtrees\Registry; 37use Fisharebest\Webtrees\Tree; 38use Illuminate\Database\Query\Builder; 39use Illuminate\Database\Query\JoinClause; 40use Illuminate\Support\Collection; 41 42use function array_merge; 43use function assert; 44use function in_array; 45use function preg_match_all; 46use function range; 47 48/** 49 * Calculate anniversaries, etc. 50 */ 51class CalendarService 52{ 53 // If no facts specified, get all except these 54 protected const SKIP_FACTS = ['CHAN', 'BAPL', 'SLGC', 'SLGS', 'ENDL', 'CENS', 'RESI', 'NOTE', 'ADDR', 'OBJE', 'SOUR', '_TODO']; 55 56 /** 57 * List all the months in a given year. 58 * 59 * @param string $calendar 60 * @param int $year 61 * 62 * @return array<string> 63 */ 64 public function calendarMonthsInYear(string $calendar, int $year): array 65 { 66 $date = new Date($calendar . ' ' . $year); 67 $calendar_date = $date->minimumDate(); 68 $month_numbers = range(1, $calendar_date->monthsInYear()); 69 $month_names = []; 70 71 foreach ($month_numbers as $month_number) { 72 $calendar_date->day = 1; 73 $calendar_date->month = $month_number; 74 $calendar_date->setJdFromYmd(); 75 76 if ($month_number === 6 && $calendar_date instanceof JewishDate && !$calendar_date->isLeapYear()) { 77 // No month 6 in Jewish non-leap years. 78 continue; 79 } 80 81 if ($month_number === 7 && $calendar_date instanceof JewishDate && !$calendar_date->isLeapYear()) { 82 // Month 7 is ADR in Jewish non-leap years (and ADS in others). 83 $mon = 'ADR'; 84 } else { 85 $mon = $calendar_date->format('%O'); 86 } 87 88 $month_names[$mon] = $calendar_date->format('%F'); 89 } 90 91 return $month_names; 92 } 93 94 /** 95 * Get a list of events which occurred during a given date range. 96 * 97 * @param int $jd1 the start range of julian day 98 * @param int $jd2 the end range of julian day 99 * @param string $facts restrict the search to just these facts or leave blank for all 100 * @param Tree $tree the tree to search 101 * @param string $filterof filter by living/recent 102 * @param string $filtersx filter by sex 103 * 104 * @return array<Fact> 105 */ 106 public function getCalendarEvents(int $jd1, int $jd2, string $facts, Tree $tree, string $filterof = '', string $filtersx = ''): array 107 { 108 // Events that start or end during the period 109 $query = DB::table('dates') 110 ->where('d_file', '=', $tree->id()) 111 ->where(static function (Builder $query) use ($jd1, $jd2): void { 112 $query->where(static function (Builder $query) use ($jd1, $jd2): void { 113 $query 114 ->where('d_julianday1', '>=', $jd1) 115 ->where('d_julianday1', '<=', $jd2); 116 })->orWhere(static function (Builder $query) use ($jd1, $jd2): void { 117 $query 118 ->where('d_julianday2', '>=', $jd1) 119 ->where('d_julianday2', '<=', $jd2); 120 }); 121 }); 122 123 // Restrict to certain types of fact 124 if ($facts === '') { 125 $query->whereNotIn('d_fact', self::SKIP_FACTS); 126 } else { 127 preg_match_all('/([_A-Z]+)/', $facts, $matches); 128 129 $query->whereIn('d_fact', $matches[1]); 130 } 131 132 if ($filterof === 'recent') { 133 $query->where('d_julianday1', '>=', Registry::timestampFactory()->now()->subtractYears(100)->julianDay()); 134 } 135 136 $ind_query = (clone $query) 137 ->join('individuals', static function (JoinClause $join): void { 138 $join->on('d_gid', '=', 'i_id')->on('d_file', '=', 'i_file'); 139 }) 140 ->select(['i_id AS xref', 'i_gedcom AS gedcom', 'd_type', 'd_day', 'd_month', 'd_year', 'd_fact', 'd_type']); 141 142 $queries = ['INDI' => $ind_query]; 143 144 if ($filtersx === '') { 145 $fam_query = (clone $query) 146 ->join('families', static function (JoinClause $join): void { 147 $join->on('d_gid', '=', 'f_id')->on('d_file', '=', 'f_file'); 148 }) 149 ->select(['f_id AS xref', 'f_gedcom AS gedcom', 'd_type', 'd_day', 'd_month', 'd_year', 'd_fact', 'd_type']); 150 151 $queries['FAM'] = $fam_query; 152 } else { 153 $queries['INDI']->where('i_sex', '=', $filtersx); 154 } 155 156 // Now fetch these events 157 $found_facts = []; 158 159 foreach ($queries as $type => $record_query) { 160 foreach ($record_query->get() as $row) { 161 if ($type === 'INDI') { 162 $record = Registry::individualFactory()->make($row->xref, $tree, $row->gedcom); 163 assert($record instanceof Individual); 164 165 if ($filterof === 'living' && $record->isDead()) { 166 continue; 167 } 168 } else { 169 $record = Registry::familyFactory()->make($row->xref, $tree, $row->gedcom); 170 assert($record instanceof Family); 171 $husb = $record->husband(); 172 $wife = $record->wife(); 173 174 if ($filterof === 'living' && ($husb && $husb->isDead() || $wife && $wife->isDead())) { 175 continue; 176 } 177 } 178 179 $anniv_date = new Date($row->d_type . ' ' . $row->d_day . ' ' . $row->d_month . ' ' . $row->d_year); 180 181 foreach ($record->facts([$row->d_fact]) as $fact) { 182 // For date ranges, we need a match on either the start/end. 183 if ($fact->date()->minimumJulianDay() === $anniv_date->minimumJulianDay() || $fact->date()->maximumJulianDay() === $anniv_date->maximumJulianDay()) { 184 $fact->anniv = 0; 185 $found_facts[] = $fact; 186 } 187 } 188 } 189 } 190 191 return $found_facts; 192 } 193 194 /** 195 * Get the list of current and upcoming events, sorted by anniversary date 196 * 197 * @param int $jd1 198 * @param int $jd2 199 * @param string $events 200 * @param bool $only_living 201 * @param string $sort_by 202 * @param Tree $tree 203 * 204 * @return Collection<int,Fact> 205 */ 206 public function getEventsList(int $jd1, int $jd2, string $events, bool $only_living, string $sort_by, Tree $tree): Collection 207 { 208 $found_facts = []; 209 $facts = new Collection(); 210 211 foreach (range($jd1, $jd2) as $jd) { 212 $found_facts = array_merge($found_facts, $this->getAnniversaryEvents($jd, $events, $tree)); 213 } 214 215 foreach ($found_facts as $fact) { 216 $record = $fact->record(); 217 // only living people ? 218 if ($only_living) { 219 if ($record instanceof Individual && $record->isDead()) { 220 continue; 221 } 222 if ($record instanceof Family) { 223 $husb = $record->husband(); 224 if ($husb === null || $husb->isDead()) { 225 continue; 226 } 227 $wife = $record->wife(); 228 if ($wife === null || $wife->isDead()) { 229 continue; 230 } 231 } 232 } 233 $facts->push($fact); 234 } 235 236 switch ($sort_by) { 237 case 'anniv': 238 case 'anniv_asc': 239 $facts = $facts->sort(static fn(Fact $x, Fact $y): int => $x->jd <=> $y->jd ?: $x->date()->minimumJulianDay() <=> $y->date()->minimumJulianDay()); 240 break; 241 242 case 'anniv_desc': 243 $facts = $facts->sort(static fn(Fact $x, Fact $y): int => $x->jd <=> $y->jd ?: $y->date()->minimumJulianDay() <=> $x->date()->minimumJulianDay()); 244 break; 245 246 case 'alpha': 247 $facts = $facts->sort(static fn(Fact $x, Fact $y): int => GedcomRecord::nameComparator()($x->record(), $y->record())); 248 break; 249 } 250 251 return $facts->values(); 252 } 253 254 /** 255 * Get a list of events whose anniversary occurred on a given julian day. 256 * Used on the on-this-day/upcoming blocks and the day/month calendar views. 257 * 258 * @param int $jd the julian day 259 * @param string $facts restrict the search to just these facts or leave blank for all 260 * @param Tree $tree the tree to search 261 * @param string $filterof filter by living/recent 262 * @param string $filtersx filter by sex 263 * 264 * @return array<Fact> 265 */ 266 public function getAnniversaryEvents(int $jd, string $facts, Tree $tree, string $filterof = '', string $filtersx = ''): array 267 { 268 $found_facts = []; 269 270 $anniversaries = [ 271 new GregorianDate($jd), 272 new JulianDate($jd), 273 new FrenchDate($jd), 274 new JewishDate($jd), 275 new HijriDate($jd), 276 ]; 277 278 // There is a bug in the Persian Calendar that gives zero months for invalid dates 279 if ($jd > (new PersianCalendar())->jdStart()) { 280 $anniversaries[] = new JalaliDate($jd); 281 } 282 283 foreach ($anniversaries as $anniv) { 284 // Build a query to match anniversaries in the appropriate calendar. 285 $query = DB::table('dates') 286 ->distinct() 287 ->where('d_file', '=', $tree->id()) 288 ->where('d_type', '=', $anniv->format('%@')); 289 290 // SIMPLE CASES: 291 // a) Non-hebrew anniversaries 292 // b) Hebrew months TVT, SHV, IYR, SVN, TMZ, AAV, ELL 293 if (!$anniv instanceof JewishDate || in_array($anniv->month, [1, 5, 6, 9, 10, 11, 12, 13], true)) { 294 $this->defaultAnniversaries($query, $anniv); 295 } else { 296 // SPECIAL CASES: 297 switch ($anniv->month) { 298 case 2: 299 $this->cheshvanAnniversaries($query, $anniv); 300 break; 301 case 3: 302 $this->kislevAnniversaries($query, $anniv); 303 break; 304 case 4: 305 $this->tevetAnniversaries($query, $anniv); 306 break; 307 case 7: 308 $this->adarIIAnniversaries($query, $anniv); 309 break; 310 case 8: 311 $this->nisanAnniversaries($query, $anniv); 312 break; 313 } 314 } 315 // Only events in the past (includes dates without a year) 316 $query->where('d_year', '<=', $anniv->year()); 317 318 if ($facts === '') { 319 // If no facts specified, get all except these 320 $query->whereNotIn('d_fact', self::SKIP_FACTS); 321 } else { 322 // Restrict to certain types of fact 323 preg_match_all('/([_A-Z]+)/', $facts, $matches); 324 325 $query->whereIn('d_fact', $matches[1]); 326 } 327 328 if ($filterof === 'recent') { 329 $query->where('d_julianday1', '>=', Registry::timestampFactory()->now()->subtractYears(100)->julianDay()); 330 } 331 332 $query 333 ->orderBy('d_day') 334 ->orderBy('d_year', 'DESC'); 335 336 $ind_query = (clone $query) 337 ->join('individuals', static function (JoinClause $join): void { 338 $join->on('d_gid', '=', 'i_id')->on('d_file', '=', 'i_file'); 339 }) 340 ->select(['i_id AS xref', 'i_gedcom AS gedcom', 'd_type', 'd_day', 'd_month', 'd_year', 'd_fact']); 341 342 $queries = ['INDI' => $ind_query]; 343 344 if ($filtersx === '') { 345 $fam_query = (clone $query) 346 ->join('families', static function (JoinClause $join): void { 347 $join->on('d_gid', '=', 'f_id')->on('d_file', '=', 'f_file'); 348 }) 349 ->select(['f_id AS xref', 'f_gedcom AS gedcom', 'd_type', 'd_day', 'd_month', 'd_year', 'd_fact']); 350 351 $queries['FAM'] = $fam_query; 352 } else { 353 $queries['INDI']->where('i_sex', '=', $filtersx); 354 } 355 356 // Now fetch these anniversaries 357 foreach ($queries as $type => $record_query) { 358 foreach ($record_query->get() as $row) { 359 if ($type === 'INDI') { 360 $record = Registry::individualFactory()->make($row->xref, $tree, $row->gedcom); 361 assert($record instanceof Individual); 362 363 if ($filterof === 'living' && $record->isDead()) { 364 continue; 365 } 366 } else { 367 $record = Registry::familyFactory()->make($row->xref, $tree, $row->gedcom); 368 assert($record instanceof Family); 369 $husb = $record->husband(); 370 $wife = $record->wife(); 371 372 if ($filterof === 'living' && ($husb && $husb->isDead() || $wife && $wife->isDead())) { 373 continue; 374 } 375 } 376 377 $anniv_date = new Date($row->d_type . ' ' . $row->d_day . ' ' . $row->d_month . ' ' . $row->d_year); 378 379 // The record may have multiple facts of this type. 380 // Find the ones that match the date. 381 foreach ($record->facts([$row->d_fact]) as $fact) { 382 $min_date = $fact->date()->minimumDate(); 383 $max_date = $fact->date()->maximumDate(); 384 385 if ($min_date->minimumJulianDay() === $anniv_date->minimumJulianDay() && $min_date::ESCAPE === $row->d_type || $max_date->maximumJulianDay() === $anniv_date->maximumJulianDay() && $max_date::ESCAPE === $row->d_type) { 386 $fact->anniv = $row->d_year === '0' ? 0 : $anniv->year - $row->d_year; 387 $fact->jd = $jd; 388 $found_facts[] = $fact; 389 } 390 } 391 } 392 } 393 } 394 395 return $found_facts; 396 } 397 398 /** 399 * By default, missing days have anniversaries on the first of the month, 400 * and invalid days have anniversaries on the last day of the month. 401 * 402 * @param Builder $query 403 * @param AbstractCalendarDate $anniv 404 */ 405 private function defaultAnniversaries(Builder $query, AbstractCalendarDate $anniv): void 406 { 407 if ($anniv->day() === 1) { 408 $query->where('d_day', '<=', 1); 409 } elseif ($anniv->day() === $anniv->daysInMonth()) { 410 $query->where('d_day', '>=', $anniv->daysInMonth()); 411 } else { 412 $query->where('d_day', '=', $anniv->day()); 413 } 414 415 $query->where('d_mon', '=', $anniv->month()); 416 } 417 418 /** 419 * 29 CSH does not include 30 CSH (but would include an invalid 31 CSH if there were no 30 CSH). 420 * 421 * @param Builder $query 422 * @param JewishDate $anniv 423 */ 424 private function cheshvanAnniversaries(Builder $query, JewishDate $anniv): void 425 { 426 if ($anniv->day === 29 && $anniv->daysInMonth() === 29) { 427 $query 428 ->where('d_mon', '=', 2) 429 ->where('d_day', '>=', 29) 430 ->where('d_day', '<>', 30); 431 } else { 432 $this->defaultAnniversaries($query, $anniv); 433 } 434 } 435 436 /** 437 * 1 KSL includes 30 CSH (if this year didn’t have 30 CSH). 438 * 29 KSL does not include 30 KSL (but would include an invalid 31 KSL if there were no 30 KSL). 439 * 440 * @param Builder $query 441 * @param JewishDate $anniv 442 */ 443 private function kislevAnniversaries(Builder $query, JewishDate $anniv): void 444 { 445 $tmp = new JewishDate([(string) $anniv->year, 'CSH', '1']); 446 447 if ($anniv->day() === 1 && $tmp->daysInMonth() === 29) { 448 $query->where(static function (Builder $query): void { 449 $query->where(static function (Builder $query): void { 450 $query->where('d_day', '<=', 1)->where('d_mon', '=', 3); 451 })->orWhere(static function (Builder $query): void { 452 $query->where('d_day', '=', 30)->where('d_mon', '=', 2); 453 }); 454 }); 455 } elseif ($anniv->day === 29 && $anniv->daysInMonth() === 29) { 456 $query 457 ->where('d_mon', '=', 3) 458 ->where('d_day', '>=', 29) 459 ->where('d_day', '<>', 30); 460 } else { 461 $this->defaultAnniversaries($query, $anniv); 462 } 463 } 464 465 /** 466 * 1 TVT includes 30 KSL (if this year didn’t have 30 KSL). 467 * 468 * @param Builder $query 469 * @param JewishDate $anniv 470 */ 471 private function tevetAnniversaries(Builder $query, JewishDate $anniv): void 472 { 473 $tmp = new JewishDate([(string) $anniv->year, 'KSL', '1']); 474 475 if ($anniv->day === 1 && $tmp->daysInMonth() === 29) { 476 $query->where(static function (Builder $query): void { 477 $query->where(static function (Builder $query): void { 478 $query->where('d_day', '<=', 1)->where('d_mon', '=', 4); 479 })->orWhere(static function (Builder $query): void { 480 $query->where('d_day', '=', 30)->where('d_mon', '=', 3); 481 }); 482 }); 483 } else { 484 $this->defaultAnniversaries($query, $anniv); 485 } 486 } 487 488 /** 489 * ADS includes non-leap ADR. 490 * 491 * @param Builder $query 492 * @param JewishDate $anniv 493 */ 494 private function adarIIAnniversaries(Builder $query, JewishDate $anniv): void 495 { 496 if ($anniv->day() === 1) { 497 $query->where('d_day', '<=', 1); 498 } elseif ($anniv->day() === $anniv->daysInMonth()) { 499 $query->where('d_day', '>=', $anniv->daysInMonth()); 500 if ($anniv->daysInMonth() === 29) { 501 // On short months, 30th Adar shown on 1st Nissan 502 $query->where('d_day', '<>', 30); 503 } 504 } else { 505 $query->where('d_day', '=', $anniv->day()); 506 } 507 508 if ($anniv->isLeapYear()) { 509 $query->where('d_mon', '=', 7); 510 } else { 511 $query->whereIn('d_mon', [6, 7]); 512 } 513 } 514 515 /** 516 * 1 NSN includes 30 ADR, if this year is non-leap. 517 * 518 * @param Builder $query 519 * @param JewishDate $anniv 520 */ 521 private function nisanAnniversaries(Builder $query, JewishDate $anniv): void 522 { 523 if ($anniv->day === 1 && !$anniv->isLeapYear()) { 524 $query->where(static function (Builder $query): void { 525 $query->where(static function (Builder $query): void { 526 $query->where('d_day', '<=', 1)->where('d_mon', '=', 8); 527 })->orWhere(static function (Builder $query): void { 528 $query->where('d_day', '=', 30)->where('d_mon', '=', 6); 529 }); 530 }); 531 } else { 532 $this->defaultAnniversaries($query, $anniv); 533 } 534 } 535} 536