Build an outline from the Observe/Interpret/Apply and background notes
Build and Push Docker Image / build-and-push (push) Successful in 4m8s

New "Build from my notes" on the Outline page: a main point per chunk with
Background, What the text says, What it means and How we respond under it
(a point per note line, long lines become a headline plus a note), with the
chunk's cross-references and words attached. Also builds a single chunk's
sections. Only adds what's missing; Undo offered.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 08:48:17 -04:00
1 parent f70f1baf15
commit d2bbf1feb4
3 files changed
+128 -2

No files matched your search

+74
View File
@@ -3,6 +3,7 @@
// 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';
import { attachableFromChunk } from './passageText.js';
export { KINDS, kindLabel, labelFor, toRoman };
@@ -197,6 +198,79 @@ export function seedFromChunks(project, tree = []) {
return [...tree, ...added];
}
// ---- building from the notes -------------------------------------------------
// The notes written in the Study stage, turned into an outline: for each chunk a main point, with
// Background, What the text says (Observe), What it means (Interpret) and How we respond (Apply) under it,
// and each line of a note as a point beneath its section. The chunk's cross-references and word studies
// are attached to its main point. Plain rules, nothing is rewritten: a long line becomes a short headline
// (its first sentence) with the rest kept as that point's note.
export const NOTE_SECTIONS = [
{ field: 'generalNotes', title: 'Background', kind: '' },
{ field: 'observation', title: 'What the text says', kind: '' },
{ field: 'interpretation', title: 'What it means', kind: '' },
{ field: 'application', title: 'How we respond', kind: 'application' },
];
const MAX_ITEMS = 15;
const HEADLINE = 110;
const MARKER = /^\s*(?:[-*•–]|\d{1,2}[.)]|[a-z][.)])\s+/i;
// One note box -> [{ text, note }]: a point per line (bullet markers dropped, indented lines join the line above).
export function notesToPoints(raw) {
const items = [];
for (const line of String(raw ?? '').split('\n')) {
if (!line.trim()) continue;
if (/^\s{2,}\S/.test(line) && items.length && !MARKER.test(line)) { items[items.length - 1] += ` ${line.trim()}`; continue; }
items.push(line.replace(MARKER, '').trim());
}
return items.filter(Boolean).slice(0, MAX_ITEMS).map((full) => {
const sentence = /^(.{20,}?[.!?])(\s+|$)/.exec(full)?.[1] ?? full;
let headline = sentence;
if (headline.length > HEADLINE) headline = `${headline.slice(0, HEADLINE).replace(/\s+\S*$/, '')}…`;
const rest = headline.endsWith('…') ? full : full.slice(sentence.length).trim();
return { text: headline.replace(/[.]$/, ''), note: rest };
});
}
const sectionNodes = (chunk, skipTitles = new Set()) => NOTE_SECTIONS
.filter((sec) => !skipTitles.has(sec.title))
.map((sec) => ({ sec, points: notesToPoints(chunk[sec.field]) }))
.filter(({ points }) => points.length > 0)
.map(({ sec, points }) => newNode(sec.title, { children: points.map((pt) => newNode(pt.text, { note: pt.note, kind: sec.kind })) }));
const hasNotes = (chunk) => NOTE_SECTIONS.some((sec) => notesToPoints(chunk[sec.field]).length > 0);
// For the whole study: a main point (with its sections) for every chunk that has notes and isn't in the tree yet.
export function buildFromNotes(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) || !hasNotes(chunk)) 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,
attachments: attachableFromChunk(chunk).map((a) => ({ id: a.key, type: a.type, label: a.label, ...(a.detail ? { detail: a.detail } : {}) })),
children: sectionNodes(chunk),
}));
}
});
return [...tree, ...added];
}
// For one chunk's own outline: its sections as main points, skipping any already there.
export function buildChunkFromNotes(project, chunkId, tree = []) {
const chunk = chunkOf(project, chunkId);
if (!chunk) return tree;
const have = new Set(tree.map((n) => n.text));
return [...tree, ...sectionNodes(chunk, have)];
}
// How many points building from the notes would add (main points for a study, sections for a chunk).
export function notesBuildCount(project, scope, tree = []) {
return scope === STUDY ? buildFromNotes(project, tree).length - tree.length : buildChunkFromNotes(project, scope, tree).length - tree.length;
}
// ---- 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.
+39 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'vitest';
import {
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent,
STUDY, chunkOf, detailLines, insertAt, outlineFor, seedFromVerses, withOutline, locate, timeSummary, totalMinutes, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH,
STUDY, chunkOf, detailLines, insertAt, outlineFor, seedFromVerses, withOutline, locate, timeSummary, totalMinutes, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH, notesToPoints, buildFromNotes, buildChunkFromNotes, notesBuildCount,
} from './outline.js';
const n = (id, text = id, children = []) => ({ id, text, ref: '', chunkId: null, children });
@@ -177,3 +177,41 @@ describe('an outline for each chunk', () => {
expect(seedFromVerses(project, 'c2', [])).toHaveLength(1);
});
});
describe('building from the notes', () => {
const chunkWith = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 5, observation: '', interpretation: '', application: '', generalNotes: '', crossReferences: [], greekWords: [], ...extra });
const study = (chunks) => ({ id: 's', title: 'T', translation: 'BSB', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks }] });
test('lines become points; bullets are cleaned; a long line becomes a headline plus a note', () => {
const points = notesToPoints('- Nicodemus comes at night\n2) He calls Jesus Rabbi\nGod is the one who gives new birth, and nothing a person does can earn it. It is a gift.\n and a mystery\n\n');
expect(points.map((p) => p.text)).toEqual(['Nicodemus comes at night', 'He calls Jesus Rabbi', 'God is the one who gives new birth, and nothing a person does can earn it']);
expect(points[2].note).toBe('It is a gift. and a mystery');
expect(notesToPoints('')).toEqual([]);
expect(notesToPoints(Array.from({ length: 30 }, (_, i) => `line ${i}`).join('\n'))).toHaveLength(15);
});
test('a study gets a main point per chunk with its sections, and chunks without notes are skipped', () => {
const project = study([
chunkWith('a', { episodeTitle: 'Born again', observation: '- Night visit', interpretation: 'Birth from above', application: 'Trust Him', generalNotes: 'Pharisee and ruler', crossReferences: ['Rom 8:1'] }),
chunkWith('b'),
]);
const tree = buildFromNotes(project);
expect(tree).toHaveLength(1);
expect(tree[0]).toMatchObject({ text: 'Born again', ref: 'John 3:1–5', chunkId: 'a' });
expect(tree[0].children.map((c) => c.text)).toEqual(['Background', 'What the text says', 'What it means', 'How we respond']);
expect(tree[0].children[3].children[0]).toMatchObject({ text: 'Trust Him', kind: 'application' });
expect(tree[0].attachments.map((a) => a.label)).toEqual(['Rom 8:1']);
expect(notesBuildCount(project, STUDY, [])).toBe(1);
expect(buildFromNotes(project, tree)).toHaveLength(1); // nothing new the second time
expect(notesBuildCount(project, STUDY, tree)).toBe(0);
});
test('a chunk\'s own outline gets its sections once', () => {
const project = study([chunkWith('a', { observation: 'One\nTwo', application: 'Do it' })]);
const tree = buildChunkFromNotes(project, 'a', []);
expect(tree.map((n) => n.text)).toEqual(['What the text says', 'How we respond']);
expect(tree[0].children.map((c) => c.text)).toEqual(['One', 'Two']);
expect(buildChunkFromNotes(project, 'a', tree)).toHaveLength(2);
expect(notesBuildCount(project, 'a', tree)).toBe(0);
});
});