xref: /webtrees/app/Services/CalendarService.php (revision e240f5e1c214ef53737cb73f56fb28d924638abd)
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;
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                    foreach ($record->facts() as $fact) {
324                        if (($fact->date()->minimumJulianDay() === $anniv_date->minimumJulianDay() || $fact->date()->maximumJulianDay() === $anniv_date->maximumJulianDay()) && $fact->getTag() === $row->d_fact) {
325                            $fact->anniv   = $row->d_year === '0' ? 0 : $anniv->year - $row->d_year;
326                            $fact->jd      = $jd;
327                            $found_facts[] = $fact;
328                        }
329                    }
330                }
331            }
332        }
333
334        return $found_facts;
335    }
336
337    /**
338     * By default, missing days have anniversaries on the first of the month,
339     * and invalid days have anniversaries on the last day of the month.
340     *
341     * @param Builder              $query
342     * @param AbstractCalendarDate $anniv
343     */
344    private function defaultAnniversaries(Builder $query, AbstractCalendarDate $anniv): void
345    {
346        if ($anniv->day() === 1) {
347            $query->where('d_day', '<=', 1);
348        } elseif ($anniv->day() === $anniv->daysInMonth()) {
349            $query->where('d_day', '>=', $anniv->daysInMonth());
350        } else {
351            $query->where('d_day', '=', $anniv->day());
352        }
353
354        $query->where('d_mon', '=', $anniv->month());
355    }
356
357    /**
358     * 29 CSH does not include 30 CSH (but would include an invalid 31 CSH if there were no 30 CSH).
359     *
360     * @param Builder    $query
361     * @param JewishDate $anniv
362     */
363    private function cheshvanAnniversaries(Builder $query, JewishDate $anniv): void
364    {
365        if ($anniv->day === 29 && $anniv->daysInMonth() === 29) {
366            $query
367                ->where('d_mon', '=', 2)
368                ->where('d_day', '>=', 29)
369                ->where('d_day', '<>', 30);
370        } else {
371            $this->defaultAnniversaries($query, $anniv);
372        }
373    }
374
375    /**
376     * 1 KSL includes 30 CSH (if this year didn’t have 30 CSH).
377     * 29 KSL does not include 30 KSL (but would include an invalid 31 KSL if there were no 30 KSL).
378     *
379     * @param Builder    $query
380     * @param JewishDate $anniv
381     */
382    private function kislevAnniversaries(Builder $query, JewishDate $anniv): void
383    {
384        $tmp = new JewishDate([(string) $anniv->year, 'CSH', '1']);
385
386        if ($anniv->day() === 1 && $tmp->daysInMonth() === 29) {
387            $query->where(static function (Builder $query): void {
388                $query->where(static function (Builder $query): void {
389                    $query->where('d_day', '<=', 1)->where('d_mon', '=', 3);
390                })->orWhere(static function (Builder $query): void {
391                    $query->where('d_day', '=', 30)->where('d_mon', '=', 2);
392                });
393            });
394        } elseif ($anniv->day === 29 && $anniv->daysInMonth() === 29) {
395            $query
396                ->where('d_mon', '=', 3)
397                ->where('d_day', '>=', 29)
398                ->where('d_day', '<>', 30);
399        } else {
400            $this->defaultAnniversaries($query, $anniv);
401        }
402    }
403
404    /**
405     * 1 TVT includes 30 KSL (if this year didn’t have 30 KSL).
406     *
407     * @param Builder    $query
408     * @param JewishDate $anniv
409     */
410    private function tevetAnniversaries(Builder $query, JewishDate $anniv): void
411    {
412        $tmp = new JewishDate([(string) $anniv->year, 'KSL', '1']);
413
414        if ($anniv->day === 1 && $tmp->daysInMonth() === 29) {
415            $query->where(static function (Builder $query): void {
416                $query->where(static function (Builder $query): void {
417                    $query->where('d_day', '<=', 1)->where('d_mon', '=', 4);
418                })->orWhere(static function (Builder $query): void {
419                    $query->where('d_day', '=', 30)->where('d_mon', '=', 3);
420                });
421            });
422        } else {
423            $this->defaultAnniversaries($query, $anniv);
424        }
425    }
426
427    /**
428     * ADS includes non-leap ADR.
429     *
430     * @param Builder    $query
431     * @param JewishDate $anniv
432     */
433    private function adarIIAnniversaries(Builder $query, JewishDate $anniv): void
434    {
435        if ($anniv->day() === 1) {
436            $query->where('d_day', '<=', 1);
437        } elseif ($anniv->day() === $anniv->daysInMonth()) {
438            $query->where('d_day', '>=', $anniv->daysInMonth());
439        } else {
440            $query->where('d_day', '<=', 1);
441        }
442
443        $query->where(static function (Builder $query): void {
444            $query
445                ->where('d_mon', '=', 7)
446                ->orWhere(static function (Builder $query): void {
447                    $query
448                        ->where('d_mon', '=', 6)
449                        ->where(new Expression('(7 * d_year + 1 % 19)'), '>=', 7);
450                });
451        });
452    }
453
454    /**
455     * 1 NSN includes 30 ADR, if this year is non-leap.
456     *
457     * @param Builder    $query
458     * @param JewishDate $anniv
459     */
460    private function nisanAnniversaries(Builder $query, JewishDate $anniv): void
461    {
462        if ($anniv->day === 1 && !$anniv->isLeapYear()) {
463            $query->where(static function (Builder $query): void {
464                $query->where(static function (Builder $query): void {
465                    $query->where('d_day', '<=', 1)->where('d_mon', '=', 8);
466                })->orWhere(static function (Builder $query): void {
467                    $query->where('d_day', '=', 30)->where('d_mon', '=', 6);
468                });
469            });
470        } else {
471            $this->defaultAnniversaries($query, $anniv);
472        }
473    }
474}
475