diff options
Diffstat (limited to 'srs-anywhere.js')
| -rw-r--r-- | srs-anywhere.js | 433 |
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(); + } +}); |