AO3 Tag Reorder

Rearrange tag order when editing a work

目前为 2025-01-29 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name AO3 Tag Reorder
  3. // @description Rearrange tag order when editing a work
  4. // @author Ifky_
  5. // @namespace http://tampermonkey.net/
  6. // @version 1.0.0
  7. // @history 1.0.0 — Rearrange tags. Copy tags for backup.
  8. // @match https://archiveofourown.org/works/new
  9. // @match https://archiveofourown.org/works/*/edit
  10. // @match https://archiveofourown.org/works/*/edit_tags
  11. // @match https://archiveofourown.org/works/*/update_tags
  12. // @icon https://archiveofourown.org/images/logo.png
  13. // @require https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js
  14. // @license GPL-3.0-only
  15. // @grant none
  16. // ==/UserScript==
  17. "use strict";
  18. (function () {
  19. // Utility function for delay
  20. const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
  21. const copyToClipboard = async (button, text) => {
  22. const originalText = button.innerText;
  23. await navigator.clipboard
  24. .writeText(text)
  25. .then(async () => {
  26. button.innerText = "Copied!";
  27. await delay(2000);
  28. button.innerText = originalText;
  29. })
  30. .catch(() => {
  31. alert("ERROR: Failed to copy tags to clipboard. REASON: Browser does not support Clipboard API or permission is disabled.");
  32. });
  33. };
  34. const resetInput = (event, list, inputId) => {
  35. if (event.oldIndex !== event.newIndex) {
  36. // Get the input
  37. const input = document.getElementById(inputId);
  38. input.value = getTagsCsv(list);
  39. }
  40. };
  41. const getTagsCsv = (listElement) => {
  42. // Get all tags
  43. const tags = Array.from(listElement.querySelectorAll("li"));
  44. tags.pop(); // Remove input from tag list
  45. return tags
  46. .map((tag) => Array.from(tag.childNodes)
  47. .filter((node) => node.nodeType === Node.TEXT_NODE)
  48. .map((node) => node.textContent)
  49. .join("")
  50. .trim())
  51. .join(",");
  52. };
  53. const onMove = (event, list) => {
  54. const targetElement = event.related;
  55. const inputElement = list.querySelector(".input");
  56. // Prevent dragging beyond the "input" element
  57. if (targetElement === inputElement) {
  58. return false;
  59. }
  60. return true;
  61. };
  62. // Style the list items
  63. const styleTag = document.createElement("style");
  64. // Add CSS rules
  65. styleTag.textContent = `
  66. .fandom .added.tag,
  67. .relationship .added.tag,
  68. .character .added.tag,
  69. .freeform .added.tag {
  70. cursor: grab;
  71. }
  72.  
  73. .fandom .added.tag.sortable-chosen,
  74. .relationship .added.tag.sortable-chosen,
  75. .character .added.tag.sortable-chosen,
  76. .freeform .added.tag.sortable-chosen {
  77. cursor: grabbing;
  78. }
  79.  
  80. .fandom .added.tag::before,
  81. .relationship .added.tag::before,
  82. .character .added.tag::before,
  83. .freeform .added.tag::before {
  84. content: '☰';
  85. border: 1px dotted;
  86. border-radius: 5px;
  87. padding-inline: 3px;
  88. margin-right: 3px;
  89. }`;
  90. // Append the <style> element to the <head>
  91. document.head.appendChild(styleTag);
  92. // Make the tag lists sortable for re-ordering
  93. const fandomTags = document.querySelector("dd.fandom>ul");
  94. const relationshipTags = document.querySelector("dd.relationship>ul");
  95. const characterTags = document.querySelector("dd.character>ul");
  96. const freeformTags = document.querySelector("dd.freeform>ul");
  97. // Insert paragraph for tags (copy text)
  98. [fandomTags, relationshipTags, characterTags, freeformTags].forEach((tagList) => {
  99. const div = document.createElement("div");
  100. div.style.padding = "5px 8px";
  101. div.style.borderRadius = "5px";
  102. div.style.border = "1px dashed";
  103. div.style.marginBottom = "10px";
  104. div.style.display = "flex";
  105. div.style.flexWrap = "wrap";
  106. div.style.background = "#444";
  107. div.style.color = "#fff";
  108. div.style.gap = "1em";
  109. div.id = `tag-copy-list-${tagList.parentElement.classList[0]}`;
  110. tagList.parentElement.insertBefore(div, tagList);
  111. const p = document.createElement("p");
  112. p.style.margin = "0";
  113. p.style.padding = "0";
  114. p.style.lineHeight = "2";
  115. p.innerText = "Drag and drop tags to reorder";
  116. div.appendChild(p);
  117. const info = document.createElement("button");
  118. info.innerText = "i";
  119. info.type = "button";
  120. info.style.padding = "3px 7px";
  121. info.style.margin = "3px 0";
  122. info.style.fontFamily = "monospace";
  123. info.style.borderRadius = "50%";
  124. info.style.border = "1px solid currentColor";
  125. info.style.cursor = "pointer";
  126. info.addEventListener("click", () => {
  127. 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.`);
  128. });
  129. div.appendChild(info);
  130. const copy = document.createElement("button");
  131. copy.style.display = "inline-block";
  132. copy.style.cursor = "pointer";
  133. copy.style.margin = "0 0 0 auto";
  134. copy.type = "button";
  135. copy.innerText = "Copy tags";
  136. copy.addEventListener("click", () => {
  137. copyToClipboard(copy, getTagsCsv(tagList));
  138. });
  139. div.appendChild(copy);
  140. });
  141. // @ts-ignore
  142. // eslint-disable-next-line
  143. Sortable.create(fandomTags, {
  144. animation: 150,
  145. filter: ".input",
  146. preventOnFilter: true,
  147. onMove: (e) => onMove(e, fandomTags),
  148. onEnd: (e) => resetInput(e, fandomTags, "work_fandom"),
  149. });
  150. // @ts-ignore
  151. // eslint-disable-next-line
  152. Sortable.create(relationshipTags, {
  153. animation: 150,
  154. filter: ".input",
  155. preventOnFilter: true,
  156. onMove: (e) => onMove(e, relationshipTags),
  157. onEnd: (e) => resetInput(e, relationshipTags, "work_relationship"),
  158. });
  159. // @ts-ignore
  160. // eslint-disable-next-line
  161. Sortable.create(characterTags, {
  162. animation: 150,
  163. filter: ".input",
  164. preventOnFilter: true,
  165. onMove: (e) => onMove(e, characterTags),
  166. onEnd: (e) => resetInput(e, characterTags, "work_character"),
  167. });
  168. // @ts-ignore
  169. // eslint-disable-next-line
  170. Sortable.create(freeformTags, {
  171. animation: 150,
  172. filter: ".input",
  173. preventOnFilter: true,
  174. onMove: (e) => onMove(e, freeformTags),
  175. onEnd: (e) => resetInput(e, freeformTags, "work_freeform"),
  176. });
  177. // Make the form send two requests: one empty and one with the real tags
  178. // In order to reset the order on AO3's backend
  179. const form = document.getElementById("work-form");
  180. const fieldset = form.querySelector(".work.meta");
  181.  
  182. if (!window.location.pathname.endsWith("/new")) {
  183. const draftButton = document.querySelector("input[name=save_button]");
  184. if (draftButton) {
  185. const draft = document.createElement("button");
  186. draft.style.display = "inline-block";
  187. draft.style.cursor = "pointer";
  188. draft.style.margin = "0 1em 1em auto";
  189. draft.style.float = "right";
  190. draft.type = "button";
  191. draft.innerText = "Save tags (Draft)";
  192. draft.addEventListener("click", () => saveReorder("Save As Draft", draft));
  193. fieldset.appendChild(draft);
  194. }
  195. const post = document.createElement("button");
  196. post.style.display = "inline-block";
  197. post.style.cursor = "pointer";
  198. post.style.margin = "0 1em 1em auto";
  199. post.style.float = "right";
  200. post.type = "button";
  201. post.innerText = "Save tags (Post)";
  202. post.addEventListener("click", () => saveReorder("Post", post));
  203. fieldset.appendChild(post);
  204. const copyAll = document.createElement("button");
  205. copyAll.style.display = "inline-block";
  206. copyAll.style.cursor = "pointer";
  207. copyAll.style.margin = "0 1em 1em auto";
  208. copyAll.style.float = "right";
  209. copyAll.type = "button";
  210. copyAll.innerText = "Copy all tags";
  211. copyAll.addEventListener("click", () => {
  212. const csv = [];
  213. [fandomTags, relationshipTags, characterTags, freeformTags].forEach((list) => {
  214. csv.push(getTagsCsv(list));
  215. });
  216. copyToClipboard(copyAll, csv.join("\n"));
  217. });
  218. fieldset.appendChild(copyAll);
  219. }
  220. const getErrorFromResponse = async (response) => {
  221. const html = new DOMParser().parseFromString(await response.text(), "text/html");
  222. const error = html.getElementById("error");
  223. if (error) {
  224. alert(`${error.innerText}`);
  225. return true;
  226. }
  227. return false;
  228. };
  229. const saveReorder = async (action, button) => {
  230. const oldText = button.innerText;
  231. button.innerText = "Saving tags...";
  232. const formData = new FormData(form);
  233. if (action === "Post") {
  234. formData.set("update_button", action);
  235. }
  236. else if (action === "Save As Draft") {
  237. formData.set("save_button", action);
  238. }
  239. formData.set("work[fandom_string]", "."); // Fandom is required, so set a single placeholder fandom
  240. formData.set("work[relationship_string]", "");
  241. formData.set("work[character_string]", "");
  242. formData.set("work[freeform_string]", "");
  243. const emptyTags = new URLSearchParams(Array.from(formData.entries()).map(([key, value]) => [
  244. key,
  245. value.toString(),
  246. ]));
  247. await fetch(form.action, {
  248. method: form.method,
  249. headers: {
  250. "content-type": "application/x-www-form-urlencoded",
  251. },
  252. body: emptyTags.toString(),
  253. })
  254. .then(async (response) => {
  255. if (await getErrorFromResponse(response)) {
  256. return;
  257. }
  258. // Wait a bit before sending next request
  259. await delay(1000);
  260. formData.set("work[fandom_string]", getTagsCsv(fandomTags));
  261. formData.set("work[relationship_string]", getTagsCsv(relationshipTags));
  262. formData.set("work[character_string]", getTagsCsv(characterTags));
  263. formData.set("work[freeform_string]", getTagsCsv(freeformTags));
  264. const realTags = new URLSearchParams(Array.from(formData.entries()).map(([key, value]) => [
  265. key,
  266. value.toString(),
  267. ]));
  268. await fetch(form.action, {
  269. method: form.method,
  270. headers: {
  271. "content-type": "application/x-www-form-urlencoded",
  272. },
  273. body: realTags.toString(),
  274. })
  275. .then(async (response) => {
  276. if (!(await getErrorFromResponse(response))) {
  277. button.innerText = "Saved!";
  278. await delay(2000);
  279. }
  280. })
  281. .catch(() => {
  282. alert(`ERROR: Failed to save tags. REASON: Possibly network issues. Try again in a minute.`);
  283. });
  284. })
  285. .catch(() => {
  286. alert(`ERROR: Failed to clear tags. REASON: Possibly network issues. Try again in a minute.`);
  287. });
  288. button.innerText = oldText;
  289. };
  290. })();