AO3: [Wrangling] Search Term Highlighting

highlights the search terms in the results

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         AO3: [Wrangling] Search Term Highlighting
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @description  highlights the search terms in the results
// @author       escctrl
// @version      6.1
// @match        *://*.archiveofourown.org/tags/search?*
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.6.1/jquery.min.js
// @require      https://update.greasyfork.org/scripts/491888/1355841/Light%20or%20Dark.js
// @license      MIT
// ==/UserScript==
 
/* eslint-disable no-multi-spaces */
/* global jQuery, lightOrDark */
 
// CONFIGURATION
 
   // this impacts how the highlighting acts with special characters (when there aren't asterisk in the search term -- yes, AO3 is being extra)
   //    false = like AO3 searches by ignoring special characters
   //    true  = as you probably meant it, by searching for the literal special character
   const STRICT_HIGHLIGHT = true;
 
 
(function($) {
    'use strict';
	
	// stop executing if we're seeing a Retry Later or an Error page
	if ($('#new_tag_search').length === 0) return;
 
    const DEBUG = false;
 
    const HIGHLIGHT = lightOrDark(window.getComputedStyle(document.body).backgroundColor) == "dark" ? '#082f03' : '#FFFF99' ;
 
    // and some more CSS styling for the table
    document.getElementsByTagName('head')[0].innerHTML += `<style type="text/css">
        #highlightButton { font-size: 0.6em; margin-right: 0.5em; }
        #highlightButton.pressed { color: #900; border-top: 1px solid #999; border-left: 1px solid #999; box-shadow: inset 2px 2px 2px #bbb; }
        a.tag span.highlight.on { background-color: ${HIGHLIGHT}; }
        a.tag:hover span.highlight.on { background-color: inherit; }
        </style>`;
 
    // *** Highlight the search terms in the results
 
    // a button to toggle the highlighting on and off
    var button = document.createElement("button");
    button.innerHTML = "Highlight Search Terms";
    button.id = "highlightButton";
    button.addEventListener("click", HighlightTerms);
    $("#main > h3").prepend(button);
 
    // first we grab the search term from the input field (which sanitizes HTML characters for us)
    var search_name = document.getElementById('tag_search_name').value;
 
    // deal with quoted terms that may include whitespaces
    var quotedterms = search_name.match(/".*?"/gi); // find all quoted searches (before we start splitting the string)
    search_name = search_name.replace(/".*?"/gi, ""); // remove the matches from the original search terms
 
    search_name = search_name.split(" "); // now we can split the remaining search terms on spaces
    // 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)
    search_name = [].concat(search_name, quotedterms).filter((val) => val !== null);
 
    // 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
    // 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
    // we're comparing lengths without quotes and asterisk, since they probably won't matter later
    search_name = search_name.sort((a,b) => b.replace(/["*]/gi, "").length - a.replace(/["*]/gi, "").length);
 
    // turn the user input into regular expressions
    var search_arr = search_name.map( Terms2Regex );
    var search_arr2 = [];
 
    // at this point, some items in the search_arr might be an array of even more search terms... need to flatten
    search_arr.forEach( function(e, i) {
        if (Array.isArray(e)) search_arr2 = search_arr2.concat(e);
        else search_arr2.push(e);
    });
 
    // running through the tag names, and highlighting the matched parts by adding a <span> with a class
    $('a.tag').each( function(i, link) {
        var tagname = link.text;
 
        if (DEBUG) console.log(tagname);
 
        search_arr2.forEach( function(term) {
            // we add only a placeholder, so we won't start matching against the HTML characters
            tagname = tagname.replace(new RegExp(term, "gi"), "\r$&\t");
        });
        // once done with finding term matches, we can turn the placeholders into actual HTML and display that
        link.innerHTML = tagname.replace(/\r/g, "<span class='highlight'>").replace(/\t/g, "</span>");
        if (DEBUG) console.log(link.innerHTML);
    });
 
    function HighlightTerms() {
        $('a.tag span.highlight').toggleClass("on");
        $(button).toggleClass("pressed");
    }
 
    function Terms2Regex(term) {
 
        // skip bad input and logical operators
        switch (term) {
            case "": case null:
            case "OR": case "NOT": case "AND":
            case "||": case "&&":
                return false;
        }
 
        // we clone our regex into a new variable, and keep the original search term unchanged for comparisons
        // get rid of asterisk at beginning and end, as well as any quotes
        var regex = term.replace(/(^\*)|(\*$)|(")/g, '');
        var regex_split = [];
 
        // special characters are essentially ignored, if no asterisk is present in the search term and as long as there's a word character
        // if user wants to follow AO3's search logic, we need to modify the regex accordingly
        if (term.indexOf('*') == -1 && regex.match(/\w/gi) !== null && !STRICT_HIGHLIGHT) {
 
            // at the borders of a word, the special character simply disappears
            regex = regex.replace(/(^\s*[^\w\s]+\s*)|(\s*[^\w\s]+\s*$)/g, '');
 
            // when surrounded by \w's (we already removed the other characters at beginning/end)...
            // with quotes, it acts like a non-word wildcard between them
            if (term.slice(0,1) == '"' && term.slice(-1) == '"') regex = regex.replace(/\s*[^\w\s]+\s*/g, "\[^\\w\]*");
            // without quotes, it splits the term in two
            else regex_split = regex.replace(/\s*[^\w\s]+\s*/g, ",").split(',');
        }
        // some characters have special meaning in regex, we need to add an escape sequence to highlight them
        // except asterisk! (at first) -- then replace any remaining asterisk in the middle with the regex equivalent
        else regex = regex.replace(/[-\/\\^$+?.()|[\]{}]/g, '\\$&').replace(/\*/g, '.*?');
 
        // finish creating the regexes by adding the boundaries to (each part of) the term
        if (regex_split.length > 0) {
            regex_split.forEach( function(v, i) { regex_split[i] = addBoundaries(v, term); });
            regex = regex_split;
        }
        else regex = addBoundaries(regex, term);
 
        if (DEBUG) console.log(term + ' => ' + regex);
 
        // this will return an array of strings, in case the term had to be split. otherwise a single string
        return regex;
    }
 
    // add the regex Metacharacter for word boundaries, only if the first/last letter was a 'word' character
    function addBoundaries(rgx, term) {
        if (term.slice(0,1) != "*" && rgx.match(/^\w/gi) !== null) { rgx = "\\b" + rgx; }
        if (term.slice(-1) != "*" && rgx.match(/\w$/gi) !== null) { rgx = rgx + "\\b"; }
 
        return rgx;
    }
 
})(jQuery);