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

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