DuelingNexus Deck Editor Revamp

Revamps the deck editor search feature.

// ==UserScript==
// @name         DuelingNexus Deck Editor Revamp
// @namespace    https://duelingnexus.com/
// @version      0.9.1
// @description  Revamps the deck editor search feature.
// @author       Sock#3222
// @grant        none
// @include      https://duelingnexus.com/editor/*
// ==/UserScript==

// TODO: separate search by name/eff
// TODO: rehash sort function

const EXT = {
    RESULTS_PER_PAGE: 30,
    MIN_INPUT_LENGTH: 1,
    SEARCH_BY_TEXT: true,
    MAX_UNDO_RECORD: 30,
    DECK_SIZE_LIMIT: null,
    Search: {
        cache: [],
        current_page: 1,
        max_page: null,
        per_page: null,
        messages: [],
    },
    // contents defined later
    EDIT_API: {}
};
window.EXT = EXT;
const FN = {
    compose: function (f, g) {
        return function (...inputs) {
            return f(g(...inputs));
        }
    },
    hook: function (f, h, g) {
        return function (...inputs) {
            let fv = f(...inputs);
            let gv = g(...inputs);
            return h(fv, gv);
        }
    },
    not: function (x) {
        return !x;
    },
    or: function (x, y) {
        return x || y;
    },
    and: function (x, y) {
        return x && y;
    },
    add: function (x, y) {
        return x + y;
    },
    fold: function (f, xs, seed = 0) {
        return xs.reduce(f, seed);
    },
    sum: function (xs) {
        return FN.fold(FN.add, xs);
    },
    // I heard you like obfuscation
    xor: function (x, y) {
        return x ? y ? 0 : 1 : y;
    },
};
window.FN = FN;

const TokenTypes = {
    OPERATOR: Symbol("TokenTypes.OPERATOR"),
    OPEN_PAREN: Symbol("TokenTypes.OPEN_PAREN"),
    CLOSE_PAREN: Symbol("TokenTypes.CLOSE_PAREN"),
    WHITESPACE: Symbol("TokenTypes.WHITESPACE"),
    EXPRESSION: Symbol("TokenTypes.EXPRESSION"),
    UNKNOWN: Symbol("TokenTypes.UNKNOWN"),
};
class SearchInputToken {
    constructor(raw, type, position) {
        this.raw = raw;
        this.type = type;
        this.position = position;
    }
    
    isWhiteSpace() {
        return this.type === TokenTypes.WHITESPACE;
    }
    
    isData() {
        return this.type === TokenTypes.EXPRESSION;
    }
    
    isOperator() {
        return this.type === TokenTypes.OPERATOR;
    }
    
    isOpenParenthesis() {
        return this.type === TokenTypes.OPEN_PAREN;
    }
    
    isCloseParenthesis() {
        return this.type === TokenTypes.CLOSE_PAREN;
    }
    
    toString() {
        // let repr = "Token[ ";
        // repr += this.position.toString().padStart(2);
        // repr += ":";
        // repr += this.type.toString().padEnd(32);
        // repr += JSON.stringify(this.raw);
        // repr += " ]";
        
        // return repr;
        
        return "SearchInputToken(" + JSON.stringify(this.raw) + ", " + this.type.toString() + ", " + this.position + ")";
    }
    
    inspect() {
        return this.toString();
    }
}
class SearchInputParser {
    constructor(input) {
        this.input = input;
        this.tokens = [];
        this.index = 0;
    }
    
    get running() {
        return this.index < this.input.length;
    }
    
    step() {
        let slice = this.input.slice(this.index);
        for(let [regex, type] of SearchInputParser.RULES) {
            let match = slice.match(regex);
            if(match && match.length) {
                match = match[0];
                if(type === TokenTypes.UNKNOWN) {
                    throw new Error("Unknown token(" + this.index + "): \"" + match + '"');
                }
                this.tokens.push(new SearchInputToken(match, type, this.index));
                this.index += match.length;
                break;
            }
        }
    }
    
    parse() {
        while(this.running) {
            this.step();
        }
    }
    
    static parse(text) {
        let inst = new SearchInputParser(text);
        inst.parse();
        return inst.tokens;
    }
    
    static parseSignificant(text) {
        return SearchInputParser.parse(text).filter(token => !token.isWhiteSpace());
    }
}
SearchInputParser.RULE_EXPRESSION = /^((?:[&=]|[!><]=?)\s*)?("(?:[^"]|"")*"|\S+?\b)/;
SearchInputParser.RULES = [
    [/^\s+/, TokenTypes.WHITESPACE],
    [/^\(/, TokenTypes.OPEN_PAREN],
    [/^\)/, TokenTypes.CLOSE_PAREN],
    [/^\b(?:OR|AND)\b|,/, TokenTypes.OPERATOR],
    [SearchInputParser.RULE_EXPRESSION, TokenTypes.EXPRESSION],
    [/^./, TokenTypes.UNKNOWN],
];

// higher = tighter
const OPERATOR_PRECEDENCE = {
    "AND":  100,
    "OR":   50,
};

class SearchInputShunter {
    constructor(input) {
        this.tokens = SearchInputParser.parseSignificant(input);
        this.index = 0;
        this.operatorStack = [];
        this.outputQueue = [];
    }

    static parseValue(raw) {
        let wholeMatch = raw.match(SearchInputParser.RULE_EXPRESSION);
        if(wholeMatch !== null) {
            let [ match, comp, value ] = wholeMatch;
            // default to = for comparison
            raw = (comp || "=") + value;
        }
        
        raw = raw.replace(/"((?:[^"]|"")*)"/g, function (match, inner) {
            // undouble escapes
            return inner.replace(/""/g, '"');
        });
        
        return raw;
    }
    
    get operatorStackTop() {
        return this.operatorStack[this.operatorStack.length - 1];
    }
    
    get running() {
        return this.index < this.tokens.length;
    }
    
    parseToken(token) {
        if(token.isData()) {
            // idc if this is impure shunting procedures, it works!
            let modifiedToken = new SearchInputToken(SearchInputShunter.parseValue(token.raw), token.type, token.position);
            this.outputQueue.push(modifiedToken);
        }
        else if(token.isOpenParenthesis()) {
            this.operatorStack.push(token);
        }
        else if(token.isCloseParenthesis()) {
            while(this.operatorStackTop && !this.operatorStackTop.isOpenParenthesis()) {
                this.outputQueue.push(this.operatorStack.pop());
            }
            if(this.operatorStackTop && this.operatorStackTop.isOpenParenthesis()) {
                this.operatorStack.pop();
            }
            else {
                throw new Error("Unbalanced parentheses");
            }
        }
        else if(token.isOperator()) {
            let currentPrecedence = OPERATOR_PRECEDENCE[token.raw];
            while(this.operatorStackTop && OPERATOR_PRECEDENCE[this.operatorStackTop.raw] > currentPrecedence) {
                this.outputQueue.push(this.operatorStack.pop());
            }
            this.operatorStack.push(token);
        }
    }
    
    shunt() {
        for(let token of this.tokens) {
            this.parseToken(token);
        }
        while(this.operatorStackTop) {
            this.outputQueue.push(this.operatorStack.pop());
        }
    }
    
    static parseSections(text) {
        let shunter = new SearchInputShunter(text);
        shunter.shunt();
        return shunter.outputQueue;
    }
}

let onStart = function () {
    // minified with https://kangax.github.io/html-minifier/
    const ADVANCED_SETTINGS_HTML_STRING = `
        <div id=rs-ext-advanced-search-bar>
          <button id=rs-ext-monster-toggle class="engine-button engine-button-default">monster</button>
          <button id=rs-ext-spell-toggle class="engine-button engine-button-default">spell</button>
          <button id=rs-ext-trap-toggle class="engine-button engine-button-default">trap</button>
          <button id=rs-ext-sort-toggle class="engine-button engine-button-default rs-ext-right-float">sort</button>
          <div id=rs-ext-advanced-pop-outs>
            <div id=rs-ext-sort class="rs-ext-shrinkable rs-ext-shrunk">
              <table id=rs-ext-sort-table class=rs-ext-table>
                <tr>
                  <th>Sort By</th>
                  <td>
                    <select class=rs-ext-input id=rs-ext-sort-by>
                      <option>Name</option>
                      <option>Level</option>
                      <option>ATK</option>
                      <option>DEF</option>
                    </select>
                  </td>
                </tr>
                <tr>
                  <th>Sort Order</th>
                  <td>
                    <select class=rs-ext-input id=rs-ext-sort-order>
                      <option>Ascending</option>
                      <option>Descending</option>
                    </select>
                  </td>
                </tr>
                <tr>
                  <th>Stratify?</th>
                  <td><input type=checkbox id=rs-ext-sort-stratify checked></td>
                </tr>
              </table>
            </div>
            <div id=rs-ext-spell class="rs-ext-shrinkable rs-ext-shrunk">
              <table>
                <tr>
                  <th>Spell Card Type</th>
                  <td>
                    <select id=rs-ext-spell-type>
                      <option></option>
                      <option>Normal</option>
                      <option>Quick-play</option>
                      <option>Field</option>
                      <option>Continuous</option>
                      <option>Ritual</option>
                      <option>Equip</option>
                    </select>
                  </td>
                </tr>
                <tr>
                  <th>Limit</th>
                  <td><input class=rs-ext-input id=rs-ext-spell-limit></td>
                </tr>
              </table>
            </div>
            <div id=rs-ext-trap class="rs-ext-shrinkable rs-ext-shrunk">
              <table>
                <tr>
                  <th>Trap Card Type</th>
                  <td>
                    <select id=rs-ext-trap-type>
                      <option></option>
                      <option>Normal</option>
                      <option>Continuous</option>
                      <option>Counter</option>
                    </select>
                  </td>
                </tr>
                <tr>
                  <th>Limit</th>
                  <td><input class=rs-ext-input id=rs-ext-trap-limit></td>
                </tr>
              </table>
            </div>
            <div id=rs-ext-monster class="rs-ext-shrinkable rs-ext-shrunk">
              <table class="rs-ext-left-float rs-ext-table"id=rs-ext-link-arrows>
                <tr>
                  <th colspan=3>Link Arrows</th>
                </tr>
                <tr>
                  <td><button class=rs-ext-toggle-button>↖</button></td>
                  <td><button class=rs-ext-toggle-button>↑</button></td>
                  <td><button class=rs-ext-toggle-button>↗</button></td>
                </tr>
                <tr>
                  <td><button class=rs-ext-toggle-button>←</button></td>
                  <td><button class=rs-ext-toggle-button id=rs-ext-equals>=</button></td>
                  <td><button class=rs-ext-toggle-button>→</button></td>
                </tr>
                <tr>
                  <td><button class=rs-ext-toggle-button>↙</button></td>
                  <td><button class=rs-ext-toggle-button>↓</button></td>
                  <td><button class=rs-ext-toggle-button>↘</button></td>
                </tr>
              </table>
              <div id=rs-ext-monster-table class="rs-ext-left-float rs-ext-table">
                <table>
                  <tr>
                    <th>Category</th>
                    <td>
                      <select class=rs-ext-input id=rs-ext-monster-category>
                        <option></option>
                        <option>Normal</option>
                        <option>Effect</option>
                        <option>Ritual</option>
                        <option>Fusion</option>
                        <option>Synchro</option>
                        <option>Xyz</option>
                        <option>Pendulum</option>
                        <option>Link</option>
                        <option>Leveled</option>
                        <option>Extra Deck</option>
                        <option>Non-Effect</option>
                        <option>Gemini</option>
                        <option>Flip</option>
                        <option>Spirit</option>
                        <option>Toon</option>
                      </select>
                    </td>
                  </tr>
                  <tr>
                    <th>Ability</th>
                    <td>
                      <select id=rs-ext-monster-ability class=rs-exit-input>
                        <option></option>
                        <option>Tuner</option>
                        <option>Toon</option>
                        <option>Spirit</option>
                        <option>Union</option>
                        <option>Gemini</option>
                        <option>Flip</option>
                        <option>Pendulum</option>
                      </select>
                    </td>
                  </tr>
                  <tr>
                    <th>Type</th>
                    <td>
                      <select id=rs-ext-monster-type class=rs-ext-input>
                        <option></option>
                        <option>Aqua</option>
                        <option>Beast</option>
                        <option>Beast-Warrior</option>
                        <option>Cyberse</option>
                        <option>Dinosaur</option>
                        <option>Dragon</option>
                        <option>Fairy</option>
                        <option>Fiend</option>
                        <option>Fish</option>
                        <option>Insect</option>
                        <option>Machine</option>
                        <option>Plant</option>
                        <option>Psychic</option>
                        <option>Pyro</option>
                        <option>Reptile</option>
                        <option>Rock</option>
                        <option>Sea Serpent</option>
                        <option>Spellcaster</option>
                        <option>Thunder</option>
                        <option>Warrior</option>
                        <option>Winged Beast</option>
                        <option>Wyrm</option>
                        <option>Zombie</option>
                        <option>Creator God</option>
                        <option>Divine-Beast</option>
                      </select>
                    </td>
                  </tr>
                  <tr>
                    <th>Attribute</th>
                    <td>
                      <select id=rs-ext-monster-attribute class=rs-ext-input>
                        <option></option>
                        <option>DARK</option>
                        <option>EARTH</option>
                        <option>FIRE</option>
                        <option>LIGHT</option>
                        <option>WATER</option>
                        <option>WIND</option>
                        <option>DIVINE</option>
                      </select>
                    </td>
                  </tr>
                  <tr>
                    <th>Limit</th>
                    <td><input class=rs-ext-input id=rs-ext-monster-limit></td>
                  </tr>
                  <tr>
                    <th>Level/Rank/Link Rating</th>
                    <td><input class=rs-ext-input id=rs-ext-level></td>
                  </tr>
                  <tr>
                    <th>Pendulum Scale</th>
                    <td><input class=rs-ext-input id=rs-ext-scale></td>
                  </tr>
                  <tr>
                    <th>ATK</th>
                    <td><input class=rs-ext-input id=rs-ext-atk></td>
                  </tr>
                  <tr>
                    <th>DEF</th>
                    <td><input class=rs-ext-input id=rs-ext-def></td>
                  </tr>
                </table>
              </div>
            </div>
          </div>
          <div id=rs-ext-spacer></div>
        </div>
    `;
    
    const ADVANCED_SETTINGS_HTML_ELS = jQuery.parseHTML(ADVANCED_SETTINGS_HTML_STRING);
    ADVANCED_SETTINGS_HTML_ELS.reverse();
    
    // minified with cssminifier.com
    const ADVANCED_SETTINGS_CSS_STRING = "#rs-ext-advanced-search-bar{width:100%}.rs-ext-toggle-button{width:3em;height:3em;background:#ddd;border:1px solid #000}.rs-ext-toggle-button:hover{background:#fff}button.rs-ext-selected{background:#00008b;color:#fff}button.rs-ext-selected:hover{background:#55d}.rs-ext-left-float{float:left}.rs-ext-right-float{float:right}.rs-ext-shrinkable{transition-property:transform;transition-duration:.3s;transition-timing-function:ease-out;height:auto;background:#ccc;width:100%;transform:scaleY(1);transform-origin:top;overflow:hidden;z-index:10000}.rs-ext-shrinkable>*{margin:10px}#rs-ext-monster,#rs-ext-spell,#rs-ext-trap,#rs-ext-sort{background:rgba(0,0,0,.7)}.rs-ext-shrunk{transform:scaleY(0);z-index:100}#rs-ext-advanced-pop-outs{position:relative}#rs-ext-advanced-pop-outs>.rs-ext-shrinkable{position:absolute;top:0;left:0}#rs-ext-monster-table th,#rs-ext-sort-table th{text-align:right}.rs-ext-table{padding-right:5px}#rs-ext-spacer{height:0;transition:height .3s ease-out}#rs-ext-sort{transition-property:top,transform}.engine-button[disabled],.engine-button:disabled{cursor:not-allowed;background:rgb(50,0,0);color:#a0a0a0;font-style:italic;}.rs-ext-card-entry-table button,.rs-ext-fullwidth-wrapper{width:100%;height:100%;}.rs-ext-card-entry-table{border-collapse:collapse;}.rs-ext-card-entry-table tr,.rs-ext-card-entry-table td{height:100%;}.editor-search-description{white-space:normal;}#editor-menu-spacer{width:15%;}.engine-button{cursor:pointer;}@media (min-width:1600px){.editor-search-result{font-size:1em}.editor-search-card{width:12.5%}.editor-search-banlist-icon{width:5%}.editor-search-result{width:100%}}.rs-ext-table-button{padding:8px;text-align:center;border:1px solid #AAAAAA;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;}.rs-ext-table-button:active{padding:8px 7px 8px 9px;}.rs-ext-flex{display:flex;width:100%;justify-content:space-between}.rs-ext-flex input{width:48%;}input::placeholder{font-style:italic;text-align:center;}";
    
    // disable default listener (Z.Pb)
    $("#editor-search-text").off("input");
    
    // VOLATILE FUNCTIONS, MAY CHANGE AFTER A MAIN UPDATE
    
    /* reload cards until pendulum hotfix */
    // const CARD_LIST = X;
    const CARD_LIST = {};
    const CardObject = function (a) {
        this.id = a.id;
        this.A = a.als || 0;
        this.za = a.sc || [];
        this.type = a.typ || 0;
        this.attack = a.atk || 0;
        this.i = a.def || 0;
        var b = a.lvl || 0;
        this.race = a.rac || 0;
        this.H = a.att || 0;
        this.level = b & 0xFF;
        this.lscale = (b >> 24) & 0xFF;
        this.rscale = (b >> 16) & 0xFF;
    };
    
    const CARD_LIST_VERSION = 84;
    const readCards = function (a) {
        jQuery.ajaxSetup({
            beforeSend: function(a) {
                a.overrideMimeType && a.overrideMimeType("application/json")
            }
        });
        jQuery.getJSON(h(`data/cards.json?v=${CARD_LIST_VERSION}`), function(b) {
            for (let card of b.cards) {
                CARD_LIST[card.id] = new CardObject(card);
            }
            // NOTE: different request from the above
            jQuery.getJSON(h(`data/cards_en.json?v=${CARD_LIST_VERSION}`), function(b) {
                for (let card of b.texts) {
                    let other = CARD_LIST[card.id];
                    if (other) {
                        other.name = card.n;
                        other.description = card.d;
                        other.ra = card.s || [];
                        other.Z = qa(other.name);
                    }
                }
                if(a) {
                    a();
                }
            })
        })
    };
    
    readCards();
    
    const TYPE_HASH = bg;
    const TYPE_LIST = Object.values(TYPE_HASH);
    
    const ATTRIBUTE_MASK_HASH = Yf;
    
    const ATTRIBUTE_HASH = {};
    for(obfs in ATTRIBUTE_MASK_HASH) {
        let attr = ag[obfs].toUpperCase();
        ATTRIBUTE_HASH[attr] = ATTRIBUTE_MASK_HASH[obfs];
    }
    
    const attributeOf = function (cardObject) {
        return cardObject.H;
    }
    
    const makeSearchable = function (name) {
        return name.replace(/ /g, "")
                   .toUpperCase();
    }
    
    const searchableCardName = function (id_or_card) {
        let card;
        if(typeof id_or_card === "number") {
            card = CARD_LIST[id_or_card];
        }
        else {
            card = id_or_card;
        }
        let searchable = makeSearchable(card.name);
        // cache name
        if(!card.searchName) {
            card.searchName = searchable;
        }
        return searchable;
    }
    
    const monsterType = function (card) {
        return Ef[card.race];
    }
    // U provides a map
    // (a.type & U[c]) => (Vf[U[c]])
    const monsterTypeMap = {};
    for(let key in Wf) {
        let value = Wf[key];
        monsterTypeMap[value] = parseInt(key, 10);
    }
    window.monsterTypeMap = monsterTypeMap;
    
    const allowedCount = function (card) {
        // card.A = the source id (e.g. for alt arts)
        // card.id = the actual id
        return Vf(card.A || card.id);
    }
    const clearVisualSearchOptions = function () {
        return Z.Db();
    }
    const isPlayableCard = function (card) {
        // internally checks U.U - having that bit means its not a playable card
        // (reserved for tokens, it seems)
        return Z.ua(card);
    }
    const sanitizeText = function (text) {
        // qa - converts to uppercase and removes:
        //      newlines, hyphens, spaces, colons, and periods
        return qa(text);
    }
    const cardCompare = function (cardA, cardB) {
        return Z.fa(cardA, cardB);
    }
    const shuffle = function (list) {
        return Z.Nb(list);
    }
    // clears the visual state, allowing editing to take place
    const clearVisualState = function () {
        Z.pa();
    }
    // restores the visual state
    const restoreVisualState = function () {
        Z.ma();
    }
    const lastElement = function (arr) {
        return arr[arr.length - 1];
    }
    // modified from https://stackoverflow.com/a/6969486/4119004
    const escapeRegex = function (string) {
        return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
    }
    const previewCard = Cc;
    
    // corresponds to EDIT_LOCATION where save was clicked
    // EXT.EDIT_API.SAVED_INDEX = 0;
    // const saveDeck = function () {
        // Z.Ob();
        // $("#editor-save-button").addClass("engine-button-disabled");
        // $.post("api/update-deck.php", {
            // id: window.Deck.id,
            // deck: JSON.stringify({
                // main: window.Deck.main,
                // extra: window.Deck.extra,
                // side: window.Deck.side
            // })
        // }, function(a) {
            // a.success || "no_changes" === a.error ? $("#editor-save-button").text("Saved!").delay(2E3).queue(function() {
                    // $(this).removeClass("engine-button-disabled").text("Save").dequeue()
                // }) :
                // $("#editor-save-button").text("Error while saving: " + a.error).delay(5E3).queue(function() {
                    // $(this).removeClass("engine-button-disabled").text("Save").dequeue()
                // })
        // }, "json");
    // }
    // EXT.EDIT_API.save = saveDeck;
    // re-attach listener
    // let saveButton = document.getElementById("editor-save-button");
    // $(saveButton).unbind();
    // saveButton.addEventListener("click", saveDeck);
    // capture page beforeunload
    // window.addEventListener("beforeunload", function (ev) {
        // if(EXT.EDIT_API.SAVED_INDEX !== EXT.EDIT_API.EDIT_LOCATION) {
            // ev.preventDefault(); // PREFERED METHOD (not supported universally)
            // return event.returnValue = ""; // deprecated
        // }
        // return null;
    // });
    
    const engineButton = function (content, id = null) {
        let button = makeElement("button", id, content);
        button.classList.add("engine-button", "engine-button-default");
        return button;
    }
    
    // Z.Ba = function() {
        // if(Z.selection) {
            // console.log("tick");
            // Z.selection.css("left", Z.Ib + 3).css("top", Z.Jb + 3);
        // }
    // };
    // reimplements Z.la
    const banlistIcons = {
        3: null,
        2: "banlist-semilimited.png",
        1: "banlist-limited.png",
        0: "banlist-banned.png",
    };
    const addCardToSearchWithButtons = function (cardId) {
        let card = CARD_LIST[cardId];
        let template = $(Z.Lb);
        template.find(".template-name").text(card.name);
        l(template.find(".template-picture"), card.id);
        if (card.type & U.L) {
            template.find(".template-if-spell").remove();
            template.find(".template-level").text(card.level);
            template.find(".template-atk").text(card.attack);
            template.find(".template-def").text(card.i);
            // Df - object indexed by powers of 2 containg *type* information
            // card.race = type
            template.find(".template-race").text(Ef[card.race]);
            // Ef - object indexed by powers of 2 containing *attribute* information
            // card.H = attribute
            template.find(".template-attribute").text(Ff[card.H]);
        }
        else {
            template.find(".template-if-monster").remove();
            var types = [];
            for (let n of Object.values(U)) {
                if(card.type & n) {
                    types.push(Wf[n]);
                }
            }
            template.find(".template-types").text(types.join("|"));
        }
        template.data("id", card.id);
        template.mouseover(function () {
            previewCard($(this).data("id"));
        });
        template.mousedown(function (a) {
            // left mouse - move card to tooltip
            if (1 == a.which) {
                let id = $(this).data("id");
                Z.ya(id);
                return false;
            }
            // right mouse - do nothing (allow contextmenu to trigger)
            if (3 == a.which) {
                return false;
            }
        });
        let addThisCard = function (el, destination = null) {
            let id = $(el).data("id");
            let card = CARD_LIST[id];
            // deduce destination
            if(!destination) {
                destination = "main";
                if (card.type & U.S || card.type & U.T || card.type & U.G || card.type & U.C) {
                    destination = "extra";
                }
            }
            addCard(id, destination, -1);
            return false;
        }
        template.on("contextmenu", function () {
            return addThisCard(this);
        });
        /* BANLIST TOKEN GENERATION */
        var banlistIcon = template.find(".editor-search-banlist-icon");
        let limitStatus = allowedCount(card);
        if(limitStatus !== 3) {
            banlistIcon.attr("src", "assets/images/" + banlistIcons[limitStatus]);
        }
        else {
            banlistIcon.remove();
        }
        let container = $("<table width=100% class=rs-ext-card-entry-table><tr><td width=74%></td><td width=13% class=rs-ext-table-button>Add to Main</td><td width=13% class=rs-ext-table-button>Add to Side</td></tr></table>");
        let [ cardTd, mainTd, sideTd ] = container.find("td");
        
        cardTd.append(...template);
        
        // let mainDeckAdd = engineButton("Add to Main");
        mainTd.addEventListener("click", function () {
            addThisCard(template);
        });
        // mainTd.append(mainDeckAdd);
        
        // let sideDeckAdd = engineButton("Add to Side");
        sideTd.addEventListener("click", function () {
            addThisCard(template, "side");
        });
        // sideTd.append(sideDeckAdd);
        
        Z.xa.append(container);
    }
    
    const addCardToSearch = function (card) {
        // return Z.la(card);
        return addCardToSearchWithButtons(card);
    }
    
    // interaction stuff
    const pluralize = function (noun, count, suffix = "s", base = "") {
        return noun + (count == 1 ? base : suffix);
    }
    
    // card identification stuff
    const isToken               = (card) => card.type & monsterTypeMap["Token"];
    
    const isTrapCard            = (card) => card.type & monsterTypeMap["Trap"];
    const isSpellCard           = (card) => card.type & monsterTypeMap["Spell"];
    const isRitualSpell         = (card) => isSpellCard(card) && (card.type & monsterTypeMap["Ritual"]);
    const isContinuous          = (card) => card.type & monsterTypeMap["Continuous"];
    const isCounter             = (card) => card.type & monsterTypeMap["Counter"];
    const isField               = (card) => card.type & monsterTypeMap["Field"];
    const isEquip               = (card) => card.type & monsterTypeMap["Equip"];
    const isQuickPlay           = (card) => card.type & monsterTypeMap["Quick-Play"];
    const isSpellOrTrap         = (card) => isSpellCard(card) || isTrapCard(card);
    
    const nonNormalSpellTraps   = [isContinuous, isQuickPlay, isField, isCounter, isEquip, isRitualSpell];
    const isNormalSpellOrTrap   = (card) =>
        isSpellOrTrap(card)
        && !nonNormalSpellTraps.some(cond => cond(card));
    
    const isNormalMonster       = (card) => card.type & monsterTypeMap["Normal"];
    const isEffectMonster       = (card) => card.type & monsterTypeMap["Effect"];
    const isMonster             = (card) => !isTrapCard(card) && !isSpellCard(card);
    const isNonEffectMonster    = (card) => !isEffectMonster(card);
    const isFusionMonster       = (card) => card.type & monsterTypeMap["Fusion"];
    const isRitualMonster       = (card) => isMonster(card) && (card.type & monsterTypeMap["Ritual"]);
    const isSynchroMonster      = (card) => card.type & monsterTypeMap["Synchro"];
    const isTunerMonster        = (card) => card.type & monsterTypeMap["Tuner"];
    const isLinkMonster         = (card) => card.type & monsterTypeMap["Link"];
    const isGeminiMonster       = (card) => card.type & monsterTypeMap["Dual"];
    const isToonMonster         = (card) => card.type & monsterTypeMap["Toon"];
    const isFlipMonster         = (card) => card.type & monsterTypeMap["Flip"];
    const isSpiritMonster       = (card) => card.type & monsterTypeMap["Spirit"];
    const isXyzMonster          = (card) => card.type & monsterTypeMap["Xyz"];
    const isPendulumMonster     = (card) => card.type & monsterTypeMap["Pendulum"];
    
    const isExtraDeckMonster = (card) => [
        isFusionMonster,
        isSynchroMonster,
        isXyzMonster,
        isLinkMonster
    ].some(fn => fn(card));
    
    const isLevelMonster = (card) => isMonster(card) && !isLinkMonster(card) && !isXyzMonster(card);
    
    // non-ritual main deck monster
    const isBasicMainDeckMonster  = (card) => isMonster(card) && !isRitualMonster(card) && !isExtraDeckMonster(card);
    
    let kindMap = {
        "TRAP": isTrapCard,
        "SPELL": isSpellCard,
        "MONSTER": isMonster,
        "CONT": isContinuous,
        "CONTINUOUS": isContinuous,
        "COUNTER": isCounter,
        "FIELD": isField,
        "EQUIP": isEquip,
        "QUICK": isQuickPlay,
        "QUICKPLAY": isQuickPlay,
        "NORMAL": isNormalMonster,
        "NORMALST": isNormalSpellOrTrap,
        "EFFECT": isEffectMonster,
        "NONEFF": isNonEffectMonster,
        "NONEFFECT": isNonEffectMonster,
        "FUSION": isFusionMonster,
        "RITUAL": isRitualMonster,
        "RITUALST": isRitualSpell,
        "TUNER": isTunerMonster,
        "LINK": isLinkMonster,
        "SYNC": isSynchroMonster,
        "SYNCHRO": isSynchroMonster,
        "DUAL": isGeminiMonster,
        "GEMINI": isGeminiMonster,
        "TOON": isToonMonster,
        "FLIP": isFlipMonster,
        "SPIRIT": isSpiritMonster,
        "XYZ": isXyzMonster,
        "PENDULUM": isPendulumMonster,
        "PEND": isPendulumMonster,
        "LEVELED": isLevelMonster,
        "EXTRA": isExtraDeckMonster,
    };
    
    
    const allSatisfies = function (tags, card) {
        return tags.every(tag => tag(card));
    }
    
    const defaultSearchOptionState = function () {
        clearVisualSearchOptions();
    };
    
    const displayResults = function () {
        defaultSearchOptionState();
        if(EXT.Search.cache.length !== 0) {
            replaceTextNode(currentPageIndicator, EXT.Search.current_page);
            let startPosition = (EXT.Search.current_page - 1) * EXT.Search.per_page;
            let endPosition = Math.min(
                EXT.Search.cache.length,
                startPosition + EXT.Search.per_page
            );
            for(let i = startPosition; i < endPosition; i++) {
                addCardToSearch(EXT.Search.cache[i].id);
            }
        }
        clearChildren(infoBox);
        for(let container of EXT.Search.messages) {
            let [kind, message] = container;
            let color, symbol;
            switch(kind) {
                case STATUS.ERROR:
                    color = GUI_COLORS.HEADER.FAILURE;
                    symbol = SYMBOLS.ERROR;
                    break;
                
                case STATUS.SUCCESS:
                    color = GUI_COLORS.HEADER.SUCCESS;
                    symbol = SYMBOLS.SUCCESS;
                    break;
                
                case STATUS.NEUTRAL:
                default:
                    color = GUI_COLORS.HEADER.NEUTRAL;
                    symbol = SYMBOLS.INFO;
                    break;
            }
            let symbolElement = document.createElement("span");
            let messageElement = document.createElement("span");
            
            appendTextNode(symbolElement, symbol);
            symbolElement.style.padding = "3px";
            appendTextNode(messageElement, message);
            
            let alignTable = document.createElement("table");
            alignTable.style.backgroundColor = color;
            alignTable.style.padding = "2px";
            alignTable.appendChild(makeElement("tr"));
            alignTable.children[0].appendChild(makeElement("td"));
            alignTable.children[0].children[0].appendChild(symbolElement);
            alignTable.children[0].appendChild(makeElement("td"));
            alignTable.children[0].children[1].appendChild(messageElement);
            
            infoBox.appendChild(alignTable);
        }
    }
    
    const STATUS = { ERROR: 0, NEUTRAL: -1, SUCCESS: 1 };
    const addMessage = function (kind, message) {
        EXT.Search.messages.push([kind, message]);
    }
    
    const initializeMessageContainer = function () {
        EXT.Search.messages = [];
    }
    // gui/dom manipulation stuff
    const clearChildren = function (el) {
        while(el.firstChild) {
            el.removeChild(el.firstChild);
        }
        return true;
    }
    const appendTextNode = function (el, text) {
        let textNode = document.createTextNode(text);
        el.appendChild(textNode);
        return true;
    }
    const makeElement = function (name, id = null, content = null, opts = {}) {
        let el = document.createElement(name);
        if(id !== null) {
            el.id = id;
        }
        if(content !== null) {
            appendTextNode(el, content);
        }
        for(let [key, val] of Object.entries(opts)) {
            el[key] = val;
        }
        return el;
    }
    const HTMLTag = function (tag, id, classes, ...children) {
        let el = makeElement(tag, id);
        if(classes) {
            el.classList.add(...classes);
        }
        for(let child of children) {
            el.appendChild(child);
        }
        return el;
    }
    const replaceTextNode = function (el, newText) {
        clearChildren(el);
        appendTextNode(el, newText);
    }
    const NO_SELECT_PROPERTIES = [
        "-webkit-touch-callout",
        "-webkit-user-select",
        "-khtml-user-select",
        "-moz-user-select",
        "-ms-user-select",
        "user-select",
    ];
    // TODO: select based on browser?
    const noSelect = function (el) {
        NO_SELECT_PROPERTIES.forEach(property => {
            el.style[property] = "none";
        });
    }
    
    const GUI_COLORS = {
        HEADER: {
            NEUTRAL: "rgba(0,   0,   0,   0.7)",
            SUCCESS: "rgba(0,   150, 0,   0.7)",
            FAILURE: "rgba(150, 0,   0,   0.7)"
        }
    };
    const SYMBOLS = {
        SUCCESS: "✔",
        INFO: "🛈",
        ERROR: "⚠",
    };
    
    let styleHeaderNeutral = function (el) {
        el.style.background = GUI_COLORS.HEADER.NEUTRAL;
        el.style.fontSize = "16px";
        el.style.padding = "3px";
        el.style.marginBottom = "10px";
    }
    
    // https://stackoverflow.com/a/30832210/4119004
    const download = function promptSaveFile (data, filename, type) {
        var file = new Blob([data], {type: type});
        if (window.navigator.msSaveOrOpenBlob) // IE10+
            window.navigator.msSaveOrOpenBlob(file, filename);
        else { // Others
            var a = document.createElement("a"),
                    url = URL.createObjectURL(file);
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            setTimeout(function() {
                document.body.removeChild(a);
                window.URL.revokeObjectURL(url);  
            }, 0); 
        }
    }
    
    /* TODO: allow larger deck sizes */
    
    /*
    let deckSizes = {
        "main":  [   40,    60,   100],
        "extra": [   10,    15,    25],
        "side":  [   10,    15,    25],
    };
    // if length > corresponding cell in deckSizes, pad by this much
    let cardMargins = {
        "main":  [ -3.6, -4.05, -6.00],
        "extra": [ -3.6, -4.05, -6.00],
        "side":  [ -3.6, -4.05, -6.00],
    };
    Z.Aa = function (a) {
        var padding = "0";
        // let 
        if(Z[a].length > ("main" == a ? 80 : 25)) {
            // padding = "main" == a ? "-4.05%" : "-4.72%";
            padding = "-6.0%";
        }
        else if(Z[a].length > ("main" == a ? 60 : 15)) {
            padding = "main" == a ? "-4.05%" : "-4.72%";
        }
        else if(Z[a].length > ("main" == a ? 40 : 10)) {
            padding = "-3.6%";
        }
        if (Z.ha[a] !== padding) {
            Z.ha[a] = padding;
            for (var c = 0; c < Z[a].length; ++c) Z[a][c].css("margin-right", padding);
        }
    },
    */
    
    /* UNDO / REDO */
    // const addCardSilent = Z.O;
    const addCardSilent = function(a, destination, c, d) {
        let card = X[a];
        if (card && !isToken(card)) {
            let toMainDeck = destination === "main";
            let toExtraDeck = destination === "extra";
            let isExtra = isExtraDeckMonster(card);
            let sizeLimit = EXT.DECK_SIZE_LIMIT || toMainDeck ? 60 : 15;
            let isInvalidLocation = (
                   isExtra && toMainDeck
                || !isExtra && toExtraDeck
                || Z[destination].length >= sizeLimit
                // cannot have more than 3 copies!
                || Z.Eb(card.A ? card.A : card.id) >= 3
            );
            
            if (!isInvalidLocation) {
                g = Z[destination];
                var k = $("#editor-" + destination + "-deck");
                d = $("<img>").css("margin-right", Z.ha[destination]).addClass("editor-card-small");
                l(d, a);
                d.mouseover(function() {
                    previewCard($(this).data("id"));
                });
                d.mousedown(function(a) {
                    if (1 == a.which) {
                        a = $(this).data("id");
                        var b = $(this).data("location"),
                            c = $(this).parent().children().index($(this));
                        Z.P(b, c);
                        Z.ya(a);
                        return false
                    }
                    if (3 == a.which) return false
                });
                d.mouseup(function(a) {
                    if (1 == a.which && Z.selection) {
                        a = $(this).data("location");
                        var b = $(this).parent().children().index($(this));
                        addCard(Z.selection.data("id"), a, b);
                        Z.aa();
                        return false
                    }
                });
                d.on("contextmenu", function() {
                    var a = $(this).data("location"),
                        b = $(this).parent().children().index($(this));
                    Z.P(a, b);
                    return false
                });
                d.data("id", a);
                d.data("alias", card.A);
                d.data("location", destination); 
                if(c === -1) {
                    k.append(d);
                    g.push(d);
                }
                else {
                    if(0 === c && 0 == k.children().length) {
                        k.append(d)
                    }
                    else {
                        k.children().eq(c).before(d);
                        g.splice(c, 0, d);
                    };
                }
                g = allowedCount(card);
                3 !== g ? (a = 2 === g ? "banlist-semilimited.png" : 1 === g ? "banlist-limited.png" : "banlist-banned.png", a = $("<img>").attr("src", "assets/images/" + a), d.data("banlist", a), $("#editor-banlist-icons").append(a)) : d.data("banlist", null);
                Z.Aa(destination);
                Z.N(destination)
            }
        }
    };
    const removeCardSilent = Z.P;
    const addCard = function (...args) {
        addCardSilent(...args);
        addEditPoint();
    };
    Z.O = addCard;
    EXT.EDIT_API.addCard = addCard;
    EXT.EDIT_API.addCardSilent = addCardSilent;
    
    const removeCard = function (...args) {
        removeCardSilent(...args);
        addEditPoint();
    };
    Z.P = removeCard;
    EXT.EDIT_API.removeCard = removeCard;
    EXT.EDIT_API.removeCardSilent = removeCardSilent;
    
    const deepCloneArray = function (arr) {
        return arr.map ? arr.map(deepCloneArray) : arr;
    };
    const isRawObject = (obj) => typeof obj === "object";
    const deepEquals = function (left, right) {
        if(left.map && right.map) {
            if(left.length !== right.length) {
                return false;
            }
            return left.every((e, i) => deepEquals(e, right[i]));
        }
        else {
            if(isRawObject(left) && isRawObject(right)) {
                let leftEntries = Object.entries(left).sort();
                let rightEntries = Object.entries(right).sort();
                return deepEquals(leftEntries, rightEntries);
            }
            return left === right;
        }
    };
    const mapIDs = function (arr) {
        return arr.map(e => e.data("id"));
    };
    const currentDeckState = function () {
        return {
            main: mapIDs(Z.main),
            extra: mapIDs(Z.extra),
            side: mapIDs(Z.side)
        };
    }
    const clearLocation = function (location) {
        while(Z[location].length) {
            removeCardSilent(location, 0);
        }
    };
    const clearDeck = function () {
        for (let location of ["main", "extra", "side"]) {
            clearLocation(location);
        }
    };
    
    // for use in undo/redo - replaces the current state with the new state
    /*
               TypeError: Z[c][e].appendTo is not a function engine.min.js:144:115
                ma https://duelingnexus.com/script/engine.min.js?v=187:144
                restoreVisualState debugger eval code:387
                updateDeckState debugger eval code:933
                undo debugger eval code:987

    */
    const updateDeckState = function (newState) {
        let state = currentDeckState();
        clearVisualState();
        overMainExtraSide((contents, location) => {
            if(deepEquals(contents, newState[location])) {
                // console.warn("EXT.EDIT_API.updateDeckState - new and old sections for " + location + " are equal, not updating");
                return;
            }
            clearLocation(location);
            for (let id of newState[location]) {
                addCardSilent(id, location, -1);
            }
        });
        restoreVisualState();
    };
    EXT.EDIT_API.updateDeckState = updateDeckState;
    EXT.EDIT_API.currentDeckState = currentDeckState;
    EXT.EDIT_API.EDIT_HISTORY = [ currentDeckState() ];
    EXT.EDIT_API.EDIT_LOCATION = 0;
    
    const addEditPoint = function () {
        let state = currentDeckState();
        let previousState = lastElement(EXT.EDIT_API.EDIT_HISTORY);
        
        // do not add a duplicate entry
        if(previousState && deepEquals(state, previousState)) {
            return false;
        }
        
        // remove all entries past the current edit location
        if(EXT.EDIT_API.EDIT_LOCATION >= EXT.EDIT_API.EDIT_HISTORY.length) {
            EXT.EDIT_API.EDIT_HISTORY.splice(EXT.EDIT_API.EDIT_LOCATION);
        }
        
        // add the state
        EXT.EDIT_API.EDIT_HISTORY.push(state);
        
        // remove a state from the front if we have too many
        if(EXT.EDIT_API.EDIT_HISTORY.length > EXT.MAX_UNDO_RECORD) {
            EXT.EDIT_API.EDIT_HISTORY.shift();
        }
        
        // update the edit location
        EXT.EDIT_API.EDIT_LOCATION = EXT.EDIT_API.EDIT_HISTORY.length - 1;
        
        // we can no longer redo, but now we can undo
        undoButton.disabled = false;
        redoButton.disabled = true;
        
        return true;
    };
    
    EXT.EDIT_API.addEditPoint = addEditPoint;
    
    const undo = function () {
        // we can't undo if there's nothing behind us
        if(EXT.EDIT_API.EDIT_LOCATION === 0) {
            console.warn("EXT.EDIT_API.undo function failed - nothing to undo!");
            return false;
        }
        
        // we moved back one
        EXT.EDIT_API.EDIT_LOCATION--;
        
        // refresh the deck state
        updateDeckState(EXT.EDIT_API.EDIT_HISTORY[EXT.EDIT_API.EDIT_LOCATION]);
        
        // we can now redo
        redoButton.disabled = false;
        
        // disable the undo button if there is nothing left to undo
        if(EXT.EDIT_API.EDIT_LOCATION === 0) {
            undoButton.disabled = true;
        }
        
        return true;
    }
    EXT.EDIT_API.undo = undo;
    
    const redo = function () {
        // if we're at the front, we can't redo
        if(EXT.EDIT_API.EDIT_LOCATION === EXT.EDIT_API.EDIT_HISTORY.length - 1) {
            console.warn("EXT.EDIT_API.redo function failed - nothing to redo!");
            return false;
        }
        
        // move forward once
        EXT.EDIT_API.EDIT_LOCATION++;
        
        // refresh the deck state
        updateDeckState(EXT.EDIT_API.EDIT_HISTORY[EXT.EDIT_API.EDIT_LOCATION]);
        
        // we can now undo
        undoButton.disabled = false;
        
        // disable the redo button if there is nothing left to redo
        if(EXT.EDIT_API.EDIT_LOCATION === EXT.EDIT_API.EDIT_HISTORY.length - 1) {
            redoButton.disabled = true;
        }
        
        return true;
    }
    EXT.EDIT_API.redo = redo;
    
    /* reimplementing nexus buttons with edit history enabled */
    const clear = function () {
        clearVisualState();
        overMainExtraSide((contents, location) => {
            Z[location] = [];
        });
        restoreVisualState();
        addEditPoint();
    }
    EXT.EDIT_API.clear = clear;
    
    // reset listener for editor's clear button
    let clearButton = document.getElementById("editor-clear-button");
    $(clearButton).unbind();
    clearButton.addEventListener("click", clear);
    
    const overMainExtraSide = function (it) {
        for(let name of ["main", "extra", "side"]) {
            it(Z[name], name);
        }
    };
    
    // sorts everything
    const sort = function () {
        clearVisualState();
        // for (var a = ["main", "extra", "side"], b = 0; b < a.length; ++b) {
            // var c = a[b];
            // Z[c].sort(function(a, b) {
                // return Z.fa(X[a.data("id")], X[b.data("id")])
            // });
            // Z.N(c);
        // }
        overMainExtraSide((contents, location) => {
            contents.sort((c1, c2) => 
                Z.fa(X[c1.data("id")], X[c2.data("id")])
            );
            Z.N(location);
        });
        restoreVisualState();
        addEditPoint();
    };
    
    // reset listener for editor's sort button
    let sortButton = document.getElementById("editor-sort-button");
    $(sortButton).unbind();
    sortButton.addEventListener("click", sort);
    
    const shuffleAll = function () {
        clearVisualState();
        overMainExtraSide((contents, location) => {
            Z.Nb(contents);
            Z.N(location);
        });
        restoreVisualState();
        addEditPoint();
    }
    
    let shuffleButton = document.getElementById("editor-shuffle-button");
    $(shuffleButton).unbind();
    shuffleButton.addEventListener("click", shuffleAll);
    
    // code for Export readable
    const countIn = function (arr, el) {
        return arr.filter(e => e === el).length;
    }

    const namesOf = function (el) {
        let names = [...el.children].map(e => CARD_LIST[$(e).data("id")].name);
        let uniq = [...new Set(names)];
        return uniq.map(name =>
            `${countIn(names, name)}x ${name}`
        );
    }

    const outputDeck = function () {
        let decks = {
            Main: "editor-main-deck",
            Extra: "editor-extra-deck",
            Side: "editor-side-deck",
        };
        
        return Object.entries(decks).map(([key, value]) =>
            [key + " Deck:", ...namesOf(document.getElementById(value))]
            .join("\n")
        ).join("\n--------------\n");
    }
    
    /* UPDATE PAGE STRUCTURE */
    // add new buttons
    
    let editorMenuContent = document.getElementById("editor-menu-content");
    
    let undoButton = makeElement("button", "rs-ext-editor-export-button", "Undo");
    undoButton.classList.add("engine-button", "engine-button", "engine-button-default");
    appendTextNode(editorMenuContent, " ");
    editorMenuContent.appendChild(undoButton);
    undoButton.disabled = true;
    
    undoButton.addEventListener("click", undo);
    
    let redoButton = makeElement("button", "rs-ext-editor-export-button", "Redo");
    redoButton.classList.add("engine-button", "engine-button", "engine-button-default");
    appendTextNode(editorMenuContent, " ");
    editorMenuContent.appendChild(redoButton);
    redoButton.disabled = true;
    
    redoButton.addEventListener("click", redo);
    
    let exportButton = makeElement("button", "rs-ext-editor-export-button", "Export .ydk");
    exportButton.classList.add("engine-button", "engine-button", "engine-button-default");
    exportButton.title = "Export Saved Version of Deck";
    appendTextNode(editorMenuContent, " ");
    editorMenuContent.appendChild(exportButton);
    
    exportButton.addEventListener("click", function () {
        let lines = [
            "#created by RefinedSearch plugin"
        ];
        for(let kind of ["main", "extra", "side"]) {
            let header = (kind === "side" ? "!" : "#") + kind;
            lines.push(header);
            // TODO: add option to use card.A rather than card.id
            lines.push(...Deck[kind]);
        }
        let message = lines.join("\n");
        download(message, Deck.name + ".ydk", "text");
    });
    
    let exportRawButton = makeElement("button", "rs-ext-editor-export-button", "Export Readable");
    exportRawButton.classList.add("engine-button", "engine-button", "engine-button-default");
    exportRawButton.title = "Export Human Readable Version of Deck";
    appendTextNode(editorMenuContent, " ");
    editorMenuContent.appendChild(exportRawButton);
    
    exportRawButton.addEventListener("click", function () {
        let message = outputDeck();
        download(message, Deck.name + ".txt", "text");
    });
    
    // add options tile
    let optionsArea = makeElement("div", "options-area"); /* USES NEXUS DEFAULT CSS/ID */
    let options = makeElement("button", "rs-ext-options");
    options.classList.add("engine-button", "engine-button", "engine-button-default");
    let cog = makeElement("i");
    cog.classList.add("fa", "fa-cog");
    options.appendChild(cog);
    appendTextNode(options, " Options");
    optionsArea.appendChild(options);
    
    // TODO: implement option toggle area
    // document.body.appendChild(optionsArea);
    
    let optionsWindow; /* USES NEXUS DEFAULT CSS/ID */
    let overflowDeckSizeElement;
    //;
    // makeElement("div", "options-window")
    optionsWindow = HTMLTag("div", "options-window", null,
        HTMLTag("p", null, null,
            overflowDeckSizeElement = makeElement("input", "rs-ext-decksize-overflow", { type: "checkbox" }),
            makeElement("label", null, "Enable deck overflow", { for: overflowDeckSizeElement.id })
        )
    );
    // TODO: add this
    
    // add css
    let rsExtCustomCss = makeElement("style", null, ADVANCED_SETTINGS_CSS_STRING);
    document.head.appendChild(rsExtCustomCss);
    
    let searchText = document.getElementById("editor-search-text");
    
    // info box
    let infoBox = makeElement("div");
    // let infoMessage = makeElement("span", "rs-ext-info-message");
    // infoBox.appendChild(document.createTextNode("🛈 "));
    // infoBox.appendChild(infoMessage);
    
    styleHeaderNeutral(infoBox);
    searchText.parentNode.insertBefore(infoBox, searchText);
    
    // advanced search settings
    for(let el of ADVANCED_SETTINGS_HTML_ELS) {
        searchText.parentNode.insertBefore(el, searchText);
    }
    
    // page navigation bar
    let navigationHolder = makeElement("div", "rs-ext-navigation");
    let leftButton = makeElement("button", "rs-ext-navigate-left", "<");
    let rightButton = makeElement("button", "rs-ext-navigate-right", ">");
    for(let button of [leftButton, rightButton]) {
        button.classList.add("engine-button");
        button.classList.add("engine-button-default");
    }
    leftButton.style.margin = rightButton.style.margin = "5px";
    let pageInfo = makeElement("span", null, "Page ");
    let currentPageIndicator = makeElement("span","rs-ext-current-page", "X");
    let maxPageIndicator = makeElement("span", "rs-ext-max-page", "X");
    pageInfo.appendChild(currentPageIndicator);
    appendTextNode(pageInfo, " of ");
    pageInfo.appendChild(maxPageIndicator);
    
    navigationHolder.appendChild(leftButton);
    navigationHolder.appendChild(pageInfo);
    navigationHolder.appendChild(rightButton);
    
    styleHeaderNeutral(navigationHolder);
    navigationHolder.style.textAlign = "center";
    noSelect(navigationHolder);
    
    searchText.parentNode.insertBefore(navigationHolder, searchText);
    
    // wire event listeners for advanced search settings
    let toggleButtonState = function () {
        let isSelected = this.classList.contains("rs-ext-selected");
        if(isSelected) {
            this.classList.remove("rs-ext-selected");
        }
        else {
            this.classList.add("rs-ext-selected");
        }
        updateSearchContents();
    };
    [...document.querySelectorAll(".rs-ext-toggle-button")].forEach(el => {
        el.addEventListener("click", toggleButtonState.bind(el));
    });
    
    let monsterTab = document.getElementById("rs-ext-monster");
    let spellTab = document.getElementById("rs-ext-spell");
    let trapTab = document.getElementById("rs-ext-trap");
    let sortTab = document.getElementById("rs-ext-sort");
    
    let spacer = document.getElementById("rs-ext-spacer");
    
    // returns `true` if the object is visible, `false` otherwise
    let toggleShrinkable = function (target, state = null) {
        let isShrunk = state;
        if(isShrunk === null) {
            isShrunk = target.classList.contains("rs-ext-shrunk");
        }
        if(isShrunk) {
            spacer.classList.add("rs-ext-activated");
            target.classList.remove("rs-ext-shrunk");
            return true;
        }
        else {
            spacer.classList.remove("rs-ext-activated");
            target.classList.add("rs-ext-shrunk");
            return false;
        }
        // equiv. `return isShrunk;`, changed for clarity
    }
    
    let createToggleOtherListener = function (target, ...others) {
        return function () {
            let wasShrunk = toggleShrinkable(target);
            if(wasShrunk) {
                others.forEach(other => other.classList.add("rs-ext-shrunk"));
            }
            updateSearchContents();
        }
    };
    
    document.getElementById("rs-ext-monster-toggle")
            .addEventListener("click", createToggleOtherListener(monsterTab,  spellTab,   trapTab));
    document.getElementById("rs-ext-spell-toggle")
            .addEventListener("click", createToggleOtherListener(spellTab,    monsterTab, trapTab));
    document.getElementById("rs-ext-trap-toggle")
            .addEventListener("click", createToggleOtherListener(trapTab,     monsterTab, spellTab));
    
    
    document.getElementById("rs-ext-sort-toggle").addEventListener("click", function () {
        toggleShrinkable(sortTab);
    });
    
    const currentSections = function () {
        return [monsterTab, spellTab, trapTab, sortTab].filter(el => !el.classList.contains("rs-ext-shrunk")) || null;
    }
    
    const updatePaddingHeight = function () {
        let sections = currentSections();
        let height = FN.sum(sections.map(section => section.clientHeight));
        spacer.style.height = height + "px";
        // update top position of sort, if necessary
        if(!sortTab.classList.contains("rs-ext-shrunk")) {
            sortTab.style.top = (height - sortTab.clientHeight) + "px";
        }
    }
    let interval = setInterval(updatePaddingHeight, 1);
    console.info("Interval started. ", interval);
    
    const LINK_ARROW_MEANING = {
        "Bottom-Left":      0b000000001,
        "Bottom-Middle":    0b000000010,
        "Bottom-Right":     0b000000100,
        "Center-Left":      0b000001000,
        // "Center-Middle":    0b000010000,
        "Center-Right":     0b000100000,        
        "Top-Left":         0b001000000,
        "Top-Middle":       0b010000000,
        "Top-Right":        0b100000000,
    };
    
    const UNICODE_TO_LINK_NUMBER = {
        "\u2196":   LINK_ARROW_MEANING["Top-Left"],
        "\u2191":   LINK_ARROW_MEANING["Top-Middle"],
        "\u2197":   LINK_ARROW_MEANING["Top-Right"],
        "\u2190":   LINK_ARROW_MEANING["Center-Left"],
        // no center middle
        "\u2192":   LINK_ARROW_MEANING["Center-Right"],
        "\u2199":   LINK_ARROW_MEANING["Bottom-Left"],
        "\u2193":   LINK_ARROW_MEANING["Bottom-Middle"],
        "\u2198":   LINK_ARROW_MEANING["Bottom-Right"],
        
        // meaningless
        "=":        0b0,    
    };
    const convertUnicodeToNumber = function (chr) {
        return UNICODE_TO_LINK_NUMBER[chr] || 0;
    }
    
    const tagStringOf = function (tag, value = null, comp = "") {
        if(value !== null) {
            return "{" + tag + " " + comp + value + "}";
        }
        else {
            return "{" + tag + "}";
        }
    }
    
    // various elements
    const INPUTS_USE_QUOTES = {
        "TYPE": true,
    };
    const MONSTER_INPUTS = {
        ARROWS:     [...document.querySelectorAll(".rs-ext-toggle-button")],
        TYPE:       document.getElementById("rs-ext-monster-type"),
        ATTRIBUTE:  document.getElementById("rs-ext-monster-attribute"),
        LEVEL:      document.getElementById("rs-ext-level"),
        SCALE:      document.getElementById("rs-ext-scale"),
        LIMIT:      document.getElementById("rs-ext-monster-limit"),
        ATK:        document.getElementById("rs-ext-atk"),
        DEF:        document.getElementById("rs-ext-def"),
        CATEGORY:   document.getElementById("rs-ext-monster-category"),
        ABILITY:    document.getElementById("rs-ext-monster-ability"),
    };
    const INPUT_TO_KEYWORD = {
        // ARROWS: "ARROWS",
        TYPE: "TYPE",
        ATTRIBUTE: "ATTR",
        LEVEL: "LEVIND",
        ATK: "ATK",
        DEF: "DEF",
        SCALE: "SCALE",
        LIMIT: "LIMIT",
    };
    const CATEGORY_TO_KEYWORD = {
        // primary
        "Normal": "NORMAL",
        "Effect": "EFFECT",
        "Ritual": "RITUAL",
        "Fusion": "FUSION",
        "Synchro": "SYNC",
        "Xyz": "XYZ",
        "Pendulum": "PEND",
        "Link": "LINK",
        // derived
        "Leveled": "LEVELED",
        "Extra Deck": "EXTRA",
        "Non-Effect": "NONEFF",
        // secondary
        "Flip": "FLIP",
        "Spirit": "SPIRIT",
        "Gemini": "GEMINI",
        "Toon": "TOON",
        "Tuner": "TUNER",
    };
    const CATEGORY_SOURCES = [ "CATEGORY", "ABILITY" ];
    const monsterSectionTags = function () {
        let tagString = "{MONSTER}";
        
        // links
        let selectedArrows = MONSTER_INPUTS.ARROWS.filter(arrow => arrow.classList.contains("rs-ext-selected"));
        let selectedSymbols = selectedArrows.map(arrow => arrow.textContent);
        let bitmaps = selectedSymbols.map(convertUnicodeToNumber);
        let mask = bitmaps.reduce((a, c) => a | c, 0b0);
        
        if(mask) {
            let comp = (selectedSymbols.indexOf("=") !== -1) ? "=" : "&";
            tagString += tagStringOf("ARROWS", mask, comp);
        }
        
        // category
        for(let category of CATEGORY_SOURCES) {
            let value = MONSTER_INPUTS[category].value;
            if(value) {
                let keyword = CATEGORY_TO_KEYWORD[value];
                tagString += tagStringOf(keyword);
            }
        }
        
        for(let [inputName, tagName] of Object.entries(INPUT_TO_KEYWORD)) {
            let inputElement = MONSTER_INPUTS[inputName];
            let value = inputElement.value;
            if(!value) continue;
            if(INPUTS_USE_QUOTES[inputName]) {
                value = '"' + value + '"';
            }
            switch(inputElement.tagName) {
                case "INPUT":
                    tagString += tagStringOf(tagName, value);
                    break;
                case "SELECT":
                    tagString += tagStringOf(tagName, value);
                    break;
                default:
                    console.error("Fatal error: unknown");
                    break;
            }
        }
        
        return tagString;
    }
    
    const SPELL_TRAP_INPUTS = {
        SPELL:       document.getElementById("rs-ext-spell-type"),
        SPELL_LIMIT: document.getElementById("rs-ext-spell-limit"),
        TRAP:        document.getElementById("rs-ext-trap-type"),
        TRAP_LIMIT:  document.getElementById("rs-ext-trap-limit"),
    };
    const SPELL_TO_KEYWORD = {
        "Normal": "NORMALST",
        "Quick-play": "QUICK",
        "Field": "FIELD",
        "Continuous": "CONT",
        "Ritual": "RITUALST",
        "Equip": "EQUIP",
    };
    const spellSectionTags = function () {
        let tagString = "{SPELL}";
        
        let value = SPELL_TRAP_INPUTS.SPELL.value;
        if(value) {
            let keyword = SPELL_TO_KEYWORD[value];
            tagString += tagStringOf(keyword);
        }
        
        let limit = SPELL_TRAP_INPUTS.SPELL_LIMIT.value;
        if(limit) {
            tagString += tagStringOf("LIM", limit);
        }
        
        return tagString;
    };
    
    const TRAP_TO_KEYWORD = {
        "Normal": "NORMALST",
        "Continuous": "CONT",
        "Counter": "COUNTER",
    };
    const trapSectionTags = function () {
        let tagString = "{TRAP}";
        
        let value = SPELL_TRAP_INPUTS.TRAP.value;
        if(value) {
            let keyword = TRAP_TO_KEYWORD[value];
            tagString += tagStringOf(keyword);
        }
        
        let limit = SPELL_TRAP_INPUTS.TRAP_LIMIT.value;
        if(limit) {
            tagString += tagStringOf("LIM", limit);
        }
        
        return tagString;
    }
    
    const generateSearchFilters = function () {
        let tags = "";
        for(let section of currentSections()) {
            switch(section) {
                case monsterTab:
                    tags += monsterSectionTags();
                    break;
                    
                case spellTab:
                    tags += spellSectionTags();
                    break;
                    
                case trapTab:
                    tags += trapSectionTags();
                    break;
                    
                // case null:
                default: 
                    break;
            }
        }
        return tags;
    }
    
    /* main code */
    
    const COMPARATORS = {
        ">":  function (x, y) { return x  >  y; },
        "<":  function (x, y) { return x  <  y; },
        "=":  function (x, y) { return x === y; },
        // used exclusively for masks
        "&":  function (x, y) { return (x & y) == y; },
        // "!":  function (x, y) { return x !== y; },
        "!=": function (x, y) { return x !== y; },
        "<=": function (x, y) { return x  <= y; },
        ">=": function (x, y) { return x  >= y; },
    };
    const generateComparator = function(compIdentifier) {
        let comp = COMPARATORS[compIdentifier];
        if(comp) {
            return comp;
        }
        else {
            return null;
        }
    }
    
    const createKindValidator = function (tagName) {
        tagName = tagName.toUpperCase();
        if(kindMap[tagName]) {
            return kindMap[tagName];
        } else {
            addMessage(STATUS.ERROR, "No such kind tag: " + tagName);
            return null;
        }
    }
    
    const VALIDATOR_ONTO_MAP = {
        "ATK": "attack",
        "DEF": "i",
        "ARROWS": "i",
        "SCALE": "lscale",
    };
    const VALIDATOR_LEVEL_MAP = {
        "LEVEL": isLevelMonster,
        "LV": isLevelMonster,
        "RANK": isXyzMonster,
        "RK": isXyzMonster,
        "LINK": isLinkMonster,
        "LR": isLinkMonster,
        "LI": () => true,
        "LEVIND": () => true,
    };
    
    const initialCapitalize = function (str) {
        return str.replace(/\w+/g, function (word) {
            return word[0].toUpperCase() + word.slice(1).toLowerCase();
        });
    }
    
    
    // returns a validation function
    /*
     * ALLOWED PARAMETERS:
     * ATK, num - search for atk 
     * DEF, num - search for atk
     * LIM, num - search by limit status (0 = banned, 1 = limited, 2 = semi-limited, 3 = unlimited)
     * LV,  num - search by level indicator
     * LEVEL,RANK,RK,LINK,LR - same for respective type
     *
     *
     * tag {
     *     value: compare to
     *     param: tag name
     *     comp:  functional comparator
     * }
     */
    const createValidator = function (tag) {
        if(VALIDATOR_ONTO_MAP[tag.param]) {
            let value = parseInt(tag.value, 10);
            let prop = VALIDATOR_ONTO_MAP[tag.param];
            return function (cardObject) {
                let objectValue = cardObject[prop];
                if(tag.param === "DEF" && isLinkMonster(cardObject)) {
                    return false;
                }
                if(tag.param === "ARROWS" && !isLinkMonster(cardObject)) {
                    return false;
                }
                if(tag.param === "SCALE" && !isPendulumMonster(cardObject)) {
                    return false;
                }
                return isMonster(cardObject) && tag.comp(objectValue, value);
            };
        }
        else if(VALIDATOR_LEVEL_MAP[tag.param]) {
            let check = VALIDATOR_LEVEL_MAP[tag.param];
            let level = parseInt(tag.value, 10);
            return function (cardObject) {
                return check(cardObject) && tag.comp(cardObject.level, level);
            }
        }
        else if(tag.param === "LIM" || tag.param === "LIMIT") {
            if(/^\d+$/.test(tag.value)) {
                let value = parseInt(tag.value, 10);
                return function (cardObject) {
                    let count = allowedCount(cardObject);
                    return tag.comp(count, value);
                };
            }
            else {
                addMessage(STATUS.ERROR, "Invalid numeral " + tag.value + ", ignoring tag");
                return function () {
                    return true;
                };
            }
        }
        else if(tag.param === "NAME") {
            let sub = sanitizeText(tag.value);
            return function (cardObject) {
                return sanitizeText(cardObject.Z).indexOf(sub) !== -1;
            };
        }
        else if(tag.param === "TYPE") {
            let searchType = initialCapitalize(tag.value);
            if(TYPE_LIST.indexOf(searchType) !== -1) {
                return function (cardObject) {
                    return monsterType(cardObject) == searchType;
                };
            }
            else {
                addMessage(STATUS.ERROR, "Invalid type " + tag.value + ", ignoring tag");
                return function () {
                    return true;
                };
            }
        }
        else if(tag.param === "ATTR" || tag.param === "ATTRIBUTE") {
            let searchAttribute = tag.value.toUpperCase();
            let attributeMask = ATTRIBUTE_HASH[searchAttribute];
            if(attributeMask !== undefined) {
                attributeMask = parseInt(attributeMask, 10);
                return function (cardObject) {
                    return attributeOf(cardObject) === attributeMask;
                };
            }
            else {
                addMessage(STATUS.ERROR, "Invalid attribute " + tag.value + ", ignoring tag");
                return function () {
                    return true;
                };
            }
        }
        else {
            addMessage(STATUS.ERROR, "No such parameter supported: " + tag.param);
            return null;
        }
    }
    
    const ISOLATE_COMPARATOR_REGEX = /^([&=]|[!><]=?)?(.+)/;
    
    // returns 1 or 2 elements
    const isolateComparator = function (str) {
        let [_, comp, rest] = str.match(ISOLATE_COMPARATOR_REGEX);
        return [rest, comp].filter(e => e);
    }
    
    const expressionToPredicate = function (expression, param, defaultComp = "=") {
        let [rest, comp] = isolateComparator(expression);
        comp = comp || defaultComp;
        let fn = generateComparator(comp);
        if(!fn) {
            throw new Error("Invalid comparator: " + comp);
        }
        
        let tag = {
            value: rest,
            param: param.toUpperCase(),
            comp: fn,
        };
        
        return createValidator(tag);
    }
    
    const operatorFunctions = {
        "OR": FN.or,
        "AND": FN.and,
    };
    const operatorNameToFunction = function (opName) {
        let fn = operatorFunctions[opName];
        return fn;
    }

    const textToPredicate = function (text, param) {
        let tokens = SearchInputShunter.parseSections(text);
        let stack = [];
        for(let token of tokens) {
            if(token.type === TokenTypes.EXPRESSION) {
                stack.push(expressionToPredicate(token.raw, param));
            }
            else if(token.type === TokenTypes.OPERATOR) {
                let right = stack.pop();
                let left = stack.pop();
                if(!left || !right) {
                    throw new Error("Insufficient arguments (incomplete input)");
                }
                let fn = operatorNameToFunction(token.raw);
                stack.push(FN.hook(left, fn, right));
            }
        }
        if(stack.length === 0) {
            addMessage(STATUS.ERROR, "Malformed input, not enough tokens.");
            return null;
        }
        return stack.pop();
    }
    
    const compare = (a, b) => (a > b) - (a < b);
    const compareBy = (f, rev = false) =>
        (a, b) => compare(f(a), f(b)) * (rev ? -1 : 1);
    
    // TODO: put traps always at end
    const SORT_BY_COMPARATORS = {
        "Name": compareBy(x => x.name),
        "Level": compareBy(x => x.level),
        "ATK": compareBy(x => x.attack),
        "DEF": compareBy(x => x.i),
        // TODO: sort each sub-strata? e.g. all level 1s by name
        // "Level": compareByAlterantives("level", "name"),
        // "ATK": compareByAlterantives("attack", "name"),
        // "DEF": compareByAlterantives("i", "name"),
    };
    let STRATA_KINDS = {
        LEVELED: [ isLevelMonster, isXyzMonster, isLinkMonster, isSpellCard, isTrapCard ],
        DEFAULT: [ isNormalMonster, isBasicMainDeckMonster, isRitualMonster, isFusionMonster, isSynchroMonster, isXyzMonster, isLinkMonster, isLevelMonster, isSpellCard, isTrapCard ],
    };
    let formStrata = function (list, by = "DEFAULT") {
        let fns = STRATA_KINDS[by];
        let strata = fns.map(e => []);
        for(let card of list) {
            let index = fns.findIndex(fn => fn(card));
            if(index !== -1) {
                strata[index].push(card);
            }
            else {
                console.error("Index not found in strata array", card);
            }
        }
        return strata;
    };
    let sortCardList = function (list, options) {
        let { methods, reverse, stratify, strataKind } = options;
        if(!Array.isArray(methods)) {
            methods = [ methods, "Name", "Level", "ATK", "DEF" ];
        }
        if(stratify) {
            let strata = formStrata(list, strataKind).map(stratum => 
                sortCardList(stratum, {
                    methods: methods,
                    reverse: reverse,
                    stratify: false,
                })
            );
            return [].concat(...strata);
        }
        let cmps = methods.map(method => SORT_BY_COMPARATORS[method]);
        let sorted = list.sort((a, b) => {
            for(let cmp of cmps) {
                let diff = cmp(a, b);
                if(diff !== 0) {
                    return diff;
                }
            }
            return 0;
        });
        if(reverse) {
            sorted.reverse();
        }
        return sorted;
    }
    
    let ensureCompareText = function (card) {
        card.compareText = card.compareText || card.description.toUpperCase();
        return card.compareText;
    };
    let parseInputQuery = function (text) {
        let prepend = "";
        let append = "";
        let inner = text.toString().trim();
        if(inner[0] === "^") {
            prepend = inner[0];
            inner = inner.slice(1);
        }
        if(lastElement(inner) === "$") {
            append = lastElement(inner);
            inner = inner.slice(0, -1);
        }
        inner = escapeRegex(inner);
        inner = inner.replace(/\\\*/g, ".*")
                     .replace(/_/g, ".");
        let compiled = prepend + inner + append;
        return new RegExp(compiled);
    };
    const ISOLATE_TAG_REGEX = /\{(!?)(\w+)([^\{\}]*?)\}/g;
    let updateSearchContents = function () {
        clearVisualSearchOptions();
        initializeMessageContainer();
        // TODO: move sanitation process later in the procedure
        let input = $("#editor-search-text").val();
        let effect = $("#editor-search-effect").val() || "";
        
        // append tags generated by search options
        let extraTags = generateSearchFilters();
        if(extraTags) {
            input += extraTags;
        }
        
        // isolate the tags in the input
        let tags = [];
        input = input.replace(ISOLATE_TAG_REGEX, function (match, isNegation, param, info) {
            // over each tag:
            let validator;
            try {
                if(info) {
                    validator = textToPredicate(info, param);
                }
                else {
                    validator = createKindValidator(param);
                }
            } catch(error) {
                addMessage(STATUS.ERROR, "Problem creating validator: " + error.message);
                return "";
            }
            
            if(validator) {
                if(isNegation) {
                    validator = FN.compose(FN.not, validator);
                }
                tags.push(validator);
            }
            else if(validator !== null) {
                addMessage(STATUS.ERROR, "Invalid validator (bug, please report): " + match);
            }
            
            // remove the tag, replace with nothing
            return "";
        });
        
        // remove any improper tags
        input = input.replace(/\{[^\}]*$/, function (tag) {
            addMessage(STATUS.NEUTRAL, "Removed incomplete tag: " + tag);
            return "";
        });
        
        // needs non-empty input
        if (input.length !== 0 || effect.length !== 0 || tags.length !== 0) {
            let exactMatches = [];
            let fuzzyMatches = [];
            
            let cardId = parseInt(input, 10);
            
            // if the user is searching by ID
            if(!isNaN(cardId)) {
                cardId = CARD_LIST[cardId];
                if(cardId && isPlayableCard(cardId)) {
                    fuzzyMatches.push(cardId);
                }
            }
            
            // only if the user is not searching by a valid ID
            let hasTags = tags.length !== 0;
            let isLongEnough = input.length >= EXT.MIN_INPUT_LENGTH || effect.length >= EXT.MIN_INPUT_LENGTH;
            let isFuzzySearch = hasTags || isLongEnough;
            
            let uppercaseInput = input.toUpperCase();
            let uppercaseText = parseInputQuery(effect.toUpperCase());
            let searchableInput = parseInputQuery(uppercaseInput.replace(/ /g, ""));
            if (0 === fuzzyMatches.length) {
                // for each card ID
                for (var e in CARD_LIST) {
                    let card = CARD_LIST[e];
                    if(!isPlayableCard(card) || !allSatisfies(tags, card)) {
                        continue;
                    }
                    let compareName = searchableCardName(card);
                    if(compareName === searchableInput) {
                        // if the search name is the input, push it
                        exactMatches.push(card);
                    } else if(isFuzzySearch) {
                        ensureCompareText(card);
                        let cardMatchesName = compareName.search(searchableInput) !== -1;
                        let cardMatchesEffect = card.compareText.search(uppercaseText) !== -1;
                        if(cardMatchesName && cardMatchesEffect) {
                            fuzzyMatches.push(card);
                        }
                    }
                }
            }
            
            // sort the results
            let method = $("#rs-ext-sort-by").val();
            let reverseResults = $("#rs-ext-sort-order").val() === "Descending";
            let stratify = document.getElementById("rs-ext-sort-stratify").checked;
            let strataKind;
            if(method === "Level") {
                strataKind = "LEVELED";
                stratify = true;
            }
            let params = {
                methods: method,
                reverse: reverseResults,
                stratify: stratify,
                strataKind: strataKind,
            };
            exactMatches = sortCardList(exactMatches, params);
            fuzzyMatches = sortCardList(fuzzyMatches, params);
            // exactMatches.sort(cardCompare);
            // fuzzyMatches.sort(cardCompare);
            
            // display 
            let totalEntryCount = exactMatches.length + fuzzyMatches.length;
            let displayAmount = Math.min(totalEntryCount, EXT.RESULTS_PER_PAGE);
            if(displayAmount === 0) {
                EXT.Search.cache = [];
                if(isFuzzySearch) {
                    let suggestion;
                    if(hasTags) {
                        suggestion = "Try changing or removing some tags.";
                    }
                    else {
                        suggestion = "Check your spelling and try again.";
                    }
                    if(input.replace(/\W/g, "").toLowerCase() == "sock") {
                        addMessage(STATUS.ERROR, "No results found. If you're looking for me, try Sock#3222 on discord :D");
                    }
                    else {
                        addMessage(STATUS.ERROR, "No results found. " + suggestion);
                    }
                }
                else {
                    addMessage(STATUS.ERROR, "Your input was too short. Try typing in some text or adding some tags.");
                }
            }
            else {
                let cache = exactMatches.concat(fuzzyMatches);
                EXT.Search.cache = cache;
                // RESULTS_PER_PAGE can change between calculations
                EXT.Search.per_page = EXT.RESULTS_PER_PAGE;
                EXT.Search.current_page = 1;
                EXT.Search.max_page = Math.ceil(EXT.Search.cache.length / EXT.Search.per_page);
                
                replaceTextNode(currentPageIndicator, EXT.Search.current_page);
                replaceTextNode(maxPageIndicator, EXT.Search.max_page);
                
                let message = "";
                
                let anyErrors = EXT.Search.messages.some(
                    message => message[0] == STATUS.ERROR
                );
                message += "Search successful";
                if(anyErrors) {
                    message += ", but there were some errors";
                }
                message += ". Found " + totalEntryCount + " ";
                message += pluralize("entr", totalEntryCount, "ies", "y");
                message += " (" + EXT.Search.max_page + " " + pluralize("page", EXT.Search.max_page) + ")";
                addMessage(anyErrors ? STATUS.NEUTRAL : STATUS.SUCCESS, message);
            }
        }
        else {
            EXT.Search.cache = [];
            replaceTextNode(currentPageIndicator, "X");
            replaceTextNode(maxPageIndicator, "X");
            addMessage(STATUS.NEUTRAL, "Please enter a search term to begin.");
        }
        displayResults();
    };
    
    // add new input function
    $("#editor-search-text").on("input", updateSearchContents)
    updateSearchContents();
    
    // add search by effect
    $("#editor-search-text").wrap($("<div class=rs-ext-flex></div>"));
    let searchWrapper = $("#editor-search-text").parent();
    let editorSearchByEffect = $("<input type=text class=engine-text-box id=editor-search-effect></input>");
    searchWrapper.append(editorSearchByEffect);
    editorSearchByEffect.on("input", updateSearchContents);
    
    // update attributes
    $("#editor-search-text").attr("placeholder", "Search by name/query");
    editorSearchByEffect.attr("placeholder", "Search by effect");
    
    // add relevant listeners
    let allInputs = [
        document.querySelectorAll("#rs-ext-monster-table input, #rs-ext-monster-table select"),
        Object.values(SPELL_TRAP_INPUTS),
        document.querySelectorAll("#rs-ext-sort-table input, #rs-ext-sort-table select"),
    ].flat();
    
    for(let input of allInputs) {
        $(input).on("input", updateSearchContents);
    }
    
    let previousPage = function () {
        EXT.Search.current_page = Math.max(1, EXT.Search.current_page - 1);
        displayResults();
    }
    let nextPage = function () {
        EXT.Search.current_page = Math.min(EXT.Search.max_page, EXT.Search.current_page + 1);
        displayResults();
    }
    $(leftButton).on("click", previousPage);
    $(rightButton).on("click", nextPage);
};

let checkStartUp = function () {
    if(Z.xa) {
        onStart();
        // destroy reference; pseudo-closure
        onStart = null;
    }
    else {
        setTimeout(checkStartUp, 100);
    }
}

checkStartUp();