xref: /webtrees/resources/js/webtrees.js (revision a2a485c37cd2b74783bf6a0721064e22864f4a08)
1/**
2 * webtrees: online genealogy
3 * Copyright (C) 2019 webtrees development team
4 * This program is free software: you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation, either version 3 of the License, or
7 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 * You should have received a copy of the GNU General Public License
13 * along with this program. If not, see <http://www.gnu.org/licenses/>.
14 */
15
16'use strict';
17
18let webtrees = function () {
19    const lang = document.documentElement.lang;
20
21    /**
22     * Tidy the whitespace in a string.
23     */
24    function trim(str) {
25        return str.replace(/\s+/g, " ").trim();
26
27    }
28
29    /**
30     * Look for non-latin characters in a string.
31     */
32    function detectScript(str) {
33        if (str.match(/[\u3400-\u9FCC]/)) {
34            return "cjk";
35        } else if (str.match(/[\u0370-\u03FF]/)) {
36            return "greek";
37        } else if (str.match(/[\u0400-\u04FF]/)) {
38            return "cyrillic";
39        } else if (str.match(/[\u0590-\u05FF]/)) {
40            return "hebrew";
41        } else if (str.match(/[\u0600-\u06FF]/)) {
42            return "arabic";
43        }
44
45        return "latin";
46    }
47
48    /**
49     * In some languages, the SURN uses a male/default form, but NAME uses a gender-inflected form.
50     */
51    function inflectSurname(surname, sex) {
52        if (lang === "pl" && sex === "F") {
53            return surname
54                .replace(/ski$/, "ska")
55                .replace(/cki$/, "cka")
56                .replace(/dzki$/, "dzka")
57                .replace(/żki$/, "żka");
58        }
59
60        return surname;
61    }
62
63    /**
64     * Build a NAME from a NPFX, GIVN, SPFX, SURN and NSFX parts.
65     *
66     * Assumes the language of the document is the same as the language of the name.
67     */
68    function buildNameFromParts(npfx, givn, spfx, surn, nsfx, sex) {
69        const usesCJK      = detectScript(npfx + givn + spfx + givn + surn + nsfx) === "cjk";
70        const separator    = usesCJK ? "" : " ";
71        const surnameFirst = usesCJK || ['hu', 'jp', 'ko', 'vi', 'zh-Hans', 'zh-Hant'].indexOf(lang) !== -1;
72        const patronym     = ['is'].indexOf(lang) !== -1;
73        const slash        = patronym ? "" : "/";
74
75        // GIVN and SURN may be a comma-separated lists.
76        npfx = trim(npfx);
77        givn = trim(givn.replace(",", separator));
78        spfx = trim(spfx);
79        surn = inflectSurname(trim(surn.replace(",", separator)), sex);
80        nsfx = trim(nsfx);
81
82        const surname = trim(spfx + separator + surn);
83
84        const name = surnameFirst ? slash + surname + slash + separator + givn : givn + separator + slash + surname + slash;
85
86        return trim(npfx + separator + name + separator + nsfx);
87    }
88
89    // Public methods
90    return {
91        buildNameFromParts: buildNameFromParts,
92        detectScript:       detectScript,
93    };
94}();
95
96function expand_layer(sid)
97{
98    $('#' + sid + '_img').toggleClass('icon-plus icon-minus');
99    $('#' + sid).slideToggle('fast');
100    $('#' + sid + '-alt').toggle(); // hide something when we show the layer - and vice-versa
101    return false;
102}
103
104// Accept the changes to a record - and reload the page
105function accept_changes(xref, ged)
106{
107    $.post(
108        'index.php',
109        {
110            route: 'accept-changes',
111            xref: xref,
112            ged: ged,
113        },
114        function () {
115            document.location.reload();
116        }
117    );
118    return false;
119}
120
121// Reject the changes to a record - and reload the page
122function reject_changes(xref, ged)
123{
124    $.post(
125        'index.php',
126        {
127            route: 'reject-changes',
128            xref: xref,
129            ged: ged,
130        },
131        function () {
132            document.location.reload();
133        }
134    );
135    return false;
136}
137
138// Delete a record - and reload the page
139function delete_record(xref, gedcom)
140{
141    $.post(
142        'index.php',
143        {
144            route: 'delete-record',
145            xref: xref,
146            ged: gedcom,
147        },
148        function () {
149            document.location.reload();
150        }
151    );
152
153    return false;
154}
155
156// Delete a fact - and reload the page
157function delete_fact(message, ged, xref, fact_id)
158{
159    if (confirm(message)) {
160        $.post(
161            'index.php',
162            {
163                route: 'delete-fact',
164                xref: xref,
165                fact_id: fact_id,
166                ged: ged
167            },
168            function () {
169                document.location.reload();
170            }
171        );
172    }
173    return false;
174}
175
176// Copy a fact to the clipboard
177function copy_fact(ged, xref, fact_id)
178{
179    $.post(
180        'index.php',
181        {
182            route: 'copy-fact',
183            xref: xref,
184            fact_id: fact_id,
185            ged: ged,
186        },
187        function () {
188            document.location.reload();
189        }
190    );
191    return false;
192}
193
194// Paste a fact from the clipboard
195function paste_fact(ged, xref, element)
196{
197    $.post(
198        'index.php',
199        {
200            route: 'paste-fact',
201            xref: xref,
202            fact_id: $(element).val(), // element is the <select> containing the option
203            ged: ged,
204        },
205        function () {
206            document.location.reload();
207        }
208    );
209    return false;
210}
211
212// Delete a user - and reload the page
213function delete_user(message, user_id)
214{
215    if (confirm(message)) {
216        $.post(
217            'index.php',
218            {
219                route: 'delete-user',
220                user_id: user_id,
221            },
222            function () {
223                document.location.reload();
224            }
225        );
226    }
227    return false;
228}
229
230// Masquerade as another user - and reload the page.
231function masquerade(user_id)
232{
233    $.post(
234        'index.php',
235        {
236            route: 'masquerade',
237            user_id: user_id,
238        },
239        function () {
240            document.location.reload();
241        }
242    );
243    return false;
244}
245
246var pastefield;
247function addmedia_links(field, iid, iname)
248{
249    pastefield = field;
250    insertRowToTable(iid, iname);
251    return false;
252}
253
254function valid_date(datefield, dmy)
255{
256    var months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
257    var hijri_months = ['MUHAR', 'SAFAR', 'RABIA', 'RABIT', 'JUMAA', 'JUMAT', 'RAJAB', 'SHAAB', 'RAMAD', 'SHAWW', 'DHUAQ', 'DHUAH'];
258    var hebrew_months = ['TSH', 'CSH', 'KSL', 'TVT', 'SHV', 'ADR', 'ADS', 'NSN', 'IYR', 'SVN', 'TMZ', 'AAV', 'ELL'];
259    var french_months = ['VEND', 'BRUM', 'FRIM', 'NIVO', 'PLUV', 'VENT', 'GERM', 'FLOR', 'PRAI', 'MESS', 'THER', 'FRUC', 'COMP'];
260    var jalali_months = ['FARVA', 'ORDIB', 'KHORD', 'TIR', 'MORDA', 'SHAHR', 'MEHR', 'ABAN', 'AZAR', 'DEY', 'BAHMA', 'ESFAN'];
261
262    var datestr = datefield.value;
263  // if a date has a date phrase marked by () this has to be excluded from altering
264    var datearr = datestr.split('(');
265    var datephrase = '';
266    if (datearr.length > 1) {
267        datestr = datearr[0];
268        datephrase = datearr[1];
269    }
270
271  // Gedcom dates are upper case
272    datestr = datestr.toUpperCase();
273  // Gedcom dates have no leading/trailing/repeated whitespace
274    datestr = datestr.replace(/\s+/, ' ');
275    datestr = datestr.replace(/(^\s)|(\s$)/, '');
276  // Gedcom dates have spaces between letters and digits, e.g. "01JAN2000" => "01 JAN 2000"
277    datestr = datestr.replace(/(\d)([A-Z])/, '$1 $2');
278    datestr = datestr.replace(/([A-Z])(\d)/, '$1 $2');
279
280  // Shortcut for quarter format, "Q1 1900" => "BET JAN 1900 AND MAR 1900". See [ 1509083 ]
281    if (datestr.match(/^Q ([1-4]) (\d\d\d\d)$/)) {
282        datestr = 'BET ' + months[RegExp.$1 * 3 - 3] + ' ' + RegExp.$2 + ' AND ' + months[RegExp.$1 * 3 - 1] + ' ' + RegExp.$2;
283    }
284
285  // Shortcut for @#Dxxxxx@ 01 01 1400, etc.
286    if (datestr.match(/^(@#DHIJRI@|HIJRI)( \d?\d )(\d?\d)( \d?\d?\d?\d)$/)) {
287        datestr = '@#DHIJRI@' + RegExp.$2 + hijri_months[parseInt(RegExp.$3, 10) - 1] + RegExp.$4;
288    }
289    if (datestr.match(/^(@#DJALALI@|JALALI)( \d?\d )(\d?\d)( \d?\d?\d?\d)$/)) {
290        datestr = '@#DJALALI@' + RegExp.$2 + jalali_months[parseInt(RegExp.$3, 10) - 1] + RegExp.$4;
291    }
292    if (datestr.match(/^(@#DHEBREW@|HEBREW)( \d?\d )(\d?\d)( \d?\d?\d?\d)$/)) {
293        datestr = '@#DHEBREW@' + RegExp.$2 + hebrew_months[parseInt(RegExp.$3, 10) - 1] + RegExp.$4;
294    }
295    if (datestr.match(/^(@#DFRENCH R@|FRENCH)( \d?\d )(\d?\d)( \d?\d?\d?\d)$/)) {
296        datestr = '@#DFRENCH R@' + RegExp.$2 + french_months[parseInt(RegExp.$3, 10) - 1] + RegExp.$4;
297    }
298
299  // e.g. 17.11.1860, 03/04/2005 or 1999-12-31. Use locale settings where DMY order is ambiguous.
300    var qsearch = /^([^\d]*)(\d+)[^\d](\d+)[^\d](\d+)$/i;
301    if (qsearch.exec(datestr)) {
302        var f0 = RegExp.$1;
303        var f1 = parseInt(RegExp.$2, 10);
304        var f2 = parseInt(RegExp.$3, 10);
305        var f3 = parseInt(RegExp.$4, 10);
306        var yyyy = new Date().getFullYear();
307        var yy = yyyy % 100;
308        var cc = yyyy - yy;
309        if (dmy === 'DMY' && f1 <= 31 && f2 <= 12 || f1 > 13 && f1 <= 31 && f2 <= 12 && f3 > 31) {
310            datestr = f0 + f1 + ' ' + months[f2 - 1] + ' ' + (f3 >= 100 ? f3 : (f3 <= yy ? f3 + cc : f3 + cc - 100));
311        } else {
312            if (dmy === 'MDY' && f1 <= 12 && f2 <= 31 || f2 > 13 && f2 <= 31 && f1 <= 12 && f3 > 31) {
313                datestr = f0 + f2 + ' ' + months[f1 - 1] + ' ' + (f3 >= 100 ? f3 : (f3 <= yy ? f3 + cc : f3 + cc - 100));
314            } else {
315                if (dmy === 'YMD' && f2 <= 12 && f3 <= 31 || f3 > 13 && f3 <= 31 && f2 <= 12 && f1 > 31) {
316                    datestr = f0 + f3 + ' ' + months[f2 - 1] + ' ' + (f1 >= 100 ? f1 : (f1 <= yy ? f1 + cc : f1 + cc - 100));
317                }
318            }
319        }
320    }
321
322  // Shortcuts for date ranges
323    datestr = datestr.replace(/^[>]([\w ]+)$/, 'AFT $1');
324    datestr = datestr.replace(/^[<]([\w ]+)$/, 'BEF $1');
325    datestr = datestr.replace(/^([\w ]+)[-]$/, 'FROM $1');
326    datestr = datestr.replace(/^[-]([\w ]+)$/, 'TO $1');
327    datestr = datestr.replace(/^[~]([\w ]+)$/, 'ABT $1');
328    datestr = datestr.replace(/^[*]([\w ]+)$/, 'EST $1');
329    datestr = datestr.replace(/^[#]([\w ]+)$/, 'CAL $1');
330    datestr = datestr.replace(/^([\w ]+) ?- ?([\w ]+)$/, 'BET $1 AND $2');
331    datestr = datestr.replace(/^([\w ]+) ?~ ?([\w ]+)$/, 'FROM $1 TO $2');
332
333  // Convert full months to short months
334    datestr = datestr.replace(/(JANUARY)/, 'JAN');
335    datestr = datestr.replace(/(FEBRUARY)/, 'FEB');
336    datestr = datestr.replace(/(MARCH)/, 'MAR');
337    datestr = datestr.replace(/(APRIL)/, 'APR');
338    datestr = datestr.replace(/(MAY)/, 'MAY');
339    datestr = datestr.replace(/(JUNE)/, 'JUN');
340    datestr = datestr.replace(/(JULY)/, 'JUL');
341    datestr = datestr.replace(/(AUGUST)/, 'AUG');
342    datestr = datestr.replace(/(SEPTEMBER)/, 'SEP');
343    datestr = datestr.replace(/(OCTOBER)/, 'OCT');
344    datestr = datestr.replace(/(NOVEMBER)/, 'NOV');
345    datestr = datestr.replace(/(DECEMBER)/, 'DEC');
346
347  // Americans frequently enter dates as SEP 20, 1999
348  // No need to internationalise this, as this is an english-language issue
349    datestr = datestr.replace(/(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)\.? (\d\d?)[, ]+(\d\d\d\d)/, '$2 $1 $3');
350
351  // Apply leading zero to day numbers
352    datestr = datestr.replace(/(^| )(\d [A-Z]{3,5} \d{4})/, '$10$2');
353
354    if (datephrase) {
355        datestr = datestr + ' (' + datephrase;
356    }
357  // Only update it if is has been corrected - otherwise input focus
358  // moves to the end of the field unnecessarily
359    if (datefield.value !== datestr) {
360        datefield.value = datestr;
361    }
362}
363
364var menutimeouts = [];
365
366function show_submenu(elementid, parentid)
367{
368    var pagewidth = document.body.scrollWidth + document.documentElement.scrollLeft;
369    var element = document.getElementById(elementid);
370
371    if (element && element.style) {
372        if (document.all) {
373            pagewidth = document.body.offsetWidth;
374        } else {
375            pagewidth = document.body.scrollWidth + document.documentElement.scrollLeft - 55;
376            if (document.documentElement.dir === 'rtl') {
377                boxright = element.offsetLeft + element.offsetWidth + 10;
378            }
379        }
380
381      // -- make sure the submenu is the size of the largest child
382        var maxwidth = 0;
383        var count = element.childNodes.length;
384        for (var i = 0; i < count; i++) {
385            var child = element.childNodes[i];
386            if (child.offsetWidth > maxwidth + 5) {
387                maxwidth = child.offsetWidth;
388            }
389        }
390        if (element.offsetWidth < maxwidth) {
391            element.style.width = maxwidth + 'px';
392        }
393        var pelement, boxright;
394        pelement = document.getElementById(parentid);
395        if (pelement) {
396            element.style.left = pelement.style.left;
397            boxright = element.offsetLeft + element.offsetWidth + 10;
398            if (boxright > pagewidth) {
399                var menuleft = pagewidth - element.offsetWidth;
400                element.style.left = menuleft + 'px';
401            }
402        }
403
404        if (element.offsetLeft < 0) {
405            element.style.left = '0px';
406        }
407
408      // -- put scrollbars on really long menus
409        if (element.offsetHeight > 500) {
410            element.style.height = '400px';
411            element.style.overflow = 'auto';
412        }
413
414        element.style.visibility = 'visible';
415    }
416    clearTimeout(menutimeouts[elementid]);
417    menutimeouts[elementid] = null;
418}
419
420function hide_submenu(elementid)
421{
422    if (typeof menutimeouts[elementid] !== 'number') {
423        return;
424    }
425    var element = document.getElementById(elementid);
426    if (element && element.style) {
427        element.style.visibility = 'hidden';
428    }
429    clearTimeout(menutimeouts[elementid]);
430    menutimeouts[elementid] = null;
431}
432
433function timeout_submenu(elementid)
434{
435    if (typeof menutimeouts[elementid] !== 'number') {
436        menutimeouts[elementid] = setTimeout("hide_submenu('" + elementid + "')", 100);
437    }
438}
439
440var monthLabels = [];
441monthLabels[1] = 'January';
442monthLabels[2] = 'February';
443monthLabels[3] = 'March';
444monthLabels[4] = 'April';
445monthLabels[5] = 'May';
446monthLabels[6] = 'June';
447monthLabels[7] = 'July';
448monthLabels[8] = 'August';
449monthLabels[9] = 'September';
450monthLabels[10] = 'October';
451monthLabels[11] = 'November';
452monthLabels[12] = 'December';
453
454var monthShort = [];
455monthShort[1] = 'JAN';
456monthShort[2] = 'FEB';
457monthShort[3] = 'MAR';
458monthShort[4] = 'APR';
459monthShort[5] = 'MAY';
460monthShort[6] = 'JUN';
461monthShort[7] = 'JUL';
462monthShort[8] = 'AUG';
463monthShort[9] = 'SEP';
464monthShort[10] = 'OCT';
465monthShort[11] = 'NOV';
466monthShort[12] = 'DEC';
467
468var daysOfWeek = [];
469daysOfWeek[0] = 'S';
470daysOfWeek[1] = 'M';
471daysOfWeek[2] = 'T';
472daysOfWeek[3] = 'W';
473daysOfWeek[4] = 'T';
474daysOfWeek[5] = 'F';
475daysOfWeek[6] = 'S';
476
477var weekStart = 0;
478
479function cal_setMonthNames(jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec)
480{
481    monthLabels[1] = jan;
482    monthLabels[2] = feb;
483    monthLabels[3] = mar;
484    monthLabels[4] = apr;
485    monthLabels[5] = may;
486    monthLabels[6] = jun;
487    monthLabels[7] = jul;
488    monthLabels[8] = aug;
489    monthLabels[9] = sep;
490    monthLabels[10] = oct;
491    monthLabels[11] = nov;
492    monthLabels[12] = dec;
493}
494
495function cal_setDayHeaders(sun, mon, tue, wed, thu, fri, sat)
496{
497    daysOfWeek[0] = sun;
498    daysOfWeek[1] = mon;
499    daysOfWeek[2] = tue;
500    daysOfWeek[3] = wed;
501    daysOfWeek[4] = thu;
502    daysOfWeek[5] = fri;
503    daysOfWeek[6] = sat;
504}
505
506function cal_setWeekStart(day)
507{
508    if (day >= 0 && day < 7) {
509        weekStart = day;
510    }
511}
512
513function calendarWidget(dateDivId, dateFieldId)
514{
515    var dateDiv = document.getElementById(dateDivId);
516    var dateField = document.getElementById(dateFieldId);
517
518    if (dateDiv.style.visibility === 'visible') {
519        dateDiv.style.visibility = 'hidden';
520        return false;
521    }
522    if (dateDiv.style.visibility === 'show') {
523        dateDiv.style.visibility = 'hide';
524        return false;
525    }
526
527  /* Javascript calendar functions only work with precise gregorian dates "D M Y" or "Y" */
528    var greg_regex = /((\d+ (JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC) )?\d+)/i;
529    var date;
530    if (greg_regex.exec(dateField.value)) {
531        date = new Date(RegExp.$1);
532    } else {
533        date = new Date();
534    }
535
536    dateDiv.innerHTML = cal_generateSelectorContent(dateFieldId, dateDivId, date);
537    if (dateDiv.style.visibility === 'hidden') {
538        dateDiv.style.visibility = 'visible';
539        return false;
540    }
541    if (dateDiv.style.visibility === 'hide') {
542        dateDiv.style.visibility = 'show';
543        return false;
544    }
545
546    return false;
547}
548
549function cal_generateSelectorContent(dateFieldId, dateDivId, date)
550{
551    var i, j;
552    var content = '<table border="1"><tr>';
553    content += '<td><select class="form-control" id="' + dateFieldId + '_daySelect" onchange="return cal_updateCalendar(\'' + dateFieldId + '\', \'' + dateDivId + '\');">';
554    for (i = 1; i < 32; i++) {
555        content += '<option value="' + i + '"';
556        if (date.getDate() === i) {
557            content += ' selected="selected"';
558        }
559        content += '>' + i + '</option>';
560    }
561    content += '</select></td>';
562    content += '<td><select class="form-control" id="' + dateFieldId + '_monSelect" onchange="return cal_updateCalendar(\'' + dateFieldId + '\', \'' + dateDivId + '\');">';
563    for (i = 1; i < 13; i++) {
564        content += '<option value="' + i + '"';
565        if (date.getMonth() + 1 === i) {
566            content += ' selected="selected"';
567        }
568        content += '>' + monthLabels[i] + '</option>';
569    }
570    content += '</select></td>';
571    content += '<td><input class="form-control" type="text" id="' + dateFieldId + '_yearInput" size="5" value="' + date.getFullYear() + '" onchange="return cal_updateCalendar(\'' + dateFieldId + '\', \'' + dateDivId + '\');" /></td></tr>';
572    content += '<tr><td colspan="3">';
573    content += '<table width="100%">';
574    content += '<tr>';
575    j = weekStart;
576    for (i = 0; i < 7; i++) {
577        content += '<td ';
578        content += 'class="descriptionbox"';
579        content += '>';
580        content += daysOfWeek[j];
581        content += '</td>';
582        j++;
583        if (j > 6) {
584            j = 0;
585        }
586    }
587    content += '</tr>';
588
589    var tdate = new Date(date.getFullYear(), date.getMonth(), 1);
590    var day = tdate.getDay();
591    day = day - weekStart;
592    var daymilli = 1000 * 60 * 60 * 24;
593    tdate = tdate.getTime() - (day * daymilli) + (daymilli / 2);
594    tdate = new Date(tdate);
595
596    for (j = 0; j < 6; j++) {
597        content += '<tr>';
598        for (i = 0; i < 7; i++) {
599            content += '<td ';
600            if (tdate.getMonth() === date.getMonth()) {
601                if (tdate.getDate() === date.getDate()) {
602                    content += 'class="descriptionbox"';
603                } else {
604                    content += 'class="optionbox"';
605                }
606            } else {
607                content += 'style="background-color:#EAEAEA; border: solid #AAAAAA 1px;"';
608            }
609            content += '><a href="#" onclick="return cal_dateClicked(\'' + dateFieldId + '\', \'' + dateDivId + '\', ' + tdate.getFullYear() + ', ' + tdate.getMonth() + ', ' + tdate.getDate() + ');">';
610            content += tdate.getDate();
611            content += '</a></td>';
612            var datemilli = tdate.getTime() + daymilli;
613            tdate = new Date(datemilli);
614        }
615        content += '</tr>';
616    }
617    content += '</table>';
618    content += '</td></tr>';
619    content += '</table>';
620
621    return content;
622}
623
624function cal_setDateField(dateFieldId, year, month, day)
625{
626    var dateField = document.getElementById(dateFieldId);
627    if (!dateField) {
628        return false;
629    }
630    if (day < 10) {
631        day = '0' + day;
632    }
633    dateField.value = day + ' ' + monthShort[month + 1] + ' ' + year;
634    return false;
635}
636
637function cal_updateCalendar(dateFieldId, dateDivId)
638{
639    var dateSel = document.getElementById(dateFieldId + '_daySelect');
640    if (!dateSel) {
641        return false;
642    }
643    var monthSel = document.getElementById(dateFieldId + '_monSelect');
644    if (!monthSel) {
645        return false;
646    }
647    var yearInput = document.getElementById(dateFieldId + '_yearInput');
648    if (!yearInput) {
649        return false;
650    }
651
652    var month = parseInt(monthSel.options[monthSel.selectedIndex].value, 10);
653    month = month - 1;
654
655    var date = new Date(yearInput.value, month, dateSel.options[dateSel.selectedIndex].value);
656    cal_setDateField(dateFieldId, date.getFullYear(), date.getMonth(), date.getDate());
657
658    var dateDiv = document.getElementById(dateDivId);
659    if (!dateDiv) {
660        alert('no dateDiv ' + dateDivId);
661        return false;
662    }
663    dateDiv.innerHTML = cal_generateSelectorContent(dateFieldId, dateDivId, date);
664
665    return false;
666}
667
668function cal_dateClicked(dateFieldId, dateDivId, year, month, day)
669{
670    cal_setDateField(dateFieldId, year, month, day);
671    calendarWidget(dateDivId, dateFieldId);
672    return false;
673}
674
675function openerpasteid(id)
676{
677    if (window.opener.paste_id) {
678        window.opener.paste_id(id);
679    }
680    window.close();
681}
682
683function paste_id(value)
684{
685    pastefield.value = value;
686}
687
688function pastename(name)
689{
690    if (nameElement) {
691        nameElement.innerHTML = name;
692    }
693    if (remElement) {
694        remElement.style.display = 'block';
695    }
696}
697
698function paste_char(value)
699{
700    if (document.selection) {
701      // IE
702        pastefield.focus();
703        document.selection.createRange().text = value;
704    } else if (pastefield.selectionStart || pastefield.selectionStart === 0) {
705      // Mozilla/Chrome/Safari
706        pastefield.value =
707        pastefield.value.substring(0, pastefield.selectionStart) +
708        value +
709        pastefield.value.substring(pastefield.selectionEnd, pastefield.value.length);
710        pastefield.selectionStart = pastefield.selectionEnd = pastefield.selectionStart + value.length;
711    } else {
712      // Fallback? - just append
713        pastefield.value += value;
714    }
715
716    if (pastefield.id === 'NPFX' || pastefield.id === 'GIVN' || pastefield.id === 'SPFX' || pastefield.id === 'SURN' || pastefield.id === 'NSFX') {
717        updatewholename();
718    }
719}
720
721/**
722 * Persistant checkbox options to hide/show extra data.
723
724 * @param element_id
725 */
726function persistent_toggle(element_id)
727{
728    let element = document.getElementById(element_id);
729    let key     = 'state-of-' + element_id;
730    let state   = localStorage.getItem(key);
731
732    // Previously selected?
733    if (state === 'true') {
734        $(element).click();
735    }
736
737    // Remember state for the next page load.
738    $(element).on('change', function() { localStorage.setItem(key, element.checked); });
739}
740
741function valid_lati_long(field, pos, neg)
742{
743  // valid LATI or LONG according to Gedcom standard
744  // pos (+) : N or E
745  // neg (-) : S or W
746    var txt = field.value.toUpperCase();
747    txt = txt.replace(/(^\s*)|(\s*$)/g, ''); // trim
748    txt = txt.replace(/ /g, ':'); // N12 34 ==> N12.34
749    txt = txt.replace(/\+/g, ''); // +17.1234 ==> 17.1234
750    txt = txt.replace(/-/g, neg); // -0.5698 ==> W0.5698
751    txt = txt.replace(/,/g, '.'); // 0,5698 ==> 0.5698
752  // 0°34'11 ==> 0:34:11
753    txt = txt.replace(/\u00b0/g, ':'); // °
754    txt = txt.replace(/\u0027/g, ':'); // '
755  // 0:34:11.2W ==> W0.5698
756    txt = txt.replace(/^([0-9]+):([0-9]+):([0-9.]+)(.*)/g, function ($0, $1, $2, $3, $4) {
757        var n = parseFloat($1);
758        n += ($2 / 60);
759        n += ($3 / 3600);
760        n = Math.round(n * 1E4) / 1E4;
761        return $4 + n;
762    });
763  // 0:34W ==> W0.5667
764    txt = txt.replace(/^([0-9]+):([0-9]+)(.*)/g, function ($0, $1, $2, $3) {
765        var n = parseFloat($1);
766        n += ($2 / 60);
767        n = Math.round(n * 1E4) / 1E4;
768        return $3 + n;
769    });
770  // 0.5698W ==> W0.5698
771    txt = txt.replace(/(.*)([N|S|E|W]+)$/g, '$2$1');
772  // 17.1234 ==> N17.1234
773    if (txt && txt.charAt(0) !== neg && txt.charAt(0) !== pos) {
774        txt = pos + txt;
775    }
776    field.value = txt;
777}
778
779// This is the default way for webtrees to show image galleries.
780// Custom themes may use a different viewer.
781function activate_colorbox(config)
782{
783    $.extend($.colorbox.settings, {
784      // Don't scroll window with document
785        fixed: true,
786        current: '',
787        previous: '\uf048',
788        next: '\uf051',
789        slideshowStart: '\uf04b',
790        slideshowStop: '\uf04c',
791        close: '\uf00d'
792    });
793    if (config) {
794        $.extend($.colorbox.settings, config);
795    }
796
797  // Trigger an event when we click on an (any) image
798    $('body').on('click', 'a.gallery', function () {
799      // Enable colorbox for images
800        $('a[type^=image].gallery').colorbox({
801            photo: true,
802            maxWidth: '95%',
803            maxHeight: '95%',
804            rel: 'gallery', // Turn all images on the page into a slideshow
805            slideshow: true,
806            slideshowAuto: false,
807          // Add wheelzoom to the displayed image
808            onComplete: function () {
809              // Disable click on image triggering next image
810              // https://github.com/jackmoore/colorbox/issues/668
811                $('.cboxPhoto').unbind('click');
812
813                wheelzoom(document.querySelectorAll('.cboxPhoto'));
814            }
815        });
816
817      // Enable colorbox for audio using <audio></audio>, where supported
818      // $('html.video a[type^=video].gallery').colorbox({
819      //  rel:         'nofollow' // Slideshows are just for images
820      // });
821
822      // Enable colorbox for video using <video></video>, where supported
823      // $('html.audio a[type^=audio].gallery').colorbox({
824      //  rel:         'nofollow', // Slideshows are just for images
825      // });
826
827      // Allow all other media types remain as download links
828    });
829}
830
831// Initialize autocomplete elements.
832function autocomplete(selector)
833{
834  // Use typeahead/bloodhound for autocomplete
835    $(selector).each(function () {
836        let that = this;
837        $(this).typeahead(null, {
838            display: 'value',
839            source: new Bloodhound({
840                datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
841                queryTokenizer: Bloodhound.tokenizers.whitespace,
842                remote: {
843                    url: this.dataset.autocompleteUrl,
844                    replace: function(url, uriEncodedQuery) {
845                        if (that.dataset.autocompleteExtra) {
846                            let extra = $(document.querySelector(that.dataset.autocompleteExtra)).val();
847                            return url.replace("QUERY",uriEncodedQuery) + '&extra=' + encodeURIComponent(extra)
848                        }
849                        return url.replace("QUERY",uriEncodedQuery);
850                    },
851                    wildcard: 'QUERY',
852
853                }
854            })
855        });
856    });
857}
858
859/**
860 * Insert text at the current cursor position in an input field.
861 *
862 * @param e The input element.
863 * @param t The text to insert.
864 */
865function insertTextAtCursor(e, t)
866{
867    var scrollTop = e.scrollTop;
868    var selectionStart = e.selectionStart;
869    var prefix = e.value.substring(0, selectionStart);
870    var suffix = e.value.substring(e.selectionEnd, e.value.length);
871    e.value = prefix + t + suffix;
872    e.selectionStart = selectionStart + t.length;
873    e.selectionEnd = e.selectionStart;
874    e.focus();
875    e.scrollTop = scrollTop;
876}
877
878
879/**
880 * Draws a google pie chart.
881 *
882 * @param {String} elementId        The element id of the HTML element the chart is rendered too
883 * @param {Array}  data             The chart data array
884 * @param {Array}  colors           The chart color array
885 * @param {String} title            The chart title
886 * @param {String} labeledValueText The type of how to display the slice text
887 */
888function drawPieChart(elementId, data, colors, title, labeledValueText)
889{
890    var data    = google.visualization.arrayToDataTable(data);
891    var options = {
892        title: title,
893        height: '100%',
894        width: '100%',
895        pieStartAngle: 0,
896        pieSliceText: 'none',
897        pieSliceTextStyle: {
898            color: '#777'
899        },
900        pieHole: 0.4,  // Donut
901        //is3D: true,  // 3D (not together with pieHole)
902        legend: {
903            alignment: 'center',
904            // Flickers on mouseover :(
905            labeledValueText: labeledValueText || 'value',
906            position: 'labeled'
907        },
908        chartArea: {
909            left: 0,
910            top: '5%',
911            height: '90%',
912            width: '100%'
913        },
914        tooltip: {
915            trigger: 'none',
916            text: 'both'
917        },
918        backgroundColor: 'transparent',
919        colors: colors
920    };
921
922    var chart = new google.visualization.PieChart(document.getElementById(elementId));
923
924    chart.draw(data, options);
925}
926
927/**
928 * Draws a google column chart.
929 *
930 * @param {String} elementId The element id of the HTML element the chart is rendered too
931 * @param {Array}  data      The chart data array
932 * @param {Object} options   The chart specific options to overwrite the default ones
933 */
934function drawColumnChart(elementId, data, options)
935{
936    var defaults = {
937        title: '',
938        subtitle: '',
939        titleTextStyle: {
940            color: '#757575',
941            fontName: 'Roboto',
942            fontSize: '16px',
943            bold: false,
944            italic: false
945        },
946        height: '100%',
947        width: '100%',
948        vAxis: {
949            title: ''
950        },
951        hAxis: {
952            title: ''
953        },
954        legend: {
955            position: 'none'
956        },
957        backgroundColor: 'transparent'
958    };
959
960    options = Object.assign(defaults, options);
961
962    var chart = new google.visualization.ColumnChart(document.getElementById(elementId));
963    var data  = google.visualization.arrayToDataTable(data);
964
965    chart.draw(data, options);
966}
967
968/**
969 * Draws a google combo chart.
970 *
971 * @param {String} elementId The element id of the HTML element the chart is rendered too
972 * @param {Array}  data      The chart data array
973 * @param {Object} options   The chart specific options to overwrite the default ones
974 */
975function drawComboChart(elementId, data, options)
976{
977    var defaults = {
978        title: '',
979        subtitle: '',
980        titleTextStyle: {
981            color: '#757575',
982            fontName: 'Roboto',
983            fontSize: '16px',
984            bold: false,
985            italic: false
986        },
987        height: '100%',
988        width: '100%',
989        vAxis: {
990            title: ''
991        },
992        hAxis: {
993            title: ''
994        },
995        legend: {
996            position: 'none'
997        },
998        seriesType: 'bars',
999        series: {
1000            2: {
1001                type: 'line'
1002            }
1003        },
1004        colors: [],
1005        backgroundColor: 'transparent'
1006    };
1007
1008    options = Object.assign(defaults, options);
1009
1010    var chart = new google.visualization.ComboChart(document.getElementById(elementId));
1011    var data  = google.visualization.arrayToDataTable(data);
1012
1013    chart.draw(data, options);
1014}
1015
1016/**
1017 * Draws a google geo chart.
1018 *
1019 * @param {String} elementId The element id of the HTML element the chart is rendered too
1020 * @param {Array}  data      The chart data array
1021 * @param {Object} options   The chart specific options to overwrite the default ones
1022 */
1023function drawGeoChart(elementId, data, options)
1024{
1025    var defaults = {
1026        title: '',
1027        subtitle: '',
1028        height: '100%',
1029        width: '100%'
1030    };
1031
1032    options = Object.assign(defaults, options);
1033
1034    var chart = new google.visualization.GeoChart(document.getElementById(elementId));
1035    var data  = google.visualization.arrayToDataTable(data);
1036
1037    chart.draw(data, options);
1038}
1039
1040// Send the CSRF token on all AJAX requests
1041$.ajaxSetup({
1042    headers: {
1043        'X-CSRF-TOKEN': $('meta[name=csrf]').attr('content')
1044    }
1045});
1046
1047// Initialisation
1048$(function () {
1049  // Page elements that load automaticaly via AJAX.
1050  // This prevents bad robots from crawling resource-intensive pages.
1051    $("[data-ajax-url]").each(function () {
1052        $(this).load($(this).data('ajaxUrl'));
1053    });
1054
1055  // Select2 - format entries in the select list
1056    function templateOptionForSelect2(data)
1057    {
1058        if (data.loading) {
1059          // If we're waiting for the server, this will be a "waiting..." message
1060            return data.text;
1061        } else {
1062          // The response from the server is already in HTML, so no need to format it here.
1063            return data.text;
1064        }
1065    }
1066
1067  // Autocomplete
1068    autocomplete('input[data-autocomplete-url]');
1069
1070  // Select2 - activate autocomplete fields
1071    $('select.select2').select2({
1072      // Do not escape.
1073        escapeMarkup: function (x) {
1074            return x }
1075      // Same formatting for both selections and rsult
1076      //templateResult: templateOptionForSelect2,
1077      //templateSelection: templateOptionForSelect2
1078    })
1079  // If we clear the select (using the "X" button), we need an empty
1080  // value (rather than no value at all) for inputs with name="array[]"
1081    .on('select2:unselect', function (evt) {
1082        $(evt.delegateTarget).append('<option value="" selected="selected"></option>');
1083    })
1084
1085  // Datatables - locale aware sorting
1086    $.fn.dataTableExt.oSort['text-asc'] = function (x, y) {
1087        return x.localeCompare(y, document.documentElement.lang, {'sensitivity': 'base'});
1088    };
1089    $.fn.dataTableExt.oSort['text-desc'] = function (x, y) {
1090        return y.localeCompare(x, document.documentElement.lang, {'sensitivity': 'base'});
1091    };
1092
1093  // DataTables - start hidden to prevent FOUC.
1094    $('table.datatables').each(function () {
1095        $(this).DataTable(); $(this).removeClass('d-none'); });
1096
1097  // Create a new record while editing an existing one.
1098  // Paste the XREF and description into the Select2 element.
1099    $('.wt-modal-create-record').on('show.bs.modal', function (event) {
1100      // Find the element ID that needs to be updated with the new value.
1101        $('form', $(this)).data('element-id', $(event.relatedTarget).data('element-id'));
1102        $('form .form-group input:first', $(this)).focus();
1103    });
1104
1105  // Submit the modal form using AJAX, and paste the returned record ID/NAME into the parent form.
1106    $('.wt-modal-create-record form').on('submit', function (event) {
1107        event.preventDefault();
1108        var elementId = $(this).data('element-id');
1109        $.ajax({
1110            url: 'index.php',
1111            type: 'POST',
1112            data: new FormData(this),
1113            async: false,
1114            cache: false,
1115            contentType: false,
1116            processData: false,
1117            success: function (data) {
1118                $('#' + elementId).select2().empty().append(new Option(data.text, data.id)).val(data.id).trigger('change');
1119            },
1120            failure: function (data) {
1121                alert(data.error_message);
1122            }
1123        });
1124      // Clear the form
1125        this.reset();
1126      // Close the modal
1127        $(this).closest('.wt-modal-create-record').modal('hide');
1128    });
1129
1130  // Activate the langauge selection menu.
1131    $('.menu-language').on('click', '[data-language]', function () {
1132        $.post('index.php', {
1133            route: 'language',
1134            language: $(this).data('language')
1135        }, function () {
1136            document.location.reload();
1137        });
1138
1139        return false;
1140    });
1141
1142  // Activate the theme selection menu.
1143    $('.menu-theme').on('click', '[data-theme]', function () {
1144        $.post('index.php', {
1145            route: 'theme',
1146            theme: $(this).data('theme')
1147        }, function () {
1148            document.location.reload();
1149        });
1150
1151        return false;
1152    });
1153
1154  // Activate the on-screen keyboard
1155    var osk_focus_element;
1156    $('.wt-osk-trigger').click(function () {
1157      // When a user clicks the icon, set focus to the corresponding input
1158        osk_focus_element = document.getElementById($(this).data('id'));
1159        osk_focus_element.focus();
1160        $('.wt-osk').show();
1161
1162    });
1163
1164    $('.wt-osk-script-button').change(function () {
1165        $('.wt-osk-script').prop('hidden', true);
1166        $('.wt-osk-script-' + $(this).data('script')).prop('hidden', false);
1167    });
1168    $('.wt-osk-shift-button').click(function () {
1169        document.querySelector('.wt-osk-keys').classList.toggle('shifted');
1170    });
1171    $('.wt-osk-keys').on('click', '.wt-osk-key', function () {
1172        var key = $(this).contents().get(0).nodeValue;
1173        var shift_state = $('.wt-osk-shift-button').hasClass('active');
1174        var shift_key = $('sup', this)[0];
1175        if (shift_state && shift_key !== undefined) {
1176            key = shift_key.innerText;
1177        }
1178        if (osk_focus_element !== null) {
1179            var cursorPos = osk_focus_element.selectionStart;
1180            var v = osk_focus_element.value;
1181            var textBefore = v.substring(0, cursorPos);
1182            var textAfter  = v.substring(cursorPos, v.length);
1183            osk_focus_element.value = textBefore + key + textAfter;
1184            if ($('.wt-osk-pin-button').hasClass('active') === false) {
1185                $('.wt-osk').hide();
1186            }
1187        }
1188    });
1189
1190    $('.wt-osk-close').on('click', function () {
1191        $('.wt-osk').hide();
1192    });
1193});
1194