Greasy Fork is available in English.

AO3: [Wrangling] Bulk-Manage Tags

Mass-select tags and set/unset the unwrangleable flag, replace fandoms, remove 0-use syns, or copy tag names/links

// ==UserScript==
// @name         AO3: [Wrangling] Bulk-Manage Tags
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @description  Mass-select tags and set/unset the unwrangleable flag, replace fandoms, remove 0-use syns, or copy tag names/links
// @author       escctrl
// @version      5.3.1
// @match        *://*.archiveofourown.org/tags/*/wrangle?*
// @match        *://*.archiveofourown.org/tags/search?*
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
// @require      https://update.greasyfork.org/scripts/491896/1516188/Copy%20Text%20and%20HTML%20to%20Clipboard.js
// @license      MIT
// ==/UserScript==

/* eslint-disable no-multi-spaces */
/* global jQuery, copy2Clipboard */


(function($) {
    'use strict';

    // loading indicator which shows after a button was pressed
    $('head').append(`<style tyle="text/css">
    .massManage .spin, .massYeet .spin { display: none; margin-left: 0.5em; }
    .massManage .spin::after, .massYeet .spin::after {
        content: "\\2312";
        display: inline-block;
        animation: loading 3s linear infinite;
    }
    @keyframes loading {
      0% { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }</style>`);

    // stuff that needs to be globally available for my sanity
    var pt = "", binStatus = "", pressed = "";
    var tagList, tagCounter = 0, errorMsg = [], successMsg = [];
    var oldText, newText;

    // kick off the script only on pages that it should really be running on
    const main = $('#main');

    if ($(main).hasClass('tags-wrangle')) {                                                    // on bins only...
        pt = 'bin';
        let binParams = new URLSearchParams(window.location.search);
        binStatus = binParams.get('status') || "";

        if ($(main).find('#wrangulator').length == 1 &&                                        // if there are tags in the bin
            $('#dashboard').find('ul.navigation.actions:last-of-type li').length == 5 &&       // if this is a fandom bin
            !(binParams.get('show') == "mergers" || binParams.get('show') == "sub_tags")) {    // if this is NOT a mergers/subtags bin

            addMassCopyButton();
            addMassManageButtons();
            addMassYeetButton();
        }
    }
    else if ($(main).hasClass('tags-search')) {
        pt = 'search';
        let results = $(main).find('#resulttable, ol.tag.index.group');

        if ($(results).find('a.tag').length > 0) {
            $('h3.heading').after(`<p class="submit actions"></p>`);
            addMassCopyButton();
            addMassManageButtons();
            addMassYeetButton();

            // add checkboxes in front of tag results - add either way in table or in regular list
            for (let a of $(main).find('a.tag')) {
                $(a).before(`<input type='checkbox' name='selected_tags[]'>`);
            }

            // in case this runs before search results table script, gotta wait for the table to appear
            const observer = new MutationObserver(function(mutList, obs) {

                for (const mut of mutList) {
                    for (const node of mut.addedNodes) {
                        if (node.id === "resulttable") {
                            obs.disconnect(); // stop listening to avoid looping with the following changes, plus we're done

                            $(main).find("#resulttable thead th.resultName").prepend(`<input type='checkbox' id='select_all'>`);
                            $(main).find("#resulttable #select_all").on('change', (e) => {
                                if (e.target.checked === true) $('input[name="selected_tags[]"]').prop('checked', true);
                                else $('input[name="selected_tags[]"]').prop('checked', false);
                            });

                            // re-add checkboxes: table rewrite would've lost previously added checkboxes
                            for (let a of $(main).find('a.tag')) {
                                $(a).before(`<input type='checkbox' name='selected_tags[]'>`);
                            }

                            // lazy-click
                            $("#resulttable").on('click', 'tbody th.resultName', (e) => {
                                let chkbx = $(e.target).find('input[name="selected_tags[]"]');
                                $(chkbx).prop('checked', !$(chkbx).prop('checked'));
                            });
                        }
                    }
                }
            });

            function startObserving() {
                $(main).each((i, elem) => {
                    observer.observe(elem, { attributes: false, childList: true, subtree: false });
                });
            }
            startObserving();

            // failsafe: stop listening after 1 seconds (in case the other script isn't running)
            let timeout = setTimeout(() => {
                observer.disconnect();
            }, 1 * 1000);
        }
    }

    // on top of the bin/search, add the buttons for managing the tags
    // everything else is triggered from here
    function addMassManageButtons() {
        let tagList = (pt == 'bin')    ? $(main).find('#wrangulator p.submit.actions') :
                      (pt == 'search') ? $(main).find('#resulttable, ol.tag.index.group').prevAll('p.submit.actions') : "";

        let searchParams = new URLSearchParams(document.location.search);

        // the type of massManage button shown in a bin depends on the tags' wrangling status
        // no Manage buttons on UW and All, no Manage buttons in Search unless searching specifically for Canonicals within a Fandom
        let buttonLabel = binStatus == "unfilterable" ? "Set Unwrangleable" :
                          binStatus == "unwrangleable" ? "Remove Unwrangleable" :
                          binStatus == "synonymous" ? "De-syn 0 use tags" :
                          binStatus == "canonical" ? "Change Case/Diacritic" :
                          (pt == "search" && searchParams.get("tag_search[canonical]") == "T" && searchParams.get("tag_search[fandoms]") !== "") ? "Change Case/Diacritic" : "";

        if (buttonLabel !== "") {
            $(tagList).prepend(`<button class="massManage" type="button" style="padding-left: 0.75em;">${buttonLabel}<span class="spin"/></button> `);
            $(main).find('.massManage').on('click', tryMassManage);
        }
    }
    function addMassYeetButton() {
        let tagList = (pt == 'bin' && binStatus != 'unwrangled' && binStatus !== '') ? $(main).find('#wrangulator p.submit.actions') : // no Yeet button on UW and All
                      (pt == 'search') ? $(main).find('#resulttable, ol.tag.index.group').prevAll('p.submit.actions') : "";

        if (tagList !== "" && $(tagList).length > 0) {
            $(tagList).prepend(`<button class="massYeet" type="button" style="padding-left: 0.75em;">Replace Fandoms<span class="spin"/></button> `);
            $(main).find('.massYeet').on('click', tryMassYeet);
        }
    }
    function addMassCopyButton() {
        let tagList = (pt == 'bin')    ? $(main).find('#wrangulator p.submit.actions') :
                      (pt == 'search') ? $(main).find('#resulttable, ol.tag.index.group').prevAll('p.submit.actions') : "";
        if (tagList !== "" && $(tagList).length > 0) {
            $(tagList).prepend(`<button class="massCopy" type="button" style="padding-left: 0.75em; border-radius: 0.25em 0 0 0.25em;">Copy</button><select
              class="action massCopy-format" style="min-width: 5.5em; box-sizing: content-box; border-radius: 0 0.25em 0.25em 0;">
              <option value="text">as Text</option>
              <option value="link">as Links</option>
              <option value="chat">for Chat</option>
            </select> `);
            
            // load the last selected option
            let lastfmt = sessionStorage.getItem('binCleanUp-Copy') || "link";
            $(main).find('.massCopy-format').prop('value', lastfmt);
            
            $(main).find('.massCopy').on('click', tryMassCopy);
            $(main).find('.massCopy-format').on('change', function(e) { // if one text/link select is changed
                $(main).find('.massCopy-format').prop('value', $(e.target).prop('value')); // change both above&below taglist to this value
                sessionStorage.setItem('binCleanUp-Copy', $(e.target).prop('value')); // and remember the new value for the next pageload
            });
        }
    }

    // button event listener: a dialog to enter the requested changes
    function tryMassManage(e) {
        e.preventDefault();
        e.stopPropagation();

        disableButtons();
        $(e.target).addClass("current").find('.spin').css('display', 'inline-block'); // loading indicator
        pressed = "manage";

        // grab the selected tags and disable the buttons
        tagList = getSelectedTags(e);

        /* background task depends on the binStatus -> the action we support there
           unfilterable  -> set unwrangleable
           unwrangleable -> remove unwrangleable
           synonymous    -> de-syn 0 use tags
                            here we check if any tag is really 0 uses
           canonical     -> change letter case/diacritic on tag name
                            for this we need input from the user: old/new tag text */
        if (binStatus == "canonical" || pt == "search") {
            // if we're replacing parts in the tag name, asking for the text that needs to be replaced
            // we're storing that info in session, so it is available between page loads across the bin --> this could be useful on tag search too!!
            oldText = sessionStorage.getItem('binCleanUp-TextReplace-old') || "";
            newText = sessionStorage.getItem('binCleanUp-TextReplace-new') || "";

            oldText = prompt("Enter the old/incorrectly formatted text:", oldText);
            if (!oldText) {
                alert("Please try again and enter the tag text that should be replaced!");
                resetButtons();
                return false;
            }
            newText = prompt(`You're replacing "${oldText}". Remember: only cases and diacritics can be changed. Enter the properly formatted text:`, newText);
            if (!newText) {
                alert("Please try again and enter the tag text that should be replaced!");
                resetButtons();
                return false;
            }
            else {
                sessionStorage.setItem('binCleanUp-TextReplace-old', oldText);
                sessionStorage.setItem('binCleanUp-TextReplace-new', newText);
            }
        }
        else if (binStatus == "synonymous") {
            if ($(tagList).filter((ix, tag) => parseInt($(tag).closest('tr').find('td[title="taggings"]').text()) === 0).length < 1) {
                alert("Please select at least one tag without uses!");
                resetButtons();
                return false;
            }
        }

        // if we didn't bow out yet, start working on the list of tags
        manageTagsLoop(0);
    }

    // button event listener: a dialog to enter the requested changes
    function tryMassYeet(e) {
        e.preventDefault();
        e.stopPropagation();

        disableButtons();
        $(e.target).addClass("current").find('.spin').css('display', 'inline-block'); // loading indicator
        pressed = "yeet";

        // grab the selected tags and disable the buttons
        tagList = getSelectedTags(e);

        let f_wrangle;
        if (pt === "search") { // grab the fandoms that were searched for automatically, manual changes in form afterwards don't count
            let params = new URLSearchParams(document.location.search);
            f_wrangle = decodeURIComponent(params.get('tag_search[fandoms]')).split(',');

            if (f_wrangle.length < 1) {
                alert("Please search within at least one fandom!");
                resetButtons();
                return false;
            }
        }
        else if (pt === "bin") { // grab the fandom we're currently wrangling in automatically
            f_wrangle = $('#main').find('h2.heading a.tag').toArray();
            f_wrangle = f_wrangle.map((f) => f.firstChild.textContent.trim());
        }

        // if the dialog was opened before, we just show it again
        if ($('#massFandoms-wrap').length > 0) $('#massFandoms-wrap').show();
        else { // otherwise we create it now

            // we're storing that info in session, so it is available between page loads across the bin/tag search
            oldText = sessionStorage.getItem('binCleanUp-ChangeFandom-old') || "";
            newText = sessionStorage.getItem('binCleanUp-ChangeFandom-new') || "";

            let bgcolor = $('body').css('background-color');
            let dlg_fandoms = `<div id="massFandoms-wrap">
            <div id="massFandoms">
            <h4>Replace Fandoms on Tags in Bulk</h4>
            <hr />
            <h5>Remove these fandoms:</h5>
            <ul class="content remove">
                ${f_wrangle.map((f) => "<li class='remove'><input type='checkbox' checked='checked' id='remove[]'><span>" + f.trim() + "</span></li>").join("\n")}
                <li class='remove'><input type='checkbox' id="removeall"><span>Remove ALL fandoms on selected tags</span><br />
                <small class="notice" style="display: none;">Caution! You might unknowingly remove other wranglers' fandoms, which a tag was shared with!</small></li>
            </ul>
            <h5>Add these fandoms:</h5>
            <p class="content add">
                <input type="text" id="add[]" name="add[]" class="fandom autocomplete" data-autocomplete-method="/autocomplete/fandom"
                data-autocomplete-hint-text="Start typing for Fandom suggestions!" data-autocomplete-no-results-text="(No suggestions found)"
                data-autocomplete-min-chars="1" data-autocomplete-searching-text="Searching..." value="${newText}" />
            </p>
            <p style="text-align: right;"><button type="button" id="massFandoms-cancel">Cancel</button> <button type="button" id="massFandoms-start">Start</button></p></div></div>`;

            // styling this minimal dialog
            $('header').append(`<style tyle="text/css">#massFandoms-wrap { position: fixed; z-index: 500; height: 100%; width: 100%; background-color: rgba(0, 0, 0, 0.5);
              display: flex; justify-content: center; align-items: center; font-size: 0.875em; }
            #massFandoms { background: ${bgcolor}; border: 10px solid #eee; margin: auto; width: 500px; padding: 1em; }
            #massFandoms h5 { font-weight: bold; margin: 1em 0 0.5em 0; }
            #massFandoms small { display: inline-block; }
            </style>`);

            // show the dialog to user
            $('body').append(dlg_fandoms);
            $('#massFandoms-wrap').trigger('click'); // workaround to make stored fandoms show as added (instead of text in the input field) ¯\_(ツ)_/¯

            // set checkboxes for removing fandoms based on stored values
            if (oldText == "Remove ALL fandoms on selected tags") {
                $("#massFandoms").find(".remove input").prop('checked', false);
                $("#massFandoms").find(".remove #removeall").prop('checked', true);
                $("#massFandoms").find(".remove small.notice").show();
            }

            // what the dialog buttons do
            $('body').on('click', '#massFandoms-cancel', function() {
                $('#massFandoms-wrap').hide();
                resetButtons();
            });
            $('body').on('click', '#massFandoms-start', function() {
                // put the remove&add fandoms into global variables so they're later available to manageTagEdit()
                oldText = $('#massFandoms').find('ul.content.remove li:has(input:checked)').toArray();
                oldText = oldText.map((f) => $(f).find('span').text());

                newText = $('#massFandoms').find('input[id="add[]"').prev('ul.autocomplete').find('li.added.tag').toArray();
                newText = newText.map((f) => f.firstChild.textContent.trim());

                // make sure the user isn't trying to remove AND add the same fandom, because what should be the end result?
                for (let val of newText) {
                    if (oldText.indexOf(val) !== -1) {
                        alert("You tried to both remove and add the same fandom. Please fix the fandoms before trying again.");
                        return false;
                    }
                }

                // store the selection for the next page
                sessionStorage.setItem('binCleanUp-ChangeFandom-old', oldText.join(","));
                sessionStorage.setItem('binCleanUp-ChangeFandom-new', newText.join(","));

                // close the dialog
                $('#massFandoms-wrap').hide();

                // start making those changes
                manageTagsLoop(0);
            });
            // show warning to user when Remove ALL is selected
            $('body').on('change', '#removeall', function(e) {
                $('#massFandoms ul.content.remove small').toggle();
                if (e.target.checked) $('#massFandoms ul.content.remove input[id="remove[]"]').prop('checked', false).prop('disabled', true);
                else $('#massFandoms ul.content.remove input[id="remove[]"]').prop('disabled', false);
            });
        }
    }

    // button event listener
    function tryMassCopy(e) {
        e.preventDefault();
        e.stopPropagation();

        // grab the selected tags and disable the buttons
        let selectedTags = getSelectedTags(e);

        // do we copy text or links?
        let copyfmt = $('.massCopy-format').eq(0).find('option:selected').prop('value');

        // build arrays of the pure tag names and urls
        let tagHTML = [], tagPlain = [];
        for (let inp of $(selectedTags).toArray()) {
            let tagname = (pt == "bin")    ? $(inp).parent().find('label').text() :
                          (pt == "search") ? $(inp).parent().find('a').text() : "";
            let taglink = (pt == "bin")    ? $(inp).closest('tr').find('a[href$="/edit"]').prop('href').slice(0, -5) :
                          (pt == "search") ? $(inp).parent().find('a').prop('href') : "";

            tagPlain.push(tagname);
            tagHTML.push(taglink);
        }

        // when copying "as text" nothing more is needed than a list of those tagnames
        if (copyfmt === "text") copy2Clipboard(e, 'txt', tagPlain.join("\n"));
        else { // puzzle together the more complex plaintext and richtext representations
            let plain = tagPlain.map((el, ix) => { return el + "\n" + tagHTML[ix]; }).join("\n");
            let rich = tagHTML.map((el, ix) => { return `<a href="${el}">${tagPlain[ix]}</a>`; }).join("<br />\n");

            if (copyfmt === "link") copy2Clipboard(e, 'fmt', rich);
            else if (copyfmt === "chat") copy2Clipboard(e, 'fmt', plain, rich);
        }

        resetButtons();
    }

    function getSelectedTags(e) {
        let tags = $(main).find('input[name="selected_tags[]"]:checked');
        if (tags.length < 1) {
            alert("Please select at least one tag!");
            return false;
        }
        else {
            disableButtons();
            $(e.target).addClass("current");                                         // make clicked button appear different
            return tags;
        }
    }
    function disableButtons() {
        $(main).find('.massYeet, .massManage, .massCopy').attr("disabled",true);
    }
    function resetButtons() {
        // reset all buttons so they can be used again
        $(main).find('.massYeet, .massManage, .massCopy').attr("disabled",false).removeClass("current");
        $(main).find('.massYeet, .massManage').find('.spin').hide();

        // reset temp values for the next round
        tagCounter = 0;
        errorMsg = [];
        successMsg = [];
    }

    // a wrapper function to open the page in background, which gets called again when the previous loop finished
    function manageTagsLoop(wait) {
        if (wait === null) wait = 2; // wait period (in seconds) between page loads

        setTimeout(() => {
            // 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
            const tagFrame = document.createElement("iframe");
            $(tagFrame).hide().appendTo('body').one('load', function() { manageTagEdit(tagFrame); });

            // grab the next tag's base URL (search) or edit URL (bin)
            let framesrc = pt === "search" ? $(tagList[tagCounter]).next('a').prop('href') + "/edit"
                                           : $(tagList[tagCounter]).closest('tr').find('a[href$="/edit"]').prop('href');

            tagFrame.src = framesrc; // at last, we let the edit page load
        }, wait*1000);
    }

    // make the requested changes in the background
    function manageTagEdit(tagFrame) {

        const frameContent = $(tagFrame).contents();
        if ($(frameContent).find('#edit_tag').length != 1) {
            document.body.removeChild(tagFrame);
            reportRetryLater(tagCounter);
            return; // stops loading any further pages
        }

        // make changes on the Edit page depending on the button pressed
        if (pressed === "yeet") {
            // remember: oldText[] is the fandoms to be removed, newText[] is the fandoms to be added
            let newTextTemp = [...newText]; // temp variable because we're changing the array content for this loop=tag

            // 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 check each existing fandom that it's supposed to be removed
            $(frameContent).find('#parent_Fandom_associations_to_remove_checkboxes a.tag').each(function(i, f) {

                if ( oldText.includes(f.innerText.trim()) || // if it's explicitly supposed to be removed
                     ( oldText.includes('Remove ALL fandoms on selected tags') && !newTextTemp.includes(f.innerText.trim()) ) // if we remove ALL and this isn't supposed to be added
                   ) {
                    $(f).prev().find('input').prop('checked', true); // tick the checkbox to remove the fandom
                }

                // fandoms that ARE supposed to be added and therefore don't need to be re-added
                if (newTextTemp.includes(f.innerText.trim())) newTextTemp.splice(i, 1);
            });

            // then we add all fandoms that remain in the array
            const fieldFandom = $(frameContent).find('input#tag_fandom_string_autocomplete')[0];
            fieldFandom.focus();
            const ke = new KeyboardEvent('keydown', { keyCode: 13, key: "Enter" });
            $(newTextTemp).each((j, fandom) => {
                fieldFandom.value = fandom;
                fieldFandom.dispatchEvent(ke);
            });
        }
        else if (pressed === "manage") {

            if (binStatus == "synonymous") {
                // remove the synonymous tag
                $(frameContent).find('#tag_syn_string').prev().find("li.added span.delete a")[0].click();
            }
            else if (binStatus == "unwrangleable" || binStatus == "unfilterable") {
                // switch the state of the unwranglable checkbox in the iframe
                let fieldUnwrangleable = $(frameContent).find('#tag_unwrangleable')[0];
                fieldUnwrangleable.checked = !fieldUnwrangleable.checked;
            }
            else if (binStatus == "canonical" || pt == "search") {
                // 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);
            }
        }

        // add another event listener to retrieve the fail/success message, and submit the iframe
        $(tagFrame).one('load', function() { manageTagSubmit(tagFrame); });
        $(tagFrame).contents().find('#edit_tag')[0].submit();
    }

    function manageTagSubmit(tagFrame) {

        const frameContent = $(tagFrame).contents();
        if ($(frameContent).find('#edit_tag').length != 1) {
            document.body.removeChild(tagFrame);
            reportRetryLater(tagCounter);
            return; // stops loading any further pages
        }

        // tracking any other errors we might've run into
        const err = $(frameContent).find('#error');
        if (err.length > 0) errorMsg[tagCounter] = err[0].innerHTML;
        else {
            // check if the desired fandoms have really attached - sometimes AO3 hiccups and we need to report an error
            // note that we're overwriting errors here instead of tracking each individual fandom. failed-to-add will win being reported over failed-to-remove
            if (pressed === "yeet") {
                let attached = $(frameContent).find('#parent_Fandom_associations_to_remove_checkboxes a.tag').map((ix, el) => { return $(el).text(); }).get();

                // confirm that no old fandoms remain
                if (oldText.includes("Remove ALL fandoms on selected tags")) { // remove all old => none remain that weren't set to be added
                    for (let a of attached) {
                        if (!newText.includes(a)) errorMsg[tagCounter] = "Some Fandoms were not removed as requested.";
                    }
                }
                else { // remove specific fandoms => they're not attached anymore
                    for (let a of attached) {
                        if (oldText.includes(a)) errorMsg[tagCounter] = "Some Fandoms were not removed as requested.";
                    }
                }

                // confirm that all fandoms to be added have saved
                for (let n of newText) {
                    if (!attached.includes(n)) errorMsg[tagCounter] = "Some Fandoms didn't attach successfully.";
                }
            }
            // track if de-syn didn't result in tag being ready to rake (tagset tag) i.e. if there's no <select> for changing between char/rel/ff
            else if (binStatus == "synonymous" && $(frameContent).find('select#tag_type').length === 0) {
                errorMsg[tagCounter] = "Tagset tag, will not rake. Consider synning again.";
            }
        }

        // when this loop is finished
        document.body.removeChild(tagFrame);                // remove the iframe
        tagCounter++;                                       // iterates to handle the next tag in the list
        if (tagList.length == tagCounter) reportComplete(); // if we're done with all tags, tell so
        else manageTagsLoop(2);                             // otherwise start next loop
    }

    // technically this might also be caused by a 503 or other error, not just Retry Later
    function reportRetryLater(tagCounter) {
        // fill up the error messages with retry later, since we're stopping the processing here
        for (let c = tagCounter; c < tagList.length; c++) {
            errorMsg[c] = `Page could not load. Retry later`;
        }
        reportComplete();
    }

    function reportComplete() {
        // make sure each tag has a corresponding entry in either error or success messages
        $(tagList).each((i, tag) => {
            let tagRow = $(tag).closest('tr');
            let tagLink = (pt == "bin") ? `<a href="${ $(tagRow).find('a[href$="/edit"]').prop('href') }">${ $(tagRow).find('th label').text() }</a>`
                                        : `<a href="${$(tag).parent().find('a').prop('href')}">${$(tag).parent().find('a').text()}</a>`;

            // errorMsg[] has the corresponding indices so we know which tag failed
            if (errorMsg[i] != undefined) { errorMsg[i] = tagLink + ': ' + errorMsg[i]; }
            else {
                successMsg.push(tagLink);
                // remove the changed tags from the bin page unless we're changing fandoms, because we could be just removing all others
                if (pressed == "manage" && (binStatus == "unwrangleable" || binStatus == "unfilterable" || binStatus == "synonymous")) $(tagRow).remove();
            }
        });

        // constructing the response message to the user and showing it
        const title = pressed == "yeet" ? "Fandom" :
                      binStatus == "synonymous" ? "Synonym of" :
                      (binStatus == "canonical" || pt == "search") ? "Case/Diacritics" : "Unwrangleable flag";
        if (errorMsg.length > 0) {
            if ($(main).find(".error."+pressed).length > 0) $(main).find(".error."+pressed).append(`<br />${errorMsg.join('<br />')}`);
            else $(main).prepend(`<div id="error" class="error ${pressed}">The following tags' ${title} could not be updated or ran into other issues:<br />${errorMsg.join('<br />')}</div>`);
            window.scrollTo(0,0);
        }
        if (successMsg.length > 0) {
            if ($(main).find(".notice."+pressed).length > 0) $(main).find(".notice."+pressed).append(`, ${successMsg.join(', ')}`);
            else $(main).prepend(`<div class="flash notice ${pressed}">The following tags' ${title} was updated successfully:<br />${successMsg.join(', ')}</div>`);
            window.scrollTo(0,0);
        }

        resetButtons();
    }

})(jQuery);