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