Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 6 additions & 71 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,81 +3,16 @@

const $ = s => document.querySelector(s);
const $$ = s => [...document.querySelectorAll(s)];
import { DAY, clamp, fmtInterval, parseCSV } from './core.js';
import { db, setDB, blank, load, save, newCard, deckOf, cardsOf, todayLog, counts, buildQueue, plan } from './store.js';

/* DAY, clamp, schedule, fmtInterval, parseCSV come from core.js */
const uid =() => Math.random().toString(36).slice(2, 10);
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; };
const startOfDay = t => { const d = new Date(t); d.setHours(0, 0, 0, 0); return d.getTime(); };

/* scheduler + parsing live in core.js (pure, unit-tested) */
const plan = (card, g, now) => schedule(card, g, now, db.settings.ret / 100);

/* ---------------- store ---------------- */
const KEY = 'core-recall-v1';
let db, view = 'home', openDeck = null;

function blank() {
// lastBackup starts "now" so a fresh install isn't nagged on day one
return { decks: [], cards: [], log: [], lastBackup: Date.now(),
settings: { new: 20, max: 200, ret: 90, theme: 'light' } };
}
function load() {
try { db = JSON.parse(localStorage.getItem(KEY)) || seed(); } catch { db = seed(); }
db.settings = Object.assign(blank().settings, db.settings);
save(); // persist the first-run seed so card ids stay stable across reloads
}
// ponytail: whole DB in one localStorage blob. Move to IndexedDB past ~10k cards.
function save() { localStorage.setItem(KEY, JSON.stringify(db)); }

function newCard(deck, front, back) {
return { id: uid(), deck, front, back, due: Date.now(), s: 0, d: 0, reps: 0, lapses: 0, last: 0, state: 0 };
}
function seed() {
const d = blank();
const deck = { id: uid(), name: '英単語', created: Date.now() };
d.decks.push(deck);
[['ubiquitous', '遍在する、どこにでもある'], ['ephemeral', 'つかの間の、短命な'],
['meticulous', '几帳面な、細部にこだわる'], ['candid', '率直な、ありのままの'],
['prudent', '慎重な、思慮深い'], ['resilient', '回復力のある、しなやかな'],
['ambiguous', '曖昧な、多義的な'], ['tenacious', '粘り強い、執拗な']]
.forEach(([f, b]) => d.cards.push(newCard(deck.id, f, b)));
return d;
}

/* ---------------- derived ---------------- */
const deckOf = id => db.decks.find(d => d.id === id);
const cardsOf = id => db.cards.filter(c => c.deck === id);
const todayLog = () => { const k = dayKey(Date.now()); return db.log.filter(l => dayKey(l.t) === k); };

function counts(deckId) {
const now = Date.now(), c = { n: 0, l: 0, d: 0 };
for (const card of cardsOf(deckId)) {
if (!card.reps) c.n++;
else if (card.due <= now) (card.state === 2 ? c.l++ : c.d++);
}
return c;
}

/** Session queue for a deck, respecting the daily caps. */
function buildQueue(deckId) {
const now = Date.now(), t = todayLog();
const newLeft = Math.max(0, db.settings.new - t.filter(l => l.n).length);
const revLeft = Math.max(0, db.settings.max - t.filter(l => !l.n).length);
const all = cardsOf(deckId);
const due = all.filter(c => c.reps && c.due <= now).sort((a, b) => a.due - b.due).slice(0, revLeft);
const fresh = all.filter(c => !c.reps).slice(0, newLeft);
// interleave new cards evenly through the review queue
const q = due.slice();
if (fresh.length) {
const step = Math.max(1, Math.floor((q.length + fresh.length) / fresh.length));
fresh.forEach((c, i) => q.splice(Math.min(q.length, i * step + i), 0, c));
}
if (deckOf(deckId)?.shuffle) for (let i = q.length - 1; i > 0; i--) { // Fisher-Yates
const j = Math.floor(Math.random() * (i + 1));
[q[i], q[j]] = [q[j], q[i]];
}
return q;
}
let view = 'home', openDeck = null;

/* ---------------- ui helpers ---------------- */
let toastTimer;
Expand Down Expand Up @@ -377,7 +312,7 @@ async function handleFile(file) {
try { data = JSON.parse(text); } catch { return toast('JSON を読み込めませんでした'); }
if (!Array.isArray(data.decks) || !Array.isArray(data.cards)) return toast('Core Recall のバックアップではありません');
if (!confirm(`現在のデータを置き換えます。\nデッキ ${data.decks.length}件 / カード ${data.cards.length}枚を復元しますか?`)) return;
db = Object.assign(blank(), data); save(); applyTheme(); render(); renderSettings();
setDB(Object.assign(blank(), data)); save(); applyTheme(); render(); renderSettings();
return toast('復元しました');
}
const rows = parseCSV(text);
Expand All @@ -393,7 +328,7 @@ async function handleFile(file) {
function wipe() {
if (!confirm('すべてのデッキ・カード・学習履歴を消去します。元に戻せません。続けますか?')) return;
if (!confirm('本当によろしいですか? 先に JSON バックアップを取ることを強くおすすめします。')) return;
db = blank(); save(); render(); renderSettings();
setDB(blank()); save(); render(); renderSettings();
toast('すべてのデータを消去しました');
}

Expand Down
11 changes: 11 additions & 0 deletions server.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
127.0.0.1 - - [13/Aug/2026 15:20:05] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2026 15:20:05] "GET /app.css HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2026 15:20:05] "GET /core.js HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2026 15:20:05] "GET /app.js HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2026 15:20:05] "GET /store.js HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /sw.js HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /index.html HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /manifest.webmanifest HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /icons/icon.svg HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /icons/icon-192.png HTTP/1.1" 200 -
127.0.0.1 - - [13/Aug/2026 15:20:06] "GET /icons/icon-512.png HTTP/1.1" 200 -
99 changes: 99 additions & 0 deletions store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { schedule } from './core.js';

const uid = () => Math.random().toString(36).slice(2, 10);
const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; };

export const KEY = 'core-recall-v1';
export let db = null;

export function normalizeDB(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return blank();
}
const b = blank();
const isRecord = x => x && typeof x === 'object' && !Array.isArray(x);

const decks = Array.isArray(value.decks) ? value.decks.filter(isRecord) : b.decks;
const cards = Array.isArray(value.cards) ? value.cards.filter(isRecord) : b.cards;
const log = Array.isArray(value.log) ? value.log.filter(isRecord) : b.log;

return {
decks,
cards,
log,
lastBackup: typeof value.lastBackup === 'number' ? value.lastBackup : b.lastBackup,
settings: Object.assign(b.settings, isRecord(value.settings) ? value.settings : {})
};
}

export function setDB(newDB) {
db = normalizeDB(newDB);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

export function blank() {
return { decks: [], cards: [], log: [], lastBackup: Date.now(),
settings: { new: 20, max: 200, ret: 90, theme: 'light' } };
}

export function load() {
let loaded;
try { loaded = JSON.parse(localStorage.getItem(KEY)); } catch {}
db = normalizeDB(loaded || seed());
save();
}

export function save() {
localStorage.setItem(KEY, JSON.stringify(db));
}

export function newCard(deck, front, back) {
return { id: uid(), deck, front, back, due: Date.now(), s: 0, d: 0, reps: 0, lapses: 0, last: 0, state: 0 };
}

export function seed() {
const d = blank();
const deck = { id: uid(), name: '英単語', created: Date.now() };
d.decks.push(deck);
[['ubiquitous', '遍在する、どこにでもある'], ['ephemeral', 'つかの間の、短命な'],
['meticulous', '几帳面な、細部にこだわる'], ['candid', '率直な、ありのままの'],
['prudent', '慎重な、思慮深い'], ['resilient', '回復力のある、しなやかな'],
['ambiguous', '曖昧な、多義的な'], ['tenacious', '粘り強い、執拗な']]
.forEach(([f, b]) => d.cards.push(newCard(deck.id, f, b)));
return d;
}

export const deckOf = id => db.decks.find(d => d.id === id);
export const cardsOf = id => db.cards.filter(c => c.deck === id);
export const todayLog = () => { const k = dayKey(Date.now()); return db.log.filter(l => dayKey(l.t) === k); };

export function counts(deckId) {
const now = Date.now(), c = { n: 0, l: 0, d: 0 };
for (const card of cardsOf(deckId)) {
if (!card.reps) c.n++;
else if (card.due <= now) (card.state === 2 ? c.l++ : c.d++);
}
return c;
}

export function buildQueue(deckId) {
const now = Date.now(), t = todayLog();
const newLeft = Math.max(0, db.settings.new - t.filter(l => l.n).length);
const revLeft = Math.max(0, db.settings.max - t.filter(l => !l.n).length);
const all = cardsOf(deckId);
const due = all.filter(c => c.reps && c.due <= now).sort((a, b) => a.due - b.due).slice(0, revLeft);
const fresh = all.filter(c => !c.reps).slice(0, newLeft);
const q = due.slice();
if (fresh.length) {
const step = Math.max(1, Math.floor((q.length + fresh.length) / fresh.length));
fresh.forEach((c, i) => q.splice(Math.min(q.length, i * step + i), 0, c));
}
if (deckOf(deckId)?.shuffle) for (let i = q.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[q[i], q[j]] = [q[j], q[i]];
}
return q;
}

export function plan(card, g, now) {
return schedule(card, g, now, db.settings.ret / 100);
}
4 changes: 2 additions & 2 deletions sw.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* Cache-first shell so the app works fully offline. Bump CACHE to ship an update. */
const CACHE = 'core-recall-v9';
const SHELL = ['./', 'index.html', 'app.css', 'core.js', 'app.js',
const CACHE = 'core-recall-v10';
const SHELL = ['./', 'index.html', 'app.css', 'core.js', 'store.js', 'app.js',
'manifest.webmanifest', 'icons/icon.svg', 'icons/icon-192.png', 'icons/icon-512.png'];

self.addEventListener('install', e => {
Expand Down
95 changes: 95 additions & 0 deletions test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,31 @@ const assert = require('assert');

(async () => {
const { schedule, fmtInterval, parseCSV, retrievability } = await import('./core.js');
const { normalizeDB } = await import('./store.js');

const NOW = Date.UTC(2026, 0, 1);

// --- normalizeDB checks
const blankState = normalizeDB(null);
assert.deepStrictEqual(blankState.decks, [], 'null DB should produce empty decks');
assert.strictEqual(blankState.settings.theme, 'light', 'default theme should be light');

const dirtyDB = {
decks: [{ id: 'd1' }, null, 42, 'str', [], { id: 'd2' }],
cards: [null, { id: 'c1' }, 0],
log: [{ id: 'l1' }, false, null],
lastBackup: 12345,
settings: { new: 10, max: 100 }
};
const cleanDB = normalizeDB(dirtyDB);
assert.deepStrictEqual(cleanDB.decks, [{ id: 'd1' }, { id: 'd2' }], 'primitives, null, arrays must be stripped from decks');
assert.deepStrictEqual(cleanDB.cards, [{ id: 'c1' }], 'primitives and null must be stripped from cards');
assert.deepStrictEqual(cleanDB.log, [{ id: 'l1' }], 'primitives and null must be stripped from log');
assert.strictEqual(cleanDB.lastBackup, 12345, 'valid lastBackup should be preserved');
assert.strictEqual(cleanDB.settings.new, 10, 'valid settings should be preserved');
assert.strictEqual(cleanDB.settings.ret, 90, 'missing settings should fallback to defaults');
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

assert.deepStrictEqual(normalizeDB('string_not_obj').decks, [], 'primitive DB root should yield blank db');
const fresh = { reps: 0, s: 0, d: 0, last: 0 };
const after = (card, g, now) => {
const r = schedule(card, g, now);
Expand Down Expand Up @@ -148,6 +171,78 @@ const assert = require('assert');
assert.ok(dueVsCard >= 3.0, `[Theme ${t.name}] switch ON state contrast ${dueVsCard.toFixed(2)}:1 must be >= 3.0`);
});

// --- store.js database normalization regression tests
const storage = new Map();
globalThis.localStorage = {
getItem: key => storage.get(key) ?? null,
setItem: (key, val) => storage.set(key, String(val)),
removeItem: key => storage.delete(key),
clear: () => storage.clear()
};

const store = await import('./store.js');

// setDB with log: null must allow todayLog() to return [] without exception
store.setDB({ log: null });
assert.doesNotThrow(() => {
assert.deepStrictEqual(store.todayLog(), [], 'todayLog() must return [] when log is null');
});

// Invalid decks, cards, log values replaced with empty arrays
store.setDB({ decks: null, cards: 'invalid', log: 123 });
assert.deepStrictEqual(store.db.decks, [], 'invalid decks must be replaced with []');
assert.deepStrictEqual(store.db.cards, [], 'invalid cards must be replaced with []');
assert.deepStrictEqual(store.db.log, [], 'invalid log must be replaced with []');

// settings: null or settings: [] replaced with default settings
store.setDB({ settings: null });
assert.deepStrictEqual(store.db.settings, store.blank().settings, 'settings: null replaced with defaults');
store.setDB({ settings: [] });
assert.deepStrictEqual(store.db.settings, store.blank().settings, 'settings: [] replaced with defaults');

// Invalid elements within decks, cards, and log arrays are filtered out
const validDeck = { id: 'd1', name: 'Test' };
const validCard = { id: 'c1', deck: 'd1', reps: 0 };
const validLog = { t: Date.now(), g: 3, n: 1, c: 'c1' };

store.setDB({
decks: [null, undefined, 123, 'str', true, [1, 2], validDeck],
cards: [null, undefined, 456, 'card', false, [3, 4], validCard],
log: [null, undefined, 789, 'log', true, [5, 6], validLog]
});
assert.deepStrictEqual(store.db.decks, [validDeck], 'invalid deck elements filtered out');
assert.deepStrictEqual(store.db.cards, [validCard], 'invalid card elements filtered out');
assert.deepStrictEqual(store.db.log, [validLog], 'invalid log elements filtered out');

// Verify functions like todayLog() and counts() don't throw when arrays contained null elements prior to setDB
store.setDB({ decks: [null], cards: [null], log: [null] });
assert.doesNotThrow(() => store.todayLog());
assert.doesNotThrow(() => store.counts('d1'));

// load() normalizes corrupted persisted data and saves normalized DB to localStorage
storage.set(store.KEY, JSON.stringify({ decks: [null, 'corrupted'], cards: null, log: [123], settings: null }));
store.load();
assert.deepStrictEqual(store.db.decks, []);
assert.deepStrictEqual(store.db.cards, []);
assert.deepStrictEqual(store.db.log, []);
assert.deepStrictEqual(store.db.settings, store.blank().settings);
assert.deepStrictEqual(JSON.parse(storage.get(store.KEY)), store.db, 'load() must persist normalized DB to localStorage');

// Verify normalizeDB with malformed fields (non-array collections, invalid lastBackup, non-record settings)
const malformedFields = store.normalizeDB({
decks: {},
cards: 'cards',
log: null,
lastBackup: '12345',
settings: []
});
const expectedBlank = store.blank();
assert.deepStrictEqual(malformedFields.decks, expectedBlank.decks, 'invalid decks fallback to []');
assert.deepStrictEqual(malformedFields.cards, expectedBlank.cards, 'invalid cards fallback to []');
assert.deepStrictEqual(malformedFields.log, expectedBlank.log, 'invalid log fallback to []');
assert.deepStrictEqual(malformedFields.settings, expectedBlank.settings, 'invalid settings fallback to blank settings');
assert.strictEqual(typeof malformedFields.lastBackup, 'number', 'invalid lastBackup falls back to default timestamp');

console.log('all checks passed');
})().catch(err => {
console.error(err);
Expand Down
Loading