diff --git a/src/pages/OutlinePage.jsx b/src/pages/OutlinePage.jsx
index f545bb8..e75d347 100644
--- a/src/pages/OutlinePage.jsx
+++ b/src/pages/OutlinePage.jsx
@@ -14,7 +14,7 @@ import IqOutlineAttach from '../components/iq/IqOutlineAttach.jsx';
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
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,
} from '../utils/outline.js';
@@ -73,6 +73,7 @@ export default function OutlinePage() {
? seedFromChunks(project, tree).length - tree.length
: seedFromVerses(project, scope, tree).length - tree.length;
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 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)));
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 where = locate(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"}>
{seedLabel}{addable > 0 ? ` (${addable})` : ''}
+
@@ -182,6 +195,7 @@ export default function OutlinePage() {
+
diff --git a/src/utils/outline.js b/src/utils/outline.js
index 04e508b..8c35ac0 100644
--- a/src/utils/outline.js
+++ b/src/utils/outline.js
@@ -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.
diff --git a/src/utils/outline.test.js b/src/utils/outline.test.js
index 7b8641b..39a2675 100644
--- a/src/utils/outline.test.js
+++ b/src/utils/outline.test.js
@@ -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);
+ });
+});