Files
study-bible-project/src/utils/personalSearch.js
T
nmemmertandClaude Sonnet 5.5 d8e522dd06 Add Insights, Observe-first, compare, handout layouts, richer search, backup nudge
- Commentary and study aids wait for 15 words of your own Observe notes.
- Insights page: this week's own work, themes across studies, words you keep
  studying, what slipped in recalls, open follow-ups.
- Recall can record what slipped; session notes (teach it back, who came,
  group notes, follow-ups); "Pray about this" from Apply.
- Compare any two passages side by side.
- Handout layouts: standard, questions only, large print, note-taking.
- Search understands "in <book>", "last week/month/year" and a year.
- Home shows the last backup age and nudges after two weeks.
- Phone: the stage bar scrolls instead of clipping Script and Export.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:42:11 -04:00

108 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.
// Search across everything the reader has written or saved on this device: study notes, project
// titles and passages, bookmarks and highlights. (Bible text itself is searched in the Reader.)
import { bookOptions, formatChunkReference } from './bibleUtils.jsx';
const NOTE_FIELDS = [
['observation', 'Observation'], ['interpretation', 'Interpretation'], ['application', 'Application'],
['generalNotes', 'Notes'], ['finalScript', 'Script'],
];
const snippetAround = (raw, at, len) => {
const start = Math.max(0, at - 40);
const end = at + len + 60;
return (start > 0 ? '…' : '') + raw.slice(start, end).replace(/\s+/g, ' ') + (end < raw.length ? '…' : '');
};
const DAY = 86400000;
// "grace in Romans last year" -> { text: 'grace', book: 'Romans', since, until }. Words it understands:
// "in <book>", "last|this week|month|year" (last = the past 7 / 30 / 365 days; this year = since 1 January) and a
// four-digit year. Whatever is left is the text to look for.
export function parseSearchQuery(query, now = Date.now()) {
let text = ` ${query} `;
let book = null;
let since = null;
let until = null;
for (const b of [...bookOptions].sort((x, y) => y.name.length - x.name.length)) {
const re = new RegExp(`\\s(?:in|from)\\s+${b.name.toLowerCase().replace(/\s+/g, '\\s+')}(?=\\s)`, 'i');
if (re.test(text)) { book = b.name; text = text.replace(re, ' '); break; }
}
const rel = text.match(/\s(last|this)\s+(week|month|year)(?=\s)/i);
if (rel) {
const [, which, unit] = rel;
const span = { week: 7, month: 30, year: 365 }[unit.toLowerCase()];
since = which.toLowerCase() === 'last' ? now - span * DAY : (unit.toLowerCase() === 'year' ? new Date(new Date(now).getFullYear(), 0, 1).getTime() : now - span * DAY);
text = text.replace(rel[0], ' ');
}
const year = text.match(/\s(20\d\d)(?=\s)/);
if (year) {
since = new Date(Number(year[1]), 0, 1).getTime();
until = new Date(Number(year[1]) + 1, 0, 1).getTime();
text = text.replace(year[0], ' ');
}
return { text: text.replace(/\s+/g, ' ').trim(), book, since, until };
}
// projects: full project objects. -> [{ projectId, projectTitle, chunkId, ref, field, snippet }], at most
// `limit`, one hit per chunk (the first field that matches).
export function searchNotes(projects, query, { limit = 20, now = Date.now() } = {}) {
const parsed = parseSearchQuery(query, now);
const q = parsed.text.toLowerCase();
const filtered = !!(parsed.book || parsed.since);
if (q.length < 3 && !(filtered && q.length === 0)) return [];
const hits = [];
for (const project of projects) {
for (const [chapterIndex, chapter] of (project.chapters ?? []).entries()) {
if (parsed.book && chapter.book !== parsed.book) continue;
for (const chunk of chapter.chunks ?? []) {
const when = chunk.editedAt ?? project.lastEdited ?? 0;
if (parsed.since && when < parsed.since) continue;
if (parsed.until && when >= parsed.until) continue;
for (const [field, label] of NOTE_FIELDS) {
const raw = chunk[field] ?? '';
if (!q && !raw.trim()) continue; // a passage/date search lists chunks that have something written
const at = q ? raw.toLowerCase().indexOf(q) : 0;
if (at === -1) continue;
hits.push({
projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
ref: formatChunkReference(project, chapterIndex, chunk, '–'), field: label, snippet: snippetAround(raw, at, q.length),
});
break;
}
if (hits.length >= limit) return hits;
}
}
}
return hits;
}
// Projects whose title, passages or tags contain the text.
export function searchProjects(index, query, { limit = 6 } = {}) {
const q = query.trim().toLowerCase();
if (!q) return [];
return index.filter((e) => [e.title, e.chapterSummary, ...(e.tags ?? [])].some((t) => (t ?? '').toLowerCase().includes(q))).slice(0, limit);
}
const bookName = (abbrev) => bookOptions.find((b) => b.abbrev === abbrev)?.name ?? abbrev;
// Highlights (by their words or what the colour is named) and bookmarks (by reference).
// -> [{ kind: 'highlight' | 'bookmark', book, bookName, chapter, verse, color, text?, label? }]
export function searchMarks({ bookmarks = {}, highlights = [], labels = {} }, query, { limit = 10 } = {}) {
const q = query.trim().toLowerCase();
if (q.length < 2) return [];
const out = [];
for (const h of highlights) {
const label = labels[h.color] ?? '';
const ref = `${bookName(h.book)} ${h.chapter}:${h.verse}`.toLowerCase();
if ((h.text ?? '').toLowerCase().includes(q) || label.toLowerCase().includes(q) || ref.includes(q)) {
out.push({ kind: 'highlight', book: h.book, bookName: bookName(h.book), chapter: h.chapter, verse: h.verse, color: h.color, text: h.text, label });
}
}
for (const [key, color] of Object.entries(bookmarks)) {
const [book, chapter, verse] = key.split('-');
const ref = `${bookName(book)} ${chapter}:${verse}`;
if (ref.toLowerCase().includes(q)) out.push({ kind: 'bookmark', book, bookName: bookName(book), chapter: Number(chapter), verse: Number(verse), color });
}
return out.slice(0, limit);
}