From d7a1601f801ed3515a3b583af51a5db3974bd8d1 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:02:40 -0400 Subject: [PATCH] Outline: share as a read-only link - Share on the Outline page opens a sheet: create / copy / turn off a read-only link, share the link with the system share sheet, or send the outline as text - The link has its own token (separate from the whole-study share link) and the public endpoint returns only the outlines, rebuilt field by field: no observations, interpretations, applications, scripts or word notes - Opening the link shows the outline(s) with no sign-in, a switcher between the whole-study and per-chunk outlines, and Print / PDF - New outline_share_token column (added automatically on start) Co-Authored-By: Claude Sonnet 5.5 --- server/db.js | 26 ++++++++++ server/index.js | 46 +++++++++++++++++ server/outlineShare.js | 59 ++++++++++++++++++++++ server/outlineShare.test.js | 42 ++++++++++++++++ src/App.flow.test.jsx | 75 +++++++++++++++++++++++++++- src/App.jsx | 7 ++- src/components/ShareOutlineSheet.jsx | 66 ++++++++++++++++++++++++ src/components/SharedOutlineView.jsx | 62 +++++++++++++++++++++++ src/hooks/useOutlineShareLink.js | 44 ++++++++++++++++ src/pages/OutlinePage.jsx | 12 ++--- src/syncService.js | 15 ++++++ 11 files changed, 444 insertions(+), 10 deletions(-) create mode 100644 server/outlineShare.js create mode 100644 server/outlineShare.test.js create mode 100644 src/components/ShareOutlineSheet.jsx create mode 100644 src/components/SharedOutlineView.jsx create mode 100644 src/hooks/useOutlineShareLink.js diff --git a/server/db.js b/server/db.js index 32668bb..4985e65 100644 --- a/server/db.js +++ b/server/db.js @@ -83,6 +83,10 @@ export function initDb() { if (!projectCols.some((c) => c.name === 'share_token')) { db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT'); } + if (!projectCols.some((c) => c.name === 'outline_share_token')) { + db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT'); + } + 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'); // Reader ink — one row per user + book + chapter, stored as JSON @@ -414,6 +418,28 @@ export function clearShareToken(id, userId) { db.prepare('UPDATE projects SET share_token = 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 ---- + +export function getOutlineShareToken(id, userId) { + const row = db.prepare('SELECT outline_share_token AS t FROM projects WHERE id = ? AND user_id = ?').get(id, userId); + return row?.t ?? null; +} + +export function setOutlineShareToken(id, userId, token) { + return db.prepare('UPDATE projects SET outline_share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId).changes > 0; +} + +export function clearOutlineShareToken(id, userId) { + db.prepare('UPDATE projects SET outline_share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId); +} + +/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */ +export function getProjectByOutlineToken(token) { + const row = db.prepare('SELECT data FROM projects WHERE outline_share_token = ?').get(token); + if (!row) return null; + try { return JSON.parse(row.data); } catch { return null; } +} + /** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */ export function getProjectByShareToken(token) { const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token); diff --git a/server/index.js b/server/index.js index ce6dee6..8b65269 100644 --- a/server/index.js +++ b/server/index.js @@ -10,6 +10,7 @@ import { countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects, enableTotp, disableTotp, setBackupCodeHashes, setPodcastName, getShareToken, setShareToken, clearShareToken, getProjectByShareToken, + getOutlineShareToken, setOutlineShareToken, clearOutlineShareToken, getProjectByOutlineToken, adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject, setUserPassword, destroyAllSessionsForUser, getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs, @@ -27,6 +28,7 @@ import { registerBibleRoutes } from './bibleApi.js'; import { cleanMarks } from './readerMarks.js'; import { cleanPrefsPatch, mergePrefs } from './prefs.js'; import { isSaveConflict, parseBase } from './projectSync.js'; +import { buildOutlinePayload } from './outlineShare.js'; // Shape returned to the client for the signed-in user (login, MFA verify, /auth/me). function userPayload(user) { @@ -599,6 +601,50 @@ app.delete('/api/projects/:id/share', requireAuth, (req, res) => { } }); +// 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) }); + } 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.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => { + try { + clearOutlineShareToken(req.params.id, req.session.userId); + res.json({ ok: true }); + } catch (err) { + console.error('DELETE /api/projects/:id/outline-share error:', err); + res.status(500).json({ error: 'Failed to revoke sharing.' }); + } +}); + +// GET /api/share-outline/:token — PUBLIC, no login required +app.get('/api/share-outline/:token', (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)); + } 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) => { try { diff --git a/server/outlineShare.js b/server/outlineShare.js new file mode 100644 index 0000000..622611f --- /dev/null +++ b/server/outlineShare.js @@ -0,0 +1,59 @@ +// What a read-only outline link shows: the outlines of a study (the whole-study one and each chunk's) +// and nothing else — no observations, interpretations, applications, scripts or word notes. The +// outline data comes from the browser, so it is rebuilt field by field instead of passed through. +const MAX_NODES = 2000; +const MAX_DEPTH = 8; +const KINDS = new Set(['', 'illustration', 'application', 'quote', 'question', 'transition']); + +const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : ''); + +function cleanTree(nodes, depth, counter) { + if (!Array.isArray(nodes) || depth > MAX_DEPTH) return []; + const out = []; + for (const n of nodes) { + if (!n || typeof n !== 'object' || counter.n >= MAX_NODES) continue; + counter.n += 1; + const minutes = Number.isFinite(n.minutes) && n.minutes >= 0 && n.minutes <= 600 ? n.minutes : null; + out.push({ + id: str(n.id, 64) || `n${counter.n}`, + text: str(n.text, 2000), + ref: str(n.ref, 200), + kind: KINDS.has(n.kind) ? n.kind : '', + minutes, + note: str(n.note, 8000), + scripture: n.scripture && typeof n.scripture.text === 'string' + ? { ref: str(n.scripture.ref, 200), text: str(n.scripture.text, 6000), translation: str(n.scripture.translation, 12) } + : null, + attachments: (Array.isArray(n.attachments) ? n.attachments : []).slice(0, 50) + .filter((a) => a && typeof a === 'object') + .map((a) => ({ id: str(a.id, 100), type: a.type === 'word' ? 'word' : 'xref', label: str(a.label, 200), ...(a.detail ? { detail: str(a.detail, 500) } : {}) })), + children: cleanTree(n.children, depth + 1, counter), + }); + } + return out; +} + +const chunkLabel = (chapters, chapterIndex, chunk) => { + const ch = chapters[chapterIndex]; + const next = chapters[chapterIndex + 1]; + if (Number.isInteger(chunk.spilloverEndVerse) && next && next.bookAbbrev === ch.bookAbbrev) { + return `${ch.book} ${ch.chapter}:${chunk.startVerse}–${next.chapter}:${chunk.spilloverEndVerse}`; + } + return chunk.startVerse === chunk.endVerse ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` : `${ch.book} ${ch.chapter}:${chunk.startVerse}–${chunk.endVerse}`; +}; + +// -> { title, translation, outlines: [{ scope, label, tree }] } with only the outlines that have points +export function buildOutlinePayload(project) { + const counter = { n: 0 }; + const outlines = []; + const whole = cleanTree(project?.outline, 0, counter); + if (whole.length > 0) outlines.push({ scope: 'study', label: 'Whole study', tree: whole }); + const chapters = Array.isArray(project?.chapters) ? project.chapters : []; + chapters.forEach((ch, chapterIndex) => { + for (const chunk of Array.isArray(ch?.chunks) ? ch.chunks : []) { + const tree = cleanTree(chunk?.outline, 0, counter); + if (tree.length > 0) outlines.push({ scope: str(chunk.id, 64), label: str(chunkLabel(chapters, chapterIndex, chunk), 200), tree }); + } + }); + return { title: str(project?.title, 500), translation: str(project?.translation, 12) || 'BSB', outlines }; +} diff --git a/server/outlineShare.test.js b/server/outlineShare.test.js new file mode 100644 index 0000000..f51b744 --- /dev/null +++ b/server/outlineShare.test.js @@ -0,0 +1,42 @@ +import { expect, test } from 'vitest'; +import { buildOutlinePayload } from './outlineShare.js'; + +const node = (id, extra = {}) => ({ id, text: id, ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [], ...extra }); +const project = { + title: 'Titus', translation: 'BSB', + outline: [node('a', { minutes: 5, kind: 'illustration', children: [node('a1')] })], + chapters: [{ + book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'secret verse text' }], + chunks: [ + { id: 'c1', startVerse: 1, endVerse: 4, observation: 'PRIVATE OBSERVATION', finalScript: 'PRIVATE SCRIPT', greekWords: [{ lexeme: 'x' }], outline: [node('b')] }, + { id: 'c2', startVerse: 5, endVerse: 5, observation: 'more private', outline: [] }, + ], + }], +}; + +test('shares the outlines only, with each chunk labelled by its passage', () => { + const p = buildOutlinePayload(project); + expect(p.title).toBe('Titus'); + expect(p.outlines.map((o) => [o.scope, o.label])).toEqual([['study', 'Whole study'], ['c1', 'Titus 1:1–4']]); + expect(p.outlines[0].tree[0]).toMatchObject({ text: 'a', minutes: 5, kind: 'illustration' }); + expect(p.outlines[0].tree[0].children[0].text).toBe('a1'); +}); + +test('nothing from the rest of the study leaks into what is shared', () => { + const json = JSON.stringify(buildOutlinePayload(project)); + for (const secret of ['PRIVATE OBSERVATION', 'PRIVATE SCRIPT', 'secret verse text', 'more private', 'greekWords']) expect(json).not.toContain(secret); +}); + +test('unknown fields on a point are dropped, and bad values are cleaned', () => { + const p = buildOutlinePayload({ title: 'T', outline: [{ id: 'x', text: 'hi', evil: '