Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7a1601f80 | ||
|
|
2ca79730f1 | ||
|
|
5ab416930b | ||
|
|
aa27a5bb18 | ||
|
|
7101be393e |
No files matched your search
@@ -83,6 +83,10 @@ export function initDb() {
|
||||
if (!projectCols.some((c) => c.name === 'share_token')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
|
||||
}
|
||||
if (!projectCols.some((c) => c.name === 'outline_share_token')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
|
||||
}
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
|
||||
|
||||
// Reader ink — one row per user + book + chapter, stored as JSON
|
||||
@@ -414,6 +418,28 @@ export function clearShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
|
||||
|
||||
export function getOutlineShareToken(id, userId) {
|
||||
const row = db.prepare('SELECT outline_share_token AS t FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
return row?.t ?? null;
|
||||
}
|
||||
|
||||
export function setOutlineShareToken(id, userId, token) {
|
||||
return db.prepare('UPDATE projects SET outline_share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId).changes > 0;
|
||||
}
|
||||
|
||||
export function clearOutlineShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET outline_share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
|
||||
export function getProjectByOutlineToken(token) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE outline_share_token = ?').get(token);
|
||||
if (!row) return null;
|
||||
try { return JSON.parse(row.data); } catch { return null; }
|
||||
}
|
||||
|
||||
/** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */
|
||||
export function getProjectByShareToken(token) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE share_token = ?').get(token);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||
getOutlineShareToken, setOutlineShareToken, clearOutlineShareToken, getProjectByOutlineToken,
|
||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||
setUserPassword, destroyAllSessionsForUser,
|
||||
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
|
||||
@@ -27,6 +28,7 @@ import { registerBibleRoutes } from './bibleApi.js';
|
||||
import { cleanMarks } from './readerMarks.js';
|
||||
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
|
||||
import { isSaveConflict, parseBase } from './projectSync.js';
|
||||
import { buildOutlinePayload } from './outlineShare.js';
|
||||
|
||||
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
|
||||
function userPayload(user) {
|
||||
@@ -599,6 +601,50 @@ app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Read-only outline links. A separate token from the study share link, and the public endpoint returns
|
||||
// only the outlines (see outlineShare.js), never the notes or scripts.
|
||||
app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json({ shareToken: getOutlineShareToken(req.params.id, req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const token = getOutlineShareToken(req.params.id, req.session.userId) || randomUUID().replace(/-/g, '');
|
||||
if (!setOutlineShareToken(req.params.id, req.session.userId, token)) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: token });
|
||||
} catch (err) {
|
||||
console.error('POST /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
clearOutlineShareToken(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/share-outline/:token — PUBLIC, no login required
|
||||
app.get('/api/share-outline/:token', (req, res) => {
|
||||
try {
|
||||
const project = getProjectByOutlineToken(req.params.token);
|
||||
if (!project) return res.status(404).json({ error: 'This outline link is invalid or has been turned off.' });
|
||||
res.json(buildOutlinePayload(project));
|
||||
} catch (err) {
|
||||
console.error('GET /api/share-outline/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load the shared outline.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
|
||||
app.get('/api/share/:token', (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -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.
|
||||
function stubServer({ user = null, putStatus = 200 } = {}) {
|
||||
const calls = [];
|
||||
const state = { putStatus };
|
||||
const state = { putStatus, outlineToken: null };
|
||||
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
|
||||
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
|
||||
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/projects') 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/')) {
|
||||
return state.putStatus === 409
|
||||
? 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);
|
||||
});
|
||||
|
||||
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();
|
||||
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
|
||||
await screen.findByRole('list', { name: 'Outline' });
|
||||
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();
|
||||
});
|
||||
|
||||
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() };
|
||||
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
|
||||
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', () => {
|
||||
test('a long run of chapters and many tags stay short', async () => {
|
||||
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 CommandPalette from './components/CommandPalette.jsx';
|
||||
import ShortcutsHelp from './components/ShortcutsHelp.jsx';
|
||||
import SharedOutlineView from './components/SharedOutlineView.jsx';
|
||||
import { useAudio } from './hooks/useAudio.js';
|
||||
import { useAuth } from './hooks/useAuth.js';
|
||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||
@@ -149,7 +150,7 @@ const AppInner = () => {
|
||||
page: currentPage,
|
||||
setPage: setCurrentPage,
|
||||
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()); },
|
||||
});
|
||||
|
||||
@@ -461,6 +462,10 @@ function PageLoading() {
|
||||
function AppRouter() {
|
||||
const { currentPage, authUser, authServerDown } = useApp();
|
||||
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 (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>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const BY_PAGE = {
|
||||
['/', 'Search this chapter or the whole Bible'],
|
||||
['G', 'Pick a book and chapter'],
|
||||
['B', 'Bookmark the selected verse'],
|
||||
['C', 'Compare translations'],
|
||||
[',', 'Reading settings'],
|
||||
['F', 'Hide or show the controls'],
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// Top and bottom chrome for the reader. Everything stays one tap away; focus
|
||||
// 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';
|
||||
|
||||
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 (
|
||||
<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">
|
||||
@@ -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">
|
||||
<BookmarkIcon filled={bookmarkCount > 0} />
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// 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.
|
||||
import { useMemo } from 'react';
|
||||
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.
|
||||
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.
|
||||
export default function ReaderText({
|
||||
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,
|
||||
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 sel = window.getSelection();
|
||||
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 && (
|
||||
<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">
|
||||
<span>{translationId}</span><span>{parallel.translation}</span>
|
||||
</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">
|
||||
<p>
|
||||
{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>}
|
||||
</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)]">
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
// to read beside the first.
|
||||
import Sheet from './Sheet.jsx';
|
||||
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]];
|
||||
|
||||
@@ -22,7 +21,6 @@ function Segmented({ options, value, onChange, label }) {
|
||||
export default function SettingsSheet({
|
||||
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
|
||||
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
|
||||
translation, parallel, onParallel,
|
||||
}) {
|
||||
const row = 'mb-4';
|
||||
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>
|
||||
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
|
||||
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
|
||||
import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.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']];
|
||||
|
||||
@@ -68,20 +70,29 @@ function CompareTab({ book, chapter, verse, current }) {
|
||||
return () => controller.abort();
|
||||
}, [book, chapter, verse]);
|
||||
|
||||
const mine = rows[current]?.text;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{READER_TRANSLATIONS.map((t) => {
|
||||
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 (
|
||||
<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.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>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 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 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>;
|
||||
@@ -103,6 +103,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS);
|
||||
const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS);
|
||||
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)]);
|
||||
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
|
||||
const [startPagePref, setStartPagePrefState] = useState(() => {
|
||||
@@ -532,6 +533,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
readerSpacing, setReaderSpacing,
|
||||
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
|
||||
readerParallel, setReaderParallel,
|
||||
readerShowDiff: readerShowDiff === 'on', setReaderShowDiff: (on) => setReaderShowDiffRaw(on ? 'on' : 'off'),
|
||||
planTranslationPref, setPlanTranslationPref,
|
||||
startPagePref, setStartPagePref,
|
||||
readerBookmarks, setReaderBookmarks,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -82,3 +82,10 @@ select {
|
||||
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;
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||
import { buildChapterSummary } from '../utils/projectStorage.js';
|
||||
import { compactPassages } from '../utils/passageSummary.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 {
|
||||
KINDS, STUDY, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
|
||||
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
|
||||
@@ -44,6 +45,7 @@ export default function OutlinePage() {
|
||||
const rows = flatten(tree);
|
||||
const [pendingFocus, setPendingFocus] = useState(null);
|
||||
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: '' });
|
||||
|
||||
// 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 () => {
|
||||
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 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})` : ''}
|
||||
</button>
|
||||
<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>
|
||||
<details className="relative">
|
||||
<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>
|
||||
)}
|
||||
</main>
|
||||
<ShareOutlineSheet open={shareOpen} onClose={() => setShareOpen(false)} projectId={project.id} outline={outline} />
|
||||
</StudioShell>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import ReaderText from '../components/reader/ReaderText.jsx';
|
||||
import PlanStrip from '../components/reader/PlanStrip.jsx';
|
||||
import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
|
||||
import SettingsSheet from '../components/reader/SettingsSheet.jsx';
|
||||
import CompareSheet from '../components/reader/CompareSheet.jsx';
|
||||
import SearchSheet from '../components/reader/SearchSheet.jsx';
|
||||
import MarksSheet from '../components/reader/MarksSheet.jsx';
|
||||
import { useToast } from '../components/Toasts.jsx';
|
||||
@@ -47,6 +48,7 @@ export default function ReaderPage() {
|
||||
readerSpacing, setReaderSpacing,
|
||||
readerVerseNumbers, setReaderVerseNumbers,
|
||||
readerParallel, setReaderParallel,
|
||||
readerShowDiff, setReaderShowDiff,
|
||||
readerBookmarks,
|
||||
readerCrossRefs, setReaderCrossRefs,
|
||||
readerCrossRefsLoading,
|
||||
@@ -271,6 +273,7 @@ export default function ReaderPage() {
|
||||
if (e.key === '/') { e.preventDefault(); openSheet('search'); }
|
||||
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); }
|
||||
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 === 'b' || e.key === 'B') && 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
|
||||
const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
|
||||
function renderVerseText(verseNum, text, searchLow) {
|
||||
function renderVerseText(verseNum, text, searchLow, diffRanges) {
|
||||
const spans = [];
|
||||
for (const r of diffRanges ?? []) spans.push({ start: r.start, end: r.end, type: 'diff' });
|
||||
for (const h of (readerTextHighlights || [])) {
|
||||
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 });
|
||||
@@ -318,6 +322,11 @@ export default function ReaderPage() {
|
||||
}
|
||||
}
|
||||
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);
|
||||
const parts = [];
|
||||
let cursor = 0;
|
||||
@@ -332,6 +341,8 @@ export default function ReaderPage() {
|
||||
{text.slice(s.start, s.end)}
|
||||
</mark>,
|
||||
);
|
||||
} else if (s.type === 'diff') {
|
||||
parts.push(<span key={`diff${s.start}`} className="diff-hl">{text.slice(s.start, s.end)}</span>);
|
||||
} 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>);
|
||||
}
|
||||
@@ -396,6 +407,7 @@ export default function ReaderPage() {
|
||||
onSearch={() => openSheet('search')}
|
||||
onBookmarks={() => openSheet('bookmarks')}
|
||||
bookmarkCount={bookmarkEntries.length + highlightEntries.length}
|
||||
onCompare={() => openSheet('compare')} compareActive={!!parallelId}
|
||||
onSettings={() => openSheet('settings')}
|
||||
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
|
||||
/>
|
||||
@@ -429,7 +441,7 @@ export default function ReaderPage() {
|
||||
title={`${readerBook?.name ?? ''} ${readerChapter}`}
|
||||
translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? 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}
|
||||
notice={readerNotice}
|
||||
verses={readerVerses}
|
||||
@@ -549,7 +561,11 @@ export default function ReaderPage() {
|
||||
font={readerFont} onFont={setReaderFont}
|
||||
spacing={readerSpacing} onSpacing={setReaderSpacing}
|
||||
verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers}
|
||||
/>
|
||||
<CompareSheet
|
||||
open={sheet === 'compare'} onClose={closeSheet}
|
||||
translation={readerTranslation} parallel={parallelId} onParallel={setReaderParallel}
|
||||
showDiff={readerShowDiff} onShowDiff={setReaderShowDiff}
|
||||
/>
|
||||
<SearchSheet
|
||||
open={sheet === 'search'} onClose={closeSheet}
|
||||
|
||||
@@ -47,6 +47,17 @@ test('renders the chapter, chrome and opens the tool sheets', () => {
|
||||
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', () => {
|
||||
render(<ReaderPage />);
|
||||
fireEvent.click(screen.getByText(/This man came to Jesus/));
|
||||
|
||||
@@ -103,6 +103,21 @@ export async function disableSharing(id) {
|
||||
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. */
|
||||
export async function getSharedProject(token) {
|
||||
return request('GET', `/share/${token}`);
|
||||
|
||||
@@ -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`;
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user