Version history, notes on highlights and bookmarks, "you've studied this", prayer journal
Build and Push Docker Image / build-and-push (push) Successful in 5m13s

- Earlier versions of a study: the server keeps compressed copies (about one per ten minutes of
  editing, the latest 30); Export shows what differs and puts one back (the replaced copy is kept)
- Notes on highlights and bookmarks: synced, merged without loss, in exports and backups
- Reader verse panel lists your studies and chunks that cover the verse, and opens them
- Prayer journal: requests, who for, linked verses, days prayed, answers; synced, in backups
  and the account export; daily prayer reminder through ntfy
- TODO.md lists the ideas not built yet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:45:32 -04:00
1 parent 54cb1c217d
commit 207bfcb919
45 files changed
+1669 -75

No files matched your search

+7 -3
View File
@@ -8,7 +8,7 @@ export const BACKUP_APP = 'bible-study-project';
export const BACKUP_VERSION = 1;
// `reader` is what the app holds in memory: { bookmarks, highlights, colorLabels, ink }.
export function buildBackup({ reader, readingPlans, email = null, now = new Date() }) {
export function buildBackup({ reader, readingPlans, prayers = [], email = null, now = new Date() }) {
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
return {
app: BACKUP_APP,
@@ -18,11 +18,13 @@ export function buildBackup({ reader, readingPlans, email = null, now = new Date
projects,
reader: {
bookmarks: reader?.bookmarks ?? {},
bookmarkNotes: reader?.bookmarkNotes ?? {},
highlights: reader?.highlights ?? [],
colorLabels: reader?.colorLabels ?? {},
ink: reader?.ink ?? {},
},
readingPlans: readingPlans ?? { progress: [], customPlans: [], activity: [] },
prayers: (prayers ?? []).filter((p) => p && !p.deleted),
};
}
@@ -35,12 +37,14 @@ export function validateBackup(data) {
const projects = Array.isArray(data.projects) ? data.projects.filter((p) => p && typeof p.id === 'string' && p.title && Array.isArray(p.chapters)) : [];
const reader = data.reader && typeof data.reader === 'object' ? data.reader : {};
const bookmarks = reader.bookmarks && typeof reader.bookmarks === 'object' && !Array.isArray(reader.bookmarks) ? reader.bookmarks : {};
const bookmarkNotes = reader.bookmarkNotes && typeof reader.bookmarkNotes === 'object' && !Array.isArray(reader.bookmarkNotes) ? reader.bookmarkNotes : {};
const highlights = Array.isArray(reader.highlights) ? reader.highlights.filter((h) => h && typeof h.id === 'string') : [];
const colorLabels = reader.colorLabels && typeof reader.colorLabels === 'object' && !Array.isArray(reader.colorLabels) ? reader.colorLabels : {};
const ink = reader.ink && typeof reader.ink === 'object' && !Array.isArray(reader.ink) ? reader.ink : {};
const plans = data.readingPlans && typeof data.readingPlans === 'object' ? data.readingPlans : {};
const prayers = Array.isArray(data.prayers) ? data.prayers.filter((p) => p && typeof p.id === 'string' && typeof p.text === 'string' && p.text.trim() && !p.deleted) : [];
const backup = {
projects, reader: { bookmarks, highlights, colorLabels, ink },
projects, prayers, reader: { bookmarks, bookmarkNotes, highlights, colorLabels, ink },
readingPlans: {
progress: Array.isArray(plans.progress) ? plans.progress : [],
customPlans: Array.isArray(plans.customPlans) ? plans.customPlans : [],
@@ -51,7 +55,7 @@ export function validateBackup(data) {
ok: true, backup,
summary: {
projects: projects.length, bookmarks: Object.keys(bookmarks).length, highlights: highlights.length,
inkPages: Object.keys(ink).length, plans: backup.readingPlans.progress.length + backup.readingPlans.customPlans.length,
inkPages: Object.keys(ink).length, prayers: prayers.length, plans: backup.readingPlans.progress.length + backup.readingPlans.customPlans.length,
},
};
}
+13 -1
View File
@@ -20,7 +20,7 @@ test('a backup holds the studies, marks and plans, and round-trips through valid
expect(backup.projects.map((p) => p.id)).toEqual(['p1']);
const checked = validateBackup(JSON.parse(JSON.stringify(backup)));
expect(checked.ok).toBe(true);
expect(checked.summary).toEqual({ projects: 1, bookmarks: 1, highlights: 1, inkPages: 1, plans: 1 });
expect(checked.summary).toEqual({ projects: 1, bookmarks: 1, highlights: 1, inkPages: 1, prayers: 0, plans: 1 });
expect(backupFileName(new Date('2026-10-01T12:00:00Z'))).toBe('bible-study-backup-2026-10-01.json');
});
@@ -56,3 +56,15 @@ test('merging plans keeps every run once, preferring the later activity', () =>
expect(merged.customPlans.map((p) => p.id)).toEqual(['c1', 'c2']);
expect(merged.activity).toEqual(['2026-09-01', '2026-09-02']);
});
test('prayers are in the backup, deleted ones are not, and they survive validation', () => {
const prayers = [{ id: 'a', text: 'Peace', forWhom: '', verses: [], prayedOn: [], createdAt: 1, updatedAt: 2, answeredAt: null, answerNote: '' }, { id: 'b', deleted: true, updatedAt: 3 }];
const backup = buildBackup({ reader: {}, readingPlans: null, prayers });
expect(backup.prayers.map((p) => p.id)).toEqual(['a']);
const checked = validateBackup(JSON.parse(JSON.stringify(backup)));
expect(checked.ok).toBe(true);
expect(checked.summary.prayers).toBe(1);
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([]);
});
+17 -7
View File
@@ -3,17 +3,17 @@
import { COLOR_NAMES, HIGHLIGHT_COLORS, colorTitle } from './highlightColors.js';
// highlights: [{ bookName, bookIndex, chapter, verse, startOffset, color, text, translation }]; bookmarks: [{ bookName, bookIndex, chapter, verse, color }]
export function buildMarksModel({ highlights = [], bookmarks = [], colorLabels = {} }) {
export function buildMarksModel({ highlights = [], bookmarks = [], colorLabels = {}, bookmarkNotes = {} }) {
const order = (a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse || (a.startOffset ?? 0) - (b.startOffset ?? 0);
const colors = [...new Set([...HIGHLIGHT_COLORS, ...highlights.map((h) => h.color)])];
const groups = colors
.map((color) => ({
color,
title: colorTitle(color, colorLabels),
items: highlights.filter((h) => h.color === color).sort(order).map((h) => ({ ref: `${h.bookName} ${h.chapter}:${h.verse}`, text: (h.text ?? '').trim(), translation: h.translation ?? '' })),
items: highlights.filter((h) => h.color === color).sort(order).map((h) => ({ ref: `${h.bookName} ${h.chapter}:${h.verse}`, text: (h.text ?? '').trim(), translation: h.translation ?? '', note: (h.note ?? '').trim() })),
}))
.filter((g) => g.items.length > 0);
const marks = bookmarks.slice().sort(order).map((b) => ({ ref: `${b.bookName} ${b.chapter}:${b.verse}`, color: COLOR_NAMES[b.color] ?? '' }));
const marks = bookmarks.slice().sort(order).map((b) => ({ ref: `${b.bookName} ${b.chapter}:${b.verse}`, color: COLOR_NAMES[b.color] ?? '', note: (bookmarkNotes[b.key ?? `${b.bookAbbrev}-${b.chapter}-${b.verse}`] ?? '').trim() }));
return { groups, bookmarks: marks };
}
@@ -21,12 +21,18 @@ export function marksToMarkdown(model, { title = 'My highlights and bookmarks',
const out = [`# ${title}`, '', `*${date.toLocaleDateString()}*`, ''];
for (const g of model.groups) {
out.push(`## ${g.title}`, '');
for (const item of g.items) out.push(`- **${item.ref}**${item.translation ? ` (${item.translation})` : ''}${item.text ? ` — “${item.text.replace(/\s+/g, ' ')}”` : ''}`);
for (const item of g.items) {
out.push(`- **${item.ref}**${item.translation ? ` (${item.translation})` : ''}${item.text ? ` — “${item.text.replace(/\s+/g, ' ')}”` : ''}`);
if (item.note) out.push(` - *${item.note.replace(/\s*\n\s*/g, ' ')}*`);
}
out.push('');
}
if (model.bookmarks.length) {
out.push('## Bookmarks', '');
for (const b of model.bookmarks) out.push(`- ${b.ref}${b.color ? ` (${b.color.toLowerCase()})` : ''}`);
for (const b of model.bookmarks) {
out.push(`- ${b.ref}${b.color ? ` (${b.color.toLowerCase()})` : ''}`);
if (b.note) out.push(` - *${b.note.replace(/\s*\n\s*/g, ' ')}*`);
}
out.push('');
}
if (!model.groups.length && !model.bookmarks.length) out.push('Nothing marked yet.', '');
@@ -46,13 +52,17 @@ export async function buildMarksDocxBlob(model, { title = 'My highlights and boo
children.push(new Paragraph({
bullet: { level: 0 },
children: [new TextRun({ text: item.ref, bold: true }), ...(item.translation ? [new TextRun({ text: ` (${item.translation})`, color: '7A7060' })] : []), ...(item.text ? [new TextRun({ text: ` — “${item.text.replace(/\s+/g, ' ')}”` })] : [])],
spacing: { after: 60 },
spacing: { after: item.note ? 20 : 60 },
}));
if (item.note) children.push(new Paragraph({ bullet: { level: 1 }, children: [new TextRun({ text: item.note, italics: true, color: '5A5246' })], spacing: { after: 60 } }));
}
}
if (model.bookmarks.length) {
children.push(heading('Bookmarks'));
for (const b of model.bookmarks) children.push(new Paragraph({ bullet: { level: 0 }, children: [new TextRun({ text: b.ref }), ...(b.color ? [new TextRun({ text: ` (${b.color.toLowerCase()})`, color: '7A7060' })] : [])], spacing: { after: 40 } }));
for (const b of model.bookmarks) {
children.push(new Paragraph({ bullet: { level: 0 }, children: [new TextRun({ text: b.ref }), ...(b.color ? [new TextRun({ text: ` (${b.color.toLowerCase()})`, color: '7A7060' })] : [])], spacing: { after: b.note ? 20 : 40 } }));
if (b.note) children.push(new Paragraph({ bullet: { level: 1 }, children: [new TextRun({ text: b.note, italics: true, color: '5A5246' })], spacing: { after: 40 } }));
}
}
return Packer.toBlob(new Document({ sections: [{ children }] }));
}
+14
View File
@@ -31,3 +31,17 @@ test('the Word file is made', async () => {
const blob = await buildMarksDocxBlob(buildMarksModel({ highlights, bookmarks, colorLabels: {} }));
expect(blob.size).toBeGreaterThan(1000);
});
test('notes appear under their highlights and bookmarks', () => {
const model = buildMarksModel({
highlights: [{ ...highlights[0], note: 'This changed how I pray' }],
bookmarks: [{ ...bookmarks[0], key: 'PSA-23-1' }],
bookmarkNotes: { 'PSA-23-1': 'Read at funerals' },
});
expect(model.groups[0].items[0].note).toBe('This changed how I pray');
expect(model.bookmarks[0].note).toBe('Read at funerals');
const md = marksToMarkdown(model);
expect(md).toContain(' - *This changed how I pray*');
expect(md).toContain(' - *Read at funerals*');
});
+84
View File
@@ -0,0 +1,84 @@
// The prayer journal: requests you are praying about, the verses tied to them, the days you prayed, and how
// God answered. Pure helpers for making, changing, merging and finding prayers. A prayer is
// { id, text, forWhom, verses: [{ book, chapter, verse, label }], prayedOn: ['2026-10-06', ...], createdAt, updatedAt,
// answeredAt, answerNote }; a deleted one is just { id, deleted: true, updatedAt } so other devices drop it too.
import { formatPassage, parsePassageRef } from './passageRef.js';
const DAY_MS = 86_400_000;
const KEEP_DELETED_MS = 90 * DAY_MS;
const MAX_DAYS = 400;
const makeId = () => (globalThis.crypto?.randomUUID?.() ?? `p-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`);
export function newPrayer({ text, forWhom = '', verses = [] }, now = Date.now()) {
return { id: makeId(), text: String(text).trim().slice(0, 2000), forWhom: String(forWhom).trim().slice(0, 80), verses: verses.slice(0, 8), prayedOn: [], createdAt: now, updatedAt: now, answeredAt: null, answerNote: '' };
}
export const touch = (prayer, patch, now = Date.now()) => ({ ...prayer, ...patch, updatedAt: Math.max(now, (prayer.updatedAt ?? 0) + 1) });
export const markDeleted = (prayer, now = Date.now()) => ({ id: prayer.id, deleted: true, updatedAt: Math.max(now, (prayer.updatedAt ?? 0) + 1) });
export function togglePrayedToday(prayer, today, now = Date.now()) {
const has = prayer.prayedOn.includes(today);
return touch(prayer, { prayedOn: has ? prayer.prayedOn.filter((d) => d !== today) : [...prayer.prayedOn, today].sort().slice(-MAX_DAYS) }, now);
}
export const markAnswered = (prayer, note, now = Date.now()) => touch(prayer, { answeredAt: now, answerNote: String(note ?? '').trim().slice(0, 1000) }, now);
export const reopen = (prayer, now = Date.now()) => touch(prayer, { answeredAt: null, answerNote: '' }, now);
// Same rules as the server's merge: the newer version of each prayer wins, days prayed are combined, deletions stick.
export function mergePrayers(a, b, now = Date.now()) {
const byId = new Map();
for (const p of [...(a ?? []), ...(b ?? [])]) {
if (!p || typeof p.id !== 'string') continue;
const have = byId.get(p.id);
if (!have) { byId.set(p.id, p); continue; }
const [newer, older] = (p.updatedAt ?? 0) >= (have.updatedAt ?? 0) ? [p, have] : [have, p];
byId.set(p.id, newer.deleted ? newer : { ...newer, prayedOn: [...new Set([...(newer.prayedOn ?? []), ...(older.deleted ? [] : older.prayedOn ?? [])])].sort().slice(-MAX_DAYS) });
}
return [...byId.values()].filter((p) => !(p.deleted && now - (p.updatedAt ?? 0) > KEEP_DELETED_MS)).sort((x, y) => (y.updatedAt ?? 0) - (x.updatedAt ?? 0));
}
export const prayersEqual = (a, b) => JSON.stringify(mergePrayers(a, [])) === JSON.stringify(mergePrayers(b, []));
export const live = (list) => (list ?? []).filter((p) => !p.deleted);
// "John 3:16, Philippians 4:6-7; Psalm 23" -> { verses: [{ book, chapter, verse, label }], unknown: ['text we couldn't read'] }
export function parseVerses(input) {
const verses = [];
const unknown = [];
for (const part of String(input ?? '').split(/[,;\n]+/).map((x) => x.trim()).filter(Boolean)) {
const first = parsePassageRef(part.replace(/[-–]\s*\d+\s*$/, ''))[0]; // "4:6-7" -> read as 4:6
if (first && (first.hasChapter || first.verse)) verses.push({ book: first.book, chapter: first.chapter, verse: first.verse, label: part });
else unknown.push(part);
}
return { verses, unknown };
}
export const labelFor = (ref) => ref.label || formatPassage({ bookName: ref.book, chapter: ref.chapter, verse: ref.verse, hasChapter: true });
// tab: 'open' | 'answered' | 'all'
export function filterPrayers(list, { tab = 'open', query = '' } = {}) {
const q = String(query).trim().toLowerCase();
return live(list)
.filter((p) => (tab === 'open' ? !p.answeredAt : tab === 'answered' ? !!p.answeredAt : true))
.filter((p) => !q || `${p.text} ${p.forWhom} ${p.answerNote} ${(p.verses ?? []).map((v) => v.label).join(' ')}`.toLowerCase().includes(q))
.sort((x, y) => (tab === 'answered' ? (y.answeredAt ?? 0) - (x.answeredAt ?? 0) : (y.updatedAt ?? 0) - (x.updatedAt ?? 0)));
}
export const daysToAnswer = (p) => (p.answeredAt ? Math.max(0, Math.round((p.answeredAt - p.createdAt) / DAY_MS)) : null);
export function prayersToMarkdown(list, { date = new Date() } = {}) {
const items = live(list);
const out = ['# Prayer journal', '', `*${date.toLocaleDateString()}*`, ''];
const section = (title, rows) => {
if (!rows.length) return;
out.push(`## ${title}`, '');
for (const p of rows) {
out.push(`- ${p.forWhom ? `**${p.forWhom}:** ` : ''}${p.text.replace(/\s*\n\s*/g, ' ')}${p.verses?.length ? ` (${p.verses.map(labelFor).join('; ')})` : ''}`);
out.push(` - Asked ${new Date(p.createdAt).toLocaleDateString()}${p.prayedOn?.length ? ` · prayed on ${p.prayedOn.length} day${p.prayedOn.length === 1 ? '' : 's'}` : ''}`);
if (p.answeredAt) out.push(` - Answered ${new Date(p.answeredAt).toLocaleDateString()}${p.answerNote ? `: ${p.answerNote.replace(/\s*\n\s*/g, ' ')}` : ''}`);
}
out.push('');
};
section('Praying', items.filter((p) => !p.answeredAt));
section('Answered', items.filter((p) => p.answeredAt));
if (!items.length) out.push('No prayers yet.', '');
return out.join('\n');
}
+70
View File
@@ -0,0 +1,70 @@
import { test, expect } from 'vitest';
import { daysToAnswer, filterPrayers, markAnswered, markDeleted, mergePrayers, newPrayer, parseVerses, prayersToMarkdown, reopen, togglePrayedToday } from './prayers.js';
const DAY = 86_400_000;
const T0 = Date.UTC(2026, 9, 1);
test('a new prayer is trimmed and starts open', () => {
const p = newPrayer({ text: ' Wisdom for the job ', forWhom: ' Sam ' }, T0);
expect(p).toMatchObject({ text: 'Wisdom for the job', forWhom: 'Sam', answeredAt: null, prayedOn: [], createdAt: T0, updatedAt: T0 });
expect(typeof p.id).toBe('string');
});
test('praying today can be switched on and off, and answering or reopening is tracked', () => {
let p = newPrayer({ text: 'x' }, T0);
p = togglePrayedToday(p, '2026-10-02', T0 + 1);
expect(p.prayedOn).toEqual(['2026-10-02']);
p = togglePrayedToday(p, '2026-10-02', T0 + 2);
expect(p.prayedOn).toEqual([]);
p = markAnswered(p, ' He did it ', T0 + 5 * DAY);
expect(p).toMatchObject({ answerNote: 'He did it', answeredAt: T0 + 5 * DAY });
expect(daysToAnswer(p)).toBe(5);
expect(reopen(p, T0 + 6 * DAY)).toMatchObject({ answeredAt: null, answerNote: '' });
expect(daysToAnswer(newPrayer({ text: 'y' }))).toBeNull();
});
test('verses are read from what you type; unreadable bits are reported', () => {
const { verses, unknown } = parseVerses('John 3:16, Philippians 4:6-7; Psalm 23; blah blah');
expect(verses.map((v) => [v.book, v.chapter, v.verse])).toEqual([['JHN', 3, 16], ['PHP', 4, 6], ['PSA', 23, null]]);
expect(verses[1].label).toBe('Philippians 4:6-7');
expect(unknown).toEqual(['blah blah']);
expect(parseVerses('')).toEqual({ verses: [], unknown: [] });
});
test('devices merge: the newer prayer wins, days combine, a deletion sticks', () => {
const a = newPrayer({ text: 'A' }, T0);
const here = [{ ...a, prayedOn: ['2026-10-01'], updatedAt: T0 + 10 }];
const there = [{ ...a, text: 'A (edited)', prayedOn: ['2026-10-02'], updatedAt: T0 + 20 }];
const merged = mergePrayers(here, there, T0 + 30);
expect(merged).toHaveLength(1);
expect(merged[0]).toMatchObject({ text: 'A (edited)', prayedOn: ['2026-10-01', '2026-10-02'] });
const afterDelete = mergePrayers(merged, [markDeleted(a, T0 + 40)], T0 + 50);
expect(afterDelete).toEqual([{ id: a.id, deleted: true, updatedAt: T0 + 40 }]);
expect(mergePrayers(afterDelete, here, T0 + 60)[0].deleted).toBe(true); // an older copy can't bring it back
});
test('the list can be filtered by tab and searched', () => {
const open = newPrayer({ text: 'Peace at home', forWhom: 'Family' }, T0);
const answered = markAnswered(newPrayer({ text: 'A new job', verses: [{ book: 'PHP', chapter: 4, verse: 6, label: 'Philippians 4:6' }] }, T0), 'Hired!', T0 + DAY);
const gone = markDeleted(newPrayer({ text: 'Removed' }, T0), T0 + 5);
const all = [open, answered, gone];
expect(filterPrayers(all, { tab: 'open' })).toEqual([open]);
expect(filterPrayers(all, { tab: 'answered' })).toEqual([answered]);
expect(filterPrayers(all, { tab: 'all' })).toHaveLength(2);
expect(filterPrayers(all, { tab: 'all', query: 'philippians' })).toEqual([answered]); // searches verses
expect(filterPrayers(all, { tab: 'all', query: 'hired' })).toEqual([answered]); // and the answer
expect(filterPrayers(all, { tab: 'all', query: 'family' })).toEqual([open]);
});
test('the Markdown separates what is being prayed for from what was answered', () => {
const open = { ...newPrayer({ text: 'Peace at home', forWhom: 'Family' }, T0), prayedOn: ['2026-10-01', '2026-10-02'] };
const answered = markAnswered(newPrayer({ text: 'A new job' }, T0), 'Hired!', T0 + DAY);
const md = prayersToMarkdown([open, answered], { date: new Date(2026, 9, 6) });
expect(md).toContain('## Praying');
expect(md).toContain('- **Family:** Peace at home');
expect(md).toContain('prayed on 2 days');
expect(md).toContain('## Answered');
expect(md).toContain('Answered');
expect(md).toContain(': Hired!');
expect(prayersToMarkdown([])).toContain('No prayers yet.');
});
+47
View File
@@ -0,0 +1,47 @@
// What is different between a study as it is now and an earlier version of it, in words a person can read.
const FIELD_NAMES = {
observation: 'Observe', interpretation: 'Interpret', application: 'Apply', generalNotes: 'Background', finalScript: 'Script',
discussionQuestions: 'Questions', episodeNumber: 'Session number', episodeTitle: 'Session title',
};
const same = (a, b) => JSON.stringify(a ?? '') === JSON.stringify(b ?? '');
const chunksOf = (project) => {
const out = new Map();
(project?.chapters ?? []).forEach((chapter) => (chapter.chunks ?? []).forEach((chunk) => out.set(chunk.id, { chunk, ref: `${chapter.book} ${chapter.chapter}:${chunk.startVerse}–${chunk.endVerse}` })));
return out;
};
// current vs older -> { titleChanged, changed: [{ ref, fields }], removedSince: [ref], addedSince: [ref], identical }
// "removedSince" = chunks the older version has that the study doesn't now (restoring brings them back);
// "addedSince" = chunks written since (restoring takes them away, though the copy you replace is kept).
export function diffProjects(current, older) {
const now = chunksOf(current);
const then = chunksOf(older);
const changed = [];
const removedSince = [];
const addedSince = [];
for (const [id, { chunk, ref }] of then) {
const mine = now.get(id);
if (!mine) { removedSince.push(ref); continue; }
const fields = Object.keys(FIELD_NAMES).filter((f) => !same(mine.chunk[f], chunk[f])).map((f) => FIELD_NAMES[f]);
if (!same(mine.chunk.tags, chunk.tags)) fields.push('Tags');
if (!same(mine.chunk.crossReferences, chunk.crossReferences)) fields.push('Cross-references');
if (!same(mine.chunk.greekWords, chunk.greekWords)) fields.push('Words');
if (fields.length) changed.push({ ref, fields });
}
for (const [id, { ref }] of now) if (!then.has(id)) addedSince.push(ref);
const titleChanged = (current?.title ?? '') !== (older?.title ?? '');
const outlineChanged = !same(current?.outline, older?.outline);
return { titleChanged, outlineChanged, changed, removedSince, addedSince, identical: !titleChanged && !outlineChanged && !changed.length && !removedSince.length && !addedSince.length };
}
export function describeDiff(diff) {
if (diff.identical) return ['This version is the same as what you have now.'];
const lines = [];
if (diff.titleChanged) lines.push('The title is different.');
for (const c of diff.changed) lines.push(`${c.ref}: ${c.fields.join(', ')} differ${c.fields.length === 1 ? 's' : ''}.`);
if (diff.removedSince.length) lines.push(`Brings back ${diff.removedSince.length} chunk${diff.removedSince.length === 1 ? '' : 's'} deleted since: ${diff.removedSince.join('; ')}.`);
if (diff.addedSince.length) lines.push(`Takes away ${diff.addedSince.length} chunk${diff.addedSince.length === 1 ? '' : 's'} added since: ${diff.addedSince.join('; ')}.`);
if (diff.outlineChanged) lines.push('The whole-study outline is different.');
return lines;
}
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from 'vitest';
import { describeDiff, diffProjects } from './projectDiff.js';
const study = (chunks, extra = {}) => ({ title: 'Romans', chapters: [{ book: 'Romans', chapter: '8', chunks }], ...extra });
const c = (id, start, end, extra = {}) => ({ id, startVerse: start, endVerse: end, observation: '', application: '', tags: [], ...extra });
test('says which notes differ, and which chunks were deleted or added since', () => {
const now = study([c('a', 1, 4, { observation: 'new words', application: 'now' }), c('c', 20, 25)]);
const then = study([c('a', 1, 4, { observation: 'old words' }), c('b', 5, 9, { observation: 'lost' })]);
const d = diffProjects(now, then);
expect(d.changed).toEqual([{ ref: 'Romans 8:1–4', fields: ['Observe', 'Apply'] }]);
expect(d.removedSince).toEqual(['Romans 8:5–9']);
expect(d.addedSince).toEqual(['Romans 8:20–25']);
expect(d.identical).toBe(false);
expect(describeDiff(d)).toEqual([
'Romans 8:1–4: Observe, Apply differ.',
'Brings back 1 chunk deleted since: Romans 8:5–9.',
'Takes away 1 chunk added since: Romans 8:20–25.',
]);
});
test('an unchanged study, a new title and an outline change are each noticed', () => {
const a = study([c('a', 1, 2)]);
expect(diffProjects(a, structuredClone(a)).identical).toBe(true);
expect(describeDiff(diffProjects(a, structuredClone(a)))).toEqual(['This version is the same as what you have now.']);
expect(diffProjects({ ...a, title: 'Romans 8' }, a).titleChanged).toBe(true);
expect(describeDiff(diffProjects({ ...a, outline: [{ id: 'x' }] }, a))).toEqual(['The whole-study outline is different.']);
expect(diffProjects(a, study([c('a', 1, 2, { tags: ['grace'] })])).changed[0].fields).toEqual(['Tags']);
});
+16 -6
View File
@@ -1,15 +1,18 @@
// Bookmarks ({ "JHN-3-16": "#fef08a" }) and text highlights ([{ id, book, chapter, verse,
// startOffset, endOffset, color }]) — helpers for keeping them in sync between devices.
export const emptyMarks = () => ({ bookmarks: {}, highlights: [] });
export const emptyMarks = () => ({ bookmarks: {}, highlights: [], bookmarkNotes: {} });
export const isEmptyMarks = (m) => Object.keys(m.bookmarks).length === 0 && m.highlights.length === 0;
export const isEmptyMarks = (m) => Object.keys(m.bookmarks).length === 0 && m.highlights.length === 0 && Object.keys(m.bookmarkNotes ?? {}).length === 0;
// Forgiving read of whatever came from localStorage or the server.
export function normalizeMarks(raw) {
const bookmarks = raw?.bookmarks && typeof raw.bookmarks === 'object' && !Array.isArray(raw.bookmarks) ? raw.bookmarks : {};
const highlights = Array.isArray(raw?.highlights) ? raw.highlights.filter((h) => h && typeof h.id === 'string') : [];
return { bookmarks, highlights };
const bookmarkNotes = raw?.bookmarkNotes && typeof raw.bookmarkNotes === 'object' && !Array.isArray(raw.bookmarkNotes)
? Object.fromEntries(Object.entries(raw.bookmarkNotes).filter(([, v]) => typeof v === 'string' && v.trim()))
: {};
return { bookmarks, highlights, bookmarkNotes };
}
const sameSpan = (a, b) => a.book === b.book && a.chapter === b.chapter && a.verse === b.verse
@@ -20,21 +23,28 @@ const sameSpan = (a, b) => a.book === b.book && a.chapter === b.chapter && a.ver
// same bookmark has two colours, the server's wins.
export function mergeMarks(local, server) {
const bookmarks = { ...local.bookmarks, ...server.bookmarks };
const bookmarkNotes = { ...(local.bookmarkNotes ?? {}), ...(server.bookmarkNotes ?? {}) };
const highlights = [...server.highlights];
for (const h of local.highlights) {
if (!highlights.some((s) => s.id === h.id || sameSpan(s, h))) highlights.push(h);
// the same highlight on both: keep the server's, but a note written only here isn't lost
const twin = highlights.findIndex((s) => s.id === h.id || sameSpan(s, h));
if (twin < 0) highlights.push(h);
else if (h.note && !highlights[twin].note) highlights[twin] = { ...highlights[twin], note: h.note };
}
return { bookmarks, highlights };
return { bookmarks, highlights, bookmarkNotes };
}
export function marksEqual(a, b) {
const ka = Object.keys(a.bookmarks);
if (ka.length !== Object.keys(b.bookmarks).length) return false;
if (ka.some((k) => a.bookmarks[k] !== b.bookmarks[k])) return false;
const na = a.bookmarkNotes ?? {};
const nb = b.bookmarkNotes ?? {};
if (Object.keys(na).length !== Object.keys(nb).length || Object.keys(na).some((k) => na[k] !== nb[k])) return false;
if (a.highlights.length !== b.highlights.length) return false;
const byId = new Map(b.highlights.map((h) => [h.id, h]));
return a.highlights.every((h) => {
const o = byId.get(h.id);
return o && sameSpan(h, o) && h.color === o.color;
return o && sameSpan(h, o) && h.color === o.color && (h.note ?? '') === (o.note ?? '');
});
}
+16
View File
@@ -36,3 +36,19 @@ test('marksEqual compares by value, not by reference or order', () => {
expect(marksEqual(a, { ...b, highlights: [hl({ id: '1' })] })).toBe(false);
expect(marksEqual(a, { ...b, highlights: [hl({ id: '1', color: '#000000' }), hl({ id: '2', verse: 2 })] })).toBe(false);
});
test('notes on highlights and bookmarks merge without being lost, and count as a change', async () => {
const { mergeMarks, marksEqual, normalizeMarks, isEmptyMarks } = await import('./readerMarks.js');
const h = (id, extra = {}) => ({ id, book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 5, color: '#fef08a', ...extra });
const local = { bookmarks: { 'JHN-3-16': '#fef08a' }, bookmarkNotes: { 'JHN-3-16': 'mine' }, highlights: [h('a', { note: 'only here' })] };
const server = { bookmarks: { 'JHN-3-16': '#fef08a' }, bookmarkNotes: { 'ROM-8-1': 'theirs' }, highlights: [h('a')] };
const merged = mergeMarks(local, server);
expect(merged.bookmarkNotes).toEqual({ 'JHN-3-16': 'mine', 'ROM-8-1': 'theirs' });
expect(merged.highlights).toHaveLength(1);
expect(merged.highlights[0].note).toBe('only here'); // the note written on this device survives
expect(marksEqual(local, server)).toBe(false); // a note alone is a difference worth syncing
expect(marksEqual(merged, structuredClone(merged))).toBe(true);
expect(normalizeMarks({ bookmarkNotes: { a: 'x', b: ' ', c: 3 } }).bookmarkNotes).toEqual({ a: 'x' });
expect(isEmptyMarks({ bookmarks: {}, highlights: [], bookmarkNotes: { a: 'x' } })).toBe(false);
});
+51
View File
@@ -0,0 +1,51 @@
// "Where have I studied this verse?": every chunk of every study of yours, by the passage it covers, so a verse in the
// Reader can list the studies and notes that already deal with it.
const SNIPPET_FIELDS = ['observation', 'interpretation', 'application', 'generalNotes'];
const snippetOf = (chunk) => {
for (const field of SNIPPET_FIELDS) {
const text = String(chunk[field] ?? '').replace(/\s+/g, ' ').replace(/^[-*•–]\s*/, '').trim();
if (text) return text.length > 110 ? `${text.slice(0, 110).replace(/\s+\S*$/, '')}…` : text;
}
return '';
};
const hasNotes = (chunk) => SNIPPET_FIELDS.some((f) => String(chunk[f] ?? '').trim()) || !!String(chunk.finalScript ?? '').trim();
// projectIndex: the dashboard's list [{ id, title }]; load(id) -> the full study or null
// -> [{ projectId, title, chunkId, book, chapter, start, end, ref, snippet, hasNotes }]
export function buildVerseIndex(projectIndex, load) {
const entries = [];
for (const summary of projectIndex ?? []) {
const project = load(summary.id);
if (!project) continue;
const chapters = project.chapters ?? [];
chapters.forEach((chapter, i) => {
const chapterNumber = Number(chapter.chapter);
for (const chunk of chapter.chunks ?? []) {
const base = {
projectId: project.id, title: project.title ?? summary.title ?? '', chunkId: chunk.id,
snippet: snippetOf(chunk), hasNotes: hasNotes(chunk),
};
const refName = chapter.book;
const next = chapters[i + 1];
const spills = Number.isInteger(chunk.spilloverEndVerse) && chunk.spilloverEndVerse > 0 && next && next.bookAbbrev === chapter.bookAbbrev;
const lastVerse = spills ? (chapter.verses?.at(-1)?.number ?? chunk.endVerse) : chunk.endVerse;
entries.push({
...base, book: chapter.bookAbbrev, chapter: chapterNumber, start: chunk.startVerse, end: spills ? lastVerse : chunk.endVerse,
ref: spills ? `${refName} ${chapter.chapter}:${chunk.startVerse}–${next.chapter}:${chunk.spilloverEndVerse}` : `${refName} ${chapter.chapter}:${chunk.startVerse}${chunk.endVerse !== chunk.startVerse ? `–${chunk.endVerse}` : ''}`,
});
if (spills) entries.push({ ...base, book: next.bookAbbrev, chapter: Number(next.chapter), start: 1, end: chunk.spilloverEndVerse, ref: entries.at(-1).ref });
}
});
}
return entries;
}
// The studies that cover this verse, ones with notes first
export function studiesForVerse(entries, book, chapter, verse) {
const seen = new Set();
return entries
.filter((e) => e.book === book && e.chapter === Number(chapter) && e.start <= verse && verse <= e.end)
.filter((e) => { const k = `${e.projectId}/${e.chunkId}`; if (seen.has(k)) return false; seen.add(k); return true; })
.sort((a, b) => Number(b.hasNotes) - Number(a.hasNotes) || a.title.localeCompare(b.title));
}
+43
View File
@@ -0,0 +1,43 @@
import { test, expect } from 'vitest';
import { buildVerseIndex, studiesForVerse } from './studyVerseIndex.js';
const chunk = (id, s, e, x = {}) => ({ id, startVerse: s, endVerse: e, ...x });
const titus = { id: 'p1', title: 'Titus Notes', chapters: [
{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: Array.from({ length: 16 }, (_, i) => ({ number: i + 1 })), chunks: [
chunk('a', 1, 4, { observation: '- Paul calls himself a servant and an apostle' }),
chunk('b', 5, 16, { application: ' ' }),
] },
{ book: 'Titus', bookAbbrev: 'TIT', chapter: '2', verses: [], chunks: [chunk('c', 1, 5)] },
] };
const john = { id: 'p2', title: 'New Birth', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [chunk('d', 1, 21, { generalNotes: 'Nicodemus came at night' })] }] };
const load = (id) => ({ p1: titus, p2: john })[id] ?? null;
const list = [{ id: 'p1', title: 'Titus Notes' }, { id: 'p2', title: 'New Birth' }, { id: 'gone', title: 'Missing' }];
test('a verse finds the chunks that cover it, with a snippet of your notes', () => {
const index = buildVerseIndex(list, load);
const hits = studiesForVerse(index, 'TIT', 1, 3);
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({ projectId: 'p1', chunkId: 'a', ref: 'Titus 1:1–4', hasNotes: true, snippet: 'Paul calls himself a servant and an apostle' });
expect(studiesForVerse(index, 'TIT', 1, 9)[0]).toMatchObject({ chunkId: 'b', hasNotes: false, snippet: '' });
expect(studiesForVerse(index, 'TIT', 1, 17)).toEqual([]);
expect(studiesForVerse(index, 'JHN', 3, 16)[0].snippet).toBe('Nicodemus came at night');
expect(studiesForVerse(index, 'ROM', 8, 1)).toEqual([]);
});
test('a chunk that runs on into the next chapter is found from both chapters', () => {
const spill = { id: 'p3', title: 'Spill', chapters: [
{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: Array.from({ length: 36 }, (_, i) => ({ number: i + 1 })), chunks: [chunk('s', 30, 36, { spilloverEndVerse: 4, observation: 'carries on' })] },
{ book: 'John', bookAbbrev: 'JHN', chapter: '4', verses: [], chunks: [] },
] };
const index = buildVerseIndex([{ id: 'p3' }], () => spill);
expect(studiesForVerse(index, 'JHN', 3, 33)[0].ref).toBe('John 3:30–4:4');
expect(studiesForVerse(index, 'JHN', 4, 2)[0].chunkId).toBe('s');
expect(studiesForVerse(index, 'JHN', 4, 5)).toEqual([]);
});
test('studies with notes come first, then by title; a study is listed once per chunk', () => {
const a = { id: 'x', title: 'Zeta', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [chunk('1', 1, 20)] }] };
const b = { id: 'y', title: 'Alpha', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [chunk('2', 10, 20, { observation: 'notes' })] }] };
const index = buildVerseIndex([{ id: 'x' }, { id: 'y' }], (id) => ({ x: a, y: b })[id]);
expect(studiesForVerse(index, 'JHN', 3, 16).map((e) => e.title)).toEqual(['Alpha', 'Zeta']);
});