xref: /webtrees/app/Services/CalendarService.php (revision fcfa147e10aaa6c7ff580c29bd6e5b88666befc1)
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 => $query) {
143            foreach ($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 Fact[]
176     */
177    public function getEventsList(int $jd1, int $jd2, string $events, bool $only_living, string $sort_by, Tree $tree): array
178    {
179        $found_facts = [];
180        $facts       = [];
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[] = $fact;
205        }
206
207        switch ($sort_by) {
208            case 'anniv':
209                $facts = Fact::sortFacts(Collection::make($facts))->all();
210                break;
211
212            case 'alpha':
213                uasort($facts, static function (Fact $x, Fact $y): int {
214                    return GedcomRecord::nameComparator()($x->record(), $y->record());
215                });
216                break;
217        }
218
219        return $facts;
220    }
221
222    /**
223     * Get a list of events whose anniversary occured on a given julian day.
224     * Used on the on-this-day/upcoming blocks and the day/month calendar views.
225     *
226     * @param int    $jd    the julian day
227     * @param string $facts restrict the search to just these facts or leave blank for all
228     * @param Tree   $tree  the tree to search
229     *
230     * @return Fact[]
231     */
232    public function getAnniversaryEvents($jd, string $facts, Tree $tree): array
233    {
234        $found_facts = [];
235
236        $anniversaries = [
237            new GregorianDate($jd),
238            new JulianDate($jd),
239            new FrenchDate($jd),
240            new JewishDate($jd),
241            new HijriDate($jd),
242        ];
243
244        // @TODO - there is a bug in the Persian Calendar that gives zero months for invalid dates
245        if ($jd > (new PersianCalendar())->jdStart()) {
246            $anniversaries[] = new JalaliDate($jd);
247        }
248
249        foreach ($anniversaries as $anniv) {
250            // Build a query to match anniversaries in the appropriate calendar.
251            $query = DB::table('dates')
252                ->distinct()
253                ->where('d_file', '=', $tree->id())
254                ->where('d_type', '=', $anniv->format('%@'));
255
256            // SIMPLE CASES:
257            // a) Non-hebrew anniversaries
258            // b) Hebrew months TVT, SHV, IYR, SVN, TMZ, AAV, ELL
259            if (!$anniv instanceof JewishDate || in_array($anniv->month, [1, 5, 6, 9, 10, 11, 12, 13], true)) {
260                $this->defaultAnniversaries($query, $anniv);
261            } else {
262                // SPECIAL CASES:
263                switch ($anniv->month) {
264                    case 2:
265                        $this->cheshvanAnniversaries($query, $anniv);
266                        break;
267                    case 3:
268                        $this->kislevAnniversaries($query, $anniv);
269                        break;
270                    case 4:
271                        $this->tevetAnniversaries($query, $anniv);
272                        break;
273                    case 7:
274                        $this->adarIIAnniversaries($query, $anniv);
275                        break;
276                    case 8:
277                        $this->nisanAnniversaries($query, $anniv);
278                        break;
279                }
280            }
281            // Only events in the past (includes dates without a year)
282            $query->where('d_year', '<=', $anniv->year());
283
284            if ($facts === '') {
285                // If no facts specified, get all except these
286                $query->whereNotIn('d_fact', self::SKIP_FACTS);
287            } else {
288                // Restrict to certain types of fact
289                preg_match_all('/([_A-Z]+)/', $facts, $matches);
290
291                $query->whereIn('d_fact', $matches[1]);
292            }
293
294            $query
295                ->orderBy('d_day')
296                ->orderBy('d_year');
297
298            $ind_query = (clone $query)
299                ->join('individuals', static function (JoinClause $join): void {
300                    $join->on('d_gid', '=', 'i_id')->on('d_file', '=', 'i_file');
301                })
302                ->select(['i_id AS xref', 'i_gedcom AS gedcom', 'd_type', 'd_day', 'd_month', 'd_year', 'd_fact']);
303
304            $fam_query = (clone $query)
305                ->join('families', static function (JoinClause $join): void {
306                    $join->on('d_gid', '=', 'f_id')->on('d_file', '=', 'f_file');
307                })
308                ->select(['f_id AS xref', 'f_gedcom AS gedcom', 'd_type', 'd_day', 'd_month', 'd_year', 'd_fact']);
309
310            // Now fetch these anniversaries
311            foreach (['INDI' => $ind_query, 'FAM' => $fam_query] as $type => $query) {
312                foreach ($query->get() as $row) {
313                    if ($type === 'INDI') {
314                        $record = Individual::getInstance($row->xref, $tree, $row->gedcom);
315                    } else {
316                        $record = Family::getInstance($row->xref, $tree, $row->gedcom);
317                    }
318
319                    $anniv_date = new Date($row->d_type . ' ' . $row->d_day . ' ' . $row->d_month . ' ' . $row->d_year);
320
321                    foreach ($record->facts() as $fact) {
322                        if (($fact->date()->minimumJulianDay() === $anniv_date->minimumJulianDay() || $fact->date()->maximumJulianDay() === $anniv_date->maximumJulianDay()) && $fact->getTag() === $row->d_fact) {
323                            $fact->anniv   = $row->d_year === '0' ? 0 : $anniv->year - $row->d_year;
324                            $fact->jd      = $jd;
325                            $found_facts[] = $fact;
326                        }
327                    }
328                }
329            }
330        }
331
332        return $found_facts;
333    }
334
335    /**
336     * By default, missing days have anniversaries on the first of the month,
337     * and invalid days have anniversaries on the last day of the month.
338     *
339     * @param Builder              $query
340     * @param AbstractCalendarDate $anniv
341     */
342    private function defaultAnniversaries(Builder $query, AbstractCalendarDate $anniv): void
343    {
344        if ($anniv->day() === 1) {
345            $query->where('d_day', '<=', 1);
346        } elseif ($anniv->day() === $anniv->daysInMonth()) {
347            $query->where('d_day', '>=', $anniv->daysInMonth());
348        } else {
349            $query->where('d_day', '=', $anniv->day());
350        }
351
352        $query->where('d_mon', '=', $anniv->month());
353    }
354
355    /**
356     * 29 CSH does not include 30 CSH (but would include an invalid 31 CSH if there were no 30 CSH).
357     *
358     * @param Builder    $query
359     * @param JewishDate $anniv
360     */
361    private function cheshvanAnniversaries(Builder $query, JewishDate $anniv): void
362    {
363        if ($anniv->day === 29 && $anniv->daysInMonth() === 29) {
364            $query
365                ->where('d_mon', '=', 2)
366                ->where('d_day', '>=', 29)
367                ->where('d_day', '<>', 30);
368        } else {
369            $this->defaultAnniversaries($query, $anniv);
370        }
371    }
372
373    /**
374     * 1 KSL includes 30 CSH (if this year didn’t have 30 CSH).
375     * 29 KSL does not include 30 KSL (but would include an invalid 31 KSL if there were no 30 KSL).
376     *
377     * @param Builder    $query
378     * @param JewishDate $anniv
379     */
380    private function kislevAnniversaries(Builder $query, JewishDate $anniv): void
381    {
382        $tmp = new JewishDate([(string) $anniv->year, 'CSH', '1']);
383
384        if ($anniv->day() === 1 && $tmp->daysInMonth() === 29) {
385            $query->where(static function (Builder $query): void {
386                $query->where(static function (Builder $query): void {
387                    $query->where('d_day', '<=', 1)->where('d_mon', '=', 3);
388                })->orWhere(static function (Builder $query): void {
389                    $query->where('d_day', '=', 30)->where('d_mon', '=', 2);
390                });
391            });
392        } elseif ($anniv->day === 29 && $anniv->daysInMonth() === 29) {
393            $query
394                ->where('d_mon', '=', 3)
395                ->where('d_day', '>=', 29)
396                ->where('d_day', '<>', 30);
397        } else {
398            $this->defaultAnniversaries($query, $anniv);
399        }
400    }
401
402    /**
403     * 1 TVT includes 30 KSL (if this year didn’t have 30 KSL).
404     *
405     * @param Builder    $query
406     * @param JewishDate $anniv
407     */
408    private function tevetAnniversaries(Builder $query, JewishDate $anniv): void
409    {
410        $tmp = new JewishDate([(string) $anniv->year, 'KSL', '1']);
411
412        if ($anniv->day === 1 && $tmp->daysInMonth() === 29) {
413            $query->where(static function (Builder $query): void {
414                $query->where(static function (Builder $query): void {
415                    $query->where('d_day', '<=', 1)->where('d_mon', '=', 4);
416                })->orWhere(static function (Builder $query): void {
417                    $query->where('d_day', '=', 30)->where('d_mon', '=', 3);
418                });
419            });
420        } else {
421            $this->defaultAnniversaries($query, $anniv);
422        }
423    }
424
425    /**
426     * ADS includes non-leap ADR.
427     *
428     * @param Builder    $query
429     * @param JewishDate $anniv
430     */
431    private function adarIIAnniversaries(Builder $query, JewishDate $anniv): void
432    {
433        if ($anniv->day() === 1) {
434            $query->where('d_day', '<=', 1);
435        } elseif ($anniv->day() === $anniv->daysInMonth()) {
436            $query->where('d_day', '>=', $anniv->daysInMonth());
437        } else {
438            $query->where('d_day', '<=', 1);
439        }
440
441        $query->where(static function (Builder $query): void {
442            $query
443                ->where('d_mon', '=', 7)
444                ->orWhere(static function (Builder $query): void {
445                    $query
446                        ->where('d_mon', '=', 6)
447                        ->where(new Expression('(7 * d_year + 1 % 19)'), '>=', 7);
448                });
449        });
450    }
451
452    /**
453     * 1 NSN includes 30 ADR, if this year is non-leap.
454     *
455     * @param Builder    $query
456     * @param JewishDate $anniv
457     */
458    private function nisanAnniversaries(Builder $query, JewishDate $anniv): void
459    {
460        if ($anniv->day === 1 && !$anniv->isLeapYear()) {
461            $query->where(static function (Builder $query): void {
462                $query->where(static function (Builder $query): void {
463                    $query->where('d_day', '<=', 1)->where('d_mon', '=', 8);
464                })->orWhere(static function (Builder $query): void {
465                    $query->where('d_day', '=', 30)->where('d_mon', '=', 6);
466                });
467            });
468        } else {
469            $this->defaultAnniversaries($query, $anniv);
470        }
471    }
472}
473