// ==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});
})();