Explain grammar labels in plain English on original words and in word-study cards
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d5cc93f9bd
commit
fa9e825444
6 files changed
+180
No files matched your search
@@ -0,0 +1,19 @@
|
|||||||
|
// What an original-language word's grammar label means, in plain English.
|
||||||
|
import { grammarNotes } from '../utils/grammar.js';
|
||||||
|
|
||||||
|
export default function GrammarPanel({ label, className = '' }) {
|
||||||
|
const notes = grammarNotes(label);
|
||||||
|
if (!label) return null;
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2.5 font-sans text-xs ${className}`} aria-label="Grammar">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Grammar · {label}</p>
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<dl className="mt-1.5 space-y-1">
|
||||||
|
{notes.map((n) => (
|
||||||
|
<div key={n.term} className="flex gap-2"><dt className="w-32 shrink-0 font-medium">{n.term}</dt><dd className="text-[var(--r-sub)]">{n.meaning}</dd></div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@
|
|||||||
// interlinear words; click an original word to add it to the chunk's word study.
|
// interlinear words; click an original word to add it to the chunk's word study.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
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 }) {
|
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
|
||||||
const [openVerse, setOpenVerse] = useState(null);
|
const [openVerse, setOpenVerse] = useState(null);
|
||||||
const [editingNote, setEditingNote] = 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];
|
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
|
||||||
return (
|
return (
|
||||||
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
||||||
@@ -38,6 +40,7 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
|||||||
const added = w.s && addedStrongs.has(w.s.toUpperCase());
|
const added = w.s && addedStrongs.has(w.s.toUpperCase());
|
||||||
return (
|
return (
|
||||||
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
|
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
|
||||||
|
onMouseEnter={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })} onFocus={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })}
|
||||||
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
|
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
|
||||||
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
|
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
|
||||||
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
|
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
|
||||||
@@ -45,6 +48,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{inspect?.key === `${v.chapter}:${v.number}` && words[inspect.index]?.p && (
|
||||||
|
<GrammarPanel label={words[inspect.index].p} className="basis-full" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js
|
|||||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||||
import { normalizeStrongs } from '../../utils/iqbible.js';
|
import { normalizeStrongs } from '../../utils/iqbible.js';
|
||||||
import IqLexicon from '../iq/IqLexicon.jsx';
|
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 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';
|
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('Part of speech', 'partOfSpeech', 'e.g. Noun')}
|
||||||
{field('English word', 'englishGloss', 'e.g. grace')}
|
{field('English word', 'englishGloss', 'e.g. grace')}
|
||||||
</div>
|
</div>
|
||||||
|
{word.parsing && <GrammarPanel label={word.parsing} />}
|
||||||
{field('Short definition', 'shortDefinition')}
|
{field('Short definition', 'shortDefinition')}
|
||||||
{word.definitionHtml && (
|
{word.definitionHtml && (
|
||||||
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
|
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
|
|||||||
lexeme: w.o || entry?.lemma || '',
|
lexeme: w.o || entry?.lemma || '',
|
||||||
transliteration: w.t || entry?.translit || entry?.xlit || '',
|
transliteration: w.t || entry?.translit || entry?.xlit || '',
|
||||||
partOfSpeech: '',
|
partOfSpeech: '',
|
||||||
|
parsing: w.p || '', // the grammar label from the interlinear, explained in the word's card
|
||||||
shortDefinition: entry?.kjv_def || w.g || '',
|
shortDefinition: entry?.kjv_def || w.g || '',
|
||||||
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
|
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user