Compare commits

...
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 d7a1601f80 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>
2026-10-01 16:02:40 -04:00
nmemmertandClaude Sonnet 5.5 2ca79730f1 Merge reader-compare: translation comparison with a button next to Aa
Build and Push Docker Image / build-and-push (push) Successful in 4m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:40:28 -04:00
nmemmertandClaude Sonnet 5.5 5ab416930b Reader: compare button next to Aa
A button beside Aa in the reader's top bar opens a Compare translations
sheet (pick the Bible to compare with, show/hide the differing-word
highlights). It shows as active while a comparison is on. The controls
moved out of the Aa sheet; "C" opens it from the keyboard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:40:24 -04:00
nmemmertandClaude Sonnet 5.5 aa27a5bb18 Reader: show how much another translation differs from the one being read
- Word-level comparison (ignores case and punctuation): differing words are
  marked in both columns of the side-by-side view
- Chapter summary ("NET is 18% different from BSB · 37 of 39 verses differ")
  with links to the most different verses, and a percentage on each verse
- Compare tab on a verse shows each translation's difference from the one
  being read, with the differing words marked
- "Highlight differences" setting; your own highlights and search matches
  always win where they overlap a difference mark

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:36:46 -04:00
nmemmertandClaude Sonnet 5.5 7101be393e Merge outline-richer: per-chunk outlines, richer points, print and share
Build and Push Docker Image / build-and-push (push) Failing after 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:49 -04:00
26 changed files with 790 additions and 34 deletions

No files matched your search

+26
View File
@@ -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);
+46
View File
@@ -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 {
+59
View File
@@ -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 };
}
+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);
});
+73 -2
View File
@@ -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
View File
@@ -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) {
+66
View File
@@ -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>
);
}
+62
View File
@@ -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>
);
}
+1
View File
@@ -14,6 +14,7 @@ const BY_PAGE = {
['/', 'Search this chapter or the whole Bible'], ['/', 'Search this chapter or the whole Bible'],
['G', 'Pick a book and chapter'], ['G', 'Pick a book and chapter'],
['B', 'Bookmark the selected verse'], ['B', 'Bookmark the selected verse'],
['C', 'Compare translations'],
[',', 'Reading settings'], [',', 'Reading settings'],
['F', 'Hide or show the controls'], ['F', 'Hide or show the controls'],
], ],
+35
View File
@@ -0,0 +1,35 @@
// Compare translations: pick a second Bible to read beside the one you're reading, and whether the
// words that differ are highlighted. The chapter shows how different the two are.
import Sheet from './Sheet.jsx';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
export default function CompareSheet({ open, onClose, translation, parallel, onParallel, showDiff, onShowDiff }) {
const chip = (active) => `rounded-full border px-3 py-1.5 text-sm font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
const options = READER_TRANSLATIONS.filter((t) => t.id !== translation);
return (
<Sheet open={open} onClose={onClose} title="Compare translations">
<p className="mb-3 text-sm text-[var(--r-sub)]">
Read another Bible beside the {translation}. You’ll see how different its wording is for the whole chapter and for each verse.
</p>
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Compare {translation} with</span>
<div role="group" aria-label="Translation to compare with" className="mb-4 flex flex-wrap gap-1.5">
{options.map((t) => (
<button key={t.id} type="button" title={t.name} aria-pressed={parallel === t.id} onClick={() => { onParallel(parallel === t.id ? '' : t.id); if (parallel !== t.id) onClose(); }} className={chip(parallel === t.id)}>{t.id}</button>
))}
<button type="button" aria-pressed={!parallel} onClick={() => onParallel('')} className={chip(!parallel)}>Off</button>
</div>
{parallel && (
<>
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Highlight the words that differ</span>
<div role="group" aria-label="Highlight differences" className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{[[true, 'Show'], [false, 'Hide']].map(([id, text]) => (
<button key={String(id)} type="button" aria-pressed={showDiff === id} onClick={() => onShowDiff(id)}
className={`flex-1 px-3 py-2 text-sm ${showDiff === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
))}
</div>
</>
)}
<p className="mt-4 text-[11px] text-[var(--r-sub)]">The wording is compared word by word, ignoring capitals and punctuation. It shows how the wording differs, not which translation is more accurate. Tap any verse and open Compare to see all six translations of it.</p>
</Sheet>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Text with the words that differ from the other translation marked.
export function DiffText({ text, ranges = [] }) {
if (!ranges.length) return text;
const parts = [];
let cursor = 0;
ranges.forEach((r, i) => {
if (r.start > cursor) parts.push(text.slice(cursor, r.start));
parts.push(<span key={i} className="diff-hl">{text.slice(r.start, r.end)}</span>);
cursor = r.end;
});
if (cursor < text.length) parts.push(text.slice(cursor));
return <>{parts}</>;
}
+3 -2
View File
@@ -1,11 +1,11 @@
// Top and bottom chrome for the reader. Everything stays one tap away; focus // Top and bottom chrome for the reader. Everything stays one tap away; focus
// mode hides both. // mode hides both.
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon } from './icons.jsx'; import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon } from './icons.jsx';
import { BookmarkIcon } from '../../utils/bibleUtils.jsx'; import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent'; const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings, onDoneDrawing }) { export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onCompare, compareActive, onSettings, onDoneDrawing }) {
return ( return (
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans"> <div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2"> <div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
@@ -25,6 +25,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks"> <button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks">
<BookmarkIcon filled={bookmarkCount > 0} /> <BookmarkIcon filled={bookmarkCount > 0} />
</button> </button>
<button type="button" onClick={onCompare} className={`${iconBtn} ${compareActive ? 'text-[var(--r-accent)]' : ''}`} aria-label="Compare translations" aria-pressed={!!compareActive} title="Compare translations"><CompareIcon /></button>
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button> <button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
</> </>
)} )}
+33 -3
View File
@@ -1,6 +1,9 @@
// The page itself: chapter heading, translation notice, and verses set in a // The page itself: chapter heading, translation notice, and verses set in a
// serif face. Tap a verse to open the verse sheet; select text to highlight. // serif face. Tap a verse to open the verse sheet; select text to highlight.
import { useMemo } from 'react';
import { formatCrossRef } from '../../utils/wordStudy.js'; import { formatCrossRef } from '../../utils/wordStudy.js';
import { diffChapter, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
// How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing. // How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing.
const FONT_STYLES = { const FONT_STYLES = {
@@ -13,10 +16,18 @@ const LINE_HEIGHTS = { snug: 1.6, normal: 1.85, airy: 2.15 };
// `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first. // `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first.
export default function ReaderText({ export default function ReaderText({
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide, title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, showDiff = true,
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower, bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
crossRefs, showCrossRefs, onSelectionEnd, footer, crossRefs, showCrossRefs, onSelectionEnd, footer,
}) { }) {
// How much the second Bible's wording differs from the first, over the chapter and verse by verse
const diff = useMemo(
() => (parallel && showDiff && !parallel.loading && !parallel.error && parallel.verses?.length ? diffChapter(verses, parallel.verses) : null),
[parallel, showDiff, verses],
);
const diffByVerse = useMemo(() => new Map((diff?.verses ?? []).map((d) => [d.number, d])), [diff]);
const jumpToVerse = (n) => document.getElementById(`reader-verse-${n}`)?.scrollIntoView?.({ block: 'center', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
const handleTap = (n) => { const handleTap = (n) => {
const sel = window.getSelection(); const sel = window.getSelection();
if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping
@@ -92,6 +103,23 @@ export default function ReaderText({
{!loading && !error && sideBySide && !parallel.error && ( {!loading && !error && sideBySide && !parallel.error && (
<div style={bodyStyle} className="font-serif"> <div style={bodyStyle} className="font-serif">
{diff && (
<div className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 font-sans text-sm" style={{ fontSize: '0.875rem' }}>
<p>
<strong>{parallel.translation}</strong>{diff.similarity === 1 ? ' uses the same words as ' : <> is <strong>{differenceLabel(diff.similarity)}</strong> from </>}{translationId} in this chapter
{diff.total > 0 && <span className="text-[var(--r-sub)]"> · {diff.differing} of {diff.total} verses differ</span>}
</p>
{diff.mostDifferent.length > 0 && (
<p className="mt-1 text-xs text-[var(--r-sub)]">
Most different:{' '}
{diff.mostDifferent.map((v, i) => (
<span key={v.number}>{i > 0 && ', '}<button type="button" onClick={() => jumpToVerse(v.number)} className="font-medium text-[var(--r-accent)] hover:underline">verse {v.number}</button> ({Math.round((1 - v.similarity) * 100)}%)</span>
))}
</p>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]"><span className="diff-hl">Marked words</span> aren’t in the other wording.</p>
</div>
)}
<div className="mb-3 hidden grid-cols-2 gap-x-10 border-b border-[var(--r-line)] pb-1 font-sans text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid"> <div className="mb-3 hidden grid-cols-2 gap-x-10 border-b border-[var(--r-line)] pb-1 font-sans text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid">
<span>{translationId}</span><span>{parallel.translation}</span> <span>{translationId}</span><span>{parallel.translation}</span>
</div> </div>
@@ -99,12 +127,14 @@ export default function ReaderText({
<div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2"> <div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2">
<p> <p>
{row.first {row.first
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower)}{chips(row.first)}</span> ? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower, diffByVerse.get(row.number)?.a)}{chips(row.first)}</span>
: <span className="text-[var(--r-sub)]">{num(row.number)}—</span>} : <span className="text-[var(--r-sub)]">{num(row.number)}—</span>}
</p> </p>
<p className="border-l-2 border-[var(--r-line)] pl-3 text-[0.95em] text-[var(--r-sub)] md:border-l-0 md:pl-0 md:text-[var(--r-text)]"> <p className="border-l-2 border-[var(--r-line)] pl-3 text-[0.95em] text-[var(--r-sub)] md:border-l-0 md:pl-0 md:text-[var(--r-text)]">
<span className="mr-1.5 font-sans text-[10px] uppercase tracking-wide md:hidden">{parallel.translation}</span> <span className="mr-1.5 font-sans text-[10px] uppercase tracking-wide md:hidden">{parallel.translation}</span>
{row.second ? <>{num(row.second.number)}{row.second.text}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'} {row.second ? <>{num(row.second.number)}<DiffText text={row.second.text} ranges={diffByVerse.get(row.number)?.b} />{diffByVerse.get(row.number) && !diffByVerse.get(row.number).same && (
<span className="ml-2 whitespace-nowrap font-sans text-[10px] text-[var(--r-sub)]">{differenceLabel(diffByVerse.get(row.number).similarity)}</span>
)}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
</p> </p>
</div> </div>
))} ))}
+53
View File
@@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { vi, test, expect } from 'vitest';
import ReaderText from './ReaderText.jsx';
const verses = [
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
{ number: 2, text: 'Now the earth was formless and void.' },
];
const second = [
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
{ number: 2, text: 'Now the earth was without shape and empty.' },
];
const base = {
title: 'Genesis 1', translation: 'Berean Standard Bible', translationId: 'BSB', notice: '', verses, loading: false, error: '',
layout: 'flow', fontSize: 1, wide: false, bookmarks: {}, bookKey: 'GEN', chapter: 1, selectedVerse: null,
onSelectVerse: vi.fn(), renderVerseText: (_n, text) => text, searchLower: '', crossRefs: null, showCrossRefs: false, onSelectionEnd: vi.fn(),
};
const parallel = (over = {}) => ({ translation: 'NET', name: 'NET Bible', notice: '', verses: second, loading: false, error: '', ...over });
test('side by side, it says how different the other Bible is and marks the differing words', () => {
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff />);
expect(screen.getByText(/is/, { selector: 'p' })).toHaveTextContent(/NET is \d+% different from BSB in this chapter/);
expect(screen.getByText(/1 of 2 verses differ/)).toBeInTheDocument();
const marked = [...container.querySelectorAll('.diff-hl')].map((n) => n.textContent);
expect(marked).toContain('without shape'); // the other Bible's side
expect(screen.getByRole('button', { name: /verse 2/i })).toBeInTheDocument(); // the most different verse
expect(screen.getByText(/% different/, { selector: 'span.ml-2' })).toBeInTheDocument(); // per-verse figure
});
test('identical wording is reported as the same words', () => {
render(<ReaderText {...base} parallel={parallel({ verses })} showDiff />);
expect(screen.getByText(/uses the same words as/)).toBeInTheDocument();
});
test('with highlighting off there is no summary and no marks', () => {
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff={false} />);
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
expect(container.querySelectorAll('.diff-hl')).toHaveLength(0);
});
test('while the other Bible loads there is no comparison yet, and the first Bible still shows', () => {
render(<ReaderText {...base} parallel={parallel({ verses: [], loading: true })} showDiff />);
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
expect(screen.getAllByText(/In the beginning God created/).length).toBeGreaterThan(0);
});
test('the left Bible gets its own differing words passed to the verse renderer', () => {
const renderVerseText = vi.fn((_n, text) => text);
render(<ReaderText {...base} renderVerseText={renderVerseText} parallel={parallel()} showDiff />);
const call = renderVerseText.mock.calls.find(([n]) => n === 2);
expect(call[3].map((r) => verses[1].text.slice(r.start, r.end))).toEqual(['formless', 'void']);
fireEvent.click(screen.getByRole('button', { name: /verse 2/i })); // jumping must not throw even if the element is absent
});
-14
View File
@@ -2,7 +2,6 @@
// to read beside the first. // to read beside the first.
import Sheet from './Sheet.jsx'; import Sheet from './Sheet.jsx';
import { READER_THEMES } from './readerThemes.js'; import { READER_THEMES } from './readerThemes.js';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]]; const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
@@ -22,7 +21,6 @@ function Segmented({ options, value, onChange, label }) {
export default function SettingsSheet({ export default function SettingsSheet({
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide, open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers, font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
translation, parallel, onParallel,
}) { }) {
const row = 'mb-4'; const row = 'mb-4';
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]'; const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
@@ -56,18 +54,6 @@ export default function SettingsSheet({
<span className={label}>Width</span> <span className={label}>Width</span>
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} /> <Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
</div> </div>
<div className={row}>
<span className={label}>Read beside another Bible</span>
<div role="group" aria-label="Second translation" className="flex flex-wrap gap-1.5">
{[{ id: '', name: 'Off' }, ...READER_TRANSLATIONS.filter((t) => t.id !== translation)].map((t) => (
<button key={t.id || 'off'} type="button" onClick={() => onParallel(t.id)} aria-pressed={parallel === t.id} title={t.name}
className={`rounded-full border px-3 py-1 text-xs font-medium ${parallel === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{t.id || 'Off'}
</button>
))}
</div>
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Shows both side by side on wider screens, and stacked on a phone.</p>
</div>
</Sheet> </Sheet>
); );
} }
+14 -3
View File
@@ -6,6 +6,8 @@ import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx'; import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js'; import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js';
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js'; import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']]; const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
@@ -68,20 +70,29 @@ function CompareTab({ book, chapter, verse, current }) {
return () => controller.abort(); return () => controller.abort();
}, [book, chapter, verse]); }, [book, chapter, verse]);
const mine = rows[current]?.text;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{READER_TRANSLATIONS.map((t) => { {READER_TRANSLATIONS.map((t) => {
const row = rows[t.id]; const row = rows[t.id];
// How this wording differs from the Bible being read, word by word
const d = row?.text && mine && t.id !== current ? diffVerses(mine, row.text) : null;
return ( return (
<div key={t.id}> <div key={t.id}>
<p className="mb-0.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{t.id}{t.id === current ? ' · reading' : ''}</p> <p className="mb-0.5 flex items-center gap-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
<span>{t.id}{t.id === current ? ' · reading' : ''}</span>
{d && <span className="normal-case tracking-normal">{differenceLabel(d.similarity)}</span>}
</p>
{!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p> {!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
: row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p> : row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p>
: <p className="font-serif text-[15px] leading-relaxed">{row.text}</p>} : <p className="font-serif text-[15px] leading-relaxed"><DiffText text={row.text} ranges={d?.b} /></p>}
</div> </div>
); );
})} })}
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">Copyright notices for each translation appear at the foot of the chapter when you read it.</p> <p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">
{mine ? <><span className="diff-hl">Marked words</span> aren’t in the {current} wording. </> : null}
Copyright notices for each translation appear at the foot of the chapter when you read it.
</p>
</div> </div>
); );
} }
+1
View File
@@ -11,3 +11,4 @@ export const PenIcon = () => <svg {...base}><path d="M13.5 3.5l3 3L7 16l-4 1 1-4
export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0 0113 0v2" /><rect x="2.5" y="11.5" width="3.5" height="5" rx="1.2" /><rect x="14" y="11.5" width="3.5" height="5" rx="1.2" /></svg>; export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0 0113 0v2" /><rect x="2.5" y="11.5" width="3.5" height="5" rx="1.2" /><rect x="14" y="11.5" width="3.5" height="5" rx="1.2" /></svg>;
export const FocusIcon = () => <svg {...base}><path d="M3 7V4h3M17 7V4h-3M3 13v3h3M17 13v3h-3" /></svg>; export const FocusIcon = () => <svg {...base}><path d="M3 7V4h3M17 7V4h-3M3 13v3h3M17 13v3h-3" /></svg>;
export const CheckIcon = () => <svg {...base}><path d="M4.5 10.5l3.5 3.5 7.5-8" /></svg>; export const CheckIcon = () => <svg {...base}><path d="M4.5 10.5l3.5 3.5 7.5-8" /></svg>;
export const CompareIcon = () => <svg {...base}><rect x="2.5" y="3.5" width="6.5" height="13" rx="1.3" /><rect x="11" y="3.5" width="6.5" height="13" rx="1.3" /><path d="M4.5 7h2.5M4.5 10h2.5M13 7h2.5M13 10h2.5" /></svg>;
+2
View File
@@ -103,6 +103,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS); const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS);
const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS); const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS);
const [readerVerseNumbers, setReaderVerseNumbersRaw] = useStoredChoice('reader-verse-numbers', 'on', ['on', 'off']); const [readerVerseNumbers, setReaderVerseNumbersRaw] = useStoredChoice('reader-verse-numbers', 'on', ['on', 'off']);
const [readerShowDiff, setReaderShowDiffRaw] = useStoredChoice('reader-show-diff', 'on', ['on', 'off']);
const [readerParallel, setReaderParallel] = useStoredChoice('reader-parallel', '', ['', ...READER_TRANSLATIONS.map((t) => t.id)]); const [readerParallel, setReaderParallel] = useStoredChoice('reader-parallel', '', ['', ...READER_TRANSLATIONS.map((t) => t.id)]);
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans' // The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
const [startPagePref, setStartPagePrefState] = useState(() => { const [startPagePref, setStartPagePrefState] = useState(() => {
@@ -532,6 +533,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerSpacing, setReaderSpacing, readerSpacing, setReaderSpacing,
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'), readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
readerParallel, setReaderParallel, readerParallel, setReaderParallel,
readerShowDiff: readerShowDiff === 'on', setReaderShowDiff: (on) => setReaderShowDiffRaw(on ? 'on' : 'off'),
planTranslationPref, setPlanTranslationPref, planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref, startPagePref, setStartPagePref,
readerBookmarks, setReaderBookmarks, readerBookmarks, setReaderBookmarks,
+44
View File
@@ -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 };
}
+7
View File
@@ -82,3 +82,10 @@ select {
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
} }
/* Words that differ between two translations (Reader → read beside another Bible) */
.diff-hl {
background: color-mix(in srgb, var(--r-accent, #a84e27) 20%, transparent);
border-bottom: 2px solid color-mix(in srgb, var(--r-accent, #a84e27) 55%, transparent);
border-radius: 2px;
}
+5 -7
View File
@@ -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>
); );
} }
+18 -2
View File
@@ -15,6 +15,7 @@ import ReaderText from '../components/reader/ReaderText.jsx';
import PlanStrip from '../components/reader/PlanStrip.jsx'; import PlanStrip from '../components/reader/PlanStrip.jsx';
import NavigatorSheet from '../components/reader/NavigatorSheet.jsx'; import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
import SettingsSheet from '../components/reader/SettingsSheet.jsx'; import SettingsSheet from '../components/reader/SettingsSheet.jsx';
import CompareSheet from '../components/reader/CompareSheet.jsx';
import SearchSheet from '../components/reader/SearchSheet.jsx'; import SearchSheet from '../components/reader/SearchSheet.jsx';
import MarksSheet from '../components/reader/MarksSheet.jsx'; import MarksSheet from '../components/reader/MarksSheet.jsx';
import { useToast } from '../components/Toasts.jsx'; import { useToast } from '../components/Toasts.jsx';
@@ -47,6 +48,7 @@ export default function ReaderPage() {
readerSpacing, setReaderSpacing, readerSpacing, setReaderSpacing,
readerVerseNumbers, setReaderVerseNumbers, readerVerseNumbers, setReaderVerseNumbers,
readerParallel, setReaderParallel, readerParallel, setReaderParallel,
readerShowDiff, setReaderShowDiff,
readerBookmarks, readerBookmarks,
readerCrossRefs, setReaderCrossRefs, readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading, readerCrossRefsLoading,
@@ -271,6 +273,7 @@ export default function ReaderPage() {
if (e.key === '/') { e.preventDefault(); openSheet('search'); } if (e.key === '/') { e.preventDefault(); openSheet('search'); }
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); } else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); }
else if (e.key === ',') { e.preventDefault(); openSheet('settings'); } else if (e.key === ',') { e.preventDefault(); openSheet('settings'); }
else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); openSheet('compare'); }
else if (e.key === 'f' || e.key === 'F') setChromeVisible((v) => !v); else if (e.key === 'f' || e.key === 'F') setChromeVisible((v) => !v);
else if ((e.key === 'b' || e.key === 'B') && selectedKey) { else if ((e.key === 'b' || e.key === 'B') && selectedKey) {
readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey); readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey);
@@ -302,8 +305,9 @@ export default function ReaderPage() {
// Render verse text with colored highlights and optional search match highlight // Render verse text with colored highlights and optional search match highlight
const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : ''; const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
function renderVerseText(verseNum, text, searchLow) { function renderVerseText(verseNum, text, searchLow, diffRanges) {
const spans = []; const spans = [];
for (const r of diffRanges ?? []) spans.push({ start: r.start, end: r.end, type: 'diff' });
for (const h of (readerTextHighlights || [])) { for (const h of (readerTextHighlights || [])) {
if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) { if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) {
spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color }); spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color });
@@ -318,6 +322,11 @@ export default function ReaderPage() {
} }
} }
if (!spans.length) return text; if (!spans.length) return text;
// Your highlights and search matches always show; a difference mark that would overlap one is dropped
const firm = spans.filter((x) => x.type !== 'diff');
for (let i = spans.length - 1; i >= 0; i -= 1) {
if (spans[i].type === 'diff' && firm.some((f) => spans[i].start < f.end && f.start < spans[i].end)) spans.splice(i, 1);
}
spans.sort((a, b) => a.start - b.start); spans.sort((a, b) => a.start - b.start);
const parts = []; const parts = [];
let cursor = 0; let cursor = 0;
@@ -332,6 +341,8 @@ export default function ReaderPage() {
{text.slice(s.start, s.end)} {text.slice(s.start, s.end)}
</mark>, </mark>,
); );
} else if (s.type === 'diff') {
parts.push(<span key={`diff${s.start}`} className="diff-hl">{text.slice(s.start, s.end)}</span>);
} else { } else {
parts.push(<mark key={`srch${s.start}`} className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{text.slice(s.start, s.end)}</mark>); parts.push(<mark key={`srch${s.start}`} className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{text.slice(s.start, s.end)}</mark>);
} }
@@ -396,6 +407,7 @@ export default function ReaderPage() {
onSearch={() => openSheet('search')} onSearch={() => openSheet('search')}
onBookmarks={() => openSheet('bookmarks')} onBookmarks={() => openSheet('bookmarks')}
bookmarkCount={bookmarkEntries.length + highlightEntries.length} bookmarkCount={bookmarkEntries.length + highlightEntries.length}
onCompare={() => openSheet('compare')} compareActive={!!parallelId}
onSettings={() => openSheet('settings')} onSettings={() => openSheet('settings')}
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined} onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
/> />
@@ -429,7 +441,7 @@ export default function ReaderPage() {
title={`${readerBook?.name ?? ''} ${readerChapter}`} title={`${readerBook?.name ?? ''} ${readerChapter}`}
translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation} translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation}
translationId={readerTranslation} translationId={readerTranslation}
font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers} font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers} showDiff={readerShowDiff}
parallel={parallelId ? { translation: parallelId, name: parallelSource?.name ?? parallelId, notice: parallelSource?.notice ?? '', ...parallelChapter } : null} parallel={parallelId ? { translation: parallelId, name: parallelSource?.name ?? parallelId, notice: parallelSource?.notice ?? '', ...parallelChapter } : null}
notice={readerNotice} notice={readerNotice}
verses={readerVerses} verses={readerVerses}
@@ -549,7 +561,11 @@ export default function ReaderPage() {
font={readerFont} onFont={setReaderFont} font={readerFont} onFont={setReaderFont}
spacing={readerSpacing} onSpacing={setReaderSpacing} spacing={readerSpacing} onSpacing={setReaderSpacing}
verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers} verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers}
/>
<CompareSheet
open={sheet === 'compare'} onClose={closeSheet}
translation={readerTranslation} parallel={parallelId} onParallel={setReaderParallel} translation={readerTranslation} parallel={parallelId} onParallel={setReaderParallel}
showDiff={readerShowDiff} onShowDiff={setReaderShowDiff}
/> />
<SearchSheet <SearchSheet
open={sheet === 'search'} onClose={closeSheet} open={sheet === 'search'} onClose={closeSheet}
+11
View File
@@ -47,6 +47,17 @@ test('renders the chapter, chrome and opens the tool sheets', () => {
expect(screen.getByPlaceholderText(/Search John 3/)).toBeTruthy(); expect(screen.getByPlaceholderText(/Search John 3/)).toBeTruthy();
}); });
test('the compare button next to Aa opens the comparison sheet, and choosing a Bible turns it on', () => {
ctx.setReaderParallel = vi.fn();
render(<ReaderPage />);
const compare = screen.getByLabelText('Compare translations');
expect(compare.nextElementSibling).toBe(screen.getByLabelText('Reading settings')); // sits right beside Aa
fireEvent.click(compare);
expect(screen.getByText(/how different its wording is/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'NET' }));
expect(ctx.setReaderParallel).toHaveBeenCalledWith('NET');
});
test('tapping a verse selects it', () => { test('tapping a verse selects it', () => {
render(<ReaderPage />); render(<ReaderPage />);
fireEvent.click(screen.getByText(/This man came to Jesus/)); fireEvent.click(screen.getByText(/This man came to Jesus/));
+15
View File
@@ -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}`);
+90
View File
@@ -0,0 +1,90 @@
// How two wordings of the same verse differ: which words are not shared, and how alike they are overall.
// Comparison ignores case and punctuation ("light,”" matches "Light"), and works on whole words.
// Words with their position in the original text: [{ word: 'light', start, end }]
export function tokenize(text) {
const out = [];
const re = /[\p{L}\p{N}][\p{L}\p{N}’'-]*/gu;
let m;
while ((m = re.exec(text ?? '')) !== null) {
out.push({ word: m[0].toLowerCase().replace(/[’']s?$/u, '').replace(/[’'-]/gu, ''), start: m.index, end: m.index + m[0].length });
}
return out;
}
// Longest common subsequence of two word lists -> the indexes kept in each (classic DP; verses are short).
function commonWords(a, b) {
const n = a.length;
const m = b.length;
const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
for (let i = n - 1; i >= 0; i -= 1) {
for (let j = m - 1; j >= 0; j -= 1) {
dp[i][j] = a[i].word === b[j].word ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const inA = new Set();
const inB = new Set();
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i].word === b[j].word) { inA.add(i); inB.add(j); i += 1; j += 1; }
else if (dp[i + 1][j] >= dp[i][j + 1]) i += 1;
else j += 1;
}
return { inA, inB };
}
// Joins neighbouring changed words into one range, so a changed phrase is one highlight, not many.
function ranges(tokens, kept, text) {
const out = [];
tokens.forEach((t, i) => {
if (kept.has(i)) return;
const last = out[out.length - 1];
if (last && /^\s*$/.test(text.slice(last.end, t.start))) last.end = t.end;
else out.push({ start: t.start, end: t.end });
});
return out;
}
// -> { a: [{ start, end }], b: [{ start, end }], similarity: 0..1, same: bool }
// `a` and `b` are the character ranges (in each text) of words the other does not have.
export function diffVerses(textA, textB) {
const a = tokenize(textA);
const b = tokenize(textB);
if (a.length === 0 && b.length === 0) return { a: [], b: [], similarity: 1, same: true };
const { inA, inB } = commonWords(a, b);
const similarity = (2 * inA.size) / (a.length + b.length);
return { a: ranges(a, inA, textA), b: ranges(b, inB, textB), similarity, same: inA.size === a.length && inB.size === b.length };
}
// A whole chapter: pairs of [mainVerse, otherVerse] by verse number ->
// { similarity, differing, total, verses: [{ number, similarity, ...ranges }] sorted most different first }
export function diffChapter(mainVerses, otherVerses) {
const other = new Map(otherVerses.map((v) => [v.number, v]));
let shared = 0;
let words = 0;
const verses = [];
for (const v of mainVerses) {
const o = other.get(v.number);
if (!o) continue;
const d = diffVerses(v.text, o.text);
const na = tokenize(v.text).length;
const nb = tokenize(o.text).length;
shared += (d.similarity * (na + nb)) / 2;
words += (na + nb) / 2;
verses.push({ number: v.number, ...d });
}
return {
similarity: words === 0 ? 1 : shared / words,
total: verses.length,
differing: verses.filter((v) => !v.same).length,
verses,
mostDifferent: [...verses].filter((v) => !v.same).sort((x, y) => x.similarity - y.similarity).slice(0, 3),
};
}
// "12% different" for a similarity of 0.88; "identical words" at 1.
export const differenceLabel = (similarity) => {
const pct = Math.round((1 - similarity) * 100);
return pct === 0 && similarity < 1 ? '<1% different' : pct === 0 ? 'Same words' : `${pct}% different`;
};
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from 'vitest';
import { diffChapter, diffVerses, differenceLabel, tokenize } from './textDiff.js';
const slice = (text, ranges) => ranges.map((r) => text.slice(r.start, r.end));
describe('tokenize', () => {
test('words only, lowercased, with their place in the text', () => {
const t = tokenize('God said, “Let there be light,”');
expect(t.map((x) => x.word)).toEqual(['god', 'said', 'let', 'there', 'be', 'light']);
expect(t[0]).toMatchObject({ start: 0, end: 3 });
});
test('possessives and apostrophes do not split or change words', () => {
expect(tokenize('God’s law').map((x) => x.word)).toEqual(['god', 'law']);
expect(tokenize("God's law").map((x) => x.word)).toEqual(['god', 'law']);
});
});
describe('diffVerses', () => {
test('identical wording, whatever the punctuation or case, is the same', () => {
const d = diffVerses('In the beginning God created the heavens and the earth.', 'in the beginning, God created the heavens and the earth');
expect(d).toMatchObject({ same: true, similarity: 1, a: [], b: [] });
});
test('marks the words each side has that the other does not', () => {
const a = 'The earth was formless and void';
const b = 'The earth was without shape and empty';
const d = diffVerses(a, b);
expect(slice(a, d.a)).toEqual(['formless', 'void']);
expect(slice(b, d.b)).toEqual(['without shape', 'empty']); // neighbouring words become one range
expect(d.same).toBe(false);
expect(d.similarity).toBeCloseTo((2 * 4) / 13, 5);
});
test('a pure addition shows only on the side that has it', () => {
const d = diffVerses('God saw the light', 'God saw that the light was good');
expect(d.a).toEqual([]);
expect(d.b.length).toBeGreaterThan(0);
});
test('totally different text is not alike at all; two empty ones are the same', () => {
expect(diffVerses('alpha beta', 'gamma delta').similarity).toBe(0);
expect(diffVerses('', '').same).toBe(true);
});
});
describe('diffChapter', () => {
const main = [{ number: 1, text: 'In the beginning God created.' }, { number: 2, text: 'The earth was formless.' }, { number: 3, text: 'Let there be light.' }];
const other = [{ number: 1, text: 'In the beginning God created.' }, { number: 2, text: 'The earth was shapeless and void.' }, { number: 4, text: 'Extra verse only here.' }];
test('scores the chapter over verses both have, and lists the most different first', () => {
const c = diffChapter(main, other);
expect(c.total).toBe(2); // verse 3 and 4 are not in both
expect(c.differing).toBe(1);
expect(c.mostDifferent.map((v) => v.number)).toEqual([2]);
expect(c.similarity).toBeGreaterThan(0.7);
expect(c.similarity).toBeLessThan(1);
});
test('an identical chapter is fully alike', () => {
expect(diffChapter(main, main)).toMatchObject({ similarity: 1, differing: 0, mostDifferent: [] });
});
});
describe('differenceLabel', () => {
test('says it in percent, and never "0% different" for something that does differ', () => {
expect(differenceLabel(0.88)).toBe('12% different');
expect(differenceLabel(1)).toBe('Same words');
expect(differenceLabel(0.9999)).toBe('<1% different');
});
});