AO3 Tag Reorder

Rearrange tag order when editing a work

// ==UserScript==
// @name         AO3 Tag Reorder
// @description  Rearrange tag order when editing a work
// @author       Ifky_
// @namespace    https://greasyfork.org/en/scripts/524994-ao3-tag-reorder
// @version      1.0.2
// @history      1.0.2 — Fix autocomplete not adding tag. Now the input field is also draggable, but this has no effect otherwise.
// @history      1.0.1 — Switch from SortableJS to AlpineJS (which depends on SortableJS), and fix autocomplete adding unintended tags.
// @history      1.0.0 — Rearrange tags. Copy tags for backup.
// @match        https://archiveofourown.org/works/new
// @match        https://archiveofourown.org/works/*/edit_tags
// @match        https://archiveofourown.org/works/*/update_tags
// @icon         https://archiveofourown.org/images/logo.png
// @require      https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js
// @license      GPL-3.0-only
// @grant        none
// ==/UserScript==
"use strict";
(function () {
    // Utility function for delay
    const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    const copyToClipboard = async (button, text) => {
        const originalText = button.innerText;
        await navigator.clipboard
            .writeText(text)
            .then(async () => {
            button.innerText = "Copied!";
            await delay(2000);
            button.innerText = originalText;
        })
            .catch(() => {
            alert("ERROR: Failed to copy tags to clipboard. REASON: Browser does not support Clipboard API or permission is disabled.");
        });
    };
    const resetInput = (event, list, inputId) => {
        if (event.oldIndex !== event.newIndex) {
            // Get the input
            const input = document.getElementById(inputId);
            input.value = getTagsCsv(list);
        }
    };
    const getTagsCsv = (listElement) => {
        // Get all tags
        const tags = Array.from(listElement.querySelectorAll("li.tag"));
        return tags
            .map((tag) => Array.from(tag.childNodes)
            .filter((node) => node.nodeType === Node.TEXT_NODE)
            .map((node) => node.textContent)
            .join("")
            .trim())
            .join(",");
    };
    // Style the list items
    const styleTag = document.createElement("style");
    // Add CSS rules
    styleTag.textContent = `
  .fandom .added.tag,
  .relationship .added.tag,
  .character .added.tag,
  .freeform .added.tag {
    cursor: grab;
  }

  .fandom .added.tag.sortable-chosen,
  .relationship .added.tag.sortable-chosen,
  .character .added.tag.sortable-chosen,
  .freeform .added.tag.sortable-chosen {
    cursor: grabbing;
  }

  .fandom .added.tag::before,
  .relationship .added.tag::before,
  .character .added.tag::before,
  .freeform .added.tag::before {
    content: '☰';
    border: 1px dotted;
    border-radius: 5px;
    padding-inline: 3px;
    margin-right: 3px;
  }`;
    // Append the <style> element to the <head>
    document.head.appendChild(styleTag);
    // Make the tag lists sortable for re-ordering
    const fandomTags = document.querySelector("dd.fandom>ul:first-of-type");
    const relationshipTags = document.querySelector("dd.relationship>ul:first-of-type");
    const characterTags = document.querySelector("dd.character>ul:first-of-type");
    const freeformTags = document.querySelector("dd.freeform>ul:first-of-type");
    [fandomTags, relationshipTags, characterTags, freeformTags].forEach((tagList) => {
        // Insert paragraph for tags (copy text)
        const div = document.createElement("div");
        div.style.padding = "5px 8px";
        div.style.borderRadius = "5px";
        div.style.border = "1px dashed";
        div.style.marginBottom = "10px";
        div.style.display = "flex";
        div.style.flexWrap = "wrap";
        div.style.background = "#444";
        div.style.color = "#fff";
        div.style.gap = "1em";
        div.id = `tag-copy-list-${tagList.parentElement.classList[0]}`;
        tagList.parentElement.insertBefore(div, tagList);
        const p = document.createElement("p");
        p.style.margin = "0";
        p.style.padding = "0";
        p.style.lineHeight = "2";
        p.innerText = "Drag and drop tags to reorder";
        div.appendChild(p);
        const info = document.createElement("button");
        info.innerText = "i";
        info.type = "button";
        info.style.padding = "3px 7px";
        info.style.margin = "3px 0";
        info.style.fontFamily = "monospace";
        info.style.borderRadius = "50%";
        info.style.border = "1px solid currentColor";
        info.style.cursor = "pointer";
        info.addEventListener("click", () => {
            alert(`Copy the tags to the clipboard in case of network issues or hitting AO3's spam filters, in order to mitigate the risk of losing ALL the tags. It's a good idea to copy all the categories and keep them safe in a backup text file. \n\nIn the worst case scenario, you only need to paste them into each respective input field and it will add the tags back, as they are separated by commas. \n\nNB: To save the reordered tags, use the "Save tags" buttons, and not the standard Post/Draft buttons. This saves everything in the work, not only the tags, as it's not possible to do a partial save.`);
        });
        div.appendChild(info);
        const copy = document.createElement("button");
        copy.style.display = "inline-block";
        copy.style.cursor = "pointer";
        copy.style.margin = "0 0 0 auto";
        copy.type = "button";
        copy.innerText = "Copy tags";
        copy.addEventListener("click", () => {
            copyToClipboard(copy, getTagsCsv(tagList));
        });
        div.appendChild(copy);
        // Make sortable
        tagList.setAttribute('x-data', '');
        tagList.setAttribute('x-sort', '');
        tagList.querySelectorAll('li').forEach((li) => {
            li.setAttribute('x-sort:item', '');
        });
    });
    // Make the form send two requests: one empty and one with the real tags
    // In order to reset the order on AO3's backend
    const form = document.getElementById("work-form");
    const fieldset = form.querySelector(".work.meta");

if (!window.location.pathname.endsWith("/new")) {
    const draftButton = document.querySelector("input[name=save_button]");
    if (draftButton) {
        const draft = document.createElement("button");
        draft.style.display = "inline-block";
        draft.style.cursor = "pointer";
        draft.style.margin = "0 1em 1em auto";
        draft.style.float = "right";
        draft.type = "button";
        draft.innerText = "Save tags (Draft)";
        draft.addEventListener("click", () => saveReorder("Save As Draft", draft));
        fieldset.appendChild(draft);
    }
    const post = document.createElement("button");
    post.style.display = "inline-block";
    post.style.cursor = "pointer";
    post.style.margin = "0 1em 1em auto";
    post.style.float = "right";
    post.type = "button";
    post.innerText = "Save tags (Post)";
    post.addEventListener("click", () => saveReorder("Post", post));
    fieldset.appendChild(post);
    const copyAll = document.createElement("button");
    copyAll.style.display = "inline-block";
    copyAll.style.cursor = "pointer";
    copyAll.style.margin = "0 1em 1em auto";
    copyAll.style.float = "right";
    copyAll.type = "button";
    copyAll.innerText = "Copy all tags";
    copyAll.addEventListener("click", () => {
        const csv = [];
        [fandomTags, relationshipTags, characterTags, freeformTags].forEach((list) => {
            csv.push(getTagsCsv(list));
        });
        copyToClipboard(copyAll, csv.join("\n"));
    });
    fieldset.appendChild(copyAll);
}
    const getErrorFromResponse = async (response) => {
        const html = new DOMParser().parseFromString(await response.text(), "text/html");
        const error = html.getElementById("error");
        if (error) {
            alert(`${error.innerText}`);
            return true;
        }
        return false;
    };
    const saveReorder = async (action, button) => {
        const oldText = button.innerText;
        button.innerText = "Saving tags...";
        const formData = new FormData(form);
        if (action === "Post") {
            formData.set("update_button", action);
        }
        else if (action === "Save As Draft") {
            formData.set("save_button", action);
        }
        formData.set("work[fandom_string]", "."); // Fandom is required, so set a single placeholder fandom
        formData.set("work[relationship_string]", "");
        formData.set("work[character_string]", "");
        formData.set("work[freeform_string]", "");
        const emptyTags = new URLSearchParams(Array.from(formData.entries()).map(([key, value]) => [
            key,
            value.toString(),
        ]));
        await fetch(form.action, {
            method: form.method,
            headers: {
                "content-type": "application/x-www-form-urlencoded",
            },
            body: emptyTags.toString(),
        })
            .then(async (response) => {
            if (await getErrorFromResponse(response)) {
                return;
            }
            // Wait a bit before sending next request
            await delay(1000);
            formData.set("work[fandom_string]", getTagsCsv(fandomTags));
            formData.set("work[relationship_string]", getTagsCsv(relationshipTags));
            formData.set("work[character_string]", getTagsCsv(characterTags));
            formData.set("work[freeform_string]", getTagsCsv(freeformTags));
            const realTags = new URLSearchParams(Array.from(formData.entries()).map(([key, value]) => [
                key,
                value.toString(),
            ]));
            await fetch(form.action, {
                method: form.method,
                headers: {
                    "content-type": "application/x-www-form-urlencoded",
                },
                body: realTags.toString(),
            })
                .then(async (response) => {
                if (!(await getErrorFromResponse(response))) {
                    button.innerText = "Saved!";
                    await delay(2000);
                }
            })
                .catch(() => {
                alert(`ERROR: Failed to save tags. REASON: Possibly network issues. Try again in a minute.`);
            });
        })
            .catch(() => {
            alert(`ERROR: Failed to clear tags. REASON: Possibly network issues. Try again in a minute.`);
        });
        button.innerText = oldText;
    };
})();