208 lines
6.4 KiB
JavaScript
208 lines
6.4 KiB
JavaScript
const searchEl = document.getElementById("explorer-search");
|
|
const srsFilterEl = document.getElementById("explorer-srs-filter");
|
|
const sortEl = document.getElementById("explorer-sort");
|
|
const countEl = document.getElementById("explorer-count");
|
|
const metaEl = document.getElementById("explorer-meta");
|
|
const listEl = document.getElementById("explorer-list");
|
|
|
|
const SRS_KEY = "frenchJourney.srs";
|
|
const FORCE_KEY = "frenchJourney.forceCard";
|
|
const DEFAULT_LIST_ID = "er-verbs";
|
|
const MIN = 60000;
|
|
const DAY = 86400000;
|
|
|
|
let cards = [];
|
|
let query = "";
|
|
let srsFilter = "all";
|
|
let sortMode = "srs";
|
|
let store = { cards: {}, meta: {} };
|
|
|
|
const PRONOUNS = ["je", "tu", "il/elle", "nous", "vous", "ils/elles"];
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, (ch) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
}[ch]));
|
|
}
|
|
|
|
function withPronoun(pron, form) {
|
|
if (pron === "je" && /^[aeiouyhàâäéèêëîïôöûü]/i.test(form)) {
|
|
return `<span class="cj-p">j'</span>${escapeHtml(form)}`;
|
|
}
|
|
return `<span class="cj-p">${pron} </span>${escapeHtml(form)}`;
|
|
}
|
|
|
|
function conjTable(card) {
|
|
if (!card.present) return "";
|
|
const cells = card.present
|
|
.map((form, i) => `<span class="cj-cell">${withPronoun(PRONOUNS[i], form)}</span>`)
|
|
.join("");
|
|
return `<div class="fc-conj">
|
|
<div class="fc-conj-label">Présent</div>
|
|
<div class="fc-conj-grid">${cells}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function loadSrs() {
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem(SRS_KEY));
|
|
if (saved && saved.cards) store = saved;
|
|
} catch {}
|
|
}
|
|
|
|
function cardKey(card) {
|
|
return `${DEFAULT_LIST_ID}:${card.infinitif}`;
|
|
}
|
|
|
|
function schedOf(card) {
|
|
return store.cards[cardKey(card)];
|
|
}
|
|
|
|
function fmtUntil(ms) {
|
|
if (ms <= 0) return "due now";
|
|
if (ms < DAY) {
|
|
const mins = Math.round(ms / MIN);
|
|
if (mins < 1) return "<1m";
|
|
if (mins < 60) return `${mins}m`;
|
|
return `${Math.round(mins / 60)}h`;
|
|
}
|
|
const days = ms / DAY;
|
|
if (days < 30) return `${Math.round(days)}d`;
|
|
if (days < 365) return `${(days / 30).toFixed(1).replace(/\.0$/, "")}mo`;
|
|
return `${(days / 365).toFixed(1).replace(/\.0$/, "")}y`;
|
|
}
|
|
|
|
function srsInfo(card) {
|
|
const s = schedOf(card);
|
|
if (!s) return { state: "new", label: "new", timeLeft: -1 };
|
|
const isLearning = s.state === "learning" || s.state === "relearning";
|
|
const timeLeft = Math.max(0, s.due - Date.now());
|
|
return {
|
|
state: s.state || "review",
|
|
label: timeLeft === 0 ? (isLearning ? "learning now" : "due now") : fmtUntil(timeLeft),
|
|
timeLeft,
|
|
};
|
|
}
|
|
|
|
function matches(card, q) {
|
|
if (!q) return true;
|
|
return [
|
|
card.infinitif,
|
|
card.anglais,
|
|
card.exemple_fr,
|
|
card.exemple_en,
|
|
].join(" ").toLowerCase().includes(q);
|
|
}
|
|
|
|
function matchesSrs(card) {
|
|
const s = schedOf(card);
|
|
if (srsFilter === "all") return true;
|
|
if (srsFilter === "new") return !s;
|
|
if (srsFilter === "scheduled") return !!s;
|
|
if (srsFilter === "due") return !!s && s.due <= Date.now();
|
|
return true;
|
|
}
|
|
|
|
function sortCards(a, b) {
|
|
if (sortMode === "az") return a.infinitif.localeCompare(b.infinitif, "fr");
|
|
const ai = srsInfo(a);
|
|
const bi = srsInfo(b);
|
|
if (ai.timeLeft !== bi.timeLeft) return ai.timeLeft - bi.timeLeft;
|
|
return a.infinitif.localeCompare(b.infinitif, "fr");
|
|
}
|
|
|
|
function studyCard(key) {
|
|
localStorage.setItem(FORCE_KEY, key);
|
|
window.location.href = "flashcards.html";
|
|
}
|
|
|
|
function render() {
|
|
const q = query.trim().toLowerCase();
|
|
const filtered = cards
|
|
.filter((card) => matches(card, q) && matchesSrs(card))
|
|
.sort(sortCards);
|
|
countEl.textContent = String(cards.length);
|
|
metaEl.textContent = `${filtered.length} of ${cards.length} cards`;
|
|
|
|
if (!cards.length) {
|
|
listEl.innerHTML = `<div class="fc-explorer-empty">No cards loaded.</div>`;
|
|
return;
|
|
}
|
|
if (!filtered.length) {
|
|
listEl.innerHTML = `<div class="fc-explorer-empty">No cards match this filter.</div>`;
|
|
return;
|
|
}
|
|
|
|
listEl.innerHTML = filtered.map((card) => `
|
|
<article class="fc-explorer-card">
|
|
<div class="fc-explorer-side front">
|
|
<span class="fc-label">Front</span>
|
|
<strong>${escapeHtml(card.infinitif)}</strong>
|
|
<span class="fc-srs-pill ${srsInfo(card).state}">${escapeHtml(srsInfo(card).label)}</span>
|
|
<button class="fc-btn ghost fc-study-card" type="button" data-card-key="${escapeHtml(cardKey(card))}">Study this</button>
|
|
</div>
|
|
<div class="fc-explorer-side back">
|
|
<span class="fc-label">Back</span>
|
|
<strong>${escapeHtml(card.anglais)}</strong>
|
|
${conjTable(card)}
|
|
<div class="fc-example">
|
|
<code>${escapeHtml(card.exemple_fr)}</code>
|
|
<span class="fc-ex-en">${escapeHtml(card.exemple_en)}</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
`).join("");
|
|
listEl.querySelectorAll("[data-card-key]").forEach((button) => {
|
|
button.onclick = () => studyCard(button.dataset.cardKey);
|
|
});
|
|
}
|
|
|
|
async function init() {
|
|
try {
|
|
loadSrs();
|
|
const SQL = await initSqlJs({
|
|
locateFile: (f) => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/${f}`,
|
|
});
|
|
const buf = await fetch("verbs.db").then((r) => {
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
return r.arrayBuffer();
|
|
});
|
|
const db = new SQL.Database(new Uint8Array(buf));
|
|
const res = db.exec("SELECT infinitif, anglais, exemple_fr, exemple_en FROM verbs ORDER BY infinitif;");
|
|
const rows = res[0]?.values || [];
|
|
cards = rows.map(([infinitif, anglais, exemple_fr, exemple_en]) => ({
|
|
infinitif, anglais, exemple_fr, exemple_en,
|
|
}));
|
|
|
|
const conj = {};
|
|
const cres = db.exec("SELECT infinitif, p1, p2, p3, p4, p5, p6 FROM conjugations WHERE tense='présent';");
|
|
if (cres[0]) {
|
|
for (const [infinitif, ...forms] of cres[0].values) conj[infinitif] = forms;
|
|
}
|
|
cards.forEach((card) => { card.present = conj[card.infinitif] || null; });
|
|
|
|
searchEl.oninput = () => {
|
|
query = searchEl.value;
|
|
render();
|
|
};
|
|
srsFilterEl.onchange = () => {
|
|
srsFilter = srsFilterEl.value;
|
|
render();
|
|
};
|
|
sortEl.onchange = () => {
|
|
sortMode = sortEl.value;
|
|
render();
|
|
};
|
|
render();
|
|
} catch (err) {
|
|
metaEl.textContent = "Could not load database.";
|
|
listEl.innerHTML = `<div class="fc-error">
|
|
<strong>Couldn't load verbs.db.</strong>
|
|
<p>Open this app through a local server:</p>
|
|
<code>python3 -m http.server 8000</code>
|
|
<p class="fc-err-detail">${escapeHtml(err.message)}</p>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
init();
|