AO3: [Wrangling] Search Term Highlighting

highlights the search terms in the results

  1. // ==UserScript==
  2. // @name AO3: [Wrangling] Search Term Highlighting
  3. // @namespace https://greasyfork.org/en/users/906106-escctrl
  4. // @description highlights the search terms in the results
  5. // @author escctrl
  6. // @version 6.1
  7. // @match *://*.archiveofourown.org/tags/search?*
  8. // @require https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js
  9. // @require https://update.greasyfork.org/scripts/491888/1355841/Light%20or%20Dark.js
  10. // @license MIT
  11. // ==/UserScript==
  12. /* eslint-disable no-multi-spaces */
  13. /* global jQuery, lightOrDark */
  14. // CONFIGURATION
  15. // this impacts how the highlighting acts with special characters (when there aren't asterisk in the search term -- yes, AO3 is being extra)
  16. // false = like AO3 searches by ignoring special characters
  17. // true = as you probably meant it, by searching for the literal special character
  18. const STRICT_HIGHLIGHT = true;
  19. (function($) {
  20. 'use strict';
  21. // stop executing if we're seeing a Retry Later or an Error page
  22. if ($('#new_tag_search').length === 0) return;
  23. const DEBUG = false;
  24. const HIGHLIGHT = lightOrDark(window.getComputedStyle(document.body).backgroundColor) == "dark" ? '#082f03' : '#FFFF99' ;
  25. // and some more CSS styling for the table
  26. document.getElementsByTagName('head')[0].innerHTML += `<style type="text/css">
  27. #highlightButton { font-size: 0.6em; margin-right: 0.5em; }
  28. #highlightButton.pressed { color: #900; border-top: 1px solid #999; border-left: 1px solid #999; box-shadow: inset 2px 2px 2px #bbb; }
  29. a.tag span.highlight.on { background-color: ${HIGHLIGHT}; }
  30. a.tag:hover span.highlight.on { background-color: inherit; }
  31. </style>`;
  32. // *** Highlight the search terms in the results
  33. // a button to toggle the highlighting on and off
  34. var button = document.createElement("button");
  35. button.innerHTML = "Highlight Search Terms";
  36. button.id = "highlightButton";
  37. button.addEventListener("click", HighlightTerms);
  38. $("#main > h3").prepend(button);
  39. // first we grab the search term from the input field (which sanitizes HTML characters for us)
  40. var search_name = document.getElementById('tag_search_name').value;
  41. // deal with quoted terms that may include whitespaces
  42. var quotedterms = search_name.match(/".*?"/gi); // find all quoted searches (before we start splitting the string)
  43. search_name = search_name.replace(/".*?"/gi, ""); // remove the matches from the original search terms
  44. search_name = search_name.split(" "); // now we can split the remaining search terms on spaces
  45. // and join the two arrays back together for further processing, but remove any null values (happen if either one of the original arrays is empty)
  46. search_name = [].concat(search_name, quotedterms).filter((val) => val !== null);
  47. // this isn't the magic bullet, but try sorting by length (from longest to shortest search term) to get around problems with highlighting matches within longer matches
  48. // hopefully it manages to avoid most situations where a longer match would not be recognized because a shorter match already added placeholders in the middle of the term
  49. // we're comparing lengths without quotes and asterisk, since they probably won't matter later
  50. search_name = search_name.sort((a,b) => b.replace(/["*]/gi, "").length - a.replace(/["*]/gi, "").length);
  51. // turn the user input into regular expressions
  52. var search_arr = search_name.map( Terms2Regex );
  53. var search_arr2 = [];
  54. // at this point, some items in the search_arr might be an array of even more search terms... need to flatten
  55. search_arr.forEach( function(e, i) {
  56. if (Array.isArray(e)) search_arr2 = search_arr2.concat(e);
  57. else search_arr2.push(e);
  58. });
  59. // running through the tag names, and highlighting the matched parts by adding a <span> with a class
  60. $('a.tag').each( function(i, link) {
  61. var tagname = link.text;
  62. if (DEBUG) console.log(tagname);
  63. search_arr2.forEach( function(term) {
  64. // we add only a placeholder, so we won't start matching against the HTML characters
  65. tagname = tagname.replace(new RegExp(term, "gi"), "\r$&\t");
  66. });
  67. // once done with finding term matches, we can turn the placeholders into actual HTML and display that
  68. link.innerHTML = tagname.replace(/\r/g, "<span class='highlight'>").replace(/\t/g, "</span>");
  69. if (DEBUG) console.log(link.innerHTML);
  70. });
  71. function HighlightTerms() {
  72. $('a.tag span.highlight').toggleClass("on");
  73. $(button).toggleClass("pressed");
  74. }
  75. function Terms2Regex(term) {
  76. // skip bad input and logical operators
  77. switch (term) {
  78. case "": case null:
  79. case "OR": case "NOT": case "AND":
  80. case "||": case "&&":
  81. return false;
  82. }
  83. // we clone our regex into a new variable, and keep the original search term unchanged for comparisons
  84. // get rid of asterisk at beginning and end, as well as any quotes
  85. var regex = term.replace(/(^\*)|(\*$)|(")/g, '');
  86. var regex_split = [];
  87. // special characters are essentially ignored, if no asterisk is present in the search term and as long as there's a word character
  88. // if user wants to follow AO3's search logic, we need to modify the regex accordingly
  89. if (term.indexOf('*') == -1 && regex.match(/\w/gi) !== null && !STRICT_HIGHLIGHT) {
  90. // at the borders of a word, the special character simply disappears
  91. regex = regex.replace(/(^\s*[^\w\s]+\s*)|(\s*[^\w\s]+\s*$)/g, '');
  92. // when surrounded by \w's (we already removed the other characters at beginning/end)...
  93. // with quotes, it acts like a non-word wildcard between them
  94. if (term.slice(0,1) == '"' && term.slice(-1) == '"') regex = regex.replace(/\s*[^\w\s]+\s*/g, "\[^\\w\]*");
  95. // without quotes, it splits the term in two
  96. else regex_split = regex.replace(/\s*[^\w\s]+\s*/g, ",").split(',');
  97. }
  98. // some characters have special meaning in regex, we need to add an escape sequence to highlight them
  99. // except asterisk! (at first) -- then replace any remaining asterisk in the middle with the regex equivalent
  100. else regex = regex.replace(/[-\/\\^$+?.()|[\]{}]/g, '\\$&').replace(/\*/g, '.*?');
  101. // finish creating the regexes by adding the boundaries to (each part of) the term
  102. if (regex_split.length > 0) {
  103. regex_split.forEach( function(v, i) { regex_split[i] = addBoundaries(v, term); });
  104. regex = regex_split;
  105. }
  106. else regex = addBoundaries(regex, term);
  107. if (DEBUG) console.log(term + ' => ' + regex);
  108. // this will return an array of strings, in case the term had to be split. otherwise a single string
  109. return regex;
  110. }
  111. // add the regex Metacharacter for word boundaries, only if the first/last letter was a 'word' character
  112. function addBoundaries(rgx, term) {
  113. if (term.slice(0,1) != "*" && rgx.match(/^\w/gi) !== null) { rgx = "\\b" + rgx; }
  114. if (term.slice(-1) != "*" && rgx.match(/\w$/gi) !== null) { rgx = rgx + "\\b"; }
  115. return rgx;
  116. }
  117. })(jQuery);