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

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