From c378f85d1b9a5ad950e1296f0c4033f180095f65 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 1 Jun 2026 11:33:06 -0400 Subject: [PATCH 1/2] Suggestions --- SUGGESTIONS.md | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 SUGGESTIONS.md diff --git a/SUGGESTIONS.md b/SUGGESTIONS.md new file mode 100644 index 0000000..a0849e1 --- /dev/null +++ b/SUGGESTIONS.md @@ -0,0 +1,95 @@ +# Study App Improvement Suggestions + +## Features + +### Study Tools +- **Bible comparison mode** — show two translations side-by-side (API already supports it) +- **Verse-level notes** — annotate individual verses, not just chunks +- **Tagging / themes** — tag chunks with themes (e.g. "faith", "grace"), filter/search across projects +- **Progress tracking** — mark chunks as "in progress" / "complete"; show progress bar on home card +- **Study templates** — pre-fill OIA fields with guiding prompts for new users +- **Print view** — clean print-optimized CSS layout +- **Old Testament support** — only NT books listed; HelloAO API supports OT. Greek suggest is NT-only but the rest could support OT with Hebrew lookup +- **Verse search** — search bar to find a verse by keyword across the loaded chapter + +### Export / Sharing +- **PDF export** — "Export PDF" button using `jsPDF` or `window.print()` +- **Share link** — read-only shareable URL pointing to a project ID on the server +- **Copy individual chunk** — "copy this chunk's notes" button alongside full "Prepare for Claude" +- **Markdown export** — useful for Obsidian and similar note-taking apps + +--- + +## UX / UI + +### Navigation +- **Keyboard shortcuts** — `←`/`→` to navigate chunks; `Ctrl+S` to save; `Escape` to close modals +- **"Jump to chunk" dropdown** — for projects with many chunks, a select menu is faster than scrolling +- **Breadcrumb in header** — show `Book Chapter:Verse range` so users always know where they are + +### Chunk Builder (Setup Page) +- **Drag-to-select verses** — click-and-drag instead of click then shift-click +- **Auto-chunk** — button to split chapter into chunks by paragraph/section breaks +- **Visual overlap indicator** — already-chunked verses are shaded but there's no tooltip explaining why you can't select them + +### Study Page +- **Collapsible sections** — collapse OIA, Cross-References, and Greek Word Studies independently +- **Word/character count** on each textarea to encourage note depth +- **Inline verse reference popup** — hover popover on cross-references showing verse text (from HelloAO) +- **Sticky chunk navigation** — Previous/Next chunk buttons should be sticky, not only at the bottom + +### Home Page +- **Search/filter projects** — text filter on the project list +- **Sort options** — sort by name, date, or passage +- **Project rename** — title is only set at creation; allow renaming from home card +- **Last opened chunk** — resume directly to the study page, not the setup page + +--- + +## Code Architecture + +### State Management +- **`App.jsx` is ~2,250 lines** — biggest maintainability issue. Split into: + - `pages/HomePage.jsx` + - `pages/SetupPage.jsx` + - `pages/StudyPage.jsx` + - `components/ChunkEditor.jsx` + - `components/GreekWordStudy.jsx` + - `components/SuggestModal.jsx` +- **Custom hooks** — extract logic into `useProject()`, `useGreekLookup()`, `useAutosave()` + +### Sync / Persistence +- **No auth** — the server has zero authentication. Any user who can reach the server can read/overwrite/delete any project. Add at minimum an API key (env var in middleware) or user accounts +- **Conflict resolution is basic** — only compares `lastEdited` timestamps. Add a "which version do you want to keep?" UI to prevent silent data loss +- **Offline-first** — use a service worker / `workbox` so the app works offline and syncs when back online + +### Security (OWASP) +- **XSS via `dangerouslySetInnerHTML`** — `word.definitionHtml` is rendered raw. Add DOMPurify sanitization +- **No input validation on server** — add max-length and character validation on `id`/`title` fields (SQL injection is prevented by parameterized queries, but still) +- **CORS** — server has no CORS headers; any origin can call the API in production + +--- + +## Performance + +- **Verse data stored in project JSON** — full verse text is saved in localStorage and SQLite for every chapter. For multi-chapter projects this grows large. Consider storing only the chapter reference and re-fetching verses on load +- **JSON files cached in refs** — `nt-strongs-gloss.json` and `nt-strongs-concordance.json` should be served with proper `Cache-Control` headers +- **Autosave fires on all state changes** — the `[project]` dependency is too broad; it fires even when just selecting a chunk. Debounce only on content field changes + +--- + +## Testing + +- Add tests for: + - `migrateProject` with the old flat format + - `buildClaudePrompt` output structure + - `parseBibleChapter` with both API response shapes + - Autosave debounce behavior + +--- + +## Developer Experience + +- **No ESLint config** — add ESLint with `eslint-plugin-react` and `eslint-plugin-react-hooks` to catch missing `useEffect` deps +- **No TypeScript** — JSDoc types or a TS migration would catch shape mismatches between old/new project formats at compile time +- **No `docker-compose.yml`** — Dockerfile exists but there's no compose file for one-command local dev with server + SQLite volume From 7eadb8500b6c49aae6452ae0ce628466d8455ce4 Mon Sep 17 00:00:00 2001 From: nmemmert Date: Mon, 1 Jun 2026 15:38:11 -0400 Subject: [PATCH 2/2] Add OT books and separate Greek/Hebrew lookup --- src/App.jsx | 237 ++++++++++++++++++++++++++++++++++++++++++----- src/App.test.jsx | 22 ++--- 2 files changed, 225 insertions(+), 34 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 074c5ab..1aebd86 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -20,6 +20,45 @@ import { } from './syncService.js'; const bookOptions = [ + { name: 'Genesis', abbrev: 'GEN' }, + { name: 'Exodus', abbrev: 'EXO' }, + { name: 'Leviticus', abbrev: 'LEV' }, + { name: 'Numbers', abbrev: 'NUM' }, + { name: 'Deuteronomy', abbrev: 'DEU' }, + { name: 'Joshua', abbrev: 'JOS' }, + { name: 'Judges', abbrev: 'JDG' }, + { name: 'Ruth', abbrev: 'RUT' }, + { name: '1 Samuel', abbrev: '1SA' }, + { name: '2 Samuel', abbrev: '2SA' }, + { name: '1 Kings', abbrev: '1KI' }, + { name: '2 Kings', abbrev: '2KI' }, + { name: '1 Chronicles', abbrev: '1CH' }, + { name: '2 Chronicles', abbrev: '2CH' }, + { name: 'Ezra', abbrev: 'EZR' }, + { name: 'Nehemiah', abbrev: 'NEH' }, + { name: 'Esther', abbrev: 'EST' }, + { name: 'Job', abbrev: 'JOB' }, + { name: 'Psalms', abbrev: 'PSA' }, + { name: 'Proverbs', abbrev: 'PRO' }, + { name: 'Ecclesiastes', abbrev: 'ECC' }, + { name: 'Song', abbrev: 'SNG' }, + { name: 'Isaiah', abbrev: 'ISA' }, + { name: 'Jeremiah', abbrev: 'JER' }, + { name: 'Lamentations', abbrev: 'LAM' }, + { name: 'Ezekiel', abbrev: 'EZK' }, + { name: 'Daniel', abbrev: 'DAN' }, + { name: 'Hosea', abbrev: 'HOS' }, + { name: 'Joel', abbrev: 'JOL' }, + { name: 'Amos', abbrev: 'AMO' }, + { name: 'Obadiah', abbrev: 'OBA' }, + { name: 'Jonah', abbrev: 'JON' }, + { name: 'Micah', abbrev: 'MIC' }, + { name: 'Nahum', abbrev: 'NAM' }, + { name: 'Habakkuk', abbrev: 'HAB' }, + { name: 'Zephaniah', abbrev: 'ZEP' }, + { name: 'Haggai', abbrev: 'HAG' }, + { name: 'Zechariah', abbrev: 'ZEC' }, + { name: 'Malachi', abbrev: 'MAL' }, { name: 'Matthew', abbrev: 'MAT' }, { name: 'Mark', abbrev: 'MRK' }, { name: 'Luke', abbrev: 'LUK' }, @@ -536,6 +575,7 @@ const App = () => { const [remoteOnlyProjects, setRemoteOnlyProjects] = useState([]); // projects on server not in localStorage const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer const [suggestingGreekForChunkId, setSuggestingGreekForChunkId] = useState(null); + const [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null); // suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] } const [suggestModal, setSuggestModal] = useState(null); const [suggestSelection, setSuggestSelection] = useState(new Set()); @@ -929,6 +969,9 @@ const App = () => { // Cache for the NT Strong's→English gloss map (from macula-greek dataset), loaded once const _glossRef = useRef(null); const _glossLoadingRef = useRef(null); + // Cache for the Hebrew Strong's dictionary (loaded once) + const _hebrewDictRef = useRef(null); + const _hebrewDictLoadingRef = useRef(null); const loadNtGloss = async () => { if (_glossRef.current) return _glossRef.current; @@ -956,6 +999,24 @@ const App = () => { return _concordanceLoadingRef.current; }; + const loadHebrewDict = async () => { + if (_hebrewDictRef.current) return _hebrewDictRef.current; + if (_hebrewDictLoadingRef.current) return _hebrewDictLoadingRef.current; + _hebrewDictLoadingRef.current = fetch( + 'https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/hebrew/strongs-hebrew-dictionary.js', + ) + .then((res) => res.text()) + .then((text) => { + const start = text.indexOf('{'); + const end = text.lastIndexOf('}') + 1; + const dict = JSON.parse(text.slice(start, end)); + _hebrewDictRef.current = dict; + _hebrewDictLoadingRef.current = null; + return dict; + }); + return _hebrewDictLoadingRef.current; + }; + // Common Greek NT function words to pre-uncheck in the picker // (articles, prepositions, conjunctions, particles, common pronouns) const GREEK_FUNCTION_WORDS = new Set([ @@ -1028,7 +1089,12 @@ const App = () => { .filter((w) => !GREEK_FUNCTION_WORDS.has(w.strongKey)) .map((w) => w.strongKey) ); - setSuggestModal({ chunkId, words: modalWords }); + setSuggestModal({ + chunkId, + language: 'greek', + helperText: "Content words are pre-checked. Uncheck any you don't need.", + words: modalWords, + }); setSuggestSelection(preSelected); } catch (err) { setStatusMessage(`Suggest failed: ${err.message}`); @@ -1038,6 +1104,99 @@ const App = () => { } }; + const ENGLISH_STOP_WORDS = new Set([ + 'the', 'and', 'for', 'that', 'with', 'this', 'from', 'were', 'was', 'have', 'has', + 'had', 'are', 'but', 'not', 'you', 'your', 'his', 'her', 'their', 'they', 'them', + 'our', 'out', 'into', 'over', 'under', 'upon', 'then', 'than', 'who', 'what', 'when', + 'where', 'why', 'how', 'also', 'there', 'here', 'all', 'any', 'one', 'two', 'three', + 'he', 'she', 'it', 'we', 'i', 'me', 'my', 'mine', 'ours', 'theirs', 'its', + ]); + + const suggestHebrewWordsForChunk = async (chunkId) => { + const chunk = allChunks.find((c) => c.id === chunkId); + const chapter = project?.chapters.find((ch) => + ch.chunks.some((c) => c.id === chunkId) + ); + if (!chunk || !chapter) return; + if (NT_BOOK_NUMBER[chapter.bookAbbrev]) { + setStatusMessage('Hebrew suggestions are for Old Testament passages.'); + window.setTimeout(() => setStatusMessage(''), 2500); + return; + } + + setSuggestingHebrewForChunkId(chunkId); + try { + const dict = await loadHebrewDict(); + const versesInChunk = (chapter.verses ?? []) + .filter((v) => v.number >= chunk.startVerse && v.number <= chunk.endVerse) + .map((v) => v.text) + .join(' ') + .toLowerCase(); + + const candidateWords = Array.from(new Set( + versesInChunk + .replace(/[^a-z\s]/g, ' ') + .split(/\s+/) + .map((w) => w.trim()) + .filter((w) => w.length >= 4 && !ENGLISH_STOP_WORDS.has(w)), + )); + + if (candidateWords.length === 0) { + setStatusMessage('No Hebrew suggestion candidates found in this passage.'); + window.setTimeout(() => setStatusMessage(''), 2500); + return; + } + + const existingNumbers = new Set( + chunk.greekWords.map((w) => (w.strongNumber || '').toUpperCase()).filter(Boolean) + ); + + const seen = new Set(); + const modalWords = []; + const entries = Object.entries(dict); + + for (const token of candidateWords) { + const match = entries.find(([, entry]) => { + const defs = `${entry.kjv_def || ''} ${entry.strongs_def || ''}`.toLowerCase(); + return defs.split(/[,;()\s]+/).includes(token); + }); + if (!match) continue; + const [strongKey, entry] = match; + if (!/^H\d+$/i.test(strongKey)) continue; + const normalized = strongKey.toUpperCase(); + if (existingNumbers.has(normalized) || seen.has(normalized)) continue; + seen.add(normalized); + modalWords.push({ + strongKey: normalized, + lexeme: entry?.lemma ?? '', + translit: entry?.xlit ?? '', + def: entry?.kjv_def ?? 'No definition found.', + entry, + }); + if (modalWords.length >= 20) break; + } + + if (modalWords.length === 0) { + setStatusMessage('No Hebrew suggestions found from this passage text.'); + window.setTimeout(() => setStatusMessage(''), 2500); + return; + } + + setSuggestModal({ + chunkId, + language: 'hebrew', + helperText: 'Heuristic matches from passage text; uncheck anything not useful.', + words: modalWords, + }); + setSuggestSelection(new Set(modalWords.map((w) => w.strongKey))); + } catch (err) { + setStatusMessage(`Hebrew suggest failed: ${err.message}`); + window.setTimeout(() => setStatusMessage(''), 3000); + } finally { + setSuggestingHebrewForChunkId(null); + } + }; + const confirmSuggestWords = () => { if (!suggestModal) return; const { chunkId, words } = suggestModal; @@ -1065,7 +1224,8 @@ const App = () => { ), })), })); - setStatusMessage(`Added ${newWords.length} Greek word${newWords.length > 1 ? 's' : ''}.`); + const label = suggestModal?.language === 'hebrew' ? 'Hebrew' : 'Greek'; + setStatusMessage(`Added ${newWords.length} ${label} word${newWords.length > 1 ? 's' : ''}.`); window.setTimeout(() => setStatusMessage(''), 2000); } setSuggestModal(null); @@ -1077,16 +1237,24 @@ const App = () => { const externalLookupLinks = (query) => { const raw = query.trim(); - const normalized = /^\d+$/.test(raw) ? `G${raw}` : raw.toUpperCase(); - const isStrongs = /^G\d+$/.test(normalized); - const num = isStrongs ? normalized.slice(1) : null; - if (isStrongs && num) { + const normalized = raw.toUpperCase(); + const isGreek = /^G\d+$/.test(normalized); + const isHebrew = /^H\d+$/.test(normalized); + const num = (isGreek || isHebrew) ? normalized.slice(1) : null; + if (isGreek && num) { return [ { label: 'BibleHub', url: `https://biblehub.com/greek/${num}.htm` }, { label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/` }, { label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/greek/${num}.html` }, ]; } + if (isHebrew && num) { + return [ + { label: 'BibleHub', url: `https://biblehub.com/hebrew/${num}.htm` }, + { label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/h${num}/kjv/wlc/0-1/` }, + { label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/hebrew/${num}.html` }, + ]; + } const enc = encodeURIComponent(raw); return [ { label: 'BibleHub', url: `https://biblehub.com/search.php?q=${enc}` }, @@ -1171,15 +1339,20 @@ const App = () => { return null; }; - const lookupGreekWord = async (chunkId, wordId) => { + const lookupWord = async (chunkId, wordId, language = 'greek') => { const chunk = allChunks.find((c) => c.id === chunkId); const word = chunk?.greekWords.find((w) => w.id === wordId); if (!word || !word.query.trim()) return; updateChunkWord(chunkId, wordId, { loading: true }); try { - // Normalize bare numbers to G prefix: "4102" → "G4102" (Greek, not Hebrew H4102). + // Normalize bare numbers based on selected lookup language. const raw = word.query.trim(); - const normalized = /^\d+$/.test(raw) ? `G${raw}` : /^[gGhH]\d+$/.test(raw) ? raw.toUpperCase() : raw; + const inferredPrefix = language === 'hebrew' ? 'H' : 'G'; + const normalized = /^\d+$/.test(raw) + ? `${inferredPrefix}${raw}` + : /^[gGhH]\d+$/.test(raw) + ? raw.toUpperCase() + : raw; const [definitions, gloss] = await Promise.all([ fetchBollsDefinition(normalized), @@ -1987,8 +2160,8 @@ const restoreRemoteProject = async (id) => {
-

Greek Word Studies

-

Add lexical notes, look up Strong's entries.

+

Word Studies (Greek/Hebrew)

+

Add lexical notes, look up Strong's entries, and suggest words from the passage.

+ +
+ + +