diff --git a/README.md b/README.md index 8f23038..c48ddbc 100644 --- a/README.md +++ b/README.md @@ -155,4 +155,8 @@ npm run test:run # all tests npm run lint # ESLint (errors fail; warnings are advice) ``` +Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created. + +Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic). + 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. diff --git a/server/accountExport.js b/server/accountExport.js index 0b6a6ec..1f22ffa 100644 --- a/server/accountExport.js +++ b/server/accountExport.js @@ -21,6 +21,7 @@ export function buildAccountExport(user, store, now = new Date()) { colorLabels: prefs.colorLabels?.v ?? {}, ink: store.ink() ?? {}, }, + readingHistory: store.history?.() ?? { chapters: {}, days: [] }, readingPlans: { progress: plans.progress ?? [], customPlans: plans.customPlans ?? [], diff --git a/server/bibleApi.js b/server/bibleApi.js index e62e47f..2ccfdbc 100644 --- a/server/bibleApi.js +++ b/server/bibleApi.js @@ -28,6 +28,12 @@ const ABBREV_BY_NAME = new Map(ESV_NAMES.map((n, i) => [n.toLowerCase(), BOOK_OR ABBREV_BY_NAME.set('psalms', 'PSA'); ABBREV_BY_NAME.set('song of songs', 'SNG'); +// The full book name for a USFM code ("JHN" -> "John"), or null. +export function bookNameFor(abbrev) { + const i = BOOK_ORDER.indexOf(String(abbrev ?? '').toUpperCase()); + return i >= 0 ? ESV_NAMES[i] : null; +} + export function parseEsvReference(ref) { const m = String(ref).match(/^(.+?)\s+(\d+):(\d+)/); const bookAbbrev = m && ABBREV_BY_NAME.get(m[1].toLowerCase()); diff --git a/server/db.js b/server/db.js index 61226ed..f1c60b9 100644 --- a/server/db.js +++ b/server/db.js @@ -81,6 +81,15 @@ export function initDb() { `); } + // Optional daily verse of the day, sent to the same ntfy topic. + if (!userCols.some((c) => c.name === 'votd_enabled')) { + db.exec(` + ALTER TABLE users ADD COLUMN votd_enabled INTEGER NOT NULL DEFAULT 0; + ALTER TABLE users ADD COLUMN votd_time TEXT; + ALTER TABLE users ADD COLUMN last_votd_date TEXT; + `); + } + // Reminders fire at the user's own local time, so remember their timezone. if (!userCols.some((c) => c.name === 'reminder_tz')) { db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT'); @@ -93,9 +102,56 @@ export function initDb() { if (!projectCols.some((c) => c.name === 'outline_share_token')) { db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT'); } + // Share links can expire and be protected by a passcode. + for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) { + if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`); + } 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'); + // Server-wide settings (who may sign up, ...) and the invite codes that let someone sign up when sign-up is invite-only. + db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS invites ( + code TEXT PRIMARY KEY, + note TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + used_by TEXT, + used_at INTEGER + ); + `); + + // Co-teachers: other accounts allowed to edit a study, and the single-use links that let them join. + db.exec(` + CREATE TABLE IF NOT EXISTS collaborators ( + project_id TEXT NOT NULL, + user_id TEXT NOT NULL, + added_at INTEGER NOT NULL, + PRIMARY KEY (project_id, user_id) + ); + CREATE INDEX IF NOT EXISTS idx_collaborators_user ON collaborators(user_id); + CREATE TABLE IF NOT EXISTS edit_invites ( + token TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + used_by TEXT + ); + `); + + // What the person has read in the Reader (chapters and days), merged from every device. + db.exec(` + CREATE TABLE IF NOT EXISTS reader_history ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + data TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + `); + // One-time codes for "forgot password": hashed, short-lived, limited tries. db.exec(` CREATE TABLE IF NOT EXISTS password_resets ( @@ -182,6 +238,7 @@ function parseUserRow(row) { ...row, totpEnabled: !!row.totpEnabled, reminderEnabled: !!row.reminderEnabled, + votdEnabled: !!row.votdEnabled, backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [], }; } @@ -191,7 +248,8 @@ const USER_SELECT = ` totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw, podcast_name AS podcastName, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime, - reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz + reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz, + votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate FROM users `; @@ -234,6 +292,25 @@ export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime, `).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, reminderTz || null, userId); } +/** Turns the daily verse of the day on or off, and says when to send it. */ +export function setVotdSettings(userId, { votdEnabled, votdTime }) { + db.prepare('UPDATE users SET votd_enabled = ?, votd_time = ? WHERE id = ?').run(votdEnabled ? 1 : 0, votdTime || null, userId); +} + +/** Every account with the verse of the day turned on and a topic to send it to. */ +export function getUsersWithVotdEnabled() { + return db.prepare(` + SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, votd_time AS votdTime, + last_votd_date AS lastVotdDate, reminder_tz AS reminderTz + FROM users + WHERE votd_enabled = 1 AND ntfy_topic IS NOT NULL AND votd_time IS NOT NULL + `).all(); +} + +export function setLastVotdDate(userId, dateString) { + db.prepare('UPDATE users SET last_votd_date = ? WHERE id = ?').run(dateString, userId); +} + /** Every account with reminders turned on and a topic configured — checked once a minute. */ export function getUsersWithRemindersEnabled() { return db.prepare(` @@ -249,6 +326,41 @@ export function setLastReminderDate(userId, dateString) { db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId); } +// --------------------------------------------------------------------------- +// Settings and sign-up invites +// --------------------------------------------------------------------------- + +export function getSetting(key, fallback = null) { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); + return row ? row.value : fallback; +} + +export function setSetting(key, value) { + db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, String(value)); +} + +export function createInvite(code, note, expiresAt) { + db.prepare('INSERT INTO invites (code, note, created_at, expires_at) VALUES (?, ?, ?, ?)').run(code, note || null, Date.now(), expiresAt); +} + +export function listInvites() { + return db.prepare('SELECT code, note, created_at AS createdAt, expires_at AS expiresAt, used_by AS usedBy, used_at AS usedAt FROM invites ORDER BY created_at DESC LIMIT 100').all(); +} + +export function deleteInvite(code) { + db.prepare('DELETE FROM invites WHERE code = ?').run(code); +} + +/** An invite that exists, hasn't expired and hasn't been used. */ +export function findOpenInvite(code) { + return db.prepare('SELECT code FROM invites WHERE code = ? AND used_by IS NULL AND expires_at > ?').get(code, Date.now()) ?? null; +} + +/** Marks an invite used by a new account; false if someone else got there first. */ +export function useInvite(code, userId) { + return db.prepare('UPDATE invites SET used_by = ?, used_at = ? WHERE code = ? AND used_by IS NULL AND expires_at > ?').run(userId, Date.now(), code, Date.now()).changes > 0; +} + // --------------------------------------------------------------------------- // Password reset codes // --------------------------------------------------------------------------- @@ -325,6 +437,18 @@ export function setReaderMarks(userId, { bookmarks, highlights }) { return updatedAt; } +export function getReaderHistory(userId) { + const row = db.prepare('SELECT data FROM reader_history WHERE user_id = ?').get(userId); + try { return row ? JSON.parse(row.data) : { chapters: {}, days: [] }; } catch { return { chapters: {}, days: [] }; } +} + +export function setReaderHistory(userId, history) { + db.prepare(` + INSERT INTO reader_history (user_id, data, updated_at) VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at + `).run(userId, JSON.stringify(history), Date.now()); +} + /** Upserts the ink strokes for one reader page. */ export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) { db.prepare(` @@ -376,20 +500,33 @@ export function claimOrphanProjects(userId) { * Does NOT return full project data to keep the response small. */ export function getAllProjects(userId) { + // the studies you own, plus the ones another account has invited you to edit const rows = db.prepare(` - SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary - FROM projects - WHERE user_id = ? - ORDER BY last_edited DESC - `).all(userId); - return rows; + SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary, + CASE WHEN p.user_id = ? THEN 0 ELSE 1 END AS shared, u.email AS ownerEmail + FROM projects p + LEFT JOIN users u ON u.id = p.user_id + WHERE p.user_id = ? OR p.id IN (SELECT project_id FROM collaborators WHERE user_id = ?) + ORDER BY p.last_edited DESC + `).all(userId, userId, userId); + return rows.map((r) => (r.shared ? { ...r, shared: true } : { id: r.id, title: r.title, lastEdited: r.lastEdited, chapterSummary: r.chapterSummary })); } +/** True when the account owns the study or has been invited to edit it. */ +export function canEditProject(id, userId) { + return !!db.prepare(` + SELECT 1 FROM projects p WHERE p.id = ? AND (p.user_id = ? OR EXISTS (SELECT 1 FROM collaborators c WHERE c.project_id = p.id AND c.user_id = ?)) + `).get(id, userId, userId); +} + +export const isProjectOwner = (id, userId) => !!db.prepare('SELECT 1 FROM projects WHERE id = ? AND user_id = ?').get(id, userId); + /** * Returns a single full project by id, scoped to userId, or null if not found/not owned. */ export function getProject(id, userId) { - const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(id, userId); + if (!canEditProject(id, userId)) return null; + const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id); if (!row) return null; try { return JSON.parse(row.data); @@ -400,7 +537,8 @@ export function getProject(id, userId) { /** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */ export function getProjectEdited(id, userId) { - const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ? AND user_id = ?').get(id, userId); + if (!canEditProject(id, userId)) return null; + const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(id); return row ? row.lastEdited : null; } @@ -410,7 +548,7 @@ export function getProjectEdited(id, userId) { */ export function upsertProject(project, userId) { const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id); - if (existing && existing.userId !== userId) { + if (existing && existing.userId !== userId && !canEditProject(project.id, userId)) { return null; } @@ -437,7 +575,54 @@ export function upsertProject(project, userId) { * Delete a project by id, scoped to userId. No-op if not found/not owned. */ export function deleteProject(id, userId) { - db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId); + db.transaction(() => { + if (!db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId).changes) { + // not the owner: "deleting" a study you were invited to just removes you from it + db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(id, userId); + return; + } + db.prepare('DELETE FROM collaborators WHERE project_id = ?').run(id); + db.prepare('DELETE FROM edit_invites WHERE project_id = ?').run(id); + })(); +} + +// --------------------------------------------------------------------------- +// Co-teachers +// --------------------------------------------------------------------------- + +export function createEditInvite(token, projectId, expiresAt) { + db.prepare('INSERT INTO edit_invites (token, project_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, projectId, Date.now(), expiresAt); +} + +export function listCollaborators(projectId) { + return db.prepare(` + SELECT c.user_id AS userId, u.email, c.added_at AS addedAt FROM collaborators c JOIN users u ON u.id = c.user_id + WHERE c.project_id = ? ORDER BY c.added_at + `).all(projectId); +} + +export function countOpenEditInvites(projectId) { + return db.prepare('SELECT COUNT(*) AS n FROM edit_invites WHERE project_id = ? AND used_by IS NULL AND expires_at > ?').get(projectId, Date.now()).n; +} + +export function removeCollaborator(projectId, userId) { + db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(projectId, userId); +} + +/** Uses a single-use invite: -> { projectId, title } | { error }. The owner is told "you own this" rather than added. */ +export function acceptEditInvite(token, userId) { + return db.transaction(() => { + const inv = db.prepare('SELECT project_id AS projectId, expires_at AS expiresAt, used_by AS usedBy FROM edit_invites WHERE token = ?').get(token); + if (!inv) return { error: 'This invite link is not valid.' }; + if (inv.usedBy) return { error: 'This invite link has already been used.' }; + if (inv.expiresAt <= Date.now()) return { error: 'This invite link has expired. Ask for a new one.' }; + const project = db.prepare('SELECT title, user_id AS ownerId FROM projects WHERE id = ?').get(inv.projectId); + if (!project) return { error: 'That study no longer exists.' }; + if (project.ownerId === userId) return { error: 'That is your own study.' }; + db.prepare('INSERT OR IGNORE INTO collaborators (project_id, user_id, added_at) VALUES (?, ?, ?)').run(inv.projectId, userId, Date.now()); + db.prepare('UPDATE edit_invites SET used_by = ? WHERE token = ?').run(userId, token); + return { projectId: inv.projectId, title: project.title }; + })(); } // --------------------------------------------------------------------------- @@ -458,7 +643,7 @@ export function setShareToken(id, userId, token) { /** Revokes a project's share link, scoped to userId. */ export function clearShareToken(id, userId) { - db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId); + db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId); } // ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ---- @@ -473,7 +658,39 @@ export function setOutlineShareToken(id, userId, token) { } export function clearOutlineShareToken(id, userId) { - db.prepare('UPDATE projects SET outline_share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId); + db.prepare('UPDATE projects SET outline_share_token = NULL, outline_share_expires = NULL, outline_share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId); +} + +// ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ---- +const shareCols = (outline) => (outline + ? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass' } + : { token: 'share_token', expires: 'share_expires', pass: 'share_pass' }); + +/** { token, expiresAt, hasPasscode } for the owner's link settings. */ +export function getShareSettings(id, userId, outline = false) { + const c = shareCols(outline); + const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId); + return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass } : null; +} + +/** The stored passcode hash of a link (kept when only the expiry is changed). */ +export function getSharePassHash(id, userId, outline = false) { + const c = shareCols(outline); + return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null; +} + +/** Sets expiry (ms timestamp or null) and passcode hash (or null) on an existing link. */ +export function setShareSettings(id, userId, { expiresAt, passHash }, outline = false) { + const c = shareCols(outline); + return db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0; +} + +/** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */ +export function getSharedByToken(token, outline = false) { + const c = shareCols(outline); + const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash FROM projects WHERE ${c.token} = ?`).get(token); + if (!row) return null; + try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null }; } catch { return null; } } /** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */ @@ -513,6 +730,7 @@ export function adminGetAllUsers() { 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('DELETE FROM collaborators WHERE user_id = ?').run(userId); 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); })(); diff --git a/server/index.js b/server/index.js index 852ad8b..9014aab 100644 --- a/server/index.js +++ b/server/index.js @@ -10,13 +10,16 @@ import { initDb, dataDir, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject, countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects, enableTotp, disableTotp, setBackupCodeHashes, setPodcastName, - getShareToken, setShareToken, clearShareToken, getProjectByShareToken, - getOutlineShareToken, setOutlineShareToken, clearOutlineShareToken, getProjectByOutlineToken, + setShareToken, clearShareToken, getShareSettings, setShareSettings, getSharedByToken, getSharePassHash, + setOutlineShareToken, clearOutlineShareToken, adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject, setUserPassword, destroyAllSessionsForUser, getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs, getReadingProgress, setReadingProgress, - setReminderSettings, + setReminderSettings, setVotdSettings, + getReaderHistory, setReaderHistory, + isProjectOwner, createEditInvite, listCollaborators, countOpenEditInvites, removeCollaborator, acceptEditInvite, + getSetting, setSetting, createInvite, listInvites, deleteInvite, findOpenInvite, useInvite, setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset, } from './db.js'; import { SqliteSessionStore } from './sessionStore.js'; @@ -26,9 +29,13 @@ import { generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword, generateResetCode, normalizeResetCode, } from './auth.js'; +import { votdAvailable } from './votd.js'; import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js'; import { registerBibleRoutes } from './bibleApi.js'; import { registerIqBibleRoutes } from './iqbible.js'; +import { expiryFromDays, hashPasscode, checkShareAccess } from './shareLinks.js'; +import { SIGNUP_MODES, isSignupMode, checkSignup, generateInviteCode, normalizeInviteCode } from './signup.js'; +import { mergeHistory } from './readerHistory.js'; import { buildAccountExport, accountExportName } from './accountExport.js'; import { startBackupScheduler } from './backups.js'; import { cleanMarks } from './readerMarks.js'; @@ -43,6 +50,7 @@ function userPayload(user) { isAdmin: isAdminEmail(user.email), reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null, reminderTime: user.reminderTime ?? null, + votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(), }; } @@ -146,6 +154,9 @@ app.post('/api/auth/register', async (req, res) => { const email = String(req.body?.email ?? '').trim().toLowerCase(); const password = String(req.body?.password ?? ''); + const inviteCode = normalizeInviteCode(req.body?.invite); + const allowed = checkSignup({ mode: getSetting('signup', 'open'), userCount: countUsers(), inviteOk: !!findOpenInvite(inviteCode) }); + if (!allowed.ok) return res.status(allowed.status).json({ error: allowed.error }); if (!isValidEmail(email)) { return res.status(400).json({ error: 'Enter a valid email address.' }); } @@ -158,6 +169,10 @@ app.post('/api/auth/register', async (req, res) => { const passwordHash = await hashPassword(password); const user = createUser({ id: randomUUID(), email, passwordHash }); + if (getSetting('signup', 'open') === 'invite' && countUsers() > 1 && !useInvite(inviteCode, user.id)) { + adminDeleteUser(user.id); // someone used the code between the check and now + return res.status(403).json({ error: 'That invite code was just used. Ask for a new one.' }); + } // The very first account inherits any projects created before multi-user support existed. if (countUsers() === 1) { @@ -313,6 +328,11 @@ app.post('/api/auth/reset', async (req, res) => { } }); +// What the sign-in page needs to know: whether to offer "create an account", and whether it needs an invite code. +app.get('/api/auth/signup-mode', (_req, res) => { + res.json({ mode: countUsers() === 0 ? 'open' : getSetting('signup', 'open') }); +}); + app.post('/api/auth/logout', (req, res) => { req.session.destroy(() => { res.clearCookie('connect.sid'); @@ -367,6 +387,14 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => { } } + // the verse of the day is optional, and only offered when the server can look it up + const votdEnabled = !!req.body?.votdEnabled && votdAvailable(); + const votdTime = String(req.body?.votdTime ?? '').trim(); + if (votdEnabled) { + if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the verse of the day.' }); + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' }); + } + setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null }); setReminderSettings(req.session.userId, { ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, @@ -376,6 +404,7 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => { }); res.json({ reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null, + votdEnabled, votdTime: votdTime || null, }); } catch (err) { console.error('PATCH /api/auth/reminder-settings error:', err); @@ -489,6 +518,7 @@ app.get('/api/account/export', requireAuth, (req, res) => { marks: () => getReaderMarks(userId), ink: () => getAllReaderInk(userId), plans: () => getReadingProgress(userId), + history: () => getReaderHistory(userId), }); res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`); res.json(backup); @@ -632,6 +662,24 @@ app.get('/api/reader/marks', requireAuth, (req, res) => { } }); +// GET /api/reader/history — what has been read, and PUT merges this device's copy in and returns the whole +app.get('/api/reader/history', requireAuth, (req, res) => { + try { res.json(mergeHistory(getReaderHistory(req.session.userId), {})); } catch (err) { + console.error('GET /api/reader/history error:', err); + res.status(500).json({ error: 'Failed to load your reading history.' }); + } +}); +app.put('/api/reader/history', requireAuth, (req, res) => { + try { + const merged = mergeHistory(getReaderHistory(req.session.userId), req.body); + setReaderHistory(req.session.userId, merged); + res.json(merged); + } catch (err) { + console.error('PUT /api/reader/history error:', err); + res.status(500).json({ error: 'Failed to save your reading history.' }); + } +}); + // PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights }) app.put('/api/reader/marks', requireAuth, (req, res) => { try { @@ -715,28 +763,37 @@ app.post('/api/auth/reminder-test', requireAuth, async (req, res) => { // GET /api/projects/:id/share — current share status for the project owner app.get('/api/projects/:id/share', requireAuth, (req, res) => { try { - const project = getProject(req.params.id, req.session.userId); - if (!project) return res.status(404).json({ error: 'Project not found.' }); - res.json({ shareToken: getShareToken(req.params.id, req.session.userId) }); + const settings = getShareSettings(req.params.id, req.session.userId); + if (!settings) return res.status(404).json({ error: 'Project not found.' }); + res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode }); } catch (err) { console.error('GET /api/projects/:id/share error:', err); res.status(500).json({ error: 'Failed to load share status.' }); } }); -// POST /api/projects/:id/share — enable sharing, returns the (new or existing) token -app.post('/api/projects/:id/share', requireAuth, (req, res) => { +// Sets up (or changes) a read-only link: body { expiresInDays?: number | null, passcode?: string | null }. +// Leaving a field out keeps what it was; null/"" clears it. Returns the (new or existing) token. +async function saveLink(req, res, outline) { try { - const existing = getShareToken(req.params.id, req.session.userId); - const token = existing || randomUUID().replace(/-/g, ''); - const ok = setShareToken(req.params.id, req.session.userId, token); - if (!ok) return res.status(404).json({ error: 'Project not found.' }); - res.json({ shareToken: token }); + const userId = req.session.userId; + const settings = getShareSettings(req.params.id, userId, outline); + if (!settings) return res.status(404).json({ error: 'Project not found.' }); + const token = settings.token || randomUUID().replace(/-/g, ''); + if (!settings.token && !(outline ? setOutlineShareToken : setShareToken)(req.params.id, userId, token)) return res.status(404).json({ error: 'Project not found.' }); + const body = req.body ?? {}; + const expiresAt = 'expiresInDays' in body ? expiryFromDays(body.expiresInDays) : settings.expiresAt; + // a blank passcode field means "keep it"; null clears; a string sets + const current = getSharePassHash(req.params.id, userId, outline); + const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current; + setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline); + res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash }); } catch (err) { - console.error('POST /api/projects/:id/share error:', err); + console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err); res.status(500).json({ error: 'Failed to enable sharing.' }); } -}); +} +app.post('/api/projects/:id/share', requireAuth, (req, res) => saveLink(req, res, false)); // DELETE /api/projects/:id/share — revoke the share link app.delete('/api/projects/:id/share', requireAuth, (req, res) => { @@ -749,27 +806,49 @@ app.delete('/api/projects/:id/share', requireAuth, (req, res) => { } }); +// --------------------------------------------------------------------------- +// Co-teachers: other accounts that may edit a study. The owner makes a single-use invite link; whoever +// opens it while signed in joins. The owner (or the co-teacher themself) can remove an editor. +// --------------------------------------------------------------------------- +app.get('/api/projects/:id/collaborators', requireAuth, (req, res) => { + if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); + res.json({ collaborators: listCollaborators(req.params.id), pendingInvites: countOpenEditInvites(req.params.id) }); +}); + +app.post('/api/projects/:id/collaborators/invite', requireAuth, (req, res) => { + if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' }); + const token = randomUUID().replace(/-/g, ''); + const expiresAt = Date.now() + 7 * 86_400_000; + createEditInvite(token, req.params.id, expiresAt); + res.json({ token, expiresAt }); +}); + +app.delete('/api/projects/:id/collaborators/:userId', requireAuth, (req, res) => { + const me = req.session.userId; + if (!isProjectOwner(req.params.id, me) && req.params.userId !== me) return res.status(403).json({ error: 'Only the owner can remove someone else.' }); + removeCollaborator(req.params.id, req.params.userId); + res.json({ ok: true }); +}); + +app.post('/api/join/:token', requireAuth, (req, res) => { + const result = acceptEditInvite(req.params.token, req.session.userId); + if (result.error) return res.status(400).json({ error: result.error }); + res.json(result); +}); + // Read-only outline links. A separate token from the study share link, and the public endpoint returns // only the outlines (see outlineShare.js), never the notes or scripts. app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => { try { - res.json({ shareToken: getOutlineShareToken(req.params.id, req.session.userId) }); + const settings = getShareSettings(req.params.id, req.session.userId, true); + res.json({ shareToken: settings?.token ?? null, expiresAt: settings?.expiresAt ?? null, hasPasscode: !!settings?.hasPasscode }); } catch (err) { console.error('GET /api/projects/:id/outline-share error:', err); res.status(500).json({ error: 'Failed to load share status.' }); } }); -app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => { - try { - const token = getOutlineShareToken(req.params.id, req.session.userId) || randomUUID().replace(/-/g, ''); - if (!setOutlineShareToken(req.params.id, req.session.userId, token)) return res.status(404).json({ error: 'Project not found.' }); - res.json({ shareToken: token }); - } catch (err) { - console.error('POST /api/projects/:id/outline-share error:', err); - res.status(500).json({ error: 'Failed to enable sharing.' }); - } -}); +app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => saveLink(req, res, true)); app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => { try { @@ -781,24 +860,37 @@ app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => { } }); -// GET /api/share-outline/:token — PUBLIC, no login required -app.get('/api/share-outline/:token', (req, res) => { +// A wrong passcode counts as a failed attempt for that link and address +async function openSharedLink(req, res, outline) { + const found = getSharedByToken(req.params.token, outline); + if (!found) { res.status(404).json({ error: outline ? 'This outline link is invalid or has been turned off.' : 'This share link is invalid or has been revoked.' }); return null; } + const keys = [`ip:${req.ip}`, `share:${req.params.token}`]; + if (isLimited(...keys)) { res.status(429).json(TOO_MANY); return null; } + const access = await checkShareAccess({ expiresAt: found.expiresAt, passHash: found.passHash, supplied: req.get('X-Share-Passcode') }); + if (!access.ok) { + if (access.needsPasscode && req.get('X-Share-Passcode')) recordFailure(...keys); + res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 }); + return null; + } + return found.project; +} + +// GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed) +app.get('/api/share-outline/:token', async (req, res) => { try { - const project = getProjectByOutlineToken(req.params.token); - if (!project) return res.status(404).json({ error: 'This outline link is invalid or has been turned off.' }); - res.json(buildOutlinePayload(project)); + const project = await openSharedLink(req, res, true); + if (project) res.json(buildOutlinePayload(project)); } catch (err) { console.error('GET /api/share-outline/:token error:', err); res.status(500).json({ error: 'Failed to load the shared outline.' }); } }); -// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only -app.get('/api/share/:token', (req, res) => { +// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed) +app.get('/api/share/:token', async (req, res) => { try { - const project = getProjectByShareToken(req.params.token); - if (!project) return res.status(404).json({ error: 'This share link is invalid or has been revoked.' }); - res.json(project); + const project = await openSharedLink(req, res, false); + if (project) res.json(project); } catch (err) { console.error('GET /api/share/:token error:', err); res.status(500).json({ error: 'Failed to load shared project.' }); @@ -848,6 +940,29 @@ app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async } }); +// Who may sign up, and the invite codes for it +app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => { + res.json({ mode: getSetting('signup', 'open'), invites: listInvites() }); +}); + +app.put('/api/admin/signup', requireAuth, requireAdmin, (req, res) => { + if (!isSignupMode(req.body?.mode)) return res.status(400).json({ error: `Mode must be one of: ${SIGNUP_MODES.join(', ')}.` }); + setSetting('signup', req.body.mode); + res.json({ mode: req.body.mode }); +}); + +app.post('/api/admin/invites', requireAuth, requireAdmin, (req, res) => { + const days = Math.min(90, Math.max(1, Number(req.body?.days) || 7)); + const code = generateInviteCode(); + createInvite(code, String(req.body?.note ?? '').trim().slice(0, 80), Date.now() + days * 86_400_000); + res.json({ code, expiresInDays: days }); +}); + +app.delete('/api/admin/invites/:code', requireAuth, requireAdmin, (req, res) => { + deleteInvite(normalizeInviteCode(req.params.code)); + res.json({ ok: true }); +}); + app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => { try { res.json(adminGetAllProjects()); diff --git a/server/prefs.js b/server/prefs.js index 7d877aa..cb5edd5 100644 --- a/server/prefs.js +++ b/server/prefs.js @@ -24,6 +24,21 @@ export function cleanPrefValue(key, value) { } return out; } + if (key === 'studyTemplates') { + // The person's own study templates: prompts for the note boxes and scaffold text to start each chunk with + if (!Array.isArray(value)) return undefined; + const text = (v, max) => (typeof v === 'string' ? v.slice(0, max) : ''); + const fields = (o, names, max) => { + const out = {}; + for (const n of names) { const t = text(o?.[n], max); if (t.trim()) out[n] = t; } + return out; + }; + return value.slice(0, 12).filter((t) => t && typeof t.id === 'string' && /^user-[\w-]{1,40}$/.test(t.id) && typeof t.name === 'string' && t.name.trim()).map((t) => { + const prompts = fields(t.prompts, ['observation', 'interpretation', 'application'], 800); + const starters = fields(t.starters, ['generalNotes', 'observation', 'interpretation', 'application'], 2000); + return { id: t.id, name: text(t.name, 40).trim(), description: text(t.description, 120), ...(Object.keys(prompts).length ? { prompts } : {}), ...(Object.keys(starters).length ? { starters } : {}) }; + }); + } if (key === 'position') { if (!value || typeof value !== 'object') return undefined; const book = String(value.book ?? '').toUpperCase(); diff --git a/server/prefs.test.js b/server/prefs.test.js index ebb3704..4ca1487 100644 --- a/server/prefs.test.js +++ b/server/prefs.test.js @@ -67,3 +67,14 @@ test('the database keeps one row per user', async () => { expect(db.getUserPrefs('u1')).toEqual({ theme: { v: 'sepia', at: 2 } }); expect(db.getUserPrefs('u2')).toEqual({}); }); + +test('study templates are kept only when well-formed, and trimmed to sane sizes', async () => { + const { cleanPrefValue } = await import('./prefs.js'); + const good = { id: 'user-abc1', name: ' Lesson ', description: 'x', prompts: { observation: 'Look', junk: 'no' }, starters: { generalNotes: 'Context:', bogus: 'no' } }; + expect(cleanPrefValue('studyTemplates', [good, { id: 'other', name: 'bad id' }, { id: 'user-2' }, null])).toEqual([ + { id: 'user-abc1', name: 'Lesson', description: 'x', prompts: { observation: 'Look' }, starters: { generalNotes: 'Context:' } }, + ]); + expect(cleanPrefValue('studyTemplates', 'nope')).toBeUndefined(); + expect(cleanPrefValue('studyTemplates', Array.from({ length: 30 }, (_, i) => ({ id: `user-${i}`, name: `T${i}` }))).length).toBe(12); + expect(cleanPrefValue('studyTemplates', [{ id: 'user-a', name: 'x', starters: { observation: 'y'.repeat(5000) } }])[0].starters.observation.length).toBe(2000); +}); diff --git a/server/readerHistory.js b/server/readerHistory.js new file mode 100644 index 0000000..0b69242 --- /dev/null +++ b/server/readerHistory.js @@ -0,0 +1,32 @@ +// What a person has read in the Reader: which chapters (how many times, and when last) and on which days. +// Merging two copies never loses anything, so two devices can both add to it: each chapter keeps the larger +// count and the later date, and the days are combined. The data comes from the browser, so it is rebuilt field by field. +const KEY = /^[1-3A-Z]{3}\.\d{1,3}$/; +const DATE = /^\d{4}-\d{2}-\d{2}$/; +const MAX_DAYS = 5000; + +export const emptyHistory = () => ({ chapters: {}, days: [] }); + +export function cleanHistory(raw) { + const chapters = {}; + if (raw?.chapters && typeof raw.chapters === 'object' && !Array.isArray(raw.chapters)) { + for (const [key, v] of Object.entries(raw.chapters).slice(0, 1300)) { + if (!KEY.test(key) || !v || typeof v !== 'object') continue; + const n = Number.isInteger(v.n) ? Math.min(9999, Math.max(1, v.n)) : 1; + chapters[key] = { n, last: DATE.test(v.last ?? '') ? v.last : '' }; + } + } + const days = Array.isArray(raw?.days) ? [...new Set(raw.days.filter((d) => typeof d === 'string' && DATE.test(d)))].sort().slice(-MAX_DAYS) : []; + return { chapters, days }; +} + +export function mergeHistory(a, b) { + const x = cleanHistory(a); + const y = cleanHistory(b); + const chapters = { ...x.chapters }; + for (const [key, v] of Object.entries(y.chapters)) { + const mine = chapters[key]; + chapters[key] = mine ? { n: Math.max(mine.n, v.n), last: mine.last > v.last ? mine.last : v.last } : v; + } + return { chapters, days: [...new Set([...x.days, ...y.days])].sort().slice(-MAX_DAYS) }; +} diff --git a/server/readerHistory.test.js b/server/readerHistory.test.js new file mode 100644 index 0000000..6efdcf5 --- /dev/null +++ b/server/readerHistory.test.js @@ -0,0 +1,23 @@ +// @vitest-environment node +import { test, expect } from 'vitest'; +import { cleanHistory, mergeHistory } from './readerHistory.js'; + +test('only well-formed chapters and days are kept', () => { + const out = cleanHistory({ + chapters: { 'JHN.3': { n: 2, last: '2026-10-01' }, 'bad': { n: 1 }, 'GEN.1': { n: -5, last: 'yesterday' }, '1JN.2': null }, + days: ['2026-10-02', 'nope', '2026-10-01', '2026-10-01'], + }); + expect(out.chapters).toEqual({ 'JHN.3': { n: 2, last: '2026-10-01' }, 'GEN.1': { n: 1, last: '' } }); + expect(out.days).toEqual(['2026-10-01', '2026-10-02']); + expect(cleanHistory(null)).toEqual({ chapters: {}, days: [] }); +}); + +test('merging keeps the larger count, the later date and every day', () => { + const a = { chapters: { 'JHN.3': { n: 2, last: '2026-10-01' }, 'GEN.1': { n: 1, last: '2026-09-01' } }, days: ['2026-10-01'] }; + const b = { chapters: { 'JHN.3': { n: 1, last: '2026-10-05' }, 'ROM.8': { n: 1, last: '2026-10-04' } }, days: ['2026-10-04', '2026-10-05'] }; + const m = mergeHistory(a, b); + expect(m.chapters['JHN.3']).toEqual({ n: 2, last: '2026-10-05' }); + expect(Object.keys(m.chapters).sort()).toEqual(['GEN.1', 'JHN.3', 'ROM.8']); + expect(m.days).toEqual(['2026-10-01', '2026-10-04', '2026-10-05']); + expect(mergeHistory(m, m)).toEqual(m); // merging again changes nothing +}); diff --git a/server/reminders.js b/server/reminders.js index 976808b..5a56f0b 100644 --- a/server/reminders.js +++ b/server/reminders.js @@ -4,7 +4,8 @@ // No API keys or push-subscription plumbing needed. import { lookup } from 'dns/promises'; import { isIP } from 'net'; -import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js'; +import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate } from './db.js'; +import { fetchVerseOfDay, votdMessage } from './votd.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. @@ -121,9 +122,30 @@ export async function checkReminders(now = new Date()) { } } +// Sends the verse of the day to everyone who asked for it, at their own time. One verse a day, the same for all. +export async function checkVotd(now = new Date()) { + const due = getUsersWithVotdEnabled().filter((u) => isDue({ reminderTime: u.votdTime, reminderTz: u.reminderTz, lastReminderDate: u.lastVotdDate }, now)); + if (due.length === 0) return; + const verse = await fetchVerseOfDay(now); + if (!verse) return; // no key, or IQ Bible didn't answer: try again next minute (within the grace window) + for (const user of due) { + const key = `votd:${user.id}`; + if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue; + _lastAttempt.set(key, now.getTime()); + try { + await sendNtfy(user, votdMessage(verse)); + _lastAttempt.delete(key); + setLastVotdDate(user.id, zonedNow(now, user.reminderTz).date); + } catch (err) { + console.error(`Verse of the day failed for user ${user.id}:`, err.message); + } + } +} + /** Checks once a minute whether any user's reminder time has arrived. */ export function startReminderScheduler() { setInterval(() => { checkReminders().catch((err) => console.error('Reminder scheduler error:', err)); + checkVotd().catch((err) => console.error('Verse of the day scheduler error:', err)); }, 60 * 1000); } diff --git a/server/shareLinks.js b/server/shareLinks.js new file mode 100644 index 0000000..076dfb7 --- /dev/null +++ b/server/shareLinks.js @@ -0,0 +1,27 @@ +// The rules for a read-only link: it can expire, and it can need a passcode. Kept separate from the routes +// so they can be tested on their own. +import bcrypt from 'bcryptjs'; + +export const MAX_PASSCODE = 100; + +// "never" (null/0) or a number of days, up to a year -> an expiry time in ms (or null) +export function expiryFromDays(days, now = Date.now()) { + const n = Number(days); + if (!days || !Number.isFinite(n) || n <= 0) return null; + return now + Math.min(365, Math.round(n)) * 86_400_000; +} + +export async function hashPasscode(passcode) { + const p = String(passcode ?? '').slice(0, MAX_PASSCODE); + return p.trim() ? bcrypt.hash(p.trim(), 10) : null; +} + +// -> { ok: true } | { ok: false, status: 410, error } (expired) | { ok: false, status: 401, needsPasscode: true, error } +export async function checkShareAccess({ expiresAt, passHash, supplied }, now = Date.now()) { + if (expiresAt && expiresAt <= now) return { ok: false, status: 410, error: 'This link has expired. Ask the person who shared it for a new one.' }; + if (!passHash) return { ok: true }; + const given = String(supplied ?? '').slice(0, MAX_PASSCODE).trim(); + if (!given) return { ok: false, status: 401, needsPasscode: true, error: 'This link needs a passcode.' }; + if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' }; + return { ok: true }; +} diff --git a/server/shareLinks.test.js b/server/shareLinks.test.js new file mode 100644 index 0000000..fdca999 --- /dev/null +++ b/server/shareLinks.test.js @@ -0,0 +1,33 @@ +// @vitest-environment node +import { test, expect } from 'vitest'; +import { checkShareAccess, expiryFromDays, hashPasscode } from './shareLinks.js'; + +const NOW = 1_800_000_000_000; + +test('expiry is a number of days from now, or never', () => { + expect(expiryFromDays(null, NOW)).toBeNull(); + expect(expiryFromDays(0, NOW)).toBeNull(); + expect(expiryFromDays('x', NOW)).toBeNull(); + expect(expiryFromDays(7, NOW)).toBe(NOW + 7 * 86_400_000); + expect(expiryFromDays(9999, NOW)).toBe(NOW + 365 * 86_400_000); // never more than a year +}); + +test('a link with no rules is open; an expired one says so', async () => { + expect((await checkShareAccess({}, NOW)).ok).toBe(true); + expect(await checkShareAccess({ expiresAt: NOW + 1000 }, NOW)).toEqual({ ok: true }); + expect(await checkShareAccess({ expiresAt: NOW - 1 }, NOW)).toMatchObject({ ok: false, status: 410 }); +}); + +test('a passcode is asked for, and only the right one opens the link', async () => { + const passHash = await hashPasscode(' moses '); + expect(passHash).not.toContain('moses'); + expect(await checkShareAccess({ passHash }, NOW)).toMatchObject({ ok: false, status: 401, needsPasscode: true }); + expect(await checkShareAccess({ passHash, supplied: 'aaron' }, NOW)).toMatchObject({ ok: false, status: 401, needsPasscode: true, error: expect.stringMatching(/not right/) }); + expect(await checkShareAccess({ passHash, supplied: ' moses ' }, NOW)).toEqual({ ok: true }); + expect(await hashPasscode(' ')).toBeNull(); +}); + +test('an expired link is reported as expired even before the passcode is checked', async () => { + const passHash = await hashPasscode('x'); + expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 }); +}); diff --git a/server/signup.js b/server/signup.js new file mode 100644 index 0000000..91b6b4e --- /dev/null +++ b/server/signup.js @@ -0,0 +1,25 @@ +// Who may create an account. The administrator picks one of three settings: +// open anyone who can reach the server (the original behaviour) +// invite only someone with an unused invite code from the administrator +// closed nobody new +// The very first account can always be made, so a fresh install can't lock itself out. +import { randomBytes } from 'crypto'; + +export const SIGNUP_MODES = ['open', 'invite', 'closed']; +export const isSignupMode = (m) => SIGNUP_MODES.includes(m); + +// -> { ok: true } or { ok: false, status, error }. `inviteOk` says whether the code given is a valid unused one. +export function checkSignup({ mode, userCount, inviteOk }) { + if (userCount === 0 || mode === 'open') return { ok: true }; + if (mode === 'closed') return { ok: false, status: 403, error: 'Sign-up is closed. Ask the administrator for an account.' }; + if (!inviteOk) return { ok: false, status: 403, error: 'Sign-up needs an invite code from the administrator.' }; + return { ok: true }; +} + +// An easy-to-read invite code (no look-alike characters), e.g. K7QP-4MXD-9TRW. +const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; +export function generateInviteCode() { + const chars = Array.from(randomBytes(12), (b) => ALPHABET[b % ALPHABET.length]).join(''); + return `${chars.slice(0, 4)}-${chars.slice(4, 8)}-${chars.slice(8, 12)}`; +} +export const normalizeInviteCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/^(.{4})(.{4})(.{4})$/, '$1-$2-$3'); diff --git a/server/signup.test.js b/server/signup.test.js new file mode 100644 index 0000000..73063f0 --- /dev/null +++ b/server/signup.test.js @@ -0,0 +1,24 @@ +// @vitest-environment node +import { test, expect } from 'vitest'; +import { checkSignup, generateInviteCode, normalizeInviteCode, isSignupMode } from './signup.js'; + +test('open lets anyone in; the first account is always allowed', () => { + expect(checkSignup({ mode: 'open', userCount: 5, inviteOk: false }).ok).toBe(true); + expect(checkSignup({ mode: 'closed', userCount: 0, inviteOk: false }).ok).toBe(true); + expect(checkSignup({ mode: 'invite', userCount: 0, inviteOk: false }).ok).toBe(true); +}); + +test('invite-only needs a good code, closed lets nobody in', () => { + expect(checkSignup({ mode: 'invite', userCount: 2, inviteOk: false })).toMatchObject({ ok: false, status: 403 }); + expect(checkSignup({ mode: 'invite', userCount: 2, inviteOk: true }).ok).toBe(true); + expect(checkSignup({ mode: 'closed', userCount: 2, inviteOk: true })).toMatchObject({ ok: false, status: 403 }); +}); + +test('invite codes are readable and typed forgivingly', () => { + const code = generateInviteCode(); + expect(code).toMatch(/^[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}$/); + expect(normalizeInviteCode(` ${code.toLowerCase().replace(/-/g, ' ')} `)).toBe(code); + expect(normalizeInviteCode('abcd2345jkmn')).toBe('ABCD-2345-JKMN'); + expect(isSignupMode('invite')).toBe(true); + expect(isSignupMode('nope')).toBe(false); +}); diff --git a/server/votd.js b/server/votd.js new file mode 100644 index 0000000..fff7e28 --- /dev/null +++ b/server/votd.js @@ -0,0 +1,33 @@ +// Today's verse of the day for the push notification: IQ Bible picks it (the same verse for everyone each UTC day), +// in the KJV, which is free to quote. Needs IQBIBLE_API_KEY on the server. One lookup a day serves every user. +import { bookNameFor } from './bibleApi.js'; + +let cached = null; // { date, verse } + +export const votdAvailable = () => Boolean(process.env.IQBIBLE_API_KEY); + +// IQ Bible's answer -> { reference, text }, or null when it isn't one we can use +export function parseVotd(body) { + const d = body?.data; + const book = bookNameFor(d?.book_usfm); + if (!book || !d?.chapter_number || !d?.verse_number || typeof d.text !== 'string' || !d.text.trim()) return null; + return { reference: `${book} ${d.chapter_number}:${d.verse_number}`, text: d.text.replace(/^[¶§*\s]+/, '').replace(/\s+/g, ' ').trim() }; +} + +export async function fetchVerseOfDay(now = new Date(), fetcher = fetch) { + const date = now.toISOString().slice(0, 10); + if (cached?.date === date) return cached.verse; + if (!votdAvailable()) return null; + const base = (process.env.IQBIBLE_API_BASE || 'https://api.iqbible.com/api/v2').replace(/\/+$/, ''); + try { + const res = await fetcher(`${base}/bibles/eng_kjv/verse-of-the-day`, { headers: { 'X-IQBible-API-Key': process.env.IQBIBLE_API_KEY }, signal: AbortSignal.timeout(10_000) }); + if (!res.ok) return null; + const verse = parseVotd(await res.json()); + if (verse) cached = { date, verse }; + return verse; + } catch { return null; } +} + +export const clearVotdCache = () => { cached = null; }; + +export const votdMessage = ({ reference, text }) => ({ title: `Verse of the day: ${reference}`, body: `${text}\n\n${reference} (KJV)`, tags: 'sunrise' }); diff --git a/server/votd.test.js b/server/votd.test.js new file mode 100644 index 0000000..8e2ac68 --- /dev/null +++ b/server/votd.test.js @@ -0,0 +1,40 @@ +// @vitest-environment node +import { test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { parseVotd, fetchVerseOfDay, clearVotdCache, votdMessage } from './votd.js'; + +beforeEach(() => { clearVotdCache(); process.env.IQBIBLE_API_KEY = 'k'; }); +afterEach(() => { delete process.env.IQBIBLE_API_KEY; vi.restoreAllMocks(); }); + +const body = { data: { book_usfm: 'ROM', chapter_number: 8, verse_number: 28, text: '¶ And we know that all things work together for good.' } }; +const ok = () => vi.fn(async () => ({ ok: true, json: async () => body })); + +test('IQ Bible\'s verse becomes a reference and clean text', () => { + expect(parseVotd(body)).toEqual({ reference: 'Romans 8:28', text: 'And we know that all things work together for good.' }); + expect(parseVotd({ data: { book_usfm: 'XXX', chapter_number: 1, verse_number: 1, text: 'x' } })).toBeNull(); + expect(parseVotd({})).toBeNull(); +}); + +test('the notification carries the verse and its reference', () => { + expect(votdMessage({ reference: 'Romans 8:28', text: 'All things.' })).toEqual({ title: 'Verse of the day: Romans 8:28', body: 'All things.\n\nRomans 8:28 (KJV)', tags: 'sunrise' }); +}); + +test('one lookup a day serves everyone, and a new day looks again', async () => { + const fetcher = ok(); + const day1 = new Date('2026-10-06T12:00:00Z'); + expect((await fetchVerseOfDay(day1, fetcher)).reference).toBe('Romans 8:28'); + await fetchVerseOfDay(new Date('2026-10-06T23:00:00Z'), fetcher); + expect(fetcher).toHaveBeenCalledTimes(1); + await fetchVerseOfDay(new Date('2026-10-07T01:00:00Z'), fetcher); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher.mock.calls[0][0]).toContain('/bibles/eng_kjv/verse-of-the-day'); + expect(fetcher.mock.calls[0][1].headers['X-IQBible-API-Key']).toBe('k'); +}); + +test('without a key, or when IQ Bible fails, there is no verse (and nothing is remembered)', async () => { + delete process.env.IQBIBLE_API_KEY; + expect(await fetchVerseOfDay(new Date(), ok())).toBeNull(); + process.env.IQBIBLE_API_KEY = 'k'; + expect(await fetchVerseOfDay(new Date(), vi.fn(async () => ({ ok: false })))).toBeNull(); + expect(await fetchVerseOfDay(new Date(), vi.fn(async () => { throw new Error('down'); }))).toBeNull(); + expect((await fetchVerseOfDay(new Date(), ok())).reference).toBe('Romans 8:28'); // a later good answer still works +}); diff --git a/src/App.jsx b/src/App.jsx index 0887dc2..33715d5 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,6 +5,7 @@ import { AppContext, useApp } from './context/AppContext.js'; import { ToastProvider, useToast } from './components/Toasts.jsx'; import CommandPalette from './components/CommandPalette.jsx'; import ShortcutsHelp from './components/ShortcutsHelp.jsx'; +import SharedPasscodeForm from './components/SharedPasscodeForm.jsx'; import SharedOutlineView from './components/SharedOutlineView.jsx'; import Tooltips from './components/Tooltips.jsx'; import { useAudio } from './hooks/useAudio.js'; @@ -12,11 +13,12 @@ import { useAuth } from './hooks/useAuth.js'; import { useBibleReader } from './hooks/useBibleReader.js'; import { useProjectLibrary } from './hooks/useProjectLibrary.js'; import { useHistoryNav } from './hooks/useHistoryNav.js'; +import { useReadingHistory } from './hooks/useReadingHistory.js'; import { useReadingPlans } from './hooks/useReadingPlans.js'; import { useSharedView } from './hooks/useSharedView.js'; import HomePage from './pages/HomePage.jsx'; import { themeVars } from './components/reader/readerThemes.js'; -import { loadRemoteProject, saveRemoteProject } from './syncService.js'; +import { joinStudy, loadRemoteProject, saveRemoteProject } from './syncService.js'; import { getSyncBase, setSyncBase } from './utils/syncBase.js'; import { bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser, @@ -73,6 +75,7 @@ const AppInner = () => { const [readerPlan, setReaderPlan] = useState(null); const readerPlanProgress = readerPlan ? readingPlans.planProgress.find((p) => p.id === readerPlan.progressId) ?? null : null; const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null); + const readingHistory = useReadingHistory({ active: currentPage === 'reader', book: reader.readerBookAbbrev, chapter: reader.readerChapter }); const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation); useEffect(() => { if (currentPage !== 'reader') setReaderPlan(null); }, [currentPage]); // A new page starts at the top. Without this the scroll position of the previous page carries over, and on @@ -99,11 +102,14 @@ const AppInner = () => { reader.pullServerMarks(); // Theme, default translation, layout, commentary and reading position. reader.pullServerPrefs(); + // What you've read, merged with this device's. + readingHistory.pullReadingHistory(); }, onSignOut: () => { reader.stopMarksSync(); reader.stopPrefsSync(); reader.stopInkSync(); + readingHistory.stopReadingHistory(); readingPlans.stopReadingPlansSync(); setProjectIndex(switchStorageUser(null)); goStudies(); @@ -117,6 +123,22 @@ const AppInner = () => { setCurrentPage, }); + // Opening an invite link (?join=TOKEN) while signed in adds that study to your list, ready to edit. + useEffect(() => { + if (!authUser) return; + const token = new URLSearchParams(window.location.search).get('join'); + if (!token) return; + window.history.replaceState({}, '', window.location.pathname); + joinStudy(token).then(async (joined) => { + if (!joined.ok) { toast(joined.error ?? 'Could not use that invite link.', { tone: 'error' }); return; } + const full = await loadRemoteProject(joined.data.projectId); + if (full.ok) { saveProjectToStorage(full.data); setSyncBase(full.data.id, full.data.lastEdited); setProjectIndex(loadProjectIndex()); } + library.refreshLibrary(); + toast(`You can now edit “${joined.data.title}”. It's in My Studies.`); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authUser]); + // ⌘K search/go-to, the "?" shortcut list, and requests other pages make of the Reader. const [paletteOpen, setPaletteOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); @@ -479,6 +501,7 @@ const AppInner = () => { ...audio, // Reader ...reader, + readingHistory: readingHistory.readingHistory, readerPlan, setReaderPlan, readerPlanActive: !!readerPlanProgress, openPlanReading, @@ -523,13 +546,14 @@ function PageLoading() { function AppRouter() { const { currentPage, authUser, authServerDown } = useApp(); - const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView(); + const { token: sharedViewToken, project: sharedProject, error: sharedError, needsPasscode, passcodeError, checking, submitPasscode } = useSharedView(); const sharedOutlineToken = new URLSearchParams(window.location.search).get('outline'); // A read-only outline link: no sign-in, nothing editable if (sharedOutlineToken) return ; if (sharedViewToken) { + if (needsPasscode) return ; if (sharedError) { return (
diff --git a/src/components/AdminSignup.jsx b/src/components/AdminSignup.jsx new file mode 100644 index 0000000..0bdfd9e --- /dev/null +++ b/src/components/AdminSignup.jsx @@ -0,0 +1,87 @@ +// Admin → Sign-ups: who may create an account, and invite codes for when it is invite-only. +import { useEffect, useState } from 'react'; +import { adminCreateInvite, adminDeleteInvite, adminGetSignup, adminSetSignupMode } from '../syncService.js'; + +const MODES = [ + ['open', 'Open', 'Anyone who can reach this site can create an account.'], + ['invite', 'Invite only', 'A new account needs an invite code from you.'], + ['closed', 'Closed', 'Nobody new can sign up.'], +]; +const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50'; + +export default function AdminSignup() { + const [state, setState] = useState({ mode: 'open', invites: [], loading: true, error: '' }); + const [note, setNote] = useState(''); + const [days, setDays] = useState(7); + const [copied, setCopied] = useState(''); + + const load = async () => { + const r = await adminGetSignup(); + setState(r.ok ? { ...r.data, loading: false, error: '' } : { mode: 'open', invites: [], loading: false, error: r.error ?? 'Could not load sign-up settings.' }); + }; + useEffect(() => { load(); }, []); + + const setMode = async (mode) => { + const r = await adminSetSignupMode(mode); + if (r.ok) setState((s) => ({ ...s, mode, error: '' })); else setState((s) => ({ ...s, error: r.error ?? 'Could not change that.' })); + }; + const create = async () => { + const r = await adminCreateInvite({ note, days }); + if (!r.ok) { setState((s) => ({ ...s, error: r.error ?? 'Could not make an invite.' })); return; } + setNote(''); + load(); + }; + const link = (code) => `${window.location.origin}/?invite=${code}`; + const copy = async (code) => { + try { await navigator.clipboard.writeText(link(code)); setCopied(code); window.setTimeout(() => setCopied(''), 1800); } catch { /* clipboard unavailable */ } + }; + const status = (inv) => (inv.usedBy ? 'Used' : inv.expiresAt < Date.now() ? 'Expired' : `Until ${new Date(inv.expiresAt).toLocaleDateString()}`); + + if (state.loading) return

Loading…

; + return ( +
+
+

Who can sign up

+
+ {MODES.map(([id, label, hint]) => ( + + ))} +
+ {state.error &&

{state.error}

} +
+ +
+

Invite codes

+

Each code works once. Send the link, and it opens the sign-up page with the code filled in.{state.mode !== 'invite' ? ' (Codes only matter while sign-up is “Invite only”.)' : ''}

+
+ + + +
+ {state.invites.length > 0 && ( +
    + {state.invites.map((inv) => ( +
  • + {inv.code}{inv.note ? {inv.note} : null} + + {status(inv)} + {!inv.usedBy && inv.expiresAt >= Date.now() && } + + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/components/CoTeachers.jsx b/src/components/CoTeachers.jsx new file mode 100644 index 0000000..69336d5 --- /dev/null +++ b/src/components/CoTeachers.jsx @@ -0,0 +1,85 @@ +// Export → Co-teachers: let other accounts edit this study with you. You make a single-use invite link; +// whoever opens it (signed in) can edit the study, and you can remove them any time. Edits from both of you +// sync through the account; if you both change the same study, "Combine both" keeps everything. +import { useCallback, useEffect, useState } from 'react'; +import { createEditInvite, getCollaborators, removeCollaborator } from '../syncService.js'; + +const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50'; + +export default function CoTeachers({ projectId, sharedBy, onLeave }) { + const [state, setState] = useState({ status: 'loading', collaborators: [], pendingInvites: 0 }); + const [invite, setInvite] = useState(null); // { url, expiresAt } + const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(''); + + const load = useCallback(async () => { + if (!projectId || sharedBy !== undefined) return; + const r = await getCollaborators(projectId); + setState(r.ok ? { status: 'ready', ...r.data } : { status: 'unavailable', collaborators: [], pendingInvites: 0 }); + }, [projectId, sharedBy]); + useEffect(() => { setInvite(null); load(); }, [load]); + + // Someone else's study that you were invited to edit + if (sharedBy !== undefined) { + return ( +
+

Shared with you

+

{sharedBy || 'Its owner'} invited you to edit this study. Your changes sync to them, and theirs to you.

+ +
+ ); + } + + const makeInvite = async () => { + setBusy(true); setError(''); + const r = await createEditInvite(projectId); + setBusy(false); + if (!r.ok) { setError(r.error ?? 'Could not make the link.'); return; } + setInvite({ url: `${window.location.origin}${window.location.pathname}?join=${r.data.token}`, expiresAt: r.data.expiresAt }); + load(); + }; + const copy = async () => { + try { await navigator.clipboard.writeText(invite.url); setCopied(true); window.setTimeout(() => setCopied(false), 1800); } catch { setError('Couldn’t copy. Select the link and copy it.'); } + }; + const remove = async (userId, email) => { + if (!window.confirm(`Remove ${email}? They will no longer be able to edit this study.`)) return; + const r = await removeCollaborator(projectId, userId); + if (r.ok) load(); else setError(r.error ?? 'Could not remove them.'); + }; + + return ( +
+

Co-teachers

+

Let someone else edit this study with you. They need an account here. Each invite link works once and lasts 7 days.

+ {state.status === 'unavailable' &&

Invites need your account and a connection, and the study has to have synced once. Try again in a moment.

} + {state.status === 'ready' && ( + <> + {state.collaborators.length > 0 && ( +
    + {state.collaborators.map((c) => ( +
  • + {c.email} + +
  • + ))} +
+ )} + {invite ? ( +
+
+ e.target.select()} className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" /> + +
+

Send it to one person. It stops working once they use it, or on {new Date(invite.expiresAt).toLocaleDateString()}.

+
+ ) : ( + + )} + {state.pendingInvites > 0 && !invite &&

{state.pendingInvites} invite{state.pendingInvites === 1 ? '' : 's'} waiting to be used.

} + + )} + {error &&

{error}

} +
+ ); +} diff --git a/src/components/CoTeachers.test.jsx b/src/components/CoTeachers.test.jsx new file mode 100644 index 0000000..e2d3c16 --- /dev/null +++ b/src/components/CoTeachers.test.jsx @@ -0,0 +1,49 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect, beforeEach } from 'vitest'; + +vi.mock('../syncService.js', () => ({ getCollaborators: vi.fn(), createEditInvite: vi.fn(), removeCollaborator: vi.fn() })); +import CoTeachers from './CoTeachers.jsx'; +import { createEditInvite, getCollaborators, removeCollaborator } from '../syncService.js'; + +beforeEach(() => { + vi.clearAllMocks(); + getCollaborators.mockResolvedValue({ ok: true, data: { collaborators: [{ userId: 'u2', email: 'sam@x.co' }], pendingInvites: 0 } }); + createEditInvite.mockResolvedValue({ ok: true, data: { token: 'abc123', expiresAt: Date.now() + 7 * 86_400_000 } }); + removeCollaborator.mockResolvedValue({ ok: true }); + vi.spyOn(window, 'confirm').mockReturnValue(true); +}); + +test('the owner sees the co-teachers and can make a single-use invite link', async () => { + const user = userEvent.setup(); + render(); + expect(await screen.findByText('sam@x.co')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /invite a co-teacher/i })); + const link = await screen.findByLabelText('Invite link'); + expect(link.value).toContain('?join=abc123'); + expect(createEditInvite).toHaveBeenCalledWith('p1'); +}); + +test('the owner can remove a co-teacher after confirming', async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('button', { name: 'Remove' })); + expect(removeCollaborator).toHaveBeenCalledWith('p1', 'u2'); +}); + +test('someone invited to a study sees who shared it and can leave', async () => { + const onLeave = vi.fn(); + const user = userEvent.setup(); + render(); + expect(screen.getByText(/owner@x.co invited you to edit/)).toBeInTheDocument(); + expect(getCollaborators).not.toHaveBeenCalled(); // only owners can list co-teachers + await user.click(screen.getByRole('button', { name: /leave this study/i })); + expect(onLeave).toHaveBeenCalled(); +}); + +test('when the server can\'t be reached, it says so instead of offering a link', async () => { + getCollaborators.mockResolvedValue({ ok: false, status: 404, error: 'Project not found.' }); + render(); + await waitFor(() => expect(screen.getByText(/Invites need your account/)).toBeInTheDocument()); + expect(screen.queryByRole('button', { name: /invite a co-teacher/i })).toBeNull(); +}); diff --git a/src/components/DictateButton.jsx b/src/components/DictateButton.jsx new file mode 100644 index 0000000..07a1a5c --- /dev/null +++ b/src/components/DictateButton.jsx @@ -0,0 +1,68 @@ +// A microphone button for a text box: tap, speak, and what you say is added to the text. It uses the browser's +// own speech recognition (Safari and Chrome have it; Firefox does not, and then the button isn't shown). The +// browser may send the audio to its maker's speech service to be turned into words, which is why it asks to use +// the microphone, and why this is a button you press rather than something that listens in the background. +import { useEffect, useRef, useState } from 'react'; + +export const speechRecognitionClass = () => (typeof window === 'undefined' ? null : window.SpeechRecognition ?? window.webkitSpeechRecognition ?? null); + +// Adds spoken words to existing text: a space (or nothing after a newline) between, and a first letter capitalised after a full stop. +export function appendSpoken(existing, spoken) { + const said = String(spoken ?? '').trim(); + if (!said) return existing ?? ''; + const base = existing ?? ''; + if (!base.trim()) return `${said.charAt(0).toUpperCase()}${said.slice(1)}`; + const gap = /[\s]$/.test(base) ? '' : ' '; + const afterStop = /[.!?]["')\]]*\s*$/.test(base); + return `${base}${gap}${afterStop ? `${said.charAt(0).toUpperCase()}${said.slice(1)}` : said}`; +} + +const MESSAGES = { + 'not-allowed': 'The microphone is blocked. Allow it for this site in your browser’s settings.', + 'service-not-allowed': 'The microphone is blocked. Allow it for this site in your browser’s settings.', + 'audio-capture': 'No microphone was found.', + network: 'Speech recognition needs a connection.', +}; + +export default function DictateButton({ onText, label = 'Dictate', className = '' }) { + const Recognition = speechRecognitionClass(); + const [listening, setListening] = useState(false); + const [error, setError] = useState(''); + const recRef = useRef(null); + const onTextRef = useRef(onText); + onTextRef.current = onText; + + useEffect(() => () => { try { recRef.current?.abort(); } catch { /* already stopped */ } }, []); + if (!Recognition) return null; + + const start = () => { + setError(''); + const rec = new Recognition(); + rec.lang = navigator.language || 'en-US'; + rec.continuous = true; + rec.interimResults = false; + rec.onresult = (event) => { + for (let i = event.resultIndex; i < event.results.length; i += 1) { + if (event.results[i].isFinal) onTextRef.current(event.results[i][0].transcript); + } + }; + rec.onerror = (event) => { if (event.error !== 'no-speech' && event.error !== 'aborted') setError(MESSAGES[event.error] ?? 'Dictation stopped. Try again.'); }; + rec.onend = () => { setListening(false); recRef.current = null; }; + recRef.current = rec; + try { rec.start(); setListening(true); } catch { setListening(false); } + }; + const stop = () => { try { recRef.current?.stop(); } catch { /* already stopped */ } }; + + return ( + + + {error && {error}} + + ); +} diff --git a/src/components/DictateButton.test.jsx b/src/components/DictateButton.test.jsx new file mode 100644 index 0000000..fe46bb9 --- /dev/null +++ b/src/components/DictateButton.test.jsx @@ -0,0 +1,59 @@ +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect, afterEach } from 'vitest'; +import DictateButton, { appendSpoken } from './DictateButton.jsx'; + +afterEach(() => { delete window.SpeechRecognition; delete window.webkitSpeechRecognition; }); + +// A stand-in for the browser's recogniser that we can make "hear" things +function fakeRecognition() { + const instances = []; + class Fake { + constructor() { instances.push(this); this.started = false; } + start() { this.started = true; } + stop() { this.started = false; this.onend?.(); } + abort() { this.started = false; } + } + window.webkitSpeechRecognition = Fake; + return instances; +} +const hear = (rec, text, isFinal = true) => rec.onresult({ resultIndex: 0, results: [Object.assign([{ transcript: text }], { isFinal })] }); + +test('spoken words are added to what is already written', () => { + expect(appendSpoken('', ' grace is a gift ')).toBe('Grace is a gift'); + expect(appendSpoken('Paul writes', 'to Titus')).toBe('Paul writes to Titus'); + expect(appendSpoken('He saved us.', 'not by works')).toBe('He saved us. Not by works'); + expect(appendSpoken('Line one\n', 'line two')).toBe('Line one\nline two'); + expect(appendSpoken('Keep this', ' ')).toBe('Keep this'); +}); + +test('with no speech recognition in the browser, the button is not shown', () => { + const { container } = render( {}} />); + expect(container).toBeEmptyDOMElement(); +}); + +test('tap to listen, final phrases are passed on, tap again to stop', async () => { + const instances = fakeRecognition(); + const onText = vi.fn(); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Dictate script' })); + expect(instances[0].started).toBe(true); + expect(screen.getByRole('button', { name: 'Stop dictating' })).toHaveAttribute('aria-pressed', 'true'); + act(() => hear(instances[0], 'the Lord is my shepherd', false)); // still being worked out: ignored + act(() => hear(instances[0], 'the Lord is my shepherd')); + expect(onText).toHaveBeenCalledTimes(1); + expect(onText).toHaveBeenCalledWith('the Lord is my shepherd'); + await user.click(screen.getByRole('button', { name: 'Stop dictating' })); + expect(screen.getByRole('button', { name: 'Dictate script' })).toHaveAttribute('aria-pressed', 'false'); +}); + +test('a blocked microphone is explained', async () => { + const instances = fakeRecognition(); + const user = userEvent.setup(); + render( {}} />); + await user.click(screen.getByRole('button', { name: 'Dictate' })); + act(() => instances[0].onerror({ error: 'not-allowed' })); + expect(await screen.findByRole('alert')).toHaveTextContent(/microphone is blocked/i); + act(() => instances[0].onerror({ error: 'no-speech' })); // silence is not an error worth showing +}); diff --git a/src/components/HandoutExport.jsx b/src/components/HandoutExport.jsx new file mode 100644 index 0000000..6abc32b --- /dev/null +++ b/src/components/HandoutExport.jsx @@ -0,0 +1,29 @@ +// Export → Lesson handout: a printable page for each session (or one session), for a group to follow along. +import { useState } from 'react'; +import { printHandout, sessionsFor } from '../utils/handout.js'; + +export default function HandoutExport({ project }) { + const sessions = sessionsFor(project); + const [which, setWhich] = useState('all'); + const [lines, setLines] = useState(true); + const [blocked, setBlocked] = useState(false); + if (sessions.length === 0) return null; + const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines })); + return ( +
+

Lesson handout

+

A page per session with the passage, the outline, discussion questions and room to write. Use “Save as PDF” in the print dialog. Write the questions in the Study stage under “More”; without them, your Application notes are used.

+
+ + + +
+ {blocked &&

Allow pop-ups for this site to print the handout.

} +
+ ); +} diff --git a/src/components/LinkRules.jsx b/src/components/LinkRules.jsx new file mode 100644 index 0000000..285aa9a --- /dev/null +++ b/src/components/LinkRules.jsx @@ -0,0 +1,45 @@ +// The rules for a read-only link: when it stops working, and an optional passcode. Shown under the link. +import { useState } from 'react'; + +const OPTIONS = [['', 'Never'], ['1', 'In 1 day'], ['7', 'In 7 days'], ['30', 'In 30 days']]; +const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; + +export const expiryText = (expiresAt, now = Date.now()) => { + if (!expiresAt) return 'Never expires'; + if (expiresAt <= now) return 'Expired'; + const days = Math.ceil((expiresAt - now) / 86_400_000); + return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`; +}; + +export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) { + const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30' + const [passcode, setPasscode] = useState(''); + const dirty = days !== 'keep' || passcode.trim() !== ''; + + const save = async () => { + const options = {}; + if (days !== 'keep') options.expiresInDays = days === '' ? null : Number(days); + if (passcode.trim()) options.passcode = passcode.trim(); + if (await onSave(options)) { setDays('keep'); setPasscode(''); } + }; + + return ( +
+

{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}

+
+ + + + {hasPasscode && } +
+

Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.

+
+ ); +} diff --git a/src/components/LinkRules.test.jsx b/src/components/LinkRules.test.jsx new file mode 100644 index 0000000..d1116bb --- /dev/null +++ b/src/components/LinkRules.test.jsx @@ -0,0 +1,50 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect } from 'vitest'; +import LinkRules, { expiryText } from './LinkRules.jsx'; +import SharedPasscodeForm from './SharedPasscodeForm.jsx'; + +test('says when a link expires and whether it has a passcode', () => { + const now = Date.UTC(2026, 9, 6); + expect(expiryText(null, now)).toBe('Never expires'); + expect(expiryText(now - 1, now)).toBe('Expired'); + expect(expiryText(now + 3 * 86_400_000, now)).toMatch(/3 days left/); + render(); + expect(screen.getByText(/Needs a passcode/)).toBeInTheDocument(); +}); + +test('only the settings you change are sent', async () => { + const onSave = vi.fn(async () => true); + const user = userEvent.setup(); + render(); + const save = screen.getByRole('button', { name: 'Save settings' }); + expect(save).toBeDisabled(); + await user.selectOptions(screen.getByLabelText('Stop working'), '7'); + await user.click(save); + expect(onSave).toHaveBeenLastCalledWith({ expiresInDays: 7 }); // no passcode key: the passcode is left alone + await user.type(screen.getByLabelText(/Passcode/), ' moses '); + await user.click(screen.getByRole('button', { name: 'Save settings' })); + expect(onSave).toHaveBeenLastCalledWith({ passcode: 'moses' }); +}); + +test('a passcode can be removed, and "Never" clears the expiry', async () => { + const onSave = vi.fn(async () => true); + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Remove passcode' })); + expect(onSave).toHaveBeenLastCalledWith({ passcode: null }); + await user.selectOptions(screen.getByLabelText('Stop working'), 'Never'); + await user.click(screen.getByRole('button', { name: 'Save settings' })); + expect(onSave).toHaveBeenLastCalledWith({ expiresInDays: null }); +}); + +test('the viewer\'s passcode form submits what was typed and shows a wrong-passcode message', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render(); + expect(screen.getByRole('alert')).toHaveTextContent(/not right/); + expect(screen.getByRole('button', { name: 'Open' })).toBeDisabled(); + await user.type(screen.getByLabelText('Passcode'), 'moses'); + await user.click(screen.getByRole('button', { name: 'Open' })); + expect(onSubmit).toHaveBeenCalledWith('moses'); +}); diff --git a/src/components/PresentMode.jsx b/src/components/PresentMode.jsx new file mode 100644 index 0000000..6e59a4f --- /dev/null +++ b/src/components/PresentMode.jsx @@ -0,0 +1,137 @@ +// Present mode: the script or the outline full-screen in large type, with a timer. The script scrolls by +// itself at a speed you set (a teleprompter), and the outline moves one point at a time with the minutes you +// planned beside the clock. The screen is kept awake while it is open. Esc, or Close, leaves. +import { useCallback, useEffect, useRef, useState } from 'react'; +import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js'; + +const SIZES = [20, 24, 28, 34, 40, 48, 60, 72]; +const btn = 'rounded-lg border border-current/30 px-3 py-1.5 text-sm hover:bg-white/10 disabled:opacity-40'; + +export default function PresentMode({ title, subtitle = '', mode, script = '', tree = [], onClose }) { + const [dark, setDark] = useState(true); + const [size, setSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-size')) || 34)); } catch { return 3; } }); + const [elapsed, setElapsed] = useState(0); + const [running, setRunning] = useState(false); + const [scrolling, setScrolling] = useState(false); + const [speed, setSpeed] = useState(30); // pixels per second + const [index, setIndex] = useState(0); + const scroller = useRef(null); + const steps = mode === 'outline' ? outlineSteps(tree) : []; + const step = steps[index]; + const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null; + + // the clock + useEffect(() => { + if (!running) return undefined; + const t = window.setInterval(() => setElapsed((e) => e + 1), 1000); + return () => window.clearInterval(t); + }, [running]); + + // the teleprompter + useEffect(() => { + if (!scrolling || mode !== 'script') return undefined; + let last = performance.now(); + let frame; + let carry = 0; + const tick = (now) => { + const el = scroller.current; + carry += ((now - last) / 1000) * speed; + last = now; + if (el && carry >= 1) { el.scrollTop += Math.floor(carry); carry -= Math.floor(carry); } + if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) { setScrolling(false); return; } + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [scrolling, speed, mode]); + + // keep the screen on + useEffect(() => { + let lock; + let gone = false; + navigator.wakeLock?.request?.('screen').then((l) => { if (gone) l.release?.(); else lock = l; }).catch(() => {}); + return () => { gone = true; lock?.release?.().catch?.(() => {}); }; + }, []); + + const bigger = (d) => setSize((s) => { + const next = Math.min(SIZES.length - 1, Math.max(0, s + d)); + try { localStorage.setItem('present-size', String(SIZES[next])); } catch { /* storage unavailable */ } + return next; + }); + const go = useCallback((d) => setIndex((i) => Math.min(steps.length - 1, Math.max(0, i + d))), [steps.length]); + + useEffect(() => { + const onKey = (e) => { + if (e.key === 'Escape') { onClose(); return; } + if (e.target?.tagName === 'INPUT') return; + if (mode === 'outline') { + if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(1); } + else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); } + } else if (e.key === ' ') { e.preventDefault(); setScrolling((s) => !s); } + if (e.key === '+' || e.key === '=') bigger(1); + if (e.key === '-') bigger(-1); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [mode, go, onClose]); + + const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]'; + const fontSize = `${SIZES[size]}px`; + const total = steps.length ? steps[steps.length - 1].plannedEnd : 0; + + return ( +
+
+

{title}

{subtitle &&

{subtitle}

}
+
+ {formatClock(elapsed)} + + +
+ {pace && {paceText(pace)} · plan {formatClock(pace.totalSeconds)}} +
+ + + +
+ {mode === 'script' && ( +
+ + +
+ )} + +
+ + {mode === 'script' ? ( +
+ {script.trim() + ?
{script} + :

There’s no script to present yet. Write one on the Script page.

} +
+ ) : ( +
+
+ {step ? ( +
+

{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}

+

{step.label}{step.text || '(untitled)'}

+ {step.ref &&

{step.ref}

} + {step.scripture?.text &&
“{step.scripture.text}” — {step.scripture.ref}
} + {step.note &&

{step.note}

} + {step.attachments.length > 0 &&

{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}

} +
+ ) :

There’s no outline to present yet. Build one on the Outline page.

} +
+
+ + {total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move + +
+
+ )} +
+ ); +} diff --git a/src/components/PresentMode.test.jsx b/src/components/PresentMode.test.jsx new file mode 100644 index 0000000..e7b8232 --- /dev/null +++ b/src/components/PresentMode.test.jsx @@ -0,0 +1,60 @@ +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect, afterEach } from 'vitest'; +import PresentMode from './PresentMode.jsx'; + +afterEach(() => vi.useRealTimers()); +const n = (id, text, minutes, extra = {}) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children: [], ...extra }); +const tree = [n('a', 'Welcome', 2, { note: 'Say hello' }), n('b', 'The gospel', 10, { ref: 'Rom 1:16' })]; + +test('the outline is stepped through a point at a time, with the keyboard or the buttons', async () => { + const user = userEvent.setup(); + render( {}} />); + expect(screen.getByText('Welcome')).toBeInTheDocument(); + expect(screen.getByText('Say hello')).toBeInTheDocument(); + expect(screen.getByText(/Point 1 of 2/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /back/i })).toBeDisabled(); + await user.keyboard('{ArrowRight}'); + expect(screen.getByText('The gospel')).toBeInTheDocument(); + expect(screen.getByText('Rom 1:16')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + await user.click(screen.getByRole('button', { name: /back/i })); + expect(screen.getByText('Welcome')).toBeInTheDocument(); +}); + +test('the timer runs, pauses and resets, and tells you how you are doing against the plan', async () => { + vi.useFakeTimers(); + render( {}} />); + const group = screen.getByRole('group', { name: 'Timer' }); + const click = (name) => act(() => { screen.getByRole('button', { name }).click(); }); + click('Start'); + act(() => { vi.advanceTimersByTime(65_000); }); + expect(group).toHaveTextContent('1:05'); + expect(screen.getByRole('status')).toHaveTextContent(/On pace/); // a minute in, on a 2-minute point + click('Pause'); + act(() => { vi.advanceTimersByTime(10_000); }); + expect(group).toHaveTextContent('1:05'); // paused + click('Reset'); + expect(group).toHaveTextContent('0:00'); +}); + +test('the text size can be changed and the choice is remembered', async () => { + const user = userEvent.setup(); + localStorage.clear(); + render( {}} />); + const text = screen.getByText('In the beginning'); + expect(text.style.fontSize).toBe('34px'); + await user.click(screen.getByRole('button', { name: 'Larger text' })); + expect(text.style.fontSize).toBe('40px'); + expect(localStorage.getItem('present-size')).toBe('40'); +}); + +test('Escape and Close both leave, and an empty script says what to do', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render(); + expect(screen.getByText(/no script to present yet/i)).toBeInTheDocument(); + await user.keyboard('{Escape}'); + await user.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalledTimes(2); +}); diff --git a/src/components/ReadingProgress.jsx b/src/components/ReadingProgress.jsx new file mode 100644 index 0000000..2b0b885 --- /dev/null +++ b/src/components/ReadingProgress.jsx @@ -0,0 +1,60 @@ +// "Your reading": how much of the Bible you have read (every chapter you spent time on in the Reader, plus the +// ones you finished in reading plans), a tile for each book that fills as you read it, and a calendar of the +// days you read in the last six months. +import { useMemo } from 'react'; +import { getPlanSchedule } from '../utils/readingPlans.js'; +import { heatmapWeeks, planChapterKeys, progressSummary } from '../utils/readingHistory.js'; + +const pct = (n) => `${Math.round(n * 100)}%`; + +export default function ReadingProgress({ history, planProgress = [], activity = [], onOpenBook }) { + const summary = useMemo(() => progressSummary(history, planChapterKeys(planProgress, getPlanSchedule)), [history, planProgress]); + const days = useMemo(() => new Set([...(history.days ?? []), ...activity]), [history.days, activity]); + const weeks = useMemo(() => heatmapWeeks(days), [days]); + const daysInView = weeks.flat().filter((d) => d.on).length; + const group = (list) => ( +
+ {list.map((b) => ( + + ))} +
+ ); + + return ( +
+
+

Your reading

+

+ {pct(summary.pct)} of the Bible · {summary.read} of {summary.total} chapters +

+
+
+
+
+

Old Testament {summary.ot.read}/{summary.ot.total} · New Testament {summary.nt.read}/{summary.nt.total}. A chapter counts once you’ve had it open for a bit in the Reader, or finished it in a plan.

+ +

Old Testament

+ {group(summary.books.filter((b) => b.testament === 'ot'))} +

New Testament

+ {group(summary.books.filter((b) => b.testament === 'nt'))} + +

Days you read · {daysInView} in the last 6 months

+
+ {weeks.map((week) => ( +
+ {week.map((d) => ( + + ))} +
+ ))} +
+
+ ); +} diff --git a/src/components/ReadingProgress.test.jsx b/src/components/ReadingProgress.test.jsx new file mode 100644 index 0000000..e5331ca --- /dev/null +++ b/src/components/ReadingProgress.test.jsx @@ -0,0 +1,30 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect } from 'vitest'; +import ReadingProgress from './ReadingProgress.jsx'; + +const history = { chapters: { 'GEN.1': { n: 1, last: '2026-10-01' }, 'GEN.2': { n: 2, last: '2026-10-02' }, 'JHN.3': { n: 1, last: '2026-10-03' } }, days: ['2026-10-01', '2026-10-02'] }; + +test('shows how much of the Bible is read, and each book\'s progress', () => { + render(); + expect(screen.getByText(/3 of 1189 chapters/)).toBeInTheDocument(); + expect(screen.getByRole('progressbar', { name: 'Bible read' })).toHaveAttribute('aria-valuenow', '0'); + expect(screen.getByRole('button', { name: 'Genesis, 2 of 50 chapters read' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'John, 1 of 21 chapters read' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Revelation, 0 of 22 chapters read' })).toBeInTheDocument(); + expect(screen.getAllByRole('button')).toHaveLength(66); +}); + +test('days from plans count on the calendar too, and a book opens in the Reader', async () => { + const onOpenBook = vi.fn(); + render(); + expect(screen.getByRole('img', { name: /3 days read in the last six months/ })).toBeInTheDocument(); + await userEvent.setup().click(screen.getByRole('button', { name: /^Genesis/ })); + expect(onOpenBook).toHaveBeenCalledWith('GEN'); +}); + +test('an empty history is fine', () => { + render(); + expect(screen.getByText(/0 of 1189 chapters/)).toBeInTheDocument(); + expect(within(screen.getByRole('img')).queryAllByTitle(/: read/)).toHaveLength(0); +}); diff --git a/src/components/SessionSchedule.jsx b/src/components/SessionSchedule.jsx new file mode 100644 index 0000000..2f792e3 --- /dev/null +++ b/src/components/SessionSchedule.jsx @@ -0,0 +1,64 @@ +// Export → Schedule: the study's sessions (one per chunk) as a calendar file, starting on a date you pick. +import { useState } from 'react'; +import { formatChunkReference } from '../utils/bibleUtils.jsx'; +import { buildIcs, downloadIcs, sessionDates } from '../utils/icsFile.js'; + +const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; +const isoDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + +// The study's sessions in order: { title, description } for each chunk. +export function sessionsOf(project) { + const list = []; + (project?.chapters ?? []).forEach((chapter, chapterIndex) => { + for (const chunk of chapter.chunks ?? []) { + const ref = formatChunkReference(project, chapterIndex, chunk, '–'); + const n = list.length + 1; + const label = `Session ${(chunk.episodeNumber ?? '').toString().trim() || n}`; + const name = (project.series ?? '').trim() || project.title; + list.push({ title: `${name} — ${label}: ${(chunk.episodeTitle ?? '').trim() || ref}`, description: `${project.title}\n${ref}` }); + } + }); + return list; +} + +export default function SessionSchedule({ project }) { + const sessions = sessionsOf(project); + const [start, setStart] = useState(() => isoDay(new Date())); + const [every, setEvery] = useState('7'); + const [time, setTime] = useState(''); + const [minutes, setMinutes] = useState('60'); + if (sessions.length === 0) return null; + + const download = () => { + const [y, m, d] = start.split('-').map(Number); + const dates = sessionDates(new Date(y, m - 1, d), sessions.length, Number(every)); + const events = sessions.map((s, i) => ({ ...s, date: dates[i], time: time || undefined, minutes: Number(minutes) || 60 })); + downloadIcs(buildIcs(events, { name: (project.series ?? '').trim() || project.title }), `${(project.title || 'study').replace(/[^\w]+/g, '-').toLowerCase()}-schedule.ics`); + }; + + return ( +
+

Schedule

+

Put the {sessions.length} session{sessions.length === 1 ? '' : 's'} on your calendar: one per chunk, in order, using each chunk’s session number and title.

+
+ + + + {time && ( + + )} + +
+
+ ); +} diff --git a/src/components/SessionSchedule.test.jsx b/src/components/SessionSchedule.test.jsx new file mode 100644 index 0000000..c0a25f0 --- /dev/null +++ b/src/components/SessionSchedule.test.jsx @@ -0,0 +1,38 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect } from 'vitest'; + +vi.mock('../utils/icsFile.js', async (orig) => ({ ...(await orig()), downloadIcs: vi.fn() })); +import SessionSchedule, { sessionsOf } from './SessionSchedule.jsx'; +import { downloadIcs } from '../utils/icsFile.js'; + +const chunk = (id, s, e, x = {}) => ({ id, startVerse: s, endVerse: e, ...x }); +const project = { + title: 'Romans 8', series: 'Romans', chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [], chunks: [ + chunk('a', 1, 11, { episodeNumber: '4', episodeTitle: 'No condemnation' }), chunk('b', 12, 17), + ] }], +}; + +test('each chunk is a session named for the series, its number and title (or passage)', () => { + const s = sessionsOf(project); + expect(s[0].title).toBe('Romans — Session 4: No condemnation'); + expect(s[1].title).toMatch(/^Romans — Session 2: Romans 8:12/); +}); + +test('the calendar file has one event per session, spaced as asked', async () => { + const user = userEvent.setup(); + render(); + await user.clear(screen.getByLabelText('First session')); + await user.type(screen.getByLabelText('First session'), '2026-10-11'); + await user.click(screen.getByRole('button', { name: /download calendar file/i })); + const [text, name] = downloadIcs.mock.calls[0]; + expect(name).toBe('romans-8-schedule.ics'); + expect(text.match(/BEGIN:VEVENT/g)).toHaveLength(2); + expect(text).toContain('DTSTART;VALUE=DATE:20261011'); + expect(text).toContain('DTSTART;VALUE=DATE:20261018'); // a week later +}); + +test('a study with no chunks shows nothing', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); +}); diff --git a/src/components/ShareOutlineSheet.jsx b/src/components/ShareOutlineSheet.jsx index ccdeafb..eef99ba 100644 --- a/src/components/ShareOutlineSheet.jsx +++ b/src/components/ShareOutlineSheet.jsx @@ -1,5 +1,6 @@ // Share an outline: a read-only link anyone can open (no account), or the plain text. import { useState } from 'react'; +import LinkRules from './LinkRules.jsx'; import Sheet from './reader/Sheet.jsx'; import { useToast } from './Toasts.jsx'; import { useOutlineShareLink } from '../hooks/useOutlineShareLink.js'; @@ -49,10 +50,11 @@ export default function ShareOutlineSheet({ open, onClose, projectId, outline })
+ { await link.create(options); return true; }} />

People see the version that last synced to your account, and it updates as you edit. Turning the link off stops it working right away.

) : ( - + ))} {link.error &&

{link.error}

} diff --git a/src/components/SharedOutlineView.jsx b/src/components/SharedOutlineView.jsx index e161353..957edb3 100644 --- a/src/components/SharedOutlineView.jsx +++ b/src/components/SharedOutlineView.jsx @@ -1,8 +1,9 @@ // What someone sees when they open a read-only outline link: a calm page with the outline, a switcher // when there is more than one, and Print / PDF. Nothing is editable. -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { getSharedOutline } from '../syncService.js'; import { themeVars } from './reader/readerThemes.js'; +import SharedPasscodeForm from './SharedPasscodeForm.jsx'; import OutlineDocument from './OutlineDocument.jsx'; import { printOutline } from '../utils/outlineExport.js'; import { flatten } from '../utils/outline.js'; @@ -17,18 +18,27 @@ export default function SharedOutlineView({ token }) { const [data, setData] = useState(null); const [error, setError] = useState(''); const [scope, setScope] = useState('study'); + const [needsPasscode, setNeedsPasscode] = useState(false); + const [passcodeError, setPasscodeError] = useState(''); + const [checking, setChecking] = useState(false); - useEffect(() => { - let active = true; - getSharedOutline(token).then((result) => { - if (!active) return; - if (result.ok) { setData(result.data); setScope(result.data.outlines[0]?.scope ?? 'study'); } else setError(result.error ?? 'This outline link is invalid or has been turned off.'); + const load = useCallback((passcode) => { + setChecking(true); + return getSharedOutline(token, passcode).then((result) => { + setChecking(false); + if (result.ok) { setData(result.data); setNeedsPasscode(false); setScope(result.data.outlines[0]?.scope ?? 'study'); return; } + if (result.data?.needsPasscode) { setNeedsPasscode(true); setPasscodeError(passcode ? result.error : ''); return; } + setNeedsPasscode(false); + setError(result.error ?? 'This outline link is invalid or has been turned off.'); }); - return () => { active = false; }; }, [token]); + useEffect(() => { load(); }, [load]); + useEffect(() => { if (data?.title) document.title = `${data.title} — Outline`; }, [data]); + if (needsPasscode) return ; + if (error) { return ( diff --git a/src/components/SharedPasscodeForm.jsx b/src/components/SharedPasscodeForm.jsx new file mode 100644 index 0000000..bac1d0a --- /dev/null +++ b/src/components/SharedPasscodeForm.jsx @@ -0,0 +1,20 @@ +// What someone sees when a shared link needs a passcode. +import { useState } from 'react'; + +export default function SharedPasscodeForm({ error, busy, onSubmit, dark = false }) { + const [value, setValue] = useState(''); + return ( +
+
{ e.preventDefault(); if (value.trim()) onSubmit(value.trim()); }} + className="w-full max-w-sm rounded-3xl border border-black/10 bg-white p-8 text-center text-slate-900 shadow-lg"> +

Bible Study Project

+

This link has a passcode

+

Enter the passcode the person who shared it gave you.

+ setValue(e.target.value)} + className="mt-5 block w-full rounded-xl border border-slate-300 px-3 py-2.5 text-center text-sm" /> + {error &&

{error}

} + +
+
+ ); +} diff --git a/src/components/reader/MarksSheet.jsx b/src/components/reader/MarksSheet.jsx index 7ae4ba1..7a8a6bd 100644 --- a/src/components/reader/MarksSheet.jsx +++ b/src/components/reader/MarksSheet.jsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import Sheet from './Sheet.jsx'; import { CloseIcon } from './icons.jsx'; +import { buildMarksDocxBlob, buildMarksModel, downloadMarksFile, marksToMarkdown } from '../../utils/marksExport.js'; import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js'; const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`; @@ -20,6 +21,15 @@ export default function MarksSheet({ const shown = filter ? highlights.filter((h) => h.color === filter) : highlights; // Colours in use that aren't one of the picker's (older data) still get listed const total = highlights.length + bookmarks.length; + const [exportError, setExportError] = useState(''); + const exportAs = async (kind) => { + setExportError(''); + const model = buildMarksModel({ highlights, bookmarks, colorLabels }); + try { + if (kind === 'md') downloadMarksFile(new Blob([marksToMarkdown(model)], { type: 'text/markdown;charset=utf-8' }), 'highlights-and-bookmarks.md'); + else downloadMarksFile(await buildMarksDocxBlob(model), 'highlights-and-bookmarks.docx'); + } catch { setExportError('Couldn’t make the file. Try again.'); } + }; return ( @@ -91,6 +101,14 @@ export default function MarksSheet({ )) )} + {total > 0 && ( +
+ Take them with you + + + {exportError && {exportError}} +
+ )}
); } diff --git a/src/components/reader/MarksSheetExport.test.jsx b/src/components/reader/MarksSheetExport.test.jsx new file mode 100644 index 0000000..cf8a677 --- /dev/null +++ b/src/components/reader/MarksSheetExport.test.jsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect } from 'vitest'; + +vi.mock('../../utils/marksExport.js', async (orig) => ({ ...(await orig()), downloadMarksFile: vi.fn() })); +import MarksSheet from './MarksSheet.jsx'; +import { downloadMarksFile } from '../../utils/marksExport.js'; + +const base = { open: true, onClose: () => {}, colorLabels: { '#bbf7d0': 'Promises' }, onSetLabel: () => {}, onJump: () => {}, onRemoveBookmark: () => {}, onRemoveHighlight: () => {} }; +const highlights = [{ id: 'h', color: '#bbf7d0', book: 'JHN', bookName: 'John', bookIndex: 42, chapter: 3, verse: 16, startOffset: 0, endOffset: 5, text: 'For God' }]; + +test('highlights and bookmarks can be exported as Markdown, named by your colour names', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Markdown' })); + const [blob, name] = downloadMarksFile.mock.calls[0]; + expect(name).toBe('highlights-and-bookmarks.md'); + expect(await blob.text()).toContain('## Promises (green)'); +}); + +test('Word export is offered too, and nothing to export means no buttons', async () => { + const user = userEvent.setup(); + const { rerender } = render(); + await user.click(screen.getByRole('button', { name: 'Word (.docx)' })); + await vi.waitFor(() => expect(downloadMarksFile).toHaveBeenCalledTimes(2)); + expect(downloadMarksFile.mock.calls[1][1]).toBe('highlights-and-bookmarks.docx'); + rerender(); + expect(screen.queryByRole('group', { name: /export your marks/i })).toBeNull(); +}); diff --git a/src/components/study/NotesPane.jsx b/src/components/study/NotesPane.jsx index 677108f..1feaade 100644 --- a/src/components/study/NotesPane.jsx +++ b/src/components/study/NotesPane.jsx @@ -1,7 +1,8 @@ // 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'; +import DictateButton, { appendSpoken } from '../DictateButton.jsx'; +import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js'; const SECTIONS = [ { field: 'observation', label: 'Observe', hint: 'What does the text say?', @@ -15,11 +16,13 @@ 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, templateId }) { +export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify }) { const [open, setOpen] = useState(() => { try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; } }); const [more, setMore] = useState(false); + const [tplName, setTplName] = useState(''); + const [tplOpen, setTplOpen] = useState(false); const [tagInput, setTagInput] = useState(''); useEffect(() => { setTagInput(''); }, [chunk.id]); useEffect(() => { try { localStorage.setItem('studyNotesOpen', open || ''); } catch { /* storage unavailable */ } }, [open]); @@ -53,6 +56,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {