summaryrefslogtreecommitdiff
path: root/srs-anywhere.js
diff options
context:
space:
mode:
Diffstat (limited to 'srs-anywhere.js')
-rw-r--r--srs-anywhere.js433
1 files changed, 433 insertions, 0 deletions
diff --git a/srs-anywhere.js b/srs-anywhere.js
new file mode 100644
index 0000000..8d587ed
--- /dev/null
+++ b/srs-anywhere.js
@@ -0,0 +1,433 @@
+/* Copyright © 2025 Jakob L. Kreuze
+
+ This file is part of SRS Anywhere.
+
+ SRS Anywhere is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation, either version 3 of the
+ License, or (at your option) any later version.
+
+ SRS Anywhere is distributed in the hope that it will be useful, but
+ WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public
+ License along with SRS Anywhere. If not, see <https://www.gnu.org/licenses/>. */
+
+function reportException(e) {
+ const error = e instanceof Error ? e : new Error(e.toString());
+ console.log(JSON.stringify(error.stack));
+ showToast(`An error has occurred!\n\n${error}\n\n${error.stack}`);
+}
+
+/***********************************************************************
+ * Actual FSRS5 algorithm *
+ ***********************************************************************/
+
+const W = [
+ 0.40255, 1.18385, 3.173, 15.69105, 7.1949, 0.5345, 1.4604, 0.0046, 1.54575, 0.1192, 1.01925,
+ 1.9395, 0.11, 0.29605, 2.2698, 0.2315, 2.9898, 0.51655, 0.6621,
+];
+
+const F = 19.0 / 81.0;
+const C = -0.5;
+
+const Grade = {
+ forgot: 1.0,
+ hard: 2.0,
+ good: 3.0,
+ easy: 4.0
+};
+
+function retrievability(t, s) {
+ return Math.pow(1.0 + F * (t / s), C);
+}
+
+function interval(r_d, s) {
+ return (s / F) * (Math.pow(r_d, 1.0 / C) - 1.0);
+}
+
+function s_0(g) {
+ switch (g) {
+ case Grade.forgot: return W[0];
+ case Grade.hard: return W[1];
+ case Grade.good: return W[2];
+ case Grade.easy: return W[3];
+ }
+}
+
+function s_success(d, s, r, g) {
+ const t_d = 11.0 - d;
+ const t_s = Math.pow(s, -W[9]);
+ const t_r = Math.exp(W[10] * (1.0 - r)) - 1.0;
+ const h = g === Grade.hard ? W[15] : 1.0;
+ const b = g === Grade.easy ? W[16] : 1.0;
+ const c = Math.exp(W[8]);
+ const alpha = 1.0 + t_d * t_s * t_r * h * b * c;
+ return s * alpha;
+}
+
+function s_fail(d, s, r) {
+ const d_f = Math.pow(d, -W[12]);
+ const s_f = Math.pow(s + 1.0, W[13]) - 1.0;
+ const r_f = Math.exp(W[14] * (1.0 - r));
+ const c_f = W[11];
+ const result = d_f * s_f * r_f * c_f;
+ return Math.min(result, s);
+}
+
+function stability(d, s, r, g) {
+ return g === Grade.forgot ? s_fail(d, s, r) : s_success(d, s, r, g);
+}
+
+function clamp_d(d) {
+ return Math.min(10.0, Math.max(1.0, d));
+}
+
+function d_0(g) {
+ return clamp_d(W[4] - Math.exp(W[5] * (g - 1.0)) + 1.0);
+}
+
+function difficulty(d, g) {
+ return clamp_d(W[7] * d_0(Grade.easy) + (1.0 - W[7]) * dp(d, g));
+}
+
+function dp(d, g) {
+ return d + delta_d(g) * ((10.0 - d) / 9.0);
+}
+
+function delta_d(g) {
+ return -W[6] * (g - 3.0);
+}
+
+function sim(grades) {
+ let t = 0.0;
+ const r_d = 0.9;
+ const steps = [];
+
+ // Initial review
+ if (grades.length === 0) {
+ throw new Error("Grades cannot be empty");
+ }
+
+ let gradesCopy = [...grades];
+ let g = gradesCopy.shift(); // Remove the first grade
+ let s = s_0(g);
+ let d = d_0(g);
+ let i = Math.max(Math.round(interval(r_d, s)), 1.0);
+
+ steps.push({ t, s, d, i });
+
+ // n-th review
+ for (let g of gradesCopy) {
+ t += i;
+ const r = retrievability(i, s);
+ s = stability(d, s, r, g);
+ d = difficulty(d, g);
+ i = Math.max(Math.round(interval(r_d, s)), 1.0);
+ steps.push({ t, s, d, i });
+ }
+
+ return steps;
+}
+
+/***********************************************************************
+ * State file processing *
+ ***********************************************************************/
+
+function statusString() {
+ const now = Date.now();
+ const elapsedSeconds = Math.floor((now - window.started) / 1000);
+ const minutes = Math.floor(elapsedSeconds / 60);
+ const seconds = elapsedSeconds % 60;
+ const pad = (n) => n < 10 ? '0' + n : n;
+ return `Card ${window.index + 1} of ${window.cards.length} scheduled for review, ${window.allCards.length} total
+Current session: ${pad(minutes)}:${pad(seconds)}`;
+}
+
+function loadCard(card) {
+ window.card.revealed = false;
+ window.card.front.innerHTML = card.front;
+ window.card.front.style.display = "block";
+ window.card.back.innerHTML = card.back;
+ window.card.back.style.display = "none";
+ window.buttons.style.display = "none";
+}
+
+function flipCard() {
+ if (window.index >= window.cards.length) {
+ return;
+ }
+
+ window.card.revealed = true;
+
+ // Reveal the "back".
+ if (window.card.front.style.display !== "block") {
+ window.card.front.style.display = "block";
+ window.card.back.style.display = "none";
+ } else {
+ window.card.front.style.display = "none";
+ window.card.back.style.display = "block";
+ }
+
+ // Reveal the buttons.
+ window.buttons.style.display = "flex";
+}
+
+function endReview() {
+ clearInterval(window.updateInterval);
+ window.statusInfo.innerHTML = "Session complete!";
+ window.flashcard.style.display = "none";
+ window.buttons.style.display = "none";
+ updateAllCards();
+ downloadJSON(window.allCards);
+}
+
+function rateCard(difficulty) {
+ if (!window.card.revealed) {
+ return;
+ }
+
+ const card = window.cards[window.index];
+
+ const r_d = 0.9;
+ const s = card._scheduler.s;
+ const d = card._scheduler.d;
+ const i = card._scheduler.i;
+ const g = card._flagged ? Grade.forgot : Grade[difficulty];
+
+ let s_n = undefined, d_n = undefined, i_n = undefined;
+
+ if (s == null || d == null || i == null) {
+ s_n = s_0(g);
+ d_n = d_0(g);
+ } else {
+ const r = retrievability(i, s);
+ s_n = stability(d, s, r, g);
+ d_n = difficulty(d, g);
+ }
+ i_n = Math.max(Math.round(interval(r_d, s)), 1.0);
+
+ let scheduled = today();
+ scheduled.setDate(scheduled.getDate() + i_n);
+
+ if (difficulty === "forgot") {
+ // Shift forgotten card to end.
+ window.cards.push(card);
+ window.cards.splice(window.index, 1);
+ loadCard(window.cards[window.index]);
+ } else {
+ // Unflag and update scheduler parameters.
+ delete card._flagged;
+ card._scheduler.s = s_n;
+ card._scheduler.d = d_n;
+ card._scheduler.i = i_n;
+ card._scheduled = scheduled;
+
+ if (++window.index < window.cards.length) {
+ loadCard(window.cards[window.index]);
+ } else {
+ endReview();
+ }
+ }
+}
+
+// Update `window.allCards` with new scheduler parameters.
+function updateAllCards() {
+ for (let i = 0; i < window.allCards.length; i++) {
+ const updated = window.cards.find((card) => card.id == window.allCards[i].id);
+ if (updated) {
+ window.allCards[i] = updated;
+ }
+ }
+}
+
+function downloadJSON(obj) {
+ // Convert the object to a JSON string (with default formatting)
+ const jsonString = JSON.stringify(obj, null, 2); // 'null, 2' adds indentation for readability
+
+ // Create a Blob with the JSON content
+ const blob = new Blob([jsonString], { type: 'application/json' });
+
+ // Generate a URL for the Blob
+ const url = URL.createObjectURL(blob);
+
+ // Create an anchor element
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'data.json'; // Default filename
+
+ // Append the anchor to the body (required for some browsers)
+ document.body.appendChild(a);
+
+ // Simulate a click on the anchor to trigger the download
+ a.click();
+
+ // Clean up
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+function today() {
+ let today = new Date();
+ today.setHours(0, 0, 0, 0);
+ return today;
+}
+
+function checkCards(cards) {
+ for (let i = 0; i < cards.length; i++) {
+ const card = cards[i];
+ if (!card.id) {
+ throw new Error("malformed card: missing id")
+ } else if (!card.front) {
+ throw new Error(`malformed card ${card.id}: missing front`)
+ } else if (!card.back) {
+ throw new Error(`malformed card ${card.id}: missing bacak`)
+ }
+ if (!card._scheduled) {
+ card._scheduled = today().toJSON();
+ }
+ if (!card._scheduler) {
+ card._scheduler = {
+ s: null,
+ d: null,
+ i: null
+ }
+ }
+ }
+}
+
+function shuffleArray(array) {
+ let currentIndex = array.length;
+ let randomIndex;
+
+ // While there remain elements to shuffle.
+ while (currentIndex !== 0) {
+ // Pick a remaining element.
+ randomIndex = Math.floor(Math.random() * currentIndex);
+ currentIndex--;
+
+ // And swap it with the current element.
+ [array[currentIndex], array[randomIndex]] = [
+ array[randomIndex],
+ array[currentIndex],
+ ];
+ }
+
+ return array;
+}
+
+function loadCards(data) {
+ checkCards(data);
+
+ document.getElementById('flashcard').style.display = "block";
+
+ window.allCards = data;
+ window.index = 0;
+ window.started = Date.now();
+ window.statusInfo = document.getElementById('status-info');
+ window.buttons = document.getElementById('button-container');
+ window.card = {
+ front: document.getElementById('flashcard-front'),
+ back: document.getElementById('flashcard-back')
+ };
+
+ document.getElementById('rating-button-forgot')
+ .addEventListener('click', () => rateCard('forgot'));
+ document.getElementById('rating-button-hard')
+ .addEventListener('click', () => rateCard('hard'));
+ document.getElementById('rating-button-good')
+ .addEventListener('click', () => rateCard('good'));
+ document.getElementById('rating-button-easy')
+ .addEventListener('click', () => rateCard('easy'));
+
+ let today = new Date();
+ // Granularity only at the day level.
+ today.setHours(0, 0, 0, 0);
+ window.cards = data.filter((card) => {
+ return (new Date(card._scheduled)) <= today;
+ });
+ shuffleArray(window.cards);
+
+ loadCard(window.cards[0]);
+
+ window.updateInterval = setInterval(() => {
+ window.statusInfo.innerText = statusString()
+ }, 1000);
+}
+
+/***********************************************************************
+ * Status display *
+ ***********************************************************************/
+
+function showToast(message, duration = 3000) {
+ const toast = document.getElementById("toast");
+ toast.innerHTML = "<pre>" + message + "</pre>";
+
+ // Show the toast
+ toast.classList.add("show");
+
+ // Hide the toast after specified time
+ setTimeout(() => {
+ toast.classList.remove("show");
+ }, duration);
+}
+
+window.addEventListener("load", (event) => {
+ document.getElementById('cards-input').addEventListener('change', function(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = function(e) {
+ try {
+ const content = e.target.result;
+ loadCards(JSON.parse(content));
+ } catch (e) {
+ reportException(e);
+ }
+ };
+
+ reader.onerror = function(error) {
+ document.getElementById('result').textContent = "Error reading file: " + error;
+ reportException(error);
+ };
+
+ reader.readAsText(file); // Read the file as text
+ });
+
+ document.getElementById('flashcard').addEventListener('click', function(event) {
+ flipCard();
+ });
+
+ const modal = document.getElementById('modal');
+ const openBtn = document.getElementById('open-modal');
+ const closeBtn = document.getElementById('close-modal');
+
+ // Show the modal when "Open Modal" is clicked
+ openBtn.addEventListener('click', () => {
+ modal.style.display = 'block';
+ });
+
+ // Hide the modal when "OK" is clicked
+ closeBtn.addEventListener('click', () => {
+ modal.style.display = 'none';
+ });
+});
+
+window.addEventListener('keydown', (event) => {
+ if (event.code === 'Space') {
+ flipCard();
+ } else if (event.code === 'Digit1' || event.code === "Numpad1") {
+ rateCard('forgot');
+ } else if (event.code === 'Digit2' || event.code === "Numpad2") {
+ rateCard('easy');
+ } else if (event.code === 'Digit3' || event.code === "Numpad3") {
+ rateCard('good');
+ } else if (event.code === 'Digit4' || event.code === "Numpad4") {
+ rateCard('hard');
+ } else if (event.code === 'KeyQ') {
+ endReview();
+ }
+});