// ==UserScript==
// @name AO3: [Wrangling] Mark Illegal Characters in Canonicals
// @namespace https://greasyfork.org/en/users/906106-escctrl
// @version 2.4
// @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
// @author escctrl
// @match *://*.archiveofourown.org/tags/*
// @license MIT
// @grant none
// ==/UserScript==
(function() {
'use strict';
// stop on retry later or styled Error pages
if ( (document.getElementById('main') === null) || (document.querySelector('#main.system.errors') !== null) ) return;
// we wanna check on a bunch of different pages, and everywhere the check is slightly different
var page_url = window.location.pathname;
// just in case the URL ended with a / we get rid of that
// that usually doesn't happen from AO3 links on the site, but may be how browsers store bookmarks or history
if (page_url.endsWith("/")) { page_url = page_url.slice(0, page_url.length-1); }
if (page_url == "/tags/new") checkAsYouType(); // New Tag page
else if (page_url == "/tags/search") checkSearchResults(); // Tag Search page
else if (page_url.match(/^\/tags\/.+\/edit$/gi)) checkEditTag(); // Edit page
else if (page_url.match(/^\/tags\/.+\/wrangle$/gi)) checkBinTags(); // Wrangle page
else if (page_url.match(/^\/tags\/[^\/]+$/gi)) checkTag(); // Tag Landing page
// that excludes anything including another slash, which would only incorrectly match on tags/new and tags/search
// but those would have already jumped into the other functions and would never get here
})();
// *************** GENERAL FUNCTIONS ***************
// a holistic function to check
// not allowed: non-latin (including accented) characters and special chars (with a few exceptions)
// two apostrophes '' (used instead of a quote ")
// a slash with spaces before or after
// an apersand without spaces before and after
// space at the beginning or end of the string
// multiple spaces after each other
// this returns the matched characters in an array
function hasIllegalChars(string) {
return string.match(/[^\p{Script=Latin}0-9 \-().&/'"|:!]|'{2,}| \/|\/ |[^ ]&|&[^ ]| {2,}|^ | $/gui);
}
// similar to above, but in fandoms we allow letters, numbers and tone/accent marks of ANY script, not just Latin
// also more special characters are allowed
function hasFandomIllegalChars(string) {
return string.match(/[^\p{L}\p{M}\p{N} \-().&/'"|:!#?_]|'{2,}| {2,}|^ | $/gui);
}
// print a box to explain the problem
function insertHeadsUp(illegalChars, refNode, befNode = null, inline = false) {
// describe non-printable chars and other hard to identify issues
illegalChars.forEach((val, ix) => {
if (val == "''") illegalChars[ix] = "2 single quotes";
else if (val == "/ " || val == " /") illegalChars[ix] = "space around the /";
else if (val.slice(0,1) == "&" || val.slice(-1) == "&") illegalChars[ix] = "no space around the &";
else if (val.trim() == "")
illegalChars[ix] = (val == "\t") ? "tab" :
(val === " " && ix == 0 && refNode.childNodes[0].value.slice(0, 1) === " ") ? "space in front" :
(val === " " && refNode.childNodes[0].value.slice(-1) === " ") ? "space at end" :
"multiple spaces";
});
// setting up the div to contain the heads-up to the user
const warningNode = document.createElement("div");
warningNode.classList.add("notice", "illegalChars");
warningNode.innerHTML = "<p>Questionable: " + illegalChars.join(", ") + "</p>";
if (inline) {
warningNode.style.display = "inline-block";
warningNode.style.padding = "0";
warningNode.style.margin = "0.1em 0.1em 0.1em 0.5em";
warningNode.children[0].style.padding = "0.1em 0.3em";
warningNode.children[0].style.fontWeight = "normal";
}
// if that already exists, we're gonna replace it rather than add more divs
if (refNode.querySelector(".illegalChars")) refNode.replaceChild(warningNode, refNode.querySelector(".illegalChars"));
else refNode.insertBefore(warningNode, befNode);
}
// remove the explain box again
function removeHeadsUp(refNode) {
if (refNode.querySelector(".illegalChars")) refNode.removeChild(refNode.querySelector(".illegalChars"));
}
// *************** PAGE HANDLING FUNCTIONS ***************
// New tag page
function checkAsYouType() {
// a little JS magic to quickly add the same event listener to all elements
[ document.getElementById("tag_name"),
document.getElementById('tag_type_fandom'),
document.getElementById('tag_type_character'),
document.getElementById('tag_type_relationship'),
document.getElementById('tag_type_freeform')
].forEach((el) => {
el.addEventListener("input", () => {
var checkNode = document.getElementById("tag_name");
// which tag type are you trying to create? fandom or anything else?
const isFandom = document.getElementById('tag_type_fandom').checked;
var issues = (isFandom) ? hasFandomIllegalChars(checkNode.value) : hasIllegalChars(checkNode.value);
if (issues !== null) insertHeadsUp(issues, checkNode.parentNode);
else removeHeadsUp(checkNode.parentNode);
// length counter
let label = document.querySelector('dt label[for="tag_name"]');
label.innerText = "Name (" + checkNode.value.length +")";
// extra special handling: tag length>100 error
const refNode = checkNode.parentNode;
if (checkNode.value.length > 100) {
const errorNode = document.createElement("div");
errorNode.id = "tooLong";
errorNode.classList.add("error");
errorNode.innerHTML = "<p>Sorry, you'll need to trim this down. You're at "+ checkNode.value.length +" characters!</p>";
// if that already exists, we're gonna replace it rather than add more divs
if (refNode.querySelector("#tooLong")) refNode.replaceChild(errorNode, refNode.querySelector("#tooLong"));
else refNode.insertBefore(errorNode, null);
}
else if (refNode.querySelector("#tooLong")) refNode.removeChild(refNode.querySelector("#tooLong"));
});
});
// 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
document.getElementById("tag_name").dispatchEvent(new Event("input"));
}
// Landing page
function checkTag() {
// only if the viewed tags is canonical
var tagDescr = document.querySelector(".tag>p").innerText;
if (tagDescr.indexOf("It's a common tag") < 0) return true;
// first the viewed tag itself
var checkNode = document.querySelector(".tag .header h2.heading");
var tagType = tagDescr.match(/This tag belongs to the (.+) Category/i);
tagType = tagType[1];
var issues = (tagType == "Fandom") ? hasFandomIllegalChars(checkNode.innerText) : hasIllegalChars(checkNode.innerText);
if (issues !== null) insertHeadsUp(issues, checkNode.parentNode.parentNode, checkNode.parentNode.parentNode.children[1]);
// then the meta and subtags (if any)
checkNode = document.querySelectorAll("div.meta.listbox a.tag, div.sub.listbox a.tag");
checkNode.forEach((n) => {
var issues = (tagType == "Fandom") ? hasFandomIllegalChars(n.innerText) : hasIllegalChars(n.innerText);
if (issues !== null) insertHeadsUp(issues, n.parentNode, n.parentNode.children[1], true);
});
// 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
}
// Wrangle Bin Page
// sadly we can't tell here at all if we're ever looking at fandoms
function checkBinTags() {
// this needs a different approach to the logic:
// don't check show=mergers at all, too repetitive
var searchParams = new URLSearchParams(window.location.search);
if (searchParams.get('show') == "mergers") return true;
// create a key -> value pair Map of the table columns, so we know which column to check
var tableIndexes = new Map();
document.querySelectorAll("#wrangulator table thead th").forEach((th, ix) => {
tableIndexes.set(th.innerText, ix);
});
// now we can loop through the list of tags
var issues, checkNode;
var checkRows = document.querySelectorAll("#wrangulator table tbody tr");
checkRows.forEach((r) => {
// if there's a column "Canonical" and the cell says "Yes" then we check the tag itself
if (tableIndexes.has("Canonical") && r.cells[tableIndexes.get("Canonical")].innerText == "Yes") {
checkNode = r.cells[0].querySelector("label");
issues = searchParams.get('show') == "fandoms" ? hasFandomIllegalChars(checkNode.innerText) : hasIllegalChars(checkNode.innerText);
if (issues !== null) insertHeadsUp(issues, checkNode.parentNode);
}
// if there's a column "Synonym", we check the content of that cell (there'll only be one tag)
if (tableIndexes.has("Synonym") && r.cells[tableIndexes.get("Synonym")].innerText.trim() !== "") {
checkNode = r.cells[tableIndexes.get("Synonym")].querySelector("a");
issues = searchParams.get('show') == "fandoms" ? hasFandomIllegalChars(checkNode.innerText) : hasIllegalChars(checkNode.innerText);
if (issues !== null) insertHeadsUp(issues, checkNode.parentNode);
}
// if there's a column "Characters", we check the content of that cell (there might be multiple tags)
if (tableIndexes.has("Characters") && r.cells[tableIndexes.get("Characters")].innerText.trim() !== "") {
checkNode = r.cells[tableIndexes.get("Characters")].querySelectorAll("a");
checkNode.forEach((n) => {
issues = hasIllegalChars(n.innerText);
if (issues !== null) insertHeadsUp(issues, n.parentNode);
});
}
// if there's a column "Metatag", we check the content of that cell (there might be multiple tags)
if (tableIndexes.has("Metatag") && r.cells[tableIndexes.get("Metatag")].innerText.trim() !== "") {
checkNode = r.cells[tableIndexes.get("Metatag")].querySelectorAll("a");
checkNode.forEach((n) => {
issues = searchParams.get('show') == "fandoms" ? hasFandomIllegalChars(checkNode.innerText) : hasIllegalChars(n.innerText);
if (issues !== null) insertHeadsUp(issues, n.parentNode);
});
}
});
}
// Tag Search
function checkSearchResults() {
// with search results table userscript enabled
var checkNodes = document.querySelectorAll("table#resulttable .resulttag.canonical a, table#resulttable .resultName.canonical a");
checkNodes.forEach((n) => {
var issues = (n.parentNode.parentNode.querySelector('.resulttype, .resultType').title == "Fandom") ? hasFandomIllegalChars(n.innerText) : hasIllegalChars(n.innerText);
if (issues !== null) insertHeadsUp(issues, n.parentNode, null, true);
});
// with plain search results page
checkNodes = document.querySelectorAll("ol.tag li span.canonical a.tag");
checkNodes.forEach((n) => {
var issues = (n.parentNode.firstChild.textContent.trim() == "Fandom:") ? hasFandomIllegalChars(n.innerText) : hasIllegalChars(n.innerText);
if (issues !== null) insertHeadsUp(issues, n.parentNode.parentNode, null, true);
});
}
// Edit Tag Page
function checkEditTag() {
const tagCanonical = document.getElementById('tag_canonical');
const tagType = document.querySelector('#edit_tag fieldset:first-of-type dd strong').innerText;
var issues;
// initial check only if the tag is already canonical
if (tagCanonical.checked) {
var checkNode = document.getElementById("tag_name");
issues = (tagType == "Fandom") ? hasFandomIllegalChars(checkNode.value) : hasIllegalChars(checkNode.value);
if (issues !== null) insertHeadsUp(issues, checkNode.parentNode);
}
// if the tag's canonical status is changed
tagCanonical.addEventListener("input", (event) => {
var checkNode = document.getElementById("tag_name");
if (event.target.checked) {
var issues = (tagType == "Fandom") ? hasFandomIllegalChars(checkNode.value) : hasIllegalChars(checkNode.value);
if (issues !== null) insertHeadsUp(issues, checkNode.parentNode);
else removeHeadsUp(checkNode.parentNode);
}
else removeHeadsUp(checkNode.parentNode);
});
// if this is a synonym, check the canonical tag it's synned to
const synonym = document.querySelector('#edit_tag fieldset:first-of-type dd ul.autocomplete .added.tag');
if (synonym !== null) {
issues = (tagType == "Fandom") ? hasFandomIllegalChars(synonym.firstChild.textContent.trim()) : hasIllegalChars(synonym.firstChild.textContent.trim());
if (issues !== null) insertHeadsUp(issues, synonym.parentNode.parentNode, synonym.parentNode.parentNode.children[1]);
}
// if this is canonical, check its sub- and metatags
const metasubs = document.querySelectorAll('#parent_MetaTag_associations_to_remove_checkboxes ul li a, #child_SubTag_associations_to_remove_checkboxes ul li a');
if (metasubs !== null) {
metasubs.forEach((n) => {
issues = (tagType == "Fandom") ? hasFandomIllegalChars(n.innerText) : hasIllegalChars(n.innerText);
if (issues !== null) insertHeadsUp(issues, n.parentNode);
});
}
// if this is any other type of tag that's in a fandom, check the fandom tag
const fandoms = document.querySelectorAll('#parent_Fandom_associations_to_remove_checkboxes ul li a');
if (fandoms !== null) {
fandoms.forEach((n) => {
issues = hasFandomIllegalChars(n.innerText);
if (issues !== null) insertHeadsUp(issues, n.parentNode);
});
}
// if this is a relationship, check the tagged characters
const chars = document.querySelectorAll('#parent_Character_associations_to_remove_checkboxes ul li a');
if (chars !== null) {
chars.forEach((n) => {
issues = hasIllegalChars(n.innerText);
if (issues !== null) insertHeadsUp(issues, n.parentNode);
});
}
}