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 */
16namespace Fisharebest\Webtrees\Functions;
17
18use Fisharebest\Webtrees\Auth;
19use Fisharebest\Webtrees\Database;
20use Fisharebest\Webtrees\Date;
21use Fisharebest\Webtrees\Fact;
22use Fisharebest\Webtrees\Family;
23use Fisharebest\Webtrees\Filter;
24use Fisharebest\Webtrees\GedcomRecord;
25use Fisharebest\Webtrees\GedcomTag;
26use Fisharebest\Webtrees\I18N;
27use Fisharebest\Webtrees\Individual;
28use Fisharebest\Webtrees\Media;
29use Fisharebest\Webtrees\Note;
30use Fisharebest\Webtrees\Place;
31use Fisharebest\Webtrees\Repository;
32use Fisharebest\Webtrees\Source;
33use Fisharebest\Webtrees\Tree;
34use Rhumsaa\Uuid\Uuid;
35
36/**
37 * Class FunctionsPrintLists - create sortable lists using datatables.net
38 */
39class FunctionsPrintLists
40{
41    /**
42     * Generate a SURN,GIVN and GIVN,SURN sortable name for an individual.
43     * This allows table data to sort by surname or given names.
44     *
45     * Use AAAA as a separator (instead of ","), as Javascript localeCompare()
46     * ignores punctuation and "ANN,ROACH" would sort after "ANNE,ROACH",
47     * instead of before it.
48     *
49     * @param Individual $individual
50     *
51     * @return string[]
52     */
53    private static function sortableNames(Individual $individual)
54    {
55        $names   = $individual->getAllNames();
56        $primary = $individual->getPrimaryName();
57
58        list($surn, $givn) = explode(',', $names[$primary]['sort']);
59
60        $givn = str_replace('@P.N.', 'AAAA', $givn);
61        $surn = str_replace('@N.N.', 'AAAA', $surn);
62
63        return array(
64            $surn . 'AAAA' . $givn,
65            $givn . 'AAAA' . $surn,
66        );
67    }
68
69    /**
70     * Print a table of individuals
71     *
72     * @param Individual[] $indiviudals
73     * @param string       $option
74     *
75     * @return string
76     */
77    public static function individualTable($indiviudals, $option = '')
78    {
79        global $controller, $WT_TREE;
80
81        $table_id = 'table-indi-' . Uuid::uuid4(); // lists requires a unique ID in case there are multiple lists per page
82
83        $controller
84            ->addExternalJavascript(WT_JQUERY_DATATABLES_JS_URL)
85            ->addInlineJavascript('
86				jQuery.fn.dataTableExt.oSort["text-asc"] = textCompareAsc;
87				jQuery.fn.dataTableExt.oSort["text-desc"] = textCompareDesc;
88				jQuery("#' . $table_id . '").dataTable( {
89					dom: \'<"H"<"filtersH_' . $table_id . '">T<"dt-clear">pf<"dt-clear">irl>t<"F"pl<"dt-clear"><"filtersF_' . $table_id . '">>\',
90					' . I18N::datatablesI18N() . ',
91					jQueryUI: true,
92					autoWidth: false,
93					processing: true,
94					retrieve: true,
95					columns: [
96						/* Given names  */ { type: "text" },
97						/* Surnames     */ { type: "text" },
98						/* SOSA numnber */ { type: "num", visible: ' . ($option === 'sosa' ? 'true' : 'false') . ' },
99						/* Birth date   */ { type: "num" },
100						/* Anniversary  */ { type: "num" },
101						/* Birthplace   */ { type: "text" },
102						/* Children     */ { type: "num" },
103						/* Deate date   */ { type: "num" },
104						/* Anniversary  */ { type: "num" },
105						/* Age          */ { type: "num" },
106						/* Death place  */ { type: "text" },
107						/* Last change  */ { visible: ' . ($WT_TREE->getPreference('SHOW_LAST_CHANGE') ? 'true' : 'false') . ' },
108						/* Filter sex   */ { sortable: false },
109						/* Filter birth */ { sortable: false },
110						/* Filter death */ { sortable: false },
111						/* Filter tree  */ { sortable: false }
112					],
113					sorting: [[' . ($option === 'sosa' ? '4, "asc"' : '1, "asc"') . ']],
114					displayLength: 20,
115					pagingType: "full_numbers"
116				});
117
118				jQuery("#' . $table_id . '")
119				/* Hide/show parents */
120				.on("click", ".btn-toggle-parents", function() {
121					jQuery(this).toggleClass("ui-state-active");
122					jQuery(".parents", jQuery(this).closest("table").DataTable().rows().nodes()).slideToggle();
123				})
124				/* Hide/show statistics */
125				.on("click", ".btn-toggle-statistics", function() {
126					jQuery(this).toggleClass("ui-state-active");
127					jQuery("#indi_list_table-charts_' . $table_id . '").slideToggle();
128				})
129				/* Filter buttons in table header */
130				.on("click", "button[data-filter-column]", function() {
131					var btn = jQuery(this);
132					// De-activate the other buttons in this button group
133					btn.siblings().removeClass("ui-state-active");
134					// Apply (or clear) this filter
135					var col = jQuery("#' . $table_id . '").DataTable().column(btn.data("filter-column"));
136					if (btn.hasClass("ui-state-active")) {
137						btn.removeClass("ui-state-active");
138						col.search("").draw();
139					} else {
140						btn.addClass("ui-state-active");
141						col.search(btn.data("filter-value")).draw();
142					}
143				});
144
145				jQuery(".indi-list").css("visibility", "visible");
146				jQuery(".loading-image").css("display", "none");
147			');
148
149        $max_age = (int) $WT_TREE->getPreference('MAX_ALIVE_AGE');
150
151        // Inititialise chart data
152        $deat_by_age = array();
153        for ($age = 0; $age <= $max_age; $age++) {
154            $deat_by_age[$age] = '';
155        }
156        $birt_by_decade = array();
157        $deat_by_decade = array();
158        for ($year = 1550; $year < 2030; $year += 10) {
159            $birt_by_decade[$year] = '';
160            $deat_by_decade[$year] = '';
161        }
162
163        $html = '
164			<div class="loading-image"></div>
165			<div class="indi-list">
166				<table id="' . $table_id . '">
167					<thead>
168						<tr>
169							<th colspan="16">
170								<div class="btn-toolbar">
171									<div class="btn-group">
172										<button
173											class="ui-state-default"
174											data-filter-column="12"
175											data-filter-value="M"
176											title="' . I18N::translate('Show only males.') . '"
177											type="button"
178										>
179										  ' . Individual::sexImage('M', 'large') . '
180										</button>
181										<button
182											class="ui-state-default"
183											data-filter-column="12"
184											data-filter-value="F"
185											title="' . I18N::translate('Show only females.') . '"
186											type="button"
187										>
188											' . Individual::sexImage('F', 'large') . '
189										</button>
190										<button
191											class="ui-state-default"
192											data-filter-column="12"
193											data-filter-value="U"
194											title="' . I18N::translate('Show only individuals for whom the gender is not known.') . '"
195											type="button"
196										>
197											' . Individual::sexImage('U', 'large') . '
198										</button>
199									</div>
200									<div class="btn-group">
201										<button
202											class="ui-state-default"
203											data-filter-column="14"
204											data-filter-value="N"
205											title="' . I18N::translate('Show individuals who are alive or couples where both partners are alive.') . '"
206											type="button"
207										>
208											' . I18N::translate('Alive') . '
209										</button>
210										<button
211											class="ui-state-default"
212											data-filter-column="14"
213											data-filter-value="Y"
214											title="' . I18N::translate('Show individuals who are dead or couples where both partners are dead.') . '"
215											type="button"
216										>
217											' . I18N::translate('Dead') . '
218										</button>
219										<button
220											class="ui-state-default"
221											data-filter-column="14"
222											data-filter-value="YES"
223											title="' . I18N::translate('Show individuals who died more than 100 years ago.') . '"
224											type="button"
225										>
226											' . GedcomTag::getLabel('DEAT') . '&gt;100
227										</button>
228										<button
229											class="ui-state-default"
230											data-filter-column="14"
231											data-filter-value="Y100"
232											title="' . I18N::translate('Show individuals who died within the last 100 years.') . '"
233											type="button"
234										>
235											' . GedcomTag::getLabel('DEAT') . '&lt;=100
236										</button>
237									</div>
238									<div class="btn-group">
239										<button
240											class="ui-state-default"
241											data-filter-column="13"
242											data-filter-value="YES"
243											title="' . I18N::translate('Show individuals born more than 100 years ago.') . '"
244											type="button"
245										>
246											' . GedcomTag::getLabel('BIRT') . '&gt;100
247										</button>
248										<button
249											class="ui-state-default"
250											data-filter-column="13"
251											data-filter-value="Y100"
252											title="' . I18N::translate('Show individuals born within the last 100 years.') . '"
253											type="button"
254										>
255											' . GedcomTag::getLabel('BIRT') . '&lt;=100
256										</button>
257									</div>
258									<div class="btn-group">
259										<button
260											class="ui-state-default"
261											data-filter-column="15"
262											data-filter-value="R"
263											title="' . I18N::translate('Show “roots” couples or individuals. These individuals may also be called “patriarchs”. They are individuals who have no parents recorded in the database.') . '"
264											type="button"
265										>
266											' . I18N::translate('Roots') . '
267										</button>
268										<button
269											class="ui-state-default"
270											data-filter-column="15"
271											data-filter-value="L"
272											title="' . I18N::translate('Show “leaves” couples or individuals. These are individuals who are alive but have no children recorded in the database.') . '"
273											type="button"
274										>
275											' . I18N::translate('Leaves') . '
276										</button>
277									</div>
278								</div>
279							</th>
280						</tr>
281						<tr>
282							<th>' . GedcomTag::getLabel('GIVN') . '</th>
283							<th>' . GedcomTag::getLabel('SURN') . '</th>
284							<th>' . /* I18N: Abbreviation for “Sosa-Stradonitz number”. This is an individual’s surname, so may need transliterating into non-latin alphabets. */ I18N::translate('Sosa') . '</th>
285							<th>' . GedcomTag::getLabel('BIRT') . '</th>
286							<th><i class="icon-reminder" title="' . I18N::translate('Anniversary') . '"></i></th>
287							<th>' . GedcomTag::getLabel('PLAC') . '</th>
288							<th><i class="icon-children" title="' . I18N::translate('Children') . '"></i></th>
289							<th>' . GedcomTag::getLabel('DEAT') . '</th>
290							<th><i class="icon-reminder" title="' . I18N::translate('Anniversary') . '"></i></th>
291							<th>' . GedcomTag::getLabel('AGE') . '</th>
292							<th>' . GedcomTag::getLabel('PLAC') . '</th>
293							<th>' . GedcomTag::getLabel('CHAN') . '</th>
294							<th hidden></th>
295							<th hidden></th>
296							<th hidden></th>
297							<th hidden></th>
298						</tr>
299					</thead>
300					<tfoot>
301						<tr>
302							<th colspan="16">
303								<div class="btn-toolbar">
304									<div class="btn-group">
305										<button type="button" class="ui-state-default btn-toggle-parents">
306											' . I18N::translate('Show parents') . '
307										</button>
308										<button type="button" class="ui-state-default btn-toggle-statistics">
309											' . I18N::translate('Show statistics charts') . '
310										</button>
311									</div>
312								</div>
313							</th>
314						</tr>
315					</tfoot>
316					<tbody>';
317
318        $hundred_years_ago = new Date(date('Y') - 100);
319        $unique_indis      = array(); // Don't double-count indis with multiple names.
320
321        foreach ($indiviudals as $key => $individual) {
322            if (!$individual->canShowName()) {
323                continue;
324            }
325            if ($individual->isPendingAddtion()) {
326                $class = ' class="new"';
327            } elseif ($individual->isPendingDeletion()) {
328                $class = ' class="old"';
329            } else {
330                $class = '';
331            }
332            $html .= '<tr' . $class . '>';
333            // Extract Given names and Surnames for sorting
334            list($surn_givn, $givn_surn) = self::sortableNames($individual);
335
336            $html .= '<td colspan="2" data-sort="' . Filter::escapeHtml($givn_surn) . '">';
337            foreach ($individual->getAllNames() as $num => $name) {
338                if ($name['type'] == 'NAME') {
339                    $title = '';
340                } else {
341                    $title = 'title="' . strip_tags(GedcomTag::getLabel($name['type'], $individual)) . '"';
342                }
343                if ($num == $individual->getPrimaryName()) {
344                    $class             = ' class="name2"';
345                    $sex_image         = $individual->getSexImage();
346                } else {
347                    $class     = '';
348                    $sex_image = '';
349                }
350                $html .= '<a ' . $title . ' href="' . $individual->getHtmlUrl() . '"' . $class . '>' . FunctionsPrint::highlightSearchHits($name['full']) . '</a>' . $sex_image . '<br>';
351            }
352            $html .= $individual->getPrimaryParentsNames('parents details1', 'none');
353            $html .= '</td>';
354
355            // Hidden column for sortable name
356            $html .= '<td hidden data-sort="' . Filter::escapeHtml($surn_givn) . '"></td>';
357
358            // SOSA
359            $html .= '<td class="center" data-sort="' . $key . '">';
360            if ($option === 'sosa') {
361                $html .= '<a href="relationship.php?pid1=' . $indiviudals[1] . '&amp;pid2=' . $individual->getXref() . '" title="' . I18N::translate('Relationships') . '">' . I18N::number($key) . '</a>';
362            }
363            $html .= '</td>';
364
365            // Birth date
366            $birth_dates = $individual->getAllBirthDates();
367            $html .= '<td data-sort="' . $individual->getEstimatedBirthDate()->julianDay() . '">';
368            foreach ($birth_dates as $n => $birth_date) {
369                if ($n > 0) {
370                    $html .= '<br>';
371                }
372                $html .= $birth_date->display(true);
373            }
374            $html .= '</td>';
375
376            // Birth anniversary
377            if (isset($birth_dates[0]) && $birth_dates[0]->gregorianYear() >= 1550 && $birth_dates[0]->gregorianYear() < 2030 && !isset($unique_indis[$individual->getXref()])) {
378                $birt_by_decade[(int) ($birth_dates[0]->gregorianYear() / 10) * 10] .= $individual->getSex();
379                $anniversary = Date::getAge($birth_dates[0], null, 2);
380            } else {
381                $anniversary = '';
382            }
383            $html .= '<td class="center" data-sort="' . -$individual->getEstimatedBirthDate()->julianDay() . '">' . $anniversary . '</td>';
384
385            // Birth place
386            $html .= '<td>';
387            foreach ($individual->getAllBirthPlaces() as $n => $birth_place) {
388                $tmp = new Place($birth_place, $individual->getTree());
389                if ($n > 0) {
390                    $html .= '<br>';
391                }
392                $html .= '<a href="' . $tmp->getURL() . '" title="' . strip_tags($tmp->getFullName()) . '">';
393                $html .= FunctionsPrint::highlightSearchHits($tmp->getShortName()) . '</a>';
394            }
395            $html .= '</td>';
396
397            // Number of children
398            $number_of_children = $individual->getNumberOfChildren();
399            $html .= '<td class="center" data-sort="' . $number_of_children . '">' . I18N::number($number_of_children) . '</td>';
400
401            // Death date
402            $death_dates = $individual->getAllDeathDates();
403            $html .= '<td data-sort="' . $individual->getEstimatedDeathDate()->julianDay() . '">';
404            foreach ($death_dates as $num => $death_date) {
405                if ($num) {
406                    $html .= '<br>';
407                }
408                $html .= $death_date->display(true);
409            }
410            $html .= '</td>';
411
412            // Death anniversary
413            if (isset($death_dates[0]) && $death_dates[0]->gregorianYear() >= 1550 && $death_dates[0]->gregorianYear() < 2030 && !isset($unique_indis[$individual->getXref()])) {
414                $birt_by_decade[(int) ($death_dates[0]->gregorianYear() / 10) * 10] .= $individual->getSex();
415                $anniversary = Date::getAge($death_dates[0], null, 2);
416            } else {
417                $anniversary = '';
418            }
419            $html .= '<td class="center" data-sort="' . -$individual->getEstimatedDeathDate()->julianDay() . '">' . $anniversary . '</td>';
420
421            // Age at death
422            if (isset($birth_dates[0]) && isset($death_dates[0])) {
423                $age_at_death      = Date::getAge($birth_dates[0], $death_dates[0], 0);
424                $age_at_death_sort = Date::getAge($birth_dates[0], $death_dates[0], 2);
425                if (!isset($unique_indis[$individual->getXref()]) && $age >= 0 && $age <= $max_age) {
426                    $deat_by_age[$age_at_death] .= $individual->getSex();
427                }
428            } else {
429                $age_at_death      = '';
430                $age_at_death_sort = PHP_INT_MAX;
431            }
432            $html .= '<td class="center" data-sort="' . $age_at_death_sort . '">' . $age_at_death . '</td>';
433
434            // Death place
435            $html .= '<td>';
436            foreach ($individual->getAllDeathPlaces() as $n => $death_place) {
437                $tmp = new Place($death_place, $individual->getTree());
438                if ($n > 0) {
439                    $html .= '<br>';
440                }
441                $html .= '<a href="' . $tmp->getURL() . '" title="' . strip_tags($tmp->getFullName()) . '">';
442                $html .= FunctionsPrint::highlightSearchHits($tmp->getShortName()) . '</a>';
443            }
444            $html .= '</td>';
445
446            // Last change
447            $html .= '<td data-sort="' . $individual->lastChangeTimestamp(true) . '">' . $individual->lastChangeTimestamp() . '</td>';
448
449            // Filter by sex
450            $html .= '<td hidden>' . $individual->getSex() . '</td>';
451
452            // Filter by birth date
453            $html .= '<td hidden>';
454            if (!$individual->canShow() || Date::compare($individual->getEstimatedBirthDate(), $hundred_years_ago) > 0) {
455                $html .= 'Y100';
456            } else {
457                $html .= 'YES';
458            }
459            $html .= '</td>';
460
461            // Filter by death date
462            $html .= '<td hidden>';
463            // Died in last 100 years? Died? Not dead?
464            if (isset($death_dates[0]) && Date::compare($death_dates[0], $hundred_years_ago) > 0) {
465                $html .= 'Y100';
466            } elseif ($individual->isDead()) {
467                $html .= 'YES';
468            } else {
469                $html .= 'N';
470            }
471            $html .= '</td>';
472
473            // Filter by roots/leaves
474            $html .= '<td hidden>';
475            if (!$individual->getChildFamilies()) {
476                $html .= 'R';
477            } elseif (!$individual->isDead() && $individual->getNumberOfChildren() < 1) {
478                $html .= 'L';
479                $html .= '&nbsp;';
480            }
481            $html .= '</td>';
482            $html .= '</tr>';
483
484            $unique_indis[$individual->getXref()] = true;
485        }
486        $html .= '
487					</tbody>
488				</table>
489				<div id="indi_list_table-charts_' . $table_id . '" style="display:none">
490					<table class="list-charts">
491						<tr>
492							<td>
493								' . self::chartByDecade($birt_by_decade, I18N::translate('Decade of birth')) . '
494							</td>
495							<td>
496								' . self::chartByDecade($deat_by_decade, I18N::translate('Decade of death')) . '
497							</td>
498						</tr>
499						<tr>
500							<td colspan="2">
501								' . self::chartByAge($deat_by_age, I18N::translate('Age related to death year')) . '
502							</td>
503						</tr>
504					</table>
505				</div>
506			</div>';
507
508        return $html;
509    }
510
511    /**
512     * Print a table of families
513     *
514     * @param Family[] $families
515     *
516     * @return string
517     */
518    public static function familyTable($families)
519    {
520        global $WT_TREE, $controller;
521
522        $table_id = 'table-fam-' . Uuid::uuid4(); // lists requires a unique ID in case there are multiple lists per page
523
524        $controller
525            ->addExternalJavascript(WT_JQUERY_DATATABLES_JS_URL)
526            ->addInlineJavascript('
527				jQuery.fn.dataTableExt.oSort["text-asc"] = textCompareAsc;
528				jQuery.fn.dataTableExt.oSort["text-desc"] = textCompareDesc;
529				jQuery("#' . $table_id . '").dataTable( {
530					dom: \'<"H"<"filtersH_' . $table_id . '"><"dt-clear">pf<"dt-clear">irl>t<"F"pl<"dt-clear"><"filtersF_' . $table_id . '">>\',
531					' . I18N::datatablesI18N() . ',
532					jQueryUI: true,
533					autoWidth: false,
534					processing: true,
535					retrieve: true,
536					columns: [
537						/* Given names         */ { type: "text" },
538						/* Surnames            */ { type: "text" },
539						/* Age                 */ { type: "num" },
540						/* Given names         */ { type: "text" },
541						/* Surnames            */ { type: "text" },
542						/* Age                 */ { type: "num" },
543						/* Marriage date       */ { type: "num" },
544						/* Anniversary         */ { type: "num" },
545						/* Marriage place      */ { type: "text" },
546						/* Children            */ { type: "num" },
547						/* Last change         */ { visible: ' . ($WT_TREE->getPreference('SHOW_LAST_CHANGE') ? 'true' : 'false') . ' },
548						/* Filter marriage     */ { sortable: false },
549						/* Filter alive/dead   */ { sortable: false },
550						/* Filter tree         */ { sortable: false }
551					],
552					sorting: [[1, "asc"]],
553					displayLength: 20,
554					pagingType: "full_numbers"
555			   });
556
557				jQuery("#' . $table_id . '")
558				/* Hide/show parents */
559				.on("click", ".btn-toggle-parents", function() {
560					jQuery(this).toggleClass("ui-state-active");
561					jQuery(".parents", jQuery(this).closest("table").DataTable().rows().nodes()).slideToggle();
562				})
563				/* Hide/show statistics */
564				.on("click",  ".btn-toggle-statistics", function() {
565					jQuery(this).toggleClass("ui-state-active");
566					jQuery("#fam_list_table-charts_' . $table_id . '").slideToggle();
567				})
568				/* Filter buttons in table header */
569				.on("click", "button[data-filter-column]", function() {
570					var btn = $(this);
571					// De-activate the other buttons in this button group
572					btn.siblings().removeClass("ui-state-active");
573					// Apply (or clear) this filter
574					var col = jQuery("#' . $table_id . '").DataTable().column(btn.data("filter-column"));
575					if (btn.hasClass("ui-state-active")) {
576						btn.removeClass("ui-state-active");
577						col.search("").draw();
578					} else {
579						btn.addClass("ui-state-active");
580						col.search(btn.data("filter-value")).draw();
581					}
582				});
583
584				jQuery(".fam-list").css("visibility", "visible");
585				jQuery(".loading-image").css("display", "none");
586		');
587
588        $max_age = (int) $WT_TREE->getPreference('MAX_ALIVE_AGE');
589
590        // init chart data
591        $marr_by_age = array();
592        for ($age = 0; $age <= $max_age; $age++) {
593            $marr_by_age[$age] = '';
594        }
595        $birt_by_decade = array();
596        $marr_by_decade = array();
597        for ($year = 1550; $year < 2030; $year += 10) {
598            $birt_by_decade[$year] = '';
599            $marr_by_decade[$year] = '';
600        }
601
602        $html = '
603			<div class="loading-image"></div>
604			<div class="fam-list">
605				<table id="' . $table_id . '">
606					<thead>
607						<tr>
608							<th colspan="14">
609								<div class="btn-toolbar">
610									<div class="btn-group">
611										<button
612											type="button"
613											data-filter-column="12"
614											data-filter-value="N"
615											class="ui-state-default"
616											title="' . I18N::translate('Show individuals who are alive or couples where both partners are alive.') . '"
617										>
618											' . I18N::translate('Both alive') . '
619										</button>
620										<button
621											type="button"
622											data-filter-column="12"
623											data-filter-value="W"
624											class="ui-state-default"
625											title="' . I18N::translate('Show couples where only the female partner is dead.') . '"
626										>
627											' . I18N::translate('Widower') . '
628										</button>
629										<button
630											type="button"
631											data-filter-column="12"
632											data-filter-value="H"
633											class="ui-state-default"
634											title="' . I18N::translate('Show couples where only the male partner is dead.') . '"
635										>
636											' . I18N::translate('Widow') . '
637										</button>
638										<button
639											type="button"
640											data-filter-column="12"
641											data-filter-value="Y"
642											class="ui-state-default"
643											title="' . I18N::translate('Show individuals who are dead or couples where both partners are dead.') . '"
644										>
645											' . I18N::translate('Both dead') . '
646										</button>
647									</div>
648									<div class="btn-group">
649										<button
650											type="button"
651											data-filter-column="13"
652											data-filter-value="R"
653											class="ui-state-default"
654											title="' . I18N::translate('Show “roots” couples or individuals. These individuals may also be called “patriarchs”. They are individuals who have no parents recorded in the database.') . '"
655										>
656											' . I18N::translate('Roots') . '
657										</button>
658										<button
659											type="button"
660											data-filter-column="13"
661											data-filter-value="L"
662											class="ui-state-default"
663											title="' . I18N::translate('Show “leaves” couples or individuals. These are individuals who are alive but have no children recorded in the database.') . '"
664										>
665											' . I18N::translate('Leaves') . '
666										</button>
667									</div>
668									<div class="btn-group">
669										<button
670											type="button"
671											data-filter-column="11"
672											data-filter-value="U"
673											class="ui-state-default"
674											title="' . I18N::translate('Show couples with an unknown marriage date.') . '"
675										>
676											' . GedcomTag::getLabel('MARR') . '
677										</button>
678										<button
679											type="button"
680											data-filter-column="11"
681											data-filter-value="YES"
682											class="ui-state-default"
683											title="' . I18N::translate('Show couples who married more than 100 years ago.') . '"
684										>
685											' . GedcomTag::getLabel('MARR') . '&gt;100
686										</button>
687										<button
688											type="button"
689											data-filter-column="11"
690											data-filter-value="Y100"
691											class="ui-state-default"
692											title="' . I18N::translate('Show couples who married within the last 100 years.') . '"
693										>
694											' . GedcomTag::getLabel('MARR') . '&lt;=100
695										</button>
696										<button
697											type="button"
698											data-filter-column="11"
699											data-filter-value="D"
700											class="ui-state-default"
701											title="' . I18N::translate('Show divorced couples.') . '"
702										>
703											' . GedcomTag::getLabel('DIV') . '
704										</button>
705										<button
706											type="button"
707											data-filter-column="11"
708											data-filter-value="M"
709											class="ui-state-default"
710											title="' . I18N::translate('Show couples where either partner married more than once.') . '"
711										>
712											' . I18N::translate('Multiple marriages') . '
713										</button>
714									</div>
715								</div>
716							</th>
717						</tr>
718						<tr>
719							<th>' . GedcomTag::getLabel('GIVN') . '</th>
720							<th>' . GedcomTag::getLabel('SURN') . '</th>
721							<th>' . GedcomTag::getLabel('AGE') . '</th>
722							<th>' . GedcomTag::getLabel('GIVN') . '</th>
723							<th>' . GedcomTag::getLabel('SURN') . '</th>
724							<th>' . GedcomTag::getLabel('AGE') . '</th>
725							<th>' . GedcomTag::getLabel('MARR') . '</th>
726							<th><i class="icon-reminder" title="' . I18N::translate('Anniversary') . '"></i></th>
727							<th>' . GedcomTag::getLabel('PLAC') . '</th>
728							<th><i class="icon-children" title="' . I18N::translate('Children') . '"></i></th>
729							<th>' . GedcomTag::getLabel('CHAN') . '</th>
730							<th hidden></th>
731							<th hidden></th>
732							<th hidden></th>
733						</tr>
734					</thead>
735					<tfoot>
736						<tr>
737							<th colspan="14">
738								<div class="btn-toolbar">
739									<div class="btn-group">
740										<button type="button" class="ui-state-default btn-toggle-parents">
741											' . I18N::translate('Show parents') . '
742										</button>
743										<button type="button" class="ui-state-default btn-toggle-statistics">
744											' . I18N::translate('Show statistics charts') . '
745										</button>
746									</div>
747								</div>
748							</th>
749						</tr>
750					</tfoot>
751					<tbody>';
752
753        $hundred_years_ago = new Date(date('Y') - 100);
754
755        foreach ($families as $family) {
756            // Retrieve husband and wife
757            $husb = $family->getHusband();
758            if ($husb === null) {
759                $husb = new Individual('H', '0 @H@ INDI', null, $family->getTree());
760            }
761            $wife = $family->getWife();
762            if ($wife === null) {
763                $wife = new Individual('W', '0 @W@ INDI', null, $family->getTree());
764            }
765            if (!$family->canShow()) {
766                continue;
767            }
768            if ($family->isPendingAddtion()) {
769                $class = ' class="new"';
770            } elseif ($family->isPendingDeletion()) {
771                $class = ' class="old"';
772            } else {
773                $class = '';
774            }
775            $html .= '<tr' . $class . '>';
776            // Husband name(s)
777            // Extract Given names and Surnames for sorting
778            list($surn_givn, $givn_surn) = self::sortableNames($husb);
779
780            $html .= '<td colspan="2" data-sort="' . Filter::escapeHtml($givn_surn) . '">';
781            foreach ($husb->getAllNames() as $num => $name) {
782                if ($name['type'] == 'NAME') {
783                    $title = '';
784                } else {
785                    $title = 'title="' . strip_tags(GedcomTag::getLabel($name['type'], $husb)) . '"';
786                }
787                if ($num == $husb->getPrimaryName()) {
788                    $class             = ' class="name2"';
789                    $sex_image         = $husb->getSexImage();
790                } else {
791                    $class     = '';
792                    $sex_image = '';
793                }
794                // Only show married names if they are the name we are filtering by.
795                if ($name['type'] != '_MARNM' || $num == $husb->getPrimaryName()) {
796                    $html .= '<a ' . $title . ' href="' . $family->getHtmlUrl() . '"' . $class . '>' . FunctionsPrint::highlightSearchHits($name['full']) . '</a>' . $sex_image . '<br>';
797                }
798            }
799            // Husband parents
800            $html .= $husb->getPrimaryParentsNames('parents details1', 'none');
801            $html .= '</td>';
802
803            // Hidden column for sortable name
804            $html .= '<td hidden data-sort="' . Filter::escapeHtml($surn_givn) . '"></td>';
805
806            // Husband age
807            $mdate = $family->getMarriageDate();
808            $hdate = $husb->getBirthDate();
809            if ($hdate->isOK() && $mdate->isOK()) {
810                if ($hdate->gregorianYear() >= 1550 && $hdate->gregorianYear() < 2030) {
811                    $birt_by_decade[(int) ($hdate->gregorianYear() / 10) * 10] .= $husb->getSex();
812                }
813                $hage = Date::getAge($hdate, $mdate, 0);
814                if ($hage >= 0 && $hage <= $max_age) {
815                    $marr_by_age[$hage] .= $husb->getSex();
816                }
817            }
818            $html .= '<td class="center" data=-sort="' . Date::getAge($hdate, $mdate, 1) . '">' . Date::getAge($hdate, $mdate, 2) . '</td>';
819
820            // Wife name(s)
821            // Extract Given names and Surnames for sorting
822            list($surn_givn, $givn_surn) = self::sortableNames($wife);
823            $html .= '<td colspan="2" data-sort="' . Filter::escapeHtml($givn_surn) . '">';
824            foreach ($wife->getAllNames() as $num => $name) {
825                if ($name['type'] == 'NAME') {
826                    $title = '';
827                } else {
828                    $title = 'title="' . strip_tags(GedcomTag::getLabel($name['type'], $wife)) . '"';
829                }
830                if ($num == $wife->getPrimaryName()) {
831                    $class             = ' class="name2"';
832                    $sex_image         = $wife->getSexImage();
833                } else {
834                    $class     = '';
835                    $sex_image = '';
836                }
837                // Only show married names if they are the name we are filtering by.
838                if ($name['type'] != '_MARNM' || $num == $wife->getPrimaryName()) {
839                    $html .= '<a ' . $title . ' href="' . $family->getHtmlUrl() . '"' . $class . '>' . FunctionsPrint::highlightSearchHits($name['full']) . '</a>' . $sex_image . '<br>';
840                }
841            }
842            // Wife parents
843            $html .= $wife->getPrimaryParentsNames('parents details1', 'none');
844            $html .= '</td>';
845
846            // Hidden column for sortable name
847            $html .= '<td hidden data-sort="' . Filter::escapeHtml($surn_givn) . '"></td>';
848
849            // Wife age
850            $mdate = $family->getMarriageDate();
851            $wdate = $wife->getBirthDate();
852            if ($wdate->isOK() && $mdate->isOK()) {
853                if ($wdate->gregorianYear() >= 1550 && $wdate->gregorianYear() < 2030) {
854                    $birt_by_decade[(int) ($wdate->gregorianYear() / 10) * 10] .= $wife->getSex();
855                }
856                $wage = Date::getAge($wdate, $mdate, 0);
857                if ($wage >= 0 && $wage <= $max_age) {
858                    $marr_by_age[$wage] .= $wife->getSex();
859                }
860            }
861            $html .= '<td class="center" data-sort="' . Date::getAge($wdate, $mdate, 1) . '">' . Date::getAge($wdate, $mdate, 2) . '</td>';
862
863            // Marriage date
864            $html .= '<td data-sort="' . $family->getMarriageDate()->julianDay() . '">';
865            if ($marriage_dates = $family->getAllMarriageDates()) {
866                foreach ($marriage_dates as $n => $marriage_date) {
867                    if ($n) {
868                        $html .= '<br>';
869                    }
870                    $html .= '<div>' . $marriage_date->display(true) . '</div>';
871                }
872                if ($marriage_dates[0]->gregorianYear() >= 1550 && $marriage_dates[0]->gregorianYear() < 2030) {
873                    $marr_by_decade[(int) ($marriage_dates[0]->gregorianYear() / 10) * 10] .= $husb->getSex() . $wife->getSex();
874                }
875            } elseif ($family->getFacts('_NMR')) {
876                $html .= I18N::translate('no');
877            } elseif ($family->getFacts('MARR')) {
878                $html .= I18N::translate('yes');
879            } else {
880                $html .= '&nbsp;';
881            }
882            $html .= '</td>';
883
884            // Marriage anniversary
885            $html .= '<td class="center" data-sort="' . -$family->getMarriageDate()->julianDay() . '">' . Date::getAge($family->getMarriageDate(), null, 2) . '</td>';
886
887            // Marriage place
888            $html .= '<td>';
889            foreach ($family->getAllMarriagePlaces() as $n => $marriage_place) {
890                $tmp = new Place($marriage_place, $family->getTree());
891                if ($n) {
892                    $html .= '<br>';
893                }
894                $html .= '<a href="' . $tmp->getURL() . '" title="' . strip_tags($tmp->getFullName()) . '">';
895                $html .= FunctionsPrint::highlightSearchHits($tmp->getShortName()) . '</a>';
896            }
897            $html .= '</td>';
898
899            // Number of children
900            $html .= '<td class="center" data-sort="' . $family->getNumberOfChildren() . '">' . I18N::number($family->getNumberOfChildren()) . '</td>';
901
902            // Last change
903            $html .= '<td data-sort="' . $family->lastChangeTimestamp(true) . '">' . $family->lastChangeTimestamp() . '</td>';
904
905            // Filter by marriage date
906            $html .= '<td hidden>';
907            if (!$family->canShow() || !$mdate->isOK()) {
908                $html .= 'U';
909            } else {
910                if (Date::compare($mdate, $hundred_years_ago) > 0) {
911                    $html .= 'Y100';
912                } else {
913                    $html .= 'YES';
914                }
915            }
916            if ($family->getFacts(WT_EVENTS_DIV)) {
917                $html .= 'D';
918            }
919            if (count($husb->getSpouseFamilies()) > 1 || count($wife->getSpouseFamilies()) > 1) {
920                $html .= 'M';
921            }
922            $html .= '</td>';
923
924            // Filter by alive/dead
925            $html .= '<td hidden>';
926            if ($husb->isDead() && $wife->isDead()) {
927                $html .= 'Y';
928            }
929            if ($husb->isDead() && !$wife->isDead()) {
930                if ($wife->getSex() == 'F') {
931                    $html .= 'H';
932                }
933                if ($wife->getSex() == 'M') {
934                    $html .= 'W';
935                } // male partners
936            }
937            if (!$husb->isDead() && $wife->isDead()) {
938                if ($husb->getSex() == 'M') {
939                    $html .= 'W';
940                }
941                if ($husb->getSex() == 'F') {
942                    $html .= 'H';
943                } // female partners
944            }
945            if (!$husb->isDead() && !$wife->isDead()) {
946                $html .= 'N';
947            }
948            $html .= '</td>';
949
950            // Filter by roots/leaves
951            $html .= '<td hidden>';
952            if (!$husb->getChildFamilies() && !$wife->getChildFamilies()) {
953                $html .= 'R';
954            } elseif (!$husb->isDead() && !$wife->isDead() && $family->getNumberOfChildren() === 0) {
955                $html .= 'L';
956            }
957            $html .= '</td>
958			</tr>';
959        }
960
961        $html .= '
962					</tbody>
963				</table>
964				<div id="fam_list_table-charts_' . $table_id . '" style="display:none">
965					<table class="list-charts">
966						<tr>
967							<td>' . self::chartByDecade($birt_by_decade, I18N::translate('Decade of birth')) . '</td>
968							<td>' . self::chartByDecade($marr_by_decade, I18N::translate('Decade of marriage')) . '</td>
969						</tr>
970						<tr>
971							<td colspan="2">' . self::chartByAge($marr_by_age, I18N::translate('Age in year of marriage')) . '</td>
972						</tr>
973					</table>
974				</div>
975			</div>';
976
977        return $html;
978    }
979
980    /**
981     * Print a table of sources
982     *
983     * @param Source[] $sources
984     *
985     * @return string
986     */
987    public static function sourceTable($sources)
988    {
989        global $WT_TREE, $controller;
990
991        // Count the number of linked records. These numbers include private records.
992        // It is not good to bypass privacy, but many servers do not have the resources
993        // to process privacy for every record in the tree
994        $count_individuals = Database::prepare(
995            "SELECT CONCAT(l_to, '@', l_file), COUNT(*) FROM `##individuals` JOIN `##link` ON l_from = i_id AND l_file = i_file AND l_type = 'SOUR' GROUP BY l_to, l_file"
996        )->fetchAssoc();
997        $count_families = Database::prepare(
998            "SELECT CONCAT(l_to, '@', l_file), COUNT(*) FROM `##families` JOIN `##link` ON l_from = f_id AND l_file = f_file AND l_type = 'SOUR' GROUP BY l_to, l_file"
999        )->fetchAssoc();
1000        $count_media = Database::prepare(
1001            "SELECT CONCAT(l_to, '@', l_file), COUNT(*) FROM `##media` JOIN `##link` ON l_from = m_id AND l_file = m_file AND l_type = 'SOUR' GROUP BY l_to, l_file"
1002        )->fetchAssoc();
1003        $count_notes = Database::prepare(
1004            "SELECT CONCAT(l_to, '@', l_file), COUNT(*) FROM `##other` JOIN `##link` ON l_from = o_id AND l_file = o_file AND o_type = 'NOTE' AND l_type = 'SOUR' GROUP BY l_to, l_file"
1005        )->fetchAssoc();
1006
1007        $html     = '';
1008        $table_id = 'table-sour-' . Uuid::uuid4(); // lists requires a unique ID in case there are multiple lists per page
1009        $controller
1010            ->addExternalJavascript(WT_JQUERY_DATATABLES_JS_URL)
1011            ->addInlineJavascript('
1012				jQuery.fn.dataTableExt.oSort["text-asc"] = textCompareAsc;
1013				jQuery.fn.dataTableExt.oSort["text-desc"] = textCompareDesc;
1014				jQuery("#' . $table_id . '").dataTable( {
1015					dom: \'<"H"pf<"dt-clear">irl>t<"F"pl>\',
1016					' . I18N::datatablesI18N() . ',
1017					jQueryUI: true,
1018					autoWidth: false,
1019					processing: true,
1020					columns: [
1021						/* Title         */ { type: "text" },
1022						/* Author        */ { type: "text" },
1023						/* Individuals   */ { type: "num" },
1024						/* Families      */ { type: "num" },
1025						/* Media objects */ { type: "num" },
1026						/* Notes         */ { type: "num" },
1027						/* Last change   */ { visible: ' . ($WT_TREE->getPreference('SHOW_LAST_CHANGE') ? 'true' : 'false') . ' },
1028						/* Delete        */ { visible: ' . (Auth::isManager($WT_TREE) ? 'true' : 'false') . ', sortable: false }
1029					],
1030					displayLength: 20,
1031					pagingType: "full_numbers"
1032			   });
1033				jQuery(".source-list").css("visibility", "visible");
1034				jQuery(".loading-image").css("display", "none");
1035			');
1036
1037        $html .= '<div class="loading-image"></div>';
1038        $html .= '<div class="source-list">';
1039        $html .= '<table id="' . $table_id . '"><thead><tr>';
1040        $html .= '<th>' . GedcomTag::getLabel('TITL') . '</th>';
1041        $html .= '<th>' . GedcomTag::getLabel('AUTH') . '</th>';
1042        $html .= '<th>' . I18N::translate('Individuals') . '</th>';
1043        $html .= '<th>' . I18N::translate('Families') . '</th>';
1044        $html .= '<th>' . I18N::translate('Media objects') . '</th>';
1045        $html .= '<th>' . I18N::translate('Shared notes') . '</th>';
1046        $html .= '<th>' . GedcomTag::getLabel('CHAN') . '</th>';
1047        $html .= '<th>' . I18N::translate('Delete') . '</th>';
1048        $html .= '</tr></thead>';
1049        $html .= '<tbody>';
1050
1051        foreach ($sources as $source) {
1052            if (!$source->canShow()) {
1053                continue;
1054            }
1055            if ($source->isPendingAddtion()) {
1056                $class = ' class="new"';
1057            } elseif ($source->isPendingDeletion()) {
1058                $class = ' class="old"';
1059            } else {
1060                $class = '';
1061            }
1062            $html .= '<tr' . $class . '>';
1063            // Source name(s)
1064            $html .= '<td data-sort="' . Filter::escapeHtml($source->getSortName()) . '">';
1065            foreach ($source->getAllNames() as $n => $name) {
1066                if ($n) {
1067                    $html .= '<br>';
1068                }
1069                if ($n == $source->getPrimaryName()) {
1070                    $html .= '<a class="name2" href="' . $source->getHtmlUrl() . '">' . FunctionsPrint::highlightSearchHits($name['full']) . '</a>';
1071                } else {
1072                    $html .= '<a href="' . $source->getHtmlUrl() . '">' . FunctionsPrint::highlightSearchHits($name['full']) . '</a>';
1073                }
1074            }
1075            $html .= '</td>';
1076            // Author
1077            $auth = $source->getFirstFact('AUTH');
1078            if ($auth) {
1079                $author = $auth->getValue();
1080            } else {
1081                $author = '';
1082            }
1083            $html .= '<td data-sort="' . Filter::escapeHtml($author) . '">' . FunctionsPrint::highlightSearchHits($author) . '</td>';
1084            $key = $source->getXref() . '@' . $source->getTree()->getTreeId();
1085            // Count of linked individuals
1086            $num = array_key_exists($key, $count_individuals) ? $count_individuals[$key] : 0;
1087            $html .= '<td class="center" data-sort="' . $num . '">' . I18N::number($num) . '</td>';
1088            // Count of linked families
1089            $num = array_key_exists($key, $count_families) ? $count_families[$key] : 0;
1090            $html .= '<td class="center" data-sort="' . $num . '">' . I18N::number($num) . '</td>';
1091            // Count of linked media objects
1092            $num = array_key_exists($key, $count_media) ? $count_media[$key] : 0;
1093            $html .= '<td class="center" data-sort="' . $num . '">' . I18N::number($num) . '</td>';
1094            // Count of linked notes
1095            $num = array_key_exists($key, $count_notes) ? $count_notes[$key] : 0;
1096            $html .= '<td class="center" data-sort="' . $num . '">' . I18N::number($num) . '</td>';
1097            // Last change
1098            $html .= '<td data-sort="' . $source->lastChangeTimestamp(true) . '">' . $source->lastChangeTimestamp() . '</td>';
1099            // Delete
1100            $html .= '<td><a href="#" title="' . I18N::translate('Delete') . '" class="deleteicon" onclick="return delete_record(\'' . I18N::translate('Are you sure you want to delete “%s”?', Filter::escapeJs(Filter::unescapeHtml($source->getFullName()))) . "', '" . $source->getXref() . '\');"><span class="link_text">' . I18N::translate('Delete') . '</span></a></td>';
1101            $html .= '</tr>';
1102        }
1103        $html .= '</tbody></table></div>';
1104
1105        return $html;
1106    }
1107
1108    /**
1109     * Print a table of shared notes
1110     *
1111     * @param Note[] $notes
1112     *
1113     * @return string
1114     */
1115    public static function noteTable($notes)
1116    {
1117        global $WT_TREE, $controller;
1118
1119        // Count the number of linked records. These numbers include private records.
1120        // It is not good to bypass privacy, but many servers do not have the resources
1121        // to process privacy for every record in the tree
1122        $count_individuals = Database::prepare(
1123            "SELECT CONCAT(l_to, '@', l_file), COUNT(*) FROM `##individuals` JOIN `##link` ON l_from = i_id AND l_file = i_file AND l_type = 'NOTE' GROUP BY l_to, l_file"
1124        )->fetchAssoc();
1125        $count_families = Database::prepare(
1126            "SELECT CONCAT(l_to, '@', l_file), COUNT(*) FROM `##families` JOIN `##link` ON l_from = f_id AND l_file = f_file AND l_type = 'NOTE' GROUP BY l_to, l_file"
1127        )->fetchAssoc();
1128        $count_media = Database::prepare(
1129            "SELECT CONCAT(l_to, '@', l_file), COUNT(*) FROM `##media` JOIN `##link` ON l_from = m_id AND l_file = m_file AND l_type = 'NOTE' GROUP BY l_to, l_file"
1130        )->fetchAssoc();
1131        $count_sources = Database::prepare(
1132            "SELECT CONCAT(l_to, '@', l_file), COUNT(*) FROM `##sources` JOIN `##link` ON l_from = s_id AND l_file = s_file AND l_type = 'NOTE' GROUP BY l_to, l_file"
1133        )->fetchAssoc();
1134
1135        $html     = '';
1136        $table_id = 'table-note-' . Uuid::uuid4(); // lists requires a unique ID in case there are multiple lists per page
1137        $controller
1138            ->addExternalJavascript(WT_JQUERY_DATATABLES_JS_URL)
1139            ->addInlineJavascript('
1140				jQuery.fn.dataTableExt.oSort["text-asc"] = textCompareAsc;
1141				jQuery.fn.dataTableExt.oSort["text-desc"] = textCompareDesc;
1142				jQuery("#' . $table_id . '").dataTable({
1143					dom: \'<"H"pf<"dt-clear">irl>t<"F"pl>\',
1144					' . I18N::datatablesI18N() . ',
1145					jQueryUI: true,
1146					autoWidth: false,
1147					processing: true,
1148					columns: [
1149						/* Title         */ { type: "text" },
1150						/* Individuals   */ { type: "num" },
1151						/* Families      */ { type: "num" },
1152						/* Media objects */ { type: "num" },
1153						/* Sources       */ { type: "num" },
1154						/* Last change   */ { type: "num", visible: ' . ($WT_TREE->getPreference('SHOW_LAST_CHANGE') ? 'true' : 'false') . ' },
1155						/* Delete        */ { visible: ' . (Auth::isManager($WT_TREE) ? 'true' : 'false') . ', sortable: false }
1156					],
1157					displayLength: 20,
1158					pagingType: "full_numbers"
1159				});
1160				jQuery(".note-list").css("visibility", "visible");
1161				jQuery(".loading-image").css("display", "none");
1162			');
1163
1164        $html .= '<div class="loading-image"></div>';
1165        $html .= '<div class="note-list">';
1166        $html .= '<table id="' . $table_id . '"><thead><tr>';
1167        $html .= '<th>' . GedcomTag::getLabel('TITL') . '</th>';
1168        $html .= '<th>' . I18N::translate('Individuals') . '</th>';
1169        $html .= '<th>' . I18N::translate('Families') . '</th>';
1170        $html .= '<th>' . I18N::translate('Media objects') . '</th>';
1171        $html .= '<th>' . I18N::translate('Sources') . '</th>';
1172        $html .= '<th>' . GedcomTag::getLabel('CHAN') . '</th>';
1173        $html .= '<th>' . I18N::translate('Delete') . '</th>';
1174        $html .= '</tr></thead>';
1175        $html .= '<tbody>';
1176
1177        foreach ($notes as $note) {
1178            if (!$note->canShow()) {
1179                continue;
1180            }
1181            if ($note->isPendingAddtion()) {
1182                $class = ' class="new"';
1183            } elseif ($note->isPendingDeletion()) {
1184                $class = ' class="old"';
1185            } else {
1186                $class = '';
1187            }
1188            $html .= '<tr' . $class . '>';
1189            // Count of linked notes
1190            $html .= '<td data-sort="' . Filter::escapeHtml($note->getSortName()) . '"><a class="name2" href="' . $note->getHtmlUrl() . '">' . FunctionsPrint::highlightSearchHits($note->getFullName()) . '</a></td>';
1191            $key = $note->getXref() . '@' . $note->getTree()->getTreeId();
1192            // Count of linked individuals
1193            $num = array_key_exists($key, $count_individuals) ? $count_individuals[$key] : 0;
1194            $html .= '<td class="center" data-sort="' . $num . '">' . I18N::number($num) . '</td>';
1195            // Count of linked families
1196            $num = array_key_exists($key, $count_families) ? $count_families[$key] : 0;
1197            $html .= '<td class="center" data-sort="' . $num . '">' . I18N::number($num) . '</td>';
1198            // Count of linked media objects
1199            $num = array_key_exists($key, $count_media) ? $count_media[$key] : 0;
1200            $html .= '<td class="center" data-sort="' . $num . '">' . I18N::number($num) . '</td>';
1201            // Count of linked sources
1202            $num = array_key_exists($key, $count_sources) ? $count_sources[$key] : 0;
1203            $html .= '<td class="center" data-sort="' . $num . '">' . I18N::number($num) . '</td>';
1204            // Last change
1205            $html .= '<td data-sort="' . $note->lastChangeTimestamp(true) . '">' . $note->lastChangeTimestamp() . '</td>';
1206            // Delete
1207            $html .= '<td><a href="#" title="' . I18N::translate('Delete') . '" class="deleteicon" onclick="return delete_record(\'' . I18N::translate('Are you sure you want to delete “%s”?', Filter::escapeJs(Filter::unescapeHtml($note->getFullName()))) . "', '" . $note->getXref() . '\');"><span class="link_text">' . I18N::translate('Delete') . '</span></a></td>';
1208            $html .= '</tr>';
1209        }
1210        $html .= '</tbody></table></div>';
1211
1212        return $html;
1213    }
1214
1215    /**
1216     * Print a table of repositories
1217     *
1218     * @param Repository[] $repositories
1219     *
1220     * @return string
1221     */
1222    public static function repositoryTable($repositories)
1223    {
1224        global $WT_TREE, $controller;
1225
1226        // Count the number of linked records. These numbers include private records.
1227        // It is not good to bypass privacy, but many servers do not have the resources
1228        // to process privacy for every record in the tree
1229        $count_sources = Database::prepare(
1230            "SELECT CONCAT(l_to, '@', l_file), COUNT(*) FROM `##sources` JOIN `##link` ON l_from = s_id AND l_file = s_file AND l_type = 'REPO' GROUP BY l_to, l_file"
1231        )->fetchAssoc();
1232
1233        $html     = '';
1234        $table_id = 'table-repo-' . Uuid::uuid4(); // lists requires a unique ID in case there are multiple lists per page
1235        $controller
1236            ->addExternalJavascript(WT_JQUERY_DATATABLES_JS_URL)
1237            ->addInlineJavascript('
1238				jQuery.fn.dataTableExt.oSort["text-asc"] = textCompareAsc;
1239				jQuery.fn.dataTableExt.oSort["text-desc"] = textCompareDesc;
1240				jQuery("#' . $table_id . '").dataTable({
1241					dom: \'<"H"pf<"dt-clear">irl>t<"F"pl>\',
1242					' . I18N::datatablesI18N() . ',
1243					jQueryUI: true,
1244					autoWidth: false,
1245					processing: true,
1246					columns: [
1247						/* Name        */ { type: "text" },
1248						/* Sources     */ { type: "num" },
1249						/* Last change */ { visible: ' . ($WT_TREE->getPreference('SHOW_LAST_CHANGE') ? 'true' : 'false') . ' },
1250						/* Delete      */ { visible: ' . (Auth::isManager($WT_TREE) ? 'true' : 'false') . ', sortable: false }
1251					],
1252					displayLength: 20,
1253					pagingType: "full_numbers"
1254				});
1255				jQuery(".repo-list").css("visibility", "visible");
1256				jQuery(".loading-image").css("display", "none");
1257			');
1258
1259        $html .= '<div class="loading-image"></div>';
1260        $html .= '<div class="repo-list">';
1261        $html .= '<table id="' . $table_id . '"><thead><tr>';
1262        $html .= '<th>' . I18N::translate('Repository name') . '</th>';
1263        $html .= '<th>' . I18N::translate('Sources') . '</th>';
1264        $html .= '<th>' . GedcomTag::getLabel('CHAN') . '</th>';
1265        $html .= '<th>' . I18N::translate('Delete') . '</th>';
1266        $html .= '</tr></thead>';
1267        $html .= '<tbody>';
1268
1269        foreach ($repositories as $repository) {
1270            if (!$repository->canShow()) {
1271                continue;
1272            }
1273            if ($repository->isPendingAddtion()) {
1274                $class = ' class="new"';
1275            } elseif ($repository->isPendingDeletion()) {
1276                $class = ' class="old"';
1277            } else {
1278                $class = '';
1279            }
1280            $html .= '<tr' . $class . '>';
1281            // Repository name(s)
1282            $html .= '<td data-sort="' . Filter::escapeHtml($repository->getSortName()) . '">';
1283            foreach ($repository->getAllNames() as $n => $name) {
1284                if ($n) {
1285                    $html .= '<br>';
1286                }
1287                if ($n == $repository->getPrimaryName()) {
1288                    $html .= '<a class="name2" href="' . $repository->getHtmlUrl() . '">' . FunctionsPrint::highlightSearchHits($name['full']) . '</a>';
1289                } else {
1290                    $html .= '<a href="' . $repository->getHtmlUrl() . '">' . FunctionsPrint::highlightSearchHits($name['full']) . '</a>';
1291                }
1292            }
1293            $html .= '</td>';
1294            $key = $repository->getXref() . '@' . $repository->getTree()->getTreeId();
1295            // Count of linked sources
1296            $num = array_key_exists($key, $count_sources) ? $count_sources[$key] : 0;
1297            $html .= '<td class="center" data-sort="' . $num . '">' . I18N::number($num) . '</td>';
1298            // Last change
1299            $html .= '<td data-sort="' . $repository->lastChangeTimestamp(true) . '">' . $repository->lastChangeTimestamp() . '</td>';
1300            // Delete
1301            $html .= '<td><a href="#" title="' . I18N::translate('Delete') . '" class="deleteicon" onclick="return delete_record(\'' . I18N::translate('Are you sure you want to delete “%s”?', Filter::escapeJs(Filter::unescapeHtml($repository->getFullName()))) . "', '" . $repository->getXref() . '\');"><span class="link_text">' . I18N::translate('Delete') . '</span></a></td>';
1302            $html .= '</tr>';
1303        }
1304        $html .= '</tbody></table></div>';
1305
1306        return $html;
1307    }
1308
1309    /**
1310     * Print a table of media objects
1311     *
1312     * @param Media[] $media_objects
1313     *
1314     * @return string
1315     */
1316    public static function mediaTable($media_objects)
1317    {
1318        global $WT_TREE, $controller;
1319
1320        $html     = '';
1321        $table_id = 'table-obje-' . Uuid::uuid4(); // lists requires a unique ID in case there are multiple lists per page
1322        $controller
1323            ->addExternalJavascript(WT_JQUERY_DATATABLES_JS_URL)
1324            ->addInlineJavascript('
1325				jQuery.fn.dataTableExt.oSort["text-asc"] = textCompareAsc;
1326				jQuery.fn.dataTableExt.oSort["text-desc"] = textCompareDesc;
1327				jQuery("#' . $table_id . '").dataTable({
1328					dom: \'<"H"pf<"dt-clear">irl>t<"F"pl>\',
1329					' . I18N::datatablesI18N() . ',
1330					jQueryUI: true,
1331					autoWidth:false,
1332					processing: true,
1333					columns: [
1334						/* Thumbnail   */ { sortable: false },
1335						/* Title       */ { type: "text" },
1336						/* Individuals */ { type: "num" },
1337						/* Families    */ { type: "num" },
1338						/* Sources     */ { type: "num" },
1339						/* Last change */ { visible: ' . ($WT_TREE->getPreference('SHOW_LAST_CHANGE') ? 'true' : 'false') . ' },
1340					],
1341					displayLength: 20,
1342					pagingType: "full_numbers"
1343				});
1344				jQuery(".media-list").css("visibility", "visible");
1345				jQuery(".loading-image").css("display", "none");
1346			');
1347
1348        $html .= '<div class="loading-image"></div>';
1349        $html .= '<div class="media-list">';
1350        $html .= '<table id="' . $table_id . '"><thead><tr>';
1351        $html .= '<th>' . I18N::translate('Media') . '</th>';
1352        $html .= '<th>' . GedcomTag::getLabel('TITL') . '</th>';
1353        $html .= '<th>' . I18N::translate('Individuals') . '</th>';
1354        $html .= '<th>' . I18N::translate('Families') . '</th>';
1355        $html .= '<th>' . I18N::translate('Sources') . '</th>';
1356        $html .= '<th>' . GedcomTag::getLabel('CHAN') . '</th>';
1357        $html .= '</tr></thead>';
1358        $html .= '<tbody>';
1359
1360        foreach ($media_objects as $media_object) {
1361            if ($media_object->canShow()) {
1362                $name = $media_object->getFullName();
1363                if ($media_object->isPendingAddtion()) {
1364                    $class = ' class="new"';
1365                } elseif ($media_object->isPendingDeletion()) {
1366                    $class = ' class="old"';
1367                } else {
1368                    $class = '';
1369                }
1370                $html .= '<tr' . $class . '>';
1371                // Media object thumbnail
1372                $html .= '<td>' . $media_object->displayImage() . '</td>';
1373                // Media object name(s)
1374                $html .= '<td data-sort="' . Filter::escapeHtml($media_object->getSortName()) . '">';
1375                $html .= '<a href="' . $media_object->getHtmlUrl() . '" class="list_item name2">';
1376                $html .= FunctionsPrint::highlightSearchHits($name) . '</a>';
1377                if (Auth::isEditor($media_object->getTree())) {
1378                    $html .= '<br><a href="' . $media_object->getHtmlUrl() . '">' . basename($media_object->getFilename()) . '</a>';
1379                }
1380                $html .= '</td>';
1381
1382                // Count of linked individuals
1383                $num = count($media_object->linkedIndividuals('OBJE'));
1384                $html .= '<td class="center" data-sort="' . $num . '">' . I18N::number($num) . '</td>';
1385                // Count of linked families
1386                $num = count($media_object->linkedFamilies('OBJE'));
1387                $html .= '<td class="center" data-sort="' . $num . '">' . I18N::number($num) . '</td>';
1388                // Count of linked sources
1389                $num = count($media_object->linkedSources('OBJE'));
1390                $html .= '<td class="center" data-sort="' . $num . '">' . I18N::number($num) . '</td>';
1391                // Last change
1392                $html .= '<td data-sort="' . $media_object->lastChangeTimestamp(true) . '">' . $media_object->lastChangeTimestamp() . '</td>';
1393                $html .= '</tr>';
1394            }
1395        }
1396        $html .= '</tbody></table></div>';
1397
1398        return $html;
1399    }
1400
1401    /**
1402     * Print a table of surnames, for the top surnames block, the indi/fam lists, etc.
1403     *
1404     * @param string[][] $surnames array (of SURN, of array of SPFX_SURN, of array of PID)
1405     * @param string $script "indilist.php" (counts of individuals) or "famlist.php" (counts of spouses)
1406     * @param Tree $tree generate links for this tree
1407     *
1408     * @return string
1409     */
1410    public static function surnameTable($surnames, $script, Tree $tree)
1411    {
1412        global $controller;
1413
1414        $html = '';
1415        $controller
1416            ->addExternalJavascript(WT_JQUERY_DATATABLES_JS_URL)
1417            ->addInlineJavascript('
1418				jQuery.fn.dataTableExt.oSort["text-asc"] = textCompareAsc;
1419				jQuery.fn.dataTableExt.oSort["text-desc"] = textCompareDesc;
1420				jQuery(".surname-list").dataTable({
1421					dom: "t",
1422					jQueryUI: true,
1423					autoWidth: false,
1424					' . I18N::datatablesI18N() . ',
1425					paging: false,
1426					sorting: [[0, "asc"]],
1427					columns: [
1428						/* Surname */ { type: "text" },
1429						/* Count   */ { type: "num" }
1430					]
1431				});
1432			');
1433
1434        if ($script == 'famlist.php') {
1435            $col_heading = I18N::translate('Spouses');
1436        } else {
1437            $col_heading = I18N::translate('Individuals');
1438        }
1439
1440        $html .=
1441            '<table class="surname-list">' .
1442            '<thead>' .
1443            '<tr>' .
1444            '<th>' . GedcomTag::getLabel('SURN') . '</th>' .
1445            '<th>' . $col_heading . '</th>' .
1446            '</tr>' .
1447            '</thead>';
1448
1449        $html .= '<tbody>';
1450        foreach ($surnames as $surn => $surns) {
1451            // Each surname links back to the indi/fam surname list
1452            if ($surn) {
1453                $url = $script . '?surname=' . rawurlencode($surn) . '&amp;ged=' . $tree->getNameUrl();
1454            } else {
1455                $url = $script . '?alpha=,&amp;ged=' . $tree->getNameUrl();
1456            }
1457            $html .= '<tr>';
1458            // Surname
1459            $html .= '<td data-sort="' . Filter::escapeHtml($surn) . '">';
1460            // Multiple surname variants, e.g. von Groot, van Groot, van der Groot, etc.
1461            foreach ($surns as $spfxsurn => $indis) {
1462                if ($spfxsurn) {
1463                    $html .= '<a href="' . $url . '" dir="auto">' . Filter::escapeHtml($spfxsurn) . '</a><br>';
1464                } else {
1465                    // No surname, but a value from "2 SURN"? A common workaround for toponyms, etc.
1466                    $html .= '<a href="' . $url . '" dir="auto">' . Filter::escapeHtml($surn) . '</a><br>';
1467                }
1468            }
1469            $html .= '</td>';
1470            // Surname count
1471            $subtotal = 0;
1472            foreach ($surns as $indis) {
1473                $subtotal += count($indis);
1474            }
1475            $html .= '<td class="center" data-sort="' . $subtotal . '">';
1476            foreach ($surns as $indis) {
1477                $html .= I18N::number(count($indis)) . '<br>';
1478            }
1479            if (count($surns) > 1) {
1480                // More than one surname variant? Show a subtotal
1481                $html .= I18N::number($subtotal);
1482            }
1483            $html .= '</td>';
1484            $html .= '</tr>';
1485        }
1486        $html .= '</tbody></table>';
1487
1488        return $html;
1489    }
1490
1491    /**
1492     * Print a tagcloud of surnames.
1493     *
1494     * @param string[][] $surnames array (of SURN, of array of SPFX_SURN, of array of PID)
1495     * @param string $script indilist or famlist
1496     * @param bool $totals show totals after each name
1497     * @param Tree $tree generate links to this tree
1498     *
1499     * @return string
1500     */
1501    public static function surnameTagCloud($surnames, $script, $totals, Tree $tree)
1502    {
1503        $minimum = PHP_INT_MAX;
1504        $maximum = 1;
1505        foreach ($surnames as $surn => $surns) {
1506            foreach ($surns as $spfxsurn => $indis) {
1507                $maximum = max($maximum, count($indis));
1508                $minimum = min($minimum, count($indis));
1509            }
1510        }
1511
1512        $html = '';
1513        foreach ($surnames as $surn => $surns) {
1514            foreach ($surns as $spfxsurn => $indis) {
1515                if ($maximum === $minimum) {
1516                    // All surnames occur the same number of times
1517                    $size = 150.0;
1518                } else {
1519                    $size = 75.0 + 125.0 * (count($indis) - $minimum) / ($maximum - $minimum);
1520                }
1521                $html .= '<a style="font-size:' . $size . '%" href="' . $script . '?surname=' . Filter::escapeUrl($surn) . '&amp;ged=' . $tree->getNameUrl() . '">';
1522                if ($totals) {
1523                    $html .= I18N::translate('%1$s (%2$s)', '<span dir="auto">' . $spfxsurn . '</span>', I18N::number(count($indis)));
1524                } else {
1525                    $html .= $spfxsurn;
1526                }
1527                $html .= '</a> ';
1528            }
1529        }
1530
1531        return '<div class="tag_cloud">' . $html . '</div>';
1532    }
1533
1534    /**
1535     * Print a list of surnames.
1536     *
1537     * @param string[][] $surnames array (of SURN, of array of SPFX_SURN, of array of PID)
1538     * @param int $style 1=bullet list, 2=semicolon-separated list, 3=tabulated list with up to 4 columns
1539     * @param bool $totals show totals after each name
1540     * @param string $script indilist or famlist
1541     * @param Tree $tree Link back to the individual list in this tree
1542     *
1543     * @return string
1544     */
1545    public static function surnameList($surnames, $style, $totals, $script, Tree $tree)
1546    {
1547        $html = array();
1548        foreach ($surnames as $surn => $surns) {
1549            // Each surname links back to the indilist
1550            if ($surn) {
1551                $url = $script . '?surname=' . urlencode($surn) . '&amp;ged=' . $tree->getNameUrl();
1552            } else {
1553                $url = $script . '?alpha=,&amp;ged=' . $tree->getNameUrl();
1554            }
1555            // If all the surnames are just case variants, then merge them into one
1556            // Comment out this block if you want SMITH listed separately from Smith
1557            $first_spfxsurn = null;
1558            foreach ($surns as $spfxsurn => $indis) {
1559                if ($first_spfxsurn) {
1560                    if (I18N::strtoupper($spfxsurn) == I18N::strtoupper($first_spfxsurn)) {
1561                        $surns[$first_spfxsurn] = array_merge($surns[$first_spfxsurn], $surns[$spfxsurn]);
1562                        unset($surns[$spfxsurn]);
1563                    }
1564                } else {
1565                    $first_spfxsurn = $spfxsurn;
1566                }
1567            }
1568            $subhtml = '<a href="' . $url . '" dir="auto">' . Filter::escapeHtml(implode(I18N::$list_separator, array_keys($surns))) . '</a>';
1569
1570            if ($totals) {
1571                $subtotal = 0;
1572                foreach ($surns as $indis) {
1573                    $subtotal += count($indis);
1574                }
1575                $subhtml .= '&nbsp;(' . I18N::number($subtotal) . ')';
1576            }
1577            $html[] = $subhtml;
1578        }
1579        switch ($style) {
1580            case 1:
1581                return '<ul><li>' . implode('</li><li>', $html) . '</li></ul>';
1582            case 2:
1583                return implode(I18N::$list_separator, $html);
1584            case 3:
1585                $i     = 0;
1586                $count = count($html);
1587                if ($count > 36) {
1588                    $col = 4;
1589                } elseif ($count > 18) {
1590                    $col = 3;
1591                } elseif ($count > 6) {
1592                    $col = 2;
1593                } else {
1594                    $col = 1;
1595                }
1596                $newcol = ceil($count / $col);
1597                $html2  = '<table class="list_table"><tr>';
1598                $html2 .= '<td class="list_value" style="padding: 14px;">';
1599
1600                foreach ($html as $surns) {
1601                    $html2 .= $surns . '<br>';
1602                    $i++;
1603                    if ($i == $newcol && $i < $count) {
1604                        $html2 .= '</td><td class="list_value" style="padding: 14px;">';
1605                        $newcol = $i + ceil($count / $col);
1606                    }
1607                }
1608                $html2 .= '</td></tr></table>';
1609
1610                return $html2;
1611        }
1612    }
1613    /**
1614     * Print a table of events
1615     *
1616     * @param int $startjd
1617     * @param int $endjd
1618     * @param string $events
1619     * @param bool $only_living
1620     * @param string $sort_by
1621     *
1622     * @return string
1623     */
1624    public static function eventsTable($startjd, $endjd, $events = 'BIRT MARR DEAT', $only_living = false, $sort_by = 'anniv')
1625    {
1626        global $controller, $WT_TREE;
1627
1628        $html     = '';
1629        $table_id = 'table-even-' . Uuid::uuid4(); // lists requires a unique ID in case there are multiple lists per page
1630        $controller
1631            ->addExternalJavascript(WT_JQUERY_DATATABLES_JS_URL)
1632            ->addInlineJavascript('
1633				jQuery.fn.dataTableExt.oSort["text-asc"] = textCompareAsc;
1634				jQuery.fn.dataTableExt.oSort["text-desc"] = textCompareDesc;
1635				jQuery("#' . $table_id . '").dataTable({
1636					dom: "t",
1637					' . I18N::datatablesI18N() . ',
1638					autoWidth: false,
1639					paging: false,
1640					lengthChange: false,
1641					filter: false,
1642					info: true,
1643					jQueryUI: true,
1644					sorting: [[ ' . ($sort_by == 'alpha' ? 0 : 1) . ', "asc"]],
1645					columns: [
1646						/* Name        */ { type: "text" },
1647						/* Date        */ { type: "num" },
1648						/* Anniversary */ { type: "num" },
1649						/* Event       */ { type: "text" }
1650					]
1651				});
1652			');
1653
1654        // Did we have any output? Did we skip anything?
1655        $filter          = 0;
1656        $filtered_events = array();
1657
1658        foreach (FunctionsDb::getEventsList($startjd, $endjd, $events, $WT_TREE) as $fact) {
1659            $record = $fact->getParent();
1660            // Only living people ?
1661            if ($only_living) {
1662                if ($record instanceof Individual && $record->isDead()) {
1663                    $filter++;
1664                    continue;
1665                }
1666                if ($record instanceof Family) {
1667                    $husb = $record->getHusband();
1668                    if ($husb === null || $husb->isDead()) {
1669                        $filter++;
1670                        continue;
1671                    }
1672                    $wife = $record->getWife();
1673                    if ($wife === null || $wife->isDead()) {
1674                        $filter++;
1675                        continue;
1676                    }
1677                }
1678            }
1679
1680            $filtered_events[] = $fact;
1681        }
1682
1683        if (!empty($filtered_events)) {
1684            $html .= '<table id="' . $table_id . '" class="width100">';
1685            $html .= '<thead><tr>';
1686            $html .= '<th>' . I18N::translate('Record') . '</th>';
1687            $html .= '<th>' . GedcomTag::getLabel('DATE') . '</th>';
1688            $html .= '<th><i class="icon-reminder" title="' . I18N::translate('Anniversary') . '"></i></th>';
1689            $html .= '<th>' . GedcomTag::getLabel('EVEN') . '</th>';
1690            $html .= '</tr></thead><tbody>';
1691
1692            foreach ($filtered_events as $n => $fact) {
1693                $record = $fact->getParent();
1694                $html .= '<tr>';
1695                $html .= '<td data-sort="' . Filter::escapeHtml($record->getSortName()) . '">';
1696                $html .= '<a href="' . $record->getHtmlUrl() . '">' . $record->getFullName() . '</a>';
1697                if ($record instanceof Individual) {
1698                    $html .= $record->getSexImage();
1699                }
1700                $html .= '</td>';
1701                $html .= '<td data-sort="' . $fact->getDate()->minimumJulianDay() . '">';
1702                $html .= $fact->getDate()->display();
1703                $html .= '</td>';
1704                $html .= '<td class="center" data-sort="' . $fact->anniv . '">';
1705                $html .= ($fact->anniv ? I18N::number($fact->anniv) : '');
1706                $html .= '</td>';
1707                $html .= '<td class="center">' . $fact->getLabel() . '</td>';
1708                $html .= '</tr>';
1709            }
1710
1711            $html .= '</tbody></table>';
1712        } else {
1713            if ($endjd === WT_CLIENT_JD) {
1714                // We're dealing with the Today’s Events block
1715                if ($filter === 0) {
1716                    $html .=  I18N::translate('No events exist for today.');
1717                } else {
1718                    $html .=  I18N::translate('No events for living individuals exist for today.');
1719                }
1720            } else {
1721                // We're dealing with the Upcoming Events block
1722                if ($filter === 0) {
1723                    if ($endjd === $startjd) {
1724                        $html .=  I18N::translate('No events exist for tomorrow.');
1725                    } else {
1726                        $html .=  /* I18N: translation for %s==1 is unused; it is translated separately as “tomorrow” */ I18N::plural('No events exist for the next %s day.', 'No events exist for the next %s days.', $endjd - $startjd + 1, I18N::number($endjd - $startjd + 1));
1727                    }
1728                } else {
1729                    if ($endjd === $startjd) {
1730                        $html .=  I18N::translate('No events for living individuals exist for tomorrow.');
1731                    } else {
1732                        // I18N: translation for %s==1 is unused; it is translated separately as “tomorrow”
1733                        $html .=  I18N::plural('No events for living people exist for the next %s day.', 'No events for living people exist for the next %s days.', $endjd - $startjd + 1, I18N::number($endjd - $startjd + 1));
1734                    }
1735                }
1736            }
1737        }
1738
1739        return $html;
1740    }
1741
1742    /**
1743     * Print a list of events
1744     *
1745     * This performs the same function as print_events_table(), but formats the output differently.
1746     *
1747     * @param int $startjd
1748     * @param int $endjd
1749     * @param string $events
1750     * @param bool $only_living
1751     * @param string $sort_by
1752     *
1753     * @return string
1754     */
1755    public static function eventsList($startjd, $endjd, $events = 'BIRT MARR DEAT', $only_living = false, $sort_by = 'anniv')
1756    {
1757        global $WT_TREE;
1758
1759        // Did we have any output? Did we skip anything?
1760        $output          = 0;
1761        $filter          = 0;
1762        $filtered_events = array();
1763        $html            = '';
1764        foreach (FunctionsDb::getEventsList($startjd, $endjd, $events, $WT_TREE) as $fact) {
1765            $record = $fact->getParent();
1766            // only living people ?
1767            if ($only_living) {
1768                if ($record instanceof Individual && $record->isDead()) {
1769                    $filter++;
1770                    continue;
1771                }
1772                if ($record instanceof Family) {
1773                    $husb = $record->getHusband();
1774                    if ($husb === null || $husb->isDead()) {
1775                        $filter++;
1776                        continue;
1777                    }
1778                    $wife = $record->getWife();
1779                    if ($wife === null || $wife->isDead()) {
1780                        $filter++;
1781                        continue;
1782                    }
1783                }
1784            }
1785
1786            $output++;
1787
1788            $filtered_events[] = $fact;
1789        }
1790
1791        // Now we've filtered the list, we can sort by event, if required
1792        switch ($sort_by) {
1793            case 'anniv':
1794                // Data is already sorted by anniversary date
1795                break;
1796            case 'alpha':
1797                uasort($filtered_events, function (Fact $x, Fact $y) {
1798                    return GedcomRecord::compare($x->getParent(), $y->getParent());
1799                });
1800                break;
1801        }
1802
1803        foreach ($filtered_events as $fact) {
1804            $record = $fact->getParent();
1805            $html .= '<a href="' . $record->getHtmlUrl() . '" class="list_item name2">' . $record->getFullName() . '</a>';
1806            if ($record instanceof Individual) {
1807                $html .= $record->getSexImage();
1808            }
1809            $html .= '<br><div class="indent">';
1810            $html .= $fact->getLabel() . ' — ' . $fact->getDate()->display(true);
1811            if ($fact->anniv) {
1812                $html .= ' (' . I18N::translate('%s year anniversary', I18N::number($fact->anniv)) . ')';
1813            }
1814            if (!$fact->getPlace()->isEmpty()) {
1815                $html .= ' — <a href="' . $fact->getPlace()->getURL() . '">' . $fact->getPlace()->getFullName() . '</a>';
1816            }
1817            $html .= '</div>';
1818        }
1819
1820        // Print a final summary message about restricted/filtered facts
1821        $summary = '';
1822        if ($endjd == WT_CLIENT_JD) {
1823            // We're dealing with the Today’s Events block
1824            if ($output == 0) {
1825                if ($filter == 0) {
1826                    $summary = I18N::translate('No events exist for today.');
1827                } else {
1828                    $summary = I18N::translate('No events for living individuals exist for today.');
1829                }
1830            }
1831        } else {
1832            // We're dealing with the Upcoming Events block
1833            if ($output == 0) {
1834                if ($filter == 0) {
1835                    if ($endjd == $startjd) {
1836                        $summary = I18N::translate('No events exist for tomorrow.');
1837                    } else {
1838                        // I18N: translation for %s==1 is unused; it is translated separately as “tomorrow”
1839                        $summary = I18N::plural('No events exist for the next %s day.', 'No events exist for the next %s days.', $endjd - $startjd + 1, I18N::number($endjd - $startjd + 1));
1840                    }
1841                } else {
1842                    if ($endjd == $startjd) {
1843                        $summary = I18N::translate('No events for living individuals exist for tomorrow.');
1844                    } else {
1845                        // I18N: translation for %s==1 is unused; it is translated separately as “tomorrow”
1846                        $summary = I18N::plural('No events for living people exist for the next %s day.', 'No events for living people exist for the next %s days.', $endjd - $startjd + 1, I18N::number($endjd - $startjd + 1));
1847                    }
1848                }
1849            }
1850        }
1851        if ($summary) {
1852            $html .= '<b>' . $summary . '</b>';
1853        }
1854
1855        return $html;
1856    }
1857
1858    /**
1859     * Print a chart by age using Google chart API
1860     *
1861     * @param int[] $data
1862     * @param string $title
1863     *
1864     * @return string
1865     */
1866    public static function chartByAge($data, $title)
1867    {
1868        $count  = 0;
1869        $agemax = 0;
1870        $vmax   = 0;
1871        $avg    = 0;
1872        foreach ($data as $age => $v) {
1873            $n      = strlen($v);
1874            $vmax   = max($vmax, $n);
1875            $agemax = max($agemax, $age);
1876            $count += $n;
1877            $avg += $age * $n;
1878        }
1879        if ($count < 1) {
1880            return '';
1881        }
1882        $avg       = round($avg / $count);
1883        $chart_url = "https://chart.googleapis.com/chart?cht=bvs"; // chart type
1884        $chart_url .= "&amp;chs=725x150"; // size
1885        $chart_url .= "&amp;chbh=3,2,2"; // bvg : 4,1,2
1886        $chart_url .= "&amp;chf=bg,s,FFFFFF99"; //background color
1887        $chart_url .= "&amp;chco=0000FF,FFA0CB,FF0000"; // bar color
1888        $chart_url .= "&amp;chdl=" . rawurlencode(I18N::translate('Males')) . "|" . rawurlencode(I18N::translate('Females')) . "|" . rawurlencode(I18N::translate('Average age') . ": " . $avg); // legend & average age
1889        $chart_url .= "&amp;chtt=" . rawurlencode($title); // title
1890        $chart_url .= "&amp;chxt=x,y,r"; // axis labels specification
1891        $chart_url .= "&amp;chm=V,FF0000,0," . ($avg - 0.3) . ",1"; // average age line marker
1892        $chart_url .= "&amp;chxl=0:|"; // label
1893        for ($age = 0; $age <= $agemax; $age += 5) {
1894            $chart_url .= $age . "|||||"; // x axis
1895        }
1896        $chart_url .= "|1:||" . rawurlencode(I18N::percentage($vmax / $count)); // y axis
1897        $chart_url .= "|2:||";
1898        $step = $vmax;
1899        for ($d = $vmax; $d > 0; $d--) {
1900            if ($vmax < ($d * 10 + 1) && ($vmax % $d) == 0) {
1901                $step = $d;
1902            }
1903        }
1904        if ($step == $vmax) {
1905            for ($d = $vmax - 1; $d > 0; $d--) {
1906                if (($vmax - 1) < ($d * 10 + 1) && (($vmax - 1) % $d) == 0) {
1907                    $step = $d;
1908                }
1909            }
1910        }
1911        for ($n = $step; $n < $vmax; $n += $step) {
1912            $chart_url .= $n . "|";
1913        }
1914        $chart_url .= rawurlencode($vmax . " / " . $count); // r axis
1915        $chart_url .= "&amp;chg=100," . round(100 * $step / $vmax, 1) . ",1,5"; // grid
1916        $chart_url .= "&amp;chd=s:"; // data : simple encoding from A=0 to 9=61
1917        $CHART_ENCODING61 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1918        for ($age = 0; $age <= $agemax; $age++) {
1919            $chart_url .= $CHART_ENCODING61[(int) (substr_count($data[$age], "M") * 61 / $vmax)];
1920        }
1921        $chart_url .= ",";
1922        for ($age = 0; $age <= $agemax; $age++) {
1923            $chart_url .= $CHART_ENCODING61[(int) (substr_count($data[$age], "F") * 61 / $vmax)];
1924        }
1925        $html = '<img src="' . $chart_url . '" alt="' . $title . '" title="' . $title . '" class="gchart">';
1926
1927        return $html;
1928    }
1929
1930    /**
1931     * Print a chart by decade using Google chart API
1932     *
1933     * @param int[] $data
1934     * @param string $title
1935     *
1936     * @return string
1937     */
1938    public static function chartByDecade($data, $title)
1939    {
1940        $count = 0;
1941        $vmax  = 0;
1942        foreach ($data as $v) {
1943            $n    = strlen($v);
1944            $vmax = max($vmax, $n);
1945            $count += $n;
1946        }
1947        if ($count < 1) {
1948            return '';
1949        }
1950        $chart_url = "https://chart.googleapis.com/chart?cht=bvs"; // chart type
1951        $chart_url .= "&amp;chs=360x150"; // size
1952        $chart_url .= "&amp;chbh=3,3"; // bvg : 4,1,2
1953        $chart_url .= "&amp;chf=bg,s,FFFFFF99"; //background color
1954        $chart_url .= "&amp;chco=0000FF,FFA0CB"; // bar color
1955        $chart_url .= "&amp;chtt=" . rawurlencode($title); // title
1956        $chart_url .= "&amp;chxt=x,y,r"; // axis labels specification
1957        $chart_url .= "&amp;chxl=0:|&lt;|||"; // <1570
1958        for ($y = 1600; $y < 2030; $y += 50) {
1959            $chart_url .= $y . "|||||"; // x axis
1960        }
1961        $chart_url .= "|1:||" . rawurlencode(I18N::percentage($vmax / $count)); // y axis
1962        $chart_url .= "|2:||";
1963        $step = $vmax;
1964        for ($d = $vmax; $d > 0; $d--) {
1965            if ($vmax < ($d * 10 + 1) && ($vmax % $d) == 0) {
1966                $step = $d;
1967            }
1968        }
1969        if ($step == $vmax) {
1970            for ($d = $vmax - 1; $d > 0; $d--) {
1971                if (($vmax - 1) < ($d * 10 + 1) && (($vmax - 1) % $d) == 0) {
1972                    $step = $d;
1973                }
1974            }
1975        }
1976        for ($n = $step; $n < $vmax; $n += $step) {
1977            $chart_url .= $n . "|";
1978        }
1979        $chart_url .= rawurlencode($vmax . " / " . $count); // r axis
1980        $chart_url .= "&amp;chg=100," . round(100 * $step / $vmax, 1) . ",1,5"; // grid
1981        $chart_url .= "&amp;chd=s:"; // data : simple encoding from A=0 to 9=61
1982        $CHART_ENCODING61 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1983        for ($y = 1570; $y < 2030; $y += 10) {
1984            $chart_url .= $CHART_ENCODING61[(int) (substr_count($data[$y], "M") * 61 / $vmax)];
1985        }
1986        $chart_url .= ",";
1987        for ($y = 1570; $y < 2030; $y += 10) {
1988            $chart_url .= $CHART_ENCODING61[(int) (substr_count($data[$y], "F") * 61 / $vmax)];
1989        }
1990        $html = '<img src="' . $chart_url . '" alt="' . $title . '" title="' . $title . '" class="gchart">';
1991
1992        return $html;
1993    }
1994}
1995