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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 16:02:40 -04:00
1 parent 2ca79730f1
commit d7a1601f80
11 files changed
+444 -10

No files matched your search

+42
View File
@@ -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);
});