booru navigational keybinds

Page navigation keyboard shortcuts for booru-type imageboards such as dervisions of Danbooru/Gelbooru, custom artist portals

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

You will need to install an extension such as Tampermonkey to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name            booru navigational keybinds
// @namespace       861ddd094884eac5bea7a3b12e074f34
// @version         3.8
// @description     Page navigation keyboard shortcuts for booru-type imageboards such as dervisions of Danbooru/Gelbooru, custom artist portals
// @author          Anonymous, Claude 4.5 Sonnet, GitHub Copilot (Claude 4.5 Haiku)
// @match           https://rule34.xxx/index.php?page=post&s=list*
// @match           https://rule34.xxx/index.php?page=favorites&s=view&id=*
// @match           https://rule34.xxx/index.php?page=comment&s=list*
// @match           https://yande.re/post
// @match           https://yande.re/post?tags=*
// @match           https://yande.re/post?page=*
// @match           https://yande.re/comment*
// @match           https://yande.re/note*
// @match           https://rule34.us/index.php?r=posts/index*
// @match           https://rule34.us/index.php?r=posts%2Findex*
// @exclude         https://rule34.us/index.php?r=posts/view*
// @match           https://rule34.us/index.php?r=favorites/view*
// @match           https://rule34.us/index.php?r=favorites%2Fview*
// @match           https://rule34.us/index.php?r=comments/list*
// @match           https://rule34.us/index.php?r=comments%2Flist*
// @match           https://gelbooru.com/index.php?page=post&s=list*
// @match           https://gelbooru.com/index.php?page=favorites&s=view&id=*
// @match           https://gelbooru.com/index.php?page=comment&s=list*
// @match           https://www.zerochan.net/*
// @exclude         https://www.zerochan.net/pm*
// @exclude         https://www.zerochan.net/moe
// @exclude         https://www.zerochan.net/faq
// @exclude         https://www.zerochan.net/about
// @exclude         https://www.zerochan.net/privacy
// @exclude         https://www.zerochan.net/api
// @exclude         https://www.zerochan.net/upload3
// @exclude         https://www.zerochan.net/forum
// @exclude         https://www.zerochan.net/options*
// @exclude         https://www.zerochan.net/report*
// @match           https://anime-pictures.net/posts?page=*
// @match           https://anime-pictures.net/posts?favorite_by=*
// @match           https://anime-pictures.net/posts?user=*
// @match           https://anime-pictures.net/stars?page=*
// @match           https://konachan.net/post
// @match           https://konachan.net/post?tags=*
// @match           https://konachan.net/post?page=*
// @match           https://konachan.net/comment*
// @match           https://konachan.net/note*
// @match           https://kusowanka.com/
// @match           https://kusowanka.com/?page=*
// @match           https://kusowanka.com/search/*
// @match           https://kusowanka.com/tag*
// @match           https://kusowanka.com/parod*
// @match           https://kusowanka.com/artist*
// @match           https://kusowanka.com/character*
// @match           https://kusowanka.com/metadata*
// @match           https://tbib.org/index.php?page=post&s=list*
// @match           https://tbib.org/index.php?page=favorites&s=view&id=*
// @match           https://*.booru.org/index.php?page=post&s=list*
// @match           https://*.booru.org/index.php?page=favorites&s=view&id=*
// @match           https://*.booru.org/index.php?page=comment&s=list*
// @match           https://safebooru.org/index.php?page=post&s=list*
// @match           https://safebooru.org/index.php?page=favorites&s=view&id=*
// @match           https://safebooru.com/index.php?page=comment&s=list*
// @match           https://xbooru.com/index.php?page=post&s=list*
// @match           https://xbooru.com/index.php?page=favorites&s=view&id=*
// @match           https://xbooru.com/index.php?page=comment&s=list*
// @match           https://realbooru.com/index.php?page=post&s=list*
// @match           https://realbooru.com/index.php?page=favorites&s=view&id=*
// @match           https://realbooru.com/index.php?page=comment&s=list*
// @match           https://derpibooru.org/*
// @exclude         https://derpibooru.org/conversations*
// @exclude         https://derpibooru.org/profiles/*/reports*
// @exclude         https://derpibooru.org/filters
// @exclude         https://derpibooru.org/settings*
// @exclude         https://derpibooru.org/*/new
// @exclude         https://derpibooru.org/pages/*
// @exclude         https://derpibooru.org/staff
// @match           https://twibooru.org/*
// @exclude         https://twibooru.org/conversations*
// @exclude         https://twiibooru.org/profiles/*/reports*
// @exclude         https://twibooru.org/filters
// @exclude         https://twibooru.org/settings*
// @exclude         https://twibooru.org/*/new
// @exclude         https://twibooru.org/pages/*
// @exclude         https://twibooru.org/staff
// @match           https://manebooru.art/*
// @exclude         https://manebooru.art/conversations*
// @exclude         https://manebooru.art/profiles/*/reports*
// @exclude         https://manebooru.art/filters
// @exclude         https://manebooru.art/settings*
// @exclude         https://manebooru.art/*/new
// @exclude         https://manebooru.art/pages/*
// @exclude         https://manebooru.art/staff
// @match           http://browse.minitokyo.net/gallery*
// @match           http://gallery.minitokyo.net/*
// @match           https://inkbunny.net/submissionsviewall.php*
// @match           https://inkbunny.net/usersviewall.php*
// @match           https://inkbunny.net/gallery/*
// @match           https://inkbunny.net/scraps/*
// @match           https://hypnohub.net/index.php?page=post&s=list*
// @match           https://hypnohub.net/index.php?page=favorites&s=view&id=*
// @match           https://hypnohub.net/index.php?page=comment&s=list*
// @match           https://booru.eu/post/list*
// @match           https://booru.eu/comment/list*
// @match           https://rule34vault.com/*
// @exclude         https://rule34vault.com/trends*
// @exclude         https://rule34vault.com/comments*
// @exclude         https://rule34vault.com/u/*?tab=subs
// @exclude         https://rule34vault.com/u/*?tab=comments
// @exclude         https://rule34vault.com/account*
// @exclude         https://rule34vault.com/post/*
// @exclude         https://rule34vault.com/dmca
// @exclude         https://rule34vault.com/terms
// @exclude         https://rule34vault.com/contact-us
// @exclude         https://rule34vault.com/upgrade-to-premium
// @match           https://rule34.xyz/*
// @exclude         https://rule34.xyz/trends*
// @exclude         https://rule34.xyz/comments*
// @exclude         https://rule34.xyz/u/*?tab=subs
// @exclude         https://rule34.xyz/u/*?tab=comments
// @exclude         https://rule34.xyz/account*
// @exclude         https://rule34.xyz/post/*
// @exclude         https://rule34.xyz/dmca
// @exclude         https://rule34.xyz/terms
// @exclude         https://rule34.xyz/contact-us
// @exclude         https://rule34.xyz/upgrade-to-premium
// @match           https://rule34.world/*
// @exclude         https://rule34.world/trends*
// @exclude         https://rule34.world/comments*
// @exclude         https://rule34.world/u/*?tab=subs
// @exclude         https://rule34.world/u/*?tab=comments
// @exclude         https://rule34.world/account*
// @exclude         https://rule34.world/post/*
// @exclude         https://rule34.world/dmca
// @exclude         https://rule34.world/terms
// @exclude         https://rule34.world/contact-us
// @exclude         https://rule34.world/upgrade-to-premium
// @match           https://furry34.com/*
// @exclude         https://furry34.com/trends*
// @exclude         https://furry34.com/comments*
// @exclude         https://furry34.com/u/*?tab=subs
// @exclude         https://furry34.com/u/*?tab=comments
// @exclude         https://furry34.com/account*
// @exclude         https://furry34.com/post/*
// @exclude         https://furry34.com/dmca
// @exclude         https://furry34.com/terms
// @exclude         https://furry34.com/contact-us
// @exclude         https://furry34.com/upgrade-to-premium
// @match           https://www.hentai-foundry.com/categories/*/pictures*
// @match           https://www.hentai-foundry.com/pictures/recent*
// @match           https://www.hentai-foundry.com/pictures/featured*
// @match           https://www.hentai-foundry.com/pictures/popular*
// @match           https://www.hentai-foundry.com/pictures/random*
// @match           https://www.hentai-foundry.com/pictures/user/*
// @match           https://www.hentai-foundry.com/pictures/tagged/*
// @match           https://shimmie.shishnet.org/post/list*
// @match           https://shimmie.shishnet.org/comment/list*
// @match           https://furbooru.org/*
// @exclude         https://furbooru.org/conversations*
// @exclude         https://furbooru.org/profiles/*/reports*
// @exclude         https://furbooru.org/filters
// @exclude         https://furbooru.org/settings*
// @exclude         https://furbooru.org/*/new
// @exclude         https://furbooru.org/pages/*
// @exclude         https://furbooru.org/staff
// @match           https://e-shuushuu.net/
// @match           https://e-shuushuu.net/?page=*
// @match           https://e-shuushuu.net/search/results/*
// @match           https://e-shuushuu.net/top.php*
// @match           https://www.luscious.net/*
// @exclude         https://www.luscious.net
// @exclude         https://www.luscious.net/categories/
// @exclude         https://www.luscious.net/blog/*
// @exclude         https://www.luscious.net/audiences/*
// @exclude         https://www.luscious.net/*/by-*/
// @exclude         https://www.luscious.net/users/*
// @exclude         https://www.luscious.net/login/
// @exclude         https://www.luscious.net/upgrade/
// @exclude         https://www.luscious.net/about/
// @exclude         https://www.luscious.net/contact/
// @exclude         https://www.luscious.net/badges/
// @exclude         https://www.luscious.net/user_of_month/
// @exclude         https://www.luscious.net/user_lists/
// @exclude         https://www.luscious.net/terms/
// @exclude         https://www.luscious.net/privacy/
// @exclude         https://www.luscious.net/dmca/
// @homepageURL     https://greasyfork.org/en/scripts/558617-booru-keybinds
// @supportURL      https://greasyfork.org/en/scripts/558617-booru-keybinds/feedback
// @license         MIT-0
// ==/UserScript==

//
// FEATURES
// - Keyboard shortcuts:
//   - Use A/D or left/right arrow keys to navigate through pages instead of clicking buttons!
//   - Use W/S or up/down arrow keys for navigating through history.
// - Page types supported:
//   - Minimally works with browse and search page types across all supported sites.
//   - Extended page type support is spotty and may include user uploads/favorites and more.
//

//
// IMPLEMENTATION NOTES
// - tbib.org has no global comments list and doesn't seem to be derived from gelbooru
//   software anymore, despite diverging from the booru.org network.
//

//
// TODO
// - Functional enhancements
//   - Support FurAffinity.net
//     (something weird going on where using gallery pagination buttons
//      break our keybinds. also, requires implementaiton of alternates
//      for route site types)
//   - Support rule34.dev
//   - Support weasyl.com
//     (requires reading DOM to update cursor/ID offset)
//   - Support thehentaiworld.com
//     (something weird going on where our keybinds navigate to posts)
//   - Support W/D keybinds for sites that natively implement arrow keys
//     - rule34hentai.net
//     - kemono.cr
//     - rule34.paheal.net
//     - nozomi.la
//       (requires JS call to key_typed_in_body() for search page nav)
// - Nice-to-haves
//   - Add 'last page' detection
//   - Briefly change the cursor to visually indicate when a requested nav
//     event fails or is rejected
// - Refinement and refactoring
//   - Rewrite 'gallery' page type into abstraction supporting multiple page
//     route exceptions similar to PAGE_PATHS, but for path index instead of
//     page minimum.
//   - Refactor getAlternateMinimum() to return default site minimum.
//   - zerochan and Hentai-Foundry post pages ought to be excluded
//     https://www.zerochan.net/[1-9][0-9]+
//     https://www.hentai-foundry.com/pictures/user/USERNAME/POST_NUMBER/POST_TITLE
// - Testing
//   - Disable "Booru Hotkeys" Firefox extension and check the full list of
//     presently planned & supported sites to see which *really* do support
//     primary and alternate nav keybinds

(function() {
    'use strict';

    const NAME = '[booru navigational keybinds]';
    const DEBUG = false;

    const SITE_SETTINGS = {
        'luscious.net': [{
            key: 'prefer_infinite_scrolling_in_lists',
            value: false
        }]
    };

    // for domains that use page numbers in an url parameter
    // { domain: [parameter, first page] }
    const PAGE_DOMAINS = {
        'yande.re': ['page', 1],
        'rule34.us': ['page', 1],
        'zerochan.net': ['p', 1],
        'anime-pictures.net': ['page', 0],
        'konachan.net': ['page', 1],
        'kusowanka.com': ['page', 1],
        'derpibooru.org': ['page', 1],
        'twibooru.org': ['page', 1],
        'manebooru.art': ['page', 1],
        'minitokyo.net': ['page', 1],
        'inkbunny.net': ['page', 1],
        'rule34vault.com': ['page', 1],
        'rule34.xyz': ['page', 1],
        'rule34.world': ['page', 1],
        'furry34.com': ['page', 1],
        'furbooru.org': ['page', 1],
        'e-shuushuu.net': ['page', 1],
        'luscious.net': ['page', 1]
    };
    // path exceptions to the site minimums above
    // { domain: [ [{ param key: param value }, minimum page # ], ... ]}
    const PAGE_PATHS = {
        'rule34.us': [
            [{ 'r': 'favorites/view' }, 0],
            [{ 'r': 'comments/list' }, 0],
        ]
    }
    // for domains that put item count in an url parameter
    // { domain: items per page }
    const PID_DOMAINS = {
        'rule34.xxx': 42,
        'gelbooru.com': 42,
        'tbib.org': 42,
        'booru.org': 20,
        'safebooru.org': 42,
        'xbooru.com': 42,
        'realbooru.com': 42,
        'hypnohub.net': 42,
    };
    // offset exceptions to the site minimums above
    // { domain: [{ param key: param value, ... }, items per page ]}
    const ALT_OFFSETS = {
        'rule34.xxx': [[{ 'page': 'comment', 's': 'list' }, 15]],
        'hypnohub.net': [[{ 'page': 'comment', 's': 'list' }, 10]],
        'safebooru.org': [[{ 'page': 'comment', 's': 'list' }, 10]],
        'xbooru.com': [[{ 'page': 'comment', 's': 'list' }, 10]],
        'realbooru.com': [[{ 'page': 'comment', 's': 'list' }, 10]],
        'gelbooru.com': [[{ 'page': 'comment', 's': 'list' }, 10]],
        'booru.org': [[{ 'page': 'comment', 's': 'list' }, 15]],
    }

    // for domains that put page count in a route (such as shimmie)
    // { domain: [minimum page, indexed page position, page param name] }
    const ROUTE_DOMAINS = {
        'booru.eu': [1, -1, ''],
        'hentai-foundry.com': [1, -1, 'page'],
        'shishnet.org': [1, -1, ''],
    }

    // fallback hardcoding for if autodetection of site software fails
    const BOR_SITES = [
        // booru-on-rails and derivatives (philomena)
        'derpibooru.org',
        'twibooru.org',
        'manebooru.art',
        'furbooru.org',
    ];
    const R34WORLD_SITES = [
        // rule34.world operations
        'rule34vault.com',
        'rule34.xyz',
        'rule34.world'
    ];

    let PATH = window.location.pathname;
    let PARAMS = new URLSearchParams(window.location.search);
    const FQDN = window.location.hostname;
    let DOMAIN = FQDN;
    const domain_parts = FQDN.split('.');
    if (domain_parts.length >= 3 && domain_parts[0] === 'www') {
        DOMAIN = (
            domain_parts[domain_parts.length - 2]
            + '.' + domain_parts[domain_parts.length - 1]
        );
    }

    // .startsWith() but accepting an array of strings to check
    function startsWithMany(str, arr) {
        return arr.some(s => str.startsWith(s));
    }

    function detectPaginationType() {
        if (DOMAIN === 'inkbunny.net'
            && startsWithMany(PATH, ['/gallery/', '/scraps/'])
        ) {
            return 'gallery';
        } else if (DOMAIN in PID_DOMAINS) {
            return 'pid';
        } else if (DOMAIN in PAGE_DOMAINS) {
            return 'page';
        } else if (DOMAIN in ROUTE_DOMAINS) {
            return 'route';
        }
        return null;
    }

    function isPathExcluded() {
        // in place of using regex for a host match exclusion,
        // ^https:\/\/www\.zerochan\.net\/[1-9][0-9]+
        if (DOMAIN === 'zerochan.net'
            && PATH.substring(1)
            && !isNaN(parseInt(PATH.substring(1)))
        ) {
            return true;
        }
        return false;
    }

    function getAlternateMinimum(config, site) {
        if (!(site in config)) return null;

        let response = null;

        // test each set of param combinations to alternates
        for (const element of config[site]) {
            const params = element[0];
            var isCompleteMatch = true;
            // test each set of param key-value pairs
            for (const [key, value] of Object.entries(element[0])) {
                if (!PARAMS.has(key) || PARAMS.get(key) !== value) {
                    isCompleteMatch = false;
                    break;
                }
            }

            if (!isCompleteMatch) {
                // if all sets fail, continue to next proposed alternate
                continue;
            } else {
                // otherwise, bubble up the alternate
                response = element[1];
                break;
            }
        }

        // no test needed since default is null
        return response;
    }

    // NAVIGATION

    function getCurrentPage(type) {
        if (type === 'pid') {
            // assume parameter is named 'pid' and first item is 0
            return parseInt(PARAMS.get(type)) || 0;
        } else if (type === 'page') {
            // retrieve parameter name from configuration
            // if no such param was passed, return known first page for domain
            const alternateMin = getAlternateMinimum(PAGE_PATHS, DOMAIN);
            const defaultMin = (alternateMin !== null )
                ? alternateMin : PAGE_DOMAINS[DOMAIN][1];
            return parseInt(
                PARAMS.get(PAGE_DOMAINS[DOMAIN][0])
            ) || defaultMin;
        } else if (type === 'route') {
            let pathParts = PATH.split('/');
            const defaultMin = ROUTE_DOMAINS[DOMAIN][0];
            const pathOffset = ROUTE_DOMAINS[DOMAIN][1];
            let pathIndex = (pathOffset < 0)
                ? pathParts.length + pathOffset
                : pathOffset;
            const current = parseInt(pathParts[pathIndex]);
            if (isNaN(current)) {
                return defaultMin;
            } else {
                return current;
            }
        } else if (type === 'gallery') {
            // special snowflake inkbunny puts its page number at position -2
            // e.x. /pageName/USERNAME/PAGE/HASH
            let pathParts = PATH.split('/');
            return parseInt(
                pathParts[pathParts.length - 2]
            ) || PAGE_DOMAINS[DOMAIN][1];
        }
    }

    function calculatePreviousPage(type, current, pageLength) {
        if (type === 'pid') {
            if (current > 0) {
                return Math.max(0, current - pageLength);
            } else {
                if (DEBUG) console.debug(NAME, `(current=${current}) <= 0`);
                return false;
            }
        } else if (type === 'page') {
            const alternateMin = getAlternateMinimum(PAGE_PATHS, DOMAIN);
            const minPage = (alternateMin !== null)
                ? alternateMin : PAGE_DOMAINS[DOMAIN][1];
            if (current > minPage) {
                return (current - 1);
            } else {
                if (DEBUG) console.debug(NAME, `(current=${current}) <= (minPage=${minPage})`);
                return false;
            }
        } else if (['route', 'gallery'].includes(type)) {
            // TODO: check ROUTE_DOMAINS
            if (current > 1) {
                return (current - 1);
            } else {
                if (DEBUG) console.debug(NAME, `(current=${current}) <= 1`);
                return false;
            }
        }
    }

    function calculateNextPage(type, current, pageLength) {
        if (type === 'pid') {
            if (DEBUG) console.debug(NAME, `nextPage = ${current} + ${pageLength}`);
            return (current + pageLength);
        } else if (['page', 'route', 'gallery'].includes(type)) {
            if (DEBUG) console.debug(NAME, `nextPage = ${current} + 1`);
            return (current + 1);
        }
    }

    function updateQuery(type, dest) {
        const pageNo = dest.toString();
        if (type === 'pid') {
            // assume parameter is named 'pid'
            PARAMS.set(type, pageNo);
        } else if (type === 'page') {
            const pageKey = PAGE_DOMAINS[DOMAIN][0];
            PARAMS.set(pageKey, pageNo);
        } else if (type === 'route') {
            let pathParts = PATH.split('/');
            const pathOffset = ROUTE_DOMAINS[DOMAIN][1];
            let pathIndex = (pathOffset < 0)
                ? pathParts.length + pathOffset
                : pathOffset;
            if (!isNaN(parseInt(pathParts[pathIndex]))) {
                pathParts[pathIndex] = pageNo;
            } else {
                if (pathParts[pathIndex] === '') pathParts.pop();
                const pageRouteName = ROUTE_DOMAINS[DOMAIN][2];
                if (pageRouteName) pathParts.push(pageRouteName);
                pathParts.push(pageNo);
            }
            PATH = pathParts.join('/');
        } else if (type === 'gallery') {
            // for /pageName/USERNAME/PAGE/HASH, replace PAGE at position -2
            let pathParts = PATH.split('/');
            pathParts[pathParts.length - 2] = pageNo;
            PATH = pathParts.join('/');
        }
        return true;
    }

    function reformatUri() {
        // redirect booru-on-rails frontpage to listing page on navigation
        const footer = document.querySelector('#serving_info');
        if (((typeof BOR_SITES === 'object'
                && BOR_SITES.includes(DOMAIN))
            || (footer !== null
                && footer.tagName === 'DIV'
                && (footer.textContent.includes('booru-on-rails')
                    || footer.textContent.includes('philomena'))))
            && (PATH === '/' || PATH.startsWith('/activity'))
        ) PATH = (DOMAIN == 'twibooru.org') ? '/posts' : '/images';

        // remove unsupported query string parameters
        // that break navigation when we alter the page param
        if ((typeof R34WORLD_SITES === 'object'
                && R34WORLD_SITES.includes(DOMAIN))
            && PARAMS.has('cursor')
        ) PARAMS.delete('cursor');

        // if there are parameters and we've modified them,
        // reconstruct the search string
        return (PARAMS.size)
            ? [PATH, PARAMS.toString()]
            : [PATH, ''];
    }

    function navigate(type, direction) {
        PATH = window.location.pathname;
        PARAMS = new URLSearchParams(window.location.search);

        let current = null;
        current = getCurrentPage(type);
        if (DEBUG) console.debug(NAME, `currentPage = ${current}`);

        let offset = null;
        if (type === 'pid') {
            const alternate = getAlternateMinimum(ALT_OFFSETS, DOMAIN);
            offset = (alternate !== null)
                ? alternate : PID_DOMAINS[DOMAIN];
            if (DEBUG) console.debug(NAME, `offset = ${offset}`);
        }

        let dest = null;
        if (direction === 'previous') {
            dest = calculatePreviousPage(type, current, offset);
        } else if (direction === 'next') {
            dest = calculateNextPage(type, current, offset);
        }
        // test specifically for false (0 is a valid page number!!)
        if (dest !== 0 && !dest) return false;
        if (DEBUG) console.debug(NAME, `dest = ${dest}`);

        updateQuery(type, dest);
        const newTrail = reformatUri().join('?');
        window.location.href = window.location.origin + newTrail;
        return true;
    }

    // function isKeybindExcluded(key) {
    //     if ((['gelbooru.com', 'booru.org'].includes(DOMAIN))
    //         && (key == 'a' || key == 'd')
    //     ) {
    //         return true;
    //     } else {
    //         return false;
    //     }
    // }

    function getLocalStorage(k, d = {}) {
        try {
            const v = localStorage.getItem(k);
            return (v === null) ? d : JSON.parse(v);
        } catch (e) {
            return d;
        }
    }

    function setLocalStorage(k, v) {
        localStorage.setItem(
            k, JSON.stringify(v)
        );
    }

    if (Object.keys(SITE_SETTINGS).includes(DOMAIN)) {
        for (const s of SITE_SETTINGS[DOMAIN]) {
            const current = getLocalStorage(s.key);
            if (!current || current !== s.value)
                setLocalStorage(s.key, s.value);
        }
    }

    const SITE_TYPE = detectPaginationType();

    document.addEventListener('keydown', function(e) {
        if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return;

        const tagExclusions = ['INPUT', 'TEXTAREA', 'SELECT'];
        if (document.activeElement.isContentEditable
            || tagExclusions.includes(e.target.tagName))
                return; // editable has focus

        // inhibit when video player has focus unless navigating
        const isNavKey = ['a', 'w', 'd', 's'].includes(e.key.toLowerCase());
        if (e.target.tagName === 'VIDEO' && isNavKey) return;

        // don't trigger on sites that partially implement our bindings
        // NOTE: native navigation via these keybinds is broken XD
        // if (isKeybindExcluded(e.key.toLowerCase())) return;

        let response;
        const key = (e.key || '').toLowerCase();
        if (key === 'arrowleft' || key === 'a') {
            e.preventDefault();
            response = navigate(SITE_TYPE, 'previous');
        } else if (key === 'arrowright' || key === 'd') {
            e.preventDefault();
            response = navigate(SITE_TYPE, 'next');
        } else if (key === 'arrowup' || key === 'w') {
            e.preventDefault();
            history.back();
        } else if (key === 'arrowdown' || key === 's') {
            e.preventDefault();
            history.forward();
        }

        if (typeof response === 'undefined' || !response)
            console.error(NAME, 'Keybind caught, but failed to navigate!');
    });

    if (isPathExcluded()) return false;

    if (!SITE_TYPE) {
        console.error(NAME, 'Failed to initialize!');
        return false;
    } else {
        console.log(
            NAME, `loaded (in ${SITE_TYPE} mode): `,
            'Use ← → arrow keys to navigate gallery pages.'
        );
    }
})();