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

+15 -1
View File
@@ -14,7 +14,7 @@ import IqOutlineAttach from '../components/iq/IqOutlineAttach.jsx';
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx'; import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js'; import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
import { import {
KINDS, STUDY, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate, KINDS, STUDY, buildChunkFromNotes, buildFromNotes, notesBuildCount, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline, moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
} from '../utils/outline.js'; } from '../utils/outline.js';
@@ -73,6 +73,7 @@ export default function OutlinePage() {
? seedFromChunks(project, tree).length - tree.length ? seedFromChunks(project, tree).length - tree.length
: seedFromVerses(project, scope, tree).length - tree.length; : seedFromVerses(project, scope, tree).length - tree.length;
const seedLabel = scope === STUDY ? 'Add my chunks' : 'Add the verses'; const seedLabel = scope === STUDY ? 'Add my chunks' : 'Add the verses';
const fromNotes = notesBuildCount(project, scope, tree);
const addMain = () => { const node = newNode(); edit((t) => addRoot(t, node)); setPendingFocus(node.id); }; const addMain = () => { const node = newNode(); edit((t) => addRoot(t, node)); setPendingFocus(node.id); };
const addSub = (id) => { const node = newNode(); edit((t) => addChild(t, id, node)); setPendingFocus(node.id); }; const addSub = (id) => { const node = newNode(); edit((t) => addChild(t, id, node)); setPendingFocus(node.id); };
@@ -81,6 +82,14 @@ export default function OutlinePage() {
edit((t) => (scope === STUDY ? seedFromChunks(project, t) : seedFromVerses(project, scope, t))); edit((t) => (scope === STUDY ? seedFromChunks(project, t) : seedFromVerses(project, scope, t)));
toast(`Added ${addable} point${addable === 1 ? '' : 's'}`); toast(`Added ${addable} point${addable === 1 ? '' : 's'}`);
}; };
// Builds the outline from the Observe / Interpret / Apply and background notes, with an Undo.
const buildFromMyNotes = () => {
const before = tree;
edit((t) => (scope === STUDY ? buildFromNotes(project, t) : buildChunkFromNotes(project, scope, t)));
toast(`Built ${fromNotes} ${scope === STUDY ? `main point${fromNotes === 1 ? '' : 's'} from your chunk notes` : `section${fromNotes === 1 ? '' : 's'} from this chunk's notes`}. Your notes are copied into it, so they appear if you share the outline.`, {
action: { label: 'Undo', onClick: () => edit(() => before) },
});
};
const remove = (id) => { const remove = (id) => {
const where = locate(tree, id); const where = locate(tree, id);
const gone = getNode(tree, id); const gone = getNode(tree, id);
@@ -157,6 +166,10 @@ export default function OutlinePage() {
title={scope === STUDY ? "Adds a main point for each chunk that isn't in the outline yet" : "Adds a point for each verse of this chunk, with its words"}> title={scope === STUDY ? "Adds a main point for each chunk that isn't in the outline yet" : "Adds a point for each verse of this chunk, with its words"}>
{seedLabel}{addable > 0 ? ` (${addable})` : ''} {seedLabel}{addable > 0 ? ` (${addable})` : ''}
</button> </button>
<button type="button" onClick={buildFromMyNotes} disabled={fromNotes <= 0} className={btn}
title="Turns your Observe, Interpret, Apply and background notes into points: a main point per chunk, or this chunk's sections">
Build from my notes{fromNotes > 0 ? ` (${fromNotes})` : ''}
</button>
<span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" /> <span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" />
<button type="button" onClick={() => setShareOpen(true)} disabled={tree.length === 0} className={btn}>Share</button> <button type="button" onClick={() => setShareOpen(true)} disabled={tree.length === 0} className={btn}>Share</button>
<button type="button" onClick={print} disabled={tree.length === 0} className={btn}>Print / PDF</button> <button type="button" onClick={print} disabled={tree.length === 0} className={btn}>Print / PDF</button>
@@ -182,6 +195,7 @@ export default function OutlinePage() {
<button type="button" onClick={seed} disabled={addable <= 0} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50"> <button type="button" onClick={seed} disabled={addable <= 0} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">
{scope === STUDY ? 'Start from my chunks' : 'Start from the verses'} {scope === STUDY ? 'Start from my chunks' : 'Start from the verses'}
</button> </button>
<button type="button" onClick={buildFromMyNotes} disabled={fromNotes <= 0} className={btn}>Build from my notes{fromNotes > 0 ? ` (${fromNotes})` : ''}</button>
<button type="button" onClick={addMain} className={btn}>Start blank</button> <button type="button" onClick={addMain} className={btn}>Start blank</button>
</div> </div>
</div> </div>
+74
View File
@@ -3,6 +3,7 @@
// returns a new tree and leaves the one it was given alone. // returns a new tree and leaves the one it was given alone.
import { formatChunkReference, getChunkVerseEntries, makeId } from './bibleUtils.jsx'; import { formatChunkReference, getChunkVerseEntries, makeId } from './bibleUtils.jsx';
import { KINDS, kindLabel, labelFor, toRoman } from './outlineLabels.js'; import { KINDS, kindLabel, labelFor, toRoman } from './outlineLabels.js';
import { attachableFromChunk } from './passageText.js';
export { KINDS, kindLabel, labelFor, toRoman }; export { KINDS, kindLabel, labelFor, toRoman };
@@ -197,6 +198,79 @@ export function seedFromChunks(project, tree = []) {
return [...tree, ...added]; 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 --------------------------------------------------- // ---- one outline per chunk ---------------------------------------------------
// A study has one outline for the whole message (project.outline) and each chunk can have its own // 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. // (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 { describe, expect, test } from 'vitest';
import { import {
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent, 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'; } from './outline.js';
const n = (id, text = id, children = []) => ({ id, text, ref: '', chunkId: null, children }); 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); 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);
});
});