Rearrange tag order when editing a work, bookmark or collection
// ==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); } } })();