// 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 ", "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); }