PageSourceSearch

https://www.wikitree.com/skins/wikitree/JS/wikitree.js.pagespeed.ce.j2DSdXGoFw.js

js wikitree.com collected 2026-10-01 10:27:43 UTC 39,495 bytes, 1,111 lines download raw bytes

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="" /> &nbsp');
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="" /> &nbsp');
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="" /> &nbsp');
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="" /> &nbsp');
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="" /> &nbsp');
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="" /> &nbsp');
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="" /> &nbsp');
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="" /> &nbsp');
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.