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 <noreply@anthropic.com>
This commit is contained in:
1 parent
2ca79730f1
commit
d7a1601f80
11 files changed
+444
-10
No files matched your search
@@ -83,6 +83,10 @@ export function initDb() {
|
|||||||
if (!projectCols.some((c) => c.name === 'share_token')) {
|
if (!projectCols.some((c) => c.name === 'share_token')) {
|
||||||
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
|
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');
|
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
|
// 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);
|
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. */
|
/** 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) {
|
export function getProjectByShareToken(token) {
|
||||||
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
|
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||||
|
getOutlineShareToken, setOutlineShareToken, clearOutlineShareToken, getProjectByOutlineToken,
|
||||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||||
setUserPassword, destroyAllSessionsForUser,
|
setUserPassword, destroyAllSessionsForUser,
|
||||||
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
|
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
|
||||||
@@ -27,6 +28,7 @@ import { registerBibleRoutes } from './bibleApi.js';
|
|||||||
import { cleanMarks } from './readerMarks.js';
|
import { cleanMarks } from './readerMarks.js';
|
||||||
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
|
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
|
||||||
import { isSaveConflict, parseBase } from './projectSync.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).
|
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
|
||||||
function userPayload(user) {
|
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
|
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
|
||||||
app.get('/api/share/:token', (req, res) => {
|
app.get('/api/share/:token', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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: '<script>', kind: 'bogus', minutes: -4, scripture: { text: 5 }, attachments: [{ type: 'word', label: 'L' }, null], children: 'nope' }], chapters: [] });
|
||||||
|
expect(p.outlines[0].tree[0]).toEqual({
|
||||||
|
id: 'x', text: 'hi', ref: '', kind: '', minutes: null, note: '', scripture: null,
|
||||||
|
attachments: [{ id: '', type: 'word', label: 'L' }], children: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a study with no outlines shares none, and a huge outline is capped', () => {
|
||||||
|
expect(buildOutlinePayload({ title: 'T', chapters: [] }).outlines).toEqual([]);
|
||||||
|
const big = { title: 'T', outline: Array.from({ length: 3000 }, (_, i) => node(`n${i}`)), chapters: [] };
|
||||||
|
expect(buildOutlinePayload(big).outlines[0].tree).toHaveLength(2000);
|
||||||
|
});
|
||||||
+73
-2
@@ -18,7 +18,7 @@ const json = (status, data) => Promise.resolve({ ok: status < 400, status, json:
|
|||||||
// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call.
|
// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call.
|
||||||
function stubServer({ user = null, putStatus = 200 } = {}) {
|
function stubServer({ user = null, putStatus = 200 } = {}) {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const state = { putStatus };
|
const state = { putStatus, outlineToken: null };
|
||||||
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||||
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
|
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
|
||||||
calls.push({ route, url, opts });
|
calls.push({ route, url, opts });
|
||||||
@@ -27,6 +27,18 @@ function stubServer({ user = null, putStatus = 200 } = {}) {
|
|||||||
if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' });
|
if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' });
|
||||||
if (route === 'GET /api/projects') return json(200, []);
|
if (route === 'GET /api/projects') return json(200, []);
|
||||||
if (route === 'GET /api/reader/ink') return json(200, {});
|
if (route === 'GET /api/reader/ink') return json(200, {});
|
||||||
|
if (route.endsWith('/outline-share')) {
|
||||||
|
if (opts.method === 'POST') state.outlineToken = 'abc123';
|
||||||
|
if (opts.method === 'DELETE') state.outlineToken = null;
|
||||||
|
return json(200, opts.method === 'DELETE' ? { ok: true } : { shareToken: state.outlineToken ?? null });
|
||||||
|
}
|
||||||
|
if (route === 'GET /api/share-outline/abc123') {
|
||||||
|
return json(200, { title: 'Titus', translation: 'BSB', outlines: [
|
||||||
|
{ scope: 'study', label: 'Whole study', tree: [{ id: 'a', text: 'Appoint elders', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
|
||||||
|
{ scope: 'c1', label: 'Titus 1:1–4', tree: [{ id: 'b', text: 'Greeting', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
|
||||||
|
] });
|
||||||
|
}
|
||||||
|
if (route === 'GET /api/share-outline/nope') return json(404, { error: 'This outline link is invalid or has been turned off.' });
|
||||||
if (route.startsWith('PUT /api/projects/')) {
|
if (route.startsWith('PUT /api/projects/')) {
|
||||||
return state.putStatus === 409
|
return state.putStatus === 409
|
||||||
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
|
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
|
||||||
@@ -282,12 +294,21 @@ describe('Outline: per chunk, details, print and share', () => {
|
|||||||
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
|
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Share copies the outline when there is no share sheet; Print opens a page of it', async () => {
|
test('Share opens a sheet; with no account it offers the text, which is copied', async () => {
|
||||||
const user = await openOutline();
|
const user = await openOutline();
|
||||||
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||||
await screen.findByRole('list', { name: 'Outline' });
|
await screen.findByRole('list', { name: 'Outline' });
|
||||||
await user.click(screen.getByRole('button', { name: /^share$/i }));
|
await user.click(screen.getByRole('button', { name: /^share$/i }));
|
||||||
|
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
|
||||||
|
expect(await within(sheet).findByText(/links need your account/i)).toBeInTheDocument();
|
||||||
|
await user.click(within(sheet).getByRole('button', { name: /share as text/i }));
|
||||||
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
|
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Print opens a page of just the outline', async () => {
|
||||||
|
const user = await openOutline();
|
||||||
|
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||||
|
await screen.findByRole('list', { name: 'Outline' });
|
||||||
const doc = { write: vi.fn(), close: vi.fn() };
|
const doc = { write: vi.fn(), close: vi.fn() };
|
||||||
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
|
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
|
||||||
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
|
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
|
||||||
@@ -295,6 +316,56 @@ describe('Outline: per chunk, details, print and share', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Read-only outline link', () => {
|
||||||
|
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
|
||||||
|
|
||||||
|
test('create the link, see it, and turn it off', async () => {
|
||||||
|
const server = stubServer({ user });
|
||||||
|
const u = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
|
||||||
|
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
|
||||||
|
await screen.findByText(/Scripture & Chunks/i);
|
||||||
|
const verses = within(screen.getByTestId('verse-list'));
|
||||||
|
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
|
||||||
|
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
|
||||||
|
await screen.findByText('1 created');
|
||||||
|
await u.click(screen.getByRole('button', { name: /^outline$/i }));
|
||||||
|
await u.click(await screen.findByRole('button', { name: /start from my chunks/i }));
|
||||||
|
await u.click(await screen.findByRole('button', { name: /^share$/i }));
|
||||||
|
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
|
||||||
|
await u.click(await within(sheet).findByRole('button', { name: /create read-only link/i }));
|
||||||
|
const box = await within(sheet).findByLabelText('Outline link');
|
||||||
|
expect(box.value).toMatch(/\?outline=abc123$/);
|
||||||
|
expect(server.calls.some((c) => c.route === 'POST /api/projects/' + c.url.split('/')[3] + '/outline-share')).toBe(true);
|
||||||
|
await u.click(within(sheet).getByRole('button', { name: /turn off link/i }));
|
||||||
|
expect(await within(sheet).findByRole('button', { name: /create read-only link/i })).toBeInTheDocument();
|
||||||
|
}, 15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Opening a read-only outline link', () => {
|
||||||
|
afterEach(() => window.history.replaceState({}, '', '/'));
|
||||||
|
|
||||||
|
test('shows the outline with no sign-in and a way to switch between outlines', async () => {
|
||||||
|
stubServer({ user: null });
|
||||||
|
window.history.replaceState({}, '', '/?outline=abc123');
|
||||||
|
render(<App />);
|
||||||
|
const frame = await screen.findByTitle('Shared outline');
|
||||||
|
expect(frame.getAttribute('srcdoc')).toContain('Appoint elders');
|
||||||
|
expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument();
|
||||||
|
await userEvent.setup().selectOptions(screen.getByRole('combobox', { name: 'Outline' }), 'c1');
|
||||||
|
expect(screen.getByTitle('Shared outline').getAttribute('srcdoc')).toContain('Greeting');
|
||||||
|
expect(screen.getByTitle('Shared outline').getAttribute('srcdoc')).toContain('Titus — Titus 1:1–4');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a turned-off or wrong link says so', async () => {
|
||||||
|
stubServer({ user: null });
|
||||||
|
window.history.replaceState({}, '', '/?outline=nope');
|
||||||
|
render(<App />);
|
||||||
|
expect(await screen.findByText(/invalid or has been turned off/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Study cards on Home', () => {
|
describe('Study cards on Home', () => {
|
||||||
test('a long run of chapters and many tags stay short', async () => {
|
test('a long run of chapters and many tags stay short', async () => {
|
||||||
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');
|
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');
|
||||||
|
|||||||
+6
-1
@@ -5,6 +5,7 @@ import { AppContext, useApp } from './context/AppContext.js';
|
|||||||
import { ToastProvider, useToast } from './components/Toasts.jsx';
|
import { ToastProvider, useToast } from './components/Toasts.jsx';
|
||||||
import CommandPalette from './components/CommandPalette.jsx';
|
import CommandPalette from './components/CommandPalette.jsx';
|
||||||
import ShortcutsHelp from './components/ShortcutsHelp.jsx';
|
import ShortcutsHelp from './components/ShortcutsHelp.jsx';
|
||||||
|
import SharedOutlineView from './components/SharedOutlineView.jsx';
|
||||||
import { useAudio } from './hooks/useAudio.js';
|
import { useAudio } from './hooks/useAudio.js';
|
||||||
import { useAuth } from './hooks/useAuth.js';
|
import { useAuth } from './hooks/useAuth.js';
|
||||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||||
@@ -149,7 +150,7 @@ const AppInner = () => {
|
|||||||
page: currentPage,
|
page: currentPage,
|
||||||
setPage: setCurrentPage,
|
setPage: setCurrentPage,
|
||||||
hasProject: !!project,
|
hasProject: !!project,
|
||||||
enabled: !new URLSearchParams(window.location.search).has('share'),
|
enabled: !new URLSearchParams(window.location.search).has('share') && !new URLSearchParams(window.location.search).has('outline'),
|
||||||
onArrive: (page) => { if (page === 'home') setProjectIndex(loadProjectIndex()); },
|
onArrive: (page) => { if (page === 'home') setProjectIndex(loadProjectIndex()); },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -461,6 +462,10 @@ function PageLoading() {
|
|||||||
function AppRouter() {
|
function AppRouter() {
|
||||||
const { currentPage, authUser, authServerDown } = useApp();
|
const { currentPage, authUser, authServerDown } = useApp();
|
||||||
const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView();
|
const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView();
|
||||||
|
const sharedOutlineToken = new URLSearchParams(window.location.search).get('outline');
|
||||||
|
|
||||||
|
// A read-only outline link: no sign-in, nothing editable
|
||||||
|
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
|
||||||
|
|
||||||
if (sharedViewToken) {
|
if (sharedViewToken) {
|
||||||
if (sharedError) {
|
if (sharedError) {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Share an outline: a read-only link anyone can open (no account), or the plain text.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sheet from './reader/Sheet.jsx';
|
||||||
|
import { useToast } from './Toasts.jsx';
|
||||||
|
import { useOutlineShareLink } from '../hooks/useOutlineShareLink.js';
|
||||||
|
import { shareOutline } from '../utils/outlineExport.js';
|
||||||
|
|
||||||
|
const btn = 'rounded-xl border border-[var(--r-line)] px-3.5 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
||||||
|
|
||||||
|
export default function ShareOutlineSheet({ open, onClose, projectId, outline }) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const link = useOutlineShareLink(projectId, open);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
try { await navigator.clipboard.writeText(link.url); setCopied(true); window.setTimeout(() => setCopied(false), 2000); } catch { toast('Couldn’t copy. Select the link and copy it.', { tone: 'error' }); }
|
||||||
|
};
|
||||||
|
// The phone/computer share sheet, with the link (falls back to copying it)
|
||||||
|
const shareLink = async () => {
|
||||||
|
if (navigator.share) {
|
||||||
|
try { await navigator.share({ title: `${outline.title} — Outline`, url: link.url }); return; } catch (err) { if (err?.name === 'AbortError') return; }
|
||||||
|
}
|
||||||
|
copyLink();
|
||||||
|
};
|
||||||
|
const shareText = async () => {
|
||||||
|
const result = await shareOutline(outline);
|
||||||
|
if (result === 'copied') toast('Outline copied. Paste it into a message or email.');
|
||||||
|
else if (result === 'failed') toast('Couldn’t share or copy the outline.', { tone: 'error' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={onClose} title="Share this outline">
|
||||||
|
<h3 className="text-sm font-medium">Read-only link</h3>
|
||||||
|
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">
|
||||||
|
Anyone with the link can read this study’s outlines (the whole-study one and each chunk’s), and print them. They can’t edit anything, and they can’t see your notes or scripts. No account needed.
|
||||||
|
</p>
|
||||||
|
{link.state === 'loading' && <p className="text-sm text-[var(--r-sub)]">Checking…</p>}
|
||||||
|
{link.state === 'unavailable' && (
|
||||||
|
<p className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm text-[var(--r-sub)]">Links need your account and a connection to the server. You can still share the text below.</p>
|
||||||
|
)}
|
||||||
|
{link.state === 'ready' && (link.token ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input readOnly aria-label="Outline link" value={link.url} onFocus={(e) => 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)]" />
|
||||||
|
<button type="button" onClick={copyLink} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{copied ? 'Copied!' : 'Copy'}</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="button" onClick={shareLink} className={btn}>Share link…</button>
|
||||||
|
<button type="button" onClick={link.turnOff} disabled={link.busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{link.busy ? 'Turning off…' : 'Turn off link'}</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-[var(--r-sub)]">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.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={link.create} disabled={link.busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{link.busy ? 'Creating…' : 'Create read-only link'}</button>
|
||||||
|
))}
|
||||||
|
{link.error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{link.error}</p>}
|
||||||
|
|
||||||
|
<div className="mt-5 border-t border-[var(--r-line)] pt-4">
|
||||||
|
<h3 className="text-sm font-medium">Or send the text</h3>
|
||||||
|
<p className="mb-2 mt-1 text-xs text-[var(--r-sub)]">The outline you’re looking at, as plain text, to paste into a message or email.</p>
|
||||||
|
<button type="button" onClick={shareText} className={btn}>Share as text</button>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// What someone sees when they open a read-only outline link: the outline(s), nothing editable, and a
|
||||||
|
// way to print or save it.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getSharedOutline } from '../syncService.js';
|
||||||
|
import { buildOutlineHtml } from '../utils/bibleUtils.jsx';
|
||||||
|
import { printOutline } from '../utils/outlineExport.js';
|
||||||
|
|
||||||
|
export default function SharedOutlineView({ token }) {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [scope, setScope] = useState('study');
|
||||||
|
|
||||||
|
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.');
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
useEffect(() => { if (data?.title) document.title = `${data.title} — Outline`; }, [data]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center bg-slate-100 px-4">
|
||||||
|
<div className="w-full max-w-sm rounded-3xl bg-white p-8 text-center shadow">
|
||||||
|
<p className="text-sm uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
||||||
|
<p className="mt-4 text-sm text-rose-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!data) return <div className="flex min-h-dvh items-center justify-center bg-slate-100"><p className="text-sm text-slate-500">Loading outline…</p></div>;
|
||||||
|
|
||||||
|
const current = data.outlines.find((o) => o.scope === scope) ?? data.outlines[0];
|
||||||
|
const title = current && current.scope !== 'study' ? `${data.title} — ${current.label}` : data.title;
|
||||||
|
const outline = current ? { title, subtitle: current.scope === 'study' ? data.translation : `${current.label} · ${data.translation}`, tree: current.tree } : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-dvh bg-slate-100">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-slate-200 bg-slate-900 px-4 py-3 text-xs text-slate-300">
|
||||||
|
<span>🔗 Read-only outline — <a href="/" className="underline hover:text-white">go to Bible Study Project</a></span>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
{data.outlines.length > 1 && (
|
||||||
|
<label className="flex items-center gap-2">Outline
|
||||||
|
<select value={current?.scope} onChange={(e) => setScope(e.target.value)} aria-label="Outline" className="rounded-md border border-slate-600 bg-slate-800 px-2 py-1 text-slate-100">
|
||||||
|
{data.outlines.map((o) => <option key={o.scope} value={o.scope}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{outline && <button type="button" onClick={() => printOutline(outline)} className="rounded-md border border-slate-500 px-3 py-1 text-slate-100 hover:bg-slate-700">Print / PDF</button>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{outline ? (
|
||||||
|
<iframe title="Shared outline" srcDoc={buildOutlineHtml(outline)} sandbox="allow-popups" className="h-[calc(100dvh-3rem)] w-full border-0 bg-white" />
|
||||||
|
) : (
|
||||||
|
<p className="p-10 text-center text-sm text-slate-500">This study doesn’t have an outline yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// The read-only link to a study's outlines: status, create, turn off, copy. A separate link from the
|
||||||
|
// whole-study share link, and it only ever shows outlines.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { disableOutlineSharing, enableOutlineSharing, getOutlineShareStatus } from '../syncService.js';
|
||||||
|
|
||||||
|
export function useOutlineShareLink(projectId, enabled = true) {
|
||||||
|
const [token, setToken] = useState(null);
|
||||||
|
const [state, setState] = useState('loading'); // 'loading' | 'ready' | 'unavailable' (no account / server)
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setToken(null);
|
||||||
|
setError('');
|
||||||
|
if (!projectId || !enabled) return undefined;
|
||||||
|
let active = true;
|
||||||
|
setState('loading');
|
||||||
|
getOutlineShareStatus(projectId).then((result) => {
|
||||||
|
if (!active) return;
|
||||||
|
if (result.ok) { setToken(result.data.shareToken); setState('ready'); } else setState('unavailable');
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [projectId, enabled]);
|
||||||
|
|
||||||
|
const url = token ? `${window.location.origin}${window.location.pathname}?outline=${token}` : '';
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
const result = await enableOutlineSharing(projectId);
|
||||||
|
setBusy(false);
|
||||||
|
if (result.ok) setToken(result.data.shareToken);
|
||||||
|
else setError(result.status === 404 ? 'Your study hasn’t synced to your account yet. Wait a moment and try again.' : 'Couldn’t create the link. Check your connection and try again.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const turnOff = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
const result = await disableOutlineSharing(projectId);
|
||||||
|
setBusy(false);
|
||||||
|
if (result.ok) setToken(null); else setError('Couldn’t turn the link off. Try again.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { token, url, state, busy, error, create, turnOff };
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
|||||||
import { buildChapterSummary } from '../utils/projectStorage.js';
|
import { buildChapterSummary } from '../utils/projectStorage.js';
|
||||||
import { compactPassages } from '../utils/passageSummary.js';
|
import { compactPassages } from '../utils/passageSummary.js';
|
||||||
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
|
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
|
||||||
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline, shareOutline } from '../utils/outlineExport.js';
|
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
|
||||||
|
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
|
||||||
import {
|
import {
|
||||||
KINDS, STUDY, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
|
KINDS, STUDY, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
|
||||||
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
|
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
|
||||||
@@ -44,6 +45,7 @@ export default function OutlinePage() {
|
|||||||
const rows = flatten(tree);
|
const rows = flatten(tree);
|
||||||
const [pendingFocus, setPendingFocus] = useState(null);
|
const [pendingFocus, setPendingFocus] = useState(null);
|
||||||
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
|
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
|
||||||
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
const [lookup, setLookup] = useState({ id: null, busy: false, error: '' });
|
const [lookup, setLookup] = useState({ id: null, busy: false, error: '' });
|
||||||
|
|
||||||
// The one way every change is made, always from the latest outline (typing is fast).
|
// The one way every change is made, always from the latest outline (typing is fast).
|
||||||
@@ -107,11 +109,6 @@ export default function OutlinePage() {
|
|||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
try { await navigator.clipboard.writeText(outlineToText(tree)); toast('Outline copied'); } catch { toast("Couldn't copy. Select the text and copy it instead.", { tone: 'error' }); }
|
try { await navigator.clipboard.writeText(outlineToText(tree)); toast('Outline copied'); } catch { toast("Couldn't copy. Select the text and copy it instead.", { tone: 'error' }); }
|
||||||
};
|
};
|
||||||
const share = async () => {
|
|
||||||
const result = await shareOutline(outline);
|
|
||||||
if (result === 'copied') toast('Outline copied. Paste it into a message or email.');
|
|
||||||
else if (result === 'failed') toast("Couldn't share or copy the outline.", { tone: 'error' });
|
|
||||||
};
|
|
||||||
const print = () => { if (!printOutline(outline)) toast('Allow pop-ups for this site to print or save as PDF.', { tone: 'error' }); };
|
const print = () => { if (!printOutline(outline)) toast('Allow pop-ups for this site to print or save as PDF.', { tone: 'error' }); };
|
||||||
const word = async () => { try { await downloadOutlineDocx(outline); } catch { toast("Couldn't make the Word file.", { tone: 'error' }); } };
|
const word = async () => { try { await downloadOutlineDocx(outline); } catch { toast("Couldn't make the Word file.", { tone: 'error' }); } };
|
||||||
|
|
||||||
@@ -160,7 +157,7 @@ export default function OutlinePage() {
|
|||||||
{seedLabel}{addable > 0 ? ` (${addable})` : ''}
|
{seedLabel}{addable > 0 ? ` (${addable})` : ''}
|
||||||
</button>
|
</button>
|
||||||
<span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" />
|
<span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" />
|
||||||
<button type="button" onClick={share} disabled={tree.length === 0} className={btn}>Share</button>
|
<button type="button" onClick={() => setShareOpen(true)} disabled={tree.length === 0} className={btn}>Share</button>
|
||||||
<button type="button" onClick={print} disabled={tree.length === 0} className={btn}>Print / PDF</button>
|
<button type="button" onClick={print} disabled={tree.length === 0} className={btn}>Print / PDF</button>
|
||||||
<details className="relative">
|
<details className="relative">
|
||||||
<summary className={`${btn} cursor-pointer list-none ${tree.length === 0 ? 'pointer-events-none opacity-50' : ''}`}>More ▾</summary>
|
<summary className={`${btn} cursor-pointer list-none ${tree.length === 0 ? 'pointer-events-none opacity-50' : ''}`}>More ▾</summary>
|
||||||
@@ -330,6 +327,7 @@ export default function OutlinePage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
<ShareOutlineSheet open={shareOpen} onClose={() => setShareOpen(false)} projectId={project.id} outline={outline} />
|
||||||
</StudioShell>
|
</StudioShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,6 +103,21 @@ export async function disableSharing(id) {
|
|||||||
return request('DELETE', `/projects/${id}/share`);
|
return request('DELETE', `/projects/${id}/share`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The project's read-only outline link: { shareToken } (null when off). A separate link from the study share. */
|
||||||
|
export async function getOutlineShareStatus(id) {
|
||||||
|
return request('GET', `/projects/${id}/outline-share`);
|
||||||
|
}
|
||||||
|
export async function enableOutlineSharing(id) {
|
||||||
|
return request('POST', `/projects/${id}/outline-share`);
|
||||||
|
}
|
||||||
|
export async function disableOutlineSharing(id) {
|
||||||
|
return request('DELETE', `/projects/${id}/outline-share`);
|
||||||
|
}
|
||||||
|
/** Public lookup — no session. Returns { title, translation, outlines: [{ scope, label, tree }] }. */
|
||||||
|
export async function getSharedOutline(token) {
|
||||||
|
return request('GET', `/share-outline/${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Public lookup — no session required. Returns the full project for a valid share token. */
|
/** Public lookup — no session required. Returns the full project for a valid share token. */
|
||||||
export async function getSharedProject(token) {
|
export async function getSharedProject(token) {
|
||||||
return request('GET', `/share/${token}`);
|
return request('GET', `/share/${token}`);
|
||||||
|
|||||||
Reference in new issue
Block a user