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

+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 };
}