- One outline for the whole study plus one per chunk (picker at the top); chunk outlines can start from the chunk's verses, each carrying its words - Points can have a type (illustration, application, quote, question, transition), minutes (with a running total), a longer note, verse text fetched from a passage, and cross-references / word studies attached from the chunk - Share (system share sheet, or copy), Print / PDF, Copy as text, Word and Markdown downloads for any one outline - All of it included in the Markdown, HTML/PDF and Word study exports, with each chunk's outline under that chunk Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
237 lines
9.8 KiB
JavaScript
237 lines
9.8 KiB
JavaScript
// 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, getChunkVerseEntries, makeId } from './bibleUtils.jsx';
|
||
import { KINDS, kindLabel, labelFor, toRoman } from './outlineLabels.js';
|
||
|
||
export { KINDS, kindLabel, labelFor, toRoman };
|
||
|
||
export const MAX_DEPTH = 5; // levels below the main points
|
||
|
||
// A point: its words, an optional passage and chunk link, plus a longer note, a kind, minutes,
|
||
// the text of a verse, and things attached from the chunks (cross-references, word studies).
|
||
export const newNode = (text = '', extra = {}) => ({
|
||
id: makeId(), text, ref: '', chunkId: null, note: '', kind: '', minutes: null, scripture: null, attachments: [], 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 minutesText = (m) => (Number.isFinite(m) && m > 0 ? `${m} min` : '');
|
||
|
||
// Total minutes written on the points (blank ones count as nothing)
|
||
export const totalMinutes = (tree) => flatten(tree).reduce((sum, { node }) => sum + (Number.isFinite(node.minutes) ? node.minutes : 0), 0);
|
||
|
||
// The headline of a point: "[Illustration] The words (passage) · 5 min"
|
||
const pointText = (n) => {
|
||
const kind = n.kind ? `[${kindLabel(n.kind)}] ` : '';
|
||
const mins = minutesText(n.minutes);
|
||
return `${kind}${(n.text ?? '').trim() || '(untitled)'}${(n.ref ?? '').trim() ? ` (${n.ref.trim()})` : ''}${mins ? ` · ${mins}` : ''}`;
|
||
};
|
||
|
||
// The lines under a point's headline: its note, verse text and attachments.
|
||
export function detailLines(node) {
|
||
const lines = [];
|
||
for (const l of (node.note ?? '').split('\n')) if (l.trim()) lines.push(l.trim());
|
||
if (node.scripture?.text) lines.push(`“${node.scripture.text}” — ${node.scripture.ref}`);
|
||
for (const a of node.attachments ?? []) lines.push(`${a.type === 'word' ? 'Word' : 'See'}: ${a.label}${a.detail ? ` — ${a.detail}` : ''}`);
|
||
return lines;
|
||
}
|
||
|
||
export const timeSummary = (tree) => {
|
||
const total = totalMinutes(tree);
|
||
return total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : '';
|
||
};
|
||
|
||
export function outlineToText(tree) {
|
||
return flatten(tree).flatMap(({ node, depth, label }) => [
|
||
`${' '.repeat(depth)}${label} ${pointText(node)}`,
|
||
...detailLines(node).map((l) => `${' '.repeat(depth)} ${l}`),
|
||
]).join('\n');
|
||
}
|
||
|
||
export function outlineToMarkdown(tree) {
|
||
return flatten(tree).flatMap(({ node, depth, label }) => [
|
||
`${' '.repeat(depth)}- **${label}** ${pointText(node)}`,
|
||
...detailLines(node).map((l) => `${' '.repeat(depth)} *${l}*`),
|
||
]).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];
|
||
}
|
||
|
||
// ---- one outline per chunk ---------------------------------------------------
|
||
// A study has one outline for the whole message (project.outline) and each chunk can have its own
|
||
// (chunk.outline). `scope` is STUDY or a chunk's id.
|
||
export const STUDY = 'study';
|
||
|
||
export function chunkOf(project, chunkId) {
|
||
for (const chapter of project?.chapters ?? []) for (const chunk of chapter.chunks ?? []) if (chunk.id === chunkId) return chunk;
|
||
return null;
|
||
}
|
||
|
||
export const outlineFor = (project, scope) => (scope === STUDY ? project?.outline ?? [] : chunkOf(project, scope)?.outline ?? []);
|
||
|
||
// A copy of the project with that scope's outline replaced.
|
||
export function withOutline(project, scope, tree) {
|
||
if (scope === STUDY) return { ...project, outline: tree };
|
||
return {
|
||
...project,
|
||
chapters: project.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => (c.id === scope ? { ...c, outline: tree } : c)) })),
|
||
};
|
||
}
|
||
|
||
// One point per verse of the chunk, each carrying that verse's words, for verses not already in `tree`.
|
||
export function seedFromVerses(project, chunkId, tree = []) {
|
||
const have = new Set(flatten(tree).map((f) => f.node.scripture?.ref).filter(Boolean));
|
||
const added = [];
|
||
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
|
||
for (const chunk of chapter.chunks ?? []) {
|
||
if (chunk.id !== chunkId) continue;
|
||
for (const v of getChunkVerseEntries(project, chapterIndex, chunk)) {
|
||
const ref = `${chapter.book} ${v.chapter}:${v.number}`;
|
||
if (have.has(ref)) continue;
|
||
added.push(newNode('', { ref, scripture: { ref, text: v.text, translation: project.translation ?? 'BSB' } }));
|
||
}
|
||
}
|
||
});
|
||
return [...tree, ...added];
|
||
}
|