GRO Index Search Helper

Adds additional functionality to the UK General Register Office (GRO) BMD index search

  1. // ==UserScript==
  2. // @name GRO Index Search Helper
  3. // @description Adds additional functionality to the UK General Register Office (GRO) BMD index search
  4. // @namespace cuffie81.scripts
  5. // @match https://www.gro.gov.uk/gro/content/certificates/indexes_search.asp*
  6. // @version 1.20
  7. // @grant GM_listValues
  8. // @grant GM_getValue
  9. // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js
  10. // @require https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.7.7/handlebars.min.js
  11. // @require https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js
  12. // ==/UserScript==
  13.  
  14.  
  15. this.$ = this.jQuery = jQuery.noConflict(true);
  16.  
  17. $(function() {
  18. let resources, recordType, results;
  19. var main = function() {
  20. // Register Handlebars helper operators
  21. Handlebars.registerHelper({
  22. eq: function (v1, v2) { return v1 === v2; },
  23. ne: function (v1, v2) { return v1 !== v2; },
  24. lt: function (v1, v2) { return v1 < v2; },
  25. gt: function (v1, v2) { return v1 > v2; },
  26. lte: function (v1, v2) { return v1 <= v2; },
  27. gte: function (v1, v2) { return v1 >= v2; },
  28. and: function () { return Array.prototype.slice.call(arguments).every(Boolean); },
  29. or: function () { return Array.prototype.slice.call(arguments, 0, -1).some(Boolean); }
  30. });
  31. buildResources();
  32. recordType = getRecordType();
  33. //console.log("resources:\r\n%s", JSON.stringify(resources));
  34. // Load the general css
  35. $("body").append($(resources.baseStyle));
  36.  
  37. initialiseSearchForm();
  38. initialiseResultViews();
  39. // Scroll down to the form. Do this last as we may add/remove/change elements in the previous calls.
  40. $("h1:contains('Search the GRO Online Index')")[0].scrollIntoView();
  41. // Wire up accesskeys to clicks, to avoid having to use the full accesskey combo (eg ALT+SHFT+#)
  42. $(document).on("keypress", function(e) {
  43. let activeElementIsTextField = (document.activeElement
  44. && document.activeElement.tagName.toLowerCase() !== "input"
  45. && document.activeElement.getAttribute("type") === "text");
  46. if (!activeElementIsTextField)
  47. {
  48. let char = String.fromCharCode(e.which);
  49. //console.log("keypress: %s", char);
  50. if ($("*[id^='groish'][accesskey='" + char + "']").length)
  51. $("*[id^='groish'][accesskey='" + char + "']").click();
  52. else if (char == "{")
  53. adjustSearchYear(-10);
  54. else if (char == "}")
  55. adjustSearchYear(10);
  56. else if (char == "?")
  57. $("form[name='SearchIndexes'] input[type='submit'][value='Search'][accesskey='?']").click();
  58. else if (char == '@')
  59. switchRecordType();
  60. }
  61. });
  62. // Remove focus
  63. if (document.activeElement)
  64. document.activeElement.blur();
  65. }
  66. var initialiseSearchForm = function() {
  67. // Hide superfluous spacing, text and buttons
  68. $("form[name='SearchIndexes'] input[type='submit'][value='Reset']").hide();
  69. $("form[name='SearchIndexes'] a.tooltip").hide();
  70. $("form[name='SearchIndexes'] span.main_text").has("i > a[href^='most_customers_want_to_know.asp']").hide();
  71. $("form[name='SearchIndexes'] a:contains('FreeBMD')").hide();
  72. $("form[name='SearchIndexes'] a:contains('(used 1837')").hide();
  73.  
  74. $("form[name='SearchIndexes'] td.main_text[colspan='5'] > br").closest("tr").hide();
  75. $("form[name='SearchIndexes'] td.main_text[colspan='5'] > strong").closest("tr").hide();
  76. $("form[name='SearchIndexes'] #SurnameMatchesText").closest("tr").hide();
  77. $("form[name='SearchIndexes'] #ForenameMatchesText").closest("tr").hide();
  78. $("form[name='SearchIndexes'] #MothersMaidenSurnameMatchesText").closest("tr").hide();
  79. // Change text
  80. $("form[name='SearchIndexes'] td span.main_text:contains('year(s)')").text("yrs");
  81. $("form[name='SearchIndexes'] td.main_text:contains('Surname at Death:')").html("Surname:<span class='redStar'>*</span>");
  82. $("form[name='SearchIndexes'] td.main_text:contains('First Forename at Death:')").text("Forename 1:");
  83. $("form[name='SearchIndexes'] td.main_text:contains('Second Forename at Death:')").text("Forename 2:");
  84. $("form[name='SearchIndexes'] td.main_text:contains('District of Death:')").text("District:");
  85. $("form[name='SearchIndexes'] td.main_text:contains('Age at'):contains('Death'):contains('in years')").text("Age:");
  86. $("form[name='SearchIndexes'] td.main_text:contains('Surname at Birth:')").html("Surname:<span class='redStar'>*</span>");
  87. $("form[name='SearchIndexes'] td.main_text:contains('First Forename:')").text("Forename 1:");
  88. $("form[name='SearchIndexes'] td.main_text:contains('Second Forename:')").text("Forename 2:");
  89. $("form[name='SearchIndexes'] td.main_text:contains('Maiden Surname:')").text("Mother:");
  90. $("form[name='SearchIndexes'] td.main_text:contains('District of Birth:')").text("District:");
  91. $("form[name='SearchIndexes'] td.main_text:contains('Register No:')").text("Register:");
  92. $("form[name='SearchIndexes'] td.main_text:contains('Entry'):contains('No:')").text("Entry:");
  93. $("form[name='SearchIndexes'] a:contains('View list of registration districts')").text("Districts");
  94.  
  95. // Add gender and year navigation buttons, and style them
  96. let searchButton = $("form[name='SearchIndexes'] input[type='submit'][value='Search']");
  97. $(searchButton).attr("accesskey", "?");
  98. $(searchButton).parent().find("br").remove();
  99.  
  100. $("<input type='button' class='formButton' accesskey='#' id='groish_BtnToggleGender' value='Gender' />").insertBefore($(searchButton));
  101. $("<input type='button' class='formButton' accesskey='[' id='groish_BtnYearsPrev' value='&lt; Years' />").insertBefore($(searchButton));
  102. $("<input type='button' class='formButton' accesskey=']' id='groish_BtnYearsNext' value='Years &gt;' />").insertBefore($(searchButton));
  103. let buttonContainer = $("form[name='SearchIndexes'] input[type='submit'][value='Search']").closest("td").addClass("groish_ButtonContainer");
  104. // Add button event handlers
  105. $("input#groish_BtnYearsPrev").click(function() { navigateYears(false); });
  106. $("input#groish_BtnYearsNext").click(function() { navigateYears(true); });
  107. $("input#groish_BtnToggleGender").click(function() { toggleGender(); });
  108. // Add death age sync checkbox
  109. let ageInput = $("form[name='SearchIndexes'] #Age");
  110. let ageInputContainer = $(ageInput).closest("td");
  111. if (ageInput && ageInputContainer && $(ageInput).is(":visible")) {
  112. // Add checkbox
  113. $("<input type='checkbox' id='groish_AgeSync' /> <label for='groish_AgeSync' class='main_text' title='Keep age in sync with year'>Sync</label>").appendTo($(ageInputContainer));
  114. // Get values
  115. let age = parseInt($(ageInput).val(), 10);
  116. let syncAge = (!isNaN(age)) && (sessionStorage.getItem("age-sync") === "true");
  117. // Set checkbox state
  118. $("input#groish_AgeSync").prop('checked', syncAge);
  119. // Add event handler to sync checkbox, to save state
  120. $("input#groish_AgeSync").click(function() { sessionStorage.setItem("age-sync", $("input#groish_AgeSync").is(":checked")); });
  121. }
  122.  
  123. // Set encoding
  124. if (typeof $("form[name='SearchIndexes']").attr("accept-charset") === typeof undefined) {
  125. $("form[name='SearchIndexes']").attr("accept-charset", "UTF-8");
  126. }
  127.  
  128. }
  129. var initialiseResultViews = function() {
  130. // Move default results table into a view container
  131. let defaultTable = $("form[name='SearchIndexes'] h3:contains('Results:')").closest("table").css("width", "100%").addClass("groish_ResultsTable");
  132. $(defaultTable).before($("<div results-view='default' />"));
  133. let defaultView = $("div[results-view='default']");
  134. $(defaultView).append($("table.groish_ResultsTable"));
  135.  
  136. // Move header row to before default view
  137. $(defaultView).before($("<div class='groish_ResultsHeader' style='margin: 10px 0px; position: relative' />"));
  138. $(".groish_ResultsHeader").append($("table.groish_ResultsTable h3:contains('Results:')"));
  139.  
  140. // Move pager row contents to after default view
  141. $(defaultView).after($("table.groish_ResultsTable > tbody > tr:last table:first"));
  142. $("div[results-view='default'] + table").css("width", "100%").addClass("groish_ResultsInfo");
  143.  
  144. // Get results, sort them and populate views
  145. results = getResults(recordType);
  146. sortResults();
  147. populateAlternateViews();
  148. }
  149. var sortResults = function(reverse, sortFieldsCsv) {
  150. //console.log("sorting results, sort fields: %s", sortFieldsCsv);
  151. if (!results || !results.items)
  152. return;
  153. let defaultSortFields = "year,quarter";
  154. // Get the last sort fields and order for the record type
  155. let sortFieldsKey = recordType + "-sort-fields";
  156. let sortOrderKey = recordType + "-sort-order";
  157. let lastSortFields = sessionStorage.getItem(sortFieldsKey);
  158. let lastSortOrder = sessionStorage.getItem(sortOrderKey);
  159. // Cleanup values
  160. sortFieldsCsv = (sortFieldsCsv || "").replace(/\s\s+/g, ' ');
  161. lastSortFields = (lastSortFields || "").replace(/\s\s+/g, ' ');
  162. //console.log("last sort fields: %s; last sort order: %s", lastSortFields, lastSortOrder);
  163. let sortOrder = "asc";
  164. if (!sortFieldsCsv) {
  165. sortFieldsCsv = lastSortFields || defaultSortFields;
  166. sortOrder = lastSortOrder || "asc";
  167. }
  168. else if (sortFieldsCsv.localeCompare(lastSortFields) == 0 && sortOrder.localeCompare(lastSortOrder) == 0 && reverse) {
  169. sortOrder = "desc";
  170. }
  171. // Build sort fields and order arrays
  172. let sortFields = sortFieldsCsv.split(",");
  173. let sortOrders = Array.apply(null, Array(sortFields.length)).map(String.prototype.valueOf, sortOrder);
  174. // Append defaults if needed
  175. if (sortFieldsCsv.localeCompare(defaultSortFields) != 0) {
  176. sortFields.push("year");
  177. sortFields.push("quarter");
  178. sortOrders.push("asc");
  179. sortOrders.push("asc");
  180. }
  181. //console.log("sorting results by: %s (%s)", sortFields, sortOrders);
  182. results.items = _.orderBy(results.items, sortFields, sortOrders);
  183. sessionStorage.setItem(sortFieldsKey, sortFieldsCsv);
  184. sessionStorage.setItem(sortOrderKey, sortOrder);
  185. }
  186.  
  187. var populateAlternateViews = function() {
  188. // Add alternate view(s)
  189. if (recordType && resources && results && results.items && results.items.length > 0) {
  190. // Remove any existing views
  191. $("div[results-view][results-view!='default']").remove();
  192. // Add alternate views
  193. //console.log("Adding alternate views...");
  194. let viewPrefix = "view_" + recordType; // record type = EW_Birth, EW_Death
  195. for (let resourceName in resources) {
  196. let resourceNamePrefix = resourceName.substring(0, viewPrefix.length);
  197. if (resources.hasOwnProperty(resourceName) && viewPrefix.localeCompare(resourceNamePrefix) == 0) {
  198. let template = resources[resourceName].toString();
  199. let compiledTemplate = Handlebars.compile(template);
  200. let html = compiledTemplate(results);
  201. if (html) {
  202. $("div[results-view]").filter(":last").after($(html));
  203. //console.log("Added alternate view");
  204. }
  205. }
  206. }
  207. // Add view helpers and event handlers, if not already added
  208. if ($("div[results-view]").length > 1) {
  209. // Add event handler to hide/show actions row
  210. // TODO: Make adding view event handlers more dynamic, so they can be specific to the view
  211. $("div[results-view][results-view!='default'] tbody tr.rec")
  212. .off("click.groish")
  213. .on("click.groish", function(event) {
  214.  
  215. event.preventDefault();
  216. $(this).next("tr.rec-actions:not(:empty)").toggle();
  217. }
  218. );
  219.  
  220. // Add event handler for column sorting
  221. $("div[results-view][results-view!='default'] thead td[sort-fields]")
  222. .off("click.groish")
  223. .on("click.groish", function(event) {
  224.  
  225. event.preventDefault();
  226. //let defaultSortFields = ($(this).closet("div[results-view]").attr("default-sort-fields");
  227. let sortFields = ($(this).attr("sort-fields") ? $(this).attr("sort-fields") : $(this).text());
  228. sortResults(true, sortFields);
  229. populateAlternateViews();
  230. }
  231. );
  232.  
  233. // Add view switcher, if it doesn't already exist
  234. if ($("#groish_ViewSwitcher").length == 0) {
  235. $(".groish_ResultsHeader").append($("<a href='#' id='groish_ViewSwitcher' class='main_text' accesskey='~'>Switch view</a>"));
  236. $("#groish_ViewSwitcher").off("click.groish").on("click.groish", function() { switchResultsView(); return false; });
  237.  
  238.  
  239. // Add results copier (if supported)
  240. if (window.getSelection && document.createRange) {
  241. $(".groish_ResultsHeader").append($("<a href='#' id='groish_ResultsCopier' class='main_text' accesskey='|'>Copy results</a>"));
  242. $("#groish_ResultsCopier")
  243. .off("click.groish")
  244. .on("click.groish", function(event) {
  245.  
  246. event.preventDefault();
  247.  
  248. // Get most specific element containing results, typically a table body
  249. let resultsContent = $("div[results-view]:visible tbody");
  250.  
  251. if (resultsContent.length == 0)
  252. resultsContent = $("div[results-view]:visible");
  253. if (resultsContent.length > 0) {
  254. resultsContent = resultsContent[0];
  255. let selection = window.getSelection();
  256. let range = document.createRange();
  257. range.selectNodeContents(resultsContent);
  258. selection.removeAllRanges();
  259. selection.addRange(range);
  260.  
  261. try {
  262. if (document.execCommand("copy")) {
  263. selection.removeAllRanges();
  264. $(".groish_Message").text("Results copied to clipboard").show();
  265. setTimeout(function() { $(".groish_Message").fadeOut(); }, 3000);
  266. }
  267. }
  268. catch(e) { }
  269. }
  270.  
  271. return false;
  272. });
  273. }
  274. }
  275. }
  276.  
  277. // Show the last used view
  278. let viewName = sessionStorage.getItem("groish_view." + recordType);
  279. //console.log("initialising view: %s", viewName);
  280. if (viewName && $("div[results-view='" + viewName + "']:hidden").length == 1) {
  281. //console.log("setting active view: %s", viewName);
  282. $("div[results-view][results-view!='" + viewName + "']").hide();
  283. $("div[results-view][results-view='" + viewName + "']").show();
  284. }
  285. }
  286. }
  287.  
  288. var switchResultsView = function() {
  289. let views = $("div[results-view]");
  290. if (views.length > 1) {
  291. let curIndex = -1;
  292. $(views).each(function(index) {
  293. if ($(this).css("display") != "none")
  294. curIndex = index;
  295. });
  296.  
  297. //console.log("current view index: %s", curIndex);
  298. if (curIndex !== -1) {
  299. let newIndex = ((curIndex == (views.length-1)) ? 0 : curIndex+1);
  300. $(views).hide();
  301. $("div[results-view]:eq(" + newIndex + ")").show();
  302.  
  303. $(".groish_Message").hide();
  304.  
  305. // Get the name and save it
  306. let viewName = $("div[results-view]:eq(" + newIndex + ")").attr("results-view")
  307. sessionStorage.setItem("groish_view." + recordType, viewName); //save it
  308. //console.log("new view: %s", viewName);
  309. }
  310. }
  311. }
  312. var getResults = function(recordType) {
  313. let results = { "ageWarningThreshold": 24, "items": [], "failures": [] };
  314. // Lookup record type - birth or death
  315. if (recordType !== null && (recordType === "EW_Birth" || recordType === "EW_Death")) {
  316. let gender = $("form[name='SearchIndexes'] select#Gender").val();
  317. let year = parseInt($("form[name='SearchIndexes'] select#Year").val(), 10);
  318. let dataFormat = (year >= 1993 ? 1993 : (year >= 1984 ? 1984 : 1837));
  319.  
  320. // Save the data format
  321. results["dataFormat" + dataFormat] = true;
  322. $("div[results-view='default'] > table > tbody > tr")
  323. .has("input[type='radio'][name='SearchResult']")
  324. .each(function(index) {
  325. try
  326. {
  327. //console.log("Parsing record (%d)...", index);
  328. let quarterNames = [ "Mar", "Jun", "Sep", "Dec" ];
  329. // Get result id, contains year and record id
  330. let recordId = null;
  331. let resultId = $(this).find("input[type='radio'][name='SearchResult']:first").val();
  332. if (resultId && resultId.length > 5 && resultId.indexOf('.') == 4)
  333. recordId = resultId.substring(5);
  334. // Get names and reference
  335. let names = $(this).find("td:eq(1)").text().replace(/\u00a0/g, " ").replace(/\s\s+/g, ' ').trim();
  336. let ref = $(this).next().find("td:eq(0)").text();
  337.  
  338. // Clean up reference
  339. ref = ref.replace(/\u00a0/g, " ");
  340. ref = ref.replace(/\s\s+/g, ' ');
  341. ref = ref.replace(/GRO Reference: /g, "");
  342. ref = ref.replace(/M Quarter in/g, "Q1");
  343. ref = ref.replace(/J Quarter in/g, "Q2");
  344. ref = ref.replace(/S Quarter in/g, "Q3");
  345. ref = ref.replace(/D Quarter in/g, "Q4");
  346. ref = ref.replace(/Order this entry as a:/g, "");
  347. ref = ref.replace(/Entry Number(:|)/gi, "Entry");
  348. ref = ref.replace(/Occasional Copy(:|)/gi, "Copy");
  349. ref = ref.replace(/^DOR /gi, "");
  350. ref = ref.replace(/ Union /gi, " ");
  351. if (/(((-|Q[1-9])\/[0-9]{4}) in )/gi.test(ref))
  352. ref = ref.replace(/(((-|Q[1-9])\/[0-9]{4}) in )/gi, "$2 ");
  353. ref = ref.replace(/\s\s+/g, ' ');
  354. ref = ref.trim();
  355.  
  356.  
  357. // Parse forenames, surname
  358. let namesArr = /([a-z' -]+),([a-z' -]*)/gi.exec(names);
  359. //console.log("index: %d, namesArr: %s", index, namesArr);
  360. // Parse mother's maiden name
  361. let mother = null;
  362. if (recordType === "EW_Birth")
  363. mother = toTitleCase($(this).find("td:eq(2)").text().replace(/\u00a0/g, " ").replace(/\s\s+/g, ' ')).trim();
  364. // Initialise record
  365. let record =
  366. {
  367. "recordId": recordId,
  368. "ref": ref,
  369. "gender": gender,
  370. "forenames": toTitleCase(namesArr[2]).trim(),
  371. "surname": toTitleCase(namesArr[1]).trim(),
  372. "age": null,
  373. "yob": null,
  374. "birth": null,
  375. "mother": mother,
  376. "actions": []
  377. };
  378.  
  379. // Parse reference
  380. // TODO: Use named capture groups when widely supported in browsers
  381. let refPatterns =
  382. [
  383. {
  384. // 1937 Q3 NORTHAMPTON Volume 03B Page 32
  385. "pattern": "([0-9]{4}) Q([1-4]) ([a-z\\.\\-,\\(\\)0-9\\&'\\/ ]*) (Volume ([a-z0-9]+)) (Page ([0-9a-z]+))( Copy ([0-9a-z]+)|)",
  386. "indexes": { "year": 1, "quarter": 2, "district": 3, "volume": 5, "page": 7, "copy": 9 }
  387. },
  388. {
  389. // 1937 Q3 NORTHAMPTON Volume 03B
  390. "pattern": "([0-9]{4}) Q([1-4]) ([a-z\\.\\-,\\(\\)0-9\\&'\\/ ]*) (Volume ([a-z0-9]+))( Copy: ([0-9a-z]+)|)",
  391. "indexes": { "year": 1, "quarter": 2, "district": 3, "volume": 5, "copy": 7 }
  392. },
  393. {
  394. // DOR -/1992 NORTHAMPTON (6701C) Volume 7 Page 2375 Entry Number 126
  395. "pattern": "(-|Q([1-4]))\\/([0-9\\-]{1,4}) ([a-z\\.\\-,\\(\\)0-9\\&'\\/ ]*) (Volume ([a-z0-9]+)) (Page ([0-9a-z]+)) (Entry ([0-9a-z]+))( Copy ([0-9a-z]+)|)",
  396. "indexes": { "quarter": 2, "year": 3, "district": 4, "volume": 6, "page": 8, "entry": 10, "copy": 12 }
  397. },
  398. {
  399. // DOR Q4/1984 NORTHAMPTON (6701B) Volume 7 Page 2456
  400. "pattern": "(-|Q([1-4]))\\/([0-9\\-]{1,4}) ([a-z\\.\\-,\\(\\)0-9\\&'\\/ ]*) (Volume ([a-z0-9]+)) (Page ([0-9a-z]+))( Copy ([0-9a-z]+)|)",
  401. "indexes": { "quarter": 2, "year": 3, "district": 4, "volume": 6, "page": 8, "copy": 10 }
  402. },
  403. {
  404. // DOR Q2/2000 Northampton (6701A) Reg A59B Entry Number 96
  405. "pattern": "(-|Q([1-4]))\\/([0-9\\-]{1,4}) ([a-z\\.\\-,\\(\\)0-9\\&'\\/ ]*) (Reg ([a-z0-9]+)) (Entry ([0-9a-z]+))( Copy ([0-9a-z]+)|)",
  406. "indexes": { "quarter": 2, "year": 3, "district": 4, "reg": 6, "entry": 8, "copy": 10 }
  407. },
  408. {
  409. // DOR Q2/2000 Northampton (6701A) Entry Number 96
  410. "pattern": "(-|Q([1-4]))\\/([0-9\\-]{1,4}) ([a-z\\.\\-,\\(\\)0-9\\&'\\/ ]*) (Entry ([0-9a-z]+))( Copy ([0-9a-z]+)|)",
  411. "indexes": { "quarter": 2, "year": 3, "district": 4, "entry": 6, "copy": 8 }
  412. }
  413. ];
  414. for (let p of refPatterns) {
  415. let re = new RegExp(p.pattern, "gi");
  416. let result = re.exec(ref);
  417. if (result) {
  418. if (p.indexes) {
  419. for (const [key, value] of Object.entries(p.indexes)) {
  420. //console.log("index: %d, name: %s, value: %s", value, key, result[value]);
  421. record[key] = (result && result.length > value && result[value]) ? result[value] : null;
  422. }
  423. }
  424. break;
  425. }
  426. }
  427. // Set format
  428. let recordYear = (record.year ? record.year : year);
  429. let recordDataFormat = (recordYear >= 1993 ? 1993 : (recordYear >= 1984 ? 1984 : 1837));
  430. record["dataFormat"] = recordDataFormat;
  431. results["dataFormat" + recordDataFormat] = true;
  432. // Parse age and year of birth
  433. if (recordType === "EW_Death") {
  434. if (record.dataFormat == 1837) {
  435. let ageArr = /^([0-9]{1,3})$/.exec($(this).find("td:eq(2)").text().replace(/\u00a0/g, " ").replace(/\s\s+/g, ' ').trim());
  436. if (ageArr)
  437. record.age = parseInt(ageArr[1], 10);
  438. }
  439. else if (record.dataFormat >= 1984) {
  440. let yobArr = /^([0-9]{4})$/.exec($(this).find("td:eq(2)").text().replace(/\u00a0/g, " ").replace(/\s\s+/g, ' ').trim());
  441. if (yobArr)
  442. record.yob = parseInt(yobArr[1], 10);
  443. }
  444. }
  445. // Tidy up data...
  446.  
  447. // Tidy up strings
  448. if (record.district) {
  449. record.district = toTitleCase(record.district);
  450. for (let prefix of [ "The ", "Of ", "Union Of "]) {
  451. if (record.district.startsWith(prefix))
  452. record.district = record.district.replace(prefix, "");
  453. }
  454. }
  455. for (let key of [ "forenames", "surname", "district", "volume", "page", "reg", "entry", "copy" ]) {
  456. if (record[key]) {
  457. record[key] = record[key].trim();
  458. }
  459. }
  460. // Tidy up integers
  461. for (let key of [ "age", "yob", "birth", "quarter", "year" ]) {
  462. if (record[key]) {
  463. record[key] = parseInt(record[key], 10);
  464. if (isNaN(record[key]))
  465. record[key] = null;
  466. }
  467. }
  468. // Set calculated data
  469. if (record.yob && record.yob > 0) {
  470. record.birth = record.yob;
  471. if (record.year && record.year > 0)
  472. record.age = record.year - record.yob;
  473. }
  474. else if (record.age != null && record.year && record.year > 0) {
  475. record.birth = record.year - record.age;
  476. }
  477. record.noForenames = (!record.forenames || record.forenames == "-");
  478. record.ageWarning = (record.age && record.age > 0 && record.age <= results.ageWarningThreshold);
  479. record.quarterName = ((record.quarter && record.quarter >=1 && record.quarter <= 4) ? quarterNames[record.quarter-1] : null);
  480.  
  481. //console.log("resultId: %s, record.recordId: %s, record.year: %s, recordType: %s", resultId, record.recordId, record.year, recordType);
  482. // Determine what actions are supported for the record and add them
  483. if (record.recordId && record.year && recordType) {
  484. // Define possible actions
  485. let actions = [
  486. { "text": "Order Certificate", "url": null, "itemType": "Certificate", "pdfStatus": 0, "selector": "img[src$='order_certificate_button.gif']" },
  487. { "text": "Order PDF", "url": null, "itemType": "PDF", "pdfStatus": 5, "selector": "img[src$='order_pdf_button.gif']" }
  488. //{ "text": "Order MSF + Bundle", "url": null, "itemType": "MSFBundle", "pdfStatus": 0, "selector": "img[src$='order_certificate_button.gif']" }
  489. ];
  490. for (let i = 0; i < actions.length; i++) {
  491. if ($(this).next().find(actions[i].selector).length) {
  492. // Build order url
  493. let orderUrl = "https://www.gro.gov.uk/gro/content/certificates/indexes_order.asp?";
  494. orderUrl += "Index=" + recordType;
  495. orderUrl += "&Year=" + record.year;
  496. orderUrl += "&EntryID=" + record.recordId;
  497. orderUrl += "&ItemType=" + actions[i].itemType;
  498. if (actions[i].pdfStatus && actions[i].pdfStatus > 0)
  499. orderUrl += "&PDF=" + actions[i].pdfStatus;
  500. actions[i].url = orderUrl;
  501. record.actions.push(actions[i]);
  502. }
  503. //console.log("action '%s' (%s), url: %s", actions[i].itemType, actions[i].selector, actions[i].url);
  504. }
  505. }
  506. //console.log(record);
  507. results.items.push(record);
  508. }
  509. catch (e)
  510. {
  511. //console.log("Failed to parse record (%d): %s", index, e.message);
  512. results.failures.push({ "index": index, "ex": e });
  513. }
  514. });
  515. }
  516. return results;
  517. }
  518.  
  519. var toTitleCase = function(str) {
  520. return str.replace(/([^\W_]+[^\s-]*) */g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();});
  521. }
  522. var switchRecordType = function() {
  523. let recordTypes = $("form[name='SearchIndexes'] input[type='Radio'][name='index']");
  524.  
  525. let curIndex = -1;
  526. for (let i = 0; i < recordTypes.length; i++) {
  527. if ($(recordTypes).eq(i).prop("checked")) {
  528. curIndex = i;
  529. break;
  530. }
  531. }
  532. //console.log("current record type: %d", curIndex);
  533.  
  534. if (curIndex >= 0) {
  535. let nextIndex = (curIndex == (recordTypes.length-1)) ? 0 : curIndex + 1;
  536.  
  537. if (nextIndex != curIndex)
  538. $(recordTypes).eq(nextIndex).prop("checked", true).click();
  539. //console.log("next record type: %d", nextIndex);
  540. }
  541. }
  542.  
  543. var toggleGender = function() {
  544. let curGender = $("form[name='SearchIndexes'] select#Gender").val();
  545. $("form[name='SearchIndexes'] select#Gender").val((curGender === "F" ? "M" : "F"));
  546. $("form[name='SearchIndexes'] input[type='submit'][value='Search']").click();
  547. }
  548. var adjustSearchYear = function(step) {
  549. let adjusted = false;
  550. // Get min and max years
  551. let minYear = parseInt($("form[name='SearchIndexes'] select#Year option:eq(2)").val(), 10);
  552. let maxYear = parseInt($("form[name='SearchIndexes'] select#Year option:last").val(), 10);
  553.  
  554. //console.log("Year range: %s - %s", minYear, maxYear);
  555.  
  556. if (!isNaN(step) && !isNaN(minYear) && !isNaN(maxYear)) {
  557. // Read current year and range
  558. let curYear = parseInt($("form[name='SearchIndexes'] select#Year").val(), 10);
  559. let curRange = parseInt($("form[name='SearchIndexes'] select#Range").val(), 10);
  560. if (isNaN(curYear) || curYear === 0)
  561. curYear = 1837;
  562.  
  563. if (!isNaN(curRange)) {
  564. // Calculate the new year
  565. let newYear = (isNaN(curYear) ? minYear : curYear+step);
  566. newYear = Math.min(Math.max(newYear, minYear), maxYear);
  567. if (newYear != curYear) {
  568. // Get list of all years
  569. let years = $("form[name='SearchIndexes'] select#Year option[value]")
  570. .toArray()
  571. .map(el => parseInt(el.value, 10))
  572. .filter(y => !isNaN(y) && y > 0)
  573. .sort();
  574. //console.log(years);
  575. // If the year doesn't exist try and find the closest
  576. if (!years.find(y => y === newYear)) {
  577. let stepRange = (Math.abs(step)-1)/2;
  578. let nowYear = new Date().getFullYear();
  579. let minYear = (step > 0) ? newYear - stepRange : 1837;
  580. let maxYear = (step < 0) ? newYear + stepRange : nowYear;
  581. minYear = Math.min(Math.max(minYear, 1837), nowYear);
  582. maxYear = Math.min(Math.max(maxYear, 1837), nowYear);
  583. years = years.filter(y => y >= minYear && y <= maxYear);
  584. newYear = findClosestNumber(years, newYear, minYear, maxYear);
  585. }
  586.  
  587. //console.log("newYear: %d", newYear);
  588. if (newYear && newYear > 0 && newYear != curYear)
  589. {
  590. $("form[name='SearchIndexes'] select#Year").val(newYear);
  591. adjusted = true;
  592. }
  593. }
  594. // Adjust death age
  595. if (adjusted && curYear && newYear && $("input#groish_AgeSync").is(":checked")) {
  596. // Is the new year in line with the step size?
  597. if (curYear + step === newYear) {
  598. let curAge = parseInt($("form[name='SearchIndexes'] #Age").val(), 10);
  599. if (!isNaN(curAge)) {
  600. let newAge = Math.max(curAge + step, 0);
  601. $("#Age").val(newAge);
  602. //console.log("syncing age: %d -> %d", curAge, newAge);
  603. }
  604. }
  605. }
  606. }
  607.  
  608. //console.log("Current year: %d +-%d (%d-%d), New year: %d (%d-%d)", curYear, curRange, curYear-curRange, curYear+curRange, newYear, newYear-curRange, newYear+curRange);
  609. }
  610.  
  611. return adjusted;
  612. }
  613. var findClosestNumber = function(numbers, target, minNumber, maxNumber) {
  614. //console.log("target: %d, minNumber: %d, maxNumber: %d", target, minNumber, maxNumber);
  615. let number = 0;
  616. if (numbers && target && minNumber && maxNumber && minNumber <= maxNumber) {
  617. target = parseInt(target, 10);
  618. minNumber = parseInt(minNumber, 10);
  619. maxNumber = parseInt(maxNumber, 10);
  620. if (numbers.find(n => n === target)) {
  621. number = target;
  622. }
  623. else {
  624. for (let i = 0; i < numbers.length; i++) {
  625. let n = numbers[i];
  626. if (!isNaN(n) && n >= minNumber && n <= maxNumber && Math.abs(target-n) < Math.abs(target-number)) {
  627. number = n;
  628. if (Math.abs(target-number) == 1)
  629. break;
  630. }
  631. }
  632. }
  633. }
  634. return number;
  635. }
  636.  
  637. var navigateYears = function(forward) {
  638. let curRange = parseInt($("form[name='SearchIndexes'] select#Range").val(), 10);
  639. if (!isNaN(curRange)) {
  640. // Calculate the new year
  641. let step = (curRange * 2) + 1;
  642. if (!forward) step = -step;
  643. if (adjustSearchYear(step)) {
  644. $("form[name='SearchIndexes'] input[type='submit'][value='Search']").click();
  645. }
  646. }
  647. }
  648. var getRecordType = function() {
  649. return $("form[name='SearchIndexes'] input[type='radio'][name='index']:checked").val();
  650. }
  651.  
  652. var buildResources = function() {
  653. resources = {
  654.  
  655. baseStyle: `
  656. <style type="text/css">
  657. body
  658. {
  659. min-height: 1200px;
  660. background-color: #EAEAEA;
  661. }
  662. /* widen the page */
  663. body > table[width="800"]
  664. {
  665. width: 960px !important;
  666. }
  667. /* widen header */
  668. table[width="780"][height="80"].banner
  669. {
  670. width: 100% !important;
  671. }
  672.  
  673. /* widen content area */
  674. body > table[width="800"] td[width="600"],
  675. body > table[width="800"] table[width="600"]
  676. {
  677. width: 760px !important;
  678. }
  679.  
  680. form[name="SearchIndexes"]
  681. {
  682. position: relative !important;
  683. }
  684. .groish_ButtonContainer
  685. {
  686. padding-bottom: 10px;
  687. }
  688. .groish_ButtonContainer input[type='submit'],
  689. .groish_ButtonContainer input[type='button']
  690. {
  691. margin-right: 20px;
  692. min-width: 100px;
  693. font-size: 13px;
  694. padding: 6px 10px;
  695. background-color: #15377E;
  696. border-width: 0px;
  697. }
  698. .groish_ButtonContainer input[type='submit']
  699. {
  700. margin-right: 0px;
  701. }
  702. #groish_ResultsCopier,
  703. #groish_ViewSwitcher
  704. {
  705. display:inline-block;
  706. position: absolute;
  707. bottom: 0px;
  708. color: #0076C0;
  709. font-weight: bold;
  710. cursor: pointer;
  711. }
  712. #groish_ResultsCopier
  713. {
  714. right: 120px;
  715. }
  716. #groish_ViewSwitcher
  717. {
  718. right: 10px;
  719. }
  720. div[results-view] td[sort-fields]:hover
  721. {
  722. cursor: pointer;
  723. }
  724.  
  725. .groish_Message
  726. {
  727. position: absolute;
  728. bottom: -30px;
  729. left: 5px;
  730. }
  731. #groish_AgeSync
  732. {
  733. vertical-align: middle;
  734. }
  735.  
  736. </style>
  737. `,
  738.  
  739. view_EW_Birth_Table: `
  740. <style type="text/css">
  741. div[results-view='EW_Birth-Table'] td
  742. {
  743. padding: 5px 3px;
  744. font-size: 75%;
  745. color: #222;
  746. vertical-align: top;
  747. }
  748. div[results-view='EW_Birth-Table'] thead td
  749. {
  750. font-weight: bold;
  751. }
  752. div[results-view='EW_Birth-Table'] tbody tr:nth-child(4n+1),
  753. div[results-view='EW_Birth-Table'] tbody tr:nth-child(4n+2)
  754. {
  755. background-color: #CCE0FF;
  756. }
  757. div[results-view='EW_Birth-Table'] tr.rec-actions a
  758. {
  759. padding: 0px 5px;
  760. font-size: 90%;
  761. color: #15377E;
  762. text-decoration: none;
  763. }
  764. </style>
  765. <div results-view='EW_Birth-Table' style='display: none; margin-bottom: 25px' default-sort-fields='year,quarter'>
  766. <table style='width: 100%; border-collapse: collapse'>
  767. <thead>
  768. <tr>
  769. <td style='width: 12%' sort-fields='year,quarter'>Date</td>
  770. <td style='width: 30%' sort-fields='forenames,surname'>Name</td>
  771. <td style='width: 15%' sort-fields='mother'>Mother</td>
  772. <td style='width: 25%' sort-fields='district'>District</td>
  773. <td style='width: 6%' sort-fields='volume,district'>Vol</td>
  774. <td style='width: 6%' sort-fields='page,volume'>Page</td>
  775. <td style='width: 6%' sort-fields='copy,volume'>Copy</td>
  776. </tr>
  777. </thead>
  778. <tbody>
  779. {{#each items}}
  780. <tr class='rec'>
  781. <td>{{year}} Q{{quarter}}</td>
  782. <td><span class='forenames'>{{forenames}}</span> <span class='surname'>{{surname}}</span>{{#if noForenames}} ({{gender}}){{/if}}</td>
  783. <td>{{mother}}</td>
  784. <td>{{district}}</td>
  785. <td>{{volume}}</td>
  786. <td>{{page}}</td>
  787. <td>{{copy}}</td>
  788. </tr>
  789. <tr class='rec-actions' style='display: none'>
  790. <td colspan='7' style='text-align: right'>
  791. {{#actions}}
  792. <a href='{{url}}' {{#if title}}title='{{title}}'{{/if}}>{{text}}</a>
  793. {{/actions}}
  794. </td>
  795. </tr>
  796. {{/each}}
  797. </tbody>
  798. </table>
  799. {{#if failures}}
  800. <p class='main_text' style='color: Red'>WARNING: Failed to parse {{failures.length}} records. See default view for full list.</p>
  801. <!--
  802. {{#each failures}}record parse exception ({{index}}): exception: {{ex.message}}{{/each}}
  803. -->
  804. {{/if}}
  805. <p class='main_text groish_Message'></p>
  806. </div>`,
  807. view_EW_Birth_Delimited: `
  808. <style type="text/css">
  809. div[results-view='EW_Birth-Delimited'] td
  810. {
  811. padding: 5px 3px;
  812. font-size: 75%;
  813. color: #222;
  814. vertical-align: top;
  815. }
  816. div[results-view='EW_Birth-Delimited'] thead td
  817. {
  818. font-weight: bold;
  819. }
  820. div[results-view='EW_Birth-Delimited'] tbody tr:nth-child(odd)
  821. {
  822. background-color: #CCE0FF;
  823. }
  824.  
  825. </style>
  826. <div results-view='EW_Birth-Delimited' style='display: none; margin-bottom: 25px' default-sort-fields='year,quarter'>
  827. <table style='width: 100%; border-collapse: collapse'>
  828. <thead>
  829. <tr>
  830. <td style='width: 100%' sort-fields='year,quarter'>Births</td>
  831. </tr>
  832. </thead>
  833. <tbody>
  834. {{#each items}}
  835. <tr class='rec'>
  836. <td>
  837. {{year}} Q{{quarter}} Birth:
  838. {{forenames}} {{surname}}{{#if noForenames}} ({{gender}}){{/if}}
  839. (mmn: {{mother}});
  840. {{district}};{{#if volume}} Vol {{volume}};{{/if}}{{#if page}} Page {{page}};{{/if}}{{#if copy}} Copy {{copy}};{{/if}}
  841. </td>
  842. </tr>
  843. {{/each}}
  844. </tbody>
  845. </table>
  846. {{#if failures}}
  847. <p class='main_text' style='color: Red'>WARNING: Failed to parse {{failures.length}} records. See default view for full list.</p>
  848. <!--
  849. {{#each failures}}record parse exception ({{index}}): exception: {{ex.message}}{{/each}}
  850. -->
  851. {{/if}}
  852. <p class='main_text groish_Message'></p>
  853. </div>`,
  854.  
  855. view_EW_Death_Table: `
  856. <style type="text/css">
  857. div[results-view='EW_Death-Table'] td
  858. {
  859. padding: 5px 3px;
  860. font-size: 75%;
  861. color: #222;
  862. vertical-align: top;
  863. }
  864. div[results-view='EW_Death-Table'] thead td
  865. {
  866. font-weight: bold;
  867. }
  868. div[results-view='EW_Death-Table'] tbody tr:nth-child(4n+1),
  869. div[results-view='EW_Death-Table'] tbody tr:nth-child(4n+2)
  870. {
  871. background-color: #CCE0FF;
  872. }
  873. div[results-view='EW_Death-Table'] tr.rec-actions a
  874. {
  875. padding: 0px 5px;
  876. font-size: 90%;
  877. color: #15377E;
  878. text-decoration: none;
  879. }
  880. </style>
  881. <div results-view='EW_Death-Table' style='display: none; margin-bottom: 25px' default-sort-fields='year,quarter'>
  882. <table style='width: 100%; border-collapse: collapse'>
  883. <thead>
  884. <tr>
  885. <td style='width: 12%' sort-fields='year,quarter'>Date</td>
  886. <td style='width: 26%' sort-fields='forenames,surname'>Name</td>
  887. <td style='width: 8%' sort-fields='age'>Age{{#if ageCautionThreshold}}*{{/if}}</td>
  888. <td style='width: 8%' sort-fields='birth'>Birth</td>
  889. <td style='width: 28%' sort-fields='district'>District</td>
  890. {{#if (and dataFormat1984 dataFormat1993)}}
  891. <td style='width: 6%' >Vl/Rg</td>
  892. <td style='width: 6%' >Pg/Ey</td>
  893. {{else if dataFormat1993}}
  894. <td style='width: 6%' sort-fields='reg,district'>Reg</td>
  895. <td style='width: 6%' sort-fields='entry,volume'>Entry</td>
  896. {{else}}
  897. <td style='width: 6%' sort-fields='volume,district'>Vol</td>
  898. <td style='width: 6%' sort-fields='page,volume'>Page</td>
  899. {{/if}}
  900. <td style='width: 6%' sort-fields='copy,volume'>Copy</td>
  901. </tr>
  902. </thead>
  903. <tbody>
  904. {{#each items}}
  905. <tr class='rec'>
  906. <td>{{year}}{{#if quarter}} Q{{quarter}}{{/if}}</td>
  907. <td><span class='forenames'>{{forenames}}</span> <span class='surname'>{{surname}}</span>{{#if noForenames}} ({{gender}}){{/if}}</td>
  908. <td>{{age}}</td>
  909. <td>{{birth}}
  910. <td>{{district}}</td>
  911. <td>{{#if volume}}{{volume}}{{else if reg}}{{reg}}{{/if}}</td>
  912. <td>{{#if page}}{{page}}{{else if entry}}{{entry}}{{/if}}</td>
  913. <td>{{copy}}</td>
  914. </tr>
  915. <tr class='rec-actions' style='display: none'>
  916. <td colspan='8' style='text-align: right'>
  917. {{#actions}}
  918. <a href='{{url}}' {{#if title}}title='{{title}}'{{/if}}>{{text}}</a>
  919. {{/actions}}
  920. </td>
  921. </tr>
  922. {{/each}}
  923. </tbody>
  924. </table>
  925. {{#if failures}}
  926. <p class='main_text' style='color: Red'>WARNING: Failed to parse {{failures.length}} records. See default view for full list.</p>
  927. <!--
  928. {{#each failures}}record parse exception ({{index}}): exception: {{ex.message}}{{/each}}
  929. -->
  930. {{/if}}
  931. <p class='main_text groish_Message'></p>
  932. </div>`,
  933.  
  934. view_EW_Death_Delimited: `
  935. <style type="text/css">
  936. div[results-view='EW_Death-Delimited'] td
  937. {
  938. padding: 5px 3px;
  939. font-size: 75%;
  940. color: #222;
  941. vertical-align: top;
  942. }
  943. div[results-view='EW_Death-Delimited'] thead td
  944. {
  945. font-weight: bold;
  946. }
  947. div[results-view='EW_Death-Delimited'] tbody tr:nth-child(odd)
  948. {
  949. background-color: #CCE0FF;
  950. }
  951. </style>
  952. <div results-view='EW_Death-Delimited' style='display: none; margin-bottom: 25px' default-sort-fields='year,quarter'>
  953. <table style='width: 100%; border-collapse: collapse'>
  954. <thead>
  955. <tr>
  956. <td style='width: 100%' sort-fields='year,quarter'>Deaths</td>
  957. </tr>
  958. </thead>
  959. <tbody>
  960. {{#each items}}
  961. <tr class='rec'>
  962. <td>
  963. {{year}}{{#if quarter}} Q{{quarter}}{{/if}} Death:
  964. {{forenames}} {{surname}}{{#if noForenames}} ({{gender}}){{/if}};
  965. {{#if yob}}
  966. Born {{yob}} (age {{age}});
  967. {{else}}
  968. Age {{age}} (b{{birth}});
  969. {{/if}}
  970. {{district}};{{#if volume}} Vol {{volume}};{{/if}}{{#if page}} Page {{page}};{{/if}}{{#if reg}} Reg {{reg}};{{/if}}{{#if entry}} Entry {{entry}};{{/if}}{{#if copy}} Copy {{copy}};{{/if}}
  971. </td>
  972. </tr>
  973. {{/each}}
  974. </tbody>
  975. </table>
  976. {{#if failures}}
  977. <p class='main_text' style='color: Red'>WARNING: Failed to parse {{failures.length}} records. See default view for full list.</p>
  978. <!--
  979. {{#each failures}}record parse exception ({{index}}): exception: {{ex.message}}{{/each}}
  980. -->
  981. {{/if}}
  982. <p class='main_text groish_Message'></p>
  983. </div>`
  984.  
  985. };
  986.  
  987. // Add custom views
  988. // NB: Although GreaseMonkey has replaced the GM_* functions with functions on the GM object
  989. // both ViolentMonkey and TamperMonkey still support the GM_* functions so that's being used.
  990.  
  991. // Custom views are defined as GM values (one value per view). The value name must begin with
  992. // either view_EW_Birth or view_EW_Death. The view may contain CSS and HTML and the views are
  993. // Handlebars.js templates (see default views above for examples).
  994. //console.log("adding custom views");
  995. if (typeof GM_listValues === "function" && typeof GM_getValue === "function") {
  996. let valueKeys = GM_listValues();
  997. for(let i = 0; i < valueKeys.length; i++) {
  998. let valueKey = valueKeys[i];
  999. //console.log("value key: %", valueKey);
  1000. if (valueKey && valueKey.length > 13 && (valueKey.startsWith("view_EW_Birth") || valueKey.startsWith("view_EW_Death"))) {
  1001. // Check the key isn't already in use
  1002. if (!resources.hasOwnProperty(valueKey)) {
  1003. let viewContent = GM_getValue(valueKey, null);
  1004. if (viewContent) {
  1005. //console.log("adding view: %s", valueKey);
  1006. resources[valueKey] = viewContent;
  1007. }
  1008. }
  1009. }
  1010. }
  1011. }
  1012. }
  1013.  
  1014. //Get the ball rolling...
  1015. main();
  1016. });