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>
This commit is contained in:
1 parent
2ddc4e1ff6
commit
d8e522dd06
24 files changed
+540
-28
No files matched your search
@@ -89,3 +89,14 @@ export function mergeReadingPlans(local, incoming) {
|
||||
activity: [...new Set([...(local.activity ?? []), ...(incoming.activity ?? [])])].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
// When you last downloaded a backup on this device, so Home can say so (and nudge when it has been a while).
|
||||
const LAST_BACKUP_KEY = 'last-backup-at';
|
||||
export const markBackedUp = (now = Date.now()) => { try { localStorage.setItem(LAST_BACKUP_KEY, String(now)); } catch { /* storage unavailable */ } };
|
||||
export const lastBackupAt = () => { try { return Number(localStorage.getItem(LAST_BACKUP_KEY)) || null; } catch { return null; } };
|
||||
export function backupAge(at, now = Date.now()) {
|
||||
if (!at) return { text: 'You haven’t downloaded a backup yet', stale: true };
|
||||
const days = Math.floor((now - at) / 86400000);
|
||||
if (days < 1) return { text: 'Backed up today', stale: false };
|
||||
return { text: `Last backup ${days} day${days === 1 ? '' : 's'} ago`, stale: days >= 14 };
|
||||
}
|
||||
@@ -68,3 +68,13 @@ test('prayers are in the backup, deleted ones are not, and they survive validati
|
||||
expect(checked.backup.prayers[0].text).toBe('Peace');
|
||||
expect(validateBackup({ app: 'bible-study-project', version: 1, prayers: [{ id: 'x' }, 5, { id: 'y', text: ' ' }] }).backup.prayers).toEqual([]);
|
||||
});
|
||||
|
||||
import { backupAge } from './backup.js';
|
||||
|
||||
test('the backup age says how long ago, and flags a long gap', () => {
|
||||
const now = 1_000_000_000_000;
|
||||
expect(backupAge(null, now)).toMatchObject({ stale: true });
|
||||
expect(backupAge(now - 3600_000, now)).toEqual({ text: 'Backed up today', stale: false });
|
||||
expect(backupAge(now - 5 * 86400000, now)).toEqual({ text: 'Last backup 5 days ago', stale: false });
|
||||
expect(backupAge(now - 20 * 86400000, now).stale).toBe(true);
|
||||
});
|
||||
+16
-7
@@ -30,16 +30,24 @@ export function questionsFor(chunk) {
|
||||
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
|
||||
}
|
||||
|
||||
function sessionHtml(project, session, { answerLines }) {
|
||||
// layout: 'standard' (everything), 'questions' (passage + questions only, one page), 'large' (big print), 'notes' (passage and a blank page to write).
|
||||
export const HANDOUT_LAYOUTS = [
|
||||
{ id: 'standard', label: 'Standard: passage, outline, questions' },
|
||||
{ id: 'questions', label: 'Questions only: passage and discussion questions' },
|
||||
{ id: 'large', label: 'Large print: passage and questions' },
|
||||
{ id: 'notes', label: 'Note-taking: passage and lots of lines' },
|
||||
];
|
||||
|
||||
function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
|
||||
const { chunk, chapterIndex, number, reference } = session;
|
||||
const series = (project.series ?? '').trim();
|
||||
const title = (chunk.episodeTitle ?? '').trim();
|
||||
const num = (chunk.episodeNumber ?? '').toString().trim() || number;
|
||||
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
|
||||
const outline = flatten(outlineForSession(project, chunk));
|
||||
const q = questionsFor(chunk);
|
||||
const outline = layout === 'standard' ? flatten(outlineForSession(project, chunk)) : [];
|
||||
const q = layout === 'notes' ? { kind: 'none', items: [] } : questionsFor(chunk);
|
||||
const blank = (n) => Array.from({ length: n }, () => '<div class="rule"></div>').join('');
|
||||
return `<section class="page">
|
||||
return `<section class="page${layout === 'standard' ? '' : ` ${layout}`}">
|
||||
<header>
|
||||
<p class="kicker">${esc(series || project.title)}</p>
|
||||
<h1>Session ${esc(num)}${title ? `: ${esc(title)}` : ''}</h1>
|
||||
@@ -49,13 +57,13 @@ function sessionHtml(project, session, { answerLines }) {
|
||||
${verses.length ? `<div class="passage">${verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${esc(v.text)}</p>`).join('')}</div>` : ''}
|
||||
${outline.length ? `<h2>Outline</h2><ul class="outline">${outline.map(({ node, depth, label }) => `<li style="margin-left:${depth * 1.2}em"><b>${esc(label)}</b> ${esc(node.text || '')}${node.ref ? ` <i>(${esc(node.ref)})</i>` : ''}</li>`).join('')}</ul>` : ''}
|
||||
${q.items.length ? `<h2>${q.kind === 'questions' ? 'Discussion questions' : 'Think about it'}</h2><ol class="questions">${q.items.map((item) => `<li>${esc(item)}${answerLines ? blank(2) : ''}</li>`).join('')}</ol>` : ''}
|
||||
${answerLines ? `<h2>Notes</h2>${blank(q.items.length ? 4 : 10)}` : ''}
|
||||
${answerLines || layout === 'notes' ? `<h2>Notes</h2>${blank(layout === 'notes' ? 16 : q.items.length ? 4 : 10)}` : ''}
|
||||
<footer>${esc(project.title)}</footer>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// ids: the chunk ids to include, or null for every session
|
||||
export function buildHandoutHtml(project, ids = null, { answerLines = true } = {}) {
|
||||
export function buildHandoutHtml(project, ids = null, { answerLines = true, layout = 'standard' } = {}) {
|
||||
const sessions = sessionsFor(project).filter((s) => !ids || ids.includes(s.chunk.id));
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(project.title)} — handout</title><style>
|
||||
@page { margin: 18mm 16mm; }
|
||||
@@ -71,8 +79,9 @@ export function buildHandoutHtml(project, ids = null, { answerLines = true } = {
|
||||
.outline { list-style: none; padding: 0; margin: 0; } .outline li { margin-bottom: .25em; } .outline b { color: #a84e27; }
|
||||
.questions { padding-left: 1.4em; } .questions li { margin-bottom: .7em; }
|
||||
.rule { border-bottom: 1px solid #c9bfa8; height: 1.7em; }
|
||||
.large { font-size: 17pt; line-height: 1.55; } .large h1 { font-size: 30pt; } .large .rule { height: 2.2em; }
|
||||
footer { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; color: #9b9484; font: 9pt -apple-system, Helvetica, Arial, sans-serif; }
|
||||
</style></head><body>${sessions.map((s) => sessionHtml(project, s, { answerLines })).join('')}</body></html>`;
|
||||
</style></head><body>${sessions.map((s) => sessionHtml(project, s, { answerLines, layout })).join('')}</body></html>`;
|
||||
}
|
||||
|
||||
export function printHandout(project, ids = null, options) {
|
||||
|
||||
@@ -46,3 +46,19 @@ test('one session only, and no writing lines when switched off', () => {
|
||||
expect(html).toContain('Session 2');
|
||||
expect(html).not.toContain('class="rule"');
|
||||
});
|
||||
|
||||
import { buildHandoutHtml as buildLayoutHtml } from './handout.js';
|
||||
|
||||
test('layouts change what the handout prints', () => {
|
||||
const project = { id: 'p', title: 'T', translation: 'BSB', outline: [], chapters: [{ book: 'John', bookAbbrev: 'Jhn', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }], chunks: [{ id: 'c', startVerse: 16, endVerse: 16, discussionQuestions: 'Why did he give?', application: 'Trust him.', outline: [{ id: 'n', text: 'Point one', children: [] }] }] }] };
|
||||
const standard = buildLayoutHtml(project);
|
||||
const questions = buildLayoutHtml(project, null, { layout: 'questions' });
|
||||
const notes = buildLayoutHtml(project, null, { layout: 'notes' });
|
||||
const large = buildLayoutHtml(project, null, { layout: 'large' });
|
||||
expect(standard).toContain('Point one');
|
||||
expect(questions).not.toContain('Point one');
|
||||
expect(questions).toContain('Why did he give?');
|
||||
expect(notes).not.toContain('Why did he give?');
|
||||
expect(notes.match(/class="rule"/g).length).toBeGreaterThanOrEqual(16);
|
||||
expect(large).toContain('class="page large"');
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
// Looks across every study at once: this week's own work, tags as themes, the original-language words you
|
||||
// keep returning to, what slipped in recalls, and follow-ups still open. `projects` are full study projects.
|
||||
import { formatChunkReference } from './bibleUtils.jsx';
|
||||
import { NOTE_FIELDS, ownShare, ownWords } from './ownWork.js';
|
||||
import { WEEK_MS } from './recall.js';
|
||||
|
||||
const lines = (text) => String(text ?? '').split('\n').map((l) => l.replace(/^\s*(?:[-*•–]|\d{1,2}[.)])\s+/, '').trim()).filter(Boolean);
|
||||
|
||||
// Every chunk with where it lives.
|
||||
function everyChunk(projects) {
|
||||
return (projects ?? []).flatMap((project) => (project?.chapters ?? []).flatMap((chapter, chapterIndex) => (chapter.chunks ?? []).map((chunk) => ({
|
||||
project, chunk, projectId: project.id, projectTitle: project.title, chunkId: chunk.id,
|
||||
reference: formatChunkReference(project, chapterIndex, chunk, '–'),
|
||||
}))));
|
||||
}
|
||||
|
||||
// Chunks edited in the last 7 days: how many, your own words in them, the share that is yours, and recalls done.
|
||||
export function weeklySummary(projects, now = Date.now()) {
|
||||
const all = everyChunk(projects);
|
||||
const recent = all.filter(({ chunk }) => now - (chunk.editedAt ?? 0) < WEEK_MS && (chunk.editedAt ?? 0) > 0);
|
||||
const ownWordsTotal = recent.reduce((n, { chunk }) => n + NOTE_FIELDS.reduce((m, f) => m + ownWords(chunk, f), 0), 0);
|
||||
const shares = recent.map(({ chunk }) => ownShare(chunk)).filter((s) => s !== null);
|
||||
return {
|
||||
chunksTouched: recent.length,
|
||||
ownWords: ownWordsTotal,
|
||||
ownShare: shares.length ? Math.round(shares.reduce((a, b) => a + b, 0) / shares.length) : null,
|
||||
recalls: all.reduce((n, { chunk }) => n + (chunk.recalls ?? []).filter((r) => now - (r.at ?? 0) < WEEK_MS).length, 0),
|
||||
teachBacks: recent.filter(({ chunk }) => (chunk.teachBack ?? '').trim()).length,
|
||||
};
|
||||
}
|
||||
|
||||
const snippet = (chunk) => {
|
||||
const raw = (chunk.observation || chunk.interpretation || chunk.application || '').replace(/\s+/g, ' ').trim();
|
||||
return raw.length > 110 ? `${raw.slice(0, 110)}…` : raw;
|
||||
};
|
||||
|
||||
// Tags across studies: [{ tag, count, items: [{ projectId, projectTitle, chunkId, reference, snippet }] }], most used first.
|
||||
export function themeIndex(projects) {
|
||||
const map = new Map();
|
||||
for (const entry of everyChunk(projects)) {
|
||||
for (const tag of entry.chunk.tags ?? []) {
|
||||
const key = tag.trim().toLowerCase();
|
||||
if (!key) continue;
|
||||
if (!map.has(key)) map.set(key, { tag: tag.trim(), items: [] });
|
||||
map.get(key).items.push({ projectId: entry.projectId, projectTitle: entry.projectTitle, chunkId: entry.chunkId, reference: entry.reference, snippet: snippet(entry.chunk) });
|
||||
}
|
||||
}
|
||||
return [...map.values()].map((t) => ({ ...t, count: t.items.length })).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||
}
|
||||
|
||||
// Original-language words studied in more than one place (or all, with minCount 1): most often first.
|
||||
export function wordIndex(projects, { minCount = 2 } = {}) {
|
||||
const map = new Map();
|
||||
for (const entry of everyChunk(projects)) {
|
||||
for (const w of entry.chunk.greekWords ?? []) {
|
||||
const strong = (w.strongNumber ?? '').toUpperCase();
|
||||
const key = strong || (w.lexeme ?? w.query ?? '').toLowerCase();
|
||||
if (!key) continue;
|
||||
if (!map.has(key)) map.set(key, { key, strong, lexeme: w.lexeme || w.query || key, gloss: w.englishGloss || w.shortDefinition || '', transliteration: w.transliteration ?? '', places: [] });
|
||||
map.get(key).places.push({ projectId: entry.projectId, projectTitle: entry.projectTitle, chunkId: entry.chunkId, reference: entry.reference });
|
||||
}
|
||||
}
|
||||
return [...map.values()].map((w) => ({ ...w, count: w.places.length })).filter((w) => w.count >= minCount).sort((a, b) => b.count - a.count || a.lexeme.localeCompare(b.lexeme));
|
||||
}
|
||||
|
||||
// What you said slipped in your recalls, newest first: [{ at, reference, projectId, chunkId, items: [line] }].
|
||||
export function slippedList(projects) {
|
||||
return everyChunk(projects).flatMap((e) => (e.chunk.recalls ?? [])
|
||||
.map((r) => ({ at: r.at ?? 0, reference: e.reference, projectId: e.projectId, projectTitle: e.projectTitle, chunkId: e.chunkId, items: lines(r.gaps) }))
|
||||
.filter((r) => r.items.length)).sort((a, b) => b.at - a.at);
|
||||
}
|
||||
|
||||
// Follow-ups across studies: open ones first (then done), newest chunk first.
|
||||
export function followUpList(projects) {
|
||||
return everyChunk(projects).flatMap((e) => (e.chunk.followUps ?? []).map((f) => ({
|
||||
id: f.id, text: f.text, done: !!f.done, reference: e.reference, projectId: e.projectId, projectTitle: e.projectTitle, chunkId: e.chunkId, editedAt: e.chunk.editedAt ?? 0,
|
||||
}))).sort((a, b) => Number(a.done) - Number(b.done) || b.editedAt - a.editedAt);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { weeklySummary, themeIndex, wordIndex, slippedList, followUpList } from './insights.js';
|
||||
import { WEEK_MS } from './recall.js';
|
||||
|
||||
const NOW = 100 * WEEK_MS;
|
||||
const text = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
|
||||
const chunk = (id, extra = {}) => ({ id, startVerse: 1, endVerse: 5, observation: text(20), editedAt: NOW - 1000, ...extra });
|
||||
const study = (chunks, id = 'p1', title = 'Romans') => ({ id, title, translation: 'BSB', chapters: [{ book: 'Romans', bookAbbrev: 'Rom', chapter: 8, chunks }] });
|
||||
|
||||
test('the weekly summary counts only chunks edited this week', () => {
|
||||
const pasted = text(10);
|
||||
const projects = [study([
|
||||
chunk('a', { teachBack: 'x', recalls: [{ at: NOW - 1000, rating: 'solid' }] }),
|
||||
chunk('b', { observation: `${text(10)} ${pasted}`, aiPastes: { observation: [pasted] }, editedAt: NOW - 500 }),
|
||||
chunk('old', { editedAt: NOW - 3 * WEEK_MS, recalls: [{ at: NOW - 3 * WEEK_MS, rating: 'solid' }] }),
|
||||
])];
|
||||
const s = weeklySummary(projects, NOW);
|
||||
expect(s).toMatchObject({ chunksTouched: 2, ownWords: 30, recalls: 1, teachBacks: 1 });
|
||||
expect(s.ownShare).toBe(75); // 100% and 50%, averaged
|
||||
expect(weeklySummary([], NOW).ownShare).toBeNull();
|
||||
});
|
||||
|
||||
test('tags group notes from different studies into themes, most used first', () => {
|
||||
const p1 = study([chunk('a', { tags: ['Grace'] }), chunk('b', { tags: ['hope'] })]);
|
||||
const p2 = study([chunk('c', { tags: ['grace '] })], 'p2', 'John');
|
||||
const themes = themeIndex([p1, p2]);
|
||||
expect(themes.map((t) => [t.tag, t.count])).toEqual([['Grace', 2], ['hope', 1]]);
|
||||
expect(themes[0].items.map((i) => i.projectTitle)).toEqual(['Romans', 'John']);
|
||||
});
|
||||
|
||||
test('words that come up in more than one place are listed', () => {
|
||||
const w = (n, g) => ({ id: n, strongNumber: n, lexeme: g, englishGloss: g });
|
||||
const projects = [study([chunk('a', { greekWords: [w('G26', 'agape'), w('G4102', 'pistis')] }), chunk('b', { greekWords: [w('g26', 'agape')] })])];
|
||||
const list = wordIndex(projects);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]).toMatchObject({ strong: 'G26', count: 2 });
|
||||
expect(wordIndex(projects, { minCount: 1 })).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('slipped lines and follow-ups are collected, open follow-ups first', () => {
|
||||
const projects = [study([chunk('a', {
|
||||
recalls: [{ at: 5, gaps: '- the serpent\nwho spoke', rating: 'partly' }, { at: 9, gaps: '', rating: 'solid' }],
|
||||
followUps: [{ id: '1', text: 'done thing', done: true }, { id: '2', text: 'call Sam', done: false }],
|
||||
})])];
|
||||
expect(slippedList(projects)).toHaveLength(1);
|
||||
expect(slippedList(projects)[0].items).toEqual(['the serpent', 'who spoke']);
|
||||
expect(followUpList(projects).map((f) => f.text)).toEqual(['call Sam', 'done thing']);
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
// conflict doesn't force a choice between them. Chunks are matched by id; each chunk carries the time it
|
||||
// was last edited here (editedAt). Nothing the person wrote is dropped: where both copies changed the
|
||||
// same note, the newer one stays and the older one is appended under a marker.
|
||||
const TEXT_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript', 'episodeNumber', 'episodeTitle'];
|
||||
const TEXT_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript', 'episodeNumber', 'episodeTitle', 'teachBack', 'attendees', 'groupNotes'];
|
||||
const LIST_FIELDS = ['tags', 'crossReferences', 'greekWords'];
|
||||
export const MERGE_MARKER = '— from your other device —';
|
||||
|
||||
@@ -22,6 +22,12 @@ function union(a = [], b = []) {
|
||||
return [...a, ...b].filter((item) => { const k = JSON.stringify(item); if (seen.has(k)) return false; seen.add(k); return true; });
|
||||
}
|
||||
|
||||
// Follow-ups are matched by id; the newer copy's wording and done-state win, nothing is dropped.
|
||||
function mergeById(newer = [], older = []) {
|
||||
const seen = new Set(newer.map((i) => i.id));
|
||||
return [...newer, ...older.filter((i) => !seen.has(i.id))];
|
||||
}
|
||||
|
||||
export function mergeChunk(mine, theirs) {
|
||||
if (same(mine, theirs)) return { chunk: mine, changed: false };
|
||||
const mineNewer = (mine.editedAt ?? 0) >= (theirs.editedAt ?? 0);
|
||||
@@ -31,6 +37,7 @@ export function mergeChunk(mine, theirs) {
|
||||
LIST_FIELDS.forEach((f) => { out[f] = union(newer[f], older[f]); });
|
||||
// Pastes follow their text: keep the union per field so merged notes still show what was pasted.
|
||||
out.aiPastes = Object.fromEntries(['observation', 'interpretation', 'application'].map((f) => [f, union(newer.aiPastes?.[f], older.aiPastes?.[f])]));
|
||||
out.followUps = mergeById(newer.followUps, older.followUps);
|
||||
out.recalls = union(newer.recalls, older.recalls);
|
||||
out.editedAt = Math.max(mine.editedAt ?? 0, theirs.editedAt ?? 0) || undefined;
|
||||
return { chunk: out, changed: !same(out, mine) };
|
||||
|
||||
@@ -47,3 +47,7 @@ export function thinFields(chunk) {
|
||||
export function chunksNeedingWork(chunks) {
|
||||
return (chunks ?? []).map((chunk) => ({ chunk, missing: thinFields(chunk) })).filter((entry) => entry.missing.length > 0);
|
||||
}
|
||||
|
||||
// Commentary and study aids stay folded away until you've written this much of your own Observe notes.
|
||||
export const OBSERVE_FIRST_MIN = 15;
|
||||
export const observeFirstDone = (chunk) => ownWords(chunk, 'observation') >= OBSERVE_FIRST_MIN;
|
||||
@@ -13,18 +13,55 @@ const snippetAround = (raw, at, len) => {
|
||||
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 } = {}) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q.length < 3) return [];
|
||||
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] ?? '';
|
||||
const at = raw.toLowerCase().indexOf(q);
|
||||
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,
|
||||
|
||||
@@ -40,3 +40,25 @@ test('finds highlights by their words or colour name, and bookmarks by reference
|
||||
expect(searchMarks(marks, 'promise')[0]).toMatchObject({ kind: 'highlight' });
|
||||
expect(searchMarks(marks, 'john 3')[0]).toMatchObject({ kind: 'bookmark', chapter: 3, verse: 16 });
|
||||
});
|
||||
|
||||
import { parseSearchQuery as parseQ } from './personalSearch.js';
|
||||
|
||||
test('search understands "in <book>" and time words', () => {
|
||||
const now = new Date('2026-10-14T12:00:00Z').getTime();
|
||||
const q = parseQ('grace in Romans last year', now);
|
||||
expect(q.text).toBe('grace');
|
||||
expect(q.book).toBe('Romans');
|
||||
expect(q.since).toBe(now - 365 * 86400000);
|
||||
expect(parseQ('hope 2025', now)).toMatchObject({ text: 'hope', since: new Date(2025, 0, 1).getTime(), until: new Date(2026, 0, 1).getTime() });
|
||||
expect(parseQ('plain words', now)).toMatchObject({ text: 'plain words', book: null, since: null });
|
||||
});
|
||||
|
||||
test('a passage and date search filters notes, even with no words to match', () => {
|
||||
const now = new Date('2026-10-14T12:00:00Z').getTime();
|
||||
const day = 86400000;
|
||||
const make = (book, editedAt, observation) => ({ id: book + editedAt, title: 'S', lastEdited: now, chapters: [{ book, bookAbbrev: book.slice(0, 3), chapter: '1', chunks: [{ id: `${book}${editedAt}`, startVerse: 1, endVerse: 3, observation, editedAt }] }] });
|
||||
const projects = [make('Romans', now - 10 * day, 'grace abounds'), make('Romans', now - 400 * day, 'grace long ago'), make('John', now - 10 * day, 'grace again')];
|
||||
expect(searchNotes(projects, 'grace in Romans last year', { now })).toHaveLength(1);
|
||||
expect(searchNotes(projects, 'in John last month', { now })).toHaveLength(1);
|
||||
expect(searchNotes(projects, 'grace', { now })).toHaveLength(3);
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
const FIELD_NAMES = {
|
||||
observation: 'Observe', interpretation: 'Interpret', application: 'Apply', generalNotes: 'Background', finalScript: 'Script',
|
||||
discussionQuestions: 'Questions', episodeNumber: 'Session number', episodeTitle: 'Session title',
|
||||
teachBack: 'Teach it back', attendees: 'Who came', groupNotes: 'Group notes',
|
||||
};
|
||||
const same = (a, b) => JSON.stringify(a ?? '') === JSON.stringify(b ?? '');
|
||||
|
||||
|
||||
Reference in new issue
Block a user