AO3: [Wrangling] Rel Helper

on unwrangled rels, build the proper canonical tag name with minimal typing

// ==UserScript==
// @name         AO3: [Wrangling] Rel Helper
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @description  on unwrangled rels, build the proper canonical tag name with minimal typing
// @author       escctrl
// @version      1.0
// @match        *://*.archiveofourown.org/tags/*/edit
// @grant        none
// @license      MIT
// @require      https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js
// @require      https://update.greasyfork.org/scripts/491896/1516188/Copy%20Text%20and%20HTML%20to%20Clipboard.js
// ==/UserScript==

/* global Sortable, copy2Clipboard */
'use strict';

// utility to reduce verboseness
const q = (selector, node=document) => node.querySelector(selector);
const qa = (selector, node=document) => node.querySelectorAll(selector);
const ins = (n, l, html) => n.insertAdjacentHTML(l, html);

/*************** CONFIGURATION ***************/

// set this to either "syn" (to get a button that sets the rel as the Synonym Of and create canonicals that way)
//                 or "copy" (to get buttons for copying the rel and chars manually into a new tag)
let cfg = 'wrangleRelHelp';
let WORKFLOW = localStorage.getItem(cfg) || "syn";

// if no other script has created it yet, write out a "Userscripts" option to the main navigation
if (qa('#scriptconfig').length === 0) {
    qa('#header nav[aria-label="Site"] li.search')[0] // insert as last li before search
        .insertAdjacentHTML('beforebegin', `<li class="dropdown" id="scriptconfig">
            <a class="dropdown-toggle" href="/" data-toggle="dropdown" data-target="#">Userscripts</a>
            <ul class="menu dropdown-menu"></ul></li>`);
}
// then add this script's config option to navigation dropdown
ins(q('#scriptconfig .dropdown-menu'), 'beforeend', `<li><a href="#" id="relhelp_workflow">Rel Helper Workflow:
        <span class="switch"><input type="checkbox" ${WORKFLOW === "syn" ? 'checked="checked"' : ""}><span class="slider round"></span></span>
        <span id="relhelp_workflow_status">${WORKFLOW === "syn" ? "SynOf" : "Copy"}</span></a></li>`);

ins(q("head"), 'beforeend', `<style tyle="text/css"> #relhelp_workflow {
    /* The switch - the box around the slider */
        .switch { position: relative; display: inline-block; width: 2em; height: 1em; vertical-align: -0.2em; }
    /* Hide default HTML checkbox */
        .switch input { opacity: 0; width: 0; height: 0; }
    /* The slider */
        .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; -webkit-transition: .4s; transition: .4s; }
        .slider:before { position: absolute; content: ""; height: 0.8em; width: 0.8em; left: 0.2em; bottom: 0.1em; background-color: white; -webkit-transition: .4s; transition: .4s; }
        input:checked + .slider { background-color: currentColor; }
        input:focus + .slider { box-shadow: 0 0 1px currentColor; }
        input:checked + .slider:before { -webkit-transform: translateX(0.8em); -ms-transform: translateX(0.8em); transform: translateX(0.8em); }
    /* Rounded sliders */
        .slider.round { border-radius: 1em; }
        .slider.round:before { border-radius: 50%; }
    }
    .sortable .tag a { cursor: default; }
    #relhelp_preview { margin: 0.643em auto; }</style>`);

// on either click on link or change of checkbox (depending where exactly user clicked), toggle between SynOf and Copy
q('#relhelp_workflow').addEventListener('click', (e)=>{
    e.preventDefault();
    if (e.currentTarget.tagName==="A") {
        WORKFLOW = WORKFLOW === "syn" ? "copy" : "syn"; // toggle
        localStorage.setItem(cfg, WORKFLOW);
        q('input', e.currentTarget).checked = WORKFLOW === "syn" ? true : false;
        q('#relhelp_workflow_status', e.currentTarget).innerText = WORKFLOW === "syn" ? "SynOf" : "Copy";
        location.reload();
    }
});

/************* CONFIGURATION END *************/

// function to get current list of added tags based on autocomplete input fields e.g. get_added(inputs.char)
const get_added = (me) => qa('.added.tag', me.parentElement.parentElement);

const inputs = {
    tag: q('#tag_name'),
    syn: q('#tag_syn_string_autocomplete'),
    char: q('#tag_character_string_autocomplete')
};

if (q('#tag_character_string') === null) return; // works only on relationships
if (q('#tag_canonical').checked) return; // works only if not canonical
if (get_added(inputs.syn).length > 0) return; // works only if not a synonym already

const chars_ul = inputs.char.parentElement.parentElement;
const individuals = inputs.tag.value.split(/\/|&|\band\b/g);
let fillCount = 0;
let newRel = "";

// make the characters <ul> sortable
let sortable = new Sortable(chars_ul, {
    draggable: ".tag", // instead of filtering .input, we make only .tag draggable to avoid problems (autocomplete selection by mouse, dragging without clicking outside the <input>)
    onEnd: updatePreview // reorder in preview immediately
});
chars_ul.classList.add('sortable');

// add some buttons (automatically select the / or & based on tagname)
ins(chars_ul, 'afterend', `<div id="relhelp_btns">
    <button type="button" id="relhelp_addchars" title="search for the next character from the rel in the autocomplete">Add Char</button>
    <button type="button" id="relhelp_settype" title="switch between / and & rels" data-relhelptype="${inputs.tag.value.indexOf('/') !== -1 ? "/" : " & "}">${inputs.tag.value.indexOf('/') !== -1 ? "&" : "/"}</button>
    <button type="button" id="relhelp_remdisambig" title="switch between keeping and removing fandom disambigs" data-relhelpdisambig="keep">Remove Disambigs</button> &middot;
    ${ WORKFLOW === "syn" ? `<button type="button" id="relhelp_setsyn" title="set the proposed tag in the Synonym Of field">Set Syn Of</button>` :
        `<button type="button" id="relhelp_copy" title="copy the proposed tag to clipboard">Copy Rel</button>
        <button type="button" id="relhelp_copychars" title="copy the selected chars to clipboard">Copy Chars</button>` }<br />
    <input  type="text" id="relhelp_preview" title="preview of the proposed tag you're building" style="max-width: 30em;" />
    <span id="relhelp_error"></span>
    </div>`);

chars_ul.parentElement.addEventListener('click', (e) => {
    if (e.target.id === "relhelp_addchars") { // with each click fill autocomplete search with the next char
        inputs.char.value = individuals[fillCount].trim();
        inputs.char.focus();
        fillCount = (fillCount+1 === individuals.length) ? 0 : fillCount+1;
    }
    else if (e.target.id === "relhelp_settype") { // with each click toggle between / and & rels
        e.target.dataset.relhelptype = e.target.dataset.relhelptype === "/" ? " & " : "/";
        e.target.innerText = (e.target.dataset.relhelptype === "/" ? "&" : "/");
        updatePreview();
    }
    else if (e.target.id === "relhelp_remdisambig") { // with each click toggle between keeping and removing the disambigs
        e.target.dataset.relhelpdisambig = e.target.dataset.relhelpdisambig === "keep" ? "rem" : "keep";
        e.target.innerText = (e.target.dataset.relhelpdisambig === "keep" ? "Remove" : "Keep") + " Disambigs";
        updatePreview();
    }
    else if (e.target.id === "relhelp_copy") { // copy final rel to clipboard
        copy2Clipboard(e, "txt", newRel);
    }
    else if (e.target.id === "relhelp_copychars") { // copy CSV of chars to clipboard, and remove the chars in one go
        let allchars = Array.from(get_added(inputs.char)); // grab all current chars
        allchars = allchars.map((el) => el.innerText.slice(0,-1).trim()); // remove the x delete button
        copy2Clipboard(e, "txt", allchars.join(','));
        for (let char of get_added(inputs.char)) {
            q('.delete a', char).click();
        }
    }
    else if (e.target.id === "relhelp_setsyn") { // populate the Synonym Of field
        inputs.syn.focus();
        inputs.syn.value = newRel;
        inputs.syn.dispatchEvent(new KeyboardEvent('keydown', { keyCode: 13, key: "Enter" }));
    }
});

q('#relhelp_preview').addEventListener('input', (e) => { // when user makes manual changes
    newRel = e.target.value;
    checkTagValidity();
});

// update preview when a char gets added/deleted
const observer = new MutationObserver(function(mutList, obs) {
    for (const mut of mutList) {
        // check if the added/removed node is an .added.tag LI
        for (const node of mut.addedNodes) {
            if (node.classList.contains('added')) {
                // make content of the LI into a link (don't need to stop observer bc it's not listening to subtree)
                let oldNode = node.childNodes[0];
                let newNode = document.createElement("a");
                newNode.innerText = oldNode.textContent;
                newNode.href = encodeURI("https://archiveofourown.org/tags/" +
                    oldNode.textContent.trim().replace('/', '*s*').replace('&', '*a*').replace('#', '*h*').replace('.', '*d*').replace('?', '*q*') +
                    "/wrangle?show=relationships&status=canonical");
                newNode.target = "_blank";
                node.replaceChild(newNode, oldNode);
                updatePreview();
            }
        }
        for (const node of mut.removedNodes) { if (node.classList.contains('added')) updatePreview(); }
    }
});

// listening to as few changes as possible: only direct children of UL
observer.observe(chars_ul, { attributes: false, childList: true, subtree: false });

function updatePreview() {
    let allchars = Array.from(get_added(inputs.char)); // grab all current chars
    allchars = allchars.map((el) => {
        el = el.innerText.slice(0,-1).trim(); // remove the x delete button
        // remove fandom disambigs aka the last parenthesis if asked - doesn't remove (s) in Character(s) or (ren) in Child(ren)
        if (q('#relhelp_remdisambig').dataset.relhelpdisambig === "rem" && el.indexOf("(") !== -1 && el.slice(-3) !== "(s)" && el.slice(-5) !== "(ren)") {
            el = el.slice(0, el.lastIndexOf("(")).trim();
        }
        return el;
    });
    newRel = allchars.join(q('#relhelp_settype').dataset.relhelptype); // join with the rel type we're supposed to use
    q('#relhelp_preview').value = newRel; // write out into preview field
    checkTagValidity();
}

function checkTagValidity() { // warn if new tag name too long or same as this tag
    let preview = q('#relhelp_preview');
    let error = "";

    if (newRel.length > 150) error = "proposed canonical is too long";
    if (newRel === inputs.tag.value) error = "proposed canonical is same as this tag";

    if (error === "") {
        preview.classList.remove('error');
        preview.title = "preview of the proposed tag you're building";
        q('#relhelp_error').innerText = "";
        qa('#relhelp_copy, #relhelp_copychars, #relhelp_setsyn').forEach((b) => { b.style.display = "inline-block"; }); // show the buttons
    }
    else {
        preview.classList.add('error');
        preview.title = error;
        q('#relhelp_error').innerText = error;
        qa('#relhelp_copy, #relhelp_copychars, #relhelp_setsyn').forEach((b) => { b.style.display = "none"; }); // hide the buttons
    }
}