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

目前為 2024-12-21 提交的版本,檢視 最新版本

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==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.4
// @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"]').not("#wranglerbutton-comments").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);