diff --git a/server/db.js b/server/db.js index 2687dd6..63b95a6 100644 --- a/server/db.js +++ b/server/db.js @@ -140,7 +140,7 @@ export function initDb() { 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']) { + for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'share_reveal INTEGER', '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'); @@ -780,7 +780,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, share_expires = NULL, share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId); + db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL, share_reveal = 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 ---- @@ -800,14 +800,14 @@ export function clearOutlineShareToken(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: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass', reveal: 'NULL' } + : { token: 'share_token', expires: 'share_expires', pass: 'share_pass', reveal: 'share_reveal' }); -/** { token, expiresAt, hasPasscode } for the owner's link settings. */ +/** { token, expiresAt, hasPasscode, revealAt } for the owner's link settings (revealAt: notes stay hidden until then). */ 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; + const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass, ${c.reveal} AS revealAt FROM projects WHERE id = ? AND user_id = ?`).get(id, userId); + return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass, revealAt: row.revealAt ?? null } : null; } /** The stored passcode hash of a link (kept when only the expiry is changed). */ @@ -816,18 +816,20 @@ export function getSharePassHash(id, userId, outline = false) { 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) { +/** Sets expiry (ms timestamp or null), passcode hash (or null) and, for a study link, when its notes appear (or null). */ +export function setShareSettings(id, userId, { expiresAt, passHash, revealAt }, 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; + const changed = db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0; + if (changed && !outline && revealAt !== undefined) db.prepare('UPDATE projects SET share_reveal = ? WHERE id = ? AND user_id = ?').run(revealAt ?? null, id, userId); + return changed; } /** 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); + const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash, ${c.reveal} AS revealAt 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; } + try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null, revealAt: row.revealAt ?? null }; } catch { return null; } } /** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */ diff --git a/server/routes/sharing.js b/server/routes/sharing.js index ee8f68d..071658e 100644 --- a/server/routes/sharing.js +++ b/server/routes/sharing.js @@ -2,7 +2,7 @@ import { randomUUID } from 'crypto'; import { acceptEditInvite, clearOutlineShareToken, clearShareToken, countOpenEditInvites, createEditInvite, getSharePassHash, getShareSettings, getSharedByToken, isProjectOwner, listCollaborators, removeCollaborator, setOutlineShareToken, setShareSettings, setShareToken } from '../db.js'; import { requireAuth } from '../auth.js'; -import { checkShareAccess, expiryFromDays, hashPasscode } from '../shareLinks.js'; +import { checkShareAccess, expiryFromDays, hashPasscode, hideUntilReveal, revealFromInput } from '../shareLinks.js'; import { buildOutlinePayload } from '../outlineShare.js'; import { TOO_MANY, isLimited, recordFailure } from '../limiter.js'; @@ -16,7 +16,7 @@ export function registerSharingRoutes(app) { try { 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 }); + res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode, revealAt: settings.revealAt }); } catch (err) { console.error('GET /api/projects/:id/share error:', err); res.status(500).json({ error: 'Failed to load share status.' }); @@ -37,8 +37,10 @@ export function registerSharingRoutes(app) { // 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 }); + // only a study link can hide its notes until a time; a past or empty time turns hiding off + const revealAt = outline ? null : ('revealAt' in body ? revealFromInput(body.revealAt) : settings.revealAt); + setShareSettings(req.params.id, userId, { expiresAt, passHash, revealAt }, outline); + res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash, revealAt }); } catch (err) { console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err); res.status(500).json({ error: 'Failed to enable sharing.' }); @@ -123,14 +125,14 @@ export function registerSharingRoutes(app) { res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 }); return null; } - return found.project; + return found; } // 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 = await openSharedLink(req, res, true); - if (project) res.json(buildOutlinePayload(project)); + const found = await openSharedLink(req, res, true); + if (found) res.json(buildOutlinePayload(found.project)); } catch (err) { console.error('GET /api/share-outline/:token error:', err); res.status(500).json({ error: 'Failed to load the shared outline.' }); @@ -140,8 +142,8 @@ export function registerSharingRoutes(app) { // 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 = await openSharedLink(req, res, false); - if (project) res.json(project); + const found = await openSharedLink(req, res, false); + if (found) res.json(hideUntilReveal(found.project, found.revealAt)); } catch (err) { console.error('GET /api/share/:token error:', err); res.status(500).json({ error: 'Failed to load shared project.' }); diff --git a/server/shareLinks.js b/server/shareLinks.js index 076dfb7..00c548e 100644 --- a/server/shareLinks.js +++ b/server/shareLinks.js @@ -25,3 +25,27 @@ export async function checkShareAccess({ expiresAt, passHash, supplied }, now = if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' }; return { ok: true }; } + +// "Hide my notes until…": before the reveal time a shared study shows only the passages and the discussion questions +// (no notes, outline, script, words or group notes), and says when the rest appears. The strip is done here, on the +// server, so the hidden text never reaches the browser. After the time (or with none set) the study is sent whole. +const OPEN_CHUNK_FIELDS = ['id', 'startVerse', 'endVerse', 'spilloverEndVerse', 'episodeNumber', 'episodeTitle', 'discussionQuestions']; + +export function hideUntilReveal(project, revealAt, now = Date.now()) { + if (!revealAt || revealAt <= now || !project || typeof project !== 'object') return project; + return { + id: project.id, title: project.title, translation: project.translation, series: project.series, outline: [], + hiddenUntil: revealAt, + chapters: (Array.isArray(project.chapters) ? project.chapters : []).map((ch) => ({ + book: ch.book, bookAbbrev: ch.bookAbbrev, chapter: ch.chapter, verses: ch.verses, + chunks: (Array.isArray(ch.chunks) ? ch.chunks : []).map((chunk) => Object.fromEntries(OPEN_CHUNK_FIELDS.filter((f) => f in chunk).map((f) => [f, chunk[f]]))), + })), + }; +} + +// A reveal time from the browser -> ms in the future (up to a year), or null for "no hiding" +export function revealFromInput(value, now = Date.now()) { + const n = Number(value); + if (!value || !Number.isFinite(n) || n <= now) return null; + return Math.min(n, now + 365 * 86_400_000); +} diff --git a/server/shareLinks.test.js b/server/shareLinks.test.js index fdca999..1e47bff 100644 --- a/server/shareLinks.test.js +++ b/server/shareLinks.test.js @@ -31,3 +31,36 @@ test('an expired link is reported as expired even before the passcode is checked const passHash = await hashPasscode('x'); expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 }); }); + +import { hideUntilReveal, revealFromInput } from './shareLinks.js'; + +const study = () => ({ + id: 's1', title: 'John', translation: 'BSB', outline: [{ id: 'n', text: 'Point' }], + chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }], + chunks: [{ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?', observation: 'secret', interpretation: 'secret', application: 'secret', finalScript: 'secret', generalNotes: 'secret', greekWords: [{ id: 'w' }], teachBack: 'secret', recalls: [{ text: 'secret' }] }] }], +}); + +test('before the reveal time a shared study keeps the passages and questions and drops every note', () => { + const now = 1_000; + const out = hideUntilReveal(study(), now + 5_000, now); + expect(out.hiddenUntil).toBe(now + 5_000); + expect(out.outline).toEqual([]); + expect(out.chapters[0].verses).toHaveLength(1); + expect(out.chapters[0].chunks[0]).toEqual({ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?' }); + expect(JSON.stringify(out)).not.toContain('secret'); +}); + +test('after the reveal time, or with none set, the whole study is sent', () => { + const full = study(); + expect(hideUntilReveal(full, 500, 1_000)).toBe(full); + expect(hideUntilReveal(full, null, 1_000)).toBe(full); +}); + +test('a reveal time must be in the future, and no more than a year away', () => { + const now = 1_000_000; + expect(revealFromInput(null, now)).toBeNull(); + expect(revealFromInput(now - 1, now)).toBeNull(); + expect(revealFromInput('nope', now)).toBeNull(); + expect(revealFromInput(now + 60_000, now)).toBe(now + 60_000); + expect(revealFromInput(now + 10 * 365 * 86_400_000, now)).toBe(now + 365 * 86_400_000); +}); diff --git a/src/App.jsx b/src/App.jsx index a4947b6..a453301 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -608,6 +608,7 @@ function AppRouter() { return (
{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}
+{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}{showReveal && revealAt && revealAt > Date.now() ? ` · Your notes appear ${new Date(revealAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}` : ''}
Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.
+Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.{showReveal ? ' Until the time you set, the group sees only the passages and discussion questions; your notes, outline and scripts stay on the server.' : ''}
A 16:9 slide for each session’s passage, each outline point (with the verse it quotes) and the discussion questions. Choose “Save as PDF” in the print dialog, then open it in any slide viewer or project it from the PDF. It isn’t a PowerPoint file.
+Allow pop-ups for this site to print the slides.
} +{share.shareError}
} @@ -64,6 +65,8 @@ export default function ExportPage() {{node.kind && {kindLabel(node.kind)}} {mins && {node.minutes} min} + {overPoint?.id === node.id && Runs past {sessionMinutes} min here} {node.ref && {node.ref}} {linked && node.chunkId !== scope && }
diff --git a/src/utils/present.js b/src/utils/present.js index c8133a1..b9af942 100644 --- a/src/utils/present.js +++ b/src/utils/present.js @@ -41,3 +41,22 @@ export const paceText = (p) => { if (Math.abs(d) < 20) return 'On pace'; return d > 0 ? `${formatClock(d)} ahead` : `${formatClock(-d)} behind`; }; + +// How the planned minutes compare with the time you have. `limit` is the session length (null/0 = not set). +// -> { state: 'unset' | 'under' | 'on' | 'over', diff (minutes, + to spare, - over), text } +export function planStatus(total, limit) { + const t = Number(total) || 0; + const l = Number(limit) || 0; + if (!l) return { state: 'unset', diff: 0, text: '' }; + const diff = l - t; + if (t === 0) return { state: 'under', diff, text: `No minutes planned yet for ${l} min` }; + if (diff === 0) return { state: 'on', diff, text: `${t} of ${l} min planned, right on time` }; + if (diff > 0) return { state: 'under', diff, text: `${t} of ${l} min planned, ${diff} min to spare` }; + return { state: 'over', diff, text: `${t} of ${l} min planned, ${-diff} min over` }; +} + +// The first point that carries the plan past the session length, or null. +export function firstOverPoint(steps, limit) { + const l = Number(limit) || 0; + return l ? steps.find((s) => s.plannedEnd > l) ?? null : null; +} diff --git a/src/utils/present.test.js b/src/utils/present.test.js index 5b8abf9..4d37f25 100644 --- a/src/utils/present.test.js +++ b/src/utils/present.test.js @@ -28,3 +28,20 @@ test('pace: ahead before a point\'s window, behind after it, on pace inside it', expect(pacing(outlineSteps([n('x', 'No minutes')]), 0, 10)).toBeNull(); expect(paceText(null)).toBe(''); }); + +import { firstOverPoint, outlineSteps as stepsOf, planStatus } from './present.js'; + +test('the plan is compared with the session length', () => { + expect(planStatus(40, null).state).toBe('unset'); + expect(planStatus(0, 45)).toMatchObject({ state: 'under' }); + expect(planStatus(42, 45)).toEqual({ state: 'under', diff: 3, text: '42 of 45 min planned, 3 min to spare' }); + expect(planStatus(45, 45).state).toBe('on'); + expect(planStatus(52, 45)).toEqual({ state: 'over', diff: -7, text: '52 of 45 min planned, 7 min over' }); +}); + +test('the first point that runs past the session length is found', () => { + const tree = [{ id: 'a', text: 'A', minutes: 20, children: [] }, { id: 'b', text: 'B', minutes: 20, children: [] }, { id: 'c', text: 'C', minutes: 10, children: [] }]; + expect(firstOverPoint(stepsOf(tree), 45).id).toBe('c'); + expect(firstOverPoint(stepsOf(tree), 60)).toBeNull(); + expect(firstOverPoint(stepsOf(tree), null)).toBeNull(); +}); diff --git a/src/utils/slides.js b/src/utils/slides.js new file mode 100644 index 0000000..adbe55c --- /dev/null +++ b/src/utils/slides.js @@ -0,0 +1,78 @@ +// Slides for a session (or the whole study): a title slide, then for each session its passage, one slide per outline +// point (with the verse the point quotes) and the discussion questions. Built as a printable 16:9 page set ("Save as +// PDF" in the print dialog) from the same sessions, outlines and questions as the lesson handout. +import { getChunkVerseEntries } from './bibleUtils.jsx'; +import { flatten } from './outline.js'; +import { compactPassages } from './passageSummary.js'; +import { buildChapterSummary } from './projectStorage.js'; +import { outlineForSession, questionsFor, sessionsFor } from './handout.js'; + +const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + +// -> [{ kind: 'title' | 'section' | 'passage' | 'point' | 'questions', ... }] in the order they are shown. +// ids: the chunk ids to include, or null for every session. +export function buildSlides(project, ids = null) { + const slides = [{ kind: 'title', title: project.title ?? '', subtitle: compactPassages(buildChapterSummary(project)) }]; + for (const session of sessionsFor(project).filter((s) => !ids || ids.includes(s.chunk.id))) { + const { chunk, chapterIndex, number, reference } = session; + const name = (chunk.episodeTitle ?? '').trim(); + slides.push({ kind: 'section', title: name || reference, subtitle: name ? reference : '', number: (chunk.episodeNumber ?? '').toString().trim() || number }); + const verses = getChunkVerseEntries(project, chapterIndex, chunk); + if (verses.length) slides.push({ kind: 'passage', reference, translation: project.translation ?? 'BSB', verses: verses.map((v) => ({ number: v.number, text: v.text })) }); + for (const { node, label, depth } of flatten(outlineForSession(project, chunk))) { + slides.push({ kind: 'point', label, depth, text: node.text ?? '', ref: node.ref ?? '', quote: node.scripture?.text ? { text: node.scripture.text, ref: node.scripture.ref ?? '' } : null }); + } + const q = questionsFor(chunk); + if (q.items.length) slides.push({ kind: 'questions', title: q.kind === 'questions' ? 'Discussion' : 'Think about it', items: q.items }); + } + return slides; +} + +// Longer text gets a smaller size so it stays on the slide. +const sizeFor = (chars, steps) => steps.find(([max]) => chars <= max)?.[1] ?? steps[steps.length - 1][1]; + +function slideHtml(s) { + if (s.kind === 'title') return ``; + if (s.kind === 'section') return ``; + if (s.kind === 'passage') { + const chars = s.verses.reduce((n, v) => n + v.text.length, 0); + const size = sizeFor(chars, [[300, 34], [550, 28], [900, 23], [1400, 19], [Infinity, 16]]); + return ``; + } + if (s.kind === 'point') { + const chars = s.text.length + (s.quote?.text.length ?? 0); + const size = sizeFor(chars, [[80, 46], [160, 38], [300, 30], [Infinity, 24]]); + return ``; + } + return ``; +} + +export function buildSlidesHtml(project, ids = null, { dark = false } = {}) { + const bg = dark ? '#16130f' : '#faf7f0'; + const fg = dark ? '#f2efe8' : '#2b2620'; + return `