xref: /webtrees/resources/js/webtrees.js (revision a6656bb5f7cff260502deaf88d26942745ca1e1e)
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
18(function(webtrees) {
19    const lang = document.documentElement.lang;
20
21    // Identify the script used by some text.
22    const scriptRegexes = {
23        Han:  /[\u3400-\u9FCC]/,
24        Grek: /[\u0370-\u03FF]/,
25        Cyrl: /[\u0400-\u04FF]/,
26        Hebr: /[\u0590-\u05FF]/,
27        Arab: /[\u0600-\u06FF]/,
28    };
29
30    /**
31     * Tidy the whitespace in a string.
32     */
33    function trim(str) {
34        return str.replace(/\s+/g, " ").trim();
35
36    }
37
38    /**
39     * Look for non-latin characters in a string.
40     */
41    webtrees.detectScript = function (str) {
42        for(const script in scriptRegexes) {
43            if (str.match(scriptRegexes[script])) {
44                return script;
45            }
46        }
47
48        return "Latn";
49    };
50
51    /**
52     * In some languages, the SURN uses a male/default form, but NAME uses a gender-inflected form.
53     */
54    function inflectSurname(surname, sex) {
55        if (lang === "pl" && sex === "F") {
56            return surname
57                .replace(/ski$/, "ska")
58                .replace(/cki$/, "cka")
59                .replace(/dzki$/, "dzka")
60                .replace(/żki$/, "żka");
61        }
62
63        return surname;
64    }
65
66    /**
67     * Build a NAME from a NPFX, GIVN, SPFX, SURN and NSFX parts.
68     *
69     * Assumes the language of the document is the same as the language of the name.
70     */
71    webtrees.buildNameFromParts = function (npfx, givn, spfx, surn, nsfx, sex) {
72        const usesCJK      = webtrees.detectScript(npfx + givn + spfx + givn + surn + nsfx) === "Han";
73        const separator    = usesCJK ? "" : " ";
74        const surnameFirst = usesCJK || ['hu', 'jp', 'ko', 'vi', 'zh-Hans', 'zh-Hant'].indexOf(lang) !== -1;
75        const patronym     = ['is'].indexOf(lang) !== -1;
76        const slash        = patronym ? "" : "/";
77
78        // GIVN and SURN may be a comma-separated lists.
79        npfx = trim(npfx);
80        givn = trim(givn.replace(",", separator));
81        spfx = trim(spfx);
82        surn = inflectSurname(trim(surn.replace(",", separator)), sex);
83        nsfx = trim(nsfx);
84
85        const surname = trim(spfx + separator + surn);
86
87        const name = surnameFirst ? slash + surname + slash + separator + givn : givn + separator + slash + surname + slash;
88
89        return trim(npfx + separator + name + separator + nsfx);
90    };
91
92    // Insert text at the current cursor position in a text field.
93    webtrees.pasteAtCursor = function (element, text) {
94        if (element !== null) {
95            const caret_pos  = element.selectionStart + text.length;
96            const textBefore = element.value.substring(0, element.selectionStart);
97            const textAfter  = element.value.substring(element.selectionEnd);
98            element.value    = textBefore + text + textAfter;
99            element.setSelectionRange(caret_pos, caret_pos);
100            element.focus();
101        }
102
103    }
104}(window.webtrees = window.webtrees || {}));
105
106function expand_layer(sid)
107{
108    $('#' + sid + '_img').toggleClass('icon-plus icon-minus');
109    $('#' + sid).slideToggle('fast');
110    $('#' + sid + '-alt').toggle(); // hide something when we show the layer - and vice-versa
111    return false;
112}
113
114var pastefield;
115
116function valid_date(datefield, dmy)
117{
118    var months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
119    var hijri_months = ['MUHAR', 'SAFAR', 'RABIA', 'RABIT', 'JUMAA', 'JUMAT', 'RAJAB', 'SHAAB', 'RAMAD', 'SHAWW', 'DHUAQ', 'DHUAH'];
120    var hebrew_months = ['TSH', 'CSH', 'KSL', 'TVT', 'SHV', 'ADR', 'ADS', 'NSN', 'IYR', 'SVN', 'TMZ', 'AAV', 'ELL'];
121    var french_months = ['VEND', 'BRUM', 'FRIM', 'NIVO', 'PLUV', 'VENT', 'GERM', 'FLOR', 'PRAI', 'MESS', 'THER', 'FRUC', 'COMP'];
122    var jalali_months = ['FARVA', 'ORDIB', 'KHORD', 'TIR', 'MORDA', 'SHAHR', 'MEHR', 'ABAN', 'AZAR', 'DEY', 'BAHMA', 'ESFAN'];
123
124    var datestr = datefield.value;
125    // if a date has a date phrase marked by () this has to be excluded from altering
126    var datearr = datestr.split('(');
127    var datephrase = '';
128    if (datearr.length > 1) {
129        datestr = datearr[0];
130        datephrase = datearr[1];
131    }
132
133    // Gedcom dates are upper case
134    datestr = datestr.toUpperCase();
135    // Gedcom dates have no leading/trailing/repeated whitespace
136    datestr = datestr.replace(/\s+/, ' ');
137    datestr = datestr.replace(/(^\s)|(\s$)/, '');
138    // Gedcom dates have spaces between letters and digits, e.g. "01JAN2000" => "01 JAN 2000"
139    datestr = datestr.replace(/(\d)([A-Z])/, '$1 $2');
140    datestr = datestr.replace(/([A-Z])(\d)/, '$1 $2');
141
142    // Shortcut for quarter format, "Q1 1900" => "BET JAN 1900 AND MAR 1900". See [ 1509083 ]
143    if (datestr.match(/^Q ([1-4]) (\d\d\d\d)$/)) {
144        datestr = 'BET ' + months[RegExp.$1 * 3 - 3] + ' ' + RegExp.$2 + ' AND ' + months[RegExp.$1 * 3 - 1] + ' ' + RegExp.$2;
145    }
146
147    // Shortcut for @#Dxxxxx@ 01 01 1400, etc.
148    if (datestr.match(/^(@#DHIJRI@|HIJRI)( \d?\d )(\d?\d)( \d?\d?\d?\d)$/)) {
149        datestr = '@#DHIJRI@' + RegExp.$2 + hijri_months[parseInt(RegExp.$3, 10) - 1] + RegExp.$4;
150    }
151    if (datestr.match(/^(@#DJALALI@|JALALI)( \d?\d )(\d?\d)( \d?\d?\d?\d)$/)) {
152        datestr = '@#DJALALI@' + RegExp.$2 + jalali_months[parseInt(RegExp.$3, 10) - 1] + RegExp.$4;
153    }
154    if (datestr.match(/^(@#DHEBREW@|HEBREW)( \d?\d )(\d?\d)( \d?\d?\d?\d)$/)) {
155        datestr = '@#DHEBREW@' + RegExp.$2 + hebrew_months[parseInt(RegExp.$3, 10) - 1] + RegExp.$4;
156    }
157    if (datestr.match(/^(@#DFRENCH R@|FRENCH)( \d?\d )(\d?\d)( \d?\d?\d?\d)$/)) {
158        datestr = '@#DFRENCH R@' + RegExp.$2 + french_months[parseInt(RegExp.$3, 10) - 1] + RegExp.$4;
159    }
160
161    // e.g. 17.11.1860, 03/04/2005 or 1999-12-31. Use locale settings where DMY order is ambiguous.
162    var qsearch = /^([^\d]*)(\d+)[^\d](\d+)[^\d](\d+)$/i;
163    if (qsearch.exec(datestr)) {
164        var f0 = RegExp.$1;
165        var f1 = parseInt(RegExp.$2, 10);
166        var f2 = parseInt(RegExp.$3, 10);
167        var f3 = parseInt(RegExp.$4, 10);
168        var yyyy = new Date().getFullYear();
169        var yy = yyyy % 100;
170        var cc = yyyy - yy;
171        if (dmy === 'DMY' && f1 <= 31 && f2 <= 12 || f1 > 13 && f1 <= 31 && f2 <= 12 && f3 > 31) {
172            datestr = f0 + f1 + ' ' + months[f2 - 1] + ' ' + (f3 >= 100 ? f3 : (f3 <= yy ? f3 + cc : f3 + cc - 100));
173        } else {
174            if (dmy === 'MDY' && f1 <= 12 && f2 <= 31 || f2 > 13 && f2 <= 31 && f1 <= 12 && f3 > 31) {
175                datestr = f0 + f2 + ' ' + months[f1 - 1] + ' ' + (f3 >= 100 ? f3 : (f3 <= yy ? f3 + cc : f3 + cc - 100));
176            } else {
177                if (dmy === 'YMD' && f2 <= 12 && f3 <= 31 || f3 > 13 && f3 <= 31 && f2 <= 12 && f1 > 31) {
178                    datestr = f0 + f3 + ' ' + months[f2 - 1] + ' ' + (f1 >= 100 ? f1 : (f1 <= yy ? f1 + cc : f1 + cc - 100));
179                }
180            }
181        }
182    }
183
184    // Shortcuts for date ranges
185    datestr = datestr.replace(/^[>]([\w ]+)$/, 'AFT $1');
186    datestr = datestr.replace(/^[<]([\w ]+)$/, 'BEF $1');
187    datestr = datestr.replace(/^([\w ]+)[-]$/, 'FROM $1');
188    datestr = datestr.replace(/^[-]([\w ]+)$/, 'TO $1');
189    datestr = datestr.replace(/^[~]([\w ]+)$/, 'ABT $1');
190    datestr = datestr.replace(/^[*]([\w ]+)$/, 'EST $1');
191    datestr = datestr.replace(/^[#]([\w ]+)$/, 'CAL $1');
192    datestr = datestr.replace(/^([\w ]+) ?- ?([\w ]+)$/, 'BET $1 AND $2');
193    datestr = datestr.replace(/^([\w ]+) ?~ ?([\w ]+)$/, 'FROM $1 TO $2');
194
195    // Convert full months to short months
196    datestr = datestr.replace(/(JANUARY)/, 'JAN');
197    datestr = datestr.replace(/(FEBRUARY)/, 'FEB');
198    datestr = datestr.replace(/(MARCH)/, 'MAR');
199    datestr = datestr.replace(/(APRIL)/, 'APR');
200    datestr = datestr.replace(/(MAY)/, 'MAY');
201    datestr = datestr.replace(/(JUNE)/, 'JUN');
202    datestr = datestr.replace(/(JULY)/, 'JUL');
203    datestr = datestr.replace(/(AUGUST)/, 'AUG');
204    datestr = datestr.replace(/(SEPTEMBER)/, 'SEP');
205    datestr = datestr.replace(/(OCTOBER)/, 'OCT');
206    datestr = datestr.replace(/(NOVEMBER)/, 'NOV');
207    datestr = datestr.replace(/(DECEMBER)/, 'DEC');
208
209    // Americans frequently enter dates as SEP 20, 1999
210    // No need to internationalise this, as this is an english-language issue
211    datestr = datestr.replace(/(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)\.? (\d\d?)[, ]+(\d\d\d\d)/, '$2 $1 $3');
212
213    // Apply leading zero to day numbers
214    datestr = datestr.replace(/(^| )(\d [A-Z]{3,5} \d{4})/, '$10$2');
215
216    if (datephrase) {
217        datestr = datestr + ' (' + datephrase;
218    }
219    // Only update it if is has been corrected - otherwise input focus
220    // moves to the end of the field unnecessarily
221    if (datefield.value !== datestr) {
222        datefield.value = datestr;
223    }
224}
225
226var monthLabels = [];
227monthLabels[1] = 'January';
228monthLabels[2] = 'February';
229monthLabels[3] = 'March';
230monthLabels[4] = 'April';
231monthLabels[5] = 'May';
232monthLabels[6] = 'June';
233monthLabels[7] = 'July';
234monthLabels[8] = 'August';
235monthLabels[9] = 'September';
236monthLabels[10] = 'October';
237monthLabels[11] = 'November';
238monthLabels[12] = 'December';
239
240var monthShort = [];
241monthShort[1] = 'JAN';
242monthShort[2] = 'FEB';
243monthShort[3] = 'MAR';
244monthShort[4] = 'APR';
245monthShort[5] = 'MAY';
246monthShort[6] = 'JUN';
247monthShort[7] = 'JUL';
248monthShort[8] = 'AUG';
249monthShort[9] = 'SEP';
250monthShort[10] = 'OCT';
251monthShort[11] = 'NOV';
252monthShort[12] = 'DEC';
253
254var daysOfWeek = [];
255daysOfWeek[0] = 'S';
256daysOfWeek[1] = 'M';
257daysOfWeek[2] = 'T';
258daysOfWeek[3] = 'W';
259daysOfWeek[4] = 'T';
260daysOfWeek[5] = 'F';
261daysOfWeek[6] = 'S';
262
263var weekStart = 0;
264
265function cal_setMonthNames(jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec)
266{
267    monthLabels[1] = jan;
268    monthLabels[2] = feb;
269    monthLabels[3] = mar;
270    monthLabels[4] = apr;
271    monthLabels[5] = may;
272    monthLabels[6] = jun;
273    monthLabels[7] = jul;
274    monthLabels[8] = aug;
275    monthLabels[9] = sep;
276    monthLabels[10] = oct;
277    monthLabels[11] = nov;
278    monthLabels[12] = dec;
279}
280
281function cal_setDayHeaders(sun, mon, tue, wed, thu, fri, sat)
282{
283    daysOfWeek[0] = sun;
284    daysOfWeek[1] = mon;
285    daysOfWeek[2] = tue;
286    daysOfWeek[3] = wed;
287    daysOfWeek[4] = thu;
288    daysOfWeek[5] = fri;
289    daysOfWeek[6] = sat;
290}
291
292function cal_setWeekStart(day)
293{
294    if (day >= 0 && day < 7) {
295        weekStart = day;
296    }
297}
298
299function calendarWidget(dateDivId, dateFieldId)
300{
301    var dateDiv = document.getElementById(dateDivId);
302    var dateField = document.getElementById(dateFieldId);
303
304    if (dateDiv.style.visibility === 'visible') {
305        dateDiv.style.visibility = 'hidden';
306        return false;
307    }
308    if (dateDiv.style.visibility === 'show') {
309        dateDiv.style.visibility = 'hide';
310        return false;
311    }
312
313    /* Javascript calendar functions only work with precise gregorian dates "D M Y" or "Y" */
314    var greg_regex = /((\d+ (JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC) )?\d+)/i;
315    var date;
316    if (greg_regex.exec(dateField.value)) {
317        date = new Date(RegExp.$1);
318    } else {
319        date = new Date();
320    }
321
322    dateDiv.innerHTML = cal_generateSelectorContent(dateFieldId, dateDivId, date);
323    if (dateDiv.style.visibility === 'hidden') {
324        dateDiv.style.visibility = 'visible';
325        return false;
326    }
327    if (dateDiv.style.visibility === 'hide') {
328        dateDiv.style.visibility = 'show';
329        return false;
330    }
331
332    return false;
333}
334
335function cal_generateSelectorContent(dateFieldId, dateDivId, date)
336{
337    var i, j;
338    var content = '<table border="1"><tr>';
339    content += '<td><select class="form-control" id="' + dateFieldId + '_daySelect" onchange="return cal_updateCalendar(\'' + dateFieldId + '\', \'' + dateDivId + '\');">';
340    for (i = 1; i < 32; i++) {
341        content += '<option value="' + i + '"';
342        if (date.getDate() === i) {
343            content += ' selected="selected"';
344        }
345        content += '>' + i + '</option>';
346    }
347    content += '</select></td>';
348    content += '<td><select class="form-control" id="' + dateFieldId + '_monSelect" onchange="return cal_updateCalendar(\'' + dateFieldId + '\', \'' + dateDivId + '\');">';
349    for (i = 1; i < 13; i++) {
350        content += '<option value="' + i + '"';
351        if (date.getMonth() + 1 === i) {
352            content += ' selected="selected"';
353        }
354        content += '>' + monthLabels[i] + '</option>';
355    }
356    content += '</select></td>';
357    content += '<td><input class="form-control" type="text" id="' + dateFieldId + '_yearInput" size="5" value="' + date.getFullYear() + '" onchange="return cal_updateCalendar(\'' + dateFieldId + '\', \'' + dateDivId + '\');" /></td></tr>';
358    content += '<tr><td colspan="3">';
359    content += '<table width="100%">';
360    content += '<tr>';
361    j = weekStart;
362    for (i = 0; i < 7; i++) {
363        content += '<td ';
364        content += 'class="descriptionbox"';
365        content += '>';
366        content += daysOfWeek[j];
367        content += '</td>';
368        j++;
369        if (j > 6) {
370            j = 0;
371        }
372    }
373    content += '</tr>';
374
375    var tdate = new Date(date.getFullYear(), date.getMonth(), 1);
376    var day = tdate.getDay();
377    day = day - weekStart;
378    var daymilli = 1000 * 60 * 60 * 24;
379    tdate = tdate.getTime() - (day * daymilli) + (daymilli / 2);
380    tdate = new Date(tdate);
381
382    for (j = 0; j < 6; j++) {
383        content += '<tr>';
384        for (i = 0; i < 7; i++) {
385            content += '<td ';
386            if (tdate.getMonth() === date.getMonth()) {
387                if (tdate.getDate() === date.getDate()) {
388                    content += 'class="descriptionbox"';
389                } else {
390                    content += 'class="optionbox"';
391                }
392            } else {
393                content += 'style="background-color:#EAEAEA; border: solid #AAAAAA 1px;"';
394            }
395            content += '><a href="#" onclick="return cal_dateClicked(\'' + dateFieldId + '\', \'' + dateDivId + '\', ' + tdate.getFullYear() + ', ' + tdate.getMonth() + ', ' + tdate.getDate() + ');">';
396            content += tdate.getDate();
397            content += '</a></td>';
398            var datemilli = tdate.getTime() + daymilli;
399            tdate = new Date(datemilli);
400        }
401        content += '</tr>';
402    }
403    content += '</table>';
404    content += '</td></tr>';
405    content += '</table>';
406
407    return content;
408}
409
410function cal_setDateField(dateFieldId, year, month, day)
411{
412    var dateField = document.getElementById(dateFieldId);
413    if (!dateField) {
414        return false;
415    }
416    if (day < 10) {
417        day = '0' + day;
418    }
419    dateField.value = day + ' ' + monthShort[month + 1] + ' ' + year;
420    return false;
421}
422
423function cal_updateCalendar(dateFieldId, dateDivId)
424{
425    var dateSel = document.getElementById(dateFieldId + '_daySelect');
426    if (!dateSel) {
427        return false;
428    }
429    var monthSel = document.getElementById(dateFieldId + '_monSelect');
430    if (!monthSel) {
431        return false;
432    }
433    var yearInput = document.getElementById(dateFieldId + '_yearInput');
434    if (!yearInput) {
435        return false;
436    }
437
438    var month = parseInt(monthSel.options[monthSel.selectedIndex].value, 10);
439    month = month - 1;
440
441    var date = new Date(yearInput.value, month, dateSel.options[dateSel.selectedIndex].value);
442    cal_setDateField(dateFieldId, date.getFullYear(), date.getMonth(), date.getDate());
443
444    var dateDiv = document.getElementById(dateDivId);
445    if (!dateDiv) {
446        alert('no dateDiv ' + dateDivId);
447        return false;
448    }
449    dateDiv.innerHTML = cal_generateSelectorContent(dateFieldId, dateDivId, date);
450
451    return false;
452}
453
454function cal_dateClicked(dateFieldId, dateDivId, year, month, day)
455{
456    cal_setDateField(dateFieldId, year, month, day);
457    calendarWidget(dateDivId, dateFieldId);
458    return false;
459}
460
461function openerpasteid(id)
462{
463    if (window.opener.paste_id) {
464        window.opener.paste_id(id);
465    }
466    window.close();
467}
468
469function paste_id(value)
470{
471    pastefield.value = value;
472}
473
474function pastename(name)
475{
476    if (nameElement) {
477        nameElement.innerHTML = name;
478    }
479    if (remElement) {
480        remElement.style.display = 'block';
481    }
482}
483
484function paste_char(value)
485{
486    if (document.selection) {
487        // IE
488        pastefield.focus();
489        document.selection.createRange().text = value;
490    } else if (pastefield.selectionStart || pastefield.selectionStart === 0) {
491        // Mozilla/Chrome/Safari
492        pastefield.value =
493        pastefield.value.substring(0, pastefield.selectionStart) +
494        value +
495        pastefield.value.substring(pastefield.selectionEnd, pastefield.value.length);
496        pastefield.selectionStart = pastefield.selectionEnd = pastefield.selectionStart + value.length;
497    } else {
498        // Fallback? - just append
499        pastefield.value += value;
500    }
501
502    if (pastefield.id === 'NPFX' || pastefield.id === 'GIVN' || pastefield.id === 'SPFX' || pastefield.id === 'SURN' || pastefield.id === 'NSFX') {
503        updatewholename();
504    }
505}
506
507/**
508 * Persistant checkbox options to hide/show extra data.
509
510 * @param element_id
511 */
512function persistent_toggle(element_id)
513{
514    let element = document.getElementById(element_id);
515    let key     = 'state-of-' + element_id;
516    let state   = localStorage.getItem(key);
517
518    // Previously selected?
519    if (state === 'true') {
520        $(element).click();
521    }
522
523    // Remember state for the next page load.
524    $(element).on('change', function() { localStorage.setItem(key, element.checked); });
525}
526
527function valid_lati_long(field, pos, neg)
528{
529    // valid LATI or LONG according to Gedcom standard
530    // pos (+) : N or E
531    // neg (-) : S or W
532    var txt = field.value.toUpperCase();
533    txt = txt.replace(/(^\s*)|(\s*$)/g, ''); // trim
534    txt = txt.replace(/ /g, ':'); // N12 34 ==> N12.34
535    txt = txt.replace(/\+/g, ''); // +17.1234 ==> 17.1234
536    txt = txt.replace(/-/g, neg); // -0.5698 ==> W0.5698
537    txt = txt.replace(/,/g, '.'); // 0,5698 ==> 0.5698
538    // 0°34'11 ==> 0:34:11
539    txt = txt.replace(/\u00b0/g, ':'); // °
540    txt = txt.replace(/\u0027/g, ':'); // '
541    // 0:34:11.2W ==> W0.5698
542    txt = txt.replace(/^([0-9]+):([0-9]+):([0-9.]+)(.*)/g, function ($0, $1, $2, $3, $4) {
543        var n = parseFloat($1);
544        n += ($2 / 60);
545        n += ($3 / 3600);
546        n = Math.round(n * 1E4) / 1E4;
547        return $4 + n;
548    });
549    // 0:34W ==> W0.5667
550    txt = txt.replace(/^([0-9]+):([0-9]+)(.*)/g, function ($0, $1, $2, $3) {
551        var n = parseFloat($1);
552        n += ($2 / 60);
553        n = Math.round(n * 1E4) / 1E4;
554        return $3 + n;
555    });
556    // 0.5698W ==> W0.5698
557    txt = txt.replace(/(.*)([N|S|E|W]+)$/g, '$2$1');
558    // 17.1234 ==> N17.1234
559    if (txt && txt.charAt(0) !== neg && txt.charAt(0) !== pos) {
560        txt = pos + txt;
561    }
562    field.value = txt;
563}
564
565// Initialize autocomplete elements.
566function autocomplete(selector)
567{
568    // Use typeahead/bloodhound for autocomplete
569    $(selector).each(function () {
570        let that = this;
571        $(this).typeahead(null, {
572            display: 'value',
573            limit: 0,
574            source: new Bloodhound({
575                datumTokenizer: Bloodhound.tokenizers.obj.whitespace('value'),
576                queryTokenizer: Bloodhound.tokenizers.whitespace,
577                remote: {
578                    url: this.dataset.autocompleteUrl,
579                    replace: function(url, uriEncodedQuery) {
580                        if (that.dataset.autocompleteExtra) {
581                            let extra = $(document.querySelector(that.dataset.autocompleteExtra)).val();
582                            return url.replace("QUERY",uriEncodedQuery) + '&extra=' + encodeURIComponent(extra)
583                        }
584                        return url.replace("QUERY",uriEncodedQuery);
585                    },
586                    wildcard: 'QUERY',
587
588                }
589            })
590        });
591    });
592}
593
594/**
595 * Insert text at the current cursor position in an input field.
596 *
597 * @param e The input element.
598 * @param t The text to insert.
599 */
600function insertTextAtCursor(e, t)
601{
602    var scrollTop = e.scrollTop;
603    var selectionStart = e.selectionStart;
604    var prefix = e.value.substring(0, selectionStart);
605    var suffix = e.value.substring(e.selectionEnd, e.value.length);
606    e.value = prefix + t + suffix;
607    e.selectionStart = selectionStart + t.length;
608    e.selectionEnd = e.selectionStart;
609    e.focus();
610    e.scrollTop = scrollTop;
611}
612
613// Send the CSRF token on all AJAX requests
614$.ajaxSetup({
615    headers: {
616        'X-CSRF-TOKEN': $('meta[name=csrf]').attr('content')
617    }
618});
619
620// Initialisation
621$(function () {
622    // Page elements that load automaticaly via AJAX.
623    // This prevents bad robots from crawling resource-intensive pages.
624    $("[data-ajax-url]").each(function () {
625        $(this).load($(this).data('ajaxUrl'));
626    });
627
628    // Select2 - format entries in the select list
629    function templateOptionForSelect2(data)
630    {
631        if (data.loading) {
632          // If we're waiting for the server, this will be a "waiting..." message
633            return data.text;
634        } else {
635          // The response from the server is already in HTML, so no need to format it here.
636            return data.text;
637        }
638    }
639
640    // Autocomplete
641    autocomplete('input[data-autocomplete-url]');
642
643    // Select2 - activate autocomplete fields
644    const lang = document.documentElement.lang;
645    const select2_languages = {
646        'zh-Hans': 'zh-CN',
647        'zh-Hant': 'zh-TW',
648    };
649    $("select.select2").select2({
650        language: select2_languages[lang] || lang,
651        // Needed for elements that are initially hidden.
652        width: "100%",
653        // Do not escape - we do it on the server.
654        escapeMarkup: function (x) {
655            return x;
656        },
657    });
658
659    // If we clear the select (using the "X" button), we need an empty value
660    // (rather than no value at all) for (non-multiple) selects with name="array[]"
661    $("select.select2:not([multiple])")
662    .on("select2:unselect", function (evt) {
663        $(evt.delegateTarget).html("<option value=\"\" selected></option>");
664    });
665
666    // Datatables - locale aware sorting
667    $.fn.dataTableExt.oSort['text-asc'] = function (x, y) {
668        return x.localeCompare(y, document.documentElement.lang, {'sensitivity': 'base'});
669    };
670    $.fn.dataTableExt.oSort['text-desc'] = function (x, y) {
671        return y.localeCompare(x, document.documentElement.lang, {'sensitivity': 'base'});
672    };
673
674    // DataTables - start hidden to prevent FOUC.
675    $('table.datatables').each(function () {
676        $(this).DataTable();
677        $(this).removeClass('d-none');
678    });
679
680    // Save button state between pages
681    document.querySelectorAll("[data-toggle=button][data-persist]").forEach((element) => {
682        // Previously selected?
683        if (localStorage.getItem("state-of-" + element.dataset.persist) === 'T') {
684            element.click();
685        }
686        // Save state on change
687        element.addEventListener("click", (event) => {
688            // Event occurs *before* the state changes, so reverse T/F.
689            localStorage.setItem("state-of-" + event.target.dataset.persist, event.target.classList.contains("active") ? 'F' : 'T');
690        });
691    });
692
693    // Activate the on-screen keyboard
694    var osk_focus_element;
695    $('.wt-osk-trigger').click(function () {
696        // When a user clicks the icon, set focus to the corresponding input
697        osk_focus_element = document.getElementById($(this).data('id'));
698        osk_focus_element.focus();
699        $('.wt-osk').show();
700    });
701    $('.wt-osk-script-button').change(function () {
702        $('.wt-osk-script').prop('hidden', true);
703        $('.wt-osk-script-' + $(this).data('script')).prop('hidden', false);
704    });
705    $('.wt-osk-shift-button').click(function () {
706        document.querySelector('.wt-osk-keys').classList.toggle('shifted');
707    });
708    $('.wt-osk-keys').on('click', '.wt-osk-key', function () {
709        var key = $(this).contents().get(0).nodeValue;
710        var shift_state = $('.wt-osk-shift-button').hasClass('active');
711        var shift_key = $('sup', this)[0];
712        if (shift_state && shift_key !== undefined) {
713            key = shift_key.innerText;
714        }
715        webtrees.pasteAtCursor(osk_focus_element, key);
716        if ($('.wt-osk-pin-button').hasClass('active') === false) {
717            $('.wt-osk').hide();
718        }
719    });
720
721    $('.wt-osk-close').on('click', function () {
722        $('.wt-osk').hide();
723    });
724});
725
726// Convert data-confirm and data-post-url attributes into useful behavior.
727document.addEventListener("click",  (event) => {
728    const target = event.target.closest("a");
729
730    if (target === null) {
731        return;
732    }
733
734    if ("confirm" in target.dataset && !confirm(target.dataset.confirm)) {
735        event.preventDefault();
736        return;
737    }
738
739    if ("postUrl" in target.dataset) {
740        let request = new XMLHttpRequest();
741        let token   = document.querySelector("meta[name=csrf]").content;
742        request.open("POST", target.dataset.postUrl, true);
743        request.setRequestHeader("X-CSRF-TOKEN", token);
744        request.onreadystatechange = () => {
745            if (request.readyState === request.DONE) {
746                document.location.reload();
747            }
748        };
749        request.send();
750        event.preventDefault();
751    }
752});
753