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

  1. // ==UserScript==
  2. // @name AO3: [Wrangling] Mark Illegal Characters in Canonicals
  3. // @namespace https://greasyfork.org/en/users/906106-escctrl
  4. // @version 3.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. /* eslint-disable no-multi-spaces */
  13. 'use strict';
  14.  
  15. // utility to reduce verboseness
  16. const q = (s, n=document) => n.querySelector(s);
  17. const qa = (s, n=document) => n.querySelectorAll(s);
  18. const ins = (n, l, html) => n.insertAdjacentHTML(l, html);
  19.  
  20. // stop on retry later or styled Error pages
  21. if ( (q('#main') === null) || (q('#main.system.errors') !== null) ) return;
  22.  
  23. ins(q("head"), 'beforeend', `<style tyle="text/css">
  24. .illegalChars svg { width: 1em; height: 1em; display: inline-block; vertical-align: -0.15em; }
  25. .illegalCharsInline { display: inline-block; padding: 0 !important; margin: 0.1em 0.1em 0.1em 0.5em !important; }
  26. .illegalCharsInline p { padding: 0.1em 0.3em; fontWeight: normal; }
  27. </style>`);
  28.  
  29. // on every page the way to find the tags to check is slightly different
  30. let main = q('#main');
  31. if (main.classList.contains('tags-wrangle')) checkBinTags();
  32. else if (main.classList.contains('tags-edit') || main.classList.contains('tags-update')) checkEditTag(); // .tags-update after a Save failed
  33. else if (main.classList.contains('tags-show')) checkTag();
  34. else if (main.classList.contains('tags-search')) checkSearchResults();
  35. else if (main.classList.contains('tags-new')) checkAsYouType();
  36. // *************** GENERAL FUNCTIONS ***************
  37.  
  38. // a holistic function to check what's not allowed
  39. // running as separate matches because regex alternation can't flag multiple issues on same letter
  40. function hasIllegalChars(string, is_fandom, refNode, befNode = null, inline = false) {
  41. let testing = { // includes the regex to check (r) and the error message to log (l)
  42. letter: { r: /[^\p{Script=Latin}0-9 \-().&/'"|:!#_]/ }, // non-latin (including accented) characters and special chars (with a few exceptions)
  43. multiap: { r: /'{2,}/, l: "two ' instead of \"" }, // two apostrophes '' used instead of a quote "
  44. slash1: { r: / \//, l: "space before /" }, // a slash with spaces before or after
  45. slash2: { r: /\/ /, l: "space after /" },
  46. amp1: { r: /[^ ]&/, l: "no space before &" }, // an apersand without spaces before and after
  47. amp2: { r: /&[^ ]/, l: "no space after &" },
  48. bracket1: { r: / \)/, l: "space before )" }, // opening parenthesis with spaces after, and closing with spaces before
  49. bracket2: { r: /\( /, l: "space after (" },
  50. multispace: { r: / {2,}/, l: "multiple spaces" }, // multiple spaces after each other
  51. spbegin: { r: /^ /, l: "space at beginning" }, // space at the beginning or end of the string
  52. spend: { r: / $/, l: "space at end" }
  53. }
  54. if (is_fandom) { //in fandoms we allow letters, numbers and tone/accent marks of ANY script (not just Latin) and more special characters
  55. testing.letter.r = /[^\p{L}\p{M}\p{N} \-().&/'"|:!#?_]/; // any script letters, accent marks, numbers, and more special chars
  56. delete testing.slash1; delete testing.slash2;
  57. delete testing.amp1; delete testing.amp2;
  58. }
  59.  
  60. let issues = [];
  61. for (const [key, value] of Object.entries(testing)) {
  62. let found = string.match(new RegExp(value.r, "gui")); // add flags, run checks
  63. if (found === null) continue; // skip if nothing's illegal
  64.  
  65. for (let match of found) { // build the list of readable issues
  66. if (key === "letter") {
  67. if (match === "\t") match = "tab";
  68. issues.push(match);
  69. }
  70. else issues.push(value.l);
  71. }
  72. }
  73. if (string.split("(").length < string.split(")").length) issues.push("missing a ("); // should have the same number of ( and )
  74. if (string.split("(").length > string.split(")").length) issues.push("missing a )");
  75.  
  76. if (issues.length !== 0) insertHeadsUp(issues, refNode, befNode, inline); // write out whatever issues there are
  77. }
  78. // print a box to explain the problem
  79. function insertHeadsUp(illegalChars, refNode, befNode, inline) {
  80.  
  81. // setting up the div to contain the heads-up to the user
  82. const warningNode = document.createElement("div");
  83. warningNode.classList.add("notice", "illegalChars");
  84. if (inline) warningNode.classList.add("illegalCharsInline");
  85.  
  86. // SVGs from Heroicons https://heroicons.com (MIT license Copyright (c) Tailwind Labs, Inc. https://github.com/tailwindlabs/heroicons/blob/master/LICENSE)
  87. let icon = `<svg viewBox="0 0 24 24" fill="currentColor"><title>Questionable characters found, please check</title><path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm11.378-3.917c-.89-.777-2.366-.777-3.255 0a.75.75 0 0 1-.988-1.129c1.454-1.272 3.776-1.272 5.23 0 1.513 1.324 1.513 3.518 0 4.842a3.75 3.75 0 0 1-.837.552c-.676.328-1.028.774-1.028 1.152v.75a.75.75 0 0 1-1.5 0v-.75c0-1.279 1.06-2.107 1.875-2.502.182-.088.351-.199.503-.331.83-.727.83-1.857 0-2.584ZM12 18a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" /></svg>`;
  88. warningNode.innerHTML = `<p>${icon} ${illegalChars.join(", ")}</p>`;
  89. // if that already exists, we're gonna replace it rather than add more divs
  90. if (refNode.querySelector(".illegalChars")) refNode.replaceChild(warningNode, refNode.querySelector(".illegalChars"));
  91. else refNode.insertBefore(warningNode, befNode);
  92. }
  93.  
  94. // remove the explain box again
  95. function removeHeadsUp(refNode) {
  96. if (refNode.querySelector(".illegalChars")) refNode.removeChild(refNode.querySelector(".illegalChars"));
  97. }
  98.  
  99. // *************** PAGE HANDLING FUNCTIONS ***************
  100.  
  101. // New tag page
  102. function checkAsYouType() {
  103. // debounce, deferred helper functions: Mulan @ https://stackoverflow.com/a/68228099
  104. function debounce (task, ms) { let t = { promise: null, cancel: _ => void 0 }; return async (...args) => { try { t.cancel(); t = deferred(ms); await t.promise; await task(...args); } catch (_) { } } }
  105. function deferred (ms) { let cancel, promise = new Promise((resolve, reject) => { cancel = reject; setTimeout(resolve, ms) }); return { promise, cancel } }
  106.  
  107. // actual checks for illegal characters and tag length
  108. function checkInput() {
  109. var checkNode = q("#tag_name");
  110. removeHeadsUp(checkNode.parentNode); // reset
  111. if (checkNode.value === "") return;
  112.  
  113. // which tag type are you trying to create? fandom or anything else?
  114. const isFandom = q('#tag_type_fandom').checked;
  115. hasIllegalChars(checkNode.value, isFandom, checkNode.parentNode);
  116.  
  117. // length counter
  118. let label = q('dt label[for="tag_name"]');
  119. label.innerText = "Name (" + checkNode.value.length +")";
  120.  
  121. // extra special handling: tag length>150 error
  122. const refNode = checkNode.parentNode;
  123. if (checkNode.value.length > 150) {
  124. const errorNode = document.createElement("div");
  125. errorNode.id = "tooLong";
  126. errorNode.classList.add("error");
  127. errorNode.innerHTML = "<p>Sorry, you'll need to trim this down. You're at "+ checkNode.value.length +" characters!</p>";
  128.  
  129. // if that already exists, we're gonna replace it rather than add more divs
  130. if (refNode.querySelector("#tooLong")) refNode.replaceChild(errorNode, refNode.querySelector("#tooLong"));
  131. else refNode.insertBefore(errorNode, null);
  132. }
  133. else if (refNode.querySelector("#tooLong")) refNode.removeChild(refNode.querySelector("#tooLong"));
  134. }
  135.  
  136. // add the same event listener to all checkbox elements - checkInput is executed immediately (no debounce necessary)
  137. [ q('#tag_type_fandom'), q('#tag_type_character'), q('#tag_type_relationship'), q('#tag_type_freeform') ].forEach((el) => el.addEventListener("input", checkInput));
  138. // debounced listener while typing
  139. q('#tag_name').addEventListener("input", debounce(checkInput, 500));
  140.  
  141. // 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
  142. q("#tag_name").dispatchEvent(new Event("input"));
  143. }
  144.  
  145. // Landing page
  146. function checkTag() {
  147. // only if the viewed tags is canonical
  148. var tagDescr = q(".tag>p").innerText;
  149. if (tagDescr.indexOf("It's a canonical tag") < 0) return true;
  150.  
  151. // first the viewed tag itself
  152. var checkNode = q(".tag .header h2.heading");
  153. var tagType = tagDescr.match(/This tag belongs to the (.+) Category/i);
  154. tagType = tagType[1];
  155. hasIllegalChars(checkNode.innerText, (tagType === "Fandom"), checkNode.parentNode.parentNode, checkNode.parentNode.parentNode.children[1]);
  156.  
  157. // then the meta and subtags (if any)
  158. checkNode = qa("div.meta.listbox a.tag, div.sub.listbox a.tag");
  159. checkNode.forEach((n) => hasIllegalChars(n.innerText, (tagType === "Fandom"), n.parentNode, n.parentNode.children[1], true));
  160. // 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
  161. }
  162.  
  163. // Wrangle Bin Page
  164. // sadly we can't tell here at all if we're ever looking at fandoms
  165. function checkBinTags() {
  166. // this needs a different approach to the logic:
  167. // don't check show=mergers at all, too repetitive
  168. var searchParams = new URLSearchParams(window.location.search);
  169. if (searchParams.get('show') == "mergers") return true;
  170.  
  171. // create a key -> value pair Map of the table columns, so we know which column to check
  172. var tableIndexes = new Map();
  173. qa("#wrangulator table thead th").forEach((th, ix) => tableIndexes.set(th.innerText, ix));
  174.  
  175. // now we can loop through the list of tags
  176. var checkNode;
  177. var checkRows = qa("#wrangulator table tbody tr");
  178. checkRows.forEach((r) => {
  179. // if there's a column "Canonical" and the cell says "Yes" then we check the tag itself
  180. if (tableIndexes.has("Canonical") && r.cells[tableIndexes.get("Canonical")].innerText == "Yes") {
  181. checkNode = q("label", r.cells[0]);
  182. hasIllegalChars(checkNode.innerText, (searchParams.get('show') === "fandoms"), checkNode.parentNode);
  183. }
  184.  
  185. // if there's a column "Synonym", we check the content of that cell (there'll only be one tag)
  186. if (tableIndexes.has("Synonym") && r.cells[tableIndexes.get("Synonym")].innerText.trim() !== "") {
  187. checkNode = q("a", r.cells[tableIndexes.get("Synonym")]);
  188. hasIllegalChars(checkNode.innerText, (searchParams.get('show') === "fandoms"), checkNode.parentNode);
  189. }
  190.  
  191. // if there's a column "Characters", we check the content of that cell (there might be multiple tags)
  192. if (tableIndexes.has("Characters") && r.cells[tableIndexes.get("Characters")].innerText.trim() !== "") {
  193. checkNode = qa("a", r.cells[tableIndexes.get("Characters")]);
  194. checkNode.forEach((n) => hasIllegalChars(n.innerText, false, n.parentNode));
  195. }
  196.  
  197. // if there's a column "Metatag", we check the content of that cell (there might be multiple tags)
  198. if (tableIndexes.has("Metatag") && r.cells[tableIndexes.get("Metatag")].innerText.trim() !== "") {
  199. checkNode = qa("a", r.cells[tableIndexes.get("Metatag")]);
  200. checkNode.forEach((n) => hasIllegalChars(n.innerText, (searchParams.get('show') === "fandoms"), n.parentNode));
  201. }
  202. });
  203. }
  204.  
  205. // Tag Search
  206. function checkSearchResults() {
  207. // with search results table userscript enabled
  208. var checkNodes = qa("table#resulttable .resulttag.canonical a, table#resulttable .resultName.canonical a");
  209. checkNodes.forEach((n) => hasIllegalChars(n.innerText, (q('.resulttype, .resultType', n.parentNode.parentNode).title.startsWith("Fandom")), n.parentNode, null, true));
  210.  
  211. // with plain search results page
  212. checkNodes = qa("ol.tag li span.canonical a.tag");
  213. checkNodes.forEach((n) => hasIllegalChars(n.innerText, (n.parentNode.firstChild.textContent.trim() == "Fandom:"), n.parentNode.parentNode, null, true));
  214. }
  215.  
  216. // Edit Tag Page
  217. function checkEditTag() {
  218. const tagCanonical = q('#tag_canonical');
  219. const tagName = q("#tag_name");
  220. const tagType = q('#tag_sortable_name') !== null ? "Fandom" : ""; // only fandom tags have the name-for-sorting field
  221.  
  222. // initial check only if the tag is already canonical
  223. if (tagCanonical.checked) hasIllegalChars(tagName.value, (tagType === "Fandom"), tagName.parentNode);
  224.  
  225. // if the tag's canonical status is changed
  226. tagCanonical.addEventListener("input", (event) => {
  227. removeHeadsUp(tagName.parentNode);
  228. if (event.target.checked) hasIllegalChars(tagName.value, (tagType === "Fandom"), tagName.parentNode);
  229. });
  230.  
  231. // if this is a synonym, check the canonical tag it's synned to
  232. const synonym = q('ul.autocomplete .added.tag', tagName.parentNode.parentNode);
  233. if (synonym !== null) hasIllegalChars(synonym.firstChild.textContent.trim(), (tagType === "Fandom"), synonym.parentNode.parentNode, synonym.parentNode.parentNode.children[1]);
  234.  
  235. // if this is canonical, check its sub- and metatags
  236. const metasubs = qa('#parent_MetaTag_associations_to_remove_checkboxes ul li a, #child_SubTag_associations_to_remove_checkboxes ul li a');
  237. if (metasubs !== null) metasubs.forEach((n) => hasIllegalChars(n.innerText, (tagType === "Fandom"), n.parentNode));
  238.  
  239. // if this is any other type of tag that's in a fandom, check the fandom tag
  240. const fandoms = qa('#parent_Fandom_associations_to_remove_checkboxes ul li a');
  241. if (fandoms !== null) fandoms.forEach((n) => hasIllegalChars(n.innerText, true, n.parentNode));
  242.  
  243. // if this is a relationship, check the tagged characters
  244. const chars = qa('#parent_Character_associations_to_remove_checkboxes ul li a');
  245. if (chars !== null) chars.forEach((n) => hasIllegalChars(n.innerText, false, n.parentNode));
  246. }