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:
commit
c76e61ea59
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal 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
4867
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
32
Cargo.toml
Normal file
32
Cargo.toml
Normal 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
145
README.md
Normal 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
12
core/Cargo.toml
Normal 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
215
core/src/database.rs
Normal 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
112
core/src/lib.rs
Normal 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(¤t.plugin)?;
|
||||
Some(plugin.render(current).await)
|
||||
}
|
||||
}
|
10
plugins/bible/Cargo.toml
Normal file
10
plugins/bible/Cargo.toml
Normal 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"
|
29
plugins/bible/src/bible.css
Normal file
29
plugins/bible/src/bible.css
Normal 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
92
plugins/bible/src/lib.rs
Normal 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
10
plugins/media/Cargo.toml
Normal 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
139
plugins/media/src/lib.rs
Normal 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()),
|
||||
})
|
||||
}
|
||||
}
|
35
plugins/media/src/media.css
Normal file
35
plugins/media/src/media.css
Normal 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;
|
||||
}
|
12
plugins/presentations/Cargo.toml
Normal file
12
plugins/presentations/Cargo.toml
Normal 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"] }
|
351
plugins/presentations/src/lib.rs
Normal file
351
plugins/presentations/src/lib.rs
Normal 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()),
|
||||
})
|
||||
}
|
||||
}
|
34
plugins/presentations/src/presentation.css
Normal file
34
plugins/presentations/src/presentation.css
Normal 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
10
plugins/songs/Cargo.toml
Normal 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
101
plugins/songs/src/lib.rs
Normal 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()),
|
||||
})
|
||||
}
|
||||
}
|
38
plugins/songs/src/song.css
Normal file
38
plugins/songs/src/song.css
Normal 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
3
src/main.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
176
tests/integration_tests.rs
Normal file
176
tests/integration_tests.rs
Normal 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);
|
||||
}
|
Loading…
Reference in a new issue