UCalgary Card & Collection Keyboard Shortcuts

Cards: number keys to answer; Enter/~/etc. Collections: number keys to Play a deck.

// ==UserScript==
// @name         UCalgary Card & Collection Keyboard Shortcuts
// @version      3.1
// @description  Cards: number keys to answer; Enter/~/etc.  Collections: number keys to Play a deck.
// @match        https://cards.ucalgary.ca/card/*
// @match        https://cards.ucalgary.ca/collection/*
// @match        https://cards.ucalgary.ca/collection*
// @grant         none
// @namespace https://greasyfork.org/users/1331386
// ==/UserScript==

(function () {
    'use strict';

    /* ─────────────── helper ─────────────── */

    const isCardPage            = location.pathname.startsWith('/card/');
    const isCollectionPage      = location.pathname.startsWith('/collection/') && location.pathname !== '/collection';
    const isCollectionRootPage  = location.pathname === '/collection'

    // common 0-19 index from keyboard (Shift+1-0 for 10-19)
    function getOptionIndex(e) {
        const map = {
            Digit1: 0, Digit2: 1, Digit3: 2, Digit4: 3, Digit5: 4,
            Digit6: 5, Digit7: 6, Digit8: 7, Digit9: 8, Digit0: 9,
        };
        if (!(e.code in map)) return null;
        return map[e.code] + (e.shiftKey ? 10 : 0);
    }

    /* ─────────────── /card/  logic ─────────────── */

    function addCardHints() {
        document.querySelectorAll('form.question').forEach(form => {
            form.querySelectorAll('.option label').forEach((label, i) => {
                if (!label.dataset.hinted) {
                    label.insertAdjacentHTML(
                        'afterbegin',
                        `<span style="font-weight:600;margin-right:4px;">(${i + 1})</span>`
                    );
                    label.dataset.hinted = 'true';
                }
            });
        });
    }

    function clearSelections() {
        document.querySelectorAll('form.question input[type="radio"], form.question input[type="checkbox"]')
            .forEach(inp => (inp.checked = false));
    }

    function handleEnter() {
        const submitBtn = document.querySelector('form.question .submit button');
        const nextBtn = document.querySelector('#next');
        const reviewBtn = document.querySelector('div.actions span.review-buttons a.save');

        if (submitBtn && submitBtn.offsetParent !== null) {
            submitBtn.click();
        } else if (nextBtn && nextBtn.offsetParent !== null) {
            nextBtn.click();
        } else if (reviewBtn && reviewBtn.offsetParent !== null) {
            reviewBtn.click();
        }
    }
    /* ─────────────── /collection/  logic ─────────────── */

    function addCollectionHints() {
        document.querySelectorAll('table.table-striped tbody tr').forEach((row, i) => {
            const name = row.querySelector('a.deck-name');
            if (name && !name.dataset.hinted) {
                name.insertAdjacentHTML(
                    'afterbegin',
                    `<span style="font-weight:600;margin-right:4px;">(${i + 1})</span>`
                );
                name.dataset.hinted = 'true';
            }
        });
    }

    function playDeck(index) {
        const buttons = document.querySelectorAll('a.btn.action.save');
        const btn     = buttons[index + 1];
        if (!btn) return;

        btn.scrollIntoView({ behavior: 'smooth', block: 'center' });

        /* -------- open in a centred 1 000 × 800 px window -------- */
        const width  = 1000;
        const height = 800;
        const left   = Math.round((screen.width  - width)  / 2);
        const top    = Math.round((screen.height - height) / 2);

        const features = [
            'noopener',         // security: no access back to this window
            'noreferrer',       // (optional) hide referrer
            'scrollbars=yes',   // allow scrolling
            'resizable=yes',    // let user resize
            `width=${width}`,
            `height=${height}`,
            `left=${left}`,
            `top=${top}`
        ].join(',');

        const win = window.open(btn.href, '_blank', features);
        if (win) win.focus();   // bring the new window to the front
    }


    /* ─── /collection  root page ─ ENTER = first *visible* Details ─── */
    function openFirstBagDetails() {
        // .bag wrappers are what get “display:none” when filtered
        const bags = document.querySelectorAll('.bag');

        for (const bag of bags) {
            // offsetParent === null → element (or an ancestor) is display:none
            if (bag.offsetParent === null) continue;

            const detailsBtn = bag.querySelector('a.btn.deck-details');
            if (detailsBtn) {
                window.open(detailsBtn.href, '_blank', 'noopener');
                break;
            }
        }
    }
    
    /* ─────────────── key handler ─────────────── */

    document.addEventListener('keydown', e => {
        const index = getOptionIndex(e);
        if (isCollectionRootPage && e.key === 'Enter') {
            openFirstBagDetails();
            e.preventDefault();          // stop the page’s default handling
            return;
        }
        if (isCardPage) {
            if (index !== null) {
                const radios = document.querySelectorAll('form.question input[type="radio"]');
                const checks = document.querySelectorAll('form.question input[type="checkbox"]');

                if (radios[index]) {
                    radios[index].checked = true;
                    radios[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
                }
                if (checks[index]) {
                    checks[index].checked = !checks[index].checked;
                    checks[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
                }
                return;
            }
            if (e.key === 'Enter') handleEnter();
            if (e.key === '~')    clearSelections();
        }

        if (isCollectionPage && index !== null) {
            playDeck(index);
        }
    });

    /* ─────────────── observers / init ─────────────── */

    function initHints() {
        if (isCardPage)       addCardHints();
        if (isCollectionPage) addCollectionHints();
    }

    window.addEventListener('load', initHints);
    document.addEventListener('DOMContentLoaded', initHints);
    new MutationObserver(initHints).observe(document.body, { childList: true, subtree: true });
})();