// ==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;
};
})();