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:
1 parent
1edbd364ea
commit
d13f55faf7
65 files changed
+2947
-208
No files matched your search
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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)})`;
|
||||
}
|
||||
}
|
||||
@@ -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}` : ''}`;
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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(', ');
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user