// ==UserScript==
// @name         AO3: [Wrangling] Mark Illegal Characters in Canonicals
// @namespace    https://greatest.deepsurf.us/en/users/906106-escctrl
// @version      1.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
// @author       escctrl
// @match        *://*.archiveofourown.org/tags/*
// @license      MIT
// @grant        none
// ==/UserScript==
(function() {
    'use strict';
    // 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 ")
//              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.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.id = "illegalChars";
    warningNode.classList.add("notice");
    warningNode.innerHTML = "<p>Questionable characters: " + 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);
            // 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 = 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 = 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 = 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");
    checkNodes.forEach((n) => {
        var issues = (n.parentNode.parentNode.querySelector('td.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;
    // initial check only if the tag is already canonical
    if (tagCanonical.checked) {
        var checkNode = document.getElementById("tag_name");
        var 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) => {
            var 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) => {
            var 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) => {
            var issues = hasIllegalChars(n.innerText);
            if (issues !== null) insertHeadsUp(issues, n.parentNode);
        });
    }
}