AO3 Tag Reorder

Rearrange tag order when editing a work, bookmark or collection

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         AO3 Tag Reorder
// @description  Rearrange tag order when editing a work, bookmark or collection
// @author       Ifky_
// @namespace    https://greasyfork.org/en/scripts/524994-ao3-tag-reorder
// @version      1.2.0
// @history      1.2.0 — Reorder collection tags, bookmark tags on collection page, bookmark tags on dashboard page, and tags on external bookmarks. Update "Save As Draft" to "Save Draft" to match AO3 changes. Update CSS selectors.
// @history      1.1.0 — Reorder bookmark tags. Refactored CSS and added new "sortable" class for lists that can be used for styling.
// @history      1.0.3 — Set increased line height on tag handles. Fix new tags not being draggable.
// @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/*/edit
// @match        https://archiveofourown.org/works/*/edit_tags
// @match        https://archiveofourown.org/works/*/update_tags
// @match        https://archiveofourown.org/works/new
// @match        https://archiveofourown.org/works/*/bookmarks
// @match        https://archiveofourown.org/bookmarks/*
// @match        https://archiveofourown.org/users/*
// @match        https://archiveofourown.org/collections/*
// @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 () {
    // Set a timeout (delays executing code)
    const delay = (ms) => {
        return new Promise((resolve) => setTimeout(resolve, ms));
    };
    // Copy text to the clipboard
    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.");
        });
    };
    // Convert a list of elements (tags) to text in comma separated values (CSV) format
    const getTagsCsv = (listElement) => {
        return Array.from(listElement.querySelectorAll("li.tag"))
            .map((li) => li.firstChild?.textContent?.trim() ?? "")
            .join(",");
    };
    // CSS rules
    const styleTag = document.createElement("style");
    styleTag.textContent = `
    .sortable .added.tag {
      cursor: grab;
      padding-inline-end: 0.5em
    }

    .sortable .added.tag.sortable-chosen {
      cursor: grabbing;
    }

    .sortable .added.tag::before {
      content: '☰';
      border: 1px dotted;
      border-radius: 5px;
      padding-inline: 3px;
      margin-right: 3px;
      line-height: 2em;
    }

    #tag-string-description {
      line-height: 2;
    }
      
    .tag-copy-list {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 1em;
      background: #444;
      color: #fff;
      padding: 5px 8px;
      border-radius: 5px;
      border: 1px dashed;
      margin-bottom: 10px;
    }
      
    .tag-copy-list p {
      margin: 0;
      padding: 0;
      line-height: 2;
    }
      
    .tag-copy-list .info {
      padding: 3px 7px;
      margin: 3px 0;
      font-family: monospace;
      border-radius: 50%;
      border: 1px solid currentColor;
      cursor: pointer;
    }
      
    .tag-copy-list .copy {
      display: inline-block;
      cursor: pointer;
      margin: 0 0 0 auto;
    }
  `;
    // Append the <style> element to the <head>
    document.head.appendChild(styleTag);
    const button = (options) => {
        const e = document.createElement("button");
        e.innerText = options.text;
        e.type = "button";
        if (options.className) {
            e.className = options.className;
        }
        e.style.cursor = "pointer";
        if (options.actionButton) {
            e.style.display = "inline-block";
            e.style.margin = "0 0 0 1em";
            e.style.float = "right";
        }
        e.addEventListener("click", options.onClick);
        return e;
    };
    // Make a list sortable
    const makeListSortable = (tagList) => {
        tagList.classList.add("sortable");
        // Insert paragraph for tags (copy text)
        const div = document.createElement("div");
        div.classList.add("tag-copy-list");
        tagList.parentElement?.insertBefore(div, tagList);
        const p = document.createElement("p");
        p.innerText = "Drag and drop tags to reorder";
        div.appendChild(p);
        // Info
        const info = button({
            text: "i",
            onClick: () => {
                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/Update buttons. (Unless it's a completely new work). This saves everything in the work/bookmark, not only the tags, as it's not possible to do a partial save.`);
            },
        });
        div.appendChild(info);
        // Copy
        const copy = button({
            text: "Copy tags",
            className: "copy",
            onClick: () => {
                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", "");
        });
        // Setup MutationObserver to monitor for new list items
        const observer = new MutationObserver((mutationsList) => {
            for (const mutation of mutationsList) {
                if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
                    mutation.addedNodes.forEach((node) => {
                        if (node instanceof HTMLLIElement) {
                            node.setAttribute("x-sort:item", "");
                        }
                    });
                }
            }
        });
        // Start observing the tagList for added <li> elements
        observer.observe(tagList, {
            childList: true,
            subtree: false,
        });
    };
    // Find the potential error message in a response
    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;
        }
        else if (response.status == 500) {
            alert("An unexpected server error occurred");
        }
        return false;
    };
    // Observe element for changes
    const waitForElement = (selector, root) => {
        return new Promise((resolve) => {
            const el = root.querySelector(selector);
            if (el) {
                return resolve(el);
            }
            const observer = new MutationObserver(() => {
                const el = root.querySelector(selector);
                if (el) {
                    observer.disconnect();
                    resolve(el);
                }
            });
            observer.observe(root, {
                childList: true,
                subtree: true,
            });
        });
    };
    let FormSubmitAction;
    (function (FormSubmitAction) {
        FormSubmitAction["Post"] = "Post";
        FormSubmitAction["Draft"] = "Save Draft";
        FormSubmitAction["Update"] = "Update";
    })(FormSubmitAction || (FormSubmitAction = {}));
    // 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 saveTagReorder = async (context, button, action) => {
        const oldText = button.innerText;
        button.innerText = "Saving tags...";
        const formData = new FormData(context.form);
        // Set button info
        switch (action) {
            case FormSubmitAction.Post:
                formData.set("update_button", action);
                break;
            case FormSubmitAction.Draft:
                formData.set("save_button", action);
                break;
            case FormSubmitAction.Update:
                formData.set("commit", action);
        }
        // Clear tags
        context.fields.forEach((f) => formData.set(f.formKey, ""));
        // Apply placeholders
        if (context.placeholder) {
            Object.entries(context.placeholder).forEach(([k, v]) => formData.set(k, v));
        }
        // Send the empty request
        const clearRequest = await sendRequest(context.form, formData, "ERROR: Failed to clear tags. REASON: Possibly network issues. Try again in a minute.");
        await delay(1000);
        // Set the real tags
        context.fields.forEach((f) => formData.set(f.formKey, getTagsCsv(f.list)));
        const saveRequest = await sendRequest(context.form, formData, "ERROR: Failed to save tags. REASON: Possibly network issues. Try again in a minute.");
        // Saved
        if (saveRequest) {
            button.innerText = "Saved!";
            await delay(2000);
        }
        button.innerText = oldText;
    };
    const sendRequest = async (form, formData, errorMessage) => {
        try {
            const response = await fetch(form.action, {
                method: form.method,
                body: formData,
                credentials: "same-origin",
            });
            if (await getErrorFromResponse(response)) {
                return false;
            }
            return true;
        }
        catch {
            alert(errorMessage);
            return false;
        }
    };
    // Add the buttons to the pages and make the tags sortable
    // Tag types: Bookmark, collection, work
    // Reorder bookmark tags (both internal and external bookmarks)
    const bookmarksFormPlacement = document.querySelectorAll("[id^=bookmark_form_placement_for_]");
    if (bookmarksFormPlacement.length > 0) {
        // Start observing the bookmark form placement for new form
        // Indicating that user has started editing it
        bookmarksFormPlacement.forEach(async (el) => {
            const form = (await waitForElement("form", el));
            // Tag container
            const tagsContainer = await waitForElement("dd:has(#bookmark_tag_string)", el);
            // Tag list
            const tagList = (await waitForElement("ul.autocomplete", tagsContainer));
            // Find the tag list and make it sortable
            makeListSortable(tagList);
            // Context
            const context = {
                form,
                fields: [
                    {
                        list: tagList,
                        formKey: "bookmark[tag_string]",
                    },
                ],
            };
            // Post
            const post = button({
                text: "Save tags (Update)",
                onClick: () => saveTagReorder(context, post),
                actionButton: true,
            });
            tagsContainer.appendChild(post);
        });
    }
    // Reorder collection tags
    const main = document.getElementById("main");
    if (main?.classList.contains("collections-edit")) {
        const form = main.querySelector(".post.collection");
        const fieldset = form?.querySelector("fieldset:first-of-type");
        if (form !== null && fieldset !== null) {
            // Find the tag list and make it sortable
            const tagList = fieldset?.querySelector("dd:has(> #tag-string-field-description) > ul.autocomplete");
            makeListSortable(tagList);
            // Set up context
            const context = {
                form,
                fields: [
                    {
                        list: tagList,
                        formKey: "collection[tag_string]",
                    },
                ],
            };
            // Post
            const post = button({
                text: "Save tags (Update)",
                onClick: () => saveTagReorder(context, post, FormSubmitAction.Update),
                actionButton: true,
            });
            fieldset.appendChild(post);
        }
    }
    // Reorder work tags
    const form = document.getElementById("work-form");
    if (form !== null) {
        const fieldset = form.querySelector(".work.meta");
        // Form key and element query selector
        const tagSelectors = [
            ["work[fandom_string]", "dd.fandom>ul:first-of-type"],
            ["work[relationship_string]", "dd.relationship>ul:first-of-type"],
            ["work[character_string]", "dd.character>ul:first-of-type"],
            ["work[freeform_string]", "dd.freeform>ul:first-of-type"],
        ];
        const fields = tagSelectors.map(([formKey, selector]) => {
            const list = form.querySelector(selector);
            if (!list) {
                return null;
            }
            // Make the tag lists sortable for re-ordering
            makeListSortable(list);
            return { list, formKey };
        });
        // Add save tags buttons on non-new work pages
        const URL = location.pathname;
        if (!URL.endsWith("/new")) {
            // Set up context
            const context = {
                form,
                fields: fields,
                placeholder: {
                    "work[fandom_string]": ".",
                },
            };
            // Draft
            const draftButton = document.querySelector("input[name=save_button]");
            if (draftButton) {
                const draft = button({
                    text: "Save tags (Draft)",
                    onClick: () => saveTagReorder(context, draft, FormSubmitAction.Draft),
                    actionButton: true,
                });
                fieldset.appendChild(draft);
            }
            // Post
            const post = button({
                text: "Save tags (Post)",
                onClick: () => saveTagReorder(context, post, FormSubmitAction.Post),
                actionButton: true,
            });
            fieldset.appendChild(post);
            // Copy all tags
            const copyAll = button({
                text: "Copy all tags",
                onClick: () => {
                    const csv = context.fields.map((f) => getTagsCsv(f.list)).join("\n");
                    copyToClipboard(copyAll, csv);
                },
                actionButton: true,
            });
            fieldset.appendChild(copyAll);
        }
    }
})();