Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s

Sync and data safety
- Autosave flushes on leaving a study or the tab; storage-full errors are reported
- Unsynced studies, deletes and ink are queued and retried when back online
- Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect
- Server assigns study versions (no client-clock conflicts); "Combine both"
  merges a conflicted study chunk by chunk
- Reading-plan progress made offline is merged, not overwritten; plans, ink and
  marks are cleared from the device on sign-out

Security
- Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and
  account; constant-time login; TOTP replay protection; input validation;
  SESSION_SECRET required in production; admin user delete fixed
- Forgot password: one-time code sent to the account's ntfy topic

Features
- IQ Bible proxy (route allow-list, text-only caching per its terms): more
  commentaries, lexicons, study aids, daily verse and devotional, plan calendar
  export, narrations, podcast background
- Study templates, reading-plan catch-up, account export, daily database snapshots

Tests and tooling
- ESLint config; App tests split into src/test; tests for sync queues, library
  hook, merge, templates, IQ Bible UI and reset form

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 08:13:36 -04:00
1 parent 6bb392933a
commit f274bc9c8a
67 files changed
+4733 -1568

No files matched your search

+22
View File
@@ -42,6 +42,18 @@ http://localhost:5173. Dev mode is much slower, so don't use it for real users.
---
## Optional keys (set in `.env` or the environment)
| Variable | What it turns on |
| --- | --- |
| `SESSION_SECRET` | **Required in production.** A long random string; the server refuses to start without it. |
| `ADMIN_EMAIL` | The one admin account. |
| `ESV_API_KEY`, `API_BIBLE_KEY` | ESV, NLT, CSB and NASB in the Reader. |
| `IQBIBLE_API_KEY` | IQ Bible: more commentaries, lexicons (BDB, LSJ, Abbott-Smith), places, topics, people, textual notes, Gospel parallels, daily verse and Spurgeon devotional, plan calendar export, more audio narrations, and background for podcast prompts. Hidden when unset. |
| `IQBIBLE_CACHE_HOURS` | Leave unset unless IQ Bible has given written permission to cache non-verse data; only verse/chapter text is cached by default (24h), as their terms require. |
| `BACKUP_KEEP`, `BACKUP_DISABLED=1` | The server saves a snapshot of the whole database once a day to `DATA_DIR/backups` (default: keep 7). Restore with `scripts/restore-data.sh`. |
| `ALLOW_PRIVATE_NTFY=1` | Lets reading reminders use an ntfy server on your own network (blocked by default). |
## Production Install (Ubuntu / systemd)
Installs the app as a persistent systemd service under `/opt/study-app`.
@@ -134,3 +146,13 @@ See [scripts/backup-data.sh](scripts/backup-data.sh) and [scripts/restore-data.s
## License
Private / personal use.
## Development
```bash
npm run test:run # all tests
npm run lint # ESLint (errors fail; warnings are advice)
```
Forgotten passwords: the sign-in page's "Forgot your password?" sends a one-time code to the account's ntfy topic (the one used for reading reminders). Accounts without a topic still ask the administrator for a temporary password.
+33
View File
@@ -0,0 +1,33 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
export default [
{ ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'server/data/**', 'public/**'] },
js.configs.recommended,
{
files: ['src/**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: { ...globals.browser },
parserOptions: { ecmaFeatures: { jsx: true } },
},
plugins: { 'react-hooks': reactHooks },
rules: {
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'no-unused-vars': ['warn', { varsIgnorePattern: '^_|^[A-Z]', argsIgnorePattern: '^_', caughtErrors: 'none' }],
'no-empty': ['error', { allowEmptyCatch: true }],
},
},
{
files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'],
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } },
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] },
},
{
files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'],
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
},
];
+977
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -14,7 +14,8 @@
"test:run": "vitest run",
"coverage": "vitest run --coverage",
"backup": "bash scripts/backup-data.sh",
"restore": "bash scripts/restore-data.sh"
"restore": "bash scripts/restore-data.sh",
"lint": "eslint ."
},
"dependencies": {
"bcryptjs": "^3.0.3",
@@ -33,6 +34,7 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -40,6 +42,10 @@
"@vitest/coverage-v8": "^4.1.7",
"autoprefixer": "^10.4.19",
"csv-parse": "^6.2.1",
"eslint": "^10.12.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"globals": "^17.13.0",
"jsdom": "^29.1.1",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.4",
+32
View File
@@ -0,0 +1,32 @@
// Everything one account holds on the server, in the same file format the app's own "Download backup"
// makes, so it can be restored from Settings on any device.
export const BACKUP_APP = 'bible-study-project';
export const BACKUP_VERSION = 1;
// `store` supplies the account's data: { projects(), prefs(), marks(), ink(), plans() }.
export function buildAccountExport(user, store, now = new Date()) {
const prefs = store.prefs() ?? {};
const marks = store.marks() ?? {};
const plans = store.plans() ?? {};
return {
app: BACKUP_APP,
version: BACKUP_VERSION,
exportedAt: now.toISOString(),
account: user.email,
source: 'server',
projects: store.projects(),
reader: {
bookmarks: marks.bookmarks ?? {},
highlights: marks.highlights ?? [],
colorLabels: prefs.colorLabels?.v ?? {},
ink: store.ink() ?? {},
},
readingPlans: {
progress: plans.progress ?? [],
customPlans: plans.customPlans ?? [],
activity: plans.activity ?? [],
},
};
}
export const accountExportName = (now = new Date()) => `bible-study-account-${now.toISOString().slice(0, 10)}.json`;
+33
View File
@@ -0,0 +1,33 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { buildAccountExport, accountExportName, BACKUP_APP } from './accountExport.js';
import { listSnapshots, pruneSnapshots } from './backups.js';
import { mkdtempSync, writeFileSync, readdirSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
test('the account export has the same shape as the in-app backup', () => {
const out = buildAccountExport({ email: 'a@b.co' }, {
projects: () => [{ id: 'p', title: 'T', chapters: [] }],
prefs: () => ({ colorLabels: { v: { '#fef08a': 'Promises' }, at: 1 } }),
marks: () => ({ bookmarks: { 'JHN-3-16': '#fff' }, highlights: [{ id: 'h' }] }),
ink: () => ({ JHN_3: [[1, 2]] }),
plans: () => ({ progress: [{ id: 'x' }] }),
}, new Date('2026-10-06T10:00:00Z'));
expect(out.app).toBe(BACKUP_APP);
expect(out.account).toBe('a@b.co');
expect(out.reader.colorLabels).toEqual({ '#fef08a': 'Promises' });
expect(out.reader.bookmarks['JHN-3-16']).toBe('#fff');
expect(out.readingPlans).toEqual({ progress: [{ id: 'x' }], customPlans: [], activity: [] });
expect(accountExportName(new Date('2026-10-06T10:00:00Z'))).toBe('bible-study-account-2026-10-06.json');
});
test('only the newest snapshots are kept, and always at least one', () => {
const dir = mkdtempSync(join(tmpdir(), 'snap-'));
['2026-10-01-03-00', '2026-10-02-03-00', '2026-10-03-03-00', '2026-10-04-03-00'].forEach((d) => writeFileSync(join(dir, `projects-${d}.db`), 'x'));
writeFileSync(join(dir, 'notes.txt'), 'not a snapshot');
expect(listSnapshots(dir).map((s) => s.name)[0]).toBe('projects-2026-10-04-03-00.db');
expect(pruneSnapshots(2, dir)).toEqual(['projects-2026-10-02-03-00.db', 'projects-2026-10-01-03-00.db']);
expect(pruneSnapshots(0, dir)).toHaveLength(1); // keep at least the newest
expect(readdirSync(dir).sort()).toEqual(['notes.txt', 'projects-2026-10-04-03-00.db']);
});
+7
View File
@@ -92,3 +92,10 @@ export async function consumeBackupCode(submitted, hashes) {
export function generateTemporaryPassword() {
return randomBytes(6).toString('hex');
}
// A one-time password-reset code: 8 characters from an alphabet without look-alikes (no 0/O, 1/I/L).
const RESET_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
export function generateResetCode() {
return Array.from(randomBytes(8), (b) => RESET_ALPHABET[b % RESET_ALPHABET.length]).join('');
}
export const normalizeResetCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
+45
View File
@@ -0,0 +1,45 @@
// Automatic daily snapshots of the whole database (every account), kept next to it in DATA_DIR/backups.
// A snapshot is a complete, consistent copy made while the app keeps running, and is restored with
// scripts/restore-data.sh. BACKUP_KEEP sets how many to keep (default 7); BACKUP_DISABLED=1 turns it off.
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
import { join } from 'path';
import { backupDatabase, dataDir } from './db.js';
const DAY = 24 * 60 * 60 * 1000;
export const backupDir = () => join(dataDir(), 'backups');
const stamp = (d) => d.toISOString().replace(/[:T]/g, '-').slice(0, 16);
const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name);
export function listSnapshots(dir = backupDir()) {
if (!existsSync(dir)) return [];
return readdirSync(dir).filter(isSnapshot).sort().reverse()
.map((name) => ({ name, bytes: statSync(join(dir, name)).size, at: statSync(join(dir, name)).mtimeMs }));
}
// Deletes all but the newest `keep` snapshots; returns the names removed.
export function pruneSnapshots(keep = 7, dir = backupDir()) {
const old = listSnapshots(dir).slice(Math.max(1, keep));
old.forEach((s) => unlinkSync(join(dir, s.name)));
return old.map((s) => s.name);
}
export async function runBackup(now = new Date()) {
const dir = backupDir();
mkdirSync(dir, { recursive: true });
const file = join(dir, `projects-${stamp(now)}.db`);
await backupDatabase(file);
pruneSnapshots(Number(process.env.BACKUP_KEEP) || 7, dir);
return file;
}
// Takes a snapshot when the newest is more than a day old (checked hourly, and once at start-up).
export function startBackupScheduler() {
if (process.env.BACKUP_DISABLED === '1') return;
const tick = () => {
const latest = listSnapshots()[0];
if (latest && Date.now() - latest.at < DAY) return;
runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => console.error('Database snapshot failed:', err.message));
};
setTimeout(tick, 30_000).unref();
setInterval(tick, 60 * 60 * 1000).unref();
}
+1 -1
View File
@@ -53,7 +53,7 @@ const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
// breaks, and stray "#" / nbsp characters. Split it back into { number, text }.
export function parseChapterText(content) {
const clean = String(content ?? '')
.replace(/ /g, ' ')
.replace(/\u00a0/g, ' ')
.replace(/#/g, '');
const parts = clean.split(/\[(\d+)\]/);
const verses = [];
+48 -1
View File
@@ -9,6 +9,13 @@ const DB_PATH = join(DATA_DIR, 'projects.db');
let db;
export const dataDir = () => DATA_DIR;
/** A consistent copy of the whole database, made while it is in use. */
export function backupDatabase(destPath) {
return db.backup(destPath);
}
// ---------------------------------------------------------------------------
// Init — create tables if they don't exist, migrate older schemas
// ---------------------------------------------------------------------------
@@ -89,6 +96,16 @@ export function initDb() {
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
// One-time codes for "forgot password": hashed, short-lived, limited tries.
db.exec(`
CREATE TABLE IF NOT EXISTS password_resets (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
expires INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0
);
`);
// Reader ink — one row per user + book + chapter, stored as JSON
db.exec(`
CREATE TABLE IF NOT EXISTS reader_ink (
@@ -232,6 +249,30 @@ export function setLastReminderDate(userId, dateString) {
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
}
// ---------------------------------------------------------------------------
// Password reset codes
// ---------------------------------------------------------------------------
export function setPasswordReset(userId, codeHash, expires) {
db.prepare(`
INSERT INTO password_resets (user_id, code_hash, expires, attempts) VALUES (?, ?, ?, 0)
ON CONFLICT(user_id) DO UPDATE SET code_hash = excluded.code_hash, expires = excluded.expires, attempts = 0
`).run(userId, codeHash, expires);
}
export function getPasswordReset(userId) {
const row = db.prepare('SELECT code_hash AS codeHash, expires, attempts FROM password_resets WHERE user_id = ?').get(userId);
return row && row.expires > Date.now() ? row : null;
}
export function bumpPasswordResetAttempts(userId) {
db.prepare('UPDATE password_resets SET attempts = attempts + 1 WHERE user_id = ?').run(userId);
}
export function clearPasswordReset(userId) {
db.prepare('DELETE FROM password_resets WHERE user_id = ?').run(userId);
}
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
export function setBackupCodeHashes(userId, backupCodeHashes) {
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
@@ -373,7 +414,9 @@ export function upsertProject(project, userId) {
return null;
}
const lastEdited = project.lastEdited ?? Date.now();
// The server's clock decides the version, so a device with a wrong clock can't hide or fake changes.
const prev = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(project.id);
const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1);
const chapterSummary = buildSummary(project);
const updated = { ...project, lastEdited };
@@ -468,7 +511,11 @@ export function adminGetAllUsers() {
/** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */
export function adminDeleteUser(userId) {
// projects.user_id is a foreign key, so release the projects first (they stay as unowned rows).
db.transaction(() => {
db.prepare('UPDATE projects SET user_id = NULL, share_token = NULL, outline_share_token = NULL WHERE user_id = ?').run(userId);
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
})();
}
/** Every project across every user, with the owner's email, for the admin projects list. */
+156 -21
View File
@@ -16,15 +16,20 @@ import {
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
getReadingProgress, setReadingProgress,
setReminderSettings,
setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset,
} from './db.js';
import { SqliteSessionStore } from './sessionStore.js';
import {
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail,
generateTotpSecret, totpKeyUri, verifyTotpToken,
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
generateResetCode, normalizeResetCode,
} from './auth.js';
import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './reminders.js';
import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js';
import { registerBibleRoutes } from './bibleApi.js';
import { registerIqBibleRoutes } from './iqbible.js';
import { buildAccountExport, accountExportName } from './accountExport.js';
import { startBackupScheduler } from './backups.js';
import { cleanMarks } from './readerMarks.js';
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
import { isSaveConflict, parseBase } from './projectSync.js';
@@ -46,26 +51,46 @@ const PORT = process.env.PORT || 3001;
const isProd = process.env.NODE_ENV === 'production';
if (isProd && !process.env.SESSION_SECRET) {
console.warn('WARNING: SESSION_SECRET is not set. Set it to a long random string in production.');
console.error('SESSION_SECRET is not set. Set it to a long random string before running in production.');
process.exit(1);
}
// Trust the reverse proxy (needed for secure cookies to work behind nginx/etc).
app.set('trust proxy', 1);
// Simple in-memory rate limiter for auth endpoints — 10 attempts per 15 min per IP.
const _authAttempts = new Map();
// In-memory limiter for sign-in style endpoints: 10 FAILED attempts per 15 min, tracked per IP and per
// account (key). Successful sign-ins don't count, so a shared IP doesn't lock everyone out.
const _failures = new Map();
const FAIL_WINDOW = 15 * 60 * 1000;
const FAIL_MAX = 10;
setInterval(() => {
const now = Date.now();
for (const [ip, e] of _authAttempts) if (now > e.resetAt) _authAttempts.delete(ip);
}, 60 * 60 * 1000);
function checkAuthRateLimit(ip) {
for (const [k, e] of _failures) if (now > e.resetAt) _failures.delete(k);
}, 60 * 60 * 1000).unref();
function isLimited(...keys) {
const now = Date.now();
const window = 15 * 60 * 1000;
const e = _authAttempts.get(ip) ?? { count: 0, resetAt: now + window };
if (now > e.resetAt) { e.count = 0; e.resetAt = now + window; }
e.count += 1;
_authAttempts.set(ip, e);
return e.count > 10;
return keys.some((k) => { const e = _failures.get(k); return e && now <= e.resetAt && e.count >= FAIL_MAX; });
}
function recordFailure(...keys) {
const now = Date.now();
for (const k of keys) {
const e = _failures.get(k);
if (!e || now > e.resetAt) _failures.set(k, { count: 1, resetAt: now + FAIL_WINDOW });
else e.count += 1;
}
}
function clearFailures(...keys) { keys.forEach((k) => _failures.delete(k)); }
const TOO_MANY = { error: 'Too many attempts. Please wait 15 minutes.' };
// Compared against when an email isn't registered, so a login takes the same time either way.
const DUMMY_HASH = '$2b$10$UbGSJvxosy5oInS6zfvqM.gWOUv9ypVg81ou.jWOswHyYgUjpkhJK';
// TOTP codes already used (userId -> code), so a captured code can't be replayed inside its time window.
const _usedTotp = new Map();
function totpAlreadyUsed(userId, token) { return _usedTotp.get(userId) === token; }
function markTotpUsed(userId, token) {
_usedTotp.set(userId, token);
setTimeout(() => { if (_usedTotp.get(userId) === token) _usedTotp.delete(userId); }, 90 * 1000).unref();
}
// Gzip responses — the interlinear JSON files shrink ~10x, and JS/CSS/API payloads too.
@@ -101,7 +126,8 @@ app.get('/api/health', (_req, res) => {
app.post('/api/auth/register', async (req, res) => {
try {
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
if (isLimited(`ip:${req.ip}`)) return res.status(429).json(TOO_MANY);
recordFailure(`ip:${req.ip}`); // every registration attempt counts
const email = String(req.body?.email ?? '').trim().toLowerCase();
const password = String(req.body?.password ?? '');
@@ -139,15 +165,18 @@ app.post('/api/auth/register', async (req, res) => {
app.post('/api/auth/login', async (req, res) => {
try {
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
const email = String(req.body?.email ?? '').trim().toLowerCase();
const password = String(req.body?.password ?? '');
const keys = [`ip:${req.ip}`, `acct:${email}`];
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
const user = getUserByEmail(email);
const valid = user && await verifyPassword(password, user.passwordHash);
const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user;
if (!valid) {
recordFailure(...keys);
return res.status(401).json({ error: 'Incorrect email or password.' });
}
clearFailures(`acct:${email}`);
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
@@ -168,11 +197,12 @@ app.post('/api/auth/login', async (req, res) => {
app.post('/api/auth/mfa/verify', async (req, res) => {
try {
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
const pendingUserId = req.session?.pendingUserId;
if (!pendingUserId) {
return res.status(400).json({ error: 'No sign-in in progress.' });
}
const keys = [`ip:${req.ip}`, `mfa:${pendingUserId}`];
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
const user = getUserById(pendingUserId);
if (!user || !user.totpEnabled) {
return res.status(400).json({ error: 'No sign-in in progress.' });
@@ -180,7 +210,8 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
const token = req.body?.token;
const backupCode = req.body?.backupCode;
let ok = token ? verifyTotpToken(String(token), user.totpSecret) : false;
let ok = token ? verifyTotpToken(String(token), user.totpSecret) && !totpAlreadyUsed(user.id, String(token)) : false;
if (ok) markTotpUsed(user.id, String(token));
if (!ok && backupCode) {
const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes);
@@ -191,8 +222,10 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
}
if (!ok) {
recordFailure(...keys);
return res.status(401).json({ error: 'Invalid code.' });
}
clearFailures(`mfa:${pendingUserId}`);
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Could not create session.' });
@@ -205,6 +238,66 @@ app.post('/api/auth/mfa/verify', async (req, res) => {
}
});
// ---------------------------------------------------------------------------
// Forgot password. There is no email here, so the one-time code goes to the account's ntfy topic (the same
// one used for reading reminders). Accounts without a topic still go through the admin. The answer is the
// same whether or not the account exists, so this can't be used to find out who has one.
// ---------------------------------------------------------------------------
const RESET_MINUTES = 15;
const RESET_REPLY = { ok: true, message: 'If that account has a notification topic set up, a reset code was just sent to it. Codes last 15 minutes. No topic? Ask the administrator for a temporary password.' };
app.post('/api/auth/forgot', async (req, res) => {
try {
const email = String(req.body?.email ?? '').trim().toLowerCase();
const keys = [`ip:${req.ip}`, `forgot:${email}`];
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
recordFailure(...keys); // every request counts
const user = isValidEmail(email) ? getUserByEmail(email) : null;
if (user?.ntfyTopic) {
const code = generateResetCode();
setPasswordReset(user.id, await hashPassword(code), Date.now() + RESET_MINUTES * 60 * 1000);
sendNtfy(user, { title: 'Password reset code', body: `Your Bible Study reset code is ${code}. It works for ${RESET_MINUTES} minutes. If you didn't ask for it, ignore this.`, tags: 'key' })
.catch((err) => console.error('Password reset notification failed:', err.message));
}
res.json(RESET_REPLY);
} catch (err) {
console.error('POST /api/auth/forgot error:', err);
res.status(500).json({ error: 'Failed to start the reset.' });
}
});
app.post('/api/auth/reset', async (req, res) => {
try {
const email = String(req.body?.email ?? '').trim().toLowerCase();
const keys = [`ip:${req.ip}`, `reset:${email}`];
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
const fail = (error) => { recordFailure(...keys); return res.status(400).json({ error }); };
const newPassword = String(req.body?.newPassword ?? '');
if (!isValidPassword(newPassword)) return res.status(400).json({ error: 'New password must be at least 8 characters.' });
const user = getUserByEmail(email);
const reset = user ? getPasswordReset(user.id) : null;
const code = normalizeResetCode(req.body?.code);
// Always spend one bcrypt compare, so timing doesn't show whether a code was issued
const codeOk = await verifyPassword(code, reset?.codeHash ?? DUMMY_HASH) && !!reset && reset.attempts < 5;
if (reset && !codeOk) bumpPasswordResetAttempts(user.id);
if (!codeOk) return fail('That code is wrong or has expired. Ask for a new one.');
if (user.totpEnabled) {
const token = String(req.body?.token ?? '');
if (!verifyTotpToken(token, user.totpSecret) || totpAlreadyUsed(user.id, token)) return fail('Enter the current 6-digit code from your authenticator app too.');
markTotpUsed(user.id, token);
}
setUserPassword(user.id, await hashPassword(newPassword));
clearPasswordReset(user.id);
destroyAllSessionsForUser(user.id);
clearFailures(...keys);
res.json({ ok: true });
} catch (err) {
console.error('POST /api/auth/reset error:', err);
res.status(500).json({ error: 'Failed to reset the password.' });
}
});
app.post('/api/auth/logout', (req, res) => {
req.session.destroy(() => {
res.clearCookie('connect.sid');
@@ -235,7 +328,7 @@ app.patch('/api/auth/profile', requireAuth, (req, res) => {
// ---------------------------------------------------------------------------
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
// ---------------------------------------------------------------------------
app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => {
app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
try {
const reminderEnabled = !!req.body?.reminderEnabled;
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
@@ -249,6 +342,9 @@ app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => {
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
}
if (ntfyUrl) {
try { await assertPublicUrl(ntfyUrl); } catch (err) { return res.status(400).json({ error: err.message }); }
}
if (reminderEnabled) {
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' });
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) {
@@ -281,14 +377,17 @@ app.post('/api/auth/change-password', requireAuth, async (req, res) => {
const currentPassword = String(req.body?.currentPassword ?? '');
const newPassword = String(req.body?.newPassword ?? '');
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
const valid = await verifyPassword(currentPassword, user.passwordHash);
if (!valid) {
recordFailure(`pw:${user.id}`);
return res.status(401).json({ error: 'Current password is incorrect.' });
}
if (!isValidPassword(newPassword)) {
return res.status(400).json({ error: 'New password must be at least 8 characters.' });
}
clearFailures(`pw:${user.id}`);
const passwordHash = await hashPassword(newPassword);
setUserPassword(user.id, passwordHash);
// Sign out every other session (e.g. a stolen one) but keep this one logged in.
@@ -327,7 +426,9 @@ app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
if (!secret) {
return res.status(400).json({ error: 'Start 2FA setup first.' });
}
if (isLimited(`mfaset:${req.session.userId}`)) return res.status(429).json(TOO_MANY);
if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) {
recordFailure(`mfaset:${req.session.userId}`);
return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' });
}
@@ -345,8 +446,10 @@ app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => {
try {
const user = getUserById(req.session.userId);
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash);
if (!valid) {
recordFailure(`pw:${user.id}`);
return res.status(401).json({ error: 'Incorrect password.' });
}
disableTotp(user.id);
@@ -358,6 +461,28 @@ app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => {
});
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// GET /api/account/export — everything this account holds, as one restorable file
// ---------------------------------------------------------------------------
app.get('/api/account/export', requireAuth, (req, res) => {
try {
const userId = req.session.userId;
const user = getUserById(userId);
const backup = buildAccountExport(user, {
projects: () => getAllProjects(userId).map((p) => getProject(p.id, userId)).filter(Boolean),
prefs: () => getUserPrefs(userId),
marks: () => getReaderMarks(userId),
ink: () => getAllReaderInk(userId),
plans: () => getReadingProgress(userId),
});
res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`);
res.json(backup);
} catch (err) {
console.error('GET /api/account/export error:', err);
res.status(500).json({ error: 'Failed to export your data.' });
}
});
// GET /api/projects — list all project summaries owned by the current user
// ---------------------------------------------------------------------------
app.get('/api/projects', requireAuth, (req, res) => {
@@ -448,8 +573,10 @@ app.get('/api/reader/ink', requireAuth, (req, res) => {
app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
try {
const { book, chapter } = req.params;
const { strokes } = req.body;
const { strokes } = req.body ?? {};
if (!/^[1-3A-Za-z]{3}$/.test(book) || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
if (strokes.length > 20000) return res.status(413).json({ error: 'Too much ink on one page.' });
setReaderInkPage(req.session.userId, book, chapter, strokes);
res.json({ ok: true });
} catch (err) {
@@ -508,6 +635,7 @@ app.put('/api/reader/marks', requireAuth, (req, res) => {
// GET /api/reading-plans — the current user's whole progress list
registerBibleRoutes(app, requireAuth);
registerIqBibleRoutes(app, requireAuth);
app.get('/api/reading-plans', requireAuth, (req, res) => {
try {
@@ -525,7 +653,12 @@ app.put('/api/reading-plans', requireAuth, (req, res) => {
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
return res.status(400).json({ error: 'Body must be an object.' });
}
setReadingProgress(req.session.userId, req.body);
const { progress, customPlans, activity } = req.body;
if ([progress, customPlans, activity].some((v) => v !== undefined && !Array.isArray(v))
|| (progress?.length ?? 0) > 500 || (customPlans?.length ?? 0) > 200 || (activity?.length ?? 0) > 20000) {
return res.status(400).json({ error: 'progress, customPlans and activity must be reasonably sized lists.' });
}
setReadingProgress(req.session.userId, { progress: progress ?? [], customPlans: customPlans ?? [], activity: activity ?? [] });
res.json({ ok: true });
} catch (err) {
console.error('PUT /api/reading-plans error:', err);
@@ -677,6 +810,7 @@ app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
return res.status(400).json({ error: "Can't delete your own admin account." });
}
adminDeleteUser(req.params.id);
destroyAllSessionsForUser(req.params.id);
res.json({ ok: true });
} catch (err) {
console.error('DELETE /api/admin/users/:id error:', err);
@@ -756,6 +890,7 @@ if (isProd) {
// ---------------------------------------------------------------------------
initDb();
startReminderScheduler();
startBackupScheduler();
app.listen(PORT, () => {
console.log(`Bible Study API running on http://localhost:${PORT}`);
if (isProd) {
+143
View File
@@ -0,0 +1,143 @@
// Proxy for the IQ Bible API (https://api.iqbible.com): commentaries, lexicons, original-language text,
// places, topics, devotionals, narrations and more. The API key stays on the server (IQBIBLE_API_KEY,
// from the environment or .env); the browser only talks to /api/iq/... and only to the routes listed here.
// Only verse/chapter text is cached (up to 24h, as the API's terms require); see isTextPath.
import { existsSync, readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const envPath = join(__dirname, '..', '.env');
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
}
}
const BASE = () => (process.env.IQBIBLE_API_BASE || 'https://api.iqbible.com/api/v2').replace(/\/+$/, '');
const S = '[\\w.~%-]+'; // one path segment
const route = (pattern, opts = {}) => ({ re: new RegExp(`^${pattern.replace(/@/g, S)}/?$`), ...opts });
// Every upstream route the app may reach. Anything else is a 404 here, whatever the browser asks for.
export const ROUTES = [
route('/commentaries'), route('/commentaries/@/@/@'), route('/commentaries/@/@/@/@'),
route('/books/@/info'), route('/books/@/chapters/@/info'), route('/books/@/commentaries'), route('/books/abbreviations'),
route('/lexicon/sources'), route('/lexicon/crosswalk/@'),
route('/lexicon/(?:greek|hebrew)/(?:strongs|bdb|lsj|abbott-smith)/@'), route('/lexicon/greek/lsj/word/@'),
route('/original-language/@/@'), route('/original-language/@/@/@'), route('/morphology/@'),
route('/geo/@/@'), route('/geo/@/@/@'), route('/geo/places/@'),
route('/topics'), route('/topics/@'), route('/topics/@/@'), route('/topics/@/@/@'),
route('/genealogies/@'), route('/bible-characters/@'), route('/bible-people/@'), route('/bible-people/@/@'),
route('/harmony'), route('/harmony/@'), route('/textual-variants/@/@'), route('/textual-variants/@/@/@'),
route('/chronology/for/@/@'), route('/reference/weights-measures'),
route('/cross-references/@/@'), route('/cross-references/@/@/@'),
route('/dictionaries'), route('/dictionaries/@'),
route('/devotionals/today'), route('/devotionals/@/@'),
route('/bibles'), route('/bibles/languages'), route('/bibles/parallel'), route('/bibles/@'), route('/bibles/@/@'), route('/bibles/@/verse-of-the-day'), route('/bibles/@/reading-plan'),
route('/bibles/@/@/@/@'), route('/bibles/@/@/@'),
route('/audio'), route('/audio/@/@/@', { cache: false }),
route('/ai/context', { heavy: true }), route('/bibles/@/search', { heavy: true }), route('/parse/citations'),
];
// Verse and chapter text is the one thing the API's terms let (and require) a client to cache, for up to 24h.
// Everything else caps at 1 hour and needs written permission, so it is only cached when IQBIBLE_CACHE_HOURS
// is set (do that only once permission is on file).
const TEXT_PATH = /^\/bibles\/[\w]+\/(?!random\/|search|reading-plan|verse-of-the-day|verses|books|word-count)[\w.~%-]+\/\d+(?:\/[\d-]+)?$/;
export const isTextPath = (p) => TEXT_PATH.test(p.split('?')[0]);
const QUERY_KEY = /^[\w-]{1,40}$/;
// Maps an incoming sub-path + query onto an upstream URL, or null when it isn't an allowed route.
// The API key can never be supplied by the browser (apikey is dropped).
export function resolveIqPath(path, query = {}) {
const clean = `/${String(path ?? '').replace(/^\/+/, '')}`;
if (clean.includes('..') || clean.includes('//') || clean.length > 300) return null;
const hit = ROUTES.find((r) => r.re.test(clean));
if (!hit) return null;
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(query).sort(([a], [b]) => a.localeCompare(b))) {
if (!QUERY_KEY.test(k) || k.toLowerCase() === 'apikey' || typeof v !== 'string' || v.length > 200) continue;
qs.append(k, v);
}
const q = qs.toString();
const key = `${clean}${q ? `?${q}` : ''}`;
return { key, cache: hit.cache !== false, heavy: !!hit.heavy, text: isTextPath(clean) };
}
// ---- caching, bounded by the API's own licensing summary ----
let policy = null; // { textMs, otherMs }
let policyAt = 0;
async function cacheTtl(headers, isText) {
if (!policy || Date.now() - policyAt > 6 * 3600_000) {
try {
const res = await fetch(`${BASE()}/licensing`, { headers, signal: AbortSignal.timeout(8000) });
const s = (await res.json())?.summary;
const allowed = s?.caching_allowed !== false;
const textHours = allowed ? Math.min(Number(s?.caching_verse_chapter_hours) || 24, 24) : 0;
const optIn = Number(process.env.IQBIBLE_CACHE_HOURS) || 0; // only with the API owner's written permission
const otherHours = allowed ? Math.min(optIn, Number(s?.caching_max_hours) || 1) : 0;
policy = { textMs: textHours * 3600_000, otherMs: otherHours * 3600_000 };
} catch {
policy = { textMs: 0, otherMs: 0 };
}
policyAt = Date.now();
}
return isText ? policy.textMs : policy.otherMs;
}
const cache = new Map(); // key -> { at, status, type, body }
const CACHE_MAX = 1500;
const hits = new Map(); // `${userId}:${bucket}` -> { n, resetAt }
function overLimit(userId, heavy) {
const bucket = heavy ? 'h' : 'n';
const max = heavy ? 10 : 120; // per minute, per signed-in user (the API budget is shared by everyone)
const k = `${userId}:${bucket}`;
const now = Date.now();
const e = hits.get(k);
if (!e || now > e.resetAt) { hits.set(k, { n: 1, resetAt: now + 60_000 }); return false; }
e.n += 1;
return e.n > max;
}
export function registerIqBibleRoutes(app, requireAuth) {
const apiKey = () => process.env.IQBIBLE_API_KEY;
app.get('/api/iq/status', requireAuth, (_req, res) => res.json({ available: Boolean(apiKey()) }));
app.get('/api/iq/*', requireAuth, async (req, res) => {
const target = resolveIqPath(req.params[0], req.query);
if (!target) return res.status(404).json({ error: 'Unknown IQ Bible route.' });
if (!apiKey()) return res.status(503).json({ error: 'IQBIBLE_API_KEY is not configured on the server.' });
if (overLimit(req.session.userId, target.heavy)) return res.status(429).json({ error: 'Slow down a little and try again in a minute.' });
const headers = { 'X-IQBible-API-Key': apiKey(), Accept: '*/*' };
const hit = target.cache && (target.text || process.env.IQBIBLE_CACHE_HOURS) ? cache.get(target.key) : null;
if (hit && Date.now() - hit.at < hit.ttl) {
res.status(hit.status).type(hit.type).send(hit.body);
return;
}
try {
const upstream = await fetch(`${BASE()}${target.key}`, { headers, signal: AbortSignal.timeout(20_000) });
const body = Buffer.from(await upstream.arrayBuffer());
const type = upstream.headers.get('content-type') || 'application/json';
if (upstream.status === 401) return res.status(502).json({ error: 'The IQ Bible key was rejected. Check IQBIBLE_API_KEY.' });
if (upstream.status === 429) {
const retry = upstream.headers.get('retry-after');
if (retry) res.set('Retry-After', retry);
return res.status(429).json({ error: 'The IQ Bible allowance is used up for now. Try again later.' });
}
if (upstream.ok && target.cache && (target.text || process.env.IQBIBLE_CACHE_HOURS)) {
const ttl = await cacheTtl(headers, target.text);
if (ttl > 0) {
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
cache.set(target.key, { at: Date.now(), ttl, status: upstream.status, type, body });
}
}
res.status(upstream.status).type(type).send(body);
} catch {
res.status(502).json({ error: 'Could not reach the IQ Bible service.' });
}
});
}
+34
View File
@@ -0,0 +1,34 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { resolveIqPath } from './iqbible.js';
test('only listed IQ Bible routes are reachable', () => {
expect(resolveIqPath('commentaries/gill/JHN/3/16')?.key).toBe('/commentaries/gill/JHN/3/16');
expect(resolveIqPath('/geo/JHN/3')?.cache).toBe(true);
expect(resolveIqPath('/me')).toBeNull();
expect(resolveIqPath('/licensing')).toBeNull();
expect(resolveIqPath('/commentaries/gill/../me')).toBeNull();
expect(resolveIqPath('/lexicon/greek/strongs/G26')).not.toBeNull();
});
test('lexicon sources and audio routes', () => {
expect(resolveIqPath('/lexicon/greek/lsj/G0026')).not.toBeNull();
expect(resolveIqPath('/lexicon/hebrew/bdb/BDB1005')).not.toBeNull();
expect(resolveIqPath('/audio/eng_kjv/JHN/3')?.cache).toBe(false);
expect(resolveIqPath('/ai/context', { ref: 'JHN.3.16' })?.heavy).toBe(true);
});
test('the browser cannot send its own key and the query is normalised', () => {
const r = resolveIqPath('/bibles/eng_kjv/reading-plan', { days: '30', apikey: 'x', 'bad key': '1', format: 'ical' });
expect(r.key).toBe('/bibles/eng_kjv/reading-plan?days=30&format=ical');
});
test('only verse and chapter text counts as cacheable text', async () => {
const { isTextPath } = await import('./iqbible.js');
expect(isTextPath('/bibles/eng_kjv/JHN/3')).toBe(true);
expect(isTextPath('/bibles/eng_kjv/JHN/3/16')).toBe(true);
expect(isTextPath('/bibles/eng_kjv/random/verse')).toBe(false);
expect(isTextPath('/bibles/eng_kjv/verse-of-the-day')).toBe(false);
expect(isTextPath('/commentaries/gill/JHN/3')).toBe(false);
expect(resolveIqPath('/bibles/parallel', { v: 'eng_kjv,spa_rv' })).not.toBeNull();
});
+48 -2
View File
@@ -2,12 +2,48 @@
// a free pub/sub push service: POST a message to a topic URL, anyone
// subscribed to that topic (via the ntfy app or a browser) gets notified.
// No API keys or push-subscription plumbing needed.
import { lookup } from 'dns/promises';
import { isIP } from 'net';
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js';
// A reminder still goes out if the server was briefly down (or restarting) at the
// exact minute — as long as it's within this window after the chosen time.
const GRACE_MINUTES = 180;
const SEND_TIMEOUT_MS = 10_000;
// A failed send is retried at most this often, so a dead ntfy server isn't hit every minute for hours.
const RETRY_AFTER_MS = 15 * 60 * 1000;
const _lastAttempt = new Map(); // userId -> timestamp of the last failed send
// True for loopback, private, link-local and other non-public addresses (IPv4 and IPv6).
export function isPrivateAddress(addr) {
const v = isIP(addr);
if (v === 4) {
const [a, b] = addr.split('.').map(Number);
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
}
if (v === 6) {
const l = addr.toLowerCase();
if (l === '::' || l === '::1') return true;
if (l.startsWith('::ffff:')) return isPrivateAddress(l.slice(7));
return /^f[cd]/.test(l) || /^fe[89ab]/.test(l);
}
return true;
}
// Rejects server URLs that would make this server call itself or its private network.
// A self-hosted ntfy on the LAN can be allowed by the server's admin with ALLOW_PRIVATE_NTFY=1.
export async function assertPublicUrl(raw) {
if (process.env.ALLOW_PRIVATE_NTFY === '1') return;
let u;
try { u = new URL(raw); } catch { throw new Error('Server URL is not valid.'); }
if (!/^https?:$/.test(u.protocol)) throw new Error('Server URL must start with http:// or https://.');
const host = u.hostname.replace(/^\[|\]$/g, '');
if (/^localhost$|\.localhost$|\.local$|\.internal$/i.test(host)) throw new Error('Server URL must be a public address.');
const addrs = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((a) => a.address);
if (addrs.length === 0) throw new Error(`Could not find ${host}.`);
if (addrs.some(isPrivateAddress)) throw new Error('Server URL must be a public address.');
}
export function isValidTimeZone(tz) {
if (!tz) return false;
@@ -40,17 +76,23 @@ export function isDue(user, now = new Date()) {
export async function sendNtfy(user, { title, body, tags = 'open_book' }) {
const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`;
await assertPublicUrl(url);
const headers = { Title: title, Tags: tags };
if (process.env.APP_URL) headers.Click = process.env.APP_URL; // tapping the notification opens the app
let res;
try {
res = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
res = await fetch(url, { method: 'POST', headers, body, redirect: 'error', signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
} catch (err) {
throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`);
throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`, { cause: err });
}
if (!res.ok) throw new Error(`ntfy responded ${res.status}`);
}
// Has the user already finished a reading day on `date` ("YYYY-MM-DD", in their timezone)?
export function readToday(progress, date) {
return Array.isArray(progress?.activity) && progress.activity.includes(date);
}
export function reminderBody(progress) {
const active = (Array.isArray(progress?.progress) ? progress.progress : []).filter((p) => !p.completedAt).length;
return active > 1 ? `Don't forget today's reading! You have ${active} plans in progress.` : "Don't forget today's reading!";
@@ -67,7 +109,11 @@ export async function checkReminders(now = new Date()) {
const progress = getReadingProgress(user.id);
const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt);
if (!hasActivePlan) continue; // nothing to nudge them about
if (readToday(progress, zonedNow(now, user.reminderTz).date)) continue; // already read today: no nudge
if (now.getTime() - (_lastAttempt.get(user.id) ?? 0) < RETRY_AFTER_MS) continue;
_lastAttempt.set(user.id, now.getTime());
await sendNtfy(user, { title: 'Reading Plan Reminder', body: reminderBody(progress) });
_lastAttempt.delete(user.id);
setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date);
} catch (err) {
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
+15
View File
@@ -1,6 +1,7 @@
// @vitest-environment node
import http from 'node:http';
import { test, expect, afterEach } from 'vitest';
process.env.ALLOW_PRIVATE_NTFY = '1'; // the tests talk to a local server
import { isDue, isValidTimeZone, reminderBody, sendNtfy, sendTestReminder, zonedNow } from './reminders.js';
const at = (iso) => new Date(iso);
@@ -84,3 +85,17 @@ test('an ntfy error response becomes a readable failure', async () => {
test('an unreachable server says so instead of hanging', async () => {
await expect(sendNtfy({ ntfyUrl: 'http://127.0.0.1:9', ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow(/Could not reach/);
});
test('private and loopback addresses are rejected as reminder servers', async () => {
const { isPrivateAddress, assertPublicUrl, readToday } = await import('./reminders.js');
for (const a of ['127.0.0.1', '10.1.2.3', '192.168.0.5', '172.16.0.1', '169.254.169.254', '::1', 'fd00::1', '::ffff:10.0.0.1']) expect(isPrivateAddress(a)).toBe(true);
expect(isPrivateAddress('8.8.8.8')).toBe(false);
const prev = process.env.ALLOW_PRIVATE_NTFY;
delete process.env.ALLOW_PRIVATE_NTFY;
await expect(assertPublicUrl('http://localhost:3001')).rejects.toThrow();
await expect(assertPublicUrl('http://169.254.169.254/latest')).rejects.toThrow();
await expect(assertPublicUrl('ftp://example.com')).rejects.toThrow();
process.env.ALLOW_PRIVATE_NTFY = prev;
expect(readToday({ activity: ['2026-09-29'] }, '2026-09-29')).toBe(true);
expect(readToday({ activity: [] }, '2026-09-29')).toBe(false);
});
+89 -29
View File
@@ -22,6 +22,9 @@ import {
bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser,
} from './utils/bibleUtils.jsx';
import { loadProjectById, migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
import { knownOnServer, unsyncedStudies } from './utils/syncQueue.js';
import { fetchStudyChapter } from './utils/studyText.js';
import { mergeStudies } from './utils/mergeStudies.js';
// Less-common pages are split into their own chunks so the first load only
// downloads what's needed to show the home screen.
@@ -100,6 +103,8 @@ const AppInner = () => {
onSignOut: () => {
reader.stopMarksSync();
reader.stopPrefsSync();
reader.stopInkSync();
readingPlans.stopReadingPlansSync();
setProjectIndex(switchStorageUser(null));
goStudies();
},
@@ -163,10 +168,12 @@ const AppInner = () => {
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
// Autosave: 1s after the last edit, save locally, then sync to the server.
// Autosave: 1s after the last edit, save locally, then sync to the server. A save still waiting out
// that second is written straight away when you leave the study or the tab, so nothing typed is lost.
// Opening a study doesn't count as editing it — saving then would stamp the server's copy as "newer"
// and make your other devices think it changed.
const savedSignatureRef = useRef({ id: null, signature: '' });
const pendingSaveRef = useRef(null); // { project, signature } waiting for the debounce
const [syncConflict, setSyncConflict] = useState(null); // { id, serverEdited } when the server has a newer copy
const conflictRef = useRef(null);
conflictRef.current = syncConflict;
@@ -177,7 +184,15 @@ const AppInner = () => {
setSyncStatus('syncing');
const result = await saveRemoteProject(toSave, { baseEdited: getSyncBase(toSave.id), force });
if (result.ok) {
setSyncBase(toSave.id, result.data?.lastEdited ?? toSave.lastEdited);
// The server's clock decides the version; keep this device's copy on the same number
const serverEdited = result.data?.lastEdited ?? toSave.lastEdited;
setSyncBase(toSave.id, serverEdited);
unsyncedStudies.remove(toSave.id);
knownOnServer.add(toSave.id);
const stored = loadProjectById(toSave.id);
if (stored && stored.lastEdited === toSave.lastEdited && serverEdited !== toSave.lastEdited) {
try { saveProjectToStorage({ ...stored, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* the copy is already saved */ }
}
setSyncConflict((c) => (c?.id === toSave.id ? null : c));
setSyncStatus('synced');
window.setTimeout(() => setSyncStatus(''), 2500);
@@ -190,8 +205,49 @@ const AppInner = () => {
return result;
};
const commitSave = async (toEdit) => {
// Stamp lastEdited once so localStorage and server receive the same value
const toSave = { ...toEdit, lastEdited: Date.now() };
// 1. Save locally (can fail when the device is out of storage)
try {
saveProjectToStorage(toSave);
} catch (err) {
console.error('Local save failed:', err);
setSaveStatus('');
toast("Couldn't save on this device — storage is full. Free some space or export a backup.", { tone: 'error' });
}
unsyncedStudies.add(toSave.id);
setProjectIndex(loadProjectIndex());
setSaveStatus('Saved');
window.setTimeout(() => setSaveStatus(''), 1400);
// 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice)
if (conflictRef.current?.id === toSave.id) return;
await pushProject(toSave);
};
const flushPendingSave = () => {
const pending = pendingSaveRef.current;
if (!pending) return;
window.clearTimeout(saveTimerRef.current);
pendingSaveRef.current = null;
savedSignatureRef.current = { id: pending.project.id, signature: pending.signature };
commitSave(pending.project);
};
const flushRef = useRef(flushPendingSave);
flushRef.current = flushPendingSave;
useEffect(() => {
if (!project) return undefined;
const flush = () => { if (document.visibilityState !== 'visible') flushRef.current(); };
window.addEventListener('pagehide', flush);
document.addEventListener('visibilitychange', flush);
return () => { window.removeEventListener('pagehide', flush); document.removeEventListener('visibilitychange', flush); };
}, []);
useEffect(() => {
if (!project) { flushPendingSave(); return undefined; }
if (pendingSaveRef.current && pendingSaveRef.current.project.id !== project.id) flushPendingSave();
const signature = signatureOf(project);
const last = savedSignatureRef.current;
if (last.id !== project.id) {
@@ -200,25 +256,10 @@ const AppInner = () => {
} else if (last.signature === signature) {
return undefined;
}
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(async () => {
savedSignatureRef.current = { id: project.id, signature };
// Stamp lastEdited once so localStorage and server receive the same value
const toSave = { ...project, lastEdited: Date.now() };
// 1. Save locally
saveProjectToStorage(toSave);
setProjectIndex(loadProjectIndex());
setSaveStatus('Saved');
window.setTimeout(() => setSaveStatus(''), 1400);
// 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice)
if (conflictRef.current?.id === toSave.id) return;
await pushProject(toSave);
}, 1000);
return () => {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
};
pendingSaveRef.current = { project, signature };
window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(() => flushRef.current(), 1000);
return undefined;
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
// The two ways out of a conflict: keep this device's version, or take the server's.
@@ -232,6 +273,7 @@ const AppInner = () => {
if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; }
saveProjectToStorage(result.data);
setSyncBase(syncConflict.id, result.data.lastEdited);
unsyncedStudies.remove(syncConflict.id);
const loaded = loadProjectById(syncConflict.id);
savedSignatureRef.current = { id: syncConflict.id, signature: signatureOf(loaded) };
setProject(loaded);
@@ -241,6 +283,25 @@ const AppInner = () => {
toast('Switched to the version from your other device');
};
// Third way out of a conflict: combine both copies chunk by chunk, then save the result as the new version.
const mergeBothVersions = async () => {
if (!project || !syncConflict) return;
const result = await loadRemoteProject(syncConflict.id);
if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; }
const { project: merged, stats } = mergeStudies(project, result.data);
setSyncBase(syncConflict.id, result.data.lastEdited);
savedSignatureRef.current = { id: merged.id, signature: signatureOf(merged) };
setProject(merged);
const toSave = { ...merged, lastEdited: Date.now() };
try { saveProjectToStorage(toSave); } catch { /* storage full: the server copy below still holds it */ }
unsyncedStudies.add(toSave.id);
setProjectIndex(loadProjectIndex());
const saved = await pushProject(toSave);
if (saved.ok) {
toast(`Combined both versions: ${stats.added} chunk${stats.added === 1 ? '' : 's'} added from the other device, ${stats.merged} merged`);
}
};
const updateProject = (updater) => {
setProject((current) => (current ? updater(current) : current));
};
@@ -250,7 +311,7 @@ const AppInner = () => {
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch } : c)),
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, editedAt: Date.now() } : c)),
})),
}));
};
@@ -309,9 +370,7 @@ const AppInner = () => {
setStudyBuildBusy(true);
try {
const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => {
const res = await fetch(`https://bible.helloao.org/api/BSB/${book}/${chapter}.json`);
if (!res.ok) throw new Error(`Failed to load ${book} ${chapter}`);
const data = await res.json();
const data = await fetchStudyChapter('BSB', book, chapter);
const verses = parseBibleChapter(data);
const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book;
const chunk = {
@@ -337,7 +396,7 @@ const AppInner = () => {
setCurrentPage('study');
} catch (err) {
console.error('studyReadingPlanDay error:', err);
toast("Couldn't load today's passage. Please try again.", { tone: 'error' });
toast(err?.message?.startsWith("You're offline") ? err.message : "Couldn't load today's passage. Please try again.", { tone: 'error' });
} finally {
setStudyBuildBusy(false);
}
@@ -374,7 +433,8 @@ const AppInner = () => {
const retrySync = async () => {
if (!project) return;
const toSave = { ...project, lastEdited: Date.now() };
saveProjectToStorage(toSave);
try { saveProjectToStorage(toSave); } catch { /* storage full: still try the server */ }
unsyncedStudies.add(toSave.id);
await pushProject(toSave);
};
@@ -395,7 +455,7 @@ const AppInner = () => {
saveStatus, setSaveStatus,
syncStatus,
retrySync,
syncConflict, keepMyVersion, useServerVersion,
syncConflict, keepMyVersion, useServerVersion, mergeBothVersions,
// Home
projectIndex,
...library,
-1441
View File
File diff suppressed because it is too large. Load diff
+82
View File
@@ -0,0 +1,82 @@
// "Forgot password": ask for a code (it is sent to the account's notification topic), then choose a new
// password with it. Shown on the sign-in page in place of the sign-in form.
import { useState } from 'react';
import { requestPasswordReset, resetPassword } from '../syncService.js';
const input = 'mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]';
const primary = 'w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50';
export default function ResetPasswordForm({ initialEmail = '', onDone, onBack }) {
const [step, setStep] = useState('ask'); // 'ask' | 'code' | 'done'
const [email, setEmail] = useState(initialEmail);
const [form, setForm] = useState({ code: '', password: '', token: '' });
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const ask = async (e) => {
e.preventDefault();
setError(''); setBusy(true);
const result = await requestPasswordReset(email.trim());
setBusy(false);
if (!result.ok) { setError(result.error ?? 'Something went wrong.'); return; }
setMessage(result.data?.message ?? 'If that account has a notification topic, a code was sent.');
setStep('code');
};
const reset = async (e) => {
e.preventDefault();
setError(''); setBusy(true);
const result = await resetPassword({ email: email.trim(), code: form.code, newPassword: form.password, token: form.token.trim() || undefined });
setBusy(false);
if (!result.ok) { setError(result.error ?? 'Something went wrong.'); return; }
setStep('done');
};
if (step === 'done') {
return (
<div>
<h2 className="text-xl font-medium">Password changed</h2>
<p className="mt-2 text-sm text-[var(--r-sub)]">You’ve been signed out everywhere. Sign in with your new password.</p>
<button type="button" onClick={onDone} className={`mt-6 ${primary}`}>Back to sign in</button>
</div>
);
}
return (
<div>
<h2 className="text-xl font-medium">Reset your password</h2>
{step === 'ask' ? (
<form onSubmit={ask} className="mt-4 space-y-4">
<p className="text-sm text-[var(--r-sub)]">We’ll send a one-time code to the notification topic you set up for reading reminders (ntfy).</p>
<label className="block text-sm">Email
<input type="email" required autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} className={input} />
</label>
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
<button type="submit" disabled={busy} className={primary}>{busy ? 'Please wait…' : 'Send me a code'}</button>
<button type="button" onClick={() => { setStep('code'); setMessage(''); }} className="block w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">I already have a code</button>
</form>
) : (
<form onSubmit={reset} className="mt-4 space-y-4">
{message && <p role="status" className="text-sm text-[var(--r-sub)]">{message}</p>}
<label className="block text-sm">Email
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} className={input} />
</label>
<label className="block text-sm">Code
<input type="text" required autoFocus autoComplete="one-time-code" value={form.code} onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))} className={`${input} text-center font-mono tracking-widest`} placeholder="ABCD2345" />
</label>
<label className="block text-sm">New password
<input type="password" required minLength={8} autoComplete="new-password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} className={input} />
<span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>
</label>
<label className="block text-sm">Authenticator code <span className="text-xs text-[var(--r-sub)]">(only if you use two-factor sign-in)</span>
<input type="text" inputMode="numeric" value={form.token} onChange={(e) => setForm((f) => ({ ...f, token: e.target.value }))} className={input} placeholder="123456" />
</label>
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
<button type="submit" disabled={busy} className={primary}>{busy ? 'Please wait…' : 'Change password'}</button>
</form>
)}
<button type="button" onClick={onBack} className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Back to sign in</button>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
vi.mock('../syncService.js', () => ({ requestPasswordReset: vi.fn(), resetPassword: vi.fn() }));
import ResetPasswordForm from './ResetPasswordForm.jsx';
import { requestPasswordReset, resetPassword } from '../syncService.js';
beforeEach(() => {
vi.clearAllMocks();
requestPasswordReset.mockResolvedValue({ ok: true, data: { message: 'A code was sent if you have a topic.' } });
resetPassword.mockResolvedValue({ ok: true, data: { ok: true } });
});
test('asks for a code, then sets the new password with it', async () => {
const user = userEvent.setup();
const onDone = vi.fn();
render(<ResetPasswordForm initialEmail="me@x.co" onDone={onDone} onBack={() => {}} />);
await user.click(screen.getByRole('button', { name: /send me a code/i }));
expect(requestPasswordReset).toHaveBeenCalledWith('me@x.co');
expect(await screen.findByText(/A code was sent/)).toBeInTheDocument();
await user.type(screen.getByLabelText(/^Code/), 'ABCD2345');
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
await user.click(screen.getByRole('button', { name: /change password/i }));
expect(resetPassword).toHaveBeenCalledWith({ email: 'me@x.co', code: 'ABCD2345', newPassword: 'brand-new-pass', token: undefined });
await user.click(await screen.findByRole('button', { name: /back to sign in/i }));
expect(onDone).toHaveBeenCalled();
});
test('a wrong code shows the server\'s message and stays on the form', async () => {
resetPassword.mockResolvedValue({ ok: false, error: 'That code is wrong or has expired. Ask for a new one.' });
const user = userEvent.setup();
render(<ResetPasswordForm initialEmail="me@x.co" onDone={() => {}} onBack={() => {}} />);
await user.click(screen.getByRole('button', { name: /i already have a code/i }));
await user.type(screen.getByLabelText(/^Code/), 'WRONG123');
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
await user.click(screen.getByRole('button', { name: /change password/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong or has expired/);
});
test('an authenticator code is passed along when one is typed', async () => {
const user = userEvent.setup();
render(<ResetPasswordForm initialEmail="me@x.co" onDone={() => {}} onBack={() => {}} />);
await user.click(screen.getByRole('button', { name: /i already have a code/i }));
await user.type(screen.getByLabelText(/^Code/), 'ABCD2345');
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
await user.type(screen.getByLabelText(/Authenticator code/), '123456');
await user.click(screen.getByRole('button', { name: /change password/i }));
expect(resetPassword).toHaveBeenCalledWith(expect.objectContaining({ token: '123456' }));
});
+20
View File
@@ -2,11 +2,31 @@
// public-domain / open sources, plus the people, places and events tied to the verse.
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useIqAvailable } from '../hooks/useIq.js';
import IqCommentary from './iq/IqCommentary.jsx';
import {
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
} from '../hooks/useVerseStudy.js';
export function CommentaryTab({ book, chapter, verse }) {
const iq = useIqAvailable();
const [more, setMore] = useState(() => { try { return localStorage.getItem('iq-commentary-mode') === '1'; } catch { return false; } });
const setMode = (on) => { setMore(on); try { localStorage.setItem('iq-commentary-mode', on ? '1' : '0'); } catch { /* storage unavailable */ } };
const chip = (on) => `rounded-full border px-2.5 py-0.5 text-[11px] ${on ? 'border-[var(--r-text,#334155)] bg-[var(--r-text,#334155)] text-[var(--r-bg,#ffffff)]' : 'border-[var(--r-line,#cbd5e1)] text-[var(--r-sub,#64748b)]'}`;
return (
<div>
{iq && (
<div className="mb-2 flex gap-1.5" role="group" aria-label="Commentary library">
<button type="button" onClick={() => setMode(false)} aria-pressed={!more} className={chip(!more)}>Classic</button>
<button type="button" onClick={() => setMode(true)} aria-pressed={more} className={chip(more)}>More sources</button>
</div>
)}
{iq && more ? <IqCommentary book={book} chapter={chapter} verse={verse} /> : <ClassicCommentary book={book} chapter={chapter} verse={verse} />}
</div>
);
}
function ClassicCommentary({ book, chapter, verse }) {
// The chosen commentary is a synced preference, shared with the Study stage
const { readerCommentary: sourceId, setReaderCommentary: changeSource } = useApp();
const source = COMMENTARIES.find((c) => c.id === sourceId) ?? COMMENTARIES[0];
+56
View File
@@ -0,0 +1,56 @@
// More commentaries from IQ Bible: Gill and Matthew Henry plus hundreds of church-history sources.
// Only sources that actually have notes on the chapter being read are offered.
import { useState } from 'react';
import { useIq } from '../../hooks/useIq.js';
import { listOf, pickIqEntries } from '../../utils/iqbible.js';
const KEY = 'iq-commentary';
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
export default function IqCommentary({ book, chapter, verse }) {
const [chosen, setChosen] = useState(() => { try { return localStorage.getItem(KEY) || 'mhenry'; } catch { return 'mhenry'; } });
const sources = useIq(`books/${book}/commentaries`, { chapter });
const list = listOf(sources.data);
const source = list.find((s) => s.name === chosen) ?? list.find((s) => s.name === 'mhenry') ?? list[0];
const notes = useIq(source ? `commentaries/${source.name}/${book}/${chapter}` : null);
const entries = pickIqEntries(notes.data?.entries, chapter, verse);
const pick = (name) => { setChosen(name); try { localStorage.setItem(KEY, name); } catch { /* storage unavailable */ } };
const groups = list.reduce((acc, s) => { (acc[s.category || 'Other'] ??= []).push(s); return acc; }, {});
if (sources.status === 'loading') return <p className={muted}>Loading…</p>;
if (sources.status === 'error') return <p className={muted}>{sources.error}</p>;
if (!list.length) return <p className={muted}>No IQ Bible commentaries cover this chapter.</p>;
return (
<div>
<label className={muted}>
Source ({list.length}){' '}
<select value={source?.name ?? ''} onChange={(e) => pick(e.target.value)} className="max-w-[16rem] rounded-lg border border-[var(--r-line,#cbd5e1)] bg-[var(--r-bg,#ffffff)] px-2 py-1 text-xs">
{Object.entries(groups).map(([cat, items]) => (
<optgroup key={cat} label={cat}>
{items.map((s) => <option key={s.name} value={s.name}>{s.author_name}{s.year ? ` (${s.year})` : ''}</option>)}
</optgroup>
))}
</select>
</label>
<div className="mt-2 space-y-2">
{notes.status === 'loading' && <p className={muted}>Loading…</p>}
{notes.status === 'error' && <p className={muted}>{notes.error}</p>}
{(notes.status === 'empty' || (notes.status === 'ready' && entries.length === 0)) && (
<p className={muted}>{source?.author_name} has no notes at or before verse {verse} in this chapter.</p>
)}
{entries.map((e, i) => (
<div key={i}>
<p className="mb-1 text-[11px] text-[var(--r-sub,#64748b)]">
{e.verse !== verse || e.verse_end ? `Notes on verse${e.verse_end && e.verse_end !== e.verse ? `s ${e.verse}–${e.verse_end}` : ` ${e.verse}`}` : ''}
{e.source_title ? ` · ${e.source_title}` : ''}
</p>
<div className="max-h-96 overflow-y-auto whitespace-pre-line rounded-xl bg-[var(--r-bg,#ffffff)] p-3 text-[13px] leading-relaxed text-[var(--r-text,#334155)]">{e.text}</div>
</div>
))}
</div>
{source && <p className="mt-2 text-[10px] text-[var(--r-sub,#64748b)]">{source.author_name}{source.year ? `, ${source.year}` : ''} · via IQ Bible{source.license ? ` · ${source.license}` : ''}</p>}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
// Home card: today's verse and Spurgeon's Morning & Evening reading, both from IQ Bible.
import { useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
import { bookOptions } from '../../utils/bibleUtils.jsx';
export default function IqDailyCard() {
const on = useIqAvailable();
if (!on) return null;
return <Card />;
}
function Card() {
const { jumpToReaderVerse, openBibleReader } = useApp();
const votd = useIq('bibles/eng_kjv/verse-of-the-day');
const dev = useIq('devotionals/today');
const [part, setPart] = useState(() => (new Date().getHours() < 15 ? 'morning' : 'evening'));
const verse = votd.data?.data;
const reading = dev.data?.data?.[part];
const name = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
if (!verse && !reading) return null;
return (
<section className="mb-4 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Daily verse and devotional">
{verse && (
<div>
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Verse of the day · KJV</p>
<p className="mt-1 font-serif text-[15px] leading-relaxed">{verse.text}</p>
<button type="button" onClick={() => { jumpToReaderVerse(verse.book_usfm, verse.chapter_number, verse.verse_number); openBibleReader(); }}
className="mt-1 text-xs font-medium text-[var(--r-accent)] hover:underline">{name(verse.book_usfm)} {verse.chapter_number}:{verse.verse_number} — read in context</button>
</div>
)}
{reading && (
<details className="mt-3 border-t border-[var(--r-line)] pt-3">
<summary className="cursor-pointer text-sm font-medium">Spurgeon, {part === 'morning' ? 'Morning' : 'Evening'}: “{reading.heading_text}”</summary>
<div className="mt-2 flex gap-1.5">
{['morning', 'evening'].map((p) => (
<button key={p} type="button" onClick={() => setPart(p)} aria-pressed={part === p}
className={`rounded-full border px-2.5 py-0.5 text-[11px] ${part === p ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)]'}`}>{p === 'morning' ? 'Morning' : 'Evening'}</button>
))}
</div>
<p className="mt-1 text-xs text-[var(--r-sub)]">{reading.heading_citation}</p>
<div className="mt-2 max-h-72 overflow-y-auto whitespace-pre-line font-serif text-[15px] leading-relaxed">{reading.body}</div>
<p className="mt-2 text-[10px] text-[var(--r-sub)]">Charles Spurgeon, Morning and Evening (1866), via IQ Bible.</p>
</details>
)}
</section>
);
}
+87
View File
@@ -0,0 +1,87 @@
// A word's entry in the original-language lexicons: Strong's, then Brown-Driver-Briggs (Hebrew) or
// Liddell-Scott-Jones and Abbott-Smith (Greek), found through IQ Bible's Strong's crosswalk.
import { useState } from 'react';
import DOMPurify from 'dompurify';
import { useIq } from '../../hooks/useIq.js';
import { listOf, normalizeStrongs } from '../../utils/iqbible.js';
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
const SOURCES = { bdb: 'Brown-Driver-Briggs', lsj: 'Liddell-Scott-Jones', 'abbott-smith': 'Abbott-Smith' };
const html = (s) => DOMPurify.sanitize(String(s ?? ''), { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'br', 'p', 'span', 'ul', 'ol', 'li', 'sup', 'sub'], ALLOWED_ATTR: [] });
function SourceEntry({ lang, source, nativeKey }) {
const { status, data, error } = useIq(`lexicon/${lang}/${source}/${encodeURIComponent(nativeKey)}`);
const entry = listOf(data)[0];
return (
<details className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
<summary className="cursor-pointer text-sm font-medium">{SOURCES[source] ?? source}</summary>
<div className="mt-2 text-[13px] leading-relaxed">
{status === 'loading' && <p className={muted}>Loading…</p>}
{status === 'error' && <p className={muted}>{error}</p>}
{status === 'empty' && <p className={muted}>No entry.</p>}
{entry && source === 'bdb' && (
<>
<p><span className="font-serif text-base" dir="rtl">{entry.head_word}</span> <span className={muted}>{entry.pos}</span></p>
{entry.primary_gloss && <p className="font-medium">{entry.primary_gloss}</p>}
<div className="whitespace-pre-line" dangerouslySetInnerHTML={{ __html: html(entry.description) }} />
</>
)}
{entry && source !== 'bdb' && (
<>
<p><span className="font-serif text-base">{entry.greek}</span> <span className={muted}>{entry.transliteration}{entry.morph ? ` · ${entry.morph}` : ''}</span></p>
{entry.gloss && <p className="font-medium">{entry.gloss}</p>}
<div className="max-h-64 overflow-y-auto" dangerouslySetInnerHTML={{ __html: html(entry.meaning) }} />
</>
)}
</div>
</details>
);
}
function Occurrences({ strongs }) {
const { status, data, error } = useIq('original-language/search', { strongs, limit: 20 });
if (status === 'loading') return <p className={muted}>Loading…</p>;
if (status === 'error') return <p className={muted}>{error}</p>;
const list = data?.occurrences ?? [];
if (!list.length) return <p className={muted}>No occurrences found.</p>;
return (
<p className="text-[13px] leading-relaxed">
{list.map((o) => `${o.name_en} ${o.chapter}:${o.verse}`).join(' · ')}
{data.count >= 20 && <span className={muted}> … first 20 shown</span>}
</p>
);
}
export default function IqLexicon({ strongs: raw, onClose }) {
const strongs = normalizeStrongs(raw);
const lang = strongs?.startsWith('H') ? 'hebrew' : 'greek';
const basic = useIq(strongs ? `lexicon/${lang}/strongs/${strongs}` : null);
const cross = useIq(strongs ? `lexicon/crosswalk/${strongs}` : null);
const [showOcc, setShowOcc] = useState(false);
if (!strongs) return null;
const strong = listOf(basic.data)[0];
const sources = listOf(cross.data).filter((c) => SOURCES[c.source] && c.native_key && (lang === 'hebrew' ? c.source === 'bdb' : c.source !== 'bdb'));
const seen = new Set();
const unique = sources.filter((c) => { const k = `${c.source}/${c.native_key}`; if (seen.has(k)) return false; seen.add(k); return true; }).slice(0, 4);
return (
<div className="mt-3 rounded-xl border border-[var(--r-line,#cbd5e1)] p-3" role="region" aria-label={`Lexicon entry for ${strongs}`}>
<div className="mb-2 flex items-center justify-between">
<p className="text-sm font-semibold">{strongs}{strong?.occurences ? <span className="ml-2 text-xs font-normal text-[var(--r-sub,#64748b)]">{strong.occurences}× in the Bible</span> : null}</p>
<button type="button" onClick={onClose} aria-label="Close lexicon entry" className="rounded p-1 text-[var(--r-sub,#64748b)] hover:bg-black/5">×</button>
</div>
{basic.status === 'loading' && <p className={muted}>Loading…</p>}
{basic.status === 'error' && <p className={muted}>{basic.error}</p>}
{strong && <p className="mb-2 whitespace-pre-line text-[13px] leading-relaxed">{strong.glossary}</p>}
<div className="space-y-1.5">
{cross.status === 'loading' && <p className={muted}>Finding other lexicons…</p>}
{unique.map((c) => <SourceEntry key={`${c.source}/${c.native_key}`} lang={lang} source={c.source} nativeKey={c.native_key} />)}
</div>
<button type="button" onClick={() => setShowOcc((o) => !o)} className="mt-2 text-xs font-medium text-[var(--r-accent,#0369a1)] hover:underline">
{showOcc ? 'Hide' : 'Where this word appears'}
</button>
{showOcc && <div className="mt-1"><Occurrences strongs={strongs} /></div>}
<p className="mt-2 text-[10px] text-[var(--r-sub,#64748b)]">Lexicons via IQ Bible (Strong's, BDB, LSJ, Abbott-Smith; see credits there).</p>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
// <option>s for IQ Bible's narrated Bibles, to add inside an audio narrator <select>. Playing a
// chapter this way uses one of the account's monthly audio streams, so the group says so.
import { useIq, useIqAvailable } from '../../hooks/useIq.js';
import { listOf } from '../../utils/iqbible.js';
export default function IqNarratorOptions() {
const on = useIqAvailable();
const { data } = useIq(on ? 'audio' : null);
const sets = listOf(data).filter((s) => s.active !== false);
if (!on || sets.length === 0) return null;
return (
<optgroup label="IQ Bible narrations (uses monthly streams)">
{sets.map((s) => <option key={s.audio_id} value={`iq:${s.audio_id}`}>{s.audio_id}{s.narrator ? ` · ${s.narrator}` : ''}</option>)}
</optgroup>
);
}
+24
View File
@@ -0,0 +1,24 @@
// Calendar (.ics) and spreadsheet (.csv) downloads of a reading plan, generated by IQ Bible so the
// readings show up as daily all-day events in any calendar app.
import { useIqAvailable } from '../../hooks/useIq.js';
import { iqUrl } from '../../utils/iqbible.js';
const link = 'text-[var(--r-sub)] hover:text-[var(--r-text)] underline-offset-2 hover:underline';
export default function IqPlanExport({ plan, mcheyne = false }) {
const on = useIqAvailable();
if (!on) return null;
const query = mcheyne
? { plan: 'mcheyne' }
: Array.isArray(plan?.books) && plan.books.length > 0 ? { days: plan.days, books: plan.books.join(',') } : null;
if (!query) return null;
const href = (format) => iqUrl('bibles/eng_kjv/reading-plan', { ...query, format });
return (
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--r-sub)]">
<span>{mcheyne ? 'M’Cheyne’s 365-day calendar' : 'Take this plan with you'}:</span>
<a href={href('ical')} download className={link}>Add to calendar (.ics)</a>
<a href={href('csv')} download className={link}>Spreadsheet (.csv)</a>
{!mcheyne && <span>Starts today, in the order of the Bible.</span>}
</p>
);
}
+142
View File
@@ -0,0 +1,142 @@
// A verse's "study aids" from IQ Bible: chapter overview, topics, places, people, textual notes, Gospel
// parallels and timeline. Each section loads only when opened, so nothing is spent on what isn't read.
import { useState } from 'react';
import { useIq } from '../../hooks/useIq.js';
import { listOf } from '../../utils/iqbible.js';
const muted = 'text-xs text-[var(--r-sub,#64748b)]';
const GOSPELS = ['MAT', 'MRK', 'LUK', 'JHN'];
function Aid({ title, path, query, children, empty = 'Nothing recorded here.' }) {
const [open, setOpen] = useState(false);
const { status, data, error } = useIq(open ? path : null, query);
return (
<details onToggle={(e) => setOpen(e.currentTarget.open)} className="rounded-xl bg-[var(--r-bg,#ffffff)] px-3 py-2">
<summary className="cursor-pointer text-sm font-medium">{title}</summary>
<div className="mt-2 text-[13px] leading-relaxed">
{status === 'loading' && <p className={muted}>Loading…</p>}
{status === 'error' && <p className={muted}>{error}</p>}
{status === 'empty' && <p className={muted}>{empty}</p>}
{status === 'ready' && children(data)}
</div>
</details>
);
}
function PersonProfile({ ustrong }) {
const { status, data } = useIq(`bible-characters/by-ustrong/${ustrong}`);
const p = data?.data;
if (status === 'loading') return <p className={muted}>Loading…</p>;
if (!p) return <p className={muted}>No profile.</p>;
return (
<div className="mt-1 space-y-1">
<p>{p.brief || p.briefest || p.description}</p>
{p.tribe_nation && <p className={muted}>{p.tribe_nation}</p>}
{p.first_appearance && <p className={muted}>First appears {p.first_appearance.book} {p.first_appearance.chapter}:{p.first_appearance.verse}</p>}
</div>
);
}
function People({ data, verse }) {
const [open, setOpen] = useState(null);
const people = listOf(data);
return (
<ul className="space-y-1">
{people.map((p) => (
<li key={p.ustrong}>
<button type="button" onClick={() => setOpen(open === p.ustrong ? null : p.ustrong)} className="text-left font-medium">
{p.name} <span className={muted}>{p.first_verse === verse ? 'this verse' : `v${p.first_verse}`} {open === p.ustrong ? '▾' : '▸'}</span>
</button>
{open === p.ustrong && <PersonProfile ustrong={p.ustrong} />}
</li>
))}
</ul>
);
}
function Places({ data }) {
const places = listOf(data);
return (
<ul className="space-y-1.5">
{places.map((p) => (
<li key={p.id} className="flex items-baseline justify-between gap-2">
<span>{p.name}<span className={muted}> {p.place_type}{p.modern_name && p.modern_name !== p.name ? ` · today: ${p.modern_name}` : ''}{p.special ? ` · ${p.special.replace(/_/g, ' ')}` : ''}</span></span>
{p.lat != null && p.lon != null && (
<a href={`https://www.openstreetmap.org/?mlat=${p.lat}&mlon=${p.lon}#map=8/${p.lat}/${p.lon}`} target="_blank" rel="noopener noreferrer" className="shrink-0 text-xs font-semibold text-sky-600 hover:underline">Map ↗</a>
)}
</li>
))}
</ul>
);
}
function Variants({ data }) {
const items = listOf(data);
return (
<ul className="space-y-3">
{items.map((v, i) => (
<li key={v.slug ?? i}>
<p className="font-medium">{v.title}</p>
{v.traditional_reading && <p><span className={muted}>Traditional (KJV/TR): </span>{v.traditional_reading}</p>}
{v.critical_reading && <p><span className={muted}>Critical text: </span>{v.critical_reading}</p>}
{v.explanation && <p className="mt-1">{v.explanation}</p>}
</li>
))}
</ul>
);
}
function Harmony({ data, book, chapter }) {
const items = listOf(data);
return (
<ul className="space-y-2">
{items.map((s) => (
<li key={s.number}>
<p className="font-medium">§{s.number} {String(s.title ?? '').toLowerCase().replace(/(^|[.\s])([a-z])/g, (m, a, b) => a + b.toUpperCase())}</p>
<p className={muted}>{(s.references ?? []).map((r) => r.citation ?? r).join(' · ')}</p>
</li>
))}
<li className={muted} aria-hidden="true">{book} {chapter}</li>
</ul>
);
}
export default function IqStudyAids({ book, chapter, verse }) {
return (
<div className="space-y-1.5">
<Aid title="About this chapter" path={`books/${book}/chapters/${chapter}/info`} empty="No chapter overview is available.">
{(d) => (
<>
<p className="whitespace-pre-line">{d.summary}</p>
{d.source_author && <p className={`mt-1 ${muted}`}>{d.source_author}{d.source_year ? `, ${d.source_year}` : ''}</p>}
</>
)}
</Aid>
<Aid title="Topics on this verse" path={`topics/${book}/${chapter}/${verse}`} empty="No topics are tagged on this verse.">
{(d) => (
<div className="flex flex-wrap gap-1.5">
{listOf(d).slice(0, 40).map((t, i) => (
<span key={i} className="rounded-full border border-[var(--r-line,#cbd5e1)] px-2 py-0.5 text-xs" title={t.label || t.edition}>{t.name}</span>
))}
</div>
)}
</Aid>
<Aid title="Places on this verse" path={`geo/${book}/${chapter}/${verse}`} empty="No places are mentioned on this verse.">{(d) => <Places data={d} />}</Aid>
<Aid title="People in this chapter" path={`bible-people/${book}/${chapter}`} empty="No named people in this chapter.">{(d) => <People data={d} verse={verse} />}</Aid>
<Aid title="Textual notes" path={`textual-variants/${book}/${chapter}/${verse}`} empty="No known manuscript differences on this verse.">{(d) => <Variants data={d} />}</Aid>
{GOSPELS.includes(book) && (
<Aid title="Parallel accounts in the Gospels" path="harmony" query={{ book, chapter }} empty="No Gospel parallels recorded for this chapter.">{(d) => <Harmony data={d} book={book} chapter={chapter} />}</Aid>
)}
<Aid title="Where this sits in the timeline" path={`chronology/for/${book}/${chapter}`}>
{(d) => (
<>
<p>{d.placement ?? `${d.date ?? ''}`}</p>
{d.preceding?.length > 0 && <p className={muted}>Before: {d.preceding.map((e) => `${e.event} (${e.date})`).join('; ')}</p>}
{d.following?.length > 0 && <p className={muted}>After: {d.following.map((e) => `${e.event} (${e.date})`).join('; ')}</p>}
</>
)}
</Aid>
<p className="pt-1 text-[10px] text-[var(--r-sub,#64748b)]">Study aids via IQ Bible.</p>
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, describe, test, expect, beforeEach, afterEach } from 'vitest';
import IqCommentary from './IqCommentary.jsx';
import IqLexicon from './IqLexicon.jsx';
import IqStudyAids from './IqStudyAids.jsx';
import IqPlanExport from './IqPlanExport.jsx';
import { resetIq } from '../../utils/iqbible.js';
// Answers /api/iq/... from a table of path -> JSON (or { status }), and records what was asked.
let routes;
let asked;
const reply = (body, status = 200) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(body), text: () => Promise.resolve('') });
beforeEach(() => {
resetIq();
asked = [];
routes = { '/api/iq/status': { available: true } };
vi.stubGlobal('fetch', vi.fn((url) => {
const path = String(url);
asked.push(path);
const hit = routes[path] ?? routes[path.split('?')[0]];
return hit === undefined ? reply({ error: 'not found' }, 404) : hit.status ? reply(hit.body ?? { error: 'x' }, hit.status) : reply(hit);
}));
localStorage.clear();
});
afterEach(() => vi.unstubAllGlobals());
describe('IqCommentary', () => {
const sources = { data: [
{ name: 'mhenry', author_name: 'Matthew Henry', category: 'Puritan', year: '1706' },
{ name: 'thomas-aquinas', author_name: 'Thomas Aquinas', category: 'Western & Medieval Theology', year: '1274' },
] };
test('offers the commentators on this chapter and shows their notes for the verse', async () => {
routes['/api/iq/books/JHN/commentaries?chapter=3'] = sources;
routes['/api/iq/commentaries/mhenry/JHN/3'] = { entries: [
{ chapter: 3, verse: 0, text: 'intro' },
{ chapter: 3, verse: 14, verse_end: 21, text: 'God so loved the world.' },
] };
render(<IqCommentary book="JHN" chapter={3} verse={16} />);
expect(await screen.findByText('God so loved the world.')).toBeInTheDocument();
expect(screen.getByText(/Notes on verses 14–21/)).toBeInTheDocument();
const options = within(screen.getByRole('combobox')).getAllByRole('option').map((o) => o.textContent);
expect(options).toEqual(['Matthew Henry (1706)', 'Thomas Aquinas (1274)']);
});
test('choosing another commentator loads theirs and remembers the choice', async () => {
routes['/api/iq/books/JHN/commentaries?chapter=3'] = sources;
routes['/api/iq/commentaries/mhenry/JHN/3'] = { entries: [{ chapter: 3, verse: 16, text: 'Henry on 16' }] };
routes['/api/iq/commentaries/thomas-aquinas/JHN/3'] = { entries: [{ chapter: 3, verse: 16, text: 'Aquinas on 16' }] };
render(<IqCommentary book="JHN" chapter={3} verse={16} />);
await screen.findByText('Henry on 16');
await userEvent.setup().selectOptions(screen.getByRole('combobox'), 'thomas-aquinas');
expect(await screen.findByText('Aquinas on 16')).toBeInTheDocument();
expect(localStorage.getItem('iq-commentary')).toBe('thomas-aquinas');
});
test('says so when nobody wrote on the chapter', async () => {
routes['/api/iq/books/JHN/commentaries?chapter=3'] = { data: [] };
render(<IqCommentary book="JHN" chapter={3} verse={16} />);
expect(await screen.findByText(/No IQ Bible commentaries cover this chapter/)).toBeInTheDocument();
});
});
describe('IqLexicon', () => {
test('shows Strong\'s and the Greek lexicons the crosswalk points to', async () => {
routes['/api/iq/lexicon/greek/strongs/G26'] = { data: [{ glossary: 'agape: love', occurences: 116 }] };
routes['/api/iq/lexicon/crosswalk/G26'] = { data: [
{ strongs_id: 'G26', source: 'lsj', native_key: 'G0026' },
{ strongs_id: 'G26', source: 'abbott-smith', native_key: 'G0026' },
] };
routes['/api/iq/lexicon/greek/lsj/G0026'] = { data: [{ greek: 'ἀγάπη', transliteration: 'agapē', gloss: 'love', meaning: '<b>love</b><script>alert(1)</script>' }] };
const { container } = render(<IqLexicon strongs="G0026" onClose={() => {}} />);
expect(await screen.findByText(/agape: love/)).toBeInTheDocument();
expect(screen.getByText('116× in the Bible')).toBeInTheDocument();
await userEvent.setup().click(await screen.findByText('Liddell-Scott-Jones'));
await screen.findByText('love', { selector: 'p.font-medium' });
expect(container.querySelector('script')).toBeNull(); // lexicon HTML is sanitised
expect(screen.getByText('Abbott-Smith')).toBeInTheDocument();
});
test('Hebrew words use BDB', async () => {
routes['/api/iq/lexicon/hebrew/strongs/H430'] = { data: [{ glossary: 'elohim' }] };
routes['/api/iq/lexicon/crosswalk/H430'] = { data: [{ source: 'bdb', native_key: 'BDB65' }, { source: 'lsj', native_key: 'x' }] };
render(<IqLexicon strongs="H430" onClose={() => {}} />);
expect(await screen.findByText('Brown-Driver-Briggs')).toBeInTheDocument();
expect(screen.queryByText('Liddell-Scott-Jones')).toBeNull();
});
});
describe('IqStudyAids', () => {
test('each section loads only when opened', async () => {
routes['/api/iq/topics/JHN/3/16'] = { data: [{ name: 'faith', edition: 'iqbible' }, { name: 'eternal life', edition: 'nave-torrey' }] };
render(<IqStudyAids book="JHN" chapter={3} verse={16} />);
expect(asked.filter((u) => u.startsWith('/api/iq/') && !u.includes('status'))).toEqual([]);
await userEvent.setup().click(screen.getByText('Topics on this verse'));
expect(await screen.findByText('faith')).toBeInTheDocument();
expect(screen.getByText('eternal life')).toBeInTheDocument();
expect(asked.some((u) => u.includes('/geo/'))).toBe(false);
});
test('a section with nothing says so, and the Gospel parallels appear only in the Gospels', async () => {
const { rerender } = render(<IqStudyAids book="ROM" chapter={8} verse={1} />);
expect(screen.queryByText('Parallel accounts in the Gospels')).toBeNull();
await userEvent.setup().click(screen.getByText('Textual notes'));
expect(await screen.findByText(/No known manuscript differences/)).toBeInTheDocument();
rerender(<IqStudyAids book="MAT" chapter={14} verse={1} />);
expect(screen.getByText('Parallel accounts in the Gospels')).toBeInTheDocument();
});
test('places link to a map when they have coordinates', async () => {
routes['/api/iq/geo/2KI/5/12'] = { data: [{ id: 1, name: 'Abana', place_type: 'river', modern_name: 'Barada River', lat: 33.5, lon: 36.3 }, { id: 2, name: 'Somewhere', place_type: 'region', special: 'unknown_place' }] };
render(<IqStudyAids book="2KI" chapter={5} verse={12} />);
await userEvent.setup().click(screen.getByText('Places on this verse'));
const link = await screen.findByRole('link', { name: /Map/ });
expect(link.href).toContain('mlat=33.5');
expect(screen.getAllByRole('link')).toHaveLength(1);
});
});
describe('IqPlanExport', () => {
test('links a plan to calendar and spreadsheet files, only when IQ Bible is on', async () => {
const plan = { id: 'p', days: 30, books: ['MAT', 'MRK'] };
const { container, rerender } = render(<IqPlanExport plan={plan} />);
await waitFor(() => expect(screen.getByRole('link', { name: /calendar/i })).toBeInTheDocument());
const href = screen.getByRole('link', { name: /calendar/i }).getAttribute('href');
expect(href).toContain('/api/iq/bibles/eng_kjv/reading-plan?');
expect(href).toContain('format=ical');
expect(href).toContain('days=30');
rerender(<IqPlanExport plan={{ id: 'q', days: 5 }} />); // a plan with no book list can't be exported
expect(container.querySelector('a')).toBeNull();
});
test('shows nothing without a key on the server', async () => {
routes['/api/iq/status'] = { available: false };
const { container } = render(<IqPlanExport plan={{ days: 30, books: ['MAT'] }} />);
await new Promise((r) => setTimeout(r, 20));
expect(container).toBeEmptyDOMElement();
});
});
+10 -4
View File
@@ -9,6 +9,7 @@ import { formatDayReadings, getPlanSchedule, daysBehindPace, nextUnreadDayIndex
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { DownloadProgress } from '../OfflineSave.jsx';
import { canSaveOffline } from '../../utils/offline.js';
import IqPlanExport from '../iq/IqPlanExport.jsx';
const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5';
const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50';
@@ -34,10 +35,13 @@ function ActiveBody({ plan, progress, onClose, actions }) {
</span>
</div>
<div className="mb-3 h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)]" style={{ width: `${pct}%` }} /></div>
{behind >= 2 && (
<div className="mb-3 flex items-center justify-between rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
<span className="text-[var(--r-sub)]">{behind} days behind pace</span>
<button type="button" onClick={() => actions.recalibrate(progress.id)} className="font-medium text-[var(--r-accent)] hover:underline">Catch me up</button>
{behind >= 1 && (
<div className="mb-3 rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
<p className="text-[var(--r-sub)]">{behind} {behind === 1 ? 'day' : 'days'} behind pace. Catch up without losing the days you've read:</p>
<div className="mt-2 flex flex-wrap items-center gap-3">
<button type="button" onClick={() => actions.recalibrate(progress.id)} className="font-medium text-[var(--r-accent)] hover:underline">Catch me up (pace starts today)</button>
<button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Redo the plan from Day 1</button>
</div>
</div>
)}
<div className="max-h-[38vh] overflow-y-auto rounded-xl border border-[var(--r-line)]">
@@ -64,6 +68,7 @@ function ActiveBody({ plan, progress, onClose, actions }) {
)}
</div>
)}
<div className="mt-3"><IqPlanExport plan={plan} /></div>
<div className="mt-4 flex items-center justify-between text-xs">
<button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
<button type="button" onClick={() => { actions.remove(progress.id); onClose(); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
@@ -109,6 +114,7 @@ function CatalogBody({ plan, active, completedCount, onClose, actions }) {
: <button type="button" className={primary} onClick={() => { actions.start(plan.id, translation); onClose(); }}>{completedCount > 0 ? 'Start again' : 'Start plan'}</button>}
</div>
<div className="mt-3"><IqPlanExport plan={plan} /></div>
{plan.custom && (
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-[var(--r-line)] pt-3 text-xs">
<button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button>
+16 -1
View File
@@ -9,12 +9,18 @@ import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
import { useIqAvailable } from '../../hooks/useIq.js';
import IqLexicon from '../iq/IqLexicon.jsx';
import IqStudyAids from '../iq/IqStudyAids.jsx';
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
const BASE_TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
function OriginalTab({ words }) {
const iq = useIqAvailable();
const [lookup, setLookup] = useState(null); // the Strong's number open in the lexicon panel
if (!words) return <p className="text-sm text-[var(--r-sub)]">No original-language data for this verse.</p>;
return (
<>
<div className="flex flex-wrap gap-2">
{words.map((w, i) => {
const isHebrew = w.s?.startsWith('H');
@@ -31,10 +37,16 @@ function OriginalTab({ words }) {
className="mt-1.5 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5"
title={`Pronounce in ${isHebrew ? 'Hebrew' : 'Greek'}`}>Speak</button>
)}
{iq && w.s && (
<button type="button" onClick={() => setLookup((cur) => (cur === w.s ? null : w.s))} aria-pressed={lookup === w.s}
className="mt-1.5 ml-1 rounded-lg bg-[var(--r-bar)] px-2 py-0.5 text-[11px] hover:bg-black/5" title="Look this word up in the lexicons">Lexicon</button>
)}
</div>
);
})}
</div>
{lookup && <IqLexicon strongs={lookup} onClose={() => setLookup(null)} />}
</>
);
}
@@ -144,6 +156,8 @@ export default function VerseSheet({
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, request,
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
}) {
const iqOn = useIqAvailable();
const TABS = iqOn ? [...BASE_TABS, ['aids', 'Study aids']] : BASE_TABS;
const [tab, setTab] = useState('commentary');
const [copied, setCopied] = useState(false);
const [preview, setPreview] = useState(null); // the cross-reference being read in the panel
@@ -230,6 +244,7 @@ export default function VerseSheet({
preview={preview && <RefPreview refItem={preview} translation={translation} onOpen={onJump} onClose={() => setPreview(null)} />} />
)}
{tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
{tab === 'aids' && iqOn && <IqStudyAids book={book} chapter={chapter} verse={verse.number} />}
</Sheet>
);
}
+4 -1
View File
@@ -72,11 +72,14 @@ export default function DataSection() {
<section id="backup" aria-label="Your data" className="scroll-mt-16 space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<div>
<h2 className="text-base font-medium">Your data</h2>
<p className="mt-1 text-sm text-[var(--r-sub)]">Everything you’ve made — studies and notes, bookmarks, highlights, ink and reading plans — in one file you keep.</p>
<p className="mt-1 text-sm text-[var(--r-sub)]">Everything you’ve made — studies and notes, bookmarks, highlights, ink and reading plans — in one file you keep.{authUser ? ' “Download backup” is what’s on this device; “Download from my account” is everything the server holds. The server also keeps a daily snapshot for the administrator.' : ''}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={download} className={btn}>Download backup</button>
{authUser && (
<a href="/api/account/export" download className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
)}
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
</div>
+3 -2
View File
@@ -1,6 +1,7 @@
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
// area for background notes and tags so the page stays quiet.
import { useEffect, useState } from 'react';
import { templatePrompt } from '../../utils/studyTemplates.js';
const SECTIONS = [
{ field: 'observation', label: 'Observe', hint: 'What does the text say?',
@@ -14,7 +15,7 @@ const SECTIONS = [
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
export default function NotesPane({ chunk, updateChunk, allTags }) {
export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {
const [open, setOpen] = useState(() => {
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
});
@@ -50,7 +51,7 @@ export default function NotesPane({ chunk, updateChunk, allTags }) {
</button>
{isOpen && (
<div className="px-4 pb-4">
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={placeholder}
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
</div>
)}
+2 -1
View File
@@ -17,7 +17,7 @@ const THEME_ORDER = ['paper', 'sepia', 'dark'];
export default function StudioShell({ stage, loading = false, fixedHeight = false, children }) {
const {
goHome, setCurrentPage, project, allChunks,
saveStatus, syncStatus, retrySync, syncConflict, keepMyVersion, useServerVersion,
saveStatus, syncStatus, retrySync, syncConflict, keepMyVersion, useServerVersion, mergeBothVersions,
readerTheme, setReaderTheme,
} = useApp();
const noChunks = allChunks.length === 0;
@@ -60,6 +60,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
<span>This study was changed on another device. Your edits here are saved on this device, but not yet synced.</span>
<span className="flex gap-2">
<button type="button" onClick={keepMyVersion} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] hover:opacity-90">Keep this version</button>
<button type="button" onClick={mergeBothVersions} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs font-medium hover:bg-black/5" title="Keeps every note from both versions, chunk by chunk">Combine both</button>
<button type="button" onClick={useServerVersion} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs font-medium hover:bg-black/5">Use the other version</button>
</span>
</div>
+11 -4
View File
@@ -8,7 +8,7 @@ export function useAudio(readerBookAbbrev, readerChapter, readerTranslation = 'B
const [audioNarrator, setAudioNarratorState] = useState(() => {
try {
const saved = localStorage.getItem('audio-narrator');
return ['david', 'hays', 'souer'].includes(saved) ? saved : 'souer';
return (['david', 'hays', 'souer'].includes(saved) || /^iq:[\w-]+$/.test(saved ?? '')) ? saved : 'souer';
} catch { return 'souer'; }
});
const setAudioNarrator = (id) => {
@@ -30,13 +30,20 @@ export function useAudio(readerBookAbbrev, readerChapter, readerTranslation = 'B
// recording; every other translation falls back to the BSB narrators.
const readerTranslationRef = useRef(readerTranslation);
readerTranslationRef.current = readerTranslation;
const readerAudioSource = () => (readerTranslationRef.current === 'ESV' ? 'ESV' : 'BSB');
const readerAudioSource = () => (audioNarratorRef.current?.startsWith('iq:') ? 'IQ' : readerTranslationRef.current === 'ESV' ? 'ESV' : 'BSB');
const playAudioChapter = async (abbrev, chapterNum, narrator) => {
const source = audioModeRef.current === 'reader' ? readerAudioSource() : 'BSB';
const iqId = narrator?.startsWith('iq:') ? narrator.slice(3) : null;
const source = iqId ? 'IQ' : audioModeRef.current === 'reader' ? readerAudioSource() : 'BSB';
let url;
let total;
if (source === 'ESV') {
if (iqId) {
// IQ Bible narration: the server mints a short-lived signed link (one monthly stream each)
const res = await fetch(`/api/iq/audio/${encodeURIComponent(iqId)}/${abbrev}/${chapterNum}`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load IQ Bible audio');
url = (await res.json()).file;
total = CHAPTER_COUNTS[abbrev] ?? chapterNum;
} else if (source === 'ESV') {
const res = await fetch(`/api/bible/ESV/audio/${abbrev}/${chapterNum}`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load ESV audio');
url = (await res.json()).url;
+50 -8
View File
@@ -373,6 +373,41 @@ export function useBibleReader(currentPage, translationOverride = null) {
});
};
// Pages whose ink the server hasn't accepted yet. Kept in localStorage so a failed save (offline,
// tab closed mid-save) is retried later instead of being lost.
const inkLatestRef = useRef(readerInkByPage);
inkLatestRef.current = readerInkByPage;
const inkDirtyKeys = () => { try { return JSON.parse(localStorage.getItem('readerInkDirty') ?? '[]'); } catch { return []; } };
const setInkDirty = (key, on) => {
const keys = new Set(inkDirtyKeys());
if (on) keys.add(key); else keys.delete(key);
try { localStorage.setItem('readerInkDirty', JSON.stringify([...keys])); } catch {}
};
const pushInkPage = useCallback(async (key, strokes) => {
const [bookAbbrev, chapter] = key.split('_');
try {
const res = await fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ strokes }),
});
if (res.ok || res.status === 400 || res.status === 413) setInkDirty(key, false); // 4xx won't ever succeed: don't retry
} catch { /* stays dirty; retried when back online or on the tab */ }
}, []);
const flushInk = useCallback(() => {
inkDirtyKeys().forEach((key) => {
const strokes = inkLatestRef.current[key];
if (Array.isArray(strokes)) pushInkPage(key, strokes); else setInkDirty(key, false);
});
}, [pushInkPage]);
useEffect(() => {
const onVisible = () => { if (document.visibilityState === 'hidden') { Object.keys(inkSaveTimerRef.current).forEach((k) => clearTimeout(inkSaveTimerRef.current[k])); } flushInk(); };
window.addEventListener('online', flushInk);
document.addEventListener('visibilitychange', onVisible);
return () => { window.removeEventListener('online', flushInk); document.removeEventListener('visibilitychange', onVisible); };
}, [flushInk]);
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
const key = `${bookAbbrev}_${chapter}`;
setReaderInkByPage((prev) => {
@@ -380,16 +415,11 @@ export function useBibleReader(currentPage, translationOverride = null) {
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
return next;
});
setInkDirty(key, true);
// Debounce server sync — wait 1 s after last stroke before sending
clearTimeout(inkSaveTimerRef.current[key]);
inkSaveTimerRef.current[key] = setTimeout(() => {
fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strokes }),
}).catch(() => {}); // silent — localStorage already saved locally
}, 1000);
}, []);
inkSaveTimerRef.current[key] = setTimeout(() => pushInkPage(key, strokes), 1000);
}, [pushInkPage]);
const cycleBookmarkColor = (verseKey) => {
setReaderBookmarks((prev) => {
@@ -479,11 +509,22 @@ export function useBibleReader(currentPage, translationOverride = null) {
// Merges reader ink loaded from the server at sign-in; server wins over local.
const mergeServerInk = useCallback((serverInk) => {
const dirty = new Set(inkDirtyKeys());
setReaderInkByPage((local) => {
// Server wins, except pages drawn on here that the server hasn't received yet.
const merged = { ...local, ...serverInk };
dirty.forEach((k) => { if (local[k]) merged[k] = local[k]; });
try { localStorage.setItem('readerInkByPage', JSON.stringify(merged)); } catch {}
return merged;
});
flushInk();
}, [flushInk]);
// Signing out clears this account's ink from the device.
const stopInkSync = useCallback(() => {
Object.keys(inkSaveTimerRef.current).forEach((k) => clearTimeout(inkSaveTimerRef.current[k]));
['readerInkByPage', 'readerInkDirty'].forEach((k) => { try { localStorage.removeItem(k); } catch {} });
setReaderInkByPage({});
}, []);
// Bookmarks and highlights follow the signed-in user across devices.
@@ -547,6 +588,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerInkByPage,
updateReaderPageInk,
mergeServerInk,
stopInkSync,
importReaderMarks,
readerTextHighlights,
...marksSync,
+12 -28
View File
@@ -4,9 +4,11 @@
import { useEffect, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
import { fetchStudyChapter } from '../utils/studyText.js';
import { templateStarter } from '../utils/studyTemplates.js';
// Every chunk starts blank; all note fields exist from the start.
export function blankChunk(start, end, spilloverEndVerse = null) {
export function blankChunk(start, end, spilloverEndVerse = null, templateId = 'blank') {
return {
id: makeId(),
startVerse: start,
@@ -22,6 +24,7 @@ export function blankChunk(start, end, spilloverEndVerse = null) {
episodeTitle: '',
finalScript: '',
tags: [],
...templateStarter(templateId),
};
}
@@ -81,7 +84,7 @@ export function uncoveredRuns(chunks, from, to) {
function initialSetupForm(project) {
const last = project?.chapters?.at(-1);
if (!last) {
return { translation: 'BSB', book: 'Titus', bookAbbrev: 'TIT', chapter: '1', title: 'Titus 1 Study' };
return { translation: 'BSB', book: 'Titus', bookAbbrev: 'TIT', chapter: '1', title: 'Titus 1 Study', template: 'blank' };
}
const next = Math.min(Number(last.chapter) + 1, CHAPTER_COUNTS[last.bookAbbrev] ?? Number(last.chapter));
return {
@@ -155,11 +158,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
setErrorMessage('');
setLoadingChapter(true);
try {
const response = await fetch(
`https://bible.helloao.org/api/${setup.translation}/${setup.bookAbbrev}/${setup.chapter}.json`,
);
if (!response.ok) throw new Error('Unable to load chapter.');
const data = await response.json();
const data = await fetchStudyChapter(setup.translation, setup.bookAbbrev, setup.chapter);
const verses = parseBibleChapter(data);
if (!data || !Array.isArray(verses) || verses.length === 0) {
throw new Error('Invalid Bible data returned.');
@@ -195,6 +194,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
id: makeId(),
title: setup.title,
translation: setup.translation,
templateId: setup.template ?? 'blank',
lastEdited: Date.now(),
selectedChunkId: null,
chapters: [
@@ -237,22 +237,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
);
if (overlap) return ch;
const newChunk = {
id: makeId(),
startVerse: start,
endVerse: end,
spilloverEndVerse,
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
generalNotes: '',
episodeNumber: '',
episodeTitle: '',
finalScript: '',
tags: [],
};
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateId);
return { ...ch, chunks: [...ch.chunks, newChunk] };
});
// Find the newly added chunk id
@@ -301,7 +286,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
skippedCount += 1;
return;
}
const newChunk = blankChunk(start, end, spilloverEndVerse);
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateId);
existing.add(key);
newChunks.push(newChunk);
addedCount += 1;
@@ -592,12 +577,12 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
if (i >= 0) {
const c = chunks[i];
chunks[i] = { ...c, endVerse: n, spilloverEndVerse: null };
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null));
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null, current.templateId));
return { ...ch, chunks };
}
if (chunks.some((c) => c.startVerse <= n && n <= c.endVerse)) return ch; // already a boundary
const start = Math.max(0, ...chunks.filter((c) => c.endVerse < n).map((c) => c.endVerse)) + 1;
const created = blankChunk(start, n);
const created = blankChunk(start, n, null, current.templateId);
selected = created.id;
const at = chunks.findIndex((c) => c.startVerse > n);
chunks.splice(at < 0 ? chunks.length : at, 0, created);
@@ -637,8 +622,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
try {
let starts = [];
try {
const res = await fetch(`https://bible.helloao.org/api/${project?.translation ?? 'BSB'}/${activeChapter.bookAbbrev}/${activeChapter.chapter}.json`);
if (res.ok) starts = headingStarts(await res.json());
starts = headingStarts(await fetchStudyChapter(project?.translation ?? 'BSB', activeChapter.bookAbbrev, activeChapter.chapter));
} catch { /* headings are a nicety — fall back to even chunks */ }
const usedHeadings = starts.length > 0;
const first = activeChapter.verses[0]?.number ?? 1;
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
import { iqAvailable, iqGet } from '../utils/iqbible.js';
// Whether the server has an IQ Bible key. false until known, so IQ-only UI never flashes up.
export function useIqAvailable() {
const [on, setOn] = useState(false);
useEffect(() => {
let live = true;
iqAvailable().then((v) => { if (live) setOn(v); });
return () => { live = false; };
}, []);
return on;
}
// { status: 'idle' | 'loading' | 'ready' | 'empty' | 'error', data, error } for one IQ Bible call.
// A null path means "don't fetch".
export function useIq(path, query) {
const [state, setState] = useState({ status: path ? 'loading' : 'idle', data: null, error: '' });
const key = path ? `${path}?${JSON.stringify(query ?? {})}` : '';
useEffect(() => {
if (!path) { setState({ status: 'idle', data: null, error: '' }); return undefined; }
let live = true;
setState({ status: 'loading', data: null, error: '' });
iqGet(path, query).then((r) => {
if (!live) return;
if (r.ok) setState({ status: 'ready', data: r.data, error: '' });
else if (r.status === 404) setState({ status: 'empty', data: null, error: '' });
else setState({ status: 'error', data: null, error: r.error });
});
return () => { live = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return state;
}
+79 -6
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { clearSyncBase, getSyncBase, setSyncBase } from '../utils/syncBase.js';
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import { knownOnServer, pendingDeletes as pendingDeleteIds, unsyncedStudies } from '../utils/syncQueue.js';
import {
saveProjectToStorage,
deleteProjectFromStorage,
@@ -29,6 +30,33 @@ export function useProjectLibrary({
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
// Studies saved here that the server hasn't accepted yet (saved offline, or the tab closed mid-sync).
// A study another device changed meanwhile is left alone; opening it offers the keep-mine/keep-theirs choice.
const pushPending = async () => {
for (const id of unsyncedStudies.list()) {
const local = loadProjectById(id);
if (!local) { unsyncedStudies.remove(id); continue; }
const result = await saveRemoteProject(local, { baseEdited: getSyncBase(id) });
if (result.ok) {
const serverEdited = result.data?.lastEdited ?? local.lastEdited;
setSyncBase(id, serverEdited);
unsyncedStudies.remove(id);
knownOnServer.add(id);
try { saveProjectToStorage({ ...local, lastEdited: serverEdited }); } catch { /* already saved */ }
} else if (result.status === 409) {
toast(`“${local.title}” changed on another device. Open it to choose which version to keep.`, { tone: 'error' });
}
}
setProjectIndex(loadProjectIndex());
};
const retryPendingDeletes = async () => {
for (const id of pendingDeleteIds.list()) {
if (pendingDeletes.current.has(id)) continue; // still inside its Undo window
const result = await deleteRemoteProject(id);
if (result.ok) { pendingDeleteIds.remove(id); knownOnServer.remove(id); }
}
};
// Once we know who's signed in, reconcile the local project index against the server.
// Runs on every authUser change (including logout -> different login) so a previous
// account's stale suggestions never linger after switching users. Projects that exist
@@ -43,15 +71,40 @@ export function useProjectLibrary({
const localIndex = loadProjectIndex();
listRemoteProjects().then(async (result) => {
if (!result.ok || cancelled) return;
// Deletes that never reached the server (offline, tab closed) go first, so those studies aren't pulled back.
// (the server's list above may still include them, so note which they are before they're cleared)
const gone = new Set(pendingDeleteIds.list());
await retryPendingDeletes();
const serverList = result.data.filter((e) => !gone.has(e.id));
const localMap = new Map(localIndex.map((e) => [e.id, e]));
// What this device has seen of each study on the server: a save made on top of anything older is
// checked against it, so a newer copy from another device is never overwritten silently.
for (const e of result.data) {
for (const e of serverList) {
const local = localMap.get(e.id);
setSyncBase(e.id, local ? Math.min(e.lastEdited ?? 0, local.lastEdited ?? 0) : e.lastEdited);
}
const missing = result.data.filter((e) => !localMap.has(e.id));
// A study this device knew the server had, that the server no longer has, was deleted somewhere else.
const onServer = new Set(serverList.map((e) => e.id));
const unsynced = new Set(unsyncedStudies.list());
const deletedElsewhere = localIndex.filter((e) => knownOnServer.has(e.id) && !onServer.has(e.id) && !unsynced.has(e.id) && !gone.has(e.id));
deletedElsewhere.forEach((e) => {
const copy = loadProjectById(e.id);
deleteProjectFromStorage(e.id);
clearSyncBase(e.id);
knownOnServer.remove(e.id);
localMap.delete(e.id);
toast(`“${e.title}” was deleted on another device`, {
action: {
label: 'Keep it',
onClick: () => { if (copy) { saveProjectToStorage(copy); unsyncedStudies.add(copy.id); setProjectIndex(loadProjectIndex()); pushPending(); } },
},
});
});
if (deletedElsewhere.length > 0) setProjectIndex(loadProjectIndex());
knownOnServer.replace(serverList.map((e) => e.id));
const missing = serverList.filter((e) => !localMap.has(e.id));
if (missing.length > 0) {
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
fetched.filter((r) => r.ok).forEach((r) => { saveProjectToStorage(r.data); setSyncBase(r.data.id, r.data.lastEdited); });
@@ -64,15 +117,23 @@ export function useProjectLibrary({
}
}
const stale = result.data.filter((e) => {
const stale = serverList.filter((e) => {
const local = localMap.get(e.id);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0) && !unsynced.has(e.id);
});
if (!cancelled) setStaleLocalProjects(stale);
pushPending();
});
return () => { cancelled = true; };
}, [authUser]);
// Back online: send what the server missed.
useEffect(() => {
const online = () => { if (authUser) { retryPendingDeletes(); pushPending(); } };
window.addEventListener('online', online);
return () => window.removeEventListener('online', online);
}, [authUser]);
const resumeProject = (id) => {
const loaded = loadProjectById(id);
if (!loaded) return;
@@ -89,8 +150,14 @@ export function useProjectLibrary({
const updated = { ...loaded, title, lastEdited: Date.now() };
saveProjectToStorage(updated);
setProjectIndex(loadProjectIndex());
unsyncedStudies.add(id);
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
if (result.ok) setSyncBase(id, updated.lastEdited);
if (result.ok) {
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
setSyncBase(id, serverEdited);
unsyncedStudies.remove(id);
try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ }
}
else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
});
if (project?.id === id) {
@@ -104,7 +171,9 @@ export function useProjectLibrary({
if (!pendingDeletes.current.has(id)) return;
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
deleteRemoteProject(id); // fire-and-forget
deleteRemoteProject(id).then((result) => { // stays on the pending list until the server confirms
if (result.ok) { pendingDeleteIds.remove(id); knownOnServer.remove(id); }
});
};
useEffect(() => {
const flush = () => [...pendingDeletes.current.keys()].forEach(finishDelete);
@@ -117,6 +186,8 @@ export function useProjectLibrary({
if (!loaded) return;
deleteProjectFromStorage(id);
clearSyncBase(id);
unsyncedStudies.remove(id);
pendingDeleteIds.add(id);
setProjectIndex(loadProjectIndex());
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
toast(`Deleted “${loaded.title}”`, {
@@ -125,6 +196,8 @@ export function useProjectLibrary({
onClick: () => {
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
pendingDeleteIds.remove(id);
unsyncedStudies.add(id); // re-sent in case the server copy was already removed
saveProjectToStorage(loaded);
setProjectIndex(loadProjectIndex());
},
+107
View File
@@ -0,0 +1,107 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
const toasts = [];
vi.mock('../components/Toasts.jsx', () => ({ useToast: () => ({ toast: (msg, opts) => toasts.push({ msg, opts }) }) }));
vi.mock('../syncService.js', () => ({
listRemoteProjects: vi.fn(),
loadRemoteProject: vi.fn(),
saveRemoteProject: vi.fn(),
deleteRemoteProject: vi.fn(),
}));
import { useProjectLibrary } from './useProjectLibrary.js';
import * as sync from '../syncService.js';
import { saveProjectToStorage, loadProjectById } from '../utils/projectStorage.js';
import { loadProjectIndex, switchStorageUser } from '../utils/bibleUtils.jsx';
import { knownOnServer, pendingDeletes, unsyncedStudies } from '../utils/syncQueue.js';
const study = (id, title = 'A study') => ({ id, title, lastEdited: 100, translation: 'BSB', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [] }] });
const setup = (authUser = { id: 'u1' }) => renderHook(() => useProjectLibrary({
authUser, project: null, setProject: vi.fn(), setProjectIndex: vi.fn(), setCurrentPage: vi.fn(),
}));
beforeEach(() => {
localStorage.clear();
switchStorageUser('u1');
toasts.length = 0;
vi.clearAllMocks();
sync.listRemoteProjects.mockResolvedValue({ ok: true, data: [] });
sync.deleteRemoteProject.mockResolvedValue({ ok: true });
sync.saveRemoteProject.mockResolvedValue({ ok: true, data: { lastEdited: 500 } });
});
afterEach(() => { vi.useRealTimers(); switchStorageUser(null); });
test('deleting removes the study here at once and asks the server only after the undo window', async () => {
vi.useFakeTimers();
saveProjectToStorage(study('s1'));
const hook = setup(null);
act(() => hook.result.current.deleteProject('s1'));
expect(loadProjectById('s1')).toBeNull();
expect(pendingDeletes.list()).toEqual(['s1']);
expect(sync.deleteRemoteProject).not.toHaveBeenCalled();
await act(async () => { await vi.advanceTimersByTimeAsync(8100); });
expect(sync.deleteRemoteProject).toHaveBeenCalledWith('s1');
expect(pendingDeletes.list()).toEqual([]); // the server confirmed
});
test('a delete the server did not confirm stays queued to be retried', async () => {
vi.useFakeTimers();
sync.deleteRemoteProject.mockResolvedValue({ ok: false, status: null, error: 'Network error' });
saveProjectToStorage(study('s1'));
const hook = setup(null);
act(() => hook.result.current.deleteProject('s1'));
await act(async () => { await vi.advanceTimersByTimeAsync(8100); });
expect(pendingDeletes.list()).toEqual(['s1']);
});
test('undo puts the study back and cancels the server delete', async () => {
vi.useFakeTimers();
saveProjectToStorage(study('s1', 'Keep me'));
const hook = setup(null);
act(() => hook.result.current.deleteProject('s1'));
act(() => toasts.at(-1).opts.action.onClick());
expect(loadProjectById('s1').title).toBe('Keep me');
expect(pendingDeletes.list()).toEqual([]);
await act(async () => { await vi.advanceTimersByTimeAsync(9000); });
expect(sync.deleteRemoteProject).not.toHaveBeenCalled();
});
test('a study deleted on the server but still queued to delete is not pulled back down at sign-in', async () => {
pendingDeletes.add('gone');
sync.listRemoteProjects.mockResolvedValue({ ok: true, data: [{ id: 'gone', title: 'Gone', lastEdited: 5 }] });
setup();
await waitFor(() => expect(sync.deleteRemoteProject).toHaveBeenCalledWith('gone'));
expect(sync.loadRemoteProject).not.toHaveBeenCalled();
expect(loadProjectById('gone')).toBeNull();
});
test('a study another device deleted is removed here, with a way to keep it', async () => {
saveProjectToStorage(study('s2', 'Old study'));
knownOnServer.add('s2'); // the server had it when we last looked
sync.listRemoteProjects.mockResolvedValue({ ok: true, data: [] });
setup();
await waitFor(() => expect(loadProjectById('s2')).toBeNull());
const note = toasts.find((t) => t.msg.includes('Old study'));
expect(note.msg).toMatch(/deleted on another device/);
act(() => note.opts.action.onClick());
expect(loadProjectById('s2').title).toBe('Old study');
expect(unsyncedStudies.list()).toContain('s2');
});
test('a study never seen on the server (made offline) is sent up, not deleted', async () => {
saveProjectToStorage(study('s3', 'Made offline'));
unsyncedStudies.add('s3');
setup();
await waitFor(() => expect(sync.saveRemoteProject).toHaveBeenCalled());
expect(loadProjectById('s3').title).toBe('Made offline');
await waitFor(() => expect(unsyncedStudies.list()).toEqual([]));
expect(loadProjectIndex()).toHaveLength(1);
});
test('studies only on the server are pulled down', async () => {
sync.listRemoteProjects.mockResolvedValue({ ok: true, data: [{ id: 'far', title: 'From elsewhere', lastEdited: 9 }] });
sync.loadRemoteProject.mockResolvedValue({ ok: true, data: study('far', 'From elsewhere') });
setup();
await waitFor(() => expect(loadProjectById('far')?.title).toBe('From elsewhere'));
});
+1 -1
View File
@@ -75,7 +75,7 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
const dirty = lsGet(KEYS.dirty) === '1';
let next = local;
let needsPush = false;
let needsPush;
if (serverAt === 0) {
needsPush = !isEmptyMarks(local); // first time on this account: the server takes what's here
} else if (syncedAt === 0) {
+36 -6
View File
@@ -11,6 +11,8 @@ import { mergeReadingPlans } from '../utils/backup.js';
import { clampPlanDays, localDateString, normalizeCustomPlan, registerCustomPlans } from '../utils/readingPlans.js';
const STORAGE_KEY = 'readingPlanProgress';
const DIRTY_KEY = 'readingPlanDirty'; // set while this device has changes the server hasn't accepted yet
const lsDirty = (on) => { try { if (on === undefined) return localStorage.getItem(DIRTY_KEY) === '1'; if (on) localStorage.setItem(DIRTY_KEY, '1'); else localStorage.removeItem(DIRTY_KEY); } catch { /* storage unavailable */ } return false; };
function loadLocal() {
try {
@@ -69,10 +71,12 @@ export function useReadingPlans() {
}
let cancelled = false;
lsDirty(true);
setSyncStatus('syncing');
saveRemoteReadingProgress(state).then((result) => {
if (cancelled) return;
if (result.ok) {
lsDirty(false);
setSyncStatus('synced');
setSyncError('');
} else {
@@ -200,12 +204,14 @@ export function useReadingPlans() {
// Resets the pace clock so a plan that's fallen behind schedule stops being
// flagged as "behind" — doesn't touch which days are actually marked done.
const recalibratePace = useCallback((progressId) => {
const before = stateRef.current.progress.find((p) => p.id === progressId);
updateProgress((prev) => prev.map((p) => (
p.id === progressId
? { ...p, startedAt: Date.now() - p.completedDays.length * 86400000, lastActivityAt: Date.now() }
: p
)));
}, [updateProgress]);
if (before) toast('Caught up — your pace starts again from today', { action: { label: 'Undo', onClick: () => updateProgress((prev) => prev.map((p) => (p.id === progressId ? before : p))) } });
}, [updateProgress, toast]);
// { title, description, days, books: [abbrev, ...], chapterRanges?, image?: dataUrl } -> new custom plan id
const addCustomPlan = useCallback(({ title, description, days, books, chapterRanges, image }) => {
@@ -277,11 +283,12 @@ export function useReadingPlans() {
customPlans: Array.isArray(serverData.customPlans) ? serverData.customPlans : [],
activity: Array.isArray(serverData.activity) ? serverData.activity : [],
};
// Temporary diagnostic — remove once cross-device sync is confirmed fixed.
console.log(
`Reading plans pulled from server: ${next.progress.length} in progress, ${next.customPlans.length} custom plan(s).`,
next,
);
// Changes this device made that never reached the server (offline, failed push) are merged in,
// not thrown away; otherwise the server's copy simply wins.
if (lsDirty()) {
setState((local) => ({ ...local, ...mergeReadingPlans(next, local) }));
return;
}
skipNextSyncRef.current = true;
setState(next);
}, []);
@@ -306,6 +313,7 @@ export function useReadingPlans() {
setSyncStatus('syncing');
saveRemoteReadingProgress(state).then((result) => {
if (result.ok) {
lsDirty(false);
setSyncStatus('synced');
setSyncError('');
} else {
@@ -315,7 +323,29 @@ export function useReadingPlans() {
});
}, [state]);
// Back online, or back on the tab: send anything the server missed.
useEffect(() => {
const retry = () => {
if (!lsDirty() || document.visibilityState === 'hidden') return;
saveRemoteReadingProgress(stateRef.current).then((result) => {
if (result.ok) { lsDirty(false); setSyncStatus('synced'); setSyncError(''); }
});
};
window.addEventListener('online', retry);
document.addEventListener('visibilitychange', retry);
return () => { window.removeEventListener('online', retry); document.removeEventListener('visibilitychange', retry); };
}, []);
// Signing out leaves nothing of this account's plans on the device for the next person.
const stopReadingPlansSync = useCallback(() => {
try { localStorage.removeItem(STORAGE_KEY); } catch { /* storage unavailable */ }
lsDirty(false);
skipNextSyncRef.current = true;
setState({ progress: [], customPlans: [], activity: [] });
}, []);
return {
stopReadingPlansSync,
readingPlansState: state,
importReadingPlans,
planProgress: state.progress,
+27
View File
@@ -65,3 +65,30 @@ test('finishing the last day completes the plan', () => {
act(() => hook.result.current.toggleDay(id, 5, 90));
expect(rec(hook).completedAt).toBeNull();
});
const progressRec = (id, extra = {}) => ({ id, planId: 'gospels-90', startedAt: 1, completedDays: [], completedAt: null, lastActivityAt: 1, chaptersRead: {}, ...extra });
test('progress this device made offline is merged with the server copy, not overwritten by it', () => {
localStorage.setItem('readingPlanProgress', JSON.stringify({ progress: [progressRec('mine', { completedDays: [0, 1], lastActivityAt: 50 })], customPlans: [], activity: ['2026-10-01'] }));
localStorage.setItem('readingPlanDirty', '1'); // changes the server never accepted
const hook = renderHook(() => useReadingPlans());
act(() => hook.result.current.mergeServerReadingProgress({ progress: [progressRec('theirs')], customPlans: [], activity: ['2026-10-02'] }));
expect(hook.result.current.planProgress.map((p) => p.id).sort()).toEqual(['mine', 'theirs']);
expect(hook.result.current.readingActivity).toEqual(['2026-10-01', '2026-10-02']);
expect(hook.result.current.planProgress.find((p) => p.id === 'mine').completedDays).toEqual([0, 1]);
});
test('with nothing unsaved, the server copy simply wins', () => {
localStorage.setItem('readingPlanProgress', JSON.stringify({ progress: [progressRec('old')], customPlans: [], activity: [] }));
const hook = renderHook(() => useReadingPlans());
act(() => hook.result.current.mergeServerReadingProgress({ progress: [progressRec('server')], customPlans: [], activity: [] }));
expect(hook.result.current.planProgress.map((p) => p.id)).toEqual(['server']);
});
test('signing out clears the plans from this device', () => {
const hook = start('BSB');
expect(hook.result.current.planProgress).toHaveLength(1);
act(() => hook.result.current.stopReadingPlansSync());
expect(hook.result.current.planProgress).toEqual([]);
expect(localStorage.getItem('readingPlanDirty')).toBeNull();
});
+12 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
import ResetPasswordForm from '../components/ResetPasswordForm.jsx';
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
// What the app does, in four groups. Everything listed here exists in the app.
@@ -89,7 +90,7 @@ function ThemeSwitch() {
export default function AuthPage() {
const { signIn, readerTheme } = useApp();
const shell = { style: themeVars(readerTheme) };
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register'
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' | 'forgot'
const [authForm, setAuthForm] = useState({ email: '', password: '' });
const [authError, setAuthError] = useState('');
const [authBusy, setAuthBusy] = useState(false);
@@ -206,6 +207,10 @@ export default function AuthPage() {
<div className="lg:order-2">
<div className="lg:sticky lg:top-10">
<div className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-7 shadow-sm sm:p-8">
{authMode === 'forgot' ? (
<ResetPasswordForm initialEmail={authForm.email} onDone={() => setAuthMode('login')} onBack={() => setAuthMode('login')} />
) : (
<>
<h2 className="text-xl font-medium">{authMode === 'login' ? 'Sign in' : 'Create an account'}</h2>
<p className="mt-1 text-sm text-[var(--r-sub)]">
{authMode === 'login' ? 'Pick up where you left off.' : 'Free to start. Your studies stay in your account.'}
@@ -244,6 +249,10 @@ export default function AuthPage() {
{authBusy ? 'Please wait…' : authMode === 'login' ? 'Sign in' : 'Create account'}
</button>
</form>
{authMode === 'login' && (
<button type="button" onClick={() => { setAuthMode('forgot'); setAuthError(''); }}
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Forgot your password?</button>
)}
<button
type="button"
onClick={() => {
@@ -254,6 +263,8 @@ export default function AuthPage() {
>
{authMode === 'login' ? 'Need an account? Sign up' : 'Already have an account? Sign in'}
</button>
</>
)}
</div>
<blockquote className="mt-6 hidden border-l-2 border-[var(--r-line)] pl-4 text-sm italic leading-relaxed text-[var(--r-sub)] lg:block">
“Make every effort to present yourself approved to God, an unashamed workman who accurately handles the word of truth.”
+4
View File
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
import PageShell from '../components/study/PageShell.jsx';
import AudioPlayer from '../components/AudioPlayer.jsx';
import {
@@ -120,6 +122,7 @@ export default function HomePage() {
>
<main className="mx-auto grid max-w-6xl gap-6 px-4 py-6 lg:grid-cols-[minmax(0,1fr)_21rem]">
<section className="min-w-0">
<IqDailyCard />
{autoRestoredCount !== null && (
<div className={notice}>Synced {autoRestoredCount} project{autoRestoredCount > 1 ? 's' : ''} from another device.</div>
)}
@@ -268,6 +271,7 @@ export default function HomePage() {
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
<IqNarratorOptions />
</select>
</>
)}
+2 -3
View File
@@ -5,11 +5,11 @@ import { vi, test, expect } from 'vitest';
const noop = () => {};
const ctx = {
goHome: noop,
readerBookAbbrev: 'JHN', setReaderBookAbbrev: noop,
readerBookAbbrev: 'JHN', setReaderBookAbbrev: vi.fn(),
readerTranslation: 'ESV', setReaderTranslation: noop,
readerTheme: 'paper', setReaderTheme: noop, readerCommentary: 'matthew-henry', setReaderCommentary: noop,
readerLayout: 'flow', setReaderLayout: noop,
readerChapter: 3, setReaderChapter: noop,
readerChapter: 3, setReaderChapter: vi.fn(),
readerVerses: [{ number: 1, text: 'Now there was a man of the Pharisees.' }, { number: 2, text: 'This man came to Jesus by night.' }],
readerTotalChapters: 21, readerLoading: false, readerError: '',
readerInterlinear: null, readerSelectedVerse: null, setReaderSelectedVerse: vi.fn(),
@@ -28,7 +28,6 @@ const ctx = {
readerInkByPage: {}, updateReaderPageInk: noop, readerTextHighlights: [], addTextHighlight: noop, removeTextHighlight: noop,
planProgress: [], toggleDay: vi.fn(), toggleChapterRead: vi.fn(), readingActivity: [],
readerPlan: null, setReaderPlan: vi.fn(), openPlanReading: vi.fn(), studyReadingPlanDay: vi.fn(), studyBuildBusy: false,
setReaderBookAbbrev: vi.fn(), setReaderChapter: vi.fn(),
};
vi.mock('../context/AppContext.js', () => ({ useApp: () => ctx }));
+8 -2
View File
@@ -1,6 +1,7 @@
// Reading plans home, styled to match the reader (paper / sepia / dark). One big
// "Continue reading" card, your plans in progress, and a browsable catalog. Details,
// the custom-plan builder and everything else open as sheets.
import IqPlanExport from '../components/iq/IqPlanExport.jsx';
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
@@ -141,13 +142,17 @@ export default function ReadingPlansPage() {
<span className={pill}>{p.translation ?? readerTranslation}</span>
</div>
<p className="mb-2 mt-0.5 text-xs text-[var(--r-sub)]">
Day {p.completedDays.length} of {plan.days} done{next ? ` · next: ${formatDayReadings(next.refs)}` : ''}{behind >= 2 ? ` · ${behind} days behind` : ''}
Day {p.completedDays.length} of {plan.days} done{next ? ` · next: ${formatDayReadings(next.refs)}` : ''}{behind >= 1 ? ` · ${behind} ${behind === 1 ? 'day' : 'days'} behind` : ''}
</p>
<ProgressBar pct={pct} />
</button>
{next && (
<div className="mt-3 flex items-center justify-between">
<div className="mt-3 flex items-center justify-between gap-2">
<span className="text-xs text-[var(--r-sub)]">{pct}%</span>
{behind >= 1 && (
<button type="button" onClick={() => setDetail({ planId: p.planId, progressId: p.id })}
className="ml-auto rounded-full px-3 py-1.5 text-xs font-medium text-[var(--r-accent)] hover:underline">Catch up</button>
)}
<button type="button" onClick={() => openPlanReading(p.id, next.dayIndex)}
className="rounded-full border border-[var(--r-line)] px-4 py-1.5 text-xs font-medium hover:bg-black/5">Continue</button>
</div>
@@ -214,6 +219,7 @@ export default function ReadingPlansPage() {
</div>
</>
)}
<div className="mt-8"><IqPlanExport mcheyne /></div>
</main>
{detailPlan && (
+26 -1
View File
@@ -2,6 +2,8 @@
// write the final script beside your study notes. Claude prompts and the
// pronunciation guide live here because this is where they're needed.
import { useState } from 'react';
import { useIqAvailable } from '../hooks/useIq.js';
import { chunkRefs, gatherIqContext } from '../utils/iqbible.js';
import { useApp } from '../context/AppContext.js';
import { useToast } from '../components/Toasts.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
@@ -16,6 +18,8 @@ const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:
export default function ScriptPage() {
const { project, allChunks, updateProject, updateChunk, authUser, setSaveStatus, setCurrentPage } = useApp();
const [notice, setNotice] = useState('');
const iq = useIqAvailable();
const [gathering, setGathering] = useState(false);
const { toast } = useToast();
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
@@ -53,7 +57,22 @@ export default function ScriptPage() {
setSaveStatus('Copied! Opening Claude…');
window.setTimeout(() => setSaveStatus(''), 3000);
window.open('https://claude.ai/new', '_blank', 'noopener,noreferrer');
});
}).catch(() => toast("Couldn't copy to the clipboard. Click the button again.", { tone: 'error' }));
};
// The podcast prompt plus IQ Bible's background on each passage (commentary, cross-references, word glosses).
const copyPodcastWithContext = async () => {
setGathering(true);
try {
const { text, count, skipped } = await gatherIqContext(project);
const base = buildPodcastPrompt(project, authUser?.podcastName);
const prompt = text ? `${base}\n\n---\nBACKGROUND REFERENCE (from IQ Bible; use it to inform the script, don't quote it at length):\n\n${text}\n` : base;
if (!text) toast('IQ Bible had no background to add; copied the standard prompt', { tone: 'error' });
else if (skipped > 0) toast(`Added background for ${count} passages (the first ${count} of ${count + skipped})`);
copyPrompt(prompt);
} finally {
setGathering(false);
}
};
const noteBlock = (label, text) => has(text) && (
@@ -112,6 +131,12 @@ export default function ScriptPage() {
title="Copies your study notes as a Claude prompt, then opens claude.ai">Study guide → Claude ↗</button>
<button type="button" onClick={() => copyPrompt(buildPodcastPrompt(project, authUser?.podcastName))} className={btn}
title="Copies a podcast script prompt for Claude — set your show name in Account Settings first">Podcast → Claude ↗</button>
{iq && (
<button type="button" disabled={gathering} onClick={copyPodcastWithContext} className={btn}
title={`Adds IQ Bible commentary, cross-references and word glosses for up to ${chunkRefs(project).refs.length} passages (each uses a few API requests)`}>
{gathering ? 'Gathering…' : 'Podcast + background → Claude ↗'}
</button>
)}
<button type="button" onClick={() => setCurrentPage('pronunciation')} className={btn}>Pronunciation guide</button>
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
</div>
+3 -1
View File
@@ -1,3 +1,4 @@
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
@@ -261,11 +262,12 @@ export default function SettingsPage() {
<PrefRow label="Reading width" hint="One column, or two on a wide screen.">
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={readerWide} onChange={setReaderWide} />
</PrefRow>
<PrefRow label="Audio narrator" hint="For the Berean Standard Bible audio.">
<PrefRow label="Audio narrator" hint="For the Berean Standard Bible audio. IQ Bible narrations count against its monthly audio allowance.">
<select aria-label="Audio narrator" value={audioNarrator} onChange={(e) => setAudioNarrator(e.target.value)} className={selectCls}>
<option value="david">David</option>
<option value="hays">Hays</option>
<option value="souer">Souer</option>
<IqNarratorOptions />
</select>
</PrefRow>
</section>
+9
View File
@@ -1,6 +1,7 @@
// The Outline stage: choose a chapter, then cut it into chunks by clicking between
// verses (or let the section headings suggest them). Each chunk becomes one unit of
// study in the next stage.
import { STUDY_TEMPLATES, getTemplate } from '../utils/studyTemplates.js';
import { useApp } from '../context/AppContext.js';
import { useChunkBuilder } from '../hooks/useChunkBuilder.js';
import StudioShell from '../components/study/StudioShell.jsx';
@@ -30,6 +31,14 @@ function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, wi
<input type="text" value={setup.title} onChange={(e) => onTitleChange(e.target.value)} className={inputCls} />
</label>
)}
{withTitle && (
<label className="block text-sm sm:col-span-2">Start from
<select value={setup.template ?? 'blank'} onChange={(e) => onField('template', e.target.value)} className={inputCls}>
{STUDY_TEMPLATES.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
<span className="mt-1 block text-xs text-[var(--r-sub)]">{getTemplate(setup.template).description}</span>
</label>
)}
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-[var(--r-sub)]">{error ? <span className="text-[var(--r-danger,#b42318)]">{error}</span> : 'Text: Berean Standard Bible (BSB).'}</p>
+1 -1
View File
@@ -132,7 +132,7 @@ export default function StudyPage() {
<section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]">
<h2 className="shrink-0 border-b border-[var(--r-line)] px-4 py-2.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</h2>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} />
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateId} />
</div>
</section>
<section aria-label="Resources" className="flex min-h-[24rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[10]">
+10
View File
@@ -160,6 +160,16 @@ export async function loginUser(email, password) {
return request('POST', '/auth/login', { email, password });
}
/** Asks for a password-reset code to be sent to the account's notification topic. */
export async function requestPasswordReset(email) {
return request('POST', '/auth/forgot', { email });
}
/** Sets a new password using the code that was sent. `token` is the authenticator code, when 2FA is on. */
export async function resetPassword({ email, code, newPassword, token }) {
return request('POST', '/auth/reset', { email, code, newPassword, token });
}
export async function logoutUser() {
return request('POST', '/auth/logout');
}
+175
View File
@@ -0,0 +1,175 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../App';
import { vi, describe, test, expect } from 'vitest';
import { installAppLifecycle, buildFetchMock, renderApp, openAccountMenu } from './appHarness.jsx';
installAppLifecycle();
// ---------------------------------------------------------------------------
// Authentication (useAuth + AuthPage/SettingsPage local form state)
// ---------------------------------------------------------------------------
describe('Authentication', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: 'My Show', isAdmin: false };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
// A tiny fake API: `session` decides what /api/auth/me returns; login can
// optionally require a 2FA code first.
function stubApi({ session = null, mfaRequired = false } = {}) {
const fallback = buildFetchMock();
const fetchMock = vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return session ? json(200, session) : json(401, { error: 'Not signed in' });
if (route === 'POST /api/auth/login') return json(200, mfaRequired ? { mfaRequired: true } : user);
if (route === 'POST /api/auth/mfa/verify') return json(200, user);
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
return json(404, { error: 'not mocked' });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
const calledWith = (fetchMock, method, url) =>
fetchMock.mock.calls.some(([u, o = {}]) => u === url && (o.method ?? 'GET') === method);
async function signInWithForm(u) {
await u.type(await screen.findByLabelText(/email/i), user.email);
await u.type(screen.getByLabelText(/password/i), 'correct-horse');
await u.click(screen.getByRole('button', { name: /^sign in$/i }));
}
test('signing in with the form shows the app and loads reader ink', async () => {
const fetchMock = stubApi();
const u = userEvent.setup();
render(<App />);
await signInWithForm(u);
await openAccountMenu(u);
expect(await screen.findByText(user.email)).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/login')).toBe(true);
await waitFor(() => expect(calledWith(fetchMock, 'GET', '/api/reader/ink')).toBe(true));
});
test('2FA: a correct code after the password signs the user in', async () => {
const fetchMock = stubApi({ mfaRequired: true });
const u = userEvent.setup();
render(<App />);
await signInWithForm(u);
await u.type(await screen.findByPlaceholderText('123456'), '123456');
await u.click(screen.getByRole('button', { name: /verify/i }));
await openAccountMenu(u);
expect(await screen.findByText(user.email)).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/mfa/verify')).toBe(true);
});
test('an existing session skips the login screen, and Log out returns to it', async () => {
const fetchMock = stubApi({ session: user });
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
expect(await screen.findByText(user.email)).toBeInTheDocument();
await u.click(screen.getByRole('menuitem', { name: /log out/i }));
expect(await screen.findByRole('button', { name: /^sign in$/i })).toBeInTheDocument();
expect(calledWith(fetchMock, 'POST', '/api/auth/logout')).toBe(true);
});
test('Settings pre-fills the podcast name from the signed-in account', async () => {
stubApi({ session: user });
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
expect(await screen.findByDisplayValue('My Show')).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Admin page (state is local to AdminPage)
// ---------------------------------------------------------------------------
describe('Admin page', () => {
const admin = { id: 'a1', email: 'owner@example.com', totpEnabled: true, podcastName: null, isAdmin: true };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
function stubAdminApi(sessionUser) {
let users = [
{ id: 'a1', email: 'owner@example.com', totpEnabled: true, projectCount: 1 },
{ id: 'u2', email: 'student@example.com', totpEnabled: false, projectCount: 0 },
];
const fallback = buildFetchMock();
const fetchMock = vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return json(200, sessionUser);
if (route === 'GET /api/admin/users') return json(200, users);
if (route === 'GET /api/admin/projects') return json(200, [{ id: 'p1', title: 'Romans Study', ownerEmail: 'owner@example.com', chapterSummary: 'Romans 1' }]);
if (route === 'DELETE /api/admin/users/u2') { users = users.filter((u) => u.id !== 'u2'); return json(200, { ok: true }); }
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
return json(404, { error: 'not mocked' });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
test('an admin can open the page and see users and projects', async () => {
stubAdminApi(admin);
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
expect(await screen.findByText('student@example.com')).toBeInTheDocument();
await u.click(screen.getByRole('button', { name: /projects \(1\)/i }));
expect(await screen.findByText('Romans Study')).toBeInTheDocument();
});
test('deleting a user removes them after the list reloads', async () => {
const fetchMock = stubAdminApi(admin);
window.confirm.mockReturnValue(true);
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
const row = (await screen.findByText('student@example.com')).closest('tr');
await u.click(within(row).getByRole('button', { name: /delete/i }));
await waitFor(() => expect(screen.queryByText('student@example.com')).not.toBeInTheDocument());
expect(fetchMock.mock.calls.some(([url, o = {}]) => url === '/api/admin/users/u2' && o.method === 'DELETE')).toBe(true);
});
test('the Admin page leads back to the dashboard', async () => {
stubAdminApi(admin);
const u = userEvent.setup();
render(<App />);
await openAccountMenu(u);
await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
await screen.findByText('student@example.com');
await u.click(screen.getByRole('button', { name: /^back$/i }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
});
test('non-admins get no Admin button', async () => {
stubAdminApi({ ...admin, isAdmin: false });
render(<App />);
await openAccountMenu();
expect(await screen.findByText(admin.email)).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: /settings/i })).toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: /admin/i })).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Import page (state is local to ImportPage)
// ---------------------------------------------------------------------------
describe('Import page', () => {
test('opens from Home, uses the BSB (no translation choice), and goes back', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /import session list/i }));
expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'KJV' })).not.toBeInTheDocument();
expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^back$/i }));
expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument();
});
});
+31
View File
@@ -0,0 +1,31 @@
import { screen } from '@testing-library/react';
import { describe, test, expect } from 'vitest';
import { installAppLifecycle, loadChapter, addChunk } from './appHarness.jsx';
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
import { unsyncedStudies } from '../utils/syncQueue.js';
installAppLifecycle();
describe('Autosave', () => {
test('leaving a study straight after an edit still saves it (no waiting for the 1-second timer)', async () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /bible study home|back to home/i }));
await screen.findByText('My Studies');
const [entry] = loadProjectIndex();
expect(entry).toBeTruthy();
expect(loadProjectById(entry.id).chapters[0].chunks).toHaveLength(1);
});
test('a saved study is queued for the server until the server accepts it', async () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /bible study home|back to home/i }));
await screen.findByText('My Studies');
// the mocked server is unreachable in these tests, so the study stays on the "not yet synced" list
expect(unsyncedStudies.list()).toContain(loadProjectIndex()[0].id);
});
});
+443
View File
@@ -0,0 +1,443 @@
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../App';
import { vi, describe, test, expect } from 'vitest';
import { installAppLifecycle, goToStudyWith3Chunks, buildFetchMock, renderApp, openAccountMenu, loadChapter, addChunk, findChunkCounter } from './appHarness.jsx';
installAppLifecycle();
// ---------------------------------------------------------------------------
// Home page (search/sort/rename local to HomePage; library + reading plan hooks)
// ---------------------------------------------------------------------------
describe('Home page', () => {
// Tests run in local-only mode (the mock API is unreachable), which uses the
// pre-account localStorage keys.
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
const project = {
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
chapters: [{
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '',
crossReferences: [], greekWords: [], tags: [] }],
}],
};
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
localStorage.setItem('bible-study-index', JSON.stringify(index));
}
test('searching 3+ characters finds matches inside study notes', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.type(screen.getByPlaceholderText(/search projects/i), 'grace');
expect(await screen.findByText(/notes matching "grace"/i)).toBeInTheDocument();
});
test('renaming a project updates its title and saved index', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /rename/i }));
const input = screen.getByDisplayValue('Titus Notes');
await user.clear(input);
await user.type(input, 'Titus Deep Dive');
await user.click(screen.getByRole('button', { name: /^save$/i }));
expect(await screen.findByText('Titus Deep Dive')).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive');
});
test('deleting a project removes it at once, and Undo brings it back', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument());
expect(localStorage.getItem('bible-study-project-p1')).toBeNull();
expect(window.confirm).not.toHaveBeenCalled();
await user.click(await screen.findByRole('button', { name: /undo/i }));
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
expect(localStorage.getItem('bible-study-project-p1')).not.toBeNull();
});
test('sort choice is remembered after leaving Home', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.selectOptions(screen.getByDisplayValue('Last edited'), 'title');
await user.click(screen.getByRole('button', { name: /read bible/i }));
await user.click(await screen.findByRole('button', { name: /back to home/i }));
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
});
test('Continue reading opens the Reader inside the plan, in the plan\'s own translation', async () => {
seedProject();
localStorage.setItem('readingPlanProgress', JSON.stringify({
progress: [{ id: 'rp1', planId: 'gospels-30', startedAt: Date.now(), completedDays: [], chaptersRead: {}, completedAt: null, lastActivityAt: Date.now(), translation: 'ESV' }],
customPlans: [], activity: [],
}));
const esvChapter = { translation: 'ESV', verses: [{ number: 1, text: 'In the beginning was the Word.' }], copyright: '' };
const fetchMock = vi.fn((url) => {
if (String(url).startsWith('/api/bible/ESV/')) return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(esvChapter) });
return buildFetchMock()(url);
});
vi.stubGlobal('fetch', fetchMock);
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /reading plans/i }));
await user.click(await screen.findByRole('button', { name: /continue reading/i }));
// The plan strip is up, on the plan's translation, and the chapter came from the ESV route
expect(await screen.findByText(/In the beginning was the Word/)).toBeInTheDocument();
expect(screen.getAllByText(/The Gospels in 30 Days · Day 1/).length).toBeGreaterThan(0);
expect(screen.getByText('ESV')).toBeInTheDocument(); // plan strip shows the plan's translation
expect(fetchMock.mock.calls.some(([u]) => String(u).startsWith('/api/bible/ESV/MAT/1'))).toBe(true);
// Leaving the reader ends the plan context: the reader's own translation applies again
await user.click(screen.getByRole('button', { name: /back to home/i }));
await user.click(screen.getByRole('button', { name: /read bible/i }));
expect(await screen.findByText(/Matthew 1 · BSB/)).toBeInTheDocument();
});
test('a reading plan can be started from the Reading Plans page', async () => {
seedProject(); // the nav button shows once there's at least one project
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /reading plans/i }));
// Tap a plan in the catalog, pick a translation for it, and start it.
await user.click(await screen.findByRole('button', { name: /The Gospels in 30 Days/ }));
await user.selectOptions(screen.getByRole('combobox'), 'ESV');
await user.click(screen.getByRole('button', { name: /^start plan$/i }));
await waitFor(() => {
const stored = JSON.parse(localStorage.getItem('readingPlanProgress'));
expect(stored?.progress?.[0]?.planId).toBe('gospels-30');
expect(stored?.progress?.[0]?.translation).toBe('ESV');
});
});
});
// ---------------------------------------------------------------------------
// Export stage (useShareLink) and the shared read-only view
// ---------------------------------------------------------------------------
describe('Project header and sharing', () => {
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
test('Export stage → Markdown downloads a file', async () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^export$/i }));
await user.click(await screen.findByRole('button', { name: /export markdown/i }));
expect(URL.createObjectURL).toHaveBeenCalled();
});
test('enabling sharing shows a copyable link', async () => {
const fallback = buildFetchMock();
const fetchMock = vi.fn((url, opts = {}) => {
if (url.startsWith('/api/projects/') && url.endsWith('/share')) {
return (opts.method ?? 'GET') === 'POST' ? json(200, { shareToken: 'tok123' }) : json(200, { shareToken: null });
}
return fallback(url, opts);
});
vi.stubGlobal('fetch', fetchMock);
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^export$/i }));
await user.click(await screen.findByRole('button', { name: /enable sharing/i }));
expect(await screen.findByDisplayValue(/\?share=tok123$/)).toBeInTheDocument();
});
test('a ?share= link shows the read-only view without signing in', async () => {
window.history.pushState({}, '', '/?share=abc');
const fallback = buildFetchMock();
vi.stubGlobal('fetch', vi.fn((url, opts) => (url === '/api/share/abc'
? json(200, { id: 's1', title: 'Shared Titus', translation: 'BSB', chapters: [] })
: fallback(url, opts))));
try {
render(<App />);
expect(await screen.findByText(/read-only shared view/i)).toBeInTheDocument();
expect(screen.getByTitle('Shared study')).toBeInTheDocument();
} finally {
window.history.pushState({}, '', '/');
}
});
});
// ---------------------------------------------------------------------------
// Study editor (StudyPage local state + useCrossRefs / useWordStudy hooks)
// ---------------------------------------------------------------------------
describe('Study editor', () => {
test('study-page messages now show as a status toast', async () => {
await goToStudyWith3Chunks();
// Titus is a New Testament book, so the Hebrew picker explains why it can't help.
fireEvent.click(screen.getByRole('tab', { name: /word study/i }));
fireEvent.click(screen.getByRole('button', { name: /suggest hebrew/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/old testament/i);
});
test('arrow keys move between chunks', async () => {
await goToStudyWith3Chunks();
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(await findChunkCounter(2, 3)).toBeInTheDocument();
fireEvent.keyDown(window, { key: 'ArrowLeft' });
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
});
test('a typed cross-reference is added, and the input clears on chunk change', async () => {
await goToStudyWith3Chunks();
fireEvent.click(screen.getByRole('tab', { name: /cross-refs/i }));
const input = screen.getByPlaceholderText(/john 1:1 or rom 3:23/i);
fireEvent.change(input, { target: { value: 'Rom 3:23' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(await screen.findByText('Rom 3:23')).toBeInTheDocument();
fireEvent.change(input, { target: { value: 'half typed' } });
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
await waitFor(() => expect(input).toHaveValue(''));
});
test('a tag can be added with Enter', async () => {
await goToStudyWith3Chunks();
fireEvent.click(screen.getByRole('button', { name: /^more/i }));
const input = screen.getByPlaceholderText(/add a tag/i);
fireEvent.change(input, { target: { value: 'grace' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(await screen.findByText(/#?grace/)).toBeInTheDocument();
expect(input).toHaveValue('');
});
});
// ---------------------------------------------------------------------------
// Chunk builder (useChunkBuilder, local to SetupPage)
// ---------------------------------------------------------------------------
describe('Chunk builder', () => {
async function addSecondChapter(user) {
await user.click(screen.getByRole('button', { name: /add chapter/i }));
// The add-chapter form starts on the chapter after the project's last one.
expect(screen.getByDisplayValue('2')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /load chapter/i }));
return screen.findByRole('button', { name: 'Titus 2' });
}
test('"Add Chapter" starts on the next chapter and adds it as a tab', async () => {
const user = await loadChapter();
expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true');
});
test('the Chunks stage reopens the chapter of the chunk being studied', async () => {
const user = await loadChapter();
await addSecondChapter(user);
addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected)
await user.click(screen.getByRole('button', { name: 'Titus 1' }));
await user.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
await user.click(screen.getByRole('button', { name: /^chunks$/i }));
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true');
});
});
// ---------------------------------------------------------------------------
// Stage bar (Chunks / Study / Outline / Script / Export)
// ---------------------------------------------------------------------------
describe('Stage bar', () => {
test('later stages are disabled until a chunk exists', async () => {
await loadChapter();
for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^export$/i]) {
expect(screen.getByRole('button', { name })).toBeDisabled();
}
expect(screen.getByRole('button', { name: /^chunks$/i })).not.toBeDisabled();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled();
});
test('Script stage opens the Pronunciation Guide, and back returns to Script', async () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^script$/i }));
await user.click(await screen.findByRole('button', { name: /pronunciation guide/i }));
expect(await screen.findByRole('heading', { name: /pronunciation guide/i })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /back to script/i }));
expect(await screen.findByLabelText(/final script/i)).toBeInTheDocument();
});
test('a script written in the Script stage is kept on the chunk', async () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^script$/i }));
const script = await screen.findByLabelText(/final script/i);
fireEvent.change(script, { target: { value: 'Welcome to the episode.' } });
await user.click(screen.getByRole('button', { name: /^study$/i }));
await user.click(screen.getByRole('button', { name: /^script$/i }));
expect(await screen.findByDisplayValue('Welcome to the episode.')).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Study workspace: outline editing, notes accordion, and the resource tabs
// ---------------------------------------------------------------------------
describe('Study workspace', () => {
const okJson = (data) => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data), headers: { get: () => 'application/json' } });
// Wraps the default mock so individual tests can answer specific URLs.
const withRoutes = (routes) => {
const base = buildFetchMock();
return vi.fn((url, opts) => {
for (const [needle, data] of routes) if (String(url).includes(needle)) return okJson(data);
return base(url, opts);
});
};
async function studyOneChunk(routes = []) {
if (routes.length) vi.stubGlobal('fetch', withRoutes(routes));
const user = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
return user;
}
test('outline: clicking between verses ends a chunk; clicking again joins them', async () => {
const user = await loadChapter();
await user.click(screen.getByRole('button', { name: /end a chunk after verse 1/i }));
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /end a chunk after verse 2/i }));
await screen.findByText('2 created');
await user.click(screen.getByRole('button', { name: /join chunks at verse 1/i }));
await screen.findByText('1 created');
expect((await screen.findAllByText(/1[-–]2/)).length).toBeGreaterThan(0);
});
test('outline: "Suggest from headings" fills the chapter, and never touches existing chunks', async () => {
const user = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /suggest from headings/i }));
await screen.findByText('2 created');
expect(screen.getByRole('status')).toHaveTextContent(/added 1 chunk/i);
});
test('notes: one section is open at a time, and text is kept when switching', async () => {
const user = await studyOneChunk();
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul calls himself a servant.' } });
await user.click(screen.getByRole('button', { name: /interpret/i }));
expect(screen.queryByRole('textbox', { name: 'Observe' })).not.toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Interpret' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /observe/i }));
expect(screen.getByRole('textbox', { name: 'Observe' })).toHaveValue('Paul calls himself a servant.');
});
test('notes: the outline dots and word count reflect what you wrote', async () => {
await studyOneChunk();
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'one two three' } });
expect(screen.getByText('3 words')).toBeInTheDocument();
});
test('interlinear: clicking an original word adds it to the word study', async () => {
const user = await studyOneChunk([['/interlinear/TIT.json', { 1: { 1: [{ o: 'Παῦλος', t: 'Paulos', g: 'Paul', s: 'G3972' }] } }]]);
await user.click(await screen.findByRole('button', { name: '1' })); // verse number opens its original words
await user.click(await screen.findByRole('button', { name: /Παῦλος/ }));
expect(await screen.findByRole('status')).toHaveTextContent(/added/i);
expect(screen.getByRole('tab', { name: /word study · 1/i })).toBeInTheDocument();
expect(within(screen.getByRole('tabpanel')).getByRole('button', { name: /Παῦλος/ })).toBeInTheDocument(); // the new card
});
test('commentary: "Add to notes" quotes a paragraph with its citation', async () => {
const user = await studyOneChunk([['/api/c/matthew-henry/TIT/1.json', { chapter: { content: [{ type: 'verse', number: 1, content: ['Paul was an apostle.'] }] } }]]);
await user.click(await screen.findByRole('button', { name: /add to notes/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/background notes/i);
await user.click(screen.getByRole('button', { name: /^more/i }));
expect(screen.getByLabelText(/background/i)).toHaveValue('Paul was an apostle. (Matthew Henry, Titus 1:1)');
});
test('people & places: lists what is tagged in the chunk', async () => {
const user = await studyOneChunk([['/d/theographic/TIT/1.json', { chapter: { people: [{ id: 'paul_1', name: 'Paul', verses: [1] }, { id: 'x_2', name: 'Elsewhere', verses: [9] }], places: [], events: [] } }]]);
await user.click(screen.getByRole('tab', { name: /people & places/i }));
const panel = screen.getByRole('tabpanel');
expect(await within(panel).findByRole('button', { name: /paul/i })).toBeInTheDocument();
expect(within(panel).queryByText('Elsewhere')).not.toBeInTheDocument();
});
test('script: "Build outline from notes" starts the script from your notes', async () => {
const user = await studyOneChunk();
fireEvent.change(screen.getByRole('textbox', { name: 'Observe' }), { target: { value: 'Paul greets Titus.' } });
await user.click(screen.getByRole('button', { name: /^script$/i }));
await user.click(await screen.findByRole('button', { name: /build outline from notes/i }));
const script = screen.getByLabelText(/final script/i);
expect(script.value).toContain('WHAT THE TEXT SAYS\nPaul greets Titus.');
expect(screen.getByText(/\d+ words · about/)).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// App bar and study layout
// ---------------------------------------------------------------------------
describe('App bar', () => {
test('shows the three destinations, and the account menu switches the theme', async () => {
const user = userEvent.setup();
await renderApp();
expect(screen.getByRole('button', { name: /^studies$/i })).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('button', { name: /read bible/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /reading plans/i })).toBeInTheDocument();
await openAccountMenu(user);
await user.click(screen.getByRole('button', { name: /^dark$/i }));
expect(localStorage.getItem('reader-theme')).toBe('dark');
// Local-only mode has no signed-in user, so there is no Settings/Log out to show
expect(screen.queryByRole('menuitem', { name: /log out/i })).not.toBeInTheDocument();
});
test('the account menu closes on Escape', async () => {
const user = userEvent.setup();
await renderApp();
await openAccountMenu(user);
expect(screen.getByRole('button', { name: /^paper$/i })).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('button', { name: /^paper$/i })).not.toBeInTheDocument();
});
test('the brand button and Studies return Home from another page', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /import session list/i }));
await screen.findByRole('heading', { name: /import session list/i });
await user.click(screen.getByRole('button', { name: /bible study home/i }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
});
});
describe('Study layout', () => {
test('notes sit in their own box above the resources, not under the passage', async () => {
const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
const notes = screen.getByRole('region', { name: 'Notes' });
const resources = screen.getByRole('region', { name: 'Resources' });
expect(within(notes).getByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
expect(within(resources).getByRole('tab', { name: /commentary/i })).toBeInTheDocument();
// notes come first in the right column
expect(notes.compareDocumentPosition(resources) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// and the passage column no longer holds them
expect(within(screen.getByRole('region', { name: 'Scripture' })).queryByRole('textbox')).not.toBeInTheDocument();
});
});
describe('Reading plan with more days than chapters', () => {
test('Continue reading opens the plan on a real chapter instead of stranding the Reader', async () => {
localStorage.setItem('readingPlanProgress', JSON.stringify({
customPlans: [{ id: 'custom-luke', title: 'Luke', days: 90, books: ['LUK'], custom: true }],
progress: [{ id: 'rp1', planId: 'custom-luke', startedAt: Date.now(), completedDays: [0, 1], chaptersRead: {}, completedAt: null, lastActivityAt: Date.now() }],
activity: [],
}));
const user = userEvent.setup();
await renderApp();
await user.click(await screen.findByRole('button', { name: /continue reading/i }));
expect(await screen.findByText(/Luke 3 · BSB/)).toBeInTheDocument();
expect(screen.getAllByText(/Luke · Day 3/).length).toBeGreaterThan(0);
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
});
});
+225
View File
@@ -0,0 +1,225 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../App';
import { vi, describe, test, expect } from 'vitest';
import { installAppLifecycle, buildFetchMock, renderApp, loadChapter, addChunk, findChunkCounter } from './appHarness.jsx';
installAppLifecycle();
// ---------------------------------------------------------------------------
// Every page has a way back to the dashboard
// ---------------------------------------------------------------------------
describe('Back to the dashboard', () => {
const home = /back to home|bible study home/i;
const dashboard = async () => expect(await screen.findByText('My Studies')).toBeInTheDocument();
test.each([
['the Reader', async (u) => { await u.click(screen.getByRole('button', { name: /read bible/i })); await screen.findByText(/Genesis 1 · BSB/); }],
['Reading Plans', async (u) => { await u.click(screen.getByRole('button', { name: /reading plans/i })); await screen.findByText(/browse plans/i); }],
['Import', async (u) => { await u.click(screen.getByRole('button', { name: /import session list/i })); await screen.findByRole('heading', { name: /import session list/i }); }],
])('%s', async (_name, open) => {
const u = userEvent.setup();
await renderApp();
await open(u);
// each page is lazy-loaded, so its app bar may take a moment to replace the loading skeleton
await u.click(await screen.findByRole('button', { name: home }));
await dashboard();
});
test.each(['Chunks', 'Study', 'Outline', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: new RegExp(`^${stage}$`, 'i') }));
await screen.findByRole('navigation', { name: /study stages/i });
await u.click(screen.getByRole('button', { name: home }));
await dashboard();
});
test('the Pronunciation guide', async () => {
const u = await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: /^script$/i }));
await u.click(await screen.findByRole('button', { name: /pronunciation guide/i }));
await screen.findByRole('heading', { name: /pronunciation guide/i });
await u.click(screen.getByRole('button', { name: home }));
await dashboard();
});
});
// ---------------------------------------------------------------------------
// The home page the user chooses
// ---------------------------------------------------------------------------
describe('Chosen home page', () => {
const homeBtn = /back to home|bible study home/i;
test('the app opens on the chosen page', async () => {
localStorage.setItem('start-page', 'reader');
render(<App />);
expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
expect(screen.queryByText('My Studies')).not.toBeInTheDocument();
});
test('by default the app opens on My Studies', async () => {
await renderApp();
expect(screen.getByText('My Studies')).toBeInTheDocument();
});
test('Home buttons go to the chosen page, and to My Studies when you are already on it', async () => {
localStorage.setItem('start-page', 'reader');
const u = userEvent.setup();
render(<App />);
await screen.findByText(/Genesis 1 · BSB/);
// already on the chosen page: Home means My Studies
await u.click(screen.getByRole('button', { name: homeBtn }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
// from another page, Home is the chosen page
await u.click(screen.getByRole('button', { name: /reading plans/i }));
await screen.findByRole('heading', { name: /^reading plans$/i });
await u.click(screen.getByRole('button', { name: homeBtn }));
expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
});
test('the logo on a page with an app bar goes to the chosen page, and Studies always goes to My Studies', async () => {
localStorage.setItem('start-page', 'plans');
const u = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /^reading plans$/i }); // opened on Reading Plans
await u.click(screen.getByRole('button', { name: homeBtn })); // already there -> My Studies
await screen.findByText('My Studies');
await u.click(screen.getByRole('button', { name: /import session list/i }));
await screen.findByRole('heading', { name: /import session list/i });
await u.click(screen.getByRole('button', { name: /bible study home/i }));
expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument();
await u.click(screen.getByRole('button', { name: homeBtn })); // -> My Studies again
await u.click(screen.getByRole('button', { name: /import session list/i }));
await u.click(screen.getByRole('button', { name: /^studies$/i }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
});
test('leaving a study with a different home page closes the study', async () => {
localStorage.setItem('start-page', 'plans');
const u = userEvent.setup();
render(<App />);
await screen.findByRole('heading', { name: /^reading plans$/i });
await u.click(screen.getByRole('button', { name: homeBtn })); // to My Studies
await u.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
await u.click(screen.getByRole('button', { name: homeBtn }));
expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument();
});
test('Settings changes it, and it is saved for next time', async () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
const fallback = buildFetchMock();
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return json(200, user);
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 });
if (route === 'GET /api/prefs') return json(200, { prefs: {} });
if (route === 'PUT /api/prefs') return json(200, { prefs: {} });
return json(404, { error: 'not mocked' });
}));
const u = userEvent.setup();
await renderApp();
await u.click(await screen.findByRole('button', { name: /account menu/i }));
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
const prefs = within(await screen.findByRole('region', { name: 'Preferences' }));
await u.click(within(prefs.getByRole('group', { name: 'Home page' })).getByRole('button', { name: 'Reading Plans' }));
expect(localStorage.getItem('start-page')).toBe('plans');
await u.click(screen.getByRole('button', { name: homeBtn })); // Settings back -> the new home page
expect(await screen.findByRole('heading', { name: /^reading plans$/i })).toBeInTheDocument();
});
});
describe('Resizable study column', () => {
async function openStudy() {
const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
return u;
}
const columns = () => screen.getByRole('separator', { name: /resize side panel/i }).closest('aside').parentElement;
test('the handle widens and narrows the right column and the choice is remembered', async () => {
const u = await openStudy();
const handle = screen.getByRole('separator', { name: /resize side panel/i });
expect(columns().style.getPropertyValue('--study-right')).toBe(''); // default width
handle.focus();
await u.keyboard('{ArrowLeft}');
expect(columns().style.getPropertyValue('--study-right')).toBe('472px');
expect(localStorage.getItem('study-right-width')).toBe('472');
await u.keyboard('{Shift>}{ArrowRight}{/Shift}');
expect(columns().style.getPropertyValue('--study-right')).toBe('376px');
});
test('double-clicking the handle restores the default width', async () => {
const u = await openStudy();
const handle = screen.getByRole('separator', { name: /resize side panel/i });
handle.focus();
await u.keyboard('{ArrowLeft}');
await u.dblClick(handle);
expect(localStorage.getItem('study-right-width')).toBeNull();
expect(columns().style.getPropertyValue('--study-right')).toBe('');
});
test('a saved width is applied when the study opens', async () => {
localStorage.setItem('study-right-width', '560');
await openStudy();
expect(columns().style.getPropertyValue('--study-right')).toBe('560px');
});
});
describe('Import template', () => {
test('the Import page offers a Word template to download', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /import session list/i }));
await user.click(await screen.findByRole('button', { name: /download template/i }));
await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalled());
const blob = URL.createObjectURL.mock.calls.at(-1)[0];
expect(blob.type).toMatch(/wordprocessingml/);
// and the button comes back to normal afterwards
expect(await screen.findByRole('button', { name: /download template/i })).toBeEnabled();
});
});
describe('System chrome follows the theme', () => {
test('the app tells the browser/OS the theme colour and scheme, and updates it when the theme changes', async () => {
localStorage.setItem('reader-theme', 'dark');
const user = userEvent.setup();
await renderApp();
const themeColor = () => document.head.querySelector('meta[name="theme-color"]')?.getAttribute('content');
expect(themeColor()).toBe('#262421');
expect(document.documentElement.style.colorScheme).toBe('dark');
await user.click(screen.getByRole('button', { name: /account menu/i }));
await user.click(screen.getByRole('button', { name: /^paper$/i }));
expect(themeColor()).toBe('#f1ece0');
expect(document.documentElement.style.colorScheme).toBe('light');
});
});
describe('New pages start at the top', () => {
test('moving to another page resets the scroll position', async () => {
const user = userEvent.setup();
await renderApp();
const scroller = document.scrollingElement ?? document.documentElement;
scroller.scrollTop = 480; // scrolled down on the dashboard
await user.click(screen.getByRole('button', { name: /reading plans/i }));
await screen.findByRole('heading', { name: /^reading plans$/i });
expect(scroller.scrollTop).toBe(0);
scroller.scrollTop = 300;
await user.click(screen.getByRole('button', { name: /back to home|bible study home/i }));
await screen.findByText('My Studies');
expect(scroller.scrollTop).toBe(0);
});
});
+251
View File
@@ -0,0 +1,251 @@
import { screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, describe, test, expect } from 'vitest';
import { installAppLifecycle, goToStudyWith3Chunks, mockChapterData, mockGreekDict, buildFetchMock, renderApp, loadChapter, addChunk, findChunkCounter } from './appHarness.jsx';
installAppLifecycle();
// ---------------------------------------------------------------------------
// Initial render
// ---------------------------------------------------------------------------
describe('Initial render', () => {
test('shows the home page with "My Studies" heading', async () => {
await renderApp();
expect(screen.getByText('My Studies')).toBeInTheDocument();
});
test('shows "No projects yet" when storage is empty', async () => {
await renderApp();
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
test('shows "New Project" button on home page', async () => {
await renderApp();
expect(screen.getAllByRole('button', { name: /new project/i }).length).toBeGreaterThan(0);
});
test('clicking "New Project" shows the project setup form', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(await screen.findByText('New study')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /load chapter/i })).toBeInTheDocument();
});
test('setup form shows book and chapter inputs, and uses the BSB', async () => {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
expect(screen.queryByText('Translation')).not.toBeInTheDocument();
expect(screen.getByText('Book')).toBeInTheDocument();
expect(screen.getByText('Chapter')).toBeInTheDocument();
expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Loading a chapter
// ---------------------------------------------------------------------------
describe('Loading a chapter', () => {
test('transitions to the chunk setup view on success', async () => {
await loadChapter();
expect(screen.getByText(/Scripture & Chunks/i)).toBeInTheDocument();
});
test('renders verse text after loading', async () => {
await loadChapter();
expect(screen.getByText(/Paul, a servant of God/i)).toBeInTheDocument();
});
test('shows "0 created" initially', async () => {
await loadChapter();
expect(screen.getByText('0 created')).toBeInTheDocument();
});
test('shows an error message when the API returns a non-ok response', async () => {
vi.stubGlobal('fetch', vi.fn((url) => {
if (url.includes('available_translations')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB']) });
}
return Promise.resolve({ ok: false });
}));
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/unable to load chapter/i);
});
test('shows an error message when chapter data contains no verses', async () => {
vi.stubGlobal('fetch', buildFetchMock({ chapterData: { chapter: { content: [] } } }));
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
await user.click(screen.getByRole('button', { name: /load chapter/i }));
await screen.findByText(/invalid bible data/i);
});
});
// ---------------------------------------------------------------------------
// Chunk management
// ---------------------------------------------------------------------------
describe('Chunk management', () => {
test('creates a chunk by clicking a verse range', async () => {
await loadChapter();
addChunk('Paul, a servant', 'Grace and peace');
expect((await screen.findAllByText(/1[-–]3/)).length).toBeGreaterThan(0);
});
test('chunk count increments after each addition', async () => {
await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
addChunk('In hope of eternal', 'Grace and peace');
await screen.findByText('2 created');
});
test('prevents adding a duplicate chunk range', async () => {
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText(/That chunk already exists/i);
});
test('deletes a chunk', async () => {
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
await screen.findByText('0 created');
});
test('"Begin Studying" is disabled when no chunks exist', async () => {
await loadChapter();
expect(screen.getByRole('button', { name: /begin studying/i })).toBeDisabled();
});
test('"Begin Studying" is enabled once a chunk exists', async () => {
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
expect(screen.getByRole('button', { name: /begin studying/i })).not.toBeDisabled();
});
});
describe('Study view navigation', () => {
test('transitions to study view when "Begin Studying" is clicked', async () => {
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
});
test('"Previous Chunk" is disabled on the first chunk', async () => {
await goToStudyWith3Chunks();
expect(screen.getByRole('button', { name: /previous chunk/i })).toBeDisabled();
});
test('"Next Chunk" advances to the second chunk', async () => {
await goToStudyWith3Chunks();
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
expect(await findChunkCounter(2, 3)).toBeInTheDocument();
});
test('"Next Chunk" is disabled on the last chunk', async () => {
await goToStudyWith3Chunks();
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
expect(await findChunkCounter(3, 3)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next chunk/i })).toBeDisabled();
});
test('"Previous Chunk" returns to prior chunk after advancing', async () => {
await goToStudyWith3Chunks();
fireEvent.click(screen.getByRole('button', { name: /next chunk/i }));
expect(await findChunkCounter(2, 3)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Greek word lookup
// ---------------------------------------------------------------------------
async function goToStudyAndAddGreekWord(fetchMock) {
if (fetchMock) vi.stubGlobal('fetch', fetchMock);
await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
fireEvent.click(screen.getByRole('tab', { name: /word study/i }));
fireEvent.click(screen.getByRole('button', { name: /add word/i }));
await screen.findByPlaceholderText(/G4102, H7225, 4102/i);
}
describe('Greek word lookup', () => {
test('adds a Greek word entry form', async () => {
await goToStudyAndAddGreekWord();
expect(screen.getByPlaceholderText(/G4102, H7225, 4102/i)).toBeInTheDocument();
});
test('populates fields after a successful lookup', async () => {
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: mockGreekDict }));
fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await screen.findByDisplayValue('πίστις');
expect(screen.getByDisplayValue('pistis')).toBeInTheDocument();
// With no gloss-map entry, the English gloss falls back to the KJV
// definition, so it matches the Short Definition field.
expect(screen.getAllByDisplayValue('faith, belief')).toHaveLength(2);
});
test('shows "No definition found." when the API returns an empty array', async () => {
await goToStudyAndAddGreekWord(buildFetchMock({ greekData: {} }));
fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await screen.findByDisplayValue('No definition found.');
});
test('shows "Lookup failed." on network error', async () => {
await goToStudyAndAddGreekWord(
vi.fn((url) => {
if (url.includes('available_translations')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB']) });
}
if (url.includes('bible.helloao.org')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(mockChapterData) });
}
return Promise.reject(new Error('Network error'));
}),
);
fireEvent.change(screen.getByPlaceholderText(/G4102, H7225, 4102/i), { target: { value: 'G4102' } });
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await screen.findByDisplayValue('Lookup failed.');
});
test('lookup is skipped when query is empty', async () => {
const fetchSpy = buildFetchMock();
await goToStudyAndAddGreekWord(fetchSpy);
const callCountBefore = fetchSpy.mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: /look up greek/i }));
await waitFor(() => {
expect(fetchSpy.mock.calls.length).toBe(callCountBefore);
});
});
test('removes a Greek word entry', async () => {
await goToStudyAndAddGreekWord();
expect(screen.getByPlaceholderText(/G4102, H7225, 4102/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => {
expect(screen.queryByPlaceholderText(/G4102, H7225, 4102/i)).not.toBeInTheDocument();
});
});
});
+250
View File
@@ -0,0 +1,250 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, describe, test, expect } from 'vitest';
import { installAppLifecycle, buildFetchMock, renderApp } from './appHarness.jsx';
installAppLifecycle();
// ---------------------------------------------------------------------------
// Bookmarks and highlights follow the account
// ---------------------------------------------------------------------------
describe('Bookmark and highlight sync', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 5, color: '#fef08a', ...over });
// A fake API that keeps the account's marks, like the real server does.
function stubServer(initial = { bookmarks: {}, highlights: [], updatedAt: 0 }) {
const server = { ...initial, puts: [], prefs: initial.prefs ?? {}, prefPuts: [] };
const fallback = buildFetchMock();
const fetchMock = vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return json(200, user);
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: server.bookmarks, highlights: server.highlights, updatedAt: server.updatedAt });
if (route === 'GET /api/prefs') return json(200, { prefs: server.prefs });
if (route === 'PUT /api/prefs') {
const patch = JSON.parse(opts.body).prefs;
server.prefPuts.push(patch);
server.prefs = { ...server.prefs, ...patch };
return json(200, { prefs: server.prefs });
}
if (route === 'PUT /api/reader/marks') {
const body = JSON.parse(opts.body);
server.puts.push(body);
server.bookmarks = body.bookmarks;
server.highlights = body.highlights;
server.updatedAt = Math.max(Date.now(), server.updatedAt + 1);
return json(200, { ok: true, updatedAt: server.updatedAt });
}
return json(404, { error: 'not mocked' });
});
vi.stubGlobal('fetch', fetchMock);
return server;
}
const local = (key) => JSON.parse(localStorage.getItem(key) ?? 'null');
test('a new device gets the account\'s bookmarks and highlights when you sign in', async () => {
stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [hl()], updatedAt: 500 });
await renderApp();
await waitFor(() => expect(local('reader-bookmarks')).toEqual({ 'JHN-3-16': '#fef08a' }));
expect(local('readerTextHighlights')).toEqual([hl()]);
});
test('marks already on this device are saved to an account that has none yet', async () => {
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
const server = stubServer();
await renderApp();
await waitFor(() => expect(server.puts).toHaveLength(1));
expect(server.puts[0].bookmarks).toEqual({ 'GEN-1-1': '#bbf7d0' });
});
test('marks on both sides are merged, and the merge is saved back', async () => {
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
const server = stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [], updatedAt: 500 });
await renderApp();
await waitFor(() => expect(server.puts).toHaveLength(1));
expect(server.puts[0].bookmarks).toEqual({ 'GEN-1-1': '#bbf7d0', 'JHN-3-16': '#fef08a' });
expect(local('reader-bookmarks')).toEqual({ 'GEN-1-1': '#bbf7d0', 'JHN-3-16': '#fef08a' });
});
test('a device that is up to date takes the newer server copy, dropping a removed bookmark', async () => {
localStorage.setItem('reader-bookmarks', JSON.stringify({ 'GEN-1-1': '#bbf7d0' }));
localStorage.setItem('reader-marks-synced-at', '100');
const server = stubServer({ bookmarks: {}, highlights: [], updatedAt: 900 }); // removed on another device
await renderApp();
await waitFor(() => expect(local('reader-bookmarks')).toEqual({}));
expect(server.puts).toHaveLength(0); // nothing to send back
});
test('bookmarking a verse in the Reader saves it to the account', async () => {
const server = stubServer({ bookmarks: {}, highlights: [], updatedAt: 500 });
localStorage.setItem('reader-marks-synced-at', '500');
const u = userEvent.setup();
await renderApp();
await u.click(screen.getByRole('button', { name: /read bible/i }));
await u.click(await screen.findByText(/Paul, a servant of God/));
await u.click(await screen.findByRole('button', { name: /bookmark verse/i }));
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
expect(Object.keys(server.puts[0].bookmarks)).toEqual(['GEN-1-1']);
});
test('signing out clears this device\'s copy so the next person starts clean', async () => {
const server = stubServer({ bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [hl()], updatedAt: 500 });
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(local('reader-bookmarks')).toEqual({ 'JHN-3-16': '#fef08a' }));
await u.click(await screen.findByRole('button', { name: /account menu/i }));
await u.click(await screen.findByRole('menuitem', { name: /log out/i }));
await waitFor(() => expect(localStorage.getItem('reader-marks-synced-at')).toBeNull());
expect(local('reader-bookmarks')).toEqual({});
expect(local('readerTextHighlights')).toEqual([]);
expect(server.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' }); // still safe on the account
});
});
describe('Preference sync', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
function stubPrefsServer(prefs = {}) {
const server = { prefs, puts: [] };
const fallback = buildFetchMock();
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
if (!url.startsWith('/api/')) return fallback(url, opts);
const route = `${opts.method ?? 'GET'} ${url}`;
if (route === 'GET /api/auth/me') return json(200, user);
if (route === 'POST /api/auth/logout') return json(200, { ok: true });
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 });
if (route === 'GET /api/prefs') return json(200, { prefs: server.prefs });
if (route === 'PUT /api/prefs') {
const patch = JSON.parse(opts.body).prefs;
server.puts.push(patch);
server.prefs = { ...server.prefs, ...patch };
return json(200, { prefs: server.prefs });
}
return json(404, { error: 'not mocked' });
}));
return server;
}
test('signing in on a new device brings your theme, translation and place in the Reader', async () => {
stubPrefsServer({
theme: { v: 'dark', at: 900 }, translation: { v: 'NASB', at: 900 }, layout: { v: 'verses', at: 900 },
position: { v: { book: 'JHN', chapter: 3 }, at: 900 },
});
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(localStorage.getItem('reader-theme')).toBe('dark'));
expect(localStorage.getItem('reader-translation')).toBe('NASB');
expect(localStorage.getItem('reader-layout')).toBe('verses');
await u.click(screen.getByRole('button', { name: /read bible/i }));
expect(await screen.findByText(/John 3 · NASB/)).toBeInTheDocument();
});
test('changing the theme is saved to the account', async () => {
const server = stubPrefsServer({ theme: { v: 'paper', at: 10 } });
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0)); // initial fill-in of the other keys
server.puts.length = 0;
await u.click(await screen.findByRole('button', { name: /account menu/i }));
await u.click(screen.getByRole('button', { name: /^sepia$/i }));
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
expect(server.puts[0].theme.v).toBe('sepia');
expect(Object.keys(server.puts[0])).toEqual(['theme']); // only what changed
});
test('the commentary you pick is shared between the Reader and the Study stage', async () => {
stubPrefsServer({ commentarySource: { v: 'tyndale', at: 900 } });
await renderApp();
await waitFor(() => expect(localStorage.getItem('reader-commentary')).toBe('tyndale'));
});
async function openSettings(u) {
await u.click(await screen.findByRole('button', { name: /account menu/i }));
await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
return screen.findByRole('region', { name: 'Preferences' });
}
test('Settings shows your preferences, and choosing a default Bible saves it and syncs it', async () => {
const server = stubPrefsServer({ translation: { v: 'BSB', at: 10 } });
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0));
server.puts.length = 0;
const prefs = within(await openSettings(u));
expect(prefs.getByLabelText('Default Bible')).toHaveValue('BSB');
await u.selectOptions(prefs.getByLabelText('Default Bible'), 'ESV');
expect(localStorage.getItem('reader-translation')).toBe('ESV');
await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
expect(server.puts[0].translation.v).toBe('ESV');
});
test('theme, verse layout, commentary and new-plan Bible can all be set from Settings', async () => {
stubPrefsServer();
const u = userEvent.setup();
await renderApp();
const prefs = within(await openSettings(u));
await u.click(within(prefs.getByRole('group', { name: 'Theme' })).getByRole('button', { name: 'Dark' }));
await u.click(within(prefs.getByRole('group', { name: 'Verse layout' })).getByRole('button', { name: 'One per line' }));
await u.selectOptions(prefs.getByLabelText('Commentary'), 'tyndale');
await u.selectOptions(prefs.getByLabelText('New reading plans'), 'NLT');
expect(localStorage.getItem('reader-theme')).toBe('dark');
expect(localStorage.getItem('reader-layout')).toBe('verses');
expect(localStorage.getItem('reader-commentary')).toBe('tyndale');
expect(localStorage.getItem('plan-translation')).toBe('NLT');
});
test('text size, reading width and narrator are remembered on this device (not synced)', async () => {
const server = stubPrefsServer();
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(server.puts.length).toBeGreaterThan(0));
server.puts.length = 0;
const prefs = within(await openSettings(u));
await u.click(within(prefs.getByRole('group', { name: 'Text size' })).getByRole('button', { name: 'XL' }));
await u.click(within(prefs.getByRole('group', { name: 'Reading width' })).getByRole('button', { name: 'Two columns' }));
await u.selectOptions(prefs.getByLabelText('Audio narrator'), 'hays');
expect(localStorage.getItem('reader-font-size')).toBe('1.25');
expect(localStorage.getItem('reader-wide')).toBe('1');
expect(localStorage.getItem('audio-narrator')).toBe('hays');
await new Promise((r) => setTimeout(r, 1500));
expect(server.puts).toHaveLength(0); // per-device settings stay off the account
});
test('Settings leads back to the dashboard', async () => {
stubPrefsServer();
const u = userEvent.setup();
await renderApp();
await openSettings(u);
await u.click(screen.getByRole('button', { name: /bible study home/i }));
expect(await screen.findByText('My Studies')).toBeInTheDocument();
});
test('a new reading plan starts in the Bible chosen for new plans', async () => {
localStorage.setItem('plan-translation', 'CSB');
stubPrefsServer({ planTranslation: { v: 'CSB', at: 10 } });
const u = userEvent.setup();
await renderApp();
await u.click(screen.getByRole('button', { name: /reading plans/i }));
await u.click(await screen.findByRole('button', { name: /The Gospels in 30 Days/ }));
expect(screen.getByRole('combobox')).toHaveValue('CSB');
});
test('signing out forgets the sync timestamps', async () => {
stubPrefsServer({ theme: { v: 'dark', at: 900 } });
const u = userEvent.setup();
await renderApp();
await waitFor(() => expect(localStorage.getItem('prefs-meta')).not.toBeNull());
await u.click(await screen.findByRole('button', { name: /account menu/i }));
await u.click(await screen.findByRole('menuitem', { name: /log out/i }));
await waitFor(() => expect(localStorage.getItem('prefs-meta')).toBeNull());
});
});
+144
View File
@@ -0,0 +1,144 @@
// Shared setup for the App tests (split by area into App.*.test.jsx): mock Bible data, a fetch mock,
// the per-test lifecycle, and helpers for the common "open the app / load a chapter / make chunks" steps.
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, beforeEach, afterEach, expect } from 'vitest';
import App from '../App';
import { resetLexiconCaches } from '../utils/wordStudy.js';
import { switchStorageUser } from '../utils/bibleUtils.jsx';
// ---------------------------------------------------------------------------
// Shared mock data
// ---------------------------------------------------------------------------
export const mockChapterData = {
chapter: {
content: [
{ type: 'verse', number: 1, content: ['Paul, a servant of God.'] },
{ type: 'verse', number: 2, content: ['In hope of eternal life.'] },
{ type: 'verse', number: 3, content: ['Grace and peace from God.'] },
],
},
};
// Mirrors the OpenScriptures Strong's Greek dictionary format loaded from jsdelivr.
export const mockGreekDict = {
G4102: {
lemma: 'πίστις',
translit: 'pistis',
kjv_def: 'faith, belief',
strongs_def: 'persuasion, i.e. credence; moral conviction',
},
};
export function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}) {
return vi.fn((url) => {
if (url.includes('available_translations')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(['BSB', 'KJV']) });
}
if (url.includes('bible.helloao.org')) {
return Promise.resolve({ ok: true, json: () => Promise.resolve(chapterData) });
}
if (url.includes('strongs-greek-dictionary') && greekData !== null) {
return Promise.resolve({
ok: true,
text: () => Promise.resolve(`var strongsGreekDictionary = ${JSON.stringify(greekData)};`),
});
}
return Promise.resolve({ ok: false });
});
}
// ---------------------------------------------------------------------------
// Test lifecycle
// ---------------------------------------------------------------------------
export function installAppLifecycle() {
beforeEach(() => {
localStorage.clear();
resetLexiconCaches();
switchStorageUser(null); // a signed-in test leaves the storage namespace switched
vi.stubGlobal('fetch', buildFetchMock());
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
vi.spyOn(window, 'confirm').mockReturnValue(false);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// App checks /api/auth/me on mount before deciding whether to show the app or
// a login gate. In tests the mock fetch reports the server as unreachable, so
// it falls back to local-only mode — but that still takes a tick to resolve.
export async function renderApp() {
render(<App />);
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
}
// The account menu (avatar button in the app bar) holds the email, Settings, Admin and Log out.
export async function openAccountMenu(user = userEvent.setup()) {
await user.click(await screen.findByRole('button', { name: /account menu/i }));
return user;
}
export async function loadChapter() {
const user = userEvent.setup();
await renderApp();
await user.click(screen.getAllByRole('button', { name: /new project/i })[0]);
// SetupPage is lazy-loaded, so wait for it on first open
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
return user;
}
export function getVerseList() {
return screen.getByTestId('verse-list');
}
export function clickVerse(textFragment) {
fireEvent.click(
within(getVerseList()).getByRole('button', { name: new RegExp(textFragment) }),
);
}
export function shiftClickVerse(textFragment) {
fireEvent.click(
within(getVerseList()).getByRole('button', { name: new RegExp(textFragment) }),
{ shiftKey: true },
);
}
export function addChunk(startFragment, endFragment) {
clickVerse(startFragment);
shiftClickVerse(endFragment);
}
export function findChunkCounter(n, total) {
return screen.findByText((_, el) => el?.textContent === `Chunk ${n} of ${total}`);
}
// ---------------------------------------------------------------------------
// Initial render
// ---------------------------------------------------------------------------
// Study view navigation
// ---------------------------------------------------------------------------
export async function goToStudyWith3Chunks() {
await loadChapter();
addChunk('Paul, a servant', 'Paul, a servant');
addChunk('In hope of eternal', 'In hope of eternal');
addChunk('Grace and peace', 'Grace and peace');
await screen.findByText('3 created');
fireEvent.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(3, 3);
// addChunk always selects the newest chunk; navigate back to chunk 1
// so navigation tests start from a known position.
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
fireEvent.click(screen.getByRole('button', { name: /previous chunk/i }));
expect(await findChunkCounter(1, 3)).toBeInTheDocument();
}
+111
View File
@@ -0,0 +1,111 @@
// Client for the server's IQ Bible proxy (/api/iq/...). The server holds the API key and decides what may be
// asked for; here we only build requests and keep answers for a few minutes so one open panel doesn't
// repeat the same call. (Longer caching is the server's job and follows the API's licence terms.)
const MEMO_MS = 5 * 60 * 1000;
const memo = new Map(); // key -> { at, promise }
let availability = null;
export function iqAvailable() {
availability ??= fetch('/api/iq/status', { credentials: 'include' })
.then((r) => (r.ok ? r.json() : { available: false }))
.then((d) => Boolean(d.available))
.catch(() => false);
return availability;
}
export function resetIq() { availability = null; memo.clear(); }
const buildPath = (path, query) => {
const qs = new URLSearchParams(Object.entries(query ?? {}).filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => [k, String(v)])).toString();
return `/api/iq/${path.replace(/^\/+/, '')}${qs ? `?${qs}` : ''}`;
};
// Link for a file the server streams from IQ Bible (calendar, CSV) — opened with the signed-in session.
export const iqUrl = buildPath;
// Resolves to { ok, data } or { ok: false, status, error }. Successful answers are remembered briefly.
export function iqGet(path, query, { fresh = false } = {}) {
const url = buildPath(path, query);
const hit = memo.get(url);
if (!fresh && hit && Date.now() - hit.at < MEMO_MS) return hit.promise;
const promise = fetch(url, { credentials: 'include' })
.then(async (res) => {
const data = await res.json().catch(() => null);
if (!res.ok) return { ok: false, status: res.status, error: data?.error?.message ?? (typeof data?.error === 'string' ? data.error : `HTTP ${res.status}`) };
return { ok: true, data };
})
.catch(() => ({ ok: false, status: null, error: 'Network error' }));
memo.set(url, { at: Date.now(), promise });
promise.then((r) => { if (!r.ok) memo.delete(url); });
return promise;
}
// Same, for endpoints that answer in plain text (ai/context with format=text). Resolves to a string or null.
export async function iqGetText(path, query) {
try {
const res = await fetch(buildPath(path, query), { credentials: 'include' });
return res.ok ? await res.text() : null;
} catch { return null; }
}
// A study's passages as background reading for an AI prompt: for the first verse of each chunk, IQ Bible's
// bundle of verse text, cross-references, commentary and Strong's glosses. Capped, because every lookup
// spends account allowance (4 requests each).
export function chunkRefs(project, max = 6) {
const refs = [];
for (const ch of project?.chapters ?? []) {
for (const c of ch.chunks ?? []) {
if (ch.bookAbbrev && Number(ch.chapter) > 0 && c.startVerse > 0) refs.push({ ref: `${ch.bookAbbrev}.${Number(ch.chapter)}.${c.startVerse}`, label: `${ch.book} ${ch.chapter}:${c.startVerse}` });
}
}
return { refs: refs.slice(0, max), skipped: Math.max(0, refs.length - max) };
}
export async function gatherIqContext(project, { max = 6, version = 'eng_kjv' } = {}) {
const { refs, skipped } = chunkRefs(project, max);
const parts = [];
for (const { ref, label } of refs) {
const text = await iqGetText('ai/context', { ref, include: 'crossrefs,commentary,strongs', version, format: 'text' });
if (text?.trim()) parts.push(`### ${label}\n${text.trim()}`);
}
return { text: parts.join('\n\n'), count: parts.length, skipped };
}
// "G0026" / "g26" / "H0430" -> "G26" / "H430"; anything else -> null
export function normalizeStrongs(s) {
const m = /^([GH])0*(\d+)/i.exec(String(s ?? '').trim());
return m ? `${m[1].toUpperCase()}${Number(m[2])}` : null;
}
// The commentary entries that speak to `verse`: those covering it by range, else the closest earlier ones.
export function pickIqEntries(entries, chapter, verse) {
const here = (entries ?? []).filter((e) => (e.chapter ?? chapter) === chapter && (e.verse ?? 0) > 0 && e.text);
const covers = here.filter((e) => (e.verse ?? 0) <= verse && verse <= ((e.chapter_end ?? chapter) === chapter ? (e.verse_end ?? e.verse) : Infinity));
if (covers.length) return covers;
const earlier = here.filter((e) => e.verse <= verse);
if (!earlier.length) return [];
const start = Math.max(...earlier.map((e) => e.verse));
return earlier.filter((e) => e.verse === start);
}
// Plain label for a loosely-shaped record from the API (topics, variants, harmony sections, ...).
const LABEL_KEYS = ['title', 'name', 'topic', 'label', 'heading', 'citation', 'event', 'term'];
export function describeRecord(item) {
if (item == null) return { title: '', lines: [] };
if (typeof item !== 'object') return { title: String(item), lines: [] };
const titleKey = LABEL_KEYS.find((k) => typeof item[k] === 'string' && item[k]);
const lines = Object.entries(item)
.filter(([k, v]) => k !== titleKey && typeof v === 'string' && v && v.length < 600 && !/url|slug|id$/i.test(k))
.slice(0, 4)
.map(([k, v]) => ({ key: k.replace(/_/g, ' '), value: v }));
return { title: titleKey ? item[titleKey] : '', lines };
}
// The list inside a response ({ data: [...] }, { topics: [...] }, or the first array found).
export function listOf(data) {
if (Array.isArray(data)) return data;
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data.data)) return data.data;
const first = Object.values(data).find(Array.isArray);
return first ?? [];
}
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from 'vitest';
import { normalizeStrongs, pickIqEntries, describeRecord, listOf, iqUrl } from './iqbible.js';
test('Strong\'s numbers are normalised', () => {
expect(normalizeStrongs('G0026')).toBe('G26');
expect(normalizeStrongs('h430')).toBe('H430');
expect(normalizeStrongs('H1647G')).toBe('H1647');
expect(normalizeStrongs('nope')).toBeNull();
});
test('commentary entries are chosen by range, then by the closest earlier verse', () => {
const entries = [
{ chapter: 3, verse: 0, text: 'intro' },
{ chapter: 3, verse: 1, verse_end: 8, text: 'a' },
{ chapter: 3, verse: 9, text: 'b' },
{ chapter: 3, verse: 9, text: 'b2' },
];
expect(pickIqEntries(entries, 3, 5).map((e) => e.text)).toEqual(['a']);
expect(pickIqEntries(entries, 3, 12).map((e) => e.text)).toEqual(['b', 'b2']);
expect(pickIqEntries(entries, 4, 1)).toEqual([]);
});
test('loosely shaped records are summarised and lists found', () => {
expect(describeRecord({ name: 'faith', edition: 'iqbible', citation: 'John 3:16' }).title).toBe('faith');
expect(describeRecord('x').title).toBe('x');
expect(listOf({ status: 'ok', data: [1, 2] })).toEqual([1, 2]);
expect(listOf({ topics: [3] })).toEqual([3]);
expect(iqUrl('bibles/eng_kjv/reading-plan', { days: 30, books: 'MAT,MRK', format: 'ical', skip: '' })).toBe('/api/iq/bibles/eng_kjv/reading-plan?days=30&books=MAT%2CMRK&format=ical');
});
+69
View File
@@ -0,0 +1,69 @@
// Combines two copies of one study (this device's and the other device's) chunk by chunk, so a
// conflict doesn't force a choice between them. Chunks are matched by id; each chunk carries the time it
// was last edited here (editedAt). Nothing the person wrote is dropped: where both copies changed the
// same note, the newer one stays and the older one is appended under a marker.
const TEXT_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript', 'episodeNumber', 'episodeTitle'];
const LIST_FIELDS = ['tags', 'crossReferences', 'greekWords'];
export const MERGE_MARKER = '— from your other device —';
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const blank = (s) => !(s ?? '').toString().trim();
function mergeText(newer, older) {
if (same(newer, older) || blank(older)) return newer ?? '';
if (blank(newer)) return older;
if (newer.includes(older)) return newer;
if (older.includes(newer)) return older;
return `${newer.replace(/\s+$/, '')}\n\n${MERGE_MARKER}\n${older}`;
}
function union(a = [], b = []) {
const seen = new Set();
return [...a, ...b].filter((item) => { const k = JSON.stringify(item); if (seen.has(k)) return false; seen.add(k); return true; });
}
export function mergeChunk(mine, theirs) {
if (same(mine, theirs)) return { chunk: mine, changed: false };
const mineNewer = (mine.editedAt ?? 0) >= (theirs.editedAt ?? 0);
const [newer, older] = mineNewer ? [mine, theirs] : [theirs, mine];
const out = { ...older, ...newer };
TEXT_FIELDS.forEach((f) => { out[f] = mergeText(newer[f], older[f]); });
LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); });
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
return { chunk: out, changed: !same(out, mine) };
}
const chapterKey = (ch) => `${ch.bookAbbrev}|${ch.chapter}`;
// -> { project, stats: { added, merged } } where `added` counts chunks that only the other copy had and
// `merged` counts chunks both copies had that differed. Settings such as the title and the open chunk stay mine.
export function mergeStudies(mine, theirs) {
const stats = { added: 0, merged: 0 };
const theirChapters = new Map((theirs.chapters ?? []).map((ch) => [chapterKey(ch), ch]));
const chapters = (mine.chapters ?? []).map((ch) => {
const other = theirChapters.get(chapterKey(ch));
theirChapters.delete(chapterKey(ch));
if (!other) return ch;
const theirChunks = new Map((other.chunks ?? []).map((c) => [c.id, c]));
const chunks = (ch.chunks ?? []).map((c) => {
const o = theirChunks.get(c.id);
theirChunks.delete(c.id);
if (!o) return c;
const { chunk, changed } = mergeChunk(c, o);
if (changed) stats.merged += 1;
return chunk;
});
const extra = [...theirChunks.values()];
stats.added += extra.length;
return { ...ch, chunks: [...chunks, ...extra] };
});
for (const ch of theirChapters.values()) {
stats.added += (ch.chunks ?? []).length;
chapters.push(ch);
}
const ids = new Set(chapters.flatMap((c) => c.chunks.map((k) => k.id)));
return {
project: { ...mine, chapters, selectedChunkId: ids.has(mine.selectedChunkId) ? mine.selectedChunkId : [...ids][0] ?? null },
stats,
};
}
+51
View File
@@ -0,0 +1,51 @@
import { test, expect } from 'vitest';
import { mergeStudies, mergeChunk, MERGE_MARKER } from './mergeStudies.js';
const chunk = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 5, observation: '', interpretation: '', application: '', generalNotes: '', finalScript: '', tags: [], crossReferences: [], greekWords: [], ...extra });
const study = (chunks, extra = {}) => ({ id: 's', title: 'T', selectedChunkId: chunks[0]?.id ?? null, chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks }], ...extra });
test('notes written on different chunks, or different fields, are both kept', () => {
const mine = study([chunk('a', { observation: 'mine' }), chunk('b')]);
const theirs = study([chunk('a', { application: 'theirs' }), chunk('b', { interpretation: 'also theirs' })]);
const { project, stats } = mergeStudies(mine, theirs);
const [a, b] = project.chapters[0].chunks;
expect(a.observation).toBe('mine');
expect(a.application).toBe('theirs');
expect(b.interpretation).toBe('also theirs');
expect(stats.merged).toBe(2);
});
test('chunks only one copy has are kept, including whole new chapters', () => {
const mine = study([chunk('a')]);
const theirs = study([chunk('a'), chunk('c', { observation: 'new here' })]);
theirs.chapters.push({ book: 'John', bookAbbrev: 'JHN', chapter: '4', verses: [], chunks: [chunk('d')] });
const { project, stats } = mergeStudies(mine, theirs);
expect(project.chapters[0].chunks.map((c) => c.id)).toEqual(['a', 'c']);
expect(project.chapters).toHaveLength(2);
expect(stats.added).toBe(2);
});
test('the same note changed on both: the newer one stays and the older is kept under a marker', () => {
const mine = chunk('a', { observation: 'Version one', editedAt: 100 });
const theirs = chunk('a', { observation: 'Version two', editedAt: 200 });
const { chunk: out } = mergeChunk(mine, theirs);
expect(out.observation.startsWith('Version two')).toBe(true);
expect(out.observation).toContain(MERGE_MARKER);
expect(out.observation).toContain('Version one');
});
test('an edit that only extends the other copy does not duplicate it; lists are unioned', () => {
const { chunk: out } = mergeChunk(
chunk('a', { observation: 'Short', tags: ['x'], editedAt: 1 }),
chunk('a', { observation: 'Short, and then more', tags: ['y', 'x'], editedAt: 2 }),
);
expect(out.observation).toBe('Short, and then more');
expect(out.tags.sort()).toEqual(['x', 'y']);
});
test('identical copies merge to themselves', () => {
const s = study([chunk('a', { observation: 'same' })]);
const { project, stats } = mergeStudies(s, structuredClone(s));
expect(project).toEqual(s);
expect(stats).toEqual({ added: 0, merged: 0 });
});
+50
View File
@@ -0,0 +1,50 @@
// Starting points for a new study. A template changes what the note boxes ask (their prompts) and can
// pre-fill the "Background / general notes" box of each new chunk with a scaffold to fill in. Choosing
// "Blank" leaves the default Observe / Interpret / Apply prompts.
export const STUDY_TEMPLATES = [
{ id: 'blank', name: 'Blank', description: 'Observe, Interpret, Apply with the standard prompts.' },
{
id: 'inductive',
name: 'Inductive study',
description: 'Careful, question-led notes: observe the text, interpret it in context, then apply it.',
prompts: {
observation: 'Look before you leap.\n• List the people, places, and events.\n• Mark repeated words, contrasts, and connecting words (but, therefore, so that).\n• What is the main verb or command here?\n• What questions does the text raise?',
interpretation: 'What did it mean then?\n• What is the author\'s main point in this section?\n• How does it fit the paragraph before and after?\n• Which other Scripture sheds light on it?\n• What does it teach about God?',
application: 'What now?\n• What truth must I believe?\n• What sin to confess, or command to obey?\n• What will I do this week, and who will I tell?',
},
starter: 'Context (author, audience, setting):\n\nStructure (how the passage is built):\n\nKey words:\n',
},
{
id: 'sermon',
name: 'Sermon prep',
description: 'A big idea, an outline and illustrations for each passage, for teaching or preaching.',
prompts: {
observation: 'What does the text say?\n• The flow of thought, verse by verse.\n• Key terms and grammar worth noting.',
interpretation: 'What does it mean?\n• The main point the author is making.\n• Where it sits in the whole Bible story, and how it points to Christ.',
application: 'How should it change us?\n• Who is in the room, and what do they need to hear?\n• Specific steps, not just ideas.',
},
starter: 'Big idea (one sentence):\n\nOutline:\n1. \n2. \n3. \n\nIllustration:\n\nChrist in this text:\n\nCall to respond:\n',
},
{
id: 'devotional',
name: 'Devotional',
description: 'Gentle, personal prompts for reading and praying through a passage.',
prompts: {
observation: 'Read it slowly, twice.\n• What word or phrase stands out?\n• What do you notice about God here?',
interpretation: 'What is God showing you?\n• What does this reveal about who God is?\n• What does it say about who you are?',
application: 'Respond.\n• What will you thank God for?\n• What will you ask, confess, or obey?\n• A prayer in your own words:',
},
starter: '',
},
];
export const getTemplate = (id) => STUDY_TEMPLATES.find((t) => t.id === id) ?? STUDY_TEMPLATES[0];
// The prompt for one note box in a study built from `templateId`, or undefined for the default one.
export const templatePrompt = (templateId, field) => getTemplate(templateId).prompts?.[field];
// Fields a new chunk starts with under this template.
export const templateStarter = (templateId) => {
const starter = getTemplate(templateId).starter;
return starter ? { generalNotes: starter } : {};
};
+18
View File
@@ -0,0 +1,18 @@
import { test, expect } from 'vitest';
import { getTemplate, templatePrompt, templateStarter, STUDY_TEMPLATES } from './studyTemplates.js';
import { blankChunk } from '../hooks/useChunkBuilder.js';
test('unknown or missing templates are the blank one', () => {
expect(getTemplate('nope').id).toBe('blank');
expect(getTemplate(undefined).id).toBe('blank');
expect(templatePrompt('blank', 'observation')).toBeUndefined();
expect(templateStarter('blank')).toEqual({});
});
test('templates change the prompts and may pre-fill background notes', () => {
expect(STUDY_TEMPLATES.map((t) => t.id)).toEqual(['blank', 'inductive', 'sermon', 'devotional']);
expect(templatePrompt('sermon', 'application')).toMatch(/room/);
expect(blankChunk(1, 5, null, 'sermon').generalNotes).toMatch(/Big idea/);
expect(blankChunk(1, 5).generalNotes).toBe('');
expect(blankChunk(1, 5, null, 'devotional').generalNotes).toBe('');
});
+19
View File
@@ -0,0 +1,19 @@
// Fetches a chapter's raw text for building a study (the free Bible source). The Reader's "Save for
// offline" downloads go through the same address, so a saved book builds a study with no connection.
// Failures say what's wrong and what to do, instead of a generic error.
export async function fetchStudyChapter(translation, bookAbbrev, chapter, { signal } = {}) {
const code = translation === 'NET' ? 'eng_net' : (translation || 'BSB');
let res;
try {
res = await fetch(`https://bible.helloao.org/api/${code}/${bookAbbrev}/${chapter}.json`, { signal });
} catch (err) {
if (err?.name === 'AbortError') throw err;
const offline = typeof navigator !== 'undefined' && navigator.onLine === false;
throw new Error(offline
? "You're offline and this chapter isn't saved on this device. In the Reader, use Save for offline while connected."
: 'Could not reach the Bible text. Check your connection and try again.', { cause: err });
}
if (res.status === 503) throw new Error("You're offline and this chapter isn't saved on this device. In the Reader, use Save for offline while connected.");
if (!res.ok) throw new Error('Unable to load chapter.');
return res.json();
}
+22
View File
@@ -0,0 +1,22 @@
// Small per-account lists of study ids kept in localStorage so sync work survives a closed tab or a
// dropped connection: studies saved here that the server hasn't accepted ("unsynced"), studies deleted
// here whose server copy is still to be removed ("deletes"), and studies the server was known to have
// ("known", so one that has since vanished from it can be recognised as deleted on another device).
import { indexKey } from './bibleUtils.jsx';
function idSet(kind) {
const key = () => `${indexKey()}:${kind}`;
const read = () => { try { const v = JSON.parse(localStorage.getItem(key()) ?? '[]'); return Array.isArray(v) ? v : []; } catch { return []; } };
const write = (ids) => { try { localStorage.setItem(key(), JSON.stringify([...new Set(ids)])); } catch { /* storage unavailable */ } };
return {
list: read,
has: (id) => read().includes(id),
add: (id) => write([...read(), id]),
remove: (id) => write(read().filter((x) => x !== id)),
replace: write,
};
}
export const unsyncedStudies = idSet('unsynced');
export const pendingDeletes = idSet('deletes');
export const knownOnServer = idSet('known');
+20
View File
@@ -0,0 +1,20 @@
import { test, expect, beforeEach } from 'vitest';
import { unsyncedStudies, pendingDeletes, knownOnServer } from './syncQueue.js';
beforeEach(() => localStorage.clear());
test('queues remember ids, once each, and forget them', () => {
unsyncedStudies.add('a'); unsyncedStudies.add('a'); unsyncedStudies.add('b');
expect(unsyncedStudies.list()).toEqual(['a', 'b']);
unsyncedStudies.remove('a');
expect(unsyncedStudies.has('a')).toBe(false);
expect(unsyncedStudies.has('b')).toBe(true);
});
test('the three queues are kept apart', () => {
pendingDeletes.add('x');
knownOnServer.replace(['y', 'z']);
expect(pendingDeletes.list()).toEqual(['x']);
expect(knownOnServer.list()).toEqual(['y', 'z']);
expect(unsyncedStudies.list()).toEqual([]);
});
+1 -1
View File
@@ -69,7 +69,7 @@ export const shortHebrewGloss = (kjvDef) => {
// take first chunk before comma, semicolon, or opening paren
s = s.split(/[,;(]/)[0].trim();
// strip trailing punctuation
s = s.replace(/[.\-]+$/, '').trim();
s = s.replace(/[.-]+$/, '').trim();
return s;
};