/* 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 . */
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 = "
" + message + "
";
// 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();
}
});