UCalgary Card & Collection Keyboard Shortcuts

• Card pages: number keys to answer, Enter/~/etc., floating counter (click badge or ⌃⌥R to reset).

当前为 2025-06-24 提交的版本,查看 最新版本

// ==UserScript==
// @name         UCalgary Card & Collection Keyboard Shortcuts
// @description  • Card pages: number keys to answer, Enter/~/etc., floating counter (click badge or ⌃⌥R to reset).
//               • Collection pages: number keys or Enter to Play a deck — counter starts at 1 / total.
// @version      4.1
// @author       you
// @namespace    https://greasyfork.org/users/1331386
// @match        https://cards.ucalgary.ca/card/*
// @match        https://cards.ucalgary.ca/collection/*
// @match        https://cards.ucalgary.ca/collection
// @grant        GM_registerMenuCommand
// @run-at       document-end
// ==/UserScript==

(() => {
  'use strict';

  /* ─────────────── helpers ─────────────── */

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

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

  /* ════════════════════════════════════════
     Card counter (and shared storage)
     ════════════════════════════════════════ */

  const STORAGE_KEY = 'ucalgaryVisitedCards'; // Set<string> of card IDs
  const TOTAL_KEY   = 'ucalgaryDeckTotal';    // Number: deck size

  const visited = new Set(JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'));
  let totalCards = Number(localStorage.getItem(TOTAL_KEY) || 0);

  function saveVisited() {
    localStorage.setItem(STORAGE_KEY, JSON.stringify([...visited]));
  }

  /* ---- badge UI ---- */

  let badge = null;
  function ensureBadge() {
    if (badge) return;
    badge = document.createElement('div');
    Object.assign(badge.style, {
      position:'fixed', top:'8px', right:'8px',
      padding:'4px 10px',
      background:'#333', color:'#fff',
      font:'14px/1.2 sans-serif',
      borderRadius:'4px', cursor:'pointer',
      zIndex:10000, opacity:0.9, userSelect:'none',
    });
    badge.addEventListener('click', resetCounter);
    document.body.appendChild(badge);
  }

  function renderBadge() {
    if (!badge) return;
    badge.textContent =
      totalCards > 0 ? `${visited.size} / ${totalCards}`
                     : `Cards: ${visited.size}`;
  }

  function resetCounter() {
    localStorage.setItem(STORAGE_KEY, '[]');  // clear IDs
    visited.clear();
    if (isCardPage && cardID) visited.add(cardID); // restart with current card
    saveVisited();
    renderBadge();
    badge?.animate([{opacity:0.4},{opacity:0.9}], {duration:300});
    // keep TOTAL_KEY as-is (manual reset shouldn't forget deck size)
  }

  /* ---- storage sync across tabs ---- */
  window.addEventListener('storage', e => {
    if (e.key === STORAGE_KEY) {
      visited.clear();
      JSON.parse(e.newValue || '[]').forEach(id => visited.add(id));
      renderBadge();
    } else if (e.key === TOTAL_KEY) {
      totalCards = Number(e.newValue || 0);
      renderBadge();
    }
  });

  /* ---- install badge & counting only on /card/ ---- */

  let cardID = null;
  if (isCardPage) {
    const m = location.pathname.match(/\/card\/(\d+)/);
    if (m) {
      cardID = m[1];
      visited.add(cardID);
      saveVisited();
    }
    ensureBadge();
    renderBadge();
  }

  /* ════════════════════════════════════════
     Shortcut logic (card & collection pages)
     ════════════════════════════════════════ */

  /* ---- /card/ helpers ---- */

  function addCardHints() {
    document.querySelectorAll('form.question').forEach(form => {
      form.querySelectorAll('.option label').forEach((label, i) => {
        if (label.dataset.hinted) return;
        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) submitBtn.click();
    else if (nextBtn   && nextBtn.offsetParent)   nextBtn.click();
    else if (reviewBtn&&  reviewBtn.offsetParent) reviewBtn.click();
  }

  /* ---- /collection/ helpers ---- */

  function addCollectionHints() {
    document.querySelectorAll('table.table-striped tbody tr').forEach((row, i) => {
      const name = row.querySelector('a.deck-name');
      if (!name || name.dataset.hinted) return;
      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];          // index+1 because first is “Details”
    if (!btn) return;

    /* -- read "x / y" label in the same row and keep the denominator -- */
    const label = btn.closest('tr')?.querySelector('span.label');
    const m = label?.textContent.trim().match(/\/\s*(\d+)/); // “6 / 13” → 13
    totalCards = m ? Number(m[1]) : 0;
    localStorage.setItem(TOTAL_KEY, String(totalCards));

    /* reset IDs BEFORE the first card loads */
    resetCounter();

    /* open deck in centred window */
    const w=1000,h=800,l=Math.round((screen.width-w)/2),t=Math.round((screen.height-h)/2);
    const feats=[
      'noopener','noreferrer','scrollbars=yes','resizable=yes',
      `width=${w}`,`height=${h}`,`left=${l}`,`top=${t}`
    ].join(',');
    const win = window.open(btn.href,'_blank',feats);
    if (win) win.focus();
  }

  /* /collection root – Enter opens first visible “Details” bag */
  function openFirstBagDetails() {
    const bags = document.querySelectorAll('.bag');
    for (const bag of bags) {
      if (bag.offsetParent === null) continue;   // hidden by filter
      const detailsBtn = bag.querySelector('a.btn.deck-details');
      if (detailsBtn) {
        window.open(detailsBtn.href, '_blank', 'noopener');
        break;
      }
    }
  }

  /* ---- global key handler ---- */

  document.addEventListener('keydown', e => {
    const index = getOptionIndex(e);

    if (isCollectionRootPage && e.key === 'Enter') {
      openFirstBagDetails();
      e.preventDefault();
      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});
})();