1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2019 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 <http://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\Family; 33use Fisharebest\Webtrees\GedcomRecord; 34use Fisharebest\Webtrees\Individual; 35use Fisharebest\Webtrees\Tree; 36use Illuminate\Database\Capsule\Manager as DB; 37use Illuminate\Database\Query\Builder; 38use Illuminate\Database\Query\Expression; 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 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 occured 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 * 101 * @return Fact[] 102 */ 103 public function getCalendarEvents(int $jd1, int $jd2, string $facts, Tree $tree): array 104 { 105 // Events that start or end during the period 106 $query = DB::table('dates') 107 ->where('d_file', '=', $tree->id()) 108 ->where(static function (Builder $query) use ($jd1, $jd2): void { 109 $query->where(static function (Builder $query) use ($jd1, $jd2): void { 110 $query 111 ->where('d_julianday1', '>=', $jd1) 112 ->where('d_julianday1', '<=', $jd2); 113 })->orWhere(static function (Builder $query) use ($jd1, $jd2): void { 114 $query 115 ->where('d_julianday2', '>=', $jd1) 116 ->where('d_julianday2', '<=', $jd2); 117 }); 118 }); 119 120 // Restrict to certain types of fact 121 if ($facts === '') { 122 $query->whereNotIn('d_fact', self::SKIP_FACTS); 123 } else { 124 preg_match_all('/([_A-Z]+)/', $facts, $matches); 125 126 $query->whereIn('d_fact', $matches[1]); 127 } 128 129 $ind_query = (clone $query) 130 ->join('individuals', static function (JoinClause $join): void { 131 $join->on('d_gid', '=', 'i_id')->on('d_file', '=', 'i_file'); 132 }) 133 ->select(['i_id AS xref', 'i_gedcom AS gedcom', 'd_type', 'd_day', 'd_month', 'd_year', 'd_fact', 'd_type']); 134 135 $fam_query = (clone $query) 136 ->join('families', static function (JoinClause $join): void { 137 $join->on('d_gid', '=', 'f_id')->on('d_file', '=', 'f_file'); 138 }) 139 ->select(['f_id AS xref', 'f_gedcom AS gedcom', 'd_type', 'd_day', 'd_month', 'd_year', 'd_fact', 'd_type']); 140 141 // Now fetch these events 142 $found_facts = []; 143 144 foreach (['INDI' => $ind_query, 'FAM' => $fam_query] as $type => $record_query) { 145 foreach ($record_query->get() as $row) { 146 if ($type === 'INDI') { 147 $record = Individual::getInstance($row->xref, $tree, $row->gedcom); 148 } else { 149 $record = Family::getInstance($row->xref, $tree, $row->gedcom); 150 } 151 152 $anniv_date = new Date($row->d_type . ' ' . $row->d_day . ' ' . $row->d_month . ' ' . $row->d_year); 153 154 foreach ($record->facts() as $fact) { 155 // For date ranges, we need a match on either the start/end. 156 if (($fact->date()->minimumJulianDay() === $anniv_date->minimumJulianDay() || $fact->date()->maximumJulianDay() === $anniv_date->maximumJulianDay()) && $fact->getTag() === $row->d_fact) { 157 $fact->anniv = 0; 158 $found_facts[] = $fact; 159 } 160 } 161 } 162 } 163 164 return $found_facts; 165 } 166 167 /** 168 * Get the list of current and upcoming events, sorted by anniversary date 169 * 170 * @param int $jd1 171 * @param int $jd2 172 * @param string $events 173 * @param bool $only_living 174 * @param string $sort_by 175 * @param Tree $tree 176 * 177 * @return Collection<Fact> 178 */ 179 public function getEventsList(int $jd1, int $jd2, string $events, bool $only_living, string $sort_by, Tree $tree): Collection 180 { 181 $found_facts = []; 182 $facts = new Collection(); 183 184 foreach (range($jd1, $jd2) as $jd) { 185 $found_facts = array_merge($found_facts, $this->getAnniversaryEvents($jd, $events, $tree)); 186 } 187 188 foreach ($found_facts as $fact) { 189 $record = $fact->record(); 190 // only living people ? 191 if ($only_living) { 192 if ($record instanceof Individual && $record->isDead()) { 193 continue; 194 } 195 if ($record instanceof Family) { 196 $husb = $record->husband(); 197 if ($husb === null || $husb->isDead()) { 198 continue; 199 } 200 $wife = $record->wife(); 201 if ($wife === null || $wife->isDead()) { 202 continue; 203 } 204 } 205 } 206 $facts->push($fact); 207 } 208 209 switch ($sort_by) { 210 case 'anniv': 211 $facts = $facts->sort(static function (Fact $x, Fact $y): int { 212 return $x->jd <=> $y->jd; 213 }); 214 break; 215 216 case 'alpha': 217 $facts = $facts->sort(static function (Fact $x, Fact $y): int { 218 return GedcomRecord::nameComparator()($x->record(), $y->record()); 219 }); 220 break; 221 } 222 223 return $facts->values(); 224 } 225 226 /** 227 * Get a list of events whose anniversary occured on a given julian day. 228 * Used on the on-this-day/upcoming blocks and the day/month calendar views. 229 * 230 * @param int $jd the julian day 231 * @param string $facts restrict the search to just these facts or leave blank for all 232 * @param Tree $tree the tree to search 233 * 234 * @return Fact[] 235 */ 236 public function getAnniversaryEvents($jd, string $facts, Tree $tree): array 237 { 238 $found_facts = []; 239 240 $anniversaries = [ 241 new GregorianDate($jd), 242 new JulianDate($jd), 243 new FrenchDate($jd), 244 new JewishDate($jd), 245 new HijriDate($jd), 246 ]; 247 248 // There is a bug in the Persian Calendar that gives zero months for invalid dates 249 if ($jd > (new PersianCalendar())->jdStart()) { 250 $anniversaries[] = new JalaliDate($jd); 251 } 252 253 foreach ($anniversaries as $anniv) { 254 // Build a query to match anniversaries in the appropriate calendar. 255 $query = DB::table('dates') 256 ->distinct() 257 ->where('d_file', '=', $tree->id()) 258 ->where('d_type', '=', $anniv->format('%@')); 259 260 // SIMPLE CASES: 261 // a) Non-hebrew anniversaries 262 // b) Hebrew months TVT, SHV, IYR, SVN, TMZ, AAV, ELL 263 if (!$anniv instanceof JewishDate || in_array($anniv->month, [1, 5, 6, 9, 10, 11, 12, 13], true)) { 264 $this->defaultAnniversaries($query, $anniv); 265 } else { 266 // SPECIAL CASES: 267 switch ($anniv->month) { 268 case 2: 269 $this->cheshvanAnniversaries($query, $anniv); 270 break; 271 case 3: 272 $this->kislevAnniversaries($query, $anniv); 273 break; 274 case 4: 275 $this->tevetAnniversaries($query, $anniv); 276 break; 277 case 7: 278 $this->adarIIAnniversaries($query, $anniv); 279 break; 280 case 8: 281 $this->nisanAnniversaries($query, $anniv); 282 break; 283 } 284 } 285 // Only events in the past (includes dates without a year) 286 $query->where('d_year', '<=', $anniv->year()); 287 288 if ($facts === '') { 289 // If no facts specified, get all except these 290 $query->whereNotIn('d_fact', self::SKIP_FACTS); 291 } else { 292 // Restrict to certain types of fact 293 preg_match_all('/([_A-Z]+)/', $facts, $matches); 294 295 $query->whereIn('d_fact', $matches[1]); 296 } 297 298 $query 299 ->orderBy('d_day') 300 ->orderBy('d_year', 'DESC'); 301 302 $ind_query = (clone $query) 303 ->join('individuals', static function (JoinClause $join): void { 304 $join->on('d_gid', '=', 'i_id')->on('d_file', '=', 'i_file'); 305 }) 306 ->select(['i_id AS xref', 'i_gedcom AS gedcom', 'd_type', 'd_day', 'd_month', 'd_year', 'd_fact']); 307 308 $fam_query = (clone $query) 309 ->join('families', static function (JoinClause $join): void { 310 $join->on('d_gid', '=', 'f_id')->on('d_file', '=', 'f_file'); 311 }) 312 ->select(['f_id AS xref', 'f_gedcom AS gedcom', 'd_type', 'd_day', 'd_month', 'd_year', 'd_fact']); 313 314 // Now fetch these anniversaries 315 foreach (['INDI' => $ind_query, 'FAM' => $fam_query] as $type => $record_query) { 316 foreach ($record_query->get() as $row) { 317 if ($type === 'INDI') { 318 $record = Individual::getInstance($row->xref, $tree, $row->gedcom); 319 } else { 320 $record = Family::getInstance($row->xref, $tree, $row->gedcom); 321 } 322 323 $anniv_date = new Date($row->d_type . ' ' . $row->d_day . ' ' . $row->d_month . ' ' . $row->d_year); 324 325 // The record may have multiple facts of this type. 326 // Find the ones that match the date. 327 foreach ($record->facts([$row->d_fact]) as $fact) { 328 $min_date = $fact->date()->minimumDate(); 329 $max_date = $fact->date()->maximumDate(); 330 331 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) { 332 $fact->anniv = $row->d_year === '0' ? 0 : $anniv->year - $row->d_year; 333 $fact->jd = $jd; 334 $found_facts[] = $fact; 335 } 336 } 337 } 338 } 339 } 340 341 return $found_facts; 342 } 343 344 /** 345 * By default, missing days have anniversaries on the first of the month, 346 * and invalid days have anniversaries on the last day of the month. 347 * 348 * @param Builder $query 349 * @param AbstractCalendarDate $anniv 350 */ 351 private function defaultAnniversaries(Builder $query, AbstractCalendarDate $anniv): void 352 { 353 if ($anniv->day() === 1) { 354 $query->where('d_day', '<=', 1); 355 } elseif ($anniv->day() === $anniv->daysInMonth()) { 356 $query->where('d_day', '>=', $anniv->daysInMonth()); 357 } else { 358 $query->where('d_day', '=', $anniv->day()); 359 } 360 361 $query->where('d_mon', '=', $anniv->month()); 362 } 363 364 /** 365 * 29 CSH does not include 30 CSH (but would include an invalid 31 CSH if there were no 30 CSH). 366 * 367 * @param Builder $query 368 * @param JewishDate $anniv 369 */ 370 private function cheshvanAnniversaries(Builder $query, JewishDate $anniv): void 371 { 372 if ($anniv->day === 29 && $anniv->daysInMonth() === 29) { 373 $query 374 ->where('d_mon', '=', 2) 375 ->where('d_day', '>=', 29) 376 ->where('d_day', '<>', 30); 377 } else { 378 $this->defaultAnniversaries($query, $anniv); 379 } 380 } 381 382 /** 383 * 1 KSL includes 30 CSH (if this year didn’t have 30 CSH). 384 * 29 KSL does not include 30 KSL (but would include an invalid 31 KSL if there were no 30 KSL). 385 * 386 * @param Builder $query 387 * @param JewishDate $anniv 388 */ 389 private function kislevAnniversaries(Builder $query, JewishDate $anniv): void 390 { 391 $tmp = new JewishDate([(string) $anniv->year, 'CSH', '1']); 392 393 if ($anniv->day() === 1 && $tmp->daysInMonth() === 29) { 394 $query->where(static function (Builder $query): void { 395 $query->where(static function (Builder $query): void { 396 $query->where('d_day', '<=', 1)->where('d_mon', '=', 3); 397 })->orWhere(static function (Builder $query): void { 398 $query->where('d_day', '=', 30)->where('d_mon', '=', 2); 399 }); 400 }); 401 } elseif ($anniv->day === 29 && $anniv->daysInMonth() === 29) { 402 $query 403 ->where('d_mon', '=', 3) 404 ->where('d_day', '>=', 29) 405 ->where('d_day', '<>', 30); 406 } else { 407 $this->defaultAnniversaries($query, $anniv); 408 } 409 } 410 411 /** 412 * 1 TVT includes 30 KSL (if this year didn’t have 30 KSL). 413 * 414 * @param Builder $query 415 * @param JewishDate $anniv 416 */ 417 private function tevetAnniversaries(Builder $query, JewishDate $anniv): void 418 { 419 $tmp = new JewishDate([(string) $anniv->year, 'KSL', '1']); 420 421 if ($anniv->day === 1 && $tmp->daysInMonth() === 29) { 422 $query->where(static function (Builder $query): void { 423 $query->where(static function (Builder $query): void { 424 $query->where('d_day', '<=', 1)->where('d_mon', '=', 4); 425 })->orWhere(static function (Builder $query): void { 426 $query->where('d_day', '=', 30)->where('d_mon', '=', 3); 427 }); 428 }); 429 } else { 430 $this->defaultAnniversaries($query, $anniv); 431 } 432 } 433 434 /** 435 * ADS includes non-leap ADR. 436 * 437 * @param Builder $query 438 * @param JewishDate $anniv 439 */ 440 private function adarIIAnniversaries(Builder $query, JewishDate $anniv): void 441 { 442 if ($anniv->day() === 1) { 443 $query->where('d_day', '<=', 1); 444 } elseif ($anniv->day() === $anniv->daysInMonth()) { 445 $query->where('d_day', '>=', $anniv->daysInMonth()); 446 } else { 447 $query->where('d_day', '<=', 1); 448 } 449 450 $query->where(static function (Builder $query): void { 451 $query 452 ->where('d_mon', '=', 7) 453 ->orWhere(static function (Builder $query): void { 454 $query 455 ->where('d_mon', '=', 6) 456 ->where(new Expression('(7 * d_year + 1 % 19)'), '>=', 7); 457 }); 458 }); 459 } 460 461 /** 462 * 1 NSN includes 30 ADR, if this year is non-leap. 463 * 464 * @param Builder $query 465 * @param JewishDate $anniv 466 */ 467 private function nisanAnniversaries(Builder $query, JewishDate $anniv): void 468 { 469 if ($anniv->day === 1 && !$anniv->isLeapYear()) { 470 $query->where(static function (Builder $query): void { 471 $query->where(static function (Builder $query): void { 472 $query->where('d_day', '<=', 1)->where('d_mon', '=', 8); 473 })->orWhere(static function (Builder $query): void { 474 $query->where('d_day', '=', 30)->where('d_mon', '=', 6); 475 }); 476 }); 477 } else { 478 $this->defaultAnniversaries($query, $anniv); 479 } 480 } 481} 482