Phase 4 consume LibreBible catalogs

This commit is contained in:
2026-07-11 22:19:48 -05:00
parent 8ff42bf4ed
commit 6877fa78f2
8 changed files with 334 additions and 23 deletions
+315 -17
View File
@@ -26,7 +26,7 @@ use std::collections::HashMap;
use std::env;
use std::fs::{self, File};
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager};
#[derive(Serialize)]
@@ -79,6 +79,46 @@ struct ImportedStrongLink {
strong: String,
}
#[derive(Deserialize)]
struct LibreBibleIndexCatalog {
resources: Vec<LibreBibleResourceSummary>,
}
#[derive(Deserialize)]
struct LibreBibleResourceSummary {
id: String,
resource_type: Option<String>,
title: Option<String>,
package_path: Option<String>,
files: Option<LibreBiblePackageFiles>,
}
#[derive(Deserialize)]
struct LibreBibleResourceCatalog {
id: String,
title: Option<String>,
files: LibreBiblePackageFiles,
}
#[derive(Deserialize)]
struct LibreBiblePackageFiles {
verses_jsonl: Option<LibreBiblePackageFile>,
strongs_jsonl: Option<LibreBiblePackageFile>,
}
#[derive(Deserialize)]
struct LibreBiblePackageFile {
path: String,
}
struct LibreBiblePackageSource {
resource_id: String,
title: String,
package_dir: PathBuf,
verses_path: PathBuf,
strongs_path: Option<PathBuf>,
}
#[tauri::command]
fn get_library_state(app: AppHandle) -> Result<LibraryState, String> {
let conn = open_db(&app)?;
@@ -183,7 +223,7 @@ fn migrate(conn: &Connection) -> Result<(), String> {
verse_id INTEGER NOT NULL,
strong TEXT NOT NULL,
position INTEGER NOT NULL,
PRIMARY KEY (verse_id, strong),
PRIMARY KEY (verse_id, strong, position),
FOREIGN KEY (verse_id) REFERENCES verses(id),
FOREIGN KEY (strong) REFERENCES lexicon(strong)
);
@@ -194,6 +234,14 @@ fn migrate(conn: &Connection) -> Result<(), String> {
color TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS library_sources (
id INTEGER PRIMARY KEY,
resource_id TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
package_dir TEXT NOT NULL,
imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS verse_tags (
verse_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
@@ -212,11 +260,20 @@ fn seed(conn: &mut Connection) -> Result<(), String> {
.map_err(|err| err.to_string())?;
if verse_count > 0 {
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 let Some(package_source) = find_default_package_source() {
reset_resource_cache(conn)?;
import_package(conn, package_source)?;
}
}
return Ok(());
}
if let Some(package_dir) = find_default_package_dir() {
import_package(conn, package_dir)?;
if let Some(package_source) = find_default_package_source() {
import_package(conn, package_source)?;
return Ok(());
}
@@ -250,12 +307,29 @@ fn seed(conn: &mut Connection) -> Result<(), String> {
conn.execute_batch(seed_sql).map_err(|err| err.to_string())
}
fn find_default_package_dir() -> Option<PathBuf> {
fn reset_resource_cache(conn: &Connection) -> Result<(), String> {
conn.execute_batch(
"
DELETE FROM verse_tags;
DELETE FROM verse_strongs;
DELETE FROM lexicon;
DELETE FROM verses;
DELETE FROM library_sources;
",
)
.map_err(|err| err.to_string())
}
fn find_default_package_source() -> Option<LibreBiblePackageSource> {
if let Ok(path) = env::var("LIBREBIBLE_CATALOG_PATH") {
if let Ok(source) = load_package_source_from_catalog(PathBuf::from(path)) {
return Some(source);
}
}
if let Ok(path) = env::var("LIBRE_BIBLE_DATA_PACKAGE") {
let candidate = PathBuf::from(path);
if candidate.join("verses.jsonl").exists() && candidate.join("strongs-links.jsonl").exists()
{
return Some(candidate);
if let Ok(source) = load_package_source_from_package_dir(PathBuf::from(path)) {
return Some(source);
}
}
@@ -266,7 +340,7 @@ fn find_default_package_dir() -> Option<PathBuf> {
.join("libre-bible-data")
.join("packages")
.join("json")
.join("kjv-eng-kjv2006"),
.join("catalog.json"),
);
if let Some(parent) = cwd.parent() {
@@ -275,21 +349,204 @@ fn find_default_package_dir() -> Option<PathBuf> {
.join("libre-bible-data")
.join("packages")
.join("json")
.join("kjv-eng-kjv2006"),
.join("catalog.json"),
);
}
candidates.into_iter().find(|path| {
path.join("verses.jsonl").exists() && path.join("strongs-links.jsonl").exists()
for candidate in candidates {
if let Ok(source) = load_package_source_from_catalog(candidate) {
return Some(source);
}
}
None
}
fn load_package_source_from_catalog(
catalog_path: PathBuf,
) -> Result<LibreBiblePackageSource, String> {
let catalog_text = fs::read_to_string(&catalog_path).map_err(|err| {
format!(
"Unable to read LibreBible catalog {}: {err}",
catalog_path.display()
)
})?;
let catalog_dir = catalog_path
.parent()
.ok_or_else(|| format!("Catalog path has no parent: {}", catalog_path.display()))?;
if let Ok(index) = serde_json::from_str::<LibreBibleIndexCatalog>(&catalog_text) {
let selected_id =
env::var("LIBREBIBLE_RESOURCE_ID").unwrap_or_else(|_| "kjv-eng-kjv2006".to_string());
let resource = index
.resources
.iter()
.find(|resource| resource.id == selected_id)
.or_else(|| {
index
.resources
.iter()
.find(|resource| resource.resource_type.as_deref() == Some("translation"))
})
.ok_or_else(|| {
format!(
"LibreBible catalog {} does not contain an importable translation.",
catalog_path.display()
)
})?;
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);
}
if let Some(files) = &resource.files {
let package_dir = catalog_dir.join(&resource.id);
return package_source_from_files(
resource.id.clone(),
resource
.title
.clone()
.unwrap_or_else(|| resource.id.clone()),
package_dir,
files,
);
}
}
load_package_source_from_resource_catalog(catalog_path)
}
fn load_package_source_from_resource_catalog(
catalog_path: PathBuf,
) -> Result<LibreBiblePackageSource, String> {
let catalog_text = fs::read_to_string(&catalog_path).map_err(|err| {
format!(
"Unable to read LibreBible resource catalog {}: {err}",
catalog_path.display()
)
})?;
let catalog: LibreBibleResourceCatalog =
serde_json::from_str(&catalog_text).map_err(|err| {
format!(
"Invalid LibreBible resource catalog {}: {err}",
catalog_path.display()
)
})?;
let package_dir = catalog_path
.parent()
.ok_or_else(|| {
format!(
"Resource catalog path has no parent: {}",
catalog_path.display()
)
})?
.to_path_buf();
package_source_from_files(
catalog.id,
catalog
.title
.unwrap_or_else(|| "LibreBible resource".to_string()),
package_dir,
&catalog.files,
)
}
fn load_package_source_from_package_dir(
package_dir: PathBuf,
) -> Result<LibreBiblePackageSource, String> {
let catalog_path = package_dir.join("catalog.json");
if catalog_path.exists() {
return load_package_source_from_resource_catalog(catalog_path);
}
let files = LibreBiblePackageFiles {
verses_jsonl: Some(LibreBiblePackageFile {
path: "verses.jsonl".to_string(),
}),
strongs_jsonl: Some(LibreBiblePackageFile {
path: "strongs-links.jsonl".to_string(),
}),
};
package_source_from_files(
package_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("legacy-package")
.to_string(),
"Legacy LibreBible package".to_string(),
package_dir,
&files,
)
}
fn package_source_from_files(
resource_id: String,
title: String,
package_dir: PathBuf,
files: &LibreBiblePackageFiles,
) -> Result<LibreBiblePackageSource, String> {
let verses_file = files.verses_jsonl.as_ref().ok_or_else(|| {
format!("LibreBible resource {resource_id} does not define verses_jsonl.")
})?;
let verses_path = package_dir.join(&verses_file.path);
if !verses_path.exists() {
return Err(format!(
"LibreBible verses file is missing: {}",
verses_path.display()
));
}
let strongs_path = files
.strongs_jsonl
.as_ref()
.map(|file| package_dir.join(&file.path))
.filter(|path| path.exists());
Ok(LibreBiblePackageSource {
resource_id,
title,
package_dir,
verses_path,
strongs_path,
})
}
fn import_package(conn: &mut Connection, package_dir: PathBuf) -> Result<(), String> {
let verses_path = package_dir.join("verses.jsonl");
let strongs_path = package_dir.join("strongs-links.jsonl");
fn resolve_catalog_relative(catalog_dir: &Path, package_path: &str) -> PathBuf {
let candidate = PathBuf::from(package_path);
if candidate.is_absolute() {
return candidate;
}
let from_repo_root = catalog_dir.join("..").join("..").join(package_path);
if from_repo_root.exists() {
return from_repo_root;
}
catalog_dir.join(package_path)
}
fn import_package(
conn: &mut Connection,
package_source: LibreBiblePackageSource,
) -> Result<(), String> {
let verses_path = package_source.verses_path.clone();
let tx = conn.transaction().map_err(|err| err.to_string())?;
let mut verse_ids = HashMap::new();
tx.execute(
"INSERT OR REPLACE INTO library_sources (resource_id, title, package_dir, imported_at)
VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP)",
params![
package_source.resource_id,
package_source.title,
package_source.package_dir.to_string_lossy().to_string()
],
)
.map_err(|err| err.to_string())?;
{
let file = File::open(&verses_path)
.map_err(|err| format!("Unable to open {}: {err}", verses_path.display()))?;
@@ -318,7 +575,7 @@ fn import_package(conn: &mut Connection, package_dir: PathBuf) -> Result<(), Str
}
}
{
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()))?;
let reader = BufReader::new(file);
@@ -561,3 +818,44 @@ fn looks_like_strongs(query: &str) -> bool {
let mut chars = query.chars();
matches!(chars.next(), Some('G' | 'g' | 'H' | 'h')) && chars.all(|char| char.is_ascii_digit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn imports_sibling_librebible_catalog_when_available() -> Result<(), String> {
let catalog_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("libre-bible-data")
.join("packages")
.join("json")
.join("catalog.json");
if !catalog_path.exists() {
return Ok(());
}
let source = load_package_source_from_catalog(catalog_path)?;
let mut conn = Connection::open_in_memory().map_err(|err| err.to_string())?;
migrate(&conn)?;
import_package(&mut conn, source)?;
let verse_count: i64 = conn
.query_row("SELECT COUNT(*) FROM verses", [], |row| row.get(0))
.map_err(|err| err.to_string())?;
let strongs_count: i64 = conn
.query_row("SELECT COUNT(*) FROM verse_strongs", [], |row| row.get(0))
.map_err(|err| err.to_string())?;
let source_count: i64 = conn
.query_row("SELECT COUNT(*) FROM library_sources", [], |row| row.get(0))
.map_err(|err| err.to_string())?;
assert_eq!(verse_count, 31_102);
assert_eq!(strongs_count, 348_884);
assert_eq!(source_count, 1);
Ok(())
}
}