Initial Libre Study app scaffold
@@ -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?
|
||||||
@@ -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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -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 |
@@ -0,0 +1,4 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
@@ -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"] }
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "enables the default permissions",
|
||||||
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
|
"permissions": [
|
||||||
|
"core:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -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())
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
After Width: | Height: | Size: 13 KiB |
@@ -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 |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||