Compare commits

..
Author SHA1 Message Date
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
nmemmertandClaude Sonnet 5.5 820886e3ab Outline: per-chunk outlines, richer points, print and share
- One outline for the whole study plus one per chunk (picker at the top);
  chunk outlines can start from the chunk's verses, each carrying its words
- Points can have a type (illustration, application, quote, question,
  transition), minutes (with a running total), a longer note, verse text
  fetched from a passage, and cross-references / word studies attached from
  the chunk
- Share (system share sheet, or copy), Print / PDF, Copy as text, Word and
  Markdown downloads for any one outline
- All of it included in the Markdown, HTML/PDF and Word study exports,
  with each chunk's outline under that chunk

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:28 -04:00
nmemmertandClaude Sonnet 5.5 d273c8cb3e Merge ui-features-flow: Outline stage, parallel reading, offline, backup, sync conflicts
Build and Push Docker Image / build-and-push (push) Failing after 21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:09:07 -04:00
nmemmertandClaude Sonnet 5.5 b4f038b185 Show the current page in the browser tab title
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:09:06 -04:00
nmemmertandClaude Sonnet 5.5 d13f55faf7 Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:08:08 -04:00
77 changed files with 3939 additions and 212 deletions

No files matched your search

+18 -6
View File
@@ -1,4 +1,4 @@
const SHELL = 'bible-shell-v1';
const SHELL = 'bible-shell-v2';
const API = 'bible-api-v1';
// Chapter text, interlinear cross-refs, and (BSB) chapter audio all come from
// these origins — cache them so the reader, reading plans, and audio all keep
@@ -27,8 +27,10 @@ self.addEventListener('fetch', (e) => {
const { request } = e;
const url = request.url;
// Never intercept API routes — let Express handle them
if (new URL(url).pathname.startsWith('/api/')) return;
// Never intercept this app's own API routes — let Express handle them. (Only same-origin ones: the
// Bible text at bible.helloao.org/api/... must pass through to the offline cache below.)
const { origin, pathname } = new URL(url);
if (origin === self.location.origin && pathname.startsWith('/api/')) return;
// External Bible API (text, cross-refs, audio): network-first, fall back to cache.
// Audio elements often issue Range requests, which come back as 206 Partial
@@ -37,7 +39,11 @@ self.addEventListener('fetch', (e) => {
e.respondWith(
fetch(request)
.then(res => {
if (res.status === 200) caches.open(API).then(c => c.put(request, res.clone()));
// Clone *now*: once the page starts reading the body a later clone() throws and nothing is saved.
if (res.status === 200) {
const copy = res.clone();
e.waitUntil(caches.open(API).then(c => c.put(request, copy)).catch(() => {}));
}
return res;
})
.catch(() =>
@@ -56,7 +62,10 @@ self.addEventListener('fetch', (e) => {
e.respondWith(
fetch(request)
.then(res => {
if (res.ok) caches.open(SHELL).then(c => c.put(request, res.clone()));
if (res.ok) {
const copy = res.clone();
e.waitUntil(caches.open(SHELL).then(c => c.put(request, copy)).catch(() => {}));
}
return res;
})
.catch(() =>
@@ -75,7 +84,10 @@ self.addEventListener('fetch', (e) => {
c.match(request).then(hit => {
if (hit) return hit;
return fetch(request).then(res => {
if (res.ok) c.put(request, res.clone());
if (res.ok) {
const copy = res.clone();
e.waitUntil(c.put(request, copy).catch(() => {}));
}
return res;
});
})
+6
View File
@@ -353,6 +353,12 @@ export function getProject(id, userId) {
}
}
/** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */
export function getProjectEdited(id, userId) {
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
return row ? row.lastEdited : null;
}
/**
* Insert or replace a project owned by userId.
* Returns the summary, or null if the id already belongs to a different user.
+7 -1
View File
@@ -6,7 +6,7 @@ import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import {
initDb, getAllProjects, getProject, upsertProject, deleteProject,
initDb, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
@@ -26,6 +26,7 @@ import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './rem
import { registerBibleRoutes } from './bibleApi.js';
import { cleanMarks } from './readerMarks.js';
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
import { isSaveConflict, parseBase } from './projectSync.js';
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
function userPayload(user) {
@@ -398,6 +399,11 @@ app.put('/api/projects/:id', requireAuth, (req, res) => {
}
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
// Refuse to overwrite a copy another device saved after the one this device last saw
const stored = getProjectEdited(body.id, req.session.userId);
if (isSaveConflict({ stored, base: parseBase(req.get('X-Base-Edited')), force: req.query.force === '1' })) {
return res.status(409).json({ error: 'This study was changed on another device.', code: 'conflict', serverEdited: stored });
}
const saved = upsertProject(body, req.session.userId);
if (!saved) {
return res.status(403).json({ error: 'That project belongs to a different account.' });
+10
View File
@@ -10,10 +10,20 @@ const ENUMS = {
commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'],
};
const DAY_MS = 86_400_000;
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
// A cleaned value, or undefined when it isn't acceptable for that preference.
export function cleanPrefValue(key, value) {
if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined;
if (key === 'colorLabels') {
// What each highlight colour means to the reader: { "#fef08a": "Promises", ... }
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const out = {};
for (const [color, label] of Object.entries(value).slice(0, 12)) {
if (COLOR.test(color) && typeof label === 'string' && label.trim()) out[color] = label.trim().slice(0, 30);
}
return out;
}
if (key === 'position') {
if (!value || typeof value !== 'object') return undefined;
const book = String(value.book ?? '').toUpperCase();
+18
View File
@@ -0,0 +1,18 @@
// Stops one device from silently overwriting a study another device has saved since. The browser sends
// the `lastEdited` of the server copy it last saw (X-Base-Edited); if the server's copy has moved past
// that, the save is refused with 409 and the person chooses whose version to keep.
// stored: the server copy's lastEdited (null when there is none); base: what the client last saw
// (null/undefined when it never synced this study); force: the person chose "keep mine".
export function isSaveConflict({ stored, base, force = false }) {
if (force || stored == null) return false;
if (!Number.isFinite(base)) return false; // a device that never saw the server's copy has nothing to compare
return stored > base;
}
// Reads the base header; anything that isn't a plain number is treated as "unknown".
export function parseBase(header) {
if (header == null || header === '') return null;
const n = Number(header);
return Number.isFinite(n) ? n : null;
}
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from 'vitest';
import { isSaveConflict, parseBase } from './projectSync.js';
test('a save is fine when the server copy is no newer than what the device last saw', () => {
expect(isSaveConflict({ stored: 100, base: 100 })).toBe(false);
expect(isSaveConflict({ stored: 90, base: 100 })).toBe(false);
});
test('a save conflicts when another device saved after the one this device last saw', () => {
expect(isSaveConflict({ stored: 200, base: 100 })).toBe(true);
});
test('first saves, unknown bases and "keep mine" never conflict', () => {
expect(isSaveConflict({ stored: null, base: 100 })).toBe(false);
expect(isSaveConflict({ stored: 200, base: null })).toBe(false);
expect(isSaveConflict({ stored: 200, base: 100, force: true })).toBe(false);
});
test('reads the base header', () => {
expect(parseBase('1700000000000')).toBe(1700000000000);
expect(parseBase('')).toBeNull();
expect(parseBase(undefined)).toBeNull();
expect(parseBase('abc')).toBeNull();
});
+5 -2
View File
@@ -30,10 +30,13 @@ export function cleanMarks(body) {
if (!int(h.startOffset, 0, 5000) || !int(h.endOffset, 1, 5000) || h.endOffset <= h.startOffset) continue;
if (typeof h.color !== 'string' || !COLOR.test(h.color)) continue;
seen.add(h.id);
highlights.push({
const entry = {
id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse,
startOffset: h.startOffset, endOffset: h.endOffset, color: h.color,
});
};
// The highlighted words, kept so lists of highlights can show them without loading the chapter
if (typeof h.text === 'string' && h.text.length > 0) entry.text = h.text.slice(0, 500);
highlights.push(entry);
}
}
return { bookmarks, highlights };
+320
View File
@@ -0,0 +1,320 @@
// How the app moves and recovers: the Back button, the ⌘K palette, Undo, sync conflicts, backups.
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
import App from './App';
import { resetLexiconCaches } from './utils/wordStudy.js';
import { switchStorageUser } from './utils/bibleUtils.jsx';
import { resetSyncBases } from './utils/syncBase.js';
const chapterData = {
chapter: { content: [
{ type: 'verse', number: 1, content: ['In the beginning God created.'] },
{ type: 'verse', number: 2, content: ['The earth was without form.'] },
] },
};
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
// 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 };
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
calls.push({ route, url, opts });
if (url.includes('bible.helloao.org')) return json(200, chapterData);
if (!url.startsWith('/api/')) return Promise.resolve({ ok: false });
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.startsWith('PUT /api/projects/')) {
return state.putStatus === 409
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
: json(200, { id: url.split('/').pop().split('?')[0], title: 'x', lastEdited: 12345, chapterSummary: '' });
}
return json(404, { error: 'not mocked' });
}));
return { calls, state };
}
beforeEach(() => {
localStorage.clear();
resetLexiconCaches();
resetSyncBases();
switchStorageUser(null);
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false })));
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
});
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
async function renderApp() {
render(<App />);
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
}
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
const project = {
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
chapters: [{
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '', crossReferences: [], greekWords: [], tags: [] }],
}],
};
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
localStorage.setItem('bible-study-index', JSON.stringify(index));
}
describe('Back button', () => {
test('moves back through the pages you visited instead of leaving the app', async () => {
const user = userEvent.setup();
await renderApp();
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /reading plans/i }));
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
window.history.back();
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
window.history.forward();
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
});
});
const paletteBox = async () => within(await screen.findByRole('dialog', { name: /search and go to/i })).getByRole('combobox');
describe('Search palette (⌘K)', () => {
test('a typed passage goes straight to the Bible reader', async () => {
const user = userEvent.setup();
await renderApp();
await user.keyboard('{Control>}k{/Control}');
await user.type(await paletteBox(), 'john 3:16');
expect(await screen.findByRole('option', { name: /John 3:16/ })).toBeInTheDocument();
await user.keyboard('{Enter}');
expect(await screen.findByRole('heading', { name: /John 3/ })).toBeInTheDocument();
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
});
test('finds a note you wrote and opens that study', async () => {
seedProject({ observation: 'Grace abounds to all.' });
const user = userEvent.setup();
await renderApp();
await user.keyboard('{Control>}k{/Control}');
await user.type(await paletteBox(), 'abounds');
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
await user.click(hit);
expect(await screen.findByText(/Your notes/i)).toBeInTheDocument();
});
test('Escape closes it', async () => {
const user = userEvent.setup();
await renderApp();
await user.keyboard('{Control>}k{/Control}');
await paletteBox();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
});
test('? lists the keyboard shortcuts', async () => {
const user = userEvent.setup();
await renderApp();
await user.keyboard('?');
expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument();
});
});
describe('Undo', () => {
test('deleting a project, then Undo, keeps its notes', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /^delete$/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/deleted/i);
await user.click(screen.getByRole('button', { name: /undo/i }));
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).chapters[0].chunks[0].observation).toBe('Grace abounds to all.');
});
});
describe('Sync conflict', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
async function startStudy(u) {
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);
}
test('a save the server refuses shows the choice instead of overwriting', async () => {
const server = stubServer({ user, putStatus: 409 });
const u = userEvent.setup();
await startStudy(u);
const banner = await screen.findByRole('alert', {}, { timeout: 4000 });
expect(banner).toHaveTextContent(/changed on another device/i);
expect(within(banner).getByRole('button', { name: /keep this version/i })).toBeInTheDocument();
expect(within(banner).getByRole('button', { name: /use the other version/i })).toBeInTheDocument();
// "Keep this version" sends the save again, this time as an overwrite
server.state.putStatus = 200;
await u.click(within(banner).getByRole('button', { name: /keep this version/i }));
await waitFor(() => expect(server.calls.some((c) => c.url.includes('?force=1'))).toBe(true));
await waitFor(() => expect(screen.queryByText(/changed on another device/i)).not.toBeInTheDocument());
}, 15000);
test('opening a study without editing it does not touch the server', async () => {
seedProject();
const server = stubServer({ user });
const u = userEvent.setup();
await renderApp();
// signed in: the namespaced storage starts empty, so seed under the user's keys by resuming nothing
expect(server.calls.filter((c) => c.route.startsWith('PUT')).length).toBe(0);
await u.click(await screen.findByRole('button', { name: /read bible/i }));
expect(server.calls.filter((c) => c.route.startsWith('PUT /api/projects')).length).toBe(0);
});
});
describe('Outline stage', () => {
async function openOutline() {
// local-only mode (the account server is unreachable), with chapter text available
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
const user = userEvent.setup();
await renderApp();
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
// one chunk covering verses 1-2
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 user.click(screen.getByRole('button', { name: /^outline$/i }));
await screen.findByRole('heading', { name: 'Outline' });
return user;
}
test('starts empty, and "Start from my chunks" makes a main point for each chunk', async () => {
const user = await openOutline();
expect(screen.getByText(/no outline yet/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
const list = await screen.findByRole('list', { name: 'Outline' });
expect(within(list).getAllByRole('textbox')).toHaveLength(1);
expect(within(list).getByText(/Open chunk in Study/i)).toBeInTheDocument();
});
test('Enter adds a point, Tab makes it a sub-point, and they are numbered I. and A.', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
const first = await screen.findByRole('textbox', { name: 'Point I.' });
await user.type(first, 'Creation{Enter}');
const second = await screen.findByRole('textbox', { name: 'Point II.' });
await user.type(second, 'Light{Tab}');
expect(await screen.findByRole('textbox', { name: 'Point A.' })).toHaveValue('Light');
expect(screen.getByRole('textbox', { name: 'Point I.' })).toHaveValue('Creation');
});
test('deleting a point can be undone', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'Keep me');
await user.click(screen.getByRole('button', { name: /delete i\./i }));
expect(screen.queryByRole('textbox', { name: 'Point I.' })).not.toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: /undo/i }));
expect(await screen.findByRole('textbox', { name: 'Point I.' })).toHaveValue('Keep me');
});
});
describe('Outline: per chunk, details, print and share', () => {
async function openOutline() {
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
const user = userEvent.setup();
await renderApp();
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await user.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 user.click(screen.getByRole('button', { name: /^outline$/i }));
await screen.findByRole('heading', { name: 'Outline' });
return user;
}
test('each chunk has its own outline, separate from the whole study\'s', async () => {
const user = await openOutline();
const picker = screen.getByRole('combobox', { name: 'Outline for' });
await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk
expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /start from the verses/i }));
const list = await screen.findByRole('list', { name: 'Outline' });
expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse
expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words
await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study');
expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched
expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count
});
test('a point can be given a type and minutes, and the total shows', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'The story');
await user.click(screen.getByRole('button', { name: /details for i\./i }));
await user.selectOptions(screen.getByLabelText('Type'), 'illustration');
await user.type(screen.getByLabelText('Minutes'), '5');
expect(await screen.findByText('About 5 minutes')).toBeInTheDocument();
await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly');
await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line
expect(await screen.findByText('Tell it slowly')).toBeInTheDocument();
expect(screen.getByText('Illustration')).toBeInTheDocument();
});
test('"Add verse text" puts the passage\'s words into the point', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.click(await screen.findByRole('button', { name: /details for i\./i }));
await user.type(screen.getByLabelText('Passage'), 'Titus 1:1');
await user.click(screen.getByRole('button', { name: /add verse text/i }));
expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument();
await user.clear(screen.getByLabelText('Passage'));
await user.type(screen.getByLabelText('Passage'), 'nonsense');
await user.click(screen.getByRole('button', { name: /replace verse text/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 () => {
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 }));
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
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 }));
expect(doc.write.mock.calls[0][0]).toContain('class="outline"');
});
});
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(', ');
const tags = ['A', 'B', 'C', 'D', 'E', 'F'];
localStorage.setItem('bible-study-index', JSON.stringify([{ id: 'p', title: 'Luke', lastEdited: Date.now(), chapterSummary: chapters, tags }]));
await renderApp();
expect(await screen.findByText('Luke 1\u201324')).toBeInTheDocument();
expect(screen.getByText('+3 more')).toBeInTheDocument();
expect(screen.queryByText('F', { selector: 'span' })).not.toBeInTheDocument(); // only in the Tag filter
});
});
describe('Window title', () => {
test('names the page you are on, including the chapter in the reader', async () => {
const user = userEvent.setup();
await renderApp();
expect(document.title).toBe('My Studies · Bible Study');
await user.click(screen.getByRole('button', { name: /reading plans/i }));
await waitFor(() => expect(document.title).toBe('Reading Plans · Bible Study'));
await user.click(screen.getByRole('button', { name: /read bible/i }));
await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/));
});
});
+151 -27
View File
@@ -2,18 +2,24 @@ import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { getPlanSchedule, nearestReadingDay } from './utils/readingPlans.js';
import { applySystemChrome } from './utils/systemChrome.js';
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 { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useHistoryNav } from './hooks/useHistoryNav.js';
import { useReadingPlans } from './hooks/useReadingPlans.js';
import { useSharedView } from './hooks/useSharedView.js';
import HomePage from './pages/HomePage.jsx';
import { saveRemoteProject } from './syncService.js';
import { themeVars } from './components/reader/readerThemes.js';
import { loadRemoteProject, saveRemoteProject } from './syncService.js';
import { getSyncBase, setSyncBase } from './utils/syncBase.js';
import {
bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser,
} from './utils/bibleUtils.jsx';
import { migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
import { loadProjectById, migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
// Less-common pages are split into their own chunks so the first load only
// downloads what's needed to show the home screen.
@@ -24,6 +30,7 @@ const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx'));
const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
const ExportPage = lazy(() => import('./pages/ExportPage.jsx'));
const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
@@ -42,10 +49,13 @@ function savedStartPage() {
try { return START_PAGES[localStorage.getItem('start-page')] ?? 'home'; } catch { return 'home'; }
}
const App = () => {
const AppInner = () => {
const { toast, setTheme: setToastTheme } = useToast();
const [project, setProject] = useState(null);
// 'home' | 'setup' | 'study' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
// 'home' | 'setup' | 'study' | 'outline' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
const [currentPage, setCurrentPage] = useState(savedStartPage);
const currentPageRef = useRef(currentPage);
currentPageRef.current = currentPage;
const [projectIndex, setProjectIndex] = useState([]);
const [saveStatus, setSaveStatus] = useState('');
const saveTimerRef = useRef(null);
@@ -67,7 +77,7 @@ const App = () => {
if (scroller) scroller.scrollTop = 0;
}, [currentPage]);
// Keep the browser/OS chrome (status bar area, overscroll, scrollbars) in the chosen theme
useEffect(() => { applySystemChrome(reader.readerTheme); }, [reader.readerTheme]);
useEffect(() => { applySystemChrome(reader.readerTheme); setToastTheme(reader.readerTheme); }, [reader.readerTheme, setToastTheme]);
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
onSignIn: (user) => {
setProjectIndex(switchStorageUser(user.id));
@@ -100,6 +110,49 @@ const App = () => {
setCurrentPage,
});
// ⌘K search/go-to, the "?" shortcut list, and requests other pages make of the Reader.
const [paletteOpen, setPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [readerRequest, setReaderRequest] = useState(null); // { search: 'text' } from the palette
const [settingsFocus, setSettingsFocus] = useState(null); // section Settings should scroll to
useEffect(() => {
const onKey = (e) => {
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 'k') { e.preventDefault(); setHelpOpen(false); setPaletteOpen((o) => !o); return; }
if (e.metaKey || e.ctrlKey || e.altKey) return;
const t = e.target;
if (t?.tagName === 'INPUT' || t?.tagName === 'TEXTAREA' || t?.tagName === 'SELECT' || t?.isContentEditable) return;
if (e.key === '?') { e.preventDefault(); setPaletteOpen(false); setHelpOpen((o) => !o); }
else if (e.key === '/' && currentPageRef.current !== 'reader') { e.preventDefault(); setPaletteOpen(true); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
// The browser tab and history show where you are ("Romans 8 · Bible Study", "Luke — Outline · Bible Study")
useEffect(() => {
const names = {
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export',
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans',
};
let page = names[currentPage] ?? '';
if (currentPage === 'reader') {
const book = bookOptions.find((b) => b.abbrev === reader.readerBookAbbrev)?.name ?? '';
page = `${book} ${reader.readerChapter}`.trim();
} else if (project?.title && ['setup', 'study', 'outline', 'script', 'export', 'pronunciation'].includes(currentPage)) {
page = `${project.title} — ${page}`;
}
document.title = page ? `${page} · Bible Study` : 'Bible Study';
}, [currentPage, project?.title, reader.readerBookAbbrev, reader.readerChapter]);
// Back/Forward move between pages instead of leaving the app. (Not for ?share= links: that page is separate.)
useHistoryNav({
page: currentPage,
setPage: setCurrentPage,
hasProject: !!project,
enabled: !new URLSearchParams(window.location.search).has('share'),
onArrive: (page) => { if (page === 'home') setProjectIndex(loadProjectIndex()); },
});
// Startup: migrate old keys and load index
useEffect(() => {
migrateOldStorageKeys();
@@ -109,10 +162,45 @@ const App = () => {
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
// Autosave: 1s after the last edit, save locally, then sync to the server.
// Opening a study doesn't count as editing it — saving then would stamp the server's copy as "newer"
// and make your other devices think it changed.
const savedSignatureRef = useRef({ id: null, signature: '' });
const [syncConflict, setSyncConflict] = useState(null); // { id, serverEdited } when the server has a newer copy
const conflictRef = useRef(null);
conflictRef.current = syncConflict;
const signatureOf = (p) => JSON.stringify({ ...p, lastEdited: 0 });
// Sends a study to the server, checked against the copy this device last saw.
const pushProject = async (toSave, { force = false } = {}) => {
setSyncStatus('syncing');
const result = await saveRemoteProject(toSave, { baseEdited: getSyncBase(toSave.id), force });
if (result.ok) {
setSyncBase(toSave.id, result.data?.lastEdited ?? toSave.lastEdited);
setSyncConflict((c) => (c?.id === toSave.id ? null : c));
setSyncStatus('synced');
window.setTimeout(() => setSyncStatus(''), 2500);
} else if (result.status === 409) {
setSyncConflict({ id: toSave.id, serverEdited: result.data?.serverEdited ?? null });
setSyncStatus('conflict');
} else {
setSyncStatus('error'); // kept visible until the next successful sync
}
return result;
};
useEffect(() => {
if (!project) return;
if (!project) return undefined;
const signature = signatureOf(project);
const last = savedSignatureRef.current;
if (last.id !== project.id) {
savedSignatureRef.current = { id: project.id, signature };
if (loadProjectById(project.id)) return undefined; // just opened an existing study: nothing to save yet
} else if (last.signature === signature) {
return undefined;
}
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
saveTimerRef.current = window.setTimeout(async () => {
savedSignatureRef.current = { id: project.id, signature };
// Stamp lastEdited once so localStorage and server receive the same value
const toSave = { ...project, lastEdited: Date.now() };
@@ -122,17 +210,34 @@ const App = () => {
setSaveStatus('Saved');
window.setTimeout(() => setSaveStatus(''), 1400);
// 2. Sync to server with the same timestamped object
setSyncStatus('syncing');
const result = await saveRemoteProject(toSave);
setSyncStatus(result.ok ? 'synced' : 'error');
// On success clear after 2.5s; on error keep visible until next successful sync
if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500);
// 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice)
if (conflictRef.current?.id === toSave.id) return;
await pushProject(toSave);
}, 1000);
return () => {
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
};
}, [project]);
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
// The two ways out of a conflict: keep this device's version, or take the server's.
const keepMyVersion = async () => {
if (!project) return;
await pushProject({ ...project, lastEdited: Date.now() }, { force: true });
};
const useServerVersion = async () => {
if (!syncConflict) return;
const result = await loadRemoteProject(syncConflict.id);
if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; }
saveProjectToStorage(result.data);
setSyncBase(syncConflict.id, result.data.lastEdited);
const loaded = loadProjectById(syncConflict.id);
savedSignatureRef.current = { id: syncConflict.id, signature: signatureOf(loaded) };
setProject(loaded);
setProjectIndex(loadProjectIndex());
setSyncConflict(null);
setSyncStatus('');
toast('Switched to the version from your other device');
};
const updateProject = (updater) => {
setProject((current) => (current ? updater(current) : current));
@@ -230,7 +335,7 @@ const App = () => {
setCurrentPage('study');
} catch (err) {
console.error('studyReadingPlanDay error:', err);
window.alert("Couldn't load today's passage. Please try again.");
toast("Couldn't load today's passage. Please try again.", { tone: 'error' });
} finally {
setStudyBuildBusy(false);
}
@@ -266,12 +371,9 @@ const App = () => {
// Manual retry after a failed background sync (shown in the project header).
const retrySync = async () => {
if (!project) return;
setSyncStatus('syncing');
const toSave = { ...project, lastEdited: Date.now() };
saveProjectToStorage(toSave);
const result = await saveRemoteProject(toSave);
setSyncStatus(result.ok ? 'synced' : 'error');
if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500);
await pushProject(toSave);
};
// Shared with every page via useApp(). Page-specific state lives in the
@@ -291,6 +393,7 @@ const App = () => {
saveStatus, setSaveStatus,
syncStatus,
retrySync,
syncConflict, keepMyVersion, useServerVersion,
// Home
projectIndex,
...library,
@@ -303,6 +406,13 @@ const App = () => {
studyBuildBusy,
openImportProject,
openNewProject,
// ⌘K palette, shortcut list, and hand-offs to the Reader / Settings
paletteOpen, openPalette: () => setPaletteOpen(true),
readerRequest, clearReaderRequest: () => setReaderRequest(null),
requestReaderSearch: (text) => setReaderRequest({ search: text }),
settingsFocus, clearSettingsFocus: () => setSettingsFocus(null),
refreshProjectIndex: () => setProjectIndex(loadProjectIndex()),
openBackup: () => { setSettingsFocus('backup'); setCurrentPage('settings'); },
// Audio (book player on Home + chapter player in the Reader)
...audio,
// Reader
@@ -324,14 +434,26 @@ const App = () => {
<Suspense fallback={<PageLoading />}>
<AppRouter />
</Suspense>
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
{helpOpen && <ShortcutsHelp onClose={() => setHelpOpen(false)} />}
</AppContext.Provider>
);
};
// Shown while a page's code downloads: the app bar's outline and a few resting blocks in the
// reading theme, so the screen doesn't flash a different colour before the page appears.
function PageLoading() {
const { readerTheme } = useApp();
return (
<div className="flex min-h-dvh items-center justify-center bg-slate-900">
<p className="text-sm text-slate-400">Loading…</p>
<div className="min-h-dvh bg-[var(--r-bg)]" style={themeVars(readerTheme)} role="status" aria-label="Loading">
<div className="safe-top border-b border-[var(--r-line)] bg-[var(--r-bar)]"><div className="h-12" /></div>
<div className="mx-auto max-w-3xl animate-pulse motion-reduce:animate-none space-y-3 px-5 pt-10" aria-hidden="true">
<div className="mx-auto h-7 w-1/3 rounded bg-[var(--r-line)]" />
<div className="h-4 rounded bg-[var(--r-line)]" />
<div className="h-4 w-11/12 rounded bg-[var(--r-line)]" />
<div className="h-4 w-10/12 rounded bg-[var(--r-line)]" />
</div>
<span className="sr-only">Loading…</span>
</div>
);
}
@@ -374,13 +496,7 @@ function AppRouter() {
);
}
if (authUser === undefined && !authServerDown) {
return (
<div className="flex min-h-dvh items-center justify-center bg-slate-900">
<p className="text-sm text-slate-400">Loading…</p>
</div>
);
}
if (authUser === undefined && !authServerDown) return <PageLoading />;
if (authUser === null && !authServerDown) return <AuthPage />;
@@ -390,6 +506,7 @@ function AppRouter() {
if (currentPage === 'reader') return <ReaderPage />;
if (currentPage === 'import') return <ImportPage />;
if (currentPage === 'study') return <StudyPage />;
if (currentPage === 'outline') return <OutlinePage />;
if (currentPage === 'script') return <ScriptPage />;
if (currentPage === 'export') return <ExportPage />;
if (currentPage === 'setup') return <SetupPage />;
@@ -397,4 +514,11 @@ function AppRouter() {
return <HomePage />;
}
// The toast provider sits outside so every page (and App itself) can show toasts.
const App = () => (
<ToastProvider>
<AppInner />
</ToastProvider>
);
export default App;
+12 -9
View File
@@ -588,14 +588,17 @@ describe('Home page', () => {
expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive');
});
test('deleting a project removes it after confirmation', async () => {
test('deleting a project removes it at once, and Undo brings it back', async () => {
seedProject();
window.confirm.mockReturnValue(true);
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /^delete$/i }));
await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument());
expect(localStorage.getItem('bible-study-project-p1')).toBeNull();
expect(window.confirm).not.toHaveBeenCalled();
await user.click(await screen.findByRole('button', { name: /undo/i }));
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
expect(localStorage.getItem('bible-study-project-p1')).not.toBeNull();
});
test('sort choice is remembered after leaving Home', async () => {
@@ -760,28 +763,28 @@ describe('Chunk builder', () => {
expect(await addSecondChapter(user)).toHaveAttribute('aria-current', 'true');
});
test('the Outline stage reopens the chapter of the chunk being studied', async () => {
test('the Chunks stage reopens the chapter of the chunk being studied', async () => {
const user = await loadChapter();
await addSecondChapter(user);
addChunk('Paul, a servant', 'In hope of eternal'); // chunk in Titus 2 (now selected)
await user.click(screen.getByRole('button', { name: 'Titus 1' }));
await user.click(screen.getByRole('button', { name: /begin studying/i }));
await findChunkCounter(1, 1);
await user.click(screen.getByRole('button', { name: /^outline$/i }));
await user.click(screen.getByRole('button', { name: /^chunks$/i }));
expect(await screen.findByRole('button', { name: 'Titus 2' })).toHaveAttribute('aria-current', 'true');
});
});
// ---------------------------------------------------------------------------
// Stage bar (Outline / Study / Script / Export)
// Stage bar (Chunks / Study / Outline / Script / Export)
// ---------------------------------------------------------------------------
describe('Stage bar', () => {
test('later stages are disabled until a chunk exists', async () => {
await loadChapter();
for (const name of [/^study$/i, /^script$/i, /^export$/i]) {
for (const name of [/^study$/i, /^outline$/i, /^script$/i, /^export$/i]) {
expect(screen.getByRole('button', { name })).toBeDisabled();
}
expect(screen.getByRole('button', { name: /^outline$/i })).not.toBeDisabled();
expect(screen.getByRole('button', { name: /^chunks$/i })).not.toBeDisabled();
addChunk('Paul, a servant', 'Paul, a servant');
await screen.findByText('1 created');
expect(screen.getByRole('button', { name: /^study$/i })).not.toBeDisabled();
@@ -1240,7 +1243,7 @@ describe('Back to the dashboard', () => {
await dashboard();
});
test.each(['Outline', 'Study', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
test.each(['Chunks', 'Study', 'Outline', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
const u = await loadChapter();
addChunk('Paul, a servant', 'In hope of eternal');
await screen.findByText('1 created');
@@ -1431,7 +1434,7 @@ describe('New pages start at the top', () => {
await screen.findByRole('heading', { name: /^reading plans$/i });
expect(scroller.scrollTop).toBe(0);
scroller.scrollTop = 300;
await user.click(screen.getByRole('button', { name: /back to home/i }));
await user.click(screen.getByRole('button', { name: /back to home|bible study home/i }));
await screen.findByText('My Studies');
expect(scroller.scrollTop).toBe(0);
});
+132
View File
@@ -0,0 +1,132 @@
// Press ⌘K (Ctrl+K) anywhere: type a passage ("john 3:16") to go straight to it, or a few letters to
// find a study, a note you wrote, a bookmark or highlight, a page, or to search the Bible's text.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars } from './reader/readerThemes.js';
import { SearchIcon } from './reader/icons.jsx';
import { formatPassage, parsePassageRef } from '../utils/passageRef.js';
import { searchMarks, searchNotes, searchProjects } from '../utils/personalSearch.js';
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
export default function CommandPalette({ onClose }) {
const {
readerTheme, authUser, authServerDown, projectIndex, readerBookmarks, readerTextHighlights, readerColorLabels,
jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans,
openBibleReader, requestReaderSearch, openBackup,
} = useApp();
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const listRef = useRef(null);
const inputRef = useRef(null);
const opener = useRef(document.activeElement);
useEffect(() => {
inputRef.current?.focus();
const from = opener.current;
return () => { if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
}, []);
const items = useMemo(() => {
const out = [];
const q = query.trim();
const go = (fn) => () => { onClose(); fn(); };
// 1. A passage typed in
for (const r of parsePassageRef(q)) {
out.push({ group: 'Go to', label: formatPassage(r), hint: 'Open in the Bible', run: go(() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }) });
}
// 2. Pages and actions
const pages = [
['Studies', 'My Studies', goStudies], ['Read Bible', 'The Bible reader', openBibleReader], ['Reading Plans', 'Plans and today’s reading', openReadingPlans],
['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
['Back up my data', 'Download everything as a file', openBackup],
];
const lower = q.toLowerCase();
for (const [label, hint, fn] of pages) {
if (!lower || label.toLowerCase().includes(lower)) out.push({ group: lower ? 'Pages' : 'Go to', label, hint, run: go(fn) });
}
if (q.length >= 2) {
// 3. Studies
for (const e of searchProjects(projectIndex, q)) {
out.push({ group: 'Studies', label: e.title, hint: e.chapterSummary, run: go(() => resumeProject(e.id)) });
}
// 4. Notes you wrote
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
for (const h of searchNotes(projects, q)) {
out.push({
group: 'Your notes', label: `${h.ref} — ${h.projectTitle}`, hint: h.snippet,
run: go(() => {
const full = loadProjectById(h.projectId);
if (!full) return;
setProject({ ...full, selectedChunkId: h.chunkId });
setCurrentPage('study');
}),
});
}
// 5. Bookmarks and highlights
for (const m of searchMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights, labels: readerColorLabels ?? {} }, q)) {
out.push({
group: 'Bookmarks & highlights', label: `${m.bookName} ${m.chapter}:${m.verse}`, color: m.color,
hint: m.kind === 'highlight' ? `${m.label ? `${m.label}: ` : ''}${m.text ?? 'Highlight'}` : 'Bookmark',
run: go(() => { jumpToReaderVerse(m.book, m.chapter, m.verse); openBibleReader(); }),
});
}
// 6. The Bible's own text
out.push({ group: 'Search', label: `Search the Bible for “${q}”`, hint: 'Whole-Bible search in the reader', run: go(() => { requestReaderSearch(q); openBibleReader(); }) });
}
return out;
}, [query]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { setActive(0); }, [query]);
useEffect(() => { listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest' }); }, [active]);
const onKeyDown = (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(a + 1, items.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
else if (e.key === 'Enter') { e.preventDefault(); items[active]?.run(); }
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); }
else if (e.key === 'Tab') { e.preventDefault(); } // the box and the list are one control
};
if (authUser === null && !authServerDown) return null; // the sign-in screen has nothing to search
let lastGroup = null;
return (
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-start justify-center bg-black/40 px-3 pt-[12vh] font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div role="dialog" aria-modal="true" aria-label="Search and go to" className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] shadow-2xl">
<div className="flex items-center gap-2 border-b border-[var(--r-line)] px-4 py-3">
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
<input
ref={inputRef} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown}
role="combobox" aria-expanded="true" aria-controls="palette-list" aria-autocomplete="list"
aria-activedescendant={items[active] ? `palette-item-${active}` : undefined}
placeholder="Go to a passage, or search your studies and notes…"
className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-[var(--r-sub)]"
/>
<kbd className="hidden rounded border border-[var(--r-line)] px-1.5 py-0.5 text-[10px] text-[var(--r-sub)] sm:block">esc</kbd>
</div>
<ul id="palette-list" role="listbox" ref={listRef} className="max-h-[50vh] overflow-y-auto p-2">
{items.length === 0 && <li className="px-3 py-6 text-center text-sm text-[var(--r-sub)]">Nothing found. Try a book and chapter, like “romans 8”.</li>}
{items.map((item, i) => {
const header = item.group !== lastGroup ? item.group : null;
lastGroup = item.group;
return (
<li key={i} role="presentation">
{header && <p className="px-3 pb-1 pt-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{header}</p>}
<div id={`palette-item-${i}`} role="option" aria-selected={i === active} onMouseMove={() => setActive(i)} onClick={item.run}
className={`flex cursor-pointer items-baseline gap-2 rounded-lg px-3 py-2 text-sm ${i === active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : ''}`}>
{item.color && <span className="h-2.5 w-2.5 shrink-0 translate-y-px rounded-full border border-black/10" style={{ backgroundColor: item.color }} />}
<span className="shrink-0 font-medium">{item.label}</span>
{item.hint && <span className={`min-w-0 truncate text-xs ${i === active ? 'opacity-80' : 'text-[var(--r-sub)]'}`}>{item.hint}</span>}
</div>
</li>
);
})}
</ul>
<p className="border-t border-[var(--r-line)] px-4 py-2 text-[11px] text-[var(--r-sub)]">↑↓ to move · Enter to open · Esc to close</p>
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
// The "Save for offline" control: a button, or progress with a Stop button while a download runs.
import { canSaveOffline } from '../utils/offline.js';
export function DownloadProgress({ job, onCancel }) {
const pct = job.total ? Math.round((job.done / job.total) * 100) : 0;
return (
<div role="status" className="rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span>Saving {job.label}… {job.done} of {job.total}</span>
<button type="button" onClick={onCancel} className="text-[var(--r-accent)] hover:underline">Stop</button>
</div>
<div className="h-1 overflow-hidden rounded-full bg-[var(--r-line)]"><div className="h-full bg-[var(--r-accent)] transition-[width]" style={{ width: `${pct}%` }} /></div>
</div>
);
}
// One book of a translation.
export function SaveBookOffline({ offline, translation, book, name }) {
if (!canSaveOffline(translation)) {
return <p className="mt-4 text-[11px] text-[var(--r-sub)]">Saving for offline works with the BSB and NET. The {translation}’s licence doesn’t allow it.</p>;
}
if (offline.job) return <div className="mt-4"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
const saved = offline.isSaved(translation, book);
return (
<div className="mt-4 flex items-center justify-between gap-3 rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm">
<span className={saved ? '' : 'text-[var(--r-sub)]'}>{saved ? `✓ ${name} (${translation}) is saved for offline` : `Read ${name} without a connection`}</span>
{!saved && (
<button type="button" onClick={() => offline.saveBook(translation, book, name)} className="shrink-0 rounded-full border border-[var(--r-line)] px-3 py-1 text-xs font-medium hover:bg-black/5">Save for offline</button>
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
// The "?" cheat sheet: every keyboard shortcut for the page you are on, plus the ones that work everywhere.
import { useEffect, useRef } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars } from './reader/readerThemes.js';
const GLOBAL = [
['⌘K / Ctrl K', 'Search and go to a passage'],
['?', 'Show this list'],
['Esc', 'Close whatever is open'],
];
const BY_PAGE = {
reader: [
['← →', 'Previous / next chapter'],
['/', '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'],
],
study: [
['← →', 'Previous / next chunk'],
],
};
export default function ShortcutsHelp({ onClose }) {
const { readerTheme, currentPage } = useApp();
const closeRef = useRef(null);
useEffect(() => {
const from = document.activeElement;
closeRef.current?.focus();
const onKey = (e) => { if (e.key === 'Escape' || e.key === '?') { e.preventDefault(); e.stopPropagation(); onClose(); } };
document.addEventListener('keydown', onKey, true);
return () => { document.removeEventListener('keydown', onKey, true); if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
}, [onClose]);
const page = BY_PAGE[currentPage] ?? [];
const section = (title, rows) => (
<div className="mb-4 last:mb-0">
<h3 className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{title}</h3>
<dl className="space-y-1.5">
{rows.map(([keys, what]) => (
<div key={keys} className="flex items-center justify-between gap-4 text-sm">
<dt><kbd className="rounded-md border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-0.5 font-mono text-xs">{keys}</kbd></dt>
<dd className="text-right text-[var(--r-sub)]">{what}</dd>
</div>
))}
</dl>
</div>
);
return (
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-3 font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="max-h-[80vh] w-full max-w-sm overflow-y-auto rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5 text-[var(--r-text)] shadow-2xl">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-medium">Keyboard shortcuts</h2>
<button ref={closeRef} type="button" onClick={onClose} className="rounded-lg px-2 py-1 text-sm text-[var(--r-sub)] hover:bg-black/5">Close</button>
</div>
{page.length > 0 && section(currentPage === 'reader' ? 'In the Bible' : 'In this study', page)}
{section('Everywhere', GLOBAL)}
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
// App-wide toasts: short messages at the bottom of the screen, optionally with one
// action button (usually "Undo"). Used instead of alert()/confirm() so a slip is
// reversible and nothing blocks the page.
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { themeVars } from './reader/readerThemes.js';
const ToastContext = createContext(null);
// toast(message, { action: { label, onClick }, ms, tone: 'info' | 'error' }) -> id
// Outside a provider (a page rendered on its own in a test) it quietly does nothing.
const NOOP = { toast: () => 0, dismiss: () => {}, setTheme: () => {} };
export const useToast = () => useContext(ToastContext) ?? NOOP;
let nextId = 1;
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const [theme, setTheme] = useState('paper'); // set by the app so toasts match the reading theme
const timers = useRef(new Map());
const dismiss = useCallback((id) => {
window.clearTimeout(timers.current.get(id));
timers.current.delete(id);
setToasts((list) => list.filter((t) => t.id !== id));
}, []);
const toast = useCallback((message, { action, ms, tone = 'info' } = {}) => {
const id = nextId++;
// Messages with an action stay long enough to read and react to.
const duration = ms ?? (action ? 8000 : tone === 'error' ? 6000 : 3500);
setToasts((list) => [...list.slice(-2), { id, message, action, tone }]);
timers.current.set(id, window.setTimeout(() => dismiss(id), duration));
return id;
}, [dismiss]);
useEffect(() => {
const live = timers.current;
return () => live.forEach((t) => window.clearTimeout(t));
}, []);
const value = useMemo(() => ({ toast, dismiss, setTheme }), [toast, dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] font-sans">
{toasts.map((t) => (
<div key={t.id} role={t.tone === 'error' ? 'alert' : 'status'}
className={`pointer-events-auto flex max-w-md items-center gap-3 rounded-full px-4 py-2.5 text-sm shadow-lg ${t.tone === 'error' ? 'bg-[var(--r-danger)] text-[var(--r-bg)]' : 'bg-[var(--r-text)] text-[var(--r-bg)]'}`}>
<span>{t.message}</span>
{t.action && (
<button type="button" onClick={() => { t.action.onClick(); dismiss(t.id); }}
className="shrink-0 rounded-full px-2 py-0.5 text-sm font-semibold underline underline-offset-2 hover:opacity-80">
{t.action.label}
</button>
)}
</div>
))}
</div>
</ToastContext.Provider>
);
}
+26 -4
View File
@@ -5,7 +5,10 @@ import { useRef, useState } from 'react';
import Sheet from '../reader/Sheet.jsx';
import TranslationSelect from './TranslationSelect.jsx';
import { resizeImageToDataUrl } from './CustomPlanBuilder.jsx';
import { formatDayReadings, getPlanSchedule, daysBehindPace } from '../../utils/readingPlans.js';
import { formatDayReadings, getPlanSchedule, daysBehindPace, nextUnreadDayIndex } from '../../utils/readingPlans.js';
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { DownloadProgress } from '../OfflineSave.jsx';
import { canSaveOffline } from '../../utils/offline.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-3 py-2 text-sm hover:bg-black/5';
const primary = 'w-full rounded-xl bg-[var(--r-accent)] py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50';
@@ -14,6 +17,13 @@ function ActiveBody({ plan, progress, onClose, actions }) {
const schedule = getPlanSchedule(plan.id);
const behind = daysBehindPace(plan, progress);
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
const offline = useOfflineSave();
const translation = progress.translation ?? actions.defaultTranslation;
// The next week of readings, so a trip or a bad connection doesn't interrupt the plan
const upcoming = (() => {
const from = nextUnreadDayIndex(plan, progress.completedDays);
return from == null ? [] : schedule.slice(from, from + 7).flat();
})();
return (
<>
<div className="mb-3 flex items-center justify-between text-sm">
@@ -42,9 +52,21 @@ function ActiveBody({ plan, progress, onClose, actions }) {
);
})}
</div>
{upcoming.length > 0 && canSaveOffline(translation) && (
<div className="mt-3">
{offline.job
? <DownloadProgress job={offline.job} onCancel={offline.cancel} />
: (
<button type="button" onClick={() => offline.saveRefs('the next 7 days', translation, upcoming)}
className="w-full rounded-xl border border-[var(--r-line)] px-3 py-2 text-xs text-[var(--r-sub)] hover:bg-black/5">
Save the next 7 days for offline ({translation})
</button>
)}
</div>
)}
<div className="mt-4 flex items-center justify-between text-xs">
<button type="button" onClick={() => { if (window.confirm('Restart this plan from day 1?')) { actions.restart(progress.id); onClose(); } }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
<button type="button" onClick={() => { if (window.confirm('Remove this plan?')) { actions.remove(progress.id); onClose(); } }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
<button type="button" onClick={() => { actions.restart(progress.id); onClose(); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Restart</button>
<button type="button" onClick={() => { actions.remove(progress.id); onClose(); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</div>
</>
);
@@ -92,7 +114,7 @@ function CatalogBody({ plan, active, completedCount, onClose, actions }) {
<button type="button" onClick={() => { onClose(); actions.edit(plan.id); }} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">Edit plan</button>
<button type="button" onClick={() => fileRef.current?.click()} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">{plan.image ? 'Change cover' : 'Add cover photo'}</button>
{plan.image && <button type="button" onClick={() => actions.updateCover(plan.id, null)} className="text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove cover</button>}
<button type="button" onClick={() => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) { actions.delete(plan.id); onClose(); } }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button>
<button type="button" onClick={() => { actions.delete(plan.id); onClose(); }} className="ml-auto text-[var(--r-danger,#b42318)] hover:underline">Delete</button>
<input ref={fileRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
{imageError && <p className="w-full text-[var(--r-danger,#b42318)]">{imageError}</p>}
</div>
-20
View File
@@ -1,20 +0,0 @@
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
export default function BookmarksSheet({ open, onClose, entries, onJump, onRemove }) {
return (
<Sheet open={open} onClose={onClose} title={`Bookmarks${entries.length ? ` (${entries.length})` : ''}`}>
{entries.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
) : entries.map((e) => (
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
</button>
<button type="button" onClick={() => onRemove(e.key)} aria-label="Remove bookmark" className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))}
</Sheet>
);
}
+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}</>;
}
+94
View File
@@ -0,0 +1,94 @@
// Everything you have marked in the Bible: highlights (grouped and filtered by colour, each colour
// named by you — "Promises", "Commands") and bookmarks. Opens from the bookmark button in the top bar.
import { useState } from 'react';
import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx';
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs 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'}`;
export default function MarksSheet({
open, onClose, bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
}) {
const [tab, setTab] = useState('highlights');
const [filter, setFilter] = useState(null); // a highlight colour, or null for all
const [editing, setEditing] = useState(false);
const counts = Object.fromEntries(HIGHLIGHT_COLORS.map((c) => [c, highlights.filter((h) => h.color === c).length]));
const shown = filter ? highlights.filter((h) => h.color === filter) : highlights;
// Colours in use that aren't one of the picker's (older data) still get listed
const total = highlights.length + bookmarks.length;
return (
<Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}>
<div role="tablist" aria-label="What to show" className="mb-3 flex gap-1.5">
<button type="button" role="tab" aria-selected={tab === 'highlights'} onClick={() => setTab('highlights')} className={tabBtn(tab === 'highlights')}>Highlights ({highlights.length})</button>
<button type="button" role="tab" aria-selected={tab === 'bookmarks'} onClick={() => setTab('bookmarks')} className={tabBtn(tab === 'bookmarks')}>Bookmarks ({bookmarks.length})</button>
</div>
{tab === 'highlights' && (
<>
{/* Colour key: tap a colour to show only that one; "Name colours" lets you say what each means */}
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by colour">
<button type="button" onClick={() => setFilter(null)} aria-pressed={filter === null} className={tabBtn(filter === null)}>All</button>
{HIGHLIGHT_COLORS.map((c) => (
<button key={c} type="button" onClick={() => setFilter(filter === c ? null : c)} aria-pressed={filter === c}
title={colorTitle(c, colorLabels)}
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${filter === c ? 'border-[var(--r-text)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
<span className="h-3 w-3 rounded-full border border-black/10" style={{ backgroundColor: c }} />
<span>{colorLabels[c] || COLOR_NAMES[c]}</span>
<span className="text-[var(--r-sub)]">{counts[c]}</span>
</button>
))}
<button type="button" onClick={() => setEditing((e) => !e)} className="ml-auto text-xs text-[var(--r-accent)] hover:underline">{editing ? 'Done' : 'Name colours'}</button>
</div>
{editing && (
<div className="mb-3 grid gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 sm:grid-cols-2">
{HIGHLIGHT_COLORS.map((c) => (
<label key={c} className="flex items-center gap-2 text-xs text-[var(--r-sub)]">
<span className="h-4 w-4 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: c }} />
<span className="sr-only">{COLOR_NAMES[c]} means</span>
<input value={colorLabels[c] ?? ''} maxLength={30} onChange={(e) => onSetLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c]}
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-transparent px-2 py-1 text-sm text-[var(--r-text)] outline-none focus:border-[var(--r-accent)]" />
</label>
))}
</div>
)}
{highlights.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No highlights yet. Select some words in a verse and pick a colour to save them here.</p>
) : shown.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">Nothing highlighted in that colour yet.</p>
) : shown.map((h) => (
<div key={h.id} className="flex items-start gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
</button>
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))}
</>
)}
{tab === 'bookmarks' && (
bookmarks.length === 0 ? (
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
) : bookmarks.map((e) => (
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
{e.bookName} {e.chapter}:{e.verse}
</button>
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
</div>
))
)}
</Sheet>
);
}
+7 -3
View File
@@ -2,12 +2,13 @@
import { useState } from 'react';
import Sheet from './Sheet.jsx';
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
import { READER_TRANSLATIONS } from '../../hooks/useBibleReader.js';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
import { SaveBookOffline } from '../OfflineSave.jsx';
const NT_START = bookOptions.findIndex((b) => b.abbrev === 'MAT');
const chip = 'rounded-lg border px-2 py-2 text-center text-sm transition';
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo }) {
export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, translation, onTranslation, translationNote, onGo, offline }) {
const [pickedBook, setPickedBook] = useState(null); // book abbrev whose chapters are showing
const close = () => { setPickedBook(null); onClose(); };
const book = pickedBook ? bookOptions.find((b) => b.abbrev === pickedBook) : null;
@@ -20,7 +21,7 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
);
return (
<Sheet open={open} onClose={close} title={book ? (
<Sheet open={open} onClose={close} label="Go to" title={book ? (
<button type="button" onClick={() => setPickedBook(null)} className="text-[var(--r-sub)] hover:text-[var(--r-text)]">‹ All books · <span className="text-[var(--r-text)]">{book.name}</span></button>
) : 'Go to'}>
{!book && (
@@ -41,6 +42,7 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
</>
)}
{book && (
<>
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-8">
{Array.from({ length: CHAPTER_COUNTS[book.abbrev] ?? 1 }, (_, i) => i + 1).map((n) => (
<button key={n} type="button" onClick={() => { onGo(book.abbrev, n); close(); }}
@@ -49,6 +51,8 @@ export default function NavigatorSheet({ open, onClose, bookAbbrev, chapter, tra
</button>
))}
</div>
{offline && <SaveBookOffline offline={offline} translation={translation} book={book.abbrev} name={book.name} />}
</>
)}
</Sheet>
);
+3 -2
View File
@@ -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>
</>
)}
+112 -10
View File
@@ -1,12 +1,33 @@
// 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 = {
serif: { fontFamily: undefined },
sans: { fontFamily: "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif" },
easy: { fontFamily: "'Atkinson Hyperlegible', Verdana, 'Trebuchet MS', ui-sans-serif, sans-serif", letterSpacing: '0.02em', wordSpacing: '0.12em' },
};
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, 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, 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
@@ -20,7 +41,12 @@ export default function ReaderText({
id: `reader-verse-${v.number}`,
'data-verse-num': v.number,
onClick: () => handleTap(v.number),
className: 'cursor-pointer rounded-sm transition-colors',
// Verses can be reached and chosen from the keyboard too
role: 'button',
tabIndex: 0,
'aria-pressed': selected,
onKeyDown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleTap(v.number); } },
className: 'cursor-pointer rounded-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[var(--r-accent)]',
style: selected
? { backgroundColor: 'var(--r-hl)' }
: bm ? { backgroundColor: `${bm}66`, boxShadow: `0 0 0 2px ${bm}66` } : undefined,
@@ -35,25 +61,90 @@ export default function ReaderText({
</span>
);
const num = (n) => <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup>;
const num = (n) => (showVerseNumbers ? <sup className="mr-1 select-none font-sans text-[0.6em] text-[var(--r-sub)]">{n}</sup> : null);
// Both Bibles by verse number, so a verse one translation leaves out still lines up with the other.
const parallelRows = parallel && !parallel.error ? (() => {
const second = new Map(parallel.verses.map((v) => [v.number, v]));
const first = new Map(verses.map((v) => [v.number, v]));
return [...new Set([...first.keys(), ...second.keys()])].sort((a, b) => a - b)
.map((n) => ({ number: n, first: first.get(n), second: second.get(n) }));
})() : null;
const sideBySide = !!parallel;
const bodyStyle = {
...FONT_STYLES[font],
lineHeight: LINE_HEIGHTS[spacing] ?? LINE_HEIGHTS.normal,
};
// The page grows with the screen: type is 18px on a phone, ~20px on an iPad and ~24px on a big
// monitor (times your text-size choice), and the column is measured in the type's own width (em), so
// the line length stays comfortable (~70 characters) instead of becoming a narrow strip on a wide
// screen. Two columns / side-by-side take more room.
const pageStyle = {
fontSize: `calc(clamp(1.125rem, 0.9rem + 0.7vw, 1.55rem) * ${fontSize})`,
maxWidth: wide || sideBySide ? 'min(100%, 72em)' : 'min(100%, 40em)',
};
return (
<article
className={`mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] ${wide ? 'max-w-5xl' : 'max-w-2xl'}`}
className="mx-auto px-5 pb-32 pt-6 text-[var(--r-text)] md:px-8"
style={pageStyle}
onPointerUp={onSelectionEnd}
>
<header className="mb-5 text-center">
<h1 className="font-serif text-3xl font-normal tracking-tight">{title}</h1>
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}</p>
<p className="mt-1 font-sans text-xs text-[var(--r-sub)]">{translation}{parallel ? ` · beside ${parallel.name}` : ''}</p>
</header>
{loading && <p className="text-center font-sans text-sm text-[var(--r-sub)]">Loading…</p>}
{error && <p className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{loading && <ChapterSkeleton />}
{error && <p role="alert" className="text-center font-sans text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{!loading && !error && parallel?.error && (
<p role="alert" className="mb-4 text-center font-sans text-sm text-[var(--r-danger,#b42318)]">Couldn’t load {parallel.name}: {parallel.error}</p>
)}
{!loading && !error && (
{!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>
{parallelRows.map((row) => (
<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, 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)}<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>
))}
</div>
)}
{!loading && !error && !sideBySide && (
<div
className={`font-serif ${wide ? 'md:columns-2 md:gap-x-12' : ''}`}
style={{ fontSize: `${1.125 * fontSize}rem`, lineHeight: 1.85 }}
style={bodyStyle}
>
{layout === 'flow' ? (
<p className="text-pretty">
@@ -73,9 +164,20 @@ export default function ReaderText({
</div>
)}
{!loading && !error && footer}
{!loading && !error && <div style={{ fontSize: '1rem' }}>{footer}</div>}
{notice && !loading && <p className="mt-10 border-t border-[var(--r-line)] pt-3 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{notice}</p>}
{parallel?.notice && !loading && !parallel.loading && <p className="mt-2 font-sans text-[11px] leading-snug text-[var(--r-sub)]">{parallel.notice}</p>}
</article>
);
}
// Grey bars the shape of a page, so a slow chapter load doesn't just show the word "Loading".
function ChapterSkeleton() {
return (
<div role="status" aria-label="Loading chapter" className="animate-pulse motion-reduce:animate-none space-y-3 pt-2">
{[100, 96, 92, 98, 84, 100, 90, 70].map((w, i) => <div key={i} className="h-4 rounded bg-[var(--r-line)]" style={{ width: `${w}%` }} />)}
<span className="sr-only">Loading…</span>
</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
});
+27 -11
View File
@@ -1,42 +1,58 @@
// "Aa" sheet: text size, theme, and how verses are laid out.
// "Aa" sheet: text size, typeface, spacing, theme, how verses are laid out, and a second Bible
// to read beside the first.
import Sheet from './Sheet.jsx';
import { READER_THEMES } from './readerThemes.js';
const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
function Segmented({ options, value, onChange }) {
function Segmented({ options, value, onChange, label }) {
return (
<div className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{options.map(([id, label]) => (
<button key={id} type="button" onClick={() => onChange(id)}
<div role="group" aria-label={label} className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{options.map(([id, text]) => (
<button key={String(id)} type="button" onClick={() => onChange(id)} aria-pressed={value === id}
className={`flex-1 px-3 py-2 text-sm ${value === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
{label}
{text}
</button>
))}
</div>
);
}
export default function SettingsSheet({ open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide }) {
export default function SettingsSheet({
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
}) {
const row = 'mb-4';
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
return (
<Sheet open={open} onClose={onClose} title="Reading settings">
<div className={row}>
<span className={label}>Text size</span>
<Segmented options={SIZES.map(([l, s]) => [s, l])} value={fontSize} onChange={onFontSize} />
<Segmented label="Text size" options={SIZES.map(([l, s]) => [s, l])} value={fontSize} onChange={onFontSize} />
</div>
<div className={row}>
<span className={label}>Typeface</span>
<Segmented label="Typeface" options={[['serif', 'Serif'], ['sans', 'Sans'], ['easy', 'Easy to read']]} value={font} onChange={onFont} />
</div>
<div className={row}>
<span className={label}>Line spacing</span>
<Segmented label="Line spacing" options={[['snug', 'Snug'], ['normal', 'Normal'], ['airy', 'Airy']]} value={spacing} onChange={onSpacing} />
</div>
<div className={row}>
<span className={label}>Theme</span>
<Segmented options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={theme} onChange={onTheme} />
<Segmented label="Theme" options={Object.entries(READER_THEMES).map(([id, t]) => [id, t.label])} value={theme} onChange={onTheme} />
</div>
<div className={row}>
<span className={label}>Verses</span>
<Segmented options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={layout} onChange={onLayout} />
<Segmented label="Verse layout" options={[['flow', 'Flowing'], ['verses', 'One per line']]} value={layout} onChange={onLayout} />
</div>
<div className={row}>
<span className={label}>Verse numbers</span>
<Segmented label="Verse numbers" options={[[true, 'Show'], [false, 'Hide']]} value={verseNumbers} onChange={onVerseNumbers} />
</div>
<div className={row}>
<span className={label}>Width</span>
<Segmented 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>
</Sheet>
);
+37 -3
View File
@@ -2,17 +2,51 @@
// close on outside tap; non-modal ones (the verse sheet) leave the text
// tappable. With `pane`, screens >= lg show it as a right-hand side pane
// instead of a bottom sheet (tablet landscape / desktop).
import { useEffect, useRef } from 'react';
import { CloseIcon } from './icons.jsx';
export default function Sheet({ open, onClose, title, headerRight, modal = true, pane = false, children }) {
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export default function Sheet({ open, onClose, title, label, headerRight, modal = true, pane = false, children }) {
const dialogRef = useRef(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Modal sheets behave like dialogs: focus moves in, Tab stays inside, Escape closes, and focus
// returns to whatever opened the sheet. (The verse pane is not modal, so it leaves focus alone.)
useEffect(() => {
if (!open || !modal) return undefined;
const opener = document.activeElement;
const dialog = dialogRef.current;
if (dialog && !dialog.contains(document.activeElement)) dialog.focus({ preventScroll: true });
const onKey = (e) => {
if (e.key === 'Escape') { e.stopPropagation(); onCloseRef.current?.(); return; }
if (e.key !== 'Tab' || !dialog) return;
const items = [...dialog.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null || el === document.activeElement);
if (items.length === 0) { e.preventDefault(); return; }
const first = items[0];
const last = items[items.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
if (opener instanceof HTMLElement && document.contains(opener)) opener.focus({ preventScroll: true });
};
}, [open, modal]);
if (!open) return null;
return (
<>
{modal && <div className="fixed inset-0 z-40 bg-black/30" onClick={onClose} aria-hidden="true" />}
<div
ref={dialogRef}
role="dialog"
aria-label={typeof title === 'string' ? title : undefined}
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
aria-modal={modal ? 'true' : undefined}
aria-label={typeof title === 'string' ? title : label}
tabIndex={-1}
className={`fixed inset-x-0 bottom-0 z-50 flex max-h-[72vh] flex-col rounded-t-2xl focus:outline-none border-t border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] font-sans ${
pane ? 'lg:inset-x-auto lg:bottom-0 lg:right-0 lg:top-12 lg:max-h-none lg:w-[24rem] lg:rounded-none lg:border-l lg:border-t-0' : 'sm:left-1/2 sm:right-auto sm:w-[36rem] sm:-translate-x-1/2'
}`}
>
+52 -3
View File
@@ -5,8 +5,11 @@ import Sheet from './Sheet.jsx';
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'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
function OriginalTab({ words }) {
if (!words) return <p className="text-sm text-[var(--r-sub)]">No original-language data for this verse.</p>;
@@ -49,6 +52,51 @@ function RefsTab({ refs, loading, onJump }) {
);
}
// This verse in every translation, one under the other. Each Bible loads the chapter it is in
// (BSB and NET come from the free source; the rest through the server) and shows this verse.
function CompareTab({ book, chapter, verse, current }) {
const [rows, setRows] = useState({}); // id -> { text } | { error } | undefined while loading
useEffect(() => {
const controller = new AbortController();
setRows({});
READER_TRANSLATIONS.forEach((t) => {
fetchChapter(t.id, book, chapter, { signal: controller.signal })
.then(({ verses }) => {
const text = verses.find((v) => v.number === verse)?.text;
setRows((r) => ({ ...r, [t.id]: text ? { text } : { error: 'No verse here in this translation.' } }));
})
.catch((err) => { if (!controller.signal.aborted) setRows((r) => ({ ...r, [t.id]: { error: err.message || 'Unavailable.' } })); });
});
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 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"><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)]">
{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>
);
}
export default function VerseSheet({
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose,
@@ -86,15 +134,16 @@ export default function VerseSheet({
</>
)}
>
<div className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
<div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
{TABS.map(([id, label]) => (
<button key={id} type="button" onClick={() => setTab(id)}
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
className={`shrink-0 rounded-full border px-3 py-1 text-xs ${tab === 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'}`}>
{label}
</button>
))}
</div>
{tab === 'commentary' && <CommentaryTab book={book} chapter={chapter} verse={verse.number} />}
{tab === 'compare' && <CompareTab book={book} chapter={chapter} verse={verse.number} current={translation} />}
{tab === 'original' && <OriginalTab words={words} />}
{tab === 'refs' && <RefsTab refs={crossRefs} loading={crossRefsLoading} onJump={onJump} />}
{tab === 'people' && <PeoplePlacesTab book={book} chapter={chapter} verse={verse.number} />}
+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 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>;
+3 -3
View File
@@ -3,15 +3,15 @@
export const READER_THEMES = {
paper: {
label: 'Paper',
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#7d7466', '--r-accent': '#b4532a', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
vars: { '--r-bg': '#faf7f0', '--r-text': '#2b2620', '--r-sub': '#6b6458', '--r-accent': '#a84e27', '--r-bar': '#f1ece0', '--r-line': '#e0d8c6', '--r-hl': '#f6e7a8', '--r-on': '#ffffff', '--r-danger': '#b42318' },
},
sepia: {
label: 'Sepia',
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#7d6b48', '--r-accent': '#9a4a1e', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
vars: { '--r-bg': '#f1e4c8', '--r-text': '#3b2f1c', '--r-sub': '#635539', '--r-accent': '#93471d', '--r-bar': '#e8d8b6', '--r-line': '#d5c197', '--r-hl': '#e9cf85', '--r-on': '#ffffff', '--r-danger': '#a1281d' },
},
dark: {
label: 'Dark',
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#9a9385', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
vars: { '--r-bg': '#1c1b19', '--r-text': '#e6e1d6', '--r-sub': '#b8b2a4', '--r-accent': '#e0895c', '--r-bar': '#262421', '--r-line': '#3a3733', '--r-hl': '#4a4224', '--r-on': '#1c1b19', '--r-danger': '#f0857a' },
},
};
@@ -0,0 +1,27 @@
import { describe, expect, test } from 'vitest';
import { READER_THEMES } from './readerThemes.js';
// WCAG relative luminance and contrast ratio
const luminance = (hex) => {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (a, b) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
// Small text needs 4.5:1. These are the pairings the app actually draws.
describe.each(Object.entries(READER_THEMES))('%s theme contrast', (_id, { vars }) => {
const v = (name) => vars[`--r-${name}`];
test.each([
['text', 'bg'], ['text', 'bar'], ['text', 'hl'],
['sub', 'bg'], ['sub', 'bar'], ['sub', 'hl'],
['accent', 'bg'], ['accent', 'bar'],
['danger', 'bg'], ['danger', 'bar'],
['on', 'accent'],
])('%s on %s is at least 4.5:1', (fg, bg) => {
expect(contrast(v(fg), v(bg))).toBeGreaterThanOrEqual(4.5);
});
});
+113
View File
@@ -0,0 +1,113 @@
// Settings → "Your data": download everything as one file, restore from one, and save Bibles for offline.
import { useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { useToast } from '../Toasts.jsx';
import { DownloadProgress } from '../OfflineSave.jsx';
import { useOfflineSave } from '../../hooks/useOfflineSave.js';
import { backupFileName, buildBackup, restoreProjects, validateBackup } from '../../utils/backup.js';
import { saveRemoteProject } from '../../syncService.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
export default function DataSection() {
const {
authUser, readerBookmarks, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
importReaderMarks, importReadingPlans, refreshProjectIndex,
} = useApp();
const { toast } = useToast();
const offline = useOfflineSave();
const fileRef = useRef(null);
const [pending, setPending] = useState(null); // { name, backup, summary } waiting for "Restore"
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const download = () => {
const backup = buildBackup({
email: authUser?.email ?? null,
reader: { bookmarks: readerBookmarks, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
readingPlans: readingPlansState,
});
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = backupFileName();
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
toast(`Backup downloaded: ${plural(backup.projects.length, 'study', 'studies')} and your reading marks`);
};
const choose = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
setError('');
setPending(null);
if (!file) return;
let data;
try { data = JSON.parse(await file.text()); } catch { setError('That file couldn’t be read as a backup.'); return; }
const checked = validateBackup(data);
if (!checked.ok) { setError(checked.error); return; }
setPending({ name: file.name, ...checked });
};
const restore = async () => {
setBusy(true);
const { backup } = pending;
const written = restoreProjects(backup.projects);
importReaderMarks(backup.reader);
importReadingPlans(backup.readingPlans);
refreshProjectIndex();
// Studies restored here also go to the account, so they follow you to other devices
if (authUser) await Promise.all(written.map((p) => saveRemoteProject(p)));
setBusy(false);
setPending(null);
toast(`Restored ${plural(written.length, 'study', 'studies')}, plus your bookmarks, highlights and plans`);
};
const s = pending?.summary;
return (
<section id="backup" aria-label="Your data" className="scroll-mt-16 space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<div>
<h2 className="text-base font-medium">Your data</h2>
<p className="mt-1 text-sm text-[var(--r-sub)]">Everything you’ve made — studies and notes, bookmarks, highlights, ink and reading plans — in one file you keep.</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={download} className={btn}>Download backup</button>
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
</div>
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
{pending && (
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 text-sm">
<p className="font-medium">{pending.name}</p>
<p className="mt-1 text-[var(--r-sub)]">
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')} and {plural(s.plans, 'reading plan')}.
Restoring adds these to what you have — nothing is deleted, and a study on this device is only replaced if the backup’s copy is newer.
</p>
<div className="mt-3 flex gap-2">
<button type="button" onClick={restore} disabled={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">{busy ? 'Restoring…' : 'Restore'}</button>
<button type="button" onClick={() => setPending(null)} className={btn}>Cancel</button>
</div>
</div>
)}
<div className="border-t border-[var(--r-line)] pt-4">
<h3 className="text-sm font-medium">Read offline</h3>
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.</p>
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
<div className="flex flex-wrap gap-3">
{['BSB', 'NET'].map((t) => (
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
</button>
))}
</div>
)}
</div>
</section>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default function OutlineRail({ project, selectedId, onSelect, onEditOutli
return (
<nav aria-label="Chunks" className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between px-3 pb-2 pt-3">
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Outline</span>
<span className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Chunks</span>
<button type="button" onClick={onEditOutline} className="text-[11px] text-[var(--r-accent)] hover:underline">Edit</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-4">
+23 -9
View File
@@ -3,12 +3,13 @@
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../../context/AppContext.js';
import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx';
import { ChevronLeft, SearchIcon } from '../reader/icons.jsx';
// `short` is what a phone shows; the full label is still the button's accessible name.
const NAV = [
{ id: 'studies', label: 'Studies' },
{ id: 'reader', label: 'Read Bible' },
{ id: 'plans', label: 'Reading Plans' },
{ id: 'studies', label: 'Studies', short: 'Studies' },
{ id: 'reader', label: 'Read Bible', short: 'Bible' },
{ id: 'plans', label: 'Reading Plans', short: 'Plans' },
];
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
@@ -61,25 +62,37 @@ function AccountMenu() {
}
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans } = useApp();
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPalette } = useApp();
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans };
return (
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
<a href="#content" className="skip-link">Skip to content</a>
<header className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)]">
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-4 px-4`}>
<div className={`mx-auto flex h-12 ${maxWidth} items-center gap-2 px-3 sm:gap-4 sm:px-4`}>
<button type="button" onClick={goHome} className="flex shrink-0 items-center gap-2" aria-label="Bible Study home">
<img src="/logo.svg" alt="" className="h-6 w-6" />
<span className="hidden text-sm font-medium sm:inline">Bible Study</span>
</button>
<nav aria-label="Main" className="flex items-center gap-0.5">
{NAV.map((n) => (
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined}
className={`rounded-full px-3 py-1.5 text-sm ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{n.label}</button>
<button key={n.id} type="button" onClick={go[n.id]} aria-current={active === n.id ? 'page' : undefined} aria-label={n.label}
className={`whitespace-nowrap rounded-full px-2.5 py-1.5 text-sm sm:px-3 ${active === n.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>
<span className="sm:hidden" aria-hidden="true">{n.short}</span><span className="hidden sm:inline" aria-hidden="true">{n.label}</span>
</button>
))}
</nav>
<div className="ml-auto"><AccountMenu /></div>
<div className="ml-auto flex items-center gap-2">
{openPalette && (
<button type="button" onClick={openPalette} aria-label="Search and go to"
className="flex items-center gap-2 rounded-full border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-1 text-xs text-[var(--r-sub)] hover:bg-black/5">
<SearchIcon /><span className="hidden sm:inline">Search</span><kbd className="hidden rounded border border-[var(--r-line)] px-1 text-[10px] md:inline">⌘K</kbd>
</button>
)}
<AccountMenu />
</div>
</div>
</header>
<div id="content" tabIndex={-1} className="focus:outline-none">
<div className={`mx-auto flex ${maxWidth} flex-wrap items-center gap-3 px-4 pt-6`}>
{onBack && (
<button type="button" onClick={onBack} aria-label="Back" className="-ml-2 flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /></button>
@@ -88,6 +101,7 @@ export default function PageShell({ title, onBack, actions, active = null, maxWi
{actions && <div className="ml-auto flex flex-wrap items-center gap-2">{actions}</div>}
</div>
{children}
</div>
</div>
);
}
+13 -3
View File
@@ -6,8 +6,9 @@ import { themeVars, READER_THEMES } from '../reader/readerThemes.js';
import { ChevronLeft } from '../reader/icons.jsx';
export const STAGES = [
{ id: 'setup', label: 'Outline' },
{ id: 'setup', label: 'Chunks' },
{ id: 'study', label: 'Study' },
{ id: 'outline', label: 'Outline' },
{ id: 'script', label: 'Script' },
{ id: 'export', label: 'Export' },
];
@@ -16,7 +17,7 @@ const THEME_ORDER = ['paper', 'sepia', 'dark'];
export default function StudioShell({ stage, loading = false, fixedHeight = false, children }) {
const {
goHome, setCurrentPage, project, allChunks,
saveStatus, syncStatus, retrySync,
saveStatus, syncStatus, retrySync, syncConflict, keepMyVersion, useServerVersion,
readerTheme, setReaderTheme,
} = useApp();
const noChunks = allChunks.length === 0;
@@ -34,7 +35,7 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
const disabled = s.id !== 'setup' && (!project || noChunks);
return (
<button key={s.id} type="button" disabled={disabled} aria-current={stage === s.id ? 'step' : undefined}
title={disabled ? 'Add a chunk in Outline first' : undefined}
title={disabled ? 'Add a chunk in Chunks first' : undefined}
onClick={() => setCurrentPage(s.id)}
className={`rounded-full px-3.5 py-1.5 text-xs ${stage === s.id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'} disabled:opacity-40 disabled:hover:bg-transparent`}>
{s.label}
@@ -54,6 +55,15 @@ export default function StudioShell({ stage, loading = false, fixedHeight = fals
</div>
</div>
</div>
{syncConflict && project && syncConflict.id === project.id && (
<div role="alert" className="flex shrink-0 flex-wrap items-center justify-center gap-x-4 gap-y-2 border-b border-[var(--r-line)] bg-[var(--r-hl)] px-4 py-2.5 text-sm">
<span>This study was changed on another device. Your edits here are saved on this device, but not yet synced.</span>
<span className="flex gap-2">
<button type="button" onClick={keepMyVersion} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] hover:opacity-90">Keep this version</button>
<button type="button" onClick={useServerVersion} className="rounded-full border border-[var(--r-text)] px-3 py-1 text-xs font-medium hover:bg-black/5">Use the other version</button>
</span>
</div>
)}
{children}
</div>
);
+106 -35
View File
@@ -6,21 +6,29 @@ import { bookOptions, CHAPTER_COUNTS, parseBibleChapter } from '../utils/bibleUt
import { useReaderMarksSync } from './useReaderMarksSync.js';
import { usePrefsSync } from './usePrefsSync.js';
import { COMMENTARIES } from './useVerseStudy.js';
import { READER_TRANSLATIONS, fetchChapter } from '../utils/bibleText.js';
import { mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
export const READER_TRANSLATIONS = [
{ id: 'BSB', name: 'Berean Standard Bible' },
{ id: 'ESV', name: 'English Standard Version', licensed: true,
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' },
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net',
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' },
{ id: 'NLT', name: 'New Living Translation', licensed: true,
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
notice: 'Christian Standard Bible® and CSB® are federally registered trademarks of Holman Bible Publishers. All rights reserved. bhpublishinggroup.com' },
{ id: 'NASB', name: 'New American Standard Bible', licensed: true,
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' },
];
export { READER_TRANSLATIONS };
// A per-device choice kept in localStorage: useStoredChoice('reader-font', 'serif', ['serif', 'sans']).
function useStoredChoice(key, fallback, allowed) {
const [value, setValue] = useState(() => {
try {
const saved = localStorage.getItem(key);
return saved !== null && allowed.includes(saved) ? saved : fallback;
} catch { return fallback; }
});
const set = (next) => {
setValue(next);
try { localStorage.setItem(key, next); } catch {}
};
return [value, set];
}
export const READER_FONTS = ['serif', 'sans', 'easy'];
export const READER_SPACINGS = ['snug', 'normal', 'airy'];
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
@@ -91,6 +99,12 @@ export function useBibleReader(currentPage, translationOverride = null) {
setReaderWideState(!!on);
try { localStorage.setItem('reader-wide', on ? '1' : '0'); } catch {}
};
// Typography and a second Bible shown beside the first — all per-device, like text size.
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(() => {
try {
@@ -136,6 +150,21 @@ export function useBibleReader(currentPage, translationOverride = null) {
return Array.isArray(val) ? val : [];
} catch { return []; }
});
// What each highlight colour means to this reader ({ '#fef08a': 'Promises' }); follows the account.
const [readerColorLabels, setReaderColorLabelsState] = useState(() => {
try {
const val = JSON.parse(localStorage.getItem('reader-color-labels') || '{}');
return val && typeof val === 'object' && !Array.isArray(val) ? val : {};
} catch { return {}; }
});
const setReaderColorLabel = (color, label) => {
setReaderColorLabelsState((prev) => {
const next = { ...prev };
if (label.trim()) next[color] = label.slice(0, 30); else delete next[color];
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
return next;
});
};
// Debounce timer ref so rapid stroke updates don't flood the server
const inkSaveTimerRef = useRef({});
@@ -210,38 +239,25 @@ export function useBibleReader(currentPage, translationOverride = null) {
// BSB comes straight from HelloAO; licensed translations (NLT/CSB/NASB) go
// through the server's api.bible proxy so the API key never reaches the browser.
// The most recent request wins: flipping chapters quickly (or changing translation) must never let a
// slow earlier response replace the page you are now on.
const chapterRequestRef = useRef(0);
const loadReaderChapter = async (bookAbbrev, chapterNumber, translation = readerTranslation) => {
const requestId = ++chapterRequestRef.current;
setReaderLoading(true);
setReaderError('');
try {
let verses;
let copyright = '';
let totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? chapterNumber;
const free = READER_TRANSLATIONS.find((t) => t.id === translation && !t.licensed);
if (free) {
const res = await fetch(`https://bible.helloao.org/api/${free.helloao ?? free.id}/${bookAbbrev}/${chapterNumber}.json`);
if (!res.ok) throw new Error('Unable to load chapter.');
const data = await res.json();
verses = parseBibleChapter(data);
totalChapters = data.book?.numberOfChapters ?? totalChapters;
} else {
const res = await fetch(`/api/bible/${translation}/${bookAbbrev}/${chapterNumber}`, { credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Unable to load ${translation}.`);
verses = data.verses;
copyright = data.copyright ?? '';
}
if (!Array.isArray(verses) || verses.length === 0) {
throw new Error('Invalid Bible data returned.');
}
const { verses, copyright, totalChapters } = await fetchChapter(translation, bookAbbrev, chapterNumber);
if (requestId !== chapterRequestRef.current) return;
setReaderVerses(verses);
setReaderCopyright(copyright);
setReaderTotalChapters(totalChapters);
} catch (err) {
if (requestId !== chapterRequestRef.current) return;
setReaderError(err.message || 'Failed to load chapter.');
setReaderVerses([]);
} finally {
setReaderLoading(false);
if (requestId === chapterRequestRef.current) setReaderLoading(false);
}
};
@@ -256,7 +272,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
if (readerJumpVerse == null || readerLoading) return;
const el = document.getElementById(`reader-verse-${readerJumpVerse}`);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.scrollIntoView({ behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth', block: 'center' });
const prevTransition = el.style.transition;
const prevBackground = el.style.backgroundColor;
el.style.transition = 'background-color 0.4s';
@@ -347,6 +363,16 @@ export function useBibleReader(currentPage, translationOverride = null) {
});
};
// Puts a removed bookmark back with its colour (used by "Undo").
const restoreReaderBookmark = (verseKey, color) => {
setReaderBookmarks((prev) => {
if (prev[verseKey]) return prev;
const next = { ...prev, [verseKey]: color };
try { localStorage.setItem('reader-bookmarks', JSON.stringify(next)); } catch {}
return next;
});
};
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
const key = `${bookAbbrev}_${chapter}`;
setReaderInkByPage((prev) => {
@@ -385,6 +411,16 @@ export function useBibleReader(currentPage, translationOverride = null) {
});
};
// Puts a removed highlight back exactly as it was (used by "Undo").
const restoreTextHighlight = (highlight) => {
setReaderTextHighlights((prev) => {
if (prev.some((h) => h.id === highlight.id)) return prev;
const next = [...prev, highlight];
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
const removeTextHighlight = (id) => {
setReaderTextHighlights(prev => {
const next = prev.filter(h => h.id !== id);
@@ -417,6 +453,30 @@ export function useBibleReader(currentPage, translationOverride = null) {
}
};
// Bookmarks, highlights, colour names and ink from a backup file are added to what is here (nothing is
// removed, and where both have something the copy already here wins).
const importReaderMarks = ({ bookmarks, highlights, colorLabels, ink }) => {
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights }));
setReaderBookmarks(merged.bookmarks);
setReaderTextHighlights(merged.highlights);
try {
localStorage.setItem('reader-bookmarks', JSON.stringify(merged.bookmarks));
localStorage.setItem('readerTextHighlights', JSON.stringify(merged.highlights));
} catch {}
if (colorLabels && Object.keys(colorLabels).length > 0) {
setReaderColorLabelsState((prev) => {
const next = { ...colorLabels, ...prev };
try { localStorage.setItem('reader-color-labels', JSON.stringify(next)); } catch {}
return next;
});
}
// Ink goes through the normal save so it also reaches the account
for (const [key, strokes] of Object.entries(ink ?? {})) {
const [book, chapter] = key.split('_');
if (!readerInkByPage[key] && book && chapter && Array.isArray(strokes)) updateReaderPageInk(book, chapter, strokes);
}
};
// Merges reader ink loaded from the server at sign-in; server wins over local.
const mergeServerInk = useCallback((serverInk) => {
setReaderInkByPage((local) => {
@@ -438,6 +498,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
planTranslation: planTranslationPref, startPage: startPagePref,
position: { book: readerBookAbbrev, chapter: readerChapter },
colorLabels: readerColorLabels,
},
apply: (key, v) => {
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
@@ -446,6 +507,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
else if (key === 'startPage') { setStartPagePrefState(v); try { localStorage.setItem('start-page', v); } catch {} }
else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} }
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
else if (key === 'colorLabels') { setReaderColorLabelsState(v); try { localStorage.setItem('reader-color-labels', JSON.stringify(v)); } catch {} }
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
},
canApplyPosition: () => pageRef.current !== 'reader',
@@ -467,6 +529,11 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerWide, setReaderWide,
readerFont, setReaderFont,
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,
@@ -480,11 +547,14 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerInkByPage,
updateReaderPageInk,
mergeServerInk,
importReaderMarks,
readerTextHighlights,
...marksSync,
...prefsSync,
addTextHighlight,
removeTextHighlight,
restoreTextHighlight,
readerColorLabels, setReaderColorLabel,
bibleIndexStatusFor,
loadBibleIndex,
_bibleIndexCacheRef,
@@ -494,6 +564,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerGoToNextChapter,
jumpToReaderVerse,
toggleReaderBookmark,
restoreReaderBookmark,
cycleBookmarkColor,
loadReaderCrossRefs,
};
+28
View File
@@ -2,6 +2,7 @@
// active chapter tab, and every way of creating chunks (click, shift-click,
// typed range, bulk ranges, cross-chapter spans), plus reorder and delete.
import { useEffect, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
// Every chunk starts blank; all note fields exist from the start.
@@ -99,6 +100,7 @@ function initialChapterIndex(project) {
}
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) {
const { toast } = useToast();
const [setup, setSetup] = useState(() => initialSetupForm(project));
const [titleEdited, setTitleEdited] = useState(false);
const [activeChapterIndex, setActiveChapterIndex] = useState(() => initialChapterIndex(project));
@@ -531,7 +533,33 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
}));
};
// Deleting a chunk also deletes its notes, so it can be taken back for a few seconds.
const deleteChunk = (chunkId) => {
let removed = null;
(project?.chapters ?? []).forEach((ch, chapterIndex) => {
const index = ch.chunks.findIndex((c) => c.id === chunkId);
if (index >= 0) removed = { chunk: ch.chunks[index], chapterIndex, index, wasSelected: project.selectedChunkId === chunkId };
});
if (removed) {
toast('Chunk deleted', {
action: {
label: 'Undo',
onClick: () => updateProject((current) => {
if (current.chapters.some((ch) => ch.chunks.some((c) => c.id === chunkId))) return current;
return {
...current,
selectedChunkId: removed.wasSelected ? chunkId : current.selectedChunkId,
chapters: current.chapters.map((ch, i) => {
if (i !== removed.chapterIndex) return ch;
const chunks = [...ch.chunks];
chunks.splice(Math.min(removed.index, chunks.length), 0, removed.chunk);
return { ...ch, chunks };
}),
};
}),
},
});
}
updateProject((current) => {
const newAllChunks = current.chapters
.flatMap((ch) => ch.chunks)
+42
View File
@@ -0,0 +1,42 @@
// Makes the browser's (and phone's) Back/Forward buttons move between the app's pages. The app keeps
// its page in state rather than in the URL, so without this Back would leave the app altogether.
// Each page change adds a history entry; Back/Forward set the page again from that entry.
import { useEffect, useRef } from 'react';
// Pages that only make sense with a study project open.
export const NEEDS_PROJECT = ['study', 'outline', 'script', 'export'];
export function useHistoryNav({ page, setPage, hasProject, onArrive, enabled = true }) {
const shown = useRef(page);
const hasProjectRef = useRef(hasProject);
hasProjectRef.current = hasProject;
const onArriveRef = useRef(onArrive);
onArriveRef.current = onArrive;
// First entry: record where the app started so Back can return to it.
useEffect(() => {
if (!enabled) return;
try { window.history.replaceState({ page }, ''); } catch { /* history unavailable */ }
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// A page change made inside the app adds a history entry.
useEffect(() => {
if (!enabled || shown.current === page) return;
shown.current = page;
try { window.history.pushState({ page }, ''); } catch { /* history unavailable */ }
}, [page, enabled]);
useEffect(() => {
if (!enabled) return undefined;
const onPop = (e) => {
let target = e.state?.page;
if (!target) return; // an entry that isn't ours (e.g. a hash change)
if (NEEDS_PROJECT.includes(target) && !hasProjectRef.current) target = 'home';
shown.current = target; // so the effect above doesn't push this page again
setPage(target);
onArriveRef.current?.(target);
};
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, [enabled, setPage]);
}
+52
View File
@@ -0,0 +1,52 @@
// Drives "Save for offline": one download at a time, with progress for the button to show,
// a cancel, and a toast when it finishes.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { bibleRefs, bookRefs, downloadChapters, loadSavedBooks, markBookSaved } from '../utils/offline.js';
export function useOnline() {
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine !== false));
useEffect(() => {
const up = () => setOnline(true);
const down = () => setOnline(false);
window.addEventListener('online', up);
window.addEventListener('offline', down);
return () => { window.removeEventListener('online', up); window.removeEventListener('offline', down); };
}, []);
return online;
}
export function useOfflineSave() {
const { toast } = useToast();
const [saved, setSaved] = useState(loadSavedBooks);
const [job, setJob] = useState(null); // { label, done, total } while downloading
const controller = useRef(null);
useEffect(() => () => controller.current?.abort(), []);
const run = useCallback(async (label, translation, refs, { bookKeys = [] } = {}) => {
if (controller.current) return; // one at a time
const abort = new AbortController();
controller.current = abort;
setJob({ label, done: 0, total: refs.length });
const result = await downloadChapters(translation, refs, { signal: abort.signal, onProgress: (done, total) => setJob({ label, done, total }) });
controller.current = null;
setJob(null);
if (abort.signal.aborted) { toast('Download stopped'); return; }
if (result.failed === 0) {
bookKeys.forEach((b) => markBookSaved(translation, b));
setSaved(loadSavedBooks());
toast(`Saved ${label} for offline`);
} else {
toast(`Saved ${result.done} of ${refs.length} chapters — check your connection and try again`, { tone: 'error' });
}
}, [toast]);
return {
saved, job,
isSaved: (translation, book) => (saved[translation] ?? []).includes(book),
saveBook: (translation, book, name) => run(name, translation, bookRefs(book), { bookKeys: [book] }),
saveBible: (translation) => run(`The whole ${translation}`, translation, bibleRefs(), { bookKeys: [...new Set(bibleRefs().map((r) => r.book))] }),
saveRefs: (label, translation, refs) => run(label, translation, refs),
cancel: () => controller.current?.abort(),
};
}
+25
View File
@@ -0,0 +1,25 @@
// The second Bible in the side-by-side view: loads the same chapter in another translation.
// Pass an empty translation to switch it off.
import { useEffect, useState } from 'react';
import { fetchChapter } from '../utils/bibleText.js';
export function useParallelChapter(translation, bookAbbrev, chapter) {
const [state, setState] = useState({ key: '', verses: [], error: '' });
const key = translation ? `${translation}/${bookAbbrev}/${chapter}` : '';
useEffect(() => {
if (!key) return undefined;
const controller = new AbortController();
fetchChapter(translation, bookAbbrev, chapter, { signal: controller.signal })
.then(({ verses }) => setState({ key, verses, error: '' }))
.catch((err) => { if (!controller.signal.aborted) setState({ key, verses: [], error: err.message || 'Unable to load.' }); });
return () => controller.abort();
}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
const ready = state.key === key;
return {
verses: ready ? state.verses : [],
error: ready ? state.error : '',
loading: !!key && !ready,
};
}
+3 -2
View File
@@ -5,7 +5,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { getRemotePrefs, saveRemotePrefs } from '../syncService.js';
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position'];
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position', 'colorLabels'];
const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device }
const PUSH_DELAY_MS = 1000;
const FOCUS_PULL_MIN_MS = 30_000;
@@ -58,7 +58,7 @@ export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}
}, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, push]); // eslint-disable-line react-hooks/exhaustive-deps
}, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, values.colorLabels, push]); // eslint-disable-line react-hooks/exhaustive-deps
const pullServerPrefs = useCallback(async () => {
enabledRef.current = true;
@@ -69,6 +69,7 @@ export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
const meta = readMeta();
const now = Date.now();
for (const key of PREF_KEYS) {
if (valuesRef.current[key] === undefined) continue; // nothing on this device to compare or upload
const remote = server[key];
const localAt = meta[key] ?? 0;
if (remote && remote.at > localAt) {
+50 -8
View File
@@ -1,6 +1,8 @@
// The signed-in user's saved projects: opening, renaming, deleting, and keeping
// this device's copies in step with the server.
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { clearSyncBase, getSyncBase, setSyncBase } from '../utils/syncBase.js';
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import {
saveProjectToStorage,
@@ -21,6 +23,9 @@ export function useProjectLibrary({
setProjectIndex,
setCurrentPage,
}) {
const { toast } = useToast();
// Deleted projects waiting out their "Undo" window before the server copy is removed too
const pendingDeletes = useRef(new Map()); // id -> timer
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
@@ -39,11 +44,17 @@ export function useProjectLibrary({
listRemoteProjects().then(async (result) => {
if (!result.ok || cancelled) return;
const localMap = new Map(localIndex.map((e) => [e.id, e]));
// What this device has seen of each study on the server: a save made on top of anything older is
// checked against it, so a newer copy from another device is never overwritten silently.
for (const e of result.data) {
const local = localMap.get(e.id);
setSyncBase(e.id, local ? Math.min(e.lastEdited ?? 0, local.lastEdited ?? 0) : e.lastEdited);
}
const missing = result.data.filter((e) => !localMap.has(e.id));
if (missing.length > 0) {
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
fetched.filter((r) => r.ok).forEach((r) => saveProjectToStorage(r.data));
fetched.filter((r) => r.ok).forEach((r) => { saveProjectToStorage(r.data); setSyncBase(r.data.id, r.data.lastEdited); });
if (cancelled) return;
const restoredCount = fetched.filter((r) => r.ok).length;
if (restoredCount > 0) {
@@ -78,26 +89,57 @@ export function useProjectLibrary({
const updated = { ...loaded, title, lastEdited: Date.now() };
saveProjectToStorage(updated);
setProjectIndex(loadProjectIndex());
saveRemoteProject(updated);
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
if (result.ok) setSyncBase(id, updated.lastEdited);
else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
});
if (project?.id === id) {
setProject((current) => (current ? { ...current, title } : current));
}
};
const deleteProject = (id) => {
if (!window.confirm('Delete this project? This cannot be undone.')) return;
deleteProjectFromStorage(id);
setProjectIndex(loadProjectIndex());
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
const finishDelete = (id) => {
if (!pendingDeletes.current.has(id)) return;
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
deleteRemoteProject(id); // fire-and-forget
};
useEffect(() => {
const flush = () => [...pendingDeletes.current.keys()].forEach(finishDelete);
window.addEventListener('pagehide', flush);
return () => window.removeEventListener('pagehide', flush);
}, []);
const deleteProject = (id) => {
const loaded = loadProjectById(id);
if (!loaded) return;
deleteProjectFromStorage(id);
clearSyncBase(id);
setProjectIndex(loadProjectIndex());
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
toast(`Deleted “${loaded.title}”`, {
action: {
label: 'Undo',
onClick: () => {
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
saveProjectToStorage(loaded);
setProjectIndex(loadProjectIndex());
},
},
});
};
const pullLatestFromServer = async (id) => {
const result = await loadRemoteProject(id);
if (!result.ok) {
alert('Could not pull latest version from server.');
toast('Could not pull the latest version from the server.', { tone: 'error' });
return;
}
saveProjectToStorage(result.data);
setSyncBase(id, result.data.lastEdited);
setProjectIndex(loadProjectIndex());
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
};
+41 -3
View File
@@ -6,6 +6,8 @@
// shown, instead of being a fire-and-forget call buried inside setState.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getRemoteReadingProgress, saveRemoteReadingProgress } from '../syncService.js';
import { useToast } from '../components/Toasts.jsx';
import { mergeReadingPlans } from '../utils/backup.js';
import { clampPlanDays, localDateString, normalizeCustomPlan, registerCustomPlans } from '../utils/readingPlans.js';
const STORAGE_KEY = 'readingPlanProgress';
@@ -33,7 +35,10 @@ function formatSyncError(result) {
}
export function useReadingPlans() {
const { toast } = useToast();
const [state, setState] = useState(loadLocal);
const stateRef = useRef(state); // the latest state, for taking a copy just before something is removed
stateRef.current = state;
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
const [syncError, setSyncError] = useState('');
// The exact object loadLocal() produced at mount. Compared by reference (not
@@ -167,17 +172,30 @@ export function useReadingPlans() {
updateProgress((prev) => prev.map((p) => (p.id === progressId ? { ...p, translation } : p)));
}, [updateProgress]);
// Restarting and removing are immediate, with an "Undo" toast that puts the plan back exactly as it was.
const restartPlan = useCallback((progressId) => {
const before = stateRef.current.progress.find((p) => p.id === progressId);
updateProgress((prev) => prev.map((p) => (
p.id === progressId
? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
: p
)));
}, [updateProgress]);
if (before) toast('Plan restarted from day 1', { action: { label: 'Undo', onClick: () => updateProgress((prev) => prev.map((p) => (p.id === progressId ? before : p))) } });
}, [updateProgress, toast]);
const removePlanProgress = useCallback((progressId) => {
const index = stateRef.current.progress.findIndex((p) => p.id === progressId);
const before = stateRef.current.progress[index];
updateProgress((prev) => prev.filter((p) => p.id !== progressId));
}, [updateProgress]);
if (before) {
toast('Plan removed', {
action: {
label: 'Undo',
onClick: () => updateProgress((prev) => (prev.some((p) => p.id === progressId) ? prev : [...prev.slice(0, index), before, ...prev.slice(index)])),
},
});
}
}, [updateProgress, toast]);
// Resets the pace clock so a plan that's fallen behind schedule stops being
// flagged as "behind" — doesn't touch which days are actually marked done.
@@ -212,9 +230,22 @@ export function useReadingPlans() {
}, [updateCustomPlans]);
const removeCustomPlan = useCallback((planId) => {
const plan = stateRef.current.customPlans.find((p) => p.id === planId);
const runs = stateRef.current.progress.filter((p) => p.planId === planId);
updateCustomPlans((prev) => prev.filter((p) => p.id !== planId));
updateProgress((prev) => prev.filter((p) => p.planId !== planId));
}, [updateCustomPlans, updateProgress]);
if (plan) {
toast(`Deleted “${plan.title}”`, {
action: {
label: 'Undo',
onClick: () => {
updateCustomPlans((prev) => (prev.some((p) => p.id === planId) ? prev : [plan, ...prev]));
updateProgress((prev) => [...runs.filter((r) => !prev.some((p) => p.id === r.id)), ...prev]);
},
},
});
}
}, [updateCustomPlans, updateProgress, toast]);
// Patches an existing custom plan. Pass resetProgress: true when the edit
// changes its books/days (day indices from before the edit no longer mean
@@ -230,6 +261,11 @@ export function useReadingPlans() {
}
}, [updateCustomPlans, updateProgress]);
// Plans from a backup file are added to what is here (nothing is removed).
const importReadingPlans = useCallback((incoming) => {
setState((prev) => ({ ...prev, ...mergeReadingPlans(prev, incoming) }));
}, []);
// Server wins over whatever's local at sign-in.
const mergeServerReadingProgress = useCallback((serverData) => {
if (!serverData || typeof serverData !== 'object' || Array.isArray(serverData)) {
@@ -280,6 +316,8 @@ export function useReadingPlans() {
}, [state]);
return {
readingPlansState: state,
importReadingPlans,
planProgress: state.progress,
customPlans,
readingActivity: state.activity,
+40
View File
@@ -49,3 +49,43 @@ select {
.scrollbar-thin::-webkit-scrollbar-track {
background: rgba(148, 163, 184, 0.12);
}
/* A clear ring wherever keyboard focus lands (mouse clicks don't show it). */
:where(button, a, summary, [role='button'], [role='tab'], [role='option'], input, select, textarea):focus-visible {
outline: 2px solid var(--r-accent, #a84e27);
outline-offset: 2px;
}
/* "Skip to content" appears only when it's tabbed to. */
.skip-link {
position: absolute;
left: 0.75rem;
top: -4rem;
z-index: 100;
padding: 0.5rem 0.9rem;
border-radius: 9999px;
background: var(--r-text, #2b2620);
color: var(--r-bg, #faf7f0);
font-size: 0.875rem;
}
.skip-link:focus {
top: 0.75rem;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !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;
}
+6 -3
View File
@@ -9,6 +9,7 @@ import {
loadProjectIndex,
} from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
export default function HomePage() {
@@ -215,10 +216,12 @@ export default function HomePage() {
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
</div>
)}
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]">{entry.chapterSummary}</p>}
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
{entry.tags?.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{entry.tags.map((tag) => <span key={tag} className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]">{tag}</span>)}
// A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them)
<div className="mt-2 flex flex-wrap gap-1" title={entry.tags.length > 3 ? entry.tags.join(', ') : undefined}>
{entry.tags.slice(0, 3).map((tag) => <span key={tag} className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]">{tag}</span>)}
{entry.tags.length > 3 && <span className="rounded-full px-1.5 py-0.5 text-[11px] text-[var(--r-sub)]">+{entry.tags.length - 3} more</span>}
</div>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]">{formatRelativeDate(entry.lastEdited)}</p>
+335
View File
@@ -0,0 +1,335 @@
// The Outline stage: the shape of the message as a real outline — main points (I.), sub-points (A.),
// and deeper (1., a.). There is one outline for the whole study and one for each chunk (pick which at
// the top). A point can be an illustration, application, quote…, carry minutes, a longer note, a verse's
// words, and cross-references or word studies from its chunk. Print it, share it, or take it as a file.
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useToast } from '../components/Toasts.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
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 {
KINDS, STUDY, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
} from '../utils/outline.js';
const iconBtn = 'flex h-7 w-7 items-center justify-center rounded-md text-sm text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] disabled:opacity-30 disabled:hover:bg-transparent';
const btn = 'rounded-xl border border-[var(--r-line)] px-3.5 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
const field = 'mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]';
const chip = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]';
// A text box that grows with what is typed, so a long point never hides behind a scroll bar.
function AutoText({ value, onChange, className, ...rest }) {
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, [value]);
return <textarea ref={ref} rows={1} value={value} onChange={(e) => onChange(e.target.value)} className={className} {...rest} />;
}
const pointBox = 'block w-full resize-none overflow-hidden rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-[15px] leading-snug text-[var(--r-text)] placeholder:text-[var(--r-sub)] hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none';
export default function OutlinePage() {
const { project, updateProject, setCurrentPage } = useApp();
const { toast } = useToast();
const [chosen, setChosen] = useState(STUDY);
const scope = chosen === STUDY || chunkOf(project, chosen) ? chosen : STUDY; // a deleted chunk falls back to the whole study
const scopeChunk = scope === STUDY ? null : chunkOf(project, scope);
const tree = outlineFor(project, scope);
const rows = flatten(tree);
const [pendingFocus, setPendingFocus] = useState(null);
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
const [lookup, setLookup] = useState({ id: null, busy: false, error: '' });
// The one way every change is made, always from the latest outline (typing is fast).
const edit = (fn) => updateProject((current) => withOutline(current, scope, fn(outlineFor(current, scope))));
const patch = (id, change) => edit((t) => updateNode(t, id, change));
useEffect(() => {
if (!pendingFocus) return;
document.querySelector(`[data-outline-id="${pendingFocus}"]`)?.focus();
setPendingFocus(null);
}, [pendingFocus, tree]);
// Every chunk, named by passage, for the scope picker and the "linked chunk" choice
const chunkChoices = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => chapter.chunks.forEach((chunk) => {
chunkChoices.push({ id: chunk.id, label: formatChunkReference(project, chapterIndex, chunk, '–'), count: countNodes(chunk.outline ?? []) });
}));
const scopeLabel = scope === STUDY ? 'Whole study' : chunkChoices.find((c) => c.id === scope)?.label ?? '';
const title = scope === STUDY ? project.title : `${project.title} — ${scopeLabel}`;
const subtitle = scope === STUDY ? compactPassages(buildChapterSummary(project)) : `${scopeLabel} · ${project.translation ?? 'BSB'}`;
const outline = { title, subtitle, tree };
const addable = scope === STUDY
? seedFromChunks(project, tree).length - tree.length
: seedFromVerses(project, scope, tree).length - tree.length;
const seedLabel = scope === STUDY ? 'Add my chunks' : 'Add the verses';
const addMain = () => { const node = newNode(); edit((t) => addRoot(t, node)); setPendingFocus(node.id); };
const addSub = (id) => { const node = newNode(); edit((t) => addChild(t, id, node)); setPendingFocus(node.id); };
const addSibling = (id) => { const node = newNode(); edit((t) => addAfter(t, id, node)); setPendingFocus(node.id); };
const seed = () => {
edit((t) => (scope === STUDY ? seedFromChunks(project, t) : seedFromVerses(project, scope, t)));
toast(`Added ${addable} point${addable === 1 ? '' : 's'}`);
};
const remove = (id) => {
const where = locate(tree, id);
const gone = getNode(tree, id);
if (!where || !gone) return;
const index = rows.findIndex((r) => r.node.id === id);
edit((t) => removeNode(t, id));
setPendingFocus(rows[index - 1]?.node.id ?? null);
const count = countNodes([gone]);
toast(count > 1 ? `Deleted a point and its ${count - 1} sub-point${count > 2 ? 's' : ''}` : 'Deleted a point', {
action: { label: 'Undo', onClick: () => edit((t) => insertAt(t, where.parentId, where.index, gone)) },
});
};
const onKeyDown = (e, id, text, hasChildren) => {
if (e.nativeEvent.isComposing) return;
const at = rows.findIndex((r) => r.node.id === id);
const el = e.currentTarget;
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); addSibling(id); }
else if (e.key === 'Tab') { e.preventDefault(); edit((t) => (e.shiftKey ? outdent(t, id) : indent(t, id))); setPendingFocus(id); }
else if (e.key === 'Backspace' && text === '' && !hasChildren) { e.preventDefault(); remove(id); }
else if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { e.preventDefault(); edit((t) => moveNode(t, id, e.key === 'ArrowUp' ? -1 : 1)); setPendingFocus(id); }
else if (e.key === 'ArrowUp' && el.selectionStart === 0 && rows[at - 1]) { e.preventDefault(); setPendingFocus(rows[at - 1].node.id); }
else if (e.key === 'ArrowDown' && el.selectionStart === text.length && rows[at + 1]) { e.preventDefault(); setPendingFocus(rows[at + 1].node.id); }
};
// ---- taking it out: copy, share, print, files
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' }); } };
const openChunk = (chunkId) => {
updateProject((current) => ({ ...current, selectedChunkId: chunkId }));
setCurrentPage('study');
};
const toggleMeta = (id) => setMetaOpen((s) => { const next = new Set(s); if (next.has(id)) next.delete(id); else next.add(id); return next; });
// Puts a passage's words into a point (the point's passage field says which).
const addVerseText = async (node) => {
setLookup({ id: node.id, busy: true, error: '' });
try {
const got = await fetchPassageText(node.ref, project.translation ?? 'BSB');
patch(node.id, { scripture: got });
setLookup({ id: null, busy: false, error: '' });
} catch (err) {
setLookup({ id: node.id, busy: false, error: err.message || 'Could not load those verses.' });
}
};
return (
<StudioShell stage="outline">
<main className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-4">
<h1 className="text-xl font-medium">Outline</h1>
<p className="mt-1 text-sm text-[var(--r-sub)]">The shape of the message. Keep one for the whole study, and one for each chunk if you like.</p>
</div>
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2">
<label className="flex items-center gap-2 text-sm text-[var(--r-sub)]">Outline for
<select value={scope} onChange={(e) => setChosen(e.target.value)} aria-label="Outline for"
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
<option value={STUDY}>Whole study{countNodes(project.outline ?? []) ? ` · ${countNodes(project.outline)}` : ''}</option>
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}{c.count ? ` · ${c.count}` : ''}</option>)}
</select>
</label>
{timeSummary(tree) && <span className="text-sm text-[var(--r-sub)]">{timeSummary(tree)}</span>}
{scopeChunk && <button type="button" onClick={() => openChunk(scope)} className="text-sm text-[var(--r-accent)] hover:underline">Open this chunk in Study</button>}
</div>
<div className="mb-5 flex flex-wrap items-center gap-2">
<button type="button" onClick={addMain} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ Main point</button>
<button type="button" onClick={seed} disabled={addable <= 0} className={btn}
title={scope === STUDY ? "Adds a main point for each chunk that isn't in the outline yet" : "Adds a point for each verse of this chunk, with its words"}>
{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={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>
<div className="absolute left-0 z-20 mt-1 w-48 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-1 shadow-lg">
<button type="button" onClick={copy} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Copy as text</button>
<button type="button" onClick={word} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Download Word (.docx)</button>
<button type="button" onClick={() => downloadOutlineMarkdown(outline)} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Download Markdown</button>
</div>
</details>
</div>
{tree.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--r-line)] p-10 text-center">
<p className="text-base font-medium">No outline yet{scope === STUDY ? '' : ` for ${scopeLabel}`}</p>
<p className="mx-auto mt-2 max-w-md text-sm text-[var(--r-sub)]">
{scope === STUDY
? 'Start from your chunks to get a main point for each passage you studied, or start blank and write your own.'
: 'Start from the verses to get a point for each one, with its words, or start blank and write your own.'}
</p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
<button type="button" onClick={seed} disabled={addable <= 0} 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">
{scope === STUDY ? 'Start from my chunks' : 'Start from the verses'}
</button>
<button type="button" onClick={addMain} className={btn}>Start blank</button>
</div>
</div>
) : (
<ul aria-label="Outline" className="space-y-0.5">
{rows.map(({ node, depth, label }, at) => {
const hasChildren = node.children.length > 0;
const showMeta = metaOpen.has(node.id);
const linked = chunkChoices.find((c) => c.id === node.chunkId);
const siblings = rows.filter((r) => r.parentId === rows[at].parentId);
const place = siblings.findIndex((r) => r.node.id === node.id);
const attachSource = chunkOf(project, node.chunkId ?? scopeChunk?.id);
const attached = new Set((node.attachments ?? []).map((a) => a.id));
const available = attachableFromChunk(attachSource).filter((a) => !attached.has(a.key));
const mins = Number.isFinite(node.minutes) && node.minutes > 0;
const noteLine = (node.note ?? '').trim().split('\n')[0];
return (
<li key={node.id} className="group" style={{ paddingLeft: `${depth * 1.75}rem` }}>
<div className="flex items-start gap-1.5">
<span className="w-9 shrink-0 pt-1.5 text-right font-serif text-sm text-[var(--r-sub)]" aria-hidden="true">{label}</span>
<div className="min-w-0 flex-1">
<AutoText
value={node.text} data-outline-id={node.id} className={pointBox}
aria-label={`Point ${label}`} placeholder={depth === 0 ? 'Main point' : 'Sub-point'}
onChange={(value) => patch(node.id, { text: value })}
onKeyDown={(e) => onKeyDown(e, node.id, node.text, hasChildren)}
/>
{/* What the point carries, at a glance */}
{(node.kind || mins || node.ref || linked) && (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-[var(--r-sub)]">
{node.kind && <span className={`${chip} border-[var(--r-accent)] text-[var(--r-accent)]`}>{kindLabel(node.kind)}</span>}
{mins && <span>{node.minutes} min</span>}
{node.ref && <span>{node.ref}</span>}
{linked && node.chunkId !== scope && <button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button>}
</p>
)}
{noteLine && !showMeta && <p className="truncate px-2 text-xs italic text-[var(--r-sub)]">{noteLine}</p>}
{node.scripture?.text && !showMeta && (
<blockquote className="mx-2 mt-1 border-l-2 border-[var(--r-line)] pl-2.5 font-serif text-sm italic text-[var(--r-sub)]">“{node.scripture.text.length > 220 ? `${node.scripture.text.slice(0, 220)}…` : node.scripture.text}” <span className="not-italic">— {node.scripture.ref}</span></blockquote>
)}
{(node.attachments ?? []).length > 0 && !showMeta && (
<div className="mt-1 flex flex-wrap gap-1 px-2">
{node.attachments.map((a) => <span key={a.id} className={chip} title={a.detail}>{a.type === 'word' ? 'Word' : 'See'}: {a.label}</span>)}
</div>
)}
{showMeta && (
<div className="mb-2 mt-1 space-y-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3">
<div className="grid gap-3 sm:grid-cols-[1fr_6rem]">
<label className="text-xs text-[var(--r-sub)]">Type
<select value={node.kind ?? ''} onChange={(e) => patch(node.id, { kind: e.target.value })} className={field}>
{KINDS.map(([id, text]) => <option key={id} value={id}>{text}</option>)}
</select>
</label>
<label className="text-xs text-[var(--r-sub)]">Minutes
<input type="number" min="0" max="240" inputMode="numeric" value={node.minutes ?? ''}
onChange={(e) => patch(node.id, { minutes: e.target.value === '' ? null : Math.max(0, Math.min(240, Math.round(Number(e.target.value)))) })} className={field} />
</label>
</div>
<label className="block text-xs text-[var(--r-sub)]">Notes
<AutoText value={node.note ?? ''} onChange={(value) => patch(node.id, { note: value })} placeholder="Anything to remember when you speak or write this point"
className={`${field} min-h-[4.5rem] resize-none overflow-hidden`} aria-label={`Notes for ${label}`} />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="text-xs text-[var(--r-sub)]">Passage
<input value={node.ref ?? ''} onChange={(e) => patch(node.id, { ref: e.target.value })} placeholder="e.g. Titus 1:5–9" className={field} />
</label>
<div className="mt-1.5 flex items-center gap-2">
<button type="button" disabled={!node.ref?.trim() || (lookup.busy && lookup.id === node.id)} onClick={() => addVerseText(node)} className={`${btn} !px-3 !py-1 text-xs`}>
{lookup.busy && lookup.id === node.id ? 'Loading…' : node.scripture ? 'Replace verse text' : 'Add verse text'}
</button>
{node.scripture && <button type="button" onClick={() => patch(node.id, { scripture: null })} className="text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove</button>}
</div>
{lookup.id === node.id && lookup.error && <p role="alert" className="mt-1 text-xs text-[var(--r-danger,#b42318)]">{lookup.error}</p>}
</div>
<label className="text-xs text-[var(--r-sub)]">Linked chunk
<select value={node.chunkId ?? ''} onChange={(e) => patch(node.id, {
chunkId: e.target.value || null,
...(e.target.value && !node.ref ? { ref: chunkChoices.find((c) => c.id === e.target.value)?.label ?? '' } : {}),
})} className={field}>
<option value="">None</option>
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</label>
</div>
{node.scripture?.text && (
<blockquote className="border-l-2 border-[var(--r-line)] pl-2.5 font-serif text-sm italic text-[var(--r-sub)]">“{node.scripture.text}” <span className="not-italic">— {node.scripture.ref} ({node.scripture.translation})</span></blockquote>
)}
<div>
<p className="text-xs text-[var(--r-sub)]">Attach from {attachSource ? 'its chunk' : 'a chunk'}</p>
{(node.attachments ?? []).length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{node.attachments.map((a) => (
<span key={a.id} className={`${chip} inline-flex items-center gap-1`} title={a.detail}>
{a.type === 'word' ? 'Word' : 'See'}: {a.label}
<button type="button" aria-label={`Remove ${a.label}`} onClick={() => patch(node.id, { attachments: node.attachments.filter((x) => x.id !== a.id) })} className="hover:text-[var(--r-danger,#b42318)]">×</button>
</span>
))}
</div>
)}
{!attachSource ? (
<p className="mt-1 text-xs text-[var(--r-sub)]">Link a chunk above to attach its cross-references and word studies.</p>
) : available.length === 0 ? (
<p className="mt-1 text-xs text-[var(--r-sub)]">{(node.attachments ?? []).length ? 'Everything from this chunk is attached.' : 'This chunk has no cross-references or word studies yet. Add some in the Study stage.'}</p>
) : (
<select value="" aria-label={`Attach to ${label}`} className={field}
onChange={(e) => {
const pick = available.find((a) => a.key === e.target.value);
if (pick) patch(node.id, { attachments: [...(node.attachments ?? []), { id: pick.key, type: pick.type, label: pick.label, detail: pick.detail }] });
}}>
<option value="">Choose a cross-reference or word…</option>
{available.map((a) => <option key={a.key} value={a.key}>{a.type === 'word' ? 'Word' : 'See'}: {a.label}</option>)}
</select>
)}
</div>
</div>
)}
</div>
<div className="flex shrink-0 items-center opacity-0 transition focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
<button type="button" className={iconBtn} aria-label={`Move ${label} out a level`} title="Out a level (Shift+Tab)" disabled={depth === 0} onClick={() => edit((t) => outdent(t, node.id))}>←</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} in a level`} title="In a level (Tab)" disabled={place === 0} onClick={() => edit((t) => indent(t, node.id))}>→</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} up`} title="Up (Alt+↑)" disabled={place === 0} onClick={() => edit((t) => moveNode(t, node.id, -1))}>↑</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} down`} title="Down (Alt+↓)" disabled={place === siblings.length - 1} onClick={() => edit((t) => moveNode(t, node.id, 1))}>↓</button>
<button type="button" className={iconBtn} aria-label={`Add a sub-point under ${label}`} title="Add a sub-point" onClick={() => addSub(node.id)}>+</button>
<button type="button" className={iconBtn} aria-label={`Details for ${label}`} aria-expanded={showMeta} title="Type, minutes, notes, verse text, attachments" onClick={() => toggleMeta(node.id)}>⋯</button>
<button type="button" className={`${iconBtn} hover:!text-[var(--r-danger,#b42318)]`} aria-label={`Delete ${label}`} title="Delete" onClick={() => remove(node.id)}>×</button>
</div>
</div>
</li>
);
})}
</ul>
)}
{tree.length > 0 && (
<p className="mt-6 text-xs text-[var(--r-sub)]">
Enter adds a point · Tab and Shift+Tab change its level · Alt+↑ / ↓ moves it · Backspace on an empty point deletes it · ⋯ opens its details.
</p>
)}
</main>
</StudioShell>
);
}
+105 -12
View File
@@ -5,7 +5,9 @@ import { useApp } from '../context/AppContext.js';
import { bookOptions } from '../utils/bibleUtils.jsx';
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js';
import DrawCanvas from './DrawCanvas.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import { useParallelChapter } from '../hooks/useParallelChapter.js';
import { useOfflineSave, useOnline } from '../hooks/useOfflineSave.js';
import { useBibleSearch } from '../hooks/useBibleSearch.js';
import { themeVars } from '../components/reader/readerThemes.js';
import { TopBar, BottomBar } from '../components/reader/ReaderBars.jsx';
@@ -13,8 +15,11 @@ 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 BookmarksSheet from '../components/reader/BookmarksSheet.jsx';
import MarksSheet from '../components/reader/MarksSheet.jsx';
import { useToast } from '../components/Toasts.jsx';
import { HIGHLIGHT_COLORS, colorTitle } from '../utils/highlightColors.js';
import AudioSheet from '../components/reader/AudioSheet.jsx';
import VerseSheet from '../components/reader/VerseSheet.jsx';
import PlanSheet from '../components/reader/PlanSheet.jsx';
@@ -22,7 +27,6 @@ import PlanFooter from '../components/reader/PlanFooter.jsx';
import { FocusIcon } from '../components/reader/icons.jsx';
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
const HIGHLIGHT_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'];
export default function ReaderPage() {
const {
@@ -40,6 +44,11 @@ export default function ReaderPage() {
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerWide: readerWideLayout, setReaderWide: setReaderWideLayout,
readerFont, setReaderFont,
readerSpacing, setReaderSpacing,
readerVerseNumbers, setReaderVerseNumbers,
readerParallel, setReaderParallel,
readerShowDiff, setReaderShowDiff,
readerBookmarks,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
@@ -56,6 +65,7 @@ export default function ReaderPage() {
readerGoToNextChapter,
jumpToReaderVerse,
toggleReaderBookmark,
restoreReaderBookmark,
cycleBookmarkColor,
loadReaderCrossRefs,
loadBibleIndex,
@@ -69,6 +79,8 @@ export default function ReaderPage() {
readerTextHighlights,
addTextHighlight,
removeTextHighlight,
restoreTextHighlight,
readerColorLabels, setReaderColorLabel,
planProgress,
toggleDay,
toggleChapterRead,
@@ -77,10 +89,18 @@ export default function ReaderPage() {
openPlanReading,
studyReadingPlanDay,
studyBuildBusy,
readerRequest, clearReaderRequest,
} = useApp();
const { toast } = useToast();
const offline = useOfflineSave();
const online = useOnline();
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
const readerNotice = READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.notice ?? '';
// A second Bible beside the first (never the same one twice, and not while drawing)
const parallelId = readerParallel && readerParallel !== readerTranslation ? readerParallel : '';
const parallelChapter = useParallelChapter(parallelId, readerBookAbbrev, readerChapter);
const parallelSource = READER_TRANSLATIONS.find((t) => t.id === parallelId);
const [sheet, setSheet] = useState(null); // null | 'nav' | 'settings' | 'search' | 'bookmarks' | 'audio'
const [chromeVisible, setChromeVisible] = useState(true);
@@ -203,6 +223,17 @@ export default function ReaderPage() {
}, [bibleIndex, bibleIndexLower, deferredBibleQuery]);
const remoteSearch = useBibleSearch(readerTranslation, readerSearch, wholeBibleSearching && !localSearch);
// "Search the Bible for …" from the ⌘K palette: open the whole-Bible search with the text filled in.
useEffect(() => {
if (!readerRequest?.search) return;
setReaderSearchScope('bible');
setReaderSearch(readerRequest.search);
setReaderSearchActive(true);
setSheet('search');
if (localSearch && indexStatus === 'idle') loadBibleIndex(readerTranslation);
clearReaderRequest();
}, [readerRequest]); // eslint-disable-line react-hooks/exhaustive-deps
const openSheet = (name) => {
setSheet(name);
if (name === 'search') setReaderSearchActive(true);
@@ -233,9 +264,21 @@ export default function ReaderPage() {
useEffect(() => {
const handleKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 'ArrowLeft') readerGoToPreviousChapter();
else if (e.key === 'ArrowRight') readerGoToNextChapter();
else if (e.key === 'Escape') { if (sheet) closeSheet(); else if (readerDrawMode) setReaderDrawMode(false); else setReaderSelectedVerse(null); }
else if (!sheet && !readerDrawMode) {
// One-key shortcuts (the "?" list shows them)
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);
}
}
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
@@ -249,10 +292,22 @@ export default function ReaderPage() {
return () => document.removeEventListener('pointerdown', dismiss);
}, [selectionPicker]);
// Removing a highlight or bookmark is one tap, so it can always be taken back.
const removeHighlight = (id) => {
const gone = (readerTextHighlights || []).find((h) => h.id === id);
removeTextHighlight(id);
if (gone) toast('Highlight removed', { action: { label: 'Undo', onClick: () => restoreTextHighlight(gone) } });
};
const removeBookmark = (key, color) => {
toggleReaderBookmark(key);
toast('Bookmark removed', { action: { label: 'Undo', onClick: () => restoreReaderBookmark?.(key, color) } });
};
// 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 });
@@ -267,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;
@@ -276,10 +336,13 @@ export default function ReaderPage() {
if (s.type === 'hl') {
parts.push(
<mark key={s.id} style={{ backgroundColor: s.color, color: '#2b2620', borderRadius: '2px', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); removeTextHighlight(s.id); }} title="Tap to remove highlight">
onClick={(e) => { e.stopPropagation(); removeHighlight(s.id); }}
title={`${colorTitle(s.color, readerColorLabels ?? {})} · tap to remove`}>
{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>);
}
@@ -307,7 +370,7 @@ export default function ReaderPage() {
const selectedText = sel.toString();
const startOffset = verse.text.indexOf(selectedText);
if (startOffset === -1) return;
setSelectionPicker({ x: (rect.left + rect.right) / 2, y: rect.top, verse: verseNum, startOffset, endOffset: startOffset + selectedText.length });
setSelectionPicker({ x: (rect.left + rect.right) / 2, y: rect.top, verse: verseNum, startOffset, endOffset: startOffset + selectedText.length, text: selectedText });
}, 20);
}
@@ -317,6 +380,12 @@ export default function ReaderPage() {
return { key, color, bookAbbrev: bAbbrev, bookName: bookOptions[bookIndex]?.name ?? bAbbrev, chapter: Number(chapterStr), verse: Number(verseStr), bookIndex };
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse);
// Highlights newest-last in reading order, with the book's name for the list
const highlightEntries = (readerTextHighlights || []).map((h) => {
const bookIndex = bookOptions.findIndex((b) => b.abbrev === h.book);
return { ...h, bookName: bookOptions[bookIndex]?.name ?? h.book, bookIndex };
}).sort((a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse || a.startOffset - b.startOffset);
const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null;
const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null;
const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`;
@@ -337,7 +406,8 @@ export default function ReaderPage() {
onOpenNavigator={() => openSheet('nav')}
onSearch={() => openSheet('search')}
onBookmarks={() => openSheet('bookmarks')}
bookmarkCount={bookmarkEntries.length}
bookmarkCount={bookmarkEntries.length + highlightEntries.length}
onCompare={() => openSheet('compare')} compareActive={!!parallelId}
onSettings={() => openSheet('settings')}
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
/>
@@ -359,11 +429,20 @@ export default function ReaderPage() {
/>
)}
{!online && (
<p role="status" className="border-b border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-1.5 text-center font-sans text-xs text-[var(--r-sub)]">
You’re offline. Chapters you’ve read or saved still open; save a whole book from “Go to” while you’re connected.
</p>
)}
<div className={paneOpen ? 'lg:pr-[24rem]' : ''}>
{!readerDrawMode && (
<ReaderText
title={`${readerBook?.name ?? ''} ${readerChapter}`}
translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation}
translationId={readerTranslation}
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}
loading={readerLoading}
@@ -445,7 +524,7 @@ export default function ReaderPage() {
crossRefsLoading={readerCrossRefsLoading}
onLoadCrossRefs={() => { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
bookmarkColor={readerBookmarks[selectedKey]}
onToggleBookmark={() => toggleReaderBookmark(selectedKey)}
onToggleBookmark={() => (readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey))}
onCycleColor={() => cycleBookmarkColor(selectedKey)}
onJump={jump}
onClose={() => setReaderSelectedVerse(null)}
@@ -471,7 +550,7 @@ export default function ReaderPage() {
bookAbbrev={readerBookAbbrev} chapter={readerChapter}
translation={readerTranslation} onTranslation={setReaderTranslation}
translationNote={ctxInfo ? `Reading “${ctxInfo.plan.title}”: this sets the plan’s translation.` : ''}
onGo={goTo}
onGo={goTo} offline={offline}
/>
<SettingsSheet
open={sheet === 'settings'} onClose={closeSheet}
@@ -479,6 +558,14 @@ export default function ReaderPage() {
theme={readerTheme} onTheme={setReaderTheme}
layout={readerLayout} onLayout={setReaderLayout}
wide={readerWideLayout} onWide={setReaderWideLayout}
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}
@@ -490,9 +577,13 @@ export default function ReaderPage() {
bibleMatches={bibleMatches} deferredBibleQuery={deferredBibleQuery} remote={remoteSearch}
onJump={jump}
/>
<BookmarksSheet
<MarksSheet
open={sheet === 'bookmarks'} onClose={closeSheet}
entries={bookmarkEntries} onJump={jump} onRemove={toggleReaderBookmark}
bookmarks={bookmarkEntries} highlights={highlightEntries}
colorLabels={readerColorLabels ?? {}} onSetLabel={setReaderColorLabel}
onJump={jump}
onRemoveBookmark={(e) => removeBookmark(e.key, e.color)}
onRemoveHighlight={(h) => removeHighlight(h.id)}
/>
<AudioSheet
open={sheet === 'audio'} onClose={closeSheet}
@@ -514,7 +605,8 @@ export default function ReaderPage() {
{HIGHLIGHT_COLORS.map((color) => (
<button
key={color}
aria-label="Highlight"
aria-label={`Highlight ${colorTitle(color, readerColorLabels ?? {}).toLowerCase()}`}
title={colorTitle(color, readerColorLabels ?? {})}
className="h-7 w-7 rounded-full border-2 border-white shadow-sm transition-transform hover:scale-110 active:scale-95"
style={{ backgroundColor: color }}
onPointerDown={(e) => {
@@ -522,6 +614,7 @@ export default function ReaderPage() {
addTextHighlight({
book: readerBookAbbrev, chapter: readerChapter, verse: selectionPicker.verse,
startOffset: selectionPicker.startOffset, endOffset: selectionPicker.endOffset, color,
text: selectionPicker.text,
});
window.getSelection()?.removeAllRanges();
setSelectionPicker(null);
+11
View File
@@ -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/));
+9 -20
View File
@@ -3,8 +3,7 @@
// the custom-plan builder and everything else open as sheets.
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
import { ChevronLeft } from '../components/reader/icons.jsx';
import PageShell from '../components/study/PageShell.jsx';
import Sheet from '../components/reader/Sheet.jsx';
import PlanDetailSheet from '../components/plans/PlanDetailSheet.jsx';
import CustomPlanBuilder from '../components/plans/CustomPlanBuilder.jsx';
@@ -14,7 +13,6 @@ import {
} from '../utils/readingPlans.js';
const CATEGORY_ORDER = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical'];
const THEME_ORDER = ['paper', 'sepia', 'dark'];
const pill = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[10px] text-[var(--r-sub)]';
const sectionTitle = 'mb-2 mt-7 text-[11px] uppercase tracking-wide text-[var(--r-sub)]';
@@ -34,13 +32,12 @@ function nextOf(progress) {
export default function ReadingPlansPage() {
const {
goHome,
planProgress, customPlans, readingActivity,
startPlan, toggleDay, restartPlan, removePlanProgress, recalibratePace, setPlanTranslation,
addCustomPlan, removeCustomPlan, updateCustomPlan,
openPlanReading, studyReadingPlanDay, studyBuildBusy,
readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync,
readerTheme, setReaderTheme, readerTranslation, planTranslationPref,
readerTranslation, planTranslationPref,
} = useApp();
const [detail, setDetail] = useState(null); // { planId, progressId? }
@@ -59,8 +56,6 @@ export default function ReadingPlansPage() {
const categories = ['All', ...CATEGORY_ORDER.filter((c) => READING_PLANS.some((p) => p.category === c)), ...(customPlans.length ? ['My Plans'] : [])];
const shown = catalog.filter((p) => category === 'All' || p.category === category);
const cycleTheme = () => setReaderTheme(THEME_ORDER[(THEME_ORDER.indexOf(readerTheme) + 1) % THEME_ORDER.length]);
const actions = {
defaultTranslation: planTranslationPref && planTranslationPref !== 'same' ? planTranslationPref : readerTranslation,
setTranslation: setPlanTranslation,
@@ -100,17 +95,11 @@ export default function ReadingPlansPage() {
};
return (
<div className="min-h-dvh bg-[var(--r-bg)] pb-16 text-[var(--r-text)]" style={themeVars(readerTheme)}>
<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-3xl items-center gap-1 px-2">
<button type="button" onClick={goHome} aria-label="Back to home" className="flex h-9 items-center gap-0.5 rounded-lg pl-1 pr-2 text-[var(--r-sub)] hover:bg-black/5"><ChevronLeft /><span className="hidden text-xs sm:inline">Home</span></button>
<h1 className="mx-auto text-sm font-medium">Reading plans</h1>
{streak.current > 0 && <span className="text-xs text-[var(--r-accent)]">{streak.current} day streak</span>}
<button type="button" onClick={cycleTheme} className="ml-2 rounded-lg px-2 py-1.5 text-xs text-[var(--r-sub)] hover:bg-black/5" title="Change theme">{READER_THEMES[readerTheme]?.label}</button>
</div>
</div>
<main className="mx-auto max-w-3xl px-4 pt-5 font-sans">
<PageShell
title="Reading Plans" active="plans" maxWidth="max-w-3xl"
actions={streak.current > 0 ? <span className="text-xs text-[var(--r-accent)]">{streak.current} day streak</span> : null}
>
<main className="mx-auto max-w-3xl px-4 pb-16 pt-5 font-sans">
{readingPlanSyncStatus === 'error' && (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-red-300 px-4 py-3 text-sm">
<span className="text-[var(--r-danger,#b42318)]">
@@ -217,7 +206,7 @@ export default function ReadingPlansPage() {
</div>
<div className="flex shrink-0 items-center gap-3 text-xs">
<button type="button" onClick={() => restartPlan(p.id)} className="font-medium hover:underline">Start again</button>
<button type="button" onClick={() => { if (window.confirm('Remove this from your history?')) removePlanProgress(p.id); }} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
<button type="button" onClick={() => removePlanProgress(p.id)} className="text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</div>
</div>
);
@@ -240,6 +229,6 @@ export default function ReadingPlansPage() {
<CustomPlanBuilder key={builderMode} initialValues={editingPlan} onSubmit={submitBuilder} onCancel={() => setBuilderMode(null)} />
)}
</Sheet>
</div>
</PageShell>
);
}
+5 -1
View File
@@ -3,6 +3,7 @@
// pronunciation guide live here because this is where they're needed.
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useToast } from '../components/Toasts.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildClaudePrompt, buildPodcastPrompt } from '../utils/exportBuilders.js';
@@ -15,6 +16,7 @@ const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:
export default function ScriptPage() {
const { project, allChunks, updateProject, updateChunk, authUser, setSaveStatus, setCurrentPage } = useApp();
const [notice, setNotice] = useState('');
const { toast } = useToast();
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
const chapterIndex = chunk ? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === chunk.id)) : -1;
@@ -26,9 +28,11 @@ export default function ScriptPage() {
const set = (patch) => updateChunk(chunk.id, patch);
const append = (text) => set({ finalScript: script.trim() ? `${script.replace(/\s+$/, '')}\n\n${text}` : text });
// Replaces the script with an outline from your notes — and says how to get the old one back.
const draft = () => {
if (has(script) && !window.confirm('Replace the current script with a fresh outline from your notes?')) return;
const before = script;
set({ finalScript: buildScriptDraft(chunk, reference) });
if (has(before)) toast('Script replaced with a fresh outline', { action: { label: 'Undo', onClick: () => set({ finalScript: before }) } });
};
const importDocx = async (file) => {
+13 -2
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import PageShell from '../components/study/PageShell.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
import { READER_TRANSLATIONS } from '../utils/bibleText.js';
import DataSection from '../components/settings/DataSection.jsx';
import { COMMENTARIES } from '../hooks/useVerseStudy.js';
import { READER_THEMES } from '../components/reader/readerThemes.js';
import {
@@ -55,7 +56,15 @@ export default function SettingsPage() {
readerWide, setReaderWide,
readerCommentary, setReaderCommentary,
audioNarrator, setAudioNarrator,
settingsFocus, clearSettingsFocus,
} = useApp();
// "Back up my data" from the ⌘K palette lands on that section
useEffect(() => {
if (!settingsFocus) return;
document.getElementById(settingsFocus)?.scrollIntoView({ block: 'start' });
clearSettingsFocus();
}, [settingsFocus]); // eslint-disable-line react-hooks/exhaustive-deps
const [mfaSetup, setMfaSetup] = useState(null); // { secret, qrCodeDataUrl } while setup is in progress
const [mfaSetupCode, setMfaSetupCode] = useState('');
const [mfaSetupError, setMfaSetupError] = useState('');
@@ -341,6 +350,8 @@ export default function SettingsPage() {
{podcastNameSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
</section>
<DataSection />
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6 space-y-4">
<div>
<h2 className="text-base font-medium text-[var(--r-text)]">My reading journal</h2>
+11 -6
View File
@@ -10,18 +10,19 @@
const BASE = '/api';
async function request(method, path, body) {
async function request(method, path, body, { keepalive = false, headers = {} } = {}) {
try {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', ...headers },
credentials: 'include',
};
if (keepalive) opts.keepalive = true; // lets the request finish even as the tab closes
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, opts);
const data = await res.json().catch(() => null);
if (!res.ok) {
return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}` };
return { ok: false, status: res.status, error: data?.error ?? `HTTP ${res.status}`, data };
}
return { ok: true, data };
} catch (err) {
@@ -51,10 +52,14 @@ export async function loadRemoteProject(id) {
/**
* Save (create or update) a project on the server.
* `baseEdited` is the server copy's lastEdited this device last saw: if the server has moved on, the
* save is refused with { ok: false, status: 409, data: { serverEdited } }. `force` overwrites anyway.
* Returns { ok, data: summary }
*/
export async function saveRemoteProject(project) {
return request('PUT', `/projects/${project.id}`, project);
export async function saveRemoteProject(project, { baseEdited, force = false } = {}) {
return request('PUT', `/projects/${project.id}${force ? '?force=1' : ''}`, project, {
headers: Number.isFinite(baseEdited) ? { 'X-Base-Edited': String(baseEdited) } : {},
});
}
/**
@@ -62,7 +67,7 @@ export async function saveRemoteProject(project) {
* Returns { ok }
*/
export async function deleteRemoteProject(id) {
return request('DELETE', `/projects/${id}`);
return request('DELETE', `/projects/${id}`, undefined, { keepalive: true });
}
// ---------------------------------------------------------------------------
+5
View File
@@ -1,4 +1,9 @@
import '@testing-library/jest-dom';
import { beforeEach } from 'vitest';
import { clearChapterCache } from './utils/bibleText.js';
// Chapter text is cached for the visit; every test starts with a clean slate.
beforeEach(() => clearChapterCache());
// Node 22+ defines experimental localStorage/sessionStorage globals that are
// undefined unless --localstorage-file is passed. Vitest's jsdom environment
+43
View File
@@ -97,6 +97,17 @@ describe('htmlToPlainText', () => {
// renderVerseContent
// ---------------------------------------------------------------------------
describe('renderVerseContent', () => {
test('a footnote marker between two words does not run them together', () => {
expect(renderVerseContent(['Let there be light,”', { noteId: 0 }, 'and there was light.'])).toBe('Let there be light,” and there was light.');
expect(renderVerseContent(['an expanse', { noteId: 2 }, 'between the waters'])).toBe('an expanse between the waters');
});
test('a footnote marker is not given a space where punctuation or a space already follows', () => {
expect(renderVerseContent(['the earth', { noteId: 1 }, ', and'])).toBe('the earth, and');
expect(renderVerseContent(['the earth ', { noteId: 1 }, 'and'])).toBe('the earth and');
expect(renderVerseContent(['the end.', { noteId: 3 }])).toBe('the end.');
});
test('returns a string unchanged', () => {
expect(renderVerseContent('hello world')).toBe('hello world');
});
@@ -635,3 +646,35 @@ describe('buildClaudePrompt', () => {
expect(prompt).not.toContain('Verse three.');
});
});
// ---------------------------------------------------------------------------
// The outline in exports
// ---------------------------------------------------------------------------
describe('outline in exports', () => {
const outlined = {
...baseProject,
outline: [
{ id: 'a', text: 'Greeting <b>', ref: 'Titus 1:1–4', chunkId: null, children: [{ id: 'a1', text: 'Servant of God', ref: '', chunkId: null, children: [] }] },
{ id: 'b', text: 'Qualifications', ref: '', chunkId: null, children: [] },
],
};
test('HTML shows the outline with labels, nested, escaped, before the chunks', () => {
const html = buildExportHtml(outlined);
expect(html).toContain('class="outline"');
expect(html).toContain('<span class="lbl">I.</span>Greeting &lt;b&gt;');
expect(html).toContain('<span class="lbl">A.</span>Servant of God');
expect(html).toContain('<span class="lbl">II.</span>Qualifications');
expect(html.indexOf('class="outline"')).toBeLessThan(html.indexOf('class="chunk"'));
});
test('Markdown shows the outline under its own heading', () => {
const md = buildMarkdownExport(outlined);
expect(md).toContain('## Outline\n\n- **I.** Greeting <b> (Titus 1:1–4)\n - **A.** Servant of God\n- **II.** Qualifications');
});
test('a study without an outline exports as before', () => {
expect(buildExportHtml(baseProject)).not.toContain('class="outline"');
expect(buildMarkdownExport(baseProject)).not.toContain('## Outline');
});
});
+87
View File
@@ -0,0 +1,87 @@
// "Download my data" / "Restore": everything you've made in one JSON file you keep — studies, bookmarks,
// highlights (and what you named the colours), ink notes and reading plans. Restoring only ever adds or
// updates; it never deletes anything you already have here.
import { loadProjectIndex } from './bibleUtils.jsx';
import { loadProjectById, saveProjectToStorage } from './projectStorage.js';
export const BACKUP_APP = 'bible-study-project';
export const BACKUP_VERSION = 1;
// `reader` is what the app holds in memory: { bookmarks, highlights, colorLabels, ink }.
export function buildBackup({ reader, readingPlans, email = null, now = new Date() }) {
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
return {
app: BACKUP_APP,
version: BACKUP_VERSION,
exportedAt: now.toISOString(),
account: email,
projects,
reader: {
bookmarks: reader?.bookmarks ?? {},
highlights: reader?.highlights ?? [],
colorLabels: reader?.colorLabels ?? {},
ink: reader?.ink ?? {},
},
readingPlans: readingPlans ?? { progress: [], customPlans: [], activity: [] },
};
}
export const backupFileName = (now = new Date()) => `bible-study-backup-${now.toISOString().slice(0, 10)}.json`;
// -> { ok: true, backup, summary } or { ok: false, error }. Checks the file really is one of ours.
export function validateBackup(data) {
if (!data || typeof data !== 'object' || data.app !== BACKUP_APP) return { ok: false, error: 'That doesn’t look like a Bible Study backup file.' };
if (typeof data.version !== 'number' || data.version > BACKUP_VERSION) return { ok: false, error: 'This backup was made by a newer version of the app.' };
const projects = Array.isArray(data.projects) ? data.projects.filter((p) => p && typeof p.id === 'string' && p.title && Array.isArray(p.chapters)) : [];
const reader = data.reader && typeof data.reader === 'object' ? data.reader : {};
const bookmarks = reader.bookmarks && typeof reader.bookmarks === 'object' && !Array.isArray(reader.bookmarks) ? reader.bookmarks : {};
const highlights = Array.isArray(reader.highlights) ? reader.highlights.filter((h) => h && typeof h.id === 'string') : [];
const colorLabels = reader.colorLabels && typeof reader.colorLabels === 'object' && !Array.isArray(reader.colorLabels) ? reader.colorLabels : {};
const ink = reader.ink && typeof reader.ink === 'object' && !Array.isArray(reader.ink) ? reader.ink : {};
const plans = data.readingPlans && typeof data.readingPlans === 'object' ? data.readingPlans : {};
const backup = {
projects, reader: { bookmarks, highlights, colorLabels, ink },
readingPlans: {
progress: Array.isArray(plans.progress) ? plans.progress : [],
customPlans: Array.isArray(plans.customPlans) ? plans.customPlans : [],
activity: Array.isArray(plans.activity) ? plans.activity : [],
},
};
return {
ok: true, backup,
summary: {
projects: projects.length, bookmarks: Object.keys(bookmarks).length, highlights: highlights.length,
inkPages: Object.keys(ink).length, plans: backup.readingPlans.progress.length + backup.readingPlans.customPlans.length,
},
};
}
// Brings the studies back onto this device: a study that isn't here is added, one that is here is
// replaced only when the backup's copy was edited more recently. -> the projects that were written
// (so the caller can send them to the server too).
export function restoreProjects(projects) {
const written = [];
for (const project of projects) {
const here = loadProjectById(project.id);
if (here && (here.lastEdited ?? 0) >= (project.lastEdited ?? 0)) continue;
written.push(saveProjectToStorage(project));
}
return written;
}
// Reading-plan progress from a backup combined with what is here: each plan run is kept once, and the
// copy with the later activity wins; custom plans and activity days are unioned.
export function mergeReadingPlans(local, incoming) {
const progress = new Map((local.progress ?? []).map((p) => [p.id, p]));
for (const p of incoming.progress ?? []) {
const mine = progress.get(p.id);
if (!mine || (p.lastActivityAt ?? 0) > (mine.lastActivityAt ?? 0)) progress.set(p.id, p);
}
const customPlans = new Map((local.customPlans ?? []).map((p) => [p.id, p]));
for (const p of incoming.customPlans ?? []) if (!customPlans.has(p.id)) customPlans.set(p.id, p);
return {
progress: [...progress.values()],
customPlans: [...customPlans.values()],
activity: [...new Set([...(local.activity ?? []), ...(incoming.activity ?? [])])].sort(),
};
}
+58
View File
@@ -0,0 +1,58 @@
import { beforeEach, expect, test } from 'vitest';
import { backupFileName, buildBackup, mergeReadingPlans, restoreProjects, validateBackup } from './backup.js';
import { loadProjectById, saveProjectToStorage } from './projectStorage.js';
import { switchStorageUser } from './bibleUtils.jsx';
const project = (id, lastEdited, title = 'Romans') => ({
id, title, translation: 'BSB', lastEdited, selectedChunkId: null,
chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'x' }], chunks: [] }],
});
beforeEach(() => { localStorage.clear(); switchStorageUser(null); });
test('a backup holds the studies, marks and plans, and round-trips through validation', () => {
saveProjectToStorage(project('p1', 100));
const backup = buildBackup({
reader: { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [{ id: 'h1' }], colorLabels: { '#fef08a': 'Key' }, ink: { JHN_3: [] } },
readingPlans: { progress: [{ id: 'r1' }], customPlans: [], activity: ['2026-09-01'] },
now: new Date('2026-10-01T12:00:00Z'),
});
expect(backup.projects.map((p) => p.id)).toEqual(['p1']);
const checked = validateBackup(JSON.parse(JSON.stringify(backup)));
expect(checked.ok).toBe(true);
expect(checked.summary).toEqual({ projects: 1, bookmarks: 1, highlights: 1, inkPages: 1, plans: 1 });
expect(backupFileName(new Date('2026-10-01T12:00:00Z'))).toBe('bible-study-backup-2026-10-01.json');
});
test('rejects files that are not backups, or are from a newer version', () => {
expect(validateBackup(null).ok).toBe(false);
expect(validateBackup({ app: 'something-else', version: 1 }).ok).toBe(false);
expect(validateBackup({ app: 'bible-study-project', version: 99 }).ok).toBe(false);
});
test('ignores malformed entries instead of failing', () => {
const checked = validateBackup({ app: 'bible-study-project', version: 1, projects: [{ id: 'ok', title: 'T', chapters: [] }, { nope: true }, null], reader: { highlights: [{ id: 'h' }, 5] } });
expect(checked.ok).toBe(true);
expect(checked.summary).toMatchObject({ projects: 1, highlights: 1 });
});
test('restoring adds missing studies and only replaces older copies', () => {
saveProjectToStorage(project('newer', 500, 'Local newer'));
saveProjectToStorage(project('older', 100, 'Local older'));
const written = restoreProjects([project('newer', 200, 'Backup'), project('older', 300, 'Backup newer'), project('missing', 50, 'Brought back')]);
expect(written.map((p) => p.id).sort()).toEqual(['missing', 'older']);
expect(loadProjectById('newer').title).toBe('Local newer');
expect(loadProjectById('older').title).toBe('Backup newer');
expect(loadProjectById('missing').title).toBe('Brought back');
});
test('merging plans keeps every run once, preferring the later activity', () => {
const merged = mergeReadingPlans(
{ progress: [{ id: 'a', lastActivityAt: 10, completedDays: [1] }], customPlans: [{ id: 'c1' }], activity: ['2026-09-02'] },
{ progress: [{ id: 'a', lastActivityAt: 20, completedDays: [1, 2] }, { id: 'b', lastActivityAt: 5 }], customPlans: [{ id: 'c1' }, { id: 'c2' }], activity: ['2026-09-01', '2026-09-02'] },
);
expect(merged.progress.find((p) => p.id === 'a').completedDays).toEqual([1, 2]);
expect(merged.progress).toHaveLength(2);
expect(merged.customPlans.map((p) => p.id)).toEqual(['c1', 'c2']);
expect(merged.activity).toEqual(['2026-09-01', '2026-09-02']);
});
+52
View File
@@ -0,0 +1,52 @@
// The Bibles the Reader can show, and one way to fetch a chapter of any of them. Used by the
// main reading text, the side-by-side view, and the verse "Compare" tab.
import { CHAPTER_COUNTS, parseBibleChapter } from './bibleUtils.jsx';
export const READER_TRANSLATIONS = [
{ id: 'BSB', name: 'Berean Standard Bible' },
{ id: 'ESV', name: 'English Standard Version', licensed: true,
notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' },
{ id: 'NET', name: 'NET Bible', helloao: 'eng_net',
notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' },
{ id: 'NLT', name: 'New Living Translation', licensed: true,
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
notice: 'Christian Standard Bible® and CSB® are federally registered trademarks of Holman Bible Publishers. All rights reserved. bhpublishinggroup.com' },
{ id: 'NASB', name: 'New American Standard Bible', licensed: true,
notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' },
];
export const translationName = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.name ?? id;
export const isLicensed = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.licensed === true;
// Free translations are kept for the visit so flipping between chapters, the side-by-side
// view and Compare don't re-download. Licensed text is never kept here (their terms limit
// storage); the server holds it briefly.
const freeChapters = new Map(); // "BSB/JHN/3" -> { verses, totalChapters }
export const clearChapterCache = () => freeChapters.clear();
// -> { verses: [{ number, text }], copyright, totalChapters }. Throws Error(message) on failure.
// Pass `signal` to cancel a request that is no longer wanted.
export async function fetchChapter(translation, bookAbbrev, chapter, { signal } = {}) {
const source = READER_TRANSLATIONS.find((t) => t.id === translation) ?? READER_TRANSLATIONS[0];
let totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? chapter;
if (!source.licensed) {
const key = `${source.id}/${bookAbbrev}/${chapter}`;
const hit = freeChapters.get(key);
if (hit) return { verses: hit.verses, copyright: '', totalChapters: hit.totalChapters };
const res = await fetch(`https://bible.helloao.org/api/${source.helloao ?? source.id}/${bookAbbrev}/${chapter}.json`, { signal });
if (!res.ok) throw new Error('Unable to load chapter.');
const data = await res.json();
const verses = parseBibleChapter(data);
if (!Array.isArray(verses) || verses.length === 0) throw new Error('Invalid Bible data returned.');
totalChapters = data.book?.numberOfChapters ?? totalChapters;
freeChapters.set(key, { verses, totalChapters });
return { verses, copyright: '', totalChapters };
}
const res = await fetch(`/api/bible/${source.id}/${bookAbbrev}/${chapter}`, { credentials: 'include', signal });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Unable to load ${source.id}.`);
if (!Array.isArray(data.verses) || data.verses.length === 0) throw new Error('Invalid Bible data returned.');
return { verses: data.verses, copyright: data.copyright ?? '', totalChapters };
}
+92 -1
View File
@@ -1,3 +1,5 @@
import { labelFor as outlineLabel, kindLabel as outlineKindLabel } from './outlineLabels.js';
// ---------------------------------------------------------------------------
// Study constants
// ---------------------------------------------------------------------------
@@ -187,6 +189,15 @@ export function buildExportHtml(project) {
.greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; }
.greek th { background: #f8fafc; }
.definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; }
.outline ul { list-style: none; margin: 0.25rem 0 0.25rem 1.5rem; padding: 0; }
.outline > ul { margin-left: 0; }
.outline li { margin: 0.3rem 0; line-height: 1.5; }
.outline .lbl { display: inline-block; min-width: 2rem; font-weight: 700; }
.outline .kind { font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; }
.outline .o-note { margin: 0.2rem 0 0 2rem; color: #334155; font-size: 0.95em; }
.outline .o-quote { margin: 0.3rem 0 0.3rem 2rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; }
.outline .o-attach { margin-left: 2rem; font-size: 0.85em; color: #64748b; }
.outline .ref { color: #64748b; font-size: 0.9em; }
.definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
@media print {
body { padding: 0; }
@@ -197,6 +208,11 @@ export function buildExportHtml(project) {
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
// The outline, if one was written: nested lists, labelled I. / A. / 1. like on the page
const outlineHtml = (project.outline ?? []).length > 0
? `<section class="outline"><h2 class="chapter-heading">Outline</h2>${outlineTimeHtml(project.outline)}${outlineListHtml(project.outline, 0)}</section>`
: '';
const chunksHtml = chapters.map((ch, chapterIndex) => {
const chapterHeader = `<h2 class="chapter-heading">${escHtml(ch.book)} ${escHtml(ch.chapter)}</h2>`;
const chunkSections = ch.chunks.map((chunk) => {
@@ -256,6 +272,7 @@ export function buildExportHtml(project) {
<section class="chunk">
<div class="scripture-ref">${escHtml(scripture)}</div>
${versesText}
${(chunk.outline ?? []).length > 0 ? `<div class="outline"><strong>OUTLINE:</strong>${outlineListHtml(chunk.outline, 0)}</div>` : ''}
<div class="oia">
<div class="oia-section"><strong>OBSERVATION:</strong><p>${observation}</p></div>
<div class="oia-section"><strong>INTERPRETATION:</strong><p>${interpretation}</p></div>
@@ -284,12 +301,75 @@ export function buildExportHtml(project) {
<div class="page">
<h1>${escHtml(project.title)}</h1>
<p>${escHtml(project.translation)}</p>
${outlineHtml}
${chunksHtml}
</div>
</body>
</html>`;
}
// <ul> of outline points, each with its label (I., A., 1. …) and passage
function outlineTimeHtml(tree) {
const total = flattenMinutes(tree);
return total > 0 ? `<p class="ref">About ${total} minute${total === 1 ? '' : 's'}</p>` : '';
}
function flattenMinutes(nodes) {
return (nodes ?? []).reduce((sum, n) => sum + (Number.isFinite(n.minutes) ? n.minutes : 0) + flattenMinutes(n.children), 0);
}
function outlineListHtml(nodes, depth) {
const one = (n, i) => {
const kind = n.kind ? `<span class="kind">${escHtml(outlineKindLabel(n.kind))}</span> ` : '';
const mins = Number.isFinite(n.minutes) && n.minutes > 0 ? ` <span class="ref">· ${n.minutes} min</span>` : '';
const note = (n.note ?? '').trim() ? `<div class="o-note">${escHtml(n.note.trim()).replace(/\n/g, '<br />')}</div>` : '';
const quote = n.scripture?.text ? `<blockquote class="o-quote">“${escHtml(n.scripture.text)}” — ${escHtml(n.scripture.ref)}</blockquote>` : '';
const attached = (n.attachments ?? []).length > 0
? `<div class="o-attach">${n.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${escHtml(a.label)}${a.detail ? ` — ${escHtml(a.detail)}` : ''}`).join(' · ')}</div>` : '';
return `<li><span class="lbl">${escHtml(outlineLabel(depth, i))}</span>${kind}${escHtml((n.text ?? '').trim() || '(untitled)')}${n.ref ? ` <span class="ref">(${escHtml(n.ref)})</span>` : ''}${mins}${note}${quote}${attached}${n.children?.length ? outlineListHtml(n.children, depth + 1) : ''}</li>`;
};
return `<ul>${nodes.map(one).join('')}</ul>`;
}
// A page of just one outline (for printing or saving as PDF): the title, the passage, the points.
export function buildOutlineHtml({ title, subtitle = '', tree }) {
const total = flattenMinutes(tree);
const style = `
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 40px; }
.page { max-width: 760px; margin: auto; }
h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
.sub { color: #64748b; margin: 0 0 1.5rem; }
.outline ul { list-style: none; margin: 0.25rem 0 0.25rem 1.6rem; padding: 0; }
.outline > ul { margin-left: 0; }
.outline li { margin: 0.45rem 0; line-height: 1.5; }
.outline > ul > li { margin-top: 0.9rem; font-weight: 600; }
.outline > ul > li li { font-weight: 400; }
.lbl { display: inline-block; min-width: 2.1rem; font-weight: 700; }
.ref { color: #64748b; font-size: 0.9em; font-weight: 400; }
.kind { font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; font-weight: 400; }
.o-note { margin: 0.2rem 0 0 2.1rem; color: #334155; font-size: 0.95em; font-weight: 400; }
.o-quote { margin: 0.3rem 0 0.3rem 2.1rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; font-weight: 400; }
.o-attach { margin-left: 2.1rem; font-size: 0.85em; color: #64748b; font-weight: 400; }
@media print { body { padding: 0; } li { break-inside: avoid; } }
`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escHtml(title)} — Outline</title>
<style>${style}</style>
</head>
<body>
<div class="page">
<h1>${escHtml(title)}</h1>
<p class="sub">${escHtml([subtitle, total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '))}</p>
<div class="outline">${outlineListHtml(tree, 0)}</div>
</div>
</body>
</html>`;
}
export function wordTableHtml(rows) {
return `
<table>
@@ -341,7 +421,18 @@ export function renderVerseContent(content) {
return content;
}
if (Array.isArray(content)) {
return content.map(renderVerseContent).join('');
// A footnote marker ({ noteId }) often sits where the source had a space ("light,” [note] and there
// was light"), which would run the words together. Put the space back when nothing else supplies one.
let out = '';
let afterNote = false;
for (const part of content) {
const piece = renderVerseContent(part);
if (piece === '' && part && typeof part === 'object' && 'noteId' in part) { afterNote = true; continue; }
if (afterNote && out && piece && !/\s$/.test(out) && /^[\p{L}\p{N}“‘"'(]/u.test(piece)) out += ' ';
afterNote = false;
out += piece;
}
return out;
}
if (content && typeof content === 'object') {
if (content.lineBreak) {
+45
View File
@@ -14,6 +14,8 @@ import {
WidthType,
} from 'docx';
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
import { detailLines, flatten, timeSummary } from './outline.js';
import { kindLabel } from './outlineLabels.js';
export function createParagraphsFromText(text) {
return text
@@ -72,6 +74,37 @@ export function sectionLabel(text) {
});
}
// The paragraphs of an outline: each point indented by level, with its note, verse and attachments under it.
export const outlineParagraphs = (tree) => flatten(tree).flatMap(({ node, depth, label }) => [
new Paragraph({
indent: { left: 420 * depth, hanging: 420 },
spacing: { after: 60 },
children: [
new TextRun({ text: `${label}\t`, bold: true, color: DOCX_ACCENT }),
...(node.kind ? [new TextRun({ text: `${kindLabel(node.kind)}: `, italics: true, color: '92400E' })] : []),
new TextRun({ text: (node.text ?? '').trim() || '(untitled)' }),
...(node.ref ? [new TextRun({ text: ` (${node.ref})`, italics: true, color: '7A7060' })] : []),
...(Number.isFinite(node.minutes) && node.minutes > 0 ? [new TextRun({ text: ` · ${node.minutes} min`, color: '7A7060' })] : []),
],
}),
...detailLines(node).map((line) => new Paragraph({
indent: { left: 420 * depth + 420 },
spacing: { after: 40 },
children: [new TextRun({ text: line, italics: true, color: '4B5563', size: 20 })],
})),
]);
// Just one outline, as its own Word document.
export async function buildOutlineDocxBlob({ title, subtitle = '', tree }) {
const children = [
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
new Paragraph({ children: [new TextRun({ text: [subtitle, timeSummary(tree)].filter(Boolean).join(' · '), italics: true, color: '7A7060' })], spacing: { after: 300 } }),
...outlineParagraphs(tree),
];
const doc = new Document({ styles: DOCX_STYLES, sections: [{ children }] });
return Packer.toBlob(doc);
}
export async function buildStudyDocxBlob(project, chapterSummary) {
const children = [
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
@@ -89,6 +122,13 @@ export async function buildStudyDocxBlob(project, chapterSummary) {
const noBorder = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' };
// The outline comes first, indented by level like the page
if ((project.outline ?? []).length > 0) {
children.push(new Paragraph({ text: 'Outline', heading: HeadingLevel.HEADING_1, spacing: { before: 240, after: 160 } }));
if (timeSummary(project.outline)) children.push(new Paragraph({ children: [new TextRun({ text: timeSummary(project.outline), italics: true, color: '7A7060' })], spacing: { after: 120 } }));
children.push(...outlineParagraphs(project.outline));
}
project.chapters.forEach((ch, chapterIndex) => {
children.push(new Paragraph({
text: `${ch.book} ${ch.chapter}`,
@@ -137,6 +177,11 @@ export async function buildStudyDocxBlob(project, chapterSummary) {
}));
children.push(new Paragraph({ text: '', spacing: { after: 120 } }));
if ((chunk.outline ?? []).length > 0) {
children.push(sectionLabel('Outline'));
children.push(...outlineParagraphs(chunk.outline));
}
if (chunk.generalNotes?.trim()) {
children.push(sectionLabel('Background / General Notes'));
children.push(...createParagraphsFromText(chunk.generalNotes));
+6 -2
View File
@@ -4,6 +4,7 @@ import {
getChunkVerseEntries,
htmlToPlainText,
} from './bibleUtils.jsx';
import { outlineToMarkdown, timeSummary } from './outline.js';
// ---------------------------------------------------------------------------
// Export / prompt builders (exported for testing)
@@ -42,13 +43,16 @@ export function buildMarkdownExport(project) {
return `- ${summary}${definition}`;
}).join('\n')}\n\n`;
return `### ${scripture}\n\n${versesText}\n\n${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
const chunkOutline = (chunk.outline ?? []).length > 0 ? `**Outline**\n\n${outlineToMarkdown(chunk.outline)}\n\n` : '';
return `### ${scripture}\n\n${versesText}\n\n${chunkOutline}${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
}).join('---\n\n');
return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`;
}).join('\n');
return `# ${project.title}\n\n*${project.translation}*\n\n${chapterSections}`;
const outline = (project.outline ?? []).length > 0 ? `## Outline\n\n${timeSummary(project.outline) ? `*${timeSummary(project.outline)}*\n\n` : ''}${outlineToMarkdown(project.outline)}\n\n` : '';
return `# ${project.title}\n\n*${project.translation}*\n\n${outline}${chapterSections}`;
}
export function buildChunkBodies(project) {
+16
View File
@@ -0,0 +1,16 @@
// The colours a highlight can be, and the meanings readers tend to give them. The reader names
// each colour themselves (see the Highlights list); these are only the starting suggestions.
export const HIGHLIGHT_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff', '#fed7aa'];
export const COLOR_NAMES = {
'#fef08a': 'Yellow', '#bbf7d0': 'Green', '#bfdbfe': 'Blue',
'#fecaca': 'Red', '#e9d5ff': 'Purple', '#fed7aa': 'Orange',
};
export const COLOR_SUGGESTIONS = {
'#fef08a': 'Key verse', '#bbf7d0': 'Promise', '#bfdbfe': 'Command',
'#fecaca': 'Warning', '#e9d5ff': 'Prayer', '#fed7aa': 'Question',
};
// "Promise (green)" when named, otherwise just "Green".
export const colorTitle = (color, labels = {}) => (labels[color] ? `${labels[color]} (${(COLOR_NAMES[color] ?? 'color').toLowerCase()})` : COLOR_NAMES[color] ?? 'Highlight');
+45
View File
@@ -0,0 +1,45 @@
// "Save for offline": fetch chapters ahead of time so they open without a connection. Fetching is
// all it takes — the service worker (public/sw.js) keeps every Bible-text response it sees. Only the
// free translations (BSB, NET) can be saved; the licensed ones' terms don't allow bulk copies.
import { CHAPTER_COUNTS } from './bibleUtils.jsx';
import { fetchChapter, isLicensed } from './bibleText.js';
const KEY = 'offline-saved';
// { 'BSB': ['JHN', 'PSA'] }: whole books saved on this device, per translation.
export function loadSavedBooks() {
try {
const raw = JSON.parse(localStorage.getItem(KEY) || '{}');
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
} catch { return {}; }
}
export function markBookSaved(translation, book) {
const all = loadSavedBooks();
all[translation] = [...new Set([...(all[translation] ?? []), book])];
try { localStorage.setItem(KEY, JSON.stringify(all)); } catch { /* storage full or unavailable */ }
return all;
}
export const canSaveOffline = (translation) => !isLicensed(translation);
export const bookRefs = (book) => Array.from({ length: CHAPTER_COUNTS[book] ?? 0 }, (_, i) => ({ book, chapter: i + 1 }));
export const bibleRefs = () => Object.keys(CHAPTER_COUNTS).flatMap(bookRefs);
// Fetches every chapter in `refs` ([{ book, chapter }]) a few at a time, calling onProgress(done, total)
// as each lands. Resolves { done, failed } — a chapter that fails doesn't stop the rest. Pass `signal`
// to stop early.
export async function downloadChapters(translation, refs, { onProgress, signal, concurrency = 4 } = {}) {
const queue = [...refs];
let done = 0;
let failed = 0;
const worker = async () => {
while (queue.length > 0 && !signal?.aborted) {
const { book, chapter } = queue.shift();
try { await fetchChapter(translation, book, chapter, { signal }); done += 1; } catch { if (!signal?.aborted) failed += 1; }
onProgress?.(done + failed, refs.length);
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, refs.length) }, worker));
return { done, failed };
}
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { bibleRefs, bookRefs, canSaveOffline, downloadChapters, loadSavedBooks, markBookSaved } from './offline.js';
beforeEach(() => localStorage.clear());
afterEach(() => vi.unstubAllGlobals());
const chapterResponse = { ok: true, json: () => Promise.resolve({ chapter: { content: [{ type: 'verse', number: 1, content: ['In the beginning.'] }] } }) };
test('lists every chapter of a book and of the whole Bible', () => {
expect(bookRefs('JHN')).toHaveLength(21);
expect(bookRefs('JHN')[0]).toEqual({ book: 'JHN', chapter: 1 });
expect(bibleRefs()).toHaveLength(1189);
});
test('only the free translations can be saved', () => {
expect(canSaveOffline('BSB')).toBe(true);
expect(canSaveOffline('NET')).toBe(true);
expect(canSaveOffline('ESV')).toBe(false);
});
test('remembers which books are saved', () => {
markBookSaved('BSB', 'JHN');
markBookSaved('BSB', 'JHN');
markBookSaved('BSB', 'PSA');
expect(loadSavedBooks()).toEqual({ BSB: ['JHN', 'PSA'] });
});
test('downloads every chapter, reports progress, and keeps going past a failure', async () => {
const fetchMock = vi.fn((url) => Promise.resolve(url.includes('/JHN/2.json') ? { ok: false } : chapterResponse));
vi.stubGlobal('fetch', fetchMock);
const progress = [];
const result = await downloadChapters('BSB', bookRefs('JHN').slice(0, 4), { onProgress: (d, t) => progress.push([d, t]) });
expect(result).toEqual({ done: 3, failed: 1 });
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(progress.at(-1)).toEqual([4, 4]);
});
test('stops when told to', async () => {
const controller = new AbortController();
const fetchMock = vi.fn(() => { controller.abort(); return Promise.resolve(chapterResponse); });
vi.stubGlobal('fetch', fetchMock);
await downloadChapters('BSB', bookRefs('PSA'), { signal: controller.signal, concurrency: 1 });
expect(fetchMock.mock.calls.length).toBeLessThan(5);
});
+236
View File
@@ -0,0 +1,236 @@
// A study's true outline: main points, sub-points under them, and so on (I. / A. / 1. / a.). It is a tree
// of nodes { id, text, ref, chunkId, children } kept on the project as `project.outline`. Every function
// returns a new tree and leaves the one it was given alone.
import { formatChunkReference, getChunkVerseEntries, makeId } from './bibleUtils.jsx';
import { KINDS, kindLabel, labelFor, toRoman } from './outlineLabels.js';
export { KINDS, kindLabel, labelFor, toRoman };
export const MAX_DEPTH = 5; // levels below the main points
// A point: its words, an optional passage and chunk link, plus a longer note, a kind, minutes,
// the text of a verse, and things attached from the chunks (cross-references, word studies).
export const newNode = (text = '', extra = {}) => ({
id: makeId(), text, ref: '', chunkId: null, note: '', kind: '', minutes: null, scripture: null, attachments: [], children: [], ...extra,
});
// ---- finding ---------------------------------------------------------------
// Where a node sits: the list of indexes from the top, or null.
export function pathTo(tree, id, base = []) {
for (let i = 0; i < tree.length; i += 1) {
if (tree[i].id === id) return [...base, i];
const deeper = pathTo(tree[i].children, id, [...base, i]);
if (deeper) return deeper;
}
return null;
}
export const getNode = (tree, id) => {
const path = pathTo(tree, id);
if (!path) return null;
return path.reduce((nodes, i, depth) => (depth === path.length - 1 ? nodes[i] : nodes[i].children), tree);
};
// The list of siblings a node is in (a new copy) and its index there, via a path.
function withSiblings(tree, path, change) {
const [head, ...rest] = path;
if (rest.length === 0) return change(tree, head);
return tree.map((n, i) => (i === head ? { ...n, children: withSiblings(n.children, rest, change) } : n));
}
// Depth of the deepest node below `node` (0 when it has no children).
const heightBelow = (node) => (node.children.length === 0 ? 0 : 1 + Math.max(...node.children.map(heightBelow)));
// ---- editing ---------------------------------------------------------------
export const updateNode = (tree, id, patch) => tree.map((n) => (n.id === id ? { ...n, ...patch } : { ...n, children: updateNode(n.children, id, patch) }));
export function addRoot(tree, node) {
return [...tree, node];
}
export function addAfter(tree, id, node) {
const path = pathTo(tree, id);
if (!path) return tree;
return withSiblings(tree, path, (list, i) => [...list.slice(0, i + 1), node, ...list.slice(i + 1)]);
}
export function addChild(tree, id, node) {
if (!getNode(tree, id)) return tree;
return updateChildren(tree, id, (children) => [...children, node]);
}
function updateChildren(tree, id, fn) {
return tree.map((n) => (n.id === id ? { ...n, children: fn(n.children) } : { ...n, children: updateChildren(n.children, id, fn) }));
}
export function removeNode(tree, id) {
const path = pathTo(tree, id);
if (!path) return tree;
return withSiblings(tree, path, (list, i) => list.filter((_, j) => j !== i));
}
// Makes the node a sub-point of the one just above it (if there is one and there is room).
export function indent(tree, id) {
const path = pathTo(tree, id);
if (!path || path[path.length - 1] === 0) return tree;
const node = getNode(tree, id);
if (path.length + heightBelow(node) >= MAX_DEPTH) return tree;
return withSiblings(tree, path, (list, i) => {
const above = list[i - 1];
return [...list.slice(0, i - 1), { ...above, children: [...above.children, node] }, ...list.slice(i + 1)];
});
}
// Brings a sub-point up a level, to sit just after its parent. Its later siblings stay behind, under the parent.
export function outdent(tree, id) {
const path = pathTo(tree, id);
if (!path || path.length < 2) return tree;
const node = getNode(tree, id);
const without = removeNode(tree, id);
return addAfter(without, getNode(tree, parentIdOf(tree, id)).id, node);
}
export function parentIdOf(tree, id, parent = null) {
for (const n of tree) {
if (n.id === id) return parent;
const found = parentIdOf(n.children, id, n.id);
if (found !== null) return found;
}
return null;
}
// Swaps a node with the sibling above (-1) or below (+1).
export function moveNode(tree, id, direction) {
const path = pathTo(tree, id);
if (!path) return tree;
return withSiblings(tree, path, (list, i) => {
const j = i + direction;
if (j < 0 || j >= list.length) return list;
const next = [...list];
[next[i], next[j]] = [next[j], next[i]];
return next;
});
}
// Where a node is: its parent (null at the top) and its place among that parent's children. With
// insertAt this puts a deleted point back exactly where it was.
export function locate(tree, id) {
const path = pathTo(tree, id);
if (!path) return null;
return { parentId: path.length > 1 ? parentIdOf(tree, id) : null, index: path[path.length - 1] };
}
export function insertAt(tree, parentId, index, node) {
const put = (list) => [...list.slice(0, Math.min(index, list.length)), node, ...list.slice(Math.min(index, list.length))];
if (parentId == null || !getNode(tree, parentId)) return put(tree);
return updateChildren(tree, parentId, put);
}
// ---- reading it ------------------------------------------------------------
// Every node in reading order with its depth and label: [{ node, depth, label, parentId }]
export function flatten(tree, depth = 0, parentId = null) {
return tree.flatMap((node, index) => [
{ node, depth, label: labelFor(depth, index), parentId },
...flatten(node.children, depth + 1, node.id),
]);
}
export const countNodes = (tree) => flatten(tree).length;
const minutesText = (m) => (Number.isFinite(m) && m > 0 ? `${m} min` : '');
// Total minutes written on the points (blank ones count as nothing)
export const totalMinutes = (tree) => flatten(tree).reduce((sum, { node }) => sum + (Number.isFinite(node.minutes) ? node.minutes : 0), 0);
// The headline of a point: "[Illustration] The words (passage) · 5 min"
const pointText = (n) => {
const kind = n.kind ? `[${kindLabel(n.kind)}] ` : '';
const mins = minutesText(n.minutes);
return `${kind}${(n.text ?? '').trim() || '(untitled)'}${(n.ref ?? '').trim() ? ` (${n.ref.trim()})` : ''}${mins ? ` · ${mins}` : ''}`;
};
// The lines under a point's headline: its note, verse text and attachments.
export function detailLines(node) {
const lines = [];
for (const l of (node.note ?? '').split('\n')) if (l.trim()) lines.push(l.trim());
if (node.scripture?.text) lines.push(`“${node.scripture.text}” — ${node.scripture.ref}`);
for (const a of node.attachments ?? []) lines.push(`${a.type === 'word' ? 'Word' : 'See'}: ${a.label}${a.detail ? ` — ${a.detail}` : ''}`);
return lines;
}
export const timeSummary = (tree) => {
const total = totalMinutes(tree);
return total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : '';
};
export function outlineToText(tree) {
return flatten(tree).flatMap(({ node, depth, label }) => [
`${' '.repeat(depth)}${label} ${pointText(node)}`,
...detailLines(node).map((l) => `${' '.repeat(depth)} ${l}`),
]).join('\n');
}
export function outlineToMarkdown(tree) {
return flatten(tree).flatMap(({ node, depth, label }) => [
`${' '.repeat(depth)}- **${label}** ${pointText(node)}`,
...detailLines(node).map((l) => `${' '.repeat(depth)} *${l}*`),
]).join('\n');
}
// ---- starting one ------------------------------------------------------------
// One main point per chunk that isn't already in the outline, named after the session title, or the
// first line of the observation, or the passage. Existing points are kept.
export function seedFromChunks(project, tree = []) {
const linked = new Set(flatten(tree).map((f) => f.node.chunkId).filter(Boolean));
const added = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
for (const chunk of chapter.chunks ?? []) {
if (linked.has(chunk.id)) continue;
const firstLine = (chunk.observation ?? '').trim().split('\n')[0].slice(0, 90);
added.push(newNode(chunk.episodeTitle?.trim() || firstLine || '', { ref: formatChunkReference(project, chapterIndex, chunk, '–'), chunkId: chunk.id }));
}
});
return [...tree, ...added];
}
// ---- one outline per chunk ---------------------------------------------------
// A study has one outline for the whole message (project.outline) and each chunk can have its own
// (chunk.outline). `scope` is STUDY or a chunk's id.
export const STUDY = 'study';
export function chunkOf(project, chunkId) {
for (const chapter of project?.chapters ?? []) for (const chunk of chapter.chunks ?? []) if (chunk.id === chunkId) return chunk;
return null;
}
export const outlineFor = (project, scope) => (scope === STUDY ? project?.outline ?? [] : chunkOf(project, scope)?.outline ?? []);
// A copy of the project with that scope's outline replaced.
export function withOutline(project, scope, tree) {
if (scope === STUDY) return { ...project, outline: tree };
return {
...project,
chapters: project.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => (c.id === scope ? { ...c, outline: tree } : c)) })),
};
}
// One point per verse of the chunk, each carrying that verse's words, for verses not already in `tree`.
export function seedFromVerses(project, chunkId, tree = []) {
const have = new Set(flatten(tree).map((f) => f.node.scripture?.ref).filter(Boolean));
const added = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
for (const chunk of chapter.chunks ?? []) {
if (chunk.id !== chunkId) continue;
for (const v of getChunkVerseEntries(project, chapterIndex, chunk)) {
const ref = `${chapter.book} ${v.chapter}:${v.number}`;
if (have.has(ref)) continue;
added.push(newNode('', { ref, scripture: { ref, text: v.text, translation: project.translation ?? 'BSB' } }));
}
}
});
return [...tree, ...added];
}
+179
View File
@@ -0,0 +1,179 @@
import { describe, expect, test } from 'vitest';
import {
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent,
STUDY, chunkOf, detailLines, insertAt, outlineFor, seedFromVerses, withOutline, locate, timeSummary, totalMinutes, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH,
} from './outline.js';
const n = (id, text = id, children = []) => ({ id, text, ref: '', chunkId: null, children });
const ids = (tree) => flatten(tree).map((f) => f.node.id);
// I. a (with A. a1, B. a2) II. b
const sample = () => [n('a', 'a', [n('a1'), n('a2')]), n('b')];
describe('labels', () => {
test('roman numerals, letters and numbers by level', () => {
expect([1, 4, 9, 14, 19].map(toRoman)).toEqual(['I', 'IV', 'IX', 'XIV', 'XIX']);
expect(labelFor(0, 1)).toBe('II.');
expect(labelFor(1, 0)).toBe('A.');
expect(labelFor(1, 26)).toBe('AA.');
expect(labelFor(2, 2)).toBe('3.');
expect(labelFor(3, 1)).toBe('b.');
expect(labelFor(4, 0)).toBe('(1)');
});
test('flatten gives reading order with depth and labels', () => {
expect(flatten(sample()).map((f) => `${f.label}${f.node.id}@${f.depth}`)).toEqual(['I.a@0', 'A.a1@1', 'B.a2@1', 'II.b@0']);
});
});
describe('editing', () => {
test('add after, add child, add root', () => {
expect(ids(addAfter(sample(), 'a1', n('x')))).toEqual(['a', 'a1', 'x', 'a2', 'b']);
expect(ids(addChild(sample(), 'b', n('x')))).toEqual(['a', 'a1', 'a2', 'b', 'x']);
expect(ids(addRoot(sample(), n('x')))).toEqual(['a', 'a1', 'a2', 'b', 'x']);
expect(flatten(addChild(sample(), 'b', n('x'))).at(-1).depth).toBe(1);
});
test('update and remove, including a node deep in the tree', () => {
expect(getNode(updateNode(sample(), 'a2', { text: 'new' }), 'a2').text).toBe('new');
expect(ids(removeNode(sample(), 'a1'))).toEqual(['a', 'a2', 'b']);
expect(ids(removeNode(sample(), 'a'))).toEqual(['b']); // takes its sub-points with it
});
test('indent makes it a sub-point of the one above; the first one cannot', () => {
const t = indent(sample(), 'b');
expect(flatten(t).map((f) => `${f.node.id}@${f.depth}`)).toEqual(['a@0', 'a1@1', 'a2@1', 'b@1']);
expect(indent(sample(), 'a')).toEqual(sample());
expect(indent(sample(), 'a1')).toEqual(sample());
});
test('indent carries a point\'s own sub-points with it', () => {
const t = indent([n('p'), n('q', 'q', [n('q1')])], 'q');
expect(flatten(t).map((f) => `${f.node.id}@${f.depth}`)).toEqual(['p@0', 'q@1', 'q1@2']);
});
test('indent stops at the deepest level', () => {
// a chain: top > s1 > s2 > ... pushing every new point in as far as it will go
let tree = [n('top')];
for (let i = 1; i <= MAX_DEPTH + 2; i += 1) {
tree = addRoot(tree, n(`s${i}`));
for (let k = 0; k < MAX_DEPTH + 2; k += 1) tree = indent(tree, `s${i}`);
}
const deepest = Math.max(...flatten(tree).map((f) => f.depth));
expect(deepest).toBe(MAX_DEPTH - 1);
});
test('outdent moves a sub-point to sit just after its parent', () => {
const t = outdent(sample(), 'a1');
expect(flatten(t).map((f) => `${f.node.id}@${f.depth}`)).toEqual(['a@0', 'a2@1', 'a1@0', 'b@0']);
expect(outdent(sample(), 'a')).toEqual(sample()); // already at the top
});
test('move swaps with a sibling and stops at the ends', () => {
expect(ids(moveNode(sample(), 'b', -1))).toEqual(['b', 'a', 'a1', 'a2']);
expect(ids(moveNode(sample(), 'a2', -1))).toEqual(['a', 'a2', 'a1', 'b']);
expect(moveNode(sample(), 'a', -1)).toEqual(sample());
expect(moveNode(sample(), 'b', 1)).toEqual(sample());
});
test('edits never change the tree they are given', () => {
const t = sample();
const copy = JSON.stringify(t);
indent(t, 'b'); outdent(t, 'a1'); removeNode(t, 'a'); moveNode(t, 'b', -1); updateNode(t, 'a', { text: 'z' });
expect(JSON.stringify(t)).toBe(copy);
});
});
describe('reading it back', () => {
test('plain text and markdown carry labels, indentation and passages', () => {
const t = [{ ...n('a', 'Creation'), ref: 'Gen 1:1-2', children: [n('a1', 'Light')] }, n('b', '')];
expect(outlineToText(t)).toBe('I. Creation (Gen 1:1-2)\n A. Light\nII. (untitled)');
expect(outlineToMarkdown(t)).toBe('- **I.** Creation (Gen 1:1-2)\n - **A.** Light\n- **II.** (untitled)');
expect(countNodes(t)).toBe(3);
});
});
describe('seeding from chunks', () => {
const project = {
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'a' }, { number: 2, text: 'b' }, { number: 3, text: 'c' }], chunks: [
{ id: 'c1', startVerse: 1, endVerse: 2, observation: 'Paul greets.\nMore.', episodeTitle: '' },
{ id: 'c2', startVerse: 3, endVerse: 3, observation: '', episodeTitle: 'Hope' },
] }],
};
test('one main point per chunk, linked to it and named from its notes or title', () => {
const t = seedFromChunks(project);
expect(t.map((x) => [x.text, x.ref, x.chunkId])).toEqual([['Paul greets.', 'Titus 1:1–2', 'c1'], ['Hope', 'Titus 1:3', 'c2']]);
});
test('keeps what is there and only adds chunks that are not yet in the outline', () => {
const first = seedFromChunks(project);
const edited = updateNode(first, first[0].id, { text: 'My own wording' });
const again = seedFromChunks(project, edited);
expect(again).toHaveLength(2);
expect(again[0].text).toBe('My own wording');
expect(seedFromChunks(project, [newNode('Extra')]).map((x) => x.chunkId)).toEqual([null, 'c1', 'c2']);
});
});
describe('putting a deleted point back', () => {
test('locate + insertAt restores a point, with its sub-points, exactly where it was', () => {
const tree = sample();
const where = locate(tree, 'a1');
const gone = getNode(tree, 'a1');
expect(where).toEqual({ parentId: 'a', index: 0 });
expect(ids(insertAt(removeNode(tree, 'a1'), where.parentId, where.index, gone))).toEqual(ids(tree));
const top = locate(tree, 'a');
expect(ids(insertAt(removeNode(tree, 'a'), top.parentId, top.index, getNode(tree, 'a')))).toEqual(ids(tree));
});
test('if the parent is gone it goes to the top level instead of vanishing', () => {
expect(ids(insertAt([n('b')], 'missing', 0, n('x')))).toEqual(['x', 'b']);
});
});
describe('richer points', () => {
const rich = [{
...n('a', 'Creation'), ref: 'Gen 1', kind: 'illustration', minutes: 5, note: 'Tell the story.\n\nKeep it short.',
scripture: { ref: 'Genesis 1:1', text: 'In the beginning', translation: 'BSB' },
attachments: [{ id: 'x', type: 'xref', label: 'John 1:1' }, { id: 'w', type: 'word', label: 'bara (H1254)', detail: 'to create' }],
children: [{ ...n('b', 'Light'), minutes: 3 }],
}];
test('text and markdown show the kind, minutes, note, verse and attachments', () => {
expect(outlineToText(rich)).toBe([
'I. [Illustration] Creation (Gen 1) · 5 min',
' Tell the story.',
' Keep it short.',
' “In the beginning” — Genesis 1:1',
' See: John 1:1',
' Word: bara (H1254) — to create',
' A. Light · 3 min',
].join('\n'));
expect(outlineToMarkdown(rich)).toContain('- **I.** [Illustration] Creation (Gen 1) · 5 min\n *Tell the story.*');
});
test('totals the minutes, and says nothing when there are none', () => {
expect(totalMinutes(rich)).toBe(8);
expect(timeSummary(rich)).toBe('About 8 minutes');
expect(timeSummary([n('z')])).toBe('');
});
test('new points start plain, and older ones without the new fields still work', () => {
expect(newNode('x')).toMatchObject({ note: '', kind: '', minutes: null, scripture: null, attachments: [] });
expect(detailLines(n('old'))).toEqual([]);
});
});
describe('an outline for each chunk', () => {
const project = {
translation: 'BSB', outline: [n('whole')],
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'One.' }, { number: 2, text: 'Two.' }, { number: 3, text: 'Three.' }], chunks: [
{ id: 'c1', startVerse: 1, endVerse: 2, observation: '', outline: [n('mine')] },
{ id: 'c2', startVerse: 3, endVerse: 3, observation: '' },
] }],
};
test('reads and replaces the outline of the whole study or of one chunk', () => {
expect(ids(outlineFor(project, STUDY))).toEqual(['whole']);
expect(ids(outlineFor(project, 'c1'))).toEqual(['mine']);
expect(outlineFor(project, 'c2')).toEqual([]);
const next = withOutline(project, 'c2', [n('new')]);
expect(ids(outlineFor(next, 'c2'))).toEqual(['new']);
expect(ids(outlineFor(next, 'c1'))).toEqual(['mine']);
expect(ids(outlineFor(next, STUDY))).toEqual(['whole']);
expect(outlineFor(project, 'c2')).toEqual([]); // the original is untouched
expect(ids(outlineFor(withOutline(project, STUDY, []), STUDY))).toEqual([]);
});
test('seeding from verses adds a point per verse with its words, and never twice', () => {
const t = seedFromVerses(project, 'c1', []);
expect(t.map((x) => [x.ref, x.scripture.text])).toEqual([['Titus 1:1', 'One.'], ['Titus 1:2', 'Two.']]);
expect(seedFromVerses(project, 'c1', t)).toHaveLength(2);
expect(seedFromVerses(project, 'c2', [])).toHaveLength(1);
});
});
+60
View File
@@ -0,0 +1,60 @@
// Taking one outline out of the app: print or save as PDF, share it, or download it.
import { buildOutlineHtml } from './bibleUtils.jsx';
import { outlineToText } from './outline.js';
const slug = (s) => String(s).replace(/[^\w]+/g, '-').replace(/^-|-$/g, '') || 'outline';
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
// What gets shared as text: a heading, then the outline.
export const outlineShareText = ({ title, subtitle = '', tree }) => `${title}${subtitle ? `\n${subtitle}` : ''}\n\n${outlineToText(tree)}`;
// Opens a print-ready page of just this outline. Returns false when the browser blocked the window.
export function printOutline({ title, subtitle, tree }) {
const win = window.open('', '_blank');
if (!win) return false;
win.document.write(buildOutlineHtml({ title, subtitle, tree }));
win.document.close();
win.onload = () => { win.focus(); win.print(); };
return true;
}
// Uses the phone/tablet/computer share sheet when there is one (Messages, Mail, AirDrop…), otherwise
// copies the text. -> 'shared' | 'copied' | 'cancelled' | 'failed'
export async function shareOutline(outline) {
const text = outlineShareText(outline);
if (navigator.share) {
try {
await navigator.share({ title: `${outline.title} — Outline`, text });
return 'shared';
} catch (err) {
if (err?.name === 'AbortError') return 'cancelled';
// fall through to copying
}
}
try {
await navigator.clipboard.writeText(text);
return 'copied';
} catch {
return 'failed';
}
}
export function downloadOutlineMarkdown({ title, subtitle, tree }) {
const text = `# ${title}\n\n${subtitle ? `*${subtitle}*\n\n` : ''}${tree.length ? outlineToText(tree) : ''}\n`;
downloadBlob(new Blob([text], { type: 'text/markdown;charset=utf-8' }), `${slug(title)}-outline.md`);
}
export async function downloadOutlineDocx(outline) {
const { buildOutlineDocxBlob } = await import('./docxExport.js'); // the Word library only loads when used
downloadBlob(await buildOutlineDocxBlob(outline), `${slug(outline.title)}-outline.docx`);
}
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { outlineShareText, printOutline, shareOutline } from './outlineExport.js';
import { buildOutlineHtml } from './bibleUtils.jsx';
const outline = {
title: 'Titus 1 — Elders', subtitle: 'Titus 1:5–9',
tree: [{ id: 'a', text: 'Appoint elders', ref: 'Titus 1:5', kind: 'application', minutes: 4, note: 'Why it matters', children: [{ id: 'b', text: 'Blameless', ref: '', children: [] }] }],
};
beforeEach(() => { vi.stubGlobal('navigator', { ...navigator, share: undefined, clipboard: { writeText: vi.fn(() => Promise.resolve()) } }); });
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
test('the share text has the title, passage and the outline', () => {
const text = outlineShareText(outline);
expect(text.startsWith('Titus 1 — Elders\nTitus 1:5–9\n\nI. [Application] Appoint elders (Titus 1:5) · 4 min')).toBe(true);
expect(text).toContain('A. Blameless');
});
test('uses the system share sheet when there is one', async () => {
const share = vi.fn(() => Promise.resolve());
vi.stubGlobal('navigator', { share, clipboard: { writeText: vi.fn() } });
expect(await shareOutline(outline)).toBe('shared');
expect(share).toHaveBeenCalledWith(expect.objectContaining({ title: 'Titus 1 — Elders — Outline' }));
});
test('closing the share sheet is not an error, and a broken one falls back to copying', async () => {
vi.stubGlobal('navigator', { share: vi.fn(() => Promise.reject(Object.assign(new Error('x'), { name: 'AbortError' }))), clipboard: { writeText: vi.fn() } });
expect(await shareOutline(outline)).toBe('cancelled');
const writeText = vi.fn(() => Promise.resolve());
vi.stubGlobal('navigator', { share: vi.fn(() => Promise.reject(new Error('nope'))), clipboard: { writeText } });
expect(await shareOutline(outline)).toBe('copied');
expect(writeText).toHaveBeenCalled();
});
test('without a share sheet it copies, and says so when even that fails', async () => {
expect(await shareOutline(outline)).toBe('copied');
vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn(() => Promise.reject(new Error('denied'))) } });
expect(await shareOutline(outline)).toBe('failed');
});
test('print writes a page of just the outline, and reports a blocked window', () => {
const doc = { write: vi.fn(), close: vi.fn() };
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
expect(printOutline(outline)).toBe(true);
const html = doc.write.mock.calls[0][0];
expect(html).toContain('Appoint elders');
expect(html).toContain('About 4 minutes');
window.open.mockReturnValue(null);
expect(printOutline(outline)).toBe(false);
});
test('the printable page escapes text and shows notes', () => {
const html = buildOutlineHtml({ title: 'A <b>', tree: [{ id: 'x', text: '<i>hi</i>', ref: '', note: 'line', children: [] }] });
expect(html).toContain('A &lt;b&gt;');
expect(html).toContain('&lt;i&gt;hi&lt;/i&gt;');
expect(html).toContain('class="o-note"');
});
+34
View File
@@ -0,0 +1,34 @@
// How outline points are numbered. Kept free of imports so both the page and the exporters can use it.
const ROMAN = [[10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I']];
export function toRoman(n) {
let out = '';
let rest = n;
for (const [value, symbol] of ROMAN) while (rest >= value) { out += symbol; rest -= value; }
return out;
}
const letter = (n, upper) => {
let out = '';
let rest = n;
while (rest > 0) { rest -= 1; out = String.fromCharCode((upper ? 65 : 97) + (rest % 26)) + out; rest = Math.floor(rest / 26); }
return out;
};
// I. / A. / 1. / a. / (1) / (a), by level
export function labelFor(depth, index) {
const n = index + 1;
switch (depth) {
case 0: return `${toRoman(n)}.`;
case 1: return `${letter(n, true)}.`;
case 2: return `${n}.`;
case 3: return `${letter(n, false)}.`;
case 4: return `(${n})`;
default: return `(${letter(n, false)})`;
}
}
export const KINDS = [
['', 'Point'], ['illustration', 'Illustration'], ['application', 'Application'],
['quote', 'Quote'], ['question', 'Question'], ['transition', 'Transition'],
];
export const kindLabel = (kind) => KINDS.find(([id]) => id === kind)?.[1] ?? '';
+51
View File
@@ -0,0 +1,51 @@
// Turns what someone types — "john 3:16", "1 cor 13", "Ps 23", "jn3" — into a place in the Bible.
import { bookOptions, CHAPTER_COUNTS } from './bibleUtils.jsx';
// Short forms people actually type that aren't just the start of the book's name.
const ALIASES = {
gn: 'GEN', ex: 'EXO', exod: 'EXO', lv: 'LEV', nm: 'NUM', nu: 'NUM', dt: 'DEU', deut: 'DEU', jos: 'JOS', josh: 'JOS',
jdg: 'JDG', judg: 'JDG', jg: 'JDG', rt: 'RUT', '1sm': '1SA', '2sm': '2SA', '1ki': '1KI', '2ki': '2KI', '1kgs': '1KI', '2kgs': '2KI',
'1chr': '1CH', '2chr': '2CH', ne: 'NEH', est: 'EST', jb: 'JOB', ps: 'PSA', psa: 'PSA', pslm: 'PSA', psalm: 'PSA', psalms: 'PSA',
pr: 'PRO', prov: 'PRO', prv: 'PRO', ecc: 'ECC', eccl: 'ECC', qoh: 'ECC', song: 'SNG', sos: 'SNG', ss: 'SNG', songofsongs: 'SNG', songofsolomon: 'SNG', canticles: 'SNG',
is: 'ISA', isa: 'ISA', jer: 'JER', lam: 'LAM', ez: 'EZK', ezek: 'EZK', dn: 'DAN', hos: 'HOS', jl: 'JOL', joel: 'JOL',
am: 'AMO', ob: 'OBA', obad: 'OBA', jon: 'JON', mi: 'MIC', mic: 'MIC', na: 'NAM', nah: 'NAM', hab: 'HAB', zep: 'ZEP', zeph: 'ZEP',
hag: 'HAG', zec: 'ZEC', zech: 'ZEC', mal: 'MAL',
mt: 'MAT', matt: 'MAT', mk: 'MRK', mr: 'MRK', mrk: 'MRK', lk: 'LUK', lu: 'LUK', jn: 'JHN', jhn: 'JHN', joh: 'JHN',
ac: 'ACT', rom: 'ROM', rm: 'ROM', '1cor': '1CO', '2cor': '2CO', '1co': '1CO', '2co': '2CO', gal: 'GAL', eph: 'EPH',
phil: 'PHP', php: 'PHP', pp: 'PHP', col: 'COL', '1thess': '1TH', '2thess': '2TH', '1th': '1TH', '2th': '2TH',
'1tim': '1TI', '2tim': '2TI', '1ti': '1TI', '2ti': '2TI', tit: 'TIT', phm: 'PHM', philem: 'PHM', heb: 'HEB',
jas: 'JAS', jm: 'JAS', '1pet': '1PE', '2pet': '2PE', '1pe': '1PE', '2pe': '2PE', '1jn': '1JN', '2jn': '2JN', '3jn': '3JN',
'1john': '1JN', '2john': '2JN', '3john': '3JN', jud: 'JUD', rev: 'REV', rv: 'REV', revelations: 'REV',
};
const squash = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, '');
const BOOKS = bookOptions.map((b) => ({ ...b, key: squash(b.name) }));
// The books a typed name could mean, best first: exact, then a known short form, then any
// book whose name starts with it ("deut" → Deuteronomy, "1 cor" → 1 Corinthians).
export function matchBooks(typed) {
const key = squash(typed);
if (!key) return [];
const exact = BOOKS.filter((b) => b.key === key);
if (exact.length) return exact;
const alias = ALIASES[key] && BOOKS.filter((b) => b.abbrev === ALIASES[key]);
if (alias?.length) return alias;
return BOOKS.filter((b) => b.key.startsWith(key));
}
// "John 3:16" -> [{ book: 'JHN', bookName: 'John', chapter: 3, verse: 16 }]; ambiguous names ("jo")
// give several. A bare book ("romans") opens chapter 1. Chapters past the end are rejected.
export function parsePassageRef(text) {
const m = String(text ?? '').trim().replace(/\./g, ' ').match(/^((?:[1-3]\s*)?[a-z][a-z ]*?)\s*(?:(\d{1,3})(?:\s*[:.v ]\s*(\d{1,3}))?)?$/i);
if (!m) return [];
const [, name, chapterText, verseText] = m;
const out = [];
for (const b of matchBooks(name).slice(0, 4)) {
const chapter = chapterText ? Number(chapterText) : 1;
if (chapter < 1 || chapter > (CHAPTER_COUNTS[b.abbrev] ?? 1)) continue;
out.push({ book: b.abbrev, bookName: b.name, chapter, verse: verseText ? Number(verseText) : null, hasChapter: !!chapterText });
}
return out;
}
export const formatPassage = (r) => `${r.bookName}${r.hasChapter || r.chapter > 1 ? ` ${r.chapter}` : ''}${r.verse ? `:${r.verse}` : ''}`;
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from 'vitest';
import { formatPassage, matchBooks, parsePassageRef } from './passageRef.js';
const first = (text) => parsePassageRef(text)[0];
test('understands a book, chapter and verse in the usual ways', () => {
expect(first('John 3:16')).toMatchObject({ book: 'JHN', chapter: 3, verse: 16 });
expect(first('jn 3 16')).toMatchObject({ book: 'JHN', chapter: 3, verse: 16 });
expect(first('Ps 23')).toMatchObject({ book: 'PSA', chapter: 23, verse: null });
expect(first('1 cor 13:4')).toMatchObject({ book: '1CO', chapter: 13, verse: 4 });
expect(first('1corinthians 13')).toMatchObject({ book: '1CO', chapter: 13 });
expect(first('song of solomon 2')).toMatchObject({ book: 'SNG', chapter: 2 });
expect(first('Rom. 8.28')).toMatchObject({ book: 'ROM', chapter: 8, verse: 28 });
});
test('a bare book name opens its first chapter', () => {
expect(first('romans')).toMatchObject({ book: 'ROM', chapter: 1, hasChapter: false });
});
test('a name that fits several books lists them; a short form picks one', () => {
expect(matchBooks('jo').length).toBeGreaterThan(1);
expect(matchBooks('jn').map((b) => b.abbrev)).toEqual(['JHN']);
});
test('rejects chapters a book does not have, and things that are not passages', () => {
expect(parsePassageRef('jude 2')).toEqual([]); // Jude has one chapter
expect(parsePassageRef('genesis 51')).toEqual([]);
expect(parsePassageRef('')).toEqual([]);
expect(parsePassageRef('3:16')).toEqual([]);
expect(parsePassageRef('zzz 4')).toEqual([]);
});
test('formats a place the way people write it', () => {
expect(formatPassage(first('john 3:16'))).toBe('John 3:16');
expect(formatPassage(first('psalm 23'))).toBe('Psalms 23');
expect(formatPassage(first('romans'))).toBe('Romans');
});
+23
View File
@@ -0,0 +1,23 @@
// "Luke 1, Luke 2, Luke 3, Luke 5" -> "Luke 1–3, 5": a long run of chapters reads as ranges, so a
// study card stays short. Anything that isn't "Book N" is left as it was.
export function compactPassages(summary) {
if (!summary) return '';
const parts = summary.split(/,\s*/);
const groups = [];
for (const part of parts) {
const m = part.match(/^(.+?)\s+(\d+)$/);
if (!m) { groups.push({ raw: part }); continue; }
const last = groups[groups.length - 1];
if (last && last.book === m[1]) last.chapters.push(Number(m[2]));
else groups.push({ book: m[1], chapters: [Number(m[2])] });
}
return groups.map((g) => {
if (g.raw !== undefined) return g.raw;
const runs = [];
for (const n of g.chapters) {
const run = runs[runs.length - 1];
if (run && n === run[1] + 1) run[1] = n; else runs.push([n, n]);
}
return `${g.book} ${runs.map(([a, b]) => (a === b ? a : b === a + 1 ? `${a}, ${b}` : `${a}–${b}`)).join(', ')}`;
}).join(', ');
}
+17
View File
@@ -0,0 +1,17 @@
import { expect, test } from 'vitest';
import { compactPassages } from './passageSummary.js';
test('runs of chapters become ranges', () => {
expect(compactPassages('Luke 1, Luke 2, Luke 3, Luke 4, Luke 5, Luke 6')).toBe('Luke 1–6');
expect(compactPassages('1 John 1, 1 John 2, 1 John 3, 1 John 4, 1 John 5')).toBe('1 John 1–5');
});
test('gaps stay separate, pairs and singles stay plain', () => {
expect(compactPassages('Luke 1, Luke 2, Luke 3, Luke 5')).toBe('Luke 1–3, 5');
expect(compactPassages('Malachi 1, Malachi 2')).toBe('Malachi 1, 2');
expect(compactPassages('John 3')).toBe('John 3');
});
test('several books, and text that is not a chapter, are kept', () => {
expect(compactPassages('Ephesians 1, Ephesians 2, Ephesians 3, Titus 1')).toBe('Ephesians 1–3, Titus 1');
expect(compactPassages('Whole book')).toBe('Whole book');
expect(compactPassages('')).toBe('');
});
+49
View File
@@ -0,0 +1,49 @@
// "Titus 1:5–9" -> the words of those verses, for putting scripture inside an outline point.
import { matchBooks } from './passageRef.js';
import { CHAPTER_COUNTS, bookOptions } from './bibleUtils.jsx';
import { fetchChapter } from './bibleText.js';
export const MAX_VERSES = 40; // a whole chapter's worth is plenty to paste into a point
// -> { book, bookName, chapter, start, end } (start/end null = the whole chapter) or null. Accepts
// "Titus 1:5-9", "titus 1:5", "John 3" and a dash or en dash.
export function parsePassageRange(text) {
const m = String(text ?? '').trim().replace(/\./g, ' ').match(/^((?:[1-3]\s*)?[a-z][a-z ]*?)\s*(\d{1,3})(?:\s*[:v ]\s*(\d{1,3})(?:\s*[-–—]\s*(\d{1,3}))?)?$/i);
if (!m) return null;
const [, name, chapterText, startText, endText] = m;
const book = matchBooks(name)[0];
const chapter = Number(chapterText);
if (!book || chapter < 1 || chapter > (CHAPTER_COUNTS[book.abbrev] ?? 1)) return null;
const start = startText ? Number(startText) : null;
const end = endText ? Number(endText) : start;
if (start != null && end < start) return null;
return { book: book.abbrev, bookName: bookOptions.find((b) => b.abbrev === book.abbrev)?.name ?? book.name, chapter, start, end };
}
export const formatRange = (r) => `${r.bookName} ${r.chapter}${r.start == null ? '' : r.end === r.start ? `:${r.start}` : `:${r.start}–${r.end}`}`;
// -> { ref, text, translation }. Throws Error(message) when the passage can't be read or found.
export async function fetchPassageText(text, translation = 'BSB') {
const range = parsePassageRange(text);
if (!range) throw new Error('Type a passage like “Titus 1:5–9”.');
const { verses } = await fetchChapter(translation, range.book, range.chapter);
const picked = range.start == null ? verses : verses.filter((v) => v.number >= range.start && v.number <= range.end);
if (picked.length === 0) throw new Error('Those verses weren’t found in that chapter.');
if (picked.length > MAX_VERSES) throw new Error(`That's ${picked.length} verses — pick ${MAX_VERSES} or fewer.`);
return { ref: formatRange(range), text: picked.map((v) => v.text).join(' '), translation };
}
// What a chunk offers to attach to a point: its cross-references and the words studied in it.
// -> [{ key, type: 'xref' | 'word', label, detail? }]
export function attachableFromChunk(chunk) {
if (!chunk) return [];
const refs = (chunk.crossReferences ?? []).map((r) => ({ key: `x:${r}`, type: 'xref', label: r }));
const words = (chunk.greekWords ?? [])
.filter((w) => w.lexeme || w.strongNumber || w.query)
.map((w) => ({
key: `w:${w.strongNumber || w.lexeme || w.query}`, type: 'word',
label: [w.lexeme, w.strongNumber && `(${w.strongNumber})`].filter(Boolean).join(' ') || w.query,
detail: w.shortDefinition && !/^(No definition|Lookup failed)/.test(w.shortDefinition) ? w.shortDefinition : undefined,
}));
return [...refs, ...words];
}
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { attachableFromChunk, fetchPassageText, parsePassageRange } from './passageText.js';
const chapter = { chapter: { content: [1, 2, 3, 4, 5].map((n) => ({ type: 'verse', number: n, content: [`Verse ${n} text.`] })) } };
beforeEach(() => vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(chapter) }))));
afterEach(() => vi.unstubAllGlobals());
test('reads a verse, a range, or a whole chapter', () => {
expect(parsePassageRange('Titus 1:5')).toMatchObject({ book: 'TIT', chapter: 1, start: 5, end: 5 });
expect(parsePassageRange('titus 1:5-9')).toMatchObject({ start: 5, end: 9 });
expect(parsePassageRange('1 Cor 13:4–7')).toMatchObject({ book: '1CO', chapter: 13, start: 4, end: 7 });
expect(parsePassageRange('John 3')).toMatchObject({ chapter: 3, start: null });
});
test('rejects things that are not passages, or backwards ranges', () => {
expect(parsePassageRange('hello')).toBeNull();
expect(parsePassageRange('Titus 9:1')).toBeNull();
expect(parsePassageRange('Titus 1:9-5')).toBeNull();
});
test('fetches just the verses asked for, and names the passage', async () => {
const got = await fetchPassageText('Titus 1:2-3');
expect(got).toEqual({ ref: 'Titus 1:2–3', text: 'Verse 2 text. Verse 3 text.', translation: 'BSB' });
});
test('says so when the verses are not there, or the passage is not understood', async () => {
await expect(fetchPassageText('Titus 1:50')).rejects.toThrow(/weren’t found/);
await expect(fetchPassageText('nonsense')).rejects.toThrow(/Type a passage/);
});
test('offers a chunk\'s cross-references and studied words to attach', () => {
const chunk = {
crossReferences: ['John 1:1'],
greekWords: [
{ strongNumber: 'G1401', lexeme: 'δοῦλος', shortDefinition: 'a slave' },
{ query: 'xyz', shortDefinition: 'No definition found.' },
],
};
expect(attachableFromChunk(chunk)).toEqual([
{ key: 'x:John 1:1', type: 'xref', label: 'John 1:1' },
{ key: 'w:G1401', type: 'word', label: 'δοῦλος (G1401)', detail: 'a slave' },
{ key: 'w:xyz', type: 'word', label: 'xyz', detail: undefined },
]);
expect(attachableFromChunk(null)).toEqual([]);
});
+70
View File
@@ -0,0 +1,70 @@
// Search across everything the reader has written or saved on this device: study notes, project
// titles and passages, bookmarks and highlights. (Bible text itself is searched in the Reader.)
import { bookOptions, formatChunkReference } from './bibleUtils.jsx';
const NOTE_FIELDS = [
['observation', 'Observation'], ['interpretation', 'Interpretation'], ['application', 'Application'],
['generalNotes', 'Notes'], ['finalScript', 'Script'],
];
const snippetAround = (raw, at, len) => {
const start = Math.max(0, at - 40);
const end = at + len + 60;
return (start > 0 ? '…' : '') + raw.slice(start, end).replace(/\s+/g, ' ') + (end < raw.length ? '…' : '');
};
// projects: full project objects. -> [{ projectId, projectTitle, chunkId, ref, field, snippet }], at most
// `limit`, one hit per chunk (the first field that matches).
export function searchNotes(projects, query, { limit = 20 } = {}) {
const q = query.trim().toLowerCase();
if (q.length < 3) return [];
const hits = [];
for (const project of projects) {
for (const [chapterIndex, chapter] of (project.chapters ?? []).entries()) {
for (const chunk of chapter.chunks ?? []) {
for (const [field, label] of NOTE_FIELDS) {
const raw = chunk[field] ?? '';
const at = raw.toLowerCase().indexOf(q);
if (at === -1) continue;
hits.push({
projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
ref: formatChunkReference(project, chapterIndex, chunk, '–'), field: label, snippet: snippetAround(raw, at, q.length),
});
break;
}
if (hits.length >= limit) return hits;
}
}
}
return hits;
}
// Projects whose title, passages or tags contain the text.
export function searchProjects(index, query, { limit = 6 } = {}) {
const q = query.trim().toLowerCase();
if (!q) return [];
return index.filter((e) => [e.title, e.chapterSummary, ...(e.tags ?? [])].some((t) => (t ?? '').toLowerCase().includes(q))).slice(0, limit);
}
const bookName = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
// Highlights (by their words or what the colour is named) and bookmarks (by reference).
// -> [{ kind: 'highlight' | 'bookmark', book, bookName, chapter, verse, color, text?, label? }]
export function searchMarks({ bookmarks = {}, highlights = [], labels = {} }, query, { limit = 10 } = {}) {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
const out = [];
for (const h of highlights) {
const label = labels[h.color] ?? '';
const ref = `${bookName(h.book)} ${h.chapter}:${h.verse}`.toLowerCase();
if ((h.text ?? '').toLowerCase().includes(q) || label.toLowerCase().includes(q) || ref.includes(q)) {
out.push({ kind: 'highlight', book: h.book, bookName: bookName(h.book), chapter: h.chapter, verse: h.verse, color: h.color, text: h.text, label });
}
}
for (const [key, color] of Object.entries(bookmarks)) {
const [book, chapter, verse] = key.split('-');
const ref = `${bookName(book)} ${chapter}:${verse}`;
if (ref.toLowerCase().includes(q)) out.push({ kind: 'bookmark', book, bookName: bookName(book), chapter: Number(chapter), verse: Number(verse), color });
}
return out.slice(0, limit);
}
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from 'vitest';
import { searchMarks, searchNotes, searchProjects } from './personalSearch.js';
const project = {
id: 'p1', title: 'Romans study',
chapters: [{
book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'a' }, { number: 2, text: 'b' }],
chunks: [
{ id: 'c1', startVerse: 1, endVerse: 2, observation: 'No condemnation for those in Christ.', interpretation: '', application: '', generalNotes: '', finalScript: '' },
{ id: 'c2', startVerse: 2, endVerse: 2, observation: '', interpretation: 'The Spirit of life sets us free', application: '', generalNotes: '', finalScript: '' },
],
}],
};
test('finds notes, one hit per chunk, saying which field matched', () => {
const hits = searchNotes([project], 'spirit');
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({ projectId: 'p1', chunkId: 'c2', field: 'Interpretation' });
expect(hits[0].snippet).toContain('Spirit of life');
});
test('needs three letters before searching notes', () => {
expect(searchNotes([project], 'sp')).toEqual([]);
});
test('finds projects by title, passage or tag', () => {
const index = [{ id: 'a', title: 'Romans study', chapterSummary: 'Romans 8', tags: ['grace'] }, { id: 'b', title: 'Psalms', chapterSummary: 'Psalms 23', tags: [] }];
expect(searchProjects(index, 'rom').map((e) => e.id)).toEqual(['a']);
expect(searchProjects(index, 'grace').map((e) => e.id)).toEqual(['a']);
expect(searchProjects(index, 'zzz')).toEqual([]);
});
test('finds highlights by their words or colour name, and bookmarks by reference', () => {
const marks = {
bookmarks: { 'JHN-3-16': '#fef08a' },
highlights: [{ id: 'h1', book: 'ROM', chapter: 8, verse: 28, color: '#bbf7d0', text: 'all things work together', startOffset: 0, endOffset: 5 }],
labels: { '#bbf7d0': 'Promise' },
};
expect(searchMarks(marks, 'together')[0]).toMatchObject({ kind: 'highlight', bookName: 'Romans', verse: 28 });
expect(searchMarks(marks, 'promise')[0]).toMatchObject({ kind: 'highlight' });
expect(searchMarks(marks, 'john 3')[0]).toMatchObject({ kind: 'bookmark', chapter: 3, verse: 16 });
});
+9
View File
@@ -0,0 +1,9 @@
// Remembers, per study, the server copy's lastEdited that this device last saw — the "base" a save is
// checked against so another device's newer copy is never overwritten silently. Kept in memory: it is
// rebuilt from the server's list each time you sign in.
const base = new Map();
export const getSyncBase = (id) => base.get(id);
export const setSyncBase = (id, lastEdited) => { if (Number.isFinite(lastEdited)) base.set(id, lastEdited); };
export const clearSyncBase = (id) => base.delete(id);
export const resetSyncBases = () => base.clear();
+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');
});
});