Greasy Fork is available in English.

AO3: [Wrangling] Bulk-Manage Tags for Clean-Up Projects

Mass-select tags in the bin and set/unset the unwrangleable flag, replace fandoms, or remove 0-use syns

// ==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);