Initial Libre Study app scaffold

This commit is contained in:
2026-07-11 21:36:30 -05:00
commit 470cde6865
46 changed files with 8226 additions and 0 deletions
+563
View File
@@ -0,0 +1,563 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
get_library_state,
search_verses,
add_tag_to_verse
])
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
use std::fs::{self, File};
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
#[derive(Serialize)]
struct LibraryState {
verses: Vec<Verse>,
tags: Vec<Tag>,
}
#[derive(Serialize)]
struct Verse {
id: i64,
book: String,
chapter: i64,
verse: i64,
text: String,
strongs: Vec<StrongLink>,
tags: Vec<Tag>,
}
#[derive(Serialize)]
struct StrongLink {
strong: String,
lemma: String,
transliteration: String,
definition: String,
language: String,
}
#[derive(Serialize)]
struct Tag {
id: i64,
label: String,
color: String,
}
#[derive(Deserialize)]
struct ImportedVerse {
id: String,
book: String,
chapter: i64,
verse: i64,
text: String,
}
#[derive(Deserialize)]
struct ImportedStrongLink {
verse_id: String,
position: i64,
surface: String,
strong: String,
}
#[tauri::command]
fn get_library_state(app: AppHandle) -> Result<LibraryState, String> {
let conn = open_db(&app)?;
let verses = load_verses(&conn, None)?;
let tags = load_all_tags(&conn)?;
Ok(LibraryState { verses, tags })
}
#[tauri::command]
fn search_verses(app: AppHandle, query: String) -> Result<Vec<Verse>, String> {
let conn = open_db(&app)?;
let trimmed = query.trim();
if trimmed.is_empty() {
return load_verses(&conn, None);
}
if let Some(label) = trimmed.strip_prefix("tag:") {
return load_verses_for_tag(&conn, label.trim());
}
if looks_like_strongs(trimmed) {
return load_verses_for_strongs(&conn, trimmed);
}
load_verses(&conn, Some(trimmed))
}
#[tauri::command]
fn add_tag_to_verse(
app: AppHandle,
verse_id: i64,
label: String,
color: String,
) -> Result<Verse, String> {
let conn = open_db(&app)?;
let normalized = label.trim();
if normalized.is_empty() {
return Err("Tag label cannot be empty.".to_string());
}
conn.execute(
"INSERT OR IGNORE INTO tags (label, color) VALUES (?1, ?2)",
params![normalized, color],
)
.map_err(|err| err.to_string())?;
let tag_id: i64 = conn
.query_row(
"SELECT id FROM tags WHERE lower(label) = lower(?1)",
params![normalized],
|row| row.get(0),
)
.map_err(|err| err.to_string())?;
conn.execute(
"INSERT OR IGNORE INTO verse_tags (verse_id, tag_id) VALUES (?1, ?2)",
params![verse_id, tag_id],
)
.map_err(|err| err.to_string())?;
load_verse(&conn, verse_id)
}
fn open_db(app: &AppHandle) -> Result<Connection, String> {
let path = db_path(app)?;
let mut conn = Connection::open(path).map_err(|err| err.to_string())?;
migrate(&conn)?;
seed(&mut conn)?;
Ok(conn)
}
fn db_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|err| format!("Unable to resolve app data directory: {err}"))?;
fs::create_dir_all(&dir).map_err(|err| err.to_string())?;
Ok(dir.join("libre-study.sqlite3"))
}
fn migrate(conn: &Connection) -> Result<(), String> {
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS verses (
id INTEGER PRIMARY KEY,
book TEXT NOT NULL,
chapter INTEGER NOT NULL,
verse INTEGER NOT NULL,
text TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS lexicon (
strong TEXT PRIMARY KEY,
lemma TEXT NOT NULL,
transliteration TEXT NOT NULL,
definition TEXT NOT NULL,
language TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS verse_strongs (
verse_id INTEGER NOT NULL,
strong TEXT NOT NULL,
position INTEGER NOT NULL,
PRIMARY KEY (verse_id, strong),
FOREIGN KEY (verse_id) REFERENCES verses(id),
FOREIGN KEY (strong) REFERENCES lexicon(strong)
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL COLLATE NOCASE UNIQUE,
color TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS verse_tags (
verse_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (verse_id, tag_id),
FOREIGN KEY (verse_id) REFERENCES verses(id),
FOREIGN KEY (tag_id) REFERENCES tags(id)
);
",
)
.map_err(|err| err.to_string())
}
fn seed(conn: &mut Connection) -> Result<(), String> {
let verse_count: i64 = conn
.query_row("SELECT COUNT(*) FROM verses", [], |row| row.get(0))
.map_err(|err| err.to_string())?;
if verse_count > 0 {
return Ok(());
}
if let Some(package_dir) = find_default_package_dir() {
import_package(conn, package_dir)?;
return Ok(());
}
let seed_sql = "
INSERT INTO verses (id, book, chapter, verse, text) VALUES
(1, 'Genesis', 1, 1, 'In the beginning God created the heaven and the earth.'),
(2, 'Psalm', 23, 1, 'The LORD is my shepherd; I shall not want.'),
(3, 'John', 3, 16, 'For God so loved the world, that he gave his only begotten Son, that whosoever believeth in him should not perish, but have everlasting life.'),
(4, 'Romans', 8, 28, 'And we know that all things work together for good to them that love God, to them who are the called according to his purpose.');
INSERT INTO lexicon (strong, lemma, transliteration, definition, language) VALUES
('H7225', '\u{05e8}\u{05b5}\u{05d0}\u{05e9}\u{05b4}\u{05c1}\u{05d9}\u{05ea}', 'reshith', 'beginning, first, chief, the first part.', 'Hebrew'),
('H430', '\u{05d0}\u{05b1}\u{05dc}\u{05b9}\u{05d4}\u{05b4}\u{05d9}\u{05dd}', 'elohim', 'God, gods, judges, rulers; plural form often used of the one true God.', 'Hebrew'),
('H1254', '\u{05d1}\u{05b8}\u{05bc}\u{05e8}\u{05b8}\u{05d0}', 'bara', 'to create, shape, form; used of divine creative action.', 'Hebrew'),
('H3068', '\u{05d9}\u{05b0}\u{05d4}\u{05d5}\u{05b8}\u{05b9}\u{05d4}', 'YHWH', 'the covenant name of the LORD.', 'Hebrew'),
('H7462', '\u{05e8}\u{05b8}\u{05e2}\u{05b8}\u{05d4}', 'raah', 'to shepherd, pasture, feed, tend a flock.', 'Hebrew'),
('G25', '\u{1f00}\u{03b3}\u{03b1}\u{03c0}\u{03ac}\u{03c9}', 'agapao', 'to love, value, welcome, or take pleasure in.', 'Greek'),
('G2889', '\u{03ba}\u{03cc}\u{03c3}\u{03bc}\u{03bf}\u{03c2}', 'kosmos', 'world, ordered system, inhabitants of the earth.', 'Greek'),
('G4100', '\u{03c0}\u{03b9}\u{03c3}\u{03c4}\u{03b5}\u{03cd}\u{03c9}', 'pisteuo', 'to believe, trust, have faith in.', 'Greek'),
('G2222', '\u{03b6}\u{03c9}\u{03ae}', 'zoe', 'life; often spiritual and eternal life.', 'Greek'),
('G2822', '\u{03ba}\u{03bb}\u{03b7}\u{03c4}\u{03cc}\u{03c2}', 'kletos', 'called, invited, appointed.', 'Greek'),
('G4286', '\u{03c0}\u{03c1}\u{03cc}\u{03b8}\u{03b5}\u{03c3}\u{03b9}\u{03c2}', 'prothesis', 'purpose, setting forth, deliberate plan.', 'Greek');
INSERT INTO verse_strongs (verse_id, strong, position) VALUES
(1, 'H7225', 1), (1, 'H430', 2), (1, 'H1254', 3),
(2, 'H3068', 1), (2, 'H7462', 2),
(3, 'G25', 1), (3, 'G2889', 2), (3, 'G4100', 3), (3, 'G2222', 4),
(4, 'G25', 1), (4, 'G2822', 2), (4, 'G4286', 3);
";
conn.execute_batch(seed_sql).map_err(|err| err.to_string())
}
fn find_default_package_dir() -> Option<PathBuf> {
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);
}
}
let cwd = env::current_dir().ok()?;
let mut candidates = Vec::new();
candidates.push(
cwd.join("..")
.join("libre-bible-data")
.join("packages")
.join("json")
.join("kjv-eng-kjv2006"),
);
if let Some(parent) = cwd.parent() {
candidates.push(
parent
.join("libre-bible-data")
.join("packages")
.join("json")
.join("kjv-eng-kjv2006"),
);
}
candidates.into_iter().find(|path| {
path.join("verses.jsonl").exists() && path.join("strongs-links.jsonl").exists()
})
}
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");
let tx = conn.transaction().map_err(|err| err.to_string())?;
let mut verse_ids = HashMap::new();
{
let file = File::open(&verses_path)
.map_err(|err| format!("Unable to open {}: {err}", verses_path.display()))?;
let reader = BufReader::new(file);
for (index, line) in reader.lines().enumerate() {
let line = line.map_err(|err| err.to_string())?;
if line.trim().is_empty() {
continue;
}
let imported: ImportedVerse =
serde_json::from_str(&line).map_err(|err| err.to_string())?;
let verse_id = (index + 1) as i64;
tx.execute(
"INSERT INTO verses (id, book, chapter, verse, text) VALUES (?1, ?2, ?3, ?4, ?5)",
params![
verse_id,
imported.book,
imported.chapter,
imported.verse,
imported.text
],
)
.map_err(|err| err.to_string())?;
verse_ids.insert(imported.id, verse_id);
}
}
{
let file = File::open(&strongs_path)
.map_err(|err| format!("Unable to open {}: {err}", strongs_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: ImportedStrongLink =
serde_json::from_str(&line).map_err(|err| err.to_string())?;
let Some(verse_id) = verse_ids.get(&imported.verse_id) else {
continue;
};
let language = if imported.strong.starts_with('H') {
"Hebrew"
} else {
"Greek"
};
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())?;
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())?;
}
}
tx.commit().map_err(|err| err.to_string())
}
fn load_verses(conn: &Connection, query: Option<&str>) -> Result<Vec<Verse>, String> {
let mut verses = Vec::new();
match query {
Some(query) => {
let pattern = format!("%{}%", query);
let mut stmt = conn
.prepare(
"
SELECT id, book, chapter, verse, text
FROM verses
WHERE text LIKE ?1
OR book || ' ' || chapter || ':' || verse LIKE ?1
ORDER BY id
",
)
.map_err(|err| err.to_string())?;
let rows = stmt
.query_map(params![pattern], row_to_verse_shell)
.map_err(|err| err.to_string())?;
for row in rows {
verses.push(hydrate_verse(conn, row.map_err(|err| err.to_string())?)?);
}
}
None => {
let mut stmt = conn
.prepare("SELECT id, book, chapter, verse, text FROM verses ORDER BY id")
.map_err(|err| err.to_string())?;
let rows = stmt
.query_map([], row_to_verse_shell)
.map_err(|err| err.to_string())?;
for row in rows {
verses.push(hydrate_verse(conn, row.map_err(|err| err.to_string())?)?);
}
}
}
Ok(verses)
}
fn load_verse(conn: &Connection, verse_id: i64) -> Result<Verse, String> {
let shell = conn
.query_row(
"SELECT id, book, chapter, verse, text FROM verses WHERE id = ?1",
params![verse_id],
row_to_verse_shell,
)
.map_err(|err| err.to_string())?;
hydrate_verse(conn, shell)
}
fn load_verses_for_tag(conn: &Connection, label: &str) -> Result<Vec<Verse>, String> {
let mut stmt = conn
.prepare(
"
SELECT v.id, v.book, v.chapter, v.verse, v.text
FROM verses v
JOIN verse_tags vt ON vt.verse_id = v.id
JOIN tags t ON t.id = vt.tag_id
WHERE lower(t.label) = lower(?1)
ORDER BY v.id
",
)
.map_err(|err| err.to_string())?;
let rows = stmt
.query_map(params![label], row_to_verse_shell)
.map_err(|err| err.to_string())?;
let mut verses = Vec::new();
for row in rows {
verses.push(hydrate_verse(conn, row.map_err(|err| err.to_string())?)?);
}
Ok(verses)
}
fn load_verses_for_strongs(conn: &Connection, strong: &str) -> Result<Vec<Verse>, String> {
let mut stmt = conn
.prepare(
"
SELECT v.id, v.book, v.chapter, v.verse, v.text
FROM verses v
JOIN verse_strongs vs ON vs.verse_id = v.id
WHERE lower(vs.strong) = lower(?1)
ORDER BY v.id
",
)
.map_err(|err| err.to_string())?;
let rows = stmt
.query_map(params![strong], row_to_verse_shell)
.map_err(|err| err.to_string())?;
let mut verses = Vec::new();
for row in rows {
verses.push(hydrate_verse(conn, row.map_err(|err| err.to_string())?)?);
}
Ok(verses)
}
fn row_to_verse_shell(row: &rusqlite::Row<'_>) -> rusqlite::Result<Verse> {
Ok(Verse {
id: row.get(0)?,
book: row.get(1)?,
chapter: row.get(2)?,
verse: row.get(3)?,
text: row.get(4)?,
strongs: Vec::new(),
tags: Vec::new(),
})
}
fn hydrate_verse(conn: &Connection, mut verse: Verse) -> Result<Verse, String> {
verse.strongs = load_strongs(conn, verse.id)?;
verse.tags = load_tags(conn, verse.id)?;
Ok(verse)
}
fn load_strongs(conn: &Connection, verse_id: i64) -> Result<Vec<StrongLink>, String> {
let mut stmt = conn
.prepare(
"
SELECT l.strong, 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
ORDER BY vs.position
",
)
.map_err(|err| err.to_string())?;
let rows = stmt
.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)?,
})
})
.map_err(|err| err.to_string())?;
let mut entries = Vec::new();
for row in rows {
entries.push(row.map_err(|err| err.to_string())?);
}
Ok(entries)
}
fn load_tags(conn: &Connection, verse_id: i64) -> Result<Vec<Tag>, String> {
let mut stmt = conn
.prepare(
"
SELECT t.id, t.label, t.color
FROM verse_tags vt
JOIN tags t ON t.id = vt.tag_id
WHERE vt.verse_id = ?1
ORDER BY t.label
",
)
.map_err(|err| err.to_string())?;
let rows = stmt
.query_map(params![verse_id], |row| {
Ok(Tag {
id: row.get(0)?,
label: row.get(1)?,
color: row.get(2)?,
})
})
.map_err(|err| err.to_string())?;
let mut tags = Vec::new();
for row in rows {
tags.push(row.map_err(|err| err.to_string())?);
}
Ok(tags)
}
fn load_all_tags(conn: &Connection) -> Result<Vec<Tag>, String> {
let mut stmt = conn
.prepare("SELECT id, label, color FROM tags ORDER BY label")
.map_err(|err| err.to_string())?;
let rows = stmt
.query_map([], |row| {
Ok(Tag {
id: row.get(0)?,
label: row.get(1)?,
color: row.get(2)?,
})
})
.map_err(|err| err.to_string())?;
let mut tags = Vec::new();
for row in rows {
tags.push(row.map_err(|err| err.to_string())?);
}
Ok(tags)
}
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())
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}