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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-01 14:08:08 -04:00
1 parent 1edbd364ea
commit d13f55faf7
65 files changed
+2947 -208

No files matched your search

+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 };
}
+30 -1
View File
@@ -1,3 +1,5 @@
import { labelFor as outlineLabel } from './outlineLabels.js';
// ---------------------------------------------------------------------------
// Study constants
// ---------------------------------------------------------------------------
@@ -187,6 +189,11 @@ 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 .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 +204,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>${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) => {
@@ -284,12 +296,18 @@ 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 outlineListHtml(nodes, depth) {
return `<ul>${nodes.map((n, i) => `<li><span class="lbl">${escHtml(outlineLabel(depth, i))}</span>${escHtml((n.text ?? '').trim() || '(untitled)')}${n.ref ? ` <span class="ref">(${escHtml(n.ref)})</span>` : ''}${n.children?.length ? outlineListHtml(n.children, depth + 1) : ''}</li>`).join('')}</ul>`;
}
export function wordTableHtml(rows) {
return `
<table>
@@ -341,7 +359,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) {
+17
View File
@@ -14,6 +14,7 @@ import {
WidthType,
} from 'docx';
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
import { flatten } from './outline.js';
export function createParagraphsFromText(text) {
return text
@@ -89,6 +90,22 @@ 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 } }));
flatten(project.outline).forEach(({ node, depth, label }) => {
children.push(new Paragraph({
indent: { left: 420 * depth, hanging: 420 },
spacing: { after: 80 },
children: [
new TextRun({ text: `${label}\t`, bold: true, color: DOCX_ACCENT }),
new TextRun({ text: (node.text ?? '').trim() || '(untitled)' }),
...(node.ref ? [new TextRun({ text: ` (${node.ref})`, italics: true, color: '7A7060' })] : []),
],
}));
});
}
project.chapters.forEach((ch, chapterIndex) => {
children.push(new Paragraph({
text: `${ch.book} ${ch.chapter}`,
+3 -1
View File
@@ -4,6 +4,7 @@ import {
getChunkVerseEntries,
htmlToPlainText,
} from './bibleUtils.jsx';
import { outlineToMarkdown } from './outline.js';
// ---------------------------------------------------------------------------
// Export / prompt builders (exported for testing)
@@ -48,7 +49,8 @@ export function buildMarkdownExport(project) {
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${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);
});
+164
View File
@@ -0,0 +1,164 @@
// 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, makeId } from './bibleUtils.jsx';
import { labelFor, toRoman } from './outlineLabels.js';
export { labelFor, toRoman };
export const MAX_DEPTH = 5; // levels below the main points
export const newNode = (text = '', extra = {}) => ({ id: makeId(), text, ref: '', chunkId: null, 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 pointText = (n) => `${n.text.trim() || '(untitled)'}${n.ref.trim() ? ` (${n.ref.trim()})` : ''}`;
export function outlineToText(tree) {
return flatten(tree).map(({ node, depth, label }) => `${' '.repeat(depth)}${label} ${pointText(node)}`).join('\n');
}
export function outlineToMarkdown(tree) {
return flatten(tree).map(({ node, depth, label }) => `${' '.repeat(depth)}- **${label}** ${pointText(node)}`).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];
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, test } from 'vitest';
import {
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent,
insertAt, locate, 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']);
});
});
+28
View File
@@ -0,0 +1,28 @@
// 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)})`;
}
}
+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('');
});
+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();