Compare commits
43
Commits
No files matched your search
+10
@@ -11,3 +11,13 @@ coverage/
|
||||
.api.log
|
||||
.DS_Store
|
||||
.env
|
||||
.session-secret
|
||||
server/data/backups/
|
||||
|
||||
# Playwright
|
||||
e2e/.auth.json
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# a local demo video, not part of the app
|
||||
demo/
|
||||
@@ -42,6 +42,18 @@ http://localhost:5173. Dev mode is much slower, so don't use it for real users.
|
||||
|
||||
---
|
||||
|
||||
## Optional keys (set in `.env` or the environment)
|
||||
|
||||
| Variable | What it turns on |
|
||||
| --- | --- |
|
||||
| `SESSION_SECRET` | A long random string that signs login cookies. Optional: if unset in production, the server makes one and keeps it in `DATA_DIR/.session-secret`. |
|
||||
| `ADMIN_EMAIL` | The one admin account. |
|
||||
| `ESV_API_KEY`, `API_BIBLE_KEY` | ESV, NLT, CSB and NASB in the Reader. |
|
||||
| `IQBIBLE_API_KEY` | IQ Bible: more commentaries, lexicons (BDB, LSJ, Abbott-Smith), places, topics, people, textual notes, Gospel parallels, daily verse and Spurgeon devotional, plan calendar export, more audio narrations, and background for podcast prompts. Hidden when unset. |
|
||||
| `IQBIBLE_CACHE_HOURS` | Leave unset unless IQ Bible has given written permission to cache non-verse data; only verse/chapter text is cached by default (24h), as their terms require. |
|
||||
| `BACKUP_KEEP`, `BACKUP_DISABLED=1` | The server saves a snapshot of the whole database once a day to `DATA_DIR/backups` (default: keep 7). Restore with `scripts/restore-data.sh`. |
|
||||
| `ALLOW_PRIVATE_NTFY=1` | Lets reading reminders use an ntfy server on your own network (blocked by default). |
|
||||
|
||||
## Production Install (Ubuntu / systemd)
|
||||
|
||||
Installs the app as a persistent systemd service under `/opt/study-app`.
|
||||
@@ -134,3 +146,24 @@ See [scripts/backup-data.sh](scripts/backup-data.sh) and [scripts/restore-data.s
|
||||
## License
|
||||
|
||||
Private / personal use.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run test:run # all unit and component tests
|
||||
npm run test:e2e # real-browser tests (Playwright, in your installed Chrome)
|
||||
npm run lint # ESLint (errors fail; warnings are advice)
|
||||
```
|
||||
|
||||
The browser tests (`e2e/`) build the app, run it on port 3101 with an empty throwaway database (your real studies are never
|
||||
touched), make an account, and click through the main flows: writing notes and the paste check, the daily five minutes and weekly
|
||||
recall, comparing translations (Bible text is faked, so no internet is needed), and presenting on two screens. They use the
|
||||
Chrome already on the computer, so there is no browser to download; install Chrome, or change `channel` in `playwright.config.js`
|
||||
and run `npx playwright install` to use Playwright's own browser.
|
||||
|
||||
Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created.
|
||||
|
||||
Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic).
|
||||
|
||||
Forgotten passwords: the sign-in page's "Forgot your password?" sends a one-time code to the account's ntfy topic (the one used for reading reminders). Accounts without a topic still ask the administrator for a temporary password.
|
||||
@@ -0,0 +1,31 @@
|
||||
# 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.
|
||||
- [x] **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.
|
||||
- [x] **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 `npm run test:run`, `npm run lint` and `npm run test:e2e` on the Gitea server for every push.
|
||||
The real-browser tests now exist (`e2e/`, run by hand); what's left is running them automatically, and adding sign-in
|
||||
and cross-device sync to them.
|
||||
- [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, 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.
|
||||
- [x] **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.
|
||||
@@ -0,0 +1,12 @@
|
||||
// Makes one account in the throwaway database and keeps its sign-in for the other tests.
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
setup('create an account and sign in', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /need an account\? sign up/i }).click();
|
||||
await page.locator('input[type="email"]').fill('e2e@example.test');
|
||||
await page.locator('input[type="password"]').fill('E2e-Test-Password-7391');
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'My Studies' })).toBeVisible();
|
||||
await page.context().storageState({ path: 'e2e/.auth.json' });
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { resumeButton, seedStudy } from './helpers.js';
|
||||
|
||||
// Fake the free Bible text so the test needs no internet: every translation answers with its own wording.
|
||||
const WORDING = {
|
||||
BSB: { 16: 'For God so loved the world that He gave His one and only Son', 17: 'For God did not send His Son into the world to condemn the world', 18: 'Whoever believes in Him is not condemned' },
|
||||
NET: { 16: 'For this is the way God loved the world: He gave his one and only Son', 17: 'For God did not send his Son into the world to condemn the world', 18: 'The one who believes in him is not condemned' },
|
||||
};
|
||||
|
||||
test('compare translations with differences highlighted, a verse note, and the notes boxes', async ({ page }) => {
|
||||
await page.route('https://bible.helloao.org/api/**', async (route) => {
|
||||
const id = route.request().url().match(/api\/([^/]+)\//)[1].replace('eng_', '').toUpperCase();
|
||||
const words = WORDING[id] ?? WORDING.BSB;
|
||||
await route.fulfill({ json: { verses: Object.entries(words).map(([number, text]) => ({ number: Number(number), text })) } });
|
||||
});
|
||||
const { title } = await seedStudy(page);
|
||||
await resumeButton(page, title).click();
|
||||
await page.getByRole('button', { name: /compare…/i }).click();
|
||||
await page.getByRole('tab', { name: 'Translations' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: /compare/i });
|
||||
await expect(dialog.getByText('Berean Standard Bible')).toBeVisible();
|
||||
await expect(dialog.getByText('NET Bible', { exact: true })).toBeVisible();
|
||||
await expect(dialog.locator('mark').first()).toBeVisible(); // words that differ are marked
|
||||
await dialog.getByLabel(/highlight the words that differ/i).uncheck();
|
||||
await expect(dialog.locator('mark')).toHaveCount(0);
|
||||
|
||||
// a note on one verse, and the Observe / Interpret / Apply boxes edit the same notes as the Study page
|
||||
await dialog.getByRole('textbox', { name: 'Note on verse 16' }).fill('NET says "the way" where BSB says "so"');
|
||||
await expect(dialog.getByRole('textbox', { name: 'Interpret' })).toHaveValue(/not a slogan/);
|
||||
await dialog.getByRole('textbox', { name: 'Apply' }).fill('Decide one thing to trust him with.');
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await page.getByRole('button', { name: 'Your notes' }).first().click(); // keep both halves tidy
|
||||
await page.keyboard.press('3');
|
||||
await expect(page.getByRole('textbox', { name: 'Apply' })).toHaveValue('Decide one thing to trust him with.');
|
||||
await expect(page.getByRole('textbox', { name: 'Verse 16 note' })).toHaveValue(/the way/);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// Shared test helpers: a ready-made study so tests don't depend on downloading Bible text.
|
||||
const DAY = 86400000;
|
||||
const verses = [
|
||||
{ number: 16, text: 'For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life.' },
|
||||
{ number: 17, text: 'For God sent not his Son into the world to condemn the world; but that the world through him might be saved.' },
|
||||
{ number: 18, text: 'He that believeth on him is not condemned: but he that believeth not is condemned already.' },
|
||||
{ number: 19, text: 'And this is the condemnation, that light is come into the world, and men loved darkness rather than light, because their deeds were evil.' },
|
||||
];
|
||||
export const NOTE_TEXT = {
|
||||
observation: 'Jesus is speaking to Nicodemus at night. The verse starts with "for", so it explains the previous verses about the serpent lifted up. God is the one who acts: he loved, he gave.',
|
||||
interpretation: 'In its setting this is not a slogan but the reason the cross happens. The world is the object of love, not just the good in it. Giving the Son points back to Moses lifting the serpent.',
|
||||
application: 'I do not have to earn being loved before I trust him. This week I want to stop treating belief as a feeling and decide one concrete thing to trust him with.',
|
||||
};
|
||||
export const SPEAKER_NOTE = 'PRIVATE: open with the story about Nicodemus at night, then pause after perish.';
|
||||
|
||||
// Writes a two-chunk John 3 study (edited two weeks ago, so a weekly recall is due) into this browser's storage
|
||||
// for the signed-in account, then reloads.
|
||||
export async function seedStudy(page, { id = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}`, withOutline = false } = {}) {
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => Object.keys(localStorage).some((k) => /^bible-study-index:.+:known$/.test(k)));
|
||||
await page.evaluate(({ id, verses, notes, speakerNote, withOutline, DAY }) => {
|
||||
const uid = Object.keys(localStorage).find((k) => /^bible-study-index:.+:known$/.test(k)).split(':')[1];
|
||||
const now = Date.now();
|
||||
const chunk = (cid, s, e, extra) => ({ id: cid, startVerse: s, endVerse: e, spilloverEndVerse: null, observation: '', interpretation: '', application: '', crossReferences: [], greekWords: [], generalNotes: '', episodeNumber: '', episodeTitle: '', finalScript: '', tags: [], editedAt: now - 14 * DAY, ...extra });
|
||||
const node = (nid, text, extra = {}) => ({ id: nid, text, ref: '', chunkId: null, note: '', kind: '', minutes: null, scripture: null, attachments: [], children: [], ...extra });
|
||||
const a = chunk(`${id}-a`, 16, 18, { ...notes });
|
||||
const b = chunk(`${id}-b`, 19, 19, {});
|
||||
// a title of its own: studies from earlier tests sync back through the account, so titles must not repeat
|
||||
const title = `John 3 (${id.slice(-6)})`;
|
||||
const outline = withOutline ? [node('n1', 'God so loved', { minutes: 5, note: speakerNote, ref: 'John 3:16', scripture: { text: verses[0].text, ref: 'John 3:16' } }), node('n2', 'Light and darkness', { minutes: 5 })] : [];
|
||||
const project = { id, title, translation: 'BSB', lastEdited: now - 2 * DAY, selectedChunkId: a.id, outline, chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses, chunks: [a, b] }] };
|
||||
localStorage.setItem(`bible-study-project:${uid}:${id}`, JSON.stringify(project));
|
||||
const index = JSON.parse(localStorage.getItem(`bible-study-index:${uid}`) || '[]').filter((e) => e.id !== id);
|
||||
index.push({ id, title: project.title, lastEdited: project.lastEdited, chapterSummary: 'John 3', series: '', tags: [] });
|
||||
localStorage.setItem(`bible-study-index:${uid}`, JSON.stringify(index));
|
||||
}, { id, verses, notes: NOTE_TEXT, speakerNote: SPEAKER_NOTE, withOutline, DAY });
|
||||
await page.reload();
|
||||
return { id, title: `John 3 (${id.slice(-6)})` };
|
||||
}
|
||||
|
||||
// The Resume button on one study's card on Home
|
||||
export const resumeButton = (page, title) => page.getByRole('heading', { name: title }).locator('xpath=ancestor::div[contains(@class,"rounded-2xl")][1]').getByRole('button', { name: 'Resume' });
|
||||
|
||||
// Fakes a long paste into a note box (Playwright can't use the real clipboard in every setup)
|
||||
export async function pasteInto(locator, text) {
|
||||
await locator.evaluate((el, value) => {
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text', value);
|
||||
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
||||
}, text);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { pasteInto, seedStudy } from './helpers.js';
|
||||
|
||||
test('the daily five minutes blocks pasting and saves to the note; the weekly recall rates and records', async ({ page }) => {
|
||||
await seedStudy(page);
|
||||
|
||||
// daily five minutes
|
||||
await expect(page.getByText(/your five minutes:/i)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Start' }).first().click();
|
||||
const box = page.getByRole('textbox', { name: /your five minutes/i });
|
||||
await expect(page.getByRole('timer')).toContainText('4:');
|
||||
await pasteInto(box, 'text from somewhere else');
|
||||
await expect(page.getByText(/pasting is off here/i)).toBeVisible();
|
||||
await expect(box).toHaveValue('');
|
||||
await box.fill('Three things I notice in the passage.');
|
||||
await page.getByRole('button', { name: /save to my notes/i }).click();
|
||||
await expect(page.getByText(/saved to your notes/i)).toBeVisible();
|
||||
|
||||
// weekly recall: write from memory, compare with the notes, rate it
|
||||
await page.getByRole('button', { name: 'Start' }).click();
|
||||
await page.getByRole('textbox', { name: 'What you remember' }).fill('God loved the world and gave his Son so that believers would not perish.');
|
||||
await page.getByRole('button', { name: 'Show my notes' }).click();
|
||||
await expect(page.getByText('Your notes', { exact: true })).toBeVisible();
|
||||
await page.getByRole('textbox', { name: 'What slipped' }).fill('who Jesus was talking to');
|
||||
await page.getByRole('button', { name: 'Partly' }).click();
|
||||
await expect(page.getByText(/recall saved/i)).toBeVisible();
|
||||
|
||||
// it shows in the history and in Insights
|
||||
await page.getByRole('button', { name: /recall history/i }).click();
|
||||
await expect(page.getByText('Partly', { exact: true }).first()).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Insights' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Insights' })).toBeVisible();
|
||||
await expect(page.getByText('who Jesus was talking to')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { SPEAKER_NOTE, resumeButton, seedStudy } from './helpers.js';
|
||||
|
||||
test('presenting on two screens: the audience window never shows the speaker notes', async ({ page, context }) => {
|
||||
const { title } = await seedStudy(page, { withOutline: true });
|
||||
await resumeButton(page, title).click();
|
||||
await page.getByRole('navigation', { name: 'Study stages' }).getByRole('button', { name: 'Outline' }).click();
|
||||
await page.getByRole('button', { name: 'Present' }).click();
|
||||
const presenter = page.getByRole('dialog', { name: /presenting/i });
|
||||
|
||||
// one screen: notes stay hidden until turned on
|
||||
await expect(presenter.getByRole('heading', { name: /God so loved/ })).toBeVisible();
|
||||
await expect(presenter.getByText(SPEAKER_NOTE)).toHaveCount(0);
|
||||
|
||||
// a second window (the projector) connects and follows along
|
||||
const audience = await context.newPage();
|
||||
await audience.goto('/?audience=1');
|
||||
await expect(audience.getByRole('heading', { name: /God so loved/ })).toBeVisible();
|
||||
await expect(presenter.getByText('Only you see this')).toBeVisible();
|
||||
await expect(presenter.getByText(SPEAKER_NOTE)).toBeVisible(); // on the presenter's screen only
|
||||
await expect(audience.getByText(SPEAKER_NOTE)).toHaveCount(0);
|
||||
expect(await audience.locator('body').innerText()).not.toMatch(/PRIVATE|On pace|Start/);
|
||||
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toBeVisible();
|
||||
|
||||
// B blacks the audience screen out, and again brings it back
|
||||
await page.keyboard.press('b');
|
||||
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toHaveCount(0);
|
||||
await page.keyboard.press('b');
|
||||
await expect(audience.getByRole('heading', { name: /Light and darkness/ })).toBeVisible();
|
||||
|
||||
// closing the presentation tells the audience window
|
||||
await presenter.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(audience.getByText(/presentation has ended/i)).toBeVisible();
|
||||
});
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the app and runs the server on port 3101 with a fresh, empty data folder (so tests never touch real studies).
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
export DATA_DIR="$(mktemp -d)"
|
||||
export PORT=3101
|
||||
export NODE_ENV=production
|
||||
npx vite build --logLevel error
|
||||
exec node server/index.js
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { NOTE_TEXT, pasteInto, resumeButton, seedStudy } from './helpers.js';
|
||||
|
||||
test('open a study, write notes, answer the paste check, and use the keyboard', async ({ page }) => {
|
||||
const { title } = await seedStudy(page);
|
||||
await expect(page.getByRole('heading', { name: title })).toBeVisible();
|
||||
await expect(page.getByText(/needs a connection for john/i).first()).toBeVisible(); // the offline badge
|
||||
await resumeButton(page, title).click();
|
||||
await expect(page.getByRole('heading', { name: 'John 3:16–18' })).toBeVisible();
|
||||
|
||||
// keyboard: 2 jumps into Interpret, Escape leaves it
|
||||
await page.keyboard.press('2');
|
||||
await expect(page.getByRole('textbox', { name: 'Interpret' })).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByRole('textbox', { name: 'Interpret' })).not.toBeFocused();
|
||||
|
||||
// a long paste asks whether it is from AI; "No, I wrote it" counts the words as yours
|
||||
await page.keyboard.press('1');
|
||||
const observe = page.getByRole('textbox', { name: 'Observe' });
|
||||
const pasted = Array.from({ length: 24 }, (_, i) => `pasted${i}`).join(' ');
|
||||
await pasteInto(observe, pasted);
|
||||
await observe.fill(`${NOTE_TEXT.observation} ${pasted}`);
|
||||
await expect(page.getByText(/is this from ai\?/i)).toBeVisible();
|
||||
await expect(page.getByText(/24 words here were pasted/i)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'No, I wrote it' }).click();
|
||||
await expect(page.getByText(/is this from ai\?/i)).toHaveCount(0);
|
||||
await expect(page.getByText(/were pasted/i)).toHaveCount(0);
|
||||
|
||||
// either half of the right column can be folded
|
||||
const resources = page.getByRole('button', { name: /commentary & resources/i });
|
||||
await resources.click();
|
||||
await expect(resources).toHaveAttribute('aria-expanded', 'false');
|
||||
await page.getByRole('button', { name: 'Your notes' }).click(); // folding the last open half opens the other
|
||||
await expect(resources).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// the reading list belongs to the study
|
||||
await page.getByRole('button', { name: 'Your notes' }).click(); // make room: fold notes, resources stay open
|
||||
await page.getByRole('tab', { name: /reading list/i }).click();
|
||||
await page.getByRole('textbox', { name: /add to the reading list/i }).fill('Carson on John');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('checkbox', { name: /read: carson on john/i }).check();
|
||||
await expect(page.getByRole('checkbox', { name: /read: carson on john/i })).toBeChecked();
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist/**', 'coverage/**', 'node_modules/**', 'server/data/**', 'public/**'] },
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: { ...globals.browser },
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
plugins: { 'react-hooks': reactHooks },
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'no-unused-vars': ['warn', { varsIgnorePattern: '^_|^[A-Z]', argsIgnorePattern: '^_', caughtErrors: 'none' }],
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['server/**/*.js', 'scripts/**/*.mjs', 'vite.config.js', 'playwright.config.js', 'eslint.config.js', 'postcss.config.js', 'tailwind.config.js'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node } },
|
||||
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }], 'no-empty': ['error', { allowEmptyCatch: true }] },
|
||||
},
|
||||
{
|
||||
// Playwright tests run in Node but hand functions to the browser page, which use browser globals
|
||||
files: ['e2e/**/*.js'],
|
||||
languageOptions: { ecmaVersion: 'latest', sourceType: 'module', globals: { ...globals.node, ...globals.browser } },
|
||||
rules: { 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrors: 'none' }] },
|
||||
},
|
||||
{
|
||||
files: ['**/*.test.{js,jsx}', 'src/setupTests.js', 'src/test/**'],
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node, vi: 'readonly' } },
|
||||
},
|
||||
];
|
||||
Generated
+1319
File diff suppressed because it is too large.
Load diff
+11
-1
@@ -14,9 +14,13 @@
|
||||
"test:run": "vitest run",
|
||||
"coverage": "vitest run --coverage",
|
||||
"backup": "bash scripts/backup-data.sh",
|
||||
"restore": "bash scripts/restore-data.sh"
|
||||
"restore": "bash scripts/restore-data.sh",
|
||||
"lint": "eslint .",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.3",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"compression": "^1.8.2",
|
||||
@@ -33,6 +37,8 @@
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -40,6 +46,10 @@
|
||||
"@vitest/coverage-v8": "^4.1.7",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"csv-parse": "^6.2.1",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.4",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Real-browser tests: a production build of the app on its own port with a throwaway database, driven in Chrome.
|
||||
// Run with `npm run test:e2e`. It uses the Chrome already on this computer (no browser download).
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
reporter: [['list']],
|
||||
use: { baseURL: 'http://localhost:3101', channel: 'chrome', trace: 'retain-on-failure' },
|
||||
webServer: { command: 'bash e2e/start-server.sh', url: 'http://localhost:3101/', timeout: 120_000, reuseExistingServer: false },
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /auth\.setup\.js/ },
|
||||
{ name: 'chrome', testMatch: /.*\.spec\.js/, dependencies: ['setup'], use: { storageState: 'e2e/.auth.json' } },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
Photos for behind the verse of the day and the "Share as image" pictures.
|
||||
|
||||
1. Drop landscape photos (about 1200px wide or larger, .jpg/.png/.webp) into this folder.
|
||||
2. List them in index.json, for example:
|
||||
|
||||
[
|
||||
{ "file": "mountain-lake.jpg", "name": "Mountain lake" },
|
||||
{ "file": "wheat.jpg", "name": "Wheat" }
|
||||
]
|
||||
|
||||
(A plain list of file names also works: ["mountain-lake.jpg", "wheat.jpg"].)
|
||||
|
||||
They then appear under "Your photos" in the background menus, and "Picture of the day" rotates through them
|
||||
along with the built-in scenes. With no index.json the built-in scenes are used. Free sources: pixabay.com,
|
||||
unsplash.com. Check each photo's licence before sharing pictures made from it.
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,35 @@
|
||||
// Everything one account holds on the server, in the same file format the app's own "Download backup"
|
||||
// makes, so it can be restored from Settings on any device.
|
||||
export const BACKUP_APP = 'bible-study-project';
|
||||
export const BACKUP_VERSION = 1;
|
||||
|
||||
// `store` supplies the account's data: { projects(), prefs(), marks(), ink(), plans() }.
|
||||
export function buildAccountExport(user, store, now = new Date()) {
|
||||
const prefs = store.prefs() ?? {};
|
||||
const marks = store.marks() ?? {};
|
||||
const plans = store.plans() ?? {};
|
||||
return {
|
||||
app: BACKUP_APP,
|
||||
version: BACKUP_VERSION,
|
||||
exportedAt: now.toISOString(),
|
||||
account: user.email,
|
||||
source: 'server',
|
||||
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 ?? [],
|
||||
activity: plans.activity ?? [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const accountExportName = (now = new Date()) => `bible-study-account-${now.toISOString().slice(0, 10)}.json`;
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { buildAccountExport, accountExportName, BACKUP_APP } from './accountExport.js';
|
||||
import { listSnapshots, pruneSnapshots } from './backups.js';
|
||||
import { mkdtempSync, writeFileSync, readdirSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
test('the account export has the same shape as the in-app backup', () => {
|
||||
const out = buildAccountExport({ email: 'a@b.co' }, {
|
||||
projects: () => [{ id: 'p', title: 'T', chapters: [] }],
|
||||
prefs: () => ({ colorLabels: { v: { '#fef08a': 'Promises' }, at: 1 } }),
|
||||
marks: () => ({ bookmarks: { 'JHN-3-16': '#fff' }, highlights: [{ id: 'h' }] }),
|
||||
ink: () => ({ JHN_3: [[1, 2]] }),
|
||||
plans: () => ({ progress: [{ id: 'x' }] }),
|
||||
}, new Date('2026-10-06T10:00:00Z'));
|
||||
expect(out.app).toBe(BACKUP_APP);
|
||||
expect(out.account).toBe('a@b.co');
|
||||
expect(out.reader.colorLabels).toEqual({ '#fef08a': 'Promises' });
|
||||
expect(out.reader.bookmarks['JHN-3-16']).toBe('#fff');
|
||||
expect(out.readingPlans).toEqual({ progress: [{ id: 'x' }], customPlans: [], activity: [] });
|
||||
expect(accountExportName(new Date('2026-10-06T10:00:00Z'))).toBe('bible-study-account-2026-10-06.json');
|
||||
});
|
||||
|
||||
test('only the newest snapshots are kept, and always at least one', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'snap-'));
|
||||
['2026-10-01-03-00', '2026-10-02-03-00', '2026-10-03-03-00', '2026-10-04-03-00'].forEach((d) => writeFileSync(join(dir, `projects-${d}.db`), 'x'));
|
||||
writeFileSync(join(dir, 'notes.txt'), 'not a snapshot');
|
||||
expect(listSnapshots(dir).map((s) => s.name)[0]).toBe('projects-2026-10-04-03-00.db');
|
||||
expect(pruneSnapshots(2, dir)).toEqual(['projects-2026-10-02-03-00.db', 'projects-2026-10-01-03-00.db']);
|
||||
expect(pruneSnapshots(0, dir)).toHaveLength(1); // keep at least the newest
|
||||
expect(readdirSync(dir).sort()).toEqual(['notes.txt', 'projects-2026-10-04-03-00.db']);
|
||||
});
|
||||
@@ -92,3 +92,10 @@ export async function consumeBackupCode(submitted, hashes) {
|
||||
export function generateTemporaryPassword() {
|
||||
return randomBytes(6).toString('hex');
|
||||
}
|
||||
|
||||
// A one-time password-reset code: 8 characters from an alphabet without look-alikes (no 0/O, 1/I/L).
|
||||
const RESET_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
export function generateResetCode() {
|
||||
return Array.from(randomBytes(8), (b) => RESET_ALPHABET[b % RESET_ALPHABET.length]).join('');
|
||||
}
|
||||
export const normalizeResetCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
@@ -0,0 +1,62 @@
|
||||
// Automatic daily snapshots of the whole database (every account), kept next to it in DATA_DIR/backups.
|
||||
// A snapshot is a complete, consistent copy made while the app keeps running, and is restored with
|
||||
// scripts/restore-data.sh. BACKUP_KEEP sets how many to keep (default 7); BACKUP_DISABLED=1 turns it off.
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { logEvent } from './health.js';
|
||||
import { backupDatabase, dataDir, replaceDatabase } from './db.js';
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
export const backupDir = () => join(dataDir(), 'backups');
|
||||
const stamp = (d) => d.toISOString().replace(/[:T]/g, '-').slice(0, 16);
|
||||
export const isSnapshot = (name) => /^projects-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}\.db$/.test(name);
|
||||
|
||||
export function listSnapshots(dir = backupDir()) {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir).filter(isSnapshot).sort().reverse()
|
||||
.map((name) => ({ name, bytes: statSync(join(dir, name)).size, at: statSync(join(dir, name)).mtimeMs }));
|
||||
}
|
||||
|
||||
// Deletes all but the newest `keep` snapshots; returns the names removed.
|
||||
export function pruneSnapshots(keep = 7, dir = backupDir()) {
|
||||
const old = listSnapshots(dir).slice(Math.max(1, keep));
|
||||
old.forEach((s) => unlinkSync(join(dir, s.name)));
|
||||
return old.map((s) => s.name);
|
||||
}
|
||||
|
||||
export async function runBackup(now = new Date()) {
|
||||
const dir = backupDir();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const file = join(dir, `projects-${stamp(now)}.db`);
|
||||
await backupDatabase(file);
|
||||
pruneSnapshots(Number(process.env.BACKUP_KEEP) || 7, dir);
|
||||
return file;
|
||||
}
|
||||
|
||||
export const snapshotPath = (name) => (isSnapshot(name) && existsSync(join(backupDir(), name)) ? join(backupDir(), name) : null);
|
||||
|
||||
// Puts a snapshot back as the live database. A fresh snapshot of what is there now is taken first (from a
|
||||
// copy of the chosen file, so the two can't collide), so a restore can itself be undone.
|
||||
export async function restoreSnapshot(name, now = new Date()) {
|
||||
const src = snapshotPath(name);
|
||||
if (!src) throw new Error('No such snapshot.');
|
||||
const hold = join(backupDir(), `.restoring-${Date.now()}.tmp`);
|
||||
copyFileSync(src, hold);
|
||||
try {
|
||||
const before = await runBackup(now);
|
||||
replaceDatabase(hold);
|
||||
return { restored: name, safetyCopy: before };
|
||||
} finally { unlinkSync(hold); }
|
||||
}
|
||||
|
||||
// Takes a snapshot when the newest is more than a day old (checked hourly, and once at start-up).
|
||||
export function startBackupScheduler() {
|
||||
if (process.env.BACKUP_DISABLED === '1') return;
|
||||
const tick = () => {
|
||||
const latest = listSnapshots()[0];
|
||||
if (latest && Date.now() - latest.at < DAY) return;
|
||||
runBackup().then((f) => console.log(`Database snapshot saved: ${f}`)).catch((err) => { console.error('Database snapshot failed:', err.message); logEvent('backup', err.message); });
|
||||
};
|
||||
setTimeout(tick, 30_000).unref();
|
||||
setInterval(tick, 60 * 60 * 1000).unref();
|
||||
}
|
||||
+7
-1
@@ -28,6 +28,12 @@ const ABBREV_BY_NAME = new Map(ESV_NAMES.map((n, i) => [n.toLowerCase(), BOOK_OR
|
||||
ABBREV_BY_NAME.set('psalms', 'PSA');
|
||||
ABBREV_BY_NAME.set('song of songs', 'SNG');
|
||||
|
||||
// The full book name for a USFM code ("JHN" -> "John"), or null.
|
||||
export function bookNameFor(abbrev) {
|
||||
const i = BOOK_ORDER.indexOf(String(abbrev ?? '').toUpperCase());
|
||||
return i >= 0 ? ESV_NAMES[i] : null;
|
||||
}
|
||||
|
||||
export function parseEsvReference(ref) {
|
||||
const m = String(ref).match(/^(.+?)\s+(\d+):(\d+)/);
|
||||
const bookAbbrev = m && ABBREV_BY_NAME.get(m[1].toLowerCase());
|
||||
@@ -53,7 +59,7 @@ const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
|
||||
// breaks, and stray "#" / nbsp characters. Split it back into { number, text }.
|
||||
export function parseChapterText(content) {
|
||||
const clean = String(content ?? '')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/#/g, '');
|
||||
const parts = clean.split(/\[(\d+)\]/);
|
||||
const verses = [];
|
||||
|
||||
+455
-20
@@ -1,5 +1,6 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { copyFileSync, mkdirSync, rmSync } from 'fs';
|
||||
import { MAX_VERSIONS, pack, shouldSnapshot, summarizeProject, unpack } from './versions.js';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
@@ -9,6 +10,25 @@ const DB_PATH = join(DATA_DIR, 'projects.db');
|
||||
|
||||
let db;
|
||||
|
||||
export const dataDir = () => DATA_DIR;
|
||||
|
||||
/** A consistent copy of the whole database, made while it is in use. */
|
||||
export function backupDatabase(destPath) {
|
||||
return db.backup(destPath);
|
||||
}
|
||||
|
||||
/** Swaps the live database for a copy of `srcFile` (a snapshot). The connection is reopened either way. */
|
||||
export function replaceDatabase(srcFile) {
|
||||
db.close();
|
||||
try {
|
||||
copyFileSync(srcFile, DB_PATH);
|
||||
rmSync(`${DB_PATH}-wal`, { force: true });
|
||||
rmSync(`${DB_PATH}-shm`, { force: true });
|
||||
} finally {
|
||||
initDb();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init — create tables if they don't exist, migrate older schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -40,6 +60,18 @@ export function initDb() {
|
||||
sess TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS passkeys (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
public_key TEXT NOT NULL,
|
||||
counter INTEGER NOT NULL DEFAULT 0,
|
||||
transports TEXT,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys(user_id);
|
||||
`);
|
||||
|
||||
// Older databases predate multi-user support — add the ownership column.
|
||||
@@ -74,6 +106,27 @@ export function initDb() {
|
||||
`);
|
||||
}
|
||||
|
||||
// Optional "new sign-in" notification, sent to the same ntfy topic.
|
||||
if (!userCols.some((c) => c.name === 'login_alerts')) db.exec('ALTER TABLE users ADD COLUMN login_alerts INTEGER NOT NULL DEFAULT 0');
|
||||
|
||||
// Optional daily verse of the day, sent to the same ntfy topic.
|
||||
if (!userCols.some((c) => c.name === 'votd_enabled')) {
|
||||
db.exec(`
|
||||
ALTER TABLE users ADD COLUMN votd_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN votd_time TEXT;
|
||||
ALTER TABLE users ADD COLUMN last_votd_date TEXT;
|
||||
`);
|
||||
}
|
||||
|
||||
// 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');
|
||||
@@ -86,9 +139,89 @@ export function initDb() {
|
||||
if (!projectCols.some((c) => c.name === 'outline_share_token')) {
|
||||
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
|
||||
}
|
||||
// Share links can expire and be protected by a passcode.
|
||||
for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'share_reveal INTEGER', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) {
|
||||
if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`);
|
||||
}
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
|
||||
|
||||
// Server-wide settings (who may sign up, ...) and the invite codes that let someone sign up when sign-up is invite-only.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
code TEXT PRIMARY KEY,
|
||||
note TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_by TEXT,
|
||||
used_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// 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 (
|
||||
project_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
added_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (project_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_collaborators_user ON collaborators(user_id);
|
||||
CREATE TABLE IF NOT EXISTS edit_invites (
|
||||
token TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used_by TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
// What the person has read in the Reader (chapters and days), merged from every device.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reader_history (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// One-time codes for "forgot password": hashed, short-lived, limited tries.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
code_hash TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Reader ink — one row per user + book + chapter, stored as JSON
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reader_ink (
|
||||
@@ -165,6 +298,9 @@ function parseUserRow(row) {
|
||||
...row,
|
||||
totpEnabled: !!row.totpEnabled,
|
||||
reminderEnabled: !!row.reminderEnabled,
|
||||
votdEnabled: !!row.votdEnabled,
|
||||
prayerEnabled: !!row.prayerEnabled,
|
||||
loginAlerts: !!row.loginAlerts,
|
||||
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
|
||||
};
|
||||
}
|
||||
@@ -174,7 +310,9 @@ const USER_SELECT = `
|
||||
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
|
||||
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
|
||||
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,
|
||||
prayer_enabled AS prayerEnabled, prayer_time AS prayerTime, login_alerts AS loginAlerts
|
||||
FROM users
|
||||
`;
|
||||
|
||||
@@ -217,6 +355,55 @@ export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime,
|
||||
`).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, reminderTz || null, userId);
|
||||
}
|
||||
|
||||
/** Turns the daily verse of the day on or off, and says when to send it. */
|
||||
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(`
|
||||
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, votd_time AS votdTime,
|
||||
last_votd_date AS lastVotdDate, reminder_tz AS reminderTz
|
||||
FROM users
|
||||
WHERE votd_enabled = 1 AND ntfy_topic IS NOT NULL AND votd_time IS NOT NULL
|
||||
`).all();
|
||||
}
|
||||
|
||||
export function setLastVotdDate(userId, dateString) {
|
||||
db.prepare('UPDATE users SET last_votd_date = ? WHERE id = ?').run(dateString, userId);
|
||||
}
|
||||
|
||||
/** Every account with reminders turned on and a topic configured — checked once a minute. */
|
||||
export function getUsersWithRemindersEnabled() {
|
||||
return db.prepare(`
|
||||
@@ -232,6 +419,65 @@ export function setLastReminderDate(userId, dateString) {
|
||||
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings and sign-up invites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getSetting(key, fallback = null) {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||
return row ? row.value : fallback;
|
||||
}
|
||||
|
||||
export function setSetting(key, value) {
|
||||
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, String(value));
|
||||
}
|
||||
|
||||
export function createInvite(code, note, expiresAt) {
|
||||
db.prepare('INSERT INTO invites (code, note, created_at, expires_at) VALUES (?, ?, ?, ?)').run(code, note || null, Date.now(), expiresAt);
|
||||
}
|
||||
|
||||
export function listInvites() {
|
||||
return db.prepare('SELECT code, note, created_at AS createdAt, expires_at AS expiresAt, used_by AS usedBy, used_at AS usedAt FROM invites ORDER BY created_at DESC LIMIT 100').all();
|
||||
}
|
||||
|
||||
export function deleteInvite(code) {
|
||||
db.prepare('DELETE FROM invites WHERE code = ?').run(code);
|
||||
}
|
||||
|
||||
/** An invite that exists, hasn't expired and hasn't been used. */
|
||||
export function findOpenInvite(code) {
|
||||
return db.prepare('SELECT code FROM invites WHERE code = ? AND used_by IS NULL AND expires_at > ?').get(code, Date.now()) ?? null;
|
||||
}
|
||||
|
||||
/** Marks an invite used by a new account; false if someone else got there first. */
|
||||
export function useInvite(code, userId) {
|
||||
return db.prepare('UPDATE invites SET used_by = ?, used_at = ? WHERE code = ? AND used_by IS NULL AND expires_at > ?').run(userId, Date.now(), code, Date.now()).changes > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Password reset codes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setPasswordReset(userId, codeHash, expires) {
|
||||
db.prepare(`
|
||||
INSERT INTO password_resets (user_id, code_hash, expires, attempts) VALUES (?, ?, ?, 0)
|
||||
ON CONFLICT(user_id) DO UPDATE SET code_hash = excluded.code_hash, expires = excluded.expires, attempts = 0
|
||||
`).run(userId, codeHash, expires);
|
||||
}
|
||||
|
||||
export function getPasswordReset(userId) {
|
||||
const row = db.prepare('SELECT code_hash AS codeHash, expires, attempts FROM password_resets WHERE user_id = ?').get(userId);
|
||||
return row && row.expires > Date.now() ? row : null;
|
||||
}
|
||||
|
||||
export function bumpPasswordResetAttempts(userId) {
|
||||
db.prepare('UPDATE password_resets SET attempts = attempts + 1 WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
export function clearPasswordReset(userId) {
|
||||
db.prepare('DELETE FROM password_resets WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
/** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
|
||||
export function setBackupCodeHashes(userId, backupCodeHashes) {
|
||||
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
|
||||
@@ -268,22 +514,34 @@ 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;
|
||||
}
|
||||
|
||||
export function getReaderHistory(userId) {
|
||||
const row = db.prepare('SELECT data FROM reader_history WHERE user_id = ?').get(userId);
|
||||
try { return row ? JSON.parse(row.data) : { chapters: {}, days: [] }; } catch { return { chapters: {}, days: [] }; }
|
||||
}
|
||||
|
||||
export function setReaderHistory(userId, history) {
|
||||
db.prepare(`
|
||||
INSERT INTO reader_history (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(history), Date.now());
|
||||
}
|
||||
|
||||
/** Upserts the ink strokes for one reader page. */
|
||||
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
|
||||
db.prepare(`
|
||||
@@ -335,20 +593,33 @@ export function claimOrphanProjects(userId) {
|
||||
* Does NOT return full project data to keep the response small.
|
||||
*/
|
||||
export function getAllProjects(userId) {
|
||||
// the studies you own, plus the ones another account has invited you to edit
|
||||
const rows = db.prepare(`
|
||||
SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary
|
||||
FROM projects
|
||||
WHERE user_id = ?
|
||||
ORDER BY last_edited DESC
|
||||
`).all(userId);
|
||||
return rows;
|
||||
SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary,
|
||||
CASE WHEN p.user_id = ? THEN 0 ELSE 1 END AS shared, u.email AS ownerEmail
|
||||
FROM projects p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
WHERE p.user_id = ? OR p.id IN (SELECT project_id FROM collaborators WHERE user_id = ?)
|
||||
ORDER BY p.last_edited DESC
|
||||
`).all(userId, userId, userId);
|
||||
return rows.map((r) => (r.shared ? { ...r, shared: true } : { id: r.id, title: r.title, lastEdited: r.lastEdited, chapterSummary: r.chapterSummary }));
|
||||
}
|
||||
|
||||
/** True when the account owns the study or has been invited to edit it. */
|
||||
export function canEditProject(id, userId) {
|
||||
return !!db.prepare(`
|
||||
SELECT 1 FROM projects p WHERE p.id = ? AND (p.user_id = ? OR EXISTS (SELECT 1 FROM collaborators c WHERE c.project_id = p.id AND c.user_id = ?))
|
||||
`).get(id, userId, userId);
|
||||
}
|
||||
|
||||
export const isProjectOwner = (id, userId) => !!db.prepare('SELECT 1 FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
|
||||
/**
|
||||
* Returns a single full project by id, scoped to userId, or null if not found/not owned.
|
||||
*/
|
||||
export function getProject(id, userId) {
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
if (!canEditProject(id, userId)) return null;
|
||||
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
|
||||
if (!row) return null;
|
||||
try {
|
||||
return JSON.parse(row.data);
|
||||
@@ -359,7 +630,8 @@ export function getProject(id, userId) {
|
||||
|
||||
/** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */
|
||||
export function getProjectEdited(id, userId) {
|
||||
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
|
||||
if (!canEditProject(id, userId)) return null;
|
||||
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(id);
|
||||
return row ? row.lastEdited : null;
|
||||
}
|
||||
|
||||
@@ -369,11 +641,14 @@ export function getProjectEdited(id, userId) {
|
||||
*/
|
||||
export function upsertProject(project, userId) {
|
||||
const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id);
|
||||
if (existing && existing.userId !== userId) {
|
||||
if (existing && existing.userId !== userId && !canEditProject(project.id, userId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastEdited = project.lastEdited ?? Date.now();
|
||||
// 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, 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 };
|
||||
|
||||
@@ -390,11 +665,101 @@ 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.
|
||||
*/
|
||||
export function deleteProject(id, userId) {
|
||||
db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
db.transaction(() => {
|
||||
if (!db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId).changes) {
|
||||
// not the owner: "deleting" a study you were invited to just removes you from it
|
||||
db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(id, userId);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
})();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Co-teachers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createEditInvite(token, projectId, expiresAt) {
|
||||
db.prepare('INSERT INTO edit_invites (token, project_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, projectId, Date.now(), expiresAt);
|
||||
}
|
||||
|
||||
export function listCollaborators(projectId) {
|
||||
return db.prepare(`
|
||||
SELECT c.user_id AS userId, u.email, c.added_at AS addedAt FROM collaborators c JOIN users u ON u.id = c.user_id
|
||||
WHERE c.project_id = ? ORDER BY c.added_at
|
||||
`).all(projectId);
|
||||
}
|
||||
|
||||
export function countOpenEditInvites(projectId) {
|
||||
return db.prepare('SELECT COUNT(*) AS n FROM edit_invites WHERE project_id = ? AND used_by IS NULL AND expires_at > ?').get(projectId, Date.now()).n;
|
||||
}
|
||||
|
||||
export function removeCollaborator(projectId, userId) {
|
||||
db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(projectId, userId);
|
||||
}
|
||||
|
||||
/** Uses a single-use invite: -> { projectId, title } | { error }. The owner is told "you own this" rather than added. */
|
||||
export function acceptEditInvite(token, userId) {
|
||||
return db.transaction(() => {
|
||||
const inv = db.prepare('SELECT project_id AS projectId, expires_at AS expiresAt, used_by AS usedBy FROM edit_invites WHERE token = ?').get(token);
|
||||
if (!inv) return { error: 'This invite link is not valid.' };
|
||||
if (inv.usedBy) return { error: 'This invite link has already been used.' };
|
||||
if (inv.expiresAt <= Date.now()) return { error: 'This invite link has expired. Ask for a new one.' };
|
||||
const project = db.prepare('SELECT title, user_id AS ownerId FROM projects WHERE id = ?').get(inv.projectId);
|
||||
if (!project) return { error: 'That study no longer exists.' };
|
||||
if (project.ownerId === userId) return { error: 'That is your own study.' };
|
||||
db.prepare('INSERT OR IGNORE INTO collaborators (project_id, user_id, added_at) VALUES (?, ?, ?)').run(inv.projectId, userId, Date.now());
|
||||
db.prepare('UPDATE edit_invites SET used_by = ? WHERE token = ?').run(userId, token);
|
||||
return { projectId: inv.projectId, title: project.title };
|
||||
})();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -415,7 +780,7 @@ export function setShareToken(id, userId, token) {
|
||||
|
||||
/** Revokes a project's share link, scoped to userId. */
|
||||
export function clearShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL, share_reveal = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
|
||||
@@ -430,7 +795,41 @@ export function setOutlineShareToken(id, userId, token) {
|
||||
}
|
||||
|
||||
export function clearOutlineShareToken(id, userId) {
|
||||
db.prepare('UPDATE projects SET outline_share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
db.prepare('UPDATE projects SET outline_share_token = NULL, outline_share_expires = NULL, outline_share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId);
|
||||
}
|
||||
|
||||
// ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ----
|
||||
const shareCols = (outline) => (outline
|
||||
? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass', reveal: 'NULL' }
|
||||
: { token: 'share_token', expires: 'share_expires', pass: 'share_pass', reveal: 'share_reveal' });
|
||||
|
||||
/** { token, expiresAt, hasPasscode, revealAt } for the owner's link settings (revealAt: notes stay hidden until then). */
|
||||
export function getShareSettings(id, userId, outline = false) {
|
||||
const c = shareCols(outline);
|
||||
const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass, ${c.reveal} AS revealAt FROM projects WHERE id = ? AND user_id = ?`).get(id, userId);
|
||||
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass, revealAt: row.revealAt ?? null } : null;
|
||||
}
|
||||
|
||||
/** The stored passcode hash of a link (kept when only the expiry is changed). */
|
||||
export function getSharePassHash(id, userId, outline = false) {
|
||||
const c = shareCols(outline);
|
||||
return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null;
|
||||
}
|
||||
|
||||
/** Sets expiry (ms timestamp or null), passcode hash (or null) and, for a study link, when its notes appear (or null). */
|
||||
export function setShareSettings(id, userId, { expiresAt, passHash, revealAt }, outline = false) {
|
||||
const c = shareCols(outline);
|
||||
const changed = db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0;
|
||||
if (changed && !outline && revealAt !== undefined) db.prepare('UPDATE projects SET share_reveal = ? WHERE id = ? AND user_id = ?').run(revealAt ?? null, id, userId);
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */
|
||||
export function getSharedByToken(token, outline = false) {
|
||||
const c = shareCols(outline);
|
||||
const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash, ${c.reveal} AS revealAt FROM projects WHERE ${c.token} = ?`).get(token);
|
||||
if (!row) return null;
|
||||
try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null, revealAt: row.revealAt ?? null }; } catch { return null; }
|
||||
}
|
||||
|
||||
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
|
||||
@@ -468,7 +867,13 @@ export function adminGetAllUsers() {
|
||||
|
||||
/** Deletes a user account. Their projects are left in place (orphaned, not cascade-deleted) so data isn't lost by accident. */
|
||||
export function adminDeleteUser(userId) {
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
db.prepare('DELETE FROM passkeys WHERE user_id = ?').run(userId);
|
||||
// projects.user_id is a foreign key, so release the projects first (they stay as unowned rows).
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM collaborators WHERE user_id = ?').run(userId);
|
||||
db.prepare('UPDATE projects SET user_id = NULL, share_token = NULL, outline_share_token = NULL WHERE user_id = ?').run(userId);
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Every project across every user, with the owner's email, for the admin projects list. */
|
||||
@@ -495,6 +900,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);
|
||||
}
|
||||
|
||||
@@ -556,3 +962,32 @@ export function destroyAllSessionsForUser(userId, exceptSid = null) {
|
||||
const placeholders = staleSids.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Passkeys, sign-in alerts and the list of signed-in devices
|
||||
// ---------------------------------------------------------------------------
|
||||
export function setLoginAlerts(userId, enabled) {
|
||||
db.prepare('UPDATE users SET login_alerts = ? WHERE id = ?').run(enabled ? 1 : 0, userId);
|
||||
}
|
||||
|
||||
export function addPasskey({ id, userId, publicKey, counter, transports, name }) {
|
||||
db.prepare('INSERT INTO passkeys (id, user_id, public_key, counter, transports, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(id, userId, publicKey, counter, JSON.stringify(transports ?? []), name, Date.now());
|
||||
}
|
||||
|
||||
const passkeyRow = (r) => (r ? { ...r, transports: JSON.parse(r.transports || '[]') } : null);
|
||||
const PASSKEY_COLS = 'id, user_id AS userId, public_key AS publicKey, counter, transports, name, created_at AS createdAt, last_used_at AS lastUsedAt';
|
||||
|
||||
export const getPasskey = (id) => passkeyRow(db.prepare(`SELECT ${PASSKEY_COLS} FROM passkeys WHERE id = ?`).get(id));
|
||||
export const listPasskeys = (userId) => db.prepare(`SELECT ${PASSKEY_COLS} FROM passkeys WHERE user_id = ? ORDER BY created_at`).all(userId).map(passkeyRow);
|
||||
export const touchPasskey = (id, counter) => db.prepare('UPDATE passkeys SET counter = ?, last_used_at = ? WHERE id = ?').run(counter, Date.now(), id);
|
||||
export const deletePasskey = (id, userId) => db.prepare('DELETE FROM passkeys WHERE id = ? AND user_id = ?').run(id, userId).changes > 0;
|
||||
export const renamePasskey = (id, userId, name) => db.prepare('UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?').run(name, id, userId).changes > 0;
|
||||
|
||||
/** Every live session of one user: [{ sid, sess, expires }]. */
|
||||
export function listUserSessions(userId) {
|
||||
return db.prepare('SELECT sid, sess, expires FROM sessions WHERE expires > ?').all(Date.now())
|
||||
.map((r) => { try { return { sid: r.sid, sess: JSON.parse(r.sess), expires: r.expires }; } catch { return null; } })
|
||||
.filter((r) => r?.sess?.userId === userId);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Passkeys (WebAuthn sign-in with Face ID, a fingerprint or a security key) and the list of devices signed in.
|
||||
// A passkey is a second factor in itself (the device checks it is really you), so signing in with one skips the
|
||||
// authenticator-code step. Passwords and 2FA keep working as before.
|
||||
import { createHash } from 'crypto';
|
||||
import {
|
||||
generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import {
|
||||
addPasskey, deletePasskey, destroyAllSessionsForUser, destroySession, getPasskey, getUserById, listPasskeys,
|
||||
listUserSessions, renamePasskey, setLoginAlerts, touchPasskey,
|
||||
} from './db.js';
|
||||
import { logEvent } from './health.js';
|
||||
import { sendNtfy } from './reminders.js';
|
||||
|
||||
const MONTH = 30 * 24 * 60 * 60 * 1000;
|
||||
const APP_NAME = 'Bible Study';
|
||||
|
||||
// "Chrome on macOS" from a User-Agent string; good enough to recognise your own devices.
|
||||
export function describeDevice(ua = '') {
|
||||
const browser = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\/|CriOS\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : null;
|
||||
const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Windows/.test(ua) ? 'Windows' : /Mac OS X|Macintosh/.test(ua) ? 'macOS' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : null;
|
||||
if (browser && os) return `${browser} on ${os}`;
|
||||
return browser ?? os ?? 'Unknown device';
|
||||
}
|
||||
|
||||
export const sessionKey = (sid) => createHash('sha256').update(sid).digest('hex').slice(0, 16);
|
||||
|
||||
// Where the browser says it is, for checking passkeys: APP_URL when set, else the address this request came to.
|
||||
export function relyingParty(req, env = process.env) {
|
||||
if (env.APP_URL) {
|
||||
try { const u = new URL(env.APP_URL); return { rpID: u.hostname, origin: u.origin }; } catch { /* fall through */ }
|
||||
}
|
||||
return { rpID: req.hostname, origin: `${req.protocol}://${req.get('host')}` };
|
||||
}
|
||||
|
||||
/** Called right after a session gets its userId: remembers the device and (if asked) sends a sign-in alert. */
|
||||
export function noteSignIn(req, user, method) {
|
||||
const label = describeDevice(req.get('user-agent') ?? '');
|
||||
req.session.device = { label, ip: req.ip, at: Date.now(), method };
|
||||
if (user.loginAlerts && user.ntfyTopic) {
|
||||
const how = { password: 'password', passkey: 'passkey', mfa: 'password and authenticator code', register: 'a new account' }[method] ?? method;
|
||||
sendNtfy(user, { title: 'New sign-in', body: `${label} signed in to your Bible Study account with ${how}. If that wasn’t you, change your password and sign out other devices.`, tags: 'warning' })
|
||||
.catch((err) => { console.error('Sign-in alert failed:', err.message); logEvent('notify', `Sign-in alert: ${err.message}`, { userId: user.id }); });
|
||||
}
|
||||
}
|
||||
|
||||
const publicPasskey = (p) => ({ id: p.id, name: p.name, createdAt: p.createdAt, lastUsedAt: p.lastUsedAt });
|
||||
|
||||
export function registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany }) {
|
||||
const fail = (res, status, error) => res.status(status).json({ error });
|
||||
|
||||
// ---- Signing in with a passkey (no email needed: the passkey says who you are) ----
|
||||
app.post('/api/auth/passkey/options', async (req, res) => {
|
||||
try {
|
||||
if (isLimited(`ip:${req.ip}`)) return res.status(429).json(tooMany);
|
||||
const { rpID } = relyingParty(req);
|
||||
const options = await generateAuthenticationOptions({ rpID, userVerification: 'required' });
|
||||
req.session.passkeyChallenge = options.challenge;
|
||||
res.json(options);
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/passkey/options error:', err);
|
||||
fail(res, 500, 'Could not start passkey sign-in.');
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/passkey/verify', async (req, res) => {
|
||||
const keys = [`ip:${req.ip}`];
|
||||
try {
|
||||
if (isLimited(...keys)) return res.status(429).json(tooMany);
|
||||
const challenge = req.session?.passkeyChallenge;
|
||||
const response = req.body;
|
||||
const passkey = typeof response?.id === 'string' ? getPasskey(response.id) : null;
|
||||
const failed = () => { recordFailure(...keys); logEvent('signin', `Passkey sign-in failed from ${req.ip}`); return fail(res, 401, 'That passkey didn’t work. Try again, or use your password.'); };
|
||||
if (!challenge || !passkey) return failed();
|
||||
const { rpID, origin } = relyingParty(req);
|
||||
let result;
|
||||
try {
|
||||
result = await verifyAuthenticationResponse({
|
||||
response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true,
|
||||
credential: { id: passkey.id, publicKey: Buffer.from(passkey.publicKey, 'base64url'), counter: passkey.counter, transports: passkey.transports },
|
||||
});
|
||||
} catch { return failed(); }
|
||||
if (!result.verified) return failed();
|
||||
const user = getUserById(passkey.userId);
|
||||
if (!user) return failed();
|
||||
touchPasskey(passkey.id, result.authenticationInfo.newCounter);
|
||||
clearFailures(...keys);
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return fail(res, 500, 'Could not create session.');
|
||||
req.session.userId = user.id;
|
||||
noteSignIn(req, user, 'passkey');
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/passkey/verify error:', err);
|
||||
fail(res, 500, 'Could not check that passkey.');
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Managing passkeys (signed in) ----
|
||||
app.get('/api/passkeys', requireAuth, (req, res) => res.json(listPasskeys(req.session.userId).map(publicPasskey)));
|
||||
|
||||
app.post('/api/passkeys/options', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const { rpID } = relyingParty(req);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: APP_NAME, rpID, userName: user.email, userID: new TextEncoder().encode(user.id), attestationType: 'none',
|
||||
excludeCredentials: listPasskeys(user.id).map((p) => ({ id: p.id, transports: p.transports })),
|
||||
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
|
||||
});
|
||||
req.session.passkeyChallenge = options.challenge;
|
||||
res.json(options);
|
||||
} catch (err) {
|
||||
console.error('POST /api/passkeys/options error:', err);
|
||||
fail(res, 500, 'Could not start adding a passkey.');
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/passkeys', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const challenge = req.session.passkeyChallenge;
|
||||
if (!challenge) return fail(res, 400, 'Start again: that request expired.');
|
||||
const { rpID, origin } = relyingParty(req);
|
||||
let result;
|
||||
try {
|
||||
result = await verifyRegistrationResponse({ response: req.body?.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: true });
|
||||
} catch (err) { return fail(res, 400, `That passkey couldn’t be added (${err.message}).`); }
|
||||
if (!result.verified) return fail(res, 400, 'That passkey couldn’t be added.');
|
||||
delete req.session.passkeyChallenge;
|
||||
const { credential } = result.registrationInfo;
|
||||
if (getPasskey(credential.id)) return fail(res, 409, 'That passkey is already registered.');
|
||||
const name = String(req.body?.name ?? '').trim().slice(0, 60) || describeDevice(req.get('user-agent') ?? '');
|
||||
addPasskey({ id: credential.id, userId: req.session.userId, publicKey: Buffer.from(credential.publicKey).toString('base64url'), counter: credential.counter, transports: credential.transports, name });
|
||||
res.json(listPasskeys(req.session.userId).map(publicPasskey));
|
||||
} catch (err) {
|
||||
console.error('POST /api/passkeys error:', err);
|
||||
fail(res, 500, 'Could not save that passkey.');
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/passkeys/:id', requireAuth, (req, res) => {
|
||||
const name = String(req.body?.name ?? '').trim().slice(0, 60);
|
||||
if (!name) return fail(res, 400, 'Give it a name.');
|
||||
if (!renamePasskey(req.params.id, req.session.userId, name)) return fail(res, 404, 'No such passkey.');
|
||||
res.json(listPasskeys(req.session.userId).map(publicPasskey));
|
||||
});
|
||||
|
||||
app.delete('/api/passkeys/:id', requireAuth, (req, res) => {
|
||||
if (!deletePasskey(req.params.id, req.session.userId)) return fail(res, 404, 'No such passkey.');
|
||||
res.json(listPasskeys(req.session.userId).map(publicPasskey));
|
||||
});
|
||||
|
||||
// ---- Where you are signed in ----
|
||||
app.get('/api/sessions', requireAuth, (req, res) => {
|
||||
const rows = listUserSessions(req.session.userId).map(({ sid, sess, expires }) => ({
|
||||
id: sessionKey(sid), current: sid === req.sessionID,
|
||||
label: sess.device?.label ?? 'Signed in before this list existed', ip: sess.device?.ip ?? null,
|
||||
method: sess.device?.method ?? null, signedInAt: sess.device?.at ?? null,
|
||||
lastSeen: expires - MONTH, // sessions are renewed on each request, so this is when it was last used
|
||||
})).sort((a, b) => Number(b.current) - Number(a.current) || b.lastSeen - a.lastSeen);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
app.delete('/api/sessions/:id', requireAuth, (req, res) => {
|
||||
const found = listUserSessions(req.session.userId).find((s) => sessionKey(s.sid) === req.params.id);
|
||||
if (!found) return fail(res, 404, 'That device is already signed out.');
|
||||
if (found.sid === req.sessionID) return fail(res, 400, 'Use Sign out for this device.');
|
||||
destroySession(found.sid);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/sessions/sign-out-others', requireAuth, (req, res) => {
|
||||
destroyAllSessionsForUser(req.session.userId, req.sessionID);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/auth/login-alerts', requireAuth, (req, res) => {
|
||||
const user = getUserById(req.session.userId);
|
||||
const enabled = !!req.body?.enabled;
|
||||
if (enabled && !user.ntfyTopic) return fail(res, 400, 'Set up a notification topic first (under Notifications).');
|
||||
setLoginAlerts(user.id, enabled);
|
||||
res.json({ loginAlerts: enabled });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import { createHash, generateKeyPairSync, randomBytes, sign } from 'crypto';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
|
||||
const b64 = (b) => Buffer.from(b).toString('base64url');
|
||||
const sha = (b) => createHash('sha256').update(b).digest();
|
||||
|
||||
// A tiny CBOR writer, just enough for the attestation a passkey device would send.
|
||||
const head = (major, n) => (n < 24 ? Buffer.from([(major << 5) | n]) : n < 256 ? Buffer.from([(major << 5) | 24, n]) : Buffer.from([(major << 5) | 25, n >> 8, n & 255]));
|
||||
const cbor = (v) => {
|
||||
if (Buffer.isBuffer(v)) return Buffer.concat([head(2, v.length), v]);
|
||||
if (typeof v === 'string') return Buffer.concat([head(3, Buffer.byteLength(v)), Buffer.from(v)]);
|
||||
if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);
|
||||
const entries = v instanceof Map ? [...v] : Object.entries(v);
|
||||
return Buffer.concat([head(5, entries.length), ...entries.flatMap(([k, x]) => [cbor(k), cbor(x)])]);
|
||||
};
|
||||
|
||||
// A software "phone": makes passkeys and answers sign-in challenges, like Face ID would.
|
||||
function authenticator(rpID, origin) {
|
||||
const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
const jwk = publicKey.export({ format: 'jwk' });
|
||||
const credId = randomBytes(16);
|
||||
let counter = 0;
|
||||
const rpHash = sha(rpID);
|
||||
const client = (type, challenge, o = origin) => Buffer.from(JSON.stringify({ type, challenge, origin: o, crossOrigin: false }));
|
||||
return {
|
||||
id: b64(credId),
|
||||
create(challenge, o) {
|
||||
const cose = cbor(new Map([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x, 'base64url')], [-3, Buffer.from(jwk.y, 'base64url')]]));
|
||||
const len = Buffer.alloc(2); len.writeUInt16BE(credId.length);
|
||||
const cnt = Buffer.alloc(4);
|
||||
const authData = Buffer.concat([rpHash, Buffer.from([0x45]), cnt, Buffer.alloc(16), len, credId, cose]);
|
||||
return { id: b64(credId), rawId: b64(credId), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(client('webauthn.create', challenge, o)), attestationObject: b64(cbor({ fmt: 'none', attStmt: {}, authData })), transports: ['internal'] } };
|
||||
},
|
||||
get(challenge, o) {
|
||||
counter += 1;
|
||||
const cnt = Buffer.alloc(4); cnt.writeUInt32BE(counter);
|
||||
const authData = Buffer.concat([rpHash, Buffer.from([0x05]), cnt]);
|
||||
const clientData = client('webauthn.get', challenge, o);
|
||||
const signature = sign('sha256', Buffer.concat([authData, sha(clientData)]), privateKey);
|
||||
return { id: b64(credId), rawId: b64(credId), type: 'public-key', clientExtensionResults: {}, response: { clientDataJSON: b64(clientData), authenticatorData: b64(authData), signature: b64(signature), userHandle: null } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let dir; let server; let base; let db;
|
||||
beforeEach(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'devices-'));
|
||||
process.env.DATA_DIR = dir; delete process.env.APP_URL;
|
||||
vi.resetModules();
|
||||
db = await import('./db.js'); db.initDb();
|
||||
const { registerDeviceRoutes } = await import('./devices.js');
|
||||
const { requireAuth } = await import('./auth.js');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(session({ secret: 'test', resave: false, saveUninitialized: false, store: new (await import('./sessionStore.js')).SqliteSessionStore(), cookie: { maxAge: 30 * 24 * 3600 * 1000 } }));
|
||||
const { noteSignIn } = await import('./devices.js');
|
||||
// stand-in for the password sign-in route
|
||||
app.post('/test/login', (req, res) => { const u = db.getUserByEmail(req.body.email); req.session.regenerate(() => { req.session.userId = u.id; noteSignIn(req, u, 'password'); res.json({ ok: true }); }); });
|
||||
registerDeviceRoutes(app, { requireAuth, userPayload: (u) => ({ id: u.id, email: u.email }), isLimited: () => false, recordFailure() {}, clearFailures() {}, tooMany: { error: 'slow down' } });
|
||||
await new Promise((r) => { server = app.listen(0, r); });
|
||||
base = `http://localhost:${server.address().port}`;
|
||||
db.createUser({ id: 'u1', email: 'a@x.test', passwordHash: 'h' });
|
||||
});
|
||||
afterEach(async () => { await new Promise((r) => server.close(r)); rmSync(dir, { recursive: true, force: true }); delete process.env.DATA_DIR; });
|
||||
|
||||
// A browser: keeps its own cookie
|
||||
function browser(ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17 Safari/605.1.15') {
|
||||
let cookie = '';
|
||||
const call = async (method, path, body) => {
|
||||
const res = await fetch(base + path, { method, headers: { 'Content-Type': 'application/json', 'User-Agent': ua, ...(cookie ? { Cookie: cookie } : {}) }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||||
const set = res.headers.get('set-cookie');
|
||||
if (set) cookie = set.split(';')[0];
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
};
|
||||
return { call, login: () => call('POST', '/test/login', { email: 'a@x.test' }) };
|
||||
}
|
||||
const originOf = () => base;
|
||||
|
||||
test('add a passkey, then sign in with it from another device', async () => {
|
||||
const phone = authenticator('localhost', originOf());
|
||||
const laptop = browser(); await laptop.login();
|
||||
const { body: opts } = await laptop.call('POST', '/api/passkeys/options');
|
||||
expect(opts.authenticatorSelection.userVerification).toBe('required');
|
||||
const added = await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge), name: 'My phone' });
|
||||
expect(added.status).toBe(200);
|
||||
expect(added.body).toMatchObject([{ id: phone.id, name: 'My phone' }]);
|
||||
|
||||
const other = browser('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1');
|
||||
expect((await other.call('GET', '/api/passkeys')).status).toBe(401); // not signed in yet
|
||||
const { body: ask } = await other.call('POST', '/api/auth/passkey/options');
|
||||
const ok = await other.call('POST', '/api/auth/passkey/verify', phone.get(ask.challenge));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.email).toBe('a@x.test');
|
||||
expect((await other.call('GET', '/api/passkeys')).body[0].lastUsedAt).toBeGreaterThan(0);
|
||||
const devices = (await other.call('GET', '/api/sessions')).body;
|
||||
expect(devices.map((d) => d.label)).toEqual(['Safari on iPhone', 'Safari on macOS']);
|
||||
expect(devices[0]).toMatchObject({ current: true, method: 'passkey' });
|
||||
});
|
||||
|
||||
test('a passkey from the wrong site, an unknown one, or a replay is refused', async () => {
|
||||
const phone = authenticator('localhost', originOf());
|
||||
const laptop = browser(); await laptop.login();
|
||||
const { body: opts } = await laptop.call('POST', '/api/passkeys/options');
|
||||
const bad = await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge, 'https://evil.example') });
|
||||
expect(bad.status).toBe(400);
|
||||
await laptop.call('POST', '/api/passkeys', { response: phone.create(opts.challenge), name: 'ok' });
|
||||
|
||||
const stranger = browser();
|
||||
const { body: ask } = await stranger.call('POST', '/api/auth/passkey/options');
|
||||
expect((await stranger.call('POST', '/api/auth/passkey/verify', authenticator('localhost', originOf()).get(ask.challenge))).status).toBe(401); // never registered
|
||||
expect((await stranger.call('POST', '/api/auth/passkey/verify', phone.get('wrong-challenge'))).status).toBe(401);
|
||||
const good = phone.get(ask.challenge);
|
||||
expect((await stranger.call('POST', '/api/auth/passkey/verify', good)).status).toBe(200);
|
||||
const replay = browser();
|
||||
await replay.call('POST', '/api/auth/passkey/options');
|
||||
expect((await replay.call('POST', '/api/auth/passkey/verify', good)).status).toBe(401); // that answer was for another challenge
|
||||
});
|
||||
|
||||
test('passkeys can be renamed and removed, and only by their owner', async () => {
|
||||
const phone = authenticator('localhost', originOf());
|
||||
const me = browser(); await me.login();
|
||||
const { body: opts } = await me.call('POST', '/api/passkeys/options');
|
||||
await me.call('POST', '/api/passkeys', { response: phone.create(opts.challenge) });
|
||||
expect((await me.call('PATCH', `/api/passkeys/${phone.id}`, { name: 'Pixel' })).body[0].name).toBe('Pixel');
|
||||
expect((await me.call('PATCH', `/api/passkeys/${phone.id}`, { name: ' ' })).status).toBe(400);
|
||||
db.createUser({ id: 'u2', email: 'b@x.test', passwordHash: 'h' });
|
||||
expect(db.deletePasskey(phone.id, 'u2')).toBe(false);
|
||||
expect((await me.call('DELETE', `/api/passkeys/${phone.id}`)).body).toEqual([]);
|
||||
const { body: ask } = await browser().call('POST', '/api/auth/passkey/options');
|
||||
expect(ask.challenge).toBeTruthy();
|
||||
});
|
||||
|
||||
test('devices can be signed out one by one or all at once', async () => {
|
||||
const a = browser(); const b = browser('Mozilla/5.0 (Windows NT 10.0) Chrome/120 Safari/537'); const c = browser('Mozilla/5.0 (Linux; Android 14) Chrome/120 Mobile Safari/537');
|
||||
await a.login(); await b.login(); await c.login();
|
||||
const list = (await a.call('GET', '/api/sessions')).body;
|
||||
expect(list).toHaveLength(3);
|
||||
expect(list[0].current).toBe(true);
|
||||
expect((await a.call('DELETE', `/api/sessions/${list[0].id}`)).status).toBe(400); // not your own
|
||||
const bId = list.find((d) => d.label === 'Chrome on Windows').id;
|
||||
expect((await a.call('DELETE', `/api/sessions/${bId}`)).status).toBe(200);
|
||||
expect((await b.call('GET', '/api/sessions')).status).toBe(401);
|
||||
expect((await a.call('DELETE', `/api/sessions/${bId}`)).status).toBe(404);
|
||||
await a.call('POST', '/api/sessions/sign-out-others');
|
||||
expect((await c.call('GET', '/api/sessions')).status).toBe(401);
|
||||
expect((await a.call('GET', '/api/sessions')).body).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('sign-in alerts need a notification topic', async () => {
|
||||
const a = browser(); await a.login();
|
||||
expect((await a.call('PUT', '/api/auth/login-alerts', { enabled: true })).status).toBe(400);
|
||||
db.setReminderSettings('u1', { ntfyUrl: null, ntfyTopic: 'my-topic', reminderTime: '08:00', reminderEnabled: false, reminderTz: 'UTC' });
|
||||
expect((await a.call('PUT', '/api/auth/login-alerts', { enabled: true })).body).toEqual({ loginAlerts: true });
|
||||
expect(db.getUserById('u1').loginAlerts).toBe(true);
|
||||
});
|
||||
|
||||
test('devices are described in plain words', async () => {
|
||||
const { describeDevice, relyingParty } = await import('./devices.js');
|
||||
expect(describeDevice('Mozilla/5.0 (Windows NT 10.0) Chrome/120 Edg/120')).toBe('Edge on Windows');
|
||||
expect(describeDevice('Mozilla/5.0 (X11; Linux) Firefox/120')).toBe('Firefox on Linux');
|
||||
expect(describeDevice('')).toBe('Unknown device');
|
||||
const req = { hostname: 'study.example', protocol: 'https', get: () => 'study.example:8443' };
|
||||
expect(relyingParty(req, {})).toEqual({ rpID: 'study.example', origin: 'https://study.example:8443' });
|
||||
expect(relyingParty(req, { APP_URL: 'https://study.example/app' })).toEqual({ rpID: 'study.example', origin: 'https://study.example' });
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// What the admin's Health page shows: a short in-memory log of things that went wrong (failed sign-ins, failed
|
||||
// notifications, backups), plus disk use and the snapshot list. The log resets when the server restarts.
|
||||
import { existsSync, statSync, statfsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { dataDir } from './db.js';
|
||||
import { backupDir, listSnapshots } from './backups.js';
|
||||
|
||||
const MAX_EVENTS = 200;
|
||||
const events = [];
|
||||
const started = Date.now();
|
||||
|
||||
// kind: 'signin' | 'notify' | 'backup'. `ok` false marks a failure.
|
||||
export function logEvent(kind, detail, { ok = false, userId = null } = {}) {
|
||||
events.unshift({ at: Date.now(), kind, ok, detail: String(detail).slice(0, 300), userId });
|
||||
if (events.length > MAX_EVENTS) events.length = MAX_EVENTS;
|
||||
}
|
||||
|
||||
export const recentEvents = (kind, limit = 50) => events.filter((e) => !kind || e.kind === kind).slice(0, limit);
|
||||
export function clearEvents() { events.length = 0; }
|
||||
|
||||
const size = (path) => { try { return statSync(path).size; } catch { return 0; } };
|
||||
|
||||
export function diskUse() {
|
||||
const dir = dataDir();
|
||||
const db = size(join(dir, 'projects.db')) + size(join(dir, 'projects.db-wal'));
|
||||
const backups = listSnapshots(backupDir()).reduce((n, s) => n + s.bytes, 0);
|
||||
let free = null; let total = null;
|
||||
try { const s = statfsSync(existsSync(dir) ? dir : '.'); free = s.bavail * s.bsize; total = s.blocks * s.bsize; } catch { /* not supported here */ }
|
||||
return { db, backups, free, total };
|
||||
}
|
||||
|
||||
export function backupStatus() {
|
||||
const snapshots = listSnapshots(backupDir());
|
||||
return { enabled: process.env.BACKUP_DISABLED !== '1', keep: Number(process.env.BACKUP_KEEP) || 7, snapshots, latest: snapshots[0] ?? null };
|
||||
}
|
||||
|
||||
export const uptimeSeconds = () => Math.round((Date.now() - started) / 1000);
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
let dir;
|
||||
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'health-')); process.env.DATA_DIR = dir; vi.resetModules(); });
|
||||
afterEach(() => { rmSync(dir, { recursive: true, force: true }); delete process.env.DATA_DIR; });
|
||||
|
||||
const load = async () => {
|
||||
const db = await import('./db.js'); db.initDb();
|
||||
return { db, backups: await import('./backups.js'), health: await import('./health.js') };
|
||||
};
|
||||
|
||||
test('the event log keeps the newest first, filters by kind and is capped', async () => {
|
||||
const { health } = await load();
|
||||
health.logEvent('signin', 'a'); health.logEvent('notify', 'b'); health.logEvent('signin', 'c');
|
||||
expect(health.recentEvents('signin').map((e) => e.detail)).toEqual(['c', 'a']);
|
||||
for (let i = 0; i < 300; i += 1) health.logEvent('signin', String(i));
|
||||
expect(health.recentEvents(null, 1000)).toHaveLength(200);
|
||||
});
|
||||
|
||||
test('a snapshot can be restored, and what was there is kept as a safety copy', async () => {
|
||||
const { db, backups } = await load();
|
||||
db.createUser({ id: 'u1', email: 'a@x.test', passwordHash: 'h' });
|
||||
await backups.runBackup(new Date('2026-10-01T10:00:00Z'));
|
||||
db.createUser({ id: 'u2', email: 'b@x.test', passwordHash: 'h' });
|
||||
expect(db.countUsers()).toBe(2);
|
||||
const [snap] = backups.listSnapshots();
|
||||
const result = await backups.restoreSnapshot(snap.name, new Date('2026-10-02T10:00:00Z'));
|
||||
expect(db.countUsers()).toBe(1); // back to when the snapshot was taken
|
||||
expect(backups.listSnapshots().map((s) => s.name)).toContain(result.safetyCopy.split('/').pop());
|
||||
});
|
||||
|
||||
test('only real snapshot names resolve', async () => {
|
||||
const { backups } = await load();
|
||||
expect(backups.snapshotPath('../projects.db')).toBeNull();
|
||||
expect(backups.snapshotPath('projects-2026-01-01-00-00.db')).toBeNull(); // valid name, but no such file
|
||||
await expect(backups.restoreSnapshot('nope')).rejects.toThrow('No such snapshot');
|
||||
});
|
||||
|
||||
test('disk use reports the database and snapshot sizes', async () => {
|
||||
const { backups, health } = await load();
|
||||
await backups.runBackup();
|
||||
const use = health.diskUse();
|
||||
expect(use.db).toBeGreaterThan(0);
|
||||
expect(use.backups).toBeGreaterThan(0);
|
||||
expect(health.backupStatus().latest).not.toBeNull();
|
||||
});
|
||||
+45
-684
@@ -1,79 +1,63 @@
|
||||
import express from 'express';
|
||||
import compression from 'compression';
|
||||
import session from 'express-session';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import {
|
||||
initDb, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
|
||||
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
|
||||
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
|
||||
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
|
||||
getOutlineShareToken, setOutlineShareToken, clearOutlineShareToken, getProjectByOutlineToken,
|
||||
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
|
||||
setUserPassword, destroyAllSessionsForUser,
|
||||
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
|
||||
getReadingProgress, setReadingProgress,
|
||||
setReminderSettings,
|
||||
} from './db.js';
|
||||
import { initDb, dataDir } from './db.js';
|
||||
import { SqliteSessionStore } from './sessionStore.js';
|
||||
import {
|
||||
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail,
|
||||
generateTotpSecret, totpKeyUri, verifyTotpToken,
|
||||
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
|
||||
} from './auth.js';
|
||||
import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './reminders.js';
|
||||
import { startReminderScheduler } from './reminders.js';
|
||||
import { startBackupScheduler } from './backups.js';
|
||||
import { registerBibleRoutes } from './bibleApi.js';
|
||||
import { cleanMarks } from './readerMarks.js';
|
||||
import { cleanPrefsPatch, mergePrefs } from './prefs.js';
|
||||
import { isSaveConflict, parseBase } from './projectSync.js';
|
||||
import { buildOutlinePayload } from './outlineShare.js';
|
||||
|
||||
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
|
||||
function userPayload(user) {
|
||||
return {
|
||||
id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null,
|
||||
isAdmin: isAdminEmail(user.email),
|
||||
reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null,
|
||||
reminderTime: user.reminderTime ?? null,
|
||||
};
|
||||
}
|
||||
import { registerIqBibleRoutes } from './iqbible.js';
|
||||
import { registerDeviceRoutes } from './devices.js';
|
||||
import { requireAuth } from './auth.js';
|
||||
import { userPayload } from './userPayload.js';
|
||||
import { isLimited, recordFailure, clearFailures, TOO_MANY } from './limiter.js';
|
||||
import { registerAuthRoutes } from './routes/auth.js';
|
||||
import { registerAccountRoutes } from './routes/account.js';
|
||||
import { registerProjectRoutes } from './routes/projects.js';
|
||||
import { registerReaderRoutes } from './routes/reader.js';
|
||||
import { registerSharingRoutes } from './routes/sharing.js';
|
||||
import { registerAdminRoutes } from './routes/admin.js';
|
||||
|
||||
// The server's setup: sessions and middleware, then each area registers its own routes (server/routes/*.js,
|
||||
// plus the Bible text, IQ Bible and passkey modules), then the built app is served and the server starts.
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
if (isProd && !process.env.SESSION_SECRET) {
|
||||
console.warn('WARNING: SESSION_SECRET is not set. Set it to a long random string in production.');
|
||||
// Session cookies are signed with SESSION_SECRET. When it isn't set (setup.sh doesn't set one), a random secret
|
||||
// is made once and kept in DATA_DIR, so it survives restarts and nobody has to configure anything.
|
||||
function sessionSecret() {
|
||||
if (process.env.SESSION_SECRET) return process.env.SESSION_SECRET;
|
||||
if (!isProd) return 'dev-only-secret-change-me';
|
||||
const file = join(dataDir(), '.session-secret');
|
||||
try {
|
||||
if (existsSync(file)) return readFileSync(file, 'utf8').trim();
|
||||
mkdirSync(dataDir(), { recursive: true });
|
||||
const made = randomBytes(48).toString('hex');
|
||||
writeFileSync(file, made, { mode: 0o600 });
|
||||
console.log(`SESSION_SECRET is not set; generated one and saved it to ${file}.`);
|
||||
return made;
|
||||
} catch (err) {
|
||||
console.error('Could not read or save a session secret; set SESSION_SECRET instead.', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Trust the reverse proxy (needed for secure cookies to work behind nginx/etc).
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Simple in-memory rate limiter for auth endpoints — 10 attempts per 15 min per IP.
|
||||
const _authAttempts = new Map();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [ip, e] of _authAttempts) if (now > e.resetAt) _authAttempts.delete(ip);
|
||||
}, 60 * 60 * 1000);
|
||||
function checkAuthRateLimit(ip) {
|
||||
const now = Date.now();
|
||||
const window = 15 * 60 * 1000;
|
||||
const e = _authAttempts.get(ip) ?? { count: 0, resetAt: now + window };
|
||||
if (now > e.resetAt) { e.count = 0; e.resetAt = now + window; }
|
||||
e.count += 1;
|
||||
_authAttempts.set(ip, e);
|
||||
return e.count > 10;
|
||||
}
|
||||
|
||||
// Gzip responses — the interlinear JSON files shrink ~10x, and JS/CSS/API payloads too.
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(session({
|
||||
store: new SqliteSessionStore(),
|
||||
secret: process.env.SESSION_SECRET || 'dev-only-secret-change-me',
|
||||
secret: sessionSecret(),
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
rolling: true,
|
||||
@@ -95,639 +79,15 @@ app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.post('/api/auth/register', async (req, res) => {
|
||||
try {
|
||||
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
return res.status(400).json({ error: 'Enter a valid email address.' });
|
||||
}
|
||||
if (!isValidPassword(password)) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
||||
}
|
||||
if (getUserByEmail(email)) {
|
||||
return res.status(409).json({ error: 'An account with that email already exists.' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = createUser({ id: randomUUID(), email, passwordHash });
|
||||
|
||||
// The very first account inherits any projects created before multi-user support existed.
|
||||
if (countUsers() === 1) {
|
||||
claimOrphanProjects(user.id);
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
res.json({
|
||||
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
|
||||
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/register error:', err);
|
||||
res.status(500).json({ error: 'Failed to register.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
try {
|
||||
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
const valid = user && await verifyPassword(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Incorrect email or password.' });
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
if (user.totpEnabled) {
|
||||
// Password is correct, but the session stays unauthenticated (no userId)
|
||||
// until a valid TOTP/backup code lands on /api/auth/mfa/verify.
|
||||
req.session.pendingUserId = user.id;
|
||||
return res.json({ mfaRequired: true });
|
||||
}
|
||||
req.session.userId = user.id;
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/login error:', err);
|
||||
res.status(500).json({ error: 'Failed to log in.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/verify', async (req, res) => {
|
||||
try {
|
||||
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' });
|
||||
const pendingUserId = req.session?.pendingUserId;
|
||||
if (!pendingUserId) {
|
||||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||
}
|
||||
const user = getUserById(pendingUserId);
|
||||
if (!user || !user.totpEnabled) {
|
||||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||
}
|
||||
|
||||
const token = req.body?.token;
|
||||
const backupCode = req.body?.backupCode;
|
||||
let ok = token ? verifyTotpToken(String(token), user.totpSecret) : false;
|
||||
|
||||
if (!ok && backupCode) {
|
||||
const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes);
|
||||
if (remaining) {
|
||||
setBackupCodeHashes(user.id, remaining);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Invalid code.' });
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/verify error:', err);
|
||||
res.status(500).json({ error: 'Failed to verify code.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/auth/profile — update account-level settings (currently just podcastName)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.patch('/api/auth/profile', requireAuth, (req, res) => {
|
||||
try {
|
||||
const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200);
|
||||
setPodcastName(req.session.userId, podcastName || null);
|
||||
res.json({ podcastName: podcastName || null });
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/profile error:', err);
|
||||
res.status(500).json({ error: 'Failed to save profile.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
|
||||
// ---------------------------------------------------------------------------
|
||||
app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => {
|
||||
try {
|
||||
const reminderEnabled = !!req.body?.reminderEnabled;
|
||||
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
|
||||
const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim();
|
||||
const reminderTime = String(req.body?.reminderTime ?? '').trim();
|
||||
const reminderTz = isValidTimeZone(req.body?.reminderTz) ? String(req.body.reminderTz) : null;
|
||||
|
||||
if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||||
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||||
}
|
||||
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||||
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||||
}
|
||||
if (reminderEnabled) {
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' });
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) {
|
||||
return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' });
|
||||
}
|
||||
}
|
||||
|
||||
setReminderSettings(req.session.userId, {
|
||||
ntfyUrl: ntfyUrl || null,
|
||||
ntfyTopic: ntfyTopic || null,
|
||||
reminderTime: reminderTime || null,
|
||||
reminderEnabled,
|
||||
reminderTz,
|
||||
});
|
||||
res.json({
|
||||
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/reminder-settings error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reminder settings.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/auth/change-password — self-service password change (Account Settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/api/auth/change-password', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const currentPassword = String(req.body?.currentPassword ?? '');
|
||||
const newPassword = String(req.body?.newPassword ?? '');
|
||||
|
||||
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect.' });
|
||||
}
|
||||
if (!isValidPassword(newPassword)) {
|
||||
return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
setUserPassword(user.id, passwordHash);
|
||||
// Sign out every other session (e.g. a stolen one) but keep this one logged in.
|
||||
destroyAllSessionsForUser(user.id, req.sessionID);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/change-password error:', err);
|
||||
res.status(500).json({ error: 'Failed to change password.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two-factor auth setup (requires an already-authenticated session)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.post('/api/auth/mfa/setup', requireAuth, (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const secret = generateTotpSecret();
|
||||
// Held only in the session until confirmed with a real code — never written
|
||||
// to the DB (and 2FA never turned on) unless /mfa/enable succeeds below.
|
||||
req.session.pendingTotpSecret = secret;
|
||||
QRCode.toDataURL(totpKeyUri(user.email, secret), (err, qrCodeDataUrl) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to generate QR code.' });
|
||||
res.json({ secret, qrCodeDataUrl });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/setup error:', err);
|
||||
res.status(500).json({ error: 'Failed to start 2FA setup.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const secret = req.session.pendingTotpSecret;
|
||||
if (!secret) {
|
||||
return res.status(400).json({ error: 'Start 2FA setup first.' });
|
||||
}
|
||||
if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) {
|
||||
return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' });
|
||||
}
|
||||
|
||||
const backupCodes = generateBackupCodes();
|
||||
const backupCodeHashes = await hashBackupCodes(backupCodes);
|
||||
enableTotp(req.session.userId, secret, backupCodeHashes);
|
||||
delete req.session.pendingTotpSecret;
|
||||
res.json({ backupCodes });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/enable error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable 2FA.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Incorrect password.' });
|
||||
}
|
||||
disableTotp(user.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/disable error:', err);
|
||||
res.status(500).json({ error: 'Failed to disable 2FA.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects — list all project summaries owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects', requireAuth, (req, res) => {
|
||||
try {
|
||||
const projects = getAllProjects(req.session.userId);
|
||||
res.json(projects);
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects error:', err);
|
||||
res.status(500).json({ error: 'Failed to list projects.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects/:id — fetch a single full project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const project = getProject(req.params.id, req.session.userId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /api/projects/:id — create or update a project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.put('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object') {
|
||||
return res.status(400).json({ error: 'Invalid JSON body.' });
|
||||
}
|
||||
if (!body.id || !body.title || !Array.isArray(body.chapters)) {
|
||||
return res.status(400).json({ error: 'Missing required fields: id, title, chapters.' });
|
||||
}
|
||||
if (body.id !== req.params.id) {
|
||||
return res.status(400).json({ error: 'URL id does not match body id.' });
|
||||
}
|
||||
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
|
||||
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
|
||||
// Refuse to overwrite a copy another device saved after the one this device last saw
|
||||
const stored = getProjectEdited(body.id, req.session.userId);
|
||||
if (isSaveConflict({ stored, base: parseBase(req.get('X-Base-Edited')), force: req.query.force === '1' })) {
|
||||
return res.status(409).json({ error: 'This study was changed on another device.', code: 'conflict', serverEdited: stored });
|
||||
}
|
||||
const saved = upsertProject(body, req.session.userId);
|
||||
if (!saved) {
|
||||
return res.status(403).json({ error: 'That project belongs to a different account.' });
|
||||
}
|
||||
res.json(saved);
|
||||
} catch (err) {
|
||||
console.error('PUT /api/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to save project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/projects/:id — remove a project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
deleteProject(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader ink — cross-device draw annotations on Bible chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/reader/ink — all saved ink pages for the current user
|
||||
app.get('/api/reader/ink', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getAllReaderInk(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reader/ink error:', err);
|
||||
res.status(500).json({ error: 'Failed to load reader ink.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reader/ink/:book/:chapter — save (upsert) one page's ink
|
||||
app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { book, chapter } = req.params;
|
||||
const { strokes } = req.body;
|
||||
if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
|
||||
setReaderInkPage(req.session.userId, book, chapter, strokes);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/ink error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reader ink.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prefs — the user's synced display preferences ({ prefs: { key: { v, at } } })
|
||||
app.get('/api/prefs', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json({ prefs: getUserPrefs(req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/prefs error:', err);
|
||||
res.status(500).json({ error: 'Failed to load preferences.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/prefs — merge changed preferences in (newest change per key wins); returns the result
|
||||
app.put('/api/prefs', requireAuth, (req, res) => {
|
||||
try {
|
||||
const merged = mergePrefs(getUserPrefs(req.session.userId), cleanPrefsPatch(req.body));
|
||||
setUserPrefs(req.session.userId, merged);
|
||||
res.json({ prefs: merged });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/prefs error:', err);
|
||||
res.status(500).json({ error: 'Failed to save preferences.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reader/marks — the user's bookmarks + highlights, with an updatedAt to compare against
|
||||
app.get('/api/reader/marks', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getReaderMarks(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reader/marks error:', err);
|
||||
res.status(500).json({ error: 'Failed to load bookmarks and highlights.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights })
|
||||
app.put('/api/reader/marks', requireAuth, (req, res) => {
|
||||
try {
|
||||
const marks = cleanMarks(req.body);
|
||||
if (!marks) return res.status(400).json({ error: 'Body must be an object.' });
|
||||
res.json({ ok: true, updatedAt: setReaderMarks(req.session.userId, marks) });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/marks error:', err);
|
||||
res.status(500).json({ error: 'Failed to save bookmarks and highlights.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading plan progress — cross-device sync for started/completed reading plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/reading-plans — the current user's whole progress list
|
||||
registerAuthRoutes(app);
|
||||
registerAccountRoutes(app);
|
||||
registerProjectRoutes(app);
|
||||
registerReaderRoutes(app);
|
||||
registerBibleRoutes(app, requireAuth);
|
||||
|
||||
app.get('/api/reading-plans', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getReadingProgress(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reading-plans error:', err);
|
||||
res.status(500).json({ error: 'Failed to load reading plan progress.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reading-plans — overwrite the current user's whole progress list
|
||||
// (an object: { progress: [...], customPlans: [...] })
|
||||
app.put('/api/reading-plans', requireAuth, (req, res) => {
|
||||
try {
|
||||
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||
return res.status(400).json({ error: 'Body must be an object.' });
|
||||
}
|
||||
setReadingProgress(req.session.userId, req.body);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reading-plans error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reading plan progress.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/reminder-test — sends a test notification to the topic in the request
|
||||
// body (so it can be tried before saving), or to the saved settings.
|
||||
const _lastReminderTest = new Map(); // userId -> timestamp, to keep test sends from being spammed
|
||||
app.post('/api/auth/reminder-test', requireAuth, async (req, res) => {
|
||||
const userId = req.session.userId;
|
||||
if (Date.now() - (_lastReminderTest.get(userId) ?? 0) < 5000) {
|
||||
return res.status(429).json({ error: 'Wait a few seconds before sending another test.' });
|
||||
}
|
||||
_lastReminderTest.set(userId, Date.now());
|
||||
const saved = getUserById(userId);
|
||||
const ntfyTopic = String(req.body?.ntfyTopic ?? saved?.ntfyTopic ?? '').trim();
|
||||
const ntfyUrl = String(req.body?.ntfyUrl ?? saved?.ntfyUrl ?? '').trim();
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'Enter a topic first.' });
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||||
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||||
}
|
||||
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||||
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||||
}
|
||||
try {
|
||||
await sendTestReminder({ ntfyTopic, ntfyUrl });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/projects/:id/share — current share status for the project owner
|
||||
app.get('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const project = getProject(req.params.id, req.session.userId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: getShareToken(req.params.id, req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/projects/:id/share — enable sharing, returns the (new or existing) token
|
||||
app.post('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const existing = getShareToken(req.params.id, req.session.userId);
|
||||
const token = existing || randomUUID().replace(/-/g, '');
|
||||
const ok = setShareToken(req.params.id, req.session.userId, token);
|
||||
if (!ok) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: token });
|
||||
} catch (err) {
|
||||
console.error('POST /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/projects/:id/share — revoke the share link
|
||||
app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
clearShareToken(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Read-only outline links. A separate token from the study share link, and the public endpoint returns
|
||||
// only the outlines (see outlineShare.js), never the notes or scripts.
|
||||
app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json({ shareToken: getOutlineShareToken(req.params.id, req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const token = getOutlineShareToken(req.params.id, req.session.userId) || randomUUID().replace(/-/g, '');
|
||||
if (!setOutlineShareToken(req.params.id, req.session.userId, token)) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: token });
|
||||
} catch (err) {
|
||||
console.error('POST /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
clearOutlineShareToken(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/share-outline/:token — PUBLIC, no login required
|
||||
app.get('/api/share-outline/:token', (req, res) => {
|
||||
try {
|
||||
const project = getProjectByOutlineToken(req.params.token);
|
||||
if (!project) return res.status(404).json({ error: 'This outline link is invalid or has been turned off.' });
|
||||
res.json(buildOutlinePayload(project));
|
||||
} catch (err) {
|
||||
console.error('GET /api/share-outline/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load the shared outline.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only
|
||||
app.get('/api/share/:token', (req, res) => {
|
||||
try {
|
||||
const project = getProjectByShareToken(req.params.token);
|
||||
if (!project) return res.status(404).json({ error: 'This share link is invalid or has been revoked.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/share/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin — restricted to the single designated admin account (see ADMIN_EMAIL
|
||||
// in server/auth.js). Full visibility/control over every user and project.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.json(adminGetAllUsers());
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/users error:', err);
|
||||
res.status(500).json({ error: 'Failed to list users.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
if (req.params.id === req.session.userId) {
|
||||
return res.status(400).json({ error: "Can't delete your own admin account." });
|
||||
}
|
||||
adminDeleteUser(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/admin/users/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete user.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/admin/users/:id/reset-password — sets a random temporary password and
|
||||
// signs the user out everywhere, since there's no self-service "forgot password" flow yet.
|
||||
app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const temporaryPassword = generateTemporaryPassword();
|
||||
const passwordHash = await hashPassword(temporaryPassword);
|
||||
setUserPassword(req.params.id, passwordHash);
|
||||
destroyAllSessionsForUser(req.params.id);
|
||||
res.json({ temporaryPassword });
|
||||
} catch (err) {
|
||||
console.error('POST /api/admin/users/:id/reset-password error:', err);
|
||||
res.status(500).json({ error: 'Failed to reset password.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.json(adminGetAllProjects());
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/projects error:', err);
|
||||
res.status(500).json({ error: 'Failed to list projects.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const project = adminGetProject(req.params.id);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load project.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
adminDeleteProject(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/admin/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete project.' });
|
||||
}
|
||||
});
|
||||
registerIqBibleRoutes(app, requireAuth);
|
||||
registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany: TOO_MANY });
|
||||
registerSharingRoutes(app);
|
||||
registerAdminRoutes(app);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serve Vite production build (when NODE_ENV=production)
|
||||
@@ -756,6 +116,7 @@ if (isProd) {
|
||||
// ---------------------------------------------------------------------------
|
||||
initDb();
|
||||
startReminderScheduler();
|
||||
startBackupScheduler();
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Bible Study API running on http://localhost:${PORT}`);
|
||||
if (isProd) {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Proxy for the IQ Bible API (https://api.iqbible.com): commentaries, lexicons, original-language text,
|
||||
// places, topics, devotionals, narrations and more. The API key stays on the server (IQBIBLE_API_KEY,
|
||||
// from the environment or .env); the browser only talks to /api/iq/... and only to the routes listed here.
|
||||
// Only verse/chapter text is cached (up to 24h, as the API's terms require); see isTextPath.
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
if (existsSync(envPath)) {
|
||||
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
||||
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
|
||||
}
|
||||
}
|
||||
|
||||
const BASE = () => (process.env.IQBIBLE_API_BASE || 'https://api.iqbible.com/api/v2').replace(/\/+$/, '');
|
||||
const S = '[\\w.~%-]+'; // one path segment
|
||||
const route = (pattern, opts = {}) => ({ re: new RegExp(`^${pattern.replace(/@/g, S)}/?$`), ...opts });
|
||||
|
||||
// Every upstream route the app may reach. Anything else is a 404 here, whatever the browser asks for.
|
||||
export const ROUTES = [
|
||||
route('/commentaries'), route('/commentaries/@/@/@'), route('/commentaries/@/@/@/@'),
|
||||
route('/books/@/info'), route('/books/@/chapters/@/info'), route('/books/@/commentaries'), route('/books/abbreviations'),
|
||||
route('/lexicon/sources'), route('/lexicon/crosswalk/@'),
|
||||
route('/lexicon/(?:greek|hebrew)/(?:strongs|bdb|lsj|abbott-smith)/@'), route('/lexicon/greek/lsj/word/@'),
|
||||
route('/original-language/@/@'), route('/original-language/@/@/@'), route('/morphology/@'),
|
||||
route('/geo/@/@'), route('/geo/@/@/@'), route('/geo/places/@'),
|
||||
route('/topics'), route('/topics/@'), route('/topics/@/@'), route('/topics/@/@/@'),
|
||||
route('/genealogies/@'), route('/bible-characters/@'), route('/bible-people/@'), route('/bible-people/@/@'),
|
||||
route('/harmony'), route('/harmony/@'), route('/textual-variants/@/@'), route('/textual-variants/@/@/@'),
|
||||
route('/chronology/for/@/@'), route('/reference/weights-measures'),
|
||||
route('/cross-references/graph'), route('/cross-references/@/@'), route('/cross-references/@/@/@'),
|
||||
route('/dictionaries'), route('/dictionaries/@'),
|
||||
route('/devotionals/today'), route('/devotionals/@/@'),
|
||||
route('/bibles'), route('/bibles/languages'), route('/bibles/parallel'), route('/bibles/@'), route('/bibles/@/@'), route('/bibles/@/verse-of-the-day'), route('/bibles/@/reading-plan'),
|
||||
route('/bibles/@/@/@/@'), route('/bibles/@/@/@'),
|
||||
route('/audio'), route('/audio/@/@/@', { cache: false }),
|
||||
route('/ai/context', { heavy: true }), route('/bibles/@/search', { heavy: true }), route('/parse/citations'),
|
||||
];
|
||||
|
||||
// Verse and chapter text is the one thing the API's terms let (and require) a client to cache, for up to 24h.
|
||||
// Everything else caps at 1 hour and needs written permission, so it is only cached when IQBIBLE_CACHE_HOURS
|
||||
// is set (do that only once permission is on file).
|
||||
const TEXT_PATH = /^\/bibles\/[\w]+\/(?!random\/|search|reading-plan|verse-of-the-day|verses|books|word-count)[\w.~%-]+\/\d+(?:\/[\d-]+)?$/;
|
||||
export const isTextPath = (p) => TEXT_PATH.test(p.split('?')[0]);
|
||||
|
||||
const QUERY_KEY = /^[\w-]{1,40}$/;
|
||||
|
||||
// Maps an incoming sub-path + query onto an upstream URL, or null when it isn't an allowed route.
|
||||
// The API key can never be supplied by the browser (apikey is dropped).
|
||||
export function resolveIqPath(path, query = {}) {
|
||||
const clean = `/${String(path ?? '').replace(/^\/+/, '')}`;
|
||||
if (clean.includes('..') || clean.includes('//') || clean.length > 300) return null;
|
||||
const hit = ROUTES.find((r) => r.re.test(clean));
|
||||
if (!hit) return null;
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (!QUERY_KEY.test(k) || k.toLowerCase() === 'apikey' || typeof v !== 'string' || v.length > 200) continue;
|
||||
qs.append(k, v);
|
||||
}
|
||||
const q = qs.toString();
|
||||
const key = `${clean}${q ? `?${q}` : ''}`;
|
||||
return { key, cache: hit.cache !== false, heavy: !!hit.heavy, text: isTextPath(clean) };
|
||||
}
|
||||
|
||||
// ---- caching, bounded by the API's own licensing summary ----
|
||||
let policy = null; // { textMs, otherMs }
|
||||
let policyAt = 0;
|
||||
async function cacheTtl(headers, isText) {
|
||||
if (!policy || Date.now() - policyAt > 6 * 3600_000) {
|
||||
try {
|
||||
const res = await fetch(`${BASE()}/licensing`, { headers, signal: AbortSignal.timeout(8000) });
|
||||
const s = (await res.json())?.summary;
|
||||
const allowed = s?.caching_allowed !== false;
|
||||
const textHours = allowed ? Math.min(Number(s?.caching_verse_chapter_hours) || 24, 24) : 0;
|
||||
const optIn = Number(process.env.IQBIBLE_CACHE_HOURS) || 0; // only with the API owner's written permission
|
||||
const otherHours = allowed ? Math.min(optIn, Number(s?.caching_max_hours) || 1) : 0;
|
||||
policy = { textMs: textHours * 3600_000, otherMs: otherHours * 3600_000 };
|
||||
} catch {
|
||||
policy = { textMs: 0, otherMs: 0 };
|
||||
}
|
||||
policyAt = Date.now();
|
||||
}
|
||||
return isText ? policy.textMs : policy.otherMs;
|
||||
}
|
||||
|
||||
const cache = new Map(); // key -> { at, status, type, body }
|
||||
const CACHE_MAX = 1500;
|
||||
|
||||
// Requests for the same thing that arrive while one is already on its way share that one call. Nothing is kept
|
||||
// afterwards (that would be caching), so this spends less of the allowance without touching the API's terms.
|
||||
const inflight = new Map(); // upstream path -> Promise of { status, type, body, retryAfter }
|
||||
function fetchOnce(key, headers) {
|
||||
let p = inflight.get(key);
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
const upstream = await fetch(`${BASE()}${key}`, { headers, signal: AbortSignal.timeout(20_000) });
|
||||
return {
|
||||
status: upstream.status, ok: upstream.ok, retryAfter: upstream.headers.get('retry-after'),
|
||||
type: upstream.headers.get('content-type') || 'application/json', body: Buffer.from(await upstream.arrayBuffer()),
|
||||
};
|
||||
})().finally(() => inflight.delete(key));
|
||||
inflight.set(key, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const hits = new Map(); // `${userId}:${bucket}` -> { n, resetAt }
|
||||
function overLimit(userId, heavy) {
|
||||
const bucket = heavy ? 'h' : 'n';
|
||||
const max = heavy ? 10 : 120; // per minute, per signed-in user (the API budget is shared by everyone)
|
||||
const k = `${userId}:${bucket}`;
|
||||
const now = Date.now();
|
||||
const e = hits.get(k);
|
||||
if (!e || now > e.resetAt) { hits.set(k, { n: 1, resetAt: now + 60_000 }); return false; }
|
||||
e.n += 1;
|
||||
return e.n > max;
|
||||
}
|
||||
|
||||
export function registerIqBibleRoutes(app, requireAuth) {
|
||||
const apiKey = () => process.env.IQBIBLE_API_KEY;
|
||||
|
||||
app.get('/api/iq/status', requireAuth, (_req, res) => res.json({ available: Boolean(apiKey()) }));
|
||||
|
||||
app.get('/api/iq/*', requireAuth, async (req, res) => {
|
||||
const target = resolveIqPath(req.params[0], req.query);
|
||||
if (!target) return res.status(404).json({ error: 'Unknown IQ Bible route.' });
|
||||
if (!apiKey()) return res.status(503).json({ error: 'IQBIBLE_API_KEY is not configured on the server.' });
|
||||
if (overLimit(req.session.userId, target.heavy)) return res.status(429).json({ error: 'Slow down a little and try again in a minute.' });
|
||||
|
||||
const headers = { 'X-IQBible-API-Key': apiKey(), Accept: '*/*' };
|
||||
const hit = target.cache && (target.text || process.env.IQBIBLE_CACHE_HOURS) ? cache.get(target.key) : null;
|
||||
if (hit && Date.now() - hit.at < hit.ttl) {
|
||||
res.status(hit.status).type(hit.type).send(hit.body);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const upstream = await fetchOnce(target.key, headers);
|
||||
const { body, type } = upstream;
|
||||
if (upstream.status === 401) return res.status(502).json({ error: 'The IQ Bible key was rejected. Check IQBIBLE_API_KEY.' });
|
||||
if (upstream.status === 429) {
|
||||
const retry = upstream.retryAfter;
|
||||
if (retry) res.set('Retry-After', retry);
|
||||
return res.status(429).json({ error: 'The IQ Bible allowance is used up for now. Try again later.' });
|
||||
}
|
||||
if (upstream.ok && target.cache && (target.text || process.env.IQBIBLE_CACHE_HOURS)) {
|
||||
const ttl = await cacheTtl(headers, target.text);
|
||||
if (ttl > 0) {
|
||||
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
|
||||
cache.set(target.key, { at: Date.now(), ttl, status: upstream.status, type, body });
|
||||
}
|
||||
}
|
||||
res.status(upstream.status).type(type).send(body);
|
||||
} catch {
|
||||
res.status(502).json({ error: 'Could not reach the IQ Bible service.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect, vi } from 'vitest';
|
||||
import { resolveIqPath } from './iqbible.js';
|
||||
|
||||
test('only listed IQ Bible routes are reachable', () => {
|
||||
expect(resolveIqPath('commentaries/gill/JHN/3/16')?.key).toBe('/commentaries/gill/JHN/3/16');
|
||||
expect(resolveIqPath('/geo/JHN/3')?.cache).toBe(true);
|
||||
expect(resolveIqPath('/me')).toBeNull();
|
||||
expect(resolveIqPath('/licensing')).toBeNull();
|
||||
expect(resolveIqPath('/commentaries/gill/../me')).toBeNull();
|
||||
expect(resolveIqPath('/lexicon/greek/strongs/G26')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('lexicon sources and audio routes', () => {
|
||||
expect(resolveIqPath('/lexicon/greek/lsj/G0026')).not.toBeNull();
|
||||
expect(resolveIqPath('/lexicon/hebrew/bdb/BDB1005')).not.toBeNull();
|
||||
expect(resolveIqPath('/audio/eng_kjv/JHN/3')?.cache).toBe(false);
|
||||
expect(resolveIqPath('/ai/context', { ref: 'JHN.3.16' })?.heavy).toBe(true);
|
||||
});
|
||||
|
||||
test('the browser cannot send its own key and the query is normalised', () => {
|
||||
const r = resolveIqPath('/bibles/eng_kjv/reading-plan', { days: '30', apikey: 'x', 'bad key': '1', format: 'ical' });
|
||||
expect(r.key).toBe('/bibles/eng_kjv/reading-plan?days=30&format=ical');
|
||||
});
|
||||
|
||||
test('only verse and chapter text counts as cacheable text', async () => {
|
||||
const { isTextPath } = await import('./iqbible.js');
|
||||
expect(isTextPath('/bibles/eng_kjv/JHN/3')).toBe(true);
|
||||
expect(isTextPath('/bibles/eng_kjv/JHN/3/16')).toBe(true);
|
||||
expect(isTextPath('/bibles/eng_kjv/random/verse')).toBe(false);
|
||||
expect(isTextPath('/bibles/eng_kjv/verse-of-the-day')).toBe(false);
|
||||
expect(isTextPath('/commentaries/gill/JHN/3')).toBe(false);
|
||||
expect(resolveIqPath('/bibles/parallel', { v: 'eng_kjv,spa_rv' })).not.toBeNull();
|
||||
});
|
||||
|
||||
test('identical requests made at the same moment share one upstream call, and nothing is kept afterwards', async () => {
|
||||
vi.resetModules();
|
||||
process.env.IQBIBLE_API_KEY = 'k';
|
||||
const realFetch = globalThis.fetch;
|
||||
let upstreamCalls = 0;
|
||||
vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
||||
if (String(url).startsWith('http://localhost')) return realFetch(url, opts); // the test's own requests
|
||||
upstreamCalls += 1;
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
return new Response(JSON.stringify({ data: 1 }), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}));
|
||||
const { registerIqBibleRoutes } = await import('./iqbible.js');
|
||||
const express = (await import('express')).default;
|
||||
const app = express();
|
||||
app.use((req, _res, next) => { req.session = { userId: 'u1' }; next(); });
|
||||
registerIqBibleRoutes(app, (_req, _res, next) => next());
|
||||
const srv = await new Promise((resolve) => { const s = app.listen(0, () => resolve(s)); });
|
||||
const url = `http://localhost:${srv.address().port}/api/iq/devotionals/today`;
|
||||
try {
|
||||
const together = await Promise.all([realFetch(url), realFetch(url), realFetch(url)]);
|
||||
expect(together.map((r) => r.status)).toEqual([200, 200, 200]);
|
||||
expect(upstreamCalls).toBe(1);
|
||||
await realFetch(url); // afterwards: not remembered, so it asks again
|
||||
expect(upstreamCalls).toBe(2);
|
||||
} finally { srv.close(); vi.unstubAllGlobals(); delete process.env.IQBIBLE_API_KEY; }
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// In-memory limiter for sign-in style endpoints: 10 FAILED attempts per 15 min, tracked per IP and per
|
||||
// account (key). Successful sign-ins don't count, so a shared IP doesn't lock everyone out.
|
||||
const _failures = new Map();
|
||||
const FAIL_WINDOW = 15 * 60 * 1000;
|
||||
const FAIL_MAX = 10;
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, e] of _failures) if (now > e.resetAt) _failures.delete(k);
|
||||
}, 60 * 60 * 1000).unref();
|
||||
export function isLimited(...keys) {
|
||||
const now = Date.now();
|
||||
return keys.some((k) => { const e = _failures.get(k); return e && now <= e.resetAt && e.count >= FAIL_MAX; });
|
||||
}
|
||||
export function recordFailure(...keys) {
|
||||
const now = Date.now();
|
||||
for (const k of keys) {
|
||||
const e = _failures.get(k);
|
||||
if (!e || now > e.resetAt) _failures.set(k, { count: 1, resetAt: now + FAIL_WINDOW });
|
||||
else e.count += 1;
|
||||
}
|
||||
}
|
||||
export function clearFailures(...keys) { keys.forEach((k) => _failures.delete(k)); }
|
||||
export const TOO_MANY = { error: 'Too many attempts. Please wait 15 minutes.' };
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -24,6 +24,21 @@ export function cleanPrefValue(key, value) {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (key === 'studyTemplates') {
|
||||
// The person's own study templates: prompts for the note boxes and scaffold text to start each chunk with
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const text = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
|
||||
const fields = (o, names, max) => {
|
||||
const out = {};
|
||||
for (const n of names) { const t = text(o?.[n], max); if (t.trim()) out[n] = t; }
|
||||
return out;
|
||||
};
|
||||
return value.slice(0, 12).filter((t) => t && typeof t.id === 'string' && /^user-[\w-]{1,40}$/.test(t.id) && typeof t.name === 'string' && t.name.trim()).map((t) => {
|
||||
const prompts = fields(t.prompts, ['observation', 'interpretation', 'application'], 800);
|
||||
const starters = fields(t.starters, ['generalNotes', 'observation', 'interpretation', 'application'], 2000);
|
||||
return { id: t.id, name: text(t.name, 40).trim(), description: text(t.description, 120), ...(Object.keys(prompts).length ? { prompts } : {}), ...(Object.keys(starters).length ? { starters } : {}) };
|
||||
});
|
||||
}
|
||||
if (key === 'position') {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const book = String(value.book ?? '').toUpperCase();
|
||||
|
||||
@@ -67,3 +67,14 @@ test('the database keeps one row per user', async () => {
|
||||
expect(db.getUserPrefs('u1')).toEqual({ theme: { v: 'sepia', at: 2 } });
|
||||
expect(db.getUserPrefs('u2')).toEqual({});
|
||||
});
|
||||
|
||||
test('study templates are kept only when well-formed, and trimmed to sane sizes', async () => {
|
||||
const { cleanPrefValue } = await import('./prefs.js');
|
||||
const good = { id: 'user-abc1', name: ' Lesson ', description: 'x', prompts: { observation: 'Look', junk: 'no' }, starters: { generalNotes: 'Context:', bogus: 'no' } };
|
||||
expect(cleanPrefValue('studyTemplates', [good, { id: 'other', name: 'bad id' }, { id: 'user-2' }, null])).toEqual([
|
||||
{ id: 'user-abc1', name: 'Lesson', description: 'x', prompts: { observation: 'Look' }, starters: { generalNotes: 'Context:' } },
|
||||
]);
|
||||
expect(cleanPrefValue('studyTemplates', 'nope')).toBeUndefined();
|
||||
expect(cleanPrefValue('studyTemplates', Array.from({ length: 30 }, (_, i) => ({ id: `user-${i}`, name: `T${i}` }))).length).toBe(12);
|
||||
expect(cleanPrefValue('studyTemplates', [{ id: 'user-a', name: 'x', starters: { observation: 'y'.repeat(5000) } }])[0].starters.observation.length).toBe(2000);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// What a person has read in the Reader: which chapters (how many times, and when last) and on which days.
|
||||
// Merging two copies never loses anything, so two devices can both add to it: each chapter keeps the larger
|
||||
// count and the later date, and the days are combined. The data comes from the browser, so it is rebuilt field by field.
|
||||
const KEY = /^[1-3A-Z]{3}\.\d{1,3}$/;
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const MAX_DAYS = 5000;
|
||||
|
||||
export const emptyHistory = () => ({ chapters: {}, days: [] });
|
||||
|
||||
export function cleanHistory(raw) {
|
||||
const chapters = {};
|
||||
if (raw?.chapters && typeof raw.chapters === 'object' && !Array.isArray(raw.chapters)) {
|
||||
for (const [key, v] of Object.entries(raw.chapters).slice(0, 1300)) {
|
||||
if (!KEY.test(key) || !v || typeof v !== 'object') continue;
|
||||
const n = Number.isInteger(v.n) ? Math.min(9999, Math.max(1, v.n)) : 1;
|
||||
chapters[key] = { n, last: DATE.test(v.last ?? '') ? v.last : '' };
|
||||
}
|
||||
}
|
||||
const days = Array.isArray(raw?.days) ? [...new Set(raw.days.filter((d) => typeof d === 'string' && DATE.test(d)))].sort().slice(-MAX_DAYS) : [];
|
||||
return { chapters, days };
|
||||
}
|
||||
|
||||
export function mergeHistory(a, b) {
|
||||
const x = cleanHistory(a);
|
||||
const y = cleanHistory(b);
|
||||
const chapters = { ...x.chapters };
|
||||
for (const [key, v] of Object.entries(y.chapters)) {
|
||||
const mine = chapters[key];
|
||||
chapters[key] = mine ? { n: Math.max(mine.n, v.n), last: mine.last > v.last ? mine.last : v.last } : v;
|
||||
}
|
||||
return { chapters, days: [...new Set([...x.days, ...y.days])].sort().slice(-MAX_DAYS) };
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { cleanHistory, mergeHistory } from './readerHistory.js';
|
||||
|
||||
test('only well-formed chapters and days are kept', () => {
|
||||
const out = cleanHistory({
|
||||
chapters: { 'JHN.3': { n: 2, last: '2026-10-01' }, 'bad': { n: 1 }, 'GEN.1': { n: -5, last: 'yesterday' }, '1JN.2': null },
|
||||
days: ['2026-10-02', 'nope', '2026-10-01', '2026-10-01'],
|
||||
});
|
||||
expect(out.chapters).toEqual({ 'JHN.3': { n: 2, last: '2026-10-01' }, 'GEN.1': { n: 1, last: '' } });
|
||||
expect(out.days).toEqual(['2026-10-01', '2026-10-02']);
|
||||
expect(cleanHistory(null)).toEqual({ chapters: {}, days: [] });
|
||||
});
|
||||
|
||||
test('merging keeps the larger count, the later date and every day', () => {
|
||||
const a = { chapters: { 'JHN.3': { n: 2, last: '2026-10-01' }, 'GEN.1': { n: 1, last: '2026-09-01' } }, days: ['2026-10-01'] };
|
||||
const b = { chapters: { 'JHN.3': { n: 1, last: '2026-10-05' }, 'ROM.8': { n: 1, last: '2026-10-04' } }, days: ['2026-10-04', '2026-10-05'] };
|
||||
const m = mergeHistory(a, b);
|
||||
expect(m.chapters['JHN.3']).toEqual({ n: 2, last: '2026-10-05' });
|
||||
expect(Object.keys(m.chapters).sort()).toEqual(['GEN.1', 'JHN.3', 'ROM.8']);
|
||||
expect(m.days).toEqual(['2026-10-01', '2026-10-04', '2026-10-05']);
|
||||
expect(mergeHistory(m, m)).toEqual(m); // merging again changes nothing
|
||||
});
|
||||
+14
-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();
|
||||
@@ -36,8 +45,12 @@ export function cleanMarks(body) {
|
||||
};
|
||||
// The highlighted words, kept so lists of highlights can show them without loading the chapter
|
||||
if (typeof h.text === 'string' && h.text.length > 0) entry.text = h.text.slice(0, 500);
|
||||
// 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);
|
||||
@@ -58,3 +58,29 @@ test('the database keeps one row per user and updatedAt only moves forward', asy
|
||||
expect(saved.highlights).toHaveLength(1);
|
||||
expect(db.getReaderMarks('u2').updatedAt).toBe(0); // other users are untouched
|
||||
});
|
||||
|
||||
test('a highlight keeps the translation it was made in and whether it covers the whole verse', async () => {
|
||||
const { cleanMarks } = await import('./readerMarks.js');
|
||||
const base = { id: 'a', book: 'JHN', chapter: 3, verse: 16, startOffset: 0, endOffset: 40, color: '#fef08a', text: 'For God so loved' };
|
||||
const out = cleanMarks({ highlights: [
|
||||
{ ...base, translation: 'BSB', whole: true },
|
||||
{ ...base, id: 'b', translation: 'not a code!!', whole: 'yes' },
|
||||
] }).highlights;
|
||||
expect(out[0]).toMatchObject({ translation: 'BSB', whole: true });
|
||||
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);
|
||||
});
|
||||
+107
-3
@@ -2,12 +2,51 @@
|
||||
// a free pub/sub push service: POST a message to a topic URL, anyone
|
||||
// subscribed to that topic (via the ntfy app or a browser) gets notified.
|
||||
// No API keys or push-subscription plumbing needed.
|
||||
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js';
|
||||
import { lookup } from 'dns/promises';
|
||||
import { isIP } from 'net';
|
||||
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate, getUsersWithPrayerReminder, setLastPrayerDate, getPrayers } from './db.js';
|
||||
import { openPrayers } from './prayers.js';
|
||||
import { logEvent } from './health.js';
|
||||
import { fetchVerseOfDay, votdMessage } from './votd.js';
|
||||
|
||||
// A reminder still goes out if the server was briefly down (or restarting) at the
|
||||
// exact minute — as long as it's within this window after the chosen time.
|
||||
const GRACE_MINUTES = 180;
|
||||
const SEND_TIMEOUT_MS = 10_000;
|
||||
// A failed send is retried at most this often, so a dead ntfy server isn't hit every minute for hours.
|
||||
const RETRY_AFTER_MS = 15 * 60 * 1000;
|
||||
const _lastAttempt = new Map(); // userId -> timestamp of the last failed send
|
||||
|
||||
// True for loopback, private, link-local and other non-public addresses (IPv4 and IPv6).
|
||||
export function isPrivateAddress(addr) {
|
||||
const v = isIP(addr);
|
||||
if (v === 4) {
|
||||
const [a, b] = addr.split('.').map(Number);
|
||||
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && b === 168) || (a === 100 && b >= 64 && b <= 127) || a >= 224;
|
||||
}
|
||||
if (v === 6) {
|
||||
const l = addr.toLowerCase();
|
||||
if (l === '::' || l === '::1') return true;
|
||||
if (l.startsWith('::ffff:')) return isPrivateAddress(l.slice(7));
|
||||
return /^f[cd]/.test(l) || /^fe[89ab]/.test(l);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rejects server URLs that would make this server call itself or its private network.
|
||||
// A self-hosted ntfy on the LAN can be allowed by the server's admin with ALLOW_PRIVATE_NTFY=1.
|
||||
export async function assertPublicUrl(raw) {
|
||||
if (process.env.ALLOW_PRIVATE_NTFY === '1') return;
|
||||
let u;
|
||||
try { u = new URL(raw); } catch { throw new Error('Server URL is not valid.'); }
|
||||
if (!/^https?:$/.test(u.protocol)) throw new Error('Server URL must start with http:// or https://.');
|
||||
const host = u.hostname.replace(/^\[|\]$/g, '');
|
||||
if (/^localhost$|\.localhost$|\.local$|\.internal$/i.test(host)) throw new Error('Server URL must be a public address.');
|
||||
const addrs = isIP(host) ? [host] : (await lookup(host, { all: true }).catch(() => [])).map((a) => a.address);
|
||||
if (addrs.length === 0) throw new Error(`Could not find ${host}.`);
|
||||
if (addrs.some(isPrivateAddress)) throw new Error('Server URL must be a public address.');
|
||||
}
|
||||
|
||||
export function isValidTimeZone(tz) {
|
||||
if (!tz) return false;
|
||||
@@ -40,17 +79,23 @@ export function isDue(user, now = new Date()) {
|
||||
export async function sendNtfy(user, { title, body, tags = 'open_book' }) {
|
||||
const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||
const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`;
|
||||
await assertPublicUrl(url);
|
||||
const headers = { Title: title, Tags: tags };
|
||||
if (process.env.APP_URL) headers.Click = process.env.APP_URL; // tapping the notification opens the app
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
|
||||
res = await fetch(url, { method: 'POST', headers, body, redirect: 'error', signal: AbortSignal.timeout(SEND_TIMEOUT_MS) });
|
||||
} catch (err) {
|
||||
throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`);
|
||||
throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`, { cause: err });
|
||||
}
|
||||
if (!res.ok) throw new Error(`ntfy responded ${res.status}`);
|
||||
}
|
||||
|
||||
// Has the user already finished a reading day on `date` ("YYYY-MM-DD", in their timezone)?
|
||||
export function readToday(progress, date) {
|
||||
return Array.isArray(progress?.activity) && progress.activity.includes(date);
|
||||
}
|
||||
|
||||
export function reminderBody(progress) {
|
||||
const active = (Array.isArray(progress?.progress) ? progress.progress : []).filter((p) => !p.completedAt).length;
|
||||
return active > 1 ? `Don't forget today's reading! You have ${active} plans in progress.` : "Don't forget today's reading!";
|
||||
@@ -67,10 +112,67 @@ export async function checkReminders(now = new Date()) {
|
||||
const progress = getReadingProgress(user.id);
|
||||
const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt);
|
||||
if (!hasActivePlan) continue; // nothing to nudge them about
|
||||
if (readToday(progress, zonedNow(now, user.reminderTz).date)) continue; // already read today: no nudge
|
||||
if (now.getTime() - (_lastAttempt.get(user.id) ?? 0) < RETRY_AFTER_MS) continue;
|
||||
_lastAttempt.set(user.id, now.getTime());
|
||||
await sendNtfy(user, { title: 'Reading Plan Reminder', body: reminderBody(progress) });
|
||||
_lastAttempt.delete(user.id);
|
||||
setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date);
|
||||
} catch (err) {
|
||||
console.error(`Reading reminder failed for user ${user.id}:`, err.message);
|
||||
logEvent('notify', `Reading reminder: ${err.message}`, { userId: user.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sends the verse of the day to everyone who asked for it, at their own time. One verse a day, the same for all.
|
||||
export async function checkVotd(now = new Date()) {
|
||||
const due = getUsersWithVotdEnabled().filter((u) => isDue({ reminderTime: u.votdTime, reminderTz: u.reminderTz, lastReminderDate: u.lastVotdDate }, now));
|
||||
if (due.length === 0) return;
|
||||
const verse = await fetchVerseOfDay(now);
|
||||
if (!verse) return; // no key, or IQ Bible didn't answer: try again next minute (within the grace window)
|
||||
for (const user of due) {
|
||||
const key = `votd:${user.id}`;
|
||||
if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue;
|
||||
_lastAttempt.set(key, now.getTime());
|
||||
try {
|
||||
await sendNtfy(user, votdMessage(verse));
|
||||
_lastAttempt.delete(key);
|
||||
setLastVotdDate(user.id, zonedNow(now, user.reminderTz).date);
|
||||
} catch (err) {
|
||||
console.error(`Verse of the day failed for user ${user.id}:`, err.message);
|
||||
logEvent('notify', `Verse of the day: ${err.message}`, { userId: user.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
logEvent('notify', `Prayer reminder: ${err.message}`, { userId: user.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,5 +181,7 @@ export async function checkReminders(now = new Date()) {
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment node
|
||||
import http from 'node:http';
|
||||
import { test, expect, afterEach } from 'vitest';
|
||||
process.env.ALLOW_PRIVATE_NTFY = '1'; // the tests talk to a local server
|
||||
import { isDue, isValidTimeZone, reminderBody, sendNtfy, sendTestReminder, zonedNow } from './reminders.js';
|
||||
|
||||
const at = (iso) => new Date(iso);
|
||||
@@ -84,3 +85,29 @@ test('an ntfy error response becomes a readable failure', async () => {
|
||||
test('an unreachable server says so instead of hanging', async () => {
|
||||
await expect(sendNtfy({ ntfyUrl: 'http://127.0.0.1:9', ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow(/Could not reach/);
|
||||
});
|
||||
|
||||
test('private and loopback addresses are rejected as reminder servers', async () => {
|
||||
const { isPrivateAddress, assertPublicUrl, readToday } = await import('./reminders.js');
|
||||
for (const a of ['127.0.0.1', '10.1.2.3', '192.168.0.5', '172.16.0.1', '169.254.169.254', '::1', 'fd00::1', '::ffff:10.0.0.1']) expect(isPrivateAddress(a)).toBe(true);
|
||||
expect(isPrivateAddress('8.8.8.8')).toBe(false);
|
||||
const prev = process.env.ALLOW_PRIVATE_NTFY;
|
||||
delete process.env.ALLOW_PRIVATE_NTFY;
|
||||
await expect(assertPublicUrl('http://localhost:3001')).rejects.toThrow();
|
||||
await expect(assertPublicUrl('http://169.254.169.254/latest')).rejects.toThrow();
|
||||
await expect(assertPublicUrl('ftp://example.com')).rejects.toThrow();
|
||||
process.env.ALLOW_PRIVATE_NTFY = prev;
|
||||
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,90 @@
|
||||
anon GET /api/health 200
|
||||
anon GET /api/auth/signup-mode 200
|
||||
anon GET /api/auth/me 401
|
||||
anon GET /api/projects 401
|
||||
anon GET /api/admin/users 401
|
||||
admin POST /api/auth/register 200
|
||||
sam POST /api/auth/register 200
|
||||
anon POST /api/auth/register 400
|
||||
anon POST /api/auth/login 401
|
||||
admin POST /api/auth/login 200
|
||||
admin GET /api/auth/me 200
|
||||
sam GET /api/auth/me 200
|
||||
anon POST /api/auth/forgot 200
|
||||
anon POST /api/auth/reset 400
|
||||
admin PATCH /api/auth/profile 200
|
||||
admin PATCH /api/auth/reminder-settings 400
|
||||
admin POST /api/auth/reminder-test 400
|
||||
admin POST /api/auth/change-password 401
|
||||
admin POST /api/auth/mfa/setup 200
|
||||
admin POST /api/auth/mfa/enable 401
|
||||
anon POST /api/auth/mfa/verify 400
|
||||
admin GET /api/account/export 200
|
||||
admin PUT /api/projects/s1 200
|
||||
admin PUT /api/projects/s1 400
|
||||
admin PUT /api/projects/s1 400
|
||||
admin GET /api/projects 200
|
||||
admin GET /api/projects/s1 200
|
||||
sam GET /api/projects/s1 404
|
||||
admin PUT /api/projects/s1 409
|
||||
admin GET /api/projects/s1/versions 200
|
||||
admin GET /api/projects/s1/versions/999 404
|
||||
admin POST /api/projects/s1/versions/999/restore 404
|
||||
sam GET /api/projects/s1/versions 404
|
||||
admin PUT /api/reader/ink/ROM/8 200
|
||||
admin PUT /api/reader/ink/ROM/8 400
|
||||
admin PUT /api/reader/ink/ROMANS/8 400
|
||||
admin GET /api/reader/ink 200
|
||||
admin PUT /api/prefs 200
|
||||
admin GET /api/prefs 200
|
||||
admin PUT /api/reader/marks 200
|
||||
admin GET /api/reader/marks 200
|
||||
admin PUT /api/reader/marks 400
|
||||
admin PUT /api/prayers 200
|
||||
admin GET /api/prayers 200
|
||||
admin PUT /api/reader/history 200
|
||||
admin GET /api/reader/history 200
|
||||
admin PUT /api/reading-plans 200
|
||||
admin PUT /api/reading-plans 400
|
||||
admin GET /api/reading-plans 200
|
||||
admin GET /api/iq/status 200
|
||||
admin GET /api/iq/nope 404
|
||||
admin GET /api/passkeys 200
|
||||
admin GET /api/sessions 200
|
||||
admin POST /api/auth/passkey/options 200
|
||||
anon GET /api/bible/nope 404
|
||||
admin POST /api/projects/s1/share 200
|
||||
admin GET /api/projects/s1/share 200
|
||||
anon GET /api/share/:token 200
|
||||
anon GET /api/share/doesnotexist 404
|
||||
admin POST /api/projects/s1/outline-share 200
|
||||
anon GET /api/share-outline/:token 200
|
||||
admin GET /api/projects/s1/outline-share 200
|
||||
admin DELETE /api/projects/s1/outline-share 200
|
||||
anon GET /api/share-outline/:token 404
|
||||
admin DELETE /api/projects/s1/share 200
|
||||
admin POST /api/projects/s1/collaborators/invite 200
|
||||
sam POST /api/join/:token 200
|
||||
admin GET /api/projects/s1/collaborators 200
|
||||
sam GET /api/projects/s1/versions 200
|
||||
admin DELETE /api/projects/s1/collaborators/:id 200
|
||||
sam GET /api/admin/users 403
|
||||
admin GET /api/admin/users 200
|
||||
admin GET /api/admin/projects 200
|
||||
admin GET /api/admin/projects/s1 200
|
||||
admin GET /api/admin/signup 200
|
||||
admin PUT /api/admin/signup 400
|
||||
admin PUT /api/admin/signup 200
|
||||
admin POST /api/admin/invites 200
|
||||
admin DELETE /api/admin/invites/:code 200
|
||||
admin GET /api/admin/health 200
|
||||
admin POST /api/admin/backups 200
|
||||
admin GET /api/admin/backups/nope.db 404
|
||||
admin POST /api/admin/backups/projects-2000-01-01-00-00.db/restore 404
|
||||
sam GET /api/auth/me 200
|
||||
admin POST /api/admin/users/:id/reset-password 200
|
||||
sam GET /api/auth/me 401
|
||||
admin DELETE /api/admin/users/:id 200
|
||||
admin DELETE /api/projects/s1 200
|
||||
admin POST /api/auth/logout 200
|
||||
admin GET /api/auth/me 401
|
||||
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment node
|
||||
// Starts the real server (server/index.js) on a spare port with a throwaway data folder and walks the whole API
|
||||
// the way the app does: accounts, studies, synced reader data, sharing, co-teaching, admin. It records the status of
|
||||
// every call, so reorganising the server's files can't quietly change what any route answers.
|
||||
import { afterAll, beforeAll, expect, test } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createServer } from 'net';
|
||||
|
||||
const ADMIN = 'admin@x.test';
|
||||
let proc; let dir; let base;
|
||||
const jars = {}; // who -> cookie
|
||||
const log = [];
|
||||
|
||||
const freePort = () => new Promise((resolve) => { const s = createServer(); s.listen(0, () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
|
||||
|
||||
async function call(who, method, path, body, headers = {}) {
|
||||
const res = await fetch(base + path, {
|
||||
method, headers: { 'Content-Type': 'application/json', ...(jars[who] ? { Cookie: jars[who] } : {}), ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual',
|
||||
});
|
||||
const set = res.headers.get('set-cookie');
|
||||
if (set) jars[who] = set.split(';')[0];
|
||||
const text = await res.text();
|
||||
let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ }
|
||||
log.push(`${who} ${method} ${path.replace(/[0-9a-f]{20,}/g, ':token').replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, ':id').replace(/[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}/g, ':code')} ${res.status}`);
|
||||
return { status: res.status, json, text };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'smoke-'));
|
||||
const port = await freePort();
|
||||
base = `http://127.0.0.1:${port}`;
|
||||
proc = spawn('node', ['server/index.js'], {
|
||||
env: { ...process.env, PORT: String(port), DATA_DIR: dir, ADMIN_EMAIL: ADMIN, SESSION_SECRET: 'smoke', BACKUP_DISABLED: '1', NODE_ENV: 'development', IQBIBLE_API_KEY: '' },
|
||||
stdio: 'ignore',
|
||||
});
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
try { if ((await fetch(`${base}/api/health`)).ok) return; } catch { /* not up yet */ }
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
throw new Error('server did not start');
|
||||
}, 20000);
|
||||
afterAll(() => { proc?.kill(); rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
const study = (id, title = 'Romans') => ({ id, title, translation: 'BSB', lastEdited: 1, chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'x' }], chunks: [] }] });
|
||||
|
||||
test('the whole API answers the way it always has', async () => {
|
||||
// ---- public ----
|
||||
expect((await call('anon', 'GET', '/api/health')).status).toBe(200);
|
||||
expect((await call('anon', 'GET', '/api/auth/signup-mode')).json.mode).toBe('open');
|
||||
expect((await call('anon', 'GET', '/api/auth/me')).status).toBe(401);
|
||||
expect((await call('anon', 'GET', '/api/projects')).status).toBe(401);
|
||||
expect((await call('anon', 'GET', '/api/admin/users')).status).toBe(401);
|
||||
|
||||
// ---- accounts ----
|
||||
expect((await call('admin', 'POST', '/api/auth/register', { email: ADMIN, password: 'correct-horse-1' })).status).toBe(200);
|
||||
expect((await call('sam', 'POST', '/api/auth/register', { email: 'sam@x.test', password: 'correct-horse-2' })).status).toBe(200);
|
||||
expect((await call('anon', 'POST', '/api/auth/register', { email: 'bad', password: 'x' })).status).toBe(400);
|
||||
expect((await call('anon', 'POST', '/api/auth/login', { email: ADMIN, password: 'wrong-password' })).status).toBe(401);
|
||||
expect((await call('admin', 'POST', '/api/auth/login', { email: ADMIN, password: 'correct-horse-1' })).status).toBe(200);
|
||||
const me = await call('admin', 'GET', '/api/auth/me');
|
||||
expect(me.json).toMatchObject({ email: ADMIN, isAdmin: true, totpEnabled: false, passkeyCount: 0 });
|
||||
expect((await call('sam', 'GET', '/api/auth/me')).json.isAdmin).toBe(false);
|
||||
expect((await call('anon', 'POST', '/api/auth/forgot', { email: 'sam@x.test' })).json.ok).toBe(true);
|
||||
expect((await call('anon', 'POST', '/api/auth/reset', { email: 'sam@x.test', code: 'nope', newPassword: 'another-pass-3' })).status).toBe(400);
|
||||
expect((await call('admin', 'PATCH', '/api/auth/profile', { podcastName: 'My Show' })).status).toBe(200);
|
||||
expect((await call('admin', 'PATCH', '/api/auth/reminder-settings', { reminderEnabled: true, ntfyTopic: '', reminderTime: '08:00' })).status).toBe(400);
|
||||
expect((await call('admin', 'POST', '/api/auth/reminder-test', { ntfyTopic: 'bad topic!' })).status).toBe(400);
|
||||
expect((await call('admin', 'POST', '/api/auth/change-password', { currentPassword: 'nope', newPassword: 'brand-new-pass-4' })).status).toBe(401);
|
||||
expect((await call('admin', 'POST', '/api/auth/mfa/setup')).json.secret).toBeTruthy();
|
||||
expect((await call('admin', 'POST', '/api/auth/mfa/enable', { token: '000000' })).status).toBe(401);
|
||||
expect((await call('anon', 'POST', '/api/auth/mfa/verify', { token: '000000' })).status).toBe(400);
|
||||
expect((await call('admin', 'GET', '/api/account/export')).json.app).toBeTruthy();
|
||||
|
||||
// ---- studies ----
|
||||
expect((await call('admin', 'PUT', '/api/projects/s1', study('s1'))).status).toBe(200);
|
||||
expect((await call('admin', 'PUT', '/api/projects/s1', study('other'))).status).toBe(400);
|
||||
expect((await call('admin', 'PUT', '/api/projects/s1', { id: 's1' })).status).toBe(400);
|
||||
expect((await call('admin', 'GET', '/api/projects')).json).toHaveLength(1);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1')).json.title).toBe('Romans');
|
||||
expect((await call('sam', 'GET', '/api/projects/s1')).status).toBe(404);
|
||||
expect((await call('admin', 'PUT', '/api/projects/s1', { ...study('s1', 'Romans 8'), lastEdited: 2 }, { 'X-Base-Edited': '1' })).status).toBe(409); // another device saved since
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/versions')).json.versions).toBeInstanceOf(Array);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/versions/999')).status).toBe(404);
|
||||
expect((await call('admin', 'POST', '/api/projects/s1/versions/999/restore')).status).toBe(404);
|
||||
expect((await call('sam', 'GET', '/api/projects/s1/versions')).status).toBe(404);
|
||||
|
||||
// ---- synced reader data ----
|
||||
expect((await call('admin', 'PUT', '/api/reader/ink/ROM/8', { strokes: [{ p: 1 }] })).status).toBe(200);
|
||||
expect((await call('admin', 'PUT', '/api/reader/ink/ROM/8', { strokes: 'no' })).status).toBe(400);
|
||||
expect((await call('admin', 'PUT', '/api/reader/ink/ROMANS/8', { strokes: [] })).status).toBe(400);
|
||||
expect(Object.keys((await call('admin', 'GET', '/api/reader/ink')).json)).toHaveLength(1);
|
||||
expect((await call('admin', 'PUT', '/api/prefs', { prefs: { theme: { v: 'dark', at: 5 } } })).json.prefs.theme.v).toBe('dark');
|
||||
expect((await call('admin', 'GET', '/api/prefs')).json.prefs.theme.v).toBe('dark');
|
||||
expect((await call('admin', 'PUT', '/api/reader/marks', { bookmarks: { 'JHN-3-16': '#fef08a' }, highlights: [] })).json.ok).toBe(true);
|
||||
expect((await call('admin', 'GET', '/api/reader/marks')).json.bookmarks['JHN-3-16']).toBe('#fef08a');
|
||||
expect((await call('admin', 'PUT', '/api/reader/marks', 'x')).status).toBe(400);
|
||||
expect((await call('admin', 'PUT', '/api/prayers', { prayers: [{ id: 'p1', text: 'Peace', createdAt: 1, updatedAt: 2 }] })).json.prayers).toHaveLength(1);
|
||||
expect((await call('admin', 'GET', '/api/prayers')).json.prayers).toHaveLength(1);
|
||||
expect((await call('admin', 'PUT', '/api/reader/history', { 'JHN-3': 1 })).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/reader/history')).status).toBe(200);
|
||||
expect((await call('admin', 'PUT', '/api/reading-plans', { progress: [{ id: 'r1' }], customPlans: [], activity: ['2026-10-01'] })).json.ok).toBe(true);
|
||||
expect((await call('admin', 'PUT', '/api/reading-plans', { progress: 'no' })).status).toBe(400);
|
||||
expect((await call('admin', 'GET', '/api/reading-plans')).json.progress).toHaveLength(1);
|
||||
|
||||
// ---- other areas mounted on the app ----
|
||||
expect((await call('admin', 'GET', '/api/iq/status')).json.available).toBe(false);
|
||||
expect((await call('admin', 'GET', '/api/iq/nope')).status).toBe(404);
|
||||
expect((await call('admin', 'GET', '/api/passkeys')).json).toEqual([]);
|
||||
expect((await call('admin', 'GET', '/api/sessions')).json).toHaveLength(1);
|
||||
expect((await call('admin', 'POST', '/api/auth/passkey/options')).json.challenge).toBeTruthy();
|
||||
expect((await call('anon', 'GET', '/api/bible/nope')).status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// ---- sharing ----
|
||||
const share = await call('admin', 'POST', '/api/projects/s1/share', {});
|
||||
expect(share.status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/share')).status).toBe(200);
|
||||
expect((await call('anon', 'GET', `/api/share/${share.json.shareToken}`)).status).toBe(200);
|
||||
expect((await call('anon', 'GET', '/api/share/doesnotexist')).status).toBe(404);
|
||||
const outline = await call('admin', 'POST', '/api/projects/s1/outline-share', {});
|
||||
expect((await call('anon', 'GET', `/api/share-outline/${outline.json.shareToken}`)).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/outline-share')).status).toBe(200);
|
||||
expect((await call('admin', 'DELETE', '/api/projects/s1/outline-share')).status).toBe(200);
|
||||
expect((await call('anon', 'GET', `/api/share-outline/${outline.json.shareToken}`)).status).toBe(404);
|
||||
expect((await call('admin', 'DELETE', '/api/projects/s1/share')).status).toBe(200);
|
||||
const invite = await call('admin', 'POST', '/api/projects/s1/collaborators/invite');
|
||||
expect(invite.status).toBe(200);
|
||||
expect((await call('sam', 'POST', `/api/join/${invite.json.token}`)).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/projects/s1/collaborators')).status).toBe(200);
|
||||
expect((await call('sam', 'GET', '/api/projects/s1/versions')).status).toBe(200);
|
||||
expect((await call('admin', 'DELETE', `/api/projects/s1/collaborators/${me.json.id}`)).status).toBe(200);
|
||||
|
||||
// ---- admin ----
|
||||
expect((await call('sam', 'GET', '/api/admin/users')).status).toBe(403);
|
||||
expect((await call('admin', 'GET', '/api/admin/users')).json).toHaveLength(2);
|
||||
expect((await call('admin', 'GET', '/api/admin/projects')).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/admin/projects/s1')).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/admin/signup')).json.mode).toBe('open');
|
||||
expect((await call('admin', 'PUT', '/api/admin/signup', { mode: 'nonsense' })).status).toBe(400);
|
||||
expect((await call('admin', 'PUT', '/api/admin/signup', { mode: 'invite' })).status).toBe(200);
|
||||
const code = await call('admin', 'POST', '/api/admin/invites', { note: 'x', days: 7 });
|
||||
expect(code.status).toBe(200);
|
||||
expect((await call('admin', 'DELETE', `/api/admin/invites/${encodeURIComponent(code.json.code ?? 'none')}`)).status).toBeGreaterThanOrEqual(200);
|
||||
const health = await call('admin', 'GET', '/api/admin/health');
|
||||
expect(health.json).toMatchObject({ iqConfigured: false });
|
||||
expect(health.json.signinFailures.length).toBeGreaterThan(0); // the wrong password above
|
||||
expect((await call('admin', 'POST', '/api/admin/backups')).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/admin/backups/nope.db')).status).toBe(404);
|
||||
expect((await call('admin', 'POST', '/api/admin/backups/projects-2000-01-01-00-00.db/restore', { confirm: 'x' })).status).toBe(404);
|
||||
const samId = (await call('sam', 'GET', '/api/auth/me')).json.id;
|
||||
expect((await call('admin', 'POST', `/api/admin/users/${samId}/reset-password`)).json.temporaryPassword).toBeTruthy();
|
||||
expect((await call('sam', 'GET', '/api/auth/me')).status).toBe(401); // signed out everywhere
|
||||
expect((await call('admin', 'DELETE', `/api/admin/users/${samId}`)).status).toBe(200);
|
||||
|
||||
// ---- finishing ----
|
||||
expect((await call('admin', 'DELETE', '/api/projects/s1')).status).toBe(200);
|
||||
expect((await call('admin', 'POST', '/api/auth/logout')).status).toBe(200);
|
||||
expect((await call('admin', 'GET', '/api/auth/me')).status).toBe(401);
|
||||
|
||||
// Every call and its status, in order. Run with UPDATE_SMOKE=1 to rewrite it after a deliberate API change.
|
||||
const file = new URL('./routes.smoke.expected.txt', import.meta.url);
|
||||
if (process.env.UPDATE_SMOKE === '1') writeFileSync(file, `${log.join('\n')}\n`);
|
||||
expect(`${log.join('\n')}\n`).toBe(readFileSync(file, 'utf8'));
|
||||
}, 60000);
|
||||
@@ -0,0 +1,133 @@
|
||||
// Account settings: profile, reminder settings, test notification, and the full data export.
|
||||
import { getAllProjects, getAllReaderInk, getPrayers, getProject, getReaderHistory, getReaderMarks, getReadingProgress, getUserById, getUserPrefs, setPodcastName, setPrayerReminder, setReminderSettings, setVotdSettings } from '../db.js';
|
||||
import { requireAuth } from '../auth.js';
|
||||
import { votdAvailable } from '../votd.js';
|
||||
import { assertPublicUrl, isValidTimeZone, sendTestReminder } from '../reminders.js';
|
||||
import { accountExportName, buildAccountExport } from '../accountExport.js';
|
||||
|
||||
export function registerAccountRoutes(app) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/auth/profile — update account-level settings (currently just podcastName)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.patch('/api/auth/profile', requireAuth, (req, res) => {
|
||||
try {
|
||||
const podcastName = String(req.body?.podcastName ?? '').trim().slice(0, 200);
|
||||
setPodcastName(req.session.userId, podcastName || null);
|
||||
res.json({ podcastName: podcastName || null });
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/profile error:', err);
|
||||
res.status(500).json({ error: 'Failed to save profile.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh
|
||||
// ---------------------------------------------------------------------------
|
||||
app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const reminderEnabled = !!req.body?.reminderEnabled;
|
||||
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
|
||||
const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim();
|
||||
const reminderTime = String(req.body?.reminderTime ?? '').trim();
|
||||
const reminderTz = isValidTimeZone(req.body?.reminderTz) ? String(req.body.reminderTz) : null;
|
||||
|
||||
if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||||
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||||
}
|
||||
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||||
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||||
}
|
||||
if (ntfyUrl) {
|
||||
try { await assertPublicUrl(ntfyUrl); } catch (err) { return res.status(400).json({ error: err.message }); }
|
||||
}
|
||||
if (reminderEnabled) {
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' });
|
||||
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) {
|
||||
return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' });
|
||||
}
|
||||
}
|
||||
|
||||
// the verse of the day is optional, and only offered when the server can look it up
|
||||
const votdEnabled = !!req.body?.votdEnabled && votdAvailable();
|
||||
const votdTime = String(req.body?.votdTime ?? '').trim();
|
||||
if (votdEnabled) {
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the verse of the day.' });
|
||||
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,
|
||||
reminderTime: reminderTime || null,
|
||||
reminderEnabled,
|
||||
reminderTz,
|
||||
});
|
||||
res.json({
|
||||
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
|
||||
votdEnabled, votdTime: votdTime || null, prayerEnabled, prayerTime: prayerTime || null,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('PATCH /api/auth/reminder-settings error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reminder settings.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/account/export — everything this account holds, as one restorable file
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/account/export', requireAuth, (req, res) => {
|
||||
try {
|
||||
const userId = req.session.userId;
|
||||
const user = getUserById(userId);
|
||||
const backup = buildAccountExport(user, {
|
||||
projects: () => getAllProjects(userId).map((p) => getProject(p.id, userId)).filter(Boolean),
|
||||
prefs: () => getUserPrefs(userId),
|
||||
marks: () => getReaderMarks(userId),
|
||||
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);
|
||||
} catch (err) {
|
||||
console.error('GET /api/account/export error:', err);
|
||||
res.status(500).json({ error: 'Failed to export your data.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/reminder-test — sends a test notification to the topic in the request
|
||||
// body (so it can be tried before saving), or to the saved settings.
|
||||
const _lastReminderTest = new Map(); // userId -> timestamp, to keep test sends from being spammed
|
||||
app.post('/api/auth/reminder-test', requireAuth, async (req, res) => {
|
||||
const userId = req.session.userId;
|
||||
if (Date.now() - (_lastReminderTest.get(userId) ?? 0) < 5000) {
|
||||
return res.status(429).json({ error: 'Wait a few seconds before sending another test.' });
|
||||
}
|
||||
_lastReminderTest.set(userId, Date.now());
|
||||
const saved = getUserById(userId);
|
||||
const ntfyTopic = String(req.body?.ntfyTopic ?? saved?.ntfyTopic ?? '').trim();
|
||||
const ntfyUrl = String(req.body?.ntfyUrl ?? saved?.ntfyUrl ?? '').trim();
|
||||
if (!ntfyTopic) return res.status(400).json({ error: 'Enter a topic first.' });
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
|
||||
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
|
||||
}
|
||||
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
|
||||
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
|
||||
}
|
||||
try {
|
||||
await sendTestReminder({ ntfyTopic, ntfyUrl });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Admin only: users, studies, sign-up rules and invites, server health and snapshots.
|
||||
import { adminDeleteProject, adminDeleteUser, adminGetAllProjects, adminGetAllUsers, adminGetProject, createInvite, dataDir, deleteInvite, destroyAllSessionsForUser, getSetting, getUserById, listInvites, setSetting, setUserPassword } from '../db.js';
|
||||
import { generateTemporaryPassword, hashPassword, requireAdmin, requireAuth } from '../auth.js';
|
||||
import { SIGNUP_MODES, generateInviteCode, isSignupMode, normalizeInviteCode } from '../signup.js';
|
||||
import { restoreSnapshot, runBackup, snapshotPath } from '../backups.js';
|
||||
import { backupStatus, diskUse, logEvent, recentEvents, uptimeSeconds } from '../health.js';
|
||||
|
||||
export function registerAdminRoutes(app) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin — restricted to the single designated admin account (see ADMIN_EMAIL
|
||||
// in server/auth.js). Full visibility/control over every user and project.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.json(adminGetAllUsers());
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/users error:', err);
|
||||
res.status(500).json({ error: 'Failed to list users.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
if (req.params.id === req.session.userId) {
|
||||
return res.status(400).json({ error: "Can't delete your own admin account." });
|
||||
}
|
||||
adminDeleteUser(req.params.id);
|
||||
destroyAllSessionsForUser(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/admin/users/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete user.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/admin/users/:id/reset-password — sets a random temporary password and
|
||||
// signs the user out everywhere, since there's no self-service "forgot password" flow yet.
|
||||
app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const temporaryPassword = generateTemporaryPassword();
|
||||
const passwordHash = await hashPassword(temporaryPassword);
|
||||
setUserPassword(req.params.id, passwordHash);
|
||||
destroyAllSessionsForUser(req.params.id);
|
||||
res.json({ temporaryPassword });
|
||||
} catch (err) {
|
||||
console.error('POST /api/admin/users/:id/reset-password error:', err);
|
||||
res.status(500).json({ error: 'Failed to reset password.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Who may sign up, and the invite codes for it
|
||||
// Server health: disk use, snapshots, and a short log of failures (in memory; resets on restart).
|
||||
app.get('/api/admin/health', requireAuth, requireAdmin, (_req, res) => {
|
||||
try {
|
||||
const emailOf = (id) => (id ? getUserById(id)?.email ?? null : null);
|
||||
const withUser = (e) => ({ ...e, email: emailOf(e.userId) });
|
||||
res.json({
|
||||
uptimeSeconds: uptimeSeconds(), node: process.version, dataDir: dataDir(),
|
||||
disk: diskUse(), backups: backupStatus(),
|
||||
iqConfigured: !!process.env.IQBIBLE_API_KEY,
|
||||
signinFailures: recentEvents('signin', 30).map(withUser),
|
||||
notifyFailures: recentEvents('notify', 30).map(withUser),
|
||||
backupFailures: recentEvents('backup', 10),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/health error:', err);
|
||||
res.status(500).json({ error: 'Failed to read server health.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/backups', requireAuth, requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
await runBackup();
|
||||
res.json(backupStatus());
|
||||
} catch (err) {
|
||||
console.error('POST /api/admin/backups error:', err);
|
||||
logEvent('backup', err.message);
|
||||
res.status(500).json({ error: 'The snapshot could not be made.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/backups/:name', requireAuth, requireAdmin, (req, res) => {
|
||||
const file = snapshotPath(req.params.name);
|
||||
if (!file) return res.status(404).json({ error: 'No such snapshot.' });
|
||||
res.download(file, req.params.name);
|
||||
});
|
||||
|
||||
// Restoring replaces every account's data, so the snapshot's name has to be typed back as confirmation.
|
||||
app.post('/api/admin/backups/:name/restore', requireAuth, requireAdmin, async (req, res) => {
|
||||
if (!snapshotPath(req.params.name)) return res.status(404).json({ error: 'No such snapshot.' });
|
||||
if (req.body?.confirm !== req.params.name) return res.status(400).json({ error: 'Type the snapshot’s name to confirm.' });
|
||||
try {
|
||||
const result = await restoreSnapshot(req.params.name);
|
||||
res.json({ restored: result.restored, safetyCopy: result.safetyCopy.split('/').pop() });
|
||||
} catch (err) {
|
||||
console.error('POST /api/admin/backups/:name/restore error:', err);
|
||||
res.status(500).json({ error: 'The snapshot could not be restored.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => {
|
||||
res.json({ mode: getSetting('signup', 'open'), invites: listInvites() });
|
||||
});
|
||||
|
||||
app.put('/api/admin/signup', requireAuth, requireAdmin, (req, res) => {
|
||||
if (!isSignupMode(req.body?.mode)) return res.status(400).json({ error: `Mode must be one of: ${SIGNUP_MODES.join(', ')}.` });
|
||||
setSetting('signup', req.body.mode);
|
||||
res.json({ mode: req.body.mode });
|
||||
});
|
||||
|
||||
app.post('/api/admin/invites', requireAuth, requireAdmin, (req, res) => {
|
||||
const days = Math.min(90, Math.max(1, Number(req.body?.days) || 7));
|
||||
const code = generateInviteCode();
|
||||
createInvite(code, String(req.body?.note ?? '').trim().slice(0, 80), Date.now() + days * 86_400_000);
|
||||
res.json({ code, expiresInDays: days });
|
||||
});
|
||||
|
||||
app.delete('/api/admin/invites/:code', requireAuth, requireAdmin, (req, res) => {
|
||||
deleteInvite(normalizeInviteCode(req.params.code));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
res.json(adminGetAllProjects());
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/projects error:', err);
|
||||
res.status(500).json({ error: 'Failed to list projects.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const project = adminGetProject(req.params.id);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/admin/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load project.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/admin/projects/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
adminDeleteProject(req.params.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/admin/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete project.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// Accounts: sign up, sign in (password + two-factor), password reset, change password, two-factor setup.
|
||||
import QRCode from 'qrcode';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { adminDeleteUser, bumpPasswordResetAttempts, claimOrphanProjects, clearPasswordReset, countUsers, createUser, destroyAllSessionsForUser, disableTotp, enableTotp, findOpenInvite, getPasswordReset, getSetting, getUserByEmail, getUserById, setBackupCodeHashes, setPasswordReset, setUserPassword, useInvite } from '../db.js';
|
||||
import { consumeBackupCode, generateBackupCodes, generateResetCode, generateTotpSecret, hashBackupCodes, hashPassword, isAdminEmail, isValidEmail, isValidPassword, normalizeResetCode, requireAuth, totpKeyUri, verifyPassword, verifyTotpToken } from '../auth.js';
|
||||
import { sendNtfy } from '../reminders.js';
|
||||
import { noteSignIn } from '../devices.js';
|
||||
import { checkSignup, normalizeInviteCode } from '../signup.js';
|
||||
import { logEvent } from '../health.js';
|
||||
import { userPayload } from '../userPayload.js';
|
||||
import { TOO_MANY, clearFailures, isLimited, recordFailure } from '../limiter.js';
|
||||
|
||||
export function registerAuthRoutes(app) {
|
||||
// Compared against when an email isn't registered, so a login takes the same time either way.
|
||||
const DUMMY_HASH = '$2b$10$UbGSJvxosy5oInS6zfvqM.gWOUv9ypVg81ou.jWOswHyYgUjpkhJK';
|
||||
|
||||
// TOTP codes already used (userId -> code), so a captured code can't be replayed inside its time window.
|
||||
const _usedTotp = new Map();
|
||||
function totpAlreadyUsed(userId, token) { return _usedTotp.get(userId) === token; }
|
||||
function markTotpUsed(userId, token) {
|
||||
_usedTotp.set(userId, token);
|
||||
setTimeout(() => { if (_usedTotp.get(userId) === token) _usedTotp.delete(userId); }, 90 * 1000).unref();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.post('/api/auth/register', async (req, res) => {
|
||||
try {
|
||||
if (isLimited(`ip:${req.ip}`)) return res.status(429).json(TOO_MANY);
|
||||
recordFailure(`ip:${req.ip}`); // every registration attempt counts
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
|
||||
const inviteCode = normalizeInviteCode(req.body?.invite);
|
||||
const allowed = checkSignup({ mode: getSetting('signup', 'open'), userCount: countUsers(), inviteOk: !!findOpenInvite(inviteCode) });
|
||||
if (!allowed.ok) return res.status(allowed.status).json({ error: allowed.error });
|
||||
if (!isValidEmail(email)) {
|
||||
return res.status(400).json({ error: 'Enter a valid email address.' });
|
||||
}
|
||||
if (!isValidPassword(password)) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
||||
}
|
||||
if (getUserByEmail(email)) {
|
||||
return res.status(409).json({ error: 'An account with that email already exists.' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const user = createUser({ id: randomUUID(), email, passwordHash });
|
||||
if (getSetting('signup', 'open') === 'invite' && countUsers() > 1 && !useInvite(inviteCode, user.id)) {
|
||||
adminDeleteUser(user.id); // someone used the code between the check and now
|
||||
return res.status(403).json({ error: 'That invite code was just used. Ask for a new one.' });
|
||||
}
|
||||
|
||||
// The very first account inherits any projects created before multi-user support existed.
|
||||
if (countUsers() === 1) {
|
||||
claimOrphanProjects(user.id);
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
noteSignIn(req, user, 'register');
|
||||
res.json({
|
||||
id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email),
|
||||
reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/register error:', err);
|
||||
res.status(500).json({ error: 'Failed to register.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
try {
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const password = String(req.body?.password ?? '');
|
||||
const keys = [`ip:${req.ip}`, `acct:${email}`];
|
||||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
const valid = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH) && !!user;
|
||||
if (!valid) {
|
||||
recordFailure(...keys);
|
||||
logEvent('signin', `Wrong password for ${email.slice(0, 80) || '(blank)'} from ${req.ip}`);
|
||||
return res.status(401).json({ error: 'Incorrect email or password.' });
|
||||
}
|
||||
clearFailures(`acct:${email}`);
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
if (user.totpEnabled) {
|
||||
// Password is correct, but the session stays unauthenticated (no userId)
|
||||
// until a valid TOTP/backup code lands on /api/auth/mfa/verify.
|
||||
req.session.pendingUserId = user.id;
|
||||
return res.json({ mfaRequired: true });
|
||||
}
|
||||
req.session.userId = user.id;
|
||||
noteSignIn(req, user, 'password');
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/login error:', err);
|
||||
res.status(500).json({ error: 'Failed to log in.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/verify', async (req, res) => {
|
||||
try {
|
||||
const pendingUserId = req.session?.pendingUserId;
|
||||
if (!pendingUserId) {
|
||||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||
}
|
||||
const keys = [`ip:${req.ip}`, `mfa:${pendingUserId}`];
|
||||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||
const user = getUserById(pendingUserId);
|
||||
if (!user || !user.totpEnabled) {
|
||||
return res.status(400).json({ error: 'No sign-in in progress.' });
|
||||
}
|
||||
|
||||
const token = req.body?.token;
|
||||
const backupCode = req.body?.backupCode;
|
||||
let ok = token ? verifyTotpToken(String(token), user.totpSecret) && !totpAlreadyUsed(user.id, String(token)) : false;
|
||||
if (ok) markTotpUsed(user.id, String(token));
|
||||
|
||||
if (!ok && backupCode) {
|
||||
const remaining = await consumeBackupCode(String(backupCode), user.backupCodeHashes);
|
||||
if (remaining) {
|
||||
setBackupCodeHashes(user.id, remaining);
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
recordFailure(...keys);
|
||||
logEvent('signin', `Wrong two-factor code for ${user.email} from ${req.ip}`, { userId: user.id });
|
||||
return res.status(401).json({ error: 'Invalid code.' });
|
||||
}
|
||||
clearFailures(`mfa:${pendingUserId}`);
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return res.status(500).json({ error: 'Could not create session.' });
|
||||
req.session.userId = user.id;
|
||||
noteSignIn(req, user, 'mfa');
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/verify error:', err);
|
||||
res.status(500).json({ error: 'Failed to verify code.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Forgot password. There is no email here, so the one-time code goes to the account's ntfy topic (the same
|
||||
// one used for reading reminders). Accounts without a topic still go through the admin. The answer is the
|
||||
// same whether or not the account exists, so this can't be used to find out who has one.
|
||||
// ---------------------------------------------------------------------------
|
||||
const RESET_MINUTES = 15;
|
||||
const RESET_REPLY = { ok: true, message: 'If that account has a notification topic set up, a reset code was just sent to it. Codes last 15 minutes. No topic? Ask the administrator for a temporary password.' };
|
||||
|
||||
app.post('/api/auth/forgot', async (req, res) => {
|
||||
try {
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const keys = [`ip:${req.ip}`, `forgot:${email}`];
|
||||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||
recordFailure(...keys); // every request counts
|
||||
const user = isValidEmail(email) ? getUserByEmail(email) : null;
|
||||
if (user?.ntfyTopic) {
|
||||
const code = generateResetCode();
|
||||
setPasswordReset(user.id, await hashPassword(code), Date.now() + RESET_MINUTES * 60 * 1000);
|
||||
sendNtfy(user, { title: 'Password reset code', body: `Your Bible Study reset code is ${code}. It works for ${RESET_MINUTES} minutes. If you didn't ask for it, ignore this.`, tags: 'key' })
|
||||
.catch((err) => console.error('Password reset notification failed:', err.message));
|
||||
}
|
||||
res.json(RESET_REPLY);
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/forgot error:', err);
|
||||
res.status(500).json({ error: 'Failed to start the reset.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/reset', async (req, res) => {
|
||||
try {
|
||||
const email = String(req.body?.email ?? '').trim().toLowerCase();
|
||||
const keys = [`ip:${req.ip}`, `reset:${email}`];
|
||||
if (isLimited(...keys)) return res.status(429).json(TOO_MANY);
|
||||
const fail = (error) => { recordFailure(...keys); return res.status(400).json({ error }); };
|
||||
const newPassword = String(req.body?.newPassword ?? '');
|
||||
if (!isValidPassword(newPassword)) return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||||
|
||||
const user = getUserByEmail(email);
|
||||
const reset = user ? getPasswordReset(user.id) : null;
|
||||
const code = normalizeResetCode(req.body?.code);
|
||||
// Always spend one bcrypt compare, so timing doesn't show whether a code was issued
|
||||
const codeOk = await verifyPassword(code, reset?.codeHash ?? DUMMY_HASH) && !!reset && reset.attempts < 5;
|
||||
if (reset && !codeOk) bumpPasswordResetAttempts(user.id);
|
||||
if (!codeOk) return fail('That code is wrong or has expired. Ask for a new one.');
|
||||
if (user.totpEnabled) {
|
||||
const token = String(req.body?.token ?? '');
|
||||
if (!verifyTotpToken(token, user.totpSecret) || totpAlreadyUsed(user.id, token)) return fail('Enter the current 6-digit code from your authenticator app too.');
|
||||
markTotpUsed(user.id, token);
|
||||
}
|
||||
setUserPassword(user.id, await hashPassword(newPassword));
|
||||
clearPasswordReset(user.id);
|
||||
destroyAllSessionsForUser(user.id);
|
||||
clearFailures(...keys);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/reset error:', err);
|
||||
res.status(500).json({ error: 'Failed to reset the password.' });
|
||||
}
|
||||
});
|
||||
|
||||
// What the sign-in page needs to know: whether to offer "create an account", and whether it needs an invite code.
|
||||
app.get('/api/auth/signup-mode', (_req, res) => {
|
||||
res.json({ mode: countUsers() === 0 ? 'open' : getSetting('signup', 'open') });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.clearCookie('connect.sid');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
const user = req.session?.userId ? getUserById(req.session.userId) : null;
|
||||
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||||
res.json(userPayload(user));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/auth/change-password — self-service password change (Account Settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post('/api/auth/change-password', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const currentPassword = String(req.body?.currentPassword ?? '');
|
||||
const newPassword = String(req.body?.newPassword ?? '');
|
||||
|
||||
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
|
||||
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
recordFailure(`pw:${user.id}`);
|
||||
return res.status(401).json({ error: 'Current password is incorrect.' });
|
||||
}
|
||||
if (!isValidPassword(newPassword)) {
|
||||
return res.status(400).json({ error: 'New password must be at least 8 characters.' });
|
||||
}
|
||||
|
||||
clearFailures(`pw:${user.id}`);
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
setUserPassword(user.id, passwordHash);
|
||||
// Sign out every other session (e.g. a stolen one) but keep this one logged in.
|
||||
destroyAllSessionsForUser(user.id, req.sessionID);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/change-password error:', err);
|
||||
res.status(500).json({ error: 'Failed to change password.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Two-factor auth setup (requires an already-authenticated session)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.post('/api/auth/mfa/setup', requireAuth, (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
const secret = generateTotpSecret();
|
||||
// Held only in the session until confirmed with a real code — never written
|
||||
// to the DB (and 2FA never turned on) unless /mfa/enable succeeds below.
|
||||
req.session.pendingTotpSecret = secret;
|
||||
QRCode.toDataURL(totpKeyUri(user.email, secret), (err, qrCodeDataUrl) => {
|
||||
if (err) return res.status(500).json({ error: 'Failed to generate QR code.' });
|
||||
res.json({ secret, qrCodeDataUrl });
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/setup error:', err);
|
||||
res.status(500).json({ error: 'Failed to start 2FA setup.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/enable', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const secret = req.session.pendingTotpSecret;
|
||||
if (!secret) {
|
||||
return res.status(400).json({ error: 'Start 2FA setup first.' });
|
||||
}
|
||||
if (isLimited(`mfaset:${req.session.userId}`)) return res.status(429).json(TOO_MANY);
|
||||
if (!verifyTotpToken(String(req.body?.token ?? ''), secret)) {
|
||||
recordFailure(`mfaset:${req.session.userId}`);
|
||||
return res.status(401).json({ error: 'That code didn\'t match. Check your authenticator app and try again.' });
|
||||
}
|
||||
|
||||
const backupCodes = generateBackupCodes();
|
||||
const backupCodeHashes = await hashBackupCodes(backupCodes);
|
||||
enableTotp(req.session.userId, secret, backupCodeHashes);
|
||||
delete req.session.pendingTotpSecret;
|
||||
res.json({ backupCodes });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/enable error:', err);
|
||||
res.status(500).json({ error: 'Failed to enable 2FA.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/mfa/disable', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const user = getUserById(req.session.userId);
|
||||
if (isLimited(`pw:${user.id}`)) return res.status(429).json(TOO_MANY);
|
||||
const valid = await verifyPassword(String(req.body?.password ?? ''), user.passwordHash);
|
||||
if (!valid) {
|
||||
recordFailure(`pw:${user.id}`);
|
||||
return res.status(401).json({ error: 'Incorrect password.' });
|
||||
}
|
||||
disableTotp(user.id);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('POST /api/auth/mfa/disable error:', err);
|
||||
res.status(500).json({ error: 'Failed to disable 2FA.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Studies: list, load, save (with conflict checks), earlier versions, delete.
|
||||
import { canEditProject, deleteProject, getAllProjects, getProject, getProjectEdited, getVersion, listVersions, restoreVersion, upsertProject } from '../db.js';
|
||||
import { requireAuth } from '../auth.js';
|
||||
import { isSaveConflict, parseBase } from '../projectSync.js';
|
||||
|
||||
export function registerProjectRoutes(app) {
|
||||
// GET /api/projects — list all project summaries owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects', requireAuth, (req, res) => {
|
||||
try {
|
||||
const projects = getAllProjects(req.session.userId);
|
||||
res.json(projects);
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects error:', err);
|
||||
res.status(500).json({ error: 'Failed to list projects.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects/:id — fetch a single full project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const project = getProject(req.params.id, req.session.userId);
|
||||
if (!project) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json(project);
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /api/projects/:id — create or update a project owned by the current user
|
||||
// ---------------------------------------------------------------------------
|
||||
app.put('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
const body = req.body;
|
||||
if (!body || typeof body !== 'object') {
|
||||
return res.status(400).json({ error: 'Invalid JSON body.' });
|
||||
}
|
||||
if (!body.id || !body.title || !Array.isArray(body.chapters)) {
|
||||
return res.status(400).json({ error: 'Missing required fields: id, title, chapters.' });
|
||||
}
|
||||
if (body.id !== req.params.id) {
|
||||
return res.status(400).json({ error: 'URL id does not match body id.' });
|
||||
}
|
||||
if (body.id.length > 100) return res.status(400).json({ error: 'Invalid project id.' });
|
||||
if (body.title.length > 500) return res.status(400).json({ error: 'Project title is too long.' });
|
||||
// Refuse to overwrite a copy another device saved after the one this device last saw
|
||||
const stored = getProjectEdited(body.id, req.session.userId);
|
||||
if (isSaveConflict({ stored, base: parseBase(req.get('X-Base-Edited')), force: req.query.force === '1' })) {
|
||||
return res.status(409).json({ error: 'This study was changed on another device.', code: 'conflict', serverEdited: stored });
|
||||
}
|
||||
const saved = upsertProject(body, req.session.userId);
|
||||
if (!saved) {
|
||||
return res.status(403).json({ error: 'That project belongs to a different account.' });
|
||||
}
|
||||
res.json(saved);
|
||||
} catch (err) {
|
||||
console.error('PUT /api/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to save project.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete('/api/projects/:id', requireAuth, (req, res) => {
|
||||
try {
|
||||
deleteProject(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete project.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Reading data synced between devices: ink, preferences, bookmarks and highlights, prayers, reading history, plan progress.
|
||||
import { getAllReaderInk, getPrayers, getReaderHistory, getReaderMarks, getReadingProgress, getUserPrefs, setPrayers, setReaderHistory, setReaderInkPage, setReaderMarks, setReadingProgress, setUserPrefs } from '../db.js';
|
||||
import { requireAuth } from '../auth.js';
|
||||
import { mergePrayers } from '../prayers.js';
|
||||
import { mergeHistory } from '../readerHistory.js';
|
||||
import { cleanMarks } from '../readerMarks.js';
|
||||
import { cleanPrefsPatch, mergePrefs } from '../prefs.js';
|
||||
|
||||
export function registerReaderRoutes(app) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader ink — cross-device draw annotations on Bible chapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/reader/ink — all saved ink pages for the current user
|
||||
app.get('/api/reader/ink', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getAllReaderInk(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reader/ink error:', err);
|
||||
res.status(500).json({ error: 'Failed to load reader ink.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reader/ink/:book/:chapter — save (upsert) one page's ink
|
||||
app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
|
||||
try {
|
||||
const { book, chapter } = req.params;
|
||||
const { strokes } = req.body ?? {};
|
||||
if (!/^[1-3A-Za-z]{3}$/.test(book) || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
|
||||
if (!Array.isArray(strokes)) return res.status(400).json({ error: 'strokes must be an array.' });
|
||||
if (strokes.length > 20000) return res.status(413).json({ error: 'Too much ink on one page.' });
|
||||
setReaderInkPage(req.session.userId, book, chapter, strokes);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/ink error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reader ink.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/prefs — the user's synced display preferences ({ prefs: { key: { v, at } } })
|
||||
app.get('/api/prefs', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json({ prefs: getUserPrefs(req.session.userId) });
|
||||
} catch (err) {
|
||||
console.error('GET /api/prefs error:', err);
|
||||
res.status(500).json({ error: 'Failed to load preferences.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/prefs — merge changed preferences in (newest change per key wins); returns the result
|
||||
app.put('/api/prefs', requireAuth, (req, res) => {
|
||||
try {
|
||||
const merged = mergePrefs(getUserPrefs(req.session.userId), cleanPrefsPatch(req.body));
|
||||
setUserPrefs(req.session.userId, merged);
|
||||
res.json({ prefs: merged });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/prefs error:', err);
|
||||
res.status(500).json({ error: 'Failed to save preferences.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reader/marks — the user's bookmarks + highlights, with an updatedAt to compare against
|
||||
app.get('/api/reader/marks', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getReaderMarks(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reader/marks error:', err);
|
||||
res.status(500).json({ error: 'Failed to load bookmarks and highlights.' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
console.error('GET /api/reader/history error:', err);
|
||||
res.status(500).json({ error: 'Failed to load your reading history.' });
|
||||
}
|
||||
});
|
||||
app.put('/api/reader/history', requireAuth, (req, res) => {
|
||||
try {
|
||||
const merged = mergeHistory(getReaderHistory(req.session.userId), req.body);
|
||||
setReaderHistory(req.session.userId, merged);
|
||||
res.json(merged);
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/history error:', err);
|
||||
res.status(500).json({ error: 'Failed to save your reading history.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights })
|
||||
app.put('/api/reader/marks', requireAuth, (req, res) => {
|
||||
try {
|
||||
const marks = cleanMarks(req.body);
|
||||
if (!marks) return res.status(400).json({ error: 'Body must be an object.' });
|
||||
res.json({ ok: true, updatedAt: setReaderMarks(req.session.userId, marks) });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reader/marks error:', err);
|
||||
res.status(500).json({ error: 'Failed to save bookmarks and highlights.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading plan progress — cross-device sync for started/completed reading plans
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/reading-plans — the current user's whole progress list
|
||||
|
||||
app.get('/api/reading-plans', requireAuth, (req, res) => {
|
||||
try {
|
||||
res.json(getReadingProgress(req.session.userId));
|
||||
} catch (err) {
|
||||
console.error('GET /api/reading-plans error:', err);
|
||||
res.status(500).json({ error: 'Failed to load reading plan progress.' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reading-plans — overwrite the current user's whole progress list
|
||||
// (an object: { progress: [...], customPlans: [...] })
|
||||
app.put('/api/reading-plans', requireAuth, (req, res) => {
|
||||
try {
|
||||
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||
return res.status(400).json({ error: 'Body must be an object.' });
|
||||
}
|
||||
const { progress, customPlans, activity } = req.body;
|
||||
if ([progress, customPlans, activity].some((v) => v !== undefined && !Array.isArray(v))
|
||||
|| (progress?.length ?? 0) > 500 || (customPlans?.length ?? 0) > 200 || (activity?.length ?? 0) > 20000) {
|
||||
return res.status(400).json({ error: 'progress, customPlans and activity must be reasonably sized lists.' });
|
||||
}
|
||||
setReadingProgress(req.session.userId, { progress: progress ?? [], customPlans: customPlans ?? [], activity: activity ?? [] });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('PUT /api/reading-plans error:', err);
|
||||
res.status(500).json({ error: 'Failed to save reading plan progress.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Sharing: read-only study and outline links, co-teacher invites, and the public pages that open them.
|
||||
import { randomUUID } from 'crypto';
|
||||
import { acceptEditInvite, clearOutlineShareToken, clearShareToken, countOpenEditInvites, createEditInvite, getSharePassHash, getShareSettings, getSharedByToken, isProjectOwner, listCollaborators, removeCollaborator, setOutlineShareToken, setShareSettings, setShareToken } from '../db.js';
|
||||
import { requireAuth } from '../auth.js';
|
||||
import { checkShareAccess, expiryFromDays, hashPasscode, hideUntilReveal, revealFromInput } from '../shareLinks.js';
|
||||
import { buildOutlinePayload } from '../outlineShare.js';
|
||||
import { TOO_MANY, isLimited, recordFailure } from '../limiter.js';
|
||||
|
||||
export function registerSharingRoutes(app) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only share links
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GET /api/projects/:id/share — current share status for the project owner
|
||||
app.get('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const settings = getShareSettings(req.params.id, req.session.userId);
|
||||
if (!settings) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode, revealAt: settings.revealAt });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Sets up (or changes) a read-only link: body { expiresInDays?: number | null, passcode?: string | null }.
|
||||
// Leaving a field out keeps what it was; null/"" clears it. Returns the (new or existing) token.
|
||||
async function saveLink(req, res, outline) {
|
||||
try {
|
||||
const userId = req.session.userId;
|
||||
const settings = getShareSettings(req.params.id, userId, outline);
|
||||
if (!settings) return res.status(404).json({ error: 'Project not found.' });
|
||||
const token = settings.token || randomUUID().replace(/-/g, '');
|
||||
if (!settings.token && !(outline ? setOutlineShareToken : setShareToken)(req.params.id, userId, token)) return res.status(404).json({ error: 'Project not found.' });
|
||||
const body = req.body ?? {};
|
||||
const expiresAt = 'expiresInDays' in body ? expiryFromDays(body.expiresInDays) : settings.expiresAt;
|
||||
// a blank passcode field means "keep it"; null clears; a string sets
|
||||
const current = getSharePassHash(req.params.id, userId, outline);
|
||||
const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current;
|
||||
// only a study link can hide its notes until a time; a past or empty time turns hiding off
|
||||
const revealAt = outline ? null : ('revealAt' in body ? revealFromInput(body.revealAt) : settings.revealAt);
|
||||
setShareSettings(req.params.id, userId, { expiresAt, passHash, revealAt }, outline);
|
||||
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash, revealAt });
|
||||
} catch (err) {
|
||||
console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err);
|
||||
res.status(500).json({ error: 'Failed to enable sharing.' });
|
||||
}
|
||||
}
|
||||
app.post('/api/projects/:id/share', requireAuth, (req, res) => saveLink(req, res, false));
|
||||
|
||||
// DELETE /api/projects/:id/share — revoke the share link
|
||||
app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
|
||||
try {
|
||||
clearShareToken(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id/share error:', err);
|
||||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Co-teachers: other accounts that may edit a study. The owner makes a single-use invite link; whoever
|
||||
// opens it while signed in joins. The owner (or the co-teacher themself) can remove an editor.
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get('/api/projects/:id/collaborators', requireAuth, (req, res) => {
|
||||
if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||
res.json({ collaborators: listCollaborators(req.params.id), pendingInvites: countOpenEditInvites(req.params.id) });
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/collaborators/invite', requireAuth, (req, res) => {
|
||||
if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
|
||||
const token = randomUUID().replace(/-/g, '');
|
||||
const expiresAt = Date.now() + 7 * 86_400_000;
|
||||
createEditInvite(token, req.params.id, expiresAt);
|
||||
res.json({ token, expiresAt });
|
||||
});
|
||||
|
||||
app.delete('/api/projects/:id/collaborators/:userId', requireAuth, (req, res) => {
|
||||
const me = req.session.userId;
|
||||
if (!isProjectOwner(req.params.id, me) && req.params.userId !== me) return res.status(403).json({ error: 'Only the owner can remove someone else.' });
|
||||
removeCollaborator(req.params.id, req.params.userId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/join/:token', requireAuth, (req, res) => {
|
||||
const result = acceptEditInvite(req.params.token, req.session.userId);
|
||||
if (result.error) return res.status(400).json({ error: result.error });
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// Read-only outline links. A separate token from the study share link, and the public endpoint returns
|
||||
// only the outlines (see outlineShare.js), never the notes or scripts.
|
||||
app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
const settings = getShareSettings(req.params.id, req.session.userId, true);
|
||||
res.json({ shareToken: settings?.token ?? null, expiresAt: settings?.expiresAt ?? null, hasPasscode: !!settings?.hasPasscode });
|
||||
} catch (err) {
|
||||
console.error('GET /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to load share status.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => saveLink(req, res, true));
|
||||
|
||||
app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
|
||||
try {
|
||||
clearOutlineShareToken(req.params.id, req.session.userId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('DELETE /api/projects/:id/outline-share error:', err);
|
||||
res.status(500).json({ error: 'Failed to revoke sharing.' });
|
||||
}
|
||||
});
|
||||
|
||||
// A wrong passcode counts as a failed attempt for that link and address
|
||||
async function openSharedLink(req, res, outline) {
|
||||
const found = getSharedByToken(req.params.token, outline);
|
||||
if (!found) { res.status(404).json({ error: outline ? 'This outline link is invalid or has been turned off.' : 'This share link is invalid or has been revoked.' }); return null; }
|
||||
const keys = [`ip:${req.ip}`, `share:${req.params.token}`];
|
||||
if (isLimited(...keys)) { res.status(429).json(TOO_MANY); return null; }
|
||||
const access = await checkShareAccess({ expiresAt: found.expiresAt, passHash: found.passHash, supplied: req.get('X-Share-Passcode') });
|
||||
if (!access.ok) {
|
||||
if (access.needsPasscode && req.get('X-Share-Passcode')) recordFailure(...keys);
|
||||
res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 });
|
||||
return null;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed)
|
||||
app.get('/api/share-outline/:token', async (req, res) => {
|
||||
try {
|
||||
const found = await openSharedLink(req, res, true);
|
||||
if (found) res.json(buildOutlinePayload(found.project));
|
||||
} catch (err) {
|
||||
console.error('GET /api/share-outline/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load the shared outline.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed)
|
||||
app.get('/api/share/:token', async (req, res) => {
|
||||
try {
|
||||
const found = await openSharedLink(req, res, false);
|
||||
if (found) res.json(hideUntilReveal(found.project, found.revealAt));
|
||||
} catch (err) {
|
||||
console.error('GET /api/share/:token error:', err);
|
||||
res.status(500).json({ error: 'Failed to load shared project.' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// The rules for a read-only link: it can expire, and it can need a passcode. Kept separate from the routes
|
||||
// so they can be tested on their own.
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export const MAX_PASSCODE = 100;
|
||||
|
||||
// "never" (null/0) or a number of days, up to a year -> an expiry time in ms (or null)
|
||||
export function expiryFromDays(days, now = Date.now()) {
|
||||
const n = Number(days);
|
||||
if (!days || !Number.isFinite(n) || n <= 0) return null;
|
||||
return now + Math.min(365, Math.round(n)) * 86_400_000;
|
||||
}
|
||||
|
||||
export async function hashPasscode(passcode) {
|
||||
const p = String(passcode ?? '').slice(0, MAX_PASSCODE);
|
||||
return p.trim() ? bcrypt.hash(p.trim(), 10) : null;
|
||||
}
|
||||
|
||||
// -> { ok: true } | { ok: false, status: 410, error } (expired) | { ok: false, status: 401, needsPasscode: true, error }
|
||||
export async function checkShareAccess({ expiresAt, passHash, supplied }, now = Date.now()) {
|
||||
if (expiresAt && expiresAt <= now) return { ok: false, status: 410, error: 'This link has expired. Ask the person who shared it for a new one.' };
|
||||
if (!passHash) return { ok: true };
|
||||
const given = String(supplied ?? '').slice(0, MAX_PASSCODE).trim();
|
||||
if (!given) return { ok: false, status: 401, needsPasscode: true, error: 'This link needs a passcode.' };
|
||||
if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// "Hide my notes until…": before the reveal time a shared study shows only the passages and the discussion questions
|
||||
// (no notes, outline, script, words or group notes), and says when the rest appears. The strip is done here, on the
|
||||
// server, so the hidden text never reaches the browser. After the time (or with none set) the study is sent whole.
|
||||
const OPEN_CHUNK_FIELDS = ['id', 'startVerse', 'endVerse', 'spilloverEndVerse', 'episodeNumber', 'episodeTitle', 'discussionQuestions'];
|
||||
|
||||
export function hideUntilReveal(project, revealAt, now = Date.now()) {
|
||||
if (!revealAt || revealAt <= now || !project || typeof project !== 'object') return project;
|
||||
return {
|
||||
id: project.id, title: project.title, translation: project.translation, series: project.series, outline: [],
|
||||
hiddenUntil: revealAt,
|
||||
chapters: (Array.isArray(project.chapters) ? project.chapters : []).map((ch) => ({
|
||||
book: ch.book, bookAbbrev: ch.bookAbbrev, chapter: ch.chapter, verses: ch.verses,
|
||||
chunks: (Array.isArray(ch.chunks) ? ch.chunks : []).map((chunk) => Object.fromEntries(OPEN_CHUNK_FIELDS.filter((f) => f in chunk).map((f) => [f, chunk[f]]))),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// A reveal time from the browser -> ms in the future (up to a year), or null for "no hiding"
|
||||
export function revealFromInput(value, now = Date.now()) {
|
||||
const n = Number(value);
|
||||
if (!value || !Number.isFinite(n) || n <= now) return null;
|
||||
return Math.min(n, now + 365 * 86_400_000);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { checkShareAccess, expiryFromDays, hashPasscode } from './shareLinks.js';
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
test('expiry is a number of days from now, or never', () => {
|
||||
expect(expiryFromDays(null, NOW)).toBeNull();
|
||||
expect(expiryFromDays(0, NOW)).toBeNull();
|
||||
expect(expiryFromDays('x', NOW)).toBeNull();
|
||||
expect(expiryFromDays(7, NOW)).toBe(NOW + 7 * 86_400_000);
|
||||
expect(expiryFromDays(9999, NOW)).toBe(NOW + 365 * 86_400_000); // never more than a year
|
||||
});
|
||||
|
||||
test('a link with no rules is open; an expired one says so', async () => {
|
||||
expect((await checkShareAccess({}, NOW)).ok).toBe(true);
|
||||
expect(await checkShareAccess({ expiresAt: NOW + 1000 }, NOW)).toEqual({ ok: true });
|
||||
expect(await checkShareAccess({ expiresAt: NOW - 1 }, NOW)).toMatchObject({ ok: false, status: 410 });
|
||||
});
|
||||
|
||||
test('a passcode is asked for, and only the right one opens the link', async () => {
|
||||
const passHash = await hashPasscode(' moses ');
|
||||
expect(passHash).not.toContain('moses');
|
||||
expect(await checkShareAccess({ passHash }, NOW)).toMatchObject({ ok: false, status: 401, needsPasscode: true });
|
||||
expect(await checkShareAccess({ passHash, supplied: 'aaron' }, NOW)).toMatchObject({ ok: false, status: 401, needsPasscode: true, error: expect.stringMatching(/not right/) });
|
||||
expect(await checkShareAccess({ passHash, supplied: ' moses ' }, NOW)).toEqual({ ok: true });
|
||||
expect(await hashPasscode(' ')).toBeNull();
|
||||
});
|
||||
|
||||
test('an expired link is reported as expired even before the passcode is checked', async () => {
|
||||
const passHash = await hashPasscode('x');
|
||||
expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 });
|
||||
});
|
||||
|
||||
import { hideUntilReveal, revealFromInput } from './shareLinks.js';
|
||||
|
||||
const study = () => ({
|
||||
id: 's1', title: 'John', translation: 'BSB', outline: [{ id: 'n', text: 'Point' }],
|
||||
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved.' }],
|
||||
chunks: [{ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?', observation: 'secret', interpretation: 'secret', application: 'secret', finalScript: 'secret', generalNotes: 'secret', greekWords: [{ id: 'w' }], teachBack: 'secret', recalls: [{ text: 'secret' }] }] }],
|
||||
});
|
||||
|
||||
test('before the reveal time a shared study keeps the passages and questions and drops every note', () => {
|
||||
const now = 1_000;
|
||||
const out = hideUntilReveal(study(), now + 5_000, now);
|
||||
expect(out.hiddenUntil).toBe(now + 5_000);
|
||||
expect(out.outline).toEqual([]);
|
||||
expect(out.chapters[0].verses).toHaveLength(1);
|
||||
expect(out.chapters[0].chunks[0]).toEqual({ id: 'c', startVerse: 16, endVerse: 18, discussionQuestions: 'Why?' });
|
||||
expect(JSON.stringify(out)).not.toContain('secret');
|
||||
});
|
||||
|
||||
test('after the reveal time, or with none set, the whole study is sent', () => {
|
||||
const full = study();
|
||||
expect(hideUntilReveal(full, 500, 1_000)).toBe(full);
|
||||
expect(hideUntilReveal(full, null, 1_000)).toBe(full);
|
||||
});
|
||||
|
||||
test('a reveal time must be in the future, and no more than a year away', () => {
|
||||
const now = 1_000_000;
|
||||
expect(revealFromInput(null, now)).toBeNull();
|
||||
expect(revealFromInput(now - 1, now)).toBeNull();
|
||||
expect(revealFromInput('nope', now)).toBeNull();
|
||||
expect(revealFromInput(now + 60_000, now)).toBe(now + 60_000);
|
||||
expect(revealFromInput(now + 10 * 365 * 86_400_000, now)).toBe(now + 365 * 86_400_000);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// Who may create an account. The administrator picks one of three settings:
|
||||
// open anyone who can reach the server (the original behaviour)
|
||||
// invite only someone with an unused invite code from the administrator
|
||||
// closed nobody new
|
||||
// The very first account can always be made, so a fresh install can't lock itself out.
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export const SIGNUP_MODES = ['open', 'invite', 'closed'];
|
||||
export const isSignupMode = (m) => SIGNUP_MODES.includes(m);
|
||||
|
||||
// -> { ok: true } or { ok: false, status, error }. `inviteOk` says whether the code given is a valid unused one.
|
||||
export function checkSignup({ mode, userCount, inviteOk }) {
|
||||
if (userCount === 0 || mode === 'open') return { ok: true };
|
||||
if (mode === 'closed') return { ok: false, status: 403, error: 'Sign-up is closed. Ask the administrator for an account.' };
|
||||
if (!inviteOk) return { ok: false, status: 403, error: 'Sign-up needs an invite code from the administrator.' };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// An easy-to-read invite code (no look-alike characters), e.g. K7QP-4MXD-9TRW.
|
||||
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
export function generateInviteCode() {
|
||||
const chars = Array.from(randomBytes(12), (b) => ALPHABET[b % ALPHABET.length]).join('');
|
||||
return `${chars.slice(0, 4)}-${chars.slice(4, 8)}-${chars.slice(8, 12)}`;
|
||||
}
|
||||
export const normalizeInviteCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/^(.{4})(.{4})(.{4})$/, '$1-$2-$3');
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect } from 'vitest';
|
||||
import { checkSignup, generateInviteCode, normalizeInviteCode, isSignupMode } from './signup.js';
|
||||
|
||||
test('open lets anyone in; the first account is always allowed', () => {
|
||||
expect(checkSignup({ mode: 'open', userCount: 5, inviteOk: false }).ok).toBe(true);
|
||||
expect(checkSignup({ mode: 'closed', userCount: 0, inviteOk: false }).ok).toBe(true);
|
||||
expect(checkSignup({ mode: 'invite', userCount: 0, inviteOk: false }).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('invite-only needs a good code, closed lets nobody in', () => {
|
||||
expect(checkSignup({ mode: 'invite', userCount: 2, inviteOk: false })).toMatchObject({ ok: false, status: 403 });
|
||||
expect(checkSignup({ mode: 'invite', userCount: 2, inviteOk: true }).ok).toBe(true);
|
||||
expect(checkSignup({ mode: 'closed', userCount: 2, inviteOk: true })).toMatchObject({ ok: false, status: 403 });
|
||||
});
|
||||
|
||||
test('invite codes are readable and typed forgivingly', () => {
|
||||
const code = generateInviteCode();
|
||||
expect(code).toMatch(/^[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}$/);
|
||||
expect(normalizeInviteCode(` ${code.toLowerCase().replace(/-/g, ' ')} `)).toBe(code);
|
||||
expect(normalizeInviteCode('abcd2345jkmn')).toBe('ABCD-2345-JKMN');
|
||||
expect(isSignupMode('invite')).toBe(true);
|
||||
expect(isSignupMode('nope')).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isAdminEmail } from './auth.js';
|
||||
import { votdAvailable } from './votd.js';
|
||||
import { listPasskeys } from './db.js';
|
||||
|
||||
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
|
||||
export function userPayload(user) {
|
||||
return {
|
||||
id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null,
|
||||
isAdmin: isAdminEmail(user.email),
|
||||
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,
|
||||
loginAlerts: !!user.loginAlerts, passkeyCount: listPasskeys(user.id).length,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Today's verse of the day for the push notification: IQ Bible picks it (the same verse for everyone each UTC day),
|
||||
// in the KJV, which is free to quote. Needs IQBIBLE_API_KEY on the server. One lookup a day serves every user.
|
||||
import { bookNameFor } from './bibleApi.js';
|
||||
|
||||
let cached = null; // { date, verse }
|
||||
|
||||
export const votdAvailable = () => Boolean(process.env.IQBIBLE_API_KEY);
|
||||
|
||||
// IQ Bible's answer -> { reference, text }, or null when it isn't one we can use
|
||||
export function parseVotd(body) {
|
||||
const d = body?.data;
|
||||
const book = bookNameFor(d?.book_usfm);
|
||||
if (!book || !d?.chapter_number || !d?.verse_number || typeof d.text !== 'string' || !d.text.trim()) return null;
|
||||
return { reference: `${book} ${d.chapter_number}:${d.verse_number}`, text: d.text.replace(/^[¶§*\s]+/, '').replace(/\s+/g, ' ').trim() };
|
||||
}
|
||||
|
||||
export async function fetchVerseOfDay(now = new Date(), fetcher = fetch) {
|
||||
const date = now.toISOString().slice(0, 10);
|
||||
if (cached?.date === date) return cached.verse;
|
||||
if (!votdAvailable()) return null;
|
||||
const base = (process.env.IQBIBLE_API_BASE || 'https://api.iqbible.com/api/v2').replace(/\/+$/, '');
|
||||
try {
|
||||
const res = await fetcher(`${base}/bibles/eng_kjv/verse-of-the-day`, { headers: { 'X-IQBible-API-Key': process.env.IQBIBLE_API_KEY }, signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) return null;
|
||||
const verse = parseVotd(await res.json());
|
||||
if (verse) cached = { date, verse };
|
||||
return verse;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export const clearVotdCache = () => { cached = null; };
|
||||
|
||||
export const votdMessage = ({ reference, text }) => ({ title: `Verse of the day: ${reference}`, body: `${text}\n\n${reference} (KJV)`, tags: 'sunrise' });
|
||||
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment node
|
||||
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { parseVotd, fetchVerseOfDay, clearVotdCache, votdMessage } from './votd.js';
|
||||
|
||||
beforeEach(() => { clearVotdCache(); process.env.IQBIBLE_API_KEY = 'k'; });
|
||||
afterEach(() => { delete process.env.IQBIBLE_API_KEY; vi.restoreAllMocks(); });
|
||||
|
||||
const body = { data: { book_usfm: 'ROM', chapter_number: 8, verse_number: 28, text: '¶ And we know that all things work together for good.' } };
|
||||
const ok = () => vi.fn(async () => ({ ok: true, json: async () => body }));
|
||||
|
||||
test('IQ Bible\'s verse becomes a reference and clean text', () => {
|
||||
expect(parseVotd(body)).toEqual({ reference: 'Romans 8:28', text: 'And we know that all things work together for good.' });
|
||||
expect(parseVotd({ data: { book_usfm: 'XXX', chapter_number: 1, verse_number: 1, text: 'x' } })).toBeNull();
|
||||
expect(parseVotd({})).toBeNull();
|
||||
});
|
||||
|
||||
test('the notification carries the verse and its reference', () => {
|
||||
expect(votdMessage({ reference: 'Romans 8:28', text: 'All things.' })).toEqual({ title: 'Verse of the day: Romans 8:28', body: 'All things.\n\nRomans 8:28 (KJV)', tags: 'sunrise' });
|
||||
});
|
||||
|
||||
test('one lookup a day serves everyone, and a new day looks again', async () => {
|
||||
const fetcher = ok();
|
||||
const day1 = new Date('2026-10-06T12:00:00Z');
|
||||
expect((await fetchVerseOfDay(day1, fetcher)).reference).toBe('Romans 8:28');
|
||||
await fetchVerseOfDay(new Date('2026-10-06T23:00:00Z'), fetcher);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
await fetchVerseOfDay(new Date('2026-10-07T01:00:00Z'), fetcher);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(fetcher.mock.calls[0][0]).toContain('/bibles/eng_kjv/verse-of-the-day');
|
||||
expect(fetcher.mock.calls[0][1].headers['X-IQBible-API-Key']).toBe('k');
|
||||
});
|
||||
|
||||
test('without a key, or when IQ Bible fails, there is no verse (and nothing is remembered)', async () => {
|
||||
delete process.env.IQBIBLE_API_KEY;
|
||||
expect(await fetchVerseOfDay(new Date(), ok())).toBeNull();
|
||||
process.env.IQBIBLE_API_KEY = 'k';
|
||||
expect(await fetchVerseOfDay(new Date(), vi.fn(async () => ({ ok: false })))).toBeNull();
|
||||
expect(await fetchVerseOfDay(new Date(), vi.fn(async () => { throw new Error('down'); }))).toBeNull();
|
||||
expect((await fetchVerseOfDay(new Date(), ok())).reference).toBe('Romans 8:28'); // a later good answer still works
|
||||
});
|
||||
+166
-55
@@ -5,23 +5,31 @@ import { AppContext, useApp } from './context/AppContext.js';
|
||||
import { ToastProvider, useToast } from './components/Toasts.jsx';
|
||||
import CommandPalette from './components/CommandPalette.jsx';
|
||||
import ShortcutsHelp from './components/ShortcutsHelp.jsx';
|
||||
import SharedPasscodeForm from './components/SharedPasscodeForm.jsx';
|
||||
import SharedOutlineView from './components/SharedOutlineView.jsx';
|
||||
import Tooltips from './components/Tooltips.jsx';
|
||||
import { useAudio } from './hooks/useAudio.js';
|
||||
import { useAuth } from './hooks/useAuth.js';
|
||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||
import { lastTemplateId, planStudyProject } from './utils/planStudy.js';
|
||||
import { isAudienceWindow } from './utils/presentSync.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 { 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,
|
||||
} from './utils/bibleUtils.jsx';
|
||||
import { loadProjectById, migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
|
||||
import { knownOnServer, unsyncedStudies } from './utils/syncQueue.js';
|
||||
import { fetchStudyChapter } from './utils/studyText.js';
|
||||
import { mergeStudies } from './utils/mergeStudies.js';
|
||||
|
||||
// Less-common pages are split into their own chunks so the first load only
|
||||
// downloads what's needed to show the home screen.
|
||||
@@ -31,12 +39,15 @@ const ImportPage = lazy(() => import('./pages/ImportPage.jsx'));
|
||||
const ReaderPage = lazy(() => import('./pages/ReaderPage.jsx'));
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
|
||||
const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
|
||||
const AudienceView = lazy(() => import('./components/AudienceView.jsx'));
|
||||
const InsightsPage = lazy(() => import('./pages/InsightsPage.jsx'));
|
||||
const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
|
||||
const OutlinePage = lazy(() => import('./pages/OutlinePage.jsx'));
|
||||
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
|
||||
@@ -54,7 +65,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;
|
||||
@@ -70,6 +81,9 @@ 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]);
|
||||
// A new page starts at the top. Without this the scroll position of the previous page carries over, and on
|
||||
@@ -96,10 +110,17 @@ const AppInner = () => {
|
||||
reader.pullServerMarks();
|
||||
// Theme, default translation, layout, commentary and reading position.
|
||||
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();
|
||||
},
|
||||
@@ -112,6 +133,22 @@ const AppInner = () => {
|
||||
setCurrentPage,
|
||||
});
|
||||
|
||||
// Opening an invite link (?join=TOKEN) while signed in adds that study to your list, ready to edit.
|
||||
useEffect(() => {
|
||||
if (!authUser) return;
|
||||
const token = new URLSearchParams(window.location.search).get('join');
|
||||
if (!token) return;
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
joinStudy(token).then(async (joined) => {
|
||||
if (!joined.ok) { toast(joined.error ?? 'Could not use that invite link.', { tone: 'error' }); return; }
|
||||
const full = await loadRemoteProject(joined.data.projectId);
|
||||
if (full.ok) { saveProjectToStorage(full.data); setSyncBase(full.data.id, full.data.lastEdited); setProjectIndex(loadProjectIndex()); }
|
||||
library.refreshLibrary();
|
||||
toast(`You can now edit “${joined.data.title}”. It's in My Studies.`);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authUser]);
|
||||
|
||||
// ⌘K search/go-to, the "?" shortcut list, and requests other pages make of the Reader.
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
@@ -133,8 +170,8 @@ const AppInner = () => {
|
||||
// The browser tab and history show where you are ("Romans 8 · Bible Study", "Luke — Outline · Bible Study")
|
||||
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',
|
||||
home: 'My Studies', setup: 'Chunks', study: 'Study', outline: 'Outline', script: 'Script', export: 'Finish',
|
||||
import: 'Import', pronunciation: 'Pronunciation Guide', settings: 'Settings', admin: 'Admin', 'reading-plans': 'Reading Plans', prayer: 'Prayer journal', insights: 'Insights',
|
||||
};
|
||||
let page = names[currentPage] ?? '';
|
||||
if (currentPage === 'reader') {
|
||||
@@ -163,10 +200,12 @@ const AppInner = () => {
|
||||
|
||||
const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : [];
|
||||
|
||||
// Autosave: 1s after the last edit, save locally, then sync to the server.
|
||||
// Autosave: 1s after the last edit, save locally, then sync to the server. A save still waiting out
|
||||
// that second is written straight away when you leave the study or the tab, so nothing typed is lost.
|
||||
// Opening a study doesn't count as editing it — saving then would stamp the server's copy as "newer"
|
||||
// and make your other devices think it changed.
|
||||
const savedSignatureRef = useRef({ id: null, signature: '' });
|
||||
const pendingSaveRef = useRef(null); // { project, signature } waiting for the debounce
|
||||
const [syncConflict, setSyncConflict] = useState(null); // { id, serverEdited } when the server has a newer copy
|
||||
const conflictRef = useRef(null);
|
||||
conflictRef.current = syncConflict;
|
||||
@@ -177,7 +216,15 @@ const AppInner = () => {
|
||||
setSyncStatus('syncing');
|
||||
const result = await saveRemoteProject(toSave, { baseEdited: getSyncBase(toSave.id), force });
|
||||
if (result.ok) {
|
||||
setSyncBase(toSave.id, result.data?.lastEdited ?? toSave.lastEdited);
|
||||
// The server's clock decides the version; keep this device's copy on the same number
|
||||
const serverEdited = result.data?.lastEdited ?? toSave.lastEdited;
|
||||
setSyncBase(toSave.id, serverEdited);
|
||||
unsyncedStudies.remove(toSave.id);
|
||||
knownOnServer.add(toSave.id);
|
||||
const stored = loadProjectById(toSave.id);
|
||||
if (stored && stored.lastEdited === toSave.lastEdited && serverEdited !== toSave.lastEdited) {
|
||||
try { saveProjectToStorage({ ...stored, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* the copy is already saved */ }
|
||||
}
|
||||
setSyncConflict((c) => (c?.id === toSave.id ? null : c));
|
||||
setSyncStatus('synced');
|
||||
window.setTimeout(() => setSyncStatus(''), 2500);
|
||||
@@ -190,8 +237,49 @@ const AppInner = () => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const commitSave = async (toEdit) => {
|
||||
// Stamp lastEdited once so localStorage and server receive the same value
|
||||
const toSave = { ...toEdit, lastEdited: Date.now() };
|
||||
|
||||
// 1. Save locally (can fail when the device is out of storage)
|
||||
try {
|
||||
saveProjectToStorage(toSave);
|
||||
} catch (err) {
|
||||
console.error('Local save failed:', err);
|
||||
setSaveStatus('');
|
||||
toast("Couldn't save on this device — storage is full. Free some space or export a backup.", { tone: 'error' });
|
||||
}
|
||||
unsyncedStudies.add(toSave.id);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
setSaveStatus('Saved');
|
||||
window.setTimeout(() => setSaveStatus(''), 1400);
|
||||
|
||||
// 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice)
|
||||
if (conflictRef.current?.id === toSave.id) return;
|
||||
await pushProject(toSave);
|
||||
};
|
||||
|
||||
const flushPendingSave = () => {
|
||||
const pending = pendingSaveRef.current;
|
||||
if (!pending) return Promise.resolve();
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
pendingSaveRef.current = null;
|
||||
savedSignatureRef.current = { id: pending.project.id, signature: pending.signature };
|
||||
return commitSave(pending.project);
|
||||
};
|
||||
const flushRef = useRef(flushPendingSave);
|
||||
flushRef.current = flushPendingSave;
|
||||
|
||||
useEffect(() => {
|
||||
if (!project) return undefined;
|
||||
const flush = () => { if (document.visibilityState !== 'visible') flushRef.current(); };
|
||||
window.addEventListener('pagehide', flush);
|
||||
document.addEventListener('visibilitychange', flush);
|
||||
return () => { window.removeEventListener('pagehide', flush); document.removeEventListener('visibilitychange', flush); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project) { flushPendingSave(); return undefined; }
|
||||
if (pendingSaveRef.current && pendingSaveRef.current.project.id !== project.id) flushPendingSave();
|
||||
const signature = signatureOf(project);
|
||||
const last = savedSignatureRef.current;
|
||||
if (last.id !== project.id) {
|
||||
@@ -200,25 +288,10 @@ const AppInner = () => {
|
||||
} else if (last.signature === signature) {
|
||||
return undefined;
|
||||
}
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = window.setTimeout(async () => {
|
||||
savedSignatureRef.current = { id: project.id, signature };
|
||||
// Stamp lastEdited once so localStorage and server receive the same value
|
||||
const toSave = { ...project, lastEdited: Date.now() };
|
||||
|
||||
// 1. Save locally
|
||||
saveProjectToStorage(toSave);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
setSaveStatus('Saved');
|
||||
window.setTimeout(() => setSaveStatus(''), 1400);
|
||||
|
||||
// 2. Sync to server with the same timestamped object (not while a conflict is waiting on a choice)
|
||||
if (conflictRef.current?.id === toSave.id) return;
|
||||
await pushProject(toSave);
|
||||
}, 1000);
|
||||
return () => {
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
};
|
||||
pendingSaveRef.current = { project, signature };
|
||||
window.clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = window.setTimeout(() => flushRef.current(), 1000);
|
||||
return undefined;
|
||||
}, [project]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// The two ways out of a conflict: keep this device's version, or take the server's.
|
||||
@@ -232,6 +305,7 @@ const AppInner = () => {
|
||||
if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; }
|
||||
saveProjectToStorage(result.data);
|
||||
setSyncBase(syncConflict.id, result.data.lastEdited);
|
||||
unsyncedStudies.remove(syncConflict.id);
|
||||
const loaded = loadProjectById(syncConflict.id);
|
||||
savedSignatureRef.current = { id: syncConflict.id, signature: signatureOf(loaded) };
|
||||
setProject(loaded);
|
||||
@@ -241,16 +315,57 @@ 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;
|
||||
const result = await loadRemoteProject(syncConflict.id);
|
||||
if (!result.ok) { toast("Couldn't load the other version. Check your connection and try again.", { tone: 'error' }); return; }
|
||||
const { project: merged, stats } = mergeStudies(project, result.data);
|
||||
setSyncBase(syncConflict.id, result.data.lastEdited);
|
||||
savedSignatureRef.current = { id: merged.id, signature: signatureOf(merged) };
|
||||
setProject(merged);
|
||||
const toSave = { ...merged, lastEdited: Date.now() };
|
||||
try { saveProjectToStorage(toSave); } catch { /* storage full: the server copy below still holds it */ }
|
||||
unsyncedStudies.add(toSave.id);
|
||||
setProjectIndex(loadProjectIndex());
|
||||
const saved = await pushProject(toSave);
|
||||
if (saved.ok) {
|
||||
toast(`Combined both versions: ${stats.added} chunk${stats.added === 1 ? '' : 's'} added from the other device, ${stats.merged} merged`);
|
||||
}
|
||||
};
|
||||
|
||||
const updateProject = (updater) => {
|
||||
setProject((current) => (current ? updater(current) : current));
|
||||
};
|
||||
|
||||
const updateChunk = (chunkId, patch) => {
|
||||
// `touch: false` saves a change without counting it as an edit (so it doesn't reset "last edited" for recalls and summaries).
|
||||
const updateChunk = (chunkId, patch, { touch = true } = {}) => {
|
||||
updateProject((current) => ({
|
||||
...current,
|
||||
chapters: current.chapters.map((ch) => ({
|
||||
...ch,
|
||||
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch } : c)),
|
||||
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch, ...(touch ? { editedAt: Date.now() } : {}) } : c)),
|
||||
})),
|
||||
}));
|
||||
};
|
||||
@@ -259,6 +374,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) => {
|
||||
@@ -308,36 +426,18 @@ const AppInner = () => {
|
||||
const studyReadingPlanDay = async (dayRefs, title) => {
|
||||
setStudyBuildBusy(true);
|
||||
try {
|
||||
const lastTemplate = lastTemplateId(); // the notes template you picked last time you started a study by hand
|
||||
const templateDef = reader.userTemplates?.find((t) => t.id === lastTemplate);
|
||||
const chapters = await Promise.all(dayRefs.map(async ({ book, chapter }) => {
|
||||
const res = await fetch(`https://bible.helloao.org/api/BSB/${book}/${chapter}.json`);
|
||||
if (!res.ok) throw new Error(`Failed to load ${book} ${chapter}`);
|
||||
const data = await res.json();
|
||||
const verses = parseBibleChapter(data);
|
||||
const bookName = bookOptions.find((b) => b.abbrev === book)?.name ?? book;
|
||||
const chunk = {
|
||||
id: makeId(),
|
||||
startVerse: verses[0]?.number ?? 1,
|
||||
endVerse: verses.at(-1)?.number ?? 1,
|
||||
spilloverEndVerse: null,
|
||||
observation: '', interpretation: '', application: '',
|
||||
crossReferences: [], greekWords: [], generalNotes: '',
|
||||
episodeNumber: '', episodeTitle: '', finalScript: '', tags: [],
|
||||
};
|
||||
return { book: bookName, bookAbbrev: book, chapter: String(chapter), verses, chunks: [chunk] };
|
||||
const data = await fetchStudyChapter('BSB', book, chapter);
|
||||
return { bookAbbrev: book, bookName: bookOptions.find((b) => b.abbrev === book)?.name ?? book, chapter, verses: parseBibleChapter(data) };
|
||||
}));
|
||||
const newProject = {
|
||||
id: makeId(),
|
||||
title: title || 'Reading Plan Study',
|
||||
translation: 'BSB',
|
||||
lastEdited: Date.now(),
|
||||
selectedChunkId: chapters[0]?.chunks[0]?.id ?? null,
|
||||
chapters,
|
||||
};
|
||||
const newProject = planStudyProject({ chapters, title, templateId: lastTemplate, templateDef, makeId });
|
||||
setProject(newProject);
|
||||
setCurrentPage('study');
|
||||
} catch (err) {
|
||||
console.error('studyReadingPlanDay error:', err);
|
||||
toast("Couldn't load today's passage. Please try again.", { tone: 'error' });
|
||||
toast(err?.message?.startsWith("You're offline") ? err.message : "Couldn't load today's passage. Please try again.", { tone: 'error' });
|
||||
} finally {
|
||||
setStudyBuildBusy(false);
|
||||
}
|
||||
@@ -374,7 +474,8 @@ const AppInner = () => {
|
||||
const retrySync = async () => {
|
||||
if (!project) return;
|
||||
const toSave = { ...project, lastEdited: Date.now() };
|
||||
saveProjectToStorage(toSave);
|
||||
try { saveProjectToStorage(toSave); } catch { /* storage full: still try the server */ }
|
||||
unsyncedStudies.add(toSave.id);
|
||||
await pushProject(toSave);
|
||||
};
|
||||
|
||||
@@ -395,7 +496,7 @@ const AppInner = () => {
|
||||
saveStatus, setSaveStatus,
|
||||
syncStatus,
|
||||
retrySync,
|
||||
syncConflict, keepMyVersion, useServerVersion,
|
||||
syncConflict, keepMyVersion, useServerVersion, mergeBothVersions, restoreEarlierVersion,
|
||||
// Home
|
||||
projectIndex,
|
||||
...library,
|
||||
@@ -419,6 +520,9 @@ const AppInner = () => {
|
||||
...audio,
|
||||
// Reader
|
||||
...reader,
|
||||
readingHistory: readingHistory.readingHistory,
|
||||
...prayerJournal,
|
||||
prayerDraft, clearPrayerDraft: () => setPrayerDraft(null), startPrayerFor, openPrayer,
|
||||
readerPlan, setReaderPlan,
|
||||
readerPlanActive: !!readerPlanProgress,
|
||||
openPlanReading,
|
||||
@@ -463,13 +567,17 @@ function PageLoading() {
|
||||
|
||||
function AppRouter() {
|
||||
const { currentPage, authUser, authServerDown } = useApp();
|
||||
const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView();
|
||||
const { token: sharedViewToken, project: sharedProject, error: sharedError, needsPasscode, passcodeError, checking, submitPasscode } = useSharedView();
|
||||
const sharedOutlineToken = new URLSearchParams(window.location.search).get('outline');
|
||||
|
||||
// A read-only outline link: no sign-in, nothing editable
|
||||
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
|
||||
|
||||
// The second window for presenting on a projector: no sign-in, it only shows what the presenter window sends.
|
||||
if (isAudienceWindow()) return <Suspense fallback={null}><AudienceView /></Suspense>;
|
||||
|
||||
if (sharedViewToken) {
|
||||
if (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />;
|
||||
if (sharedError) {
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-slate-900 px-4">
|
||||
@@ -490,6 +598,7 @@ function AppRouter() {
|
||||
return (
|
||||
<div className="min-h-dvh bg-slate-100">
|
||||
<div className="border-b border-slate-200 bg-slate-900 px-4 py-3 text-center text-xs text-slate-300">
|
||||
{sharedProject.hiddenUntil ? `Notes appear ${new Date(sharedProject.hiddenUntil).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}. For now: the passages and questions. ` : ''}
|
||||
🔗 Read-only shared view —{' '}
|
||||
<a href="/" className="underline hover:text-white">go to Bible Study Project</a>
|
||||
</div>
|
||||
@@ -510,8 +619,10 @@ 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 === 'insights') return <InsightsPage />;
|
||||
if (currentPage === 'study') return <StudyPage />;
|
||||
if (currentPage === 'outline') return <OutlinePage />;
|
||||
if (currentPage === 'script') return <ScriptPage />;
|
||||
|
||||
-1441
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,137 @@
|
||||
// Admin → Health: is the server in good shape? Disk use, snapshots (make, download, restore) and recent failures.
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { adminBackupNow, adminGetHealth, adminRestoreSnapshot, adminSnapshotUrl } from '../syncService.js';
|
||||
|
||||
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||
const card = 'rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-4';
|
||||
|
||||
export const formatBytes = (n) => {
|
||||
if (n == null) return 'unknown';
|
||||
if (n < 1024) return `${n} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let v = n / 1024; let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
|
||||
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
||||
};
|
||||
export const formatUptime = (s) => {
|
||||
if (s < 90) return `${s} seconds`;
|
||||
if (s < 5400) return `${Math.round(s / 60)} minutes`;
|
||||
if (s < 129600) return `${Math.round(s / 3600)} hours`;
|
||||
return `${Math.round(s / 86400)} days`;
|
||||
};
|
||||
const ago = (at) => {
|
||||
const m = Math.round((Date.now() - at) / 60000);
|
||||
if (m < 2) return 'just now';
|
||||
if (m < 120) return `${m} minutes ago`;
|
||||
if (m < 2880) return `${Math.round(m / 60)} hours ago`;
|
||||
return `${Math.round(m / 1440)} days ago`;
|
||||
};
|
||||
|
||||
function Log({ title, empty, rows }) {
|
||||
return (
|
||||
<section className={card}>
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{rows.length === 0 ? <p className="mt-2 text-xs text-[var(--r-sub)]">{empty}</p> : (
|
||||
<ul className="mt-2 divide-y divide-[var(--r-line)] text-xs">
|
||||
{rows.map((e, i) => (
|
||||
<li key={`${e.at}-${i}`} className="flex flex-wrap justify-between gap-x-3 py-1.5">
|
||||
<span className="min-w-0 break-words">{e.detail}{e.email && e.kind === 'notify' ? ` (${e.email})` : ''}</span>
|
||||
<span className="shrink-0 text-[var(--r-sub)]">{ago(e.at)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminHealth() {
|
||||
const [state, setState] = useState({ loading: true, error: '', data: null });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [restoring, setRestoring] = useState(null); // snapshot name awaiting typed confirmation
|
||||
const [typed, setTyped] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const r = await adminGetHealth();
|
||||
setState(r.ok ? { loading: false, error: '', data: r.data } : { loading: false, error: r.error ?? 'Could not read server health.', data: null });
|
||||
}, []);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const backupNow = async () => {
|
||||
setBusy(true); setMessage('');
|
||||
const r = await adminBackupNow();
|
||||
setBusy(false);
|
||||
setMessage(r.ok ? 'Snapshot saved.' : r.error ?? 'The snapshot could not be made.');
|
||||
load();
|
||||
};
|
||||
const restore = async () => {
|
||||
setBusy(true); setMessage('');
|
||||
const r = await adminRestoreSnapshot(restoring);
|
||||
setBusy(false);
|
||||
if (r.ok) { setRestoring(null); setTyped(''); setMessage(`Restored ${r.data.restored}. What was there before is saved as ${r.data.safetyCopy}. You may need to sign in again.`); load(); }
|
||||
else setMessage(r.error ?? 'The snapshot could not be restored.');
|
||||
};
|
||||
|
||||
if (state.loading) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
|
||||
if (state.error) return <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>;
|
||||
const { data } = state;
|
||||
const { backups, disk } = data;
|
||||
const stale = backups.enabled && (!backups.latest || Date.now() - backups.latest.at > 36 * 3600 * 1000);
|
||||
const usedPct = disk.total ? Math.round(((disk.total - disk.free) / disk.total) * 100) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className={card}>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
<div><dt className="text-xs text-[var(--r-sub)]">Running for</dt><dd>{formatUptime(data.uptimeSeconds)}</dd></div>
|
||||
<div><dt className="text-xs text-[var(--r-sub)]">Database</dt><dd>{formatBytes(disk.db)}</dd></div>
|
||||
<div><dt className="text-xs text-[var(--r-sub)]">Snapshots</dt><dd>{formatBytes(disk.backups)}</dd></div>
|
||||
<div><dt className="text-xs text-[var(--r-sub)]">Disk free</dt><dd>{formatBytes(disk.free)}{usedPct != null ? ` (${usedPct}% used)` : ''}</dd></div>
|
||||
</dl>
|
||||
<p className="mt-3 text-xs text-[var(--r-sub)]">Node {data.node} · IQ Bible {data.iqConfigured ? 'connected' : 'not set up'} · data in {data.dataDir}</p>
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Snapshots</h3>
|
||||
<button type="button" onClick={backupNow} disabled={busy} className={btn}>{busy && !restoring ? 'Working…' : 'Back up now'}</button>
|
||||
</div>
|
||||
{!backups.enabled && <p className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Automatic snapshots are turned off (BACKUP_DISABLED).</p>}
|
||||
{stale && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{backups.latest ? `The last snapshot is ${ago(backups.latest.at)}.` : 'There are no snapshots yet.'} One should be made every day.</p>}
|
||||
{data.backupFailures.length > 0 && <p className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Last failure: {data.backupFailures[0].detail} ({ago(data.backupFailures[0].at)})</p>}
|
||||
{backups.snapshots.length > 0 && (
|
||||
<ul className="mt-2 divide-y divide-[var(--r-line)] text-sm">
|
||||
{backups.snapshots.map((s) => (
|
||||
<li key={s.name} className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||
<span className="min-w-0"><span className="break-all">{s.name}</span> <span className="text-xs text-[var(--r-sub)]">· {formatBytes(s.bytes)} · {ago(s.at)}</span></span>
|
||||
<span className="flex gap-2">
|
||||
<a href={adminSnapshotUrl(s.name)} download className={btn}>Download</a>
|
||||
<button type="button" onClick={() => { setRestoring(s.name); setTyped(''); setMessage(''); }} className={btn}>Restore…</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-[var(--r-sub)]">Keeps the newest {backups.keep}. A snapshot holds every account’s data.</p>
|
||||
{restoring && (
|
||||
<div role="group" aria-label="Confirm restore" className="mt-3 space-y-2 rounded-xl border border-[var(--r-danger,#b42318)] p-3 text-sm">
|
||||
<p>This replaces <strong>everyone’s</strong> data with what was saved in <span className="break-all">{restoring}</span>. Anything changed since then is lost, though what’s there now is saved as a new snapshot first. Type the snapshot’s name to go ahead.</p>
|
||||
<input value={typed} onChange={(e) => setTyped(e.target.value)} aria-label="Snapshot name" placeholder={restoring}
|
||||
className="w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs" />
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={restore} disabled={busy || typed !== restoring} className="rounded-lg bg-[var(--r-danger,#b42318)] px-3 py-1.5 text-xs font-medium text-white disabled:opacity-40">{busy ? 'Restoring…' : 'Restore this snapshot'}</button>
|
||||
<button type="button" onClick={() => setRestoring(null)} className={btn}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{message && <p role="status" className="mt-2 text-xs text-[var(--r-sub)]">{message}</p>}
|
||||
</section>
|
||||
|
||||
<Log title="Failed sign-ins" empty="None since the server last started." rows={data.signinFailures} />
|
||||
<Log title="Failed notifications" empty="None since the server last started." rows={data.notifyFailures} />
|
||||
<p className="text-xs text-[var(--r-sub)]">The failure lists are kept in memory, so they start over when the server restarts.</p>
|
||||
<button type="button" onClick={load} className={btn}>Refresh</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import AdminHealth, { formatBytes, formatUptime } from './AdminHealth.jsx';
|
||||
|
||||
const health = (over = {}) => ({
|
||||
uptimeSeconds: 7200, node: 'v22', dataDir: '/data', iqConfigured: false,
|
||||
disk: { db: 2048, backups: 4096, free: 5e9, total: 1e10 },
|
||||
backups: { enabled: true, keep: 7, latest: { name: 'projects-2026-10-05-03-00.db', bytes: 2048, at: Date.now() - 3600000 }, snapshots: [{ name: 'projects-2026-10-05-03-00.db', bytes: 2048, at: Date.now() - 3600000 }] },
|
||||
signinFailures: [{ at: Date.now() - 60000, kind: 'signin', detail: 'Wrong password for a@x.test from 1.2.3.4' }],
|
||||
notifyFailures: [], backupFailures: [], ...over,
|
||||
});
|
||||
const mockFetch = (routes) => vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
||||
const key = `${opts?.method ?? 'GET'} ${String(url).replace(/^.*\/api/, '')}`;
|
||||
const r = routes[key] ?? routes.default;
|
||||
return { ok: r.ok ?? true, status: r.status ?? 200, json: async () => r.body };
|
||||
}));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test('formats sizes and times plainly', () => {
|
||||
expect(formatBytes(2048)).toBe('2.0 KB');
|
||||
expect(formatBytes(5e9)).toBe('4.7 GB');
|
||||
expect(formatBytes(null)).toBe('unknown');
|
||||
expect(formatUptime(7200)).toBe('2 hours');
|
||||
});
|
||||
|
||||
test('shows disk use, snapshots and sign-in failures', async () => {
|
||||
mockFetch({ 'GET /admin/health': { body: health() } });
|
||||
render(<AdminHealth />);
|
||||
expect(await screen.findByText('2 hours')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Wrong password for a@x.test/)).toBeInTheDocument();
|
||||
expect(screen.getByText('None since the server last started.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Download' })).toHaveAttribute('href', expect.stringContaining('/admin/backups/projects-2026-10-05-03-00.db'));
|
||||
});
|
||||
|
||||
test('warns when the newest snapshot is old', async () => {
|
||||
const old = Date.now() - 4 * 86400000;
|
||||
mockFetch({ 'GET /admin/health': { body: health({ backups: { enabled: true, keep: 7, latest: { name: 'x', bytes: 1, at: old }, snapshots: [] } }) } });
|
||||
render(<AdminHealth />);
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/last snapshot is 4 days ago/);
|
||||
});
|
||||
|
||||
test('restoring needs the snapshot name typed first', async () => {
|
||||
const name = 'projects-2026-10-05-03-00.db';
|
||||
mockFetch({ 'GET /admin/health': { body: health() }, [`POST /admin/backups/${name}/restore`]: { body: { restored: name, safetyCopy: 'projects-new.db' } } });
|
||||
const user = userEvent.setup();
|
||||
render(<AdminHealth />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Restore…' }));
|
||||
const go = screen.getByRole('button', { name: 'Restore this snapshot' });
|
||||
expect(go).toBeDisabled();
|
||||
await user.type(screen.getByLabelText('Snapshot name'), name);
|
||||
expect(go).toBeEnabled();
|
||||
await user.click(go);
|
||||
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Restored projects-2026-10-05-03-00.db'));
|
||||
expect(fetch.mock.calls.some(([u, o]) => o?.method === 'POST' && JSON.parse(o.body).confirm === name)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// Admin → Sign-ups: who may create an account, and invite codes for when it is invite-only.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { adminCreateInvite, adminDeleteInvite, adminGetSignup, adminSetSignupMode } from '../syncService.js';
|
||||
|
||||
const MODES = [
|
||||
['open', 'Open', 'Anyone who can reach this site can create an account.'],
|
||||
['invite', 'Invite only', 'A new account needs an invite code from you.'],
|
||||
['closed', 'Closed', 'Nobody new can sign up.'],
|
||||
];
|
||||
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 AdminSignup() {
|
||||
const [state, setState] = useState({ mode: 'open', invites: [], loading: true, error: '' });
|
||||
const [note, setNote] = useState('');
|
||||
const [days, setDays] = useState(7);
|
||||
const [copied, setCopied] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
const r = await adminGetSignup();
|
||||
setState(r.ok ? { ...r.data, loading: false, error: '' } : { mode: 'open', invites: [], loading: false, error: r.error ?? 'Could not load sign-up settings.' });
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const setMode = async (mode) => {
|
||||
const r = await adminSetSignupMode(mode);
|
||||
if (r.ok) setState((s) => ({ ...s, mode, error: '' })); else setState((s) => ({ ...s, error: r.error ?? 'Could not change that.' }));
|
||||
};
|
||||
const create = async () => {
|
||||
const r = await adminCreateInvite({ note, days });
|
||||
if (!r.ok) { setState((s) => ({ ...s, error: r.error ?? 'Could not make an invite.' })); return; }
|
||||
setNote('');
|
||||
load();
|
||||
};
|
||||
const link = (code) => `${window.location.origin}/?invite=${code}`;
|
||||
const copy = async (code) => {
|
||||
try { await navigator.clipboard.writeText(link(code)); setCopied(code); window.setTimeout(() => setCopied(''), 1800); } catch { /* clipboard unavailable */ }
|
||||
};
|
||||
const status = (inv) => (inv.usedBy ? 'Used' : inv.expiresAt < Date.now() ? 'Expired' : `Until ${new Date(inv.expiresAt).toLocaleDateString()}`);
|
||||
|
||||
if (state.loading) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Who can sign up">
|
||||
<h2 className="text-base font-medium">Who can sign up</h2>
|
||||
<div role="radiogroup" aria-label="Sign-up setting" className="mt-3 space-y-2">
|
||||
{MODES.map(([id, label, hint]) => (
|
||||
<label key={id} className="flex cursor-pointer items-start gap-3 text-sm">
|
||||
<input type="radio" name="signup-mode" checked={state.mode === id} onChange={() => setMode(id)} className="mt-1 accent-[var(--r-accent)]" />
|
||||
<span><span className="font-medium">{label}</span><span className="block text-xs text-[var(--r-sub)]">{hint}</span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{state.error && <p role="alert" className="mt-3 text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>}
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Invite codes">
|
||||
<h2 className="text-base font-medium">Invite codes</h2>
|
||||
<p className="mt-1 text-xs text-[var(--r-sub)]">Each code works once. Send the link, and it opens the sign-up page with the code filled in.{state.mode !== 'invite' ? ' (Codes only matter while sign-up is “Invite only”.)' : ''}</p>
|
||||
<div className="mt-3 flex flex-wrap items-end gap-2">
|
||||
<label className="text-xs text-[var(--r-sub)]">Who is it for (optional)
|
||||
<input type="text" value={note} onChange={(e) => setNote(e.target.value)} maxLength={80} className="mt-1 block w-52 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]" />
|
||||
</label>
|
||||
<label className="text-xs text-[var(--r-sub)]">Good for
|
||||
<select value={days} onChange={(e) => setDays(Number(e.target.value))} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||
{[1, 7, 30].map((d) => <option key={d} value={d}>{d} day{d === 1 ? '' : 's'}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" onClick={create} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">Make an invite</button>
|
||||
</div>
|
||||
{state.invites.length > 0 && (
|
||||
<ul className="mt-4 divide-y divide-[var(--r-line)] text-sm">
|
||||
{state.invites.map((inv) => (
|
||||
<li key={inv.code} className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||
<span><span className="font-mono tracking-wider">{inv.code}</span>{inv.note ? <span className="ml-2 text-[var(--r-sub)]">{inv.note}</span> : null}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--r-sub)]">{status(inv)}</span>
|
||||
{!inv.usedBy && inv.expiresAt >= Date.now() && <button type="button" onClick={() => copy(inv.code)} className={btn}>{copied === inv.code ? 'Copied' : 'Copy link'}</button>}
|
||||
<button type="button" onClick={async () => { await adminDeleteInvite(inv.code); load(); }} className={`${btn} text-[var(--r-danger,#b42318)]`}>Delete</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// The audience window for two-screen presenting: drag it to the projector or second display and press F (or click)
|
||||
// for full screen. It only draws what the presenter window sends, which never includes private notes.
|
||||
import { useEffect, useState } from 'react';
|
||||
import StepFace from './StepFace.jsx';
|
||||
import { openChannel } from '../utils/presentSync.js';
|
||||
|
||||
export default function AudienceView() {
|
||||
const [state, setState] = useState(null);
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [hint, setHint] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Audience display';
|
||||
const channel = openChannel();
|
||||
if (!channel) return undefined;
|
||||
channel.onmessage = (e) => {
|
||||
if (e.data?.type === 'state') { setState(e.data); setEnded(false); }
|
||||
else if (e.data?.type === 'end') setEnded(true);
|
||||
};
|
||||
channel.postMessage({ type: 'hello' }); // ask a presenter already running to send what it is showing
|
||||
return () => channel.close();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fullscreen = () => { if (document.fullscreenElement) document.exitFullscreen?.(); else document.documentElement.requestFullscreen?.().catch(() => {}); };
|
||||
const onKey = (e) => { if (e.key === 'f' || e.key === 'F') fullscreen(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
const timer = window.setTimeout(() => setHint(false), 8000);
|
||||
return () => { window.removeEventListener('keydown', onKey); window.clearTimeout(timer); };
|
||||
}, []);
|
||||
|
||||
const dark = state?.dark ?? true;
|
||||
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
|
||||
const fontSize = `${state?.fontPx ?? 40}px`;
|
||||
let body;
|
||||
if (!state || ended) {
|
||||
body = <p className="text-center text-lg opacity-60">{ended ? 'The presentation has ended.' : 'Waiting for the presenter…'}</p>;
|
||||
} else if (state.blank) {
|
||||
body = null;
|
||||
} else if (state.mode === 'outline' && state.step) {
|
||||
body = <StepFace step={state.step} index={state.index} total={state.total} fontSize={fontSize} />;
|
||||
} else {
|
||||
body = (
|
||||
<div className="text-center font-serif" style={{ fontSize }}>
|
||||
<h1 className="font-semibold">{state.title}</h1>
|
||||
{state.subtitle && <p className="mt-3 text-[0.55em] opacity-70">{state.subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`fixed inset-0 flex cursor-none items-center justify-center overflow-hidden px-[6vw] py-[6dvh] ${state?.blank ? 'bg-black' : colors}`} onClick={() => document.documentElement.requestFullscreen?.().catch(() => {})}>
|
||||
{body}
|
||||
{hint && !state?.blank && <p className="fixed bottom-3 left-0 right-0 text-center font-sans text-xs opacity-40">Press F or click for full screen</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// The picture on a study's card: a coloured cover for the kind of book, with the book's name.
|
||||
import { coverFor } from '../utils/bookCovers.js';
|
||||
|
||||
export default function BookCover({ summary, className = '', compact = false }) {
|
||||
const { group, name, extra } = coverFor(summary);
|
||||
const [from, to] = group.colors;
|
||||
const id = `cover-${group.id}`;
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-xl ${className}`} role="img" aria-label={`${name}${extra ? ` and ${extra} more` : ''}, ${group.label}`}>
|
||||
<svg viewBox={compact ? '0 0 200 120' : '0 0 400 120'} preserveAspectRatio="xMidYMid slice" className="block h-full w-full" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor={from} /><stop offset="1" stopColor={to} /></linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="120" fill={`url(#${id})`} />
|
||||
<g fill="none" stroke="#fff" strokeOpacity="0.1" strokeWidth="1.5">
|
||||
<circle cx="360" cy="20" r="70" /><circle cx="360" cy="20" r="50" /><circle cx="360" cy="20" r="30" />
|
||||
<circle cx="30" cy="120" r="60" /><circle cx="30" cy="120" r="40" />
|
||||
</g>
|
||||
<rect x={compact ? 14 : 20} y="22" width="28" height="3" rx="1.5" fill="#fff" fillOpacity="0.7" />
|
||||
{compact ? (
|
||||
<>
|
||||
<text x="14" y="70" fill="#fff" fontFamily="Georgia, 'Times New Roman', serif" fontSize={name.length > 11 ? 21 : name.length > 8 ? 25 : 30} fontWeight="600">{name}</text>
|
||||
{extra > 0 && <text x="14" y="98" fill="#fff" fillOpacity="0.85" fontFamily="-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" fontSize="14" letterSpacing="1">+{extra} MORE</text>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<text x="20" y="62" fill="#fff" fontFamily="Georgia, 'Times New Roman', serif" fontSize={name.length > 13 ? 26 : 32} fontWeight="600">{name}</text>
|
||||
<text x="20" y="90" fill="#fff" fillOpacity="0.8" fontFamily="-apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" fontSize="13" letterSpacing="1.5">
|
||||
{group.label.toUpperCase()}{extra ? ` · +${extra} MORE` : ''}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Finish → Co-teachers: let other accounts edit this study with you. You make a single-use invite link;
|
||||
// whoever opens it (signed in) can edit the study, and you can remove them any time. Edits from both of you
|
||||
// sync through the account; if you both change the same study, "Combine both" keeps everything.
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createEditInvite, getCollaborators, removeCollaborator } from '../syncService.js';
|
||||
|
||||
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
|
||||
|
||||
export default function CoTeachers({ projectId, sharedBy, onLeave }) {
|
||||
const [state, setState] = useState({ status: 'loading', collaborators: [], pendingInvites: 0 });
|
||||
const [invite, setInvite] = useState(null); // { url, expiresAt }
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId || sharedBy !== undefined) return;
|
||||
const r = await getCollaborators(projectId);
|
||||
setState(r.ok ? { status: 'ready', ...r.data } : { status: 'unavailable', collaborators: [], pendingInvites: 0 });
|
||||
}, [projectId, sharedBy]);
|
||||
useEffect(() => { setInvite(null); load(); }, [load]);
|
||||
|
||||
// Someone else's study that you were invited to edit
|
||||
if (sharedBy !== undefined) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Co-teachers">
|
||||
<h2 className="text-sm font-medium">Shared with you</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">{sharedBy || 'Its owner'} invited you to edit this study. Your changes sync to them, and theirs to you.</p>
|
||||
<button type="button" onClick={onLeave} className={btn}>Leave this study</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const makeInvite = async () => {
|
||||
setBusy(true); setError('');
|
||||
const r = await createEditInvite(projectId);
|
||||
setBusy(false);
|
||||
if (!r.ok) { setError(r.error ?? 'Could not make the link.'); return; }
|
||||
setInvite({ url: `${window.location.origin}${window.location.pathname}?join=${r.data.token}`, expiresAt: r.data.expiresAt });
|
||||
load();
|
||||
};
|
||||
const copy = async () => {
|
||||
try { await navigator.clipboard.writeText(invite.url); setCopied(true); window.setTimeout(() => setCopied(false), 1800); } catch { setError('Couldn’t copy. Select the link and copy it.'); }
|
||||
};
|
||||
const remove = async (userId, email) => {
|
||||
if (!window.confirm(`Remove ${email}? They will no longer be able to edit this study.`)) return;
|
||||
const r = await removeCollaborator(projectId, userId);
|
||||
if (r.ok) load(); else setError(r.error ?? 'Could not remove them.');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Co-teachers">
|
||||
<h2 className="text-sm font-medium">Co-teachers</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Let someone else edit this study with you. They need an account here. Each invite link works once and lasts 7 days.</p>
|
||||
{state.status === 'unavailable' && <p className="text-xs text-[var(--r-sub)]">Invites need your account and a connection, and the study has to have synced once. Try again in a moment.</p>}
|
||||
{state.status === 'ready' && (
|
||||
<>
|
||||
{state.collaborators.length > 0 && (
|
||||
<ul className="mb-3 divide-y divide-[var(--r-line)] text-sm">
|
||||
{state.collaborators.map((c) => (
|
||||
<li key={c.userId} className="flex items-center justify-between gap-3 py-2">
|
||||
<span>{c.email}</span>
|
||||
<button type="button" onClick={() => remove(c.userId, c.email)} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{invite ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input readOnly aria-label="Invite link" value={invite.url} onFocus={(e) => e.target.select()} className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
|
||||
<button type="button" onClick={copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{copied ? 'Copied!' : 'Copy'}</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-[var(--r-sub)]">Send it to one person. It stops working once they use it, or on {new Date(invite.expiresAt).toLocaleDateString()}.</p>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={makeInvite} disabled={busy} className={btn}>{busy ? 'Making a link…' : 'Invite a co-teacher'}</button>
|
||||
)}
|
||||
{state.pendingInvites > 0 && !invite && <p className="mt-2 text-[11px] text-[var(--r-sub)]">{state.pendingInvites} invite{state.pendingInvites === 1 ? '' : 's'} waiting to be used.</p>}
|
||||
</>
|
||||
)}
|
||||
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../syncService.js', () => ({ getCollaborators: vi.fn(), createEditInvite: vi.fn(), removeCollaborator: vi.fn() }));
|
||||
import CoTeachers from './CoTeachers.jsx';
|
||||
import { createEditInvite, getCollaborators, removeCollaborator } from '../syncService.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getCollaborators.mockResolvedValue({ ok: true, data: { collaborators: [{ userId: 'u2', email: 'sam@x.co' }], pendingInvites: 0 } });
|
||||
createEditInvite.mockResolvedValue({ ok: true, data: { token: 'abc123', expiresAt: Date.now() + 7 * 86_400_000 } });
|
||||
removeCollaborator.mockResolvedValue({ ok: true });
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('the owner sees the co-teachers and can make a single-use invite link', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CoTeachers projectId="p1" />);
|
||||
expect(await screen.findByText('sam@x.co')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /invite a co-teacher/i }));
|
||||
const link = await screen.findByLabelText('Invite link');
|
||||
expect(link.value).toContain('?join=abc123');
|
||||
expect(createEditInvite).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
test('the owner can remove a co-teacher after confirming', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CoTeachers projectId="p1" />);
|
||||
await user.click(await screen.findByRole('button', { name: 'Remove' }));
|
||||
expect(removeCollaborator).toHaveBeenCalledWith('p1', 'u2');
|
||||
});
|
||||
|
||||
test('someone invited to a study sees who shared it and can leave', async () => {
|
||||
const onLeave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<CoTeachers projectId="p1" sharedBy="owner@x.co" onLeave={onLeave} />);
|
||||
expect(screen.getByText(/owner@x.co invited you to edit/)).toBeInTheDocument();
|
||||
expect(getCollaborators).not.toHaveBeenCalled(); // only owners can list co-teachers
|
||||
await user.click(screen.getByRole('button', { name: /leave this study/i }));
|
||||
expect(onLeave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('when the server can\'t be reached, it says so instead of offering a link', async () => {
|
||||
getCollaborators.mockResolvedValue({ ok: false, status: 404, error: 'Project not found.' });
|
||||
render(<CoTeachers projectId="p1" />);
|
||||
await waitFor(() => expect(screen.getByText(/Invites need your account/)).toBeInTheDocument());
|
||||
expect(screen.queryByRole('button', { name: /invite a co-teacher/i })).toBeNull();
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import { loadProjectById } from '../utils/projectStorage.js';
|
||||
export default function CommandPalette({ onClose }) {
|
||||
const {
|
||||
readerTheme, authUser, authServerDown, projectIndex, readerBookmarks, readerTextHighlights, readerColorLabels,
|
||||
jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans,
|
||||
jumpToReaderVerse, setCurrentPage, setProject, resumeProject, openNewProject, goStudies, openReadingPlans, openPrayer,
|
||||
openBibleReader, requestReaderSearch, openBackup,
|
||||
} = useApp();
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -40,7 +40,7 @@ export default function CommandPalette({ onClose }) {
|
||||
// 2. Pages and actions
|
||||
const pages = [
|
||||
['Studies', 'My Studies', goStudies], ['Read Bible', 'The Bible reader', openBibleReader], ['Reading Plans', 'Plans and today’s reading', openReadingPlans],
|
||||
['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
|
||||
['Prayer journal', 'Requests, answers and the verses behind them', openPrayer], ['New project', 'Start a Bible study', openNewProject], ['Settings', 'Account and preferences', () => setCurrentPage('settings')],
|
||||
['Back up my data', 'Download everything as a file', openBackup],
|
||||
];
|
||||
const lower = q.toLowerCase();
|
||||
@@ -94,7 +94,7 @@ export default function CommandPalette({ onClose }) {
|
||||
if (authUser === null && !authServerDown) return null; // the sign-in screen has nothing to search
|
||||
let lastGroup = null;
|
||||
return (
|
||||
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-start justify-center bg-black/40 px-3 pt-[12vh] font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-start justify-center bg-black/40 px-3 pt-[12dvh] font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Search and go to" className="w-full max-w-xl overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] text-[var(--r-text)] shadow-2xl">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--r-line)] px-4 py-3">
|
||||
<span className="text-[var(--r-sub)]"><SearchIcon /></span>
|
||||
@@ -107,7 +107,7 @@ export default function CommandPalette({ onClose }) {
|
||||
/>
|
||||
<kbd className="hidden rounded border border-[var(--r-line)] px-1.5 py-0.5 text-[10px] text-[var(--r-sub)] sm:block">esc</kbd>
|
||||
</div>
|
||||
<ul id="palette-list" role="listbox" ref={listRef} className="max-h-[50vh] overflow-y-auto p-2">
|
||||
<ul id="palette-list" role="listbox" ref={listRef} className="max-h-[50dvh] overflow-y-auto p-2">
|
||||
{items.length === 0 && <li className="px-3 py-6 text-center text-sm text-[var(--r-sub)]">Nothing found. Try a book and chapter, like “romans 8”.</li>}
|
||||
{items.map((item, i) => {
|
||||
const header = item.group !== lastGroup ? item.group : null;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Home card: your five minutes for today. A short prompt on a passage from your studies, a five-minute clock, and a
|
||||
// box to write in. What you write is added to that passage's notes. Pasting is turned off here on purpose.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { loadProjectById } from '../utils/projectStorage.js';
|
||||
import { FIELD_NAMES, MINUTES, dailyPatch, pickDaily } from '../utils/dailyFive.js';
|
||||
import { formatClock } from '../utils/present.js';
|
||||
|
||||
const box = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
|
||||
|
||||
export default function DailyFive() {
|
||||
const { projectIndex, saveChunkPatch } = useApp();
|
||||
const [step, setStep] = useState('card'); // card -> write -> done
|
||||
const [text, setText] = useState('');
|
||||
const [left, setLeft] = useState(MINUTES * 60);
|
||||
const [pasteTried, setPasteTried] = useState(false);
|
||||
const pick = useMemo(() => pickDaily(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)), [projectIndex]);
|
||||
const started = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'write') return undefined;
|
||||
started.current = Date.now();
|
||||
const t = window.setInterval(() => setLeft(Math.max(0, MINUTES * 60 - Math.round((Date.now() - started.current) / 1000))), 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [step]);
|
||||
|
||||
if (step === 'done') return <div role="status" className={box}>Saved to your notes. Same time tomorrow.</div>;
|
||||
if (!pick) return null;
|
||||
const { prompt } = pick;
|
||||
|
||||
const save = () => {
|
||||
saveChunkPatch(pick.projectId, pick.chunk.id, dailyPatch(pick.chunk, prompt.field, text));
|
||||
setStep('done');
|
||||
};
|
||||
|
||||
if (step === 'card') {
|
||||
return (
|
||||
<div className={`${box} flex flex-wrap items-center justify-between gap-3`}>
|
||||
<span><span className="font-medium">Your five minutes:</span> {pick.reference} <span className="text-[var(--r-sub)]">· {FIELD_NAMES[prompt.field]} · {pick.projectTitle}</span></span>
|
||||
<button type="button" onClick={() => { setText(''); setLeft(MINUTES * 60); setPasteTried(false); setStep('write'); }} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)]">Start</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<section aria-label="Your five minutes" className={box}>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="font-medium">{pick.reference} <span className="font-normal text-[var(--r-sub)]">· {FIELD_NAMES[prompt.field]}</span></p>
|
||||
<span role="timer" aria-label="Time left" className={`font-mono text-sm tabular-nums ${left === 0 ? 'text-[var(--r-accent)]' : 'text-[var(--r-sub)]'}`}>{left === 0 ? 'Time’s up, finish your thought' : formatClock(left)}</span>
|
||||
</div>
|
||||
{pick.verses.length > 0 && (
|
||||
<div className="mt-2 max-h-40 overflow-y-auto rounded-lg bg-[var(--r-bg)] p-3 font-serif text-[13px] leading-relaxed">
|
||||
{pick.verses.map((v) => <p key={v.number}><sup className="mr-1 font-sans text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-[var(--r-sub)]">{prompt.text}</p>
|
||||
<textarea aria-label="Your five minutes" value={text} onChange={(e) => setText(e.target.value)} rows={6} autoFocus
|
||||
onPaste={(e) => { e.preventDefault(); setPasteTried(true); }}
|
||||
className="mt-2 w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 font-serif text-[15px] leading-7 focus:outline-none" />
|
||||
{pasteTried && <p role="note" className="mt-1 text-xs text-[var(--r-accent)]">Pasting is off here. Type it in your own words; that’s what the five minutes are for.</p>}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button type="button" disabled={!text.trim()} onClick={save} className="rounded-full bg-[var(--r-text)] px-3 py-1 text-xs font-medium text-[var(--r-bg)] disabled:opacity-40">Save to my notes</button>
|
||||
<button type="button" onClick={() => setStep('card')} className="text-xs text-[var(--r-sub)] hover:underline">Not now</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import { AppContext } from '../context/AppContext.js';
|
||||
import { saveProjectToStorage } from '../utils/projectStorage.js';
|
||||
import DailyFive from './DailyFive.jsx';
|
||||
import { promptFor } from '../utils/dailyFive.js';
|
||||
|
||||
const project = () => ({
|
||||
id: 'p1', title: 'John', translation: 'BSB', lastEdited: 1,
|
||||
chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [{ number: 16, text: 'For God so loved the world.' }],
|
||||
chunks: [{ id: 'c1', startVerse: 16, endVerse: 16, observation: '', interpretation: '', application: '', editedAt: 1 }] }],
|
||||
});
|
||||
|
||||
function setup() {
|
||||
localStorage.clear();
|
||||
saveProjectToStorage(project());
|
||||
const saveChunkPatch = vi.fn();
|
||||
const value = { projectIndex: [{ id: 'p1' }], saveChunkPatch };
|
||||
render(<AppContext.Provider value={value}><DailyFive /></AppContext.Provider>);
|
||||
return { saveChunkPatch, user: userEvent.setup() };
|
||||
}
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
test('the card names a passage and what kind of note the five minutes are for', () => {
|
||||
setup();
|
||||
expect(screen.getByText(/your five minutes/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/John 3:16/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('pasting is blocked, and what you type is saved to the matching note', async () => {
|
||||
const { saveChunkPatch, user } = setup();
|
||||
await user.click(screen.getByRole('button', { name: 'Start' }));
|
||||
expect(screen.getByText('For God so loved the world.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('timer')).toHaveTextContent('5:00');
|
||||
const box = screen.getByRole('textbox', { name: /your five minutes/i });
|
||||
fireEvent.paste(box, { clipboardData: { getData: () => 'text from somewhere else' } });
|
||||
expect(screen.getByText(/pasting is off here/i)).toBeInTheDocument();
|
||||
expect(box).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: /save to my notes/i })).toBeDisabled();
|
||||
await user.type(box, 'God gave.');
|
||||
await user.click(screen.getByRole('button', { name: /save to my notes/i }));
|
||||
const field = promptFor().field;
|
||||
expect(saveChunkPatch).toHaveBeenCalledWith('p1', 'c1', expect.objectContaining({ [field]: 'God gave.', fiveMinutes: [expect.objectContaining({ field })] }));
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/saved to your notes/i);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// A microphone button for a text box: tap, speak, and what you say is added to the text. It uses the browser's
|
||||
// own speech recognition (Safari and Chrome have it; Firefox does not, and then the button isn't shown). The
|
||||
// browser may send the audio to its maker's speech service to be turned into words, which is why it asks to use
|
||||
// the microphone, and why this is a button you press rather than something that listens in the background.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export const speechRecognitionClass = () => (typeof window === 'undefined' ? null : window.SpeechRecognition ?? window.webkitSpeechRecognition ?? null);
|
||||
|
||||
// Adds spoken words to existing text: a space (or nothing after a newline) between, and a first letter capitalised after a full stop.
|
||||
export function appendSpoken(existing, spoken) {
|
||||
const said = String(spoken ?? '').trim();
|
||||
if (!said) return existing ?? '';
|
||||
const base = existing ?? '';
|
||||
if (!base.trim()) return `${said.charAt(0).toUpperCase()}${said.slice(1)}`;
|
||||
const gap = /[\s]$/.test(base) ? '' : ' ';
|
||||
const afterStop = /[.!?]["')\]]*\s*$/.test(base);
|
||||
return `${base}${gap}${afterStop ? `${said.charAt(0).toUpperCase()}${said.slice(1)}` : said}`;
|
||||
}
|
||||
|
||||
const MESSAGES = {
|
||||
'not-allowed': 'The microphone is blocked. Allow it for this site in your browser’s settings.',
|
||||
'service-not-allowed': 'The microphone is blocked. Allow it for this site in your browser’s settings.',
|
||||
'audio-capture': 'No microphone was found.',
|
||||
network: 'Speech recognition needs a connection.',
|
||||
};
|
||||
|
||||
export default function DictateButton({ onText, label = 'Dictate', className = '' }) {
|
||||
const Recognition = speechRecognitionClass();
|
||||
const [listening, setListening] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const recRef = useRef(null);
|
||||
const onTextRef = useRef(onText);
|
||||
onTextRef.current = onText;
|
||||
|
||||
useEffect(() => () => { try { recRef.current?.abort(); } catch { /* already stopped */ } }, []);
|
||||
if (!Recognition) return null;
|
||||
|
||||
const start = () => {
|
||||
setError('');
|
||||
const rec = new Recognition();
|
||||
rec.lang = navigator.language || 'en-US';
|
||||
rec.continuous = true;
|
||||
rec.interimResults = false;
|
||||
rec.onresult = (event) => {
|
||||
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
||||
if (event.results[i].isFinal) onTextRef.current(event.results[i][0].transcript);
|
||||
}
|
||||
};
|
||||
rec.onerror = (event) => { if (event.error !== 'no-speech' && event.error !== 'aborted') setError(MESSAGES[event.error] ?? 'Dictation stopped. Try again.'); };
|
||||
rec.onend = () => { setListening(false); recRef.current = null; };
|
||||
recRef.current = rec;
|
||||
try { rec.start(); setListening(true); } catch { setListening(false); }
|
||||
};
|
||||
const stop = () => { try { recRef.current?.stop(); } catch { /* already stopped */ } };
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 ${className}`}>
|
||||
<button type="button" onClick={listening ? stop : start} aria-pressed={listening} aria-label={listening ? 'Stop dictating' : label}
|
||||
title={listening ? 'Stop dictating' : 'Speak your notes'}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${listening ? 'border-[var(--r-danger,#b42318)] text-[var(--r-danger,#b42318)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
|
||||
<svg viewBox="0 0 20 20" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"><rect x="7" y="2.5" width="6" height="10" rx="3" /><path d="M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" strokeLinecap="round" /></svg>
|
||||
{listening ? 'Listening… tap to stop' : label}
|
||||
{listening && <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--r-danger,#b42318)] motion-reduce:animate-none" aria-hidden="true" />}
|
||||
</button>
|
||||
{error && <span role="alert" className="text-[11px] text-[var(--r-danger,#b42318)]">{error}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, afterEach } from 'vitest';
|
||||
import DictateButton, { appendSpoken } from './DictateButton.jsx';
|
||||
|
||||
afterEach(() => { delete window.SpeechRecognition; delete window.webkitSpeechRecognition; });
|
||||
|
||||
// A stand-in for the browser's recogniser that we can make "hear" things
|
||||
function fakeRecognition() {
|
||||
const instances = [];
|
||||
class Fake {
|
||||
constructor() { instances.push(this); this.started = false; }
|
||||
start() { this.started = true; }
|
||||
stop() { this.started = false; this.onend?.(); }
|
||||
abort() { this.started = false; }
|
||||
}
|
||||
window.webkitSpeechRecognition = Fake;
|
||||
return instances;
|
||||
}
|
||||
const hear = (rec, text, isFinal = true) => rec.onresult({ resultIndex: 0, results: [Object.assign([{ transcript: text }], { isFinal })] });
|
||||
|
||||
test('spoken words are added to what is already written', () => {
|
||||
expect(appendSpoken('', ' grace is a gift ')).toBe('Grace is a gift');
|
||||
expect(appendSpoken('Paul writes', 'to Titus')).toBe('Paul writes to Titus');
|
||||
expect(appendSpoken('He saved us.', 'not by works')).toBe('He saved us. Not by works');
|
||||
expect(appendSpoken('Line one\n', 'line two')).toBe('Line one\nline two');
|
||||
expect(appendSpoken('Keep this', ' ')).toBe('Keep this');
|
||||
});
|
||||
|
||||
test('with no speech recognition in the browser, the button is not shown', () => {
|
||||
const { container } = render(<DictateButton onText={() => {}} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
test('tap to listen, final phrases are passed on, tap again to stop', async () => {
|
||||
const instances = fakeRecognition();
|
||||
const onText = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<DictateButton onText={onText} label="Dictate script" />);
|
||||
await user.click(screen.getByRole('button', { name: 'Dictate script' }));
|
||||
expect(instances[0].started).toBe(true);
|
||||
expect(screen.getByRole('button', { name: 'Stop dictating' })).toHaveAttribute('aria-pressed', 'true');
|
||||
act(() => hear(instances[0], 'the Lord is my shepherd', false)); // still being worked out: ignored
|
||||
act(() => hear(instances[0], 'the Lord is my shepherd'));
|
||||
expect(onText).toHaveBeenCalledTimes(1);
|
||||
expect(onText).toHaveBeenCalledWith('the Lord is my shepherd');
|
||||
await user.click(screen.getByRole('button', { name: 'Stop dictating' }));
|
||||
expect(screen.getByRole('button', { name: 'Dictate script' })).toHaveAttribute('aria-pressed', 'false');
|
||||
});
|
||||
|
||||
test('a blocked microphone is explained', async () => {
|
||||
const instances = fakeRecognition();
|
||||
const user = userEvent.setup();
|
||||
render(<DictateButton onText={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Dictate' }));
|
||||
act(() => instances[0].onerror({ error: 'not-allowed' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/microphone is blocked/i);
|
||||
act(() => instances[0].onerror({ error: 'no-speech' })); // silence is not an error worth showing
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// A person's family: the relatives named in their record (click one to read about them), and a tree of ancestors and
|
||||
// descendants a few generations out. From Theographic Bible Metadata.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { loadPerson } from '../hooks/useVerseStudy.js';
|
||||
import { relatives, walk } from '../utils/family.js';
|
||||
|
||||
function Branch({ node, onOpen }) {
|
||||
return (
|
||||
<li>
|
||||
<button type="button" onClick={() => onOpen(node.id)} className="text-[var(--r-accent)] hover:underline">{node.name || node.id}</button>
|
||||
{node.more && <span className="ml-1 text-[var(--r-sub)]" title="More people beyond this point">…</span>}
|
||||
{node.kids.length > 0 && <ul className="ml-4 border-l border-[var(--r-line)] pl-3">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Tree({ id, onOpen }) {
|
||||
const [state, setState] = useState({ loading: true });
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
Promise.all([walk(loadPerson, id, { direction: 'up', depth: 3, max: 30 }), walk(loadPerson, id, { direction: 'down', depth: 2, max: 40 })])
|
||||
.then(([up, down]) => { if (live) setState({ up, down }); })
|
||||
.catch(() => { if (live) setState({ error: true }); });
|
||||
return () => { live = false; };
|
||||
}, [id]);
|
||||
if (state.loading) return <p className="mt-1 text-[var(--r-sub)]">Loading the family…</p>;
|
||||
if (state.error) return <p className="mt-1 text-[var(--r-sub)]">The family tree couldn’t be loaded right now.</p>;
|
||||
const block = (title, node, empty) => (
|
||||
<div>
|
||||
<p className="font-medium text-[var(--r-text)]">{title}</p>
|
||||
{node.kids.length ? <ul className="mt-1 space-y-0.5">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul> : <p className="text-[var(--r-sub)]">{empty}</p>}
|
||||
</div>
|
||||
);
|
||||
return <div className="mt-2 grid gap-3 sm:grid-cols-2">{block('Ancestors', state.up, 'None recorded.')}{block('Descendants', state.down, 'None recorded.')}</div>;
|
||||
}
|
||||
|
||||
// `person` is the record; `renderPerson(id)` shows another person's details inside this one when a name is clicked.
|
||||
export default function Family({ person, renderPerson }) {
|
||||
const groups = relatives(person);
|
||||
const [shown, setShown] = useState(null); // a relative opened from a name or the tree
|
||||
const [treeOpen, setTreeOpen] = useState(false); // the tree's records are only fetched once it is opened
|
||||
if (groups.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 space-y-1.5 border-t border-[var(--r-line)] pt-2" aria-label="Family">
|
||||
{groups.map((g) => (
|
||||
<p key={g.label} className="flex flex-wrap items-baseline gap-x-1.5">
|
||||
<span className="w-16 shrink-0 text-[11px] uppercase tracking-wide">{g.label}</span>
|
||||
{g.people.map((p) => (
|
||||
<button key={p.id} type="button" onClick={() => setShown(shown === p.id ? null : p.id)} aria-pressed={shown === p.id}
|
||||
className={`rounded-full border px-2 py-0.5 ${shown === p.id ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{p.name}</button>
|
||||
))}
|
||||
</p>
|
||||
))}
|
||||
<details onToggle={(e) => setTreeOpen(e.currentTarget.open)}>
|
||||
<summary className="cursor-pointer text-[var(--r-accent)]">Family tree</summary>
|
||||
{treeOpen && <Tree id={person.id} onOpen={(id) => setShown(id)} />}
|
||||
</details>
|
||||
{shown && <div className="rounded-lg bg-[var(--r-bar)] p-2">{renderPerson(shown)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import Family from './FamilyTree.jsx';
|
||||
|
||||
const ref = (id, name) => ({ id, name, type: 'people' });
|
||||
const RECORDS = {
|
||||
fam_seth: { id: 'fam_seth', name: 'Seth', father: [ref('fam_adam', 'Adam')], mother: [ref('fam_eve', 'Eve')], children: [ref('fam_enos', 'Enos')], siblings: [ref('fam_abel', 'Abel')] },
|
||||
fam_adam: { id: 'fam_adam', name: 'Adam', children: [ref('fam_seth', 'Seth')] },
|
||||
fam_eve: { id: 'fam_eve', name: 'Eve', children: [ref('fam_seth', 'Seth')] },
|
||||
fam_enos: { id: 'fam_enos', name: 'Enos', father: [ref('fam_seth', 'Seth')] },
|
||||
fam_abel: { id: 'fam_abel', name: 'Abel' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url) => {
|
||||
const id = String(url).match(/people\/(.+)\.json/)?.[1];
|
||||
return RECORDS[id] ? { ok: true, json: async () => ({ person: RECORDS[id] }) } : { ok: false, json: async () => ({}) };
|
||||
}));
|
||||
});
|
||||
|
||||
test('the relatives named in the record are listed, and clicking one opens them', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
|
||||
const family = screen.getByLabelText('Family');
|
||||
for (const name of ['Adam', 'Eve', 'Abel', 'Enos']) expect(within(family).getByRole('button', { name })).toBeInTheDocument();
|
||||
await user.click(within(family).getByRole('button', { name: 'Adam' }));
|
||||
expect(screen.getByText('details for fam_adam')).toBeInTheDocument();
|
||||
await user.click(within(family).getByRole('button', { name: 'Adam' }));
|
||||
expect(screen.queryByText('details for fam_adam')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the family tree shows ancestors and descendants', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
|
||||
await user.click(screen.getByText('Family tree'));
|
||||
expect(await screen.findByText('Ancestors')).toBeInTheDocument();
|
||||
const tree = screen.getByText('Ancestors').closest('div').parentElement;
|
||||
expect(within(tree).getAllByRole('button', { name: 'Adam' }).length).toBeGreaterThan(0);
|
||||
expect(within(tree).getAllByRole('button', { name: 'Enos' }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('a person with no recorded family shows nothing', () => {
|
||||
const { container } = render(<Family person={{ id: 'x', name: 'X' }} renderPerson={() => null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// What an original-language word's grammar label means, in plain English.
|
||||
import { grammarNotes } from '../utils/grammar.js';
|
||||
|
||||
export default function GrammarPanel({ label, className = '' }) {
|
||||
const notes = grammarNotes(label);
|
||||
if (!label) return null;
|
||||
return (
|
||||
<div className={`rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2.5 font-sans text-xs ${className}`} aria-label="Grammar">
|
||||
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Grammar · {label}</p>
|
||||
{notes.length > 0 && (
|
||||
<ul className="mt-1.5 space-y-1.5">
|
||||
{notes.map((n) => (
|
||||
<li key={n.term}><span className="font-medium">{n.term}.</span> <span className="text-[var(--r-sub)]">{n.meaning}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Finish → Lesson handout: a printable page for each session (or one session), for a group to follow along.
|
||||
import { useState } from 'react';
|
||||
import { HANDOUT_LAYOUTS, printHandout, sessionsFor } from '../utils/handout.js';
|
||||
|
||||
export default function HandoutExport({ project }) {
|
||||
const sessions = sessionsFor(project);
|
||||
const [which, setWhich] = useState('all');
|
||||
const [lines, setLines] = useState(true);
|
||||
const [layout, setLayout] = useState('standard');
|
||||
const [blocked, setBlocked] = useState(false);
|
||||
if (sessions.length === 0) return null;
|
||||
const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines, layout }));
|
||||
return (
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Lesson handout">
|
||||
<h2 className="text-sm font-medium">Lesson handout</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">A page per session with the passage, the outline, discussion questions and room to write. Use “Save as PDF” in the print dialog. Write the questions in the Study stage under “More”; without them, your Application notes are used.</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="text-xs text-[var(--r-sub)]">Sessions
|
||||
<select value={which} onChange={(e) => setWhich(e.target.value)} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||
<option value="all">All {sessions.length} (one page each)</option>
|
||||
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-[var(--r-sub)]">Layout
|
||||
<select value={layout} onChange={(e) => setLayout(e.target.value)} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||
{HANDOUT_LAYOUTS.map((l) => <option key={l.id} value={l.id}>{l.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 pb-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={lines} onChange={(e) => setLines(e.target.checked)} className="accent-[var(--r-accent)]" /> Lines to write on</label>
|
||||
<button type="button" onClick={go} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Print / Save PDF</button>
|
||||
</div>
|
||||
{blocked && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Allow pop-ups for this site to print the handout.</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Finish → Import notes: bring notes from a Word or Markdown file into this study, matched to its chunks by passage.
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useToast } from './Toasts.jsx';
|
||||
import { applyImportedNotes, parseNotesText } from '../utils/importNotes.js';
|
||||
|
||||
export default function ImportNotes() {
|
||||
const { project, updateProject } = useApp();
|
||||
const { toast } = useToast();
|
||||
const [file, setFile] = useState('');
|
||||
const [sections, setSections] = useState(null); // null until a file is read
|
||||
const [own, setOwn] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const choose = async (e) => {
|
||||
const f = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
setError(''); setSections(null);
|
||||
if (!f) return;
|
||||
try {
|
||||
let text;
|
||||
if (/\.docx$/i.test(f.name)) {
|
||||
const { default: mammoth } = await import('mammoth');
|
||||
text = (await mammoth.extractRawText({ arrayBuffer: await f.arrayBuffer() })).value;
|
||||
} else text = await f.text();
|
||||
const found = parseNotesText(text);
|
||||
setFile(f.name);
|
||||
setSections(found);
|
||||
if (found.length === 0) setError('No passage headings with Observe / Interpret / Apply notes were found in that file. Start each section with a heading like “John 3:16–18”.');
|
||||
} catch { setError('That file couldn’t be read.'); }
|
||||
};
|
||||
|
||||
const preview = sections ? applyImportedNotes(project, sections, { own }) : null;
|
||||
const add = () => {
|
||||
updateProject((current) => applyImportedNotes(current, sections, { own }).project);
|
||||
toast(`Added notes to ${preview.matched.length} chunk${preview.matched.length === 1 ? '' : 's'}`);
|
||||
setSections(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Import notes">
|
||||
<h2 className="text-sm font-medium">Import notes</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Bring in notes from a Word (.docx), Markdown or text file. Start each section with a passage heading (“John 3:16–18”), then label the notes “Observe:”, “Interpret:”, “Apply:”, “Background:” or “Questions:”. Notes you’ve already written are kept; imported text is added below them.</p>
|
||||
<label className="inline-block cursor-pointer rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Choose a file
|
||||
<input type="file" accept=".docx,.md,.markdown,.txt,text/markdown,text/plain" className="hidden" onChange={choose} />
|
||||
</label>
|
||||
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
{preview && sections.length > 0 && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
<p className="text-xs text-[var(--r-sub)]">{file}: {sections.length} section{sections.length === 1 ? '' : 's'} found.</p>
|
||||
<ul className="space-y-1 text-xs">
|
||||
{preview.matched.map((m) => <li key={m.heading}>✓ {m.heading}: {m.fields.join(', ')}</li>)}
|
||||
{preview.unmatched.map((h) => <li key={h} className="text-[var(--r-sub)]">No chunk for {h}, skipped</li>)}
|
||||
</ul>
|
||||
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={own} onChange={(e) => setOwn(e.target.checked)} className="accent-[var(--r-accent)]" /> These notes are my own writing (untick if they came from AI or another source)</label>
|
||||
<button type="button" onClick={add} disabled={preview.matched.length === 0} className="rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] disabled:opacity-40">Add to this study</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// The rules for a read-only link: when it stops working, and an optional passcode. Shown under the link.
|
||||
import { useState } from 'react';
|
||||
|
||||
const OPTIONS = [['', 'Never'], ['1', 'In 1 day'], ['7', 'In 7 days'], ['30', 'In 30 days']];
|
||||
const field = '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)]';
|
||||
|
||||
export const expiryText = (expiresAt, now = Date.now()) => {
|
||||
if (!expiresAt) return 'Never expires';
|
||||
if (expiresAt <= now) return 'Expired';
|
||||
const days = Math.ceil((expiresAt - now) / 86_400_000);
|
||||
return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`;
|
||||
};
|
||||
|
||||
// "2026-10-18T09:00" in the person's own time zone <-> ms
|
||||
const toLocalInput = (ms) => { if (!ms) return ''; const d = new Date(ms); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; };
|
||||
|
||||
// `showReveal` adds "Hide my notes until…" (study links only; an outline link never shows notes).
|
||||
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave, showReveal = false, revealAt = null }) {
|
||||
const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30'
|
||||
const [passcode, setPasscode] = useState('');
|
||||
const [reveal, setReveal] = useState(null); // null = leave as is; '' = show everything now; else a local date-time
|
||||
const dirty = days !== 'keep' || passcode.trim() !== '' || reveal !== null;
|
||||
|
||||
const save = async () => {
|
||||
const options = {};
|
||||
if (days !== 'keep') options.expiresInDays = days === '' ? null : Number(days);
|
||||
if (passcode.trim()) options.passcode = passcode.trim();
|
||||
if (reveal !== null) options.revealAt = reveal === '' ? null : new Date(reveal).getTime();
|
||||
if (await onSave(options)) { setDays('keep'); setPasscode(''); setReveal(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-[var(--r-line)] p-3" role="group" aria-label="Link settings">
|
||||
<p className="text-xs text-[var(--r-sub)]">{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}{showReveal && revealAt && revealAt > Date.now() ? ` · Your notes appear ${new Date(revealAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })}` : ''}</p>
|
||||
<div className="mt-2 flex flex-wrap items-end gap-3">
|
||||
<label className="text-xs text-[var(--r-sub)]">Stop working
|
||||
<select value={days} onChange={(e) => setDays(e.target.value)} className={`${field} mt-1 block`}>
|
||||
<option value="keep">{expiresAt ? 'Keep as is' : 'Never (as is)'}</option>
|
||||
{OPTIONS.map(([v, label]) => <option key={v || 'never'} value={v}>{label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-[var(--r-sub)]">{hasPasscode ? 'New passcode' : 'Passcode (optional)'}
|
||||
<input type="text" value={passcode} onChange={(e) => setPasscode(e.target.value)} maxLength={100} autoComplete="off" placeholder={hasPasscode ? 'Type to change' : 'None'} className={`${field} mt-1 block w-44`} />
|
||||
</label>
|
||||
{showReveal && (
|
||||
<label className="text-xs text-[var(--r-sub)]">Hide my notes until
|
||||
<input type="datetime-local" value={reveal ?? toLocalInput(revealAt > Date.now() ? revealAt : null)} onChange={(e) => setReveal(e.target.value)} className={`${field} mt-1 block`} />
|
||||
</label>
|
||||
)}
|
||||
{showReveal && (reveal ?? toLocalInput(revealAt > Date.now() ? revealAt : null)) && (
|
||||
<button type="button" onClick={() => setReveal('')} className="text-xs text-[var(--r-sub)] hover:underline">Show everything now</button>
|
||||
)}
|
||||
<button type="button" onClick={save} disabled={!dirty || busy} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-50">Save settings</button>
|
||||
{hasPasscode && <button type="button" onClick={() => onSave({ passcode: null })} disabled={busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove passcode</button>}
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-[var(--r-sub)]">Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.{showReveal ? ' Until the time you set, the group sees only the passages and discussion questions; your notes, outline and scripts stay on the server.' : ''}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
import LinkRules, { expiryText } from './LinkRules.jsx';
|
||||
import SharedPasscodeForm from './SharedPasscodeForm.jsx';
|
||||
|
||||
test('says when a link expires and whether it has a passcode', () => {
|
||||
const now = Date.UTC(2026, 9, 6);
|
||||
expect(expiryText(null, now)).toBe('Never expires');
|
||||
expect(expiryText(now - 1, now)).toBe('Expired');
|
||||
expect(expiryText(now + 3 * 86_400_000, now)).toMatch(/3 days left/);
|
||||
render(<LinkRules expiresAt={null} hasPasscode busy={false} onSave={vi.fn()} />);
|
||||
expect(screen.getByText(/Needs a passcode/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('only the settings you change are sent', async () => {
|
||||
const onSave = vi.fn(async () => true);
|
||||
const user = userEvent.setup();
|
||||
render(<LinkRules expiresAt={null} hasPasscode={false} busy={false} onSave={onSave} />);
|
||||
const save = screen.getByRole('button', { name: 'Save settings' });
|
||||
expect(save).toBeDisabled();
|
||||
await user.selectOptions(screen.getByLabelText('Stop working'), '7');
|
||||
await user.click(save);
|
||||
expect(onSave).toHaveBeenLastCalledWith({ expiresInDays: 7 }); // no passcode key: the passcode is left alone
|
||||
await user.type(screen.getByLabelText(/Passcode/), ' moses ');
|
||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||
expect(onSave).toHaveBeenLastCalledWith({ passcode: 'moses' });
|
||||
});
|
||||
|
||||
test('a passcode can be removed, and "Never" clears the expiry', async () => {
|
||||
const onSave = vi.fn(async () => true);
|
||||
const user = userEvent.setup();
|
||||
render(<LinkRules expiresAt={Date.now() + 86_400_000} hasPasscode busy={false} onSave={onSave} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Remove passcode' }));
|
||||
expect(onSave).toHaveBeenLastCalledWith({ passcode: null });
|
||||
await user.selectOptions(screen.getByLabelText('Stop working'), 'Never');
|
||||
await user.click(screen.getByRole('button', { name: 'Save settings' }));
|
||||
expect(onSave).toHaveBeenLastCalledWith({ expiresInDays: null });
|
||||
});
|
||||
|
||||
test('the viewer\'s passcode form submits what was typed and shows a wrong-passcode message', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<SharedPasscodeForm error="That passcode is not right." onSubmit={onSubmit} />);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/not right/);
|
||||
expect(screen.getByRole('button', { name: 'Open' })).toBeDisabled();
|
||||
await user.type(screen.getByLabelText('Passcode'), 'moses');
|
||||
await user.click(screen.getByRole('button', { name: 'Open' }));
|
||||
expect(onSubmit).toHaveBeenCalledWith('moses');
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
// On a study card: whether its Bible text works without a connection, and a button to fix that.
|
||||
import { DownloadProgress } from './OfflineSave.jsx';
|
||||
import { offlineStatus } from '../utils/offline.js';
|
||||
|
||||
export default function OfflineBadge({ project, offline }) {
|
||||
if (!project) return null;
|
||||
const status = offlineStatus(project, offline.saved);
|
||||
const label = 'mt-1 text-xs';
|
||||
if (offline.job) return <div className="mt-2"><DownloadProgress job={offline.job} onCancel={offline.cancel} /></div>;
|
||||
if (status.state === 'ready') return <p className={`${label} text-[var(--r-accent)]`} title="Your notes and the Bible text for this study open without a connection">✓ Works offline</p>;
|
||||
if (status.state === 'licensed') {
|
||||
return <p className={`${label} text-[var(--r-sub)]`} title={`The ${status.translation} licence doesn’t allow saving its text for offline use`}>Notes work offline; {status.translation} text needs a connection</p>;
|
||||
}
|
||||
const next = status.missing[0];
|
||||
return (
|
||||
<p className={`${label} flex flex-wrap items-center gap-x-2 text-[var(--r-sub)]`}>
|
||||
<span>Needs a connection for {status.missing.map((b) => b.name).join(', ')}</span>
|
||||
<button type="button" onClick={() => offline.saveBook(status.translation, next.abbrev, next.name)} className="text-[var(--r-accent)] hover:underline">Save {next.name} for offline</button>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// Present mode: the script or the outline full-screen in large type, with a timer. The script scrolls by
|
||||
// itself at a speed you set (a teleprompter), and the outline moves one point at a time with the minutes you
|
||||
// planned beside the clock. The screen is kept awake while it is open. Esc, or Close, leaves.
|
||||
//
|
||||
// With a projector or second screen: "Audience window" opens a second window to drag over. It shows only the point,
|
||||
// its passage and quoted verse; your speaker notes, the script, the timer and the plan stay on this screen. This
|
||||
// screen then becomes a presenter view (what the audience sees, your notes, the next point). On one screen, notes are
|
||||
// hidden unless you turn them on, so a mirrored display never shows them by accident. B blacks out the audience screen.
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js';
|
||||
import { audienceState, audienceUrl, openChannel } from '../utils/presentSync.js';
|
||||
import StepFace from './StepFace.jsx';
|
||||
|
||||
const SIZES = [20, 24, 28, 34, 40, 48, 60, 72];
|
||||
const btn = 'rounded-lg border border-current/30 px-3 py-1.5 text-sm hover:bg-white/10 disabled:opacity-40';
|
||||
|
||||
export default function PresentMode({ title, subtitle = '', mode, script = '', tree = [], onClose }) {
|
||||
const [dark, setDark] = useState(true);
|
||||
const [size, setSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-size')) || 34)); } catch { return 3; } });
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [scrolling, setScrolling] = useState(false);
|
||||
const [speed, setSpeed] = useState(30); // pixels per second
|
||||
const [index, setIndex] = useState(0);
|
||||
const scroller = useRef(null);
|
||||
const [showNotes, setShowNotes] = useState(() => { try { return localStorage.getItem('present-notes-here') === 'on'; } catch { return false; } });
|
||||
const [audienceOpen, setAudienceOpen] = useState(false); // an audience window is connected: this screen is the presenter's
|
||||
const [blank, setBlank] = useState(false); // the audience screen is blacked out
|
||||
const [audSize, setAudSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-aud-size')) || 48)); } catch { return 5; } });
|
||||
const [popupBlocked, setPopupBlocked] = useState(false);
|
||||
const audienceWin = useRef(null);
|
||||
const channel = useRef(null);
|
||||
const latest = useRef(null);
|
||||
const steps = mode === 'outline' ? outlineSteps(tree) : [];
|
||||
const step = steps[index];
|
||||
const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null;
|
||||
|
||||
// Two screens: keep the audience window in step with this one, answering a window that has just opened.
|
||||
const audience = audienceState({ title, subtitle, mode, step, index, total: steps.length, dark, fontPx: SIZES[audSize], blank });
|
||||
latest.current = audience;
|
||||
useEffect(() => {
|
||||
const ch = openChannel();
|
||||
channel.current = ch;
|
||||
if (!ch) return undefined;
|
||||
ch.onmessage = (e) => { if (e.data?.type === 'hello') { setAudienceOpen(true); ch.postMessage(latest.current); } };
|
||||
return () => { ch.postMessage({ type: 'end' }); ch.close(); channel.current = null; };
|
||||
}, []);
|
||||
useEffect(() => { channel.current?.postMessage(audience); }); // every change: the point, the colours, blank
|
||||
useEffect(() => {
|
||||
if (!audienceOpen) return undefined;
|
||||
const t = window.setInterval(() => { if (audienceWin.current?.closed) { audienceWin.current = null; setAudienceOpen(false); setBlank(false); } }, 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [audienceOpen]);
|
||||
const openAudience = () => {
|
||||
if (!channel.current) return;
|
||||
const win = window.open(audienceUrl(), 'bible-study-audience', 'popup,width=1280,height=720');
|
||||
setPopupBlocked(!win);
|
||||
if (win) { audienceWin.current = win; setAudienceOpen(true); }
|
||||
};
|
||||
const toggleNotes = () => setShowNotes((on) => { const next = !on; try { localStorage.setItem('present-notes-here', next ? 'on' : 'off'); } catch { /* storage unavailable */ } return next; });
|
||||
const audBigger = (d) => setAudSize((i) => { const next = Math.min(SIZES.length - 1, Math.max(0, i + d)); try { localStorage.setItem('present-aud-size', String(SIZES[next])); } catch { /* storage unavailable */ } return next; });
|
||||
|
||||
// the clock
|
||||
useEffect(() => {
|
||||
if (!running) return undefined;
|
||||
const t = window.setInterval(() => setElapsed((e) => e + 1), 1000);
|
||||
return () => window.clearInterval(t);
|
||||
}, [running]);
|
||||
|
||||
// the teleprompter
|
||||
useEffect(() => {
|
||||
if (!scrolling || mode !== 'script') return undefined;
|
||||
let last = performance.now();
|
||||
let frame;
|
||||
let carry = 0;
|
||||
const tick = (now) => {
|
||||
const el = scroller.current;
|
||||
carry += ((now - last) / 1000) * speed;
|
||||
last = now;
|
||||
if (el && carry >= 1) { el.scrollTop += Math.floor(carry); carry -= Math.floor(carry); }
|
||||
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) { setScrolling(false); return; }
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [scrolling, speed, mode]);
|
||||
|
||||
// keep the screen on
|
||||
useEffect(() => {
|
||||
let lock;
|
||||
let gone = false;
|
||||
navigator.wakeLock?.request?.('screen').then((l) => { if (gone) l.release?.(); else lock = l; }).catch(() => {});
|
||||
return () => { gone = true; lock?.release?.().catch?.(() => {}); };
|
||||
}, []);
|
||||
|
||||
const bigger = (d) => setSize((s) => {
|
||||
const next = Math.min(SIZES.length - 1, Math.max(0, s + d));
|
||||
try { localStorage.setItem('present-size', String(SIZES[next])); } catch { /* storage unavailable */ }
|
||||
return next;
|
||||
});
|
||||
const go = useCallback((d) => setIndex((i) => Math.min(steps.length - 1, Math.max(0, i + d))), [steps.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') { onClose(); return; }
|
||||
if (e.target?.tagName === 'INPUT') return;
|
||||
if (mode === 'outline') {
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(1); }
|
||||
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); }
|
||||
} else if (e.key === ' ') { e.preventDefault(); setScrolling((s) => !s); }
|
||||
if ((e.key === 'b' || e.key === 'B') && audienceOpen) setBlank((b) => !b);
|
||||
if ((e.key === 'n' || e.key === 'N') && !audienceOpen && mode === 'outline') toggleNotes();
|
||||
if (e.key === '+' || e.key === '=') bigger(1);
|
||||
if (e.key === '-') bigger(-1);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [mode, go, onClose, audienceOpen]);
|
||||
|
||||
const banner = audienceOpen && (
|
||||
<p role="status" className={`shrink-0 px-4 py-1.5 text-center text-xs ${blank ? 'bg-red-500/25' : 'bg-emerald-500/15'}`}>
|
||||
{blank ? 'The audience screen is blank. Press B to bring it back.' : mode === 'script' ? 'The audience window is connected. It shows only the title; your script stays on this screen.' : 'The audience window is connected. It shows the points and verses, never your notes.'}
|
||||
</p>
|
||||
);
|
||||
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
|
||||
const fontSize = `${SIZES[size]}px`;
|
||||
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" aria-label={`Presenting ${title}`} className={`fixed inset-0 z-[80] flex flex-col ${colors}`}>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-current/15 px-4 py-2.5">
|
||||
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{title}</p>{subtitle && <p className="truncate text-xs opacity-60">{subtitle}</p>}</div>
|
||||
<div className="flex items-center gap-2" role="group" aria-label="Timer">
|
||||
<span className="min-w-[4.5rem] text-center font-mono text-xl tabular-nums" aria-live="off">{formatClock(elapsed)}</span>
|
||||
<button type="button" onClick={() => setRunning((r) => !r)} className={btn}>{running ? 'Pause' : elapsed ? 'Resume' : 'Start'}</button>
|
||||
<button type="button" onClick={() => { setElapsed(0); setRunning(false); }} disabled={!elapsed} className={btn}>Reset</button>
|
||||
</div>
|
||||
{pace && <span className={`text-sm ${pace.diffSeconds < -20 ? 'text-amber-400' : 'opacity-80'}`} role="status">{paceText(pace)} · plan {formatClock(pace.totalSeconds)}</span>}
|
||||
<div className="flex items-center gap-1.5" role="group" aria-label="Text size">
|
||||
<button type="button" onClick={() => bigger(-1)} disabled={size === 0} aria-label="Smaller text" className={btn}>A−</button>
|
||||
<button type="button" onClick={() => bigger(1)} disabled={size === SIZES.length - 1} aria-label="Larger text" className={btn}>A+</button>
|
||||
<button type="button" onClick={() => setDark((d) => !d)} aria-pressed={dark} className={btn}>{dark ? 'Light' : 'Dark'}</button>
|
||||
</div>
|
||||
{mode === 'script' && (
|
||||
<div className="flex items-center gap-2" role="group" aria-label="Auto-scroll">
|
||||
<button type="button" onClick={() => setScrolling((s) => !s)} aria-pressed={scrolling} className={btn}>{scrolling ? 'Stop scrolling' : 'Auto-scroll'}</button>
|
||||
<label className="flex items-center gap-1.5 text-xs opacity-80">Speed
|
||||
<input type="range" min="8" max="120" value={speed} onChange={(e) => setSpeed(Number(e.target.value))} aria-label="Scroll speed" />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{typeof BroadcastChannel !== 'undefined' ? (
|
||||
<div className="flex items-center gap-2" role="group" aria-label="Second screen">
|
||||
{audienceOpen ? (
|
||||
<>
|
||||
<button type="button" onClick={() => setBlank((b) => !b)} aria-pressed={blank} className={btn} title="Black out the audience screen (B)">{blank ? 'Show audience' : 'Blank audience'}</button>
|
||||
{mode === 'outline' && (
|
||||
<span className="flex items-center gap-1" role="group" aria-label="Audience text size">
|
||||
<button type="button" onClick={() => audBigger(-1)} disabled={audSize === 0} aria-label="Smaller audience text" className={btn}>Screen A−</button>
|
||||
<button type="button" onClick={() => audBigger(1)} disabled={audSize === SIZES.length - 1} aria-label="Larger audience text" className={btn}>A+</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={openAudience} className={btn} title="Opens a second window for a projector or other screen. It shows the points, never your notes.">Audience window</button>
|
||||
{mode === 'outline' && <button type="button" onClick={toggleNotes} aria-pressed={showNotes} className={btn} title="Speaker notes stay hidden on this screen unless you turn them on (N)">{showNotes ? 'Hide notes' : 'Show notes'}</button>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
|
||||
</div>
|
||||
|
||||
{mode === 'script' ? (
|
||||
<>
|
||||
{banner}
|
||||
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8dvh]" style={{ scrollBehavior: 'auto' }}>
|
||||
{script.trim()
|
||||
? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50dvh' }} aria-hidden="true" /></div>
|
||||
: <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{popupBlocked && <p role="alert" className="shrink-0 bg-amber-500/20 px-4 py-1.5 text-center text-xs">Your browser blocked the audience window. Allow pop-ups for this site, then try again.</p>}
|
||||
{banner}
|
||||
{audienceOpen && step ? (
|
||||
// Presenter view: what the audience sees, your private notes, and what comes next
|
||||
<div className="grid min-h-0 flex-1 gap-0 md:grid-cols-[3fr_2fr]">
|
||||
<div className="min-h-0 overflow-y-auto border-current/15 px-[3vw] py-[4dvh] md:border-r">
|
||||
<p className="mb-3 font-sans text-[11px] uppercase tracking-[0.16em] opacity-50">The audience sees</p>
|
||||
<StepFace step={step} index={index} total={steps.length} fontSize={`${SIZES[Math.max(0, size - 3)]}px`} minutes={step.minutes} />
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto px-[3vw] py-[4dvh]">
|
||||
<p className="mb-3 font-sans text-[11px] uppercase tracking-[0.16em] text-amber-500">Only you see this</p>
|
||||
{step.note.trim()
|
||||
? <p className="whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.45 }}>{step.note}</p>
|
||||
: <p className="font-sans text-sm opacity-50">No speaker notes for this point. Add them under the point’s “⋯” details on the Outline page.</p>}
|
||||
{steps[index + 1] && (
|
||||
<div className="mt-8 border-t border-current/15 pt-4">
|
||||
<p className="font-sans text-[11px] uppercase tracking-[0.16em] opacity-50">Next</p>
|
||||
<p className="mt-1 font-serif text-xl"><span className="mr-2 opacity-60">{steps[index + 1].label}</span>{steps[index + 1].text || '(untitled)'}</p>
|
||||
{steps[index + 1].minutes > 0 && <p className="font-sans text-xs opacity-60">{steps[index + 1].minutes} min</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6dvh]">
|
||||
{step ? (
|
||||
<>
|
||||
<StepFace step={step} index={index} total={steps.length} fontSize={fontSize} minutes={step.minutes} note={showNotes ? step.note : ''} />
|
||||
{showNotes && step.note && <p className="mx-auto mt-2 max-w-[46em] font-sans text-[11px] uppercase tracking-[0.16em] text-amber-500">Speaker notes are showing on this screen (N to hide)</p>}
|
||||
</>
|
||||
) : <p className="mt-20 text-center text-lg opacity-70">There’s no outline to present yet. Build one on the Outline page.</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-current/15 px-4 py-3">
|
||||
<button type="button" onClick={() => go(-1)} disabled={index === 0} className={btn}>← Back</button>
|
||||
<span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span>
|
||||
<button type="button" onClick={() => go(1)} disabled={index >= steps.length - 1} className={btn}>Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, afterEach, beforeEach } from 'vitest';
|
||||
import PresentMode from './PresentMode.jsx';
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
|
||||
const n = (id, text, minutes, extra = {}) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children: [], ...extra });
|
||||
const tree = [n('a', 'Welcome', 2, { note: 'Say hello' }), n('b', 'The gospel', 10, { ref: 'Rom 1:16' })];
|
||||
|
||||
test('the outline is stepped through a point at a time, with the keyboard or the buttons', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||
expect(screen.getByText('Welcome')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Say hello')).not.toBeInTheDocument(); // speaker notes are private by default
|
||||
expect(screen.getByText(/Point 1 of 2/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
|
||||
await user.keyboard('{ArrowRight}');
|
||||
expect(screen.getByText('The gospel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rom 1:16')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
|
||||
await user.click(screen.getByRole('button', { name: /back/i }));
|
||||
expect(screen.getByText('Welcome')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the timer runs, pauses and resets, and tells you how you are doing against the plan', async () => {
|
||||
vi.useFakeTimers();
|
||||
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||
const group = screen.getByRole('group', { name: 'Timer' });
|
||||
const click = (name) => act(() => { screen.getByRole('button', { name }).click(); });
|
||||
click('Start');
|
||||
act(() => { vi.advanceTimersByTime(65_000); });
|
||||
expect(group).toHaveTextContent('1:05');
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/On pace/); // a minute in, on a 2-minute point
|
||||
click('Pause');
|
||||
act(() => { vi.advanceTimersByTime(10_000); });
|
||||
expect(group).toHaveTextContent('1:05'); // paused
|
||||
click('Reset');
|
||||
expect(group).toHaveTextContent('0:00');
|
||||
});
|
||||
|
||||
test('the text size can be changed and the choice is remembered', async () => {
|
||||
const user = userEvent.setup();
|
||||
localStorage.clear();
|
||||
render(<PresentMode mode="script" title="Romans 8" script="In the beginning" onClose={() => {}} />);
|
||||
const text = screen.getByText('In the beginning');
|
||||
expect(text.style.fontSize).toBe('34px');
|
||||
await user.click(screen.getByRole('button', { name: 'Larger text' }));
|
||||
expect(text.style.fontSize).toBe('40px');
|
||||
expect(localStorage.getItem('present-size')).toBe('40');
|
||||
});
|
||||
|
||||
test('Escape and Close both leave, and an empty script says what to do', async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<PresentMode mode="script" title="T" script=" " onClose={onClose} />);
|
||||
expect(screen.getByText(/no script to present yet/i)).toBeInTheDocument();
|
||||
await user.keyboard('{Escape}');
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// A stand-in for the browser's BroadcastChannel that records what the presenter window sends
|
||||
class FakeChannel {
|
||||
static instances = [];
|
||||
constructor() { this.sent = []; FakeChannel.instances.push(this); }
|
||||
postMessage(m) { this.sent.push(m); }
|
||||
close() {}
|
||||
}
|
||||
const withChannel = () => { FakeChannel.instances = []; vi.stubGlobal('BroadcastChannel', FakeChannel); };
|
||||
const last = () => FakeChannel.instances[0].sent.filter((m) => m.type === 'state').at(-1);
|
||||
|
||||
test('on one screen the speaker notes stay hidden until you turn them on', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||
expect(screen.queryByText('Say hello')).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Show notes' }));
|
||||
expect(screen.getByText('Say hello')).toBeInTheDocument();
|
||||
expect(localStorage.getItem('present-notes-here')).toBe('on');
|
||||
await user.click(screen.getByRole('button', { name: 'Hide notes' }));
|
||||
expect(screen.queryByText('Say hello')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('with an audience window the notes appear only on the presenter screen, never in what is sent', async () => {
|
||||
withChannel();
|
||||
vi.stubGlobal('open', vi.fn(() => ({ closed: false })));
|
||||
const user = userEvent.setup();
|
||||
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Audience window' }));
|
||||
expect(window.open).toHaveBeenCalledWith(expect.stringContaining('?audience=1'), 'bible-study-audience', expect.any(String));
|
||||
expect(screen.getByText('Only you see this')).toBeInTheDocument();
|
||||
expect(screen.getByText('Say hello')).toBeInTheDocument(); // the presenter's own screen
|
||||
expect(screen.getByText(/never your notes/i)).toBeInTheDocument();
|
||||
expect(last()).toMatchObject({ mode: 'outline', index: 0, total: 2, blank: false });
|
||||
expect(last().step.text).toBe('Welcome');
|
||||
const everything = JSON.stringify(FakeChannel.instances[0].sent);
|
||||
expect(everything).not.toContain('Say hello');
|
||||
expect(screen.getByText('Next')).toBeInTheDocument(); // the preview of the next point
|
||||
await user.keyboard('{ArrowRight}');
|
||||
expect(last().step.text).toBe('The gospel');
|
||||
});
|
||||
|
||||
test('a window that opens later is brought up to date, and B blacks the audience screen out', async () => {
|
||||
withChannel();
|
||||
const user = userEvent.setup();
|
||||
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||
const channel = FakeChannel.instances[0];
|
||||
act(() => channel.onmessage({ data: { type: 'hello' } })); // someone opened the audience address by hand
|
||||
expect(await screen.findByText('Only you see this')).toBeInTheDocument();
|
||||
expect(channel.sent.at(-1).step.text).toBe('Welcome');
|
||||
await user.keyboard('b');
|
||||
expect(last().blank).toBe(true);
|
||||
expect(screen.getByText(/audience screen is blank/i)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Show audience' }));
|
||||
expect(last().blank).toBe(false);
|
||||
});
|
||||
|
||||
test('in script mode the audience is only ever sent the title', async () => {
|
||||
withChannel();
|
||||
vi.stubGlobal('open', vi.fn(() => ({ closed: false })));
|
||||
const user = userEvent.setup();
|
||||
render(<PresentMode mode="script" title="Romans 8" script="My private script text" onClose={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Audience window' }));
|
||||
expect(last()).toMatchObject({ mode: 'script', title: 'Romans 8', step: null });
|
||||
expect(JSON.stringify(FakeChannel.instances[0].sent)).not.toContain('private script');
|
||||
expect(screen.getByText(/only the title/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the presentation ending tells the audience window', () => {
|
||||
withChannel();
|
||||
const { unmount } = render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
|
||||
const channel = FakeChannel.instances[0];
|
||||
unmount();
|
||||
expect(channel.sent.at(-1)).toEqual({ type: 'end' });
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// "Your reading": how much of the Bible you have read (every chapter you spent time on in the Reader, plus the
|
||||
// ones you finished in reading plans), a tile for each book that fills as you read it, and a calendar of the
|
||||
// days you read in the last six months.
|
||||
import { useMemo } from 'react';
|
||||
import { getPlanSchedule } from '../utils/readingPlans.js';
|
||||
import { heatmapWeeks, planChapterKeys, progressSummary } from '../utils/readingHistory.js';
|
||||
|
||||
const pct = (n) => `${Math.round(n * 100)}%`;
|
||||
|
||||
export default function ReadingProgress({ history, planProgress = [], activity = [], onOpenBook }) {
|
||||
const summary = useMemo(() => progressSummary(history, planChapterKeys(planProgress, getPlanSchedule)), [history, planProgress]);
|
||||
const days = useMemo(() => new Set([...(history.days ?? []), ...activity]), [history.days, activity]);
|
||||
const weeks = useMemo(() => heatmapWeeks(days), [days]);
|
||||
const daysInView = weeks.flat().filter((d) => d.on).length;
|
||||
const group = (list) => (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1.5">
|
||||
{list.map((b) => (
|
||||
<button key={b.abbrev} type="button" onClick={() => onOpenBook?.(b.abbrev)} title={`${b.name}: ${b.read} of ${b.total} chapters read`}
|
||||
aria-label={`${b.name}, ${b.read} of ${b.total} chapters read`}
|
||||
className="relative overflow-hidden rounded-lg border border-[var(--r-line)] px-2 py-1.5 text-left text-xs hover:border-[var(--r-accent)]">
|
||||
<span aria-hidden="true" className="absolute inset-y-0 left-0 bg-[var(--r-accent)] opacity-25" style={{ width: pct(b.pct) }} />
|
||||
<span className="relative block truncate font-medium">{b.name}</span>
|
||||
<span className="relative block text-[10px] text-[var(--r-sub)]">{b.read}/{b.total}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section aria-label="Your reading" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 className="text-sm font-medium">Your reading</h2>
|
||||
<p className="text-xs text-[var(--r-sub)]">
|
||||
<span className="text-base font-medium text-[var(--r-text)]">{pct(summary.pct)}</span> of the Bible · {summary.read} of {summary.total} chapters
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(summary.pct * 100)} aria-label="Bible read">
|
||||
<div className="h-full bg-[var(--r-accent)]" style={{ width: pct(summary.pct) }} />
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Old Testament {summary.ot.read}/{summary.ot.total} · New Testament {summary.nt.read}/{summary.nt.total}. A chapter counts once you’ve had it open for a bit in the Reader, or finished it in a plan.</p>
|
||||
|
||||
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Old Testament</h3>
|
||||
{group(summary.books.filter((b) => b.testament === 'ot'))}
|
||||
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">New Testament</h3>
|
||||
{group(summary.books.filter((b) => b.testament === 'nt'))}
|
||||
|
||||
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Days you read · {daysInView} in the last 6 months</h3>
|
||||
<div className="flex gap-[3px] overflow-x-auto pb-1" role="img" aria-label={`${daysInView} days read in the last six months`}>
|
||||
{weeks.map((week) => (
|
||||
<div key={week[0].date} className="flex flex-col gap-[3px]">
|
||||
{week.map((d) => (
|
||||
<span key={d.date} title={d.future ? '' : `${d.date}${d.on ? ': read' : ''}`}
|
||||
className={`h-3 w-3 rounded-[3px] ${d.future ? 'opacity-0' : d.on ? 'bg-[var(--r-accent)]' : 'bg-[var(--r-line)]'}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
import ReadingProgress from './ReadingProgress.jsx';
|
||||
|
||||
const history = { chapters: { 'GEN.1': { n: 1, last: '2026-10-01' }, 'GEN.2': { n: 2, last: '2026-10-02' }, 'JHN.3': { n: 1, last: '2026-10-03' } }, days: ['2026-10-01', '2026-10-02'] };
|
||||
|
||||
test('shows how much of the Bible is read, and each book\'s progress', () => {
|
||||
render(<ReadingProgress history={history} planProgress={[]} activity={[]} />);
|
||||
expect(screen.getByText(/3 of 1189 chapters/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('progressbar', { name: 'Bible read' })).toHaveAttribute('aria-valuenow', '0');
|
||||
expect(screen.getByRole('button', { name: 'Genesis, 2 of 50 chapters read' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'John, 1 of 21 chapters read' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Revelation, 0 of 22 chapters read' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button')).toHaveLength(66);
|
||||
});
|
||||
|
||||
test('days from plans count on the calendar too, and a book opens in the Reader', async () => {
|
||||
const onOpenBook = vi.fn();
|
||||
render(<ReadingProgress history={history} planProgress={[]} activity={['2026-10-05']} onOpenBook={onOpenBook} />);
|
||||
expect(screen.getByRole('img', { name: /3 days read in the last six months/ })).toBeInTheDocument();
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: /^Genesis/ }));
|
||||
expect(onOpenBook).toHaveBeenCalledWith('GEN');
|
||||
});
|
||||
|
||||
test('an empty history is fine', () => {
|
||||
render(<ReadingProgress history={{ chapters: {}, days: [] }} />);
|
||||
expect(screen.getByText(/0 of 1189 chapters/)).toBeInTheDocument();
|
||||
expect(within(screen.getByRole('img')).queryAllByTitle(/: read/)).toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// Home: how your weekly recalls have gone — the last eight weeks at a glance, then every recall you've written.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { loadProjectById } from '../utils/projectStorage.js';
|
||||
import { recallHistory, recallSummary } from '../utils/recall.js';
|
||||
|
||||
const LABEL = { solid: 'Had it', partly: 'Partly', forgot: 'Slipped' };
|
||||
const DOT = { solid: 'bg-[var(--r-accent)] border-[var(--r-accent)]', partly: 'bg-[var(--r-hl)] border-[var(--r-accent)]', forgot: 'border-[var(--r-sub)]' };
|
||||
|
||||
export default function RecallHistory() {
|
||||
const { projectIndex, openStudyChunk } = useApp();
|
||||
const [open, setOpen] = useState(false);
|
||||
const history = useMemo(() => recallHistory(projectIndex.map((e) => loadProjectById(e.id)).filter(Boolean)), [projectIndex]);
|
||||
if (history.length === 0) return null;
|
||||
const { counts, total, recent } = recallSummary(history);
|
||||
|
||||
return (
|
||||
<section aria-label="Recall history" className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm">
|
||||
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} className="flex w-full flex-wrap items-center justify-between gap-3 text-left">
|
||||
<span><span className="font-medium">Recall history</span> <span className="text-xs text-[var(--r-sub)]">{total} recall{total === 1 ? '' : 's'} · {counts.solid} had it · {counts.partly} partly · {counts.forgot} slipped</span></span>
|
||||
<span className="flex items-center gap-1" aria-label="Last eight weeks">
|
||||
{recent.map((w) => (
|
||||
<span key={w.start} title={w.rating ? LABEL[w.rating] : 'No recall that week'}
|
||||
className={`h-3 w-3 rounded-full border ${w.rating ? DOT[w.rating] : 'border-[var(--r-line)]'}`} />
|
||||
))}
|
||||
<span className="ml-1 text-xs text-[var(--r-sub)]">{open ? '▾' : '▸'}</span>
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="mt-3 space-y-2.5">
|
||||
{history.map((h) => (
|
||||
<li key={`${h.chunkId}-${h.at}`} className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<span><span className="font-medium text-sm">{h.reference}</span> <span className="text-[var(--r-sub)]">· {h.projectTitle} · {new Date(h.at).toLocaleDateString()}</span></span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5">{LABEL[h.rating] ?? '—'}</span>
|
||||
<button type="button" onClick={() => openStudyChunk(h.projectId, h.chunkId)} className="text-[var(--r-accent)] hover:underline">Open</button>
|
||||
</span>
|
||||
</div>
|
||||
{h.text && <p className="mt-1.5 line-clamp-3 whitespace-pre-line font-serif text-[13px] leading-relaxed text-[var(--r-sub)]">{h.text}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// "Forgot password": ask for a code (it is sent to the account's notification topic), then choose a new
|
||||
// password with it. Shown on the sign-in page in place of the sign-in form.
|
||||
import { useState } from 'react';
|
||||
import { requestPasswordReset, resetPassword } from '../syncService.js';
|
||||
|
||||
const input = 'mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 text-sm text-[var(--r-text)]';
|
||||
const primary = 'w-full rounded-full bg-[var(--r-accent)] px-4 py-2.5 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
export default function ResetPasswordForm({ initialEmail = '', onDone, onBack }) {
|
||||
const [step, setStep] = useState('ask'); // 'ask' | 'code' | 'done'
|
||||
const [email, setEmail] = useState(initialEmail);
|
||||
const [form, setForm] = useState({ code: '', password: '', token: '' });
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const ask = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(''); setBusy(true);
|
||||
const result = await requestPasswordReset(email.trim());
|
||||
setBusy(false);
|
||||
if (!result.ok) { setError(result.error ?? 'Something went wrong.'); return; }
|
||||
setMessage(result.data?.message ?? 'If that account has a notification topic, a code was sent.');
|
||||
setStep('code');
|
||||
};
|
||||
|
||||
const reset = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(''); setBusy(true);
|
||||
const result = await resetPassword({ email: email.trim(), code: form.code, newPassword: form.password, token: form.token.trim() || undefined });
|
||||
setBusy(false);
|
||||
if (!result.ok) { setError(result.error ?? 'Something went wrong.'); return; }
|
||||
setStep('done');
|
||||
};
|
||||
|
||||
if (step === 'done') {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-medium">Password changed</h2>
|
||||
<p className="mt-2 text-sm text-[var(--r-sub)]">You’ve been signed out everywhere. Sign in with your new password.</p>
|
||||
<button type="button" onClick={onDone} className={`mt-6 ${primary}`}>Back to sign in</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-medium">Reset your password</h2>
|
||||
{step === 'ask' ? (
|
||||
<form onSubmit={ask} className="mt-4 space-y-4">
|
||||
<p className="text-sm text-[var(--r-sub)]">We’ll send a one-time code to the notification topic you set up for reading reminders (ntfy).</p>
|
||||
<label className="block text-sm">Email
|
||||
<input type="email" required autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} className={input} />
|
||||
</label>
|
||||
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
<button type="submit" disabled={busy} className={primary}>{busy ? 'Please wait…' : 'Send me a code'}</button>
|
||||
<button type="button" onClick={() => { setStep('code'); setMessage(''); }} className="block w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">I already have a code</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={reset} className="mt-4 space-y-4">
|
||||
{message && <p role="status" className="text-sm text-[var(--r-sub)]">{message}</p>}
|
||||
<label className="block text-sm">Email
|
||||
<input type="email" required value={email} onChange={(e) => setEmail(e.target.value)} className={input} />
|
||||
</label>
|
||||
<label className="block text-sm">Code
|
||||
<input type="text" required autoFocus autoComplete="one-time-code" value={form.code} onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))} className={`${input} text-center font-mono tracking-widest`} placeholder="ABCD2345" />
|
||||
</label>
|
||||
<label className="block text-sm">New password
|
||||
<input type="password" required minLength={8} autoComplete="new-password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} className={input} />
|
||||
<span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>
|
||||
</label>
|
||||
<label className="block text-sm">Authenticator code <span className="text-xs text-[var(--r-sub)]">(only if you use two-factor sign-in)</span>
|
||||
<input type="text" inputMode="numeric" value={form.token} onChange={(e) => setForm((f) => ({ ...f, token: e.target.value }))} className={input} placeholder="123456" />
|
||||
</label>
|
||||
{error && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
<button type="submit" disabled={busy} className={primary}>{busy ? 'Please wait…' : 'Change password'}</button>
|
||||
</form>
|
||||
)}
|
||||
<button type="button" onClick={onBack} className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Back to sign in</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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', () => ({ requestPasswordReset: vi.fn(), resetPassword: vi.fn() }));
|
||||
import ResetPasswordForm from './ResetPasswordForm.jsx';
|
||||
import { requestPasswordReset, resetPassword } from '../syncService.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requestPasswordReset.mockResolvedValue({ ok: true, data: { message: 'A code was sent if you have a topic.' } });
|
||||
resetPassword.mockResolvedValue({ ok: true, data: { ok: true } });
|
||||
});
|
||||
|
||||
test('asks for a code, then sets the new password with it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDone = vi.fn();
|
||||
render(<ResetPasswordForm initialEmail="me@x.co" onDone={onDone} onBack={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: /send me a code/i }));
|
||||
expect(requestPasswordReset).toHaveBeenCalledWith('me@x.co');
|
||||
expect(await screen.findByText(/A code was sent/)).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByLabelText(/^Code/), 'ABCD2345');
|
||||
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
|
||||
await user.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(resetPassword).toHaveBeenCalledWith({ email: 'me@x.co', code: 'ABCD2345', newPassword: 'brand-new-pass', token: undefined });
|
||||
await user.click(await screen.findByRole('button', { name: /back to sign in/i }));
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a wrong code shows the server\'s message and stays on the form', async () => {
|
||||
resetPassword.mockResolvedValue({ ok: false, error: 'That code is wrong or has expired. Ask for a new one.' });
|
||||
const user = userEvent.setup();
|
||||
render(<ResetPasswordForm initialEmail="me@x.co" onDone={() => {}} onBack={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: /i already have a code/i }));
|
||||
await user.type(screen.getByLabelText(/^Code/), 'WRONG123');
|
||||
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
|
||||
await user.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong or has expired/);
|
||||
});
|
||||
|
||||
test('an authenticator code is passed along when one is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ResetPasswordForm initialEmail="me@x.co" onDone={() => {}} onBack={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: /i already have a code/i }));
|
||||
await user.type(screen.getByLabelText(/^Code/), 'ABCD2345');
|
||||
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
|
||||
await user.type(screen.getByLabelText(/Authenticator code/), '123456');
|
||||
await user.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(resetPassword).toHaveBeenCalledWith(expect.objectContaining({ token: '123456' }));
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Under a note: each Scripture reference written in it (Rom 8:28, Psalm 23…) as a chip. Click one to read the verse
|
||||
// (BSB) right there, or open it in the Bible reader. The text itself is never changed.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { fetchChapter } from '../utils/bibleText.js';
|
||||
import { labelFor, uniqueReferences } from '../utils/scriptureRefs.js';
|
||||
|
||||
const MAX_VERSES = 12;
|
||||
const CHAPTER_PREVIEW = 6;
|
||||
|
||||
function Preview({ r }) {
|
||||
const { jumpToReaderVerse, openBibleReader } = useApp();
|
||||
const [state, setState] = useState({ loading: true });
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setState({ loading: true });
|
||||
fetchChapter('BSB', r.book, r.chapter)
|
||||
.then(({ verses }) => {
|
||||
const from = r.verse ?? 1;
|
||||
const to = r.verse ? (r.endVerse ?? r.verse) : from + CHAPTER_PREVIEW - 1;
|
||||
const picked = verses.filter((v) => v.number >= from && v.number <= to).slice(0, MAX_VERSES);
|
||||
if (live) setState({ verses: picked, more: !r.verse && verses.length > picked.length });
|
||||
})
|
||||
.catch((err) => { if (live) setState({ error: err.message || 'Could not load the verse.' }); });
|
||||
return () => { live = false; };
|
||||
}, [r.book, r.chapter, r.verse, r.endVerse]);
|
||||
|
||||
return (
|
||||
<div role="region" aria-label={`${labelFor(r)} preview`} className="mt-1.5 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 text-[13px]">
|
||||
{state.loading && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
|
||||
{state.error && <p className="text-xs text-[var(--r-sub)]">{state.error} You can still open it in the Bible.</p>}
|
||||
{state.verses && (state.verses.length === 0
|
||||
? <p className="text-xs text-[var(--r-sub)]">That verse isn’t in the BSB.</p>
|
||||
: <div className="space-y-1 font-serif leading-relaxed">{state.verses.map((v) => <p key={v.number}><sup className="mr-1 font-sans text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}{state.more && <p className="text-xs text-[var(--r-sub)]">…</p>}</div>)}
|
||||
<div className="mt-2 flex items-center gap-3 font-sans text-xs">
|
||||
<button type="button" onClick={() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }} className="text-[var(--r-accent)] hover:underline">Read in the Bible</button>
|
||||
<span className="text-[var(--r-sub)]">Berean Standard Bible</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScriptureRefs({ text }) {
|
||||
const refs = useMemo(() => uniqueReferences(text), [text]);
|
||||
const [open, setOpen] = useState(null);
|
||||
if (refs.length === 0) return null;
|
||||
const openRef = refs.find((r) => labelFor(r) === open);
|
||||
return (
|
||||
<div className="mt-1.5" aria-label="References in this note">
|
||||
<p className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="text-[var(--r-sub)]">Verses mentioned:</span>
|
||||
{refs.map((r) => {
|
||||
const label = labelFor(r);
|
||||
return (
|
||||
<button key={label} type="button" aria-pressed={open === label} onClick={() => setOpen(open === label ? null : label)}
|
||||
className={`rounded-full border px-2 py-0.5 ${open === label ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{label}</button>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
{openRef && <Preview r={openRef} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import { AppContext } from '../context/AppContext.js';
|
||||
import ScriptureRefs from './ScriptureRefs.jsx';
|
||||
|
||||
const jump = vi.fn();
|
||||
const open = vi.fn();
|
||||
beforeEach(() => {
|
||||
jump.mockClear(); open.mockClear();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
ok: true, status: 200,
|
||||
json: async () => ({ book: { numberOfChapters: 16 }, verses: [28, 29, 30].map((n) => ({ number: n, text: `Verse ${n} text.` })) }),
|
||||
})));
|
||||
});
|
||||
const setup = (text) => render(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><ScriptureRefs text={text} /></AppContext.Provider>);
|
||||
|
||||
test('references in a note become chips, and a note with none shows nothing', () => {
|
||||
const { container } = setup('Nothing to see here.');
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
test('clicking a reference shows the verse, and "Read in the Bible" opens the reader there', async () => {
|
||||
const user = userEvent.setup();
|
||||
setup('God works all things together, Rom 8:28-29. See also Romans 8:28.');
|
||||
expect(screen.getAllByRole('button', { name: /Romans 8/ })).toHaveLength(2); // 8:28–29 and 8:28 are different references
|
||||
await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' }));
|
||||
const preview = await screen.findByRole('region', { name: /Romans 8:28–29 preview/ });
|
||||
expect(await within(preview).findByText('Verse 28 text.')).toBeInTheDocument();
|
||||
expect(within(preview).getByText('Verse 29 text.')).toBeInTheDocument();
|
||||
expect(within(preview).queryByText('Verse 30 text.')).not.toBeInTheDocument();
|
||||
await user.click(within(preview).getByRole('button', { name: 'Read in the Bible' }));
|
||||
expect(jump).toHaveBeenCalledWith('ROM', 8, 28);
|
||||
expect(open).toHaveBeenCalled();
|
||||
await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' }));
|
||||
expect(screen.queryByRole('region')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a failure to load the verse still leaves the link to the Bible', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||
const user = userEvent.setup();
|
||||
setup('Psalm 23:1');
|
||||
await user.click(screen.getByRole('button', { name: 'Psalms 23:1' }));
|
||||
const preview = await screen.findByRole('region');
|
||||
expect(await within(preview).findByText(/You can still open it in the Bible/)).toBeInTheDocument();
|
||||
expect(within(preview).getByRole('button', { name: 'Read in the Bible' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Finish → Schedule: the study's sessions (one per chunk) as a calendar file, starting on a date you pick.
|
||||
import { useState } from 'react';
|
||||
import { formatChunkReference } from '../utils/bibleUtils.jsx';
|
||||
import { buildIcs, downloadIcs, sessionDates } from '../utils/icsFile.js';
|
||||
|
||||
const field = '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)]';
|
||||
const isoDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// The study's sessions in order: { title, description } for each chunk.
|
||||
export function sessionsOf(project) {
|
||||
const list = [];
|
||||
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
|
||||
for (const chunk of chapter.chunks ?? []) {
|
||||
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
|
||||
const n = list.length + 1;
|
||||
const label = `Session ${(chunk.episodeNumber ?? '').toString().trim() || n}`;
|
||||
const name = (project.series ?? '').trim() || project.title;
|
||||
list.push({ title: `${name} — ${label}: ${(chunk.episodeTitle ?? '').trim() || ref}`, description: `${project.title}\n${ref}` });
|
||||
}
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
export default function SessionSchedule({ project }) {
|
||||
const sessions = sessionsOf(project);
|
||||
const [start, setStart] = useState(() => isoDay(new Date()));
|
||||
const [every, setEvery] = useState('7');
|
||||
const [time, setTime] = useState('');
|
||||
const [minutes, setMinutes] = useState('60');
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
const download = () => {
|
||||
const [y, m, d] = start.split('-').map(Number);
|
||||
const dates = sessionDates(new Date(y, m - 1, d), sessions.length, Number(every));
|
||||
const events = sessions.map((s, i) => ({ ...s, date: dates[i], time: time || undefined, minutes: Number(minutes) || 60 }));
|
||||
downloadIcs(buildIcs(events, { name: (project.series ?? '').trim() || project.title }), `${(project.title || 'study').replace(/[^\w]+/g, '-').toLowerCase()}-schedule.ics`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Schedule">
|
||||
<h2 className="text-sm font-medium">Schedule</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Put the {sessions.length} session{sessions.length === 1 ? '' : 's'} on your calendar: one per chunk, in order, using each chunk’s session number and title.</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="text-xs text-[var(--r-sub)]">First session
|
||||
<input type="date" value={start} onChange={(e) => setStart(e.target.value)} className={`${field} mt-1 block`} />
|
||||
</label>
|
||||
<label className="text-xs text-[var(--r-sub)]">Then every
|
||||
<select value={every} onChange={(e) => setEvery(e.target.value)} className={`${field} mt-1 block`}>
|
||||
<option value="1">day</option><option value="7">week</option><option value="14">2 weeks</option><option value="30">30 days</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs text-[var(--r-sub)]">At (optional)
|
||||
<input type="time" value={time} onChange={(e) => setTime(e.target.value)} className={`${field} mt-1 block`} />
|
||||
</label>
|
||||
{time && (
|
||||
<label className="text-xs text-[var(--r-sub)]">Minutes
|
||||
<input type="number" min="5" max="480" value={minutes} onChange={(e) => setMinutes(e.target.value)} className={`${field} mt-1 block w-20`} />
|
||||
</label>
|
||||
)}
|
||||
<button type="button" onClick={download} disabled={!start} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50">Download calendar file (.ics)</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
|
||||
vi.mock('../utils/icsFile.js', async (orig) => ({ ...(await orig()), downloadIcs: vi.fn() }));
|
||||
import SessionSchedule, { sessionsOf } from './SessionSchedule.jsx';
|
||||
import { downloadIcs } from '../utils/icsFile.js';
|
||||
|
||||
const chunk = (id, s, e, x = {}) => ({ id, startVerse: s, endVerse: e, ...x });
|
||||
const project = {
|
||||
title: 'Romans 8', series: 'Romans', chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [], chunks: [
|
||||
chunk('a', 1, 11, { episodeNumber: '4', episodeTitle: 'No condemnation' }), chunk('b', 12, 17),
|
||||
] }],
|
||||
};
|
||||
|
||||
test('each chunk is a session named for the series, its number and title (or passage)', () => {
|
||||
const s = sessionsOf(project);
|
||||
expect(s[0].title).toBe('Romans — Session 4: No condemnation');
|
||||
expect(s[1].title).toMatch(/^Romans — Session 2: Romans 8:12/);
|
||||
});
|
||||
|
||||
test('the calendar file has one event per session, spaced as asked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SessionSchedule project={project} />);
|
||||
await user.clear(screen.getByLabelText('First session'));
|
||||
await user.type(screen.getByLabelText('First session'), '2026-10-11');
|
||||
await user.click(screen.getByRole('button', { name: /download calendar file/i }));
|
||||
const [text, name] = downloadIcs.mock.calls[0];
|
||||
expect(name).toBe('romans-8-schedule.ics');
|
||||
expect(text.match(/BEGIN:VEVENT/g)).toHaveLength(2);
|
||||
expect(text).toContain('DTSTART;VALUE=DATE:20261011');
|
||||
expect(text).toContain('DTSTART;VALUE=DATE:20261018'); // a week later
|
||||
});
|
||||
|
||||
test('a study with no chunks shows nothing', () => {
|
||||
const { container } = render(<SessionSchedule project={{ title: 'x', chapters: [] }} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
// Share an outline: a read-only link anyone can open (no account), or the plain text.
|
||||
import { useState } from 'react';
|
||||
import LinkRules from './LinkRules.jsx';
|
||||
import Sheet from './reader/Sheet.jsx';
|
||||
import { useToast } from './Toasts.jsx';
|
||||
import { useOutlineShareLink } from '../hooks/useOutlineShareLink.js';
|
||||
@@ -49,10 +50,11 @@ export default function ShareOutlineSheet({ open, onClose, projectId, outline })
|
||||
<button type="button" onClick={shareLink} className={btn}>Share link…</button>
|
||||
<button type="button" onClick={link.turnOff} disabled={link.busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{link.busy ? 'Turning off…' : 'Turn off link'}</button>
|
||||
</div>
|
||||
<LinkRules expiresAt={link.expiresAt} hasPasscode={link.hasPasscode} busy={link.busy} onSave={async (options) => { await link.create(options); return true; }} />
|
||||
<p className="text-[11px] text-[var(--r-sub)]">People see the version that last synced to your account, and it updates as you edit. Turning the link off stops it working right away.</p>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={link.create} disabled={link.busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{link.busy ? 'Creating…' : 'Create read-only link'}</button>
|
||||
<button type="button" onClick={() => link.create()} disabled={link.busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{link.busy ? 'Creating…' : 'Create read-only link'}</button>
|
||||
))}
|
||||
{link.error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{link.error}</p>}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// What someone sees when they open a read-only outline link: a calm page with the outline, a switcher
|
||||
// when there is more than one, and Print / PDF. Nothing is editable.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getSharedOutline } from '../syncService.js';
|
||||
import { themeVars } from './reader/readerThemes.js';
|
||||
import SharedPasscodeForm from './SharedPasscodeForm.jsx';
|
||||
import OutlineDocument from './OutlineDocument.jsx';
|
||||
import { printOutline } from '../utils/outlineExport.js';
|
||||
import { flatten } from '../utils/outline.js';
|
||||
@@ -17,18 +18,27 @@ export default function SharedOutlineView({ token }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [scope, setScope] = useState('study');
|
||||
const [needsPasscode, setNeedsPasscode] = useState(false);
|
||||
const [passcodeError, setPasscodeError] = useState('');
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getSharedOutline(token).then((result) => {
|
||||
if (!active) return;
|
||||
if (result.ok) { setData(result.data); setScope(result.data.outlines[0]?.scope ?? 'study'); } else setError(result.error ?? 'This outline link is invalid or has been turned off.');
|
||||
const load = useCallback((passcode) => {
|
||||
setChecking(true);
|
||||
return getSharedOutline(token, passcode).then((result) => {
|
||||
setChecking(false);
|
||||
if (result.ok) { setData(result.data); setNeedsPasscode(false); setScope(result.data.outlines[0]?.scope ?? 'study'); return; }
|
||||
if (result.data?.needsPasscode) { setNeedsPasscode(true); setPasscodeError(passcode ? result.error : ''); return; }
|
||||
setNeedsPasscode(false);
|
||||
setError(result.error ?? 'This outline link is invalid or has been turned off.');
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => { if (data?.title) document.title = `${data.title} — Outline`; }, [data]);
|
||||
|
||||
if (needsPasscode) return <Frame><SharedPasscodeForm error={passcodeError} busy={checking} onSubmit={load} /></Frame>;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Frame>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// What someone sees when a shared link needs a passcode.
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function SharedPasscodeForm({ error, busy, onSubmit, dark = false }) {
|
||||
const [value, setValue] = useState('');
|
||||
return (
|
||||
<div className={`flex min-h-dvh items-center justify-center px-4 ${dark ? 'bg-slate-900' : 'bg-[var(--r-bg)] text-[var(--r-text)]'}`}>
|
||||
<form onSubmit={(e) => { e.preventDefault(); if (value.trim()) onSubmit(value.trim()); }}
|
||||
className="w-full max-w-sm rounded-3xl border border-black/10 bg-white p-8 text-center text-slate-900 shadow-lg">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
|
||||
<h1 className="mt-4 text-lg font-medium">This link has a passcode</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Enter the passcode the person who shared it gave you.</p>
|
||||
<input type="password" autoFocus autoComplete="off" aria-label="Passcode" value={value} onChange={(e) => setValue(e.target.value)}
|
||||
className="mt-5 block w-full rounded-xl border border-slate-300 px-3 py-2.5 text-center text-sm" />
|
||||
{error && <p role="alert" className="mt-3 text-sm text-rose-600">{error}</p>}
|
||||
<button type="submit" disabled={busy || !value.trim()} className="mt-5 w-full rounded-full bg-slate-900 px-4 py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50">{busy ? 'Checking…' : 'Open'}</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,12 +15,17 @@ const BY_PAGE = {
|
||||
['G', 'Pick a book and chapter'],
|
||||
['B', 'Bookmark the selected verse'],
|
||||
['C', 'Compare translations'],
|
||||
['M', 'Map this chapter’s cross-references (needs IQ Bible)'],
|
||||
['H', 'Your highlights'],
|
||||
[',', 'Reading settings'],
|
||||
['F', 'Hide or show the controls'],
|
||||
],
|
||||
study: [
|
||||
['← →', 'Previous / next chunk'],
|
||||
['1 2 3', 'Write in Observe / Interpret / Apply'],
|
||||
['Esc', 'Leave a note box (then keys work again)'],
|
||||
['C W X P R', 'Commentary / Word study / Cross-refs / People & places / Reading list'],
|
||||
['V', 'Compare passages or translations'],
|
||||
],
|
||||
};
|
||||
|
||||
@@ -52,7 +57,7 @@ export default function ShortcutsHelp({ onClose }) {
|
||||
|
||||
return (
|
||||
<div style={themeVars(readerTheme)} className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-3 font-sans" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="max-h-[80vh] w-full max-w-sm overflow-y-auto rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5 text-[var(--r-text)] shadow-2xl">
|
||||
<div role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="max-h-[80dvh] w-full max-w-sm overflow-y-auto rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5 text-[var(--r-text)] shadow-2xl">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-base font-medium">Keyboard shortcuts</h2>
|
||||
<button ref={closeRef} type="button" onClick={onClose} className="rounded-lg px-2 py-1 text-sm text-[var(--r-sub)] hover:bg-black/5">Close</button>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Finish → Slides: a printable slide deck for a session or the whole study (Save as PDF in the print dialog).
|
||||
import { useState } from 'react';
|
||||
import { sessionsFor } from '../utils/handout.js';
|
||||
import { printSlides } from '../utils/slides.js';
|
||||
|
||||
export default function SlidesExport({ project }) {
|
||||
const sessions = sessionsFor(project);
|
||||
const [which, setWhich] = useState('all');
|
||||
const [dark, setDark] = useState(false);
|
||||
const [blocked, setBlocked] = useState(false);
|
||||
if (sessions.length === 0) return null;
|
||||
const go = () => setBlocked(!printSlides(project, which === 'all' ? null : [which], { dark }));
|
||||
return (
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Slides">
|
||||
<h2 className="text-sm font-medium">Slides</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">A 16:9 slide for each session’s passage, each outline point (with the verse it quotes) and the discussion questions. Choose “Save as PDF” in the print dialog, then open it in any slide viewer or project it from the PDF. It isn’t a PowerPoint file.</p>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="text-xs text-[var(--r-sub)]">Sessions
|
||||
<select value={which} onChange={(e) => setWhich(e.target.value)} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
|
||||
<option value="all">All {sessions.length}</option>
|
||||
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 pb-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={dark} onChange={(e) => setDark(e.target.checked)} className="accent-[var(--r-accent)]" /> Dark slides</label>
|
||||
<button type="button" onClick={go} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Print / Save PDF</button>
|
||||
</div>
|
||||
{blocked && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Allow pop-ups for this site to print the slides.</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// One outline point as it appears on screen: the point, its passage and quoted verse, and what it carries.
|
||||
// Used for the whole screen in present mode, for the presenter's preview, and on the audience window. `note` and
|
||||
// `minutes` are only passed where the speaker is the one looking.
|
||||
export default function StepFace({ step, index, total, fontSize, minutes = null, note = '' }) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}>
|
||||
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {total}{minutes ? ` · ${minutes} min` : ''}</p>
|
||||
<h1 className="mt-2 font-semibold"><span className="mr-3 opacity-60">{step.label}</span>{step.text || '(untitled)'}</h1>
|
||||
{step.ref && <p className="mt-2 text-[0.6em] opacity-70">{step.ref}</p>}
|
||||
{step.scripture?.text && <blockquote className="mt-5 border-l-4 border-current/30 pl-5 text-[0.8em] italic opacity-90">“{step.scripture.text}” <span className="not-italic opacity-60">— {step.scripture.ref}</span></blockquote>}
|
||||
{note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{note}</p>}
|
||||
{step.attachments?.length > 0 && <p className="mt-5 text-[0.55em] opacity-60">{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,32 @@
|
||||
// public-domain / open sources, plus the people, places and events tied to the verse.
|
||||
import { useState } from 'react';
|
||||
import { useApp } from '../context/AppContext.js';
|
||||
import { useIqAvailable } from '../hooks/useIq.js';
|
||||
import IqCommentary from './iq/IqCommentary.jsx';
|
||||
import Family from './FamilyTree.jsx';
|
||||
import {
|
||||
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
||||
} from '../hooks/useVerseStudy.js';
|
||||
|
||||
export function CommentaryTab({ book, chapter, verse }) {
|
||||
const iq = useIqAvailable();
|
||||
const [more, setMore] = useState(() => { try { return localStorage.getItem('iq-commentary-mode') === '1'; } catch { return false; } });
|
||||
const setMode = (on) => { setMore(on); try { localStorage.setItem('iq-commentary-mode', on ? '1' : '0'); } catch { /* storage unavailable */ } };
|
||||
const chip = (on) => `rounded-full border px-2.5 py-0.5 text-[11px] ${on ? 'border-[var(--r-text,#334155)] bg-[var(--r-text,#334155)] text-[var(--r-bg,#ffffff)]' : 'border-[var(--r-line,#cbd5e1)] text-[var(--r-sub,#64748b)]'}`;
|
||||
return (
|
||||
<div>
|
||||
{iq && (
|
||||
<div className="mb-2 flex gap-1.5" role="group" aria-label="Commentary library">
|
||||
<button type="button" onClick={() => setMode(false)} aria-pressed={!more} className={chip(!more)}>Classic</button>
|
||||
<button type="button" onClick={() => setMode(true)} aria-pressed={more} className={chip(more)}>More sources</button>
|
||||
</div>
|
||||
)}
|
||||
{iq && more ? <IqCommentary book={book} chapter={chapter} verse={verse} /> : <ClassicCommentary book={book} chapter={chapter} verse={verse} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClassicCommentary({ book, chapter, verse }) {
|
||||
// The chosen commentary is a synced preference, shared with the Study stage
|
||||
const { readerCommentary: sourceId, setReaderCommentary: changeSource } = useApp();
|
||||
const source = COMMENTARIES.find((c) => c.id === sourceId) ?? COMMENTARIES[0];
|
||||
@@ -51,6 +72,7 @@ function PersonDetails({ id }) {
|
||||
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
|
||||
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
||||
{!data.description?.length && <p>No description available.</p>}
|
||||
<Family person={data} renderPerson={(pid) => <PersonDetails key={pid} id={pid} />} />
|
||||
{data.minYear != null && (
|
||||
<p className="text-[var(--r-sub,#64748b)]">
|
||||
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ToastProvider({ children }) {
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px))] font-sans">
|
||||
<div style={themeVars(theme)} className="pointer-events-none fixed inset-x-0 bottom-0 z-[100] flex flex-col items-center gap-2 px-4 pb-[calc(1rem+env(safe-area-inset-bottom,0px)+var(--bottom-nav,0px))] font-sans">
|
||||
{toasts.map((t) => (
|
||||
<div key={t.id} role={t.tone === 'error' ? 'alert' : 'status'}
|
||||
className={`pointer-events-auto flex max-w-md items-center gap-3 rounded-full px-4 py-2.5 text-sm shadow-lg ${t.tone === 'error' ? 'bg-[var(--r-danger)] text-[var(--r-bg)]' : 'bg-[var(--r-text)] text-[var(--r-bg)]'}`}>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// "Share as image": previews a verse card, lets you pick its look (plain theme, a scene, one of your
|
||||
// photos, or a photo from your device), then shares or saves the picture.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { READER_THEMES } from './reader/readerThemes.js';
|
||||
import { renderVerseCard, shareOrSaveImage, verseCardFileName } from '../utils/verseCard.js';
|
||||
import { SCENES, loadImage, loadPhotoList, resolveBackground } from '../utils/verseBackgrounds.js';
|
||||
import { useToast } from './Toasts.jsx';
|
||||
|
||||
const STYLE_KEY = 'verse-card-style';
|
||||
export const BACKGROUND_KEY = 'votd-background';
|
||||
|
||||
export default function VerseShareImage({ text, reference, translation, notice = '', defaultStyle = 'paper', defaultBackground = 'plain', onClose }) {
|
||||
const { toast } = useToast();
|
||||
const [style, setStyle] = useState(() => { try { return localStorage.getItem(STYLE_KEY) || defaultStyle; } catch { return defaultStyle; } });
|
||||
const [choice, setChoice] = useState(defaultBackground); // 'plain' | 'auto' | scene id | photo id | 'mine'
|
||||
const [photos, setPhotos] = useState([]);
|
||||
const [mine, setMine] = useState(null); // a photo picked from this device: { image, name }
|
||||
const [image, setImage] = useState({ blob: null, url: '', error: '' });
|
||||
const fileRef = useRef(null);
|
||||
|
||||
useEffect(() => { let live = true; loadPhotoList().then((list) => { if (live) setPhotos(list); }); return () => { live = false; }; }, []);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
let url = '';
|
||||
const background = choice === 'mine' && mine ? { kind: 'image', image: mine.image } : resolveBackground(choice === 'mine' ? 'plain' : choice, photos);
|
||||
renderVerseCard({ text, reference, translation, notice, themeId: style, background })
|
||||
.then((blob) => { if (!live) return; url = URL.createObjectURL(blob); setImage({ blob, url, error: '' }); })
|
||||
.catch((err) => { if (live) setImage({ blob: null, url: '', error: err.message }); });
|
||||
return () => { live = false; if (url) URL.revokeObjectURL(url); };
|
||||
}, [text, reference, translation, notice, style, choice, photos, mine]);
|
||||
|
||||
const chooseStyle = (id) => { setStyle(id); setChoice('plain'); try { localStorage.setItem(STYLE_KEY, id); } catch { /* storage unavailable */ } };
|
||||
const pickPhoto = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
try { setMine({ image: await loadImage(url), name: file.name }); setChoice('mine'); } catch (err) { toast(err.message, { tone: 'error' }); } finally { URL.revokeObjectURL(url); }
|
||||
};
|
||||
const go = async () => {
|
||||
const result = await shareOrSaveImage(image.blob, verseCardFileName(reference), { title: reference, text: `${reference} (${translation})` });
|
||||
if (result === 'saved') toast('Image saved');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3" role="region" aria-label="Verse image">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-[var(--r-sub)]">Background
|
||||
<select value={choice} onChange={(e) => (e.target.value === 'upload' ? fileRef.current?.click() : setChoice(e.target.value))} aria-label="Image background"
|
||||
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2 py-1 text-xs text-[var(--r-text)]">
|
||||
<option value="plain">Plain</option>
|
||||
<option value="auto">Picture of the day</option>
|
||||
<optgroup label="Scenes">{SCENES.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</optgroup>
|
||||
{photos.length > 0 && <optgroup label="Your photos">{photos.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</optgroup>}
|
||||
{mine && <option value="mine">{mine.name}</option>}
|
||||
<option value="upload">Use a photo from this device…</option>
|
||||
</select>
|
||||
</label>
|
||||
{onClose && <button type="button" onClick={onClose} aria-label="Close image" className="rounded p-1 text-[var(--r-sub)] hover:bg-black/5">×</button>}
|
||||
</div>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={pickPhoto} className="hidden" aria-label="Choose a background photo" />
|
||||
{choice === 'plain' && (
|
||||
<div role="group" aria-label="Image style" className="mb-2 flex w-fit overflow-hidden rounded-full border border-[var(--r-line)] text-xs">
|
||||
{Object.entries(READER_THEMES).map(([id, t]) => (
|
||||
<button key={id} type="button" aria-pressed={style === id} onClick={() => chooseStyle(id)}
|
||||
className={`px-3 py-1 ${style === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{image.error ? <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{image.error}</p>
|
||||
: image.url ? <img src={image.url} alt={`${reference} (${translation}) as a picture`} className="mx-auto w-full max-w-xs rounded-lg shadow-sm" />
|
||||
: <p className="text-sm text-[var(--r-sub)]">Making the image…</p>}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button type="button" onClick={go} disabled={!image.blob} className="flex-1 rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">
|
||||
{typeof navigator !== 'undefined' && navigator.canShare ? 'Share image' : 'Save image'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../utils/verseCard.js', async (orig) => ({
|
||||
...(await orig()),
|
||||
renderVerseCard: vi.fn(async () => new Blob(['png'], { type: 'image/png' })),
|
||||
shareOrSaveImage: vi.fn(async () => 'saved'),
|
||||
}));
|
||||
vi.mock('./Toasts.jsx', () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
import VerseShareImage from './VerseShareImage.jsx';
|
||||
import { renderVerseCard, shareOrSaveImage } from '../utils/verseCard.js';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
URL.createObjectURL = vi.fn(() => 'blob:card');
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
const props = { text: 'For God so loved the world.', reference: 'John 3:16', translation: 'BSB' };
|
||||
|
||||
test('previews the card, redraws when the look changes, and remembers the look', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VerseShareImage {...props} />);
|
||||
expect(await screen.findByAltText(/John 3:16 \(BSB\) as a picture/)).toBeInTheDocument();
|
||||
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ themeId: 'paper', reference: 'John 3:16' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Dark' }));
|
||||
await screen.findByAltText(/as a picture/);
|
||||
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ themeId: 'dark' }));
|
||||
expect(localStorage.getItem('verse-card-style')).toBe('dark');
|
||||
});
|
||||
|
||||
test('Save/Share sends the image under a file name from the reference', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VerseShareImage {...props} />);
|
||||
await screen.findByAltText(/as a picture/);
|
||||
await user.click(screen.getByRole('button', { name: /save image|share image/i }));
|
||||
expect(shareOrSaveImage).toHaveBeenCalledWith(expect.any(Blob), 'john-3-16.png', expect.objectContaining({ title: 'John 3:16' }));
|
||||
});
|
||||
|
||||
test('says so if the picture can\'t be drawn', async () => {
|
||||
renderVerseCard.mockRejectedValueOnce(new Error('This browser can’t draw the image.'));
|
||||
render(<VerseShareImage {...props} />);
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/can’t draw/);
|
||||
});
|
||||
|
||||
test('a background picture can be chosen, and is passed to the card; the plain looks come back with Plain', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VerseShareImage {...props} />);
|
||||
await screen.findByAltText(/as a picture/);
|
||||
expect(screen.getByRole('group', { name: /image style/i })).toBeInTheDocument();
|
||||
await user.selectOptions(screen.getByLabelText('Image background'), 'sea');
|
||||
await screen.findByAltText(/as a picture/);
|
||||
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ background: { kind: 'scene', id: 'sea' } }));
|
||||
expect(screen.queryByRole('group', { name: /image style/i })).toBeNull(); // theme looks only apply to the plain card
|
||||
await user.selectOptions(screen.getByLabelText('Image background'), 'plain');
|
||||
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ background: null }));
|
||||
});
|
||||
|
||||
test('the picture of the day is used when the Home card passes it in', async () => {
|
||||
render(<VerseShareImage {...props} defaultBackground="auto" />);
|
||||
await screen.findByAltText(/as a picture/);
|
||||
expect(renderVerseCard).toHaveBeenLastCalledWith(expect.objectContaining({ background: expect.objectContaining({ kind: 'scene' }) }));
|
||||
});
|
||||
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user