AO3: [Wrangling] Boot tags to another fandom!

Easily move tags from one fandom to another via wrangulator!!

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         AO3: [Wrangling] Boot tags to another fandom!
// @description  Easily move tags from one fandom to another via wrangulator!!
// @version      1.0.1

// @author       owlwinter
// @namespace    N/A
// @license      MIT license

// @match        *://*.archiveofourown.org/tags/*/wrangle?*status=unfilterable*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    //Gets the edit url of the tag from the checkbox
    const get_url = function get_url(checkbox) {
        //If iconify is enabled, this will return the tag's link
        const a = checkbox.parentElement.parentElement.querySelector("ul.actions > li[title='Edit'] > a");
        if (a) {
            return a.href;
        }
        //If iconify is not enabled, we'll use the default path
        const buttons = checkbox.parentElement.parentElement.querySelectorAll("ul.actions > li > a");
        return array(buttons).filter(b => b.innerText == "Edit")[0].href;
    }

    //Hides the checked rows after we do our work
    const delete_tag_row = function delete_tag_row(checkbox) {
        const row = checkbox.parentElement.parentElement;
        row.parentElement.removeChild(row);
    }

    //Each time we finish a request, we'll add 1 to the "done" count
    //When it's equal to the number of tags we had to make requests for, we know we gone through all the checked rows!
    var done;
    //Holds any errors from form submit
    var errors;
    //All tags selected to boot
    var tags;

    // Called for each checked tag
    // Submits form data & passes on any resulting error messages
    // If succesfully done for every selected tag, will also display success banner :)
    // `url` is the URL that will process the form submission
    // `fullfandoms` is an array of all the new fandoms to be added
    // `tag` is the tag being edited
    // `fd` is the new formdata
    const submit_xhr = function submit_xhr(url, fullfandoms, tag, fd) {
        const xhr = new XMLHttpRequest();
        xhr.onreadystatechange = function xhr_onreadystatechange() {
            if (xhr.readyState == xhr.DONE ) {
                //After form submitted:
                if (xhr.status == 200) {
                    // Check for errors
                    const err = xhr.responseXML.documentElement.querySelector("#error");
                    if (err) {
                        alert(err.innerText);
                        errors.push(err)
                    }
                    done += 1;
                    //If succesfully done for every selected tag, we want to display a success banner!
                    if (done == tags.length) {
                        // for some reason this seems to always be present on the page, even if there is no content in it
                        var flash = document.getElementsByClassName("flash")[0]
                        flash.innerHTML = "";
                        flash.classList.add("notice")
                        if (errors.length != 0) {
                            //In case the user wasn't bonked enough for their error,
                            //puts the red box with the error message from the iframe into the flash action message area
                            errors.forEach(e => {
                                e.parentElement.removeChild(e)
                                flash.appendChild(e);
                            });
                        } else {
                            // happy path :)
                            flash.appendChild(document.createTextNode("The following tags were successfully booted to "));
                            flash.appendChild(document.createTextNode(fullfandoms.toString().replaceAll(',', ', ')))
                            flash.appendChild(document.createTextNode(": "));
                            const as = tags.map(checkbox => {
                                const a = document.createElement("a")
                                a.href = get_url(checkbox);
                                a.target = "_blank"
                                a.innerText = checkbox.labels[0].innerText;
                                return a;
                            });
                            as.forEach((a, i) => {
                                if (i != 0) {
                                    flash.appendChild(document.createTextNode(", "))
                                }
                                flash.appendChild(a);
                            });
                            tags.forEach(checkbox => delete_tag_row(checkbox));
                        }
                        flash.appendChild(document.createElement("br"))
                        flash.scrollIntoView();
                        //Unset explicit background to let it go back to the default CSS background
                        button.style.background = ""
                        button.disabled = false
                        button.innerText = "Boot";
                    }
                }
                else if (xhr.status == 429) {
                    // ~ao3jail
                    alert("Rate limited. Sorry :(")
                } else {
                    alert("Unexpected error, check the console :(")
                    console.log(xhr)
                }
            }
        }
        xhr.open("POST", url)
        xhr.responseType = "document"
        xhr.send(fd)
    }

    // Called for each checked tag
    // Gets the edit form data about the tag and sets up request that will actually submit
    // `url` is the url of the edit tag page
    // `newfandoms` is an array of all the new fandoms to be added
    const boot_xhr = function boot_xhr(url, newfandoms) {
        const xhr = new XMLHttpRequest();
        xhr.onreadystatechange = function xhr_onreadystatechange() {
            if (xhr.readyState == xhr.DONE ) {
                if (xhr.status == 200) {
                    //Fandoms the tag has pre-boot
                    const oldfandoms = []
                    const inner_input = xhr.responseXML.documentElement.querySelector("input[name='tag[syn_string]']")
                    const tag = inner_input.value;

                    const form = xhr.responseXML.documentElement.querySelector("#edit_tag")

                    //Removing all fandoms the tag has pre-boot
                    const checks = array(xhr.responseXML.documentElement.querySelectorAll("label > input[name='tag[associations_to_remove][]']")).filter(foo => foo.id.indexOf("parent_Fandom_associations_to_remove") != -1);
                    for (const check of checks) {
                        const name = check.parentElement.nextElementSibling.innerText
                        oldfandoms.push(name)
                        //Edge case - if user boots a tag to a fandom currently on the tag
                        if (!newfandoms.includes(name)) {
                            check.checked = true;
                        }
                    }
                    //Setting the new fandoms on the tag
                    xhr.responseXML.documentElement.querySelector("#tag_fandom_string").value = newfandoms;

                    console.log("Tag at url " + url + " booted from " + oldfandoms.toString() + " and booted to " + newfandoms)

                    const fd = new FormData(form);

                    //Second xhr call that will submit our edited data to the form
                    submit_xhr(form.action, newfandoms, tag, fd);
                } else if (xhr.status == 429) {
                    // ~ao3jail
                    alert("Rate limited. Sorry :(")
                } else {
                    alert("Unexpected error, check the console :(")
                    console.log(xhr)
                }
            }
        }
        xhr.open("GET", url)
        xhr.responseType = "document"
        xhr.send()
    }

    //Runs when the boot button is clicked
    const boot_fandoms = function boot_fandoms(e) {
        e.preventDefault()

        //Gets all the rows with selected tags
        tags = array(document.querySelectorAll("input[name='selected_tags[]']")).filter(inp => inp.checked);

        //If no tags are selected, bonk user >:(
        if (tags.length == 0) {
            alert("You need to select at least one tag to boot!")
            return;
        }

        //The fandoms checked via the Fandom Assignment Shortcut script, if it is installed
        const fandomschecked = array(document.querySelectorAll("input[name='fandom_shortcut']:checked")).map(f => f.value)
        //The fandoms added via wrangulator textbox
        const fandomstext = document.getElementById("fandom_string").value.split(',')
        //Combine both fandom lists, then remove empty elements
        const fullfandoms = fandomschecked.concat(fandomstext).filter(n => n)

        //If no fandoms are selected, bonk user >:(
        if (fullfandoms.length == 0) {
            alert("You need to select at least one fandom to boot to!")
            return;
        }

        //Each time we finish a request, we'll add 1 to "done", and when it's equal to the number of tags we had to make requests for, we know we have finished them all
        done = 0;
        errors = []

        button.disabled = "true";
        button.innerText = "Processing..."
        // There is no special style in the default ao3 styles for disabled buttons, so even when disabled, the button still looks clickable. This sets the background to
        // "lightgrey" instead of the default light -> dark gradient, to make it look more disabled. We undo this later by setting this property back to an empty string
        // after all our requests are done.
        button.style.background = "lightgrey";

        //Runs hard boot xhr call on each tag selected
        tags.forEach(checkbox => {
            const url = get_url(checkbox)
            boot_xhr(url, fullfandoms)
        })
    }

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

    //Creates boot button
    const button = document.createElement("button");
    button.type = "text";
    button.style.textAlign = "center"
    button.style.marginRight = "5px"
    button.textContent = "Boot";
    button.addEventListener("click", boot_fandoms)
    document.querySelector("dd.submit").prepend(button);

    // Your code here...
})();