- 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>
108 lines
5.1 KiB
JavaScript
108 lines
5.1 KiB
JavaScript
// 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);
|
||
}
|