AO3: [Wrangling] Action Buttons Everywhere

Adds buttons to manage tags EVERYWHERE you want, turns tag-URLs in comments into links, and can show search results as a table

질문, 리뷰하거나, 이 스크립트를 신고하세요.
// ==UserScript==
// @name         AO3: [Wrangling] Action Buttons Everywhere
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @description  Adds buttons to manage tags EVERYWHERE you want, turns tag-URLs in comments into links, and can show search results as a table
// @author       escctrl
// @version      2.3
// @match        *://*.archiveofourown.org/tags/*
// @match        *://*.archiveofourown.org/comments*
// @match        *://*.archiveofourown.org/users/*/inbox*
// @grant        none
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
// @require      https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.3/jquery.ui.touch-punch.min.js
// @require      https://update.greasyfork.org/scripts/491888/1355841/Light%20or%20Dark.js
// @require      https://update.greasyfork.org/scripts/491896/1355860/Copy%20Text%20and%20HTML%20to%20Clipboard.js
// @license      MIT
// ==/UserScript==

/* eslint-disable no-multi-spaces */
/* global jQuery, lightOrDark, copy2Clipboard */


(function($) {
    'use strict';

    /*********** INITIALIZING ***********/

    const page_type = findPageType();
    if (page_type == "") return; // page that isn't supported or we're in retry later

    // for each button, we need the URL, the icon or text that appears on the button, and a helpful tooltip text
    // by the way, the working ICONS are here --> https://fontawesome.com/v4/icons
    const buttons = {
        search:       { link: "/tags/search",
                        icon: "",
                        text: "Search",
                        tooltip: "Search tags",
                        pages: ["top"] },
        search_fan:   { link: "/tags/search?tag_search[fandoms]=",
                        icon: "",
                        text: "Search this Fandom",
                        tooltip: "Search this Fandom",
                        pages: ["top"] },
        new:          { link: "/tags/new",
                        icon: "",
                        text: "New Tag",
                        tooltip: "Create new tag",
                        pages: ["top"] },
        landing:      { link: "",
                        icon: "",
                        text: "Landing Page",
                        tooltip: "Tag Landing Page",
                        pages: ["top", "bin", "inbox", "search"] },
        edit:         { link: "/edit",
                        icon: "",
                        text: "Edit",
                        tooltip: "Edit tag & associations",
                        pages: ["top", "bin", "inbox", "search"] },
        wrangle:      { link: "/wrangle?page=1&show=mergers", // since the plain /wrangle page is empty, we might as well go straight to the Syns bin
                        icon: "",
                        text: "Wrangle",
                        tooltip: "Wrangle all child tags",
                        pages: ["top", "bin", "inbox", "search"] },
        comments:     { link: "/comments",
                        icon: "",
                        text: "Comments",
                        tooltip: "Comments", // this tooltip is overwritten by "Last comment: DATE" when there's a comment
                        pages: ["top", "bin", "inbox", "search"] },
        works:        { link: "/works",
                        icon: "",
                        text: "Works",
                        tooltip: "Works",
                        pages: ["top", "bin", "inbox", "search"] },
        bookmarks:    { link: "/bookmarks",
                        icon: "",
                        text: "Bookmarks",
                        tooltip: "Bookmarks",
                        pages: ["top", "bin", "inbox", "search"] },
        troubleshoot: { link: "/troubleshooting",
                        icon: "",
                        text: "Troubleshooting",
                        tooltip: "Troubleshooting",
                        pages: ["top"] },
        taglink:      { link: "javascript:void(0);",
                        icon: "",
                        text: "Copy Link",
                        tooltip: "Copy tag link to clipboard",
                        pages: ["top", "bin", "inbox", "search"] },
        tagname:      { link: "javascript:void(0);" ,
                        icon: "",
                        text: "Copy Name",
                        tooltip: "Copy tag name to clipboard",
                        pages: ["top", "bin", "inbox", "search"] },
        remove:       { link: "",
                        icon: "",
                        text: "Remove",
                        tooltip: "Remove from fandom",
                        pages: ["bin"] }
    };

    let cfg = 'wrangleActionButtons'; // name of dialog and localstorage used throughout
    let dlg = '#'+cfg;
    let stored = loadConfig(); // get previously stored configuration
    createDialog();
    if (stored.pages.includes("top") === true && page_type !== "inbox") buildTopButtonBar();
    if (stored.pages.includes("bin") === true && page_type == "wrangle") buildBinManageBar();
    if (stored.pages.includes("inbox") === true && (page_type == "inbox" || page_type == "comments")) buildCommentsLinkBars();
    if (page_type == "search") buildSearchPage();

    // adding the script and CSS for icons
    if (stored.iconify) {
        $("head").append(`<script src="https://use.fontawesome.com/ed555db3cc.js" />`)
        .append(`<style type="text/css">#wranglerbuttons, .wrangleactions, .heading .wrangleactions a, .wrangleactions label, a.rss, #new_favorite_tag input[type=submit],
        form[id^="edit_favorite_tag"] input[type=submit], a#go_to_filters, #resulttable .resultType { font-family: FontAwesome, sans-serif; }</style>`);
    }
    $("head").append(`<style type="text/css">a.rss span { background: none; padding-left: 0; } .wrangleactions li { padding: 0; }
        .wrangleactions input[type='checkbox'] { margin: auto auto auto 0.5em; vertical-align: bottom; }
        span.wrangleactions a.action { font-size: 91%; padding: 0.2em 0.4em; } .heading span.wrangleactions a.action { font-size: 80%; }
        .wrangleactions a:visited { border-bottom-color: transparent; }
        .tags-search span.wrangleactions a { border: 0; padding: 0 0.2em; }
        #resulttable { line-height: 1.5; ${stored.tableflex ? `width: auto; margin-left:0;` : ''} }
        #resulttable .resultcheck { display: none; }
        #resulttable .resultManage { ${(!stored.pages.includes("search") || stored.search.length == 0) ? `display: none;` : ''} }
        #resulttable tbody .resultManage { padding: 0 0.25em; } #resulttable tbody .resultManage * { margin: 0; }
        #resulttable .resultType, #resulttable .resultManage { text-align: center; } #resulttable .resultUses { text-align: right; }</style>`);

    /*********** FUNCTIONS FOR CONFIG DIALOG AND STORAGE ***********/

    function findPageType() {
        // simpler than interpreting the URL: determine page type based on classes assigned to #main
        let main = $('#main');
        return $(main).hasClass('tags-wrangle') ? "wrangle" :
               $(main).hasClass('tags-edit') ? "edit" :
               $(main).hasClass('tags-show') ? "landing" :
               $(main).hasClass('tags-search') ? "search" :
               $(main).hasClass('tags-new') ? "new" :
               $(main).hasClass('works-index') ? "works" :
               $(main).hasClass('bookmarks-index') ? "bookmarks" :
               $(main).hasClass('comments-index') ? "comments" :
               $(main).hasClass('comments-show') ? "comments" :
               $(main).hasClass('inbox-show') ? "inbox" : "";
    }

    function createDialog() {
        // if the background is dark, use the dark UI theme to match
        let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "dark-hive" : "base";

        // adding the jQuery stylesheet to style the dialog, and fixing the interference of AO3's styling
        $("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/${dialogtheme}/jquery-ui.css">`)
        .prepend(`<script src="https://use.fontawesome.com/ed555db3cc.js" />`)
        .append(`<style tyle="text/css">${dlg}, .ui-dialog .ui-dialog-buttonpane button {font-size: revert; line-height: 1.286;}
        ${dlg} form {box-shadow: revert; cursor:auto;}
        ${dlg} fieldset {background: revert; box-shadow: revert;}
        ${dlg} fieldset p { padding-left: 0; padding-right: 0; }
        ${dlg} legend {font-size: inherit; height: auto; width: auto; opacity: inherit;}
        ${dlg} li { background-color: revert; border: revert; margin: 0; float: none; clear: none; box-shadow: revert; width: auto; }
        ${dlg} .sortable li { float: left; }
        ${dlg} #table-columns.sortable .ui-button { cursor: move; }
        ${dlg} #table-columns.sortable li.sortfixed { cursor: text; }
        </style>`);

        // filling up the enabled buttons with the remaining available - Set() does the job of keeping values unique
        let order_top    = new Set(stored.top);
        let order_bin    = new Set(stored.bin);
        let order_inbox  = new Set(stored.inbox);
        let order_search = new Set(stored.search);
        Object.keys(buttons).forEach(function(val) {
            if (buttons[val].pages.includes("top"))    order_top.add(val);
            if (buttons[val].pages.includes("bin"))    order_bin.add(val);
            if (buttons[val].pages.includes("inbox"))  order_inbox.add(val);
            if (buttons[val].pages.includes("search")) order_search.add(val);
        });

        // wrapper div for the dialog
        $("#main").append(`<div id="${dlg.slice(1)}"></div>`);

        // building  the dialog content
        let pages = { top: "Top of Page", bin: "Bins", inbox: "Inbox & Comments", search: "Search" };
        let enabled = Object.keys(pages).map(function (me) {
            return `<label for='enbl${me}'>${pages[me]}</label><input type='checkbox' name='${me}' id='enbl${me}' ${stored.pages.includes(me) ? "checked='checked'" : ""}>`;
        });
        let sort_top = Array.from(order_top.values()).map(function(me) {
            return `<li><label for='ckbx-top-${me}'>${buttons[me].text}</label><input type='checkbox' name='${me}' id='ckbx-top-${me}' ${stored.top.includes(me) ? "checked='checked'" : ""}></li>`;
        });
        let sort_bin = Array.from(order_bin.values()).map(function(me) {
            return `<li><label for='ckbx-bin-${me}'>${buttons[me].text}</label><input type='checkbox' name='${me}' id='ckbx-bin-${me}' ${stored.bin.includes(me) ? "checked='checked'" : ""}></li>`;
        });
        let sort_inbox = Array.from(order_inbox.values()).map(function(me) {
            return `<li><label for='ckbx-inbox-${me}'>${buttons[me].text}</label><input type='checkbox' name='${me}' id='ckbx-inbox-${me}' ${stored.inbox.includes(me) ? "checked='checked'" : ""}></li>`;
        });
        let sort_search = Array.from(order_search.values()).map(function(me) {
            return `<li><label for='ckbx-search-${me}'>${buttons[me].text}</label><input type='checkbox' name='${me}' id='ckbx-search-${me}' ${stored.search.includes(me) ? "checked='checked'" : ""}></li>`;
        });
        let sort_tablecols = Array.from(stored.tablecols.values()).map(function(me) {
            return `<li><span class='ui-button ui-widget ui-corner-all'>${me}</span></li>`;
        });

        $(dlg).html(`<form>
        <fieldset><legend>General Settings:</legend>
        <p id='enable-page'>Choose where tag wrangling action buttons are added:<br/>${enabled.join(" ")}</p>
        <p><label for='showicons'>Use Icons instead of Text Labels</label><input type='checkbox' name='showicons' id='showicons' ${stored.iconify ? "checked='checked'" : ""}></p>
        </fieldset>
        <p>For each of these places, enable the buttons you wish to use and drag them into your preferred order.</p>
        <fieldset id='fs-top' ${!stored.pages.includes("top") ? "style='display: none;'" : ""}><legend>${pages.top}</legend>
        <ul id='sortable-top' class='sortable'>${sort_top.join("\n")}</ul>
        </fieldset>
        <fieldset id='fs-bin' ${!stored.pages.includes("bin") ? "style='display: none;'" : ""}><legend>${pages.bin}</legend>
        <ul id='sortable-bin' class='sortable'>${sort_bin.join("\n")}</ul>
        </fieldset>
        <fieldset id='fs-inbox' ${!stored.pages.includes("inbox") ? "style='display: none;'" : ""}><legend>${pages.inbox}</legend>
        <p>While this is enabled, it'll also turn plaintext URLs into links, and give you a button to turn any text into a taglink.</p>
        <ul id='sortable-inbox' class='sortable'>${sort_inbox.join("\n")}</ul>
        </fieldset>
        <fieldset id='fs-search'><legend>${pages.search}</legend>
        <ul id='sortable-search' class='sortable' ${!stored.pages.includes("search") ? "style='display: none;'" : ""}>${sort_search.join("\n")}</ul>
        <p style="clear: left;"><label for='resultstable'>Show Search Results as Table</label><input type='checkbox' name='resultstable' id='resultstable' ${stored.table ? "checked='checked'" : ""}>
        (works even if the Action buttons on Search aren't enabled)</p>
        <div style="margin-left: 2em;">
            <div id="table-options" ${!stored.table ? "style='display: none;'" : ""}>
                <p>Drag the columns into your preferred order:</p>
                <ul id='table-columns' class='sortable'>
                    ${sort_tablecols.join("\n")}
                    <li class="sortfixed"><span class='ui-button ui-widget ui-corner-all ui-state-disabled'>Peek Script</span></li>
                </ul>
                <p style="clear: left;"><label for='table-flex'>Resize table dynamically with tags</label><input type='checkbox' name='table-flex' id='table-flex' ${stored.tableflex ? "checked='checked'" : ""}></p>
            </div>
        </div>
        </fieldset>
        </form>`);

        // optimizing the size of the GUI in case it's a mobile device
        let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px)
        dialogwidth = dialogwidth > 500 ? dialogwidth * 0.7 : dialogwidth * 0.9;

        $(dlg).dialog({
            appendTo: "#main",
            modal: true,
            title: 'Wrangling Buttons Everywhere Config',
            draggable: true,
            resizable: false,
            autoOpen: false,
            width: dialogwidth,
            position: {my:"center", at: "center top"},
            buttons: {
                Reset: deleteConfig,
                Save: storeConfig,
                Cancel: function() { $( dlg ).dialog( "close" ); }
            }
        });

        // show/hide the corresponding fieldsets when a page is enabled or disabled
        $('#enable-page input').on('change', function(e) {
            if (e.target.name == "search") $('#fs-search #sortable-search').toggle();
            else $('#fs-'+e.target.name).toggle();
        });
        // show/hide the table options when displaying search results in a table is enabled or disabled
        $('input#resultstable').on('change', function(e) {
            $('#table-options').toggle();
        });

        // if no other script has created it yet, write out a "Userscripts" option to the main navigation
        if ($('#scriptconfig').length == 0) {
            $('#header ul.primary.navigation li.dropdown').last()
                .after(`<li class="dropdown" id="scriptconfig">
                    <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a>
                    <ul class="menu dropdown-menu"></ul></li>`);
        }
        // then add this script's config option to navigation dropdown
        $('#scriptconfig .dropdown-menu').append(`<li><a href="javascript:void(0);" id="opencfg_${cfg}">Wrangling Buttons Everywhere</a></li>`);

        // on click, open the configuration dialog
        $("#opencfg_"+cfg).on("click", function(e) {
            $( dlg ).dialog('open');

            // turn checkboxes and radiobuttons into pretty buttons (must be after 'open' for dropdowns to work)
            $( dlg+" .sortable input[type='checkbox']" ).checkboxradio({ icon: false });
            $( dlg+" input[type='checkbox']" ).checkboxradio();
            $( ".sortable" ).sortable({
                forcePlaceholderSize: true,
                tolerance: 'pointer', // makes the movement behavior more predictable
                revert: true,         // animates reversal when dropped where it can't be sorted
                opacity: 0.5,         // transparency for the handle that's being dragged around
                cancel: '.sortfixed', // disables sorting for elements with this class
                cursor: "grabbing",   // switches cursor while dragging a tag for A+ cursor responsiveness
                containment: "parent" // limits dragging to the box and avoids scrollbars
            });
        });
    }

    function deleteConfig() {
        localStorage.removeItem(cfg);
        $(dlg).dialog('close');
    }

    function storeConfig() {
        let stored = { pages: [], top: [], bin: [], inbox: [], search: [], iconify: false, table: false, tableflex: false, tablecols: [] };

        stored.pages = $(dlg+' #enable-page input:checked').toArray().map((val) => val.name );       // the pages/places where buttons should work
        stored.top = $(dlg+' #sortable-top input:checked').toArray().map((val) => val.name );        // enabled buttons on top of page in correct order
        stored.bin = $(dlg+' #sortable-bin input:checked').toArray().map((val) => val.name );        // enabled buttons in the bin in correct order
        stored.inbox = $(dlg+' #sortable-inbox input:checked').toArray().map((val) => val.name );    // enabled buttons in the inbox in correct order
        stored.search = $(dlg+' #sortable-search input:checked').toArray().map((val) => val.name );  // enabled buttons on search results in correct order
        stored.iconify = $(dlg+' input#showicons').prop('checked');                                  // the question of ICONIFY
        stored.table = $(dlg+' input#resultstable').prop('checked');                                 // display search results as table
        stored.tableflex = $(dlg+' input#table-flex').prop('checked');                               // resize table dynamically with tag length
        stored.tablecols = $(dlg+' #table-columns li span').not('.ui-state-disabled').toArray().map((el) => el.innerText ); // search table columns in correct order

        localStorage.setItem(cfg, JSON.stringify(stored));
        $(dlg).dialog('close');
    }

    function loadConfig() {
        // gets an Object { pages: [pages], top: [buttons], ..., iconify: boolean, ... } or creates default values if no storage was found
        return JSON.parse(localStorage.getItem(cfg)) ?? {
            pages: ["top"],
            top: ["search", "new", "landing", "edit", "wrangle", "comments", "works", "bookmarks", "troubleshoot", "tagname", "taglink"],
            bin: ["remove", "edit", "comments", "wrangle", "works"],
            inbox: [],
            search: ["edit"],
            iconify: true,
            table: true,
            tableflex: false,
            tablecols: ["Name", "Type", "Uses", "Manage"]
        };
    }

    /*********** FUNCTIONS FOR BUTTONS ON TOP OF TAGS PAGES ***********/

    function buildTopButtonBar() {

        const [tag_name, tag_url] = findTag(page_type);

        let button_bar = "";
        stored.top.forEach((val) => {
            // first, skip over the ones we don't need
            if (page_type == val && page_type != "search" && page_type != "new") return; // don't link to the page we're on (except on search and new)
            if ((page_type == "search" || page_type == "new") && val != "search" && val != "new") return; // don't link to tag-specific stuff on search/new
            if (val == "search_fan") {
                // only link to fandom-search if it's a fandom tag (and, ideally, canonical)
                if (page_type == "wrangle" && $('#inner').find('ul.navigation.actions').eq(1).find('li').length != 5) return;
                else if (page_type == "edit" && !($('#edit_tag').find('fieldset:first-of-type dd').eq(2).text().trim() == "Fandom" && $('#tag_canonical').prop('checked'))) return;
                else if (page_type == "landing" && $('.tag.home > p').text().search(/^This tag belongs to the Fandom Category\.\s.*canonical/) == -1) return;
                else if (page_type.search(/^(wrangle|edit|landing)$/) == -1) return; // only link to fandom-search on a bin, edit, landing page
            }

            let link = (val.search(/^(new|search|search_fan|taglink|tagname|remove)$/) == -1) ? tag_url : "";
            if (val == "search_fan") buttons[val].link += encodeURIComponent(tag_name);

            button_bar += `<li title="${buttons[val].tooltip}" ${val=="troubleshoot" ? 'class="reindex"' : ""}>
                <a href="${link}${buttons[val].link}" id="wranglerbutton-${val}">
                ${(val=="comments" || val=="works" || val=="bookmarks") ? '<span id="'+val+'Count"></span>' : ""}
                ${stored.iconify ? buttons[val].icon : buttons[val].text}</a>
                </li>`;
        });

        let main = $('#main');

        // comments doesn't have a navbar yet, we need to create it
        if (page_type == "comments") {
            let prevheader = (page_type == "comments" && $(main).find('h2.heading').length == 0) ? $(main).find('h3.heading').prev() : // when clicking through from the inbox
                         $(main).find('h2.heading');
            $(`<ul class="navigation actions" role="navigation"'></ul>`).insertAfter(prevheader);
        }
        // edit has only the comment link in a <p> so we move it into a <ul> for the sake of simplicity for the rest of the script
        else if (page_type == "edit") {
            let oldbar = $(main).find('p.navigation.actions');
            let newbar = `<ul class="navigation actions" role="navigation"'><li>${$(oldbar).html()}</li></ul>`;
            $(oldbar).before(newbar).remove();
        }

        // make the navbar ours aka give it an ID (mostly so we can apply ICONIFY)
        let navbar = $(main).find('ul.navigation.actions');
        $(navbar).prop('id', "wranglerbuttons");

        // hide the buttons which are already there but we'd be adding again
        $(navbar).find('li').filter(function() {
            let link = $(this).find('a');
            if (link.length == 1) return $(link).prop('href').match(/\/(works|bookmarks|edit|comments|troubleshooting)$/);
            else if ($(this).find('span.current').length == 1) return true;
            else return false; // this keeps all other buttons intact
        }).hide();
        $(navbar).append(button_bar);

        // works and bookmarks: reduce the FILTER, FAVORITE and RSS buttons on Works page to its icon, if everything else is icons too
        // on bookmarks page, only the FILTER button applies
        if (stored.iconify && (page_type == "works" || page_type == "bookmarks")) {
            $(main).find('.navigation.actions a.rss span').html("&#xf143;"); // RSS already has an icon, just keep that without text
            $(main).find('#new_favorite_tag input[type="submit"]').val("\u{f004}").attr("title", "Favorite Tag");
            $(main).find('form[id^="edit_favorite_tag"] input[type="submit"]').val("\u{f1f8}").attr("title", "Unfavorite Tag");
            $(main).find('a#go_to_filters').html("&#xf0b0;").attr("title", "Open Filters sidebar");
        }

        // set the click-events for the copy-to-clipboard buttons
        $('#main').on('click', '#wranglerbutton-tagname, #wranglerbutton-taglink', (e) => {
            let str = e.target.id == "wranglerbutton-tagname" ? tag_name : `<a href="${tag_url}">${tag_name}</a>`;
            copy2Clipboard(e, str);
        });

        loadWorksBookmarksCommentsCounts();
    }

    function findTag(p) {
        let name = (p == "landing") ? $('div.tag h2.heading').text() :
                   (p == "search" || p == "new" || p == "inbox") ? "" : $('#main > .heading a.tag').text();
        let link = (p == "landing") ? window.location.origin+window.location.pathname :
                   (p == "search" || p == "new" || p == "inbox") ? "" : $('#main > .heading a.tag').prop('href');

        // monitoring if there are ever multiple tags found. some pages use h2, some h3
        if (!(p == "landing" || p == "search" || p == "new" || p == "inbox") && $('#main > .heading a.tag').length !== 1) {
            console.log($('#main > .heading a.tag'));
            alert($('#main > .heading a.tag').length + ' tag headings found, check console');
        }

        return [name, link];
    }

    function loadWorksBookmarksCommentsCounts() {
        // a variable to store our XMLHttpRequests
        let find = {comments: null, works: null, bookmarks: null};
        const [tag_name, tag_url] = findTag(page_type);

        // find the comment info
        if ($("#wranglerbutton-comments").length > 0) {
            // most pages, except works and bookmarks, have the number and date already on a button
            if (!(page_type == "works" || page_type == "bookmarks")) {
                printComment($('.navigation.actions a[href$="/comments"]:hidden').text());
            }
            // other pages we have to load it in the background - we use the landing page
            else {
                find.comments = $.ajax({ url: tag_url, type: 'GET' })
                    .fail(function(xhr, status) {
                        find.comments = null;
                        cancelAllPageLoads(find, status);
                    }).done(function(response) {
                        find.comments = null;
                        printComment($(response).find('.navigation.actions a[href$="/comments"]').text());
                    });
            }
        }

        // find the works and bookmarks info - only on the edit page of a canonical tag
        if (page_type == "edit" && $('#tag_canonical').prop('checked') == true) {

            const [tag_name, tag_url] = findTag(page_type);

            // bookmarks always need to be loaded, no way to tell from the canonical page
            if ($("#wranglerbutton-bookmarks").length > 0) {
                find.bookmarks = $.ajax({ url: tag_url+buttons.bookmarks.link, type: 'GET' })
                    .fail(function(xhr, status) {
                        find.bookmarks = null;
                        cancelAllPageLoads(find, status);
                    }).done(function(response) {
                        find.bookmarks = null;
                        // .contents().first() grabs only the textNode "1-20 of X Bookmarks in" instead of the whole heading text
                        printBookmarks($(response).find('#main h2.heading').contents().first().text());
                    });
            }

            if ($("#wranglerbutton-works").length > 0) {
                // if the tag has no syns or subs, use the values from the sidebar
                if ($('#child_SubTag_associations_to_remove_checkboxes').length == 0 && $('#child_Merger_associations_to_remove_checkboxes').length == 0) {
                    if ($("#wranglerbutton-works").length > 0) printWorks($('#inner > #dashboard a[href$="/works"]').text(), "link");
                }
                // otherwise load the pages in the background to get the totals
                else {
                    find.works = $.ajax({ url: tag_url+buttons.works.link, type: 'GET' })
                        .fail(function(xhr, status) {
                            find.works = null;
                            cancelAllPageLoads(find, status);
                        }).done(function(response) {
                            find.works = null;
                            // .contents().first() grabs only the textNode "1-20 of X Works in" instead of the whole heading text
                            printWorks($(response).find('#main h2.heading').contents().first().text(), "title");
                        });
                }
            }
        }
    }

    function printComment(button_cmt) {
        $("#wranglerbutton-comments #commentsCount").text(button_cmt.match(/^\d+/)[0]);
        if ($("#wranglerbutton-comments #commentsCount").text() !== "0")
            $("#wranglerbutton-comments").parent().attr('title', "Last comment: "+ button_cmt.match(/last comment: (.*)\)$/)[1] );
    }

    function printWorks(text, source) {
        if (source == "link") text = parseInt(text.match(/\d+/)[0]).toLocaleString('en'); // sidebar has no thousands separator
        else text = text.match(/([0-9,]+) Work/)[1]; // title already has a thousands separator
        $("#wranglerbutton-works #worksCount").text(text);
    }

    function printBookmarks(text) {
        text = text.match(/([0-9,]+) Bookmarked/)[1];  // title already has a thousands separator
        $("#wranglerbutton-bookmarks #bookmarksCount").text(text);
    }

    function cancelAllPageLoads(xhrs, status) {
        if (status == "abort") return; // avoid a loop by this being called due to the xhr being aborted from other failed pageload
        // abort all the potential background pageloads
        if (xhrs.comments !== null)  $(xhrs.comments).abort("Retry Later");
        if (xhrs.works !== null)     $(xhrs.works).abort("Retry Later");
        if (xhrs.bookmarks !== null) $(xhrs.bookmarks).abort("Retry Later");
    }

    /*********** FUNCTIONS FOR BUTTONS ON TAG COMMENTS IN INBOX ***********/

    function buildCommentsLinkBars() {
        addButtonName2Link();

        if (page_type == "inbox") {
            // turn any plaintext URLs in the comment into links
            $('ol.comment.index li.comment .userstuff > *').not('a').each((i, el) => plainURI2Link(el));
            // create the buttons bar for all existing links
            $('ol.comment.index li.comment .heading a, ol.comment.index li.comment .userstuff a').filter('[href*="/tags/"]').each((i, a) => addCommentLinkBar(a));
        }
        if (page_type == "comments") {
            // turn any plaintext URLs in the comment into links
            $('#comments_placeholder li.comment .userstuff > *').not('a').each((i, el) => plainURI2Link(el));
            // create the buttons bar for all existing links
            $('#comments_placeholder li.comment .userstuff a[href*="/tags/"]').each((i, a) => addCommentLinkBar(a));
        }

        // set the click-events for the copy-to-clipboard buttons
        $('#main').on('click', '.wrangleactions-tagname, .wrangleactions-taglink', (e) => {
                // grab the URL from the <a> in front, but cut it off at the tag name so we don't include the /comments/commentID at the end
                let link = $(e.target).parent().prev().prop('href').match(/.*\/tags\/[^\/]*/)[0];
                let name = $(e.target).parent().prev().text();
                let str = $(e.target).hasClass("wrangleactions-tagname") ? name : `<a href="${link}">${name}</a>`;
                copy2Clipboard(e, str);
        });
    }

    function plainURI2Link(el) {
        // first we test for a construct of <a href="URL">URL</a> and turn the text readable
        $(el).find('a[href*="/tags/"]').each((i, a) => {
            if (a.innerText.startsWith("http")) {
                // from the HREF attribute, we grab the tag name into Group 1 (still URI encoded)
                let tagEnc = a.href.match(/\/tags\/([^\/]*)/)[1];
                // we decode that into a readable tag name
                let tagDec = decodeURIComponent(tagEnc).replace('*s*','/').replace('*a*','&').replace('*d*','.').replace('*h*','#').replace('*q*','?');
                // and re-build this into a readable link
                $(a).text(tagDec).prop('href', `https://archiveofourown.org/tags/${tagEnc}`);
            }
        });

        // after linked URLs were fixed, we test for unlinked URLs in text
        // ?<! ... ) is a "negative lookbehind" RegEx and ensures we don't pick up any URIs in a <a> href property
        el.innerHTML = el.innerHTML.replaceAll(/(?<!href=["'])https:\/\/.*?archiveofourown\.org\/tags\/[^<>\s]*/g, function (x) {
            // from the URL, we grab the tag name into Group 1 (still URI encoded)
            let tagEnc = x.match(/https:\/\/.*?archiveofourown\.org\/tags\/([^\/\s]+)/)[1];
            // we decode that into a readable tag name
            let tagDec = decodeURIComponent(tagEnc).replace('*s*','/').replace('*a*','&').replace('*d*','.').replace('*h*','#').replace('*q*','?');
            // and re-build this into a readable link
            return `<a href="https://archiveofourown.org/tags/${tagEnc}">${tagDec}</a>`;
        });
    }

    function addButtonName2Link() {
        // add a button on each comment's actionbar - has to be a <button> since <a> would overwrite the getSelection()
        $('li.comment ul.actions').prepend(`<li><button class="name2link" type="button">Create Tag Link</button></li>`);

        // when button is clicked, find current text highlight and turn it into a link to the tag
        $('#main').on('click', 'button.name2link', (e) => {
            let sel = document.getSelection();

            // selection has to be a range of text to work, but mustn't cross a <br> or <p> for example, and has to be within a comment text
            if (sel.type == "Range" && sel.anchorNode == sel.focusNode && $(sel.anchorNode).parents('li.comment blockquote.userstuff').length > 0) {

                // create the corresponding URL for the highlighted text
                let link = encodeURI("https://archiveofourown.org/tags/" +
                    sel.toString().replace('/', '*s*').replace('&', '*a*').replace('#', '*h*').replace('.', '*d*').replace('?', '*q*'));

                // wrap the highlighted text in an <a>
                let a = document.createElement("a");
                a.href = link;
                sel.getRangeAt(0).surroundContents(a);

                // add the button bar after the inserted <a>
                $(a).after(addCommentLinkBar(a));
                // set the click-events for the copy-to-clipboard buttons
                $('#main').on('click', '.wrangleactions-tagname, .wrangleactions-taglink', (e) => {
                    let tag = $(e.target).parent().prev();
                    let str = $(e.target).hasClass("wrangleactions-tagname") ? $(tag).text() : `<a href="${$(tag).prop('href')}">${$(tag).text()}</a>`;
                    copy2Clipboard(e, str);
                });

                // remove the text highlighting
                sel.removeAllRanges();
            }
            else alert ('Please select some text in a comment first');
        });
    }

    function addCommentLinkBar(a) {

        // cut off the link text at the tag name so we're not linking to /edit pages or /comments/commentID
        // this makes the button to landing pages unnecessary!
        let link = $(a).prop('href').match(/.*\/tags\/[^\/]*/)[0];
        $(a).prop('href', link);

        let actions_bar = ` <span class="wrangleactions">`;
        stored.inbox.forEach((val) => {
            let label = stored.iconify ? buttons[val].icon : buttons[val].text;
            actions_bar += `<a href="${(val == "taglink" || val == "tagname") ? "" : link}${buttons[val].link}"
            title="${buttons[val].tooltip}" class="action wrangleactions-${val}">${label}</a> `;
        });
        actions_bar += "</span>";
        $(a).after(actions_bar);
    }

    /*********** FUNCTIONS FOR BUTTONS IN BIN TABLE ***********/

    function buildBinManageBar() {
        let unwrangled = new URLSearchParams(document.location.search).get('status') == "unwrangled" ? true : false;

        // working our way down the table
        $('#wrangulator table tbody tr').each((i, row) => {
            let link = $(row).find("a[href$='/edit']").prop('href').slice(0,-5);

            // pick up the existing buttons bar
            // we could be replacing it entirly with the new buttons, but that might break the Comment from Bins script if it already added the button
            let actions_bar = $(row).find('td:last-of-type ul.actions').addClass('wrangleactions');

            // create the list of new buttons
            let new_buttons = [];
            stored.bin.forEach((val) => {
                let label = stored.iconify ? buttons[val].icon : buttons[val].text;
                if (val == "remove") {
                    if (!unwrangled) { // if on an unwrangled page, we skip the remove button
                        let remove_btn = $(actions_bar).find('li:first-child').clone().get(0); // clone the original remove button (easier)
                        remove_btn.innerHTML = remove_btn.innerHTML.replace(/Remove/, label); // html replace so the icon will work
                        new_buttons.push(remove_btn.outerHTML);
                    }
                }
                else {
                    new_buttons.push(`<li title="${buttons[val].tooltip}">
                    <a href="${(val == "taglink" || val == "tagname") ? "" : link}${buttons[val].link}" class="wrangleactions-${val}">
                    ${label}</a></li>`);
                }
            });

            // figuring out the position of the Add Comment button (Comment from Bins script)
            let pos_divider = $(actions_bar).children('li[title="Add Comment"]').index(); // is -1 if button doesn't exist
            // if the divider exists, we want to hook it in right after the comments button, so where is that?
            if (pos_divider > -1) {
                let pos_cmt_btn = stored.bin.indexOf('comments'); // is -1 if button doesn't exist
                // if there is a comment button, we might need to shift its index on unwrangled pages where the "remove" button is being skipped
                if (pos_cmt_btn > -1) pos_divider = (unwrangled && stored.bin.indexOf('remove') < pos_cmt_btn) ? pos_cmt_btn-1 : pos_cmt_btn;
                // if there is no comment button, we want to hook it in at its original position (counted from the end)
                else pos_divider = pos_divider - $(actions_bar).children().length;
            }

            // empty out the buttons bar except any existing Add Comment button
            $(actions_bar).children().not('li[title="Add Comment"]').remove();

            // if no Add Comment button exists just dump them all in
            if (pos_divider == -1) $(actions_bar).prepend(new_buttons.join(' '));
            // wrap the new buttons around the existing Add Comment button so that Add Comment follows the Comments link
            else {
                $(actions_bar).prepend(new_buttons.slice(0, pos_divider+1).join(' '));
                $(actions_bar).append(new_buttons.slice(pos_divider+1).join(' '));
            }

            // add a hidden Edit button at the beginning no matter (for other scripts and for the Copy Link option)
            $(actions_bar).prepend(`<li style="display: none;" title="Edit"><a href="${link + buttons.edit.link}">Edit</a></li>`);
        });

        // set the click-events for the copy-to-clipboard buttons
        $('#main').on('click', '.wrangleactions-tagname, .wrangleactions-taglink', (e) => {
            let name = $(e.target).parents('tr').first().find("th label").text();
            let link = $(e.target).parent().parent().find("a[href$='/edit']").first().prop('href').slice(0,-5);
            let str = $(e.target).hasClass("wrangleactions-tagname") ? name : `<a href="${link}">${name}</a>`;
            copy2Clipboard(e, str);
        });
    }

    /*********** FUNCTIONS FOR BUTTONS ON SEARCH RESULTS ***********/

    // this replaces the whole table logic of the old script. in the old script, only the highlighting will remain (which barely anyone ever uses)
    // it does no longer supports sorting on the page itself, since sorting options have been added in the search form itself

    function buildSearchPage() {

        if (stored.table) buildSearchTable();
        else if (stored.pages.includes("search") === true) {
            // working our way down the list
            $('a.tag').each((i, a) => {
                // create buttons bar and put it in front of the line
                let actions_bar = buildSearchManageBars(a);
                $(a).parent().before(actions_bar);
            });
        }

        // set the click-events for the copy-to-clipboard buttons
        $('#main').on('click', '.wrangleactions-tagname, .wrangleactions-taglink', (e) => {
            let name = $(e.target).parents("li, tr").find("a.tag").text();
            let link = $(e.target).parents("li, tr").find("a.tag").prop('href');
            let str = $(e.target).hasClass("wrangleactions-tagname") ? name : `<a href="${link}">${name}</a>`;
            copy2Clipboard(e, str);
        });
    }

    function buildSearchTable() {
        let header = `<thead><tr>`;
        stored.tablecols.forEach((val) => { header += `<th scope="col" class="result${val}">${val}</th>`; });
        header += `<th scope="col" class="resultcheck">Fandom/Synonym</th></tr></thead>`;

        // as always, a list of available icons is here --> https://fontawesome.com/v4/icons
        const typetext = (stored.iconify) ?
              { UnsortedTag: "&#xf128;", Fandom: "&#xf187;", Character: "&#xf007;", Relationship: "&#xf0c0;", Freeform: "&#xf02c;" } :
              { UnsortedTag: "?", Fandom: "F", Character: "Char", Relationship: "Rel", Freeform: "FF" };


        // the search results are in an <ol> under an <h4>
        // the individual result within the li is in another (useless) span, with text before (the type), a link (the tag), and text after (the count)
        let results = $('h4 ~ ol.tag li>span').not('.wrangleactions').toArray().map((result) => {

            let cols = {};                          // where we keep the columns content: "Name", "Type", "Uses", "Manage"
            let tag = $(result).children('a')[0];   // the <a> containing the tag's name and link
            let line = result.innerText.split(' '); // simple string manipulations are faster than complicated RegEx

            // if the script to find illegal chars is running, it might have added a <div class="notice">
            let illegalchar = $(result).nextAll("div.notice").length > 0 ? $(result).nextAll("div.notice")[0].outerHTML : "";

            // if the tag is canonical, the span has a corresponding CSS class
            cols.Name = `<th scope="row" class="resultName ${$(result).hasClass('canonical') ? " canonical" : ""}">${tag.outerHTML} ${illegalchar}</th>`;

            // first word (minus the colon) is the type
            line[0] = line[0].slice(0, -1);
            cols.Type = `<td title="${line[0]}" class="resultType">${typetext[line[0]]}</td>`;

            // last word (minus the parenthesis) is the count
            cols.Uses = `<td class="resultUses">${line[line.length-1].slice(1, -1)}</td>`;

            // create this tag's action buttons bar
            cols.Manage = `<td class="resultManage">${ stored.pages.includes("search") === true ? buildSearchManageBars(tag) : "" }</td>`;

            // join them all in the configured order
            let colsOrdered = `<tr>`;
            stored.tablecols.forEach((val) => { colsOrdered += cols[val]; });
            colsOrdered += `<td class="resultcheck">&nbsp;</td></tr>`;

            return colsOrdered;
        });

        let body = `<tbody>${results.join("\n")}</tbody>`;

        $('h4.landmark.heading:first-of-type').after(`<table id="resulttable">${header}${body}</table>`);

        // hide the type column if we've searched for a specific type (of course they'll all be the same then)
        var searchParams = new URLSearchParams(window.location.search);
        var search_type = searchParams.has('tag_search[type]') ? searchParams.get('tag_search[type]') : "";
        if (search_type != "") {
            $('#resulttable .resultType').hide();
            $('#resulttable thead .resultName').prepend(search_type+": ");
        }

        $('h4 ~ ol.tag').hide(); // hide the original results list
    }

    function buildSearchManageBars(a) {
        let link = $(a).prop('href');

        let actions_bar = ` <ul class="wrangleactions actions" style="float: none; display: inline-block;">`;
        stored.search.forEach((val) => {
            let label = stored.iconify ? buttons[val].icon : buttons[val].text;
            actions_bar += `<li><a href="${(val == "taglink" || val == "tagname") ? "" : link}${buttons[val].link}"
            title="${buttons[val].tooltip}" class="action wrangleactions-${val}">${label}</a> </li>`;
        });
        actions_bar += "</ul>";

        return actions_bar;
    }

})(jQuery);