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