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