AO3 Tag Reorder

Rearrange tag order when editing a work

目前為 2025-01-29 提交的版本,檢視 最新版本

// ==UserScript==
// @name         AO3 Tag Reorder
// @description  Rearrange tag order when editing a work
// @author       Ifky_
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @history      1.0.0 — Rearrange tags. Copy tags for backup.
// @match        https://archiveofourown.org/works/new
// @match        https://archiveofourown.org/works/*/edit
// @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/[email protected]/Sortable.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"));
        tags.pop(); // Remove input from tag list
        return tags
            .map((tag) => Array.from(tag.childNodes)
            .filter((node) => node.nodeType === Node.TEXT_NODE)
            .map((node) => node.textContent)
            .join("")
            .trim())
            .join(",");
    };
    const onMove = (event, list) => {
        const targetElement = event.related;
        const inputElement = list.querySelector(".input");
        // Prevent dragging beyond the "input" element
        if (targetElement === inputElement) {
            return false;
        }
        return true;
    };
    // 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");
    const relationshipTags = document.querySelector("dd.relationship>ul");
    const characterTags = document.querySelector("dd.character>ul");
    const freeformTags = document.querySelector("dd.freeform>ul");
    // Insert paragraph for tags (copy text)
    [fandomTags, relationshipTags, characterTags, freeformTags].forEach((tagList) => {
        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);
    });
    // @ts-ignore
    // eslint-disable-next-line
    Sortable.create(fandomTags, {
        animation: 150,
        filter: ".input",
        preventOnFilter: true,
        onMove: (e) => onMove(e, fandomTags),
        onEnd: (e) => resetInput(e, fandomTags, "work_fandom"),
    });
    // @ts-ignore
    // eslint-disable-next-line
    Sortable.create(relationshipTags, {
        animation: 150,
        filter: ".input",
        preventOnFilter: true,
        onMove: (e) => onMove(e, relationshipTags),
        onEnd: (e) => resetInput(e, relationshipTags, "work_relationship"),
    });
    // @ts-ignore
    // eslint-disable-next-line
    Sortable.create(characterTags, {
        animation: 150,
        filter: ".input",
        preventOnFilter: true,
        onMove: (e) => onMove(e, characterTags),
        onEnd: (e) => resetInput(e, characterTags, "work_character"),
    });
    // @ts-ignore
    // eslint-disable-next-line
    Sortable.create(freeformTags, {
        animation: 150,
        filter: ".input",
        preventOnFilter: true,
        onMove: (e) => onMove(e, freeformTags),
        onEnd: (e) => resetInput(e, freeformTags, "work_freeform"),
    });
    // 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;
    };
})();