Files
study-bible-project/src/utils/outline.js
T
nmemmertandClaude Sonnet 5.5 d13f55faf7 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>
2026-10-01 14:08:08 -04:00

165 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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];
}