Greasy Fork is available in English.

AO3: [Wrangling] All Action Buttons on all pages

Adds all action buttons at the top of all tag wrangling pages

// ==UserScript==
// @name         AO3: [Wrangling] All Action Buttons on all pages
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @description  Adds all action buttons at the top of all tag wrangling pages
// @author       escctrl
// @version      3.6
// @match        *://*.archiveofourown.org/tags/*
// @match        *://*.archiveofourown.org/comments*
// @grant        none
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.6.4/jquery.min.js
// @license      MIT
// ==/UserScript==

// CONFIGURATION
    // whether to use icons or text for all the action buttons (true = icons, false = text)
    const ICONIFY = true;

    // the following types of action buttons are available:
    // "landing", "edit", "wrangle", "comments", "works", "bookmarks", "troubleshoot", "search", "new", "tagname", "taglink"
    // add or remove them from the list to control which action buttons are shown
    // reorder within the list to change the order in which they appear on the pages
    const button_list = ["search", "new", "tagname", "taglink", "landing", "edit", "wrangle", "comments", "works", "bookmarks"];

(function($) {
    'use strict';

    // ********* what page are we on?? *************

    var page_url = window.location.pathname;
    // just in case the URL ended with a / we'll need to get rid of that
    // that usually doesn't happen from AO3 links on the site, but may be how browsers store bookmarks or history
    if (page_url.endsWith("/")) { page_url = page_url.slice(0, page_url.length-1); }
    // split the URL apart.  [0] is always an empty string since the URL starts with a /
    // we have to check URL length for this, since we could be looking at a tag named "bookmarks" (and shouldn't confuse this with being on a tag's bookmarks page)
    var page_type = page_url.split("/");

    // in the instances of landing pages, search and new, the array length is 3
    // also fixing page_type for search and new. although those technically exist as tags as well, going to their landing page doesn't work XD and there's a low chance you want to wrangle them
    if (page_type.length == 3) {
        page_type = (page_type[2] == "search" || page_type[2] == "new") ? page_type[2] : "landing";
    }
    // fixing recognizing comment pages after a comment was submitted ()
    else if (page_type.length == 2 && page_type[1] == "comments") {
        page_type = page_type[1];
    }
    // in all other cases, array length is 4
    else {
        page_type = page_type[3];
    }

    // ********* track down the pure tag link *************

    // let's use the header, it's convenient. unless we're on a landing page which doesn't have that link (because it's that same page already)
    // for search and new, we just need to set a path to avoid later code errors
    const tag_url = (page_type == "landing" || page_type == "search" || page_type == "new") ? page_url : $('h2.heading a.tag').attr('href');

    // ********* are we on a canonical tag's Edit page? *************

    // total works and bookmarks counters will only display on Edit pages of canonical tags
    const loadWBCount = (page_type == "edit" && document.getElementById('tag_canonical').checked == true) ? true : false;

    // ********* create the wrangler navigation buttons *************

    // 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 tag_pages = {
        search:       { link: "/tags/search",
                        icon: "",
                        text: "Search",
                        tooltip: "Search tags" },
        new:          { link: "/tags/new",
                        icon: "",
                        text: "New Tag",
                        tooltip: "Create new tag" },
        landing:      { link: tag_url,
                        icon: "",
                        text: "Landing Page",
                        tooltip: "Tag Landing Page" },
        edit:         { link: tag_url + "/edit",
                        icon: "",
                        text: "Edit",
                        tooltip: "Edit tag & associations" },
        wrangle:      { link: tag_url + "/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" },
        comments:     { link: tag_url + "/comments",
                        icon: "",
                        text: "Comments",
                        tooltip: "Comments" }, // this tooltip is overwritten by "Last comment: DATE" when there's a comment
        works:        { link: tag_url + "/works",
                        icon: "",
                        text: "Works",
                        tooltip: "Works" },
        bookmarks:    { link: tag_url + "/bookmarks",
                        icon: "",
                        text: "Bookmarks",
                        tooltip: "Bookmarks" },
        troubleshoot: { link: tag_url + "/troubleshooting",
                        icon: "",
                        text: "Troubleshooting",
                        tooltip: "Troubleshooting" },
        taglink:      { link: tag_url.startsWith('https://') ? tag_url : 'https://www.archiveofourown.org'+tag_url ,
                        icon: "",
                        text: "Copy Link",
                        tooltip: "Copy tag link to clipboard (for Slack or comments)" },
        tagname:      { link: "" ,
                        icon: "",
                        text: "Copy Name",
                        tooltip: "Copy tag name to clipboard" }
    };

    // code from adustyspectacle's script (https://greasyfork.org/scripts/30563)
    // This section here is to load FontAwesome so the icons will properly render
    if (ICONIFY) {
        var font_awesome_icons = document.createElement('script');
        font_awesome_icons.setAttribute('src', 'https://use.fontawesome.com/ed555db3cc.js');
        document.getElementsByTagName('head')[0].appendChild(font_awesome_icons);

        var fa_icons_css = document.createElement('style');
        fa_icons_css.setAttribute('type', 'text/css');
        fa_icons_css.innerHTML = '#wranglerbuttons, a.rss, #new_favorite_tag input[type=submit], form[id^="edit_favorite_tag"] input[type=submit], a#go_to_filters { font-family: FontAwesome, sans-serif; } a.rss span { background: none; padding-left: 0; }';
        document.getElementsByTagName('head')[0].appendChild(fa_icons_css);
    }

    // let's make our own list of buttons. we're basically building the whole thing in variables before we display it
    const node_ul = document.createElement('ul');
          node_ul.className = "navigation actions";
          node_ul.id = "wranglerbuttons";
          node_ul.role = "navigation";
          node_ul.style = "padding: .429em .25em .25em 0;"; // no clue why (oh the beauty of the box model), but this fixes short headings floating off to the right

    button_list.forEach( function(val, i, arr) {
        // on search and new pages, we don't need any button except search and new buttons
        if (page_type == "search" || page_type == "new") {
            if (val != "search" && val != "new") { return true; }
        }
        // on any other page, print everything except the button for the type of page we're currently on
        else { if (page_type == val) { return true; } }

        // pick the button label to display: icon or text
        var button_text = (ICONIFY) ? tag_pages[val].icon : tag_pages[val].text;

        // create the list items with a title attribute as tooltips
        var node_li = document.createElement('li');
            node_li.title = tag_pages[val].tooltip;
            // a little ID so if somebody wants to style them through CSS, they can do so more easily
            node_li.id = val;
            // the AO3-original troubleshoot li is styled slightly different through this class, so we copy this behavior
            if (val == "troubleshoot") { node_li.className = "reindex"; }

        // button for copying the tag name/link is a very special case :)
        if (val == "taglink" || val == "tagname") {
            const tag_name = (page_type == "landing") ? $('div#main.tags-show h2.heading').text() : $('div#main h2.heading a').text();
            node_li.innerHTML = (val == "taglink") ? `<a href="#">${button_text}</a><span style="display: none;"><a href="${tag_pages[val].link}">${tag_name}</a></span>`
                                                   : `<a href="#">${button_text}</a><span style="display: none;">${tag_name}</span>`;

            node_li.addEventListener("click", function(e) {
                const val = e.target.parentNode.id;
                var str = document.getElementById(val).querySelector('span');
                str = (val == "taglink") ? str.innerHTML : str.innerText;
                copy2Clipboard(e, str);
            });
        }
        else {
            // create the links (which will look like buttons)
            var node_a = document.createElement('a');
                node_a.href = tag_pages[val].link;
                // add the <span> on the buttons we need it, which we can fill with the counters as soon as the number is loaded
                switch (val) {
                    case "comments":
                        node_a.innerHTML = '<span id="'+val+'Count"></span> ' + button_text;
                        break;
                    case "works":
                    case "bookmarks":
                        node_a.innerHTML = (loadWBCount == true ? '<span id="'+val+'Count"></span> ' : '') + button_text;
                        break;
                    default:
                        node_a.innerHTML = button_text;
                        break;
                }

            // hook the new HTML nodes into their new list structure
            node_li.appendChild(node_a);
        }
        node_ul.appendChild(node_li);
        node_ul.appendChild(document.createTextNode(" "));
    });

    /* now we have all the necessary <ul> built up, but here comes trouble
     *
     * usually we can get rid of that entire old block of original buttons in the <ul class="navigation actions">
     * ...except Comments doesn't have any navigation action buttons to begin with
     * ...except the Edit Tag page has a <p> instead of an <ul>
     * ...except the works and bookmarks pages have FILTER buttons with events on narrow screens. tried cloning the nodes, but it refused to clone the events we need
     * ...except the works page also has a FAVORITE TAG and an RSS button that I want to keep
     *
     * and most pages have an <h2> and right below it the buttons
     * ...except the works page is more nested than others, and has its action buttons inside another <div> (with the same freaking classes!) after an <h3>
     */

    // insert the new list on the page
    const prevheader = (page_type == "works") ? $('div#main div.navigation.actions.module h3.heading') : $('div#main h2.heading');
    $(node_ul).insertAfter(prevheader);
    if (page_type == "works") $('div#main div.navigation.actions.module').css("float", "right").css("width", "unset");

    // works and bookmarks: hide the first couple of buttons (they're already in our new list)
    // the remaining buttons will display on a new line on narrow screens, although on wide screens (when there's no FILTER button) they move up in line nicely ¯\_ (ツ)_/¯
    if (page_type == "works" || page_type == "bookmarks") {
        $('div#main ul.navigation.actions:not(#wranglerbuttons) li').slice(0,2).hide();

        // 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 (ICONIFY) {
            $('div#main .navigation.actions a.rss span').html("&#xf143;"); // RSS already has an icon, just keep that without text
            $('#new_favorite_tag input[type="submit"]').val("\u{f004}").attr("title", "Favorite Tag"); // also add a title as a tooltip
            $('form[id^="edit_favorite_tag"] input[type="submit"]').val("\u{f1f8}").attr("title", "Unfavorite Tag"); // also add a title as a tooltip
            $('a#go_to_filters').html("&#xf0b0;").attr("title", "Open Filters sidebar");
        }
    }
    // then hide all the original buttons (except on Comments page which has none)
    // not specifying <p> or <ul> allows this to work on the Edit page as well
    else if (page_type != "comments") {
        $('div#main .navigation.actions:not(#wranglerbuttons)').hide();
    }

    /* now that the whole button setup is complete, we can start worrying about filling in the counters
     *
     * here's how that goes:
     * (1) we've set up the buttons so that the ones which show counts have empty <span>s that we can easily put text into
     * (2) the number we need appears somewhere on other pages, so we load that page through AJAX
     *     that happens asynchronously, meaning the rest of the script completes in the meantime
     * (3) as soon as the background pageload completes, we jump back into the .done() function
     *     at that point, we (a) retrieve the text we need from the background page and (b) call a function
     * (4) the function (a) finds that number we're looking for and (b) puts it into the <span> we prepared
     */


    // set up the counters
    const tag_counts = {
        comments:  0,
        works:     0,
        bookmarks: 0,
        lastComment: ""
    };

    // resetting from any previous page loads so the fun can start
    localStorage.setItem("ao3jail", "false");

    // COMMENTS
    // skip this if we're already on the comments page or if there's no comment buttons to be displayed anyways
    if (page_type != "comments" && button_list.indexOf("comments") >= 0) {

        // most wrangling pages actually have a Comments button at the top that says how many comments there are
        // this is heavy-handed, but works and bookmarks pages are structured differently and made it hard not to recognize our own Comments button
        var commentsButton = (page_type == "works" || page_type == "bookmarks") ? "" : $('.navigation.actions:not(#wranglerbuttons) a[href$="/comments"]').text();

        // if there was no button we could read a comment counter from (and we're not in jail)
        if (commentsButton.length <= 0 && localStorage.getItem("ao3jail") == "false") {

            // try retrieving it from the landing page instead
            $.get(tag_url, function(response) {

            // if loading the page worked (still not in jail), we can pull the comment counter from here
            }).done(function(response) {
                commentsButton = $(response).find('.navigation.actions a[href$="/comments"]').text();
                printCommentCount(commentsButton);

            // if that sent us to jail, set the ao3jail marker
            }).fail(function(data, textStatus, xhr) {
                localStorage.setItem("ao3jail", "true"); // store it so next AJAX can skip
                //This shows status code eg. 429
                console.log("All Wrangling Buttons has hit Retry later", data.status);
                printCommentCount("");
                return false;
            });
        }
        // if we had a button on our page, this function call runs immediately (which is why this code has to be AFTER we displayed the buttons)
        else { printCommentCount(commentsButton); }
    }

    // WORKS
    // skip this if we're already on the works page or if there's no works button to be displayed anyways
    if (page_type != "works" && button_list.indexOf("works") >= 0 && localStorage.getItem("ao3jail") == "false" && loadWBCount == true) {
        var worksTitle = "";

        // let's grab the number of works from the works page
        $.get(tag_pages.works.link, function(response) {
        }).done(function(response) {
            // .contents().first() grabs only the textNode (1-20 of X Works in) instead of the whole heading text
            // that way we don't care what numbers might be part of the tag, it's not going to confuse the code
            worksTitle = $(response).find('#main h2.heading').contents().first().text();
            printWBCount(worksTitle, "works");

        // if that sent us to jail, set the ao3jail marker
        }).fail(function(data, textStatus, xhr) {
            localStorage.setItem("ao3jail", "true"); // store it so next AJAX can skip
            //This shows status code eg. 429
            console.log("All Wrangling Buttons has hit Retry later", data.status);
            printWBCount("", "works");
            return false;
        });
    }

    // BOOKMARKS
    // skip this if we're already on the bookmarks page or if there's no bookmarks button to be displayed anyways
    if (page_type != "bookmarks" && button_list.indexOf("bookmarks") >= 0 && localStorage.getItem("ao3jail") == "false" && loadWBCount == true) {
        var bookmarksTitle = "";

        // let's grab the number of works from the works page
        $.get(tag_pages.bookmarks.link, function(response) {
        }).done(function(response) {
            // .contents().first() grabs only the textNode (1-20 of X Bookmarks in) instead of the whole heading text
            // that way we don't care what numbers might be part of the tag, it's not going to confuse the code
            bookmarksTitle = $(response).find('#main h2.heading').contents().first().text();
            printWBCount(bookmarksTitle, "bookmarks");

        // if that sent us to jail, set the ao3jail marker
        }).fail(function(data, textStatus, xhr) {
            localStorage.setItem("ao3jail", "true"); // store it so next AJAX can skip
            //This shows status code eg. 429
            console.log("All Wrangling Buttons has hit Retry later", data.status);
            printWBCount("", "bookmarks");
            return false;
        });
    }

    // helper function that receives the button text and parses out the number of comments + date of last comment
    function printCommentCount(buttonText) {
        const comments_match = buttonText.match(/^\d+/); // text starts with the number of comments
        // if a match if found, parse it out as an int and write it to the prepared span
        if (comments_match != null) {
            tag_counts.comments = parseInt(comments_match[0]);
            $('#wranglerbuttons #commentsCount').text(tag_counts.comments);
        }
        // if there's no match, we might have hit ao3jail and leave it as a "? Comments"
        else { $('#wranglerbuttons #commentsCount').text("?"); }

        // if there actually are comments, parse out the date of the last comment to put into the tooltip
        if (tag_counts.comments > 0) {
            tag_counts.lastComment = commentsButton.match(/\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\sUTC/)[0];
            $('#wranglerbuttons li#comments').attr("title", "Last comment: " + tag_counts.lastComment);
        }
    }

    // helper function, requires the text in the heading and the type of page ("works" or "bookmarks")
    function printWBCount(titleText, page) {
        const WB_match = titleText.match(/[0-9,]+/g); // if "1-20 of X Works", [0] match is "1" and [1] is "20" from the "1-20" part, and [2] is the total number
                                                      // if "X Works" (below 20), [0] is the total number

        // if a match if found, parse it out as an int and write it to the prepared span
        if (WB_match != null) {
            tag_counts[page] = WB_match[WB_match.length-1]; // last entry in the array is the total number
            $('#wranglerbuttons #'+page+'Count').text(tag_counts[page]);
            $('#wranglerbuttons li#'+page).attr("title", tag_pages[page].tooltip + " (incl Synonyms and Subtags)");
        }
        // if there's no match, we might have hit ao3jail and leave it as a "? Works" and "? Bookmarks"
        else { $('#wranglerbuttons #'+page+'Count').text("?"); }
    }


})(jQuery);

// solution for setting richtext clipboard content found at https://jsfiddle.net/jdhenckel/km7prgv4/3/
// and https://stackoverflow.com/questions/34191780/javascript-copy-string-to-clipboard-as-text-html/74216984#74216984
function copy2Clipboard(e, str) {
    // trying first with the new Clipboard API
    try {
        const clipboardItem = new ClipboardItem({'text/html': new Blob([str], {type: 'text/html'}),
                                                 'text/plain': new Blob([str], {type: 'text/plain'})});
        navigator.clipboard.write([clipboardItem]);
    }
    // fallback method in case clipboard.write is not enabled - especially in Firefox it's disabled by default
    // to enable, go to about:config and turn dom.events.asyncClipboard.clipboardItem to true
    catch {
        console.log('Copy Tag to Clipboard: Clipboard API is not enabled in your browser - fallback option used');
        function listener(e) {
            e.clipboardData.setData("text/html", str);
            e.clipboardData.setData("text/plain", str);
            e.preventDefault();
        }
        document.addEventListener("copy", listener);
        document.execCommand("copy");
        document.removeEventListener("copy", listener);
    }
}