├── .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 | "
";
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: ` 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 |
--------------------------------------------------------------------------------