garyc.me sketch tweaks

QoL tweaks and personal mods for garyc.me/sketch

// ==UserScript==
// @name        garyc.me sketch tweaks
// @namespace   garyc.me by quackbarc
// @description QoL tweaks and personal mods for garyc.me/sketch
// @homepage    https://github.com/quackbarc/garyc-sketch-tweaks
// @author      quac
// @version     1.6.4
// @match       https://garyc.me/sketch/*
// @match       http*://noz.rip/sketch/*
// @match       http*://noz.rip/sketch_bunker/*
// @icon        https://raw.githubusercontent.com/quackbarc/garyc-sketch-tweaks/master/crunge.png
// @license     MIT
// @run-at      document-body
// @grant       none
// ==/UserScript==

/* TODO:
    - animation speed setting..?
    - improve tag autocomplete caching..?
    - narrow down _purgeIntervals() to just the necessary intervals?
      cuz it might consequently affect other extensions.

    - sketch: update():
      - update the UI with updateUI(State.IDLE)
      - fix animation ending one line too early
      - fix animation using the moveTo/lineTo way of drawing

    - debug:
      - having the viewer open takes up a lot of CPU for some reason; i'm blaming pixi.
*/

/* / */

var settings = {};

var baseURL = "https://garyc.me/sketch";
if(window.location.pathname.startsWith("/sketch_bunker/") && window.location.hostname == "noz.rip") {
    baseURL = "https://noz.rip/sketch_bunker";
}

if(window.location.pathname.startsWith("/sketch")) {
    let db = new URLSearchParams(window.location.search).get("db");
    window.db = db && parseInt(db);    // db can be `null`
}

// Using a custom implementation of GM_addStyle instead of giving a @grant GM_addstyle;
// the latter limits our access to `window` properties very greatly.
// Implementation gracefully snagged from https://gist.github.com/arantius/3123124/ (MIT).
/**
 * @param {string} aCss The CSS to append to the page, specifically <head\>.
 */
function GM_addStyle(aCss) {
  'use strict';
  let head = document.getElementsByTagName('head')[0];
  if (head) {
    let style = document.createElement('style');
    style.setAttribute('type', 'text/css');
    style.textContent = aCss;
    head.appendChild(style);
    return style;
  }
  return null;
}

async function _sleep(ms) {
    return new Promise(res => setTimeout(res, ms));
}

function _purgeIntervals() {
    const lastInterval = setTimeout(() => void 0, 0) - 1;
    for(let int = 0; int <= lastInterval; int++) {
        clearInterval(int);
    }
}

function _getSettings() {
    let defaultSettings = {
        cacheSize: 100,
        theme: "auto",
        noAnimation: false,
        doReplay: true,
        thumbQuality: "default",
        sketchQuality: "default",
        relativeTimestamps: true,
        showDatecards: true,    // on the UI, these would be called "time cards"
        saveAsCanvas: false,
        sketchSaveResolution: 1,
        showStats: true,
        supportApril2023: true,
    };
    if(window.location.hostname == "noz.rip") {
        defaultSettings = {
            ...defaultSettings,
            useArchiveAsBooruSource: true,
            samePageBooru: true,
            showingBooruMenu: false,
            // noz.rip has its own cache with a limited size; gotta be faithful with it.
            cacheSize: 10,
        };
    }

    let settings = {};
    let storedSettings = JSON.parse(localStorage.getItem("settings_sketch")) || {};
    for(const [setting, defaultValue] of Object.entries(defaultSettings)) {
        settings[setting] = storedSettings[setting] ?? defaultValue;
    }

    return settings;
}

function _saveSettings() {
    localStorage.setItem("settings_sketch", JSON.stringify(settings));
}

function _updateTheme() {
    switch(settings.theme) {
        case "auto": {
            let prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
            document.documentElement.setAttribute("theme", prefersDark ? "dark" : "light");
            break;
        }
        case "dark":
        case "light": {
            document.documentElement.setAttribute("theme", settings.theme);
            break;
        }
        default: {
            document.documentElement.setAttribute("theme", "light");
        }
    }
}

function _updateSketchQuality(quality) {
    const ctx = $("canvas")[0].getContext("2d");

    switch(quality) {
        case "spiky": {
            ctx.lineJoin = "miter";
            break;
        }
        case "default":
        default: {
            ctx.lineJoin = "round";
            break;
        }
    }
}

function currentClient() {
    return window.location.hostname + window.location.pathname;
}

/* /: Main */

function main() {
    _purgeIntervals();
    settings = _getSettings();

    GM_addStyle(`
        /* dark theme */
        :root[theme="dark"] body {
            background-color: #111;
            color: #ccc;
        }
        :root[theme="dark"] #holder {
            background-color: #191919;
        }
        :root[theme="dark"] #holder img:not([src^=save]) {
            filter: invert(90%);
        }
        :root[theme="dark"] input[type="submit" i]:disabled button:disabled {
            background-color: #fff3;
            color: #fff8
        }
        :root[theme="dark"] h1 {
            color: #eee;
        }
        :root[theme="dark"] a {
            color: #5c99ff;
        }
        :root[theme="dark"] a:hover {
            color: #5c99ffcc;
        }
        :root[theme="dark"] a:visited {
            color: #8c1ae9;
        }
        :root[theme="dark"] a:visited:hover {
            color: #8c1ae9cc;
        }

        /* noz.rip */
        :root[theme="dark"] .panel {
            border-color: #888;
        }
        :root[theme="dark"] #holder svg {
            stroke: #e5e5e5;
        }

        /* userscript-created elements */
        :root {
            --z-index-dropdown: 10;
            --background-tag-suggestions: #fff;
            --background-tag-suggestions-selected: #eee;
        }
        :root[theme="dark"] {
            --background-tag-suggestions: #111;
            --background-tag-suggestions-selected: #222;
        }
    `);
    _updateTheme();
}

main();

/* /sketch/gallery.php */

const booruStates = {};
const cache = {};
let lastAlertPromise = null;
let lastAutocompletePromise = null;
let lastAutocompleteQuery = null;
let lastTagsValue = null;
let autocompleteSelected = null;
let cachedCanvasBlob = null;
let datecardDates = new Map();
window.details = null;

// enums

const BooruPostState = {
    POSTED: 1,
    ALREADY_POSTED: 2,
    PARSING_ERROR: 3,
}

const FooterState = {
    NORMAL: 0,
    END_OF_GALLERY: 1,
};

// miscellaneous methods

function _getCurrentTag(tagsBar) {
    const cursorPos = tagsBar.selectionStart;
    // Match everything from the beginning of the tags value to the nth
    // character, and any word/part of word that comes immediately after it.
    // Using [^ \n] instead of just [^ ] just to match with what . captures.
    // Using {0,n} instead of {n} because I don't want match breakage
    //   (from an n that's bigger than the search string).
    const pattern = new RegExp(`^.{0,${cursorPos}}[^ \n]*`);

    const rawTags = tagsBar.value;
    const [rawTagsShort,] = rawTags.match(pattern);
    const tags = rawTagsShort.split(" ");
    const currentTag = tags.at(-1);

    return currentTag;
}

function toSVG(dat, linejoin="round") {
    const commands = [];
    for(const line of dat.split(" ")) {
        for(let ind = 0; ind + 4 <= line.length; ind += 4) {
            const [x, y] = [
                parseInt(line.slice(ind, ind+2), 36),
                parseInt(line.slice(ind+2, ind+4), 36)
            ];
            const command = ind == 0 ? `M${x},${y}` : `L${x},${y}`;
            commands.push(command);
        }
    }

    const path = commands.join("");
    const xml = [
        '<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg">',
            '<path',
            `d="${path}"`,
            'fill="none"',
            'stroke="black"',
            'stroke-width="3px"',
            'stroke-miterlimit="10"',
            `stroke-linecap="butt"`,
            `stroke-linejoin="${linejoin}"/>`,
        '</svg>'
    ].join("\n");
    return xml;
}

// UI and public API methods

function _tileAnchorOverride(event) {
    event.preventDefault();

    const a = event.currentTarget;
    const idMatch = a.href.match(/#(\d+)/)
    const [hashID, id] = idMatch;
    window.history.pushState(window.history.state, "", hashID);
    show(parseInt(id));
}

function _navAnchorOverride(event) {
    event.preventDefault();

    const a = event.currentTarget;
    const idMatch = a.href.match(/#(\d+)/)
    const id = parseInt(idMatch[1]);
    show(id);
}

function _getThumbSize(qualityName) {
    switch(qualityName) {
        case "awful":
            return 4;
        case "oldDefault":
            return 20;
        case "raster":
            return 20.1;
        case "hq":
            return 40;
        case "default":
        default:
            return 100;
    };
}

function _getNozSVGAsset(type) {
    // These COULD be put on separate files for cacheability
    switch(type) {
        case "top": {
            return (`
                <svg
                    fill="none"
                    stroke="black"
                    stroke-width="30"
                    stroke-linejoin="round"
                    xmlns="http://www.w3.org/2000/svg"
                    viewBox="0 0 300 300"
                    width="300" height="300">
                        <circle cx="150" cy="150" r="135"></circle>
                        <path d="${[
                            "M 95,75 L 205,225 z",
                            "M 205,75 L 95,225 z"
                        ].join(" ")}">
                        </path>
                </svg>
            `);
        }
        case "left": {
            return (`
                <svg
                    fill="none"
                    stroke="black"
                    stroke-width="20"
                    xmlns="http://www.w3.org/2000/svg"
                    viewBox="0 0 300 300"
                    width="300" height="300">
                        <path d="${[
                            "M 180,30 L 16,150 L 180,270",
                            "V 200 H 290 V 100 H 180 V 30 z"
                        ].join(" ")}">
                        </path>
                </svg>
            `);
        }
        case "right": {
            return (`
                <svg
                    fill="none"
                    stroke="black"
                    stroke-width="20"
                    xmlns="http://www.w3.org/2000/svg"
                    viewBox="0 0 300 300"
                    width="300" height="300">
                        <path d="${[
                            "M 120,30 L 284,150 L 120,270",
                            "V 200 H 10 V 100 H 120 V 30 z",
                        ].join(" ")}">
                        </path>
                </svg>
            `);
        }
        default: {
            throw Error(`unknown asset type "${type}"`);
        }
    }
}

function getTile(id) {
    const client = currentClient();
    let imgURL;
    if(client == "noz.rip/sketch_bunker/gallery.php") {
        imgURL = `getIMG.php?id=${id}`;
    } else {
        let size = _getThumbSize(settings.thumbQuality);
        let dbParam = window.db != null ? `&db=${window.db}` : "";
        imgURL = `https://garyc.me/sketch/getIMG.php?format=png${dbParam}&id=${id}&size=${size}`;
    }

    const tile = $([
        `<a href="#${id}">`,
        `<img src="${imgURL}" style="`,
            `padding: 5px;`,
            `width: 160px;`,
            `height: 120px;`,
        `"></a>`,
    ].join(""));
    tile.click(_tileAnchorOverride);

    return tile;
}

function createDateCard(dt) {
    let weekday = dt.toLocaleString("default", {weekday: "long"});
    let date = dt.toLocaleString("default", {month: "long", day: "numeric", year: "numeric"});
    return $(`
        <div class="datecard">
            <div>
                ${weekday}<br>${date}
            </div>
        </div>
    `);
}

function currentURL() {
    const client = window.location.hostname + window.location.pathname;
    if(window.db != null) {
        return `https://${client}?db=${window.db}#${window.current}`;
    } else {
        return `https://${client}#${window.current}`;
    }
}

function currentArchiveURL() {
    if(window.db != null) {
        return null;
    } else {
        return `https://noz.rip/sketch_bunker/gallery.php?maxid=${window.current}#${window.current}`;
    }
}

function updateDetails(options={}) {
    const defaultOptions = {
        message: null,
        showFullTimestamp: false,
    };
    const mergedOptions = {...defaultOptions, ...options};
    const {message, showFullTimestamp} = mergedOptions;

    const unavailable = (window.dat == "wait" || window.dat == "wait ");    // thanks drawData();
    let elems = [];

    if(message != null) {
        elems.push(message);
    } else if(unavailable) {
        elems.push("(unavailable)");
    } else {
        let ink = Math.floor(window.dat.length / 65535 * 100);
        let inkText = `${ink}% ink used`;
        elems.push(inkText);
    }

    // This build custom HTML for the URL, unlike currentURL(), which only
    // returns it as a string.
    let client = window.location.hostname + window.location.pathname;
    let current = `<span class="id">#${window.current}</span>`;
    let url = (
        window.db != null
        ? `https://${client}?db=${window.db}${current}`
        : `https://${client}${current}`
    );
    elems.push(url);

    const hasSketchDetails = window.details.origin || (window.details.timestamp != null);  // for timestamp=0 ig
    if(hasSketchDetails) {
        let origin = window.details.origin;
        let date = new Date(window.details.timestamp * 1000);
        let timestamp = date
            .toLocaleString("default", {
                weekday: "short",
                month: "long",
                day: "2-digit",
                year: "numeric",
                hour: "2-digit",
                minute: "2-digit",
            });
        let timestampTooltip = date
            .toLocaleString("default", {
                weekday: "short",
                month: "long",
                day: "2-digit",
                year: "numeric",
                hour: "2-digit",
                minute: "2-digit",
                second: "2-digit",
                timeZoneName: "short",
            });
        if(settings.relativeTimestamps) {
            const today = new Date();
            const yesterday = new Date(today - 86_400_000);
            const dateOptions = {
                weekday: "short",
                month: "long",
                day: "2-digit",
                year: "numeric",
            };
            timestamp = timestamp
                .replace(today.toLocaleString("default", dateOptions), "Today")
                .replace(yesterday.toLocaleString("default", dateOptions), "Yesterday");

            const weekdayMin = new Date();
            weekdayMin.setDate(today.getDate() - 6);
            weekdayMin.setHours(0, 0, 0, 0);

            if(date >= weekdayMin) {
                timestamp = timestamp
                    .replace(
                        date.toLocaleString("default", dateOptions),
                        date.toLocaleString("default", {weekday: "long"})
                    );
            }
        }

        let timestampHTML = `<span title="${timestampTooltip}">${timestamp}</span>`;
        if(showFullTimestamp) {
            timestampHTML = `<span>${timestampTooltip}</span>`;
        }

        let detailsText = `from ${origin} • ${timestampHTML}`;
        if(origin == null) {
            detailsText = timestampHTML;
        }
        let detailsHTML = `<span class="extra">${detailsText}</span>`

        elems.push(detailsHTML);
    }

    switch(window.location.hostname + window.location.pathname) {
        case "noz.rip/sketch/gallery.php": {
            const left = $(`<div id="details-left"></div>`);
            const right = $(`<div id="details-right"></div>`);

            const [booruForm, booruToggle] = createBooruFormUI(window.current);

            $("#details").empty();
            $("#details").append(left, right);
            left.append(elems.join("<br>"));
            right.append(booruForm, booruToggle);
            break;
        }
        default: {
            $("#details").empty();
            $("#details").append(elems.join("<br>"));
        }
    }

    $(".extra span[title]").click(() => detailsFullTimestamp());
}

async function detailsAlert(msg) {
    updateDetails({message: msg});
    let alertPromise = lastAlertPromise = _sleep(3000);
    await alertPromise;
    if(alertPromise === lastAlertPromise) {
        updateDetails();
    }
}

async function detailsFullTimestamp() {
    updateDetails({showFullTimestamp: true});
    let alertPromise = lastAlertPromise = _sleep(3000);
    await alertPromise;
    if(alertPromise === lastAlertPromise) {
        updateDetails();
    }
}

function createStats() {
    const stats = $(`<span id="stats">...</span>`);
    return stats;
}

function updateStats(json) {
    const {sketches, artists, peekers} = json;
    let es_were = sketches == 1 ? " was" : "es were";
    let different_artists = artists == 1 ? " artist" : "different artists";
    let were = peekers == 1 ? "was" : "were";
    let people = peekers == 1 ? "person" : "people";

    $("#stats").html(
        "In the past 5 minutes, "
        + `<b>${sketches}</b> sketch${es_were} swapped by `
        + `<b>${artists}</b> ${different_artists}. There ${were} also `
        + `<b>${peekers}</b> ${people} who only peeked.`
    );
}

function createGalleryButtons(id) {
    let topAsset, leftAsset, rightAsset;
    switch(window.location.hostname) {
        case "noz.rip": {
            topAsset = _getNozSVGAsset("top");
            leftAsset = _getNozSVGAsset("left");
            rightAsset = _getNozSVGAsset("right");
            break;
        }
        default: {
            topAsset = `<img src="https://garyc.me/sketch/top.png">`;
            leftAsset = `<img src="https://garyc.me/sketch/left.png">`;
            rightAsset = `<img src="https://garyc.me/sketch/right.png">`;
        }
    }

    let leftID = Math.max(window.min, id + 1);
    let rightID = Math.min(window.max, id - 1);

    var top = `<a href="#0" onclick="hide()" class="top">${topAsset}</a>`;
    var leftReg = `<a href="#${leftID}" class="left">${leftAsset}</a>`;
    var leftMax = `<div class="left"></div>`;
    var rightReg = `<a href="#${rightID}" class="right">${rightAsset}</a>`;
    var rightMin = `<div class="right"></div>`;
    var left = id >= window.max ? leftMax : leftReg;
    var right = id <= window.min ? rightMin : rightReg;

    return {
        top: top,
        left: left,
        right: right,
    };
}

function updateGalleryButtons() {
    if(window.current == null) {
        return;
    }

    const {top, left, right} = createGalleryButtons(window.current);
    $(".top").replaceWith(top);
    $(".left").replaceWith(left);
    $(".right").replaceWith(right);
}

function saveBooruChanges(id, form) {
    if(!booruStates.hasOwnProperty(id)) {
        booruStates[id] = {
            booruPostID: null,
            booruPostStatus: null,
            uploading: false,
            tags: null,
            rating: null,
        };
    }

    const tagsBar = form.find("input[name='tags']");
    const ratingSelect = form.find("select#rating");

    const state = booruStates[id];
    state.tags = tagsBar.val();
    state.rating = ratingSelect.val();
}

async function getDateCards(endID, size) {
    if(size <= 0) {
        return [];
    }

    let fromID = endID - size + 1;
    let toID = endID;
    let lastTimestamp = new Date();

    var ret = [];

    const fetchIDFrom = Math.ceil(fromID / 100) * 100;
    const fetchIDTo = Math.ceil(toID / 100) * 100;
    for(let fetchID = fetchIDTo; fetchID >= fetchIDFrom; fetchID -= 100) {
        let html = await fetch(`https://garyc.me/sketch/getMore.php?start=${fetchID}&db=${db || ""}`)
            .then(r => r.text());

        // Parsing HTML with regex instead of making a document fragment,
        // since one, it's cleaner to write than the alternative, and two,
        // we won't get 404s from thumbnails of sketches that don't exist.

        const htmlRegex = /class='timestamp'.+?>(?<timestamp>\d*)<\/div><a href=['"](?<href>#\d+)/g;
        for(const match of html.matchAll(htmlRegex)) {
            if(!match.groups.timestamp) {
                continue;
            }

            let timestamp = new Date(match.groups.timestamp * 1000);
            let href = match.groups.href;
            let id = parseInt(href.replace("#", ""));

            if(lastTimestamp.toDateString() != timestamp.toDateString()) {
                ret.push([timestamp, createDateCard(timestamp), id]);
            }

            lastTimestamp = timestamp;
        }
    }

    return ret;
}

async function getDateCardMapping(last, size) {
    let datecards = {};
    for(const [timestamp, datecard, id] of await getDateCards(last, size)) {
        let date = timestamp.toDateString();
        datecards[id] = [datecard, date];
    }
    return datecards;
}

async function saveCanvas() {
    if(window.current == null) {
        return;
    }

    // Render the entire sketch first before saving
    window.setData(window.dat);

    const scale = settings.sketchSaveResolution;

    let downloadFn = window.db == null
        ? `${window.current}`
        : `${window.db}#${window.current}`;
    if(scale != 1) {
        downloadFn = `${downloadFn}_${scale}x`
    }

    await scaleCanvas(scale);

    const sketch = window.sketch[0];
    let blob = await new Promise((res, rej) => sketch.toBlob(blob => res(blob)));
    let url = URL.createObjectURL(blob);

    const a = document.createElement("a");
    a.href = url;
    a.download = downloadFn;
    a.click();

    URL.revokeObjectURL(url);

    await scaleCanvas(1);
}

function saveSVG() {
    if(window.current == null) {
        return;
    }

    const linejoin = settings.sketchQuality == "spiky" ? "miter" : "round";
    const svg = toSVG(window.dat, linejoin);

    const blob = new Blob([svg], {type: "image/svg+xml"});
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.download = `${window.current}.svg`;
    a.href = url;
    a.click();
    URL.revokeObjectURL(url);
}

async function scaleCanvas(size) {
    const width = (800 * size) | 0;
    const height = (600 * size) | 0;

    $("#sketch").attr({
        width: `${width}px`,
        height: `${height}px`
    });
    graphics.setTransform(0, 0, size, size);

    // For some reason, the lineJoin would revert itself back to "miter", the default.
    _updateSketchQuality(settings.sketchQuality);

    // Give the canvas some time to do the resize before we return
    await new Promise((res, rej) => window.requestAnimationFrame(res));
}

// Booru and tag autocomplete methods (for noz.rip/booru)

async function selfUploadToBooru(id, form) {
    // Form can only be serialized before it gets disabled.
    const formSerial = form.serialize();

    saveBooruChanges(id, form);
    const booruState = booruStates[id];

    booruState.uploading = true;
    updateDetails();

    let resp = await fetch(
        "/booru/upload",
        {
            method: "POST",
            body: new URLSearchParams(formSerial),
        }
    );

    const uploadSuccessful = resp.redirected;
    const loggedOut = resp.status == 403;

    if(loggedOut) {
        booruState.uploading = false;
        detailsAlert("can't upload; logged out of booru");
        return;
    }

    if(uploadSuccessful) {
        const match = resp.url.match(/\/view\/(\d+)/);
        const postID = parseInt(match[1]);
        booruState.booruPostID = postID;
        booruState.booruPostStatus = BooruPostState.POSTED;
    }
    else {
        // Until I find a way to properly check for errors and hash duplicates through the wire,
        // this will have to do.

        const idPattern = /data-post-id='(\d+)'/;

        const text = await resp.text();
        const match = text.match(idPattern);
        if(!match) {
            const doc = new DOMParser().parseFromString(text, "text/html");

            const xEmptyErrorElem = $(doc).find("section[id^=Error_with] .blockbody");
            const generalErrorElem = $(doc).find("section[id^=Error] .blockbody");

            const isXEmptyError = xEmptyErrorElem.length > 0;
            const isGeneralError = generalErrorElem.length > 0;
            if(isXEmptyError) {
                booruState.uploading = false;
                detailsAlert("can't upload; unavailable sketch");
                return;
            }
            else if(isGeneralError) {
                const errorMessage = generalErrorElem.text();

                booruState.uploading = false;
                detailsAlert(`booru error: ${errorMessage}`);
                return;
            }
            else {
                console.error("Unexpected response from Shimmie:", doc);
                booruState.booruPostStatus = BooruPostState.PARSING_ERROR;
            }
        }
        else {
            const postID = parseInt(match[1]);
            booruState.booruPostID = postID;
            booruState.booruPostStatus = BooruPostState.ALREADY_POSTED;
        }
    }

    booruState.uploading = false;
    if(window.current == id) {
        updateDetails();
    }
}

async function hideTagSuggestions() {
    $("#tagSuggestions").hide();
    lastAutocompletePromise = null;
    lastAutocompleteQuery = null;
    autocompleteSelected = null;
}

async function updateTagSuggestions() {
    const tagsBar = $("input[name='tags']");
    const tagsBarElement = tagsBar[0];

    const currentTag = _getCurrentTag(tagsBarElement);
    if(!currentTag) {
        hideTagSuggestions();
        return;
    }

    $("#tagSuggestions").hide();

    let autocompletePromise = lastAutocompletePromise = _sleep(200);
    await autocompletePromise;
    if(autocompletePromise !== lastAutocompletePromise) {
        return;
    }

    const baseURL = "https://noz.rip/booru/api/internal/autocomplete";
    const url = baseURL + "?s=" + currentTag;
    // Endpoint doesn't send caching instructions;
    // we're on our own here
    const cacheType = "reload";

    let p = fetch(url, {cache: cacheType}).catch(err => err);
    let fetchPromise = lastAutocompletePromise = p;
    const resp = await fetchPromise;
    if(fetchPromise !== lastAutocompletePromise) {
        return;
    }
    lastAutocompletePromise = null;

    // Network issue; ignore
    if(resp instanceof TypeError) {
        return;
    }

    if(!resp.ok) {
        await autocompleteError(resp);
        return;
    }

    const json = await resp.json();
    await autocompleteDropdown(json, currentTag);
}

async function autocompleteError(response) {
    $("#tagSuggestions").show();
    $("#tagSuggestions").html(`
        <tr role="option" class="tagInfo">
            <td colspan="2">
                (something went wrong: ${response.status} ${response.statusText})
            </td>
        </tr>
    `);
}

async function autocompleteDropdown(json, query) {
    const tagElements = [];
    let tags = Object.entries(json);
    if(json instanceof Array) {
        // For queries with zero results. Damn this API is terrible
        tags = json;
    }

    if(tags.length == 0) {
        const element = $(`
            <tr role="option" class="tagInfo">
                <td colspan="2">
                    (new tag: ${query})
                </td>
            </tr>
        `);
        tagElements.push(element);
        $("#tagSuggestions").show();
        $("#tagSuggestions").html(tagElements);
    }

    const lastSelectedIndex = tags.findIndex(([name, count]) => name == autocompleteSelected);
    const selectedIsKept = lastSelectedIndex >= 0;

    if(tags.length >= 1 && selectedIsKept) {
        autocompleteSelected = autocompleteSelected;
    }
    else if(tags.length >= 1 && !selectedIsKept) {
        autocompleteSelected = tags[0][0];
    }
    else if(tags.length == 0) {
        autocompleteSelected = null;
    }

    const maxTagCount = 20;
    for(let i = 0; i < Math.min(tags.length, maxTagCount); i++) {
        const [name, result] = tags[i];
        const {count} = result;
        const element = $(`
            <tr role="option" name="${name}">
                <td class="tagName">${name}</td>
                <td class="tagCount">${count}</td>
            </tr>
        `);

        // Don't lose focus off the tags bar.
        element.on("pointerdown", function(event) {
            const focusingTagsBar = $("input[name='tags']").has(":focus");
            return !focusingTagsBar;
        });
        element.on("pointerup", () => addTag(name, query));

        element.on("pointerover", () => autocompleteSelect(name));
        element.attr("aria-selected", (name == autocompleteSelected).toString());
        tagElements.push(element);
    }

    if(tags.length > maxTagCount) {
        const remainingTags = tags.slice(maxTagCount);
        const element = $(`
            <tr role="option" class="tagInfo">
                <td colspan="2">
                    (${remainingTags.length} more...)
                </td>
            </tr>
        `);
        tagElements.push(element);
    }

    $("#tagSuggestions").show();
    $("#tagSuggestions").html(tagElements);
}

function autocompleteSelect(name) {
    const option = $(`#tagSuggestions [name="${name}"]`);
    const optionExists = option.length >= 1;
    if(!optionExists) {
        console.debug(`"${name}" doesn't exist in visible tags, ignoring that`);
        return;
    }

    const optionLast = $(`#tagSuggestions [aria-selected]`);
    optionLast.attr("aria-selected", "false");
    option.attr("aria-selected", "true");

    autocompleteSelected = name;
}

function addTag(name, query) {
    const tagsBar = $("#booruForm input[name=tags]");
    const rawTags = tagsBar.val();
    const index = tagsBar.prop("selectionStart");

    const [section,] = rawTags.match(new RegExp(`.{0,${index}}[^ ]*`));
    let sectionTags = section.split(" ");
    sectionTags[sectionTags.length - 1] = name;
    sectionTags = sectionTags.join(" ");

    const newIndex = sectionTags.length + 1;
    const newTags = sectionTags + " " + rawTags.slice(section.length).trimLeft(" ");
    tagsBar.prop("value", newTags);
    tagsBar.prop("selectionStart", newIndex);
    tagsBar.prop("selectionEnd", newIndex);

    tagsBar.focus();
    hideTagSuggestions();
}

// overrides

function gallery_update() {
    if(autodrawpos >= 0) {
        for(var i = 0; i < 8; i++) {
            if(autodrawpos == lines.length) {
                autodrawpos = -1;
                break;
            }
            var line = lines[autodrawpos++];
            if(line.moveTo) {
                graphics.moveTo(line.x1, line.y1);
            }
            graphics.lineTo(line.x2, line.y2);
        }
    }
}

async function refresh() {
    $("#refresh").prop("disabled", true);
    $("#refresh").val("checking...");

    function enableRefresh() {
        $("#refresh").prop("disabled", false);
        $("#refresh").val("refresh");
    }

    $.ajax({
        url: `https://garyc.me/sketch/getStats.php?details&db=${db || ""}`,
        dataType: "json",
        success: function(json) {
            updateStats(json);
            const newMax = json.maxID;

            // noz.rip: `window.max` can be fetched from a $.ajax() on init,
            // but it's saved as a string. Firing this request a bit after
            // the $.ajax() call SHOULD fix that on time.

            const init = window.max == null || typeof window.max == "string";
            if(init) {
                window.max = newMax;
                window.min = json.minID;
                updateGalleryButtons();
                return enableRefresh();
            }

            if(window.max == newMax) {
                return enableRefresh();
            }

            for(let id = window.max + 1; id <= newMax; id++) {
                $("#tiles").prepend(
                    $(getTile(id))
                      .hide()
                      .show(1000)
                );
            }

            if(settings.showDatecards) {
                // Max values are -1'd so that IDs ending with 00 are NOT
                // equal to IDs ending with 01; the latter's where
                // `addMore.php`'s thumbnails start.
                let lastMax100 = Math.floor((window.max - 1) / 100);
                let newMax100 = Math.floor((newMax - 1) / 100);
                if(newMax100 > lastMax100) {
                    // Size is +1'd so the previous sketch gets a datecard
                    // when the current day changes.
                    addDateCards(newMax, newMax - window.max + 1);
                }
            }

            const viewingLatestSketch = window.current == window.max;
            window.max = newMax;
            window.min = json.minID;

            if(viewingLatestSketch) {
                updateGalleryButtons();
            }

            enableRefresh();
        },
        error: function(req) {
            enableRefresh();
        },
    });
}

async function nozBunker_refresh() {
    if(window.customMax != null) {
        return;
    }

    $("#refresh").prop("disabled", true);
    $("#refresh").val("checking...");

    function enableRefresh() {
        $("#refresh").prop("disabled", false);
        $("#refresh").val("refresh");
    }

    $.ajax({
        url: `https://noz.rip/sketch_bunker/getMaxID.php`,
        dataType: "text",
        success: function(resp) {
            const newMax = parseInt(resp);
            if(window.max == newMax) {
                return enableRefresh();
            }

            for(let id = window.max + 1; id <= newMax; id++) {
                $("#tiles").prepend(
                    $(getTile(id))
                      .hide()
                      .show(1000)
                );
            }

            const viewingLatestSketch = window.current == window.max;
            window.max = newMax;

            if(viewingLatestSketch) {
                updateGalleryButtons();
            }

            enableRefresh();
        },
        error: function(req) {
            enableRefresh();
        },
    });
}

function _getAprilFoolsColor(id) {
    const index = [
        "#4B0082",  // purple
        "#0000FF",  // blue
        "#008000",  // dark green
        "#FFFF00",  // yellow
        "#FFA500",  // orange
        "#FF0000",  // red
    ];

    return index[id % 6];
}

function gallery_drawData(data) {
    reset();

    var parts = data.split(" ");
    var ox = 0;
    var oy = 0;
    for(var i = 0; i < parts.length; i++) {
        var part = parts[i];
        for(var j = 0; j < part.length; j += 4) {
            var x = dec(part.substr(j, 2));
            var y = dec(part.substr(j+2, 2));
            if(j >= 4) {
                lines.push({
                    moveTo: (j == 4),
                    x1: ox,
                    y1: oy,
                    x2: x,
                    y2: y,
                });
            }
            ox = x;
            oy = y;
        }
    }

    // dunno what this extra space is for but that's what was
    // on the original client
    window.dat = data.trim() + " ";

    autodrawpos = 0;
}

function gallery_reset() {
    let fillColor = 0xFFFFFF;

    // April Fools' 2023 color support
    if(settings.supportApril2023 && window.details) {
        const {id, timestamp} = window.details;
        const aprilFools2023 = (timestamp >= 1680332400) && (timestamp < 1680418800);
        if(aprilFools2023) {
            const color = _getAprilFoolsColor(id);
            const colorInt = parseInt(color.slice(1), 16);
            fillColor = colorInt;
        }
    }

    graphics.clear();

    graphics.beginFill(fillColor);
    graphics.drawRect(0, 0, 800, 600);
    graphics.endFill();

    graphics.lineStyle(3, 0x000000);
    graphics.moveTo(0,0);

    dat = "";
    lines = [];
    autodrawpos = -1;
}

function show(id) {
    // show() via page init passes the ID as a string (from URL hash).
    // can't change that since it's fired from an event listener.
    id = parseInt(id);
    if(Number.isNaN(id)) return;

    if(id == 0) return;
    // prevents showing the same sketch again.
    if(id == window.current) return;

    const client = window.location.hostname + window.location.pathname;
    const nozClient = client == "noz.rip/sketch/gallery.php";
    if(nozClient) {
        hideTagSuggestions();
    }

    const hashID = `#${id}`
    const historyState = window.history.state;
    const showingFromHash = window.location.hash == hashID;
    if(window.current == null && !showingFromHash) {
        window.history.pushState(historyState, "", hashID);
    }
    else {
        window.history.replaceState(historyState, "", hashID);
    }

    window.current = id;

    // html building
    // TODO: don't rebuild this everytime this function's called

    const {top, left, right} = createGalleryButtons(id);

    let saveParts = [];
    let saveSVGParts = [];

    let saveAnchorStart;
    if(settings.saveAsCanvas) {
        saveAnchorStart = '<a class="save" title="Save (PNG)">'
    } else {
        let sizeParam = settings.sketchSaveResolution * 100;
        let dbParam = window.db != null ? `&db=${window.db}` : "";
        let downloadFn = window.db == null ? `${id}` : `${window.db}#${id}`;
        saveAnchorStart = [
            `<a`,
                ` href="${baseURL}/getIMG.php?format=png${dbParam}&id=${id}&size=${sizeParam}"`,
                ` download="${downloadFn}.png"`,
                ` class="save"`,
                ` title="Save (PNG)"`,
            `>`
        ].join("");
    }

    saveParts.push(
        saveAnchorStart,
        `<img src="save.png" style="width: 25px; height: 25px; position: relative;">`,
        `</a>`,
    );

    if(nozClient) {
        saveSVGParts.push(
            '<a class="saveSVG" title="Save (SVG)">',
            `<img src="svg.png" style="width: 25px; height: 25px; position: relative;">`,
            '</a>',
        );
    }

    var saves = [`<div class="saves">`, ...saveParts, ...saveSVGParts, `</div>`].join("");
    var bottom = `<div id="details">...</div>`;

    $("#holder").addClass("active");
    $("#holder").empty();
    $("#holder").append([top, left, sketch, right, bottom, saves]);
    $("#tiles").css({opacity: "75%"});

    $("a.left").click(_navAnchorOverride);
    $("a.right").click(_navAnchorOverride);
    if(settings.saveAsCanvas) {
        $(".save").click(() => saveCanvas());
    }
    if(window.location.hostname == "noz.rip") {
        $(".saveSVG").click(() => saveSVG());
    }

    // clear alerts and other cached properties from the last shown sketch
    lastAlertPromise = null;
    // clearing the cached blob is better done on reset() but i don't wanna
    // monkeypatch that method right now just for this
    cachedCanvasBlob = null;

    sketch.show();
    sketch.on("click", () => {
        if(autodrawpos == -1 && settings.doReplay) {
            drawData(window.dat);
        } else {
            setData(window.dat);
        }
    });
    reset();
    get(id);
}

function hide() {
    $("#tiles").css({opacity: "100%"});
    $("#holder").removeClass("active");
    window.current = null;
    window.details = null;
    reset();

    // Prevent back-forward soft-lock from navigating to gallery.php (w/o hash)
    const firedFromHash = (!window.location.hash || window.location.hash == "#0");
    if(!firedFromHash) {
        window.history.pushState(window.history.state, "", "#0");
    }

    const client = window.location.hostname + window.location.pathname;
    const nozClient = client == "noz.rip/sketch/gallery.php";
    if(nozClient) {
        hideTagSuggestions();
    }
}

function addToCache(id, details) {
    details.data = details.data.trim();
    cache['#' + id] = details;
    let keys = Object.keys(cache);
    let tail = keys[0];
    if(keys.length > settings.cacheSize) {
        delete cache[tail];
    }
}

async function get(id) {
    function success(details) {
        let dat = details.data;
        window.dat = dat;
        window.details = details;
        updateDetails();

        if(dat == "wait") return;
        if(window.autodrawpos == -1) {
            if(settings.noAnimation) {
                setData(dat);
            } else {
                drawData(dat);
            }
        }
    }

    if(cache.hasOwnProperty("#" + id)) {
        return success(cache["#" + id]);
    }

    $.ajax({
        url: `${baseURL}/get.php?db=${db || ""}&id=${id}&details`,
        dataType: "text",
        success: function(resp) {
            // Despite being a JSON endpoint, "wait" still gets sent as plain
            // text without quotes.
            let details;
            if(resp == "wait") {
                details = {
                    id: id,
                    data: "wait",
                    timestamp: null,
                    origin: null,
                };
            } else {
                try {
                    details = JSON.parse(resp);
                }
                catch(err) {
                    // If we're here, then this is just plain data.
                    details = {
                        id: id,
                        data: resp,
                        timestamp: null,
                        origin: null,
                    };
                }
            }

            if(window.dat.trim() == details.data.trim()) {
                // We already loaded this sketch; don't load it again.
                return;
            }

            addToCache(id, details);
            if(window.current == id) {
                success(details);
            }
        },
        error: function(req) {
            $("#details").html("network error.");
        },
    });
}

async function addDateCards(last, size) {
    if(!settings.showDatecards) {
        return;
    }

    for(const [timestamp, datecard, id] of await getDateCards(last, size)) {
        let date = timestamp.toDateString();

        if(datecardDates.has(date)) {
            const datecardID = datecardDates.get(date);
            const datecardNeedsUpdate = id > datecardID;
            if(!datecardNeedsUpdate) {
                continue;
            }

            const a = $(`#tiles a[href='#${datecardID}']`);
            const oldDatecard = a.prev();
            oldDatecard.remove();
        }

        const a = $(`#tiles a[href='#${id}']`);
        if(a.length > 0) {
            a.before(datecard);
            datecardDates.set(date, id);
        }
    }
}

async function addMore(n=100) {
    const client = window.location.hostname + window.location.pathname;
    const bunkerClient = client == "noz.rip/sketch_bunker/gallery.php";

    let limit;
    if(bunkerClient) {
        limit = 1;
    } else {
        const hardLimit = 1;
        const lastPossible = Math.max(hardLimit, window.min);
        limit = lastPossible;
    }

    let newtiles = [];
    let last = window.max - ($("#tiles").children("a").length) + 1;
    let target = Math.max(last - n, limit);

    for(let id = last - 1; id >= target; id--) {
        newtiles.push(getTile(id));
    }

    const footerState = target == limit
        ? FooterState.END_OF_GALLERY
        : FooterState.NORMAL;
    if(footerState == FooterState.END_OF_GALLERY && !(last == target)) {
        const tilesEnd = createGalleryFooter(footerState);
        $("#tilesEnd").replaceWith(tilesEnd);
    }

    $("#tiles").append(newtiles);

    if(!bunkerClient) {
        addDateCards(last - 1, n);
    }
}

function addMoreTop(n=100) {
    const client = window.location.hostname + window.location.pathname;
    const bunkerClient = client == "noz.rip/sketch_bunker/gallery.php";
    if(!bunkerClient) {
        return;
    }

    let newtiles = [];
    let last = window.max;
    let target = Math.min(last + n, window.archiveMax);

    for(let id = target; id > window.max; id--) {
        newtiles.push(getTile(id));
    }

    window.max = target;
    $("#tiles").prepend(newtiles);
    $("#status").html(`Showing sketches up to #${target}`);
    if(target == window.archiveMax) {
        $("#loadmoretop").prop("disabled", true);
    }

    const viewingLatestSketch = window.current == last;
    if(viewingLatestSketch) {
        updateGalleryButtons();
    }
}

function createBooruFormUI(id) {
    const cookies = document.cookie.split(";");
    const shimUser = cookies.some((c) => c.trim().startsWith("shm_user="));
    const shimSess = cookies.some((c) => c.trim().startsWith("shm_session="));
    const hasBooruCredentials = shimUser && shimSess;
    if(!hasBooruCredentials) {
        return [null, null];
    }

    const sketch = cache["#" + id];
    const unavailable = (sketch.data == "wait" || sketch.data == "wait ");  // thanks drawData();
    if(unavailable) {
        return [null, null];
    }

    if(window.db) {
        const warning = $(
            `<button disabled>
                booru doesn't support custom DBs
            </button>
        `);
        return [null, warning];
    }

    const showButton = $("<button>show booru menu</button>");
    const form = $(`
        <form
            id="booruForm"
            target="_blank"
            action="/booru/upload"
            method="POST"
            enctype="multipart/form-data"
            style="display: none;">
            <input type="hidden" name="sketchid" value="${id}">
            <input type="hidden" name="source" value="${currentArchiveURL()}">
            <span id="postStatus"></span>
            <div id="tagsContainer">
                <table id="tagSuggestions" role="listbox"></table>
                <input
                    type="text"
                    name="tags"
                    required
                    placeholder="tagme"
                    autocomplete="off"
                    class="autocomplete_tags">
            </div>
            <div id="booruButtons">
                <!-- Select isn't natively part of the form; post-processing is done to make
                     ratings actually get sent. -->
                <select id="rating">
                    <option value="?" selected>Unrated</option>
                    <option value="s">Safe</option>
                    <option value="q">Questionable</option>
                    <option value="e">Explicit</option>
                </select>
                <button type="submit">post to booru</button>
                <button type="button" id="hideBooru">hide</button>
            </div>
        </form>
    `);

    // UI and property assignment

    const tagsBar = form.find("input[name='tags']");
    const ratingSelect = form.find("select#rating");
    const postStatus = form.find("#postStatus");
    const sourceField = form.find("input[name='source']");

    postStatus.hide();
    sourceField.prop("disabled", !settings.useArchiveAsBooruSource);

    const booruState = booruStates[id];
    if(booruState) {
        tagsBar.val(booruState.tags);
        ratingSelect.val(booruState.rating);

        const formInputs = form.find(`input, button, select`);
        formInputs.prop("disabled", booruState.uploading);
    }

    const booruPostStatus =  settings.samePageBooru && booruState && booruState.booruPostStatus;
    if(booruPostStatus) {
        const otherFormElements = form.children(`*:not(#booruButtons, #postStatus)`);
        const otherButtons = form.find(`#booruButtons *:not(#hideBooru)`);
        otherFormElements.hide();
        otherButtons.hide();

        const postURL = `https://noz.rip/booru/post/view/${booruState.booruPostID}`;
        const postIDHTML = [
            `<a href=${postURL} target="_blank">`,
                `/${booruState.booruPostID}`,
            `</a>`
        ].join("");

        switch(booruState.booruPostStatus) {
            case BooruPostState.POSTED: {
                postStatus.html(`sketch uploaded: ${postIDHTML}`);
                break;
            }
            case BooruPostState.ALREADY_POSTED: {
                postStatus.html(`sketch was already uploaded! ${postIDHTML}`);
                break;
            }

            default: {
                console.error("Unexpected booru post state:", booruState.booruPostStatus);
            }
            case BooruPostState.PARSING_ERROR: {
                postStatus.html(`something went wrong! check console for details.`);

                const submit = form.find("button[type=submit]");
                submit.html("try again");
                submit.show();
                break;
            }
        }

        postStatus.show();
    }

    if(settings.showingBooruMenu) {
        form.show();
        showButton.hide();
    }

    // Autocomplete-related

    const tagSuggestions = form.find("#tagSuggestions");
    tagSuggestions.hide();
    tagsBar.on("input", function() {
        updateTagSuggestions();
    });
    tagsBar.on("keydown", function(event) {
        switch(event.key) {
            case "Tab":
            case "Enter": {
                const dropdownClosed = tagSuggestions.is(":hidden");
                const hasModifiers = (
                    event.ctrlKey
                    || event.altKey
                    || event.metaKey
                    || event.shiftKey
                );
                if(dropdownClosed || hasModifiers) {
                    return;
                }

                // Prevent form submission or loss of tags bar focus
                event.preventDefault();

                tagSuggestions.hide();

                const currentTag = _getCurrentTag(this);
                const newTag = autocompleteSelected || currentTag;
                addTag(newTag, currentTag);

                break;
            }

            case "ArrowUp":
            case "ArrowDown": {
                const dropdownClosed = tagSuggestions.is(":hidden");
                if(dropdownClosed) {
                    return;
                }

                // Prevent text caret from moving to the beginning/end of the tags bar
                event.preventDefault();

                const visibleTagElems = tagSuggestions.children(":not(.tagInfo)");
                const visibleTags = Array.from(visibleTagElems).map(
                    (element) => element.querySelector(".tagName").innerHTML
                );
                if(visibleTags.length == 0 || visibleTags.length == 1) {
                    return;
                }

                let selectedIndex = visibleTags.findIndex((tag) => tag == autocompleteSelected);
                if(selectedIndex == -1) {
                    selectedIndex = 0;
                }

                const dir = event.key == "ArrowUp" ? -1 : 1;
                const ind = selectedIndex;
                const length = visibleTagElems.length;
                const selectedIndexNew = (((ind + dir) % length) + length) % length;
                const selectedNew = visibleTags[selectedIndexNew];
                autocompleteSelect(selectedNew);

                break;
            }

            case "Escape": {
                const dropdownClosed = tagSuggestions.is(":hidden");
                if(dropdownClosed) {
                    return;
                }

                // Don't know what this should be preventing specifically, but
                // just in case
                event.preventDefault();

                hideTagSuggestions();
            }
        }
    });
    tagsBar.on("blur", function(event) {
        hideTagSuggestions();
    });
    $(document).on("selectionchange", function() {
        if(!tagsBar.is(":focus")) {
            return;
        }

        const tagsBarElement = tagsBar[0];

        // Don't catch text caret movements from text input.
        const tagsValue = tagsBar.val();
        const tagsValueChanged = lastTagsValue != tagsValue;
        if(tagsValueChanged) {
            const currentTag = _getCurrentTag(tagsBarElement);
            lastTagsValue = tagsValue;
            lastAutocompleteQuery = currentTag;
            return;
        }

        // Text selections should hide #tagSuggestions (we're only catching text caret movement).
        const selectionStart = $("#booruForm input[name=tags]").prop("selectionStart");
        const selectionEnd = $("#booruForm input[name=tags]").prop("selectionEnd");
        const selectingText = selectionStart != selectionEnd;
        if(selectingText) {
            hideTagSuggestions();
            return;
        }

        const currentTag = _getCurrentTag(tagsBarElement);
        const currentTagChanged = lastAutocompleteQuery != currentTag;
        if(currentTagChanged) {
            lastAutocompleteQuery = currentTag;
            updateTagSuggestions();
        }
    });

    // Event listeners

    function toggleForm(showing){
        settings.showingBooruMenu = showing;
        form.toggle(showing);
        showButton.toggle(!showing);
        _saveSettings();
    }

    const hideButton = form.find("#booruButtons #hideBooru");
    showButton.click(() => toggleForm(true));
    hideButton.click(() => toggleForm(false));

    tagsBar.on("change", () => saveBooruChanges(id, form));
    ratingSelect.on("change", () => saveBooruChanges(id, form));

    form.submit(async function(event) {
        const form = $(this);
        const ratingSelect = form.find("select");
        const rating = ratingSelect.val();

        const tagsBar = form.find("input[name='tags']");
        let tags = tagsBar.val();
        let newtags = tags
            .replace(/\s+$/gi, "")
            + (
                tags.match(/\s?rating:./gi)
                ? ""
                : ` rating:${rating}`
            );
        tagsBar.val(newtags.trim());

        if(settings.samePageBooru) {
            event.preventDefault();

            // In the case of retries, clear the existing post status.
            if(booruState && booruState.booruPostStatus) {
                booruState.booruPostStatus = null;
            }

            selfUploadToBooru(id, form);
        }
    });

    return [form, showButton];
}

function createPreferencesUI() {
    const button = $("<button>userscript preferences</button>");
    const preferences = $(`<fieldset id="preferences" style="display: none"></fieldset>`);
    preferences.html(`
        <legend>Preferences</legend>
        <fieldset id="preferences-gallery">
            <legend>Gallery</legend>
            <div class="preference">
                <label for="theme">Theme:</label>
                <select id="theme" name="theme">
                    <option value="auto" selected>System default</option>
                    <option value="dark">Dark</option>
                    <option value="light">Light</option>
                </select>
            </div>
            <div class="preference">
                <label for="thumbquality">Thumbnail quality:</label>
                <select id="thumbquality" name="thumbquality">
                    <option value="default" selected>Default</option>
                    <option value="hq">Downscaled</option>
                    <option value="raster">Rasterized</option>
                    <option value="oldDefault">Old default</option>
                    <option value="awful">What</option>
                </select>
            </div>
            <div class="preference">
                <label for="showstats">Show the "past 5 minutes" stats bar:</label>
                <input type="checkbox" id="showstats">
            </div>
            <div class="preference">
                <label for="showdatecards">Show time cards:</label>
                <input type="checkbox" id="showdatecards">
                <br>
                <i>(cards might not show up for newer sketches due to an API limitation)</i>
            </div>
        </fieldset>
        <fieldset id="preferences-sketches">
            <legend>Sketches</legend>
            <div class="preference">
                <label for="skipanimation">Auto-skip sketch animation:</label>
                <input type="checkbox" id="skipanimation">
            </div>
            <div class="preference">
                <label for="doreplay">Enable sketch animation replay:</label>
                <input type="checkbox" id="doreplay">
                <br>
                <i>(by clicking on the sketch player or pressing Space)</i>
            </div>
            <div class="preference">
                <label for="sketchquality">Sketch quality:</label>
                <select id="sketchquality" name="sketchquality">
                    <option value="default" selected>No spikes (default)</option>
                    <option value="spiky">Spiky (old)</option>
                </select>
            </div>
            <div class="preference">
                <label for="sketchsaveresolution">Sketch save resolution:</label>
                <select id="sketchsaveresolution" name="sketchsaveresolution">
                    <option value="1" selected>1x</option>
                    <option value="2" title="haha, kinda like the artist">2x</option>
                    <!-- There's an artist in GaryC that usually goes by "2x". -->
                    <option value="4">4x</option>
                </select>
                <br>
                <i>(only works for sketch player quality saves)</i>
            </div>
            <div class="preference">
                <label for="saveascanvas">Save sketches in sketch player quality:</label>
                <input type="checkbox" id="saveascanvas">
                <br>
                <i>(useful if you don't like how screentones look in saves)</i>
            </div>
        </fieldset>
        <fieldset id="preferences-advanced">
            <legend>Advanced</legend>
            <div class="preference">
                <label for="cachesize">Sketch cache size:</label>
                <input type="number" id="cachesize" min="0">
            </div>
            <div class="preference">
                <label for="relativetimestamps">Show sketch timestamps as relative:</label>
                <input type="checkbox" id="relativetimestamps">
            </div>
            <div class="preference">
                <label for="supportapril2023">Add color to sketches from April Fools' 2023:</label>
                <input type="checkbox" id="supportapril2023">
            </div>
        </fieldset>
    `);

    button.click(() => preferences.slideToggle(200));

    preferences.find("#theme").val(settings.theme);
    preferences.find("#cachesize").val(settings.cacheSize);
    preferences.find("#skipanimation").prop("checked", settings.noAnimation);
    preferences.find("#doreplay").prop("checked", settings.doReplay);
    preferences.find("#thumbquality").val(settings.thumbQuality);
    preferences.find("#sketchquality").val(settings.sketchQuality);
    preferences.find("#relativetimestamps").prop("checked", settings.relativeTimestamps);
    preferences.find("#showdatecards").prop("checked", settings.showDatecards);
    preferences.find("#saveascanvas").prop("checked", settings.saveAsCanvas);
    preferences.find("#sketchsaveresolution").val(settings.sketchSaveResolution);
    preferences.find("#showstats").prop("checked", settings.showStats);
    preferences.find("#supportapril2023").prop("checked", settings.supportApril2023);

    preferences.find("#cachesize").change(function(e) {
        settings.cacheSize = e.target.value;
        _saveSettings();
    });
    preferences.find("#skipanimation").change(function(e) {
        settings.noAnimation = e.target.checked;
        _saveSettings();
    });
    preferences.find("#doreplay").change(function(e) {
        settings.doReplay = e.target.checked;
        _saveSettings();
    });
    preferences.find("#theme").change(function(e) {
        settings.theme = e.target.value;
        _updateTheme();
        _saveSettings();
    });
    preferences.find("#thumbquality").change(function(e) {
        settings.thumbQuality = e.target.value;
        _saveSettings();

        let size = _getThumbSize(settings.thumbQuality);
        $("a > img").each(function(ind, img) {
            img.src = img.src.replace(
                /size=[\d.]+/,
                `size=${size}`
            );
        });
    });
    preferences.find("#sketchquality").change(function(e) {
        settings.sketchQuality = e.target.value;
        _updateSketchQuality(settings.sketchQuality);
        _saveSettings();
    });
    preferences.find("#relativetimestamps").change(function(e) {
        settings.relativeTimestamps = e.target.checked;
        _saveSettings();
    });
    preferences.find("#showdatecards").change(function(e) {
        settings.showDatecards = e.target.checked;
        _saveSettings();

        if(e.target.checked) {
            addDateCards(window.max, $("#tiles").children().length - 1);
        } else {
            $(".datecard").remove();
            datecardDates.clear();
        }
    });
    preferences.find("#saveascanvas").change(function(e) {
        settings.saveAsCanvas = e.target.checked;
        _saveSettings();
    });
    preferences.find("#sketchsaveresolution").change(function(e) {
        settings.sketchSaveResolution = parseInt(e.target.value);
        _saveSettings();
    });
    preferences.find("#showstats").change(function(e) {
        settings.showStats = e.target.checked;
        _saveSettings();

        $("#stats").toggle(settings.showStats);
    });
    preferences.find("#supportapril2023").change(function(e) {
        settings.supportApril2023 = e.target.checked;
        _saveSettings();
    });

    const client = window.location.hostname + window.location.pathname;
    switch(client) {
        case "noz.rip/sketch/gallery.php": {
            applyNozPreferences(preferences);
            break;
        }
        case "noz.rip/sketch_bunker/gallery.php": {
            applyBunkerPreferences(preferences);
            break;
        }
    }

    return [button, preferences];
}

function applyNozPreferences(preferences) {
    const preferencesSketches = preferences.find("#preferences-sketches");
    const preferencesBooru = $(`
        <fieldset id="preferences-booru">
            <legend>Booru</legend>
            <div class="preference">
                <label for="samepagebooru">Post to booru without opening a new tab:</label>
                <input type="checkbox" id="samepagebooru">
            </div>
            <div class="preference">
                <label for="archiveassource">Add archive link as booru source:</label>
                <input type="checkbox" id="archiveassource">
            </div>
        </fieldset>
    `);
    preferencesSketches.after(preferencesBooru);

    preferences.find("#samepagebooru").prop("checked", settings.samePageBooru);
    preferences.find("#archiveassource").prop("checked", settings.useArchiveAsBooruSource);

    preferences.find("#samepagebooru").change(function(e) {
        settings.samePageBooru = e.target.checked;

        // Updates the booru menu
        if(window.current != null) {
            updateDetails();
        }

        _saveSettings();
    });
    preferences.find("#archiveassource").change(function(e) {
        settings.useArchiveAsBooruSource = e.target.checked;
        _saveSettings();
    });
}

function applyBunkerPreferences(preferences) {
    const toremove = [
        preferences.find("#thumbquality"),
        preferences.find("#showdatecards"),
        preferences.find("#showstats"),
    ];
    for(const pref of toremove) {
        pref.parent().remove();
    }
}

function createGalleryFooter(footerState=FooterState.NORMAL) {
    const tilesEnd = $(`<footer id="tilesEnd"></footer>`);

    switch(footerState) {
        case FooterState.END_OF_GALLERY: {
            tilesEnd.html(`
                and then there were none.
                <button>back to top</button>
            `);
            tilesEnd.find("button").on("click", () => document.documentElement.scrollIntoView());
            break;
        }

        case FooterState.NORMAL:
        default: {
            tilesEnd.html(`
                <button>load more</button>
            `);
            tilesEnd.find("button").on("click", () => addMore(100));
            break;
        }
    }

    return tilesEnd;
}

function createLoadMoreTopButton() {
    const button = $(`<button id="loadmoretop">load more</button>`);

    button.click(() => addMoreTop(100));
    return button;
}

function createBunkerStatus() {
    const status = $(`<span id="status"></span>`);
    return status;
}

async function personalKeybinds(e) {
    if(window.current == null) {
        return;
    }
    if(document.activeElement.nodeName == "INPUT") {
        return;
    }

    switch(e.key.toLowerCase()) {
        case " ": {
            // space -- skip/replay animation
            if(!(e.ctrlKey || e.altKey || e.metaKey || e.shiftKey)) {
                e.preventDefault();
                if(autodrawpos == -1 && settings.doReplay) {
                    drawData(window.dat);
                } else {
                    setData(window.dat);
                }
            }
            break;
        }
        case "c": {
            // ctrl+C -- copying URL to clipboard
            if(e.ctrlKey && !(e.altKey || e.metaKey || e.shiftKey)) {
                e.preventDefault();

                if(!navigator.clipboard) {
                    await detailsAlert("no clipboard permissions!");
                    return false;
                }

                await navigator.clipboard.writeText(currentURL());
                await detailsAlert("copied url");
            }

            // ctrl+shift+C -- copying canvas image to clipboard
            if(e.ctrlKey && e.shiftKey && !(e.altKey || e.metaKey)) {
                e.preventDefault();

                if(!window.ClipboardItem) {
                    await detailsAlert("no permission to copy canvas");
                    return false;
                }
                if(!navigator.clipboard) {
                    await detailsAlert("no clipboard permissions!");
                    return false;
                }

                let blob = cachedCanvasBlob || await new Promise((resolve) => {
                    document.querySelector("#sketch").toBlob(blob => resolve(blob))
                });

                if(autodrawpos == -1) {
                    cachedCanvasBlob = blob;
                }

                try {
                    await navigator.clipboard.write([new ClipboardItem({[blob.type]: blob})]);
                }
                catch (e) {
                    // .write will raise a DOMException if the document lost focus.
                    // that should be the only user-made error to expect during the copying anyway.
                    await detailsAlert("failed to copy canvas. try again?")
                    throw e;
                }

                await detailsAlert("copied canvas");
            }
            break;
        }
        case "s": {
            // ctrl+S -- downloads/saves a sketch
            if(e.ctrlKey && !(e.altKey || e.metaKey || e.shiftKey)) {
                e.preventDefault();
                $(".save").click();
            }
        }
    }
}


function _gallery_commonStyles() {
    GM_addStyle(`
        body {
            margin: 10px 10px;
        }

        input[type=text] {
            margin: 0px 4px;
        }

        input[type=submit], button {
            margin: 5px 4px;
        }

        #stats,
        #status {
            display: inline-block;
            font-family: "Helvetica", "Arial", sans-serif;
            margin: 0px 4px;
        }

        #status {
            font-style: italic;
        }

        canvas {
            /* prevent canvas from showing up for a split second on page boot */
            display: none;
            /* re-add garyc.me border on noz.rip */
            border: 1px black solid;
        }

        #tiles {
            font-family: monospace;
        }

        #details {
            box-sizing: border-box;
            padding: 10px 60px;
            width: 100%;
            height: 100%;
            overflow: auto;

            text-align: left;
            font-size: 18px;
            font-family: monospace;
        }

        #details .id {
            font-weight: bold;
        }

        #details .extra {
            opacity: 80%;
            font-style: italic;
        }

        #details .extra span[title]:hover {
            text-decoration: underline dotted;
        }

        #holder {
            display: none;
            z-index: 1;
            background-color: white;
            box-shadow: 0px 0px 10px #00000077;
            position: fixed;

            /* fixes garyc.me's centering management */
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
        }

        #holder img {
            user-select: none;
        }

        #tilesEnd {
            padding: 10px;
            text-align: center;
            font-family: monospace;
        }

        /* preferences */
        #preferences {
            max-width: 350px;
            margin: 5px; /* match that of #tiles */
            font-family: monospace;
        }
        #preferences fieldset {
            border-left: none;
            border-right: none;
            border-bottom: none;
        }
        #preferences .preference {
            padding: 4px;
        }
        #preferences .preference i {
            opacity: 50%;
        }

        /* grid styles for holder */
        #holder.active {
            display: grid;
        }
        #holder {
            width: auto;
            justify-items: center;
            padding: 0px 2px;
            grid-template-columns: 100px 808px 100px;
            grid-template-rows: 100px 577px 25px 100px;
            grid-template-areas:
                "x x x"
                "l c r"
                "l c s"
                "d d d";
        }
        #holder > .top {grid-area: x;}
        #holder > .left {grid-area: l;}
        #holder > canvas {grid-area: c;}
        #holder > .right {
            grid-area: r;
            /* prevent overflowing to .saves */
            overflow: hidden;
            height: 100%;
        }
        #holder > #details {grid-area: d;}
        #holder > .saves {
            box-sizing: border-box;
            width: 100%;
            padding-left: 5px;
            grid-area: s;
            justify-self: start;
        }

        /* datecards */
        .datecard {
            display: inline-block;
            vertical-align: middle;
            width: 160px;
            height: 120px;

            box-sizing: border-box;
            border: 2px solid #ccd;
            margin: 5px;
        }
        .datecard div {
            display: flex;
            width: 100%;
            height: 100%;
            padding: 10px;

            align-items: center;
            justify-content: center;
            box-sizing: border-box;
        }
        a img {
            /* aligns sketch thumbnails with the cards */
            vertical-align: middle;
        }

        /* just some stylistic choices */
        #tiles {
            transition: opacity 0.2s ease;
        }
        #holder a {
            cursor: pointer;
        }
        #holder img:hover {
            opacity: 80%;
        }
    `);
}

function _gallery_commonNozStyles() {
    GM_addStyle(`
        /* #holder svg styles */

        #holder svg {
            width: 100%;
            height: 100%;
            padding: 10px;
            box-sizing: border-box;
        }
        #holder .top,
        #holder .left,
        #holder .right {
            height: 100%;
            width: 100%;
        }

        /* alignment of close button */
        #holder .top {
            display: flex;
            flex-direction: column-reverse;
            align-items: flex-end;

            padding-right: 50px;
            box-sizing: border-box;
        }
        #holder .top svg {
            height: 60px;
            width: 60px;
        }

        /* stylistic choices */

        #holder .top:hover,
        #holder .left:hover,
        #holder .right:hover {
            opacity: 80%;
        }
    `);
}

function _gallery_commonOverrides() {
    document.addEventListener("keydown", personalKeybinds.bind(this));

    // On garyc.me, this uses the scrolling behavior the site used to have;
    // i.e. thumbnails will only get added at the *bottom* of the page.
    $(window).off("scroll");
    $(window).on("scroll", function(e) {
        let pageHeight = document.documentElement.scrollHeight;
        let pageScroll = window.scrollY + window.innerHeight;
        let bottom = pageHeight - pageScroll <= 1;  // == 1 is for garyc.me
        if(bottom) {
            addMore(100);
        }
    });

    $(document).off("keydown");
    $(document).on("keydown", function(e) {
        if(document.activeElement.nodeName == "INPUT") {
            return;
        }

        switch(e.key) {
            case "Escape": {
                if(e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;
                if(window.current != null) {
                    hide();
                    // Prevent abortion of page load when the viewer is still open.
                    // The user only wants to exit the viewer in this case.
                    e.preventDefault();
                }
            }

            // ArrowLeft and ArrowRight no longer
            // update window.current.

            case "ArrowLeft": {
                if(e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;
                if(window.current == null) return;
                if(window.current >= window.max) return;
                if(window.current < window.min) {
                    show(window.min);
                    return false;
                }
                show(window.current + 1);
                return false;
            }

            case "ArrowRight": {
                if(e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;
                if(window.current == null || window.current > window.max) {
                    show(window.max);
                    return false;
                }
                if(window.current <= window.min) return;
                show(window.current - 1);
                return false;
            }
        }
    });

    window.addEventListener("hashchange", function(e) {
        if(!window.location.hash) {
            hide();
        }

        let id = parseInt(window.location.hash.slice(1));
        // prevent show() from firing again
        if(id == window.current) return;
        if(id == 0) {
            hide();
        } else {
            show(id);
        }
    });
}

function _gallery_commonDOMOverrides() {
    // remove text nodes that causes buttons to be spaced out.
    // the spacing will get re-added as css.
    let text_nodes = Array
        .from(document.body.childNodes)
        .filter(e => e.nodeType == Node.TEXT_NODE);
    $(text_nodes).remove();

    const [button, preferences] = createPreferencesUI();
    $("input[type=submit]:last-of-type").after(button);
    $("#tiles").before(preferences);

    const tilesEnd = createGalleryFooter();
    $("#tiles").after(tilesEnd);
}


if(window.location.pathname == "/sketch/gallery.php" && window.location.hostname == "garyc.me") {
    _gallery_commonStyles();

    window.update = gallery_update;
    window.refresh = refresh;
    setInterval(window.update, 1000/30);
    setInterval(window.refresh, 15000);

    window.reset = gallery_reset;
    window.drawData = gallery_drawData;
    window.show = show;
    window.hide = hide;
    window.get = get;
    window.addMore = addMore;

    _gallery_commonOverrides();

    // garyc.me doesn't even HAVE a <body> tag;
    // DOM manipulation can only happen after DOMContentLoaded.

    document.addEventListener("DOMContentLoaded", function() {
        window.current = null;

        _gallery_commonDOMOverrides();

        // clear the script tag and the extra newline that causes
        // misalignment of new sketches
        document.getElementById("tiles").innerHTML = "";
        // add a little init text for the stats
        document.getElementById("stats").innerHTML = "...";

        // remove inline css for the style overrides
        $("#holder").css({
            top: "",
            left: "",
            margin: "",
            position: "",
            width: "",
        });
        $("#sketch").css({
            border: "",
        });
    });

    // these are assigned on another `.ready` event;
    // overwrite them on another one
    $(document).ready(function() {
        $("#sketch").attr({
            tabindex: "0",
            // fix canvas not being 798x598
            width: "800px",
            height: "600px",
        });
        $("#sketch").css({
            // constrain actual size to 800x600 in case the canvas gets scaled up
            width: "800px",
            height: "600px",
        });

        _updateSketchQuality(settings.sketchQuality);
    });
}

if(window.location.pathname == "/sketch/gallery.php" && window.location.hostname == "noz.rip") {
    _gallery_commonStyles();
    _gallery_commonNozStyles();
    GM_addStyle(`
        /* noz.rip-specific #details styles */

        #details {
            display: flex;
            gap: 30px;

            height: min-content;
            max-height: 100%;
        }

        #details #details-left {
            flex: 0 1 auto;
            overflow: auto;
        }

        #details #details-right {
            flex: 1 0 auto;

            display: flex;
            flex-direction: column;
            align-items: flex-end;
            justify-content: flex-end;
        }

        #details form {
            width: 100%;
            display: flex;
            flex-direction: column;
            align-items: flex-end;
            text-align: right;
        }

        #details form input[type="text"] {
            min-width: min-content;
            width: 100%;
            max-width: 400px;
            height: 2em;
            padding: 0px 5px;
            box-sizing: border-box;
        }

        /* tag autocomplete styles */

        #details {
            overflow: visible;
        }

        #tagsContainer {
            width: 100%;
            max-width: 400px;

            /* Position #tagSuggestions' parent so #tagSuggestions can be
            absolutely positioned to it */
            position: relative;
        }

        #tagSuggestions {
            position: absolute;
            right: calc(100% + 10px);
            bottom: 0px;

            display: block;
            user-select: none;
            z-index: var(--z-index-dropdown);
            background-color: var(--background-tag-suggestions);
            box-shadow: 0px 0px 10px #00000077;
            margin: 0;
            padding: 10px;
            width: max-content;

            /* Ditch default border spacing */
            border-spacing: 0;
        }

        #tagSuggestions td {
            /* Alternative to border-spacing in #tagSuggestions where the <tr>
            background would actually fill in the spacing gaps */
            padding: 0 5px;
        }

        #tagSuggestions tr[aria-selected="true"] {
            background-color: var(--background-tag-suggestions-selected);
            text-decoration: underline;
        }

        #tagSuggestions tr.tagInfo {
            text-align: center;
            font-style: italic;
        }
        #tagSuggestions tr.tagInfo:not(:only-child) td {
            padding: 5px;
        }
        #tagSuggestions .tagName {
            text-align: right;
        }
        #tagSuggestions .tagCount {
            text-align: left;
            font-style: italic;
            opacity: 50%;
        }
    `);

    // noz.rip has the JS code AFTER the <body> tag.
    // Same case with the jQuery import, so DOM manipulation
    // can only be executed after DOMContentLoaded.
    // One of these days, I'm just gonna snap.

    document.addEventListener("DOMContentLoaded", function() {
        _purgeIntervals();

        // noz.rip doesn't have a stats bar but this works surprisingly fine.
        window.refresh = refresh;
        setInterval(window.refresh, 15000);

        window.reset = gallery_reset;
        window.show = show;
        window.hide = hide;
        window.get = get;
        window.addMore = addMore;

        _gallery_commonOverrides();

        window.max = null;
        window.min = null;
        window.current = null;
        // turn window.max into a Number;
        // the window.max fetched via $.ajax() is saved as a string.
        window.refresh();

        // use the new show();
        // setupOverlay override cancels the old show() from being used
        window.setupOverlay = (() => void 0);
        let hash = window.location.hash.slice(1);
        if(hash) {
            window.show(hash);
        }

        _gallery_commonDOMOverrides();

        const stats = $("#stats");
        const statsExists = stats.length >= 1;
        if(!statsExists) {
            const preferencesButton = $("button + a").prev();
            const stats = createStats();
            stats.toggle(settings.showStats);
            preferencesButton.after(stats);
        }

        // remove inline css for the style overrides
        $("#holder").css({
            position: "",
            width: "",
            height: "",
            backgroundColor: "",
            display: "",
        });
        $("#sketch").css({
            // remove white background of the canvas
            background: "",
            // remove absolute positioning of the canvas
            position: "",
            top: "",
            left: "",
            transform: "",
            // replace box-shadow with border; caused dark mode to show
            // white edges around the canvas
            boxShadow: "",
            // constrain actual size to 800x600 in case the canvas gets scaled up
            width: "800px",
            height: "600px",
        });

        $("#sketch").attr({
            tabindex: "0",
            width: "800px",
            height: "600px",
        });

        _updateSketchQuality(settings.sketchQuality);
    });
}

if(window.location.pathname == "/sketch_bunker/gallery.php" && window.location.hostname == "noz.rip") {
    _gallery_commonStyles();
    _gallery_commonNozStyles();

    // Use .customMax instead of noz.rip's .custom_max for the sake of naming consistency.
    // I advise contributors to use this one too for the same reason.
    window.customMax = null;
    const customMax = new URLSearchParams(window.location.search).get("maxid");
    const cm = parseInt(customMax);
    if(!Number.isNaN(cm)) {
        window.customMax = cm;
    }

    // Hide <tiles> for this site's addMore() monkeypatch
    const style = document.createElement("style");
    style.innerHTML = (`
        #tiles {
            display: none;
        }
    `);
    document.head.appendChild(style);

    // noz.rip/sketch_bunker/ ALSO has the body after the JS tag.
    // There will be bloodshed.

    document.addEventListener("DOMContentLoaded", function() {
        _purgeIntervals();

        window.refresh = nozBunker_refresh;
        setInterval(window.refresh, 15000);

        window.reset = gallery_reset;
        window.show = show;
        window.hide = hide;
        window.get = get;
        window.addMore = addMore;

        _gallery_commonOverrides();

        window.min = 1;
        window.max = window.customMax || window.max;
        window.archiveMax = null;
        window.current = null;

        for(const script of $("#tiles ~ script")) {
            const maxMatch = $(script).html().match(/max=(?<max>\d+)/);
            if(maxMatch) {
                window.archiveMax = parseInt(maxMatch.groups.max);
                break;
            }
        }

        // use the new show();
        // setupOverlay override cancels the old show() from being used
        window.setupOverlay = (() => void 0);
        let hash = window.location.hash.slice(1);
        if(hash) {
            window.show(hash);
        }

        // DOM manipulation

        _gallery_commonDOMOverrides();

        // addMore() can't be monkeypatched in time before it gets first fired.
        // Guess we have to do some dirty work "behind-the-scenes".
        $("#tiles").empty();
        style.remove();
        addMore();

        if(window.archiveMax && (window.archiveMax > window.max)) {
            const loadmoreTop = createLoadMoreTopButton();
            const status = createBunkerStatus();

            const preferencesButton = $("button + #holder").prev();
            preferencesButton.after(status);
            $("#refresh").after(loadmoreTop);
            $("#refresh").hide();

            status.html(`Showing sketches up to #${window.max}`);
        }

        // remove inline css for the style overrides
        $("#holder").css({
            position: "",
            width: "",
            height: "",
            backgroundColor: "",
            display: "",
        });

        $("#sketch").css({
            // constrain actual size to 800x600 in case the canvas gets scaled up
            width: "800px",
            height: "600px",
        });

        $("#sketch").attr({
            tabindex: "0",
            width: "800px",
            height: "600px",
        });

        $("#refresh").prop("disabled", !!window.customMax);

        _updateSketchQuality(settings.sketchQuality);
    });
}


/* /sketch/ */

const SwapState = {
    IDLE: 0,
    SWAPPING: 1,
    PEEKING_FROM_SWAP: 2,
    PEEKING: 3,
    WAITING_PEEK: 4,
    DONE_FROM_SWAP: 5,
    DONE: 6,
}

function _setProgress(n) {
    n = Math.min(Math.max(n, 0), 3);
    let width = Math.round(n / 3 * 100);
    $("#progress").attr({"aria-valuenow": n});
    $("#progressBar").width(`${width}%`);
}

function updateUI(state) {
    let dat;
    if(window.location.hostname == "noz.rip") {
        dat = window.arrdat.join(" ");
    } else {
        dat = window.dat;
    }

    const ink = Math.floor(dat.length / window.limit * 100);

    switch(state) {
        case SwapState.IDLE: {
            $("#ink").html(`Ink used: ${ink}%`);
            $("#reset").prop("disabled", false);
            $("#undo").prop("disabled", dat.length == 0);
            $("#swap").prop("disabled", ink < 1);
            $("#peek").prop("disabled", ink >= 1);
            $("#swap").val("swap");
            _setProgress(0);
            break;
        }
        case SwapState.SWAPPING: {
            $("#ink").html(`Ink used: ${ink}%`);
            $("#reset").prop("disabled", true);
            $("#undo").prop("disabled", true);
            $("#swap").prop("disabled", true);
            $("#peek").prop("disabled", true);
            $("#swap").val("swapping...");
            _setProgress(1);
            break;
        }
        case SwapState.PEEKING_FROM_SWAP: {
            $("#swap").val("swapping...");
        }
        case SwapState.PEEKING_FROM_SWAP:
        case SwapState.PEEKING: {
            $("#ink").html(`Ink used: ${ink}%`);
            $("#reset").prop("disabled", true);
            $("#undo").prop("disabled", true);
            $("#swap").prop("disabled", true);
            $("#peek").prop("disabled", true);
            _setProgress(2);
            break;
        }
        case SwapState.WAITING_PEEK: {
            $("#ink").html(`Ink used: ${ink}%`);
            $("#reset").prop("disabled", true);
            $("#undo").prop("disabled", true);
            $("#swap").prop("disabled", true);
            $("#peek").prop("disabled", true);
            $("#swap").val("waiting for other sketch to be drawn...");
            _setProgress(2);
            break;
        }
        case SwapState.DONE_FROM_SWAP: {
            $("#swap").val("swapped!");
        }
        case SwapState.DONE_FROM_SWAP:
        case SwapState.DONE: {
            $("#ink").html(`Ink used: ${ink}%`);
            $("#reset").prop("disabled", false);
            $("#undo").prop("disabled", true);
            $("#swap").prop("disabled", true);
            $("#peek").prop("disabled", true);
            _setProgress(3);
            break;
        }
    }
}

function resetUI() {
    updateUI(SwapState.IDLE);
    window.locked = false;
}

// overrides

function resetCanvas() {
    graphics.clear();
    graphics.beginFill(0xFFFFFF);
    graphics.drawRect(0,0,800,600);
    graphics.endFill();
    graphics.lineStyle(3, 0x000000);
}

function sketch_setData(data) {
    window.dat = `${data.trim()} `;

    // using normal reset() would've left the wrong buttons enabled
    // every time as if ink really was 0%.
    resetCanvas();
    resetUI();

    const parts = data.split(" ");
    for(var i = 0; i < parts.length; i++) {
        let part = parts[i];
        for(var j = 0; j < part.length; j += 4) {
            var x = dec(part.substr(j, 2));
            var y = dec(part.substr(j+2, 2));
            if(j == 0) {
                graphics.moveTo(x, y);
            } else {
                graphics.lineTo(x, y);
            }
        }
    }
}

function noz_sketch_setData(arrdata) {
    window.arrdat = arrdata = arrdata.filter((part) => part != "");

    // using normal reset() would've left the wrong buttons enabled
    // every time as if ink really was 0%.
    resetCanvas();
    resetUI();

    for(var h = 0; h < arrdata.length; h++) {
        const arrpart = arrdata[h];
        const parts = arrpart.split(" ");
        for(var i = 0; i < parts.length; i++) {
            let part = parts[i];
            for(var j = 0; j < part.length; j += 4) {
                var x = dec(part.substr(j, 2));
                var y = dec(part.substr(j+2, 2));
                if(j == 0) {
                    graphics.moveTo(x, y);
                } else {
                    graphics.lineTo(x, y);
                }
            }
        }
    }
}

function sketch_reset() {
    window.dat = "";
    window.lines = [];
    window.autodrawpos = -1;
    resetCanvas();
    resetUI();
}

function noz_sketch_reset(manual) {
    if(manual) {
        window.backupdat = {
            arrdat: window.arrdat,
            screentoningPoints: window.screentoningPoints,
        };
        window.screentoningPoints = {};
        saveIncomplete(false);
    }

    if(window.locked) {
        window.backupdat = {};
    }

    window.dat = "";
    window.arrdat = [];
    window.autodrawpos = -1;
    resetCanvas();
    resetUI();
}

function swap() {
    // lock the client *before* the swap request, gary
    updateUI(SwapState.SWAPPING);
    window.locked = true;

    $.ajax({
        url: `swap.php?db=${db || ""}&v=32`,
        method: "POST",
        data: window.dat,
        error: function() {
            alert("There was an error swapping.");
            resetUI();
        },
        success: function(n) {
            n = parseInt(n);
            if(n < 0) {
                alert(`On cooldown; please wait ${n} more seconds before swapping again.`);
                resetUI();
                return;
            }
            window.swapID = n;

            updateUI(SwapState.PEEKING_FROM_SWAP);
            attemptSwap();
        },
    });
}

function noz_swap() {
    updateUI(SwapState.SWAPPING);
    window.locked = true;

    let dat = window.arrdat.join(" ") + " ";

    $.ajax({
        url: `https://garyc.me/sketch/swap.php?db=${db || ""}&v=32`,
        method: "POST",
        data: dat,
        error: function() {
            alert("There was an error swapping.");
            resetUI();
        },
        success: function(n) {
            n = parseInt(n);
            if(n < 0) {
                alert(`On cooldown; please wait ${n} more seconds before swapping again.`);
                resetUI();
                return;
            }

            window.swapID = n;
            window.backupdat = {};
            window.screentoningPoints = {};
            saveIncomplete(false);
            attemptSwap();
        },
    });
}

function attemptSwap() {
    getStats();

    $.ajax({
        url: `https://garyc.me/sketch/get.php?id=${swapID}&db=${db || ""}`,
        method: "GET",
        error: function() {
            setTimeout(attemptSwap, 2000);
        },
        success: function(result) {
            if(result == "wait") {
                updateUI(SwapState.WAITING_PEEK);
                setTimeout(attemptSwap, 2000);
                return;
            }

            switch(window.location.hostname) {
                case "noz.rip":
                    drawData([result]);
                    break;
                default:
                    drawData(result);
                    break;
            }
            getStats();
            updateUI(SwapState.DONE_FROM_SWAP);
        }
    });
}

function getLatest() {
    updateUI(SwapState.PEEKING);
    window.locked = true;

    $.ajax({
        url: `https://garyc.me/sketch/get.php?db=${db || ""}`,
        method: "GET",
        error: function() {
            alert("There was an error getting the latest sketch.");
            resetUI();
        },
        success: function(result) {
            switch(window.location.hostname) {
                case "noz.rip":
                    drawData([result]);
                    break;
                default:
                    drawData(result);
                    break;
            }
            getStats();
            updateUI(SwapState.DONE);
        }
    });
}


if(window.location.pathname == "/sketch/") {
    GM_addStyle(`
        /* save button */
        img[src="save.png"] {
            /* shift 5px to the right.
               i don't feel like making this button statically positioned
               because there's whitespace text preceding it, and leaving or
               relying on that might result in inconsistent positioning from,
               say, font size changes...
               doesn't seem easy to take out either in a userscript context,
               unless i maybe go with regex, which i'm not insane enough to
               tackle right now. */
            left: 815px;
        }

        /* flash UI mimicking */
        td input {
            width: 100%;
            height: 30px;
        }
        img[src="save.png"] {
            opacity: .8;
        }
        img[src="save.png"]:hover {
            opacity: 1;
        }

        /* progress bar */
        td.swapContainer {
            display: flex;
            align-items: center;
        }
        td.swapContainer #swap {
            flex: 2;
            min-width: min-content;
        }
        td.swapContainer #progress {
            flex: 3;
            background-color: #f9f9f9;
            border: 1px solid #767676;
            border-radius: 4px;
            height: 16px;
            margin-top: 4px;
            margin-left: 10px;
        }
        #progressBar {
            height: 100%;
            background-color: #a1ef55;
            border-radius: 3px;
            transition: width 0.15s ease;
        }

        /* personal tweaks */
        td {
            padding: 3px;
        }
    `);

    // both noz.rip and garyc.me's JS happen at the document body,
    // inject when that finishes loading

    document.addEventListener("DOMContentLoaded", function() {
        setInterval(window.getStats, 30000);

        window.reset = sketch_reset;
        window.setData = sketch_setData;
        window.swap = swap;
        window.attemptSwap = attemptSwap;
        window.getLatest = getLatest;

        // Fix ink limit from 50KiB to 64KiB, the largest amount of data that
        // garyc.me can take in without truncating it.
        window.limit = 64 * 1024;

        // mark parent of swap button and add progress bar
        // don't wanna use native <progress> due to lack of its styling
        // support on firefox
        const container = $("#swap").parent();
        container.addClass("swapContainer");
        container.append(`
            <div id="progress"
                 role="progressbar"
                 aria-label="swap progress"
                 aria-valuenow="0"
                 aria-valuemin="0"
                 aria-valuemax="3">
                <div id="progressBar" style="width: 0%"></div>
            </div>
        `);

        $("img[src='save.png']").css({
            left: "",
        });

        _updateSketchQuality(settings.sketchQuality);
    });
}

if(window.location.pathname == "/sketch/" && window.location.hostname == "garyc.me") {
    document.addEventListener("DOMContentLoaded", function() {
        setInterval(window.update, 1000/30);
    });
}

if(window.location.pathname == "/sketch/" && window.location.hostname == "noz.rip") {
    document.addEventListener("DOMContentLoaded", function() {
        // not sure how i'd monkeypatch update() here;
        // it uses requestAnimationFrame instead of setInterval
        setInterval(() => saveIncomplete(true), 10000);

        window.reset = noz_sketch_reset;
        window.setData = noz_sketch_setData;
        window.swap = noz_swap;
    });
}