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

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

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

No files matched your search

+57 -2
View File
@@ -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) {