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
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:
1 parent
54cb1c217d
commit
207bfcb919
45 files changed
+1669
-75
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# To do
|
||||
|
||||
Ideas for the app that haven't been built yet, roughly in the order I'd do them. Tick one off when it ships.
|
||||
|
||||
## Running it and keeping it safe
|
||||
- [ ] **Email (SMTP).** A mail setting for password resets and invite links, so they don't depend on the ntfy topic. Also lets a
|
||||
co-teacher get their invite by email. Needs a `SMTP_URL`-style setting and a fallback to today's behaviour.
|
||||
- [ ] **Passkeys and a device list.** Sign in with Face ID or a fingerprint (WebAuthn); show where you're signed in with
|
||||
"sign out other devices"; optionally a login alert through ntfy.
|
||||
- [ ] **Server health page for the admin.** Last backup, disk use, failed notifications, recent sign-in failures, and a
|
||||
"restore a snapshot" button (snapshots already exist under `DATA_DIR/backups`).
|
||||
- [ ] **Checks on every push.** Run tests and lint on the Gitea server, plus a few real-browser tests (Playwright) for sign-in,
|
||||
creating a study and syncing; so far everything is tested in simulation.
|
||||
- [ ] **Split `server/index.js`** (now about 1,100 lines) into one file per area (auth, projects, sharing, reader data, admin).
|
||||
|
||||
## Teaching together
|
||||
- [ ] **Comments between co-teachers.** Notes on a chunk addressed to the other person, instead of editing each other's text.
|
||||
- [ ] **Live updates and "who's here".** A co-teacher's changes appear within seconds, with a marker like "Sam is on chunk 3"
|
||||
(server-sent events or short polling). Today changes arrive when the tab regains focus or every couple of minutes.
|
||||
- [ ] **Ask Claude inside the app.** Draft discussion questions, tighten an outline point, summarise notes, using the owner's own
|
||||
API key kept on the server. The Claude prompts today are copy-and-paste.
|
||||
|
||||
## Reading
|
||||
- [ ] **Read-aloud with highlighting.** The device's voice reads the chapter and the verse being read is highlighted; useful for
|
||||
the BSB and NET, which have no audio.
|
||||
- [ ] **Cross-reference map.** A picture of the links between chapters, from IQ Bible's reference graph (needs the IQ Bible key).
|
||||
|
||||
## Decided against, for now
|
||||
- Scripture memory (verse practice with spaced review).
|
||||
- Extra-biblical books (Enoch, Maccabees) and non-standard canons.
|
||||
@@ -17,11 +17,13 @@ export function buildAccountExport(user, store, now = new Date()) {
|
||||
projects: store.projects(),
|
||||
reader: {
|
||||
bookmarks: marks.bookmarks ?? {},
|
||||
bookmarkNotes: marks.bookmarkNotes ?? {},
|
||||
highlights: marks.highlights ?? [],
|
||||
colorLabels: prefs.colorLabels?.v ?? {},
|
||||
ink: store.ink() ?? {},
|
||||
},
|
||||
readingHistory: store.history?.() ?? { chapters: {}, days: [] },
|
||||
prayers: store.prayers?.() ?? [],
|
||||
readingPlans: {
|
||||
progress: plans.progress ?? [],
|
||||
customPlans: plans.customPlans ?? [],
|
||||
|
||||
+116
-6
@@ -1,5 +1,6 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { MAX_VERSIONS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
@@ -90,6 +91,15 @@ export function initDb() {
|
||||
`);
|
||||
}
|
||||
|
||||
// Optional daily prayer reminder, sent to the same ntfy topic.
|
||||
if (!userCols.some((c) => c.name === 'prayer_enabled')) {
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN prayer_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN prayer_time TEXT;
|
||||
ALTER TABLE users ADD COLUMN last_prayer_date TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
// Reminders fire at the user's own local time, so remember their timezone.
|
||||
if (!userCols.some((c) => c.name === 'reminder_tz')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT');
|
||||
@@ -125,6 +135,29 @@ export function initDb() {
|
||||
);
|
||||
`);
|
||||
|
||||
// The prayer journal: one row per account (the whole list as JSON; see server/prayers.js).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS prayers (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Earlier copies of each study (compressed), for "Restore an earlier version".
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS project_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id TEXT NOT NULL,
|
||||
state_at INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
chunks INTEGER NOT NULL DEFAULT 0,
|
||||
words INTEGER NOT NULL DEFAULT 0,
|
||||
data BLOB NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_project ON project_versions(project_id, state_at);
|
||||
`);
|
||||
|
||||
// Co-teachers: other accounts allowed to edit a study, and the single-use links that let them join.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS collaborators (
|
||||
@@ -239,6 +272,7 @@ function parseUserRow(row) {
|
||||
totpEnabled: !!row.totpEnabled,
|
||||
reminderEnabled: !!row.reminderEnabled,
|
||||
votdEnabled: !!row.votdEnabled,
|
||||
prayerEnabled: !!row.prayerEnabled,
|
||||
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
||||
};
|
||||
}
|
||||
@@ -249,7 +283,8 @@ const USER_SELECT = `
|
||||
podcast_name AS podcastName,
|
||||
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
|
||||
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz,
|
||||
votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate
|
||||
votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate,
|
||||
prayer_enabled AS prayerEnabled, prayer_time AS prayerTime
|
||||
FROM users
|
||||
`;
|
||||
|
||||
@@ -297,6 +332,36 @@ export function setVotdSettings(userId, { votdEnabled, votdTime }) {
|
||||
db.prepare('UPDATE users SET votd_enabled = ?, votd_time = ? WHERE id = ?').run(votdEnabled ? 1 : 0, votdTime || null, userId);
|
||||
}
|
||||
|
||||
/** Turns the daily prayer reminder on or off, and says when to send it. */
|
||||
export function setPrayerReminder(userId, { prayerEnabled, prayerTime }) {
|
||||
db.prepare('UPDATE users SET prayer_enabled = ?, prayer_time = ? WHERE id = ?').run(prayerEnabled ? 1 : 0, prayerTime || null, userId);
|
||||
}
|
||||
|
||||
export function getUsersWithPrayerReminder() {
|
||||
return db.prepare(`
|
||||
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, prayer_time AS prayerTime,
|
||||
last_prayer_date AS lastPrayerDate, reminder_tz AS reminderTz
|
||||
FROM users
|
||||
WHERE prayer_enabled = 1 AND ntfy_topic IS NOT NULL AND prayer_time IS NOT NULL
|
||||
`).all();
|
||||
}
|
||||
|
||||
export function setLastPrayerDate(userId, dateString) {
|
||||
db.prepare('UPDATE users SET last_prayer_date = ? WHERE id = ?').run(dateString, userId);
|
||||
}
|
||||
|
||||
export function getPrayers(userId) {
|
||||
const row = db.prepare('SELECT data FROM prayers WHERE user_id = ?').get(userId);
|
||||
try { return row ? JSON.parse(row.data) : []; } catch { return []; }
|
||||
}
|
||||
|
||||
export function setPrayers(userId, list) {
|
||||
db.prepare(`
|
||||
INSERT INTO prayers (user_id, data, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||
`).run(userId, JSON.stringify(list), Date.now());
|
||||
}
|
||||
|
||||
/** Every account with the verse of the day turned on and a topic to send it to. */
|
||||
export function getUsersWithVotdEnabled() {
|
||||
return db.prepare(`
|
||||
@@ -421,19 +486,19 @@ export function setUserPrefs(userId, prefs) {
|
||||
/** The user's bookmarks + highlights: { bookmarks, highlights, updatedAt } (updatedAt 0 = never saved). */
|
||||
export function getReaderMarks(userId) {
|
||||
const row = db.prepare('SELECT data, updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId);
|
||||
if (!row) return { bookmarks: {}, highlights: [], updatedAt: 0 };
|
||||
if (!row) return { bookmarks: {}, highlights: [], bookmarkNotes: {}, updatedAt: 0 };
|
||||
const data = JSON.parse(row.data);
|
||||
return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], updatedAt: row.updatedAt };
|
||||
return { bookmarks: data.bookmarks ?? {}, highlights: data.highlights ?? [], bookmarkNotes: data.bookmarkNotes ?? {}, updatedAt: row.updatedAt };
|
||||
}
|
||||
|
||||
/** Replaces the user's bookmarks + highlights; returns the new updatedAt (always moves forward). */
|
||||
export function setReaderMarks(userId, { bookmarks, highlights }) {
|
||||
export function setReaderMarks(userId, { bookmarks, highlights, bookmarkNotes = {} }) {
|
||||
const prev = db.prepare('SELECT updated_at AS updatedAt FROM reader_marks WHERE user_id = ?').get(userId);
|
||||
const updatedAt = Math.max(Date.now(), (prev?.updatedAt ?? 0) + 1);
|
||||
db.prepare(`
|
||||
INSERT INTO reader_marks (user_id, data, updated_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||
`).run(userId, JSON.stringify({ bookmarks, highlights }), updatedAt);
|
||||
`).run(userId, JSON.stringify({ bookmarks, highlights, bookmarkNotes }), updatedAt);
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
@@ -553,7 +618,8 @@ export function upsertProject(project, userId) {
|
||||
}
|
||||
|
||||
// The server's clock decides the version, so a device with a wrong clock can't hide or fake changes.
|
||||
const prev = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(project.id);
|
||||
const prev = db.prepare('SELECT last_edited AS lastEdited, data FROM projects WHERE id = ?').get(project.id);
|
||||
if (prev) keepVersion(project.id, prev);
|
||||
const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1);
|
||||
const chapterSummary = buildSummary(project);
|
||||
const updated = { ...project, lastEdited };
|
||||
@@ -571,6 +637,48 @@ export function upsertProject(project, userId) {
|
||||
return { id: project.id, title: project.title, lastEdited, chapterSummary };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Earlier versions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Keeps the copy that is about to be replaced (when it's been long enough since the last kept one), then trims the oldest. */
|
||||
function keepVersion(projectId, prev, force = false) {
|
||||
const last = db.prepare('SELECT MAX(state_at) AS at FROM project_versions WHERE project_id = ?').get(projectId)?.at;
|
||||
if (!shouldSnapshot({ prevEditedAt: prev.lastEdited, lastKeptAt: last ?? undefined, force })) return;
|
||||
let project;
|
||||
try { project = JSON.parse(prev.data); } catch { return; }
|
||||
if (db.prepare('SELECT 1 FROM project_versions WHERE project_id = ? AND state_at = ?').get(projectId, prev.lastEdited)) return;
|
||||
const { chunks, words } = summarizeProject(project);
|
||||
db.prepare('INSERT INTO project_versions (project_id, state_at, title, chunks, words, data) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(projectId, prev.lastEdited, project.title ?? '', chunks, words, pack(prev.data));
|
||||
db.prepare(`DELETE FROM project_versions WHERE project_id = ? AND id NOT IN (SELECT id FROM project_versions WHERE project_id = ? ORDER BY state_at DESC LIMIT ?)`).run(projectId, projectId, MAX_VERSIONS);
|
||||
}
|
||||
|
||||
export function listVersions(projectId) {
|
||||
return db.prepare('SELECT id, state_at AS savedAt, title, chunks, words FROM project_versions WHERE project_id = ? ORDER BY state_at DESC').all(projectId);
|
||||
}
|
||||
|
||||
export function getVersion(projectId, versionId) {
|
||||
const row = db.prepare('SELECT data FROM project_versions WHERE project_id = ? AND id = ?').get(projectId, versionId);
|
||||
if (!row) return null;
|
||||
try { return unpack(row.data); } catch { return null; }
|
||||
}
|
||||
|
||||
/** Puts an earlier version back as the current one. The copy it replaces is kept first, so a restore can be undone too. */
|
||||
export function restoreVersion(projectId, versionId, userId) {
|
||||
const old = getVersion(projectId, versionId);
|
||||
if (!old || !canEditProject(projectId, userId)) return null;
|
||||
return db.transaction(() => {
|
||||
const prev = db.prepare('SELECT last_edited AS lastEdited, data FROM projects WHERE id = ?').get(projectId);
|
||||
if (prev) keepVersion(projectId, prev, true);
|
||||
const lastEdited = Math.max(Date.now(), (prev?.lastEdited ?? 0) + 1);
|
||||
const restored = { ...old, id: projectId, lastEdited };
|
||||
db.prepare('UPDATE projects SET title = ?, last_edited = ?, chapter_summary = ?, data = ? WHERE id = ?')
|
||||
.run(restored.title, lastEdited, buildSummary(restored), JSON.stringify(restored), projectId);
|
||||
return restored;
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a project by id, scoped to userId. No-op if not found/not owned.
|
||||
*/
|
||||
@@ -583,6 +691,7 @@ export function deleteProject(id, userId) {
|
||||
}
|
||||
db.prepare('DELETE FROM collaborators WHERE project_id = ?').run(id);
|
||||
db.prepare('DELETE FROM edit_invites WHERE project_id = ?').run(id);
|
||||
db.prepare('DELETE FROM project_versions WHERE project_id = ?').run(id);
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -760,6 +869,7 @@ export function adminGetProject(id) {
|
||||
|
||||
/** Deletes any project by id, regardless of owner. */
|
||||
export function adminDeleteProject(id) {
|
||||
db.prepare('DELETE FROM project_versions WHERE project_id = ?').run(id);
|
||||
db.prepare('DELETE FROM projects WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
|
||||
+57
-2
@@ -16,8 +16,9 @@ import {
|
||||
setUserPassword, destroyAllSessionsForUser,
|
||||
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
|
||||
getReadingProgress, setReadingProgress,
|
||||
setReminderSettings, setVotdSettings,
|
||||
setReminderSettings, setVotdSettings, setPrayerReminder, getPrayers, setPrayers,
|
||||
getReaderHistory, setReaderHistory,
|
||||
canEditProject, listVersions, getVersion, restoreVersion,
|
||||
isProjectOwner, createEditInvite, listCollaborators, countOpenEditInvites, removeCollaborator, acceptEditInvite,
|
||||
getSetting, setSetting, createInvite, listInvites, deleteInvite, findOpenInvite, useInvite,
|
||||
setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
||||
generateResetCode, normalizeResetCode,
|
||||
} from './auth.js';
|
||||
import { mergePrayers } from './prayers.js';
|
||||
import { votdAvailable } from './votd.js';
|
||||
import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js';
|
||||
import { registerBibleRoutes } from './bibleApi.js';
|
||||
@@ -51,6 +53,7 @@ function userPayload(user) {
|
||||
reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null,
|
||||
reminderTime: user.reminderTime ?? null,
|
||||
votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(),
|
||||
prayerEnabled: !!user.prayerEnabled, prayerTime: user.prayerTime ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,6 +398,13 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' });
|
||||
}
|
||||
setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null });
|
||||
const prayerEnabled = !!req.body?.prayerEnabled;
|
||||
const prayerTime = String(req.body?.prayerTime ?? '').trim();
|
||||
if (prayerEnabled) {
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the prayer reminder.' });
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(prayerTime)) return res.status(400).json({ error: 'The prayer reminder time must be in HH:MM format.' });
|
||||
}
|
||||
setPrayerReminder(req.session.userId, { prayerEnabled, prayerTime: prayerTime || null });
|
||||
setReminderSettings(req.session.userId, {
|
||||
ntfyUrl: ntfyUrl || null,
|
||||
ntfyTopic: ntfyTopic || null,
|
||||
@@ -404,7 +414,7 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
|
||||
});
|
||||
res.json({
|
||||
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
|
||||
votdEnabled, votdTime: votdTime || null,
|
||||
votdEnabled, votdTime: votdTime || null, prayerEnabled, prayerTime: prayerTime || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/reminder-settings error:', err);
|
||||
@@ -519,6 +529,7 @@ app.get('/api/account/export', requireAuth, (req, res) => {
|
||||
ink: () => getAllReaderInk(userId),
|
||||
plans: () => getReadingProgress(userId),
|
||||
history: () => getReaderHistory(userId),
|
||||
prayers: () => getPrayers(userId).filter((p) => !p.deleted),
|
||||
});
|
||||
res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`);
|
||||
res.json(backup);
|
||||
@@ -587,6 +598,32 @@ app.put('/api/projects/:id', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Earlier versions of a study: list, look at one, and put one back (the copy it replaces is kept too)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects/:id/versions', requireAuth, (req, res) => {
|
||||
if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ versions: listVersions(req.params.id) });
|
||||
});
|
||||
|
||||
app.get('/api/projects/:id/versions/:versionId', requireAuth, (req, res) => {
|
||||
if (!canEditProject(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||
const version = getVersion(req.params.id, Number(req.params.versionId));
|
||||
if (!version) return res.status(404).json({ error: 'That version is gone.' });
|
||||
res.json(version);
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/versions/:versionId/restore', requireAuth, (req, res) => {
|
||||
try {
|
||||
const restored = restoreVersion(req.params.id, Number(req.params.versionId), req.session.userId);
|
||||
if (!restored) return res.status(404).json({ error: 'That version is gone.' });
|
||||
res.json(restored);
|
||||
} catch (err) {
|
||||
console.error('POST restore error:', err);
|
||||
res.status(500).json({ error: 'Failed to restore that version.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/projects/:id — remove a project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -662,6 +699,24 @@ app.get('/api/reader/marks', requireAuth, (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prayers — the prayer journal; PUT merges this device's copy in (newer prayer wins) and returns the whole
|
||||
app.get('/api/prayers', requireAuth, (req, res) => {
|
||||
try { res.json({ prayers: mergePrayers(getPrayers(req.session.userId), []) }); } catch (err) {
|
||||
console.error('GET /api/prayers error:', err);
|
||||
res.status(500).json({ error: 'Failed to load your prayers.' });
|
||||
}
|
||||
});
|
||||
app.put('/api/prayers', requireAuth, (req, res) => {
|
||||
try {
|
||||
const merged = mergePrayers(getPrayers(req.session.userId), req.body?.prayers);
|
||||
setPrayers(req.session.userId, merged);
|
||||
res.json({ prayers: merged });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/prayers error:', err);
|
||||
res.status(500).json({ error: 'Failed to save your prayers.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reader/history — what has been read, and PUT merges this device's copy in and returns the whole
|
||||
app.get('/api/reader/history', requireAuth, (req, res) => {
|
||||
try { res.json(mergeHistory(getReaderHistory(req.session.userId), {})); } catch (err) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// The prayer journal, kept per account. Each prayer carries its own `updatedAt`, so two devices that each add or
|
||||
// change prayers merge cleanly: the newer version of each prayer wins, the days prayed are combined, and a
|
||||
// deleted prayer stays as a small marker for a while so another device can't bring it back.
|
||||
const ID = /^[\w-]{1,64}$/;
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const BOOK = /^[1-3A-Z]{3}$/;
|
||||
const MAX_PRAYERS = 2000;
|
||||
const MAX_DAYS = 400;
|
||||
const KEEP_DELETED_MS = 90 * 86_400_000;
|
||||
|
||||
const text = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
||||
const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
|
||||
const time = (v, fallback) => (Number.isFinite(v) && v > 0 ? v : fallback);
|
||||
|
||||
// One prayer from the browser, rebuilt field by field; null when it isn't one.
|
||||
export function cleanPrayer(raw, now = Date.now()) {
|
||||
if (!raw || typeof raw !== 'object' || !ID.test(String(raw.id ?? ''))) return null;
|
||||
const updatedAt = Math.min(time(raw.updatedAt, 0), now + 86_400_000);
|
||||
if (raw.deleted === true) return { id: raw.id, deleted: true, updatedAt };
|
||||
const body = text(raw.text, 2000).trim();
|
||||
if (!body) return null;
|
||||
const verses = (Array.isArray(raw.verses) ? raw.verses : []).slice(0, 8)
|
||||
.filter((v) => v && BOOK.test(String(v.book)) && int(v.chapter, 1, 200) && (v.verse == null || int(v.verse, 1, 200)))
|
||||
.map((v) => ({ book: v.book, chapter: v.chapter, verse: v.verse ?? null, label: text(v.label, 60) }));
|
||||
const prayedOn = [...new Set((Array.isArray(raw.prayedOn) ? raw.prayedOn : []).filter((d) => typeof d === 'string' && DAY.test(d)))].sort().slice(-MAX_DAYS);
|
||||
return {
|
||||
id: raw.id, text: body, forWhom: text(raw.forWhom, 80).trim(), verses, prayedOn,
|
||||
createdAt: time(raw.createdAt, updatedAt), updatedAt,
|
||||
answeredAt: Number.isFinite(raw.answeredAt) && raw.answeredAt > 0 ? raw.answeredAt : null,
|
||||
answerNote: text(raw.answerNote, 1000).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function cleanPrayers(list, now = Date.now()) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const raw of (Array.isArray(list) ? list : []).slice(0, MAX_PRAYERS)) {
|
||||
const p = cleanPrayer(raw, now);
|
||||
if (!p || seen.has(p.id)) continue;
|
||||
seen.add(p.id);
|
||||
out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Two copies of the journal -> one. The newer version of each prayer wins; the days prayed are the union of both.
|
||||
export function mergePrayers(a, b, now = Date.now()) {
|
||||
const byId = new Map();
|
||||
for (const p of [...cleanPrayers(a, now), ...cleanPrayers(b, now)]) {
|
||||
const have = byId.get(p.id);
|
||||
if (!have) { byId.set(p.id, p); continue; }
|
||||
const [newer, older] = p.updatedAt >= have.updatedAt ? [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 > KEEP_DELETED_MS)) // old markers are forgotten
|
||||
.sort((x, y) => (y.updatedAt ?? 0) - (x.updatedAt ?? 0));
|
||||
}
|
||||
|
||||
export const openPrayers = (list) => list.filter((p) => !p.deleted && !p.answeredAt);
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { cleanPrayer, cleanPrayers, mergePrayers, openPrayers } from './prayers.js';
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
const p = (id, extra = {}) => ({ id, text: `Pray ${id}`, forWhom: '', verses: [], prayedOn: [], createdAt: NOW - 5000, updatedAt: NOW - 1000, answeredAt: null, answerNote: '', ...extra });
|
||||
|
||||
test('only well-formed prayers are kept, rebuilt field by field', () => {
|
||||
const out = cleanPrayer({ id: 'a-1', text: ' For my family ', forWhom: 'Home', evil: '<script>', verses: [{ book: 'PHP', chapter: 4, verse: 6, label: 'Philippians 4:6' }, { book: 'xx', chapter: 1 }, { book: 'JHN', chapter: 99999 }], prayedOn: ['2026-10-01', 'nope', '2026-10-01'], updatedAt: NOW - 1 }, NOW);
|
||||
expect(out).toMatchObject({ id: 'a-1', text: 'For my family', forWhom: 'Home', prayedOn: ['2026-10-01'], answeredAt: null });
|
||||
expect(out.verses).toEqual([{ book: 'PHP', chapter: 4, verse: 6, label: 'Philippians 4:6' }]);
|
||||
expect('evil' in out).toBe(false);
|
||||
expect(cleanPrayer({ id: 'bad id!', text: 'x' }, NOW)).toBeNull();
|
||||
expect(cleanPrayer({ id: 'a', text: ' ' }, NOW)).toBeNull();
|
||||
expect(cleanPrayer(null)).toBeNull();
|
||||
expect(cleanPrayer({ id: 'g', deleted: true, updatedAt: NOW }, NOW)).toEqual({ id: 'g', deleted: true, updatedAt: NOW });
|
||||
expect(cleanPrayers([p('a'), p('a'), 5, p('b')], NOW).map((x) => x.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('the newer version of a prayer wins and the days prayed are combined', () => {
|
||||
const mine = [p('a', { text: 'old wording', prayedOn: ['2026-10-01'], updatedAt: NOW - 5000 })];
|
||||
const theirs = [p('a', { text: 'new wording', prayedOn: ['2026-10-02'], updatedAt: NOW - 1000 }), p('b')];
|
||||
const merged = mergePrayers(mine, theirs, NOW);
|
||||
expect(merged.find((x) => x.id === 'a')).toMatchObject({ text: 'new wording', prayedOn: ['2026-10-01', '2026-10-02'] });
|
||||
expect(merged.map((x) => x.id).sort()).toEqual(['a', 'b']);
|
||||
expect(mergePrayers(merged, merged, NOW)).toEqual(merged);
|
||||
});
|
||||
|
||||
test('a deleted prayer is not brought back by an older copy, and old markers are forgotten', () => {
|
||||
const gone = [{ id: 'a', deleted: true, updatedAt: NOW - 100 }];
|
||||
const stale = [p('a', { updatedAt: NOW - 9000 })];
|
||||
expect(mergePrayers(gone, stale, NOW)).toEqual([{ id: 'a', deleted: true, updatedAt: NOW - 100 }]);
|
||||
expect(mergePrayers([{ id: 'z', deleted: true, updatedAt: NOW - 100 * 86_400_000 }], [], NOW)).toEqual([]);
|
||||
expect(mergePrayers([p('a', { updatedAt: NOW })], [{ id: 'a', deleted: true, updatedAt: NOW - 5 }], NOW)[0].deleted).toBeUndefined(); // written again after the delete
|
||||
});
|
||||
|
||||
test('open prayers are the ones not answered or deleted', () => {
|
||||
expect(openPrayers([p('a'), p('b', { answeredAt: NOW }), { id: 'c', deleted: true, updatedAt: 1 }]).map((x) => x.id)).toEqual(['a']);
|
||||
});
|
||||
+11
-1
@@ -5,6 +5,7 @@ const BOOKMARK_KEY = /^[1-3]?[A-Za-z]{2,3}-\d{1,3}-\d{1,3}$/; // e.g. "JHN-3-16"
|
||||
const BOOK = /^[1-3A-Za-z]{3}$/;
|
||||
const MAX_BOOKMARKS = 5000;
|
||||
const MAX_HIGHLIGHTS = 20000;
|
||||
const MAX_NOTE = 500;
|
||||
|
||||
const int = (v, min, max) => Number.isInteger(v) && v >= min && v <= max;
|
||||
|
||||
@@ -20,6 +21,14 @@ export function cleanMarks(body) {
|
||||
}
|
||||
}
|
||||
|
||||
// A short note on a bookmark: { "JHN-3-16": "my note" }
|
||||
const bookmarkNotes = {};
|
||||
if (body.bookmarkNotes && typeof body.bookmarkNotes === 'object' && !Array.isArray(body.bookmarkNotes)) {
|
||||
for (const [key, note] of Object.entries(body.bookmarkNotes).slice(0, MAX_BOOKMARKS)) {
|
||||
if (BOOKMARK_KEY.test(key) && typeof note === 'string' && note.trim()) bookmarkNotes[key] = note.slice(0, MAX_NOTE);
|
||||
}
|
||||
}
|
||||
|
||||
const highlights = [];
|
||||
if (Array.isArray(body.highlights)) {
|
||||
const seen = new Set();
|
||||
@@ -39,8 +48,9 @@ export function cleanMarks(body) {
|
||||
// Which translation the words were selected in, and whether it is a whole-verse highlight
|
||||
if (typeof h.translation === 'string' && /^[A-Za-z0-9]{2,8}$/.test(h.translation)) entry.translation = h.translation;
|
||||
if (h.whole === true) entry.whole = true;
|
||||
if (typeof h.note === 'string' && h.note.trim()) entry.note = h.note.slice(0, MAX_NOTE);
|
||||
highlights.push(entry);
|
||||
}
|
||||
}
|
||||
return { bookmarks, highlights };
|
||||
return { bookmarks, highlights, bookmarkNotes };
|
||||
}
|
||||
@@ -15,7 +15,7 @@ test('a non-object body is rejected', () => {
|
||||
|
||||
test('good bookmarks and highlights pass through unchanged', () => {
|
||||
const body = { bookmarks: { 'JHN-3-16': '#fef08a', '1SA-3-5': '#bbf7d0' }, highlights: [hl()] };
|
||||
expect(cleanMarks(body)).toEqual(body);
|
||||
expect(cleanMarks(body)).toEqual({ ...body, bookmarkNotes: {} });
|
||||
});
|
||||
|
||||
test('malformed bookmarks are dropped, good ones kept', () => {
|
||||
@@ -38,7 +38,7 @@ test('highlights are validated field by field and de-duplicated by id', () => {
|
||||
});
|
||||
|
||||
test('missing sections become empty ones, and unknown fields are not stored', () => {
|
||||
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [] });
|
||||
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [], bookmarkNotes: {} });
|
||||
expect(cleanMarks({ highlights: [{ ...hl(), evil: '<script>' }] }).highlights[0]).not.toHaveProperty('evil');
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ test('the database keeps one row per user and updatedAt only moves forward', asy
|
||||
db.initDb();
|
||||
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
|
||||
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
|
||||
expect(db.getReaderMarks('u1')).toEqual({ bookmarks: {}, highlights: [], updatedAt: 0 });
|
||||
expect(db.getReaderMarks('u1')).toEqual({ bookmarks: {}, highlights: [], bookmarkNotes: {}, updatedAt: 0 });
|
||||
const t1 = db.setReaderMarks('u1', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] });
|
||||
const t2 = db.setReaderMarks('u1', { bookmarks: {}, highlights: [hl()] });
|
||||
expect(t2).toBeGreaterThan(t1);
|
||||
@@ -70,3 +70,17 @@ test('a highlight keeps the translation it was made in and whether it covers the
|
||||
expect(out[1].translation).toBeUndefined();
|
||||
expect(out[1].whole).toBeUndefined();
|
||||
});
|
||||
|
||||
test('notes on highlights and bookmarks are kept, trimmed, and dropped when empty or malformed', async () => {
|
||||
const { cleanMarks } = await import('./readerMarks.js');
|
||||
const base = { book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 40, color: '#fef08a' };
|
||||
const out = cleanMarks({
|
||||
bookmarks: { 'JHN-3-16': '#fef08a' },
|
||||
bookmarkNotes: { 'JHN-3-16': 'Memorise this', 'JHN-3-17': ' ', 'bad key': 'x', 'ROM-8-1': 5 },
|
||||
highlights: [{ ...base, id: 'a', note: 'The heart of the gospel' }, { ...base, id: 'b', note: ' ' }, { ...base, id: 'c', note: 'x'.repeat(900) }],
|
||||
});
|
||||
expect(out.bookmarkNotes).toEqual({ 'JHN-3-16': 'Memorise this' });
|
||||
expect(out.highlights[0].note).toBe('The heart of the gospel');
|
||||
expect(out.highlights[1].note).toBeUndefined();
|
||||
expect(out.highlights[2].note).toHaveLength(500);
|
||||
});
|
||||
+33
-1
@@ -4,7 +4,8 @@
|
||||
// No API keys or push-subscription plumbing needed.
|
||||
import { lookup } from 'dns/promises';
|
||||
import { isIP } from 'net';
|
||||
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate } from './db.js';
|
||||
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate, getUsersWithPrayerReminder, setLastPrayerDate, getPrayers } from './db.js';
|
||||
import { openPrayers } from './prayers.js';
|
||||
import { fetchVerseOfDay, votdMessage } from './votd.js';
|
||||
|
||||
// A reminder still goes out if the server was briefly down (or restarting) at the
|
||||
@@ -142,10 +143,41 @@ export async function checkVotd(now = new Date()) {
|
||||
}
|
||||
}
|
||||
|
||||
// The message for the daily prayer reminder: how many requests are open, and the first few.
|
||||
export function prayerMessage(open) {
|
||||
const lines = open.slice(0, 3).map((p) => `• ${p.forWhom ? `${p.forWhom}: ` : ''}${p.text.replace(/\s+/g, ' ').slice(0, 80)}${p.text.length > 80 ? '…' : ''}`);
|
||||
const more = open.length - lines.length;
|
||||
return {
|
||||
title: open.length === 1 ? 'Time to pray: 1 request' : `Time to pray: ${open.length} requests`,
|
||||
body: `${lines.join('\n')}${more > 0 ? `\n…and ${more} more` : ''}`,
|
||||
tags: 'pray',
|
||||
};
|
||||
}
|
||||
|
||||
// Sends each person who asked for it a nudge to pray, with what they are praying about. Nothing is sent when
|
||||
// the journal has no open requests.
|
||||
export async function checkPrayerReminders(now = new Date()) {
|
||||
for (const user of getUsersWithPrayerReminder().filter((u) => isDue({ reminderTime: u.prayerTime, reminderTz: u.reminderTz, lastReminderDate: u.lastPrayerDate }, now))) {
|
||||
const open = openPrayers(getPrayers(user.id));
|
||||
if (open.length === 0) continue;
|
||||
const key = `prayer:${user.id}`;
|
||||
if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue;
|
||||
_lastAttempt.set(key, now.getTime());
|
||||
try {
|
||||
await sendNtfy(user, prayerMessage(open));
|
||||
_lastAttempt.delete(key);
|
||||
setLastPrayerDate(user.id, zonedNow(now, user.reminderTz).date);
|
||||
} catch (err) {
|
||||
console.error(`Prayer reminder failed for user ${user.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks once a minute whether any user's reminder time has arrived. */
|
||||
export function startReminderScheduler() {
|
||||
setInterval(() => {
|
||||
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
|
||||
checkVotd().catch((err) => console.error('Verse of the day scheduler error:', err));
|
||||
checkPrayerReminders().catch((err) => console.error('Prayer reminder scheduler error:', err));
|
||||
}, 60 * 1000);
|
||||
}
|
||||
@@ -99,3 +99,15 @@ test('private and loopback addresses are rejected as reminder servers', async ()
|
||||
expect(readToday({ activity: ['2026-09-29'] }, '2026-09-29')).toBe(true);
|
||||
expect(readToday({ activity: [] }, '2026-09-29')).toBe(false);
|
||||
});
|
||||
|
||||
test('the prayer reminder lists the first few open requests and counts the rest', async () => {
|
||||
const { prayerMessage } = await import('./reminders.js');
|
||||
const open = [
|
||||
{ text: 'Healing for the whole family and a peaceful recovery after the surgery next week, with no complications at all', forWhom: 'Mum' },
|
||||
{ text: 'Wisdom at work', forWhom: '' }, { text: 'Rain', forWhom: 'Farm' }, { text: 'Peace', forWhom: '' }, { text: 'Joy', forWhom: '' },
|
||||
];
|
||||
const m = prayerMessage(open);
|
||||
expect(m.title).toBe('Time to pray: 5 requests');
|
||||
expect(m.body.split('\n')).toEqual([`• Mum: ${open[0].text.slice(0, 80)}…`, '• Wisdom at work', '• Farm: Rain', '…and 2 more']);
|
||||
expect(prayerMessage([{ text: 'One', forWhom: '' }]).title).toBe('Time to pray: 1 request');
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Earlier versions of a study. Just before a save replaces what the server holds, that copy can be kept, so a
|
||||
// bad merge or an accidental delete can be undone. To stay small, a copy is kept at most once per window of
|
||||
// editing, it is compressed, and only the newest few are kept.
|
||||
import { gunzipSync, gzipSync } from 'zlib';
|
||||
|
||||
export const MIN_GAP_MS = 10 * 60 * 1000; // one version per ten minutes of editing history
|
||||
export const MAX_VERSIONS = 30;
|
||||
|
||||
// `prevEditedAt` is when the copy about to be replaced was saved; `lastKeptAt` is when the newest kept version was.
|
||||
export function shouldSnapshot({ prevEditedAt, lastKeptAt, force = false, minGap = MIN_GAP_MS }) {
|
||||
if (!Number.isFinite(prevEditedAt)) return false;
|
||||
if (force) return true;
|
||||
return !Number.isFinite(lastKeptAt) || prevEditedAt - lastKeptAt >= minGap;
|
||||
}
|
||||
|
||||
export const pack = (json) => gzipSync(Buffer.from(typeof json === 'string' ? json : JSON.stringify(json)));
|
||||
export const unpack = (buf) => JSON.parse(gunzipSync(buf).toString('utf8'));
|
||||
|
||||
const words = (s) => (String(s ?? '').trim() ? String(s).trim().split(/\s+/).length : 0);
|
||||
|
||||
// How big a study is: chunks and the words in its notes and scripts.
|
||||
export function summarizeProject(project) {
|
||||
let chunks = 0;
|
||||
let noteWords = 0;
|
||||
for (const chapter of project?.chapters ?? []) {
|
||||
for (const chunk of chapter.chunks ?? []) {
|
||||
chunks += 1;
|
||||
for (const f of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) noteWords += words(chunk[f]);
|
||||
}
|
||||
}
|
||||
return { chunks, words: noteWords };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { MIN_GAP_MS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js';
|
||||
|
||||
test('a version is kept once per window of editing, and always the first', () => {
|
||||
const t = 1_800_000_000_000;
|
||||
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: undefined })).toBe(true);
|
||||
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: t - 60_000 })).toBe(false); // a minute after the last one
|
||||
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: t - MIN_GAP_MS })).toBe(true);
|
||||
expect(shouldSnapshot({ prevEditedAt: t, lastKeptAt: t - 1000, force: true })).toBe(true); // before a restore
|
||||
expect(shouldSnapshot({ prevEditedAt: undefined, lastKeptAt: undefined })).toBe(false); // nothing to keep
|
||||
});
|
||||
|
||||
test('a stored copy round-trips and is smaller than the original', () => {
|
||||
const study = { id: 'a', title: 'T', chapters: [{ verses: Array.from({ length: 200 }, (_, i) => ({ number: i, text: 'In the beginning God created the heavens and the earth.' })) }] };
|
||||
const packed = pack(study);
|
||||
expect(packed.length).toBeLessThan(JSON.stringify(study).length / 5);
|
||||
expect(unpack(packed)).toEqual(study);
|
||||
});
|
||||
|
||||
test('a study is summarised by its chunks and the words in its notes', () => {
|
||||
const s = summarizeProject({ chapters: [{ chunks: [{ observation: 'one two three', application: ' four ' }, { finalScript: 'a b' }] }, { chunks: [{}] }] });
|
||||
expect(s).toEqual({ chunks: 3, words: 6 });
|
||||
expect(summarizeProject(null)).toEqual({ chunks: 0, words: 0 });
|
||||
});
|
||||
+39
-6
@@ -13,12 +13,13 @@ import { useAuth } from './hooks/useAuth.js';
|
||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
||||
import { useHistoryNav } from './hooks/useHistoryNav.js';
|
||||
import { usePrayers } from './hooks/usePrayers.js';
|
||||
import { useReadingHistory } from './hooks/useReadingHistory.js';
|
||||
import { useReadingPlans } from './hooks/useReadingPlans.js';
|
||||
import { useSharedView } from './hooks/useSharedView.js';
|
||||
import HomePage from './pages/HomePage.jsx';
|
||||
import { themeVars } from './components/reader/readerThemes.js';
|
||||
import { joinStudy, loadRemoteProject, saveRemoteProject } from './syncService.js';
|
||||
import { joinStudy, loadRemoteProject, restoreStudyVersion, saveRemoteProject } from './syncService.js';
|
||||
import { getSyncBase, setSyncBase } from './utils/syncBase.js';
|
||||
import {
|
||||
bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser,
|
||||
@@ -42,6 +43,7 @@ const ScriptPage = lazy(() => import('./pages/ScriptPage.jsx'));
|
||||
const ExportPage = lazy(() => import('./pages/ExportPage.jsx'));
|
||||
const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
|
||||
const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx'));
|
||||
const PrayerPage = lazy(() => import('./pages/PrayerPage.jsx'));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App component
|
||||
@@ -59,7 +61,7 @@ function savedStartPage() {
|
||||
const AppInner = () => {
|
||||
const { toast, setTheme: setToastTheme } = useToast();
|
||||
const [project, setProject] = useState(null);
|
||||
// 'home' | 'setup' | 'study' | 'outline' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
|
||||
// 'home' | 'setup' | 'study' | 'outline' | 'script' | 'export' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans' | 'prayer'
|
||||
const [currentPage, setCurrentPage] = useState(savedStartPage);
|
||||
const currentPageRef = useRef(currentPage);
|
||||
currentPageRef.current = currentPage;
|
||||
@@ -75,6 +77,8 @@ const AppInner = () => {
|
||||
const [readerPlan, setReaderPlan] = useState(null);
|
||||
const readerPlanProgress = readerPlan ? readingPlans.planProgress.find((p) => p.id === readerPlan.progressId) ?? null : null;
|
||||
const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null);
|
||||
const prayerJournal = usePrayers();
|
||||
const [prayerDraft, setPrayerDraft] = useState(null); // a verse to start a prayer with (from the Reader)
|
||||
const readingHistory = useReadingHistory({ active: currentPage === 'reader', book: reader.readerBookAbbrev, chapter: reader.readerChapter });
|
||||
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation);
|
||||
useEffect(() => { if (currentPage !== 'reader') setReaderPlan(null); }, [currentPage]);
|
||||
@@ -104,12 +108,14 @@ const AppInner = () => {
|
||||
reader.pullServerPrefs();
|
||||
// What you've read, merged with this device's.
|
||||
readingHistory.pullReadingHistory();
|
||||
prayerJournal.pullPrayers();
|
||||
},
|
||||
onSignOut: () => {
|
||||
reader.stopMarksSync();
|
||||
reader.stopPrefsSync();
|
||||
reader.stopInkSync();
|
||||
readingHistory.stopReadingHistory();
|
||||
prayerJournal.stopPrayers();
|
||||
readingPlans.stopReadingPlansSync();
|
||||
setProjectIndex(switchStorageUser(null));
|
||||
goStudies();
|
||||
@@ -161,7 +167,7 @@ const AppInner = () => {
|
||||
useEffect(() => {
|
||||
const names = {
|
||||
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Export',
|
||||
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans',
|
||||
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal',
|
||||
};
|
||||
let page = names[currentPage] ?? '';
|
||||
if (currentPage === 'reader') {
|
||||
@@ -251,11 +257,11 @@ const AppInner = () => {
|
||||
|
||||
const flushPendingSave = () => {
|
||||
const pending = pendingSaveRef.current;
|
||||
if (!pending) return;
|
||||
if (!pending) return Promise.resolve();
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
pendingSaveRef.current = null;
|
||||
savedSignatureRef.current = { id: pending.project.id, signature: pending.signature };
|
||||
commitSave(pending.project);
|
||||
return commitSave(pending.project);
|
||||
};
|
||||
const flushRef = useRef(flushPendingSave);
|
||||
flushRef.current = flushPendingSave;
|
||||
@@ -305,6 +311,27 @@ const AppInner = () => {
|
||||
toast('Switched to the version from your other device');
|
||||
};
|
||||
|
||||
// Puts an earlier version of the open study back. What you just typed is saved first (so it is in the list too),
|
||||
// then the server swaps the versions, and this device takes the restored one.
|
||||
const restoreEarlierVersion = async (versionId) => {
|
||||
if (!project) return false;
|
||||
await flushPendingSave();
|
||||
const result = await restoreStudyVersion(project.id, versionId);
|
||||
if (!result.ok) return false;
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
pendingSaveRef.current = null;
|
||||
const restored = result.data;
|
||||
try { saveProjectToStorage(restored); } catch { /* storage full: the account has it */ }
|
||||
unsyncedStudies.remove(restored.id);
|
||||
setSyncBase(restored.id, restored.lastEdited);
|
||||
savedSignatureRef.current = { id: restored.id, signature: signatureOf(restored) };
|
||||
setSyncConflict(null);
|
||||
setProject(restored);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
toast('Earlier version put back. What you had before is still in the list.');
|
||||
return true;
|
||||
};
|
||||
|
||||
// Third way out of a conflict: combine both copies chunk by chunk, then save the result as the new version.
|
||||
const mergeBothVersions = async () => {
|
||||
if (!project || !syncConflict) return;
|
||||
@@ -342,6 +369,9 @@ const AppInner = () => {
|
||||
const openBibleReader = () => setCurrentPage('reader');
|
||||
|
||||
const openReadingPlans = () => setCurrentPage('reading-plans');
|
||||
const openPrayer = () => setCurrentPage('prayer');
|
||||
// "Pray about this verse": opens the journal with the verse filled in
|
||||
const startPrayerFor = (label) => { setPrayerDraft({ label }); setCurrentPage('prayer'); };
|
||||
|
||||
// Jumps straight into a specific chapter and opens the reader (no plan context).
|
||||
const readReadingPlanDay = (bookAbbrev, chapter) => {
|
||||
@@ -477,7 +507,7 @@ const AppInner = () => {
|
||||
saveStatus, setSaveStatus,
|
||||
syncStatus,
|
||||
retrySync,
|
||||
syncConflict, keepMyVersion, useServerVersion, mergeBothVersions,
|
||||
syncConflict, keepMyVersion, useServerVersion, mergeBothVersions, restoreEarlierVersion,
|
||||
// Home
|
||||
projectIndex,
|
||||
...library,
|
||||
@@ -502,6 +532,8 @@ const AppInner = () => {
|
||||
// Reader
|
||||
...reader,
|
||||
readingHistory: readingHistory.readingHistory,
|
||||
...prayerJournal,
|
||||
prayerDraft, clearPrayerDraft: () => setPrayerDraft(null), startPrayerFor, openPrayer,
|
||||
readerPlan, setReaderPlan,
|
||||
readerPlanActive: !!readerPlanProgress,
|
||||
openPlanReading,
|
||||
@@ -594,6 +626,7 @@ function AppRouter() {
|
||||
if (currentPage === 'admin') return <AdminPage />;
|
||||
if (currentPage === 'settings') return <SettingsPage />;
|
||||
if (currentPage === 'reading-plans') return <ReadingPlansPage />;
|
||||
if (currentPage === 'prayer') return <PrayerPage />;
|
||||
if (currentPage === 'reader') return <ReaderPage />;
|
||||
if (currentPage === 'import') return <ImportPage />;
|
||||
if (currentPage === 'study') return <StudyPage />;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Export → Earlier versions: the copies the server kept of this study as you worked. Look at what is different
|
||||
// from now, and put one back. The version you replace is kept, so putting one back can be undone too.
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getStudyVersion, listStudyVersions } from '../syncService.js';
|
||||
import { describeDiff, diffProjects } from '../utils/projectDiff.js';
|
||||
|
||||
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||
|
||||
export default function VersionHistory({ project, onRestore }) {
|
||||
const [state, setState] = useState({ status: 'loading', versions: [] });
|
||||
const [open, setOpen] = useState(null); // { id, lines } the version being looked at
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!project?.id) return;
|
||||
const r = await listStudyVersions(project.id);
|
||||
setState(r.ok ? { status: 'ready', versions: r.data.versions } : { status: 'unavailable', versions: [] });
|
||||
}, [project?.id]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const preview = async (v) => {
|
||||
if (open?.id === v.id) { setOpen(null); return; }
|
||||
setError('');
|
||||
const r = await getStudyVersion(project.id, v.id);
|
||||
if (!r.ok) { setError(r.error ?? 'Could not open that version.'); return; }
|
||||
setOpen({ id: v.id, lines: describeDiff(diffProjects(project, r.data)) });
|
||||
};
|
||||
const restore = async (v) => {
|
||||
if (!window.confirm(`Put back the version from ${new Date(v.savedAt).toLocaleString()}? What you have now is kept in this list, so you can undo it.`)) return;
|
||||
setBusy(true); setError('');
|
||||
const ok = await onRestore(v.id);
|
||||
setBusy(false);
|
||||
if (ok) { setOpen(null); load(); } else setError('Could not restore that version. Check your connection and try again.');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Earlier versions">
|
||||
<h2 className="text-sm font-medium">Earlier versions</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">As you work, your account keeps earlier copies of this study (about one for every ten minutes of editing, the latest 30). Use one to undo a bad merge or a deletion.</p>
|
||||
{state.status === 'loading' && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
|
||||
{state.status === 'unavailable' && <p className="text-xs text-[var(--r-sub)]">Earlier versions need your account and a connection, and the study has to have synced once.</p>}
|
||||
{state.status === 'ready' && state.versions.length === 0 && <p className="text-xs text-[var(--r-sub)]">Nothing kept yet. The first earlier version appears after you’ve changed the study a couple of times.</p>}
|
||||
{state.versions.length > 0 && (
|
||||
<ul className="divide-y divide-[var(--r-line)] text-sm">
|
||||
{state.versions.map((v) => (
|
||||
<li key={v.id} className="py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span><span className="font-medium">{new Date(v.savedAt).toLocaleString()}</span> <span className="text-xs text-[var(--r-sub)]">{v.chunks} chunk{v.chunks === 1 ? '' : 's'} · {v.words} words</span></span>
|
||||
<span className="flex gap-2">
|
||||
<button type="button" onClick={() => preview(v)} aria-expanded={open?.id === v.id} className={btn}>{open?.id === v.id ? 'Hide' : 'What’s different'}</button>
|
||||
<button type="button" onClick={() => restore(v)} disabled={busy} className={btn}>Put this back</button>
|
||||
</span>
|
||||
</div>
|
||||
{open?.id === v.id && (
|
||||
<ul className="mt-2 list-disc space-y-0.5 rounded-lg bg-[var(--r-bg)] py-2 pl-7 pr-3 text-xs text-[var(--r-sub)]">
|
||||
{open.lines.map((line) => <li key={line}>{line}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../syncService.js', () => ({ listStudyVersions: vi.fn(), getStudyVersion: vi.fn() }));
|
||||
import VersionHistory from './VersionHistory.jsx';
|
||||
import { getStudyVersion, listStudyVersions } from '../syncService.js';
|
||||
|
||||
const project = { id: 'p1', title: 'Romans', chapters: [{ book: 'Romans', chapter: '8', chunks: [{ id: 'a', startVerse: 1, endVerse: 4, observation: 'new words' }] }] };
|
||||
const older = { id: 'p1', title: 'Romans', chapters: [{ book: 'Romans', chapter: '8', chunks: [{ id: 'a', startVerse: 1, endVerse: 4, observation: 'old words' }, { id: 'b', startVerse: 5, endVerse: 9, observation: 'lost' }] }] };
|
||||
const at = new Date(2026, 9, 5, 14, 30).getTime();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listStudyVersions.mockResolvedValue({ ok: true, data: { versions: [{ id: 7, savedAt: at, chunks: 2, words: 12 }] } });
|
||||
getStudyVersion.mockResolvedValue({ ok: true, data: older });
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('lists the kept versions and says what is different from now', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VersionHistory project={project} onRestore={vi.fn()} />);
|
||||
expect(await screen.findByText(/2 chunks · 12 words/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /what’s different/i }));
|
||||
expect(await screen.findByText('Romans 8:1–4: Observe differs.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Brings back 1 chunk deleted since: Romans 8:5–9/)).toBeInTheDocument();
|
||||
expect(getStudyVersion).toHaveBeenCalledWith('p1', 7);
|
||||
});
|
||||
|
||||
test('putting one back asks first, then restores and refreshes the list', async () => {
|
||||
const onRestore = vi.fn(async () => true);
|
||||
const user = userEvent.setup();
|
||||
render(<VersionHistory project={project} onRestore={onRestore} />);
|
||||
await user.click(await screen.findByRole('button', { name: /put this back/i }));
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(onRestore).toHaveBeenCalledWith(7);
|
||||
expect(listStudyVersions).toHaveBeenCalledTimes(2); // reloaded after
|
||||
});
|
||||
|
||||
test('declining the question restores nothing; a failed restore says so', async () => {
|
||||
const onRestore = vi.fn(async () => false);
|
||||
const user = userEvent.setup();
|
||||
window.confirm.mockReturnValueOnce(false);
|
||||
render(<VersionHistory project={project} onRestore={onRestore} />);
|
||||
await user.click(await screen.findByRole('button', { name: /put this back/i }));
|
||||
expect(onRestore).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole('button', { name: /put this back/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/Could not restore/);
|
||||
});
|
||||
|
||||
test('with nothing kept, or no connection, it says so', async () => {
|
||||
listStudyVersions.mockResolvedValueOnce({ ok: true, data: { versions: [] } });
|
||||
const { unmount } = render(<VersionHistory project={project} onRestore={vi.fn()} />);
|
||||
expect(await screen.findByText(/Nothing kept yet/)).toBeInTheDocument();
|
||||
unmount();
|
||||
listStudyVersions.mockResolvedValueOnce({ ok: false, status: 404 });
|
||||
render(<VersionHistory project={project} onRestore={vi.fn()} />);
|
||||
expect(await screen.findByText(/need your account and a connection/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// A short note on a highlight or a bookmark: shown under it, added or edited in place.
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function MarkNote({ note = '', onSave, label = 'note', className = '' }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const open = () => { setDraft(note); setEditing(true); };
|
||||
const save = () => { onSave(draft.trim()); setEditing(false); };
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<textarea autoFocus value={draft} maxLength={500} rows={2} aria-label={`Your ${label}`} onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); save(); } else if (e.key === 'Escape') { e.stopPropagation(); setEditing(false); } }}
|
||||
className="w-full resize-y rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" placeholder="What does this mean to you?" />
|
||||
<div className="mt-1 flex items-center gap-3 text-xs">
|
||||
<button type="button" onClick={save} className="font-medium text-[var(--r-accent)] hover:underline">Save</button>
|
||||
<button type="button" onClick={() => setEditing(false)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
|
||||
<span className="ml-auto text-[10px] text-[var(--r-sub)]">{draft.length}/500</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!note) {
|
||||
return <div className={className}><button type="button" onClick={open} className="text-xs text-[var(--r-accent)] hover:underline">+ Add {label}</button></div>;
|
||||
}
|
||||
return (
|
||||
<div className={`${className} flex items-start gap-2`}>
|
||||
<p className="min-w-0 flex-1 whitespace-pre-wrap rounded-lg bg-[var(--r-bg)] px-2.5 py-1.5 text-[13px] italic text-[var(--r-text)]">{note}</p>
|
||||
<button type="button" onClick={open} aria-label={`Edit ${label}`} className="shrink-0 text-xs text-[var(--r-sub)] hover:underline">Edit</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
import MarkNote from './MarkNote.jsx';
|
||||
|
||||
test('a note can be added, and is saved trimmed', async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<MarkNote note="" onSave={onSave} label="highlight note" />);
|
||||
await user.click(screen.getByRole('button', { name: /add highlight note/i }));
|
||||
await user.type(screen.getByLabelText('Your highlight note'), ' Pray this daily ');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(onSave).toHaveBeenCalledWith('Pray this daily');
|
||||
});
|
||||
|
||||
test('an existing note is shown, and can be edited or cleared', async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<MarkNote note="First thought" onSave={onSave} label="note" />);
|
||||
expect(screen.getByText('First thought')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Edit note' }));
|
||||
const box = screen.getByLabelText('Your note');
|
||||
expect(box).toHaveValue('First thought');
|
||||
await user.clear(box);
|
||||
await user.click(screen.getByRole('button', { name: 'Save' })); // an empty note removes it
|
||||
expect(onSave).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
test('cancelling keeps what was there; Ctrl+Enter saves', async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<MarkNote note="Keep me" onSave={onSave} />);
|
||||
await user.click(screen.getByRole('button', { name: /edit/i }));
|
||||
await user.type(screen.getByLabelText(/your/i), ' more');
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Keep me')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /edit/i }));
|
||||
await user.type(screen.getByLabelText(/your/i), ' more{Control>}{Enter}{/Control}');
|
||||
expect(onSave).toHaveBeenCalledWith('Keep me more');
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { CloseIcon } from './icons.jsx';
|
||||
import MarkNote from './MarkNote.jsx';
|
||||
import { buildMarksDocxBlob, buildMarksModel, downloadMarksFile, marksToMarkdown } from '../../utils/marksExport.js';
|
||||
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||
|
||||
@@ -10,6 +11,7 @@ const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${
|
||||
|
||||
export default function MarksSheet({
|
||||
open, onClose, initialTab = 'highlights', bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
|
||||
bookmarkNotes = {}, onSetHighlightNote, onSetBookmarkNote,
|
||||
}) {
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
// Opening from the Highlights button shows highlights; from the Bookmarks button, bookmarks
|
||||
@@ -24,7 +26,7 @@ export default function MarksSheet({
|
||||
const [exportError, setExportError] = useState('');
|
||||
const exportAs = async (kind) => {
|
||||
setExportError('');
|
||||
const model = buildMarksModel({ highlights, bookmarks, colorLabels });
|
||||
const model = buildMarksModel({ highlights, bookmarks, colorLabels, bookmarkNotes });
|
||||
try {
|
||||
if (kind === 'md') downloadMarksFile(new Blob([marksToMarkdown(model)], { type: 'text/markdown;charset=utf-8' }), 'highlights-and-bookmarks.md');
|
||||
else downloadMarksFile(await buildMarksDocxBlob(model), 'highlights-and-bookmarks.docx');
|
||||
@@ -73,15 +75,18 @@ export default function MarksSheet({
|
||||
) : shown.length === 0 ? (
|
||||
<p className="text-sm text-[var(--r-sub)]">Nothing highlighted in that colour yet.</p>
|
||||
) : shown.map((h) => (
|
||||
<div key={h.id} className="flex items-start gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
|
||||
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
|
||||
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
|
||||
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
|
||||
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
|
||||
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
<div key={h.id} className="rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
|
||||
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
|
||||
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
|
||||
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
|
||||
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
|
||||
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
</div>
|
||||
{onSetHighlightNote && <MarkNote className="ml-5 mt-1" note={h.note} label={`note on ${h.bookName} ${h.chapter}:${h.verse}`} onSave={(text) => onSetHighlightNote(h.id, text)} />}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
@@ -91,13 +96,16 @@ export default function MarksSheet({
|
||||
bookmarks.length === 0 ? (
|
||||
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
|
||||
) : bookmarks.map((e) => (
|
||||
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
|
||||
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
|
||||
{e.bookName} {e.chapter}:{e.verse}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
|
||||
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
<div key={e.key} className="rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
|
||||
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
|
||||
{e.bookName} {e.chapter}:{e.verse}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
|
||||
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
</div>
|
||||
{onSetBookmarkNote && <MarkNote className="ml-5 mt-1" note={bookmarkNotes[e.key]} label={`note on ${e.bookName} ${e.chapter}:${e.verse}`} onSave={(text) => onSetBookmarkNote(e.key, text)} />}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DiffText } from './DiffText.jsx';
|
||||
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||
import { isWholeVerse } from '../../utils/highlightSpans.js';
|
||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||
import MarkNote from './MarkNote.jsx';
|
||||
import VerseShareImage from '../VerseShareImage.jsx';
|
||||
import IqLexicon from '../iq/IqLexicon.jsx';
|
||||
import IqStudyAids from '../iq/IqStudyAids.jsx';
|
||||
@@ -155,7 +156,7 @@ function CompareTab({ book, chapter, verse, current }) {
|
||||
|
||||
export default function VerseSheet({
|
||||
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, request,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, bookmarkNote = '', onSetBookmarkNote, onSetHighlightNote, studiesHere = [], onOpenStudy, onPray, onJump, onClose, request,
|
||||
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
|
||||
}) {
|
||||
const iqOn = useIqAvailable();
|
||||
@@ -204,6 +205,11 @@ export default function VerseSheet({
|
||||
<span className="block h-4 w-4 rounded-full border border-black/10" style={{ backgroundColor: bookmarkColor }} />
|
||||
</button>
|
||||
)}
|
||||
{onPray && (
|
||||
<button type="button" onClick={() => onPray(`${bookName} ${chapter}:${verse.number}`)} className={`${actionBtn} text-xs`} aria-label="Pray about this verse" title="Add a prayer about this verse">
|
||||
Pray
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setImageOpen((o) => !o)} aria-pressed={imageOpen} className={`${actionBtn} text-xs`} aria-label="Share verse as image" title="Share as image">
|
||||
Image
|
||||
</button>
|
||||
@@ -240,6 +246,40 @@ export default function VerseSheet({
|
||||
<span className="basis-full text-[11px] text-[var(--r-sub)]">Or select words in the text to highlight just those.</span>
|
||||
</div>
|
||||
)}
|
||||
{studiesHere.length > 0 && onOpenStudy && (
|
||||
<div className="mb-3 rounded-xl border border-[var(--r-line)] p-3" aria-label="In your studies">
|
||||
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">You’ve studied this</p>
|
||||
<ul className="space-y-2">
|
||||
{studiesHere.slice(0, 5).map((e) => (
|
||||
<li key={`${e.projectId}/${e.chunkId}`} className="flex items-start justify-between gap-3 text-sm">
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{e.title}</span> <span className="text-xs text-[var(--r-sub)]">{e.ref}</span>
|
||||
{e.snippet && <span className="mt-0.5 block truncate font-serif text-[13px] text-[var(--r-sub)]">{e.snippet}</span>}
|
||||
</span>
|
||||
<button type="button" onClick={() => onOpenStudy(e.projectId, e.chunkId)} className="shrink-0 text-xs font-medium text-[var(--r-accent)] hover:underline">Open</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{studiesHere.length > 5 && <p className="mt-1.5 text-[11px] text-[var(--r-sub)]">and {studiesHere.length - 5} more</p>}
|
||||
</div>
|
||||
)}
|
||||
{(bookmarkColor && onSetBookmarkNote) || (verseHighlights.length > 0 && onSetHighlightNote) ? (
|
||||
<div className="mb-3 space-y-2 rounded-xl border border-[var(--r-line)] p-3" aria-label="Your notes on this verse">
|
||||
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</p>
|
||||
{bookmarkColor && onSetBookmarkNote && (
|
||||
<div>
|
||||
<p className="flex items-center gap-1.5 text-xs text-[var(--r-sub)]"><span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: bookmarkColor }} />Bookmark</p>
|
||||
<MarkNote note={bookmarkNote} label="bookmark note" onSave={onSetBookmarkNote} className="mt-1" />
|
||||
</div>
|
||||
)}
|
||||
{onSetHighlightNote && verseHighlights.map((h) => (
|
||||
<div key={h.id}>
|
||||
<p className="flex items-center gap-1.5 truncate text-xs text-[var(--r-sub)]"><span className="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} /><span className="truncate">{isWholeVerse(h, verse.text) ? 'Whole verse' : `“${h.text}”`}</span></p>
|
||||
<MarkNote note={h.note} label="highlight note" onSave={(text) => onSetHighlightNote(h.id, text)} className="mt-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
|
||||
{TABS.map(([id, label]) => (
|
||||
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
import VerseSheet from './VerseSheet.jsx';
|
||||
|
||||
vi.mock('../StudyPanel.jsx', () => ({ CommentaryTab: () => null, PeoplePlacesTab: () => null }));
|
||||
|
||||
const base = {
|
||||
verse: { number: 1, text: 'Paul, a servant of God.' }, book: 'TIT', bookName: 'Titus', chapter: 1, translation: 'BSB',
|
||||
onToggleBookmark: () => {}, onCycleColor: () => {}, onJump: () => {}, onClose: () => {}, onLoadCrossRefs: () => {},
|
||||
};
|
||||
const here = [{ projectId: 'p1', chunkId: 'a', title: 'Titus Notes', ref: 'Titus 1:1–4', snippet: 'Paul calls himself a servant' }];
|
||||
|
||||
test('the verse panel lists your studies that cover the verse, and opens one at that chunk', async () => {
|
||||
const onOpenStudy = vi.fn();
|
||||
render(<VerseSheet {...base} studiesHere={here} onOpenStudy={onOpenStudy} />);
|
||||
expect(screen.getByText('You’ve studied this')).toBeInTheDocument();
|
||||
expect(screen.getByText('Titus Notes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Paul calls himself a servant')).toBeInTheDocument();
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Open' }));
|
||||
expect(onOpenStudy).toHaveBeenCalledWith('p1', 'a');
|
||||
});
|
||||
|
||||
test('nothing is shown when no study covers the verse', () => {
|
||||
render(<VerseSheet {...base} studiesHere={[]} onOpenStudy={() => {}} />);
|
||||
expect(screen.queryByText('You’ve studied this')).toBeNull();
|
||||
});
|
||||
|
||||
test('your notes on the verse show up in the panel and can be added', async () => {
|
||||
const onSetBookmarkNote = vi.fn();
|
||||
const onSetHighlightNote = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<VerseSheet {...base} bookmarkColor="#fef08a" bookmarkNote="Memorise" onSetBookmarkNote={onSetBookmarkNote}
|
||||
verseHighlights={[{ id: 'h1', color: '#bbf7d0', startOffset: 0, endOffset: 4, text: 'Paul' }]} onSetHighlightNote={onSetHighlightNote} />);
|
||||
expect(screen.getByText('Memorise')).toBeInTheDocument();
|
||||
expect(screen.getByText('“Paul”')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /add highlight note/i }));
|
||||
await user.type(screen.getByLabelText('Your highlight note'), 'Who he is');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(onSetHighlightNote).toHaveBeenCalledWith('h1', 'Who he is');
|
||||
});
|
||||
@@ -13,8 +13,8 @@ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
export default function DataSection() {
|
||||
const {
|
||||
authUser, readerBookmarks, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
|
||||
importReaderMarks, importReadingPlans, refreshProjectIndex,
|
||||
authUser, readerBookmarks, readerBookmarkNotes, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
|
||||
importReaderMarks, importReadingPlans, refreshProjectIndex, prayers, importPrayers,
|
||||
} = useApp();
|
||||
const { toast } = useToast();
|
||||
const offline = useOfflineSave();
|
||||
@@ -26,8 +26,9 @@ export default function DataSection() {
|
||||
const download = () => {
|
||||
const backup = buildBackup({
|
||||
email: authUser?.email ?? null,
|
||||
reader: { bookmarks: readerBookmarks, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
|
||||
reader: { bookmarks: readerBookmarks, bookmarkNotes: readerBookmarkNotes, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
|
||||
readingPlans: readingPlansState,
|
||||
prayers,
|
||||
});
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
|
||||
const link = document.createElement('a');
|
||||
@@ -59,6 +60,7 @@ export default function DataSection() {
|
||||
const written = restoreProjects(backup.projects);
|
||||
importReaderMarks(backup.reader);
|
||||
importReadingPlans(backup.readingPlans);
|
||||
importPrayers?.(backup.prayers);
|
||||
refreshProjectIndex();
|
||||
// Studies restored here also go to the account, so they follow you to other devices
|
||||
if (authUser) await Promise.all(written.map((p) => saveRemoteProject(p)));
|
||||
@@ -88,7 +90,7 @@ export default function DataSection() {
|
||||
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 text-sm">
|
||||
<p className="font-medium">{pending.name}</p>
|
||||
<p className="mt-1 text-[var(--r-sub)]">
|
||||
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')} and {plural(s.plans, 'reading plan')}.
|
||||
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')}, {plural(s.plans, 'reading plan')} and {plural(s.prayers ?? 0, 'prayer')}.
|
||||
Restoring adds these to what you have — nothing is deleted, and a study on this device is only replaced if the backup’s copy is newer.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
|
||||
@@ -10,6 +10,7 @@ const NAV = [
|
||||
{ id: 'studies', label: 'Studies', short: 'Studies' },
|
||||
{ id: 'reader', label: 'Read Bible', short: 'Bible' },
|
||||
{ id: 'plans', label: 'Reading Plans', short: 'Plans' },
|
||||
{ id: 'prayer', label: 'Prayer', short: 'Prayer' },
|
||||
];
|
||||
|
||||
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
|
||||
@@ -62,8 +63,8 @@ function AccountMenu() {
|
||||
}
|
||||
|
||||
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
|
||||
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPalette } = useApp();
|
||||
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans };
|
||||
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPrayer, openPalette } = useApp();
|
||||
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans, prayer: openPrayer };
|
||||
return (
|
||||
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
|
||||
<a href="#content" className="skip-link">Skip to content</a>
|
||||
|
||||
@@ -130,6 +130,10 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
const [readerBookmarks, setReaderBookmarks] = useState(() => {
|
||||
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
|
||||
});
|
||||
// A short note on a bookmark: { "JHN-3-16": "text" }. (A note on a highlight lives on the highlight itself.)
|
||||
const [readerBookmarkNotes, setReaderBookmarkNotes] = useState(() => {
|
||||
try { const v = JSON.parse(localStorage.getItem('reader-bookmark-notes') || '{}'); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; } catch { return {}; }
|
||||
});
|
||||
const [readerBookmarksPanelOpen, setReaderBookmarksPanelOpen] = useState(false);
|
||||
const [readerJumpVerse, setReaderJumpVerse] = useState(null); // verse number to scroll to + flash after a jump
|
||||
const [readerCrossRefs, setReaderCrossRefs] = useState(null);
|
||||
@@ -451,6 +455,29 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
});
|
||||
};
|
||||
|
||||
const setBookmarkNote = (verseKey, note) => {
|
||||
setReaderBookmarkNotes((prev) => {
|
||||
const text = String(note ?? '').slice(0, 500);
|
||||
const next = { ...prev };
|
||||
if (text.trim()) next[verseKey] = text; else delete next[verseKey];
|
||||
try { localStorage.setItem('reader-bookmark-notes', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const setHighlightNote = (id, note) => {
|
||||
setReaderTextHighlights((prev) => {
|
||||
const text = String(note ?? '').slice(0, 500);
|
||||
const next = prev.map((h) => {
|
||||
if (h.id !== id) return h;
|
||||
const { note: _old, ...rest } = h;
|
||||
return text.trim() ? { ...rest, note: text } : rest;
|
||||
});
|
||||
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Puts a removed highlight back exactly as it was (used by "Undo").
|
||||
const restoreTextHighlight = (highlight) => {
|
||||
setReaderTextHighlights((prev) => {
|
||||
@@ -495,13 +522,15 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
|
||||
// Bookmarks, highlights, colour names and ink from a backup file are added to what is here (nothing is
|
||||
// removed, and where both have something the copy already here wins).
|
||||
const importReaderMarks = ({ bookmarks, highlights, colorLabels, ink }) => {
|
||||
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights }));
|
||||
const importReaderMarks = ({ bookmarks, bookmarkNotes, highlights, colorLabels, ink }) => {
|
||||
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights, bookmarkNotes }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights, bookmarkNotes: readerBookmarkNotes }));
|
||||
setReaderBookmarks(merged.bookmarks);
|
||||
setReaderTextHighlights(merged.highlights);
|
||||
setReaderBookmarkNotes(merged.bookmarkNotes);
|
||||
try {
|
||||
localStorage.setItem('reader-bookmarks', JSON.stringify(merged.bookmarks));
|
||||
localStorage.setItem('readerTextHighlights', JSON.stringify(merged.highlights));
|
||||
localStorage.setItem('reader-bookmark-notes', JSON.stringify(merged.bookmarkNotes));
|
||||
} catch {}
|
||||
if (colorLabels && Object.keys(colorLabels).length > 0) {
|
||||
setReaderColorLabelsState((prev) => {
|
||||
@@ -540,8 +569,8 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
|
||||
// Bookmarks and highlights follow the signed-in user across devices.
|
||||
const marksSync = useReaderMarksSync({
|
||||
bookmarks: readerBookmarks, highlights: readerTextHighlights,
|
||||
setBookmarks: setReaderBookmarks, setHighlights: setReaderTextHighlights,
|
||||
bookmarks: readerBookmarks, highlights: readerTextHighlights, bookmarkNotes: readerBookmarkNotes,
|
||||
setBookmarks: setReaderBookmarks, setHighlights: setReaderTextHighlights, setBookmarkNotes: setReaderBookmarkNotes,
|
||||
});
|
||||
|
||||
// Theme, default translation, layout, commentary and reading position follow the account too.
|
||||
@@ -590,6 +619,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
|
||||
planTranslationPref, setPlanTranslationPref,
|
||||
startPagePref, setStartPagePref,
|
||||
readerBookmarks, setReaderBookmarks,
|
||||
readerBookmarkNotes, setBookmarkNote, setHighlightNote,
|
||||
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
|
||||
readerCrossRefs, setReaderCrossRefs,
|
||||
readerCrossRefsLoading,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// The prayer journal on this device, kept in step with your account. Every change is saved here at once and
|
||||
// sent to the account a moment later; signing in merges the two (the newer version of each prayer wins), so a prayer
|
||||
// added on your phone appears on your computer. Signing out clears this device's copy.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
|
||||
import { markAnswered, markDeleted, mergePrayers, newPrayer, prayersEqual, reopen, touch, togglePrayedToday } from '../utils/prayers.js';
|
||||
import { localDateString } from '../utils/readingPlans.js';
|
||||
|
||||
const KEY = 'prayers';
|
||||
const PUSH_DELAY_MS = 1500;
|
||||
const load = () => { try { const v = JSON.parse(localStorage.getItem(KEY) || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } };
|
||||
const save = (list) => { try { localStorage.setItem(KEY, JSON.stringify(list)); } catch { /* storage unavailable */ } };
|
||||
|
||||
export function usePrayers() {
|
||||
const [prayers, setPrayersState] = useState(load);
|
||||
const ref = useRef(prayers);
|
||||
ref.current = prayers;
|
||||
const enabledRef = useRef(false);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const push = useCallback(async () => {
|
||||
window.clearTimeout(timerRef.current);
|
||||
if (!enabledRef.current) return;
|
||||
const result = await saveRemotePrayers(ref.current);
|
||||
if (result.ok) {
|
||||
const merged = mergePrayers(ref.current, result.data?.prayers);
|
||||
if (!prayersEqual(merged, ref.current)) { ref.current = merged; save(merged); setPrayersState(merged); }
|
||||
}
|
||||
}, []);
|
||||
|
||||
const set = useCallback((next) => {
|
||||
ref.current = next;
|
||||
save(next);
|
||||
setPrayersState(next);
|
||||
if (enabledRef.current) { window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); }
|
||||
}, [push]);
|
||||
|
||||
const replace = (id, fn) => set(ref.current.map((p) => (p.id === id ? fn(p) : p)));
|
||||
|
||||
const addPrayer = (fields) => { const p = newPrayer(fields); set([p, ...ref.current]); return p; };
|
||||
const editPrayer = (id, patch) => replace(id, (p) => touch(p, patch));
|
||||
const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString()));
|
||||
const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note));
|
||||
const reopenPrayer = (id) => replace(id, (p) => reopen(p));
|
||||
// Deleting leaves a marker so the other devices drop it too; the returned function puts it back
|
||||
const deletePrayer = (id) => {
|
||||
const before = ref.current.find((p) => p.id === id);
|
||||
if (!before) return () => {};
|
||||
set(ref.current.map((p) => (p.id === id ? markDeleted(p) : p)));
|
||||
return () => set(ref.current.map((p) => (p.id === id ? touch(before, {}) : p)));
|
||||
};
|
||||
// Prayers from a backup file are added; a prayer already here keeps whichever copy is newer
|
||||
const importPrayers = (list) => set(mergePrayers(ref.current, list));
|
||||
|
||||
const pull = useCallback(async () => {
|
||||
enabledRef.current = true;
|
||||
const result = await getRemotePrayers();
|
||||
if (!result.ok) return;
|
||||
const merged = mergePrayers(ref.current, result.data?.prayers);
|
||||
if (!prayersEqual(merged, ref.current)) { ref.current = merged; save(merged); setPrayersState(merged); }
|
||||
if (!prayersEqual(merged, result.data?.prayers ?? [])) await push();
|
||||
}, [push]);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
enabledRef.current = false;
|
||||
window.clearTimeout(timerRef.current);
|
||||
try { localStorage.removeItem(KEY); } catch { /* storage unavailable */ }
|
||||
ref.current = [];
|
||||
setPrayersState([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => window.clearTimeout(timerRef.current), []);
|
||||
return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../syncService.js', () => ({ getRemotePrayers: vi.fn(), saveRemotePrayers: vi.fn() }));
|
||||
import { usePrayers } from './usePrayers.js';
|
||||
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
getRemotePrayers.mockResolvedValue({ ok: true, data: { prayers: [] } });
|
||||
saveRemotePrayers.mockImplementation(async (list) => ({ ok: true, data: { prayers: list } }));
|
||||
});
|
||||
|
||||
test('prayers are added, changed, answered and kept on this device', () => {
|
||||
const { result } = renderHook(() => usePrayers());
|
||||
let id;
|
||||
act(() => { id = result.current.addPrayer({ text: 'Peace at home', forWhom: 'Family' }).id; });
|
||||
expect(result.current.prayers[0]).toMatchObject({ text: 'Peace at home', forWhom: 'Family' });
|
||||
act(() => result.current.editPrayer(id, { text: 'Peace and joy at home' }));
|
||||
act(() => result.current.togglePrayed(id));
|
||||
expect(result.current.prayers[0].prayedOn).toHaveLength(1);
|
||||
act(() => result.current.answerPrayer(id, 'Yes'));
|
||||
expect(result.current.prayers[0]).toMatchObject({ text: 'Peace and joy at home', answerNote: 'Yes' });
|
||||
expect(result.current.prayers[0].answeredAt).toBeGreaterThan(0);
|
||||
act(() => result.current.reopenPrayer(id));
|
||||
expect(result.current.prayers[0].answeredAt).toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem('prayers'))[0].text).toBe('Peace and joy at home');
|
||||
});
|
||||
|
||||
test('a deleted prayer can be taken back', () => {
|
||||
const { result } = renderHook(() => usePrayers());
|
||||
let id; let undo;
|
||||
act(() => { id = result.current.addPrayer({ text: 'Keep me' }).id; });
|
||||
act(() => { undo = result.current.deletePrayer(id); });
|
||||
expect(result.current.prayers[0].deleted).toBe(true);
|
||||
act(() => undo());
|
||||
expect(result.current.prayers[0]).toMatchObject({ id, text: 'Keep me' });
|
||||
expect(result.current.prayers[0].deleted).toBeUndefined();
|
||||
});
|
||||
|
||||
test('signing in merges with the account and sends back what it lacked', async () => {
|
||||
localStorage.setItem('prayers', JSON.stringify([{ id: 'local', text: 'Only here', forWhom: '', verses: [], prayedOn: [], createdAt: 1, updatedAt: 5, answeredAt: null, answerNote: '' }]));
|
||||
getRemotePrayers.mockResolvedValue({ ok: true, data: { prayers: [{ id: 'remote', text: 'Only there', forWhom: '', verses: [], prayedOn: [], createdAt: 1, updatedAt: 6, answeredAt: null, answerNote: '' }] } });
|
||||
const { result } = renderHook(() => usePrayers());
|
||||
await act(async () => { await result.current.pullPrayers(); });
|
||||
expect(result.current.prayers.map((p) => p.id).sort()).toEqual(['local', 'remote']);
|
||||
expect(saveRemotePrayers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('signing out clears the journal from this device', async () => {
|
||||
const { result } = renderHook(() => usePrayers());
|
||||
act(() => { result.current.addPrayer({ text: 'Private' }); });
|
||||
act(() => result.current.stopPrayers());
|
||||
expect(result.current.prayers).toEqual([]);
|
||||
expect(localStorage.getItem('prayers')).toBeNull();
|
||||
});
|
||||
@@ -171,6 +171,14 @@ export function useProjectLibrary({
|
||||
setCurrentPage(hasSelectedChunk ? 'study' : 'setup');
|
||||
};
|
||||
|
||||
// Opens a study on one particular chunk (from the Reader's "In your studies").
|
||||
const openStudyChunk = (id, chunkId) => {
|
||||
const loaded = loadProjectById(id);
|
||||
if (!loaded) return;
|
||||
setProject({ ...loaded, selectedChunkId: chunkId });
|
||||
setCurrentPage('study');
|
||||
};
|
||||
|
||||
const renameProjectInStorage = (id, title) => {
|
||||
const loaded = loadProjectById(id);
|
||||
if (!loaded) return;
|
||||
@@ -276,6 +284,7 @@ export function useProjectLibrary({
|
||||
staleLocalProjects,
|
||||
autoRestoredCount,
|
||||
resumeProject,
|
||||
openStudyChunk,
|
||||
renameProjectInStorage,
|
||||
setProjectSeries,
|
||||
deleteProject,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useCallback, useEffect, useRef } from 'react';
|
||||
import { getRemoteMarks, saveRemoteMarks } from '../syncService.js';
|
||||
import { emptyMarks, isEmptyMarks, marksEqual, mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
|
||||
|
||||
const KEYS = { bookmarks: 'reader-bookmarks', highlights: 'readerTextHighlights', syncedAt: 'reader-marks-synced-at', dirty: 'reader-marks-dirty' };
|
||||
const KEYS = { bookmarks: 'reader-bookmarks', highlights: 'readerTextHighlights', bookmarkNotes: 'reader-bookmark-notes', syncedAt: 'reader-marks-synced-at', dirty: 'reader-marks-dirty' };
|
||||
const PUSH_DELAY_MS = 1500;
|
||||
const FOCUS_PULL_MIN_MS = 20_000;
|
||||
|
||||
@@ -14,10 +14,10 @@ const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return nu
|
||||
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* storage unavailable */ } };
|
||||
const lsDel = (k) => { try { localStorage.removeItem(k); } catch { /* storage unavailable */ } };
|
||||
|
||||
export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHighlights }) {
|
||||
export function useReaderMarksSync({ bookmarks, highlights, bookmarkNotes = {}, setBookmarks, setHighlights, setBookmarkNotes = () => {} }) {
|
||||
const enabledRef = useRef(false);
|
||||
const latestRef = useRef({ bookmarks, highlights });
|
||||
latestRef.current = { bookmarks, highlights };
|
||||
const latestRef = useRef({ bookmarks, highlights, bookmarkNotes });
|
||||
latestRef.current = { bookmarks, highlights, bookmarkNotes };
|
||||
const timerRef = useRef(null);
|
||||
const skipRef = useRef(false); // set while applying server data, so it isn't mistaken for a local edit
|
||||
const firstRunRef = useRef(true);
|
||||
@@ -31,13 +31,13 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
|
||||
if (!enabledRef.current) return;
|
||||
if (pushingRef.current) { await pushingRef.current; }
|
||||
const snapshot = latestRef.current;
|
||||
pushingRef.current = saveRemoteMarks({ bookmarks: snapshot.bookmarks, highlights: snapshot.highlights });
|
||||
pushingRef.current = saveRemoteMarks({ bookmarks: snapshot.bookmarks, highlights: snapshot.highlights, bookmarkNotes: snapshot.bookmarkNotes });
|
||||
const result = await pushingRef.current;
|
||||
pushingRef.current = null;
|
||||
if (!result.ok) return; // stays dirty; retried on the next change or when the tab regains focus
|
||||
lsSet(KEYS.syncedAt, String(result.data.updatedAt));
|
||||
// Only clean if nothing changed while the request was in flight
|
||||
if (latestRef.current.bookmarks === snapshot.bookmarks && latestRef.current.highlights === snapshot.highlights) setDirty(false);
|
||||
if (latestRef.current.bookmarks === snapshot.bookmarks && latestRef.current.highlights === snapshot.highlights && latestRef.current.bookmarkNotes === snapshot.bookmarkNotes) setDirty(false);
|
||||
else timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
@@ -50,7 +50,7 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
|
||||
}
|
||||
}, [bookmarks, highlights, push]);
|
||||
}, [bookmarks, highlights, bookmarkNotes, push]);
|
||||
|
||||
// Puts `next` on screen and in localStorage without treating it as a local edit.
|
||||
const apply = useCallback((next) => {
|
||||
@@ -58,9 +58,11 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
|
||||
skipRef.current = true;
|
||||
lsSet(KEYS.bookmarks, JSON.stringify(next.bookmarks));
|
||||
lsSet(KEYS.highlights, JSON.stringify(next.highlights));
|
||||
lsSet(KEYS.bookmarkNotes, JSON.stringify(next.bookmarkNotes ?? {}));
|
||||
setBookmarks(next.bookmarks);
|
||||
setHighlights(next.highlights);
|
||||
}, [setBookmarks, setHighlights]);
|
||||
setBookmarkNotes(next.bookmarkNotes ?? {});
|
||||
}, [setBookmarks, setHighlights, setBookmarkNotes]);
|
||||
|
||||
// Sign-in (and tab focus): reconcile this device with the server's copy.
|
||||
const pullServerMarks = useCallback(async () => {
|
||||
@@ -103,7 +105,7 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
|
||||
const stopMarksSync = useCallback(() => {
|
||||
enabledRef.current = false;
|
||||
window.clearTimeout(timerRef.current);
|
||||
[KEYS.bookmarks, KEYS.highlights, KEYS.syncedAt, KEYS.dirty].forEach(lsDel);
|
||||
[KEYS.bookmarks, KEYS.highlights, KEYS.bookmarkNotes, KEYS.syncedAt, KEYS.dirty].forEach(lsDel);
|
||||
apply(emptyMarks());
|
||||
}, [apply]);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useApp } from '../context/AppContext.js';
|
||||
import { useShareLink } from '../hooks/useShareLink.js';
|
||||
import HandoutExport from '../components/HandoutExport.jsx';
|
||||
import SessionSchedule from '../components/SessionSchedule.jsx';
|
||||
import VersionHistory from '../components/VersionHistory.jsx';
|
||||
import CoTeachers from '../components/CoTeachers.jsx';
|
||||
import LinkRules from '../components/LinkRules.jsx';
|
||||
import StudioShell from '../components/study/StudioShell.jsx';
|
||||
@@ -21,7 +22,7 @@ function Row({ title, note, button, onClick }) {
|
||||
}
|
||||
|
||||
export default function ExportPage() {
|
||||
const { project, allChunks, setCurrentPage, sharedStudies, deleteProject } = useApp();
|
||||
const { project, allChunks, setCurrentPage, sharedStudies, deleteProject, restoreEarlierVersion } = useApp();
|
||||
const share = useShareLink(project?.id);
|
||||
const filled = allChunks.filter((c) => (c.finalScript ?? '').trim()).length;
|
||||
|
||||
@@ -66,6 +67,8 @@ export default function ExportPage() {
|
||||
|
||||
<CoTeachers projectId={project?.id} sharedBy={project && sharedStudies?.[project.id]} onLeave={() => { deleteProject(project.id); setCurrentPage('home'); }} />
|
||||
|
||||
<VersionHistory project={project} onRestore={restoreEarlierVersion} />
|
||||
|
||||
<section className={card} aria-label="Pronunciation">
|
||||
<Row title="Pronunciation guide" note="Every Greek and Hebrew word in this study, with transliterations." button="Open guide" onClick={() => setCurrentPage('pronunciation')} />
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// The prayer journal: what you are praying about (and for whom), the verses tied to each request, the days you
|
||||
// prayed, and the answers when they come.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import PageShell from '../components/study/PageShell.jsx';
|
||||
import { useToast } from '../components/Toasts.jsx';
|
||||
import { filterPrayers, daysToAnswer, labelFor, live, parseVerses, prayersToMarkdown } from '../utils/prayers.js';
|
||||
import { localDateString } from '../utils/readingPlans.js';
|
||||
|
||||
const field = 'rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
|
||||
const small = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1 text-xs hover:bg-black/5';
|
||||
const tabBtn = (on) => `rounded-full border px-3 py-1 text-xs font-medium ${on ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
|
||||
|
||||
function VerseChips({ verses, onOpen }) {
|
||||
if (!verses?.length) return null;
|
||||
return (
|
||||
<span className="mt-1 flex flex-wrap gap-1.5">
|
||||
{verses.map((v, i) => (
|
||||
<button key={`${v.book}${v.chapter}${v.verse}${i}`} type="button" onClick={() => onOpen(v.book, v.chapter, v.verse ?? 1)}
|
||||
className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-accent)] hover:bg-black/5" title="Read it in the Bible">{labelFor(v)}</button>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PrayerCard({ prayer, today, actions, onOpenVerse }) {
|
||||
const [mode, setMode] = useState(null); // null | 'edit' | 'answer'
|
||||
const [text, setText] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const prayedToday = prayer.prayedOn.includes(today);
|
||||
const days = daysToAnswer(prayer);
|
||||
return (
|
||||
<li className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4">
|
||||
{mode === 'edit' ? (
|
||||
<div>
|
||||
<textarea aria-label="Edit prayer" value={text} onChange={(e) => setText(e.target.value)} rows={3} maxLength={2000} className={`${field} w-full`} />
|
||||
<div className="mt-2 flex gap-3 text-xs">
|
||||
<button type="button" onClick={() => { if (text.trim()) actions.edit(prayer.id, { text: text.trim() }); setMode(null); }} className="font-medium text-[var(--r-accent)] hover:underline">Save</button>
|
||||
<button type="button" onClick={() => setMode(null)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{prayer.forWhom && <p className="text-xs font-medium uppercase tracking-wide text-[var(--r-sub)]">For {prayer.forWhom}</p>}
|
||||
<p className="whitespace-pre-wrap text-[15px] leading-relaxed">{prayer.text}</p>
|
||||
<VerseChips verses={prayer.verses} onOpen={onOpenVerse} />
|
||||
</div>
|
||||
)}
|
||||
{prayer.answeredAt && (
|
||||
<div className="mt-3 rounded-xl bg-[var(--r-bg)] px-3 py-2 text-sm" role="note">
|
||||
<p className="text-xs font-medium text-[var(--r-accent)]">Answered {new Date(prayer.answeredAt).toLocaleDateString()}{days != null ? ` · after ${days} day${days === 1 ? '' : 's'}` : ''}</p>
|
||||
{prayer.answerNote && <p className="mt-0.5 whitespace-pre-wrap">{prayer.answerNote}</p>}
|
||||
</div>
|
||||
)}
|
||||
{mode === 'answer' && (
|
||||
<div className="mt-3">
|
||||
<textarea aria-label="How was it answered?" autoFocus value={note} onChange={(e) => setNote(e.target.value)} rows={2} maxLength={1000} placeholder="How was it answered? (optional)" className={`${field} w-full`} />
|
||||
<div className="mt-2 flex gap-3 text-xs">
|
||||
<button type="button" onClick={() => { actions.answer(prayer.id, note); setMode(null); }} className="font-medium text-[var(--r-accent)] hover:underline">Mark answered</button>
|
||||
<button type="button" onClick={() => setMode(null)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
{!prayer.answeredAt && (
|
||||
<button type="button" onClick={() => actions.toggle(prayer.id)} aria-pressed={prayedToday} className={`${small} ${prayedToday ? 'border-[var(--r-accent)] text-[var(--r-accent)]' : ''}`}>
|
||||
{prayedToday ? '✓ Prayed today' : 'I prayed'}
|
||||
</button>
|
||||
)}
|
||||
{prayer.prayedOn.length > 0 && <span className="text-xs text-[var(--r-sub)]">Prayed on {prayer.prayedOn.length} day{prayer.prayedOn.length === 1 ? '' : 's'}</span>}
|
||||
<span className="ml-auto flex gap-2">
|
||||
{prayer.answeredAt
|
||||
? <button type="button" onClick={() => actions.reopen(prayer.id)} className={small}>Pray again</button>
|
||||
: <button type="button" onClick={() => { setNote(''); setMode('answer'); }} className={small}>Answered</button>}
|
||||
<button type="button" onClick={() => { setText(prayer.text); setMode('edit'); }} className={small}>Edit</button>
|
||||
<button type="button" onClick={() => actions.remove(prayer.id)} className={`${small} text-[var(--r-danger,#b42318)]`}>Delete</button>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PrayerPage() {
|
||||
const { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, prayerDraft, clearPrayerDraft, jumpToReaderVerse, openBibleReader } = useApp();
|
||||
const { toast } = useToast();
|
||||
const [tab, setTab] = useState('open');
|
||||
const [query, setQuery] = useState('');
|
||||
const [form, setForm] = useState({ text: '', forWhom: '', verses: '' });
|
||||
const today = localDateString();
|
||||
|
||||
// "Pray about this verse" from the Reader arrives with the verse filled in
|
||||
useEffect(() => {
|
||||
if (prayerDraft) { setForm((f) => ({ ...f, verses: prayerDraft.label })); clearPrayerDraft(); }
|
||||
}, [prayerDraft, clearPrayerDraft]);
|
||||
|
||||
const parsed = useMemo(() => parseVerses(form.verses), [form.verses]);
|
||||
const shown = filterPrayers(prayers, { tab, query });
|
||||
const counts = { open: filterPrayers(prayers, { tab: 'open' }).length, answered: filterPrayers(prayers, { tab: 'answered' }).length };
|
||||
|
||||
const add = (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.text.trim()) return;
|
||||
addPrayer({ text: form.text, forWhom: form.forWhom, verses: parsed.verses });
|
||||
setForm({ text: '', forWhom: '', verses: '' });
|
||||
setTab('open');
|
||||
};
|
||||
const actions = {
|
||||
edit: editPrayer, toggle: togglePrayed, reopen: reopenPrayer,
|
||||
answer: (id, note) => { answerPrayer(id, note); toast('Marked answered. Thank God for it.'); },
|
||||
remove: (id) => { const undo = deletePrayer(id); toast('Prayer deleted', { action: { label: 'Undo', onClick: undo } }); },
|
||||
};
|
||||
const openVerse = (book, chapter, verse) => { jumpToReaderVerse(book, chapter, verse); openBibleReader(); };
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(new Blob([prayersToMarkdown(prayers)], { type: 'text/markdown;charset=utf-8' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'prayer-journal.md';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell title="Prayer journal" active="prayer" maxWidth="max-w-3xl">
|
||||
<main className="mx-auto max-w-3xl space-y-5 px-4 py-6">
|
||||
<form onSubmit={add} className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4" aria-label="Add a prayer">
|
||||
<label className="block text-sm font-medium">What are you praying about?
|
||||
<textarea value={form.text} onChange={(e) => setForm((f) => ({ ...f, text: e.target.value }))} rows={3} maxLength={2000} className={`${field} mt-1 w-full`} placeholder="A request, a thanks, someone to keep in prayer…" />
|
||||
</label>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<label className="block text-xs text-[var(--r-sub)]">For (optional)
|
||||
<input type="text" value={form.forWhom} onChange={(e) => setForm((f) => ({ ...f, forWhom: e.target.value }))} maxLength={80} placeholder="e.g. Sam, my church" className={`${field} mt-1 w-full`} />
|
||||
</label>
|
||||
<label className="block text-xs text-[var(--r-sub)]">Verses (optional)
|
||||
<input type="text" value={form.verses} onChange={(e) => setForm((f) => ({ ...f, verses: e.target.value }))} placeholder="e.g. Philippians 4:6, Psalm 23" className={`${field} mt-1 w-full`} />
|
||||
</label>
|
||||
</div>
|
||||
{(parsed.verses.length > 0 || parsed.unknown.length > 0) && (
|
||||
<p className="mt-2 text-xs text-[var(--r-sub)]">
|
||||
{parsed.verses.length > 0 && <>Linked: {parsed.verses.map(labelFor).join(' · ')}. </>}
|
||||
{parsed.unknown.length > 0 && <span className="text-[var(--r-danger,#b42318)]">Couldn’t read: {parsed.unknown.join(', ')}.</span>}
|
||||
</p>
|
||||
)}
|
||||
<button type="submit" disabled={!form.text.trim()} className="mt-3 rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">Add to my journal</button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div role="tablist" aria-label="Which prayers" className="flex gap-1.5">
|
||||
<button type="button" role="tab" aria-selected={tab === 'open'} onClick={() => setTab('open')} className={tabBtn(tab === 'open')}>Praying ({counts.open})</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'answered'} onClick={() => setTab('answered')} className={tabBtn(tab === 'answered')}>Answered ({counts.answered})</button>
|
||||
<button type="button" role="tab" aria-selected={tab === 'all'} onClick={() => setTab('all')} className={tabBtn(tab === 'all')}>All</button>
|
||||
</div>
|
||||
<input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search prayers…" aria-label="Search prayers" className={`${field} ml-auto w-48 py-1.5`} />
|
||||
{live(prayers).length > 0 && <button type="button" onClick={download} className={small}>Download</button>}
|
||||
</div>
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<p className="rounded-2xl border border-dashed border-[var(--r-line)] p-8 text-center text-sm text-[var(--r-sub)]">
|
||||
{live(prayers).length === 0 ? 'Nothing here yet. Write down something you’re praying about, and come back to mark how God answers.'
|
||||
: query ? 'No prayers match that search.' : tab === 'answered' ? 'No answered prayers yet.' : 'Nothing open right now.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3" aria-label="Prayers">
|
||||
{shown.map((p) => <PrayerCard key={p.id} prayer={p} today={today} actions={actions} onOpenVerse={openVerse} />)}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
import { AppContext } from '../context/AppContext.js';
|
||||
import { ToastProvider } from '../components/Toasts.jsx';
|
||||
import PrayerPage from './PrayerPage.jsx';
|
||||
import { markAnswered, newPrayer } from '../utils/prayers.js';
|
||||
|
||||
vi.mock('../components/study/PageShell.jsx', () => ({ default: ({ children }) => <div>{children}</div> }));
|
||||
|
||||
const T0 = Date.UTC(2026, 9, 1);
|
||||
function setup(initial = [], extra = {}) {
|
||||
const ctx = {
|
||||
prayers: initial, addPrayer: vi.fn(), editPrayer: vi.fn(), togglePrayed: vi.fn(), answerPrayer: vi.fn(), reopenPrayer: vi.fn(), deletePrayer: vi.fn(() => vi.fn()),
|
||||
prayerDraft: null, clearPrayerDraft: vi.fn(), jumpToReaderVerse: vi.fn(), openBibleReader: vi.fn(), ...extra,
|
||||
};
|
||||
render(<AppContext.Provider value={ctx}><ToastProvider><PrayerPage /></ToastProvider></AppContext.Provider>);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
test('a prayer is added with who it is for and the verses it is tied to', async () => {
|
||||
const user = userEvent.setup();
|
||||
const ctx = setup();
|
||||
const form = screen.getByRole('form', { name: /add a prayer/i });
|
||||
await user.type(within(form).getByLabelText(/what are you praying about/i), 'Peace at home');
|
||||
await user.type(within(form).getByLabelText(/for \(optional\)/i), 'Family');
|
||||
await user.type(within(form).getByLabelText(/verses/i), 'Philippians 4:6, nonsense words');
|
||||
expect(within(form).getByText(/Linked: Philippians 4:6/)).toBeInTheDocument();
|
||||
expect(within(form).getByText(/Couldn’t read: nonsense words/)).toBeInTheDocument();
|
||||
await user.click(within(form).getByRole('button', { name: /add to my journal/i }));
|
||||
expect(ctx.addPrayer).toHaveBeenCalledWith({ text: 'Peace at home', forWhom: 'Family', verses: [expect.objectContaining({ book: 'PHP', chapter: 4, verse: 6 })] });
|
||||
});
|
||||
|
||||
test('praying and answered lists are separate, with counts; a verse chip opens the Bible', async () => {
|
||||
const open = { ...newPrayer({ text: 'Wisdom at work', verses: [{ book: 'JAS', chapter: 1, verse: 5, label: 'James 1:5' }] }, T0) };
|
||||
const done = markAnswered(newPrayer({ text: 'A new job' }, T0), 'Hired!', T0 + 3 * 86_400_000);
|
||||
const user = userEvent.setup();
|
||||
const ctx = setup([open, done]);
|
||||
expect(screen.getByRole('tab', { name: 'Praying (1)' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Wisdom at work')).toBeInTheDocument();
|
||||
expect(screen.queryByText('A new job')).toBeNull();
|
||||
await user.click(screen.getByRole('button', { name: 'James 1:5' }));
|
||||
expect(ctx.jumpToReaderVerse).toHaveBeenCalledWith('JAS', 1, 5);
|
||||
expect(ctx.openBibleReader).toHaveBeenCalled();
|
||||
await user.click(screen.getByRole('tab', { name: 'Answered (1)' }));
|
||||
expect(screen.getByText('A new job')).toBeInTheDocument();
|
||||
expect(screen.getByText(/after 3 days/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Hired!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a prayer can be marked answered with a note, prayed for today, and deleted with undo', async () => {
|
||||
const p = newPrayer({ text: 'Healing' }, T0);
|
||||
const user = userEvent.setup();
|
||||
const ctx = setup([p]);
|
||||
await user.click(screen.getByRole('button', { name: 'I prayed' }));
|
||||
expect(ctx.togglePrayed).toHaveBeenCalledWith(p.id);
|
||||
await user.click(screen.getByRole('button', { name: 'Answered' }));
|
||||
await user.type(screen.getByLabelText('How was it answered?'), 'Better each day');
|
||||
await user.click(screen.getByRole('button', { name: 'Mark answered' }));
|
||||
expect(ctx.answerPrayer).toHaveBeenCalledWith(p.id, 'Better each day');
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
expect(ctx.deletePrayer).toHaveBeenCalledWith(p.id);
|
||||
expect(await screen.findByRole('button', { name: 'Undo' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a verse sent from the Reader fills in the verse box; an empty journal explains itself', () => {
|
||||
const ctx = setup([], { prayerDraft: { label: 'John 3:16' } });
|
||||
expect(screen.getByLabelText(/verses/i)).toHaveValue('John 3:16');
|
||||
expect(ctx.clearPrayerDraft).toHaveBeenCalled();
|
||||
expect(screen.getByText(/Nothing here yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('search narrows the list', async () => {
|
||||
const a = newPrayer({ text: 'Rain for the farm' }, T0);
|
||||
const b = newPrayer({ text: 'Peace at home' }, T0 + 5);
|
||||
setup([a, b]);
|
||||
await userEvent.setup().type(screen.getByLabelText('Search prayers'), 'rain');
|
||||
expect(screen.getByText('Rain for the farm')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Peace at home')).toBeNull();
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
// The reading side of the app: a distraction-free page with slim chrome. Every
|
||||
// tool (navigation, search, settings, audio, verse study) lives in a sheet.
|
||||
import { buildVerseIndex, studiesForVerse } from '../utils/studyVerseIndex.js';
|
||||
import { loadProjectById } from '../utils/projectStorage.js';
|
||||
import { placeHighlights, isWholeVerse } from '../utils/highlightSpans.js';
|
||||
import { useState, useEffect, useRef, useMemo, useDeferredValue, useCallback } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
@@ -78,7 +80,8 @@ export default function ReaderPage() {
|
||||
handleSeekAudioTo,
|
||||
readerInkByPage,
|
||||
updateReaderPageInk,
|
||||
readerTextHighlights,
|
||||
readerTextHighlights, readerBookmarkNotes, setBookmarkNote, setHighlightNote,
|
||||
projectIndex, openStudyChunk, startPrayerFor,
|
||||
addTextHighlight,
|
||||
removeTextHighlight,
|
||||
restoreTextHighlight,
|
||||
@@ -428,6 +431,10 @@ export default function ReaderPage() {
|
||||
|
||||
const selectedVerse = readerSelectedVerse != null ? readerVerses.find((v) => v.number === readerSelectedVerse) : null;
|
||||
const verseHighlights = selectedVerse ? (readerTextHighlights || []).filter((h) => h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === selectedVerse.number) : [];
|
||||
// Your studies that cover the selected verse (looked up only while a verse is selected, and again when your studies change)
|
||||
const studyIndexKey = (projectIndex ?? []).map((e) => `${e.id}:${e.lastEdited}`).join('|');
|
||||
const verseIndex = useMemo(() => (selectedVerse ? buildVerseIndex(projectIndex ?? [], loadProjectById) : []), [selectedVerse != null, studyIndexKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const studiesHere = selectedVerse ? studiesForVerse(verseIndex, readerBookAbbrev, readerChapter, selectedVerse.number) : [];
|
||||
const selectedKey = selectedVerse ? `${readerBookAbbrev}-${readerChapter}-${selectedVerse.number}` : null;
|
||||
const title = `${readerBook?.name ?? ''} ${readerChapter} · ${readerTranslation}`;
|
||||
const audio = {
|
||||
@@ -568,6 +575,8 @@ export default function ReaderPage() {
|
||||
crossRefsLoading={readerCrossRefsLoading}
|
||||
onLoadCrossRefs={() => { if (!readerCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
|
||||
bookmarkColor={readerBookmarks[selectedKey]}
|
||||
studiesHere={studiesHere} onOpenStudy={openStudyChunk} onPray={startPrayerFor}
|
||||
bookmarkNote={readerBookmarkNotes?.[selectedKey] ?? ''} onSetBookmarkNote={(text) => setBookmarkNote(selectedKey, text)} onSetHighlightNote={setHighlightNote}
|
||||
onToggleBookmark={() => (readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey))}
|
||||
onCycleColor={() => cycleBookmarkColor(selectedKey)}
|
||||
onJump={jump}
|
||||
@@ -640,6 +649,7 @@ export default function ReaderPage() {
|
||||
onJump={jump}
|
||||
onRemoveBookmark={(e) => removeBookmark(e.key, e.color)}
|
||||
onRemoveHighlight={(h) => removeHighlight(h.id)}
|
||||
bookmarkNotes={readerBookmarkNotes ?? {}} onSetHighlightNote={setHighlightNote} onSetBookmarkNote={setBookmarkNote}
|
||||
/>
|
||||
<AudioSheet
|
||||
open={sheet === 'audio'} onClose={closeSheet}
|
||||
|
||||
@@ -87,6 +87,8 @@ export default function SettingsPage() {
|
||||
reminderTime: authUser?.reminderTime ?? '07:00',
|
||||
votdEnabled: authUser?.votdEnabled ?? false,
|
||||
votdTime: authUser?.votdTime ?? '06:30',
|
||||
prayerEnabled: authUser?.prayerEnabled ?? false,
|
||||
prayerTime: authUser?.prayerTime ?? '20:00',
|
||||
});
|
||||
const [reminderBusy, setReminderBusy] = useState(false);
|
||||
const [reminderError, setReminderError] = useState('');
|
||||
@@ -428,6 +430,18 @@ export default function SettingsPage() {
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[var(--r-line)] p-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
|
||||
<input type="checkbox" checked={reminderForm.prayerEnabled} onChange={(e) => setReminderForm((f) => ({ ...f, prayerEnabled: e.target.checked }))} className="h-4 w-4 accent-[var(--r-accent)]" />
|
||||
Daily prayer reminder
|
||||
</label>
|
||||
<label className="mt-2 block max-w-xs text-sm font-medium text-[var(--r-text)]">
|
||||
Remind me at
|
||||
<input type="time" value={reminderForm.prayerTime} onChange={(e) => setReminderForm((f) => ({ ...f, prayerTime: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||
</label>
|
||||
<span className="mt-1 block text-xs text-[var(--r-sub)]">A notification to the same topic listing a few of the requests in your prayer journal. Nothing is sent when there are no open requests.</span>
|
||||
</div>
|
||||
{authUser?.votdAvailable && (
|
||||
<div className="rounded-xl border border-[var(--r-line)] p-3">
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
|
||||
|
||||
@@ -103,6 +103,12 @@ export async function disableSharing(id) {
|
||||
return request('DELETE', `/projects/${id}/share`);
|
||||
}
|
||||
|
||||
// ---- earlier versions of a study ----
|
||||
export async function listStudyVersions(id) { return request('GET', `/projects/${id}/versions`); }
|
||||
export async function getStudyVersion(id, versionId) { return request('GET', `/projects/${id}/versions/${versionId}`); }
|
||||
/** Puts an earlier version back; returns the study as it now is. */
|
||||
export async function restoreStudyVersion(id, versionId) { return request('POST', `/projects/${id}/versions/${versionId}/restore`); }
|
||||
|
||||
// ---- co-teachers: other accounts that may edit a study ----
|
||||
export async function getCollaborators(id) { return request('GET', `/projects/${id}/collaborators`); }
|
||||
/** A single-use link (valid 7 days) that lets whoever opens it, signed in, edit this study. -> { token, expiresAt } */
|
||||
@@ -222,6 +228,11 @@ export async function saveRemoteMarks(marks) {
|
||||
return request('PUT', '/reader/marks', marks);
|
||||
}
|
||||
|
||||
/** The prayer journal: { prayers: [...] }. */
|
||||
export async function getRemotePrayers() { return request('GET', '/prayers'); }
|
||||
/** Merges this device's prayers into the account's (the newer version of each wins); returns the combined list. */
|
||||
export async function saveRemotePrayers(prayers) { return request('PUT', '/prayers', { prayers }); }
|
||||
|
||||
/** What the person has read: { chapters, days }. */
|
||||
export async function getRemoteHistory() { return request('GET', '/reader/history'); }
|
||||
/** Merges this device's reading history into the account's; returns the combined history. */
|
||||
|
||||
+7
-3
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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 }] }));
|
||||
}
|
||||
|
||||
@@ -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*');
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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 ?? '');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
Reference in new issue
Block a user