// ==UserScript==
// @name AO3: [Wrangling] Bulk-Manage Tags for Clean-Up Projects
// @namespace https://greasyfork.org/en/users/906106-escctrl
// @description Mass-select tags in the bin and set/unset the unwrangleable flag, replace fandoms, or remove 0-use syns
// @author escctrl
// @version 4.2
// @match *://*.archiveofourown.org/tags/*/wrangle?*
// @require https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
// @license MIT
// ==/UserScript==
(function($) {
'use strict';
const binParams = new URLSearchParams(window.location.search);
const binStatus = binParams.has('status') ? binParams.get('status') : "";
// bow out gracefully if we're not wrangling in a fandom bin (but on a char/rel/freeform tag)
if ($('div#dashboard.tag.wrangling ul.navigation.actions:last-of-type li').length < 5) return false;
// bow out gracefully if this is a mergers or subtags bin on a fandom, to avoid accidents (userscript @exclude isn't trustworthy enough in Tampermonkey)
if (binParams.get('show') == "mergers" || binParams.get('show') == "sub_tags") return false;
// build the action buttons (above and below the tag table)
var labelManage = binStatus == "unfilterable" ? "Set Unwrangleable" :
binStatus == "unwrangleable" ? "Remove Unwrangleable" :
binStatus == "synonymous" ? "De-syn 0 use tags" :
binStatus == "canonical" ? "Change Case/Diacritic" : "";
const labelYeet = "Replace Fandoms";
if (labelManage != "") { // only create the Manage button on UF/UW-able/Syn
const buttonManage = `<input id="massManage" name="massManage" type="submit" style="padding-left: 0.75em;" value="${labelManage}"> `;
$('#wrangulator p.submit.actions').prepend(buttonManage);
}
if (binStatus != "unwrangled") { // Yeeting is supported on all pages except UW (because it's pointless there)
const buttonYeet = `<input id="massYeet" name="massYeet" type="submit" style="padding-left: 0.75em;" value="${labelYeet}"> `;
$('#wrangulator p.submit.actions').prepend(buttonYeet);
}
// helper vars need to be available globally for my sanity
var errorMsg = [];
var completeTags = 0;
var selectedTags = [];
var selectedFandoms = [];
var bgAction = "";
var oldText = "";
var newText = "";
// add the event listener to the buttons with function to load the Edit pages
$('input#massManage,input#massYeet').click(function(e) {
// stop button from automatically submitting the form it's in
e.preventDefault();
// reset counters between button clicks
completeTags = 0;
errorMsg = [];
// which button has been clicked? that decides what we're doing in the background
bgAction = $(this).attr("id");
// disable both buttons so they can't be clicked again while a background process is running
$('input#massManage,input#massYeet').attr("disabled",true);
$('input#'+bgAction).attr("value", 'running...');
// grab the selected tags
selectedTags = array($('input[name="selected_tags[]"]')).filter(ckbx => ckbx.checked);
selectedFandoms = array($('#fandom_string').prev().find('li.added.tag'));
// check that we have the necessary info to start working
if (selectedTags.length < 1) { alert("Please select at least one tag!"); return false; }
if (bgAction == "massYeet" && selectedFandoms.length < 1) { alert("Please select at least one fandom to yeet to!"); return false; }
// if we're replacing parts in the tag name, asking for the text that needs to be replaced
if (binStatus == "canonical" && bgAction == "massManage") {
oldText = prompt("Enter the old/incorrectly formatted text:");
newText = prompt("Remember: only cases and diacritics can be changed. Enter the properly formatted text:");
}
// now let's run through all the tags and update them
$(selectedTags).each(function(i, tag) {
// quick check before we create background page loads: removing synonyms should only work for 0-use tags
if (binStatus == "synonymous" && bgAction == "massManage") {
const tagUses = $(tag).closest('tr').find('td[title="taggings"]')[0].innerText;
if (parseInt(tagUses) > 0) {
errorMsg[i] = "Tag is not zero uses, skipped de-synning";
bgEditComplete();
return;
}
}
// grab the URL for the Edit Page that we'll load in an iframe
var url = $(tag).closest('tr').find('a[href$="/edit"]')[0]; // nifty jQuery, will work with icon action buttons and without
url = url.href; // jQuery would return the relative URL, but we need the absolute
// create the iframe, hide it, add it to the DOM, and attach an event listener to make the desired changes
// we're routing through: the iFrame we're operating in, and the index of the tag that's being changed
const tagFrame = document.createElement("iframe");
$(tagFrame).hide().appendTo('body').one('load', function() { bgEditLoad(tagFrame, i); }); // .one() runs only once and removes itself
tagFrame.src = url; // at last, we let the edit page load
});
});
// event listener function to do the actual Edit page changes in the iframe
function bgEditLoad(tagFrame, iTag) {
const frameContent = $(tagFrame).contents();
// need to pick up on ao3jail. since the load event always triggers on iFrames, and HTTP responses are impossible to get a hold of, we'll just look at the page content...
if ($(frameContent).find('#edit_tag').length != 1) {
errorMsg[iTag] = "Page could not load. Retry later";
bgEditComplete(tagFrame);
return;
}
// if we're mass-managing Syns or Unwrangleable flags
if (bgAction == "massManage") {
switch (binStatus) {
case "synonymous": {
// remove the synonymous tag
let fieldSyn = $(frameContent).find('#tag_syn_string').prev().find("li.added span.delete a")[0];
fieldSyn.click();
break;
}
case "unwrangleable":
case "unfilterable": {
// switch the state of the unwranglable checkbox in the iframe
let fieldUnwrangleable = $(frameContent).find('#tag_unwrangleable')[0];
fieldUnwrangleable.checked = fieldUnwrangleable.checked == true ? false : true;
break;
}
case "canonical": {
// get the tag name
let fieldTagname = $(frameContent).find("#tag_name")[0];
// replace the old text part (regex-excaped and case insensitive!) with the new text part
oldText = oldText.replace(/[/.*+?^${}()|[\]\\]/g, '\\$&')
fieldTagname.value = fieldTagname.value.replace(new RegExp(oldText, "ig"), newText);
break;
}
default:
break;
}
}
// if we're resetting fandoms to move tags
else {
// if we check the checkbox & add the same fandom in the input field, it still gets removed
// so we have to be smarter and check only boxes on fandoms we don't want anymore
// first we create an array that only includes the the names of the wanted fandoms
const workingFandoms = [];
$(selectedFandoms).each((i, sel) => {
workingFandoms.push(sel.childNodes[0].textContent.trim());
});
$(frameContent).find('#parent_Fandom_associations_to_remove_checkboxes a.tag').each((j, fandom) => {
// first we find out if the existing fandom needs to be kept
var keep = workingFandoms.indexOf(fandom.innerText.trim());
// then we're removing (ticking) anything that's on the tag but not wanted
if (keep < 0) {
var ckbx = $(fandom).prev().find('input')[0];
ckbx.checked = true;
}
// and kicking out any fandoms we want to keep, that are already set on the tag
else workingFandoms.splice(keep, 1);
});
// finally we're adding all fandoms that are left to be added
const fieldFandom = $(frameContent).find('input#tag_fandom_string_autocomplete')[0];
fieldFandom.focus();
const ke = new KeyboardEvent('keydown', { keyCode: 13, key: "Enter" });
$(workingFandoms).each((j, fandom) => {
fieldFandom.value = fandom;
fieldFandom.dispatchEvent(ke);
});
}
// add another event listener to retrieve the fail/success message, and submit the iframe
$(tagFrame).one('load', function() { bgEditSubmit(tagFrame, iTag); });
$(tagFrame).contents().find('#edit_tag')[0].submit();
}
// event listener function to catch errors occurring in the iframe after submitting the form
function bgEditSubmit(tagFrame, iTag) {
// need to pick up on ao3jail. since the load event always triggers on iFrames, and HTTP responses are impossible to get a hold of, we'll just look at the page content...
if ($(tagFrame).contents().find('#edit_tag').length != 1) {
errorMsg[iTag] = "Page could not load. Retry later";
}
// tracking any errors we've run into
else {
const frameContent = $(tagFrame).contents();
const err = $(frameContent).find('#error');
if (err.length > 0) { errorMsg[iTag] = err[0].innerHTML; }
else if (binStatus == "synonymous" && bgAction == "massManage") {
// also track if de-syn didn't result in tag being ready to rake (tagset tag)
// retrieve the numbers showing in the sidebar. we only need the last one (total taggings)
// we grab the text, match only the number in it, convert it from string to int
const taggings = parseInt($(frameContent).find('#dashboard.tag.wrangling ul:nth-of-type(2) li:last-of-type span').text().match(/\d+/g));
if (taggings == 0 && $(frameContent).find('select#tag_type').length === 0) {
errorMsg[iTag] = "Tagset tag, will not rake. Consider synning again.";
}
}
}
bgEditComplete(tagFrame);
}
function bgEditComplete(tagFrame = false) {
if (tagFrame !== false) document.body.removeChild(tagFrame);
// if this was the last tag, we can write out errors on the bin's page
// note: there are no iframes open anymore
completeTags++;
if (completeTags == selectedTags.length) {
printResults();
}
}
// write out all errors that occurred and all tags that were changed successfully
function printResults() {
const successMsg = [];
const failMsg = [];
$(selectedTags).each(function(i, tag) {
var tagRow = $(tag).closest('tr');
var tagURL = $(tagRow).find('a[href$="/edit"]')[0];
tagURL = tagURL.href;
var tagName = $(tagRow).find('th label')[0];
tagName = tagName.innerText;
// errorMsg[] has the corresponding indices so we know which tag failed
if (errorMsg[i] != undefined) { failMsg.push('<a href="' + tagURL + '">' + tagName + '</a>: ' + errorMsg[i]); }
else {
successMsg.push('<a href="' + tagURL + '">' + tagName + '</a>');
// remove the changed tags from the bin page
if (binStatus == "unwrangleable" || binStatus == "unfilterable" || binStatus == "synonymous") $(tagRow).remove();
}
});
// constructing the response message to the user and showing it
const title = bgAction == "massYeet" ? "Fandom" :
binStatus == "synonymous" ? "Synonym of" :
binStatus == "canonical" ? "Case/Diacritics" : "Unwrangleable flag";
if (failMsg.length > 0) {
const fail = '<div id="error" class="error">The following tags\' '+ title +' could not be updated or ran into other issues:<br />' + failMsg.join('<br />') + '</div>';
$(fail).prependTo('#main');
window.scrollTo(0,0);
}
if (successMsg.length > 0) {
const success = '<div class="flash notice">The following tags\' '+ title +' was updated successfully:<br />' + successMsg.join(', ') + '</div>';
$(success).prependTo('#main');
window.scrollTo(0,0);
}
// reset both buttons so they can be used again
$('input#massManage').attr("disabled",false).attr("value", labelManage);
$('input#massYeet').attr("disabled",false).attr("value", labelYeet);
}
// the return value of document.querySelectorAll is technically a "NodeList", which can be indexed like an array, but
// doesn't have helpful functions like .map() or .forEach(). So this is a simple helper function to turn a NodeList
// (or any other array-like object (indexed by integers starting at zero)) into an array
const array = a => Array.prototype.slice.call(a, 0);
})(jQuery);