├── .gitignore ├── babel.config.json ├── jest.config.js ├── docs └── roamsr.mdx ├── README.md ├── src ├── schedulers │ ├── ankiScheduler.test.js │ └── ankiScheduler.js ├── ui │ ├── srButton.js │ ├── hiding-sidebar.js │ ├── styles.js │ └── uiElements.js ├── main.js ├── core │ ├── state.js │ ├── helperFunctions.js │ ├── keybindings.js │ ├── sessions.js │ ├── mainFunctions.js │ ├── loadingCards.test.js │ └── loadingCards.js ├── debug.js └── misc │ └── sample-settings.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | js 5 | .vscode -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | "^.+\\.jsx?$": "babel-jest", 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /docs/roamsr.mdx: -------------------------------------------------------------------------------- 1 | Currently, this plugin resides [here](https://roamresearch.com/#/app/roam-depot-developers/page/uQSCwVKx0) in the roam-depot Roam database. For now, continue there please. I'll flesh out this page more in the future. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🗃️ roam/sr - Spaced Repetition in Roam Research 2 | 3 | For more info, see: https://roamresearch.com/#/app/roam-depot-developers/page/uQSCwVKx0 4 | 5 | ## Contributing 6 | 7 | - Contact me on Twitter: https://twitter.com/adam_krivka or email krivka.adam@gmail.com. 8 | - Create issues and pull requests in this repository. 9 | -------------------------------------------------------------------------------- /src/schedulers/ankiScheduler.test.js: -------------------------------------------------------------------------------- 1 | import { calcNewFactorAndInterval, defaultConfig } from "./ankiScheduler"; 2 | 3 | test("calcNewFactorAndInterval", () => { 4 | // always "good" response, sanity check 5 | const signal = "3"; 6 | let factor = defaultConfig.defaultFactor; 7 | let interval = 6; 8 | 9 | // bigger than 7 results in the maximum interval 10 | for (let n = 0; n < 8; n++) { 11 | const [tempFac, tempInter] = calcNewFactorAndInterval(defaultConfig, factor, interval, 0, signal); 12 | 13 | // if "good" factor never changes 14 | expect(tempFac).toBe(defaultConfig.defaultFactor); 15 | // if "good" interval grows via the default factor 16 | expect(tempInter).toBe(interval * defaultConfig.defaultFactor); 17 | 18 | factor = tempFac; 19 | interval = tempInter; 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /src/ui/srButton.js: -------------------------------------------------------------------------------- 1 | export const buttonClickHandler = async (e) => { 2 | if (e.target.tagName === "BUTTON") { 3 | const text = e.target.textContent; 4 | if (roamsr.settings.mainTags.some((tag) => tag === text)) { 5 | const block = e.target.closest(".roam-block"); 6 | if (block) { 7 | const uid = block.id.substring(block.id.length - 9); 8 | const q = `[:find (pull ?page 9 | [{:block/children [:block/uid :block/string]}]) 10 | :in $ ?uid 11 | :where [?page :block/uid ?uid]]`; 12 | const results = await window.roamAlphaAPI.q(q, uid); 13 | if (results.length == 0) return; 14 | const children = results[0][0].children; 15 | for (let child of children) { 16 | window.roamAlphaAPI.updateBlock({ 17 | block: { 18 | uid: child.uid, 19 | string: child.string.trim() + " #" + text, 20 | }, 21 | }); 22 | } 23 | } 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | /* roam/sr - Spaced Repetition in Roam Research 2 | Author: Adam Krivka 3 | v1.1.0 4 | https://github.com/aidam38/roamsr 5 | */ 6 | 7 | import { loadSettings, loadState } from "./core/sessions"; 8 | import { buttonClickHandler } from "./ui/srButton"; 9 | import { standbyState } from "./core/state"; 10 | import { addBasicStyles } from "./ui/styles"; 11 | import { addDelimiter, addWidget } from "./ui/uiElements"; 12 | 13 | export const init = () => { 14 | var VERSION = "v1.1.0"; 15 | 16 | if (!window.roamsr) window.roamsr = { state: {}, settings: {} }; 17 | 18 | console.log("🗃️ Loading roam/sr " + VERSION + "."); 19 | 20 | standbyState(); 21 | 22 | document.addEventListener("click", buttonClickHandler, false); 23 | 24 | loadSettings(); 25 | addBasicStyles(); 26 | loadState(-1).then(() => { 27 | addDelimiter(); 28 | addWidget(); 29 | }); 30 | 31 | console.log("🗃️ Successfully loaded roam/sr " + VERSION + "."); 32 | }; 33 | 34 | init(); 35 | -------------------------------------------------------------------------------- /src/ui/hiding-sidebar.js: -------------------------------------------------------------------------------- 1 | import { sleep } from "../core/helperFunctions"; 2 | 3 | // simulateClick by Viktor Tabori 4 | const simulateMouseEvents = (element, events, opts) => { 5 | setTimeout(function () { 6 | events.forEach(function (type) { 7 | var _event = new MouseEvent(type, { 8 | view: window, 9 | bubbles: true, 10 | cancelable: true, 11 | buttons: 1, 12 | ...opts, 13 | }); 14 | _event.simulated = true; 15 | element.dispatchEvent(_event); 16 | }); 17 | }, 0); 18 | }; 19 | 20 | export const showLeftSidebar = async () => { 21 | var firstButton = document.querySelector(".bp3-icon-menu"); 22 | console.log(firstButton); 23 | if (firstButton) { 24 | simulateMouseEvents(firstButton, ["mouseover"]); 25 | await sleep(150); 26 | var secondButton = document.querySelector(".bp3-icon-menu-open"); 27 | secondButton.click(); 28 | } 29 | }; 30 | 31 | export const hideLeftSidebar = () => { 32 | try { 33 | document.getElementsByClassName("bp3-icon-menu-closed")[0].click(); 34 | } catch (e) {} 35 | }; 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roamsr", 3 | "version": "1.1.0", 4 | "description": "For more info, see: https://roamresearch.com/#/app/roam-depot-developers/page/uQSCwVKx0", 5 | "main": "./src/main.js", 6 | "scripts": { 7 | "format": "prettier --write 'src/**/*.{js,json}'", 8 | "test": "jest", 9 | "start": "parcel ./src/debug.js", 10 | "build-debug": "parcel build ./src/debug.js --no-source-maps --no-content-hash --no-minify --out-file stable.js -d ./js/", 11 | "build": "npm test && parcel build ./src/main.js --no-source-maps --no-content-hash --no-minify --out-file stable.js -d ./js/" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/aidam38/roamsr.git" 16 | }, 17 | "author": "", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/aidam38/roamsr/issues" 21 | }, 22 | "homepage": "https://github.com/aidam38/roamsr#readme", 23 | "devDependencies": { 24 | "@babel/preset-env": "^7.13.9", 25 | "@types/jest": "^26.0.20", 26 | "babel-jest": "^26.6.3", 27 | "jest": "^26.6.3", 28 | "parcel-bundler": "^1.12.3", 29 | "prettier": "^2.2.1" 30 | }, 31 | "browserslist": [ 32 | "last 3 and_chr versions", 33 | "last 3 chrome versions", 34 | "last 3 opera versions", 35 | "last 3 ios_saf versions", 36 | "last 3 safari versions", 37 | "last 3 firefox versions" 38 | ], 39 | "prettier": { 40 | "printWidth": 120, 41 | "useTabs": true 42 | } 43 | } -------------------------------------------------------------------------------- /src/core/state.js: -------------------------------------------------------------------------------- 1 | // possible states 2 | // inquiry: roaming around, "Return"-button is visible 3 | // question: answer closed 4 | // answer: answer open 5 | // standby: in Roam 6 | 7 | // all mutation of state is located here 8 | 9 | export const setStatus = (status) => { 10 | console.log("roamsr is entering state: " + status); 11 | roamsr.state = { ...roamsr.state, status }; 12 | }; 13 | 14 | export const questionState = () => setStatus("question"); 15 | export const answerState = () => setStatus("answer"); 16 | export const inquiryState = () => setStatus("inquiry"); 17 | export const standbyState = () => setStatus("standby"); 18 | 19 | export const setCards = (queue, extraCards) => { 20 | roamsr.state = { ...roamsr.state, queue, extraCards }; 21 | }; 22 | 23 | export const incrementCurrentCardIndex = () => { 24 | roamsr.state.currentIndex++; 25 | }; 26 | 27 | export const addCardToQueue = (card) => { 28 | roamsr.state.queue.push(card); 29 | }; 30 | 31 | export const addExtraCardToQueue = (j) => { 32 | const extraCard = roamsr.state.extraCards[j].shift(); 33 | if (extraCard) roamsr.state.queue.push(extraCard); 34 | }; 35 | 36 | export const setLimitActivation = (activation) => { 37 | roamsr.state = { ...roamsr.state, limits: activation }; 38 | }; 39 | 40 | export const toggleLimitActivation = () => { 41 | roamsr.state.limits = !roamsr.state.limits; 42 | }; 43 | 44 | export const setCurrentCardIndex = (index) => { 45 | roamsr.state.currentIndex = index; 46 | }; 47 | -------------------------------------------------------------------------------- /src/core/helperFunctions.js: -------------------------------------------------------------------------------- 1 | export const sleep = (m) => { 2 | var t = m ? m : 10; 3 | return new Promise((r) => setTimeout(r, t)); 4 | }; 5 | 6 | // From roam42 based on https://github.com/ai/nanoid#js version 3.1.2 7 | const nanoid = (t = 21) => { 8 | let e = "", 9 | r = crypto.getRandomValues(new Uint8Array(t)); 10 | for (; t--; ) { 11 | let n = 63 & r[t]; 12 | e += n < 36 ? n.toString(36) : n < 62 ? (n - 26).toString(36).toUpperCase() : n < 63 ? "_" : "-"; 13 | } 14 | return e; 15 | }; 16 | 17 | export const createUid = () => { 18 | return nanoid(9); 19 | }; 20 | 21 | export const removeSelector = (selector) => { 22 | document.querySelectorAll(selector).forEach((element) => { 23 | element.remove(); 24 | }); 25 | }; 26 | 27 | export const goToUid = (uid) => { 28 | var baseUrl = "/" + new URL(window.location.href).hash.split("/").slice(0, 3).join("/"); 29 | var url = uid ? baseUrl + "/page/" + uid : baseUrl; 30 | location.assign(url); 31 | }; 32 | 33 | export const dailyPageUIDToCrossBrowserDate = (str) => { 34 | if (!str) return null; 35 | let strSplit = str.split("-"); 36 | // if we use "null" as input for a new Date, we get the lowest possible Date (1970...) 37 | if (strSplit.length != 3) return null; 38 | try { 39 | let date = new Date(strSplit[2] + "-" + strSplit[0] + "-" + strSplit[1]); 40 | date.setTime(date.getTime() + date.getTimezoneOffset() * 60 * 1000); 41 | return date; 42 | } catch (e) { 43 | console.log(e); 44 | } 45 | }; 46 | 47 | export const getRoamDate = (date) => { 48 | if (!date || date == 0) date = new Date(); 49 | 50 | var months = [ 51 | "January", 52 | "February", 53 | "March", 54 | "April", 55 | "May", 56 | "June", 57 | "July", 58 | "August", 59 | "September", 60 | "October", 61 | "November", 62 | "December", 63 | ]; 64 | var suffix = ((d) => { 65 | if (d > 3 && d < 21) return "th"; 66 | switch (d % 10) { 67 | case 1: 68 | return "st"; 69 | case 2: 70 | return "nd"; 71 | case 3: 72 | return "rd"; 73 | default: 74 | return "th"; 75 | } 76 | })(date.getDate()); 77 | 78 | var pad = (n) => n.toString().padStart(2, "0"); 79 | 80 | var roamDate = { 81 | title: months[date.getMonth()] + " " + date.getDate() + suffix + ", " + date.getFullYear(), 82 | uid: pad(date.getMonth() + 1) + "-" + pad(date.getDate()) + "-" + date.getFullYear(), 83 | }; 84 | 85 | return roamDate; 86 | }; 87 | 88 | export const getIntervalHumanReadable = (n) => { 89 | if (n == 0) return "<10 min"; 90 | else if (n > 0 && n <= 15) return n + " d"; 91 | else if (n <= 30) return (n / 7).toFixed(1) + " w"; 92 | else if (n <= 365) return (n / 30).toFixed(1) + " m"; 93 | }; 94 | -------------------------------------------------------------------------------- /src/core/keybindings.js: -------------------------------------------------------------------------------- 1 | import { flagCard, responseHandler, stepToNext } from "./mainFunctions"; 2 | import { endSession, getCurrentCard } from "./sessions"; 3 | import { showAnswerAndCloze } from "../ui/styles"; 4 | import { addResponseButtons } from "../ui/uiElements"; 5 | 6 | const questionAndAnswerCodeMap = { 7 | KeyF: flagCard, 8 | KeyS: (e) => { 9 | if (!e.ctrlKey && !e.shiftKey) stepToNext(); 10 | }, 11 | KeyD: (e) => { 12 | // TODO: this does not work in any version because alt+d is opening the daily page 13 | if (e.altKey) endSession(); 14 | }, 15 | }; 16 | 17 | const questionCodeMap = { 18 | Space: () => { 19 | showAnswerAndCloze(); 20 | addResponseButtons(); 21 | }, 22 | ...questionAndAnswerCodeMap, 23 | }; 24 | 25 | const handleNthResponse = async (n, responses) => { 26 | console.log("Handling response: " + n); 27 | // TODO: we shouldnt need to check for having responses because we are in the answer-state 28 | if (n >= 0) { 29 | const res = responses[n]; 30 | await responseHandler(getCurrentCard(), res.interval, res.signal.toString()); 31 | await stepToNext(); 32 | } 33 | }; 34 | 35 | const digits = [1, 2, 3, 4, 5, 6, 7, 8, 9]; 36 | const handleDigitResponse = (digit) => { 37 | var responses = getCurrentCard().algorithm(getCurrentCard().history); 38 | var n = Math.min(digit - 1, responses.length - 1); 39 | handleNthResponse(n, responses); 40 | }; 41 | const digitsCodeMap = Object.fromEntries(digits.map((digit) => ["Digit" + digit, () => handleDigitResponse(digit)])); 42 | 43 | const letters = ["KeyH", "KeyJ", "KeyK", "KeyL"]; 44 | const handleLetterResponse = (letter) => { 45 | var responses = getCurrentCard().algorithm(getCurrentCard().history); 46 | var n = Math.min(letters.indexOf(letter), responses.length - 1); 47 | handleNthResponse(n, responses); 48 | }; 49 | const lettersCodeMap = Object.fromEntries(letters.map((letter) => [letter, () => handleLetterResponse(letter)])); 50 | 51 | const answerCodeMap = { 52 | ...digitsCodeMap, 53 | ...lettersCodeMap, 54 | ...questionAndAnswerCodeMap, 55 | Space: () => handleDigitResponse(3), 56 | }; 57 | 58 | const statusCodeMaps = { question: questionCodeMap, answer: answerCodeMap }; 59 | 60 | // note: changing these requires reloading Roam because of the keylistener 61 | export const processKey = (e) => { 62 | // if we are editing, dont process 63 | if (document.activeElement.type === "textarea" || document.activeElement.type === "input") return; 64 | 65 | // this is not be necessary anymore because we have status 66 | // !location.href.includes(getCurrentCard().uid) 67 | 68 | const statusCodeMap = statusCodeMaps[roamsr.state.status]; 69 | if (statusCodeMap) { 70 | const func = statusCodeMap[e.code]; 71 | if (func) { 72 | func(e); 73 | } 74 | } 75 | }; 76 | 77 | export const processKeyAlways = (e) => { 78 | // TODO: Alt+enter 79 | }; 80 | 81 | export const addKeyListener = () => { 82 | document.addEventListener("keydown", processKey); 83 | }; 84 | 85 | export const removeKeyListener = () => { 86 | document.removeEventListener("keydown", processKey); 87 | }; 88 | -------------------------------------------------------------------------------- /src/core/sessions.js: -------------------------------------------------------------------------------- 1 | import { removeSelector, goToUid, sleep } from "./helperFunctions"; 2 | import { addKeyListener, removeKeyListener } from "./keybindings"; 3 | import { loadCards } from "./loadingCards"; 4 | import { goToCurrentCard } from "./mainFunctions"; 5 | import { setCards, setCurrentCardIndex, setLimitActivation, standbyState } from "./state"; 6 | import { setCustomStyle, removeCustomStyle, removeRoamsrMainviewCSS } from "../ui/styles"; 7 | import { addWidget, removeContainer, removeReturnButton, setLoading, updateCounters } from "../ui/uiElements"; 8 | import { hideLeftSidebar, showLeftSidebar } from "../ui/hiding-sidebar"; 9 | 10 | const defaultSettings = { 11 | closeLeftSideBar: true, 12 | startWithNewCards: true, 13 | mainTags: ["sr"], 14 | flagTag: "f", 15 | clozeStyle: "highlight", // "highlight" or "block-ref" 16 | defaultDeck: { 17 | algorithm: null, 18 | config: {}, 19 | newCardLimit: 20, 20 | reviewLimit: 100, 21 | }, 22 | customDecks: [], 23 | }; 24 | 25 | export const loadSettings = () => { 26 | roamsr.settings = Object.assign(defaultSettings, window.roamsrUserSettings); 27 | if (roamsr.settings.mainTag) { 28 | roamsr.settings.mainTags = [roamsr.settings.mainTag]; 29 | } 30 | }; 31 | 32 | export const loadState = async (i) => { 33 | setLimitActivation(true); 34 | setCurrentCardIndex(i); 35 | const { cards, extraCards } = await loadCards(roamsr.state.limits, roamsr.settings, window.roamAlphaAPI.q); 36 | setCards(cards, extraCards); 37 | updateCounters(roamsr.state); 38 | return; 39 | }; 40 | 41 | export const getCurrentCard = () => { 42 | var card = roamsr.state.queue[roamsr.state.currentIndex]; 43 | return card ? card : {}; 44 | }; 45 | 46 | export const startSession = async () => { 47 | if (roamsr.state) { 48 | loadSettings(); 49 | await loadState(0); 50 | 51 | if (roamsr.state.queue.length > 0) { 52 | console.log("Starting session."); 53 | 54 | setCustomStyle(); 55 | 56 | if (roamsr.settings.closeLeftSideBar) { 57 | hideLeftSidebar(); 58 | } 59 | 60 | console.log("The queue: "); 61 | console.log(roamsr.state.queue); 62 | 63 | await goToCurrentCard(); 64 | 65 | addKeyListener(); 66 | 67 | // Change widget 68 | var widget = document.querySelector(".roamsr-widget"); 69 | widget.innerHTML = 70 | "
END SESSION
"; 71 | widget.firstChild.onclick = endSession; 72 | } 73 | } 74 | }; 75 | 76 | export const endSession = async () => { 77 | window.onhashchange = () => {}; 78 | console.log("Ending session."); 79 | 80 | standbyState(); 81 | 82 | setLoading(true); 83 | 84 | // Remove elements 85 | var doStuff = async () => { 86 | removeContainer(); 87 | removeReturnButton(); 88 | removeCustomStyle(); 89 | removeRoamsrMainviewCSS(); 90 | removeKeyListener(); 91 | await showLeftSidebar(); 92 | goToUid(); 93 | }; 94 | 95 | await doStuff(); 96 | await sleep(200); 97 | await doStuff(); // ... again to make sure 98 | await sleep(300); 99 | 100 | // Reload state 101 | await loadState(-1); 102 | }; 103 | -------------------------------------------------------------------------------- /src/debug.js: -------------------------------------------------------------------------------- 1 | import { ankiScheduler } from "./schedulers/ankiScheduler"; 2 | import { 3 | sleep, 4 | createUid, 5 | removeSelector, 6 | goToUid, 7 | dailyPageUIDToCrossBrowserDate, 8 | getRoamDate, 9 | getIntervalHumanReadable, 10 | } from "./core/helperFunctions"; 11 | import { processKey, processKeyAlways, addKeyListener, removeKeyListener } from "./core/keybindings"; 12 | import { loadCards } from "./core/loadingCards"; 13 | import { scheduleCardIn, responseHandler, flagCard, stepToNext, goToCurrentCard } from "./core/mainFunctions"; 14 | import { loadSettings, loadState, getCurrentCard, startSession, endSession } from "./core/sessions"; 15 | import { buttonClickHandler } from "./ui/srButton"; 16 | 17 | // need this to force execution 18 | import { init } from "./main"; 19 | 20 | import { addBasicStyles, setCustomStyle, showAnswerAndCloze } from "./ui/styles"; 21 | import { 22 | getCounter, 23 | updateCounters, 24 | addContainer, 25 | removeContainer, 26 | clearAndGetResponseArea, 27 | addShowAnswerButton, 28 | addResponseButtons, 29 | addReturnButton, 30 | removeReturnButton, 31 | createWidget, 32 | addWidget, 33 | setLoading, 34 | } from "./ui/uiElements"; 35 | 36 | export const exposeInternalAPI = () => { 37 | /* ====== SCHEDULERS / ALGORITHMS ====== */ 38 | 39 | roamsr.ankiScheduler = ankiScheduler; 40 | 41 | /* ====== HELPER FUNCTIONS ====== */ 42 | 43 | roamsr.sleep = sleep; 44 | 45 | roamsr.createUid = createUid; 46 | 47 | roamsr.removeSelector = removeSelector; 48 | 49 | roamsr.goToUid = goToUid; 50 | 51 | roamsr.dailyPageUIDToCrossBrowserDate = dailyPageUIDToCrossBrowserDate; 52 | 53 | roamsr.getRoamDate = getRoamDate; 54 | 55 | roamsr.getIntervalHumanReadable = getIntervalHumanReadable; 56 | 57 | /* ====== LOADING CARDS ====== */ 58 | 59 | roamsr.loadCards = () => loadCards(roamsr.state.limits, roamsr.settings, window.roamAlphaAPI.q); 60 | 61 | /* ====== STYLES ====== */ 62 | 63 | roamsr.addBasicStyles = addBasicStyles; 64 | 65 | roamsr.setCustomStyle = setCustomStyle; 66 | 67 | roamsr.showAnswerAndCloze = showAnswerAndCloze; 68 | 69 | /* ====== MAIN FUNCTIONS ====== */ 70 | 71 | roamsr.scheduleCardIn = scheduleCardIn; 72 | 73 | roamsr.responseHandler = responseHandler; 74 | 75 | roamsr.flagCard = flagCard; 76 | 77 | roamsr.stepToNext = stepToNext; 78 | 79 | roamsr.goToCurrentCard = goToCurrentCard; 80 | 81 | /* ====== SESSIONS ====== */ 82 | 83 | roamsr.loadSettings = loadSettings; 84 | 85 | roamsr.loadState = loadState; 86 | 87 | roamsr.getCurrentCard = getCurrentCard; 88 | 89 | roamsr.startSession = startSession; 90 | 91 | roamsr.endSession = endSession; 92 | 93 | /* ====== UI ELEMENTS ====== */ 94 | 95 | // COMMON 96 | roamsr.getCounter = (deck) => getCounter(roamsr.state, deck); 97 | 98 | roamsr.updateCounters = () => updateCounters(roamsr.state); 99 | 100 | // CONTAINER 101 | roamsr.addContainer = () => addContainer(roamsr.state); 102 | 103 | roamsr.removeContainer = removeContainer; 104 | 105 | roamsr.clearAndGetResponseArea = clearAndGetResponseArea; 106 | 107 | roamsr.addShowAnswerButton = addShowAnswerButton; 108 | 109 | roamsr.addResponseButtons = addResponseButtons; 110 | 111 | // RETURN BUTTON 112 | roamsr.addReturnButton = addReturnButton; 113 | 114 | roamsr.removeReturnButton = removeReturnButton; 115 | 116 | // SIDEBAR WIDGET 117 | roamsr.createWidget = createWidget; 118 | 119 | roamsr.addWidget = addWidget; 120 | 121 | /* ====== KEYBINDINGS ====== */ 122 | roamsr.processKey = processKey; 123 | 124 | roamsr.processKeyAlways = processKeyAlways; 125 | 126 | roamsr.addKeyListener = addKeyListener; 127 | 128 | roamsr.removeKeyListener = removeKeyListener; 129 | 130 | /* ====== {{sr}} BUTTON ====== */ 131 | roamsr.buttonClickHandler = buttonClickHandler; 132 | 133 | roamsr.setLoading = setLoading; 134 | }; 135 | 136 | exposeInternalAPI(); 137 | -------------------------------------------------------------------------------- /src/ui/styles.js: -------------------------------------------------------------------------------- 1 | import { standbyState, answerState } from "../core/state"; 2 | 3 | const basicCSS = ` 4 | .roamsr-widget__review-button { 5 | color: #5C7080 !important; 6 | } 7 | 8 | .roamsr-widget__review-button:hover { 9 | color: #F5F8FA !important; 10 | } 11 | 12 | .roamsr-return-button-container { 13 | z-index: 100000; 14 | margin: 5px 0px 5px 45px; 15 | } 16 | 17 | .roamsr-wrapper { 18 | pointer-events: none; 19 | position: relative; 20 | bottom: 180px; 21 | justify-content: center; 22 | } 23 | 24 | .roamsr-container { 25 | width: 100%; 26 | max-width: 600px; 27 | justify-content: center; 28 | align-items: center; 29 | padding: 5px 20px; 30 | } 31 | 32 | .roamsr-button { 33 | z-index: 10000; 34 | pointer-events: all; 35 | } 36 | 37 | .roamsr-response-area { 38 | flex-wrap: wrap; 39 | justify-content: center; 40 | margin-bottom: 15px; 41 | } 42 | 43 | .roamsr-flag-button-container { 44 | width: 100%; 45 | } 46 | 47 | .loader { 48 | border: 4px solid #f3f3f3; 49 | border-top: 4px solid #3498db; 50 | border-radius: 50%; 51 | width: 30px; 52 | height: 30px; 53 | -webkit-animation: spin 2s linear infinite; /* Safari */ 54 | animation: spin 2s linear infinite; 55 | } 56 | 57 | /* Safari */ 58 | @-webkit-keyframes spin { 59 | 0% { -webkit-transform: rotate(0deg); } 60 | 100% { -webkit-transform: rotate(360deg); } 61 | } 62 | 63 | @keyframes spin { 64 | 0% { transform: rotate(0deg); } 65 | 100% { transform: rotate(360deg); } 66 | } 67 | `; 68 | 69 | export const addBasicStyles = () => { 70 | var basicStyles = Object.assign(document.createElement("style"), { 71 | id: "roamsr-css-basic", 72 | innerHTML: basicCSS, 73 | }); 74 | document.getElementsByTagName("head")[0].appendChild(basicStyles); 75 | }; 76 | 77 | const roamsrCustomStyleCSSID = "roamsr-css-custom"; 78 | 79 | export const removeCustomStyle = () => { 80 | const element = document.getElementById(roamsrCustomStyleCSSID); 81 | if (element) element.remove(); 82 | }; 83 | 84 | export const setCustomStyle = () => { 85 | removeCustomStyle(); 86 | 87 | // Query new style 88 | const styleQuery = window.roamAlphaAPI.q( 89 | `[:find (pull ?style [:block/string]) :where [?roamsr :node/title "roam\/sr"] [?roamsr :block/children ?css] [?css :block/refs ?roamcss] [?roamcss :node/title "roam\/css"] [?css :block/children ?style]]` 90 | ); 91 | 92 | // this is necessary because having three ` breaks Roam-code-blocks 93 | // other solutions have lead to the minifier appending three ` 94 | const replaceStrPartial = "``"; 95 | 96 | if (styleQuery && styleQuery.length != 0) { 97 | const customStyle = styleQuery[0][0].string 98 | .replace("`" + replaceStrPartial + "css", "") 99 | .replace("`" + replaceStrPartial, ""); 100 | 101 | const roamsrCSS = Object.assign(document.createElement("style"), { 102 | id: roamsrCustomStyleCSSID, 103 | innerHTML: customStyle, 104 | }); 105 | 106 | document.getElementsByTagName("head")[0].appendChild(roamsrCSS); 107 | } 108 | }; 109 | 110 | const roamsrMainviewCSSID = "roamsr-css-mainview"; 111 | 112 | // we use to nearly identical functions here because they have different intentions 113 | // as expressed in the state-set call 114 | export const removeRoamsrMainviewCSS = () => { 115 | const element = document.getElementById(roamsrMainviewCSSID); 116 | if (element) element.remove(); 117 | }; 118 | 119 | export const showAnswerAndCloze = () => { 120 | // change to standby first to prevent unwanted key processing 121 | standbyState(); 122 | removeRoamsrMainviewCSS(); 123 | answerState(); 124 | }; 125 | 126 | export const hideAnswerAndCloze = () => { 127 | removeRoamsrMainviewCSS(); 128 | 129 | const clozeStyle = roamsr.settings.clozeStyle || "highlight"; 130 | const style = ` 131 | .roam-article .rm-reference-main, 132 | .roam-article .rm-block-children 133 | { 134 | visibility: hidden; 135 | } 136 | 137 | .roam-article .rm-${clozeStyle} { 138 | background-color: #cccccc; 139 | color: #cccccc; 140 | }`; 141 | 142 | const basicStyles = Object.assign(document.createElement("style"), { 143 | id: roamsrMainviewCSSID, 144 | innerHTML: style, 145 | }); 146 | document.getElementsByTagName("head")[0].appendChild(basicStyles); 147 | }; 148 | -------------------------------------------------------------------------------- /src/misc/sample-settings.js: -------------------------------------------------------------------------------- 1 | window.roamsrUserSettings = {}; 2 | 3 | /* ====== MAIN SETTINGS ====== */ 4 | 5 | // If we start with new or old cards 6 | // Type: Boolean 7 | roamsrUserSettings.startWithNewCards = true; 8 | 9 | // If the left sidebar should be closed when starting a session 10 | // Type: Boolean 11 | roamsrUserSettings.closeLeftSideBar = true; 12 | 13 | // Main tags used to add cards. 14 | // Type: Array of Strings 15 | roamsrUserSettings.mainTags = ["sr"]; 16 | 17 | // Tag used to flag cards. 18 | // Cardblocks with this tag won't get shown in review (meant for rewrite) 19 | // Type: String 20 | roamsrUserSettings.flagTag = "f"; 21 | 22 | // Cloze deletion style 23 | // Type: String 24 | // Valid values: "highlight" (^^cloze^^), 25 | // "block-ref" (using "Create as block below.") 26 | roamsrUserSettings.clozeStyle = "highlight"; 27 | 28 | /* ====== DEFAULT DECK ====== */ 29 | roamsrUserSettings.defaultDeck = {}; 30 | 31 | // Daily new card and review card limits 32 | // Type: Int 33 | // Valid values: positive integers 34 | roamsrUserSettings.defaultDeck.newCardLimit = 20; 35 | roamsrUserSettings.defaultDeck.reviewLimit = 50; 36 | 37 | // Default scheduler 38 | // Type: String or function (see custom algorithms at the end) 39 | // Valid values: "anki", function (without the `()`) 40 | roamsrUserSettings.defaultDeck.scheduler = "anki"; 41 | 42 | // Default scheduler config 43 | // For more info on Anki, see: 44 | // https://faqs.ankiweb.net/what-spaced-repetition-algorithm.html 45 | roamsrUserSettings.defaultDeck.config = { 46 | defaultFactor: 2.5, 47 | firstFewIntervals: [1, 6], 48 | factorModifier: 0.15, 49 | easeBonus: 1.3, 50 | hardFactor: 1.2, 51 | minFactor: 1.3, 52 | jitterPercentage: 0.05, 53 | maxInterval: 50 * 365, 54 | responseTexts: ["Again.", "Hard.", "Good.", "Easy."], 55 | }; 56 | 57 | /* ====== CUSTOM DECKS ====== */ 58 | // Don't forget to add your decks to the Array at the end of this section 59 | 60 | /* MY DECK (example) */ 61 | var myDeck = {}; 62 | // Deck's main tag (if a card references this page, it's in this deck) 63 | // If a card is in multiple decks, the most recent one is picked 64 | // Generally, try to have only one deck per card 65 | // Type: String 66 | myDeck.tag = "mydeck"; 67 | 68 | // Deck's new card and review card limits 69 | // Gets enforced on top of default's decks limit 70 | // Type: positive integer 71 | myDeck.newCardLimit = 10; 72 | myDeck.reviewLimit = 30; 73 | 74 | // Custom scheduler 75 | // Should be a function that returns a function, 76 | // which takes the history of a card and the current signal as input 77 | // and outputs the set of responses 78 | // See: https://roamresearch.com/#/app/roam-depot-developers/page/uQSCwVKx0 79 | // Type: function (without the `()`, i.e. not the return value, but the function itself) 80 | myDeck.scheduler = (config) => { 81 | // Configure your scheduler using config... 82 | 83 | var algorithm = (history, signal) => { 84 | // The argument `history` has the format: 85 | // [ { date: "MM-DD-YYYY", signal: |yoursignal| }, ... ] (it is ordered by date) 86 | // So its an Array of objects with the date and signal of each review 87 | 88 | // The argument `signal` holds the current signal 89 | 90 | let lastInterval = new Date() - new Date(history[history.length - 1].date); 91 | let response1 = { 92 | signal: 0, // Arbitrary signal, Type: String or Int 93 | interval: lastInterval.getDate(), // Next interval, Type: Int 94 | responseText: "Same as last time.", // Text on the button, Type: String 95 | }; 96 | 97 | // Return value should be an array of responses 98 | // Each response must contain: 99 | // * `signal` (completely your choice), Type: String or Int; 100 | // * `interval` (when to schedule in), Type: Int 101 | // * `responseText` (text to render on the button), Type: String 102 | return [response1 /* ... other responses */]; 103 | }; 104 | return algorithm; 105 | }; 106 | 107 | // Whatever parameters you want your scheduler to have 108 | // Type: Object 109 | myDeck.config = {}; 110 | 111 | /* ARRAY OF CUSTOM DECKS */ 112 | // Type: Array of Objects 113 | roamsrUserSettings.defaultDeck.customDecks = [myDeck /* ... other decks */]; 114 | 115 | console.log("🗃️ Loaded roam/sr settings."); 116 | -------------------------------------------------------------------------------- /src/core/mainFunctions.js: -------------------------------------------------------------------------------- 1 | import { getRoamDate, sleep, createUid, goToUid } from "./helperFunctions"; 2 | import { getCurrentCard, endSession } from "./sessions"; 3 | import { 4 | addCardToQueue, 5 | addExtraCardToQueue, 6 | incrementCurrentCardIndex, 7 | inquiryState, 8 | questionState, 9 | standbyState, 10 | } from "./state"; 11 | import { hideAnswerAndCloze, removeRoamsrMainviewCSS } from "../ui/styles"; 12 | import { 13 | updateCounters, 14 | removeReturnButton, 15 | addContainer, 16 | addShowAnswerButton, 17 | removeContainer, 18 | addReturnButton, 19 | } from "../ui/uiElements"; 20 | 21 | export const scheduleCardIn = async (card, interval) => { 22 | var nextDate = new Date(); 23 | nextDate.setDate(nextDate.getDate() + interval); 24 | 25 | var nextRoamDate = getRoamDate(nextDate); 26 | 27 | // Create daily note if it doesn't exist yet 28 | await window.roamAlphaAPI.createPage({ 29 | page: { 30 | title: nextRoamDate.title, 31 | }, 32 | }); 33 | 34 | await sleep(); 35 | 36 | // Query for the [[roam/sr/review]] block 37 | var queryReviewBlock = window.roamAlphaAPI.q( 38 | '[:find (pull ?reviewBlock [:block/uid]) :in $ ?dailyNoteUID :where [?reviewBlock :block/refs ?reviewPage] [?reviewPage :node/title "roam/sr/review"] [?dailyNote :block/children ?reviewBlock] [?dailyNote :block/uid ?dailyNoteUID]]', 39 | nextRoamDate.uid 40 | ); 41 | 42 | // Check if it's there; if not, create it 43 | var topLevelUid; 44 | if (queryReviewBlock.length == 0) { 45 | topLevelUid = createUid(); 46 | await window.roamAlphaAPI.createBlock({ 47 | location: { 48 | "parent-uid": nextRoamDate.uid, 49 | order: 0, 50 | }, 51 | block: { 52 | string: "[[roam/sr/review]]", 53 | uid: topLevelUid, 54 | }, 55 | }); 56 | await sleep(); 57 | } else { 58 | topLevelUid = queryReviewBlock[0][0].uid; 59 | } 60 | 61 | // Generate the block 62 | var block = { 63 | uid: createUid(), 64 | string: "((" + card.uid + "))", 65 | }; 66 | // Finally, schedule the card 67 | await window.roamAlphaAPI.createBlock({ 68 | location: { 69 | "parent-uid": topLevelUid, 70 | order: 0, 71 | }, 72 | block: block, 73 | }); 74 | await sleep(); 75 | 76 | return { 77 | date: nextRoamDate.uid, 78 | signal: null, 79 | uid: block.uid, 80 | string: block.string, 81 | }; 82 | }; 83 | 84 | export const responseHandler = async (card, interval, signal) => { 85 | console.log("Signal: " + signal + ", Interval: " + interval); 86 | var hist = card.history; 87 | 88 | // If new card, make it look like it was scheduled for today 89 | if (hist.length == 0 || (hist[hist.length - 1] && hist[hist.length - 1].date !== new Date())) { 90 | var last = hist.pop(); 91 | if (last) { 92 | await window.roamAlphaAPI.deleteBlock({ 93 | block: { 94 | uid: last.uid, 95 | }, 96 | }); 97 | } 98 | var todayReviewBlock = await scheduleCardIn(card, 0); 99 | hist.push(todayReviewBlock); 100 | } 101 | 102 | // Record response 103 | var last = hist.pop(); 104 | last.string = last.string + " #[[r/" + signal + "]]"; 105 | last.signal = signal; 106 | await window.roamAlphaAPI.updateBlock({ 107 | block: { 108 | uid: last.uid, 109 | string: last.string, 110 | }, 111 | }); 112 | hist.push(last); 113 | 114 | // Schedule card to future 115 | var nextReview = await scheduleCardIn(card, interval); 116 | hist.push(nextReview); 117 | 118 | // If it's scheduled for today, add it to the end of the queue 119 | if (interval == 0) { 120 | var newCard = card; 121 | newCard.history = hist; 122 | newCard.isNew = false; 123 | addCardToQueue(newCard); 124 | } 125 | }; 126 | 127 | export const flagCard = async () => { 128 | const card = getCurrentCard(); 129 | 130 | await window.roamAlphaAPI.updateBlock({ 131 | block: { 132 | uid: card.uid, 133 | string: card.string + " #" + roamsr.settings.flagTag, 134 | }, 135 | }); 136 | 137 | const j = getCurrentCard().isNew ? 0 : 1; 138 | 139 | addExtraCardToQueue(j); 140 | 141 | await stepToNext(); 142 | }; 143 | 144 | export const stepToNext = async () => { 145 | if (roamsr.state.currentIndex + 1 >= roamsr.state.queue.length) { 146 | endSession(); 147 | } else { 148 | incrementCurrentCardIndex(); 149 | goToCurrentCard(); 150 | } 151 | updateCounters(roamsr.state); 152 | }; 153 | 154 | export const goToCurrentCard = async () => { 155 | // change to standby first to prevent unwanted key processing 156 | standbyState(); 157 | 158 | window.onhashchange = () => {}; 159 | hideAnswerAndCloze(); 160 | removeReturnButton(); 161 | 162 | var doStuff = async () => { 163 | goToUid(getCurrentCard().uid); 164 | await sleep(50); 165 | addContainer(roamsr.state); 166 | addShowAnswerButton(); 167 | }; 168 | 169 | await doStuff(); 170 | questionState(); 171 | await sleep(200); 172 | await doStuff(); 173 | 174 | window.onhashchange = () => { 175 | inquiryState(); 176 | removeContainer(); 177 | addReturnButton(); 178 | removeRoamsrMainviewCSS(); 179 | window.onhashchange = () => {}; 180 | }; 181 | }; 182 | -------------------------------------------------------------------------------- /src/schedulers/ankiScheduler.js: -------------------------------------------------------------------------------- 1 | export const defaultConfig = { 2 | defaultFactor: 2.5, 3 | firstFewIntervals: [1, 6], 4 | factorModifier: 0.15, 5 | easeBonus: 1.3, 6 | hardFactor: 1.2, 7 | minFactor: 1.3, 8 | jitterPercentage: 0.05, 9 | maxInterval: 50 * 365, 10 | responseTexts: ["Again.", "Hard.", "Good.", "Easy."], 11 | }; 12 | 13 | const getLastFail = (history) => (history ? history.map((review) => review.signal).lastIndexOf("1") : 0); 14 | 15 | const isLearningPhase = (config, history) => history.length == 0 || history.length <= config.firstFewIntervals.length; 16 | 17 | const getLearningPhaseResponses = (config, history) => { 18 | return [ 19 | { 20 | responseText: config.responseTexts[0], 21 | signal: 1, 22 | interval: 0, 23 | }, 24 | { 25 | responseText: config.responseTexts[2], 26 | signal: 3, 27 | interval: config.firstFewIntervals[history ? Math.max(history.length - 1, 0) : 0], 28 | }, 29 | ]; 30 | }; 31 | 32 | // TODO: this might be a problem because its not "totally" accurate 33 | // https://swizec.com/blog/a-day-is-not-606024-seconds-long 34 | const dayInMiliseconds = 1000 * 60 * 60 * 24; 35 | 36 | const getDelay = (history, prevInterval) => { 37 | if (history && history.length > 1) { 38 | const milisecondsSincePenultimateReview = history[history.length - 1].date - history[history.length - 2].date; 39 | 40 | return Math.max(milisecondsSincePenultimateReview / dayInMiliseconds - prevInterval, 0); 41 | } else return 0; 42 | }; 43 | 44 | const addJitter = (config, interval) => { 45 | const jitter = interval * config.jitterPercentage; 46 | return interval + (-jitter + Math.random() * jitter); 47 | }; 48 | 49 | const calcNewFactor = (config, prevFactor, signal) => { 50 | switch (signal) { 51 | case "1": 52 | return prevFactor - 0.2; 53 | case "2": 54 | return prevFactor - config.factorModifier; 55 | case "3": 56 | return prevFactor; 57 | case "4": 58 | return prevFactor + config.factorModifier; 59 | default: 60 | return prevFactor; 61 | } 62 | }; 63 | 64 | const calcNewInterval = (config, prevFactor, prevInterval, delay, signal) => { 65 | let newInterval; 66 | 67 | switch (signal) { 68 | case "1": 69 | newInterval = 0; 70 | break; 71 | 72 | case "2": 73 | newInterval = prevInterval * config.hardFactor; 74 | break; 75 | 76 | case "3": 77 | newInterval = (prevInterval + delay / 2) * prevFactor; 78 | break; 79 | 80 | case "4": 81 | newInterval = (prevInterval + delay) * prevFactor * config.easeBonus; 82 | break; 83 | 84 | default: 85 | newInterval = prevInterval * prevFactor; 86 | break; 87 | } 88 | 89 | return Math.min(newInterval, config.maxInterval); 90 | }; 91 | 92 | export const calcNewFactorAndInterval = (config, prevFactor, prevInterval, delay, signal) => { 93 | return [calcNewFactor(config, prevFactor, signal), calcNewInterval(config, prevFactor, prevInterval, delay, signal)]; 94 | }; 95 | 96 | // to get the last factor and interval, we go through the (signal-)history 97 | // and simulate each decision to arrive at each intermediate factor and interval 98 | const calcLastFactorAndInterval = (config, history) => { 99 | if (!history || history.length <= config.firstFewIntervals.length) { 100 | return [config.defaultFactor, config.firstFewIntervals[config.firstFewIntervals.length - 1]]; 101 | } else { 102 | const [prevFactor, prevInterval] = calcLastFactorAndInterval(config, history.slice(0, -1)); 103 | return calcNewFactorAndInterval( 104 | config, 105 | prevFactor, 106 | prevInterval, 107 | getDelay(history, prevInterval), 108 | history[history.length - 1].signal 109 | ); 110 | } 111 | }; 112 | 113 | const getRetainingPhaseResponse = (config, finalFactor, finalInterval, signal, history) => { 114 | return { 115 | responseText: config.responseTexts[parseInt(signal) - 1], 116 | signal: signal, 117 | interval: Math.floor( 118 | addJitter(config, calcNewInterval(config, finalFactor, finalInterval, getDelay(history, finalInterval), signal)) 119 | ), 120 | }; 121 | }; 122 | 123 | const getRetainingPhaseResponses = (config, history) => { 124 | const [finalFactor, finalInterval] = calcLastFactorAndInterval(config, history.slice(0, -1)); 125 | 126 | return [ 127 | getRetainingPhaseResponse(config, finalFactor, finalInterval, "1", history), 128 | getRetainingPhaseResponse(config, finalFactor, finalInterval, "2", history), 129 | getRetainingPhaseResponse(config, finalFactor, finalInterval, "3", history), 130 | getRetainingPhaseResponse(config, finalFactor, finalInterval, "4", history), 131 | ]; 132 | }; 133 | 134 | export const ankiScheduler = (userConfig) => { 135 | const config = Object.assign(defaultConfig, userConfig); 136 | 137 | const algorithm = (history) => { 138 | const lastFail = getLastFail(history); 139 | history = history ? (lastFail == -1 ? history : history.slice(lastFail + 1)) : []; 140 | 141 | if (isLearningPhase(config, history)) { 142 | return getLearningPhaseResponses(config, history); 143 | } else { 144 | return getRetainingPhaseResponses(config, history); 145 | } 146 | }; 147 | return algorithm; 148 | }; 149 | -------------------------------------------------------------------------------- /src/ui/uiElements.js: -------------------------------------------------------------------------------- 1 | import { removeSelector, getIntervalHumanReadable } from "../core/helperFunctions"; 2 | import { loadCards } from "../core/loadingCards"; 3 | import { flagCard, stepToNext, responseHandler, goToCurrentCard } from "../core/mainFunctions"; 4 | import { getCurrentCard, startSession } from "../core/sessions"; 5 | import { setCards, toggleLimitActivation } from "../core/state"; 6 | import { showAnswerAndCloze } from "./styles"; 7 | 8 | // COMMON 9 | export const getCounter = (state, deck) => { 10 | // Getting the number of new cards 11 | var cardCount = [0, 0]; 12 | if (state.queue) { 13 | var remainingQueue = state.queue.slice(Math.max(state.currentIndex, 0)); 14 | var filteredQueue = !deck ? remainingQueue : remainingQueue.filter((card) => card.decks.includes(deck)); 15 | cardCount = filteredQueue.reduce( 16 | (a, card) => { 17 | if (card.isNew) a[0]++; 18 | else a[1]++; 19 | return a; 20 | }, 21 | [0, 0] 22 | ); 23 | } 24 | 25 | // Create the element 26 | var counter = Object.assign(document.createElement("div"), { 27 | className: "roamsr-counter", 28 | innerHTML: 29 | `` + 30 | cardCount[0] + 31 | ` ` + 32 | cardCount[1] + 33 | ``, 34 | }); 35 | return counter; 36 | }; 37 | 38 | export const updateCounters = (state) => { 39 | document.querySelectorAll(".roamsr-counter").forEach((counter) => { 40 | counter.innerHTML = getCounter(state).innerHTML; 41 | counter.style.cssText = !state.limits ? "font-style: italic;" : "font-style: inherit;"; 42 | }); 43 | }; 44 | 45 | // CONTAINER 46 | export const addContainer = (state) => { 47 | if (!document.querySelector(".roamsr-container")) { 48 | var wrapper = Object.assign(document.createElement("div"), { 49 | className: "flex-h-box roamsr-wrapper", 50 | }); 51 | var container = Object.assign(document.createElement("div"), { 52 | className: "flex-v-box roamsr-container", 53 | }); 54 | 55 | var flagButtonContainer = Object.assign(document.createElement("div"), { 56 | className: "flex-h-box roamsr-flag-button-container", 57 | }); 58 | var flagButton = Object.assign(document.createElement("button"), { 59 | className: "bp3-button roamsr-button", 60 | innerHTML: "Flag.", 61 | onclick: async () => { 62 | await flagCard(); 63 | stepToNext(); 64 | }, 65 | }); 66 | var skipButton = Object.assign(document.createElement("button"), { 67 | className: "bp3-button roamsr-button", 68 | innerHTML: "Skip.", 69 | onclick: stepToNext, 70 | }); 71 | flagButtonContainer.style.cssText = "justify-content: space-between;"; 72 | flagButtonContainer.append(flagButton, skipButton); 73 | 74 | var responseArea = Object.assign(document.createElement("div"), { 75 | className: "flex-h-box roamsr-container__response-area", 76 | }); 77 | 78 | container.append(getCounter(state), responseArea, flagButtonContainer); 79 | wrapper.append(container); 80 | 81 | var bodyDiv = document.querySelector(".roam-body-main"); 82 | bodyDiv.append(wrapper); 83 | } 84 | }; 85 | 86 | export const removeContainer = () => { 87 | removeSelector(".roamsr-wrapper"); 88 | }; 89 | 90 | export const clearAndGetResponseArea = () => { 91 | var responseArea = document.querySelector(".roamsr-container__response-area"); 92 | if (responseArea) responseArea.innerHTML = ""; 93 | return responseArea; 94 | }; 95 | 96 | export const addShowAnswerButton = () => { 97 | var responseArea = clearAndGetResponseArea(); 98 | 99 | var showAnswerAndClozeButton = Object.assign(document.createElement("button"), { 100 | className: "bp3-button roamsr-container__response-area__show-answer-button roamsr-button", 101 | innerHTML: "Show answer.", 102 | onclick: () => { 103 | showAnswerAndCloze(); 104 | addResponseButtons(); 105 | }, 106 | }); 107 | showAnswerAndClozeButton.style.cssText = "margin: 5px;"; 108 | 109 | responseArea.append(showAnswerAndClozeButton); 110 | }; 111 | 112 | export const addResponseButtons = () => { 113 | var responseArea = clearAndGetResponseArea(); 114 | 115 | // Add new responses 116 | var responses = getCurrentCard().algorithm(getCurrentCard().history); 117 | for (let res of responses) { 118 | var responseButton = Object.assign(document.createElement("button"), { 119 | id: "roamsr-response-" + res.signal, 120 | className: "bp3-button roamsr-container__response-area__response-button roamsr-button", 121 | innerHTML: res.responseText + "" + getIntervalHumanReadable(res.interval) + "", 122 | onclick: async () => { 123 | if (res.interval != 0) { 124 | responseHandler(getCurrentCard(), res.interval, res.signal.toString()); 125 | } else { 126 | await responseHandler(getCurrentCard(), res.interval, res.signal.toString()); 127 | } 128 | stepToNext(); 129 | }, 130 | }); 131 | responseButton.style.cssText = "margin: 5px;"; 132 | responseArea.append(responseButton); 133 | } 134 | }; 135 | 136 | // RETURN BUTTON 137 | export const addReturnButton = () => { 138 | var returnButtonClass = "roamsr-return-button-container"; 139 | if (document.querySelector(returnButtonClass)) return; 140 | 141 | var main = document.querySelector(".roam-main"); 142 | var body = document.querySelector(".roam-body-main"); 143 | var returnButtonContainer = Object.assign(document.createElement("div"), { 144 | className: "flex-h-box " + returnButtonClass, 145 | }); 146 | var returnButton = Object.assign(document.createElement("button"), { 147 | className: "bp3-button bp3-large roamsr-return-button", 148 | innerText: "Return.", 149 | onclick: goToCurrentCard, 150 | }); 151 | returnButtonContainer.append(returnButton); 152 | main.insertBefore(returnButtonContainer, body); 153 | }; 154 | 155 | export const removeReturnButton = () => { 156 | removeSelector(".roamsr-return-button-container"); 157 | }; 158 | 159 | // SIDEBAR WIDGET 160 | const pushBeforeStarredPages = (element) => { 161 | var sidebar = document.querySelector(".roam-sidebar-content"); 162 | var starredPages = document.querySelector(".starred-pages-wrapper"); 163 | 164 | sidebar.insertBefore(element, starredPages); 165 | }; 166 | 167 | export const addDelimiter = () => { 168 | removeSelector(".roamsr-widget-delimiter"); 169 | var delimiter = Object.assign(document.createElement("div"), { 170 | className: "roamsr-widget-delimiter", 171 | }); 172 | delimiter.style.cssText = "flex: 0 0 1px; background-color: rgb(57, 75, 89); margin: 8px 20px;"; 173 | 174 | pushBeforeStarredPages(delimiter); 175 | }; 176 | 177 | const createWidgetContainer = () => { 178 | var widgetContainer = Object.assign(document.createElement("div"), { 179 | className: "log-button flex-h-box roamsr-widget", 180 | }); 181 | widgetContainer.style.cssText = "align-items: center; justify-content: space-around; padding-top: 8px; height: 47px;"; 182 | return widgetContainer; 183 | }; 184 | 185 | const createWidgetContent = () => { 186 | var widgetContent = Object.assign(document.createElement("div"), { 187 | className: "flex-h-box roamsr-widget__content", 188 | }); 189 | widgetContent.style.cssText = "align-items: center; justify-content: space-around; width: 100%;"; 190 | var reviewButton = Object.assign(document.createElement("div"), { 191 | className: "bp3-button bp3-minimal roamsr-widget__review-button", 192 | innerHTML: ` 193 | 194 | 195 | 197 | 198 | REVIEW`, 199 | // ` 200 | onclick: startSession, 201 | }); 202 | reviewButton.style.cssText = "padding: 2px 8px;"; 203 | 204 | var counter = Object.assign(getCounter(roamsr.state), { 205 | className: "bp3-button bp3-minimal roamsr-counter", 206 | onclick: async () => { 207 | toggleLimitActivation(); 208 | const { cards, extraCards } = await loadCards(roamsr.state.limits, roamsr.settings, window.roamAlphaAPI.q); 209 | setCards(cards, extraCards); 210 | updateCounters(roamsr.state); 211 | }, 212 | }); 213 | var counterContainer = Object.assign(document.createElement("div"), { 214 | className: "flex-h-box roamsr-widget__counter", 215 | }); 216 | counterContainer.style.cssText = "justify-content: center; width: 50%"; 217 | counterContainer.append(counter); 218 | 219 | widgetContent.append(reviewButton, counterContainer); 220 | 221 | return widgetContent; 222 | }; 223 | 224 | export const addWidget = () => { 225 | if (!document.querySelector(".roamsr-widget")) { 226 | var widgetContainer = createWidgetContainer(); 227 | var widgetContent = createWidgetContent(); 228 | widgetContainer.append(widgetContent); 229 | 230 | pushBeforeStarredPages(widgetContainer); 231 | } 232 | }; 233 | 234 | const createLoader = () => { 235 | return Object.assign(document.createElement("div"), { 236 | classList: "loader", 237 | }); 238 | }; 239 | 240 | export const setLoading = (loading) => { 241 | var widgetContainer = document.querySelector(".roamsr-widget"); 242 | if (widgetContainer) { 243 | if (loading) { 244 | widgetContainer.innerHTML = createLoader().outerHTML; 245 | } else { 246 | widgetContainer.innerHTML = ""; 247 | widgetContainer.append(createWidgetContent()); 248 | } 249 | } 250 | }; 251 | -------------------------------------------------------------------------------- /src/core/loadingCards.test.js: -------------------------------------------------------------------------------- 1 | import { filterCardsOverLimit, isLastRelevantDeck, isNew } from "./loadingCards"; 2 | 3 | test("isNew", () => { 4 | // card can be ref'ed everywhere and still be new 5 | let res = { 6 | _refs: [{ page: { uid: "01-28-2020" } }, { page: { uid: "test" } }], 7 | }; 8 | expect(isNew(res)).toBe(true); 9 | 10 | // card is only not new if it is ref'ed under a review-parent 11 | res = { 12 | _refs: [ 13 | { 14 | page: { uid: "01-28-2020" }, 15 | _children: [{ refs: [{ title: "roam/sr/review" }] }], 16 | }, 17 | ], 18 | }; 19 | expect(isNew(res)).toBe(false); 20 | }); 21 | 22 | test("isLastRelevantDeck", () => { 23 | let iterationDeckTags = ["deck1", "deck0", "deck-1", "deck2", "default"]; 24 | let cardDecksTags = ["deck2", "deck-1", "deck1"]; 25 | 26 | expect(isLastRelevantDeck("deck2", iterationDeckTags, cardDecksTags)).toBe(true); 27 | expect(isLastRelevantDeck("deck1", iterationDeckTags, cardDecksTags)).toBe(false); 28 | expect(isLastRelevantDeck("deck-1", iterationDeckTags, cardDecksTags)).toBe(false); 29 | 30 | iterationDeckTags = ["deck1", "deck0", "deck-1", "default", "deck2"]; 31 | cardDecksTags = ["deck2", "deck-1", "deck1"]; 32 | 33 | expect(isLastRelevantDeck("deck2", iterationDeckTags, cardDecksTags)).toBe(true); 34 | expect(isLastRelevantDeck("deck1", iterationDeckTags, cardDecksTags)).toBe(false); 35 | expect(isLastRelevantDeck("deck-1", iterationDeckTags, cardDecksTags)).toBe(false); 36 | 37 | iterationDeckTags = ["deck2", "deck1", "deck0", "deck-1", "default"]; 38 | cardDecksTags = ["deck2"]; 39 | 40 | expect(isLastRelevantDeck("deck2", iterationDeckTags, cardDecksTags)).toBe(true); 41 | }); 42 | 43 | test("filterCardsOverLimit: defaultDeck", () => { 44 | const settings = { 45 | defaultDeck: { 46 | algorithm: null, 47 | config: {}, 48 | newCardLimit: 1, 49 | reviewLimit: 1, 50 | }, 51 | customDecks: [], 52 | }; 53 | const cards = [ 54 | { 55 | uid: "uid1", 56 | isNew: true, 57 | decks: [], 58 | }, 59 | { 60 | uid: "uid2", 61 | isNew: true, 62 | decks: [], 63 | }, 64 | { 65 | uid: "uid3", 66 | isNew: false, 67 | decks: [], 68 | }, 69 | ]; 70 | const todayReviewedCards = [{ uid: "NoggQc_vG", isNew: false, decks: [] }]; 71 | 72 | const res = filterCardsOverLimit(settings, cards, todayReviewedCards); 73 | 74 | expect(res.extraCards.length).toBe(2); 75 | 76 | expect(res.extraCards[0].length).toBe(1); 77 | expect(res.extraCards[0][0]).toEqual(cards[0]); 78 | 79 | expect(res.extraCards[1].length).toBe(1); 80 | expect(res.extraCards[1][0]).toEqual(cards[2]); 81 | 82 | expect(res.filteredCards).toEqual([cards[1]]); 83 | }); 84 | 85 | test("filterCardsOverLimit: multiple decks", () => { 86 | const generateCard = (newness, nr, deck) => { 87 | return { 88 | uid: "uid" + nr, 89 | isNew: newness, 90 | decks: deck ? (Array.isArray(deck) ? deck : [deck]) : [], 91 | }; 92 | }; 93 | const generateTrueCard = (nr, deck) => generateCard(true, nr, deck); 94 | const generateFalseCard = (nr, deck) => generateCard(false, nr, deck); 95 | 96 | const filterForDeck = (arr, deck) => arr.filter((v) => v.decks.includes(deck)); 97 | const filterForDefault = (arr) => arr.filter((v) => v.decks.length === 0); 98 | const filterForNew = (arr) => arr.filter((v) => v.isNew); 99 | const filterForOld = (arr) => arr.filter((v) => !v.isNew); 100 | 101 | const settings = { 102 | defaultDeck: { 103 | algorithm: null, 104 | config: {}, 105 | newCardLimit: 4, 106 | reviewLimit: 5, 107 | }, 108 | customDecks: [ 109 | { tag: "deck1", newCardLimit: 7, reviewLimit: 5 }, 110 | { tag: "deck2", newCardLimit: 8, reviewLimit: 11 }, 111 | { tag: "deck3", newCardLimit: 20, reviewLimit: 50 }, 112 | ], 113 | }; 114 | 115 | // default: 5 new cards 116 | // default: 7 review cards 117 | const defaultCards = [1, 2, 3, 4, 5] 118 | .map((nr) => generateTrueCard(nr)) 119 | .concat([6, 7, 8, 9, 10, 11, 12].map((nr) => generateFalseCard(nr))); 120 | 121 | const multiDeckCards = [34, 35, 36, 37, 38] 122 | .map((nr) => generateTrueCard(nr, ["deck2", "deck1"])) 123 | .concat([39, 40, 41, 42, 43].map((nr) => generateFalseCard(nr, ["deck2", "deck1"]))); 124 | 125 | // deck1: 5 (multi) + 4 = 9 new cards 126 | // deck1: 5 (multi) + 5 = 10 review cards 127 | const deck1Cards = [13, 14, 15, 16] 128 | .map((nr) => generateTrueCard(nr, "deck1")) 129 | .concat([17, 18, 19, 20, 21].map((nr) => generateFalseCard(nr, "deck1"))); 130 | 131 | // deck2: 5 (multi) + 5 = 10 new cards 132 | // deck2: 5 (multi) + 7 = 12 review cards 133 | const deck2Cards = [22, 23, 24, 25, 26] 134 | .map((nr) => generateTrueCard(nr, "deck2")) 135 | .concat([27, 28, 29, 30, 31, 32, 33].map((nr) => generateFalseCard(nr, "deck2"))); 136 | 137 | const deck3Cards = [44, 45, 46] 138 | .map((nr) => generateTrueCard(nr, "deck3")) 139 | .concat([47, 48, 49].map((nr) => generateFalseCard(nr, "deck3"))); 140 | 141 | // minus duplicates: 142 | // default: 2 new cards 143 | // default: 1 review card 144 | // deck1: 1 new card + 1 (multi) = 2 new cards 145 | // deck2: 1 review card, 1 new card (multi) 146 | const todayReviewedCards = [ 147 | { uid: "review0", isNew: false, decks: [] }, 148 | { uid: "review1", isNew: true, decks: [] }, 149 | { uid: "review0", isNew: false, decks: [] }, 150 | { uid: "review2", isNew: true, decks: [] }, 151 | { uid: "review0", isNew: false, decks: [] }, 152 | { uid: "review3", isNew: true, decks: ["deck1"] }, 153 | { uid: "review4", isNew: false, decks: ["deck2"] }, 154 | { uid: "review4", isNew: false, decks: ["deck2"] }, 155 | { uid: "review3", isNew: true, decks: ["deck1"] }, 156 | { uid: "review5", isNew: true, decks: ["deck1", "deck2"] }, 157 | { uid: "review6", isNew: false, decks: ["deck1", "deck2"] }, 158 | ]; 159 | 160 | const execute = (cards) => { 161 | const res = filterCardsOverLimit(settings, cards, todayReviewedCards); 162 | 163 | // check that there are no duplicates 164 | expect(new Set(res.filteredCards.map((card) => card.uid)).size).toBe(res.filteredCards.length); 165 | expect(new Set(res.extraCards[0].map((v) => v.uid)).size).toBe(res.extraCards[0].length); 166 | expect(new Set(res.extraCards[1].map((v) => v.uid)).size).toBe(res.extraCards[1].length); 167 | 168 | expect(res.extraCards.length).toBe(2); 169 | 170 | // check if cards are sorted in the correct extraCards position 171 | expect(filterForNew(res.extraCards[0]).length).toBe(res.extraCards[0].length); 172 | expect(filterForOld(res.extraCards[1]).length).toBe(res.extraCards[1].length); 173 | 174 | const defaultResFiltered = filterForDefault(res.filteredCards); 175 | const defaultResFilteredNew = filterForNew(defaultResFiltered); 176 | const defaultResFilteredOld = filterForOld(defaultResFiltered); 177 | const defaultResExtra0 = filterForDefault(res.extraCards[0]); 178 | const defaultResExtra1 = filterForDefault(res.extraCards[1]); 179 | 180 | const deck1ResFiltered = filterForDeck(res.filteredCards, "deck1"); 181 | const deck1ResFilteredNew = filterForNew(deck1ResFiltered); 182 | const deck1ResFilteredOld = filterForOld(deck1ResFiltered); 183 | const deck1ResExtra0 = filterForDeck(res.extraCards[0], "deck1"); 184 | const deck1ResExtra1 = filterForDeck(res.extraCards[1], "deck1"); 185 | 186 | const deck2ResFiltered = filterForDeck(res.filteredCards, "deck2"); 187 | const deck2ResFilteredNew = filterForNew(deck2ResFiltered); 188 | const deck2ResFilteredOld = filterForOld(deck2ResFiltered); 189 | const deck2ResExtra0 = filterForDeck(res.extraCards[0], "deck2"); 190 | const deck2ResExtra1 = filterForDeck(res.extraCards[1], "deck2"); 191 | 192 | const deck3ResFiltered = filterForDeck(res.filteredCards, "deck3"); 193 | const deck3ResFilteredNew = filterForNew(deck3ResFiltered); 194 | const deck3ResFilteredOld = filterForOld(deck3ResFiltered); 195 | const deck3ResExtra0 = filterForDeck(res.extraCards[0], "deck3"); 196 | const deck3ResExtra1 = filterForDeck(res.extraCards[1], "deck3"); 197 | 198 | // new expectations: 199 | 200 | // default: 4 new card limit - 2 new cardTodayReviews = 2 new card limit 201 | // default: 5 new cards are ready 202 | // default: should have 2 new in queue, 3 new in extraCards 203 | expect(defaultResFilteredNew.length).toBe(2); 204 | expect(defaultResExtra0.length).toBe(3); 205 | 206 | // deck1: 7 new card limit - 2 new cardTodayReviews = 5 new card limit 207 | // deck1: 9 new cards are ready 208 | // deck1: should have 5 new in queue, 4 new in extraCards 209 | expect(deck1ResFilteredNew.length).toBe(5); 210 | expect(deck1ResExtra0.length).toBe(4); 211 | 212 | // deck2: 8 new card limit - 2 new cardTodayReviews = 6 new card limit 213 | // deck2: 10 new cards are ready 214 | // deck2: should have 7 new in queue, 3 new in extraCards 215 | expect(deck2ResFilteredNew.length).toBe(7); 216 | expect(deck2ResExtra0.length).toBe(3); 217 | 218 | // deck3: limits are much higher than ready new cards, so should have 3 new in queue, 0 in extraCards 219 | expect(deck3ResFilteredNew.length).toBe(3); 220 | expect(deck3ResExtra0.length).toBe(0); 221 | 222 | // review expectations: 223 | 224 | // default: 5 review limit - 1 old cardTodayReviews = 4 review limit 225 | // default: 7 reviews are ready 226 | // default: should have 4 review in queue, 3 review in extraCards 227 | expect(defaultResFilteredOld.length).toBe(4); 228 | expect(defaultResExtra1.length).toBe(3); 229 | 230 | // deck1: 5 review limit - 1 old cardTodayReviews = 4 review limit 231 | // deck1: 10 reviews are ready 232 | // deck1: should have 4 review in queue, 6 review in extraCards 233 | expect(deck1ResFilteredOld.length).toBe(4); 234 | expect(deck1ResExtra1.length).toBe(6); 235 | 236 | // deck2: 11 review limit - 2 old cardTodayReviews = 9 review limit 237 | // deck2: 12 reviews are ready 238 | // deck2: should have 9 review in queue, 3 review in extraCards 239 | expect(deck2ResFilteredOld.length).toBe(9); 240 | expect(deck2ResExtra1.length).toBe(3); 241 | 242 | // deck3: should have 3 review in queue, 0 in extraCards 243 | expect(deck3ResFilteredOld.length).toBe(3); 244 | expect(deck3ResExtra1.length).toBe(0); 245 | }; 246 | 247 | // the general order of single-deck cards does not matter in the length of the results 248 | // note: the order of the multi-deck cards matters, see the filter function comments 249 | const cards = [...defaultCards, ...deck1Cards, ...multiDeckCards, ...deck3Cards, ...deck2Cards]; 250 | const cards1 = [...deck1Cards, ...multiDeckCards, ...deck3Cards, ...deck2Cards, ...defaultCards]; 251 | const cards2 = [...deck3Cards, ...deck1Cards, ...multiDeckCards, ...deck2Cards, ...defaultCards]; 252 | 253 | execute(cards); 254 | execute(cards1); 255 | execute(cards2); 256 | }); 257 | -------------------------------------------------------------------------------- /src/core/loadingCards.js: -------------------------------------------------------------------------------- 1 | import { ankiScheduler } from "../schedulers/ankiScheduler"; 2 | import { dailyPageUIDToCrossBrowserDate, getRoamDate, sleep } from "./helperFunctions"; 3 | import { setLoading } from "../ui/uiElements"; 4 | 5 | const recurDeck = (part) => { 6 | const result = []; 7 | // decks are tags, so we need to evaluate the included tags 8 | if (part.refs) result.push(...part.refs); 9 | // if this query result has _children, it might be a review-block 10 | // so to get the real tags we need to recur until we find the original 11 | if (part._children && part._children.length > 0) result.push(...recurDeck(part._children[0])); 12 | return result; 13 | }; 14 | 15 | const getDecks = (res, settings) => { 16 | const possibleDecks = recurDeck(res).map((deck) => deck.title); 17 | return possibleDecks.filter((deckTag) => settings.customDecks.map((customDeck) => customDeck.tag).includes(deckTag)); 18 | }; 19 | 20 | const getAlgorithm = (res, settings) => { 21 | const decks = getDecks(res, settings); 22 | 23 | let preferredDeck; 24 | if (decks && decks.length > 0) { 25 | preferredDeck = settings.customDecks.filter((customDeck) => customDeck.tag == decks[decks.length - 1])[0]; 26 | } else preferredDeck = settings.defaultDeck; 27 | 28 | const scheduler = preferredDeck.scheduler || preferredDeck.algorithm; 29 | const config = preferredDeck.config; 30 | 31 | let algorithm; 32 | if (!scheduler || scheduler === "anki") { 33 | algorithm = ankiScheduler(config); 34 | } else algorithm = scheduler(config); 35 | 36 | return algorithm; 37 | }; 38 | 39 | const isReviewBlock = (block) => 40 | // is a child-block 41 | block._children && 42 | // first parent has refs 43 | block._children[0].refs 44 | ? // refs of parent include "roam/sr/review" = parent is a review-parent-block 45 | block._children[0].refs.map((ref2) => ref2.title).includes("roam/sr/review") 46 | : false; 47 | 48 | // first ref is always a r/x-page where x is the repetition count / signal value 49 | // r/x -> x is done via the slice 50 | const extractSignalFromReviewBlock = (block) => (block.refs[0] ? block.refs[0].title.slice(2) : null); 51 | 52 | const reviewBlockToHistoryUnit = (block) => { 53 | return { 54 | date: dailyPageUIDToCrossBrowserDate(block.page.uid), 55 | signal: extractSignalFromReviewBlock(block), 56 | uid: block.uid, 57 | string: block.string, 58 | }; 59 | }; 60 | 61 | const extractHistoryFromQueryResult = (result) => { 62 | // having history means that the card-block is ref'ed by at least one review block 63 | // that can be found nested under the "roam/sr/review"-block / review-parent-block on the respective daily-page 64 | if (result._refs) { 65 | return result._refs 66 | .filter(isReviewBlock) 67 | .map(reviewBlockToHistoryUnit) 68 | .sort((a, b) => a.date - b.date); 69 | } else return []; 70 | }; 71 | 72 | const isDue = (card, dateBasis) => 73 | card.history.length > 0 74 | ? // if one history unit contains no signal and fits the date, the card is due 75 | card.history.some((review) => { 76 | return !review.signal && new Date(review.date) <= dateBasis; 77 | }) 78 | : true; 79 | 80 | const srPageTagsToClause = (tags) => "(or " + tags.map((tag) => `[?srPage :node/title "${tag}"]`).join("\n") + ")"; 81 | 82 | //cards with the flag-tag or the "query"-tag are not permissible 83 | const createQueryForAllPermissibleCards = (settings) => `[ 84 | :find (pull ?card [ 85 | :block/string 86 | :block/uid 87 | {:block/refs [:node/title]} 88 | {:block/_refs 89 | [:block/uid :block/string 90 | {:block/_children 91 | [:block/uid {:block/refs [:node/title]}]} 92 | {:block/refs [:node/title]} 93 | {:block/page [:block/uid]}]} 94 | {:block/_children ...} 95 | ]) 96 | :where 97 | ${srPageTagsToClause(settings.mainTags)} 98 | [?card :block/refs ?srPage] 99 | (not-join [?card] 100 | [?flagPage :node/title "${settings.flagTag}"] 101 | [?card :block/refs ?flagPage]) 102 | (not-join [?card] 103 | [?queryPage :node/title "query"] 104 | [?card :block/refs ?queryPage]) 105 | ]`; 106 | 107 | export const isNew = (res) => { 108 | return res._refs ? res._refs.filter(isReviewBlock).length === 0 : true; 109 | }; 110 | 111 | const queryDueCards = async (settings, dateBasis, asyncQueryFunction) => { 112 | const allPermissibleCardsQuery = createQueryForAllPermissibleCards(settings); 113 | const allPermissibleCardsQueryResults = await asyncQueryFunction(allPermissibleCardsQuery); 114 | return allPermissibleCardsQueryResults 115 | .map((result) => { 116 | let res = result[0]; 117 | let card = { 118 | uid: res.uid, 119 | isNew: isNew(res), 120 | decks: getDecks(res, settings), 121 | algorithm: getAlgorithm(res, settings), 122 | string: res.string, 123 | history: extractHistoryFromQueryResult(res), 124 | }; 125 | return card; 126 | }) 127 | .filter((card) => isDue(card, dateBasis)) 128 | .filter((card) => card.uid); 129 | }; 130 | 131 | const getTodayQuery = (settings, todayUid) => `[ 132 | :find (pull ?card 133 | [:block/uid 134 | {:block/refs [:node/title]} 135 | {:block/_refs 136 | [ 137 | {:block/page [:block/uid]} 138 | {:block/_children 139 | [:block/uid {:block/refs [:node/title]}]} 140 | ]}]) 141 | (pull ?review [:block/refs]) 142 | :where 143 | ${srPageTagsToClause(settings.mainTags)} 144 | [?card :block/refs ?srPage] 145 | [?review :block/refs ?card] 146 | [?reviewPage :node/title "roam/sr/review"] 147 | [?reviewParent :block/refs ?reviewPage] 148 | [?reviewParent :block/children ?review] 149 | [?todayPage :block/uid "${todayUid}"] 150 | [?reviewParent :block/page ?todayPage] 151 | ]`; 152 | 153 | const queryTodayReviewedCards = async (settings, asyncQueryFunction) => { 154 | // Query for today's review 155 | const todayUid = getRoamDate().uid; 156 | const todayQuery = getTodayQuery(settings, todayUid); 157 | const todayQueryResult = await asyncQueryFunction(todayQuery); 158 | return todayQueryResult 159 | .filter((result) => result[1].refs.length == 2) 160 | .map((result) => { 161 | const res = result[0]; 162 | const card = { 163 | uid: res.uid, 164 | isNew: isNew(res), 165 | decks: getDecks(res, settings), 166 | }; 167 | return card; 168 | }); 169 | }; 170 | 171 | export const isLastRelevantDeck = (currentDeckTag, iterationDeckTags, cardDecksTags) => { 172 | // assumes the current deck tag is included in cardDecksTags 173 | const curTagIndex = iterationDeckTags.indexOf(currentDeckTag); 174 | const indicesInIteration = cardDecksTags.map((tag) => iterationDeckTags.indexOf(tag)); 175 | return indicesInIteration.filter((index) => index > curTagIndex).length === 0; 176 | }; 177 | 178 | export const filterCardsOverLimit = (settings, cards, todayReviewedCards) => { 179 | const extraCards = [[], []]; 180 | const filteredCards = [...cards]; 181 | 182 | const resCardsUIDs = []; 183 | const resCards = []; 184 | 185 | const decks = settings.customDecks.concat(settings.defaultDeck); 186 | // to simplify the algorithm, we assume that the provided cards work with the provided limits 187 | // in the case of multi-deck cards this might not always be the case 188 | // example: 189 | // if we have X cards that belong to deck1 with limit X AND deck2 with limit X-1, 190 | // then the limit of deck2 will not be adhered to, the higher limit "wins" 191 | // if we have multi-deck cards AND single-deck cards, 192 | // then both limits might be adhered to depending on the ordering of the cards 193 | 194 | const deckTags = decks.map((deck) => deck.tag); 195 | 196 | for (let deck of decks) { 197 | const reviewUIDs = []; 198 | const todayReviews = [0, 0]; 199 | for (let i = 0; i < todayReviewedCards.length; i++) { 200 | const card = todayReviewedCards[i]; 201 | if (deck.tag ? card.decks.includes(deck.tag) : card.decks.length == 0) { 202 | // need to check, because a card can be reviewed multiple times per day 203 | if (!reviewUIDs.includes(card.uid)) { 204 | reviewUIDs.push(card.uid); 205 | todayReviews[card.isNew ? 0 : 1]++; 206 | } 207 | } 208 | } 209 | 210 | // because we support multi-deck cards, we need to make sure we include already picked cards in the limit 211 | let alreadyPickedNew = 0; 212 | let alreadyPickedOld = 0; 213 | if (deck.tag) { 214 | const alreadyPicked = resCards.filter((card) => card.decks.includes(deck.tag)); 215 | alreadyPickedNew = alreadyPicked.filter((card) => card.isNew).length; 216 | alreadyPickedOld = alreadyPicked.filter((card) => !card.isNew).length; 217 | } 218 | 219 | const limits = [ 220 | deck.newCardLimit !== undefined ? Math.max(0, deck.newCardLimit - todayReviews[0] - alreadyPickedNew) : Infinity, 221 | deck.reviewLimit !== undefined ? Math.max(0, deck.reviewLimit - todayReviews[1] - alreadyPickedOld) : Infinity, 222 | ]; 223 | 224 | for (let i = filteredCards.length - 1; i >= 0; i--) { 225 | const card = filteredCards[i]; 226 | 227 | if (deck.tag ? card.decks.includes(deck.tag) : card.decks.length == 0) { 228 | const j = card.isNew ? 0 : 1; 229 | 230 | // with multi-deck cards its possible that the card was already added 231 | // because we include this case in the limits, we dont need to do anything 232 | if (!resCardsUIDs.includes(card.uid)) { 233 | if (limits[j] === Infinity || limits[j] > 0) { 234 | resCards.push(card); 235 | // for performance we maintain a second UID arr 236 | resCardsUIDs.push(card.uid); 237 | 238 | if (limits[j] !== Infinity) { 239 | limits[j]--; 240 | } 241 | } else { 242 | // card is only in default deck 243 | if (!deck.tag) { 244 | extraCards[j].push(card); 245 | } 246 | // if multiple decks then only the last deck should put it into the extraCards 247 | // because otherwise a different deck might be able to still use it! 248 | else if (card.decks.length > 1) { 249 | if (isLastRelevantDeck(deck.tag, deckTags, card.decks)) { 250 | extraCards[j].push(card); 251 | } 252 | } else { 253 | // single deck case 254 | extraCards[j].push(card); 255 | } 256 | } 257 | } 258 | } 259 | } 260 | } 261 | return { extraCards, filteredCards: resCards }; 262 | }; 263 | 264 | export const loadCards = async (hasLimits, settings, asyncQueryFunction, dateBasis = new Date()) => { 265 | setLoading(true); 266 | await sleep(50); 267 | let cards = await queryDueCards(settings, dateBasis, asyncQueryFunction); 268 | const todayReviewedCards = await queryTodayReviewedCards(settings, asyncQueryFunction); 269 | setLoading(false); 270 | 271 | let extraCardsResult; 272 | if (hasLimits) { 273 | const { extraCards, filteredCards } = filterCardsOverLimit(settings, cards, todayReviewedCards); 274 | extraCardsResult = extraCards; 275 | cards = filteredCards; 276 | } else { 277 | extraCardsResult = [[], []]; 278 | } 279 | 280 | if (settings.startWithNewCards) { 281 | cards.sort((a, b) => a.history.length - b.history.length); 282 | extraCardsResult[0].sort((a, b) => a.history.length - b.history.length); 283 | extraCardsResult[1].sort((a, b) => a.history.length - b.history.length); 284 | } else { 285 | cards.sort((a, b) => b.history.length - a.history.length); 286 | extraCardsResult[0].sort((a, b) => b.history.length - a.history.length); 287 | extraCardsResult[1].sort((a, b) => b.history.length - a.history.length); 288 | } 289 | 290 | return { extraCards: extraCardsResult, cards }; 291 | }; 292 | --------------------------------------------------------------------------------