AO3: [Wrangling] Boot tags to another fandom!

Easily move tags from one fandom to another via wrangulator!!

  1. // ==UserScript==
  2. // @name AO3: [Wrangling] Boot tags to another fandom!
  3. // @description Easily move tags from one fandom to another via wrangulator!!
  4. // @version 1.0.1
  5.  
  6. // @author owlwinter
  7. // @namespace N/A
  8. // @license MIT license
  9.  
  10. // @match *://*.archiveofourown.org/tags/*/wrangle?*status=unfilterable*
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. //Gets the edit url of the tag from the checkbox
  18. const get_url = function get_url(checkbox) {
  19. //If iconify is enabled, this will return the tag's link
  20. const a = checkbox.parentElement.parentElement.querySelector("ul.actions > li[title='Edit'] > a");
  21. if (a) {
  22. return a.href;
  23. }
  24. //If iconify is not enabled, we'll use the default path
  25. const buttons = checkbox.parentElement.parentElement.querySelectorAll("ul.actions > li > a");
  26. return array(buttons).filter(b => b.innerText == "Edit")[0].href;
  27. }
  28.  
  29. //Hides the checked rows after we do our work
  30. const delete_tag_row = function delete_tag_row(checkbox) {
  31. const row = checkbox.parentElement.parentElement;
  32. row.parentElement.removeChild(row);
  33. }
  34.  
  35. //Each time we finish a request, we'll add 1 to the "done" count
  36. //When it's equal to the number of tags we had to make requests for, we know we gone through all the checked rows!
  37. var done;
  38. //Holds any errors from form submit
  39. var errors;
  40. //All tags selected to boot
  41. var tags;
  42.  
  43. // Called for each checked tag
  44. // Submits form data & passes on any resulting error messages
  45. // If succesfully done for every selected tag, will also display success banner :)
  46. // `url` is the URL that will process the form submission
  47. // `fullfandoms` is an array of all the new fandoms to be added
  48. // `tag` is the tag being edited
  49. // `fd` is the new formdata
  50. const submit_xhr = function submit_xhr(url, fullfandoms, tag, fd) {
  51. const xhr = new XMLHttpRequest();
  52. xhr.onreadystatechange = function xhr_onreadystatechange() {
  53. if (xhr.readyState == xhr.DONE ) {
  54. //After form submitted:
  55. if (xhr.status == 200) {
  56. // Check for errors
  57. const err = xhr.responseXML.documentElement.querySelector("#error");
  58. if (err) {
  59. alert(err.innerText);
  60. errors.push(err)
  61. }
  62. done += 1;
  63. //If succesfully done for every selected tag, we want to display a success banner!
  64. if (done == tags.length) {
  65. // for some reason this seems to always be present on the page, even if there is no content in it
  66. var flash = document.getElementsByClassName("flash")[0]
  67. flash.innerHTML = "";
  68. flash.classList.add("notice")
  69. if (errors.length != 0) {
  70. //In case the user wasn't bonked enough for their error,
  71. //puts the red box with the error message from the iframe into the flash action message area
  72. errors.forEach(e => {
  73. e.parentElement.removeChild(e)
  74. flash.appendChild(e);
  75. });
  76. } else {
  77. // happy path :)
  78. flash.appendChild(document.createTextNode("The following tags were successfully booted to "));
  79. flash.appendChild(document.createTextNode(fullfandoms.toString().replaceAll(',', ', ')))
  80. flash.appendChild(document.createTextNode(": "));
  81. const as = tags.map(checkbox => {
  82. const a = document.createElement("a")
  83. a.href = get_url(checkbox);
  84. a.target = "_blank"
  85. a.innerText = checkbox.labels[0].innerText;
  86. return a;
  87. });
  88. as.forEach((a, i) => {
  89. if (i != 0) {
  90. flash.appendChild(document.createTextNode(", "))
  91. }
  92. flash.appendChild(a);
  93. });
  94. tags.forEach(checkbox => delete_tag_row(checkbox));
  95. }
  96. flash.appendChild(document.createElement("br"))
  97. flash.scrollIntoView();
  98. //Unset explicit background to let it go back to the default CSS background
  99. button.style.background = ""
  100. button.disabled = false
  101. button.innerText = "Boot";
  102. }
  103. }
  104. else if (xhr.status == 429) {
  105. // ~ao3jail
  106. alert("Rate limited. Sorry :(")
  107. } else {
  108. alert("Unexpected error, check the console :(")
  109. console.log(xhr)
  110. }
  111. }
  112. }
  113. xhr.open("POST", url)
  114. xhr.responseType = "document"
  115. xhr.send(fd)
  116. }
  117.  
  118. // Called for each checked tag
  119. // Gets the edit form data about the tag and sets up request that will actually submit
  120. // `url` is the url of the edit tag page
  121. // `newfandoms` is an array of all the new fandoms to be added
  122. const boot_xhr = function boot_xhr(url, newfandoms) {
  123. const xhr = new XMLHttpRequest();
  124. xhr.onreadystatechange = function xhr_onreadystatechange() {
  125. if (xhr.readyState == xhr.DONE ) {
  126. if (xhr.status == 200) {
  127. //Fandoms the tag has pre-boot
  128. const oldfandoms = []
  129. const inner_input = xhr.responseXML.documentElement.querySelector("input[name='tag[syn_string]']")
  130. const tag = inner_input.value;
  131.  
  132. const form = xhr.responseXML.documentElement.querySelector("#edit_tag")
  133.  
  134. //Removing all fandoms the tag has pre-boot
  135. const checks = array(xhr.responseXML.documentElement.querySelectorAll("label > input[name='tag[associations_to_remove][]']")).filter(foo => foo.id.indexOf("parent_Fandom_associations_to_remove") != -1);
  136. for (const check of checks) {
  137. const name = check.parentElement.nextElementSibling.innerText
  138. oldfandoms.push(name)
  139. //Edge case - if user boots a tag to a fandom currently on the tag
  140. if (!newfandoms.includes(name)) {
  141. check.checked = true;
  142. }
  143. }
  144. //Setting the new fandoms on the tag
  145. xhr.responseXML.documentElement.querySelector("#tag_fandom_string").value = newfandoms;
  146.  
  147. console.log("Tag at url " + url + " booted from " + oldfandoms.toString() + " and booted to " + newfandoms)
  148.  
  149. const fd = new FormData(form);
  150.  
  151. //Second xhr call that will submit our edited data to the form
  152. submit_xhr(form.action, newfandoms, tag, fd);
  153. } else if (xhr.status == 429) {
  154. // ~ao3jail
  155. alert("Rate limited. Sorry :(")
  156. } else {
  157. alert("Unexpected error, check the console :(")
  158. console.log(xhr)
  159. }
  160. }
  161. }
  162. xhr.open("GET", url)
  163. xhr.responseType = "document"
  164. xhr.send()
  165. }
  166.  
  167. //Runs when the boot button is clicked
  168. const boot_fandoms = function boot_fandoms(e) {
  169. e.preventDefault()
  170.  
  171. //Gets all the rows with selected tags
  172. tags = array(document.querySelectorAll("input[name='selected_tags[]']")).filter(inp => inp.checked);
  173.  
  174. //If no tags are selected, bonk user >:(
  175. if (tags.length == 0) {
  176. alert("You need to select at least one tag to boot!")
  177. return;
  178. }
  179.  
  180. //The fandoms checked via the Fandom Assignment Shortcut script, if it is installed
  181. const fandomschecked = array(document.querySelectorAll("input[name='fandom_shortcut']:checked")).map(f => f.value)
  182. //The fandoms added via wrangulator textbox
  183. const fandomstext = document.getElementById("fandom_string").value.split(',')
  184. //Combine both fandom lists, then remove empty elements
  185. const fullfandoms = fandomschecked.concat(fandomstext).filter(n => n)
  186.  
  187. //If no fandoms are selected, bonk user >:(
  188. if (fullfandoms.length == 0) {
  189. alert("You need to select at least one fandom to boot to!")
  190. return;
  191. }
  192.  
  193. //Each time we finish a request, we'll add 1 to "done", and when it's equal to the number of tags we had to make requests for, we know we have finished them all
  194. done = 0;
  195. errors = []
  196.  
  197. button.disabled = "true";
  198. button.innerText = "Processing..."
  199. // There is no special style in the default ao3 styles for disabled buttons, so even when disabled, the button still looks clickable. This sets the background to
  200. // "lightgrey" instead of the default light -> dark gradient, to make it look more disabled. We undo this later by setting this property back to an empty string
  201. // after all our requests are done.
  202. button.style.background = "lightgrey";
  203.  
  204. //Runs hard boot xhr call on each tag selected
  205. tags.forEach(checkbox => {
  206. const url = get_url(checkbox)
  207. boot_xhr(url, fullfandoms)
  208. })
  209. }
  210.  
  211. // the return value of document.querySelectorAll is technically a "NodeList", which can be indexed like an array, but
  212. // doesn't have helpful functions like .map() or .forEach(). So this is a simple helper function to turn a NodeList
  213. // (or any other array-like object (indexed by integers starting at zero)) into an array
  214. const array = a => Array.prototype.slice.call(a, 0)
  215.  
  216. //Creates boot button
  217. const button = document.createElement("button");
  218. button.type = "text";
  219. button.style.textAlign = "center"
  220. button.style.marginRight = "5px"
  221. button.textContent = "Boot";
  222. button.addEventListener("click", boot_fandoms)
  223. document.querySelector("dd.submit").prepend(button);
  224.  
  225. // Your code here...
  226. })();