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