- Study cards say whether the Bible text works offline, with a button to save the missing book (notes are always kept on the device). - Each note keeps its last 12 versions with Restore (History under each note). - Keyboard-only study: 1/2/3 write in Observe/Interpret/Apply, C/W/X/P switch resource tabs, V compares, Esc leaves a note box; listed under "?". - Export > Import notes: bring Word/Markdown/text notes into the matching chunks without overwriting anything; can mark them as not your own. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
91 lines
5.1 KiB
JavaScript
91 lines
5.1 KiB
JavaScript
// Bring notes written elsewhere (a Word file or Markdown from another app) into a study. A passage heading starts
|
||
// each section ("## John 3:16–18"), and labelled lines or sub-headings ("Observe: …", "### Interpret") hold the
|
||
// notes. Sections are matched to your chunks by passage. Nothing already written is overwritten.
|
||
import { parsePassageRef } from './passageRef.js';
|
||
import { withPaste } from './ownWork.js';
|
||
|
||
const LABELS = [
|
||
[/^observ(?:e|ation|ations)$/i, 'observation'],
|
||
[/^interpret(?:ation)?$/i, 'interpretation'],
|
||
[/^appl(?:y|ication)$/i, 'application'],
|
||
[/^(?:background|notes?|general notes)$/i, 'generalNotes'],
|
||
[/^(?:discussion )?questions$/i, 'discussionQuestions'],
|
||
];
|
||
const FIELD_NAMES = { observation: 'Observe', interpretation: 'Interpret', application: 'Apply', generalNotes: 'Background', discussionQuestions: 'Questions' };
|
||
export const importFieldName = (f) => FIELD_NAMES[f] ?? f;
|
||
|
||
const clean = (line) => line.replace(/^\s*#+\s*/, '').replace(/\*\*|__/g, '').trim();
|
||
const labelOf = (word) => LABELS.find(([re]) => re.test(word.trim()))?.[1] ?? null;
|
||
|
||
// A heading that is just a passage ("John 3:16-18", "Session 2: Romans 8:1–4") -> { book, chapter, verse, text } or null.
|
||
export function passageHeading(line) {
|
||
const text = clean(line).replace(/^(?:session|lesson|week|episode|part)\s*\d+\s*[:.\-–—]\s*/i, '');
|
||
const m = text.match(/^((?:[1-3]\s*)?[A-Za-z][A-Za-z .]*?)\s+(\d{1,3})(?:\s*:\s*(\d{1,3}))?(?:\s*[-–—]\s*\d{1,3}(?:\s*:\s*\d{1,3})?)?\s*$/);
|
||
if (!m || text.length > 60) return null;
|
||
const ref = parsePassageRef(`${m[1]} ${m[2]}${m[3] ? `:${m[3]}` : ''}`)[0];
|
||
return ref ? { book: ref.book, chapter: ref.chapter, verse: m[3] ? Number(m[3]) : null, text } : null;
|
||
}
|
||
|
||
// -> [{ heading, ref, observation?, interpretation?, application?, generalNotes?, discussionQuestions? }]
|
||
export function parseNotesText(raw) {
|
||
const sections = [];
|
||
let current = null;
|
||
let field = null;
|
||
const append = (value) => {
|
||
if (!current || !field) return;
|
||
current[field] = current[field] ? `${current[field]}\n${value}` : value;
|
||
};
|
||
for (const line of String(raw ?? '').replace(/\r/g, '').split('\n')) {
|
||
const heading = passageHeading(line);
|
||
if (heading) { current = { heading: heading.text, ref: heading }; sections.push(current); field = null; continue; }
|
||
if (!current) continue;
|
||
const stripped = clean(line).replace(/^[-*•]\s+/, '');
|
||
const labelled = stripped.match(/^([A-Za-z ]{3,22}?)\s*[:\-–]\s*(.*)$/);
|
||
const bare = /^\s*(?:#+\s|\*\*|__)/.test(line) ? labelOf(stripped.replace(/[:\-–]\s*$/, '')) : null; // "### Observe", "**Observe**"
|
||
if (labelled && labelOf(labelled[1])) { field = labelOf(labelled[1]); current[field] = current[field] ?? ''; if (labelled[2].trim()) append(labelled[2].trim()); continue; }
|
||
if (bare) { field = bare; current[field] = current[field] ?? ''; continue; }
|
||
if (field) append(line.trim());
|
||
}
|
||
return sections.map((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, typeof v === 'string' ? v.replace(/^\n+|\n+$/g, '').trim() : v])))
|
||
.filter((s) => Object.keys(FIELD_NAMES).some((f) => s[f]));
|
||
}
|
||
|
||
// Which chunk a section belongs to: the one whose verses include the heading's verse (or the first in that chapter).
|
||
export function matchChunk(project, ref) {
|
||
for (const chapter of project?.chapters ?? []) {
|
||
if (chapter.bookAbbrev !== ref.book || Number(chapter.chapter) !== ref.chapter) continue;
|
||
const chunks = chapter.chunks ?? [];
|
||
const hit = ref.verse == null ? chunks[0] : chunks.find((c) => ref.verse >= c.startVerse && ref.verse <= c.endVerse);
|
||
if (hit) return hit;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
const MARKER = '— imported —';
|
||
|
||
// -> { project, matched: [{ heading, fields }], unmatched: [heading] }. Where a note already has text, the imported
|
||
// text is added below a marker instead of replacing it. `own: false` records the text as pasted, not your own work.
|
||
export function applyImportedNotes(project, sections, { own = true, now = Date.now() } = {}) {
|
||
const patches = new Map(); // chunk id -> merged chunk
|
||
const matched = [];
|
||
const unmatched = [];
|
||
for (const section of sections) {
|
||
const chunk = matchChunk(project, section.ref);
|
||
if (!chunk) { unmatched.push(section.heading); continue; }
|
||
const next = { ...(patches.get(chunk.id) ?? chunk) };
|
||
const fields = [];
|
||
for (const f of Object.keys(FIELD_NAMES)) {
|
||
const incoming = section[f];
|
||
if (!incoming) continue;
|
||
const have = (next[f] ?? '').trim();
|
||
if (have.includes(incoming)) continue;
|
||
next[f] = have ? `${next[f].replace(/\s+$/, '')}\n\n${MARKER}\n${incoming}` : incoming;
|
||
if (!own && ['observation', 'interpretation', 'application'].includes(f)) next.aiPastes = withPaste({ aiPastes: next.aiPastes }, f, incoming) ?? { ...(next.aiPastes ?? {}), [f]: [...(next.aiPastes?.[f] ?? []), incoming] };
|
||
fields.push(importFieldName(f));
|
||
}
|
||
if (fields.length) { next.editedAt = now; patches.set(chunk.id, next); matched.push({ heading: section.heading, fields }); }
|
||
}
|
||
const out = { ...project, chapters: project.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => patches.get(c.id) ?? c) })) };
|
||
return { project: out, matched, unmatched };
|
||
}
|