xref: /webtrees/app/Services/CalendarService.php (revision 8ae6e9f4162b4cb128ac973e1753d8fbf5d50623)
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\Webtrees\Database;
21use Fisharebest\Webtrees\Date;
22use Fisharebest\Webtrees\Date\FrenchDate;
23use Fisharebest\Webtrees\Date\GregorianDate;
24use Fisharebest\Webtrees\Date\HijriDate;
25use Fisharebest\Webtrees\Date\JalaliDate;
26use Fisharebest\Webtrees\Date\JewishDate;
27use Fisharebest\Webtrees\Date\JulianDate;
28use Fisharebest\Webtrees\Fact;
29use Fisharebest\Webtrees\Family;
30use Fisharebest\Webtrees\GedcomRecord;
31use Fisharebest\Webtrees\Individual;
32use Fisharebest\Webtrees\Tree;
33use Illuminate\Database\Capsule\Manager as DB;
34use Illuminate\Database\Query\Builder;
35use Illuminate\Database\Query\JoinClause;
36
37/**
38 * Calculate anniversaries, etc.
39 */
40class CalendarService
41{
42    /**
43     * List all the months in a given year.
44     *
45     * @param string $calendar
46     * @param int    $year
47     *
48     * @return string[]
49     */
50    public function calendarMonthsInYear(string $calendar, int $year): array
51    {
52        $date          = new Date($calendar . ' ' . $year);
53        $calendar_date = $date->minimumDate();
54        $month_numbers = range(1, $calendar_date->monthsInYear());
55        $month_names   = [];
56
57        foreach ($month_numbers as $month_number) {
58            $calendar_date->day   = 1;
59            $calendar_date->month = $month_number;
60            $calendar_date->setJdFromYmd();
61
62            if ($month_number === 6 && $calendar_date instanceof JewishDate && !$calendar_date->isLeapYear()) {
63                // No month 6 in Jewish non-leap years.
64                continue;
65            }
66
67            if ($month_number === 7 && $calendar_date instanceof JewishDate && !$calendar_date->isLeapYear()) {
68                // Month 7 is ADR in Jewish non-leap years (and ADS in others).
69                $mon = 'ADR';
70            } else {
71                $mon = $calendar_date->format('%O');
72            }
73
74
75            $month_names[$mon] = $calendar_date->format('%F');
76        }
77
78        return $month_names;
79    }
80
81    /**
82     * Get a list of events which occured during a given date range.
83     *
84     * @param int      $jd1   the start range of julian day
85     * @param int      $jd2   the end range of julian day
86     * @param string[] $facts restrict the search to just these facts or leave blank for all
87     * @param Tree     $tree  the tree to search
88     *
89     * @return Fact[]
90     */
91    public function getCalendarEvents(int $jd1, int $jd2, array $facts, Tree $tree): array
92    {
93        // If no facts specified, get all except these
94        $skipfacts = ['CHAN', 'BAPL', 'SLGC', 'SLGS', 'ENDL', 'CENS', 'RESI', 'NOTE', 'ADDR', 'OBJE', 'SOUR'];
95
96        $i_query = DB::table('individuals')->join('dates', function (JoinClause $join): void {
97            $join->on('d_gid', '=', 'i_id');
98            $join->on('d_file', '=', 'i_file');
99        })
100        ->select(['i_id AS xref', 'i_gedcom AS gedcom', 'd_type', 'd_day', 'd_month', 'd_year', 'd_fact', 'd_type']);
101
102        $f_query = DB::table('families')->join('dates', function (JoinClause $join): void {
103            $join->on('d_gid', '=', 'f_id');
104            $join->on('d_file', '=', 'f_file');
105        })
106        ->select(['f_id AS xref', 'f_gedcom AS gedcom', 'd_type', 'd_day', 'd_month', 'd_year', 'd_fact', 'd_type']);
107
108        // Now fetch these events
109        $found_facts = [];
110
111        foreach (['INDI' => $i_query, 'FAM' => $f_query] as $type => $query) {
112            // Events that start or end during the period
113            $query
114                ->where('d_file', '=', $tree->id())
115                ->where(function (Builder $query) use ($jd1, $jd2) {
116                    $query->where(function (Builder $query) use ($jd1, $jd2) {
117                        $query
118                        ->where('d_julianday1', '>=', $jd1)
119                        ->where('d_julianday1', '<=', $jd2);
120                    })->orWhere(function (Builder $query) use ($jd1, $jd2) {
121                        $query
122                        ->where('d_julianday2', '>=', $jd1)
123                        ->where('d_julianday2', '<=', $jd2);
124                    });
125                });
126
127            // Restrict to certain types of fact
128            if (empty($facts)) {
129                $query->whereNotIn('d_fact', $skipfacts);
130            } else {
131                $query->whereIn('d_fact', $facts);
132            }
133
134            $rows = $query->get();
135
136            foreach ($rows as $row) {
137                if ($type === 'INDI') {
138                    $record = Individual::getInstance($row->xref, $tree, $row->gedcom);
139                } else {
140                    $record = Family::getInstance($row->xref, $tree, $row->gedcom);
141                }
142                $anniv_date = new Date($row->d_type . ' ' . $row->d_day . ' ' . $row->d_month . ' ' . $row->d_year);
143                foreach ($record->facts() as $fact) {
144                    // For date ranges, we need a match on either the start/end.
145                    if (($fact->date()->minimumJulianDay() === $anniv_date->minimumJulianDay() || $fact->date()->maximumJulianDay() == $anniv_date->maximumJulianDay()) && $fact->getTag() === $row->d_fact) {
146                        $fact->anniv   = 0;
147                        $found_facts[] = $fact;
148                    }
149                }
150            }
151        }
152
153        return $found_facts;
154    }
155
156    /**
157     * Get the list of current and upcoming events, sorted by anniversary date
158     *
159     * @param int     $jd1
160     * @param int     $jd2
161     * @param string  $events
162     * @param bool    $only_living
163     * @param string  $sort_by
164     * @param Tree    $tree
165     *
166     * @return Fact[]
167     */
168    public function getEventsList(int $jd1, int $jd2, string $events, bool $only_living, string $sort_by, Tree $tree): array
169    {
170        $found_facts = [];
171        $facts       = [];
172
173        foreach (range($jd1, $jd2) as $jd) {
174            $found_facts = array_merge($found_facts, $this->getAnniversaryEvents($jd, $events, $tree));
175        }
176
177        foreach ($found_facts as $fact) {
178            $record = $fact->record();
179            // only living people ?
180            if ($only_living) {
181                if ($record instanceof Individual && $record->isDead()) {
182                    continue;
183                }
184                if ($record instanceof Family) {
185                    $husb = $record->getHusband();
186                    if ($husb === null || $husb->isDead()) {
187                        continue;
188                    }
189                    $wife = $record->getWife();
190                    if ($wife === null || $wife->isDead()) {
191                        continue;
192                    }
193                }
194            }
195            $facts[] = $fact;
196        }
197
198        switch ($sort_by) {
199            case 'anniv':
200                uasort($facts, function (Fact $x, Fact $y): int {
201                    return Fact::compareDate($y, $x);
202                });
203                break;
204            case 'alpha':
205                uasort($facts, function (Fact $x, Fact $y): int {
206                    return GedcomRecord::compare($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, $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            new JalaliDate($jd),
235        ];
236
237        foreach ($anniversaries as $anniv) {
238            // Build a SQL where clause to match anniversaries in the appropriate calendar.
239            $ind_sql =
240                "SELECT DISTINCT i_id AS xref, i_gedcom AS gedcom, d_type, d_day, d_month, d_year, d_fact" .
241                " FROM `##dates` JOIN `##individuals` ON d_gid = i_id AND d_file = i_file" .
242                " WHERE d_type = :type AND d_file = :tree_id";
243            $fam_sql =
244                "SELECT DISTINCT f_id AS xref, f_gedcom AS gedcom, d_type, d_day, d_month, d_year, d_fact" .
245                " FROM `##dates` JOIN `##families` ON d_gid = f_id AND d_file = f_file" .
246                " WHERE d_type = :type AND d_file = :tree_id";
247            $args = [
248                'type'    => $anniv->format('%@'),
249                'tree_id' => $tree->id(),
250            ];
251
252            $where = "";
253            // SIMPLE CASES:
254            // a) Non-hebrew anniversaries
255            // b) Hebrew months TVT, SHV, IYR, SVN, TMZ, AAV, ELL
256            if (!$anniv instanceof JewishDate || in_array($anniv->month, [
257                    1,
258                    5,
259                    6,
260                    9,
261                    10,
262                    11,
263                    12,
264                    13,
265                ])) {
266                // Dates without days go on the first day of the month
267                // Dates with invalid days go on the last day of the month
268                if ($anniv->day === 1) {
269                    $where .= " AND d_day <= 1";
270                } elseif ($anniv->day === $anniv->daysInMonth()) {
271                    $where       .= " AND d_day >= :day";
272                    $args['day'] = $anniv->day;
273                } else {
274                    $where       .= " AND d_day = :day";
275                    $args['day'] = $anniv->day;
276                }
277                $where .= " AND d_mon = :month";
278                $args['month'] = $anniv->month;
279            } else {
280                // SPECIAL CASES:
281                switch ($anniv->month) {
282                    case 2:
283                        // 29 CSH does not include 30 CSH (but would include an invalid 31 CSH if there were no 30 CSH)
284                        if ($anniv->day === 1) {
285                            $where .= " AND d_day <= 1 AND d_mon = 2";
286                        } elseif ($anniv->day === 30) {
287                            $where .= " AND d_day >= 30 AND d_mon = 2";
288                        } elseif ($anniv->day === 29 && $anniv->daysInMonth() === 29) {
289                            $where .= " AND (d_day = 29 OR d_day > 30) AND d_mon = 2";
290                        } else {
291                            $where .= " AND d_day = :day AND d_mon = 2";
292                            $args['day'] = $anniv->day;
293                        }
294                        break;
295                    case 3:
296                        // 1 KSL includes 30 CSH (if this year didn’t have 30 CSH)
297                        // 29 KSL does not include 30 KSL (but would include an invalid 31 KSL if there were no 30 KSL)
298                        if ($anniv->day === 1) {
299                            $tmp = new JewishDate([
300                                (string) $anniv->year,
301                                'CSH',
302                                1,
303                            ]);
304                            if ($tmp->daysInMonth() === 29) {
305                                $where .= " AND (d_day <= 1 AND d_mon = 3 OR d_day = 30 AND d_mon = 2)";
306                            } else {
307                                $where .= " AND d_day <= 1 AND d_mon = 3";
308                            }
309                        } elseif ($anniv->day === 30) {
310                            $where .= " AND d_day >= 30 AND d_mon = 3";
311                        } elseif ($anniv->day == 29 && $anniv->daysInMonth() === 29) {
312                            $where .= " AND (d_day = 29 OR d_day > 30) AND d_mon = 3";
313                        } else {
314                            $where .= " AND d_day = :day AND d_mon = 3";
315                            $args['day'] = $anniv->day;
316                        }
317                        break;
318                    case 4:
319                        // 1 TVT includes 30 KSL (if this year didn’t have 30 KSL)
320                        if ($anniv->day === 1) {
321                            $tmp = new JewishDate([
322                                (string) $anniv->year,
323                                'KSL',
324                                1,
325                            ]);
326                            if ($tmp->daysInMonth() === 29) {
327                                $where .= " AND (d_day <=1 AND d_mon = 4 OR d_day = 30 AND d_mon = 3)";
328                            } else {
329                                $where .= " AND d_day <= 1 AND d_mon = 4";
330                            }
331                        } elseif ($anniv->day === $anniv->daysInMonth()) {
332                            $where       .= " AND d_day >= :day AND d_mon=4";
333                            $args['day'] = $anniv->day;
334                        } else {
335                            $where       .= " AND d_day = :day AND d_mon=4";
336                            $args['day'] = $anniv->day;
337                        }
338                        break;
339                    case 7: // ADS includes ADR (non-leap)
340                        if ($anniv->day === 1) {
341                            $where .= " AND d_day <= 1";
342                        } elseif ($anniv->day === $anniv->daysInMonth()) {
343                            $where       .= " AND d_day >= :day";
344                            $args['day'] = $anniv->day;
345                        } else {
346                            $where       .= " AND d_day = :day";
347                            $args['day'] = $anniv->day;
348                        }
349                        $where .= " AND (d_mon = 6 AND MOD(7 * d_year + 1, 19) >= 7 OR d_mon = 7)";
350                        break;
351                    case 8: // 1 NSN includes 30 ADR, if this year is non-leap
352                        if ($anniv->day === 1) {
353                            if ($anniv->isLeapYear()) {
354                                $where .= " AND d_day <= 1 AND d_mon = 8";
355                            } else {
356                                $where .= " AND (d_day <= 1 AND d_mon = 8 OR d_day = 30 AND d_mon = 6)";
357                            }
358                        } elseif ($anniv->day === $anniv->daysInMonth()) {
359                            $where       .= " AND d_day >= :day AND d_mon = 8";
360                            $args['day'] = $anniv->day;
361                        } else {
362                            $where       .= " AND d_day = :day AND d_mon = 8";
363                            $args['day'] = $anniv->day;
364                        }
365                        break;
366                }
367            }
368            // Only events in the past (includes dates without a year)
369            $where .= " AND d_year <= :year";
370            $args['year'] = $anniv->year;
371
372            if ($facts) {
373                // Restrict to certain types of fact
374                $where .= " AND d_fact IN (";
375                preg_match_all('/([_A-Z]+)/', $facts, $matches);
376                foreach ($matches[1] as $n => $fact) {
377                    $where              .= $n ? ", " : "";
378                    $where              .= ":fact_" . $n;
379                    $args['fact_' . $n] = $fact;
380                }
381                $where .= ")";
382            } else {
383                // If no facts specified, get all except these
384                $where .= " AND d_fact NOT IN ('CHAN', 'BAPL', 'SLGC', 'SLGS', 'ENDL', 'CENS', 'RESI', '_TODO')";
385            }
386
387            $order_by = " ORDER BY d_day, d_year DESC";
388
389            // Now fetch these anniversaries
390            foreach ([
391                         'INDI' => $ind_sql . $where . $order_by,
392                         'FAM'  => $fam_sql . $where . $order_by,
393                     ] as $type => $sql) {
394                $rows = Database::prepare($sql)->execute($args)->fetchAll();
395                foreach ($rows as $row) {
396                    if ($type === 'INDI') {
397                        $record = Individual::getInstance($row->xref, $tree, $row->gedcom);
398                    } else {
399                        $record = Family::getInstance($row->xref, $tree, $row->gedcom);
400                    }
401                    $anniv_date = new Date($row->d_type . ' ' . $row->d_day . ' ' . $row->d_month . ' ' . $row->d_year);
402                    foreach ($record->facts() as $fact) {
403                        if (($fact->date()->minimumJulianDay() === $anniv_date->minimumJulianDay() || $fact->date()->maximumJulianDay() === $anniv_date->maximumJulianDay()) && $fact->getTag() === $row->d_fact) {
404                            $fact->anniv   = $row->d_year === '0' ? 0 : $anniv->year - $row->d_year;
405                            $fact->jd      = $jd;
406                            $found_facts[] = $fact;
407                        }
408                    }
409                }
410            }
411        }
412
413        return $found_facts;
414    }
415}
416