diff --git a/CHANGELOG.md b/CHANGELOG.md index 9427127..15c1440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 128c175..86ad69c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index d88b891..868f421 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c9c999a..aa7101e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "0.1.1" +version = "0.1.2" dependencies = [ "log", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c4898f7..3cbec10 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.1.1" +version = "0.1.2" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0bf3605..eb35eb8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, strongs_jsonl: Option, + entries_jsonl: Option, } #[derive(Deserialize)] @@ -117,6 +120,18 @@ struct LibreBiblePackageSource { package_dir: PathBuf, verses_path: PathBuf, strongs_path: Option, + lexicon_path: Option, +} + +#[derive(Deserialize)] +struct ImportedStrongEntry { + strongs_number: String, + language: String, + lemma: Option, + transliteration: Option, + pronunciation: Option, + strongs_definition: Option, + kjv_definition: Option, } #[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,16 +674,23 @@ fn import_package( }; tx.execute( - "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())?; + "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], - ) - .map_err(|err| err.to_string())?; + "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, 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, 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, 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 { + 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 { + 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 { + 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, 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(()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2d4c212..611f975 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.css b/src/App.css index 652c46a..97e2ac6 100644 --- a/src/App.css +++ b/src/App.css @@ -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; diff --git a/src/App.tsx b/src/App.tsx index 1accc02..6debb03 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ function App() { const [verses, setVerses] = useState([]) const [tags, setTags] = useState([]) const [selectedId, setSelectedId] = useState(null) + const [selectedStrong, setSelectedStrong] = useState(null) const [tagInput, setTagInput] = useState('') const [loading, setLoading] = useState(true) const [error, setError] = useState(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 (
@@ -154,6 +168,20 @@ function App() {

{selectedVerse.text}

+
+ {selectedVerse.strongs.map((entry) => ( + + ))} +
@@ -162,12 +190,30 @@ function App() {

Strong's Links

{selectedVerse.strongs.length}
+ {selectedStrongEntry ? ( +
+
+ {selectedStrongEntry.strong} + {selectedStrongEntry.language} +
+

{selectedStrongEntry.lemma}

+

{selectedStrongEntry.transliteration}

+

{selectedStrongEntry.definition}

+ +
+ ) : null}
{selectedVerse.strongs.map((entry) => ( -
+
setSelectedStrong(entry.strong)} + >
{entry.strong} - {entry.language} + {entry.surface}

{entry.lemma}

{entry.transliteration}

diff --git a/src/studyClient.ts b/src/studyClient.ts index a0ba690..8b7dda0 100644 --- a/src/studyClient.ts +++ b/src/studyClient.ts @@ -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.', diff --git a/src/types.ts b/src/types.ts index 488cebd..f9d13d4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,8 @@ export type Verse = { export type StrongLink = { strong: string + surface: string + position: number lemma: string transliteration: string definition: string