// ==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(""); // 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("").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);
}
}