diff --git a/src/components/GrammarPanel.jsx b/src/components/GrammarPanel.jsx new file mode 100644 index 0000000..d877652 --- /dev/null +++ b/src/components/GrammarPanel.jsx @@ -0,0 +1,19 @@ +// What an original-language word's grammar label means, in plain English. +import { grammarNotes } from '../utils/grammar.js'; + +export default function GrammarPanel({ label, className = '' }) { + const notes = grammarNotes(label); + if (!label) return null; + return ( +
+

Grammar · {label}

+ {notes.length > 0 && ( +
+ {notes.map((n) => ( +
{n.term}
{n.meaning}
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/study/ScripturePane.jsx b/src/components/study/ScripturePane.jsx index a5af161..c453382 100644 --- a/src/components/study/ScripturePane.jsx +++ b/src/components/study/ScripturePane.jsx @@ -2,10 +2,12 @@ // interlinear words; click an original word to add it to the chunk's word study. import { useState } from 'react'; import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx'; +import GrammarPanel from '../GrammarPanel.jsx'; export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) { const [openVerse, setOpenVerse] = useState(null); const [editingNote, setEditingNote] = useState(null); + const [inspect, setInspect] = useState(null); // the original word whose grammar is showing: { key, index } const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev]; return (
@@ -38,6 +40,7 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea const added = w.s && addedStrongs.has(w.s.toUpperCase()); return ( ); })} + {inspect?.key === `${v.chapter}:${v.number}` && words[inspect.index]?.p && ( + + )} )} diff --git a/src/components/study/WordStudyTab.jsx b/src/components/study/WordStudyTab.jsx index 0c5525a..ff669fb 100644 --- a/src/components/study/WordStudyTab.jsx +++ b/src/components/study/WordStudyTab.jsx @@ -6,6 +6,7 @@ import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js import { useIqAvailable } from '../../hooks/useIq.js'; import { normalizeStrongs } from '../../utils/iqbible.js'; import IqLexicon from '../iq/IqLexicon.jsx'; +import GrammarPanel from '../GrammarPanel.jsx'; const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50'; @@ -50,6 +51,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) { {field('Part of speech', 'partOfSpeech', 'e.g. Noun')} {field('English word', 'englishGloss', 'e.g. grace')} + {word.parsing && } {field('Short definition', 'shortDefinition')} {word.definitionHtml && (
diff --git a/src/hooks/useWordStudy.js b/src/hooks/useWordStudy.js index 8e3c050..c72fd4a 100644 --- a/src/hooks/useWordStudy.js +++ b/src/hooks/useWordStudy.js @@ -94,6 +94,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) { lexeme: w.o || entry?.lemma || '', transliteration: w.t || entry?.translit || entry?.xlit || '', partOfSpeech: '', + parsing: w.p || '', // the grammar label from the interlinear, explained in the word's card shortDefinition: entry?.kjv_def || w.g || '', definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '', loading: false, diff --git a/src/utils/grammar.js b/src/utils/grammar.js new file mode 100644 index 0000000..cedd6a8 --- /dev/null +++ b/src/utils/grammar.js @@ -0,0 +1,111 @@ +// Plain-English meanings for the grammar labels on the original-language words ("Verb - Aorist Indicative Active - +// 3rd Person Singular"). The labels come with the interlinear text; this only explains them, so a reader who never +// studied Greek or Hebrew can see what "aorist" or "Hiphil" tells them. Meanings are the usual textbook ones and +// are a guide, not a rule: the context decides. + +// Hebrew verbs name a "stem" (how the action is done); when one is present, Perfect, Imperfect and the rest +// mean the Hebrew thing, not the Greek one. +const HEBREW_STEMS = [ + ['qal', 'The basic, simple action ("he kept").'], + ['niphal', 'Often passive or reflexive ("it was kept", "he kept himself").'], + ['piel', 'Intensive or repeated action ("he kept carefully").'], + ['pual', 'The passive of the Piel ("it was kept carefully").'], + ['hithpael', 'Reflexive or repeated action ("he kept himself busy").'], + ['hiphil', 'Causing the action ("he made someone keep").'], + ['hophal', 'The passive of the Hiphil ("he was made to keep").'], +]; +const HEBREW_FORMS = [ + ['perfect', 'Action seen as complete, often past ("he kept").'], + ['imperfect', 'Action seen as incomplete: ongoing, repeated or still to come ("he keeps", "he will keep").'], + ['imperative', 'A command ("keep!").'], + ['infinitive construct', 'The verb as a noun, linked to what follows ("keeping of…").'], + ['infinitive absolute', 'The verb as a noun on its own, often to stress the action ("keeping, keep").'], + ['participle', 'A verb used like an adjective or noun ("keeping", "the one who keeps").'], +]; +const GREEK_TENSES = [ + ['present', 'Ongoing or repeated action ("is doing").'], + ['imperfect', 'Past action that was ongoing or repeated ("was doing").'], + ['future', 'Action that will happen ("will do").'], + ['aorist', 'Action seen as a whole, with no focus on how long it took. Often just "did".'], + ['perfect', 'A finished action whose results still stand ("has done").'], + ['pluperfect', 'A finished action whose results stood at some past time ("had done").'], +]; +const MOODS = [ + ['indicative', 'States something as a fact or asks a question.'], + ['subjunctive', 'Possibility, purpose or doubt ("may", "might", "let us").'], + ['imperative', 'A command or request.'], + ['optative', 'A wish or a remote possibility ("may it be").'], + ['infinitive', 'The verb as a noun ("to do").'], + ['participle', 'A verb used like an adjective ("-ing", "the one who…").'], +]; +const VOICES = [ + ['middle or passive', 'The form can mean the subject acts on itself, or receives the action; context decides.'], + ['middle', 'The subject acts on itself or for its own benefit.'], + ['passive', 'The subject receives the action ("was done").'], + ['active', 'The subject does the action.'], +]; +const CASES = [ + ['nominative', 'The subject of the sentence, or a name for something.'], + ['genitive', 'Belonging, source or description. Often "of".'], + ['dative', 'The one the action is to, for or by. Often "to", "for", "in", "with".'], + ['accusative', 'The direct object, the thing acted on.'], + ['vocative', 'The one being spoken to.'], +]; +const PARTS = [ + ['noun', 'A person, place or thing.'], + ['verb', 'An action or state.'], + ['adjective', 'Describes a noun.'], + ['adverb', 'Describes a verb, adjective or another adverb.'], + ['article', 'The word "the".'], + ['conjunction', 'Joins words or clauses ("and", "but", "for").'], + ['preposition', 'Shows how a noun relates to something else ("in", "to", "from").'], + ['personal pronoun', 'Stands in for a person ("he", "you").'], + ['relative pronoun', 'Links a clause back to a noun ("who", "which").'], + ['demonstrative pronoun', 'Points something out ("this", "that").'], + ['reflexive pronoun', 'Points back to the subject ("himself").'], + ['interrogative pronoun', 'Asks a question ("who?", "what?").'], + ['pronoun', 'Stands in for a noun.'], + ['particle', 'A small word that adds emphasis or links ideas.'], + ['interjection', 'An exclamation.'], + ['numeral', 'A number.'], +]; +const PREFIXES = [ + ['conjunctive waw', '"And" (or "but", "then") joined to the front of the next word.'], + ['preposition-b', '"In", "with" or "by", joined to the front of the next word.'], + ['preposition-l', '"To" or "for", joined to the front of the next word.'], + ['preposition-k', '"Like" or "as", joined to the front of the next word.'], + ['preposition-m', '"From", joined to the front of the next word.'], + ['construct', 'Linked to the next word, usually "of".'], +]; +const PERSONS = { '1st': 'I / we', first: 'I / we', '2nd': 'you', second: 'you', '3rd': 'he / she / it / they', third: 'he / she / it / they' }; + +const NUMBER_WORDS = { singular: 'one', plural: 'more than one', dual: 'two' }; + +// -> [{ term: 'Aorist', meaning: '…' }] in the order the terms appear in the label; [] when there is nothing to say. +export function grammarNotes(label) { + const text = String(label ?? '').toLowerCase(); + if (!text.trim()) return []; + const isHebrewVerb = HEBREW_STEMS.some(([t]) => new RegExp(`\\b${t}\\b`).test(text)); + const found = []; + const add = (table, tableName) => { + for (const [term, meaning] of table) { + const m = new RegExp(`(?:^|[^a-z-])${term.replace(/[-]/g, '[- ]?')}(?![a-z])`).exec(text); + if (m) found.push({ at: m.index, term, meaning, tableName }); + } + }; + add(PARTS, 'part'); + if (isHebrewVerb) { add(HEBREW_STEMS, 'stem'); add(HEBREW_FORMS, 'form'); } else { add(GREEK_TENSES, 'tense'); } + add(MOODS.filter(([t]) => !isHebrewVerb || (t !== 'indicative' && t !== 'subjunctive' && t !== 'optative' && !['imperative', 'infinitive', 'participle'].includes(t))), 'mood'); + if (!isHebrewVerb) add(VOICES, 'voice'); else add([['active', VOICES[3][1]], ['passive', VOICES[2][1]]], 'voice'); + add(CASES, 'case'); + add(PREFIXES, 'prefix'); + // the same label can match a short term inside a longer one ("middle" inside "middle or passive", "pronoun" inside "personal pronoun") + const covered = (a, b) => b !== a && b.term.length > a.term.length && b.term.replace(/-/g, ' ').includes(a.term.replace(/-/g, ' ')) && a.at >= b.at - 1 && a.at < b.at + b.term.length + 1; + const kept = found.filter((a) => !found.some((b) => covered(a, b) && (b.tableName === a.tableName || b.tableName === 'prefix'))); + const person = text.match(/\b(1st|2nd|3rd|first|second|third)\s+person\b/); + const number = text.match(/\b(singular|plural|dual)\b/); + const first = (t) => t.charAt(0).toUpperCase() + t.slice(1); + const notes = kept.sort((a, b) => a.at - b.at).map(({ term, meaning }) => ({ term: first(term), meaning })); + if (person) notes.push({ term: `${person[1]} person${number ? ` ${number[1]}` : ''}`.replace(/^\w/, (c) => c.toUpperCase()), meaning: `${PERSONS[person[1]]}${number ? ` (${NUMBER_WORDS[number[1]]})` : ''}.` }); + return notes; +} diff --git a/src/utils/grammar.test.js b/src/utils/grammar.test.js new file mode 100644 index 0000000..e1448bf --- /dev/null +++ b/src/utils/grammar.test.js @@ -0,0 +1,41 @@ +import { test, expect } from 'vitest'; +import { grammarNotes } from './grammar.js'; + +const terms = (label) => grammarNotes(label).map((n) => n.term); + +test('a Greek verb is explained part by part', () => { + const notes = grammarNotes('Verb - Aorist Indicative Active - 3rd Person Singular'); + expect(notes.map((n) => n.term)).toEqual(['Verb', 'Aorist', 'Indicative', 'Active', '3rd person singular']); + expect(notes.find((n) => n.term === 'Aorist').meaning).toMatch(/as a whole/); + expect(notes.at(-1).meaning).toBe('he / she / it / they (one).'); +}); + +test('nouns and articles give their case', () => { + expect(terms('Noun - Nominative Masculine Singular')).toEqual(['Noun', 'Nominative']); + expect(terms('Article - Genitive Neuter Plural')).toEqual(['Article', 'Genitive']); + expect(terms('Personal / Relative Pronoun - Accusative 1st Person Singular')).toContain('Accusative'); +}); + +test('moods, participles and voices that share words are not double counted', () => { + expect(terms('Verb - Present Participle Middle or Passive - Nominative Masculine Singular')).toEqual(['Verb', 'Present', 'Participle', 'Middle or passive', 'Nominative']); + expect(terms('Verb - Perfect Infinitive Active')).toEqual(['Verb', 'Perfect', 'Infinitive', 'Active']); +}); + +test('a Hebrew verb is read with Hebrew meanings, not Greek ones', () => { + const notes = grammarNotes('Verb - Qal - Perfect - third person masculine singular'); + expect(notes.map((n) => n.term)).toEqual(['Verb', 'Qal', 'Perfect', 'Third person singular']); + expect(notes.find((n) => n.term === 'Perfect').meaning).toMatch(/complete/); + expect(grammarNotes('Verb - Hiphil - Imperfect - third person masculine singular').find((n) => n.term === 'Hiphil').meaning).toMatch(/Causing/); +}); + +test('Hebrew words with attached prefixes explain the prefix', () => { + const notes = grammarNotes('Preposition-b | Noun - feminine singular'); + expect(notes.map((n) => n.term)).toEqual(expect.arrayContaining(['Noun', 'Preposition-b'])); + expect(notes.find((n) => n.term === 'Preposition-b').meaning).toMatch(/joined to the front/); +}); + +test('nothing to explain gives an empty list', () => { + expect(grammarNotes('')).toEqual([]); + expect(grammarNotes(null)).toEqual([]); + expect(grammarNotes('???')).toEqual([]); +});