diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9427127 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## 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. +- Adopted the convention that every named implementation phase bumps the app version and is committed locally and remotely. + +## 0.1.0 - 2026-07-12 + +- Initial Windows-first Tauri/React/SQLite scaffold with KJV/Strong's seed data, browser dev mode, native SQLite commands, search, and tags. diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 017859f..531f165 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -14,6 +14,8 @@ Libre Study is the desktop-first Bible study application. Its job is to provide - Local development happens in the workspace repo on drive `W:`. - The Christ Unscripted Gitea remote is the remote backup, collaboration, and publishing copy. - Do not treat work as durable until it is committed locally and pushed to Gitea. +- Every named implementation phase should bump the app version and be recorded in Git both locally and remotely. +- Phase completion means the version bump, code/docs changes, and validation result are committed locally and pushed to Gitea. - The normal flow is local edit, local verify, local commit, push to Gitea, then publish or release from the pushed state. - Do not publish release artifacts from a dirty worktree unless Jason explicitly asks for that exact operation. - Keep source-control bookkeeping quiet unless Jason asks about it or a source-control problem affects the work. @@ -69,6 +71,7 @@ git@git.christit.com:libre-study/libre-study.git ## Data Boundary - The app should import from `libre-bible-data` packages rather than scraping or downloading Bible sources directly. +- The app should prefer LibreBible machine catalogs over direct file guesses. Catalog paths may come from `LIBREBIBLE_CATALOG_PATH`, a sibling `libre-bible-data` checkout, or later a public ChristIT.com catalog. - The local SQLite database is an app cache and user workspace, not the canonical source of public Bible data. - User-created tags, highlights, notes, saved searches, layouts, and reading state belong to Libre Study. - Translation text, Strong's links, study notes, commentaries, maps, and source metadata belong to `libre-bible-data`. diff --git a/package-lock.json b/package-lock.json index 6985091..128c175 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "libre-study", - "version": "0.0.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "libre-study", - "version": "0.0.0", + "version": "0.1.1", "dependencies": { "@tauri-apps/api": "^2.11.1", "react": "^19.2.7", diff --git a/package.json b/package.json index 18d1a2c..d88b891 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "libre-study", "private": true, - "version": "0.0.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "vite --host 127.0.0.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a03f523..c9c999a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "app" -version = "0.1.0" +version = "0.1.1" dependencies = [ "log", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 39be720..c4898f7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.1.0" +version = "0.1.1" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bea37db..0bf3605 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, +} + +#[derive(Deserialize)] +struct LibreBibleResourceSummary { + id: String, + resource_type: Option, + title: Option, + package_path: Option, + files: Option, +} + +#[derive(Deserialize)] +struct LibreBibleResourceCatalog { + id: String, + title: Option, + files: LibreBiblePackageFiles, +} + +#[derive(Deserialize)] +struct LibreBiblePackageFiles { + verses_jsonl: Option, + strongs_jsonl: Option, +} + +#[derive(Deserialize)] +struct LibreBiblePackageFile { + path: String, +} + +struct LibreBiblePackageSource { + resource_id: String, + title: String, + package_dir: PathBuf, + verses_path: PathBuf, + strongs_path: Option, +} + #[tauri::command] fn get_library_state(app: AppHandle) -> Result { 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 { +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 { + 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 { .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 { .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 { + 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::(&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 { + 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 { + 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 { + 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(()) + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 67c14fe..2d4c212 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.0", + "version": "0.1.1", "identifier": "org.librestudy.desktop", "build": { "frontendDist": "../dist",