Wire Strong's dictionary concordance UI

This commit is contained in:
2026-07-12 07:56:26 -05:00
parent 6877fa78f2
commit 81ba163207
11 changed files with 364 additions and 31 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.1.2 - 2026-07-12
- Wired Libre Study to import the LibreBible Strong's dictionary package alongside KJV Strong's links.
- Added linked-word selection, focused Strong's definition display, and one-click search for matching Strong's-number verses.
- Verified native SQLite import with 31,102 verses, 348,884 KJV Strong's links, and 14,197 Strong's dictionary entries.
## 0.1.1 - 2026-07-12
- Phase 4 Libre Study consumption: the app can discover the KJV package from a LibreBible `packages/json/catalog.json` catalog, import files declared by the resource catalog, and retain a local record of the imported LibreBible resource.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "libre-study",
"version": "0.1.1",
"version": "0.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "libre-study",
"version": "0.1.1",
"version": "0.1.2",
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"react": "^19.2.7",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "libre-study",
"private": true,
"version": "0.1.1",
"version": "0.1.2",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "app"
version = "0.1.1"
version = "0.1.2"
dependencies = [
"log",
"rusqlite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.1.1"
version = "0.1.2"
description = "A Tauri App"
authors = ["you"]
license = ""
+208 -13
View File
@@ -49,6 +49,8 @@ struct Verse {
#[derive(Serialize)]
struct StrongLink {
strong: String,
surface: String,
position: i64,
lemma: String,
transliteration: String,
definition: String,
@@ -104,6 +106,7 @@ struct LibreBibleResourceCatalog {
struct LibreBiblePackageFiles {
verses_jsonl: Option<LibreBiblePackageFile>,
strongs_jsonl: Option<LibreBiblePackageFile>,
entries_jsonl: Option<LibreBiblePackageFile>,
}
#[derive(Deserialize)]
@@ -117,6 +120,18 @@ struct LibreBiblePackageSource {
package_dir: PathBuf,
verses_path: PathBuf,
strongs_path: Option<PathBuf>,
lexicon_path: Option<PathBuf>,
}
#[derive(Deserialize)]
struct ImportedStrongEntry {
strongs_number: String,
language: String,
lemma: Option<String>,
transliteration: Option<String>,
pronunciation: Option<String>,
strongs_definition: Option<String>,
kjv_definition: Option<String>,
}
#[tauri::command]
@@ -251,7 +266,17 @@ fn migrate(conn: &Connection) -> Result<(), String> {
);
",
)
.map_err(|err| err.to_string())
.map_err(|err| err.to_string())?;
add_column_if_missing(conn, "verse_strongs", "surface", "TEXT NOT NULL DEFAULT ''")?;
add_column_if_missing(conn, "lexicon", "pronunciation", "TEXT NOT NULL DEFAULT ''")?;
add_column_if_missing(
conn,
"lexicon",
"kjv_definition",
"TEXT NOT NULL DEFAULT ''",
)?;
Ok(())
}
fn seed(conn: &mut Connection) -> Result<(), String> {
@@ -263,7 +288,7 @@ fn seed(conn: &mut Connection) -> Result<(), String> {
let source_count: i64 = conn
.query_row("SELECT COUNT(*) FROM library_sources", [], |row| row.get(0))
.map_err(|err| err.to_string())?;
if source_count == 0 {
if source_count == 0 || needs_resource_refresh(conn)? {
if let Some(package_source) = find_default_package_source() {
reset_resource_cache(conn)?;
import_package(conn, package_source)?;
@@ -397,12 +422,14 @@ fn load_package_source_from_catalog(
if let Some(package_path) = &resource.package_path {
let resource_catalog_path = resolve_catalog_relative(catalog_dir, package_path);
return load_package_source_from_resource_catalog(resource_catalog_path);
let mut source = load_package_source_from_resource_catalog(resource_catalog_path)?;
source.lexicon_path = find_catalog_lexicon_path(catalog_dir, &index);
return Ok(source);
}
if let Some(files) = &resource.files {
let package_dir = catalog_dir.join(&resource.id);
return package_source_from_files(
let mut source = package_source_from_files(
resource.id.clone(),
resource
.title
@@ -410,7 +437,9 @@ fn load_package_source_from_catalog(
.unwrap_or_else(|| resource.id.clone()),
package_dir,
files,
);
)?;
source.lexicon_path = find_catalog_lexicon_path(catalog_dir, &index);
return Ok(source);
}
}
@@ -468,6 +497,7 @@ fn load_package_source_from_package_dir(
strongs_jsonl: Some(LibreBiblePackageFile {
path: "strongs-links.jsonl".to_string(),
}),
entries_jsonl: None,
};
package_source_from_files(
@@ -504,6 +534,11 @@ fn package_source_from_files(
.as_ref()
.map(|file| package_dir.join(&file.path))
.filter(|path| path.exists());
let lexicon_path = files
.entries_jsonl
.as_ref()
.map(|file| package_dir.join(&file.path))
.filter(|path| path.exists());
Ok(LibreBiblePackageSource {
resource_id,
@@ -511,6 +546,7 @@ fn package_source_from_files(
package_dir,
verses_path,
strongs_path,
lexicon_path,
})
}
@@ -575,6 +611,47 @@ fn import_package(
}
}
if let Some(lexicon_path) = package_source.lexicon_path {
let file = File::open(&lexicon_path)
.map_err(|err| format!("Unable to open {}: {err}", lexicon_path.display()))?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line.map_err(|err| err.to_string())?;
if line.trim().is_empty() {
continue;
}
let imported: ImportedStrongEntry =
serde_json::from_str(&line).map_err(|err| err.to_string())?;
let language = match imported.language.as_str() {
"hebrew" => "Hebrew",
"greek" => "Greek",
other => other,
};
let definition = imported
.strongs_definition
.clone()
.or(imported.kjv_definition.clone())
.unwrap_or_default();
tx.execute(
"INSERT OR REPLACE INTO lexicon
(strong, lemma, transliteration, pronunciation, definition, kjv_definition, language)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
imported.strongs_number,
imported.lemma.unwrap_or_default(),
imported.transliteration.unwrap_or_default(),
imported.pronunciation.unwrap_or_default(),
definition,
imported.kjv_definition.unwrap_or_default(),
language
],
)
.map_err(|err| err.to_string())?;
}
}
if let Some(strongs_path) = package_source.strongs_path {
let file = File::open(&strongs_path)
.map_err(|err| format!("Unable to open {}: {err}", strongs_path.display()))?;
@@ -597,14 +674,21 @@ fn import_package(
};
tx.execute(
"INSERT OR IGNORE INTO lexicon (strong, lemma, transliteration, definition, language)
"INSERT OR IGNORE INTO lexicon
(strong, lemma, transliteration, definition, language)
VALUES (?1, ?2, '', 'Linked Strong''s number from the source USFM package.', ?3)",
params![imported.strong, imported.surface, language],
)
.map_err(|err| err.to_string())?;
tx.execute(
"INSERT OR IGNORE INTO verse_strongs (verse_id, strong, position) VALUES (?1, ?2, ?3)",
params![verse_id, imported.strong, imported.position],
"INSERT OR IGNORE INTO verse_strongs (verse_id, strong, position, surface)
VALUES (?1, ?2, ?3, ?4)",
params![
verse_id,
imported.strong,
imported.position,
imported.surface
],
)
.map_err(|err| err.to_string())?;
}
@@ -735,7 +819,7 @@ fn load_strongs(conn: &Connection, verse_id: i64) -> Result<Vec<StrongLink>, Str
let mut stmt = conn
.prepare(
"
SELECT l.strong, l.lemma, l.transliteration, l.definition, l.language
SELECT l.strong, vs.surface, vs.position, l.lemma, l.transliteration, l.definition, l.language
FROM verse_strongs vs
JOIN lexicon l ON l.strong = vs.strong
WHERE vs.verse_id = ?1
@@ -748,10 +832,12 @@ fn load_strongs(conn: &Connection, verse_id: i64) -> Result<Vec<StrongLink>, Str
.query_map(params![verse_id], |row| {
Ok(StrongLink {
strong: row.get(0)?,
lemma: row.get(1)?,
transliteration: row.get(2)?,
definition: row.get(3)?,
language: row.get(4)?,
surface: row.get(1)?,
position: row.get(2)?,
lemma: row.get(3)?,
transliteration: row.get(4)?,
definition: row.get(5)?,
language: row.get(6)?,
})
})
.map_err(|err| err.to_string())?;
@@ -763,6 +849,103 @@ fn load_strongs(conn: &Connection, verse_id: i64) -> Result<Vec<StrongLink>, Str
Ok(entries)
}
fn add_column_if_missing(
conn: &Connection,
table: &str,
column: &str,
definition: &str,
) -> Result<(), String> {
let mut stmt = conn
.prepare(&format!("PRAGMA table_info({table})"))
.map_err(|err| err.to_string())?;
let columns = stmt
.query_map([], |row| row.get::<_, String>(1))
.map_err(|err| err.to_string())?;
for existing in columns {
if existing.map_err(|err| err.to_string())? == column {
return Ok(());
}
}
conn.execute(
&format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
[],
)
.map_err(|err| err.to_string())?;
Ok(())
}
fn needs_resource_refresh(conn: &Connection) -> Result<bool, String> {
let lexicon_count: i64 = conn
.query_row("SELECT COUNT(*) FROM lexicon", [], |row| row.get(0))
.map_err(|err| err.to_string())?;
let real_definition_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM lexicon WHERE definition <> 'Linked Strong''s number from the source USFM package.'",
[],
|row| row.get(0),
)
.map_err(|err| err.to_string())?;
Ok(lexicon_count < 10_000 || real_definition_count < 10_000)
}
fn find_catalog_lexicon_path(
catalog_dir: &Path,
index: &LibreBibleIndexCatalog,
) -> Option<PathBuf> {
for resource in &index.resources {
if resource.resource_type.as_deref() != Some("lexicon") {
continue;
}
if let Some(package_path) = &resource.package_path {
if let Ok(catalog) =
load_resource_catalog_for_files(resolve_catalog_relative(catalog_dir, package_path))
{
let package_dir = resolve_catalog_relative(catalog_dir, package_path)
.parent()
.map(Path::to_path_buf)?;
if let Some(file) = catalog.files.entries_jsonl {
let path = package_dir.join(file.path);
if path.exists() {
return Some(path);
}
}
}
}
if let Some(files) = &resource.files {
if let Some(file) = &files.entries_jsonl {
let path = catalog_dir.join(&resource.id).join(&file.path);
if path.exists() {
return Some(path);
}
}
}
}
None
}
fn load_resource_catalog_for_files(
catalog_path: PathBuf,
) -> Result<LibreBibleResourceCatalog, String> {
let catalog_text = fs::read_to_string(&catalog_path).map_err(|err| {
format!(
"Unable to read LibreBible resource catalog {}: {err}",
catalog_path.display()
)
})?;
serde_json::from_str(&catalog_text).map_err(|err| {
format!(
"Invalid LibreBible resource catalog {}: {err}",
catalog_path.display()
)
})
}
fn load_tags(conn: &Connection, verse_id: i64) -> Result<Vec<Tag>, String> {
let mut stmt = conn
.prepare(
@@ -851,9 +1034,21 @@ mod tests {
let source_count: i64 = conn
.query_row("SELECT COUNT(*) FROM library_sources", [], |row| row.get(0))
.map_err(|err| err.to_string())?;
let lexicon_count: i64 = conn
.query_row("SELECT COUNT(*) FROM lexicon", [], |row| row.get(0))
.map_err(|err| err.to_string())?;
let g25_definition: String = conn
.query_row(
"SELECT definition FROM lexicon WHERE strong = 'G25'",
[],
|row| row.get(0),
)
.map_err(|err| err.to_string())?;
assert_eq!(verse_count, 31_102);
assert_eq!(strongs_count, 348_884);
assert_eq!(lexicon_count, 14_197);
assert!(g25_definition.contains("to love"));
assert_eq!(source_count, 1);
Ok(())
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Libre Study",
"version": "0.1.1",
"version": "0.1.2",
"identifier": "org.librestudy.desktop",
"build": {
"frontendDist": "../dist",
+72 -4
View File
@@ -218,6 +218,37 @@ input:focus-visible {
line-height: 1.55;
}
.linked-word-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 22px;
padding-top: 18px;
border-top: 1px solid #e4e0d6;
}
.word-link {
display: inline-flex;
align-items: baseline;
gap: 7px;
padding: 7px 9px;
border-color: #cfc8b8;
background: #f3efe4;
color: #202124;
font-size: 13px;
}
.word-link small {
color: #657061;
font-size: 11px;
font-weight: 800;
}
.word-link.active {
border-color: #2f6f5e;
background: #e7f0eb;
}
.study-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr);
@@ -255,13 +286,17 @@ input:focus-visible {
padding-top: 14px;
}
.strong-entry {
border: 1px solid #e4e0d6;
.strong-focus {
display: grid;
gap: 9px;
margin-top: 14px;
padding: 16px;
border: 1px solid #b9cabf;
border-radius: 8px;
padding: 14px;
background: #fbfaf4;
background: #eef6f1;
}
.strong-focus div,
.strong-entry div {
display: flex;
justify-content: space-between;
@@ -271,6 +306,39 @@ input:focus-visible {
font-size: 12px;
}
.strong-focus h4 {
margin: 0;
color: #1f211e;
font-size: 22px;
}
.strong-focus p {
margin: 0;
color: #3f443d;
font-size: 14px;
line-height: 1.5;
}
.strong-focus button {
justify-self: start;
margin-top: 4px;
background: #2f6f5e;
color: #ffffff;
}
.strong-entry {
border: 1px solid #e4e0d6;
border-radius: 8px;
padding: 14px;
background: #fbfaf4;
cursor: pointer;
}
.strong-entry.active {
border-color: #2f6f5e;
background: #f3faf6;
}
.strong-entry h4 {
margin-top: 8px;
color: #1f211e;
+48 -2
View File
@@ -14,6 +14,7 @@ function App() {
const [verses, setVerses] = useState<Verse[]>([])
const [tags, setTags] = useState<Tag[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [selectedStrong, setSelectedStrong] = useState<string | null>(null)
const [tagInput, setTagInput] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -60,6 +61,19 @@ function App() {
() => verses.find((verse) => verse.id === selectedId) ?? verses[0],
[selectedId, verses],
)
const selectedStrongEntry = useMemo(() => {
if (!selectedVerse?.strongs.length) return null
return (
selectedVerse.strongs.find((entry) => entry.strong === selectedStrong) ??
selectedVerse.strongs[0]
)
}, [selectedStrong, selectedVerse])
function searchStrong(strong: string) {
setQuery(strong)
setSelectedStrong(strong)
runSearch(strong).catch((reason) => setError(String(reason)))
}
return (
<main className="app-shell">
@@ -154,6 +168,20 @@ function App() {
<article className="verse-card">
<p>{selectedVerse.text}</p>
<div className="linked-word-list" aria-label="Linked Strong's words">
{selectedVerse.strongs.map((entry) => (
<button
key={`${entry.strong}-${entry.position}`}
className={entry.strong === selectedStrongEntry?.strong ? 'word-link active' : 'word-link'}
type="button"
title={`${entry.surface}: ${entry.strong}`}
onClick={() => setSelectedStrong(entry.strong)}
>
<span>{entry.surface}</span>
<small>{entry.strong}</small>
</button>
))}
</div>
</article>
<div className="study-grid">
@@ -162,12 +190,30 @@ function App() {
<h3>Strong's Links</h3>
<span>{selectedVerse.strongs.length}</span>
</div>
{selectedStrongEntry ? (
<article className="strong-focus">
<div>
<strong>{selectedStrongEntry.strong}</strong>
<span>{selectedStrongEntry.language}</span>
</div>
<h4>{selectedStrongEntry.lemma}</h4>
<p className="translit">{selectedStrongEntry.transliteration}</p>
<p>{selectedStrongEntry.definition}</p>
<button type="button" onClick={() => searchStrong(selectedStrongEntry.strong)}>
Show matching verses
</button>
</article>
) : null}
<div className="strong-list">
{selectedVerse.strongs.map((entry) => (
<article key={entry.strong} className="strong-entry">
<article
key={`${entry.strong}-${entry.position}`}
className={entry.strong === selectedStrongEntry?.strong ? 'strong-entry active' : 'strong-entry'}
onClick={() => setSelectedStrong(entry.strong)}
>
<div>
<strong>{entry.strong}</strong>
<span>{entry.language}</span>
<span>{entry.surface}</span>
</div>
<h4>{entry.lemma}</h4>
<p className="translit">{entry.transliteration}</p>
+16
View File
@@ -19,6 +19,8 @@ const browserVerses: Verse[] = [
strongs: [
{
strong: 'H7225',
surface: 'beginning',
position: 1,
lemma: 'רֵאשִׁית',
transliteration: 'reshith',
definition: 'Beginning, first, chief, the first part.',
@@ -26,6 +28,8 @@ const browserVerses: Verse[] = [
},
{
strong: 'H430',
surface: 'God',
position: 2,
lemma: 'אֱלֹהִים',
transliteration: 'elohim',
definition: 'God, gods, judges, rulers; plural form often used of the one true God.',
@@ -43,6 +47,8 @@ const browserVerses: Verse[] = [
strongs: [
{
strong: 'H3068',
surface: 'LORD',
position: 1,
lemma: 'יְהוָֹה',
transliteration: 'YHWH',
definition: 'The covenant name of the LORD.',
@@ -50,6 +56,8 @@ const browserVerses: Verse[] = [
},
{
strong: 'H7462',
surface: 'shepherd',
position: 2,
lemma: 'רָעָה',
transliteration: 'raah',
definition: 'To shepherd, pasture, feed, tend a flock.',
@@ -67,6 +75,8 @@ const browserVerses: Verse[] = [
strongs: [
{
strong: 'G25',
surface: 'loved',
position: 1,
lemma: 'ἀγαπάω',
transliteration: 'agapao',
definition: 'To love, value, welcome, or take pleasure in.',
@@ -74,6 +84,8 @@ const browserVerses: Verse[] = [
},
{
strong: 'G4100',
surface: 'believeth',
position: 3,
lemma: 'πιστεύω',
transliteration: 'pisteuo',
definition: 'To believe, trust, have faith in.',
@@ -91,6 +103,8 @@ const browserVerses: Verse[] = [
strongs: [
{
strong: 'G2822',
surface: 'called',
position: 2,
lemma: 'κλητός',
transliteration: 'kletos',
definition: 'Called, invited, appointed.',
@@ -98,6 +112,8 @@ const browserVerses: Verse[] = [
},
{
strong: 'G4286',
surface: 'purpose',
position: 3,
lemma: 'πρόθεσις',
transliteration: 'prothesis',
definition: 'Purpose, setting forth, deliberate plan.',
+2
View File
@@ -10,6 +10,8 @@ export type Verse = {
export type StrongLink = {
strong: string
surface: string
position: number
lemma: string
transliteration: string
definition: string