Teaching: hide-notes-until share, session length check, slides export

- A study share link can hide the owner's notes until a chosen time: the server
  sends only passages and discussion questions until then, with a banner in the
  shared view (new share_reveal column, migrated on start).
- Outline: a session length with "N of M min planned, X min over" and a marker
  on the point where the plan runs past it.
- Export > Slides: printable 16:9 slides (title, passage, outline points with
  their verse, discussion questions) for one session or all; light or dark.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 13:08:21 -04:00
1 parent 67645cf1df
commit 2c7c7145c4
14 files changed
+303 -33

No files matched your search

+14 -12
View File
@@ -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). */
+11 -9
View File
@@ -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.' });
+24
View File
@@ -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);
}
+33
View File
@@ -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);
});