AO3: [Wrangling] Mark Illegal Characters in Canonicals

Warns about any canonical tag that includes characters which should, per guidelines, be avoided. Checks on new tag, edit tag, search results, wrangle bins, and tag landing pages

As of 2024-08-02. See the latest version.

  1. // ==UserScript==
  2. // @name AO3: [Wrangling] Mark Illegal Characters in Canonicals
  3. // @namespace https://greasyfork.org/en/users/906106-escctrl
  4. // @version 2.0
  5. // @description Warns about any canonical tag that includes characters which should, per guidelines, be avoided. Checks on new tag, edit tag, search results, wrangle bins, and tag landing pages
  6. // @author escctrl
  7. // @match *://*.archiveofourown.org/tags/*
  8. // @license MIT
  9. // @grant none
  10. // ==/UserScript==
  11.  
  12. (function() {
  13. 'use strict';
  14.  
  15. // we wanna check on a bunch of different pages, and everywhere the check is slightly different
  16.  
  17. var page_url = window.location.pathname;
  18. // just in case the URL ended with a / we get rid of that
  19. // that usually doesn't happen from AO3 links on the site, but may be how browsers store bookmarks or history
  20. if (page_url.endsWith("/")) { page_url = page_url.slice(0, page_url.length-1); }
  21.  
  22. if (page_url == "/tags/new") checkAsYouType(); // New Tag page
  23. else if (page_url == "/tags/search") checkSearchResults(); // Tag Search page
  24. else if (page_url.match(/^\/tags\/.+\/edit$/gi)) checkEditTag(); // Edit page
  25. else if (page_url.match(/^\/tags\/.+\/wrangle$/gi)) checkBinTags(); // Wrangle page
  26. else if (page_url.match(/^\/tags\/[^\/]+$/gi)) checkTag(); // Tag Landing page
  27. // that excludes anything including another slash, which would only incorrectly match on tags/new and tags/search
  28. // but those would have already jumped into the other functions and would never get here
  29. })();
  30.  
  31. // *************** GENERAL FUNCTIONS ***************
  32.  
  33. // a holistic function to check
  34. // not allowed: non-latin (including accented) characters and special chars (with a few exceptions)
  35. // two apostrophes '' (used instead of a quote ")
  36. // a slash with spaces before or after
  37. // an apersand without spaces before and after
  38. // space at the beginning or end of the string
  39. // multiple spaces after each other
  40. // this returns the matched characters in an array
  41. function hasIllegalChars(string) {
  42. return string.match(/[^\p{Script=Latin}0-9 \-().&/'"|:!]|'{2,}| \/|\/ |[^ ]&|&[^ ]| {2,}|^ | $/gui);
  43. }
  44.  
  45. // similar to above, but in fandoms we allow letters, numbers and tone/accent marks of ANY script, not just Latin
  46. // also more special characters are allowed
  47. function hasFandomIllegalChars(string) {
  48. return string.match(/[^\p{L}\p{M}\p{N} \-().&/'"|:!#?_]|'{2,}| {2,}|^ | $/gui);
  49. }
  50.  
  51. // print a box to explain the problem
  52. function insertHeadsUp(illegalChars, refNode, befNode = null, inline = false) {
  53. // describe non-printable chars and other hard to identify issues
  54. illegalChars.forEach((val, ix) => {
  55. if (val == "''") illegalChars[ix] = "2 single quotes";
  56. else if (val == "/ " || val == " /") illegalChars[ix] = "space around the /";
  57. else if (val.slice(0,1) == "&" || val.slice(-1) == "&") illegalChars[ix] = "no space around the &";
  58. else if (val.trim() == "")
  59. illegalChars[ix] = (val == "\t") ? "tab" :
  60. (val === " " && ix == 0 && refNode.childNodes[0].value.slice(0, 1) === " ") ? "space in front" :
  61. (val === " " && refNode.childNodes[0].value.slice(-1) === " ") ? "space at end" :
  62. "multiple spaces";
  63. });
  64. // setting up the div to contain the heads-up to the user
  65. const warningNode = document.createElement("div");
  66. warningNode.id = "illegalChars";
  67. warningNode.classList.add("notice");
  68.  
  69. warningNode.innerHTML = "<p>Questionable: " + illegalChars.join(", ") + "</p>";
  70.  
  71. if (inline) {
  72. warningNode.style.display = "inline-block";
  73. warningNode.style.padding = "0";
  74. warningNode.style.margin = "0.1em 0.1em 0.1em 0.5em";
  75. warningNode.children[0].style.padding = "0.1em 0.3em";
  76. warningNode.children[0].style.fontWeight = "normal";
  77. }
  78.  
  79. // if that already exists, we're gonna replace it rather than add more divs
  80. if (refNode.querySelector("#illegalChars")) refNode.replaceChild(warningNode, refNode.querySelector("#illegalChars"));
  81. else refNode.insertBefore(warningNode, befNode);
  82. }
  83.  
  84. // remove the explain box again
  85. function removeHeadsUp(refNode) {
  86. if (refNode.querySelector("#illegalChars")) refNode.removeChild(refNode.querySelector("#illegalChars"));
  87. }
  88.  
  89. // *************** PAGE HANDLING FUNCTIONS ***************
  90.  
  91. // New tag page
  92. function checkAsYouType() {
  93. // a little JS magic to quickly add the same event listener to all elements
  94. [ document.getElementById("tag_name"),
  95. document.getElementById('tag_type_fandom'),
  96. document.getElementById('tag_type_character'),
  97. document.getElementById('tag_type_relationship'),
  98. document.getElementById('tag_type_freeform')
  99. ].forEach((el) => {
  100. el.addEventListener("input", () => {
  101. var checkNode = document.getElementById("tag_name");
  102.  
  103. // which tag type are you trying to create? fandom or anything else?
  104. const isFandom = document.getElementById('tag_type_fandom').checked;
  105. var issues = (isFandom) ? hasFandomIllegalChars(checkNode.value) : hasIllegalChars(checkNode.value);
  106. if (issues !== null) insertHeadsUp(issues, checkNode.parentNode);
  107. else removeHeadsUp(checkNode.parentNode);
  108.  
  109. // length counter
  110. let label = document.querySelector('dt label[for="tag_name"]');
  111. label.innerText = "Name (" + checkNode.value.length +")";
  112.  
  113. // extra special handling: tag length>100 error
  114. const refNode = checkNode.parentNode;
  115. if (checkNode.value.length > 100) {
  116. const errorNode = document.createElement("div");
  117. errorNode.id = "tooLong";
  118. errorNode.classList.add("error");
  119. errorNode.innerHTML = "<p>Sorry, you'll need to trim this down. You're at "+ checkNode.value.length +" characters!</p>";
  120.  
  121. // if that already exists, we're gonna replace it rather than add more divs
  122. if (refNode.querySelector("#tooLong")) refNode.replaceChild(errorNode, refNode.querySelector("#tooLong"));
  123. else refNode.insertBefore(errorNode, null);
  124. }
  125. else if (refNode.querySelector("#tooLong")) refNode.removeChild(refNode.querySelector("#tooLong"));
  126. });
  127. });
  128. // on page load, trigger event once. browser remembers previous form selections/input upon page refresh and box would otherwise not appear until another change is made
  129. document.getElementById("tag_name").dispatchEvent(new Event("input"));
  130. }
  131.  
  132. // Landing page
  133. function checkTag() {
  134. // only if the viewed tags is canonical
  135. var tagDescr = document.querySelector(".tag>p").innerText;
  136. if (tagDescr.indexOf("It's a common tag") < 0) return true;
  137.  
  138. // first the viewed tag itself
  139. var checkNode = document.querySelector(".tag .header h2.heading");
  140. var tagType = tagDescr.match(/This tag belongs to the (.+) Category/i);
  141. tagType = tagType[1];
  142. var issues = (tagType == "Fandom") ? hasFandomIllegalChars(checkNode.innerText) : hasIllegalChars(checkNode.innerText);
  143. if (issues !== null) insertHeadsUp(issues, checkNode.parentNode.parentNode, checkNode.parentNode.parentNode.children[1]);
  144.  
  145. // then the meta and subtags (if any)
  146. checkNode = document.querySelectorAll("div.meta.listbox a.tag, div.sub.listbox a.tag");
  147. checkNode.forEach((n) => {
  148. var issues = (tagType == "Fandom") ? hasFandomIllegalChars(n.innerText) : hasIllegalChars(n.innerText);
  149. if (issues !== null) insertHeadsUp(issues, n.parentNode, n.parentNode.children[1], true);
  150. });
  151. // it would be really cool if we could check Parent Tags as well, but we can't tell which of those are fandoms vs. anything else
  152. }
  153.  
  154. // Wrangle Bin Page
  155. // sadly we can't tell here at all if we're ever looking at fandoms
  156. function checkBinTags() {
  157. // this needs a different approach to the logic:
  158. // don't check show=mergers at all, too repetitive
  159. var searchParams = new URLSearchParams(window.location.search);
  160. if (searchParams.get('show') == "mergers") return true;
  161.  
  162. // create a key -> value pair Map of the table columns, so we know which column to check
  163. var tableIndexes = new Map();
  164. document.querySelectorAll("#wrangulator table thead th").forEach((th, ix) => {
  165. tableIndexes.set(th.innerText, ix);
  166. });
  167.  
  168. // now we can loop through the list of tags
  169. var issues, checkNode;
  170. var checkRows = document.querySelectorAll("#wrangulator table tbody tr");
  171. checkRows.forEach((r) => {
  172. // if there's a column "Canonical" and the cell says "Yes" then we check the tag itself
  173. if (tableIndexes.has("Canonical") && r.cells[tableIndexes.get("Canonical")].innerText == "Yes") {
  174. checkNode = r.cells[0].querySelector("label");
  175. issues = searchParams.get('show') == "fandoms" ? hasFandomIllegalChars(checkNode.innerText) : hasIllegalChars(checkNode.innerText);
  176. if (issues !== null) insertHeadsUp(issues, checkNode.parentNode);
  177. }
  178.  
  179. // if there's a column "Synonym", we check the content of that cell (there'll only be one tag)
  180. if (tableIndexes.has("Synonym") && r.cells[tableIndexes.get("Synonym")].innerText.trim() !== "") {
  181. checkNode = r.cells[tableIndexes.get("Synonym")].querySelector("a");
  182. issues = searchParams.get('show') == "fandoms" ? hasFandomIllegalChars(checkNode.innerText) : hasIllegalChars(checkNode.innerText);
  183. if (issues !== null) insertHeadsUp(issues, checkNode.parentNode);
  184. }
  185.  
  186. // if there's a column "Characters", we check the content of that cell (there might be multiple tags)
  187. if (tableIndexes.has("Characters") && r.cells[tableIndexes.get("Characters")].innerText.trim() !== "") {
  188. checkNode = r.cells[tableIndexes.get("Characters")].querySelectorAll("a");
  189. checkNode.forEach((n) => {
  190. issues = hasIllegalChars(n.innerText);
  191. if (issues !== null) insertHeadsUp(issues, n.parentNode);
  192. });
  193. }
  194.  
  195. // if there's a column "Metatag", we check the content of that cell (there might be multiple tags)
  196. if (tableIndexes.has("Metatag") && r.cells[tableIndexes.get("Metatag")].innerText.trim() !== "") {
  197. checkNode = r.cells[tableIndexes.get("Metatag")].querySelectorAll("a");
  198. checkNode.forEach((n) => {
  199. issues = searchParams.get('show') == "fandoms" ? hasFandomIllegalChars(checkNode.innerText) : hasIllegalChars(n.innerText);
  200. if (issues !== null) insertHeadsUp(issues, n.parentNode);
  201. });
  202. }
  203. });
  204. }
  205.  
  206. // Tag Search
  207. function checkSearchResults() {
  208. // with search results table userscript enabled
  209. var checkNodes = document.querySelectorAll("table#resulttable .resulttag.canonical a, table#resulttable .resultName.canonical a");
  210. checkNodes.forEach((n) => {
  211. var issues = (n.parentNode.parentNode.querySelector('td.resulttype').title == "Fandom") ? hasFandomIllegalChars(n.innerText) : hasIllegalChars(n.innerText);
  212. if (issues !== null) insertHeadsUp(issues, n.parentNode, null, true);
  213. });
  214.  
  215. // with plain search results page
  216. checkNodes = document.querySelectorAll("ol.tag li span.canonical a.tag");
  217. checkNodes.forEach((n) => {
  218. var issues = (n.parentNode.firstChild.textContent.trim() == "Fandom:") ? hasFandomIllegalChars(n.innerText) : hasIllegalChars(n.innerText);
  219. if (issues !== null) insertHeadsUp(issues, n.parentNode.parentNode, null, true);
  220. });
  221. }
  222.  
  223. // Edit Tag Page
  224. function checkEditTag() {
  225. const tagCanonical = document.getElementById('tag_canonical');
  226. const tagType = document.querySelector('#edit_tag fieldset:first-of-type dd strong').innerText;
  227. var issues;
  228.  
  229. // initial check only if the tag is already canonical
  230. if (tagCanonical.checked) {
  231. var checkNode = document.getElementById("tag_name");
  232. issues = (tagType == "Fandom") ? hasFandomIllegalChars(checkNode.value) : hasIllegalChars(checkNode.value);
  233. if (issues !== null) insertHeadsUp(issues, checkNode.parentNode);
  234. }
  235.  
  236. // if the tag's canonical status is changed
  237. tagCanonical.addEventListener("input", (event) => {
  238. var checkNode = document.getElementById("tag_name");
  239. if (event.target.checked) {
  240. var issues = (tagType == "Fandom") ? hasFandomIllegalChars(checkNode.value) : hasIllegalChars(checkNode.value);
  241. if (issues !== null) insertHeadsUp(issues, checkNode.parentNode);
  242. else removeHeadsUp(checkNode.parentNode);
  243. }
  244. else removeHeadsUp(checkNode.parentNode);
  245. });
  246.  
  247. // if this is a synonym, check the canonical tag it's synned to
  248. const synonym = document.querySelector('#edit_tag fieldset:first-of-type dd ul.autocomplete .added.tag');
  249. if (synonym !== null) {
  250. issues = (tagType == "Fandom") ? hasFandomIllegalChars(synonym.firstChild.textContent.trim()) : hasIllegalChars(synonym.firstChild.textContent.trim());
  251. if (issues !== null) insertHeadsUp(issues, synonym.parentNode.parentNode, synonym.parentNode.parentNode.children[1]);
  252. }
  253.  
  254. // if this is canonical, check its sub- and metatags
  255. const metasubs = document.querySelectorAll('#parent_MetaTag_associations_to_remove_checkboxes ul li a, #child_SubTag_associations_to_remove_checkboxes ul li a');
  256. if (metasubs !== null) {
  257. metasubs.forEach((n) => {
  258. issues = (tagType == "Fandom") ? hasFandomIllegalChars(n.innerText) : hasIllegalChars(n.innerText);
  259. if (issues !== null) insertHeadsUp(issues, n.parentNode);
  260. });
  261. }
  262.  
  263. // if this is any other type of tag that's in a fandom, check the fandom tag
  264. const fandoms = document.querySelectorAll('#parent_Fandom_associations_to_remove_checkboxes ul li a');
  265. if (fandoms !== null) {
  266. fandoms.forEach((n) => {
  267. issues = hasFandomIllegalChars(n.innerText);
  268. if (issues !== null) insertHeadsUp(issues, n.parentNode);
  269. });
  270. }
  271.  
  272. // if this is a relationship, check the tagged characters
  273. const chars = document.querySelectorAll('#parent_Character_associations_to_remove_checkboxes ul li a');
  274. if (chars !== null) {
  275. chars.forEach((n) => {
  276. issues = hasIllegalChars(n.innerText);
  277. if (issues !== null) insertHeadsUp(issues, n.parentNode);
  278. });
  279. }
  280. }