diff --git a/TODO.md b/TODO.md index 2dbd48f..339fd19 100644 --- a/TODO.md +++ b/TODO.md @@ -15,7 +15,8 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them. - [x] **Split `server/index.js`** into one file per area (`server/routes/`: auth, account, projects, reader, sharing, admin). ## Teaching together -- [ ] **Comments between co-teachers.** Notes on a chunk addressed to the other person, instead of editing each other's text. +- [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 @@ -23,7 +24,7 @@ Ideas for the app that haven't been built yet, roughly in the order I'd do them. ## Reading - [ ] **Read-aloud with highlighting.** The device's voice reads the chapter and the verse being read is highlighted; useful for - the BSB and NET, which have no audio. + 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 diff --git a/server/index.js b/server/index.js index 16975d9..550183d 100644 --- a/server/index.js +++ b/server/index.js @@ -11,6 +11,7 @@ import { startReminderScheduler } from './reminders.js'; import { startBackupScheduler } from './backups.js'; import { registerBibleRoutes } from './bibleApi.js'; import { registerIqBibleRoutes } from './iqbible.js'; +import { registerStrongsRoutes } from './strongs.js'; import { registerDeviceRoutes } from './devices.js'; import { requireAuth } from './auth.js'; import { userPayload } from './userPayload.js'; @@ -85,6 +86,7 @@ registerProjectRoutes(app); registerReaderRoutes(app); registerBibleRoutes(app, requireAuth); registerIqBibleRoutes(app, requireAuth); +registerStrongsRoutes(app, requireAuth); registerDeviceRoutes(app, { requireAuth, userPayload, isLimited, recordFailure, clearFailures, tooMany: TOO_MANY }); registerSharingRoutes(app); registerAdminRoutes(app); diff --git a/server/strongs.js b/server/strongs.js new file mode 100644 index 0000000..8123d3d --- /dev/null +++ b/server/strongs.js @@ -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.' }); + } + }); +} diff --git a/server/strongs.test.js b/server/strongs.test.js new file mode 100644 index 0000000..43d12c6 --- /dev/null +++ b/server/strongs.test.js @@ -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); +}); diff --git a/src/components/SeriesProgress.jsx b/src/components/SeriesProgress.jsx new file mode 100644 index 0000000..b4ad0ed --- /dev/null +++ b/src/components/SeriesProgress.jsx @@ -0,0 +1,41 @@ +// Finish → Series progress: every session of the study with where it stands (studied, ready to teach, taught) and +// what is still open on it, so a long study doesn't lose track of itself. +import { seriesProgress, STAGES } from '../utils/seriesProgress.js'; + +const PILL = ['border border-[var(--r-line)] text-[var(--r-sub)]', 'bg-[var(--r-bar)] border border-[var(--r-line)]', 'border border-[var(--r-accent)] text-[var(--r-accent)]', 'bg-[var(--r-accent)] text-[var(--r-on)]']; + +export default function SeriesProgress({ project, onOpen }) { + const { rows, counts, taught, total, next } = seriesProgress(project); + if (total < 2) return null; // a series needs more than one session + return ( +
+

Series progress

+

+ {taught} of {total} session{total === 1 ? '' : 's'} taught + {next ? `. Next up: session ${next.number}, ${next.reference}${next.title ? ` (${next.title})` : ''}.` : '. The series is finished.'} + {' '}Worked out from what you’ve written: notes make a session “Studied”, an outline, script or questions make it “Ready to teach”, and session notes make it “Taught”. +

+
`${counts[i]} ${s.toLowerCase()}`).join(', ')}> + {counts.map((n, i) => n > 0 && )} +
+ +
+ ); +} diff --git a/src/components/study/ChunkComments.jsx b/src/components/study/ChunkComments.jsx new file mode 100644 index 0000000..177b57b --- /dev/null +++ b/src/components/study/ChunkComments.jsx @@ -0,0 +1,40 @@ +// Comments for the people teaching this study together, shown in the notes' "More" area. Open ones can be marked done. +import { useState } from 'react'; +import { addComment, removeComment, toggleComment } from '../../utils/chunkComments.js'; + +const when = (at) => new Date(at).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + +export default function ChunkComments({ chunk, updateChunk, author }) { + const comments = chunk.comments ?? []; + const [text, setText] = useState(''); + const set = (next) => updateChunk(chunk.id, { comments: next }); + const post = () => { + const next = addComment(comments, { by: author, text }); + if (next !== comments) { set(next); setText(''); } + }; + return ( +
+

Comments (notes between the people teaching this study; tick one off when it’s dealt with)

+ {comments.length > 0 && ( + + )} +
+ setText(e.target.value)} maxLength={1000} placeholder="Write a comment, then Enter" + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); post(); } }} + className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none" /> + +
+
+ ); +} diff --git a/src/components/study/NotesPane.jsx b/src/components/study/NotesPane.jsx index b567876..74d665e 100644 --- a/src/components/study/NotesPane.jsx +++ b/src/components/study/NotesPane.jsx @@ -3,6 +3,9 @@ import { useEffect, useRef, useState } from 'react'; import SessionNotes from './SessionNotes.jsx'; import FirstDraft from './FirstDraft.jsx'; +import ChunkComments from './ChunkComments.jsx'; +import { openCommentCount } from '../../utils/chunkComments.js'; +import { bookBackground, hasBookInfo } from '../../utils/bookInfo.js'; import ScriptureRefs from '../ScriptureRefs.jsx'; import { usePasteCheck } from './PastePrompt.jsx'; import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js'; @@ -22,7 +25,7 @@ const SECTIONS = [ const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length; -export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify, onPray, focusRequest }) { +export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify, onPray, focusRequest, author, bookAbbrev, bookName }) { const [open, setOpen] = useState(() => { try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; } }); @@ -142,7 +145,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
{more && ( @@ -153,12 +156,17 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} /> updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /> + {hasBookInfo(bookAbbrev) && !(chunk.generalNotes ?? '').includes('at a glance') && ( + + )}