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