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
+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
src-tauri/target
src-tauri/gen
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+97
View File
@@ -0,0 +1,97 @@
# Libre Study Conventions
Libre Study is the desktop-first Bible study application. Its job is to provide a fast, local, searchable, taggable study workspace that can consume normalized Bible and study-resource packages from `libre-bible-data`.
## Repository Role
- This repo owns the application shell, interface, local storage, search behavior, import behavior, and desktop packaging.
- This repo does not own source Bible texts, commentaries, maps, lexicons, or study notes.
- Bible and study resources should enter the app through importable packages generated by `libre-bible-data`.
- Keep app code and resource data separate unless a tiny fallback seed is needed for development.
## Development Workflow
- 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.
- 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.
- Use the actual project scripts instead of ad hoc equivalents.
- Use browser dev mode for fast visual iteration:
```powershell
npm.cmd run dev:web
```
- Use Tauri dev mode when testing native behavior:
```powershell
npm.cmd run dev:tauri
```
- Use packaged builds only when testing installers, app signing, updater behavior, or desktop release behavior.
- Browser dev mode may use mock/localStorage data. Tauri mode should use the native SQLite-backed commands.
- If a command starts failing because of PowerShell, Bash, WSL, quoting, heredocs, pipes, regexes, `$` variables, or nested shell layers, stop retrying the same command shape. Move the logic into a script file or use argv-style execution.
- Prefer PowerShell for normal Windows-local Node/Tauri work in this repo. Use WSL Bash only when a tool or script is genuinely Linux-oriented.
## Publishing Model
- Gitea is the Git host and backup/publishing surface, not the primary app UI.
- Public Gitea host: `https://git.christit.com`.
- Planned remote URL pattern:
```text
https://git.christit.com/libre-study/libre-study.git
git@git.christit.com:libre-study/libre-study.git
```
- Public download pages, catalogs, release notes, and package indexes should be generated from repo metadata where possible.
- Do not hand-maintain public catalog details that can be derived from committed manifests, package catalogs, tags, or releases.
- Release artifacts should be traceable to a pushed commit.
- Signed installers and updater metadata should only be produced from intentional release commits.
- Generated app metadata, package indexes, update manifests, and release notes should be regenerated by scripts rather than hand-corrected after the fact.
## Backup Copies
- Source control is the main recovery layer, but local `.old` backups are still useful before large, risky, or release-bound edits.
- Put backups near the affected file or module, not in random temp locations.
- Prefer backup filenames with timestamp plus short reason, for example `App.tsx.20260712-study-layout.old`.
- Do not create `.old` backups for every tiny edit. Use them when rollback clarity is worth the extra file.
## Changelogs
- Keep changelogs newest-first when changelogs are introduced.
- Use a `## Current` section when helpful, followed by dated release sections.
- Keep entries concrete and operational: what changed, what behavior was preserved, and what was verified.
- Mark reconstructed history clearly if it is built from artifacts, old packages, or prior notes.
## Data Boundary
- The app should import from `libre-bible-data` packages rather than scraping or downloading Bible sources directly.
- 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`.
## Product Direction
- First target: Windows desktop.
- First study source: KJV with Strong's-linked data.
- Core experience: parallel reading, search, tags, notes, highlights, Strong's lookup, and saved study workspaces.
- Later targets may include macOS, Linux, web/PWA, and mobile, but do not let those future targets compromise the desktop study workflow.
## Engineering Rules
- Prefer TypeScript for UI and Rust/Tauri commands for native filesystem/database work.
- Keep UI components decoupled from Tauri `invoke` calls through a client layer so browser dev mode remains usable.
- Do not hardcode Windows-only paths in app logic unless wrapped behind a platform adapter.
- Treat installer output as a release artifact, not a normal development artifact.
- Public release readiness is separate from internal app maturity. A build can be useful for local testing without being ready for public distribution.
- Run at minimum:
```powershell
npm.cmd run lint
npm.cmd run build
```
before considering app changes complete.
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>libre-study</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1663
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "libre-study",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"dev:web": "vite --host 127.0.0.1",
"dev:tauri": "tauri dev",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+4
View File
@@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas
+4823
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "app"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
license = ""
repository = ""
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.11.3", features = [] }
tauri-plugin-log = "2"
rusqlite = { version = "0.37", features = ["bundled"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+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();
}
+40
View File
@@ -0,0 +1,40 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Libre Study",
"version": "0.1.0",
"identifier": "org.librestudy.desktop",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm.cmd run dev",
"beforeBuildCommand": "npm.cmd run build"
},
"app": {
"windows": [
{
"title": "Libre Study",
"width": 1280,
"height": 820,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"android": {
"debugApplicationIdSuffix": ".debug"
}
}
}
+319
View File
@@ -0,0 +1,319 @@
.app-shell {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
min-height: 100vh;
background: #f7f7f4;
color: #202124;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 22px;
min-height: 100vh;
padding: 24px;
background: #20231f;
color: #f6f3eb;
border-right: 1px solid #d8d5cb;
box-sizing: border-box;
}
.brand-block {
display: flex;
gap: 14px;
align-items: center;
}
.brand-mark {
display: grid;
place-items: center;
flex: 0 0 44px;
width: 44px;
height: 44px;
border-radius: 8px;
background: #d6a84f;
color: #161711;
font-weight: 800;
font-size: 16px;
}
.brand-block h1,
.reader h2,
.panel h3,
.strong-entry h4 {
margin: 0;
letter-spacing: 0;
}
.brand-block h1 {
color: #ffffff;
font-size: 22px;
line-height: 1.1;
}
.brand-block p,
.empty-text,
.result small,
.panel-note,
.translit {
margin: 0;
color: #a9ada3;
font-size: 13px;
line-height: 1.4;
}
.search-box,
.tag-filter,
.result-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.search-box label,
.section-title {
color: #d9d4c4;
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.search-row,
.tag-editor {
display: flex;
gap: 8px;
}
input {
min-width: 0;
width: 100%;
border: 1px solid #bdb8aa;
border-radius: 6px;
padding: 10px 12px;
background: #fffef9;
color: #202124;
font: inherit;
font-size: 14px;
box-sizing: border-box;
}
button {
border: 1px solid transparent;
border-radius: 6px;
padding: 10px 12px;
background: #d6a84f;
color: #141510;
font: inherit;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
button:focus-visible,
input:focus-visible {
outline: 2px solid #5d7f71;
outline-offset: 2px;
}
.tag-list,
.reader-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag-chip {
padding: 6px 9px;
background: transparent;
color: #f6f3eb;
border: 1px solid #5d7f71;
font-size: 12px;
}
.result-list {
min-height: 0;
overflow: auto;
padding-right: 4px;
}
.result {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 12px;
text-align: left;
background: #2c302a;
color: #f8f5ed;
border: 1px solid #3d4339;
}
.result.active {
border-color: #d6a84f;
background: #363b33;
}
.result span {
font-weight: 800;
}
.reader {
display: flex;
flex-direction: column;
gap: 22px;
padding: 30px;
min-width: 0;
box-sizing: border-box;
}
.reader-header {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-start;
}
.eyebrow {
color: #657061;
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.reader h2 {
margin-top: 4px;
color: #202124;
font-size: 34px;
line-height: 1.1;
}
.reader-tags span {
border-radius: 999px;
color: white;
font-size: 12px;
font-weight: 700;
padding: 5px 9px;
}
.verse-card,
.panel,
.status-panel {
border: 1px solid #dad6cc;
border-radius: 8px;
background: #fffef9;
box-shadow: 0 12px 30px rgba(44, 48, 42, 0.08);
}
.verse-card {
padding: 30px;
}
.verse-card p {
margin: 0;
color: #1f211e;
font-family: Georgia, 'Times New Roman', serif;
font-size: 31px;
line-height: 1.55;
}
.study-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr);
gap: 18px;
align-items: start;
}
.panel {
padding: 18px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1px solid #e4e0d6;
}
.panel h3 {
color: #2a2d28;
font-size: 18px;
}
.panel-header span {
color: #657061;
font-size: 13px;
font-weight: 800;
}
.strong-list {
display: grid;
gap: 12px;
padding-top: 14px;
}
.strong-entry {
border: 1px solid #e4e0d6;
border-radius: 8px;
padding: 14px;
background: #fbfaf4;
}
.strong-entry div {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
color: #657061;
font-size: 12px;
}
.strong-entry h4 {
margin-top: 8px;
color: #1f211e;
font-size: 18px;
}
.strong-entry p {
margin: 8px 0 0;
color: #3f443d;
font-size: 14px;
line-height: 1.5;
}
.tag-editor {
margin-top: 16px;
}
.panel-note {
margin-top: 14px;
color: #657061;
}
.status-panel {
padding: 28px;
color: #3f443d;
}
.status-panel.error {
border-color: #b42318;
color: #b42318;
}
@media (max-width: 900px) {
.app-shell,
.study-grid {
grid-template-columns: 1fr;
}
.sidebar {
min-height: auto;
}
.reader-header {
flex-direction: column;
}
}
+214
View File
@@ -0,0 +1,214 @@
import { useEffect, useMemo, useState } from 'react'
import { addTagToVerse, getLibraryState, searchVerses } from './studyClient'
import type { Tag, Verse } from './types'
import './App.css'
const tagColors = ['#2f6f5e', '#735c0f', '#8a3ffc', '#b42318', '#245ba7']
function reference(verse: Verse) {
return `${verse.book} ${verse.chapter}:${verse.verse}`
}
function App() {
const [query, setQuery] = useState('')
const [verses, setVerses] = useState<Verse[]>([])
const [tags, setTags] = useState<Tag[]>([])
const [selectedId, setSelectedId] = useState<number | null>(null)
const [tagInput, setTagInput] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function loadLibrary() {
setError(null)
const state = await getLibraryState()
setVerses(state.verses)
setTags(state.tags)
setSelectedId((current) => current ?? state.verses[0]?.id ?? null)
}
async function runSearch(nextQuery = query) {
setError(null)
const results = await searchVerses(nextQuery)
setVerses(results)
setSelectedId(results[0]?.id ?? null)
}
async function addTag() {
const label = tagInput.trim()
if (!selectedId || !label) return
const color = tagColors[Math.abs(label.length) % tagColors.length]
const updated = await addTagToVerse(selectedId, label, color)
setVerses((current) =>
current.map((verse) => (verse.id === updated.id ? updated : verse)),
)
setTags((current) => {
const exists = current.some((tag) => tag.label.toLowerCase() === label.toLowerCase())
return exists ? current : [...current, updated.tags[updated.tags.length - 1]]
})
setTagInput('')
}
useEffect(() => {
loadLibrary()
.catch((reason) => setError(String(reason)))
.finally(() => setLoading(false))
}, [])
const selectedVerse = useMemo(
() => verses.find((verse) => verse.id === selectedId) ?? verses[0],
[selectedId, verses],
)
return (
<main className="app-shell">
<aside className="sidebar">
<div className="brand-block">
<span className="brand-mark">LS</span>
<div>
<h1>Libre Study</h1>
<p>KJV with Strong's-linked study data</p>
</div>
</div>
<form
className="search-box"
onSubmit={(event) => {
event.preventDefault()
runSearch().catch((reason) => setError(String(reason)))
}}
>
<label htmlFor="search">Search</label>
<div className="search-row">
<input
id="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder='faith, "kingdom", G4102'
/>
<button type="submit">Go</button>
</div>
</form>
<section className="tag-filter">
<div className="section-title">Tags</div>
<div className="tag-list">
{tags.length === 0 ? (
<span className="empty-text">No tags yet</span>
) : (
tags.map((tag) => (
<button
key={tag.id}
className="tag-chip"
type="button"
style={{ borderColor: tag.color }}
onClick={() => {
setQuery(`tag:${tag.label}`)
runSearch(`tag:${tag.label}`).catch((reason) => setError(String(reason)))
}}
>
{tag.label}
</button>
))
)}
</div>
</section>
<section className="result-list" aria-label="Search results">
<div className="section-title">{verses.length} verses</div>
{verses.map((verse) => (
<button
key={verse.id}
className={verse.id === selectedVerse?.id ? 'result active' : 'result'}
type="button"
onClick={() => setSelectedId(verse.id)}
>
<span>{reference(verse)}</span>
<small>{verse.text}</small>
</button>
))}
</section>
</aside>
<section className="reader">
{loading ? (
<div className="status-panel">Loading local library...</div>
) : error ? (
<div className="status-panel error">{error}</div>
) : selectedVerse ? (
<>
<header className="reader-header">
<div>
<span className="eyebrow">King James Version</span>
<h2>{reference(selectedVerse)}</h2>
</div>
<div className="reader-tags">
{selectedVerse.tags.map((tag) => (
<span key={tag.id} style={{ backgroundColor: tag.color }}>
{tag.label}
</span>
))}
</div>
</header>
<article className="verse-card">
<p>{selectedVerse.text}</p>
</article>
<div className="study-grid">
<section className="panel">
<div className="panel-header">
<h3>Strong's Links</h3>
<span>{selectedVerse.strongs.length}</span>
</div>
<div className="strong-list">
{selectedVerse.strongs.map((entry) => (
<article key={entry.strong} className="strong-entry">
<div>
<strong>{entry.strong}</strong>
<span>{entry.language}</span>
</div>
<h4>{entry.lemma}</h4>
<p className="translit">{entry.transliteration}</p>
<p>{entry.definition}</p>
</article>
))}
</div>
</section>
<section className="panel">
<div className="panel-header">
<h3>Tag This Verse</h3>
</div>
<div className="tag-editor">
<input
value={tagInput}
onChange={(event) => setTagInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
addTag().catch((reason) => setError(String(reason)))
}
}}
placeholder="prophecy, sermon, doctrine"
/>
<button type="button" onClick={() => addTag().catch((reason) => setError(String(reason)))}>
Add
</button>
</div>
<p className="panel-note">
This is backed by the local SQLite database. Notes, highlights, saved
searches, and import tools can sit on this same foundation.
</p>
</section>
</div>
</>
) : (
<div className="status-panel">No verses matched that search.</div>
)}
</section>
</main>
)
}
export default App
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+18
View File
@@ -0,0 +1,18 @@
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
color: #202124;
background: #f7f7f4;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+202
View File
@@ -0,0 +1,202 @@
import { invoke } from '@tauri-apps/api/core'
import type { LibraryState, Tag, Verse } from './types'
type TauriWindow = Window &
typeof globalThis & {
__TAURI_INTERNALS__?: unknown
}
const browserTagKey = 'libre-study-browser-tags'
const browserVerses: Verse[] = [
{
id: 1,
book: 'Genesis',
chapter: 1,
verse: 1,
text: 'In the beginning God created the heaven and the earth.',
tags: [],
strongs: [
{
strong: 'H7225',
lemma: 'רֵאשִׁית',
transliteration: 'reshith',
definition: 'Beginning, first, chief, the first part.',
language: 'Hebrew',
},
{
strong: 'H430',
lemma: 'אֱלֹהִים',
transliteration: 'elohim',
definition: 'God, gods, judges, rulers; plural form often used of the one true God.',
language: 'Hebrew',
},
],
},
{
id: 2,
book: 'Psalm',
chapter: 23,
verse: 1,
text: 'The LORD is my shepherd; I shall not want.',
tags: [],
strongs: [
{
strong: 'H3068',
lemma: 'יְהוָֹה',
transliteration: 'YHWH',
definition: 'The covenant name of the LORD.',
language: 'Hebrew',
},
{
strong: 'H7462',
lemma: 'רָעָה',
transliteration: 'raah',
definition: 'To shepherd, pasture, feed, tend a flock.',
language: 'Hebrew',
},
],
},
{
id: 3,
book: 'John',
chapter: 3,
verse: 16,
text: '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.',
tags: [],
strongs: [
{
strong: 'G25',
lemma: 'ἀγαπάω',
transliteration: 'agapao',
definition: 'To love, value, welcome, or take pleasure in.',
language: 'Greek',
},
{
strong: 'G4100',
lemma: 'πιστεύω',
transliteration: 'pisteuo',
definition: 'To believe, trust, have faith in.',
language: 'Greek',
},
],
},
{
id: 4,
book: 'Romans',
chapter: 8,
verse: 28,
text: '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.',
tags: [],
strongs: [
{
strong: 'G2822',
lemma: 'κλητός',
transliteration: 'kletos',
definition: 'Called, invited, appointed.',
language: 'Greek',
},
{
strong: 'G4286',
lemma: 'πρόθεσις',
transliteration: 'prothesis',
definition: 'Purpose, setting forth, deliberate plan.',
language: 'Greek',
},
],
},
]
function inTauri() {
return Boolean((window as TauriWindow).__TAURI_INTERNALS__)
}
function readBrowserTags(): Record<number, Tag[]> {
const raw = localStorage.getItem(browserTagKey)
if (!raw) return {}
try {
return JSON.parse(raw) as Record<number, Tag[]>
} catch {
return {}
}
}
function writeBrowserTags(tagsByVerse: Record<number, Tag[]>) {
localStorage.setItem(browserTagKey, JSON.stringify(tagsByVerse))
}
function hydrateBrowserVerses() {
const tagsByVerse = readBrowserTags()
return browserVerses.map((verse) => ({
...verse,
tags: tagsByVerse[verse.id] ?? [],
}))
}
function allBrowserTags() {
const unique = new Map<string, Tag>()
for (const tags of Object.values(readBrowserTags())) {
for (const tag of tags) {
unique.set(tag.label.toLowerCase(), tag)
}
}
return [...unique.values()].sort((a, b) => a.label.localeCompare(b.label))
}
export async function getLibraryState(): Promise<LibraryState> {
if (inTauri()) {
return invoke<LibraryState>('get_library_state')
}
return {
verses: hydrateBrowserVerses(),
tags: allBrowserTags(),
}
}
export async function searchVerses(query: string): Promise<Verse[]> {
if (inTauri()) {
return invoke<Verse[]>('search_verses', { query })
}
const trimmed = query.trim().toLowerCase()
const verses = hydrateBrowserVerses()
if (!trimmed) return verses
if (trimmed.startsWith('tag:')) {
const label = trimmed.slice(4)
return verses.filter((verse) =>
verse.tags.some((tag) => tag.label.toLowerCase() === label),
)
}
if (/^[gh]\d+$/.test(trimmed)) {
return verses.filter((verse) =>
verse.strongs.some((entry) => entry.strong.toLowerCase() === trimmed),
)
}
return verses.filter((verse) =>
`${verse.book} ${verse.chapter}:${verse.verse} ${verse.text}`
.toLowerCase()
.includes(trimmed),
)
}
export async function addTagToVerse(verseId: number, label: string, color: string) {
if (inTauri()) {
return invoke<Verse>('add_tag_to_verse', { verseId, label, color })
}
const tagsByVerse = readBrowserTags()
const current = tagsByVerse[verseId] ?? []
const existing = current.find((tag) => tag.label.toLowerCase() === label.toLowerCase())
const tag = existing ?? { id: Date.now(), label, color }
tagsByVerse[verseId] = existing ? current : [...current, tag]
writeBrowserTags(tagsByVerse)
const verse = hydrateBrowserVerses().find((item) => item.id === verseId)
if (!verse) throw new Error(`Verse ${verseId} was not found.`)
return verse
}
+28
View File
@@ -0,0 +1,28 @@
export type Verse = {
id: number
book: string
chapter: number
verse: number
text: string
strongs: StrongLink[]
tags: Tag[]
}
export type StrongLink = {
strong: string
lemma: string
transliteration: string
definition: string
language: 'Greek' | 'Hebrew'
}
export type Tag = {
id: number
label: string
color: string
}
export type LibraryState = {
verses: Verse[]
tags: Tag[]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})