UCalgary Card & Collection Keyboard Shortcuts

cards hotkeys and counter

  1. // ==UserScript==
  2. // @name UCalgary Card & Collection Keyboard Shortcuts
  3. // @version 5.0
  4. // @description cards hotkeys and counter
  5. // @author you
  6. // @namespace https://greasyfork.org/users/1331386
  7. // @match https://cards.ucalgary.ca/card/*
  8. // @match https://cards.ucalgary.ca/collection/*
  9. // @match https://cards.ucalgary.ca/collection*
  10. // @run-at document-end
  11. // ==/UserScript==
  12.  
  13. (() => {
  14. 'use strict';
  15.  
  16. /* ─────────────── helpers ─────────────── */
  17.  
  18. const isCardPage = location.pathname.startsWith('/card/');
  19. const isCollectionPage = location.pathname.startsWith('/collection/') && location.pathname !== '/collection';
  20. const isCollectionRootPage = location.pathname === '/collection';
  21.  
  22. // keyboard 0-19 index (Shift+1-0 → 10-19)
  23. const key2index = {
  24. Digit1: 0, Digit2: 1, Digit3: 2, Digit4: 3, Digit5: 4,
  25. Digit6: 5, Digit7: 6, Digit8: 7, Digit9: 8, Digit0: 9,
  26. };
  27. const getOptionIndex = e =>
  28. e.code in key2index ? key2index[e.code] + (e.shiftKey ? 10 : 0) : null;
  29.  
  30. /* ════════════════════════════════════════
  31. Card counter (and shared storage)
  32. ════════════════════════════════════════ */
  33.  
  34. const STORAGE_KEY = 'ucalgaryVisitedCards'; // Set<string> of card IDs
  35. const TOTAL_KEY = 'ucalgaryDeckTotal'; // Number: deck size
  36.  
  37. const visited = new Set(JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'));
  38. let totalCards = Number(localStorage.getItem(TOTAL_KEY) || 0);
  39.  
  40. function saveVisited() {
  41. localStorage.setItem(STORAGE_KEY, JSON.stringify([...visited]));
  42. }
  43.  
  44. /* ---- badge UI ---- */
  45.  
  46. let badge = null;
  47. function ensureBadge() {
  48. if (badge) return;
  49. badge = document.createElement('div');
  50. Object.assign(badge.style, {
  51. position:'fixed', top:'8px', right:'8px',
  52. padding:'4px 10px',
  53. background:'#333', color:'#fff',
  54. font:'14px/1.2 sans-serif',
  55. borderRadius:'4px', cursor:'pointer',
  56. zIndex:10000, opacity:0.9, userSelect:'none',
  57. });
  58. badge.addEventListener('click', resetCounter);
  59. document.body.appendChild(badge);
  60. }
  61.  
  62. function renderBadge() {
  63. if (!badge) return;
  64. badge.textContent =
  65. totalCards > 0 ? `${visited.size} / ${totalCards}`
  66. : `Cards: ${visited.size}`;
  67. }
  68.  
  69. function resetCounter() {
  70. localStorage.setItem(STORAGE_KEY, '[]'); // clear IDs
  71. visited.clear();
  72. if (isCardPage && cardID) visited.add(cardID); // restart with current card
  73. saveVisited();
  74. renderBadge();
  75. badge?.animate([{opacity:0.4},{opacity:0.9}], {duration:300});
  76. // keep TOTAL_KEY as-is (manual reset shouldn't forget deck size)
  77. }
  78.  
  79. /* ---- storage sync across tabs ---- */
  80. window.addEventListener('storage', e => {
  81. if (e.key === STORAGE_KEY) {
  82. visited.clear();
  83. JSON.parse(e.newValue || '[]').forEach(id => visited.add(id));
  84. renderBadge();
  85. } else if (e.key === TOTAL_KEY) {
  86. totalCards = Number(e.newValue || 0);
  87. renderBadge();
  88. }
  89. });
  90.  
  91. /* ---- install badge & counting only on /card/ ---- */
  92.  
  93. let cardID = null;
  94. if (isCardPage) {
  95. const m = location.pathname.match(/\/card\/(\d+)/);
  96. if (m) {
  97. cardID = m[1];
  98. visited.add(cardID);
  99. saveVisited();
  100. }
  101. ensureBadge();
  102. renderBadge();
  103. }
  104.  
  105. /* ════════════════════════════════════════
  106. Shortcut logic (card & collection pages)
  107. ════════════════════════════════════════ */
  108.  
  109. /* ---- /card/ helpers ---- */
  110.  
  111. function addCardHints() {
  112. document.querySelectorAll('form.question').forEach(form => {
  113. form.querySelectorAll('.option label').forEach((label, i) => {
  114. if (label.dataset.hinted) return;
  115. label.insertAdjacentHTML(
  116. 'afterbegin',
  117. `<span style="font-weight:600;margin-right:4px;">(${i+1})</span>`
  118. );
  119. label.dataset.hinted = 'true';
  120. });
  121. });
  122. }
  123.  
  124. function clearSelections() {
  125. document.querySelectorAll(
  126. 'form.question input[type="radio"], form.question input[type="checkbox"]'
  127. ).forEach(inp => (inp.checked = false));
  128. }
  129.  
  130. function handleEnter() {
  131. const submitBtn = document.querySelector('form.question .submit button');
  132. const nextBtn = document.querySelector('#next');
  133. const reviewBtn = document.querySelector('div.actions span.review-buttons a.save');
  134. if (submitBtn && submitBtn.offsetParent) submitBtn.click();
  135. else if (nextBtn && nextBtn.offsetParent) nextBtn.click();
  136. else if (reviewBtn&& reviewBtn.offsetParent) reviewBtn.click();
  137. }
  138.  
  139. /* ---- /collection/ helpers ---- */
  140.  
  141. function addCollectionHints() {
  142. document.querySelectorAll('table.table-striped tbody tr').forEach((row, i) => {
  143. const name = row.querySelector('a.deck-name');
  144. if (!name || name.dataset.hinted) return;
  145. name.insertAdjacentHTML(
  146. 'afterbegin',
  147. `<span style="font-weight:600;margin-right:4px;">(${i+1})</span>`
  148. );
  149. name.dataset.hinted = 'true';
  150. });
  151. }
  152.  
  153. function playDeck(index) {
  154. const buttons = document.querySelectorAll('a.btn.action.save');
  155. const btn = buttons[index + 1]; // index+1 because first is “Details”
  156. if (!btn) return;
  157.  
  158. /* -- read "x / y" label in the same row and keep the denominator -- */
  159. const label = btn.closest('tr')?.querySelector('span.label');
  160. const m = label?.textContent.trim().match(/\/\s*(\d+)/); // “6 / 13” → 13
  161. totalCards = m ? Number(m[1]) : 0;
  162. localStorage.setItem(TOTAL_KEY, String(totalCards));
  163.  
  164. /* reset IDs BEFORE the first card loads */
  165. resetCounter();
  166.  
  167. /* open deck in centred window */
  168. const w=1000,h=800,l=Math.round((screen.width-w)/2),t=Math.round((screen.height-h)/2);
  169. const feats=[
  170. 'noopener','noreferrer','scrollbars=yes','resizable=yes',
  171. `width=${w}`,`height=${h}`,`left=${l}`,`top=${t}`
  172. ].join(',');
  173. const win = window.open(btn.href,'_blank',feats);
  174. if (win) win.focus();
  175. }
  176.  
  177. /* /collection root – Enter opens first visible “Details” bag */
  178. function openFirstBagDetails() {
  179. const bags = document.querySelectorAll('.bag');
  180. for (const bag of bags) {
  181. if (bag.offsetParent === null) continue; // hidden by filter
  182. const detailsBtn = bag.querySelector('a.btn.deck-details');
  183. if (detailsBtn) {
  184. window.open(detailsBtn.href, '_blank', 'noopener');
  185. break;
  186. }
  187. }
  188. }
  189.  
  190. /* ---- global key handler ---- */
  191.  
  192. document.addEventListener('keydown', e => {
  193. const index = getOptionIndex(e);
  194.  
  195. if (isCollectionRootPage && e.key === 'Enter') {
  196. openFirstBagDetails();
  197. e.preventDefault();
  198. return;
  199. }
  200.  
  201. if (isCardPage) {
  202. if (index !== null) {
  203. const radios = document.querySelectorAll('form.question input[type="radio"]');
  204. const checks = document.querySelectorAll('form.question input[type="checkbox"]');
  205. if (radios[index]) {
  206. radios[index].checked = true;
  207. radios[index].scrollIntoView({behavior:'smooth',block:'center'});
  208. }
  209. if (checks[index]) {
  210. checks[index].checked = !checks[index].checked;
  211. checks[index].scrollIntoView({behavior:'smooth',block:'center'});
  212. }
  213. return;
  214. }
  215. if (e.key === 'Enter') handleEnter();
  216. if (e.key === '~') clearSelections();
  217. }
  218.  
  219. if (isCollectionPage && index !== null) {
  220. playDeck(index);
  221. }
  222. });
  223.  
  224. /* ---- observers / init ---- */
  225.  
  226. function initHints() {
  227. if (isCardPage) addCardHints();
  228. if (isCollectionPage) addCollectionHints();
  229. }
  230. window.addEventListener('load', initHints);
  231. document.addEventListener('DOMContentLoaded', initHints);
  232. new MutationObserver(initHints).observe(document.body, {childList:true,subtree:true});
  233. })();