1/* 2 * wikitree.js 3 * 4 * Common JavaScript functions used throughout WikiTree, or commonly enough that they are loaded on most every page via footer_javascript.tpl. 5 * 6 */ 7 8 9/* 10 * Scan the page for links of a particular format. Update these to include affiliate code. This runs in JS rather than server-side so it 11 * can catch content inside wiki templates, biography data, and other content that's just pulled from the db. 12 * Executed on-ready, runs against all 'a' tags on the page and modifies those with matching links. 13 */ 14function referralLinks() { 15 var link = $(this).attr('href'); 16 if (!link) { return; } 17 18 var encodedLink = encodeURIComponent(link); 19 var ancestryId = 'Xib7NfnK11s'; 20 var matches = null; 21 22 // Convert Ancestry links to use our affiliate links 23 if ( link.match(/^https?:\/\/((www|search|trees)\.)?ancestry.com(\/|$)/i) ) { 24 //$(this).attr('href', 'https://ancestry.prf.hn/click/camref:1011l4xx5/type:cpc/destination:'+link); 25 $(this).attr('href', `https://click.linksynergy.com/deeplink?id=${ancestryId}&mid=50138&murl=${encodedLink}`); 26 } 27 else if ( link.match(/^https?:\/\/((www|search|trees)\.)?ancestry.co.uk/i) ) { 28 //$(this).attr('href', 'https://ancestry.prf.hn/click/camref:1011l4JYM/type:cpc/destination:'+link); 29 $(this).attr('href', `https://click.linksynergy.com/deeplink?id=${ancestryId}&mid=50140&murl=${encodedLink}`); 30 } 31 else if ( link.match(/^https?:\/\/((www|search|trees)\.)?ancestry.ca/i) ) { 32 //$(this).attr('href', 'https://ancestry.prf.hn/click/camref:1101l4JVX/type:cpc/destination:'+link); 33 $(this).attr('href', `https://click.linksynergy.com/deeplink?id=${ancestryId}&mid=50139&murl=${encodedLink}`); 34 } 35 else if ( link.match(/^https?:\/\/((www|search|trees)\.)?ancestry.com.au/i) ) { 36 //$(this).attr('href', 'https://ancestry.prf.hn/click/camref:1011l4JYL/type:cpc/destination:'+link); 37 $(this).attr('href', `https://click.linksynergy.com/deeplink?id=${ancestryId}&mid=50142&murl=${encodedLink}`); 38 } 39 40 // Convert old-style Ancestry affiliate links to use our new affiliate links 41 else if ( matches = link.match(/^https?:\/\/(ancestry.)?prf.hn\/click\/camref:(.*)\/type:cpc\/destination:(.*)$/) ) { 42 let destination = encodeURIComponent(decodeURIComponent(matches[3])); 43 let camref = matches[2]; 44 let mid = '50138'; 45 if (camref == '1011l4JYL') { mid = '50142'; } 46 if (camref == '1011l4JYM') { mid = '50140'; } 47 if (camref == '1101l4JVX') { mid = '50139'; } 48 if (camref == '1011l4xx5') { mid = '50138'; } 49 $(this).attr('href', `https://click.linksynergy.com/deeplink?id=${ancestryId}&mid=${mid}&murl=${destination}`); 50 } 51 else if ( matches = link.match(/^https?:\/\/prf.hn\/click\/camref:(.*)\/destination:(.*)$/) ) { 52 let destination = encodeURIComponent(decodeURIComponent(matches[2])); 53 let camref = matches[1]; 54 let mid = '50138'; 55 if (camref == '1011l4JYL') { mid = '50142'; } 56 if (camref == '1011l4JYM') { mid = '50140'; } 57 if (camref == '1101l4JVX') { mid = '50139'; } 58 if (camref == '1011l4xx5') { mid = '50138'; } 59 $(this).attr('href', `https://click.linksynergy.com/deeplink?id=${ancestryId}&mid=${mid}&murl=${destination}`); 60 } 61 62 63 // Convert MyHeritage links to use our affiliate links 64 else if ( link.match(/^https?:\/\/(.+)?myheritage.com/) ) { 65 if (link.match(/utm_medium/)) { return; } 66 67 var funnel = ''; 68 if (link.match(/\/(research|last-name|search-records)/)) { funnel = 'data' ; } 69 else if (link.match(/\/dna/)) { funnel = 'mh+dna'; } 70 else { funnel = 'web'; } 71 72 var postscript = 'utm_source=partner_wikitree&utm_medium=partner&utm_campaign=wikitree_apr20&tr_funnel='+funnel; 73 if (link.match(/\?/)) { 74 $(this).attr('href', link+'&'+postscript); 75 } else { 76 $(this).attr('href', link+'?'+postscript); 77 } 78 } 79} 80 81 82 83/* 84 * This enables the click-to-copy feature on IDs, WikiTree IDs, URLs, etc. 85 * Trigger enabled on-ready on all content with a .copyWidget class. 86 */ 87function copyWidget(e) { 88 e.preventDefault(); 89 var target = e.currentTarget; 90 var text = $(target).html(); 91 if ($(target).data('copy-text')) { text = $(target).data('copy-text'); } 92 93 copyTextToClipboard(text); 94 95 let oldTitle = $(target).attr('data-bs-title'); 96 $(target).attr('data-bs-title','Copied').tooltip('dispose').tooltip('show'); 97 setTimeout( () => { 98 $(target).attr('data-bs-title', oldTitle).tooltip('dispose').tooltip('enable'); 99 }, 1000); 100 101} 102 103// This function is what actually copies a bit of text into the clipboard. 104// Ref: https://stackoverflow.com/questions/400212/how-do-i-copy-to-the-clipboard-in-javascript 105function copyTextToClipboard(text) { 106 var textArea = document.createElement("textarea"); 107 108 // *** This styling is an extra step which is likely not required. *** 109 // 110 // Why is it here? To ensure: 111 // 1. the element is able to have focus and selection. 112 // 2. if element was to flash render it has minimal visual impact. 113 // 3. less flakyness with selection and copying which **might** occur if the textarea element is not visible. 114 // 115 // The likelihood is the element won't even render, not even a flash, so some of these are just precautions. However in 116 // Internet Explorer the element is visible whilst the popup box asking the user for permission for the web page to 117 // copy to the clipboard. 118 // 119 // Place in top-left corner of screen regardless of scroll position. 120 textArea.style.position = 'fixed'; 121 textArea.style.top = 0; 122 textArea.style.left = 0; 123 124 // Ensure it has a small width and height. Setting to 1px / 1em 125 // doesn't work as this gives a negative w/h on some browsers. 126 textArea.style.width = '2em'; 127 textArea.style.height = '2em'; 128 129 // We don't need padding, reducing the size if it does flash render. 130 textArea.style.padding = 0; 131 132 // Clean up any borders. 133 textArea.style.border = 'none'; 134 textArea.style.outline = 'none';
135 textArea.style.boxShadow = 'none'; 136 137 // Avoid flash of white box if rendered for any reason. 138 textArea.style.background = 'transparent'; 139 140 // Set the value and copy it. 141 textArea.value = text; 142 document.body.appendChild(textArea); 143 textArea.focus(); 144 textArea.select(); 145 146 try { 147 var successful = document.execCommand('copy'); 148 var msg = successful ? 'successful' : 'unsuccessful'; 149 } catch (err) { 150 // console.log('Oops, unable to copy'); 151 } 152 153 document.body.removeChild(textArea); 154} 155 156 157 158/* Pop-up Interactions 159 * 160 * We have a number of page actions/interactions that are implemented with pop-up modals rather than fully separate pages. 161 * These all use the same Bootstrap dialog (#bsModal) for displaying the content. The content itself is loaded via Ajax. 162 * 163 * takedownRequest() - Triggered by link in footer on profile pages to request a takedown of a profile. 164 * privateMessage() - Triggered by link on profile page to send message to profile manager. 165 * problemQuestion() - Triggered by link on profile page to report a privacy problem or ask a question about a profile. 166 * rcIdDetails() - Triggered by clicking the Recent Change ID in a Network Feed item, to show details about that change (sysop only) 167 * crDetails() - Triggered by clicking the Content Rank badge in a profile, to show details about that Content Rank. 168 * g2gDetails() - Triggered by clicking a "share" to g2g button on a profile, to display a form that leads the user through posting on g2g. 169 * scsDetails() - Triggered by clicking the Surname Collaboration Scores button on a surname page, etc to show details of the collaborationscore. 170 */ 171 172function takedownRequest(e) { 173 let mNameDB = ""; 174 if(e) { 175 e.preventDefault(); 176 let target = e.currentTarget; 177 mNameDB = $(target).data('mnamedb'); 178 } else { 179 mNameDB = $('#pageData').data('mnamedb'); 180 } 181 182 $('#bsModalBody').html('<img src="/images/icons/ajax-loader-snake-333-trans.gif" alt="" />  '); 183 $('#bsModal').modal('show'); 184 185 $.post( 186 '/index.php', 187 { 188 'title': 'Special:Contact', 189 'action': 'takedownRequest', 190 'n': mNameDB 191 }, 192 function(data) { 193 var json = JSON.parse(data); 194 $('#bsModalBody').html(json.body); 195 $('#bsModalLabel').html(json.label); 196 197 } 198 ); 199} 200function takedownRequestSubmit() { 201 var mNameDB = $('#takedown-mnamedb').val(); 202 var formData = $('#takedownRequestForm').serializeArray(); 203 var formJSON = JSON.stringify(formData); 204 $.post( 205 '/index.php', 206 { 207 'title': 'Special:Contact', 208 'action': 'takedownRequest', 209 'n': mNameDB, 210 'go': 1, 211 'formJSON': formJSON 212 }, 213 function(data) { 214 var json = JSON.parse(data); 215 $('#bsModalBody').html(json.body); 216 $('#bsModalLabel').html(json.label); 217 } 218 ); 219} 220 221function privateMessage(e) { 222 e.preventDefault(); 223 var target = e.currentTarget; 224 var who = $(target).data('who'); 225 var ref = $(target).data('ref'); 226 227 doPrivateMessage(who,ref); 228} 229function doPrivateMessage(who,ref) { 230 $('#bsModalBody').html('<img src="/images/icons/ajax-loader-snake-333-trans.gif" alt="" />  '); 231 $('#bsModal').modal('show'); 232 233 $.post( 234 '/index.php', 235 { 236 'title': 'Special:PrivateMessage', 237 'action': 'privateMessage', 238 'who': who, 239 'ref': ref 240 }, 241 function(data) { 242 var json = JSON.parse(data); 243 $('#bsModalBody').html(json.body); 244 $('#bsModalLabel').html(json.label); 245 } 246 ); 247} 248function privateMessageSubmit() { 249 $.post( 250 '/index.php', 251 { 252 'title': 'Special:PrivateMessage', 253 'action': 'privateMessage', 254 'who': $('#privateMessage-who').val(), 255 'ref': $('#privateMessage-ref').val(), 256 'sender_name': $('#privateMessage-sender_name').val(), 257 'sender_email': $('#privateMessage-sender_email').val(), 258 'subject': $('#privateMessage-subject').val(), 259 'comments': $('#privateMessage-comments').val(), 260 'answer': $('#privateMessage-answer').val(), 261 'answer2': $('#privateMessage-answer2').val(), 262 'has_js': $('#privateMessage-has_js').val(), 263 'g-recaptcha-response': $('#g-recaptcha-response').val(), 264 'go': 1 265 }, 266 function(data) { 267 var json = JSON.parse(data); 268 if (json.sent == -1) { 269 $('#bsModalBody').html(); 270 $('#bsModalLabel').html(); 271 $('#bsModal').hide(); 272 } else { 273 $('#bsModalBody').html(json.body); 274 $('#bsModalLabel').html(json.label); 275 } 276 } 277 ); 278} 279 280function problemQuestion(e) { 281 e.preventDefault(); 282 var target = e.currentTarget; 283 var mNameDB = $(target).data('mnamedb'); 284 285 $('#bsModalBody').html('<img src="/images/icons/ajax-loader-snake-333-trans.gif" alt="" />  '); 286 $('#bsModal').modal('show'); 287 288 $.post( 289 '/index.php', 290 { 291 'title': 'Special:Contact', 292 'action': 'problemQuestion', 293 'n': mNameDB 294 }, 295 function(data) { 296 var json = JSON.parse(data); 297 $('#bsModalBody').html(json.body); 298 $('#bsModalLabel').html(json.label); 299 } 300 ); 301 302} 303 304 305/* This displays the details of a recent change in a Network Feed item (including Thanks feeds, etc).
306 * Trigger set up on-ready for the displayed ids on feed items with the .rcIdDetails class. Clicking launches the modal, filled via Ajax request. 307 * The class is only added for sysops, and the Ajax response is limited to sysop users. 308 */ 309function rcIdDetails(e) { 310 e.preventDefault(); 311 var target = e.currentTarget; 312 var rc_id = $(target).html(); 313 314 $('#bsModalBody').html('<img src="/images/icons/ajax-loader-snake-333-trans.gif" alt="" />  '); 315 $('#bsModal').modal('show'); 316 317 $.post( 318 '/index.php', 319 { 320 'title': 'Special:NetworkFeed', 321 'action': 'rcIdDetails', 322 'rc_id': rc_id 323 }, 324 function(data) { 325 var json = JSON.parse(data); 326 $('#bsModalBody').html(json.body); 327 $('#bsModalLabel').html(json.label); 328 } 329 ); 330 331} 332 333/* This displays the details of a Content Rank. 334 * Trigger set up on-ready for the displayed CR "badge" in profile_data.tpl with the .cr-details class. Clicking launches the modal, filled via Ajax request. 335 */ 336function crDetails(e) { 337 e.preventDefault(); 338 var target = e.currentTarget; 339 var personId = $(target).data('personid'); 340 if (personId) { doCRDetails(personId);} 341} 342function doCRDetails(personId) { 343 $('#bsModalBody').html('<img src="/images/icons/ajax-loader-snake-333-trans.gif" alt="" />  '); 344 $('#bsModal').modal('show'); 345 346 var postData = "rs="+encodeURIComponent('User::ajaxGetCRDetails') + "&rsargs[]="+personId; 347 $.post( '/index.php?action=ajax', postData, function(data) { 348 if (data) { 349 var json = JSON.parse(data); 350 if (json.error) { 351 $('#bsModalBody').html('<div class="alert alert-danger" role="alert">There was an error retrieving the Content Rank details.</div>'); 352 $('#bsModalLabel').html('Content Rank Details'); 353 // console.log(json.error);args 354 } else { 355 $('#bsModalBody').html(json.body); 356 if (json.label) { 357 $('#bsModalLabel').html(json.label); 358 } else { 359 $('#bsModalLabel').html('Content Rank Details'); 360 } 361 } 362 } 363 }); 364} 365 366/* This displays the details of a Surname Collaboration Score. 367 * Trigger set up on-ready for the displayed SCS "badge" in surname/surname_collaboration.tpl with the .scs-details class. Clicking launches the modal, filled via Ajax request. 368 */ 369function scsDetails(e) { 370 e.preventDefault(); 371 var target = e.currentTarget; 372 var surname = $(target).data('surname'); 373 374 $('#bsModalBody').html('<img src="/images/icons/ajax-loader-snake-333-trans.gif" alt="" />  '); 375 $('#bsModal').modal('show'); 376 377 var postData = "rs="+encodeURIComponent('Surname::ajaxGetSCSDetails') + "&rsargs[]="+surname; 378 $.post( '/index.php?action=ajax', postData, function(data) { 379 if (data) { 380 var json = JSON.parse(data); 381 if (json.error) { 382 $('#bsModalBody').html('<div class="alert alert-danger" role="alert">There was an error retrieving the Surname CollborationScores details.</div>'); 383 $('#bsModalLabel').html('Surname Collaboration Scores Details'); 384 // console.log(json.error);args 385 } else { 386 $('#bsModalBody').html(json.body); 387 if (json.label) { 388 $('#bsModalLabel').html(json.label); 389 } else { 390 $('#bsModalLabel').html('Surname Collaboration Scores Details'); 391 } 392 } 393 } 394 }); 395 396} 397 398 399 400 401/* In several places on the site we want to facilitate the user posting a question to G2G. From a trigger we pop-up a modal that contains a form 402 * where the user can be lead through various questions/selections. The result of that form is then gathered, via another template, and used 403 * to actually post the data to G2G. The user is taken there and the result of the post is as though they had used the "ask a question" form on G2G directly. 404 * There are different triggers with different leading questions. So the input is managed via template, gathered from the server. 405 * Then the "output" -- the data we put into form elements to post back to G2G -- is managed by another post to the server and a separate output template. 406 */ 407function g2gPost(e) { 408 // If we don't have a template name/key, the server will use a default one. 409 // This shouldn't happen -- we should only add the data-g2gtemplate attribute to triggers where we have a specific template in mind. 410 let template = ""; 411 let label = ""; 412 let dataFields = {}; 413 414 if(e) { 415 e.preventDefault(); 416 let target = e.currentTarget; 417 template = $(target).data('template'); 418 label = $(target).data('label'); 419 420 $.each(target.attributes, function() { 421 if (this.name.indexOf('data-') === 0) { 422 let key = this.name.substring(5).replace(/-/g, '_'); 423 424 if (key !== 'template' && key !== 'label') { 425 dataFields[key] = this.value; 426 } 427 } 428 }); 429 430 } 431 432 $('#bsModalBody').html('<img src="/images/icons/ajax-loader-snake-333-trans.gif" alt="" />  '); 433 $('#bsModal').modal('show'); 434 435 $.post( 436 '/index.php', 437 $.extend( 438 { 439 'title': 'Special:G2GPost', 440 'action': 'getForm', 441 't': template, 442 'label': label 443 }, 444 dataFields 445 ), 446 447 function(data) { 448 var json = JSON.parse(data); 449 $('#bsModalBody').html(json.body); 450 $('#bsModalLabel').html(json.label); 451 } 452 ); 453} 454 455 456// When a G2GPost modal widget is submitted, we gather the data from that form and post it to the server. 457// The server processes the data and uses a template to send back computed content that we can then post to G2G. 458function g2gPostSubmit(e) { 459 // Check for HTML5 form validation errors first. 460 const form = document.getElementById('g2gPostFormInput'); 461 462 if (form && !form.checkValidity()) { 463 form.reportValidity(); 464 return false;
465 } 466 467 // Gather all data from the g2GPost form. 468 let formData = $('#g2gPostFormInput').serializeArray(); 469 470 // Add the "title" and "action" for the post, so the request ends up at Special:G2GPost. 471 formData.push({name: 'title', value: 'Special:G2GPost'}); 472 formData.push({name: 'action', value: 'getPost'}); 473 474 $.post( 475 '/index.php', 476 formData, 477 function(data) { 478 var json = JSON.parse(data); 479 $('#bsModalBody').html(json.body); 480 $('#bsModalLabel').html(json.label); 481 482 $('#g2gPostFormOutput').submit(); 483 484 // Close the modal after a delay so we can do the submit first. 485 $('#bsModalBody').html(); 486 $('#bsModalLabel').html(); 487 $('#bsModal').modal('hide'); 488 } 489 ); 490} 491 492 493 494 495 496 497/* 498 * We edit the Research Status for a profile through a modal instead of as a field on EditPerson. We don't save versions, etc. We save changes like changes to the 499 * privacy, but done through a modal instead of a separate page like Special:TrustedList. 500 */ 501function editResearchStatus(e) { 502 let userId = ""; 503 let onSave=""; 504 if(e) { 505 e.preventDefault(); 506 let target = e.currentTarget; 507 userId = $(target).data('userid'); 508 onSave = $(target).data('onsave'); 509 } 510 511 $('#bsModalBody').html('<img src="/images/icons/ajax-loader-snake-333-trans.gif" alt="" />  '); 512 $('#bsModal').modal('show'); 513 514 $.post( 515 '/index.php', 516 { 517 'title': 'Special:EditResearchStatus', 518 'userId': userId, 519 'onSave': onSave 520 }, 521 function(data) { 522 var json = JSON.parse(data); 523 $('#bsModalBody').html(json.body); 524 $('#bsModalLabel').html(json.label); 525 } 526 ); 527} 528 529 530// When an EditResearchStatus modal widget is submitted, we gather the data from that form and post it to the server. 531// The server processes the data and uses a template to send back the results to display in the modal. 532function editResearchStatusSubmit(e) { 533 // Gather all data from the EditResearchStatus form. 534 let formData = $('#editResearchStatusForm').serializeArray(); 535 536 // Add the "title" and "action" for the post, so the request ends up at Special:EditResearchStatus. 537 formData.push({name: 'title', value: 'Special:EditResearchStatus'}); 538 formData.push({name: 'action', value: 'save'}); 539 540 // If we have an on-save value, and it has an iconId action, we need to check that iconId 541 // container for template parameters (data-minstatus, data-showtext) so we can pass those 542 // through on the ajax call and get back a replacement icon. 543 let onSave = $('#rs_onsave').val(); 544 let actions = onSave.split(','); 545 for (let action of actions) { 546 if (action.startsWith('iconId:')) { 547 let iconId = action.split(':')[1]; 548 let iconContainer = $('#'+iconId); 549 if (iconContainer.length) { 550 let minStatus = iconContainer.data('minstatus'); 551 let showText = iconContainer.data('showtext'); 552 formData.push({name: 'minStatus', value: minStatus}); 553 formData.push({name: 'showText', value: showText}); 554 } 555 } 556 } 557 558 559 $.post( 560 '/index.php', 561 formData, 562 function(data) { 563 var json = JSON.parse(data); 564 565 // If the form had an onSave value, do that action now, unless there was an error. 566 let mNameDB = $('#rs_mnamedb').val(); 567 568 if (onSave && mNameDB && !json?.error) { 569 // Our onSave might be mutli-valued, separated by commas. Do them all. 570 let closeModal = false; 571 let newLocation = ''; 572 for (let action of actions) { 573 if (action == 'profile') { 574 // Go to the profile page with a status. 575 newLocation = '/index.php?title='+mNameDB+'&errcode=rssaved'; 576 } 577 578 // If the onSave is status:something then we want to update the container with "something" as the id. 579 else if (action.startsWith('statusId:')) { 580 let statusId = action.split(':')[1]; 581 $('#'+statusId).html(json.status).show(); 582 closeModal = true; 583 } 584 585 // If the onSave is iconId:something then we want to update the icon with "something" as the id. 586 // How do we replace this? We dont' know the parameters used in the original icon build. 587 else if (action.startsWith('iconId:')) { 588 let iconId = action.split(':')[1]; 589 $('#'+iconId).html(json.iconHtml); 590 } 591 } 592 593 if (closeModal) { 594 $('#bsModal').modal('hide'); 595 $('#bsModalBody').html(''); 596 $('#bsModalLabel').html(''); 597 } 598 if (newLocation) { 599 window.location.href = newLocation; 600 } 601 602 return; 603 } 604 605 // Default without onSave is just to show our content back in the modal. 606 $('#bsModalBody').html(json.body); 607 $('#bsModalLabel').html(json.label); 608 609 } 610 ); 611} 612 613/* 614 * 615 * fsLocationSuggestion() 616 * Make use of the Family Search Location API and JavaScript SDK to get suggestions for a location input field. 617 * Requires jQuery, FamilySearch SDK, and jQuery.auto-complete. 618 * Also requires the user's IP address which it looks for in a hidden item with id=IP. 619 * 620 * Requires: jQuery, FamilySearch SDK, jquery.auto-complete. 621 * Be sure these are loaded in footer_javascript.tpl before this wikitree.js file. 622 * 623 * Styling of results via .autocomplete-suggestions, .autocomplete-suggestion, .autocomplete-suggestion.selected 624 * See: http://familysearch.github.io/familysearch-javascript-sdk/2.9/#/api/places.functions:getPlacesSearch 625 * https://goodies.pixabay.com/jquery/auto-complete/demo.html 626 * 627 * 628 * Updated April 2024 to use the new FS API and the new "thin" SDK. 629 * The old Family Search SDK is obsolete/deprecated (https://unpkg.com/[email protected]/dist/familysearch-javascript-sdk.min.js) 630 * Instead we need to use the one at https://github.com/FamilySearch/fs-js-lite -- i.e., https://unpkg.com/fs-js-lite@latest/dist/FamilySearch.min.js 631 * See docs at https://www.familysearch.org/developers/docs/api/resources 632 * 633 */ 634 635var fsClient; 636var lastLocationId; 637var languageNoteShown = 0; 638var disableLocationSuggestions = 0; 639 640 641function initLocationSuggestions() { 642 if (!fsClient) { 643 // Make sure the FamilySearch code was loaded (from the CDN or wherever). 644 if (typeof FamilySearch === 'function') { 645 fsClient = new FamilySearch({ 646 appKey: 'a0T3000000Bhy5yEAB', 647 environment: 'production',
648 saveAccessToken: true, 649 }); 650 } 651 } 652 653 // FS search won't work without an IP address. 654 if (!$('#IPaddr').length) { return; } 655 656 // If we successfully created the FS client, set up auto-complete to use it for suggestions. 657 // For every input with the class "location", check for a data element of "location-date". Then 658 // Create an autoComplete on it that uses fsLocationSearch as the source, with the date element id as an additional parameter. 659 // Also set up the renderItem function to use fsLocationSearchItemRender, which allows us to customize the display of the results to include the temporal description and other data. 660 if (fsClient) { 661 $('.location').each( function() { 662 let dateId = $(this).data('location-date'); 663 $(this).autoComplete({ 664 cache: false, 665 menuClass: 'hidden', 666 source: function (term, suggest) { fsLocationSearch(term,suggest,dateId); }, 667 renderItem: function (place,search) { return fsLocationSearchItemRender(place,search); }, 668 onSelect: function (event, term, item) { changesMade = 1; }, 669 }); 670 671 // If we're setting an autocomplete on an input field, we don't want enter (selecting a suggestion) to submit the form. 672 $(this).on('keydown',function(event) { 673 lastLocationId = $(this).attr('id'); 674 if (event.keyCode == 13) { event.preventDefault(); return false; } 675 }); 676 }); 677 } 678 679} 680 681function toggleLocationSuggestions() { 682 if (disableLocationSuggestions == 0) { 683 disableLocationSuggestions = 1; 684 $('.location').autoComplete("destroy"); 685 $('.toggleLocationSuggestions').html("show place name suggestions"); 686 } else { 687 disableLocationSuggestions = 0; 688 initLocationSuggestions(); 689 $('.toggleLocationSuggestions').html("hide place name suggestions"); 690 } 691 var post_data = "rs=" + encodeURIComponent('User::ajaxUpdateOption') + "&rsargs[]=" + encodeURIComponent('disableLocationSuggestions') + "&rsargs[]="+encodeURIComponent(disableLocationSuggestions); 692 $.post('/index.php?action=ajax', post_data, function (result) { 693 //if (result) { 694 // var j = JSON.parse(result); 695 // // if (j.error) { console.log(j.error); } 696 //} 697 }); 698 699} 700 701 702function fsLocationSearch(term, suggest, dateId) { 703 var language = ''; 704 if ($('#mOptions_person_language')) { language = $('#mOptions_person_language').val(); } 705 if (fsClient) { 706 let params = { 707 headers: { Accept: 'application/x-gedcomx-atom+json' } 708 }; 709 if (language) { 710 params.headers['Accept-Language'] = language; 711 } 712 713 let query = 'name:"'+encodeURIComponent(term)+'"'; 714 if (dateId) { 715 let dateVal = $('#'+dateId).val(); 716 if (dateVal) { 717 // Strip the YYYY year out of our date. 718 let yearMatch = dateVal.match(/(\d{4})/); 719 let year = yearMatch ? yearMatch[1] : ''; 720 if (year) { 721 query += "%20" + encodeURIComponent("date:+"+year); 722 } 723 } 724 } 725 726 console.log('/platform/places/search?q='+query); 727 fsClient.get('/platform/places/search?q='+query, 728 params, 729 function(error, response) { 730 if (error) { 731 // console.log("Network error from FS /places/search: " + error); 732 } 733 else if (response.statusCode != 200) { 734 // A 204 no data response just means there are no results... 735 if (response.statusCode == 204) { 736 //console.log("204 - no data"); 737 } else { 738 // console.log("Result error from FS /places/search: " + response.statusCode + " " + response.statusText); 739 // console.log(response); 740 } 741 } 742 else { 743 // The response has a data element with an array of entries. 744 // Each entry has an id and then a content.gedcomx element with an array of places. 745 // For some reason the gedcomx data has multiple (three) places. They each have an id, and one matches the id on the entry. 746 // That's the one we want. Don't know what's up with the other two. They seem to repeat somewhat across entries. 747 var suggestions = []; 748 var searchResults = response.data.entries; 749 if (searchResults.length) {
750 searchResults.forEach( 751 function(item,index,arr) { 752 let id = item.id; 753 var place = null; 754 item.content.gedcomx.places.forEach( function(p) { if (p.id == id) { place = p; } } ); 755 if (place) { 756 suggestions.push( place ); 757 } 758 } 759 ); 760 suggest( suggestions ); 761 } 762 else { 763 //console.log("No results to suggest."); 764 } 765 } 766 } 767 ); 768 } 769} 770 771function cleanPlaceName(name) { 772 name = name.replace(/,?\s*British Colonial America/i,''); 773 name = name.replace(/,?\s*British North America/i,''); 774 775 return name; 776} 777 778function fsLocationSearchItemRender(place,search) { 779 var item = place.display.fullName; 780 781 // Default view is to display the place with the search term highlighted within it. 782 search = search.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); 783 var re = new RegExp("(" + search.split(' ').join('|') + ")", "gi"); 784 785 // Build a bigger result with more data. 786 var cleanItem = cleanPlaceName(item); 787 if (cleanItem == '') { return ''; } 788 var itemWithTerms = cleanItem.replace(re, "<span class='autocomplete-suggestion-term'>$1</span>"); 789 790 var html = `<div class="autocomplete-suggestion" data-val="${cleanItem}">`; 791 html += '<img src="/images/icons/map.gif">'; 792 html += `<span>${itemWithTerms}</span>`; 793 794 let temporalDescription = place?.temporalDescription?.formal; 795 if (temporalDescription) { 796 temporalDescription = temporalDescription.replace(/\+/g, ''); 797 temporalDescription = temporalDescription.replace(/\//g, ' - '); 798 html += ' ('+temporalDescription+') '; 799 } 800 801 html += '</div>'; // Close autocomplete-suggestion 802 803 return html; 804} 805 806 807function onHashChange() { 808 var hash = window.location.hash; 809 810 // Check for in-page actions like private message or take-down requests. 811 found = hash.match(/#PM\-(\d+)(\-(\d+))?/); 812 if (found) { 813 doPrivateMessage(found[1],found[2]); 814 } 815 816 found = hash.match(/#TD/); 817 if (found) { 818 takedownRequest(); 819 } 820 821 found = hash.match(/#Slideshow/); 822 if (found) { 823 document.getElementById('Photos').scrollIntoView(); 824 $('.profile--photo').first().click(); 825 } 826 827 found = hash.match(/#CR\-(\d+)/); 828 if (found) { 829 doCRDetails(found[1]); 830 } else { 831 found = hash.match(/#CR/); 832 if (found) { 833 let personId = $('#pageData').data('mid'); 834 if (personId) { doCRDetails(personId); } 835 } 836 } 837 838 839 // Check for page tabs/panes for Ancestors, Descendants, Family. 840 // Only switch the pane if the hash is one of our tab anchors, not just any anchor on the page. 841 if ($('#Ancestors-pane').length > 0) { 842 if (hash) { 843 if (hash == '#Ancestors') { displayTab = 'ancestors'; } 844 else if (hash == '#Descendants') { displayTab = 'descendants'; } 845 else if (hash == '#Family') { displayTab = 'family'; } 846 else { return; } 847 } 848 849 // Show the tab that we've determined should be displayed. 850 if (displayTab == 'ancestors') { 851 $('.ancestors--content').addClass('is--visible'); 852 let tabElement = document.querySelector('#Ancestors-tab'); 853 let b = new bootstrap.Tab(tabElement); 854 b.show(); 855 $('#Descendants-pane').removeClass('active').removeClass('show'); 856 $('#Family-pane').removeClass('active').removeClass('show'); 857 } 858 else if (displayTab == 'descendants') { 859 showDescendants(); 860 } 861 else if (displayTab == 'family') { 862 let tabElement = document.querySelector('#Family-tab'); 863 let b = new bootstrap.Tab(tabElement); 864 b.show(); 865 $('#Ancestors-pane').removeClass('active').removeClass('show'); 866 $('#Descendants-pane').removeClass('active').removeClass('show'); 867 } 868 else { 869 // If we don't have a set displayTab, default to the Family one. 870 let tabElement = document.querySelector('#Family-tab'); 871 let b = new bootstrap.Tab(tabElement); 872 b.show(); 873 $('#Ancestors-pane').removeClass('active').removeClass('show'); 874 $('#Descendants-pane').removeClass('active').removeClass('show'); 875 } 876 } 877} 878 879 880/* 881 * Toggle a cell in the #Ancestors display to swap out a non-biological person for their biological equivalent, or the reverse. 882 */ 883function toggleAncestorCell(e) { 884 // Don't disable this...we want the toggle switch to switch. 885 // This becomes moot when we replace the cell actually. 886 //e.preventDefault(); 887 var target = e.currentTarget; 888 889 // We might have a tooltip displayed from the switch. Clear it in this cell before we move forward replacing the content. 890 $(target).tooltip('dispose'); 891 892 // Are we "on" meaning we want the biological person, or "off" so we want the regular person? 893 var wantBio = $(target).is(':checked'); 894 895 // Go up the DOM from the target to find the div with an id like "ancestor-cell-foo". 896 var cellDiv = $(target).closest('div[id^="ancestor-cell-"]'); 897 if (!cellDiv.length) { return; } 898 899 // From the cell id we can tell which relative we need to replace, encoded father/mother sequence. 900 var cellId = cellDiv.attr('id'); 901 var cellRelatives = cellId.match(/^ancestor-cell-(.+)$/); 902 if (!cellRelatives) { return; } 903 var thisRelative = cellRelatives[1]; 904 905 // The person/user id of the profile we're replacing from the switch data. 906 var personId = $('#'+cellId).data('mid'); 907 908 // The child whose parent we're toggling is the one "above" this cell in the tree. 909 var childPersonId = ''; 910 var childCellId = 'ancestor-cell-' + thisRelative.slice(0,-1); 911 var replacedRelative = thisRelative.slice(-1); 912 if (childCellId == 'ancestor-cell-') { 913 childPersonId = $('#pageData').data('mid'); 914 } else { 915 childPersonId = $('#'+childCellId).data('mid'); 916 } 917 if (!childPersonId) { return; } 918 919 920 // Make an Ajax call to get the replacement ancestor and each of _that_ profile's ancestors so 921 // we can replace up the #Ancestors tree. 922 var postData = "rs="+encodeURIComponent('User::ajaxToggleAncestorCell') + "&rsargs[]="+childPersonId + "&rsargs[]="+thisRelative + "&rsargs[]="+(wantBio?1:0); 923 $.post( '/index.php?action=ajax', postData, function(data) { 924 if (data) { 925 var json = JSON.parse(data); 926 if (json.error) { 927 $('#bsModalBody').html('<div class="alert alert-danger" role="alert">There was an error retrieving the ancestor data.</div>'); 928 $('#bsModalLabel').html('Ancestor Data'); 929 console.log(json.error); 930 } else { 931 // Replace the cell in the ancestor tree with our replacement html. 932 if (json.htmlByCellKey) { 933 //$('#'+cellId).html(json.html); 934 935 // Replace each cell by key with the HTML in the returned array.
936 for (const [key, value] of Object.entries(json.htmlByCellKey)) { 937 let replaceCellId = 'ancestor-cell-' + key; 938 $('#'+replaceCellId).animate({opacity: 0}, 200, function() { 939 $(this).html(value).animate({opacity: 1}, 200); 940 941 // Enable the Bootstrap tooltips in the new content. 942 var tooltipTriggerList = Array.from(document.querySelectorAll('#' + replaceCellId + ' [data-bs-toggle="tooltip"]')); 943 var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { 944 return new bootstrap.Tooltip(tooltipTriggerEl); 945 }); 946 }); 947 } 948 949 // We also need to update the data-mid attribute in the cell div so cascading toggles work. 950 $('#'+cellId).data('mid', json.mId); 951 952 } else { 953 $('#bsModalBody').html('<div class="alert alert-danger" role="alert">There was an error retrieving the ancestor data.</div>'); 954 $('#bsModalLabel').html('Ancestor Data'); 955 } 956 } 957 } 958 }); 959 960 961} 962 963/* 964 * On DOM ready, set up all the triggers and execute our page-ready functions like referral links, etc. 965 */ 966$(document).ready(function(){ 967 968 // Translate timestamps into relative ones 969 $("time.timeago").timeago(); 970 971 // Replace links with referral/affiliate links 972 $('a').each(referralLinks); 973 974 // Enable copy-widgets 975 $(document).on('click', '.copyWidget', function(e) { copyWidget(e); }); 976 $(document).on('mouseout', '.copyWidget', function(e) { 977 if ($(this).data('copy-label')) { 978 $(this).attr('aria-label', $(this).data('copy-label')); 979 } else { 980 $(this).attr('aria-label', 'Copy ID'); 981 } 982 }); 983 $(document).on('focusout', '.copyWidget', function(e) { 984 if ($(this).data('copy-label')) { 985 $(this).attr('aria-label', $(this).data('copy-label')); 986 } else { 987 $(this).attr('aria-label', 'Copy ID'); 988 } 989 }); 990 991 // Enable the triggering links/buttons on the page which call functions that open modals and fill with content. 992 $(document).on('click', '.takedownRequestLink', function(e) { takedownRequest(e); }); 993 $(document).on('click', '.privateMessageLink', function(e) { privateMessage(e); }); 994 $(document).on('click', '.problemQuestionLink', function(e) { problemQuestion(e); }); 995 $(document).on('click', '.rcIdDetails', function(e) { rcIdDetails(e); }); 996 $(document).on('click', '.cr-details', function(e) { crDetails(e); }); 997 $(document).on('click', '.scs-details', function(e) { scsDetails(e); }); 998 $(document).on('click', '.g2gPost', function(e) { g2gPost(e); }); 999 $(document).on('click', '.edit--research-status-link', function(e) { editResearchStatus(e); }); 1000 1001 // Check the hash now, and set up an event handler to catch when it changes. 1002 onHashChange(); 1003 addEventListener("hashchange", (event) => { onHashChange(); }); 1004 1005 1006 // Density controls. On several pages we have the "compact" vs "comfortable" icons/buttons to change the styling of the table. 1007 // This can also be done by default based on an option saved to the user's session. 1008 $("#density-compact").click(function(){ 1009 $(".density-content").toggleClass("table-sm",true); 1010 $(".density-content").toggleClass("small",true); 1011 $("#density-compact").addClass("density-on").removeClass('density-off'); 1012 $("#density-comfortable").addClass("density-off").removeClass("density-on"); 1013 var post_data = "rs=" + encodeURIComponent('User::ajaxUpdateOption') + "&rsargs[]=" + encodeURIComponent('density') + "&rsargs[]=compact"; 1014 $.post('/index.php?action=ajax', post_data, function (result) { 1015 //if (result) { 1016 // var j = JSON.parse(result); 1017 // // if (j.error) { console.log(j.error); } 1018 //} 1019 }); 1020 }); 1021 1022 $("#density-comfortable").click(function(){ 1023 $(".density-content").toggleClass("table-sm",false); 1024 $(".density-content").toggleClass("small",false); 1025 $("#density-comfortable").addClass("density-on").removeClass('density-off'); 1026 $("#density-compact").addClass("density-off").removeClass("density-on"); 1027 var post_data = "rs=" + encodeURIComponent('User::ajaxUpdateOption') + "&rsargs[]=" + encodeURIComponent('density') + "&rsargs[]=comfortable"; 1028 $.post('/index.php?action=ajax', post_data, function (result) { 1029 //if (result) { 1030 // var j = JSON.parse(result); 1031 // // if (j.error) { console.log(j.error); } 1032 //} 1033 }); 1034 1035 }); 1036 1037 var layoutDensity = 'comfortable'; 1038 if ($('#userOptions')?.data('density')) { 1039 layoutDensity = $('#userOptions').data('density'); 1040 } 1041 if (layoutDensity == 'compact') { 1042 $(".density-content").toggleClass("table-sm",true); 1043 $(".density-content").toggleClass("small",true); 1044 $("#density-compact").addClass("density-on").removeClass('density-off'); 1045 $("#density-comfortable").addClass("density-off").removeClass("density-on"); 1046 } else { 1047 $("#density-comfortable").addClass("density-on").removeClass('density-off'); 1048 $("#density-compact").addClass("density-off").removeClass("density-on"); 1049 } 1050 1051 1052 // Set up Location Suggestions. 1053 $('.toggleLocationSuggestions').click(function() { toggleLocationSuggestions(); }); 1054 1055 disableLocationSuggestions = 0; 1056 if ($('#userOptions')?.data('disable-location-suggestions')) { 1057 disableLocationSuggestions = parseInt($('#userOptions').data('disable-location-suggestions')); 1058 } 1059 if (disableLocationSuggestions) { 1060 if ($('.toggleLocationSuggestions')) { 1061 $('.toggleLocationSuggestions').html("show place name suggestions"); 1062 } 1063 } else { 1064 if ($('.toggleLocationSuggestions')) { 1065 $('.toggleLocationSuggestions').html("hide place name suggestions"); 1066 } 1067 initLocationSuggestions(); 1068 } 1069 1070 // Track changes in language 1071 if ( $('#mOptions_person_language') ) { 1072 $('#mOptions_person_language').change(function() { 1073 if (lastLocationId) { 1074 $('#'+lastLocationId).focus(); 1075 } 1076 }); 1077 } 1078 1079 // Add event handler for our (non)biological toggle on the #Ancestors tab of person pages. 1080 $('body').on('click', '.non-bio-toggle', function(e) { toggleAncestorCell(e); }); 1081 1082 // If we show our default/standard BootStrap modal, and we have a second WikiTree modal open (below the BS one), 1083 // we want to fade the background WT one. 1084 $('#bsModal').on('show.bs.modal', function() { 1085 $('#wtModal').css('opacity', '0.5'); 1086 }); 1087 $('#bsModal').on('hide.bs.modal', function() { 1088 $('#wtModal').css('opacity', '1.0'); 1089 if (document.activeElement) { 1090 document.activeElement.blur(); 1091 } 1092 }); 1093 $('#wtModal').on('hide.bs.modal', function() { 1094 if (document.activeElement) { 1095 document.activeElement.blur(); 1096 } 1097 }); 1098 1099 // Disable .anon links for anonymous users 1100 if (typeof wgUserName === "undefined" || wgUserName === null) { 1101 document.querySelectorAll(".anon a").forEach(function(a) { 1102 const parent = a.parentNode; 1103 1104 while (a.firstChild) { 1105 parent.insertBefore(a.firstChild, a); 1106 } 1107 1108 parent.removeChild(a); 1109 }); 1110 } 1111});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.