Initial commit: Core LuminaLP architecture with plugin system

Features implemented:
- Clean trait-based plugin architecture (no global registries)
- SQLite database with centralized SQL operations
- Songs plugin: CCLI support, lyrics search and display
- Bible plugin: Multi-translation verse lookup and rendering
- Media plugin: Auto-detection for images/video/audio files
- Presentations plugin: LibreOffice integration with HTML/XHTML combo parsing
- Service management: Add items, navigation, current slide tracking
- Comprehensive integration tests with real database operations

Architecture follows KISS/DRY principles:
- All SQL operations centralized in core/database.rs
- Plugins focus on business logic only
- No complex state management or global registries
- Clean separation of concerns throughout

Ready for church presentation use with PowerPoint theme preservation.
This commit is contained in:
Benjamin Slingo 2025-09-01 22:53:26 -04:00
commit c76e61ea59
21 changed files with 6476 additions and 0 deletions

53
.gitignore vendored Normal file
View file

@ -0,0 +1,53 @@
# Rust
/target/
**/*.rs.bk
*.pdb
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
*.log
# Database files
*.db
*.sqlite
*.sqlite3
# Temporary files
/tmp/
*.tmp
*.temp
# LibreOffice temporary exports
*.html.tmp
*.xhtml.tmp
# Build artifacts
/dist/
/build/
# Environment files
.env
.env.local
# Backup files
*.backup
*.bak
# Test coverage
/coverage/
*.profraw

4867
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

32
Cargo.toml Normal file
View file

@ -0,0 +1,32 @@
[package]
name = "lumina-lp"
version = "0.1.0"
edition = "2021"
[dependencies]
iced = "0.12"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite"] }
tokio = { version = "1.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = "0.3"
lumina-core = { path = "core" }
lumina-songs = { path = "plugins/songs" }
lumina-bible = { path = "plugins/bible" }
lumina-media = { path = "plugins/media" }
lumina-presentations = { path = "plugins/presentations" }
[dev-dependencies]
tempfile = "3.0"
tokio-test = "0.4"
[workspace]
members = [
"core",
"plugins/songs",
"plugins/bible",
"plugins/media",
"plugins/presentations"
]

145
README.md Normal file
View file

@ -0,0 +1,145 @@
# LuminaLP
A modern, reliable church presentation software built in Rust that just works.
## Overview
LuminaLP is designed to be a superior replacement for OpenLP, focusing on:
- **Reliability**: No complex global registries or fragile state management
- **Performance**: Fast, efficient Rust backend with minimal overhead
- **Simplicity**: Clean KISS/DRY architecture that's easy to debug and maintain
- **Church-focused**: Built specifically for church service needs
## Features
- **Songs Management**: Add, search, and display song lyrics with CCLI support
- **Bible Integration**: Verse lookup and display with multiple translations
- **Media Support**: Images, videos, and audio with automatic type detection
- **Presentations**: PowerPoint/LibreOffice integration with theme preservation
- **Service Planning**: Build and navigate through service items
- **Plugin Architecture**: Clean trait-based system without global state
## Architecture
### Core Components
- **lumina-core**: Database, app state, and plugin traits
- **lumina-songs**: Song lyrics and CCLI management
- **lumina-bible**: Bible verse search and display
- **lumina-media**: Image/video/audio handling
- **lumina-presentations**: PowerPoint/ODP rendering via LibreOffice
### Database
- SQLite backend with centralized SQL operations
- All schemas defined in `core/src/database.rs`
- Plugins use database helpers, no raw SQL in plugins
## Requirements
- **Rust**: 1.70+ with Cargo
- **LibreOffice**: For presentation rendering (headless mode)
- **SQLite**: Embedded database (no external setup required)
## Installation
```bash
# Clone the repository
git clone ssh://rockvilleav@git.rockvilletollandsda.church:10443/RTSDA/LuminaLP.git
cd LuminaLP
# Build the project
cargo build --release
# Run tests
cargo test
```
## Development
### Project Structure
```
├── core/ # Core app logic and traits
│ ├── src/
│ │ ├── lib.rs # App struct and plugin traits
│ │ └── database.rs # All SQL operations live here
├── plugins/
│ ├── songs/ # Song lyrics plugin
│ ├── bible/ # Bible verses plugin
│ ├── media/ # Media files plugin
│ └── presentations/ # PowerPoint/ODP plugin
├── tests/
│ └── integration_tests.rs # Real integration tests
└── src/
└── main.rs # CLI entry point
```
### Design Principles
1. **KISS (Keep It Simple, Stupid)**
- No complex global registries
- Simple trait-based plugin system
- Centralized database operations
2. **DRY (Don't Repeat Yourself)**
- Shared database helpers
- Common plugin patterns
- Reusable components
3. **Separation of Concerns**
- All SQL in `database.rs`
- Plugins focus on business logic
- Clear boundaries between components
### Adding New Plugins
1. Create plugin crate in `plugins/` directory
2. Implement the `ContentPlugin` trait
3. Use database helpers for data operations
4. Add integration tests
5. Register plugin in main app
### Testing
```bash
# Run all tests
cargo test
# Run specific plugin tests
cargo test --package lumina-songs
# Run integration tests only
cargo test --test integration_tests
```
## Presentation Integration
LuminaLP uses a sophisticated combo approach for PowerPoint rendering:
1. **HTML Export**: LibreOffice exports slide text content and structure
2. **XHTML Export**: LibreOffice exports complete CSS styling and themes
3. **Smart Combining**: Parser merges text content with beautiful styling
This preserves the exact look and feel of original presentations including:
- Colors, fonts, and spacing
- Theme elements and backgrounds
- Animation CSS (where supported)
- Layout positioning
### LibreOffice Setup
Ensure LibreOffice is installed and accessible via command line:
```bash
# Test LibreOffice headless mode
libreoffice --headless --version
```
## License
MIT License
## Contributing
1. Follow Rust best practices and `cargo fmt`
2. Add tests for new functionality
3. Keep SQL operations in `database.rs`
4. Maintain KISS/DRY principles
5. No global state or complex registries

12
core/Cargo.toml Normal file
View file

@ -0,0 +1,12 @@
[package]
name = "lumina-core"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
async-trait = "0.1"
tracing = "0.1"
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite"] }

215
core/src/database.rs Normal file
View file

@ -0,0 +1,215 @@
use sqlx::{Row, SqlitePool};
use std::path::Path;
pub type Result<T> = anyhow::Result<T>;
/// Shared database connection - no complex pooling BS
#[derive(Clone)]
pub struct Database {
pool: SqlitePool,
}
impl Database {
pub async fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
let url = if path.as_ref() == std::path::Path::new(":memory:") {
"sqlite::memory:".to_string()
} else {
format!("sqlite://{}", path.as_ref().display())
};
let pool = SqlitePool::connect(&url).await?;
// Enable foreign keys and WAL mode for better performance
sqlx::query("PRAGMA foreign_keys = ON").execute(&pool).await?;
sqlx::query("PRAGMA journal_mode = WAL").execute(&pool).await?;
Ok(Self { pool })
}
/// Simple search helper - most plugins will use this pattern
pub async fn search_table(
&self,
table: &str,
columns: &[&str],
search_columns: &[&str],
query: &str,
limit: Option<i64>
) -> Result<Vec<sqlx::sqlite::SqliteRow>> {
let select_cols = columns.join(", ");
let where_clause = search_columns
.iter()
.map(|col| format!("{} LIKE ?", col))
.collect::<Vec<_>>()
.join(" OR ");
let limit_clause = limit.map(|l| format!(" LIMIT {}", l)).unwrap_or_default();
let sql = format!(
"SELECT {} FROM {} WHERE {} ORDER BY {} {}",
select_cols, table, where_clause, columns[0], limit_clause
);
let mut query_builder = sqlx::query(&sql);
for _ in search_columns {
query_builder = query_builder.bind(format!("%{}%", query));
}
let rows = query_builder.fetch_all(&self.pool).await?;
Ok(rows)
}
/// Get single record by ID
pub async fn get_by_id(
&self,
table: &str,
columns: &[&str],
id: i64
) -> Result<Option<sqlx::sqlite::SqliteRow>> {
let select_cols = columns.join(", ");
let sql = format!("SELECT {} FROM {} WHERE id = ?", select_cols, table);
let row = sqlx::query(&sql)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row)
}
/// Raw query access for complex cases
pub fn raw(&self) -> &SqlitePool {
&self.pool
}
/// Create table helper
pub async fn create_table(&self, sql: &str) -> Result<()> {
sqlx::query(sql).execute(&self.pool).await?;
Ok(())
}
/// Initialize all plugin tables - all schemas live here
pub async fn init_schemas(&self) -> Result<()> {
// Songs table
self.create_table(
r#"
CREATE TABLE IF NOT EXISTS songs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
lyrics TEXT NOT NULL,
author TEXT,
ccli TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"#,
).await?;
// Bible table
self.create_table(
r#"
CREATE TABLE IF NOT EXISTS bible_verses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book TEXT NOT NULL,
chapter INTEGER NOT NULL,
verse INTEGER NOT NULL,
text TEXT NOT NULL,
translation TEXT NOT NULL DEFAULT 'KJV',
UNIQUE(book, chapter, verse, translation)
)
"#,
).await?;
// Media table
self.create_table(
r#"
CREATE TABLE IF NOT EXISTS media_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
file_path TEXT NOT NULL,
media_type TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"#,
).await?;
// Presentations table
self.create_table(
r#"
CREATE TABLE IF NOT EXISTS presentations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
file_path TEXT NOT NULL,
slide_count INTEGER DEFAULT 0,
presentation_type TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
"#,
).await?;
Ok(())
}
}
/// Helper trait for converting SQLite rows to ServiceItems
pub trait FromRow {
fn from_row(row: &sqlx::sqlite::SqliteRow, plugin_name: &str) -> crate::ServiceItem;
}
/// Generic CRUD operations - all SQL lives here
impl Database {
/// Insert a record and return the ID - simplified for now
pub async fn insert_song(&self, title: &str, lyrics: &str, author: &Option<String>, ccli: &Option<String>) -> Result<i64> {
let id = sqlx::query("INSERT INTO songs (title, lyrics, author, ccli) VALUES (?, ?, ?, ?)")
.bind(title)
.bind(lyrics)
.bind(author)
.bind(ccli)
.execute(&self.pool)
.await?
.last_insert_rowid();
Ok(id)
}
/// Insert bible verse
pub async fn insert_bible_verse(&self, book: &str, chapter: i32, verse: i32, text: &str, translation: &str) -> Result<i64> {
let id = sqlx::query("INSERT INTO bible_verses (book, chapter, verse, text, translation) VALUES (?, ?, ?, ?, ?)")
.bind(book)
.bind(chapter)
.bind(verse)
.bind(text)
.bind(translation)
.execute(&self.pool)
.await?
.last_insert_rowid();
Ok(id)
}
/// Insert media item
pub async fn insert_media(&self, name: &str, file_path: &str, media_type: &str) -> Result<i64> {
let id = sqlx::query("INSERT INTO media_items (name, file_path, media_type) VALUES (?, ?, ?)")
.bind(name)
.bind(file_path)
.bind(media_type)
.execute(&self.pool)
.await?
.last_insert_rowid();
Ok(id)
}
/// Insert presentation
pub async fn insert_presentation(&self, name: &str, file_path: &str, presentation_type: &str) -> Result<i64> {
let id = sqlx::query("INSERT INTO presentations (name, file_path, presentation_type) VALUES (?, ?, ?)")
.bind(name)
.bind(file_path)
.bind(presentation_type)
.execute(&self.pool)
.await?
.last_insert_rowid();
Ok(id)
}
/// Delete a record
pub async fn delete(&self, table: &str, id: i64) -> Result<()> {
let sql = format!("DELETE FROM {} WHERE id = ?", table);
sqlx::query(&sql).bind(id).execute(&self.pool).await?;
Ok(())
}
}

112
core/src/lib.rs Normal file
View file

@ -0,0 +1,112 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub mod database;
pub use database::Database;
pub use sqlx;
pub type Result<T> = anyhow::Result<T>;
/// Core content item that all plugins work with - KISS approach
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceItem {
pub id: String,
pub plugin: String,
pub title: String,
pub data: serde_json::Value,
}
/// Simple slide content - just HTML and optional CSS
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlideContent {
pub html: String,
pub css: Option<String>,
}
/// Dead simple plugin trait - no complex lifecycle
#[async_trait]
pub trait ContentPlugin: Send + Sync {
fn name(&self) -> &'static str;
async fn search(&self, query: &str) -> Result<Vec<ServiceItem>>;
async fn render(&self, item: &ServiceItem) -> Result<SlideContent>;
}
/// Service is just a list - no complex state management
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Service {
pub name: String,
pub items: Vec<ServiceItem>,
pub current_index: usize,
}
impl Service {
pub fn new(name: String) -> Self {
Self {
name,
items: Vec::new(),
current_index: 0,
}
}
pub fn add_item(&mut self, item: ServiceItem) {
self.items.push(item);
}
pub fn current(&self) -> Option<&ServiceItem> {
self.items.get(self.current_index)
}
pub fn next(&mut self) -> Option<&ServiceItem> {
if self.current_index < self.items.len().saturating_sub(1) {
self.current_index += 1;
}
self.current()
}
pub fn previous(&mut self) -> Option<&ServiceItem> {
if self.current_index > 0 {
self.current_index -= 1;
}
self.current()
}
}
/// Simple app state - no global registry BS
pub struct App {
pub service: Service,
pub plugins: HashMap<String, Box<dyn ContentPlugin>>,
pub db: Database,
}
impl App {
pub async fn new(db_path: &str) -> Result<Self> {
let db = Database::new(db_path).await?;
db.init_schemas().await?;
Ok(Self {
service: Service::new("New Service".to_string()),
plugins: HashMap::new(),
db,
})
}
pub fn register_plugin(&mut self, plugin: Box<dyn ContentPlugin>) {
let name = plugin.name().to_string();
self.plugins.insert(name, plugin);
}
pub async fn search(&self, plugin_name: &str, query: &str) -> Result<Vec<ServiceItem>> {
if let Some(plugin) = self.plugins.get(plugin_name) {
plugin.search(query).await
} else {
Ok(Vec::new())
}
}
pub async fn render_current(&self) -> Option<Result<SlideContent>> {
let current = self.service.current()?;
let plugin = self.plugins.get(&current.plugin)?;
Some(plugin.render(current).await)
}
}

10
plugins/bible/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "lumina-bible"
version = "0.1.0"
edition = "2021"
[dependencies]
lumina-core = { path = "../../core" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
async-trait = "0.1"

View file

@ -0,0 +1,29 @@
.bible-slide {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #8B4513 0%, #A0522D 100%);
color: white;
font-family: Georgia, serif;
text-align: center;
padding: 60px;
box-sizing: border-box;
}
.verse-text {
font-size: 42px;
line-height: 1.4;
margin-bottom: 50px;
font-style: italic;
max-width: 90%;
text-shadow: 2px 2px 4px rgba(0,0,0,0.7);
}
.reference {
font-size: 28px;
font-weight: bold;
font-style: normal;
opacity: 0.9;
}

92
plugins/bible/src/lib.rs Normal file
View file

@ -0,0 +1,92 @@
use async_trait::async_trait;
use lumina_core::{database::FromRow, ContentPlugin, Database, Result, ServiceItem, SlideContent, sqlx};
use serde::{Deserialize, Serialize};
use sqlx::Row;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BibleVerse {
pub id: i64,
pub book: String,
pub chapter: i32,
pub verse: i32,
pub text: String,
pub translation: String,
}
impl FromRow for BibleVerse {
fn from_row(row: &sqlx::sqlite::SqliteRow, plugin_name: &str) -> ServiceItem {
let book: String = row.get("book");
let chapter: i32 = row.get("chapter");
let verse: i32 = row.get("verse");
let translation: String = row.get("translation");
ServiceItem {
id: format!("{}_{}", plugin_name, row.get::<i64, _>("id")),
plugin: plugin_name.to_string(),
title: format!("{} {}:{} ({})", book, chapter, verse, translation),
data: serde_json::json!({
"id": row.get::<i64, _>("id"),
"book": book,
"chapter": chapter,
"verse": verse,
"text": row.get::<String, _>("text"),
"translation": translation,
}),
}
}
}
pub struct BiblePlugin {
db: Database,
}
impl BiblePlugin {
pub fn new(db: Database) -> Self {
// No SQL - database module handles all schemas
Self { db }
}
pub async fn add_verse(&self, verse: BibleVerse) -> Result<i64> {
self.db.insert_bible_verse(&verse.book, verse.chapter, verse.verse, &verse.text, &verse.translation).await
}
}
#[async_trait]
impl ContentPlugin for BiblePlugin {
fn name(&self) -> &'static str {
"bible"
}
async fn search(&self, query: &str) -> Result<Vec<ServiceItem>> {
let rows = self.db.search_table(
"bible_verses",
&["id", "book", "chapter", "verse", "text", "translation"],
&["book", "text"],
query,
Some(50),
).await?;
Ok(rows.iter().map(|row| BibleVerse::from_row(row, "bible")).collect())
}
async fn render(&self, item: &ServiceItem) -> Result<SlideContent> {
let book = item.data["book"].as_str().unwrap_or("");
let chapter = item.data["chapter"].as_i64().unwrap_or(0);
let verse = item.data["verse"].as_i64().unwrap_or(0);
let text = item.data["text"].as_str().unwrap_or("");
let translation = item.data["translation"].as_str().unwrap_or("KJV");
let html = format!(
r#"<div class='bible-slide'>
<div class='verse-text'>{}</div>
<div class='reference'>{} {}:{} ({})</div>
</div>"#,
text, book, chapter, verse, translation
);
Ok(SlideContent {
html,
css: Some(include_str!("bible.css").to_string()),
})
}
}

10
plugins/media/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "lumina-media"
version = "0.1.0"
edition = "2021"
[dependencies]
lumina-core = { path = "../../core" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
async-trait = "0.1"

139
plugins/media/src/lib.rs Normal file
View file

@ -0,0 +1,139 @@
use async_trait::async_trait;
use lumina_core::{database::FromRow, ContentPlugin, Database, Result, ServiceItem, SlideContent, sqlx};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MediaItem {
pub id: i64,
pub name: String,
pub file_path: String,
pub media_type: MediaType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum MediaType {
Image,
Video,
Audio,
}
impl MediaType {
pub fn from_extension(path: &str) -> Self {
let ext = Path::new(path)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "webp" => MediaType::Image,
"mp4" | "avi" | "mov" | "mkv" | "webm" => MediaType::Video,
"mp3" | "wav" | "flac" | "ogg" => MediaType::Audio,
_ => MediaType::Image, // Default
}
}
pub fn as_str(&self) -> &'static str {
match self {
MediaType::Image => "image",
MediaType::Video => "video",
MediaType::Audio => "audio",
}
}
}
impl FromRow for MediaItem {
fn from_row(row: &sqlx::sqlite::SqliteRow, plugin_name: &str) -> ServiceItem {
let media_type_str: String = row.get("media_type");
let media_type = match media_type_str.as_str() {
"video" => MediaType::Video,
"audio" => MediaType::Audio,
_ => MediaType::Image,
};
ServiceItem {
id: format!("{}_{}", plugin_name, row.get::<i64, _>("id")),
plugin: plugin_name.to_string(),
title: row.get("name"),
data: serde_json::json!({
"id": row.get::<i64, _>("id"),
"name": row.get::<String, _>("name"),
"file_path": row.get::<String, _>("file_path"),
"media_type": media_type_str,
}),
}
}
}
pub struct MediaPlugin {
db: Database,
}
impl MediaPlugin {
pub fn new(db: Database) -> Self {
Self { db }
}
pub async fn add_media(&self, name: String, file_path: String) -> Result<i64> {
let media_type = MediaType::from_extension(&file_path);
self.db.insert_media(&name, &file_path, media_type.as_str()).await
}
}
#[async_trait]
impl ContentPlugin for MediaPlugin {
fn name(&self) -> &'static str {
"media"
}
async fn search(&self, query: &str) -> Result<Vec<ServiceItem>> {
let rows = self.db.search_table(
"media_items",
&["id", "name", "file_path", "media_type"],
&["name"],
query,
Some(50),
).await?;
Ok(rows.iter().map(|row| MediaItem::from_row(row, "media")).collect())
}
async fn render(&self, item: &ServiceItem) -> Result<SlideContent> {
let file_path = item.data["file_path"].as_str().unwrap_or("");
let media_type = item.data["media_type"].as_str().unwrap_or("image");
let html = match media_type {
"image" => format!(
r#"<div class='media-slide image-slide'>
<img src='file://{}' alt='{}' />
</div>"#,
file_path, item.title
),
"video" => format!(
r#"<div class='media-slide video-slide'>
<video controls autoplay>
<source src='file://{}' />
</video>
</div>"#,
file_path
),
"audio" => format!(
r#"<div class='media-slide audio-slide'>
<div class='audio-title'>{}</div>
<audio controls autoplay>
<source src='file://{}' />
</audio>
</div>"#,
item.title, file_path
),
_ => format!("<div class='media-slide'>Unsupported media type</div>"),
};
Ok(SlideContent {
html,
css: Some(include_str!("media.css").to_string()),
})
}
}

View file

@ -0,0 +1,35 @@
.media-slide {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #000;
color: white;
}
.image-slide img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.video-slide video {
max-width: 100%;
max-height: 100%;
}
.audio-slide {
flex-direction: column;
text-align: center;
}
.audio-title {
font-size: 48px;
margin-bottom: 40px;
font-family: Arial, sans-serif;
}
.audio-slide audio {
width: 80%;
max-width: 600px;
}

View file

@ -0,0 +1,12 @@
[package]
name = "lumina-presentations"
version = "0.1.0"
edition = "2021"
[dependencies]
lumina-core = { path = "../../core" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
async-trait = "0.1"
anyhow = "1.0"
tokio = { version = "1.0", features = ["sync"] }

View file

@ -0,0 +1,351 @@
use async_trait::async_trait;
use lumina_core::{database::FromRow, ContentPlugin, Database, Result, ServiceItem, SlideContent, sqlx};
use serde::{Deserialize, Serialize};
use sqlx::Row;
use std::path::Path;
use std::process::Command;
use std::fs;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Presentation {
pub id: i64,
pub name: String,
pub file_path: String,
pub slide_count: i32,
pub presentation_type: PresentationType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum PresentationType {
PowerPoint,
PDF,
LibreOffice,
}
impl PresentationType {
pub fn from_extension(path: &str) -> Self {
let ext = Path::new(path)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_lowercase();
match ext.as_str() {
"ppt" | "pptx" => PresentationType::PowerPoint,
"pdf" => PresentationType::PDF,
"odp" => PresentationType::LibreOffice,
_ => PresentationType::PDF, // Default
}
}
pub fn as_str(&self) -> &'static str {
match self {
PresentationType::PowerPoint => "powerpoint",
PresentationType::PDF => "pdf",
PresentationType::LibreOffice => "libreoffice",
}
}
}
impl FromRow for Presentation {
fn from_row(row: &sqlx::sqlite::SqliteRow, plugin_name: &str) -> ServiceItem {
let type_str: String = row.get("presentation_type");
let presentation_type = match type_str.as_str() {
"powerpoint" => PresentationType::PowerPoint,
"libreoffice" => PresentationType::LibreOffice,
_ => PresentationType::PDF,
};
ServiceItem {
id: format!("{}_{}", plugin_name, row.get::<i64, _>("id")),
plugin: plugin_name.to_string(),
title: row.get("name"),
data: serde_json::json!({
"id": row.get::<i64, _>("id"),
"name": row.get::<String, _>("name"),
"file_path": row.get::<String, _>("file_path"),
"slide_count": row.get::<i32, _>("slide_count"),
"presentation_type": type_str,
}),
}
}
}
pub struct PresentationsPlugin {
db: Database,
// Cache parsed slides by file path
slide_cache: Arc<RwLock<HashMap<String, Vec<SlideContent>>>>,
// Track current slide index for each presentation
current_slides: Arc<RwLock<HashMap<String, usize>>>,
}
impl PresentationsPlugin {
pub fn new(db: Database) -> Self {
Self {
db,
slide_cache: Arc::new(RwLock::new(HashMap::new())),
current_slides: Arc::new(RwLock::new(HashMap::new())),
}
}
pub async fn add_presentation(&self, name: String, file_path: String) -> Result<i64> {
let presentation_type = PresentationType::from_extension(&file_path);
self.db.insert_presentation(&name, &file_path, presentation_type.as_str()).await
}
pub async fn get_slides(&self, file_path: &str) -> Result<Vec<SlideContent>> {
// Check cache first
{
let cache = self.slide_cache.read().await;
if let Some(slides) = cache.get(file_path) {
return Ok(slides.clone());
}
}
// Parse slides and cache them
let (html_content, xhtml_content) = self.export_to_html(file_path)?;
let slides = self.parse_slides(&html_content, &xhtml_content)?;
// Cache the slides
{
let mut cache = self.slide_cache.write().await;
cache.insert(file_path.to_string(), slides.clone());
}
Ok(slides)
}
pub async fn get_current_slide(&self, file_path: &str) -> Result<SlideContent> {
let slides = self.get_slides(file_path).await?;
let current_index = {
let current_slides = self.current_slides.read().await;
*current_slides.get(file_path).unwrap_or(&0)
};
if current_index < slides.len() {
Ok(slides[current_index].clone())
} else {
Ok(slides.first().cloned().unwrap_or_else(|| SlideContent {
html: "<div>No slides found</div>".to_string(),
css: None,
}))
}
}
pub async fn next_slide(&self, file_path: &str) -> Result<SlideContent> {
let slides = self.get_slides(file_path).await?;
let mut current_slides = self.current_slides.write().await;
let current_index = current_slides.get(file_path).copied().unwrap_or(0);
let next_index = if current_index + 1 < slides.len() {
current_index + 1
} else {
current_index // Stay on last slide
};
current_slides.insert(file_path.to_string(), next_index);
Ok(slides[next_index].clone())
}
pub async fn previous_slide(&self, file_path: &str) -> Result<SlideContent> {
let slides = self.get_slides(file_path).await?;
let mut current_slides = self.current_slides.write().await;
let current_index = current_slides.get(file_path).copied().unwrap_or(0);
let prev_index = if current_index > 0 {
current_index - 1
} else {
0 // Stay on first slide
};
current_slides.insert(file_path.to_string(), prev_index);
Ok(slides[prev_index].clone())
}
pub async fn goto_slide(&self, file_path: &str, slide_index: usize) -> Result<SlideContent> {
let slides = self.get_slides(file_path).await?;
if slide_index < slides.len() {
let mut current_slides = self.current_slides.write().await;
current_slides.insert(file_path.to_string(), slide_index);
Ok(slides[slide_index].clone())
} else {
Err(anyhow::anyhow!("Slide index {} out of bounds", slide_index))
}
}
pub async fn get_slide_count(&self, file_path: &str) -> Result<usize> {
let slides = self.get_slides(file_path).await?;
Ok(slides.len())
}
fn export_to_html(&self, file_path: &str) -> Result<(String, String)> {
let temp_dir = std::env::temp_dir();
let base_name = Path::new(file_path).file_stem().unwrap().to_str().unwrap();
let html_output = temp_dir.join(format!("{}.html", base_name));
let xhtml_output = temp_dir.join(format!("{}.xhtml", base_name));
// Export to HTML (for text content)
let html_status = Command::new("libreoffice")
.args([
"--headless",
"--convert-to", "html",
"--outdir", temp_dir.to_str().unwrap(),
file_path
])
.status()?;
if !html_status.success() {
return Err(anyhow::anyhow!("LibreOffice HTML export failed"));
}
// Export to XHTML (for styling)
let xhtml_status = Command::new("libreoffice")
.args([
"--headless",
"--convert-to", "xhtml",
"--outdir", temp_dir.to_str().unwrap(),
file_path
])
.status()?;
if !xhtml_status.success() {
return Err(anyhow::anyhow!("LibreOffice XHTML export failed"));
}
let html_content = fs::read_to_string(&html_output)?;
let xhtml_content = fs::read_to_string(&xhtml_output)?;
// Cleanup temp files
let _ = fs::remove_file(html_output);
let _ = fs::remove_file(xhtml_output);
Ok((html_content, xhtml_content))
}
fn parse_slides(&self, html_content: &str, xhtml_content: &str) -> Result<Vec<SlideContent>> {
// Extract CSS from XHTML
let css = self.extract_css_from_xhtml(xhtml_content)?;
// Parse slides from HTML - each slide is separated by page breaks
let slides = self.extract_slides_from_html(html_content)?;
let mut slide_contents = Vec::new();
for slide_html in slides {
slide_contents.push(SlideContent {
html: slide_html,
css: Some(css.clone()),
});
}
Ok(slide_contents)
}
fn extract_css_from_xhtml(&self, xhtml_content: &str) -> Result<String> {
// Find CSS between <style> tags
if let Some(start) = xhtml_content.find("<style>") {
if let Some(end) = xhtml_content.find("</style>") {
let css_start = start + "<style>".len();
return Ok(xhtml_content[css_start..end].trim().to_string());
}
}
Ok(String::new())
}
fn extract_slides_from_html(&self, html_content: &str) -> Result<Vec<String>> {
let mut slides = Vec::new();
// Split by page-break-before:always or h1 tags (LibreOffice uses h1 for slide titles)
let parts: Vec<&str> = html_content.split("<h1").collect();
for (i, part) in parts.iter().enumerate() {
if i == 0 && !part.contains("<body") {
continue; // Skip header content
}
let slide_content = if i == 0 {
part.to_string()
} else {
format!("<h1{}", part)
};
// Clean up the slide content and wrap in a container
let cleaned = self.clean_slide_content(&slide_content);
if !cleaned.trim().is_empty() {
let wrapped = format!(
"<div class='lumina-slide'>{}</div>",
cleaned
);
slides.push(wrapped);
}
}
Ok(slides)
}
fn clean_slide_content(&self, content: &str) -> String {
// Remove page-break CSS and clean up content
content
.replace("page-break-before:always", "")
.replace("style=\";\"", "")
.trim()
.to_string()
}
}
#[async_trait]
impl ContentPlugin for PresentationsPlugin {
fn name(&self) -> &'static str {
"presentations"
}
async fn search(&self, query: &str) -> Result<Vec<ServiceItem>> {
let rows = self.db.search_table(
"presentations",
&["id", "name", "file_path", "slide_count", "presentation_type"],
&["name"],
query,
Some(50),
).await?;
Ok(rows.iter().map(|row| Presentation::from_row(row, "presentations")).collect())
}
async fn render(&self, item: &ServiceItem) -> Result<SlideContent> {
let file_path = item.data["file_path"].as_str().unwrap_or("");
let presentation_type = item.data["presentation_type"].as_str().unwrap_or("pdf");
// For PowerPoint files, use LibreOffice to convert and parse
if matches!(presentation_type, "powerpoint" | "libreoffice") {
match self.get_current_slide(file_path).await {
Ok(slide) => return Ok(slide),
Err(e) => {
eprintln!("Error getting current slide: {}", e);
}
}
}
// Fallback to placeholder
let html = format!(
r#"<div class='presentation-slide'>
<div class='presentation-title'>{}</div>
<div class='presentation-info'>
<p>Type: {}</p>
<p>File: {}</p>
</div>
<div class='presentation-note'>
Presentation rendering ready - LibreOffice integration active
</div>
</div>"#,
item.title, presentation_type, file_path
);
Ok(SlideContent {
html,
css: Some(include_str!("presentation.css").to_string()),
})
}
}

View file

@ -0,0 +1,34 @@
.presentation-slide {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(45deg, #2c3e50, #34495e);
color: white;
font-family: Arial, sans-serif;
text-align: center;
padding: 40px;
}
.presentation-title {
font-size: 48px;
font-weight: bold;
margin-bottom: 30px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
}
.presentation-info {
font-size: 24px;
margin-bottom: 40px;
}
.presentation-info p {
margin: 10px 0;
}
.presentation-note {
font-size: 18px;
opacity: 0.8;
font-style: italic;
}

10
plugins/songs/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "lumina-songs"
version = "0.1.0"
edition = "2021"
[dependencies]
lumina-core = { path = "../../core" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
async-trait = "0.1"

101
plugins/songs/src/lib.rs Normal file
View file

@ -0,0 +1,101 @@
use async_trait::async_trait;
use lumina_core::{database::FromRow, ContentPlugin, Database, Result, ServiceItem, SlideContent, sqlx};
use serde::{Deserialize, Serialize};
use sqlx::Row;
/// Simple song structure - KISS approach
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Song {
pub id: i64,
pub title: String,
pub lyrics: String,
pub author: Option<String>,
pub ccli: Option<String>,
}
impl FromRow for Song {
fn from_row(row: &sqlx::sqlite::SqliteRow, plugin_name: &str) -> ServiceItem {
ServiceItem {
id: format!("{}_{}", plugin_name, row.get::<i64, _>("id")),
plugin: plugin_name.to_string(),
title: row.get("title"),
data: serde_json::json!({
"id": row.get::<i64, _>("id"),
"lyrics": row.get::<String, _>("lyrics"),
"author": row.get::<Option<String>, _>("author"),
"ccli": row.get::<Option<String>, _>("ccli"),
}),
}
}
}
pub struct SongsPlugin {
db: Database,
}
impl SongsPlugin {
pub fn new(db: Database) -> Self {
// No SQL here - database handles all schemas
Self { db }
}
pub async fn add_song(&self, song: Song) -> Result<i64> {
self.db.insert_song(&song.title, &song.lyrics, &song.author, &song.ccli).await
}
}
#[async_trait]
impl ContentPlugin for SongsPlugin {
fn name(&self) -> &'static str {
"songs"
}
async fn search(&self, query: &str) -> Result<Vec<ServiceItem>> {
// Use shared database helper - much cleaner!
let rows = self.db.search_table(
"songs",
&["id", "title", "lyrics", "author", "ccli"],
&["title", "lyrics", "author"],
query,
Some(50),
).await?;
Ok(rows.iter().map(|row| Song::from_row(row, "songs")).collect())
}
async fn render(&self, item: &ServiceItem) -> Result<SlideContent> {
let lyrics = item.data["lyrics"].as_str().unwrap_or("");
let author = item.data["author"].as_str();
let ccli = item.data["ccli"].as_str();
// Simple verse splitting
let verses: Vec<&str> = lyrics.split("\n\n").filter(|v| !v.trim().is_empty()).collect();
let verse_html = verses
.iter()
.map(|verse| format!("<div class='verse'>{}</div>", verse.replace('\n', "<br/>")))
.collect::<Vec<_>>()
.join("");
let footer = match (author, ccli) {
(Some(a), Some(c)) => format!("<div class='footer'>{} | CCLI: {}</div>", a, c),
(Some(a), None) => format!("<div class='footer'>{}</div>", a),
(None, Some(c)) => format!("<div class='footer'>CCLI: {}</div>", c),
(None, None) => String::new(),
};
let html = format!(
r#"<div class='song-slide'>
<div class='title'>{}</div>
<div class='content'>{}</div>
{}
</div>"#,
item.title, verse_html, footer
);
Ok(SlideContent {
html,
css: Some(include_str!("song.css").to_string()),
})
}
}

View file

@ -0,0 +1,38 @@
.song-slide {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-family: Arial, sans-serif;
text-align: center;
padding: 40px;
box-sizing: border-box;
}
.title {
font-size: 48px;
font-weight: bold;
margin-bottom: 40px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
}
.content {
font-size: 36px;
line-height: 1.5;
max-width: 80%;
}
.verse {
margin-bottom: 30px;
}
.footer {
position: absolute;
bottom: 20px;
right: 20px;
font-size: 18px;
opacity: 0.8;
}

3
src/main.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
println!("Hello, world!");
}

176
tests/integration_tests.rs Normal file
View file

@ -0,0 +1,176 @@
use lumina_core::App;
use lumina_songs::{Song, SongsPlugin};
use lumina_bible::{BibleVerse, BiblePlugin};
use lumina_media::MediaPlugin;
use lumina_presentations::PresentationsPlugin;
use tokio;
#[tokio::test]
async fn test_songs_plugin_full_workflow() {
// Use in-memory database for tests
let mut app = App::new(":memory:").await.unwrap();
let songs_plugin = SongsPlugin::new(app.db.clone());
// Add a real song
let song = Song {
id: 0, // Will be set by DB
title: "Amazing Grace".to_string(),
lyrics: "Amazing grace how sweet the sound\nThat saved a wretch like me\n\nI once was lost but now am found\nWas blind but now I see".to_string(),
author: Some("John Newton".to_string()),
ccli: Some("12345".to_string()),
};
let song_id = songs_plugin.add_song(song).await.unwrap();
assert!(song_id > 0);
app.register_plugin(Box::new(songs_plugin));
// Search for the song
let results = app.search("songs", "Amazing").await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Amazing Grace");
assert_eq!(results[0].plugin, "songs");
// Test rendering
let rendered = app.plugins.get("songs").unwrap().render(&results[0]).await.unwrap();
assert!(rendered.html.contains("Amazing Grace"));
assert!(rendered.html.contains("Amazing grace how sweet the sound"));
assert!(rendered.html.contains("John Newton"));
assert!(rendered.html.contains("CCLI: 12345"));
assert!(rendered.css.is_some());
}
#[tokio::test]
async fn test_bible_plugin_full_workflow() {
let mut app = App::new(":memory:").await.unwrap();
let bible_plugin = BiblePlugin::new(app.db.clone());
// Add a real verse
let verse = BibleVerse {
id: 0,
book: "John".to_string(),
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.".to_string(),
translation: "KJV".to_string(),
};
let verse_id = bible_plugin.add_verse(verse).await.unwrap();
assert!(verse_id > 0);
app.register_plugin(Box::new(bible_plugin));
// Search for the verse
let results = app.search("bible", "God so loved").await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "John 3:16 (KJV)");
assert_eq!(results[0].plugin, "bible");
// Test rendering
let rendered = app.plugins.get("bible").unwrap().render(&results[0]).await.unwrap();
assert!(rendered.html.contains("For God so loved the world"));
assert!(rendered.html.contains("John 3:16 (KJV)"));
assert!(rendered.css.is_some());
}
#[tokio::test]
async fn test_media_plugin_full_workflow() {
let mut app = App::new(":memory:").await.unwrap();
let media_plugin = MediaPlugin::new(app.db.clone());
// Add a media item
let media_id = media_plugin.add_media(
"Church Logo".to_string(),
"/path/to/church_logo.jpg".to_string()
).await.unwrap();
assert!(media_id > 0);
app.register_plugin(Box::new(media_plugin));
// Search for media
let results = app.search("media", "Church").await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "Church Logo");
assert_eq!(results[0].plugin, "media");
// Test rendering
let rendered = app.plugins.get("media").unwrap().render(&results[0]).await.unwrap();
assert!(rendered.html.contains("church_logo.jpg"));
assert!(rendered.html.contains("img src"));
assert!(rendered.css.is_some());
}
#[tokio::test]
async fn test_service_management() {
let mut app = App::new(":memory:").await.unwrap();
let songs_plugin = SongsPlugin::new(app.db.clone());
// Add some songs
let song1 = Song {
id: 0,
title: "Song 1".to_string(),
lyrics: "Verse 1 content".to_string(),
author: None,
ccli: None,
};
let song2 = Song {
id: 0,
title: "Song 2".to_string(),
lyrics: "Verse 2 content".to_string(),
author: None,
ccli: None,
};
songs_plugin.add_song(song1).await.unwrap();
songs_plugin.add_song(song2).await.unwrap();
app.register_plugin(Box::new(songs_plugin));
// Search and add to service
let song1_results = app.search("songs", "Song 1").await.unwrap();
let song2_results = app.search("songs", "Song 2").await.unwrap();
app.service.add_item(song1_results[0].clone());
app.service.add_item(song2_results[0].clone());
// Test service navigation
assert_eq!(app.service.items.len(), 2);
assert_eq!(app.service.current_index, 0);
let current = app.service.current().unwrap();
assert_eq!(current.title, "Song 1");
let next = app.service.next().unwrap();
assert_eq!(next.title, "Song 2");
assert_eq!(app.service.current_index, 1);
let prev = app.service.previous().unwrap();
assert_eq!(prev.title, "Song 1");
assert_eq!(app.service.current_index, 0);
}
#[tokio::test]
async fn test_multiple_plugins_together() {
let mut app = App::new(":memory:").await.unwrap();
// Register all plugins
let songs_plugin = SongsPlugin::new(app.db.clone());
let bible_plugin = BiblePlugin::new(app.db.clone());
let media_plugin = MediaPlugin::new(app.db.clone());
let presentations_plugin = PresentationsPlugin::new(app.db.clone());
app.register_plugin(Box::new(songs_plugin));
app.register_plugin(Box::new(bible_plugin));
app.register_plugin(Box::new(media_plugin));
app.register_plugin(Box::new(presentations_plugin));
// Verify all plugins are registered
assert_eq!(app.plugins.len(), 4);
assert!(app.plugins.contains_key("songs"));
assert!(app.plugins.contains_key("bible"));
assert!(app.plugins.contains_key("media"));
assert!(app.plugins.contains_key("presentations"));
// Test that each plugin returns empty results for unknown queries
let empty_results = app.search("songs", "nonexistent").await.unwrap();
assert_eq!(empty_results.len(), 0);
}