// ==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(""); // 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("").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: "", Fandom: "", Character: "", Relationship: "", Freeform: "" } :
{ 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"> </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);