AO3: [Wrangling] Keyboard Shortcuts

adds keyboard shortcuts to the AO3 wrangling interface (modified from vaaas@github)

// ==UserScript==
// @name         AO3: [Wrangling] Keyboard Shortcuts
// @namespace    https://greasyfork.org/en/users/906106-escctrl
// @description	 adds keyboard shortcuts to the AO3 wrangling interface (modified from vaaas@github)
// @author       escctrl
// @version      5.1
// @match        https://archiveofourown.org/tags/*
// @match        https://archiveofourown.org/tag_wranglings*
// @match        https://archiveofourown.org/tag_wranglers/*
// @match        https://archiveofourown.org/comments*
// @license      AGPLv3 - https://www.gnu.org/licenses/agpl.html
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.7.0/jquery.min.js
// @require      https://ajax.googleapis.com/ajax/libs/jqueryui/1.13.2/jquery-ui.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/jqueryui-touch-punch/0.2.3/jquery.ui.touch-punch.min.js
// ==/UserScript==

// vaaas' original script can be retrieved here:
// https://raw.githubusercontent.com/vaaas/ao3_wrangling_scripts/master/userscripts/ao3_wrangling_shortcuts.js

/* THE GUI CONFIGURATION AND STORAGE */

var kbdmap, pagemap;

(function($) {
    /* READS PREVIOUS BINDINGS CONFIGURATION FROM LOCALSTORAGE */

    // the list of available shortcut actions + some description what it does which'll be shown as a tooltip
    // the 'ENCBL' shows which pages the shortcut works on - Edit, New, Comment, Bin, Landing
    let kbdoptions = [
        ['save', 'Save', 'Submits various forms', 'ENCB'],
        ['focus_tag_name', 'Focus Tag Name', 'Sets cursor into the tag name textfield', 'EN'],
        ['select_characters', 'Select Characters', 'Toggles the checkboxes on the Characters list between All and None', 'EN'],
        ['select_fandoms', 'Select Fandoms', 'Toggles the checkboxes on the Fandoms list between All and None', 'EN'],
        ['focus_syn', 'Focus Syn', 'Sets cursor into the Synonym Of textfield (will only work in Bin if you have the \'Wrangle straight from the bins\' script)', 'EB'],
        ['save_syn', 'Save Syn', 'Selects the \'Wrangle to existing tag\' button (if you have the \'Wrangle straight from the bins\' script)', 'B'],
        ['toggle_unwrangleable', 'Unwrangleable Checkbox', 'Toggles the unwrangleable checkbox', 'E'],
        ['toggle_canonical', 'Canonical Checkbox', 'Toggles the canonical checkbox', 'EN'],

        ['edit_tag', 'Open Edit Tag', 'Opens the tag\'s Edit Tag page', 'CBL'],
        ['open_works', 'Open Works', 'Opens the tag\'s Works page', 'ECBL'],
        ['open_comments', 'Open Comments', 'Opens the tag\'s Comments page', 'EBL'],
        ['open_mergers', 'Open Mergers', 'Opens the tag\'s Mergers (Syns) page', 'ECBL'],
        ['go_to_canonical', 'Open Canonical', 'Opens the Canonical\'s page', 'E'],

        ['focus_fandom', 'Focus Fandom', 'Sets cursor into the fandom textfield, or selects the fandom radiobutton', 'ENB'],
        ['focus_characters', 'Focus Characters', 'Sets cursor into the characters textfield, or selects the characters radiobutton', 'EN'],
        ['click_freeform', 'Select Freeform', 'Selects the freeform radiobutton', 'N'],
        ['click_relationship', 'Select Relationship', 'Selects the relationship radiobutton', 'N'],

        ['toggle_rel_helper', 'Toggle Relhelper', 'Toggles the Relhelper on/off', 'E'],
        ['toggle_rel_type', 'Toggle relationship type', 'Toggles between platonic (&) and romantic (/) relationship in the Relhelper', 'E'],
        ['flip', 'Flip Nameorder', 'Flips given and family name within the character\'s Relhelper textfield', 'E'],
        ['add', 'Add Relhelper Line', 'Adds another Relhelper textfield for another character', 'E'],
        ['remove', 'Remove Relhelper Line', 'Remove the Relhelper textfield of this character', 'E'],
        ['previous', 'Previous', 'Move the Relhelper field up and shift this character to the left in the rel', 'E'],
        ['next', 'Next', 'Move the Relhelper field down and shift this character to the right in the rel', 'E'],

        ['down', 'Down', 'Sets cursor into the Relhelper field below, or moves the focus down to next tag in the Bin', 'EB'],
        ['up', 'Up', 'Sets cursor into the Relhelper field above, or moves the focus up to previous tag in the Bin', 'EB'],
        ['toggle_selection', 'Toggle Tag Checkbox', 'Toggles the checkbox to select the tag in the Bin (for this to work, you also have to set shortcuts for Up and Down)', 'B']
    ];

    // load storage on page startup
    kbdmap = new Map(JSON.parse(localStorage.getItem('kbdshortcuts')));
    pagemap = new Map(JSON.parse(localStorage.getItem('kbdpages')));
    // populate with empty values in case no storage was found (after reset)
    if (kbdmap.size == 0) { kbdoptions.forEach((v) => kbdmap.set(v[0], "")); kbdmap.set('fandom-tag', "").set('fandom-key', "").set('syn-tag', "").set('syn-key', ""); }
    if (pagemap.size == 0) pagemap.set('page_edit', false).set('page_new', false).set('page_cmt', false).set('page_bin', false);

    /* CREATING THE CONFIGURATION DIALOG */

    // if the background is dark, use the dark UI theme to match
    let dialogtheme = lightOrDark($('body').css('background-color')) == "dark" ? "ui-darkness" : "base";

    // adding the jQuery stylesheet to style the dialog, and fixing the interferance of AO3's styling
    $("head").append(`<link rel="stylesheet" href="https://code.jquery.com/ui/1.13.2/themes/${dialogtheme}/jquery-ui.css">`)
    .append(`<style tyle="text/css">#kbdConfigDialog, .ui-dialog .ui-dialog-buttonpane button {font-size: revert; line-height: 1.286;}
    #kbdConfigDialog form {box-shadow: revert; cursor:auto;}
    #kbdConfigDialog legend {font-size: inherit; height: auto; width: auto; opacity: inherit;}
    #kbdConfigDialog fieldset {background: revert; box-shadow: revert;}
    #kbdConfigDialog input[type='text'] { position: relative; top: 1px; padding: .4em; width: 3em; }
    #kbdConfigDialog tr, #kbdConfigDialog tr:hover {border-width: 0;}
    #kbdConfigDialog td {vertical-align: middle; padding-left: 1em;}
    #kbdConfigDialog .ui-tabs, #kbdConfigDialog #tabs div {padding: 0}
    #kbdConfigDialog .ui-state-error {padding: 0.5em;}
    </style>`);

    // the config dialog container
    let cfg = document.createElement('div');
    cfg.id = 'kbdConfigDialog';

    // build options into a table of selectable options for the user (shift checkbox=>on/off button with jQueryUI)
    let kbdconfig = "";
    kbdoptions.forEach((v) => {
        let cfgsaved = kbdmap.get(v[0]) || ""; // migration help for actions added since v4.0, default them to blank
        kbdconfig += `<tr title="${v[2]}" class="${v[3].replace('E', 'view_edit ').replace('N', 'view_new ').replace('C', 'view_cmt ').replace('B', 'view_bin ').replace('L', 'view_land ')}">
        <td>${v[1]}</td>
        <td>
            <label for="${v[0]}-s">Shift</label><input type="checkbox" name="${v[0]}-s" id="${v[0]}-s" ${(cfgsaved.indexOf('S')>-1) ? 'checked="checked"' : ""}>
            <input type="text" maxlength=1 name="${v[0]}-k" id="${v[0]}-k" value="${cfgsaved.slice(-1)}">
        </td>
        </tr>`;
    });

    // quick check if we're in ALT or CTRL mode
    let maincfg;
    for (let i of kbdmap.entries()) {
        if (i[1] != "") maincfg = i[1].slice(0,1);
    }

    // build the rest of the configuration options for the user (checkboxes and radiobuttions => buttons with jQueryUI)
    // todo: load previously stored choices
    $(cfg).html(`
    <form>
    <fieldset>
        <legend>Shortcuts work on these pages</legend>
        <label for="page_edit">Edit Tag</label><input type="checkbox" name="page_edit" id="page_edit" ${(pagemap.get('page_edit')==="true") ? 'checked="checked"' : ""}>
        <label for="page_new">New Tag</label><input type="checkbox" name="page_new" id="page_new" ${(pagemap.get('page_new')==="true") ? 'checked="checked"' : ""}>
        <label for="page_cmt">Comments</label><input type="checkbox" name="page_cmt" id="page_cmt" ${(pagemap.get('page_cmt')==="true") ? 'checked="checked"' : ""}>
        <label for="page_bin">Bin</label><input type="checkbox" name="page_bin" id="page_bin" ${(pagemap.get('page_bin')==="true") ? 'checked="checked"' : ""}>
        <label for="page_land">Landing</label><input type="checkbox" name="page_land" id="page_land" ${(pagemap.get('page_land')==="true") ? 'checked="checked"' : ""}>
    </fieldset>
    <fieldset>
        <legend>Main Shortcut Key</legend>
        <div style="float: left; margin-right: 0.5em; text-align: center;">
            <p style="padding: 0.2em 0;"><label for="main-alt">Alt</label><input type="radio" name="mainkbd" id="main-alt" value="A" ${(maincfg=="A") ? 'checked="checked"' : ""}></p>
            <p style="padding: 0.2em 0;"><label for="main-ctrl">Ctrl</label><input type="radio" name="mainkbd" id="main-ctrl" value="C" ${(maincfg=="C") ? 'checked="checked"' : ""}></p>
        </div>
        <div>This key is used in combination with the letters defined below. Recommendation: If you're on Windows, use Alt, if you're on Mac, use Ctrl.
        Then it's less likely to interfere with your OS's typical shortcut key.</div>
    </fieldset>
    <fieldset>
        <legend>Standard Shortcuts</legend>
        <p>Choose letters or numbers for each shortcut. If you don't want to use an action, just leave its letter field empty. Use the tabs to filter the list of options by the wrangling page they work on.</p>
        <div id="tabs">
            <ul>
                <li><a href="#view_edit">Edit Tag</a></li>
                <li><a href="#view_new">New Tag</a></li>
                <li><a href="#view_cmt">Comments</a></li>
                <li><a href="#view_bin">Bin</a></li>
                <li><a href="#view_land">Landing</a></li>
            </ul>
            <div id="view_edit"></div><div id="view_new"></div><div id="view_cmt"></div><div id="view_bin"></div><div id="view_land"></div>
        </div>
        <table>
        ${kbdconfig}
        </table>
    </fieldset>
    <fieldset class="view_edit">
        <legend>Shortcut for adding a Fandom</legend>
        <input type="text" name="fandom-n" id="fandom-n" style="width: 16em;" value="${kbdmap.get('fandom-tag')}" placeholder="Fandom canonical name">
        <label for="fandom-s">Shift</label><input type="checkbox" name="fandom-s" id="fandom-s" ${(kbdmap.get('fandom-key').indexOf('S')>-1) ? 'checked="checked"' : ""}>
        <input type="text" maxlength=1 name="fandom-k" id="fandom-k" value="${kbdmap.get('fandom-key').slice(-1)}">
    </fieldset>
    <fieldset class="view_edit">
        <legend>Shortcut for adding a Synonym Of</legend>
        <input type="text" name="syn-n" id="syn-n" style="width: 16em;" value="${kbdmap.get('syn-tag')}" placeholder="Synonym Of canonical name">
        <label for="syn-s">Shift</label><input type="checkbox" name="syn-s" id="syn-s" ${(kbdmap.get('syn-key').indexOf('S')>-1) ? 'checked="checked"' : ""}>
        <input type="text" maxlength=1 name="syn-k" id="syn-k" value="${kbdmap.get('syn-key').slice(-1)}">
    </fieldset>
    <!-- Allow form submission with keyboard without duplicating the dialog button -->
    <input type="submit" tabindex="-1" style="display: none;">
    </form>
    `);

    // attach it to the DOM so that selections work (but only if #main exists, else it might be a Retry Later error page)
    if ($("#main").length == 1) $("body").append(cfg);

    /* JQUERYUI TIME: TURNING PLAIN HTML INTO A NICE CONFIG DIALOG */

    // turn checkboxes and radiobuttons into pretty buttons
    $( "#kbdConfigDialog input[type='checkbox'], #kbdConfigDialog input[type='radio']" ).checkboxradio({
        icon: false
    });

    // prettify the tabs in the dialog (which are abused for filtering the long list of options)
    $( function() { $( "#kbdConfigDialog #tabs" ).tabs(); } );

    // prettify the tooltips, so they're not the tiny native ones. on mobile tap anywhere in the row to see them
    $( function() { $( "#kbdConfigDialog" ).tooltip(); } );

    // optimizing the size of the GUI in case it's a mobile device
    let dialogwidth = parseInt($("body").css("width")); // parseInt ignores letters (px)
    dialogwidth = dialogwidth > 500 ? 500 : dialogwidth * 0.9;

    // initialize the dialog (but don't open it)
    $( "#kbdConfigDialog" ).dialog({
        appendTo: "#main",
        modal: true,
        title: 'Wrangling Keyboard Shortcuts',
        draggable: true,
        resizable: false,
        autoOpen: false,
        width: dialogwidth,
        position: {my:"center", at: "center top"},
        buttons: {
            Reset: deleteConfig,
            Save: storeConfig,
            Cancel: function() {
                $( "#kbdConfigDialog" ).dialog( "close" );
            }
        }
    });

    // event triggers if form is submitted with the <enter> key
    $( "#kbdConfigDialog form" ).on("submit", (e)=>{
        e.preventDefault();
        storeConfig();
    });

    // event to filter the shortcut options (by wrangling page they work on) when tabs are switched
    // note: we can only filter, because everything breaks/becomes hella complicated if we create duplicates when the same action (e.g. save) works on multiple pages
    $( "#kbdConfigDialog #tabs" ).on( "tabsactivate", function( e, ui ) {
        $('#kbdConfigDialog tr, #kbdConfigDialog .view_edit').hide();
        $('#kbdConfigDialog .'+e.originalEvent.target.attributes.getNamedItem("href").value.slice(1)).show();
    });

    // function triggered directly when the Save button is clicked
    function storeConfig() {
        // grab all form fields for easier selection later
        let allfields = $( "#kbdConfigDialog form [name]" );

        // build a Map() for pages it should run on in the format pagetype => true/false
        pagemap = new Map();
        $(allfields).filter('[name^="page"]').each((i, page) => {
            pagemap.set($(page).prop('name'), String($(page).prop('checked')));
        });
        localStorage.setItem('kbdpages', JSON.stringify(Array.from(pagemap.entries())));

        // build a Map() of the bindings in the format shortcut => 'C|A[-S]-letter'
        //   C means control
        //   A means alt
        //   S means shift
        //   don't capitalise the letter!
        kbdmap = new Map();

        // the selected "main" shortcut key (ALT -> A or CTRL -> C)
        let mainkey = $(allfields).filter('[name="mainkbd"]:checked').val() || false;

        // standard shortcuts
        kbdoptions.forEach((v) => {
            let kbdletter = $(allfields).filter(`[name="${v[0]}-k"]`).val();
            // only save changes if the mainkey was picked
            if (kbdletter.length > 0 && mainkey) {
                let kbdshift = $(allfields).filter(`[name="${v[0]}-s"]`).prop('checked') == true ? "-S" : "";
                kbdmap.set(v[0], `${mainkey}${kbdshift}-${kbdletter.toLowerCase()}`);
            }
            else kbdmap.set(v[0], "");
        });

        // fandom shortcut
        let fandomname = $(allfields).filter(`[name="fandom-n"]`).val(),
            fandomshift = $(allfields).filter(`[name="fandom-s"]`).prop('checked') == true ? "-S" : "",
            fandomletter = $(allfields).filter(`[name="fandom-k"]`).val();
        if (fandomname.length > 0 && fandomletter.length > 0) {
            kbdmap.set('fandom-tag', fandomname);
            kbdmap.set('fandom-key', `${mainkey}${fandomshift}-${fandomletter.toLowerCase()}`);
        }
        else kbdmap.set('fandom-tag', "").set('fandom-key', "");

        // synonym shortcut
        let synname = $(allfields).filter(`[name="syn-n"]`).val(),
            synshift = $(allfields).filter(`[name="syn-s"]`).prop('checked') == true ? "-S" : "",
            synletter = $(allfields).filter(`[name="syn-k"]`).val();
        if (synname.length > 0 && synletter.length > 0) {
            kbdmap.set('syn-tag', synname);
            kbdmap.set('syn-key', `${mainkey}${synshift}-${synletter.toLowerCase()}`);
        }
        else kbdmap.set('syn-tag', "").set('syn-key', "");

        // error checking: if the same shortcut was configured for any two actions
        // first, count up how often each of the configured shortcuts were used
        let countUses = Array.from(kbdmap.entries()).reduce((count, it) => {

            if(it[1]!="" && !it[0].endsWith("-tag")) // ignores empty strings and configured tag names, which are also in this array
                count[it[1]] = count[it[1]] + 1 || 1; // adds the string as a key to the object (with 1 use), and counts up each time it appears again
            return count;

        }, {});

        // turn result into an array so we can filter down for anything that was counted more than once, then check the final array length (0 = no duplicates)
        let duplicates = Object.values(countUses).filter((val) => { return val > 1; }).length;

        // if there are no duplicates, continue as usual
        if (duplicates == 0) {
            // remove any previous error messages
            $("#kbdConfigDialog .ui-state-error").remove();

            // save the new data to local storage
            localStorage.setItem('kbdshortcuts', JSON.stringify(Array.from(kbdmap.entries())));

            // reload the function since the shortcuts have changed and we want to reflect that change immediately
            wrangling_keystrokes(window);

            $( "#kbdConfigDialog" ).dialog( "close" );
        }
        // if there are duplicates, don't do anything with the data and show an error message
        else {
            $("#kbdConfigDialog").append(`<p class="ui-state-error">Please check your shortcuts, you have chosen the same on multiple actions.</p>`);
        }
    }

    function deleteConfig() {
        // deselects all buttons, empties all fields in the form
        $('#kbdConfigDialog form').trigger("reset");

        // remove any previous error messages
        $("#kbdConfigDialog .ui-state-error").remove();

        // deletes the localStorage
        localStorage.removeItem('kbdshortcuts');
        localStorage.removeItem('kbdpages');

        // reload the function since the shortcuts have changed and we want to reflect that change immediately
        wrangling_keystrokes(window);
    }

    /* CREATING THE LINK TO OPEN THE CONFIGURATION DIALOG */

    // if no other script has created it yet, write out a "Userscripts" option to the main navigation
    if ($('#scriptconfig').length == 0) {
        $('#header ul.primary.navigation li.dropdown').last()
            .after(`<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
    $('#scriptconfig .dropdown-menu').append(`<li><a href="javascript:void(0);" id="opencfg_kbdcfg">Wrangling Keyboard Shortcuts</a></li>`);

    // on click, open the configuration dialog
    $("#opencfg_kbdcfg").on("click", function(e) {

        // filter the shortcut options by page when the GUI opens
        let chosentab = $('#kbdConfigDialog .ui-tabs-tab.ui-tabs-active a').attr('href').slice(1);
        $('#kbdConfigDialog tr').hide();
        $('#kbdConfigDialog tr.'+chosentab).show();

        // then actually open the dialog
        $( "#kbdConfigDialog" ).dialog('open');
    });

})(jQuery);

// helper function to determine whether a color (the background in use) is light or dark
// https://awik.io/determine-color-bright-dark-using-javascript/
function lightOrDark(color) {

    // Variables for red, green, blue values
    var r, g, b, hsp;

    // Check the format of the color, HEX or RGB?
    if (color.match(/^rgb/)) {
        // If RGB --> store the red, green, blue values in separate variables
        color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/);
        r = color[1];
        g = color[2];
        b = color[3];
    }
    else {
        // If hex --> Convert it to RGB: http://gist.github.com/983661
        color = +("0x" + color.slice(1).replace(color.length < 5 && /./g, '$&$&'));
        r = color >> 16;
        g = color >> 8 & 255;
        b = color & 255;
    }

    // HSP (Highly Sensitive Poo) equation from http://alienryderflex.com/hsp.html
    hsp = Math.sqrt( 0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b) );

    // Using the HSP value, determine whether the color is light or dark
    if (hsp>127.5) { return 'light'; }
    else { return 'dark'; }
}

/* NOW THE ACTUAL KEYBOARD SHORTCUTS CODE */

function wrangling_keystrokes(window) {
    'use strict'

    // reparsing from local storage, in case function gets called because config was changed (so it takes effect immediately)
    kbdmap = new Map(JSON.parse(localStorage.getItem('kbdshortcuts')));
    pagemap = new Map(JSON.parse(localStorage.getItem('kbdpages')));

    // set the old tried-and-true bindings object to the GUI-configured/stored shortcuts
    const bindings = {};
    for (let i of kbdmap) {
        bindings[i[0]] = i[1];
    }
    // migration help: set actions added since v4.0 to blank if user didn't have them in storage yet
    for (let i of ['save_syn']) {
        if (!bindings[i]) bindings[i] = "";
    }

    const runonpages = {};
    for (let i of pagemap) {
        runonpages[i[0]] = (i[1] === "true");
    }

    const DEBUG = false;

	function main() { wrangling_check(window.location.pathname) }

	let keys = new Map()

	const K1 = a => b => () => a(b)
	const B = a => b => c => a(b(c))
	const B1 = a => b => c => d => a(b(c)(d))
	const T = x => f => f(x)
	const P = (x, ...xs) => xs.reduce((a,b) => b(a), x)
	const PP = (...xs) => x => xs.reduce((a,b) => b(a), x)
	const $ = (q, node=document) => node.querySelector(q)
	const $$ = (q, node=document) => Array.from(node.querySelectorAll(q))
	const not = x => !x
	const qs = q => node => node.querySelector(q)
	const qss = q => node => Array.from(node.querySelectorAll(q))
	const href = x => x.href
	const endsWith = s => x => x.endsWith(s)
	const last = xs => xs[xs.length - 1]
	const initial = xs => xs.slice(0, xs.length - 1)
	const focus = x => x.focus()
	const click = x => x.click()
	const open = x => window.open(x, 1)
	const map = f => x => x.map(f)
	const split = d => x => x.split(d)
	const insertBefore = (what, where) => where.parentNode.insertBefore(what, where)
	const flatten = x => x.flat()
	const join = s => x => x.join(s)
	const find = f => xs => rejecter(undefined)(xs.find(f))
	const rejecter = bad => x => x === bad ? null : x
	const pluck = k => x => x[k]
	const tap = f => x => { f(x) ; return x }
	const addr = a => b => b + a
	const is = a => b => a === b
	const isnt = B1(not)(is)
	const match = regex => x => x.match(regex)
	const add_class = c => tap(x => x.classList.add(c))
	const remove_class = c => tap(x => x.classList.remove(c))
	const scroll_into_view = tap(x => x.scrollIntoView(false))
	const target = x => x.target
	const trim = x => x.trim()
	const when = cond => then => x => cond(x) ? then(x) : x
	const maybe = when(isnt(null))
	const nothing = when(is(null))
	const first = pluck(0)
	const elem = x => document.createElement(x)
	const listen = e => f => tap(x => x.addEventListener(e, f))
	const child = c => tap(x => x.appendChild(c))
	const set = k => v => tap(x => x[k] = v)
	const update = o => tap(x => Object.keys(o).forEach(k => x[k] = o[k]))
	const each = f => tap(xs => xs.forEach(f))
	const children = xs => tap(x => P(xs, map(child), each(T(x))))
	const before = a => tap(b => a.before(b))
	const after = a => tap(b => a.after(b))
	const value = x => x.value

	const options = PP(Object.entries,
		map(PP(
			x => ({ innerText: first(x), value: last(x) }),
			update,
			x => x(elem('option')))),
		children,
		T(elem('select')))

	const swap = (a, b) => x =>
		{ const av = x[a]
		const bv = x[b]
		return x.map((x, i) =>
			{ if (i === a) return bv
			else if (i === b) return av
			else return x }) }

	function key_pressed(keyevent)
		{ const cb = valid_shortcut_p(keyevent)
		if (cb === false) return true
		else
			{ cb()
			keyevent.preventDefault()
			keyevent.stopPropagation()
			return false }}

	function is_in_view(el)
		{ const rect = el.getBoundingClientRect()
		return (rect.top >= 0) && (rect.bottom <= window.innerHeight) }

	function define_key(keystring, cb) {
		if (keystring !== '') {
if (DEBUG) console.log("Wrangling Shortcuts: *** defining configured shortcut: " + keystring)
            const keyparts = keystring.split('-')
            const modset = new Set(initial(keyparts))
            let charcode = last(keyparts).charCodeAt(0)
if (DEBUG) console.log(`Wrangling Shortcuts: defining ${keystring}: letter ${last(keyparts)} is ASCII charcode ${charcode}`)
            if (charcode > 0b1111111) throw new RangeError('character code is larger than 255')
            if (modset.has('C')) charcode += 0b10000000000
            if (modset.has('A')) charcode += 0b01000000000
            if (modset.has('S')) charcode += 0b00100000000
if (DEBUG) console.log(`Wrangling Shortcuts: defining ${keystring}: full shortcut is charcode ${charcode}`)
            keys.set(charcode, cb)
        }
    }

	function keyevent_to_bitmap(event) {
        if (event.key.length > 1) return null

        // this only gets fired for letters/numbers, not Ctrl/Alt/Shift by themselves. therfore event.key matches the _letter_ in the defined shortcut
        if (DEBUG) console.log(`Wrangling Shortcuts: *** pressed: ${(event.ctrlKey ? "Ctrl + " : "")}${(event.altKey ? "Alt + " : "")}${(event.shiftKey ? "Shift + " : "")}${event.key}`);
        // oh the stupidity. for letters, event.keyCode is always the same, no matter if lowercase or uppercase (Shift combo)
        //                   for numbers, event.key.charCodeAt(0) is the same between standard and numpad digits
        // code is now built to work with charCodeAt(0), since it's easier to shift a letter to lowercase
        if (DEBUG) console.log(`Wrangling Shortcuts: pressed letter ${event.key} -> charcode is ${event.key.charCodeAt(0)} -> keyCode is ${event.keyCode}`)

        // if the event was a shift-combo, the reported key is uppercase and we have to shift the charcode to lowercase again
        let charcode = event.key.toLowerCase().charCodeAt(0)

		if (charcode > 0b11111111) return null
		if (event.ctrlKey)  charcode += 0b10000000000
		if (event.altKey)   charcode += 0b01000000000
		if (event.shiftKey) charcode += 0b00100000000
        if (DEBUG) console.log(`Wrangling Shortcuts: pressed full key combo charcode is ${charcode}`)
        if (DEBUG) console.log(`Wrangling Shortcuts: ${charcode} is ${ (keys.has(charcode)) ? "" : "NOT " }found in shortcut configuration`, keys)
		return charcode
    }

	function valid_shortcut_p(keyevent)
		{ const charcode = keyevent_to_bitmap(keyevent)
		if (charcode !== null && (charcode & 0b11100000000) > 0 && keys.has(charcode))
			return keys.get(charcode)
		else return false }

	function wrangling_check(x) {
        let y = x.match(new RegExp('^/tags/([^/]+)(/(.+))?$'))
        // this matches in a way that:
        //       /tags/Fluff/edit     /tags/Fluff
        // [0] = /tags/Fluff/edit     /tags/Fluff
        // [1] = Fluff                Fluff
        // [2] = /edit                --
        // [3] = edit                 --
        if (DEBUG) console.log("Wrangling Shortcuts: URL pieces", x, y);
		switch(true) {
            case x === '/tags/new' && runonpages.page_new:
                new_tag_page()
                window.onkeydown = key_pressed
                break
            case x === '/tag_wranglings' && runonpages.page_bin:
                wrangle_tags_page()
                window.onkeydown = key_pressed
                break
            case x === '/comments' && runonpages.page_cmt:
                tag_comments_page()
                window.onkeydown = key_pressed
                break
            case y === null: break
            case y[3] === 'edit' && runonpages.page_edit:
                edit_tag_page()
                window.onkeydown = key_pressed
                break
            case y[3] === 'wrangle' && runonpages.page_bin:
                wrangle_tags_page()
                window.onkeydown = key_pressed
                break
            case y[3] === 'comments' && runonpages.page_cmt:
                tag_comments_page()
                window.onkeydown = key_pressed
                break
            case y[2] === undefined && x !== '/tags/search' && runonpages.page_land:
                tag_landing_page()
                window.onkeydown = key_pressed
                break
            default: break
        }
    }

	function edit_tag_page()
		{ if (DEBUG) console.log('Wrangling Shortcuts: edit tag page activated')
		document.styleSheets[0].insertRule('.focused { outline: 2px solid #D50000; }', 1)
		const save = $('p.submit.actions > input[name="commit"]')
		const fandom = $('input#tag_fandom_string_autocomplete')
		const unwrangleable = $('#tag_unwrangleable')
		const works = $('ul.navigation.actions:nth-of-type(2) > li > a')
		const comments = $('p.navigation.actions > a')
		const canonical = $('#tag_canonical')
		const tagname = $('#tag_name')
		const mergers = location.origin + location.pathname.match(/(\/tags\/[^\/]+)/)[1] + '/wrangle?page=1&show=mergers'

		define_key(bindings.save, K1(click)(save))
		define_key(bindings.focus_syn, focus_syn_bar)
		define_key(bindings.focus_fandom, K1(focus)(fandom))
		define_key(bindings.toggle_unwrangleable, K1(click)(unwrangleable))
		define_key(bindings.open_works, K1(open)(works.href))
		define_key(bindings.open_comments, K1(open)(comments.href))
		define_key(bindings.toggle_canonical, K1(click)(canonical))
		define_key(bindings.open_mergers, K1(open)(mergers))
		define_key(bindings.focus_tag_name, K1(focus)(tagname))
        define_key(bindings['fandom-key'],add_fandom)
        define_key(bindings['syn-key'],no_beta)

        if (fandoms_check()) {
            const all_fandoms = $('dd[title="Fandoms"] a.check_all')
            const no_fandoms = $('dd[title="Fandoms"] a.check_none')
            define_key(bindings.select_fandoms, K1(click)(all_fandoms))
            all_fandoms.onclick = () => define_key(bindings.select_fandoms, K1(click)(no_fandoms))
            no_fandoms.onclick = () => define_key(bindings.select_fandoms, K1(click)(all_fandoms)) }

		if (relationship_check()) {
            const characters = $('#tag_character_string_autocomplete')
			define_key(bindings.toggle_rel_helper, rel_helper)
			define_key(bindings.focus_characters, K1(focus)(characters)) }

		if (synonym_check()) {
            const edit_synonym = $('p.actions:nth-of-type(2) > a')
			define_key(bindings.go_to_canonical, K1(click)(edit_synonym)) }

		if (characters_check()) {
            const allchars = $('dd[title="Characters"] a.check_all')
			define_key(bindings.select_characters, K1(click)(allchars))
            // these next three lines added for toggling between all and no characters selected
            const nochars = $('dd[title="Characters"] a.check_none')
            allchars.onclick = () => define_key(bindings.select_characters, K1(click)(nochars))
            nochars.onclick = () => define_key(bindings.select_characters, K1(click)(allchars))
            }

		function focus_syn_bar()
			{ P(document,
				qs('#edit_tag fieldset:first-of-type .delete'),
				maybe(click),
				() => P(document,
					qs('input#tag_syn_string_autocomplete'),
					maybe(focus))) }

		function relationship_check()
			{ const element = $('#edit_tag > fieldset > dl > dd:nth-of-type(2) > strong')
			return (element && element.innerHTML === 'Relationship') }

		function synonym_check()
			{ return Boolean($('p.actions:nth-of-type(2) > a')) }

        function fandoms_check()
			{ return Boolean($('dd[title="Fandoms"] a.check_all')) }

		function characters_check()
			{ return Boolean($('dd[title="Characters"] a.check_all')) }

        function add_fandom() {
            fandom.focus();
            const ke = new KeyboardEvent('keydown', { keyCode: 13, key: "Enter" });
            fandom.value=bindings['fandom-tag'];
            fandom.dispatchEvent(ke);
        }
        function no_beta() {
            const synbar = $('#tag_syn_string_autocomplete')
            synbar.focus();
            const ke = new KeyboardEvent('keydown', { keyCode: 13, key: "Enter" });
            synbar.value=bindings['syn-tag'];
            synbar.dispatchEvent(ke);
        }
    }

	function wrangle_tags_page()
		{ if (DEBUG) console.log('Wrangling Shortcuts: wrangle tags page activated')
		document.styleSheets[0].insertRule('.focused { outline: 2px solid #D50000; }', 1)
		const save = $('dd.submit > input[name="commit"]')
		const next = $('li.next > a')
		const previous = $('li.previous > a')
		const inputbar = $('#fandom_string_autocomplete')

		const rows = $$('tbody > tr')
		let selected_row = null
		const current_row = () => maybe(x => rows[x])(selected_row)

		define_key(bindings.save, K1(click)(save))
		define_key(bindings.focus_fandom, K1(focus)(inputbar))
		define_key(bindings.down, select_next_row)
		define_key(bindings.up, select_previous_row)
		define_key(bindings.edit_tag, open_edit_tag_page)
		define_key(bindings.toggle_selection, toggle_mass_wrangling_selected)
		define_key(bindings.next, K1(click)(next))
		define_key(bindings.previous, K1(click)(previous))
		define_key(bindings.open_works, open_works)
		define_key(bindings.open_mergers, open_mergers_page)
		define_key(bindings.open_comments, open_comments)

        // the problem with enabling the 'syn from bin' field: it probably doesn't exist yet in the DOM when the bindings are set
        // solution: a DOM Mutation observer that adds it once they appear in the DOM (added by the other script)
        // but: we only need to wait if either of the shortcuts was actually configured, otherwise there's a good chance the user doesn't have the script
        if (document.querySelector('#syn_tag_autocomplete_autocomplete') === null && (bindings.focus_syn !== "" || bindings.save_syn !== "")) {
            var synFields = 2;
            if (DEBUG) console.log('Wrangling Shortcuts: waiting for '+synFields+ ' fields from the "wrangle from bin" script');
            const synObserver = new MutationObserver(function(mutList, obs) {
                for (const mut of mutList) {
                    if (mut.addedNodes[0].id == 'syn_tag_autocomplete_autocomplete') {
                        if (DEBUG) console.log('Wrangling Shortcuts: synonym input field from the "wrangle from bin" script found');
                        define_key(bindings.focus_syn, K1(focus)($('#syn_tag_autocomplete_autocomplete')));
                        synFields--;
                    }
                    else if (mut.addedNodes[0].innerHTML.indexOf("wrangle_existing") > 0) {
                        if (DEBUG) console.log('Wrangling Shortcuts: submit button from the "wrangle from bin" script found');
                        define_key(bindings.save_syn, K1(click)($('button[name="wrangle_existing"]')));
                        synFields--;
                    }
                    if (synFields <= 0) synObserver.disconnect(); // stop watching, we've got all fields that we need
                }
            });
            // start observing the wrangulator form for child nodes being added by the 'syn from bin' script
            synObserver.observe(document.querySelector('#wrangulator > fieldset > dl'), { attributes: false, childList: true, subtree: true });
        }
        // if the fields already exist (the 'syn from bin' script finished first or we're refreshing after config was changed), add the keybindings immediately
        else {
            if (DEBUG) console.log('Wrangling Shortcuts: synonym input field and submit button from the "wrangle from bin" script found');
            define_key(bindings.focus_syn, K1(focus)($('#syn_tag_autocomplete_autocomplete')))
            define_key(bindings.save_syn, K1(click)($('button[name="wrangle_existing"]')))
        }

		function deselect_row()
			{ maybe(remove_class('focused'))(current_row()) }

		function select_row()
			{ P(current_row(),
				maybe(PP(add_class('focused'),
					when(B(not)(is_in_view))(scroll_into_view)))) }

		function select_first_row()
			{ deselect_row()
			selected_row = 0
			select_row() }

		function select_last_row()
			{ deselect_row()
			selected_row = rows.length - 1
			select_row() }

		function select_next_row()
			{ P(selected_row,
				maybe(rejecter(rows.length - 1)),
				maybe(PP
					(tap(deselect_row),
					() => selected_row += 1,
					select_row)),
				nothing(select_first_row)) }

		function select_previous_row()
			{ P(selected_row,
				maybe(rejecter(0)),
				maybe(PP
					(tap(deselect_row),
					() => selected_row -= 1,
					select_row)),
				nothing(select_last_row)) }

		function open_edit_tag_page()
			{ P(current_row(),
				maybe(PP(
					qss('ul.actions li a'),
					find(B(endsWith('edit'))(href)))),
				maybe(PP(
					href,
					open))) }

		function open_mergers_page()
			{ P(current_row(),
				maybe(PP(
					qss('ul.actions li a'),
					find(B(endsWith('edit'))(href)))),
				maybe(PP(
					href,
					match(/(.+)\/edit/),
					pluck(1),
					addr('/wrangle?page=1&show=mergers'),
					open))) }

		function open_comments()
			{ P(current_row(),
				maybe(PP(
					qss('ul.actions li a'),
					find(B(endsWith('edit'))(href)))),
				maybe(PP(
					href,
					match(/(.+)\/edit/),
					pluck(1),
					addr('/comments'),
					open))) }

		function toggle_mass_wrangling_selected()
			{ P(current_row(),
				maybe(qs('th input[type="checkbox"]')),
				maybe(click)) }

		function open_works()
			{ P(current_row(),
				maybe(PP(
					qss('ul.actions li a'),
					find(B(endsWith('works'))(href)))),
				maybe(PP(href, open))) }

    }

	function tag_comments_page()
		{ if (DEBUG) console.log('Wrangling Shortcuts: tag comments page activated')
		const textarea = $('div#add_comment textarea')
		const submit = $('div#add_comment .new_comment input[type="submit"]')
		const href = $('h2.heading a.tag').href + '/edit'
        const works = $('h2.heading a.tag').href + '/works'
        const mergers = $('h2.heading a.tag').href + '/wrangle?page=1&show=mergers'

		window.requestAnimationFrame(K1(focus)(textarea))

		define_key(bindings.save, K1(click)(submit))
		define_key(bindings.edit_tag, K1(open)(href))
        define_key(bindings.open_works, K1(open)(works))
		define_key(bindings.open_mergers, K1(open)(mergers))
    }

    function tag_landing_page()
		{ if (DEBUG) console.log('Wrangling Shortcuts: tag landing page activated')
		const href = $('.tag.home ul.navigation a[href$="/edit"]').href
        const works = href.slice(0,-5) + '/works'
        const mergers = href.slice(0,-5) + '/wrangle?page=1&show=mergers'
        const comments = href.slice(0,-5) + '/comments'

		define_key(bindings.edit_tag, K1(open)(href))
        define_key(bindings.open_works, K1(open)(works))
		define_key(bindings.open_mergers, K1(open)(mergers))
        define_key(bindings.open_comments, K1(open)(comments))
    }

	function new_tag_page()
		{ if (DEBUG) console.log('Wrangling Shortcuts: new tag page activated')
		const name = $('#tag_name')
		const canonical = $('#tag_canonical')
		const fandom = $('#tag_type_fandom')
		const character = $('#tag_type_character')
		const relationship = $('#tag_type_relationship')
		const freeform = $('#tag_type_freeform')
		const submit = $('p.submit.actions input[type="submit"]')

		define_key(bindings.focus_tag_name, K1(focus)(name))
		define_key(bindings.toggle_canonical, K1(click)(canonical))
		define_key(bindings.focus_fandom, K1(click)(fandom))
		define_key(bindings.focus_characters, K1(click)(character))
		define_key(bindings.click_relationship, K1(click)(relationship))
		define_key(bindings.click_freeform, K1(click)(freeform))
		define_key(bindings.save, K1(click)(submit)) }

	function rel_helper()
		{ if (DEBUG) console.log('Wrangling Shortcuts: rel helper activated')
		maybe(click)($('#edit_tag fieldset:first-of-type .delete'))
		const keys_cache = keys
		keys = new Map()
		const rel_field = $('input#tag_syn_string_autocomplete')

		let focused = null

		const reltype = P(
			options({ 'romantic': '/', 'platonic': ' & ' }),
			set('style')('max-width: 10em; margin: 1em; display: block;'))

		const editbox = P($('#tag_name'),
			value,
			split('/'),
			tap(x => reltype.value = x.length > 1 ? '/' : ' & '),
			map(split('&')),
			flatten,
			map(PP(trim, input_field)),
			children,
			T(elem('div')))

		const fieldset = P(elem('fieldset'),
			child(reltype),
			child(editbox))

		insertBefore(fieldset, $('#edit_tag fieldset:nth-of-type(2)'))
		editbox.firstElementChild.focus()

		define_key(bindings.save, commit_rel)
		define_key(bindings.toggle_rel_helper, cancel)
		define_key(bindings.add, append_char)
		define_key(bindings.remove, remove_char)
		define_key(bindings.down, focus_next)
		define_key(bindings.up, focus_prev)
		define_key(bindings.previous, move_before)
		define_key(bindings.next, move_after)
		define_key(bindings.toggle_rel_type, toggle_rel)
		define_key(bindings.flip, flip_name)

		function input_field(x)
			{ return P(elem('input'),
				set('value')(x),
				set('style')('max-width: 50em; margin: 1em; display: block;'),
				listen('focus')(PP(target, add_class('focused'), tap(x=>focused=x))),
				listen('focusout')(PP(target, remove_class('focused'), tap(()=>focused=null)))) }

		function flip_name()
			{ P(focused,
			maybe(x => x.value = P(x.value,
				split(' '),
				when(x=>x.length>1)(swap(0,1)),
				join(' ')))) }

		function toggle_rel()
			{ reltype.value = reltype.value === '/' ? ' & ' : '/' }

		function commit_rel()
			{ rel_field.focus()
			P(editbox,
			pluck('children'),
			Array.from,
			map(value),
			join(reltype.value),
			set('value'),
			T(rel_field),
			cancel) }

		function cancel()
			{ fieldset.remove()
			keys = keys_cache }

		function append_char()
			{ editbox.appendChild(input_field(''))
			editbox.lastElementChild.focus() }

		function remove_char()
			{ if (focused) {
				const p = x.previousSibling
				focused.remove()
				if (p) p.focus()
				else P(editbox.children, Array.from, last, focus) }}

		function focus_next()
			{ P(focused,
			maybe(pluck('nextSibling')),
			maybe(focus),
			nothing(()=>editbox.firstElementChild.focus())) }

		function focus_prev()
			{ P(focused,
			maybe(pluck('previousSibling')),
			maybe(focus),
			nothing(()=>editbox.lastElementChild.focus())) }

		function move_before()
			{ P(focused,
			maybe(pluck('previousSibling')),
			maybe(PP(before, T(focused), focus))) }

		function move_after()
			{ P(focused,
			maybe(pluck('nextSibling')),
			maybe(PP(after, T(focused), focus))) } }

	main() }

// we're waiting for the page to have finished loading before we register the shortcuts
if (document.readyState === 'complete')
	wrangling_keystrokes(window)
else
	window.addEventListener('load', () => wrangling_keystrokes(window))