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