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