Files
study-bible-project/src/utils/importNotes.js
T
nmemmertandClaude Sonnet 5.5 67645cf1df App quality: offline badge, per-note history, keyboard study mode, notes import
- 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>
2026-10-14 13:01:11 -04:00

91 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };
}