diff options
Diffstat (limited to 'script.js')
| -rw-r--r-- | script.js | 235 |
1 files changed, 219 insertions, 16 deletions
@@ -1,3 +1,122 @@ +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) { + const g_value = Grade[g]; + return clamp_d(W[4] - Math.exp(W[5] * (g_value - 1.0)) + 1.0); +} + +function difficulty(d, g) { + return clamp_d(W[7] * d_0('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) { + const g_value = Grade[g]; + return -W[6] * (g_value - 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 * ***********************************************************************/ @@ -22,6 +141,10 @@ function loadCard(card) { } function flipCard() { + if (window.index >= window.cards.length) { + return; + } + window.card.revealed = true; // Reveal the "back". @@ -37,26 +160,105 @@ function flipCard() { 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"; + + // TODO: Update cards. +} + function rateCard(difficulty) { if (!window.card.revealed) { return; } - // if (difficulty === "forgot") { - // } - if (++window.index < window.cards.length) { + const card = window.cards[window.index]; + + const r_d = 0.9; + const s = card.s; + const d = card.d; + const i = card.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); + + const scheduled = new Date(card.scheduled); + scheduled.setDate(scheduled + i); + + if (difficulty === "forgot") { + // Shift forgotten card to end. + window.cards.push(card); + window.cards.splice(window.index, 1); loadCard(window.cards[window.index]); } else { - clearInterval(window.updateInterval); - window.statusInfo.innerHTML = "Session complete!"; - window.flashcard.style.display = "none"; - window.buttons.style.display = "none"; + // Unflag and update scheduler parameters. + delete card.flagged; + card.s = s_n; + card.d = d_n; + card.i = i_n; + + 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; + } } } -// Todo: check if card is revealed before allowing button inpt. +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 loadCards(data) { + checkCards(data); + document.getElementById('flashcard').style.display = "block"; window.allCards = data; @@ -73,8 +275,8 @@ function loadCards(data) { .addEventListener('click', () => rateCard('forgot')); document.getElementById('rating-button-hard') .addEventListener('click', () => rateCard('hard')); - document.getElementById('rating-button-medium') - .addEventListener('click', () => rateCard('medium')); + document.getElementById('rating-button-good') + .addEventListener('click', () => rateCard('good')); document.getElementById('rating-button-easy') .addEventListener('click', () => rateCard('easy')); @@ -85,7 +287,6 @@ function loadCards(data) { return (new Date(card.scheduled)) <= today; }); - // Temporary loadCard(window.cards[0]); window.updateInterval = setInterval(() => { @@ -99,7 +300,7 @@ function loadCards(data) { function showToast(message, duration = 3000) { const toast = document.getElementById("toast"); - toast.textContent = message; + toast.innerHTML = "<pre>" + message + "</pre>"; // Show the toast toast.classList.add("show"); @@ -111,6 +312,7 @@ function showToast(message, duration = 3000) { } window.addEventListener("load", (event) => { + document.getElementById('cards-input').addEventListener('change', function(event) { const file = event.target.files[0]; if (!file) return; @@ -120,18 +322,19 @@ window.addEventListener("load", (event) => { try { const content = e.target.result; loadCards(JSON.parse(content)); - } catch (error) { - console.error("JSON Parsing Error:", error); + } catch (e) { + reportException(e); } }; reader.onerror = function(error) { document.getElementById('result').textContent = "Error reading file: " + error; - console.error("File Reading Error:", error); + reportException(error); }; reader.readAsText(file); // Read the file as text }); + }); window.addEventListener('keydown', (event) => { @@ -143,7 +346,7 @@ window.addEventListener('keydown', (event) => { } else if (event.code === 'Digit2' || event.code === "Numpad2") { rateCard('easy') } else if (event.code === 'Digit3' || event.code === "Numpad3") { - rateCard('medium') + rateCard('good') } else if (event.code === 'Digit4' || event.code === "Numpad4") { rateCard('hard') } |