Compare commits

...
108 Commits
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 bbbb581af1 Study page: chunk list as a slide-over card, notes and resources as tabs in one column
Build and Push Docker Image / build-and-push (push) Successful in 4m57s
- Chunks: a tab on the left edge opens a card over the passage; choosing a
  chunk, Esc, or clicking the passage closes it (state remembered)
- Right column: Notes is the first tab, followed by the resource tabs;
  the separate notes and resources boxes and their fold state are gone
- Column can be dragged wider (up to 960px)
- Pop-out icon opens the whole column in a large window (PopOut)
- Tests updated for the tabbed layout; new tests for the chunk card and pop-out

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 16:38:26 -04:00
nmemmertandClaude Sonnet 5.5 ed22967d16 Make word-study Speak work without a Greek or Hebrew voice
Build and Push Docker Image / build-and-push (push) Successful in 5m27s
- Pick the language from the word's own letters, then the Strong's prefix
- Fall back to reading the transliteration when the device has no matching voice
- Wait for the browser to load its voice list; clear a stuck queue first
- Strip Hebrew cantillation marks before speaking
- Pass the transliteration from the word study, verse sheet and pronunciation page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 15:54:58 -04:00
nmemmertandClaude Sonnet 5.5 a6af9bd1c6 Teaching tools: word occurrences, blanks handout, templates, series progress, book info, comments
Build and Push Docker Image / build-and-push (push) Successful in 6m41s
- Words tab: "Where else" lists every verse using a Strong's number, by book
  (new /api/strongs/:number/occurrences, indexed from the interlinear files)
- Handout: fill-in-the-blanks layout with a word bank
- Four new study templates: character, topical, word, verse by verse
- Finish: series progress (not started / studied / ready / taught)
- Background: book at a glance for all 66 books
- Chunk comments for co-teachers, merged between copies
- TODO: correct BSB audio note, tick co-teacher comments

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 15:44:37 -04:00
nmemmertandClaude Sonnet 5.5 d9891ff9fe Pin reader and tab bars to their own compositing layer
Build and Push Docker Image / build-and-push (push) Successful in 5m1s
iOS Safari/PWA can strand fixed and sticky bars mid-screen after the
toolbar collapses or the page rubber-bands. Add a .fixed-bar class
(translateZ(0), will-change: transform) to the reader top/bottom bars
and the phone tab bar.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 15:13:18 -04:00
nmemmertandClaude Sonnet 5.5 593769b4f6 Make app icon corners transparent
Build and Push Docker Image / build-and-push (push) Successful in 4m46s
The 192 and 512 icons had opaque white corners that showed as white tips in the browser tab.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 14:01:52 -04:00
nmemmertandClaude Sonnet 5.5 843056b963 Studies: Finished & archived section with Archive/Restore
Build and Push Docker Image / build-and-push (push) Successful in 6m3s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:55:33 -04:00
nmemmertandClaude Sonnet 5.5 afc3206da4 Pinned prayers card: accent styling, check toggle, status badge, prayed count
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:40:44 -04:00
nmemmertandClaude Sonnet 5.5 f692cf7177 Home: align header, sticky side columns, tidy verse header, move backup line
Build and Push Docker Image / build-and-push (push) Successful in 6m25s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:36:28 -04:00
nmemmertandClaude Sonnet 5.5 00fd0cf43d Home: move prayers and recall history to sidebar, add left column for recall cards
Build and Push Docker Image / build-and-push (push) Successful in 4m47s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:32:43 -04:00
nmemmertandClaude Sonnet 5.5 1cf157be64 Prayer journal: pinning, waiting/partial status, import, dashboard card
Build and Push Docker Image / build-and-push (push) Successful in 5m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:27:04 -04:00
nmemmertandClaude Sonnet 5.5 6d647e8d8a Merge more-translations-and-aids: KJV/ASV/LSV, dictionary, grammar, family tree, units, reference links
Build and Push Docker Image / build-and-push (push) Successful in 5m7s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:28:50 -04:00
nmemmertandClaude Sonnet 5.5 c271f49694 Grammar panel: one flowing line per term so it reads in a narrow column
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:28:07 -04:00
nmemmertandClaude Sonnet 5.5 79d9b091f9 Link Scripture references in notes and scripts to the verse (chips with a BSB preview)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:23:09 -04:00
nmemmertandClaude Sonnet 5.5 f05d38ecde Units hover notes and a family tree for people (Theographic)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:20:20 -04:00
nmemmertandClaude Sonnet 5.5 fdb7f16d8a Bible dictionary lookup in the Word study tab (IQ Bible: Easton, Hastings, Hitchcock, Schaff, Smith)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:18:01 -04:00
nmemmertandClaude Sonnet 5.5 fa9e825444 Explain grammar labels in plain English on original words and in word-study cards
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:17:02 -04:00
nmemmertandClaude Sonnet 5.5 d5cc93f9bd Add the KJV, ASV and LSV (free helloao translations; savable offline)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 15:14:25 -04:00
nmemmertandClaude Sonnet 5.5 6a89ee058f Merge fix-backup-nudge: account download counts as a backup
Build and Push Docker Image / build-and-push (push) Successful in 4m56s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:21:55 -04:00
nmemmertandClaude Sonnet 5.5 5e80d307a7 Count "Download from my account" as a backup for the Home nudge
Only "Download backup" recorded the date, so downloading from the account left
Home saying "You haven't downloaded a backup yet". Both now record it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:21:20 -04:00
nmemmertandClaude Sonnet 5.5 1bc515164a Merge studying-and-housekeeping: daily five minutes, reading list, browser tests
Build and Push Docker Image / build-and-push (push) Successful in 8m10s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:13:03 -04:00
nmemmertandClaude Sonnet 5.5 3ee1535c87 Real-browser tests (Playwright) and housekeeping
- npm run test:e2e builds the app, runs it on port 3101 with an empty throwaway
  database and drives Chrome (the installed one; no browser download) through
  notes and the paste check, the daily five minutes and weekly recall,
  translation compare (Bible text faked), and two-screen presenting.
- Vitest skips e2e/; ESLint knows the e2e folder and playwright.config.js.
- .gitignore: the local demo video, Playwright output and sign-in file.
- README and TODO describe the browser tests and what CI still needs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 14:00:15 -04:00
nmemmertandClaude Sonnet 5.5 eccc5e509d Studying: daily five minutes, reading list, plan-day studies use your template
- Home: "Your five minutes": a rotating Observe/Interpret/Apply prompt on the
  passage whose matching note is thinnest in your own words, a 5:00 clock and a
  box that appends to that note. Pasting is blocked; sessions show in Insights.
- Study: a Reading list tab (R) for the study: books and articles to consult,
  with a note and a tick; merges across devices.
- Starting a study from a reading-plan day now uses the notes template you last
  picked when starting a study by hand (logic moved to utils/planStudy.js).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:51:49 -04:00
nmemmertandClaude Sonnet 5.5 0781fa3476 Merge more-study-features: Insights, compare, Finish stage, presenter view and more
Build and Push Docker Image / build-and-push (push) Successful in 5m28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:25:15 -04:00
nmemmertandClaude Sonnet 5.5 7ba5ed41b5 Present mode: private speaker notes, audience window, presenter view
- Speaker notes are hidden on the presenting screen by default (N / "Show notes"
  turns them on for one-screen use), so a mirrored display never shows them.
- "Audience window" opens a second window for a projector (?audience=1, no sign-in)
  that shows only the point, its passage and quoted verse. Notes, minutes, the
  script and the timer are never sent to it.
- With it connected this screen becomes a presenter view: what the audience sees,
  your notes, the next point, plus Blank audience (B) and audience text size.
- Script mode sends the audience only the title.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:23:16 -04:00
nmemmertandClaude Sonnet 5.5 0f1a181d87 Rename the Export stage to Finish
It now holds files, sharing, handouts, slides, importing notes, schedule,
co-teachers and earlier versions, not just exports.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:17:08 -04:00
nmemmertandClaude Sonnet 5.5 2c7c7145c4 Teaching: hide-notes-until share, session length check, slides export
- A study share link can hide the owner's notes until a chosen time: the server
  sends only passages and discussion questions until then, with a banner in the
  shared view (new share_reveal column, migrated on start).
- Outline: a session length with "N of M min planned, X min over" and a marker
  on the point where the plan runs past it.
- Export > Slides: printable 16:9 slides (title, passage, outline points with
  their verse, discussion questions) for one session or all; light or dark.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:08:21 -04:00
nmemmertandClaude Sonnet 5.5 67645cf1df App quality: offline badge, per-note history, keyboard study mode, notes import
- Study cards say whether the Bible text works offline, with a button to save
  the missing book (notes are always kept on the device).
- Each note keeps its last 12 versions with Restore (History under each note).
- Keyboard-only study: 1/2/3 write in Observe/Interpret/Apply, C/W/X/P switch
  resource tabs, V compares, Esc leaves a note box; listed under "?".
- Export > Import notes: bring Word/Markdown/text notes into the matching
  chunks without overwriting anything; can mark them as not your own.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:01:11 -04:00
nmemmertandClaude Sonnet 5.5 648c3206df Highlight translation differences, first-draft snapshot, verse notes, paste check
- Compare translations can highlight the words that differ from the first
  translation (toggle remembered).
- A copy of your notes is kept the first time commentary is shown; "First draft,
  before commentary" under More shows what changed since. Saving it doesn't
  count as editing the chunk.
- Notes on single verses: a pencil next to each verse in Study, and a note line
  under each verse in Compare. They merge per verse across devices.
- Pasting 15+ words into a note asks "Is this from AI?"; "I wrote it" counts the
  words as yours again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:56:57 -04:00
nmemmertandClaude Sonnet 5.5 3a28ffa51a Add Observe/Interpret/Apply boxes to the translation compare view
The same notes as the Study page, editable beside the translations; long
pastes are tagged as pasted the same way.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:50:26 -04:00
nmemmertandClaude Sonnet 5.5 76df145b1e Fold the Study notes/resources halves; compare translations
- Your notes and Commentary & resources can each be folded so the other
  fills the right column (remembered on this device; never both folded).
- Compare has a Translations view: pick BSB/ESV/NET/NLT/CSB/NASB and read the
  chunk verse by verse, with each translation's notice.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:48:38 -04:00
nmemmertandClaude Sonnet 5.5 d8e522dd06 Add Insights, Observe-first, compare, handout layouts, richer search, backup nudge
- Commentary and study aids wait for 15 words of your own Observe notes.
- Insights page: this week's own work, themes across studies, words you keep
  studying, what slipped in recalls, open follow-ups.
- Recall can record what slipped; session notes (teach it back, who came,
  group notes, follow-ups); "Pray about this" from Apply.
- Compare any two passages side by side.
- Handout layouts: standard, questions only, large print, note-taking.
- Search understands "in <book>", "last week/month/year" and a year.
- Home shows the last backup age and nudges after two weeks.
- Phone: the stage bar scrolls instead of clipping Script and Export.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:42:11 -04:00
nmemmertandClaude Sonnet 5.5 2ddc4e1ff6 Merge own-work-and-recall: own-work tracking, Claude check, weekly recall
Build and Push Docker Image / build-and-push (push) Successful in 6m18s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:26:23 -04:00
nmemmertandClaude Sonnet 5.5 0c8a632cff Add own-work tracking, Claude hand-off check, and weekly recall
- Long pastes into Observe/Interpret/Apply are tagged as pasted; notes show
  Mine / N pasted and an own-words share. Pastes follow text through merges.
- The "-> Claude" buttons on the Script stage ask first when a chunk lacks
  enough of the user's own (non-pasted) words.
- Weekly recall card on Home: write a passage from memory, compare with notes,
  self-rate; recalls are saved on the chunk and synced.
- Recall history on Home with an eight-week strip and the full list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 12:25:49 -04:00
nmemmertandClaude Sonnet 5.5 edc5a65d2b Keep the verse of the day when IQ Bible is down
Build and Push Docker Image / build-and-push (push) Successful in 5m42s
- Fall back to a built-in verse (one a day, same for everyone) in the reader's own translation
- No translation picker in fallback mode; card shows even with no IQ Bible key

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 10:30:04 -04:00
nmemmertandClaude Sonnet 5.5 512c6b6889 Put every preference in Settings; same header on every page
Build and Push Docker Image / build-and-push (push) Successful in 5m7s
- Settings > Preferences: typeface, line spacing, verse numbers, highlight colour names,
  My Studies grid/list and sort, verse-of-the-day Bible and picture, commentary sources, study templates
- Header uses one fixed width and reserves scrollbar space so the nav sits in the same place on every page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 14:10:12 -04:00
nmemmertandClaude Sonnet 5.5 a2185ba1dd Easier navigation on iPhone
Build and Push Docker Image / build-and-push (push) Successful in 5m7s
- Phones get a bottom tab bar (Studies, Bible, Plans, Prayer, Account); wider screens keep the header nav
- The Account menu opens upward from the bar; toasts sit above it
- Reader and study pages get a labelled Home button instead of a small arrow

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:54:24 -04:00
nmemmertandClaude Sonnet 5.5 9893883b19 Split server/index.js into one file per area
Build and Push Docker Image / build-and-push (push) Successful in 5m37s
Routes move to server/routes/ (auth, account, projects, reader, sharing, admin);
index.js keeps setup only. A smoke test walks 90 API calls against the real
server and compares every status with the pre-split behaviour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:25:52 -04:00
nmemmertandClaude Sonnet 5.5 31ac6b9179 Make fewer IQ Bible calls
- Load the narrator list only when the audio dropdown is opened
- Keep today's verse and devotional in memory for an hour (not saved)
- Back off for a minute after 'allowance used up' or unreachable; keep 'nothing here' answers
- Server shares one upstream call between identical simultaneous requests (nothing stored)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:25:51 -04:00
nmemmertandClaude Sonnet 5.5 d29a66f984 Make the app work better on iPhone and iPad
Build and Push Docker Image / build-and-push (push) Successful in 7m32s
- Text fields are at least 16px on touch screens (stops iOS zooming on focus)
- Chapter swipe ignores sheets, fields, sliders, sideways-scrolling rows and text selection
- vh -> dvh for sheets, palette and dialogs
- Bigger tap targets for small links, chips and the logo; no double-tap delay
- No tap flash or landscape text inflation

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 13:08:07 -04:00
nmemmertandClaude Sonnet 5.5 bdb04cdedc Add cross-reference map, admin health page, passkeys and device list, settings tabs
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph
- Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs
- Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts
- Settings split into Account, Preferences, Notifications, Security, Your data tabs
- ntfy server field: catch a topic typed into the server box

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 12:52:49 -04:00
nmemmertandClaude Sonnet 5.5 207bfcb919 Version history, notes on highlights and bookmarks, "you've studied this", prayer journal
Build and Push Docker Image / build-and-push (push) Successful in 5m13s
- Earlier versions of a study: the server keeps compressed copies (about one per ten minutes of
  editing, the latest 30); Export shows what differs and puts one back (the replaced copy is kept)
- Notes on highlights and bookmarks: synced, merged without loss, in exports and backups
- Reader verse panel lists your studies and chunks that cover the verse, and opens them
- Prayer journal: requests, who for, linked verses, days prayed, answers; synced, in backups
  and the account export; daily prayer reminder through ntfy
- TODO.md lists the ideas not built yet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 10:45:32 -04:00
nmemmertandClaude Sonnet 5.5 54cb1c217d Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
Build and Push Docker Image / build-and-push (push) Successful in 6m47s
- Admin: sign-up Open / Invite only / Closed with single-use invite links
- Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes)
- Series: group studies on the dashboard, filter by series, calendar file for a study's sessions
- Lesson handout: printable page per session (passage, outline, questions, writing lines)
- Save a chunk's notes as your own study template (synced)
- Voice dictation for notes and scripts (browser speech recognition)
- Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices
- Export highlights and bookmarks as Markdown or Word, grouped by named colour
- Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY)
- Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 10:26:43 -04:00
nmemmertandClaude Sonnet 5.5 88bc59bac0 Dashboard: search and filter studies by tag
Build and Push Docker Image / build-and-push (push) Failing after 20s
- The search box matches tags as well as title and passage; "#tag" searches
  tags only
- Tag chips (with counts) replace the Tag dropdown; several can be on at
  once and a study must have all of them
- Tags on each study card are clickable filters
- "No studies match" message with a Clear filters link

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 10:03:06 -04:00
nmemmertandClaude Sonnet 5.5 dc825f0112 My Studies: show studies as a grid or a list
Build and Push Docker Image / build-and-push (push) Failing after 19s
A Grid / List toggle beside the sort and tag filters. List is one compact
row per study (small book cover, title with Rename, passage, tags, date,
Resume and Delete). The choice is remembered on the device.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 10:00:30 -04:00
nmemmertandClaude Sonnet 5.5 a0971eab29 Reader: visible text selection in every theme; highlights follow across translations
Build and Push Docker Image / build-and-push (push) Failing after 19s
Selection
- Selected text uses a per-theme colour (it was nearly invisible on Dark)
- The colour picker also appears on touch screens after dragging the selection
  handles, and sits below the selection there so the system menu doesn't cover it

Highlights
- Each highlight remembers its translation and whether it covers the whole verse
- In another translation the same words are found again (ignoring case and
  punctuation); whole-verse highlights cover the whole verse; when the wording
  differs, the verse gets a small colour dot instead of marking the wrong words
- Older highlights are checked against their saved words instead of trusted
- Server keeps the new fields

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 09:55:09 -04:00
nmemmertandClaude Sonnet 5.5 aabd4267b8 Verse of the day with picture backgrounds, shareable image, and study covers
Build and Push Docker Image / build-and-push (push) Failing after 21s
- Verse of the day: card over a picture (a different one each day, or chosen),
  12 drawn scenes plus photos from public/verse-backgrounds, and "Share as
  image" (also on the Reader's verse panel) that saves or shares a PNG with the
  same background, a theme look, or a photo from the device
- Study cards get a cover banner named for the book, coloured by kind of book
- Outline "Build from my notes": abbreviations like "A.D. 63." no longer end a
  sentence

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 09:21:44 -04:00
nmemmertandClaude Sonnet 5.5 d2bbf1feb4 Build an outline from the Observe/Interpret/Apply and background notes
Build and Push Docker Image / build-and-push (push) Successful in 4m8s
New "Build from my notes" on the Outline page: a main point per chunk with
Background, What the text says, What it means and How we respond under it
(a point per note line, long lines become a headline plus a note), with the
chunk's cross-references and words attached. Also builds a single chunk's
sections. Only adds what's missing; Undo offered.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 08:48:17 -04:00
nmemmertandClaude Sonnet 5.5 f70f1baf15 IQ Bible on the Study side: commentaries, lexicons, study aids, outline
Build and Push Docker Image / build-and-push (push) Failing after 19s
- Commentary tab: "More sources" switch with notes for the whole chunk and
  "Add to notes" quoting
- Word study: Lexicons button for words with a Strong's number
- New "Study aids" tab (any verse of the chunk; topics add as tags)
- Outline points: "Add from IQ Bible" attaches topics, Gospel parallels,
  manuscript notes and places

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 08:30:56 -04:00
nmemmertandClaude Sonnet 5.5 8939a7f49f Verse of the day in a translation you choose
Build and Push Docker Image / build-and-push (push) Successful in 5m44s
IQ Bible picks the verse; its words come from BSB, NET, ESV, NLT, CSB or
NASB (default: your reading translation, remembered per device), with the
KJV as a fallback if that Bible can't load.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 08:22:57 -04:00
nmemmertandClaude Sonnet 5.5 31fb3f958d Generate a session secret instead of refusing to start
Build and Push Docker Image / build-and-push (push) Failing after 20s
setup.sh starts the production server without SESSION_SECRET, so the new
"refuse to start" check stopped the app after an update. When the variable
is unset in production the server now makes a random secret once and keeps
it in DATA_DIR/.session-secret (mode 600, git-ignored).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 08:18:02 -04:00
nmemmertandClaude Sonnet 5.5 f274bc9c8a Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
Sync and data safety
- Autosave flushes on leaving a study or the tab; storage-full errors are reported
- Unsynced studies, deletes and ink are queued and retried when back online
- Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect
- Server assigns study versions (no client-clock conflicts); "Combine both"
  merges a conflicted study chunk by chunk
- Reading-plan progress made offline is merged, not overwritten; plans, ink and
  marks are cleared from the device on sign-out

Security
- Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and
  account; constant-time login; TOTP replay protection; input validation;
  SESSION_SECRET required in production; admin user delete fixed
- Forgot password: one-time code sent to the account's ntfy topic

Features
- IQ Bible proxy (route allow-list, text-only caching per its terms): more
  commentaries, lexicons, study aids, daily verse and devotional, plan calendar
  export, narrations, podcast background
- Study templates, reading-plan catch-up, account export, daily database snapshots

Tests and tooling
- ESLint config; App tests split into src/test; tests for sync queues, library
  hook, merge, templates, IQ Bible UI and reset form

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 08:13:36 -04:00
nmemmertandClaude Sonnet 5.5 6bb392933a Merge resizable-panel: drag to resize the reader's side panel
Build and Push Docker Image / build-and-push (push) Successful in 4m28s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:41:37 -04:00
nmemmertandClaude Sonnet 5.5 e5812fd4ee Reader: resizable side panel
Drag the left edge of the verse side panel to resize it (arrow keys too,
Shift for bigger steps; double-click resets). The text reflows to make room,
and the width is remembered on this device. Reuses the Study page's resize
handle; the hook now takes the CSS variable to drive.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:37:50 -04:00
nmemmertandClaude Sonnet 5.5 cc2a8cd111 Merge highlights-button: Highlights button in the reader's top bar
Build and Push Docker Image / build-and-push (push) Failing after 19s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:35:12 -04:00
nmemmertandClaude Sonnet 5.5 a9ca647d5a Reader: Highlights button in the top bar
A highlighter button beside Bookmarks opens your highlights (by colour);
Bookmarks now opens the bookmarks. Each shows in the accent colour when you
have some. "H" opens the highlights.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:34:02 -04:00
nmemmertandClaude Sonnet 5.5 22f9700798 Merge tooltips: hover tooltips, clickable cross-references, easier highlighting
Build and Push Docker Image / build-and-push (push) Failing after 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:31:44 -04:00
nmemmertandClaude Sonnet 5.5 7f0256be06 Hover tooltips, clickable cross-references, easier highlighting
- Tooltips on hover and keyboard focus across the app: icon-only buttons show
  their label, existing titles show in the app's style without the browser's
  delay, and the reader's main buttons show shortcut hints ("Search (/)").
  Touch screens are unaffected.
- Reader: cross-references (under verses and in the Cross-refs tab) open the
  passage's text in the side panel without leaving the chapter; "Open chapter"
  goes there when wanted
- Reader: the verse panel now has a Highlight row of colours for one-tap
  whole-verse highlighting (tap again to remove, Clear for all, with Undo);
  selecting words still highlights just those
- Fix: a cross-reference preview was cleared when React ran the panel's
  effect twice (development builds)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:31:27 -04:00
nmemmertandClaude Sonnet 5.5 bb80623c45 Merge login-redesign: login page showing what the app can do
Build and Push Docker Image / build-and-push (push) Failing after 19s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:19:06 -04:00
nmemmertandClaude Sonnet 5.5 46a6e3bf7a Redesign the login page around what the app can do
- Hero ("Read it. Study it. Teach it.") with small previews of the outline
  and the translation comparison
- Four groups of features (Read, Study, Teach, Everywhere), all things the
  app really does
- Sign-in / create-account card stays in view beside the tour on wide
  screens and comes first on a phone; theme switcher before signing in
- Sign-in, account creation, 2FA and error handling unchanged

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:18:50 -04:00
nmemmertandClaude Sonnet 5.5 49a0eb9d0b Merge outline-style: restyled outline share page, print/PDF and Word
Build and Push Docker Image / build-and-push (push) Failing after 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:12:41 -04:00
nmemmertandClaude Sonnet 5.5 57db2310dd Outline: restyle the share page and the printed / Word outline
- Read-only share page: a calm paper-toned page rendered natively (no
  iframe), with outline tabs, Print / PDF, numbered points with accent
  numerals, type tags, notes and verse quotes; works on phones
- Printable / PDF page restyled to match (serif, hanging numerals, tidy
  spacing, footer)
- Word outline restyled: eyebrow + title block, bold main points, accent
  numerals, set-off verse quotes, tidy margins

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:10:42 -04:00
nmemmertandClaude Sonnet 5.5 34d04fd077 Merge outline-share-link: read-only outline links
Build and Push Docker Image / build-and-push (push) Successful in 4m33s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:02:41 -04:00
nmemmertandClaude Sonnet 5.5 d7a1601f80 Outline: share as a read-only link
- Share on the Outline page opens a sheet: create / copy / turn off a
  read-only link, share the link with the system share sheet, or send the
  outline as text
- The link has its own token (separate from the whole-study share link) and
  the public endpoint returns only the outlines, rebuilt field by field:
  no observations, interpretations, applications, scripts or word notes
- Opening the link shows the outline(s) with no sign-in, a switcher between
  the whole-study and per-chunk outlines, and Print / PDF
- New outline_share_token column (added automatically on start)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 16:02:40 -04:00
nmemmertandClaude Sonnet 5.5 2ca79730f1 Merge reader-compare: translation comparison with a button next to Aa
Build and Push Docker Image / build-and-push (push) Successful in 4m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:40:28 -04:00
nmemmertandClaude Sonnet 5.5 5ab416930b Reader: compare button next to Aa
A button beside Aa in the reader's top bar opens a Compare translations
sheet (pick the Bible to compare with, show/hide the differing-word
highlights). It shows as active while a comparison is on. The controls
moved out of the Aa sheet; "C" opens it from the keyboard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:40:24 -04:00
nmemmertandClaude Sonnet 5.5 aa27a5bb18 Reader: show how much another translation differs from the one being read
- Word-level comparison (ignores case and punctuation): differing words are
  marked in both columns of the side-by-side view
- Chapter summary ("NET is 18% different from BSB · 37 of 39 verses differ")
  with links to the most different verses, and a percentage on each verse
- Compare tab on a verse shows each translation's difference from the one
  being read, with the differing words marked
- "Highlight differences" setting; your own highlights and search matches
  always win where they overlap a difference mark

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:36:46 -04:00
nmemmertandClaude Sonnet 5.5 7101be393e Merge outline-richer: per-chunk outlines, richer points, print and share
Build and Push Docker Image / build-and-push (push) Failing after 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:49 -04:00
nmemmertandClaude Sonnet 5.5 820886e3ab Outline: per-chunk outlines, richer points, print and share
- One outline for the whole study plus one per chunk (picker at the top);
  chunk outlines can start from the chunk's verses, each carrying its words
- Points can have a type (illustration, application, quote, question,
  transition), minutes (with a running total), a longer note, verse text
  fetched from a passage, and cross-references / word studies attached from
  the chunk
- Share (system share sheet, or copy), Print / PDF, Copy as text, Word and
  Markdown downloads for any one outline
- All of it included in the Markdown, HTML/PDF and Word study exports,
  with each chunk's outline under that chunk

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:28 -04:00
nmemmertandClaude Sonnet 5.5 d273c8cb3e Merge ui-features-flow: Outline stage, parallel reading, offline, backup, sync conflicts
Build and Push Docker Image / build-and-push (push) Failing after 21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:09:07 -04:00
nmemmertandClaude Sonnet 5.5 b4f038b185 Show the current page in the browser tab title
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:09:06 -04:00
nmemmertandClaude Sonnet 5.5 d13f55faf7 Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:08:08 -04:00
nmemmertandClaude Sonnet 5.5 1edbd364ea Stop the page extending under the status bar; use visible-viewport units
Build and Push Docker Image / build-and-push (push) Failing after 19s
Drops viewport-fit=cover so the iPad/iPhone status bar is its own area and the system's
top-edge blur no longer covers the app bar. Fixed-height study stages are position:fixed so
they cannot scroll the document, and min-h-screen/100vh become dvh units.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:38:51 -04:00
nmemmertandClaude Sonnet 5.5 bb7b5bd6d8 Start every page at the top
Build and Push Docker Image / build-and-push (push) Successful in 4m27s
The scroll position of the previous page used to carry over, which on iPad makes the
system blur the app bar when content is scrolled under it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:32:26 -04:00
nmemmertandClaude Sonnet 5.5 7fc5e4b534 Match the browser/OS chrome to the theme and clear the status bar
Build and Push Docker Image / build-and-push (push) Successful in 4m30s
The app now sets theme-color, color-scheme and the page background from the chosen theme,
so the iPad status-bar area no longer shows a light band over a dark theme. Every top bar
leaves room for the status bar via a safe-top class (replacing a rule that padded every
<header>). Install defaults match the paper theme.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:24:58 -04:00
nmemmertandClaude Sonnet 5.5 ec59f7c82d Import: downloadable Word template for the session list
Build and Push Docker Image / build-and-push (push) Failing after 20s
A blank three-column session list (Session #, Title, Passage) with instructions and
example rows, built in the browser on demand. A test imports the generated file back
through the real importer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:18:24 -04:00
nmemmertandClaude Sonnet 5.5 7a010fcda8 Resizable right column in the Study stage; start on the chosen page without a flash
Build and Push Docker Image / build-and-push (push) Failing after 20s
The Study stage's right column has a drag handle (mouse, arrow keys, double-click
or Home to reset); the width is remembered per device. The app now starts on the
user's chosen home page from the first render instead of showing My Studies
first. Tests wait on page headings so a lazily loaded page can't be confused
with hidden text from the previous one.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:01:56 -04:00
nmemmertandClaude Sonnet 5.5 8c4f54e033 Preferences in Settings and a home page the user chooses
Build and Push Docker Image / build-and-push (push) Failing after 20s
Settings gets a Preferences section: theme, default Bible, the Bible new reading
plans start in, verse layout, commentary, text size, reading width, audio
narrator (the last three stay per device; text size and narrator are now
remembered). A Home page setting (My Studies, Bible Reader or Reading Plans)
decides where the app opens and where every Home button goes; already being
there means My Studies. Back arrows are labelled Home. Tests cover every page
leading back.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:55:07 -04:00
nmemmertandClaude Sonnet 5.5 760c1e568b Sync bookmarks, highlights and display preferences to the account
Build and Push Docker Image / build-and-push (push) Failing after 19s
Bookmarks and text highlights are saved per user and merged across devices
(union on first contact, newer server copy when this device is clean). Theme,
default translation, verse layout, commentary source and reading position sync
too, each with its own timestamp so the newest change to each setting wins.
The Reader never moves under you while it is open. Signing out clears this
device's marks and forgets sync timestamps.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:45:23 -04:00
nmemmertandClaude Sonnet 5.5 297f2c7e84 Fix plan days with no chapters, add a way out of ink mode, harden ntfy reminders
Build and Push Docker Image / build-and-push (push) Failing after 19s
Plans can no longer have more days than chapters (existing ones heal), and
opening a plan never strands the Reader on an empty day. The Reader shows
Done drawing while inking (Escape also exits). Reminders use the user's own
timezone, send within a 3 hour grace window, and Settings has a Send test
notification button.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:34:46 -04:00
nmemmertandClaude Sonnet 5.5 ecb1c701fa Rework app bar, notes layout, Settings/Admin/login restyle
Build and Push Docker Image / build-and-push (push) Failing after 20s
App bar: brand, Studies / Read Bible / Reading Plans, and one account menu
(theme, Settings, Admin, Log out). Study stage: notes get their own box above
the resources in the right column. Settings, Admin and the login page use the
reader themes; add a theme-aware danger colour.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:27:02 -04:00
nmemmertandClaude Sonnet 5.5 31abee0695 Redesign the study side: Outline, Study, Script and Export stages
Build and Push Docker Image / build-and-push (push) Successful in 4m15s
Studies now move through a stage bar in the reader's themes. Outline splits
chunks by clicking between verses (join merges notes) and suggests chunks from
section headings. Study is three panes: outline, passage with an Observe /
Interpret / Apply accordion, and commentary / word study / cross-refs / people
& places for the chunk; original words are added by clicking them in the
interlinear. Script drafts an outline from notes; Export holds files and the
share link. Home, Import and the Pronunciation guide are restyled. Study
projects are BSB only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 10:13:46 -04:00
nmemmertandClaude Sonnet 5.5 b1bc1c8ea4 Reading plans: integrated into the Reader, redesigned, with per-plan translation
Build and Push Docker Image / build-and-push (push) Failing after 19s
Reader: plan strip with a dot per chapter, plan sheet (day picker, chapter
ticks, translation, streak, other plans), end-of-day card that steps through
the day and rolls into the next, and chapter-level auto-marking.
Each plan remembers its own translation (BSB, ESV, NLT, CSB, NASB, NET) and the
Reader follows it while a plan is open. Plans home redesigned in the reader's
themes with a Continue card, in-progress plans, a filterable catalog, and
sheets for details and the custom-plan builder.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:47:51 -04:00
nmemmertandClaude Sonnet 5.5 c11b6a60bf Reader: whole-Bible search in every translation
Build and Push Docker Image / build-and-push (push) Failing after 20s
BSB and NET are indexed in the browser; ESV, NLT, CSB and NASB are searched
through a server proxy (keys stay server-side). Results page in 25 at a time
and jump to the verse. Also fixes a stray trailing comma in CSB chapter text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:25:00 -04:00
nmemmertandClaude Sonnet 5.5 3279dddae5 Reader: ESV chapter audio
Build and Push Docker Image / build-and-push (push) Failing after 19s
The Reader's player follows the translation on screen: ESV plays its own
recording via a server route that resolves the ESV API's audio redirect;
other translations keep the BSB narrators.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:20:15 -04:00
nmemmertandClaude Sonnet 5.5 e50a73d351 Study panel: follow the reader theme colours
Build and Push Docker Image / build-and-push (push) Successful in 4m54s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:12:17 -04:00
nmemmertandClaude Sonnet 5.5 9e722a296d Redesign the Reader; add ESV
Build and Push Docker Image / build-and-push (push) Failing after 20s
New reading surface with paper/sepia/dark themes, slim top and bottom bars,
and sheets for navigation, search, settings, audio and per-verse study tools.
Plan progress is built into the reader. Page-flip mode removed. ESV added via
the server-side proxy (ESV_API_KEY).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 09:11:47 -04:00
nmemmertandClaude Sonnet 5.5 9e8420a1ae Reader: verse study panel (commentaries, people & places) and NET Bible
Build and Push Docker Image / build-and-push (push) Failing after 20s
Adds a per-verse panel with seven HelloAO commentaries and Theographic
people/places/events, and NET as a fifth Reader translation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 08:57:51 -04:00
nmemmertandClaude Sonnet 5.5 84a5bb8838 Reader: note that audio is BSB-only for licensed translations
Build and Push Docker Image / build-and-push (push) Failing after 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 08:42:05 -04:00
nmemmertandClaude Sonnet 5.5 22c80d2bc3 Add NLT, CSB and NASB via api.bible proxy; new app logo and icons
Build and Push Docker Image / build-and-push (push) Failing after 21s
Reader and Reading Plans get a translation picker. Licensed translations are
fetched through a server-side api.bible proxy so the key stays off the client.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 08:37:48 -04:00
nmemmertandClaude Sonnet 5 986d2e9331 Surface reading-plan pull failures too, fix StrictMode false sync
Build and Push Docker Image / build-and-push (push) Failing after 21s
The sign-in pull (GET /api/reading-plans -> merge) had the same silent-
failure gap the push side had before the last fix: a failed fetch just
left local state as-is with zero indication anything was wrong. Moved it
into the hook as pullServerReadingProgress(), which reports failures
through the same error banner/retry the push path already uses.

Also found and fixed a real bug while testing this: the "skip syncing on
the very first render" guard used a boolean ref, which React 18 StrictMode
defeats by double-invoking effects in dev — the second invocation saw the
flag already flipped and fired a spurious sync of stale localStorage data
before the app had even confirmed sign-in, occasionally producing a false
"couldn't sync" banner on fresh loads. Replaced it with a reference-equality
check against the state object captured at mount, which both of
StrictMode's synthetic invocations see identically. Caught this in
useReadingPlans.js's own test run, not manual testing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:47:51 -04:00
nmemmertandClaude Sonnet 5 b14dbb52cc Surface reading plan sync failures instead of failing silently
Build and Push Docker Image / build-and-push (push) Successful in 4m34s
Reading plan sync was truly fire-and-forget: a failed PUT to
/api/reading-plans left the change stuck on that device with no
indication anything was wrong, which is exactly what "doesn't sync
across devices" looks like from the outside.

Moved the sync call out of the setState updater and into a proper effect
keyed on state, so success/failure can be tracked. Both Home and Reading
Plans now show a red banner with the actual error (HTTP status or network
error) and a Retry button when a sync fails, mirroring how study project
sync already reports errors. Verified the full cycle locally: normal sync,
a killed-server failure showing the banner, and Retry recovering once the
server's back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:18:09 -04:00
nmemmertandClaude Sonnet 5 787edcbe73 Connect reading plans to study/audio, cache offline audio, add journal export
Build and Push Docker Image / build-and-push (push) Successful in 4m33s
Reading plan days now offer "Study this" (seeds a new chunk-builder project
with the day's full passage and jumps straight into the Study editor) and
"Listen" (starts book-mode audio at the right chapter via a new
handlePlayBookAudioFrom, avoiding a stale-closure bug the reader-state
approach would hit). Fixed the "Listen to BSB Audio" player only rendering
when a study project existed, leaving Listen with nowhere to show up.

The service worker now caches audio.bible.helloao.org (the actual host
serving narrator audio, not just the text/cross-ref API) and no longer
tries to cache 206 Partial Content responses, which the Cache API rejects
and was throwing unhandled rejections for every range-requested audio file.

Added a reading journal export (Settings > My reading journal): gathers
bookmarks, text highlights, and completed plans, fetches their verse text,
and offers a .docx download or a print-to-PDF page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 13:41:40 -04:00
nmemmertandClaude Sonnet 5 1dd6aef6a5 Add streaks, today view, custom plan editing/chapter ranges, and ntfy reminders
Build and Push Docker Image / build-and-push (push) Failing after 21s
Reading plans now track a cross-plan activity log for streaks, surface a
combined "today's reading" view on both Home and the Reading Plans page,
and let custom plans be edited after creation (including narrowing a book
to a specific chapter range). The reader auto-marks a plan's day read after
a dwell period, active plans that fall behind their pace get a "catch me
up" banner, and a new Settings section wires up daily reading reminders
via ntfy.sh, checked once a minute by a small server-side scheduler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 13:16:27 -04:00
nmemmertandClaude Sonnet 5 600b3d3ab9 Add reading plans: catalog, progress tracking, and custom plans
Replaces the old single-book reading goal with a full Reading Plans page:
9 built-in plans (Whole Bible, OT/NT, topical) with cover photos and
color-themed cards, day-by-day checklists synced to the server per user,
inline "mark today read" in the Bible reader, and user-created plans with
their own book picker and an optional uploaded cover photo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 12:51:28 -04:00
nmemmertandClaude Sonnet 5 f1026dc194 Redesign audio controls into a real media player
Build and Push Docker Image / build-and-push (push) Successful in 5m5s
Adds a shared AudioPlayer component with a seekable progress bar,
big play/pause button, and icon transport controls, replacing the
row of text buttons on both the book and chapter audio players.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:59:42 -04:00
nmemmertandClaude Sonnet 5 b9f9b97cba Add chapter skip and 10s seek controls to audio players
Build and Push Docker Image / build-and-push (push) Successful in 5m42s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:49:32 -04:00
nmemmertandClaude Opus 5.5 5287b471d0 Redesign project header and study navigation
Build and Push Docker Image / build-and-push (push) Failing after 20s
- Header: navigation buttons grouped; Study Guide, Podcast, and
  Pronunciation Guide move into a Tools menu (disabled until a chunk
  exists), set apart from Export and Share by a divider.
- Only one header dropdown is open at a time, and it closes on an
  outside click or Escape.
- Study page: drop the duplicate Prev/Next and counter at the top; the
  fixed bottom bar is the single chunk navigation and now shows a
  readable "Chunk N of M".
- Mobile empty-state hint points to Back to Chunks.
- Tests: Tools menu, one-dropdown-at-a-time, Escape/outside click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:37:29 -04:00
nmemmertandClaude Opus 5.5 33e3f2bdc6 Fix Greek lookup test expecting a single 'faith, belief' field
Build and Push Docker Image / build-and-push (push) Successful in 4m7s
With no gloss-map entry, the English gloss falls back to the KJV
definition, so both English Gloss and Short Definition show it. Assert
both instead of failing on the duplicate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:35:32 -04:00
nmemmertandClaude Opus 5.5 d08c701698 Fix Pronunciation page Speak buttons; tidy App.jsx
Build and Push Docker Image / build-and-push (push) Successful in 7m53s
PronunciationPage still read speakOriginalWord from context after it
moved to utils/wordStudy, so Speak would have thrown. Import it directly.
App.jsx: consolidate imports, update comments, list all pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:04:55 -04:00
nmemmertandClaude Opus 5.5 0f1a9c3d5b Move chunk builder into SetupPage via useChunkBuilder
- The setup form, active chapter tab, verse range selection, typed/bulk/
  cross-chapter chunk creation, reorder, and delete move out of App.
- Setup now opens on the chapter holding the chunk last studied (e.g.
  after Back to Chunks), and the Add Chapter form suggests the chapter
  after the project's last one instead of repeating the current one.
- openNewProject/goHome/resume/import no longer reset setup state by hand;
  SetupPage starts fresh each time it opens.
- Tests: add-chapter default, Back to Chunks chapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 15:01:53 -04:00
nmemmertandClaude Opus 5.5 bf02e071fa Move study editor state into StudyPage and hooks
- StudyPage owns layout/tabs/collapsed sections, tags, DOCX script
  import, chunk navigation, keyboard shortcuts, and the selected-chunk
  values derived from the project.
- useStudyResources: commentary + interlinear for the selected chunk's
  chapter. These now load only on the study page (they previously also
  fetched while on Setup).
- useCrossRefs: typed refs, dataset suggestions, verse text.
- useWordStudy: word entries, Strong's lookups, suggestion picker.
- Fix: study-page messages (word suggestions, DOCX import, lookups) set a
  status that only SetupPage displayed, so they were never seen. They
  now show as a toast on the study page.
- copyVerse moves into ReaderPage.
- Tests: toast, arrow-key navigation, cross-refs, tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:59:22 -04:00
nmemmertandClaude Opus 5.5 4b14b3fbe6 Extract project header, share link, exports, and auth status
- ProjectHeader component replaces the headerButtons JSX in context; it
  owns the export menu and share panel state.
- useShareLink (share status/enable/revoke/copy) and useSharedView
  (?share= read-only view, now used directly by the router).
- utils/projectExport: HTML/Markdown/DOCX download and print-to-PDF.
- AuthStatus component replaces the authStatus JSX in context.
- Pages import wordStudy helpers directly instead of via context.
- Remove dead copyPronunciationGuide (PronunciationPage has its own).
- Tests: Markdown export, enabling a share link, the ?share= view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:56:31 -04:00
nmemmertandClaude Opus 5.5 365fb1b567 Keep draw pen settings inside DrawCanvas
Only the Reader's canvas uses them now. A module-level lastPen keeps the
chosen tool/color/size when Draw mode is toggled off and on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:53:17 -04:00
nmemmertandClaude Opus 5.5 a7e00a76b5 Split Home: local UI state, useProjectLibrary, useReadingPlan
- Search, tag filter, full-text note search, and rename state live in
  HomePage. The sort choice is remembered in localStorage.
- useProjectLibrary owns opening/renaming/deleting projects and the
  server reconcile (auto-restore + newer-on-server list).
- useReadingPlan owns the reading plan shared by Home and Reader.
- Tests: note search, rename, delete, sort memory, reading plan. Test
  setup now resets the storage namespace between tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:52:46 -04:00
nmemmertandClaude Opus 5.5 8bb3b104c8 Move import state into ImportPage; share translation list via hook
The session-list import form, preview, and runEpisodeImport now live in
ImportPage. useAvailableTranslations fetches the translation list once per
visit for both Import and Setup. Drops the never-read importFile state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:50:21 -04:00
nmemmertandClaude Opus 5.5 d7bbbe60de Move word-study helpers into utils/wordStudy
Strong's dictionary loaders, lexicon lookup, gloss helpers, cross-ref
formatting, and speech move out of App. Dictionaries are now cached at
module level (still one download per visit), and a failed download is no
longer cached forever, so the next lookup retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:49:21 -04:00
nmemmertandClaude Opus 5.5 8c55765ae6 Move admin state into AdminPage
Build and Push Docker Image / build-and-push (push) Failing after 21s
All admin state (tab, users, projects, loading/error, preview, reset
result) and the data loader were only used by AdminPage, so they now live
there as local state instead of in App's context. The page loads its data
when it opens (still gated on authUser.isAdmin; the server enforces it too).

Add tests: admin sees users and projects, deleting a user reloads the
list, and non-admins get no Admin button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:46:35 -04:00
nmemmertandClaude Opus 5.5 49ffefed54 Extract auth into hooks/useAuth; move form state into its pages
Build and Push Docker Image / build-and-push (push) Successful in 6m28s
- useAuth owns authUser/authServerDown, the startup session check, and
  signIn/logout, so every sign-in path runs the same follow-up (switch
  local storage to the account, load reader ink). Previously reader ink
  only loaded on a page refresh, not right after logging in.
- Login/2FA form state now lives in AuthPage, and 2FA setup, password
  change, and podcast name state in SettingsPage (23 fewer context
  values; typing in these forms no longer re-renders all of App).
- Add tests for form sign-in, 2FA, session restore + logout, and the
  Settings podcast-name prefill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 14:44:52 -04:00
389 changed files with 33281 additions and 6184 deletions

No files matched your search

+11
View File
@@ -10,3 +10,14 @@ coverage/
.api.pid .api.pid
.api.log .api.log
.DS_Store .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/
+33
View File
@@ -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) ## Production Install (Ubuntu / systemd)
Installs the app as a persistent systemd service under `/opt/study-app`. 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 ## License
Private / personal use. 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.
-68
View File
@@ -1,68 +0,0 @@
# Study App Improvement Suggestions
_Refreshed 2026-07-06 (multiple passes) — items already shipped have been removed; this reflects what's actually still open. Recent additions: multi-user auth with per-account data scoping, TOTP 2FA with backup codes, podcast terminology generalized to "Session" for study-only users (with a configurable podcast/show name), auto-restore on new devices, study templates (richer OIA guiding prompts), PDF/print export, Markdown export, a passage breadcrumb, whole-Bible search, better bookmark UX (always-visible SVG icons + a jump-to panel), read-only share links, and an admin panel (`server/auth.js` `ADMIN_EMAIL`, hardcoded to the site owner's account) showing every user/project with view/delete controls._
## Features
### Study Tools
- **Bible comparison mode** — show two translations side-by-side. `availableTranslations` is already fetched and a translation is already selectable per project, but only one renders at a time — no split/parallel view
- **Verse-level notes** — annotations are still chunk-level only (OIA fields); no way to attach a note to a single verse within a chunk
- **Progress tracking** — no "in progress"/"complete" marker per chunk and no progress bar on the home project card
### Export / Sharing
- **Episode length estimate** — Final Script field exists per chunk; a word-count-based "~X minutes read aloud" estimate would help podcast planning
- **Share link is single-use-case** — one share token per project, all-or-nothing (whole project, all chunks). A per-chunk or per-chapter share might be worth it for someone who only wants to share one episode's notes rather than the whole series
### Chunk Builder (Setup Page)
- **Drag-to-select verses** — still click-then-shift-click; no click-and-drag range selection
- **Auto-chunk** — no "split by paragraph/section" button; every chunk boundary is manual or typed
---
## UX / UI
### Study Page
### Reader
- **Bookmark color picker is still an emoji button** (🎨) — the bookmark/copy icons became proper SVGs, but color-cycling didn't get the same treatment
### Home Page
- Search/filter/sort/rename are all implemented — nothing open here currently
---
## Code Architecture
### State Management
- **`App.jsx` is now ~5,700+ lines** — still one component. Splitting into `pages/HomePage.jsx`, `pages/SetupPage.jsx`, `pages/StudyPage.jsx`, `pages/BibleReaderPage.jsx`, plus extracted hooks (`useProject`, `useGreekLookup`, `useAutosave`) is more valuable now than ever given the continued size growth
### Sync / Persistence
- No rate-limiting on `/api/auth/*` — a determined attacker could brute-force a weak password or 2FA code; worth adding if this is ever reachable beyond a small trusted group
- **Conflict resolution is still last-write-wins** — only `lastEdited` timestamps are compared; no "which version do you want to keep?" UI
- **Offline-first** — still no service worker; app requires a live connection to `bible.helloao.org` for chapter/audio/commentary loads with no cached fallback if that API is down
### Security (OWASP)
- **No input validation on server** — still no max-length/character validation on `id`/`title` in `server/index.js`
- **CORS** — still no CORS headers configured
- **Shared HTML view is sandboxed but not escaped** — `buildExportHtml` interpolates OIA notes into HTML without escaping `<`/`>`/`&`; the public share view mitigates this by rendering in a `sandbox="allow-popups"` iframe (no `allow-scripts`, so injected `<script>`/event handlers can't execute), but the underlying string-building still isn't defense-in-depth. Worth properly HTML-escaping user text in `buildExportHtml` itself
---
## Performance
- **Verse data stored in project JSON** — still true; full verse text is saved per chapter in both localStorage and SQLite
- **Hardcoded external API, no fallback** — audio and commentary both call `bible.helloao.org` directly with no retry UI if the free API is briefly down; whole-Bible search now adds a third hard dependency on this API (`/complete.json`)
---
## Testing
- Missing: autosave debounce behavior, DOCX session-list import, cross-ref auto-suggest, commentary loading, and no coverage yet for the newer share-link/2FA/whole-Bible-search flows (all verified manually in-browser instead)
---
## Developer Experience
- **No ESLint config** — still true; no `.eslintrc*` or `eslint.config.*` in the repo
- **No TypeScript** — still true
- **No `docker-compose.yml`** — still true; Dockerfile exists but no one-command local dev with server + SQLite volume
+32
View File
@@ -0,0 +1,32 @@
# 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
- [x] **Comments between co-teachers.** Comments on a chunk (under "More"), merged between copies and ticked off when dealt with.
Still to do: address a comment to one person, and notify them.
- [ ] **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 NET and other translations that have no matching audio (the BSB and ESV already have narration).
- [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.
+12
View File
@@ -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' });
});
+37
View File
@@ -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/);
});
+51
View File
@@ -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);
}
+34
View File
@@ -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();
});
+36
View File
@@ -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();
});
+9
View File
@@ -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
+43
View File
@@ -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();
});
+39
View File
@@ -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' } },
},
];
+3 -3
View File
@@ -2,14 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bible Study Project</title> <title>Bible Study Project</title>
<!-- PWA manifest --> <!-- PWA manifest -->
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<!-- Theme / status bar --> <!-- Theme / status bar -->
<meta name="theme-color" content="#0f172a" /> <meta name="theme-color" content="#f1ece0" />
<!-- iOS PWA --> <!-- iOS PWA -->
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
@@ -20,7 +20,7 @@
<!-- Fallback icon for browsers --> <!-- Fallback icon for browsers -->
<link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" /> <link rel="icon" type="image/png" sizes="192x192" href="/icon-192.png" />
</head> </head>
<body class="bg-slate-50 text-slate-900"> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
+1319
View File
File diff suppressed because it is too large. Load diff
+11 -1
View File
@@ -14,9 +14,13 @@
"test:run": "vitest run", "test:run": "vitest run",
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage",
"backup": "bash scripts/backup-data.sh", "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": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.3",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.10.0", "better-sqlite3": "^12.10.0",
"compression": "^1.8.2", "compression": "^1.8.2",
@@ -33,6 +37,8 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.64.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
@@ -40,6 +46,10 @@
"@vitest/coverage-v8": "^4.1.7", "@vitest/coverage-v8": "^4.1.7",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"csv-parse": "^6.2.1", "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", "jsdom": "^29.1.1",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"tailwindcss": "^3.4.4", "tailwindcss": "^3.4.4",
+18
View File
@@ -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' } },
],
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 B

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 757 B

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Bible Study Project">
<defs>
<radialGradient id="glow" cx="50%" cy="34%" r="45%">
<stop offset="0" stop-color="#fbbf24" stop-opacity=".35"/>
<stop offset="1" stop-color="#fbbf24" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="512" height="512" rx="112" fill="#0f172a"/>
<rect width="512" height="512" rx="112" fill="url(#glow)"/>
<!-- cross -->
<rect x="238" y="82" width="36" height="170" rx="6" fill="#fbbf24"/>
<rect x="200" y="122" width="112" height="34" rx="6" fill="#fbbf24"/>
<!-- open book -->
<path d="M256 392 C206 356 146 350 80 366 L80 262 C146 246 206 252 256 286 Z" fill="#f8fafc"/>
<path d="M256 392 C306 356 366 350 432 366 L432 262 C366 246 306 252 256 286 Z" fill="#e2e8f0"/>
<path d="M60 384 C136 366 206 374 256 414 C306 374 376 366 452 384" fill="none" stroke="#fbbf24" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M256 286 V392" stroke="#0f172a" stroke-width="6" opacity=".35"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+9 -3
View File
@@ -6,8 +6,8 @@
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#f8fafc", "background_color": "#faf7f0",
"theme_color": "#0f172a", "theme_color": "#f1ece0",
"icons": [ "icons": [
{ {
"src": "/icon-192.png", "src": "/icon-192.png",
@@ -19,7 +19,13 @@
"src": "/icon-512.png", "src": "/icon-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "any maskable" "purpose": "any"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
] ]
} }
+18
View File
@@ -0,0 +1,18 @@
Drop a cover photo here for each built-in reading plan, named exactly:
bible-year.jpg Whole Bible in a Year
bible-90.jpg Whole Bible in 90 Days
nt-90.jpg New Testament in 90 Days
nt-30.jpg New Testament in 30 Days
gospels-30.jpg The Gospels in 30 Days
psalms-30.jpg Psalms in 30 Days
proverbs-31.jpg Proverbs in 31 Days
pentateuch-60.jpg The Pentateuch in 60 Days
wisdom-40.jpg Wisdom Literature in 40 Days
Landscape orientation, ~1200px wide, works best (cards crop to a 16:9 box).
Any plan without a matching file just shows its colorful gradient + icon —
nothing breaks if some are missing.
See src/utils/readingPlans.js -> PIXABAY_SEARCH_LINKS for a Pixabay search
link per plan to help pick one.
Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

+25 -8
View File
@@ -1,6 +1,9 @@
const SHELL = 'bible-shell-v1'; const SHELL = 'bible-shell-v2';
const API = 'bible-api-v1'; const API = 'bible-api-v1';
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life']; // Chapter text, interlinear cross-refs, and (BSB) chapter audio all come from
// these origins — cache them so the reader, reading plans, and audio all keep
// working offline once you've visited a passage before.
const BIBLE_ORIGINS = ['https://bible.helloao.org', 'https://bolls.life', 'https://audio.bible.helloao.org'];
self.addEventListener('install', (e) => { self.addEventListener('install', (e) => {
e.waitUntil( e.waitUntil(
@@ -24,15 +27,23 @@ self.addEventListener('fetch', (e) => {
const { request } = e; const { request } = e;
const url = request.url; const url = request.url;
// Never intercept API routes — let Express handle them // Never intercept this app's own API routes — let Express handle them. (Only same-origin ones: the
if (new URL(url).pathname.startsWith('/api/')) return; // Bible text at bible.helloao.org/api/... must pass through to the offline cache below.)
const { origin, pathname } = new URL(url);
if (origin === self.location.origin && pathname.startsWith('/api/')) return;
// External Bible API: network-first, fall back to cache // External Bible API (text, cross-refs, audio): network-first, fall back to cache.
// Audio elements often issue Range requests, which come back as 206 Partial
// Content — the Cache API throws on those, so only cache full (200) responses.
if (BIBLE_ORIGINS.some(o => url.startsWith(o))) { if (BIBLE_ORIGINS.some(o => url.startsWith(o))) {
e.respondWith( e.respondWith(
fetch(request) fetch(request)
.then(res => { .then(res => {
if (res.ok) caches.open(API).then(c => c.put(request, res.clone())); // Clone *now*: once the page starts reading the body a later clone() throws and nothing is saved.
if (res.status === 200) {
const copy = res.clone();
e.waitUntil(caches.open(API).then(c => c.put(request, copy)).catch(() => {}));
}
return res; return res;
}) })
.catch(() => .catch(() =>
@@ -51,7 +62,10 @@ self.addEventListener('fetch', (e) => {
e.respondWith( e.respondWith(
fetch(request) fetch(request)
.then(res => { .then(res => {
if (res.ok) caches.open(SHELL).then(c => c.put(request, res.clone())); if (res.ok) {
const copy = res.clone();
e.waitUntil(caches.open(SHELL).then(c => c.put(request, copy)).catch(() => {}));
}
return res; return res;
}) })
.catch(() => .catch(() =>
@@ -70,7 +84,10 @@ self.addEventListener('fetch', (e) => {
c.match(request).then(hit => { c.match(request).then(hit => {
if (hit) return hit; if (hit) return hit;
return fetch(request).then(res => { return fetch(request).then(res => {
if (res.ok) c.put(request, res.clone()); if (res.ok) {
const copy = res.clone();
e.waitUntil(c.put(request, copy).catch(() => {}));
}
return res; return res;
}); });
}) })
+15
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
[]
+35
View File
@@ -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`;
+33
View File
@@ -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']);
});
+7
View File
@@ -92,3 +92,10 @@ export async function consumeBackupCode(submitted, hashes) {
export function generateTemporaryPassword() { export function generateTemporaryPassword() {
return randomBytes(6).toString('hex'); 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, '');
+62
View File
@@ -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();
}
+212
View File
@@ -0,0 +1,212 @@
// Proxy for licensed translations (NLT, CSB, NASB) served by api.bible.
// The API key stays on the server (API_BIBLE_KEY, loaded from .env if present);
// the browser only ever talks to /api/bible/...
import { existsSync, readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Minimal .env loader so the key doesn't have to be exported in every launcher
// (setup.sh, systemd, docker). Real environment variables win.
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 API_BASE = 'https://rest.api.bible/v1';
// Canonical book order (index + 1 = the book number the ESV API uses in ranges).
const BOOK_ORDER = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV'];
// ESV search returns "Psalm 23:1"-style references; map the names back to abbreviations.
const ESV_NAMES = ['Genesis','Exodus','Leviticus','Numbers','Deuteronomy','Joshua','Judges','Ruth','1 Samuel','2 Samuel','1 Kings','2 Kings','1 Chronicles','2 Chronicles','Ezra','Nehemiah','Esther','Job','Psalm','Proverbs','Ecclesiastes','Song of Solomon','Isaiah','Jeremiah','Lamentations','Ezekiel','Daniel','Hosea','Joel','Amos','Obadiah','Jonah','Micah','Nahum','Habakkuk','Zephaniah','Haggai','Zechariah','Malachi','Matthew','Mark','Luke','John','Acts','Romans','1 Corinthians','2 Corinthians','Galatians','Ephesians','Philippians','Colossians','1 Thessalonians','2 Thessalonians','1 Timothy','2 Timothy','Titus','Philemon','Hebrews','James','1 Peter','2 Peter','1 John','2 John','3 John','Jude','Revelation'];
const ABBREV_BY_NAME = new Map(ESV_NAMES.map((n, i) => [n.toLowerCase(), BOOK_ORDER[i]]));
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());
return bookAbbrev ? { bookAbbrev, chapter: Number(m[2]), verse: Number(m[3]) } : null;
}
export const LICENSED_TRANSLATIONS = {
ESV: { provider: 'esv', name: 'English Standard Version' },
NLT: { provider: 'api.bible', bibleId: 'd6e14a625393b4da-01', name: 'New Living Translation' },
CSB: { provider: 'api.bible', bibleId: 'a556c5305ee15c3f-01', name: 'Christian Standard Bible' },
NASB: { provider: 'api.bible', bibleId: 'b8ee27bcd1cae43a-01', name: 'New American Standard Bible (1995)' },
};
const CACHE_TTL = 24 * 60 * 60 * 1000;
const CACHE_MAX = 1000;
// Crossway limits how much ESV text an app may store, so ESV chapters are only
// held briefly and in small numbers (enough to absorb repeat loads).
const ESV_CACHE_TTL = 60 * 60 * 1000;
const ESV_CACHE_MAX = 20;
const cache = new Map(); // "NLT/PSA/23" -> { at, payload }
// api.bible returns plain text with inline "[12]" verse markers, poetry line
// breaks, and stray "#" / nbsp characters. Split it back into { number, text }.
export function parseChapterText(content) {
const clean = String(content ?? '')
.replace(/\u00a0/g, ' ')
.replace(/#/g, '');
const parts = clean.split(/\[(\d+)\]/);
const verses = [];
for (let i = 1; i < parts.length; i += 2) {
const text = parts[i + 1].replace(/\s+/g, ' ').trim().replace(/\s+,$/, '');
if (text) verses.push({ number: Number(parts[i]), text });
}
return verses;
}
export function registerBibleRoutes(app, requireAuth) {
app.get('/api/bible/translations', requireAuth, (_req, res) => {
res.json(Object.entries(LICENSED_TRANSLATIONS).map(([id, t]) => ({
id, name: t.name, available: Boolean(t.provider === 'esv' ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY),
})));
});
// Whole-Bible search for the licensed translations, proxied so the keys stay
// server-side. Results are { bookAbbrev, chapter, verse, text }; nothing is cached.
const SEARCH_PAGE_SIZE = 25;
app.get('/api/bible/:translation/search', requireAuth, async (req, res) => {
const id = req.params.translation.toUpperCase();
const translation = LICENSED_TRANSLATIONS[id];
const q = String(req.query.q ?? '').trim();
const page = Math.max(1, Math.min(200, parseInt(req.query.page, 10) || 1));
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
if (q.length < 2 || q.length > 100) return res.status(400).json({ error: 'Search must be 2 to 100 characters.' });
const isEsv = translation.provider === 'esv';
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
try {
let results;
let total;
if (isEsv) {
const qs = new URLSearchParams({ q, 'page-size': String(SEARCH_PAGE_SIZE), page: String(page) });
const upstream = await fetch(`https://api.esv.org/v3/passage/search/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
const body = await upstream.json();
total = body.total_results ?? 0;
results = (body.results ?? []).map((r) => {
const ref = parseEsvReference(r.reference);
return ref && { ...ref, text: String(r.content ?? '').replace(/<[^>]+>/g, '').trim() };
}).filter(Boolean);
} else {
const qs = new URLSearchParams({ query: q, limit: String(SEARCH_PAGE_SIZE), offset: String((page - 1) * SEARCH_PAGE_SIZE), sort: 'canonical' });
const upstream = await fetch(`${API_BASE}/bibles/${translation.bibleId}/search?${qs}`, { headers: { 'api-key': apiKey } });
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
const body = await upstream.json();
total = body.data?.total ?? 0;
results = (body.data?.verses ?? []).map((v) => ({
bookAbbrev: v.bookId,
chapter: Number(String(v.chapterId).split('.')[1]),
verse: Number(String(v.id).split('.')[2]),
text: String(v.text ?? '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(),
})).filter((r) => r.bookAbbrev && r.chapter && r.verse);
}
res.json({ translation: id, query: q, page, total, hasMore: page * SEARCH_PAGE_SIZE < total, results });
} catch {
res.status(502).json({ error: 'Could not reach the Bible provider.' });
}
});
// ESV chapter audio (read by David Cochran Heath). The ESV API answers with a
// redirect to a public mp3; hand that URL to the browser's <audio> element.
app.get('/api/bible/ESV/audio/:book/:chapter', requireAuth, async (req, res) => {
const { book, chapter } = req.params;
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
if (!bookNum || !/^\d{1,3}$/.test(chapter)) return res.status(400).json({ error: 'Invalid book or chapter.' });
if (!process.env.ESV_API_KEY) return res.status(503).json({ error: 'ESV_API_KEY is not configured on the server.' });
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
try {
const upstream = await fetch(`https://api.esv.org/v3/passage/audio/?q=${prefix}001-${prefix}999`, {
headers: { Authorization: `Token ${process.env.ESV_API_KEY}` }, redirect: 'manual',
});
const url = upstream.headers.get('location');
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
if (!url) return res.status(502).json({ error: 'ESV audio is not available for this chapter.' });
res.json({ url });
} catch {
res.status(502).json({ error: 'Could not reach the ESV audio service.' });
}
});
app.get('/api/bible/:translation/:book/:chapter', requireAuth, async (req, res) => {
const translation = LICENSED_TRANSLATIONS[req.params.translation.toUpperCase()];
const { book, chapter } = req.params;
if (!translation) return res.status(404).json({ error: 'Unknown translation.' });
if (!/^[1-3A-Z]{3}$/i.test(book) || !/^\d{1,3}$/.test(chapter)) {
return res.status(400).json({ error: 'Invalid book or chapter.' });
}
const id = req.params.translation.toUpperCase();
const isEsv = translation.provider === 'esv';
const apiKey = isEsv ? process.env.ESV_API_KEY : process.env.API_BIBLE_KEY;
if (!apiKey) return res.status(503).json({ error: `${isEsv ? 'ESV_API_KEY' : 'API_BIBLE_KEY'} is not configured on the server.` });
const key = `${id}/${book.toUpperCase()}/${chapter}`;
const ttl = isEsv ? ESV_CACHE_TTL : CACHE_TTL;
const hit = cache.get(key);
if (hit && Date.now() - hit.at < ttl) return res.json(hit.payload);
try {
let payload;
if (isEsv) {
const bookNum = BOOK_ORDER.indexOf(book.toUpperCase()) + 1;
if (!bookNum) return res.status(400).json({ error: 'Invalid book.' });
const prefix = String(bookNum).padStart(2, '0') + String(Number(chapter)).padStart(3, '0');
const qs = new URLSearchParams({
q: `${prefix}001-${prefix}999`,
'include-passage-references': 'false', 'include-verse-numbers': 'true', 'include-first-verse-numbers': 'true',
'include-footnotes': 'false', 'include-headings': 'false', 'include-short-copyright': 'false',
'include-passage-horizontal-lines': 'false', 'include-heading-horizontal-lines': 'false',
});
const upstream = await fetch(`https://api.esv.org/v3/passage/text/?${qs}`, { headers: { Authorization: `Token ${apiKey}` } });
if (upstream.status === 429) return res.status(429).json({ error: 'ESV rate limit reached. Try again in a minute.' });
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
const body = await upstream.json();
const verses = parseChapterText(body.passages?.[0]);
if (verses.length === 0) return res.status(404).json({ error: 'Chapter not found.' });
payload = { translation: id, verses, copyright: '', fumsToken: null };
} else {
const qs = new URLSearchParams({
'content-type': 'text', 'include-notes': 'false', 'include-titles': 'false',
'include-chapter-numbers': 'false', 'include-verse-numbers': 'true', 'include-verse-spans': 'false',
});
const upstream = await fetch(
`${API_BASE}/bibles/${translation.bibleId}/chapters/${book.toUpperCase()}.${chapter}?${qs}`,
{ headers: { 'api-key': apiKey } },
);
if (upstream.status === 404) return res.status(404).json({ error: 'Chapter not found.' });
if (!upstream.ok) return res.status(502).json({ error: `Bible provider returned ${upstream.status}.` });
const body = await upstream.json();
const verses = parseChapterText(body.data?.content);
if (verses.length === 0) return res.status(502).json({ error: 'Bible provider returned no verses.' });
payload = { translation: id, verses, copyright: body.data?.copyright ?? '', fumsToken: body.meta?.fumsToken ?? null };
}
const max = isEsv ? ESV_CACHE_MAX : CACHE_MAX;
if (cache.size >= CACHE_MAX + ESV_CACHE_MAX) cache.delete(cache.keys().next().value);
if (isEsv) {
const esvKeys = [...cache.keys()].filter((k) => k.startsWith('ESV/'));
if (esvKeys.length >= max) cache.delete(esvKeys[0]);
}
cache.set(key, { at: Date.now(), payload });
res.json(payload);
} catch {
res.status(502).json({ error: 'Could not reach the Bible provider.' });
}
});
}
+23
View File
@@ -0,0 +1,23 @@
import { test, expect } from 'vitest';
import { parseChapterText, parseEsvReference } from './bibleApi.js';
test('parseChapterText splits inline verse markers and cleans provider artifacts', () => {
const text = ' [1] The Lord is my shepherd;\n I have what I need.\n [2] He lets me lie down #— in pastures, ,\n';
expect(parseChapterText(text)).toEqual([
{ number: 1, text: 'The Lord is my shepherd; I have what I need.' },
{ number: 2, text: 'He lets me lie down — in pastures,' },
]);
});
test('parseChapterText ignores headings before the first verse', () => {
expect(parseChapterText('Psalm 23\n\nA Psalm of David.\n\n[1] The LORD is my shepherd;')).toEqual([
{ number: 1, text: 'The LORD is my shepherd;' },
]);
});
test('parseEsvReference maps ESV book names back to abbreviations', () => {
expect(parseEsvReference('Psalm 23:1')).toEqual({ bookAbbrev: 'PSA', chapter: 23, verse: 1 });
expect(parseEsvReference('1 John 3:16-18')).toEqual({ bookAbbrev: '1JN', chapter: 3, verse: 16 });
expect(parseEsvReference('Song of Solomon 2:4')).toEqual({ bookAbbrev: 'SNG', chapter: 2, verse: 4 });
expect(parseEsvReference('Not a book 1:1')).toBeNull();
});
+613 -14
View File
@@ -1,5 +1,6 @@
import Database from 'better-sqlite3'; 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 { join, dirname } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -9,6 +10,25 @@ const DB_PATH = join(DATA_DIR, 'projects.db');
let 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 // Init — create tables if they don't exist, migrate older schemas
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -40,6 +60,18 @@ export function initDb() {
sess TEXT NOT NULL, sess TEXT NOT NULL,
expires INTEGER 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. // Older databases predate multi-user support — add the ownership column.
@@ -63,12 +95,133 @@ export function initDb() {
db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT'); db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT');
} }
// Older databases predate ntfy.sh daily reading reminders.
if (!userCols.some((c) => c.name === 'ntfy_url')) {
db.exec(`
ALTER TABLE users ADD COLUMN ntfy_url TEXT;
ALTER TABLE users ADD COLUMN ntfy_topic TEXT;
ALTER TABLE users ADD COLUMN reminder_time TEXT;
ALTER TABLE users ADD COLUMN reminder_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN last_reminder_date TEXT;
`);
}
// 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');
}
// Older databases predate shareable read-only links. // Older databases predate shareable read-only links.
if (!projectCols.some((c) => c.name === 'share_token')) { if (!projectCols.some((c) => c.name === 'share_token')) {
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT'); db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
} }
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'); 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 // Reader ink — one row per user + book + chapter, stored as JSON
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS reader_ink ( CREATE TABLE IF NOT EXISTS reader_ink (
@@ -81,6 +234,35 @@ export function initDb() {
); );
`); `);
// Display preferences (theme, default translation, layout, ...) — one row per user.
db.exec(`
CREATE TABLE IF NOT EXISTS user_prefs (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Reader bookmarks + text highlights — one row per user (small JSON), so they follow the
// user across devices. updated_at lets a device tell whether the server copy is newer.
db.exec(`
CREATE TABLE IF NOT EXISTS reader_marks (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// Reading plan progress — one row per user, holding the whole list of
// in-progress/completed plans as JSON (small; no need to normalize).
db.exec(`
CREATE TABLE IF NOT EXISTS reading_progress (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
console.log(`SQLite database ready at ${DB_PATH}`); console.log(`SQLite database ready at ${DB_PATH}`);
} }
@@ -115,6 +297,10 @@ function parseUserRow(row) {
return { return {
...row, ...row,
totpEnabled: !!row.totpEnabled, totpEnabled: !!row.totpEnabled,
reminderEnabled: !!row.reminderEnabled,
votdEnabled: !!row.votdEnabled,
prayerEnabled: !!row.prayerEnabled,
loginAlerts: !!row.loginAlerts,
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [], backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
}; };
} }
@@ -122,7 +308,11 @@ function parseUserRow(row) {
const USER_SELECT = ` const USER_SELECT = `
SELECT id, email, password_hash AS passwordHash, created_at AS createdAt, SELECT id, email, password_hash AS passwordHash, created_at AS createdAt,
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw, totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
podcast_name AS podcastName podcast_name AS podcastName,
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz,
votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate,
prayer_enabled AS prayerEnabled, prayer_time AS prayerTime, login_alerts AS loginAlerts
FROM users FROM users
`; `;
@@ -153,6 +343,141 @@ export function setPodcastName(userId, podcastName) {
db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId); db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId);
} }
// ---------------------------------------------------------------------------
// Daily reading reminders (sent via ntfy.sh — see server/reminders.js)
// ---------------------------------------------------------------------------
/** Saves a user's ntfy reminder settings ({ ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }). */
export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime, reminderEnabled, reminderTz }) {
db.prepare(`
UPDATE users SET ntfy_url = ?, ntfy_topic = ?, reminder_time = ?, reminder_enabled = ?, reminder_tz = ?
WHERE id = ?
`).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(`
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz
FROM users
WHERE reminder_enabled = 1 AND ntfy_topic IS NOT NULL AND reminder_time IS NOT NULL
`).all();
}
/** Marks that a user's reminder was sent today, so it isn't sent twice. */
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). */ /** Rewrites the remaining backup-code hashes after one is used (single-use codes). */
export function setBackupCodeHashes(userId, backupCodeHashes) { export function setBackupCodeHashes(userId, backupCodeHashes) {
db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId); db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId);
@@ -172,6 +497,51 @@ export function getAllReaderInk(userId) {
); );
} }
/** The user's synced preferences: { key: { v, at } } (empty when none saved yet). */
export function getUserPrefs(userId) {
const row = db.prepare('SELECT data FROM user_prefs WHERE user_id = ?').get(userId);
return row ? JSON.parse(row.data) : {};
}
/** Replaces the user's stored preferences with `prefs` ({ key: { v, at } }). */
export function setUserPrefs(userId, prefs) {
db.prepare(`
INSERT INTO user_prefs (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(prefs), Date.now());
}
/** 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: [], bookmarkNotes: {}, updatedAt: 0 };
const data = JSON.parse(row.data);
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, 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, 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. */ /** Upserts the ink strokes for one reader page. */
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) { export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
db.prepare(` db.prepare(`
@@ -182,6 +552,30 @@ export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now()); `).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
} }
// ---------------------------------------------------------------------------
// Reading plan progress — cross-device sync for started/completed reading plans
// ---------------------------------------------------------------------------
/** Returns the user's reading-plan data ({ progress, customPlans }, parsed JSON), or an empty shell. */
export function getReadingProgress(userId) {
const row = db.prepare('SELECT data FROM reading_progress WHERE user_id = ?').get(userId);
if (!row) return { progress: [], customPlans: [] };
try {
return JSON.parse(row.data);
} catch {
return { progress: [], customPlans: [] };
}
}
/** Overwrites the user's whole reading-plan progress list. */
export function setReadingProgress(userId, progress) {
db.prepare(`
INSERT INTO reading_progress (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(progress), Date.now());
}
/** /**
* Assigns any pre-existing, unowned projects (from before multi-user support) * Assigns any pre-existing, unowned projects (from before multi-user support)
* to the given user. Intended to run once, right after the first account is created. * to the given user. Intended to run once, right after the first account is created.
@@ -199,20 +593,33 @@ export function claimOrphanProjects(userId) {
* Does NOT return full project data to keep the response small. * Does NOT return full project data to keep the response small.
*/ */
export function getAllProjects(userId) { export function getAllProjects(userId) {
// the studies you own, plus the ones another account has invited you to edit
const rows = db.prepare(` const rows = db.prepare(`
SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary,
FROM projects CASE WHEN p.user_id = ? THEN 0 ELSE 1 END AS shared, u.email AS ownerEmail
WHERE user_id = ? FROM projects p
ORDER BY last_edited DESC LEFT JOIN users u ON u.id = p.user_id
`).all(userId); WHERE p.user_id = ? OR p.id IN (SELECT project_id FROM collaborators WHERE user_id = ?)
return rows; 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. * Returns a single full project by id, scoped to userId, or null if not found/not owned.
*/ */
export function getProject(id, userId) { 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; if (!row) return null;
try { try {
return JSON.parse(row.data); return JSON.parse(row.data);
@@ -221,17 +628,27 @@ 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) {
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;
}
/** /**
* Insert or replace a project owned by userId. * Insert or replace a project owned by userId.
* Returns the summary, or null if the id already belongs to a different user. * Returns the summary, or null if the id already belongs to a different user.
*/ */
export function upsertProject(project, userId) { export function upsertProject(project, userId) {
const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id); 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; 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 chapterSummary = buildSummary(project);
const updated = { ...project, lastEdited }; const updated = { ...project, lastEdited };
@@ -248,11 +665,101 @@ export function upsertProject(project, userId) {
return { id: project.id, title: project.title, lastEdited, chapterSummary }; 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. * Delete a project by id, scoped to userId. No-op if not found/not owned.
*/ */
export function deleteProject(id, userId) { 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 };
})();
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -273,7 +780,63 @@ export function setShareToken(id, userId, token) {
/** Revokes a project's share link, scoped to userId. */ /** Revokes a project's share link, scoped to userId. */
export function clearShareToken(id, 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 ----
export function getOutlineShareToken(id, userId) {
const row = db.prepare('SELECT outline_share_token AS t FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
return row?.t ?? null;
}
export function setOutlineShareToken(id, userId, token) {
return db.prepare('UPDATE projects SET outline_share_token = ? WHERE id = ? AND user_id = ?').run(token, id, userId).changes > 0;
}
export function clearOutlineShareToken(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). */
export function getProjectByOutlineToken(token) {
const row = db.prepare('SELECT data FROM projects WHERE outline_share_token = ?').get(token);
if (!row) return null;
try { return JSON.parse(row.data); } catch { return null; }
} }
/** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */ /** Public lookup: returns the full project data for a valid share token, or null. No ownership check — this is the point. */
@@ -304,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. */ /** 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) { 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. */ /** Every project across every user, with the owner's email, for the admin projects list. */
@@ -331,6 +900,7 @@ export function adminGetProject(id) {
/** Deletes any project by id, regardless of owner. */ /** Deletes any project by id, regardless of owner. */
export function adminDeleteProject(id) { export function adminDeleteProject(id) {
db.prepare('DELETE FROM project_versions WHERE project_id = ?').run(id);
db.prepare('DELETE FROM projects WHERE id = ?').run(id); db.prepare('DELETE FROM projects WHERE id = ?').run(id);
} }
@@ -392,3 +962,32 @@ export function destroyAllSessionsForUser(userId, exceptSid = null) {
const placeholders = staleSids.map(() => '?').join(','); const placeholders = staleSids.map(() => '?').join(',');
db.prepare(`DELETE FROM sessions WHERE sid IN (${placeholders})`).run(...staleSids); 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);
}
+185
View File
@@ -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 });
});
}
+171
View File
@@ -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' });
});
+37
View File
@@ -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);
+50
View File
@@ -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();
});
+50 -476
View File
@@ -1,60 +1,64 @@
import express from 'express'; import express from 'express';
import compression from 'compression'; import compression from 'compression';
import session from 'express-session'; import session from 'express-session';
import QRCode from 'qrcode'; import { randomBytes } from 'crypto';
import { randomUUID } from 'crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { import { initDb, dataDir } from './db.js';
initDb, getAllProjects, getProject, upsertProject, deleteProject,
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
getShareToken, setShareToken, clearShareToken, getProjectByShareToken,
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
setUserPassword, destroyAllSessionsForUser,
getAllReaderInk, setReaderInkPage,
} from './db.js';
import { SqliteSessionStore } from './sessionStore.js'; import { SqliteSessionStore } from './sessionStore.js';
import { import { startReminderScheduler } from './reminders.js';
isValidEmail, isValidPassword, hashPassword, verifyPassword, requireAuth, requireAdmin, isAdminEmail, import { startBackupScheduler } from './backups.js';
generateTotpSecret, totpKeyUri, verifyTotpToken, import { registerBibleRoutes } from './bibleApi.js';
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword, import { registerIqBibleRoutes } from './iqbible.js';
} from './auth.js'; import { registerStrongsRoutes } from './strongs.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 __dirname = dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const isProd = process.env.NODE_ENV === 'production'; const isProd = process.env.NODE_ENV === 'production';
if (isProd && !process.env.SESSION_SECRET) { // Session cookies are signed with SESSION_SECRET. When it isn't set (setup.sh doesn't set one), a random secret
console.warn('WARNING: SESSION_SECRET is not set. Set it to a long random string in production.'); // 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). // Trust the reverse proxy (needed for secure cookies to work behind nginx/etc).
app.set('trust proxy', 1); 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. // Gzip responses — the interlinear JSON files shrink ~10x, and JS/CSS/API payloads too.
app.use(compression()); app.use(compression());
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
app.use(session({ app.use(session({
store: new SqliteSessionStore(), store: new SqliteSessionStore(),
secret: process.env.SESSION_SECRET || 'dev-only-secret-change-me', secret: sessionSecret(),
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
rolling: true, rolling: true,
@@ -76,448 +80,16 @@ app.get('/api/health', (_req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// --------------------------------------------------------------------------- registerAuthRoutes(app);
// Auth registerAccountRoutes(app);
// --------------------------------------------------------------------------- registerProjectRoutes(app);
registerReaderRoutes(app);
app.post('/api/auth/register', async (req, res) => { registerBibleRoutes(app, requireAuth);
try { registerIqBibleRoutes(app, requireAuth);
if (checkAuthRateLimit(req.ip)) return res.status(429).json({ error: 'Too many attempts. Please wait 15 minutes.' }); registerStrongsRoutes(app, requireAuth);
const email = String(req.body?.email ?? '').trim().toLowerCase(); registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany: TOO_MANY });
const password = String(req.body?.password ?? ''); registerSharingRoutes(app);
registerAdminRoutes(app);
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) });
});
} 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({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
});
} 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({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) });
});
} 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({
id: user.id, email: user.email, totpEnabled: user.totpEnabled,
podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email),
});
});
// ---------------------------------------------------------------------------
// 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.' });
}
});
// ---------------------------------------------------------------------------
// 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.' });
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.' });
}
});
// ---------------------------------------------------------------------------
// 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.' });
}
});
// 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.' });
}
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Serve Vite production build (when NODE_ENV=production) // Serve Vite production build (when NODE_ENV=production)
@@ -545,6 +117,8 @@ if (isProd) {
// Start // Start
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
initDb(); initDb();
startReminderScheduler();
startBackupScheduler();
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Bible Study API running on http://localhost:${PORT}`); console.log(`Bible Study API running on http://localhost:${PORT}`);
if (isProd) { if (isProd) {
+160
View File
@@ -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.' });
}
});
}
+61
View File
@@ -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; }
});
+23
View File
@@ -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.' };
+59
View File
@@ -0,0 +1,59 @@
// What a read-only outline link shows: the outlines of a study (the whole-study one and each chunk's)
// and nothing else — no observations, interpretations, applications, scripts or word notes. The
// outline data comes from the browser, so it is rebuilt field by field instead of passed through.
const MAX_NODES = 2000;
const MAX_DEPTH = 8;
const KINDS = new Set(['', 'illustration', 'application', 'quote', 'question', 'transition']);
const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
function cleanTree(nodes, depth, counter) {
if (!Array.isArray(nodes) || depth > MAX_DEPTH) return [];
const out = [];
for (const n of nodes) {
if (!n || typeof n !== 'object' || counter.n >= MAX_NODES) continue;
counter.n += 1;
const minutes = Number.isFinite(n.minutes) && n.minutes >= 0 && n.minutes <= 600 ? n.minutes : null;
out.push({
id: str(n.id, 64) || `n${counter.n}`,
text: str(n.text, 2000),
ref: str(n.ref, 200),
kind: KINDS.has(n.kind) ? n.kind : '',
minutes,
note: str(n.note, 8000),
scripture: n.scripture && typeof n.scripture.text === 'string'
? { ref: str(n.scripture.ref, 200), text: str(n.scripture.text, 6000), translation: str(n.scripture.translation, 12) }
: null,
attachments: (Array.isArray(n.attachments) ? n.attachments : []).slice(0, 50)
.filter((a) => a && typeof a === 'object')
.map((a) => ({ id: str(a.id, 100), type: a.type === 'word' ? 'word' : 'xref', label: str(a.label, 200), ...(a.detail ? { detail: str(a.detail, 500) } : {}) })),
children: cleanTree(n.children, depth + 1, counter),
});
}
return out;
}
const chunkLabel = (chapters, chapterIndex, chunk) => {
const ch = chapters[chapterIndex];
const next = chapters[chapterIndex + 1];
if (Number.isInteger(chunk.spilloverEndVerse) && next && next.bookAbbrev === ch.bookAbbrev) {
return `${ch.book} ${ch.chapter}:${chunk.startVerse}–${next.chapter}:${chunk.spilloverEndVerse}`;
}
return chunk.startVerse === chunk.endVerse ? `${ch.book} ${ch.chapter}:${chunk.startVerse}` : `${ch.book} ${ch.chapter}:${chunk.startVerse}–${chunk.endVerse}`;
};
// -> { title, translation, outlines: [{ scope, label, tree }] } with only the outlines that have points
export function buildOutlinePayload(project) {
const counter = { n: 0 };
const outlines = [];
const whole = cleanTree(project?.outline, 0, counter);
if (whole.length > 0) outlines.push({ scope: 'study', label: 'Whole study', tree: whole });
const chapters = Array.isArray(project?.chapters) ? project.chapters : [];
chapters.forEach((ch, chapterIndex) => {
for (const chunk of Array.isArray(ch?.chunks) ? ch.chunks : []) {
const tree = cleanTree(chunk?.outline, 0, counter);
if (tree.length > 0) outlines.push({ scope: str(chunk.id, 64), label: str(chunkLabel(chapters, chapterIndex, chunk), 200), tree });
}
});
return { title: str(project?.title, 500), translation: str(project?.translation, 12) || 'BSB', outlines };
}
+42
View File
@@ -0,0 +1,42 @@
import { expect, test } from 'vitest';
import { buildOutlinePayload } from './outlineShare.js';
const node = (id, extra = {}) => ({ id, text: id, ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [], ...extra });
const project = {
title: 'Titus', translation: 'BSB',
outline: [node('a', { minutes: 5, kind: 'illustration', children: [node('a1')] })],
chapters: [{
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'secret verse text' }],
chunks: [
{ id: 'c1', startVerse: 1, endVerse: 4, observation: 'PRIVATE OBSERVATION', finalScript: 'PRIVATE SCRIPT', greekWords: [{ lexeme: 'x' }], outline: [node('b')] },
{ id: 'c2', startVerse: 5, endVerse: 5, observation: 'more private', outline: [] },
],
}],
};
test('shares the outlines only, with each chunk labelled by its passage', () => {
const p = buildOutlinePayload(project);
expect(p.title).toBe('Titus');
expect(p.outlines.map((o) => [o.scope, o.label])).toEqual([['study', 'Whole study'], ['c1', 'Titus 1:1–4']]);
expect(p.outlines[0].tree[0]).toMatchObject({ text: 'a', minutes: 5, kind: 'illustration' });
expect(p.outlines[0].tree[0].children[0].text).toBe('a1');
});
test('nothing from the rest of the study leaks into what is shared', () => {
const json = JSON.stringify(buildOutlinePayload(project));
for (const secret of ['PRIVATE OBSERVATION', 'PRIVATE SCRIPT', 'secret verse text', 'more private', 'greekWords']) expect(json).not.toContain(secret);
});
test('unknown fields on a point are dropped, and bad values are cleaned', () => {
const p = buildOutlinePayload({ title: 'T', outline: [{ id: 'x', text: 'hi', evil: '<script>', kind: 'bogus', minutes: -4, scripture: { text: 5 }, attachments: [{ type: 'word', label: 'L' }, null], children: 'nope' }], chapters: [] });
expect(p.outlines[0].tree[0]).toEqual({
id: 'x', text: 'hi', ref: '', kind: '', minutes: null, note: '', scripture: null,
attachments: [{ id: '', type: 'word', label: 'L' }], children: [],
});
});
test('a study with no outlines shares none, and a huge outline is capped', () => {
expect(buildOutlinePayload({ title: 'T', chapters: [] }).outlines).toEqual([]);
const big = { title: 'T', outline: Array.from({ length: 3000 }, (_, i) => node(`n${i}`)), chapters: [] };
expect(buildOutlinePayload(big).outlines[0].tree).toHaveLength(2000);
});
+63
View File
@@ -0,0 +1,63 @@
// 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(),
status: raw.status === 'waiting' || raw.status === 'partial' ? raw.status : '',
pinned: raw.pinned === true,
rank: int(raw.rank, 0, 100_000) ? raw.rank : null,
};
}
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);
+44
View File
@@ -0,0 +1,44 @@
// @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']);
});
test('pin, rank and status are kept when valid and dropped when not', () => {
expect(cleanPrayer(p('a', { pinned: true, rank: 2, status: 'partial' }), NOW)).toMatchObject({ pinned: true, rank: 2, status: 'partial' });
expect(cleanPrayer(p('a', { pinned: 'yes', rank: -4, status: 'bogus' }), NOW)).toMatchObject({ pinned: false, rank: null, status: '' });
});
+72
View File
@@ -0,0 +1,72 @@
// A user's synced display preferences: theme, default Reader translation, verse layout, the
// commentary they last chose, and where they left off in the Reader. Each preference carries
// its own timestamp ({ v, at }) so the most recent change to each one wins, device by device.
const ENUMS = {
theme: ['paper', 'sepia', 'dark'],
translation: ['BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'],
layout: ['flow', 'verses'],
startPage: ['studies', 'reader', 'plans'], // where the app opens and where Home buttons go
planTranslation: ['same', 'BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'], // which Bible new reading plans start in
commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'],
};
const DAY_MS = 86_400_000;
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
// A cleaned value, or undefined when it isn't acceptable for that preference.
export function cleanPrefValue(key, value) {
if (ENUMS[key]) return ENUMS[key].includes(value) ? value : undefined;
if (key === 'colorLabels') {
// What each highlight colour means to the reader: { "#fef08a": "Promises", ... }
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const out = {};
for (const [color, label] of Object.entries(value).slice(0, 12)) {
if (COLOR.test(color) && typeof label === 'string' && label.trim()) out[color] = label.trim().slice(0, 30);
}
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();
if (!/^[1-3A-Z]{3}$/.test(book) || !Number.isInteger(value.chapter) || value.chapter < 1 || value.chapter > 200) return undefined;
return { book, chapter: value.chapter };
}
return undefined;
}
// Body -> { key: { v, at } } with only known keys, valid values and sane timestamps.
export function cleanPrefsPatch(body, now = Date.now()) {
const out = {};
const prefs = body?.prefs;
if (!prefs || typeof prefs !== 'object' || Array.isArray(prefs)) return out;
for (const [key, entry] of Object.entries(prefs)) {
const v = cleanPrefValue(key, entry?.v);
const at = entry?.at;
if (v === undefined || !Number.isFinite(at) || at < 0 || at > now + DAY_MS) continue;
out[key] = { v, at };
}
return out;
}
// Per key, the entry with the newer timestamp wins (the stored one on a tie).
export function mergePrefs(existing = {}, patch = {}) {
const merged = { ...existing };
for (const [key, entry] of Object.entries(patch)) {
if (!merged[key] || entry.at > merged[key].at) merged[key] = entry;
}
return merged;
}
+80
View File
@@ -0,0 +1,80 @@
// @vitest-environment node
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test, expect } from 'vitest';
import { cleanPrefValue, cleanPrefsPatch, mergePrefs } from './prefs.js';
const NOW = 1_800_000_000_000;
test('only known preferences with valid values are accepted', () => {
expect(cleanPrefValue('theme', 'dark')).toBe('dark');
expect(cleanPrefValue('theme', 'neon')).toBeUndefined();
expect(cleanPrefValue('translation', 'ESV')).toBe('ESV');
expect(cleanPrefValue('translation', 'KJV')).toBeUndefined();
expect(cleanPrefValue('layout', 'flow')).toBe('flow');
expect(cleanPrefValue('commentarySource', 'tyndale')).toBe('tyndale');
expect(cleanPrefValue('startPage', 'reader')).toBe('reader');
expect(cleanPrefValue('startPage', 'admin')).toBeUndefined();
expect(cleanPrefValue('planTranslation', 'same')).toBe('same');
expect(cleanPrefValue('planTranslation', 'NASB')).toBe('NASB');
expect(cleanPrefValue('planTranslation', 'KJV')).toBeUndefined();
expect(cleanPrefValue('fontSize', 2)).toBeUndefined(); // not a synced preference
});
test('position needs a real book and chapter, and the book is normalised', () => {
expect(cleanPrefValue('position', { book: 'jhn', chapter: 3 })).toEqual({ book: 'JHN', chapter: 3 });
expect(cleanPrefValue('position', { book: 'JOHN', chapter: 3 })).toBeUndefined();
expect(cleanPrefValue('position', { book: 'JHN', chapter: 0 })).toBeUndefined();
expect(cleanPrefValue('position', { book: 'JHN', chapter: '3' })).toBeUndefined();
expect(cleanPrefValue('position', null)).toBeUndefined();
});
test('cleanPrefsPatch drops unknown keys, bad values and silly timestamps', () => {
const patch = cleanPrefsPatch({ prefs: {
theme: { v: 'sepia', at: NOW },
layout: { v: 'nope', at: NOW },
evil: { v: 'x', at: NOW },
translation: { v: 'NLT', at: 'yesterday' },
commentarySource: { v: 'tyndale', at: NOW + 5 * 86_400_000 }, // far in the future
position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 },
} }, NOW);
expect(patch).toEqual({ theme: { v: 'sepia', at: NOW }, position: { v: { book: 'GEN', chapter: 2 }, at: NOW - 10 } });
expect(cleanPrefsPatch(null)).toEqual({});
expect(cleanPrefsPatch({ prefs: [] })).toEqual({});
});
test('the newest change to each preference wins, independently', () => {
const existing = { theme: { v: 'dark', at: 100 }, layout: { v: 'flow', at: 500 } };
const patch = { theme: { v: 'paper', at: 200 }, layout: { v: 'verses', at: 300 }, translation: { v: 'ESV', at: 50 } };
expect(mergePrefs(existing, patch)).toEqual({
theme: { v: 'paper', at: 200 }, // patch is newer
layout: { v: 'flow', at: 500 }, // stored is newer
translation: { v: 'ESV', at: 50 }, // new key
});
expect(mergePrefs({ theme: { v: 'dark', at: 100 } }, { theme: { v: 'paper', at: 100 } }).theme.v).toBe('dark'); // tie keeps stored
});
test('the database keeps one row per user', async () => {
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'prefs-'));
const db = await import('./db.js');
db.initDb();
db.createUser({ id: 'u1', email: 'a@example.test', passwordHash: 'x' });
db.createUser({ id: 'u2', email: 'b@example.test', passwordHash: 'x' });
expect(db.getUserPrefs('u1')).toEqual({});
db.setUserPrefs('u1', { theme: { v: 'dark', at: 1 } });
db.setUserPrefs('u1', { theme: { v: 'sepia', at: 2 } });
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);
});
+18
View File
@@ -0,0 +1,18 @@
// Stops one device from silently overwriting a study another device has saved since. The browser sends
// the `lastEdited` of the server copy it last saw (X-Base-Edited); if the server's copy has moved past
// that, the save is refused with 409 and the person chooses whose version to keep.
// stored: the server copy's lastEdited (null when there is none); base: what the client last saw
// (null/undefined when it never synced this study); force: the person chose "keep mine".
export function isSaveConflict({ stored, base, force = false }) {
if (force || stored == null) return false;
if (!Number.isFinite(base)) return false; // a device that never saw the server's copy has nothing to compare
return stored > base;
}
// Reads the base header; anything that isn't a plain number is treated as "unknown".
export function parseBase(header) {
if (header == null || header === '') return null;
const n = Number(header);
return Number.isFinite(n) ? n : null;
}
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from 'vitest';
import { isSaveConflict, parseBase } from './projectSync.js';
test('a save is fine when the server copy is no newer than what the device last saw', () => {
expect(isSaveConflict({ stored: 100, base: 100 })).toBe(false);
expect(isSaveConflict({ stored: 90, base: 100 })).toBe(false);
});
test('a save conflicts when another device saved after the one this device last saw', () => {
expect(isSaveConflict({ stored: 200, base: 100 })).toBe(true);
});
test('first saves, unknown bases and "keep mine" never conflict', () => {
expect(isSaveConflict({ stored: null, base: 100 })).toBe(false);
expect(isSaveConflict({ stored: 200, base: null })).toBe(false);
expect(isSaveConflict({ stored: 200, base: 100, force: true })).toBe(false);
});
test('reads the base header', () => {
expect(parseBase('1700000000000')).toBe(1700000000000);
expect(parseBase('')).toBeNull();
expect(parseBase(undefined)).toBeNull();
expect(parseBase('abc')).toBeNull();
});
+32
View File
@@ -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) };
}
+23
View File
@@ -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
});
+56
View File
@@ -0,0 +1,56 @@
// Validation for a user's synced Reader bookmarks and text highlights. The data comes
// from the browser, so everything is checked and malformed entries are dropped.
const COLOR = /^#[0-9a-fA-F]{3,8}$/;
const BOOKMARK_KEY = /^[1-3]?[A-Za-z]{2,3}-\d{1,3}-\d{1,3}$/; // e.g. "JHN-3-16", "1SA-3-5"
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;
// Returns { bookmarks, highlights } with only well-formed entries, or null when the body is
// not an object at all.
export function cleanMarks(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) return null;
const bookmarks = {};
if (body.bookmarks && typeof body.bookmarks === 'object' && !Array.isArray(body.bookmarks)) {
for (const [key, color] of Object.entries(body.bookmarks).slice(0, MAX_BOOKMARKS)) {
if (BOOKMARK_KEY.test(key) && typeof color === 'string' && COLOR.test(color)) bookmarks[key] = color;
}
}
// 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();
for (const h of body.highlights.slice(0, MAX_HIGHLIGHTS)) {
if (!h || typeof h !== 'object') continue;
if (typeof h.id !== 'string' || h.id.length === 0 || h.id.length > 64 || seen.has(h.id)) continue;
if (!BOOK.test(String(h.book)) || !int(h.chapter, 1, 200) || !int(h.verse, 1, 200)) continue;
if (!int(h.startOffset, 0, 5000) || !int(h.endOffset, 1, 5000) || h.endOffset <= h.startOffset) continue;
if (typeof h.color !== 'string' || !COLOR.test(h.color)) continue;
seen.add(h.id);
const entry = {
id: h.id, book: String(h.book).toUpperCase(), chapter: h.chapter, verse: h.verse,
startOffset: h.startOffset, endOffset: h.endOffset, color: h.color,
};
// 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, bookmarkNotes };
}
+86
View File
@@ -0,0 +1,86 @@
// @vitest-environment node
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test, expect } from 'vitest';
import { cleanMarks } from './readerMarks.js';
const hl = (over = {}) => ({ id: 'h1', book: 'JHN', chapter: 3, verse: 16, startOffset: 4, endOffset: 20, color: '#fef08a', ...over });
test('a non-object body is rejected', () => {
expect(cleanMarks(null)).toBeNull();
expect(cleanMarks([])).toBeNull();
expect(cleanMarks('x')).toBeNull();
});
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, bookmarkNotes: {} });
});
test('malformed bookmarks are dropped, good ones kept', () => {
const out = cleanMarks({ bookmarks: { 'JHN-3-16': '#fef08a', 'bad key': '#fef08a', 'GEN-1-1': 'red', 'GEN-1-2': 5, '../x-1-1': '#fff' } });
expect(out.bookmarks).toEqual({ 'JHN-3-16': '#fef08a' });
});
test('highlights are validated field by field and de-duplicated by id', () => {
const out = cleanMarks({ highlights: [
hl(),
hl({ id: 'h1', verse: 17 }), // duplicate id
hl({ id: '' }), hl({ id: 'x'.repeat(65) }),
hl({ id: 'a', book: 'JOHN' }), hl({ id: 'b', chapter: 0 }), hl({ id: 'c', verse: 201 }),
hl({ id: 'd', startOffset: 10, endOffset: 10 }), hl({ id: 'e', startOffset: -1 }), hl({ id: 'f', color: 'yellow' }),
'nope', null,
hl({ id: 'ok', book: 'gen' }),
] });
expect(out.highlights.map((h) => h.id)).toEqual(['h1', 'ok']);
expect(out.highlights[1].book).toBe('GEN'); // normalised
});
test('missing sections become empty ones, and unknown fields are not stored', () => {
expect(cleanMarks({})).toEqual({ bookmarks: {}, highlights: [], bookmarkNotes: {} });
expect(cleanMarks({ highlights: [{ ...hl(), evil: '<script>' }] }).highlights[0]).not.toHaveProperty('evil');
});
test('the database keeps one row per user and updatedAt only moves forward', async () => {
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), 'marks-'));
const db = await import('./db.js');
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: [], 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);
const saved = db.getReaderMarks('u1');
expect(saved.updatedAt).toBe(t2);
expect(saved.bookmarks).toEqual({});
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);
});
+187
View File
@@ -0,0 +1,187 @@
// Daily "don't forget to read" reminders, sent via ntfy.sh (https://ntfy.sh) —
// 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 { 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;
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
}
// The calendar date ("YYYY-MM-DD") and minutes-since-midnight in a timezone; the
// server's own timezone when none (or an invalid one) is saved.
export function zonedNow(date = new Date(), tz) {
const timeZone = isValidTimeZone(tz) ? tz : undefined;
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
}).formatToParts(date).map((p) => [p.type, p.value]),
);
return { date: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute) };
}
// Is this user's reminder due right now (and not already sent today)?
export function isDue(user, now = new Date()) {
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(user.reminderTime ?? '');
if (!match) return false;
const target = Number(match[1]) * 60 + Number(match[2]);
const local = zonedNow(now, user.reminderTz);
if (user.lastReminderDate === local.date) return false;
return local.minutes >= target && local.minutes < target + GRACE_MINUTES;
}
// POSTs one notification to the user's ntfy topic. Throws with a readable message on failure.
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, 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})`, { 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!";
}
/** Sends a one-off notification so the user can confirm their topic/server work. */
export function sendTestReminder(user) {
return sendNtfy(user, { title: 'Reading reminders are working', body: 'This is a test from Bible Study Project. Your daily reminder will arrive like this.', tags: 'white_check_mark' });
}
export async function checkReminders(now = new Date()) {
for (const user of getUsersWithRemindersEnabled().filter((u) => isDue(u, now))) {
try {
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 });
}
}
}
/** Checks once a minute whether any user's reminder time has arrived. */
export function startReminderScheduler() {
setInterval(() => {
checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
checkVotd().catch((err) => console.error('Verse of the day scheduler error:', err));
checkPrayerReminders().catch((err) => console.error('Prayer reminder scheduler error:', err));
}, 60 * 1000);
}
+113
View File
@@ -0,0 +1,113 @@
// @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);
test('zonedNow reads the date and time in the user\'s timezone, not the server\'s', () => {
// 2026-09-30T03:30Z is 23:30 the day before in New York, and 12:30 the same day in Tokyo
const d = at('2026-09-30T03:30:00Z');
expect(zonedNow(d, 'America/New_York')).toEqual({ date: '2026-09-29', minutes: 23 * 60 + 30 });
expect(zonedNow(d, 'Asia/Tokyo')).toEqual({ date: '2026-09-30', minutes: 12 * 60 + 30 });
expect(zonedNow(d, 'Not/AZone')).toEqual(zonedNow(d)); // bad timezone falls back to the server's
});
test('timezone validation', () => {
expect(isValidTimeZone('America/Chicago')).toBe(true);
expect(isValidTimeZone('Mars/Base')).toBe(false);
expect(isValidTimeZone('')).toBe(false);
});
test('a reminder is due at its time in the user\'s timezone', () => {
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
expect(isDue(user, at('2026-09-29T11:59:00Z'))).toBe(false); // 06:59 in Chicago (CDT)
expect(isDue(user, at('2026-09-29T12:00:00Z'))).toBe(true); // 07:00
});
test('a late server still sends today\'s reminder, but only within the grace window', () => {
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
expect(isDue(user, at('2026-09-29T12:40:00Z'))).toBe(true); // 07:40 — server was down at 07:00
expect(isDue(user, at('2026-09-29T15:30:00Z'))).toBe(false); // 10:30 — too late to be useful
});
test('it is not sent twice on the same local day', () => {
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: '2026-09-29' };
expect(isDue(user, at('2026-09-29T12:05:00Z'))).toBe(false);
expect(isDue({ ...user, lastReminderDate: '2026-09-28' }, at('2026-09-29T12:05:00Z'))).toBe(true);
});
test('a missing or malformed time is never due', () => {
expect(isDue({ reminderTime: null }, new Date())).toBe(false);
expect(isDue({ reminderTime: '7am' }, new Date())).toBe(false);
});
test('the message mentions how many plans are in progress', () => {
expect(reminderBody({ progress: [{ completedAt: null }] })).toBe("Don't forget today's reading!");
expect(reminderBody({ progress: [{ completedAt: null }, { completedAt: null }, { completedAt: 1 }] })).toContain('2 plans');
});
// --- delivery, against a local stand-in for an ntfy server ------------------------
let server;
afterEach(() => new Promise((resolve) => (server ? server.close(resolve) : resolve())));
const startNtfy = (handler) => new Promise((resolve) => {
const seen = [];
server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => { seen.push({ method: req.method, url: req.url, headers: req.headers, body }); handler(res); });
});
server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, seen }));
});
test('sendNtfy POSTs the message to <server>/<topic> with a title', async () => {
const ntfy = await startNtfy((res) => res.end('{}'));
await sendNtfy({ ntfyUrl: `${ntfy.url}/`, ntfyTopic: 'my-topic_1' }, { title: 'Hello', body: 'Body text' });
expect(ntfy.seen).toHaveLength(1);
expect(ntfy.seen[0]).toMatchObject({ method: 'POST', url: '/my-topic_1', body: 'Body text' });
expect(ntfy.seen[0].headers.title).toBe('Hello');
});
test('the test notification reaches the topic', async () => {
const ntfy = await startNtfy((res) => res.end('{}'));
await sendTestReminder({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' });
expect(ntfy.seen[0].url).toBe('/abc');
expect(ntfy.seen[0].headers.title).toMatch(/working/i);
});
test('an ntfy error response becomes a readable failure', async () => {
const ntfy = await startNtfy((res) => { res.statusCode = 403; res.end('nope'); });
await expect(sendNtfy({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow('ntfy responded 403');
});
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');
});
+90
View File
@@ -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
+167
View File
@@ -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);
+133
View File
@@ -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 });
}
});
}
+153
View File
@@ -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.' });
}
});
}
+324
View File
@@ -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.' });
}
});
}
+104
View File
@@ -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.' });
}
});
}
+154
View File
@@ -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.' });
}
});
}
+152
View File
@@ -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.' });
}
});
}
+51
View File
@@ -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);
}
+66
View File
@@ -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);
});
+25
View File
@@ -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');
+24
View File
@@ -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);
});
+89
View File
@@ -0,0 +1,89 @@
// "Where else does this word appear?": every verse that uses a Strong's number, grouped by book. Built once, in
// memory, from the interlinear files the Reader already ships (public/interlinear, or dist/interlinear in a build),
// the first time anyone asks. Greek (G…) and Hebrew (H…) numbers both work.
import { existsSync } from 'fs';
import { readdir, readFile } from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { bookNameFor } from './bibleApi.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Strong's numbers come as "G4102", "g4102", "H7225" or sometimes with a letter suffix ("H1234a").
export function normalizeStrongs(input) {
const m = String(input ?? '').trim().match(/^([GH])0*(\d{1,5})[a-z]?$/i);
return m ? `${m[1].toUpperCase()}${Number(m[2])}` : null;
}
// One book's interlinear file -> adds each verse it uses a number in to the index (once per verse).
export function indexBook(index, book, data) {
for (const [chapter, verses] of Object.entries(data ?? {})) {
for (const [verse, words] of Object.entries(verses ?? {})) {
const seen = new Set();
for (const w of Array.isArray(words) ? words : []) {
const s = normalizeStrongs(w?.s);
if (!s || seen.has(s)) continue;
seen.add(s);
let entry = index.get(s);
if (!entry) { entry = { count: 0, books: new Map() }; index.set(s, entry); }
let refs = entry.books.get(book);
if (!refs) { refs = []; entry.books.set(book, refs); }
refs.push([Number(chapter), Number(verse)]);
entry.count += 1;
}
}
}
}
// The answer for one number: { strongs, total, books: [{ book, name, count, refs: [[chapter, verse], ...] }] },
// books in the order the index was built (Bible order when files are indexed in canon order).
export function occurrences(index, strongs) {
const key = normalizeStrongs(strongs);
const entry = key && index.get(key);
if (!entry) return { strongs: key, total: 0, books: [] };
const books = [...entry.books].map(([book, refs]) => ({
book, name: bookNameFor(book) ?? book, count: refs.length,
refs: refs.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]),
}));
return { strongs: key, total: entry.count, books };
}
// Canon order so the books come back Genesis → Revelation, whatever order the directory lists them.
const CANON = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV'];
export async function buildIndex(dir) {
const index = new Map();
const files = new Set(await readdir(dir));
for (const book of CANON) {
if (!files.has(`${book}.json`)) continue;
try { indexBook(index, book, JSON.parse(await readFile(join(dir, `${book}.json`), 'utf8'))); } catch { /* a damaged file just leaves that book out */ }
}
return index;
}
function interlinearDir() {
return [join(__dirname, '..', 'dist', 'interlinear'), join(__dirname, '..', 'public', 'interlinear')].find(existsSync) ?? null;
}
let pending = null;
const getIndex = () => {
if (!pending) {
const dir = interlinearDir();
pending = (dir ? buildIndex(dir) : Promise.resolve(new Map())).catch((err) => { pending = null; throw err; });
}
return pending;
};
export function registerStrongsRoutes(app, requireAuth) {
// GET /api/strongs/:number/occurrences
app.get('/api/strongs/:number/occurrences', requireAuth, async (req, res) => {
const key = normalizeStrongs(req.params.number);
if (!key) return res.status(400).json({ error: 'Use a Strong’s number like G4102 or H7225.' });
try {
res.json(occurrences(await getIndex(), key));
} catch (err) {
console.error('GET /api/strongs occurrences error:', err);
res.status(500).json({ error: 'Could not look up that word.' });
}
});
}
+40
View File
@@ -0,0 +1,40 @@
import { test, expect } from 'vitest';
import { indexBook, normalizeStrongs, occurrences } from './strongs.js';
test('normalizeStrongs accepts the usual spellings and rejects the rest', () => {
expect(normalizeStrongs('g4102')).toBe('G4102');
expect(normalizeStrongs(' H07225 ')).toBe('H7225');
expect(normalizeStrongs('H1234a')).toBe('H1234');
expect(normalizeStrongs('4102')).toBeNull();
expect(normalizeStrongs('')).toBeNull();
expect(normalizeStrongs(undefined)).toBeNull();
});
test('occurrences groups verses by book and counts a verse once however often the word repeats', () => {
const index = new Map();
indexBook(index, 'JHN', {
3: { 16: [{ s: 'G25' }, { s: 'G25' }, { s: 'G2316' }], 17: [{ s: 'G2316' }] },
1: { 1: [{ s: 'G3056' }, { s: 'G2316' }] },
});
indexBook(index, 'ROM', { 5: { 8: [{ s: 'G25' }, { s: 'G2316' }] } });
const love = occurrences(index, 'g25');
expect(love.total).toBe(2);
expect(love.books.map((b) => [b.book, b.name, b.count])).toEqual([['JHN', 'John', 1], ['ROM', 'Romans', 1]]);
const god = occurrences(index, 'G2316');
expect(god.total).toBe(4);
expect(god.books[0].refs).toEqual([[1, 1], [3, 16], [3, 17]]); // sorted by chapter then verse
});
test('an unknown or invalid number comes back empty', () => {
const index = new Map();
expect(occurrences(index, 'G99999')).toEqual({ strongs: 'G99999', total: 0, books: [] });
expect(occurrences(index, 'nonsense')).toEqual({ strongs: null, total: 0, books: [] });
});
test('malformed interlinear data is skipped, not thrown', () => {
const index = new Map();
expect(() => indexBook(index, 'GEN', { 1: { 1: null, 2: 'x', 3: [null, { s: 5 }, { s: 'H430' }] } })).not.toThrow();
expect(occurrences(index, 'H430').total).toBe(1);
});
+16
View File
@@ -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,
};
}
+32
View File
@@ -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 };
}
+25
View File
@@ -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 });
});
+33
View File
@@ -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' });
+40
View File
@@ -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
});
+443
View File
@@ -0,0 +1,443 @@
// How the app moves and recovers: the Back button, the ⌘K palette, Undo, sync conflicts, backups.
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
import App from './App';
import { resetLexiconCaches } from './utils/wordStudy.js';
import { switchStorageUser } from './utils/bibleUtils.jsx';
import { resetSyncBases } from './utils/syncBase.js';
const chapterData = {
chapter: { content: [
{ type: 'verse', number: 1, content: ['In the beginning God created.'] },
{ type: 'verse', number: 2, content: ['The earth was without form.'] },
] },
};
const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
// A fake server: signed in as `user`, a settable answer for saving studies, and a log of every call.
function stubServer({ user = null, putStatus = 200 } = {}) {
const calls = [];
const state = { putStatus, outlineToken: null };
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
const route = `${opts.method ?? 'GET'} ${url.split('?')[0]}`;
calls.push({ route, url, opts });
if (url.includes('bible.helloao.org')) return json(200, chapterData);
if (!url.startsWith('/api/')) return Promise.resolve({ ok: false });
if (route === 'GET /api/auth/me') return user ? json(200, user) : json(401, { error: 'Not signed in' });
if (route === 'GET /api/projects') return json(200, []);
if (route === 'GET /api/reader/ink') return json(200, {});
if (route.endsWith('/outline-share')) {
if (opts.method === 'POST') state.outlineToken = 'abc123';
if (opts.method === 'DELETE') state.outlineToken = null;
return json(200, opts.method === 'DELETE' ? { ok: true } : { shareToken: state.outlineToken ?? null });
}
if (route === 'GET /api/share-outline/abc123') {
return json(200, { title: 'Titus', translation: 'BSB', outlines: [
{ scope: 'study', label: 'Whole study', tree: [{ id: 'a', text: 'Appoint elders', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
{ scope: 'c1', label: 'Titus 1:1–4', tree: [{ id: 'b', text: 'Greeting', ref: '', kind: '', minutes: null, note: '', scripture: null, attachments: [], children: [] }] },
] });
}
if (route === 'GET /api/share-outline/nope') return json(404, { error: 'This outline link is invalid or has been turned off.' });
if (route.startsWith('PUT /api/projects/')) {
return state.putStatus === 409
? json(409, { error: 'This study was changed on another device.', code: 'conflict', serverEdited: 999 })
: json(200, { id: url.split('/').pop().split('?')[0], title: 'x', lastEdited: 12345, chapterSummary: '' });
}
return json(404, { error: 'not mocked' });
}));
return { calls, state };
}
beforeEach(() => {
localStorage.clear();
resetLexiconCaches();
resetSyncBases();
switchStorageUser(null);
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false })));
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
});
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
async function renderApp() {
render(<App />);
await waitFor(() => expect(screen.queryByText('Loading…')).not.toBeInTheDocument());
}
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
const project = {
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
chapters: [{
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '', crossReferences: [], greekWords: [], tags: [] }],
}],
};
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
localStorage.setItem('bible-study-index', JSON.stringify(index));
}
describe('Back button', () => {
test('moves back through the pages you visited instead of leaving the app', async () => {
const user = userEvent.setup();
await renderApp();
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /reading plans/i }));
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
window.history.back();
expect(await screen.findByRole('heading', { name: 'My Studies' })).toBeInTheDocument();
window.history.forward();
expect(await screen.findByRole('heading', { name: /reading plans/i })).toBeInTheDocument();
});
});
const paletteBox = async () => within(await screen.findByRole('dialog', { name: /search and go to/i })).getByRole('combobox');
describe('Search palette (⌘K)', () => {
test('a typed passage goes straight to the Bible reader', async () => {
const user = userEvent.setup();
await renderApp();
await user.keyboard('{Control>}k{/Control}');
await user.type(await paletteBox(), 'john 3:16');
expect(await screen.findByRole('option', { name: /John 3:16/ })).toBeInTheDocument();
await user.keyboard('{Enter}');
expect(await screen.findByRole('heading', { name: /John 3/ })).toBeInTheDocument();
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
});
test('finds a note you wrote and opens that study', async () => {
seedProject({ observation: 'Grace abounds to all.' });
const user = userEvent.setup();
await renderApp();
await user.keyboard('{Control>}k{/Control}');
await user.type(await paletteBox(), 'abounds');
const hit = await screen.findByRole('option', { name: /Titus 1:1/ });
await user.click(hit);
expect(await screen.findByRole('textbox', { name: 'Observe' })).toBeInTheDocument();
});
test('Escape closes it', async () => {
const user = userEvent.setup();
await renderApp();
await user.keyboard('{Control>}k{/Control}');
await paletteBox();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog', { name: /search and go to/i })).not.toBeInTheDocument();
});
test('? lists the keyboard shortcuts', async () => {
const user = userEvent.setup();
await renderApp();
await user.keyboard('?');
expect(await screen.findByRole('dialog', { name: /keyboard shortcuts/i })).toBeInTheDocument();
});
});
describe('Undo', () => {
test('deleting a project, then Undo, keeps its notes', async () => {
seedProject();
const user = userEvent.setup();
await renderApp();
await user.click(screen.getByRole('button', { name: /^delete$/i }));
expect(await screen.findByRole('status')).toHaveTextContent(/deleted/i);
await user.click(screen.getByRole('button', { name: /undo/i }));
expect(await screen.findByText('Titus Notes')).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).chapters[0].chunks[0].observation).toBe('Grace abounds to all.');
});
});
describe('Sync conflict', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
async function startStudy(u) {
await renderApp();
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
}
test('a save the server refuses shows the choice instead of overwriting', async () => {
const server = stubServer({ user, putStatus: 409 });
const u = userEvent.setup();
await startStudy(u);
const banner = await screen.findByRole('alert', {}, { timeout: 4000 });
expect(banner).toHaveTextContent(/changed on another device/i);
expect(within(banner).getByRole('button', { name: /keep this version/i })).toBeInTheDocument();
expect(within(banner).getByRole('button', { name: /use the other version/i })).toBeInTheDocument();
// "Keep this version" sends the save again, this time as an overwrite
server.state.putStatus = 200;
await u.click(within(banner).getByRole('button', { name: /keep this version/i }));
await waitFor(() => expect(server.calls.some((c) => c.url.includes('?force=1'))).toBe(true));
await waitFor(() => expect(screen.queryByText(/changed on another device/i)).not.toBeInTheDocument());
}, 15000);
test('opening a study without editing it does not touch the server', async () => {
seedProject();
const server = stubServer({ user });
const u = userEvent.setup();
await renderApp();
// signed in: the namespaced storage starts empty, so seed under the user's keys by resuming nothing
expect(server.calls.filter((c) => c.route.startsWith('PUT')).length).toBe(0);
await u.click(await screen.findByRole('button', { name: /read bible/i }));
expect(server.calls.filter((c) => c.route.startsWith('PUT /api/projects')).length).toBe(0);
});
});
describe('Outline stage', () => {
async function openOutline() {
// local-only mode (the account server is unreachable), with chapter text available
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
const user = userEvent.setup();
await renderApp();
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
// one chunk covering verses 1-2
const verses = within(screen.getByTestId('verse-list'));
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^outline$/i }));
await screen.findByRole('heading', { name: 'Outline' });
return user;
}
test('starts empty, and "Start from my chunks" makes a main point for each chunk', async () => {
const user = await openOutline();
expect(screen.getByText(/no outline yet/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
const list = await screen.findByRole('list', { name: 'Outline' });
expect(within(list).getAllByRole('textbox')).toHaveLength(1);
expect(within(list).getByText(/Open chunk in Study/i)).toBeInTheDocument();
});
test('Enter adds a point, Tab makes it a sub-point, and they are numbered I. and A.', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
const first = await screen.findByRole('textbox', { name: 'Point I.' });
await user.type(first, 'Creation{Enter}');
const second = await screen.findByRole('textbox', { name: 'Point II.' });
await user.type(second, 'Light{Tab}');
expect(await screen.findByRole('textbox', { name: 'Point A.' })).toHaveValue('Light');
expect(screen.getByRole('textbox', { name: 'Point I.' })).toHaveValue('Creation');
});
test('deleting a point can be undone', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'Keep me');
await user.click(screen.getByRole('button', { name: /delete i\./i }));
expect(screen.queryByRole('textbox', { name: 'Point I.' })).not.toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: /undo/i }));
expect(await screen.findByRole('textbox', { name: 'Point I.' })).toHaveValue('Keep me');
});
});
describe('Outline: per chunk, details, print and share', () => {
async function openOutline() {
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
const user = userEvent.setup();
await renderApp();
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
const verses = within(screen.getByTestId('verse-list'));
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^outline$/i }));
await screen.findByRole('heading', { name: 'Outline' });
return user;
}
test('each chunk has its own outline, separate from the whole study\'s', async () => {
const user = await openOutline();
const picker = screen.getByRole('combobox', { name: 'Outline for' });
await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk
expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /start from the verses/i }));
const list = await screen.findByRole('list', { name: 'Outline' });
expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse
expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words
await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study');
expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched
expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count
});
test('a point can be given a type and minutes, and the total shows', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'The story');
await user.click(screen.getByRole('button', { name: /details for i\./i }));
await user.selectOptions(screen.getByLabelText('Type'), 'illustration');
await user.type(screen.getByLabelText('Minutes'), '5');
expect(await screen.findByText('About 5 minutes')).toBeInTheDocument();
await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly');
await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line
expect(await screen.findByText('Tell it slowly')).toBeInTheDocument();
expect(screen.getByText('Illustration')).toBeInTheDocument();
});
test('"Add verse text" puts the passage\'s words into the point', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.click(await screen.findByRole('button', { name: /details for i\./i }));
await user.type(screen.getByLabelText('Passage'), 'Titus 1:1');
await user.click(screen.getByRole('button', { name: /add verse text/i }));
expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument();
await user.clear(screen.getByLabelText('Passage'));
await user.type(screen.getByLabelText('Passage'), 'nonsense');
await user.click(screen.getByRole('button', { name: /replace verse text/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
});
test('Share opens a sheet; with no account it offers the text, which is copied', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
await screen.findByRole('list', { name: 'Outline' });
await user.click(screen.getByRole('button', { name: /^share$/i }));
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
expect(await within(sheet).findByText(/links need your account/i)).toBeInTheDocument();
await user.click(within(sheet).getByRole('button', { name: /share as text/i }));
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
});
test('Print opens a page of just the outline', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
await screen.findByRole('list', { name: 'Outline' });
const doc = { write: vi.fn(), close: vi.fn() };
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
expect(doc.write.mock.calls[0][0]).toContain('class="outline"');
});
});
describe('Read-only outline link', () => {
const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: '', isAdmin: false };
test('create the link, see it, and turn it off', async () => {
const server = stubServer({ user });
const u = userEvent.setup();
await renderApp();
await u.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await u.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
const verses = within(screen.getByTestId('verse-list'));
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
await screen.findByText('1 created');
await u.click(screen.getByRole('button', { name: /^outline$/i }));
await u.click(await screen.findByRole('button', { name: /start from my chunks/i }));
await u.click(await screen.findByRole('button', { name: /^share$/i }));
const sheet = await screen.findByRole('dialog', { name: /share this outline/i });
await u.click(await within(sheet).findByRole('button', { name: /create read-only link/i }));
const box = await within(sheet).findByLabelText('Outline link');
expect(box.value).toMatch(/\?outline=abc123$/);
expect(server.calls.some((c) => c.route === 'POST /api/projects/' + c.url.split('/')[3] + '/outline-share')).toBe(true);
await u.click(within(sheet).getByRole('button', { name: /turn off link/i }));
expect(await within(sheet).findByRole('button', { name: /create read-only link/i })).toBeInTheDocument();
}, 15000);
});
describe('Opening a read-only outline link', () => {
afterEach(() => window.history.replaceState({}, '', '/'));
test('shows the outline with no sign-in and a way to switch between outlines', async () => {
stubServer({ user: null });
window.history.replaceState({}, '', '/?outline=abc123');
render(<App />);
expect(await screen.findByRole('heading', { name: 'Titus' })).toBeInTheDocument();
expect(screen.getByText('Appoint elders')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /sign in/i })).not.toBeInTheDocument();
expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); // nothing to edit
await userEvent.setup().click(screen.getByRole('button', { name: 'Titus 1:1–4' }));
expect(await screen.findByRole('heading', { name: 'Titus — Titus 1:1–4' })).toBeInTheDocument();
expect(screen.getByText('Greeting')).toBeInTheDocument();
expect(screen.queryByText('Appoint elders')).not.toBeInTheDocument();
});
test('a turned-off or wrong link says so', async () => {
stubServer({ user: null });
window.history.replaceState({}, '', '/?outline=nope');
render(<App />);
expect(await screen.findByText(/invalid or has been turned off/i)).toBeInTheDocument();
});
});
describe('Study cards on Home', () => {
test('a long run of chapters and many tags stay short', async () => {
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');
const tags = ['A', 'B', 'C', 'D', 'E', 'F'];
localStorage.setItem('bible-study-index', JSON.stringify([{ id: 'p', title: 'Luke', lastEdited: Date.now(), chapterSummary: chapters, tags }]));
await renderApp();
expect(await screen.findByText('Luke 1\u201324')).toBeInTheDocument();
expect(screen.getByText('+3 more')).toBeInTheDocument();
expect(screen.queryByText('F', { selector: 'span' })).not.toBeInTheDocument(); // only in the Tag filter
});
});
describe('Window title', () => {
test('names the page you are on, including the chapter in the reader', async () => {
const user = userEvent.setup();
await renderApp();
expect(document.title).toBe('My Studies · Bible Study');
await user.click(screen.getByRole('button', { name: /reading plans/i }));
await waitFor(() => expect(document.title).toBe('Reading Plans · Bible Study'));
await user.click(screen.getByRole('button', { name: /read bible/i }));
await waitFor(() => expect(document.title).toMatch(/^[A-Za-z0-9 ]+ \d+ · Bible Study$/));
});
});
describe('Login page', () => {
async function openLogin() {
stubServer({ user: null });
render(<App />);
return screen.findByRole('button', { name: /^sign in$/i });
}
test('tells you what the app can do, in groups', async () => {
await openLogin();
expect(screen.getByRole('heading', { level: 1, name: /read it\. study it\./i })).toBeInTheDocument();
for (const group of ['Read', 'Study', 'Teach', 'Everywhere']) expect(screen.getByRole('region', { name: group })).toBeInTheDocument();
expect(screen.getByText('A real outline')).toBeInTheDocument();
expect(screen.getByText('Compare them')).toBeInTheDocument();
});
test('switches between signing in and creating an account', async () => {
const user = userEvent.setup();
await openLogin();
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /need an account/i }));
expect(screen.getByRole('heading', { name: 'Create an account' })).toBeInTheDocument();
expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^create account$/i })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /already have an account/i }));
expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
});
test('a theme can be picked before signing in', async () => {
const user = userEvent.setup();
await openLogin();
await user.click(screen.getByRole('button', { name: 'Dark' }));
expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true');
expect(localStorage.getItem('reader-theme')).toBe('dark');
});
test('a wrong password shows the error', async () => {
const user = userEvent.setup();
vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
if (url === '/api/auth/me') return json(401, {});
if (url === '/api/auth/login') return json(401, { error: 'Incorrect email or password.' });
return Promise.resolve({ ok: false });
}));
render(<App />);
await user.type(await screen.findByLabelText(/email/i), 'a@b.co');
await user.type(screen.getByLabelText(/^password/i), 'wrong-password');
await user.click(screen.getByRole('button', { name: /^sign in$/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/incorrect email or password/i);
});
});
+464 -2280
View File
File diff suppressed because it is too large. Load diff
+137
View File
@@ -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>
);
}
+56
View File
@@ -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);
});
+87
View File
@@ -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>
);
}
+56
View File
@@ -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>
);
}
+177
View File
@@ -0,0 +1,177 @@
// A compact media-player control: big play/pause button, seekable progress
// bar, and transport buttons. Presentation only — playback state and handlers
// come from useAudio.js so the Home (whole book) and Reader (chapter) players
// can share this UI.
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
}
function PlayIcon() {
return (
<svg viewBox="0 0 20 20" width="20" height="20" fill="currentColor">
<path d="M5.5 3.6c0-.9 1-1.45 1.76-.97l9.4 5.9c.72.45.72 1.5 0 1.94l-9.4 5.9c-.76.48-1.76-.07-1.76-.97V3.6z" />
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 20 20" width="20" height="20" fill="currentColor">
<rect x="4.5" y="3.5" width="4" height="13" rx="1" />
<rect x="11.5" y="3.5" width="4" height="13" rx="1" />
</svg>
);
}
function StopIcon() {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill="currentColor">
<rect x="4.5" y="4.5" width="11" height="11" rx="1.5" />
</svg>
);
}
function Seek10Icon({ direction }) {
const flip = direction === 'back' ? 'scale(-1,1) translate(-20,0)' : undefined;
return (
<svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6">
<g transform={flip}>
<path d="M4 8.5a6.5 6.5 0 1 1 1.2 5.3" strokeLinecap="round" />
<path d="M3 5.5v3.3h3.3" strokeLinecap="round" strokeLinejoin="round" />
</g>
<text x="10" y="12.2" fontSize="6.5" fontFamily="sans-serif" stroke="none" fill="currentColor" textAnchor="middle">10</text>
</svg>
);
}
function NextTrackIcon() {
return (
<svg viewBox="0 0 20 20" width="16" height="16" fill="currentColor">
<path d="M5 4.3c0-.8.9-1.3 1.6-.9l7.4 4.7c.7.4.7 1.4 0 1.8l-7.4 4.7c-.7.4-1.6-.1-1.6-.9V4.3z" />
<rect x="14" y="3.7" width="1.6" height="12.6" rx="0.5" />
</svg>
);
}
export default function AudioPlayer({
title,
subtitle,
errorMessage,
status, // 'idle' | 'playing' | 'paused' | 'error'
currentTime = 0,
duration = 0,
onPlay,
onTogglePause,
onSeekBack,
onSeekForward,
onSeekTo,
onNext,
nextDisabled,
onStop,
controls, // optional extra controls (e.g. book/narrator selects), rendered top-right
}) {
const isActive = status === 'playing' || status === 'paused';
const progressPct = duration > 0 ? Math.min(100, (currentTime / duration) * 100) : 0;
return (
<div className="rounded-3xl border border-[var(--r-line,#e2e8f0)] bg-[var(--r-bar,#ffffff)] p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-[10rem] flex-1">
<h3 className="truncate text-sm font-semibold text-[var(--r-text,#0f172a)] sm:text-base">{title}</h3>
<p className="mt-0.5 truncate text-xs text-[var(--r-sub,#64748b)] sm:text-sm">{subtitle}</p>
{status === 'error' && errorMessage && (
<p className="mt-1 text-xs text-rose-600">{errorMessage}</p>
)}
</div>
{controls && <div className="flex flex-wrap items-center gap-2">{controls}</div>}
</div>
{isActive && (
<div className="mt-4">
<input
type="range"
min={0}
max={duration || 0}
step={0.1}
value={Math.min(currentTime, duration || 0)}
onChange={(e) => onSeekTo?.(Number(e.target.value))}
disabled={!duration}
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-[var(--r-line,#e2e8f0)] accent-[var(--r-accent,#0284c7)] disabled:cursor-default"
style={{ background: `linear-gradient(to right, var(--r-accent, rgb(2 132 199)) ${progressPct}%, var(--r-line, rgb(226 232 240)) ${progressPct}%)` }}
aria-label="Seek"
/>
<div className="mt-1 flex justify-between text-xs tabular-nums text-[var(--r-sub,#64748b)]">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
)}
<div className="mt-3 flex items-center justify-center gap-3">
{isActive ? (
<>
<button
type="button"
onClick={onSeekBack}
title="Rewind 10 seconds"
aria-label="Rewind 10 seconds"
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
>
<Seek10Icon direction="back" />
</button>
<button
type="button"
onClick={onTogglePause}
aria-label={status === 'paused' ? 'Resume' : 'Pause'}
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
>
{status === 'paused' ? <PlayIcon /> : <PauseIcon />}
</button>
<button
type="button"
onClick={onSeekForward}
title="Fast-forward 10 seconds"
aria-label="Fast-forward 10 seconds"
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
>
<Seek10Icon direction="forward" />
</button>
{onNext && (
<button
type="button"
onClick={onNext}
disabled={nextDisabled}
title="Next chapter"
aria-label="Next chapter"
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent"
>
<NextTrackIcon />
</button>
)}
<button
type="button"
onClick={onStop}
title="Stop"
aria-label="Stop"
className="flex h-9 w-9 items-center justify-center rounded-full text-[var(--r-sub,#64748b)] hover:bg-black/5"
>
<StopIcon />
</button>
</>
) : (
<button
type="button"
onClick={onPlay}
className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--r-accent,#0284c7)] text-[var(--r-on,#ffffff)] hover:opacity-90"
aria-label="Play"
>
<PlayIcon />
</button>
)}
</div>
</div>
);
}
+36
View File
@@ -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>
);
}
+85
View File
@@ -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>
);
}
+49
View File
@@ -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();
});
+132
View File
@@ -0,0 +1,132 @@
// Press ⌘K (Ctrl+K) anywhere: type a passage ("john 3:16") to go straight to it, or a few letters to
// find a study, a note you wrote, a bookmark or highlight, a page, or to search the Bible's text.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { themeVars } from './reader/readerThemes.js';
import { SearchIcon } from './reader/icons.jsx';
import { formatPassage, parsePassageRef } from '../utils/passageRef.js';
import { searchMarks, searchNotes, searchProjects } from '../utils/personalSearch.js';
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
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, openPrayer,
openBibleReader, requestReaderSearch, openBackup,
} = useApp();
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const listRef = useRef(null);
const inputRef = useRef(null);
const opener = useRef(document.activeElement);
useEffect(() => {
inputRef.current?.focus();
const from = opener.current;
return () => { if (from instanceof HTMLElement && document.contains(from)) from.focus({ preventScroll: true }); };
}, []);
const items = useMemo(() => {
const out = [];
const q = query.trim();
const go = (fn) => () => { onClose(); fn(); };
// 1. A passage typed in
for (const r of parsePassageRef(q)) {
out.push({ group: 'Go to', label: formatPassage(r), hint: 'Open in the Bible', run: go(() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }) });
}
// 2. Pages and actions
const pages = [
['Studies', 'My Studies', goStudies], ['Read Bible', 'The Bible reader', openBibleReader], ['Reading Plans', 'Plans and today’s reading', openReadingPlans],
['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();
for (const [label, hint, fn] of pages) {
if (!lower || label.toLowerCase().includes(lower)) out.push({ group: lower ? 'Pages' : 'Go to', label, hint, run: go(fn) });
}
if (q.length >= 2) {
// 3. Studies
for (const e of searchProjects(projectIndex, q)) {
out.push({ group: 'Studies', label: e.title, hint: e.chapterSummary, run: go(() => resumeProject(e.id)) });
}
// 4. Notes you wrote
const projects = loadProjectIndex().map((e) => loadProjectById(e.id)).filter(Boolean);
for (const h of searchNotes(projects, q)) {
out.push({
group: 'Your notes', label: `${h.ref} — ${h.projectTitle}`, hint: h.snippet,
run: go(() => {
const full = loadProjectById(h.projectId);
if (!full) return;
setProject({ ...full, selectedChunkId: h.chunkId });
setCurrentPage('study');
}),
});
}
// 5. Bookmarks and highlights
for (const m of searchMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights, labels: readerColorLabels ?? {} }, q)) {
out.push({
group: 'Bookmarks & highlights', label: `${m.bookName} ${m.chapter}:${m.verse}`, color: m.color,
hint: m.kind === 'highlight' ? `${m.label ? `${m.label}: ` : ''}${m.text ?? 'Highlight'}` : 'Bookmark',
run: go(() => { jumpToReaderVerse(m.book, m.chapter, m.verse); openBibleReader(); }),
});
}
// 6. The Bible's own text
out.push({ group: 'Search', label: `Search the Bible for “${q}”`, hint: 'Whole-Bible search in the reader', run: go(() => { requestReaderSearch(q); openBibleReader(); }) });
}
return out;
}, [query]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { setActive(0); }, [query]);
useEffect(() => { listRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView?.({ block: 'nearest' }); }, [active]);
const onKeyDown = (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((a) => Math.min(a + 1, items.length - 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
else if (e.key === 'Enter') { e.preventDefault(); items[active]?.run(); }
else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); }
else if (e.key === 'Tab') { e.preventDefault(); } // the box and the list are one control
};
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-[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>
<input
ref={inputRef} value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKeyDown}
role="combobox" aria-expanded="true" aria-controls="palette-list" aria-autocomplete="list"
aria-activedescendant={items[active] ? `palette-item-${active}` : undefined}
placeholder="Go to a passage, or search your studies and notes…"
className="min-w-0 flex-1 bg-transparent text-base outline-none placeholder:text-[var(--r-sub)]"
/>
<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-[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;
lastGroup = item.group;
return (
<li key={i} role="presentation">
{header && <p className="px-3 pb-1 pt-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{header}</p>}
<div id={`palette-item-${i}`} role="option" aria-selected={i === active} onMouseMove={() => setActive(i)} onClick={item.run}
className={`flex cursor-pointer items-baseline gap-2 rounded-lg px-3 py-2 text-sm ${i === active ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : ''}`}>
{item.color && <span className="h-2.5 w-2.5 shrink-0 translate-y-px rounded-full border border-black/10" style={{ backgroundColor: item.color }} />}
<span className="shrink-0 font-medium">{item.label}</span>
{item.hint && <span className={`min-w-0 truncate text-xs ${i === active ? 'opacity-80' : 'text-[var(--r-sub)]'}`}>{item.hint}</span>}
</div>
</li>
);
})}
</ul>
<p className="border-t border-[var(--r-line)] px-4 py-2 text-[11px] text-[var(--r-sub)]">↑↓ to move · Enter to open · Esc to close</p>
</div>
</div>
);
}
+66
View File
@@ -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>
);
}
+46
View File
@@ -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);
});
+68
View File
@@ -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>
);
}
+59
View File
@@ -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
});
+61
View File
@@ -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>
);
}
Loaded 100 of 389 files, more files were not shown because too many files have changed in this diff. Show more