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