Initial commit: Church Core Rust library
Some checks are pending
iOS UniFFI Build / build-ios (push) Waiting to run
Some checks are pending
iOS UniFFI Build / build-ios (push) Waiting to run
Add church management API library with cross-platform support for iOS, Android, and WASM. Features include event management, bulletin handling, contact forms, and authentication.
This commit is contained in:
commit
4d6b23beb3
38
.github/workflows/ios-build.yml
vendored
Normal file
38
.github/workflows/ios-build.yml
vendored
Normal file
|
@ -0,0 +1,38 @@
|
|||
name: iOS UniFFI Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
build-ios:
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-ios,x86_64-apple-ios,aarch64-apple-ios-sim
|
||||
|
||||
- name: Install uniffi_bindgen
|
||||
run: cargo install uniffi_bindgen --bin uniffi-bindgen
|
||||
|
||||
- name: Build iOS framework
|
||||
run: make ios
|
||||
|
||||
- name: Test Rust code
|
||||
run: cargo test --features uniffi
|
||||
|
||||
- name: Upload iOS artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ios-bindings
|
||||
path: |
|
||||
bindings/ios/church_core.swift
|
||||
bindings/ios/church_coreFFI.h
|
||||
bindings/ios/church_coreFFI.modulemap
|
||||
bindings/ios/libchurch_core_universal.a
|
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
|
@ -0,0 +1,43 @@
|
|||
# Rust
|
||||
/target/
|
||||
Cargo.lock
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
*.a
|
||||
*.dylib
|
||||
*.so
|
||||
*.dll
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Generated bindings (if you want to regenerate them)
|
||||
church_core.swift
|
||||
church_coreFFI.h
|
||||
church_coreFFI.modulemap
|
||||
libchurch_core_*.a
|
||||
|
||||
# Build directories
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Test artifacts
|
||||
coverage/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
113
Cargo.toml
Normal file
113
Cargo.toml
Normal file
|
@ -0,0 +1,113 @@
|
|||
[package]
|
||||
name = "church-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Shared Rust crate for church application APIs and data models"
|
||||
authors = ["Benjamin Slingo <benjamin@example.com>"]
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
# HTTP client (using rustls to avoid OpenSSL cross-compilation issues)
|
||||
reqwest = { version = "0.11", features = ["json", "multipart", "stream", "rustls-tls"], default-features = false }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
# JSON handling
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Date/time handling
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
|
||||
# Caching and utilities
|
||||
moka = { version = "0.12", features = ["future"] }
|
||||
async-trait = "0.1"
|
||||
rand = "0.8"
|
||||
urlencoding = "2.1"
|
||||
|
||||
# UUID generation
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
|
||||
# Base64 encoding for image caching
|
||||
base64 = "0.21"
|
||||
|
||||
# URL handling
|
||||
url = "2.4"
|
||||
|
||||
# Regular expressions
|
||||
regex = "1.10"
|
||||
|
||||
# System calls for iOS device detection
|
||||
libc = "0.2"
|
||||
|
||||
# HTML processing
|
||||
html2text = "0.12"
|
||||
|
||||
# UniFFI for mobile bindings
|
||||
uniffi = { version = "0.27", features = ["tokio"] }
|
||||
|
||||
# Build dependencies
|
||||
[build-dependencies]
|
||||
uniffi = { version = "0.27", features = ["build"], optional = true }
|
||||
uniffi_bindgen = { version = "0.27", features = ["clap"] }
|
||||
|
||||
# Bin dependencies
|
||||
[dependencies.uniffi_bindgen_dep]
|
||||
package = "uniffi_bindgen"
|
||||
version = "0.27"
|
||||
optional = true
|
||||
|
||||
# Testing dependencies
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
mockito = "0.31"
|
||||
serde_json = "1.0"
|
||||
tempfile = "3.8"
|
||||
pretty_assertions = "1.4"
|
||||
|
||||
# Optional FFI support
|
||||
[dependencies.wasm-bindgen]
|
||||
version = "0.2"
|
||||
optional = true
|
||||
|
||||
[dependencies.wasm-bindgen-futures]
|
||||
version = "0.4"
|
||||
optional = true
|
||||
|
||||
[dependencies.js-sys]
|
||||
version = "0.3"
|
||||
optional = true
|
||||
|
||||
[dependencies.web-sys]
|
||||
version = "0.3"
|
||||
optional = true
|
||||
features = [
|
||||
"console",
|
||||
"Window",
|
||||
"Document",
|
||||
"Element",
|
||||
"HtmlElement",
|
||||
"Storage",
|
||||
"Request",
|
||||
"RequestInit",
|
||||
"Response",
|
||||
"Headers",
|
||||
]
|
||||
|
||||
[features]
|
||||
default = ["native"]
|
||||
native = []
|
||||
wasm = ["wasm-bindgen", "wasm-bindgen-futures", "js-sys", "web-sys"]
|
||||
ffi = ["uniffi/tokio"]
|
||||
uniffi = ["ffi", "uniffi/build"]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "staticlib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "church-core-test"
|
||||
path = "src/bin/test.rs"
|
||||
|
69
Makefile
Normal file
69
Makefile
Normal file
|
@ -0,0 +1,69 @@
|
|||
# UniFFI iOS Build Makefile
|
||||
# No Python dependencies required
|
||||
|
||||
.PHONY: all clean ios android wasm help install-deps
|
||||
|
||||
# Default target
|
||||
all: ios
|
||||
|
||||
# Build iOS framework with UniFFI bindings
|
||||
ios:
|
||||
@echo "🚀 Building iOS framework..."
|
||||
@./build_ios.sh
|
||||
|
||||
# Clean all build artifacts
|
||||
clean:
|
||||
@echo "🧹 Cleaning build artifacts..."
|
||||
@rm -rf target/
|
||||
@rm -rf bindings/
|
||||
@rm -rf libchurch_core_universal.a
|
||||
|
||||
# Install required dependencies
|
||||
install-deps:
|
||||
@echo "📦 Installing build dependencies..."
|
||||
@rustup target add aarch64-apple-ios
|
||||
@rustup target add x86_64-apple-ios
|
||||
@rustup target add aarch64-apple-ios-sim
|
||||
@cargo install uniffi_bindgen --bin uniffi-bindgen
|
||||
|
||||
# Test the Rust library
|
||||
test:
|
||||
@echo "🧪 Running tests..."
|
||||
@cargo test --features uniffi
|
||||
|
||||
# Build for development (faster, debug mode)
|
||||
dev:
|
||||
@echo "🔧 Building development version..."
|
||||
@cargo build --features uniffi
|
||||
|
||||
# Generate only Swift bindings (no rebuild)
|
||||
bindings-only:
|
||||
@echo "⚡ Generating Swift bindings only..."
|
||||
@mkdir -p bindings/ios
|
||||
@uniffi-bindgen generate \
|
||||
--library target/aarch64-apple-ios/release/libchurch_core.a \
|
||||
--language swift \
|
||||
--out-dir bindings/ios \
|
||||
src/church_core.udl
|
||||
@echo "📋 Creating module map..."
|
||||
@echo 'module church_coreFFI {\n header "church_coreFFI.h"\n export *\n}' > bindings/ios/church_coreFFI.modulemap
|
||||
|
||||
# Copy generated files to iOS project
|
||||
copy-to-ios:
|
||||
@echo "📦 Copying files to iOS project..."
|
||||
@cp bindings/ios/church_core.swift ../RTSDA-iOS/RTSDA/
|
||||
@cp bindings/ios/church_coreFFI.h ../RTSDA-iOS/RTSDA/
|
||||
@cp bindings/ios/church_coreFFI.modulemap ../RTSDA-iOS/RTSDA/
|
||||
@cp bindings/ios/libchurch_core_universal.a ../RTSDA-iOS/RTSDA/libchurch_core.a
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "📚 Available commands:"
|
||||
@echo " make ios - Build complete iOS framework (default)"
|
||||
@echo " make clean - Clean all build artifacts"
|
||||
@echo " make install-deps - Install required build dependencies"
|
||||
@echo " make test - Run Rust tests"
|
||||
@echo " make dev - Quick development build"
|
||||
@echo " make bindings-only - Generate Swift bindings only"
|
||||
@echo " make copy-to-ios - Copy generated files to iOS project"
|
||||
@echo " make help - Show this help message"
|
348
README.md
Normal file
348
README.md
Normal file
|
@ -0,0 +1,348 @@
|
|||
# Church Core
|
||||
|
||||
A shared Rust crate providing unified API access and data models for church applications across multiple platforms (iOS, Android, Web, Desktop).
|
||||
|
||||
## Overview
|
||||
|
||||
Church Core centralizes all church application logic, API communication, and data management into a single, well-tested Rust crate. This enables client applications to become "dumb display devices" while ensuring consistency across platforms.
|
||||
|
||||
## Features
|
||||
|
||||
- **Unified API Client**: Single interface for all church APIs
|
||||
- **Consistent Data Models**: Shared types for events, bulletins, sermons, etc.
|
||||
- **Cross-Platform Support**: Native bindings for iOS, Android, WASM, and FFI
|
||||
- **Built-in Caching**: Automatic response caching with TTL
|
||||
- **Offline Support**: Graceful offline operation with cached data
|
||||
- **Authentication**: PocketBase and Jellyfin integration
|
||||
- **Error Handling**: Comprehensive error types with retry logic
|
||||
- **Type Safety**: Rust's type system prevents API mismatches
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
church-core/
|
||||
├── src/
|
||||
│ ├── client/ # HTTP client and API modules
|
||||
│ │ ├── events.rs # Event operations
|
||||
│ │ ├── bulletins.rs # Bulletin operations
|
||||
│ │ ├── sermons.rs # Sermon operations
|
||||
│ │ ├── contact.rs # Contact form handling
|
||||
│ │ └── config.rs # Configuration management
|
||||
│ ├── models/ # Data structures
|
||||
│ │ ├── event.rs # Event models
|
||||
│ │ ├── bulletin.rs # Bulletin models
|
||||
│ │ ├── sermon.rs # Sermon models
|
||||
│ │ ├── contact.rs # Contact models
|
||||
│ │ └── auth.rs # Authentication models
|
||||
│ ├── auth/ # Authentication modules
|
||||
│ ├── cache/ # Caching system
|
||||
│ ├── utils/ # Utility functions
|
||||
│ └── error.rs # Error types
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```rust
|
||||
use church_core::{ChurchApiClient, ChurchCoreConfig};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Create client with default configuration
|
||||
let config = ChurchCoreConfig::default();
|
||||
let client = ChurchApiClient::new(config)?;
|
||||
|
||||
// Get upcoming events
|
||||
let events = client.get_upcoming_events(Some(10)).await?;
|
||||
for event in events {
|
||||
println!("{}: {}", event.title, event.start_time);
|
||||
}
|
||||
|
||||
// Get current bulletin
|
||||
if let Some(bulletin) = client.get_current_bulletin().await? {
|
||||
println!("Current bulletin: {}", bulletin.title);
|
||||
}
|
||||
|
||||
// Submit contact form
|
||||
let contact = ContactForm::new(
|
||||
"John Doe".to_string(),
|
||||
"john@example.com".to_string(),
|
||||
"Prayer Request".to_string(),
|
||||
"Please pray for my family.".to_string()
|
||||
);
|
||||
let submission_id = client.submit_contact_form(contact).await?;
|
||||
println!("Contact form submitted: {}", submission_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
```rust
|
||||
use church_core::{ChurchApiClient, ChurchCoreConfig};
|
||||
use std::time::Duration;
|
||||
|
||||
let config = ChurchCoreConfig::new()
|
||||
.with_base_url("https://api.mychurch.org/api")
|
||||
.with_cache_ttl(Duration::from_secs(600))
|
||||
.with_timeout(Duration::from_secs(15))
|
||||
.with_retry_attempts(5)
|
||||
.with_offline_mode(true);
|
||||
|
||||
let client = ChurchApiClient::new(config)?;
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
### Event
|
||||
|
||||
```rust
|
||||
use church_core::{Event, EventCategory, NewEvent};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
// Create a new event
|
||||
let new_event = NewEvent {
|
||||
title: "Bible Study".to_string(),
|
||||
description: "Weekly Bible study group".to_string(),
|
||||
start_time: Utc::now(),
|
||||
end_time: Utc::now() + chrono::Duration::hours(2),
|
||||
location: "Fellowship Hall".to_string(),
|
||||
location_url: Some("https://maps.google.com/...".to_string()),
|
||||
category: EventCategory::Education,
|
||||
is_featured: true,
|
||||
// ... other fields
|
||||
};
|
||||
|
||||
let event_id = client.create_event(new_event).await?;
|
||||
```
|
||||
|
||||
### Bulletin
|
||||
|
||||
```rust
|
||||
use church_core::{Bulletin, NewBulletin};
|
||||
use chrono::NaiveDate;
|
||||
|
||||
// Get current bulletin
|
||||
if let Some(bulletin) = client.get_current_bulletin().await? {
|
||||
println!("Sabbath School: {}", bulletin.sabbath_school);
|
||||
println!("Divine Worship: {}", bulletin.divine_worship);
|
||||
|
||||
// Check for announcements
|
||||
for announcement in bulletin.active_announcements() {
|
||||
println!("📢 {}: {}", announcement.title, announcement.content);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Contact Form
|
||||
|
||||
```rust
|
||||
use church_core::{ContactForm, ContactCategory, VisitorInfo};
|
||||
|
||||
let contact = ContactForm::new(
|
||||
"Jane Smith".to_string(),
|
||||
"jane@example.com".to_string(),
|
||||
"New Visitor".to_string(),
|
||||
"I'm interested in learning more about your church.".to_string()
|
||||
)
|
||||
.with_category(ContactCategory::Visitor)
|
||||
.with_phone("555-123-4567".to_string())
|
||||
.with_visitor_info(VisitorInfo {
|
||||
is_first_time: true,
|
||||
wants_follow_up: true,
|
||||
wants_newsletter: true,
|
||||
// ... other fields
|
||||
});
|
||||
|
||||
let submission_id = client.submit_contact_form(contact).await?;
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### PocketBase Authentication
|
||||
|
||||
```rust
|
||||
use church_core::auth::{LoginRequest, AuthToken};
|
||||
|
||||
// Authenticate with PocketBase
|
||||
let login = LoginRequest {
|
||||
identity: "admin@church.org".to_string(),
|
||||
password: "secure_password".to_string(),
|
||||
};
|
||||
|
||||
// Note: Authentication methods would be implemented in auth module
|
||||
// let token = client.authenticate_pocketbase(login).await?;
|
||||
// client.set_auth_token(token).await;
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
The client automatically caches responses with configurable TTL:
|
||||
|
||||
```rust
|
||||
// Cache is automatically used
|
||||
let events = client.get_upcoming_events(None).await?; // Fetches from API
|
||||
let events = client.get_upcoming_events(None).await?; // Returns from cache
|
||||
|
||||
// Manual cache management
|
||||
client.clear_cache().await;
|
||||
let (cache_size, max_size) = client.get_cache_stats().await;
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```rust
|
||||
use church_core::{ChurchApiError, Result};
|
||||
|
||||
match client.get_event("invalid-id").await {
|
||||
Ok(Some(event)) => println!("Found event: {}", event.title),
|
||||
Ok(None) => println!("Event not found"),
|
||||
Err(ChurchApiError::NotFound) => println!("Event does not exist"),
|
||||
Err(ChurchApiError::Network(_)) => println!("Network error - check connection"),
|
||||
Err(ChurchApiError::Auth(_)) => println!("Authentication required"),
|
||||
Err(e) => println!("Other error: {}", e),
|
||||
}
|
||||
```
|
||||
|
||||
## Platform Integration
|
||||
|
||||
### iOS (Swift)
|
||||
|
||||
```swift
|
||||
// FFI bindings would be generated for iOS
|
||||
import ChurchCore
|
||||
|
||||
let client = ChurchApiClient()
|
||||
client.getUpcomingEvents(limit: 10) { events in
|
||||
// Handle events
|
||||
}
|
||||
```
|
||||
|
||||
### Android (Kotlin/Java)
|
||||
|
||||
```kotlin
|
||||
// JNI bindings for Android
|
||||
import org.church.ChurchCore
|
||||
|
||||
val client = ChurchCore()
|
||||
val events = client.getUpcomingEvents(10)
|
||||
```
|
||||
|
||||
### Web (WASM)
|
||||
|
||||
```javascript
|
||||
// WASM bindings for web
|
||||
import { ChurchApiClient } from 'church-core-wasm';
|
||||
|
||||
const client = new ChurchApiClient();
|
||||
const events = await client.getUpcomingEvents(10);
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test binary to verify API connectivity:
|
||||
|
||||
```bash
|
||||
cargo run --bin church-core-test
|
||||
```
|
||||
|
||||
This will test:
|
||||
- Health check endpoint
|
||||
- Upcoming events retrieval
|
||||
- Current bulletin fetching
|
||||
- Configuration loading
|
||||
- Cache statistics
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The client interfaces with these standard endpoints:
|
||||
|
||||
- `GET /events/upcoming` - Get upcoming events
|
||||
- `GET /events/{id}` - Get single event
|
||||
- `POST /events` - Create event
|
||||
- `GET /bulletins/current` - Get current bulletin
|
||||
- `GET /bulletins` - List bulletins
|
||||
- `GET /config` - Get church configuration
|
||||
- `POST /contact` - Submit contact form
|
||||
- `GET /sermons` - List sermons
|
||||
- `GET /sermons/search` - Search sermons
|
||||
|
||||
## Development
|
||||
|
||||
### Building for iOS (No Python Required!)
|
||||
|
||||
```bash
|
||||
# Quick build using Makefile
|
||||
make ios
|
||||
|
||||
# Or use the build script directly
|
||||
./build_ios.sh
|
||||
|
||||
# Install dependencies first time
|
||||
make install-deps
|
||||
|
||||
# Development build (faster)
|
||||
make dev
|
||||
|
||||
# Clean build artifacts
|
||||
make clean
|
||||
```
|
||||
|
||||
### Building Steps Explained
|
||||
|
||||
1. **Install iOS targets**: Adds Rust toolchains for iOS devices and simulators
|
||||
2. **Build static libraries**: Creates `.a` files for each iOS architecture
|
||||
3. **Create universal library**: Combines all architectures using `lipo`
|
||||
4. **Generate Swift bindings**: Uses `uniffi-bindgen` to create Swift interfaces
|
||||
5. **Copy to iOS project**: Moves all files to your Xcode project
|
||||
|
||||
Generated files:
|
||||
- `church_core.swift` - Swift interface to your Rust code
|
||||
- `church_coreFFI.h` - C header file
|
||||
- `church_coreFFI.modulemap` - Module map for Swift
|
||||
- `libchurch_core.a` - Universal static library
|
||||
|
||||
### Standard Rust Building
|
||||
|
||||
```bash
|
||||
# Standard build
|
||||
cargo build
|
||||
|
||||
# Build with WASM support
|
||||
cargo build --features wasm
|
||||
|
||||
# Build with UniFFI support
|
||||
cargo build --features uniffi
|
||||
|
||||
# Run tests
|
||||
cargo test --features uniffi
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- `default`: Native Rust client
|
||||
- `wasm`: WebAssembly bindings
|
||||
- `uniffi`: UniFFI bindings for iOS/Android (replaces old `ffi` feature)
|
||||
- `native`: Native platform features
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see LICENSE file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Add tests for new functionality
|
||||
4. Ensure all tests pass
|
||||
5. Submit a pull request
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
- Check the API documentation
|
||||
- Review existing GitHub issues
|
||||
- Create a new issue with detailed information
|
66
RTSDA/Info.plist
Normal file
66
RTSDA/Info.plist
Normal file
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AvailableLibraries</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>libchurch_core_device.a</string>
|
||||
<key>HeadersPath</key>
|
||||
<string>Headers</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>ios-arm64</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>libchurch_core_device.a</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>SupportedPlatform</key>
|
||||
<string>ios</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>libchurch_core_sim.a</string>
|
||||
<key>HeadersPath</key>
|
||||
<string>Headers</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>ios-arm64-simulator</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>libchurch_core_sim.a</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>SupportedPlatform</key>
|
||||
<string>ios</string>
|
||||
<key>SupportedPlatformVariant</key>
|
||||
<string>simulator</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>libchurch_core_mac_catalyst.a</string>
|
||||
<key>HeadersPath</key>
|
||||
<string>Headers</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>ios-arm64_x86_64-maccatalyst</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>libchurch_core_mac_catalyst.a</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
<string>x86_64</string>
|
||||
</array>
|
||||
<key>SupportedPlatform</key>
|
||||
<string>ios</string>
|
||||
<key>SupportedPlatformVariant</key>
|
||||
<string>maccatalyst</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XFWK</string>
|
||||
<key>XCFrameworkFormatVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
95
bindings/android/README.md
Normal file
95
bindings/android/README.md
Normal file
|
@ -0,0 +1,95 @@
|
|||
# Church Core Android Bindings
|
||||
|
||||
This directory contains the generated Kotlin bindings for the church-core Rust crate.
|
||||
|
||||
## Files:
|
||||
- `uniffi/church_core/` - Generated Kotlin bindings
|
||||
|
||||
## What's Missing:
|
||||
- Native libraries (.so files) - You need to compile these with Android NDK
|
||||
- JNI library structure - Will be created when you compile native libraries
|
||||
|
||||
## To Complete Android Setup:
|
||||
|
||||
### 1. Install Android Development Tools:
|
||||
```bash
|
||||
# Install Android SDK/NDK (via Android Studio or command line tools)
|
||||
# Set environment variables:
|
||||
export ANDROID_SDK_ROOT=/path/to/android/sdk
|
||||
export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/[version]
|
||||
```
|
||||
|
||||
### 2. Install cargo-ndk:
|
||||
```bash
|
||||
cargo install cargo-ndk
|
||||
```
|
||||
|
||||
### 3. Build native libraries:
|
||||
```bash
|
||||
# From church-core directory
|
||||
cargo ndk --target arm64-v8a --platform 21 build --release --features uniffi
|
||||
cargo ndk --target armeabi-v7a --platform 21 build --release --features uniffi
|
||||
cargo ndk --target x86_64 --platform 21 build --release --features uniffi
|
||||
cargo ndk --target x86 --platform 21 build --release --features uniffi
|
||||
```
|
||||
|
||||
### 4. Create JNI structure:
|
||||
```bash
|
||||
mkdir -p jniLibs/{arm64-v8a,armeabi-v7a,x86_64,x86}
|
||||
cp target/aarch64-linux-android/release/libchurch_core.so jniLibs/arm64-v8a/
|
||||
cp target/armv7-linux-androideabi/release/libchurch_core.so jniLibs/armeabi-v7a/
|
||||
cp target/x86_64-linux-android/release/libchurch_core.so jniLibs/x86_64/
|
||||
cp target/i686-linux-android/release/libchurch_core.so jniLibs/x86/
|
||||
```
|
||||
|
||||
## Integration in Android Project:
|
||||
|
||||
### 1. Add JNA dependency to your `build.gradle`:
|
||||
```gradle
|
||||
implementation 'net.java.dev.jna:jna:5.13.0@aar'
|
||||
```
|
||||
|
||||
### 2. Copy files to your Android project:
|
||||
- Copy `uniffi/church_core/` to `src/main/java/`
|
||||
- Copy `jniLibs/` to `src/main/`
|
||||
|
||||
### 3. Usage in Kotlin:
|
||||
```kotlin
|
||||
import uniffi.church_core.*
|
||||
|
||||
class ChurchRepository {
|
||||
fun fetchEvents(): String {
|
||||
return fetchEventsJson()
|
||||
}
|
||||
|
||||
fun fetchSermons(): String {
|
||||
return fetchSermonsJson()
|
||||
}
|
||||
|
||||
fun fetchBulletins(): String {
|
||||
return fetchBulletinsJson()
|
||||
}
|
||||
|
||||
// All other functions from the UDL file are available
|
||||
}
|
||||
```
|
||||
|
||||
## Functions Available:
|
||||
All functions defined in `src/church_core.udl` are available in Kotlin:
|
||||
- `fetchEventsJson()`
|
||||
- `fetchSermonsJson()`
|
||||
- `fetchBulletinsJson()`
|
||||
- `fetchBibleVerseJson(query: String)`
|
||||
- `fetchRandomBibleVerseJson()`
|
||||
- `submitContactV2Json(...)`
|
||||
- `fetchCachedImageBase64(url: String)`
|
||||
- `getOptimalStreamingUrl(mediaId: String)`
|
||||
- `parseEventsFromJson(eventsJson: String)`
|
||||
- `parseSermonsFromJson(sermonsJson: String)`
|
||||
- And many more...
|
||||
|
||||
## Architecture Notes:
|
||||
- All business logic is in Rust (networking, parsing, validation, etc.)
|
||||
- Kotlin only handles UI and calls Rust functions
|
||||
- Same RTSDA architecture as iOS version
|
||||
- JSON responses from Rust, parse to data classes in Kotlin
|
2068
bindings/android/uniffi/church_core/church_core.kt
Normal file
2068
bindings/android/uniffi/church_core/church_core.kt
Normal file
File diff suppressed because it is too large
Load diff
66
bindings/ios/ChurchCore.xcframework/Info.plist
Normal file
66
bindings/ios/ChurchCore.xcframework/Info.plist
Normal file
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AvailableLibraries</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>libchurch_core_device.a</string>
|
||||
<key>HeadersPath</key>
|
||||
<string>Headers</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>ios-arm64</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>libchurch_core_device.a</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>SupportedPlatform</key>
|
||||
<string>ios</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>libchurch_core_mac_catalyst.a</string>
|
||||
<key>HeadersPath</key>
|
||||
<string>Headers</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>ios-arm64_x86_64-maccatalyst</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>libchurch_core_mac_catalyst.a</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
<string>x86_64</string>
|
||||
</array>
|
||||
<key>SupportedPlatform</key>
|
||||
<string>ios</string>
|
||||
<key>SupportedPlatformVariant</key>
|
||||
<string>maccatalyst</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>libchurch_core_sim.a</string>
|
||||
<key>HeadersPath</key>
|
||||
<string>Headers</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>ios-arm64-simulator</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>libchurch_core_sim.a</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>SupportedPlatform</key>
|
||||
<string>ios</string>
|
||||
<key>SupportedPlatformVariant</key>
|
||||
<string>simulator</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XFWK</string>
|
||||
<key>XCFrameworkFormatVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
6
build.rs
Normal file
6
build.rs
Normal file
|
@ -0,0 +1,6 @@
|
|||
fn main() {
|
||||
#[cfg(feature = "uniffi")]
|
||||
{
|
||||
uniffi::generate_scaffolding("src/church_core.udl").unwrap();
|
||||
}
|
||||
}
|
32
build.sh
Executable file
32
build.sh
Executable file
|
@ -0,0 +1,32 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Building iOS framework with UniFFI..."
|
||||
|
||||
# Add iOS targets
|
||||
rustup target add aarch64-apple-ios
|
||||
rustup target add aarch64-apple-ios-sim
|
||||
rustup target add x86_64-apple-ios
|
||||
|
||||
# Build for iOS targets
|
||||
cargo build --release --target aarch64-apple-ios --features uniffi
|
||||
cargo build --release --target aarch64-apple-ios-sim --features uniffi
|
||||
cargo build --release --target x86_64-apple-ios --features uniffi
|
||||
|
||||
# Create universal library
|
||||
mkdir -p target/universal
|
||||
lipo -create \
|
||||
target/aarch64-apple-ios/release/libchurch_core.a \
|
||||
target/aarch64-apple-ios-sim/release/libchurch_core.a \
|
||||
target/x86_64-apple-ios/release/libchurch_core.a \
|
||||
-output target/universal/libchurch_core.a
|
||||
|
||||
# Generate Swift bindings using cargo (this should work!)
|
||||
cargo run --features uniffi --bin uniffi-bindgen generate --language swift --out-dir target/universal src/church_core.udl
|
||||
|
||||
# Copy to iOS project
|
||||
cp target/universal/church_core.swift ../RTSDA-iOS/RTSDA/
|
||||
cp target/universal/church_coreFFI.h ../RTSDA-iOS/RTSDA/
|
||||
cp target/universal/libchurch_core.a ../RTSDA-iOS/RTSDA/
|
||||
|
||||
echo "Done! Generated files copied to iOS project."
|
120
build_android.sh
Executable file
120
build_android.sh
Executable file
|
@ -0,0 +1,120 @@
|
|||
#!/bin/bash
|
||||
|
||||
# UniFFI Android Build Script
|
||||
# Generates Kotlin bindings and Android libraries
|
||||
|
||||
set -e
|
||||
|
||||
echo "🤖 Building UniFFI Android Library..."
|
||||
|
||||
# Clean previous Android builds
|
||||
echo "🧹 Cleaning previous Android builds..."
|
||||
rm -rf bindings/android/
|
||||
mkdir -p bindings/android
|
||||
|
||||
# Install required tools
|
||||
if ! command -v uniffi-bindgen &> /dev/null; then
|
||||
echo "📦 Installing uniffi_bindgen..."
|
||||
cargo install uniffi_bindgen --bin uniffi-bindgen
|
||||
fi
|
||||
|
||||
if ! command -v cargo-ndk &> /dev/null; then
|
||||
echo "📦 Installing cargo-ndk..."
|
||||
cargo install cargo-ndk
|
||||
fi
|
||||
|
||||
# Android targets for cargo-ndk
|
||||
ANDROID_ABIS=(
|
||||
"arm64-v8a" # ARM64 devices
|
||||
"armeabi-v7a" # ARM32 devices
|
||||
"x86_64" # x86_64 emulator
|
||||
"x86" # x86 emulator
|
||||
)
|
||||
|
||||
echo "🔧 Building Rust library for Android targets using cargo-ndk..."
|
||||
for abi in "${ANDROID_ABIS[@]}"; do
|
||||
echo "Building for $abi..."
|
||||
cargo ndk --target $abi --platform 21 build --release --features uniffi
|
||||
done
|
||||
|
||||
echo "⚡ Generating Kotlin bindings..."
|
||||
# Find the first available library for binding generation
|
||||
if [ -f "target/aarch64-linux-android/release/libchurch_core.so" ]; then
|
||||
LIB_FILE="target/aarch64-linux-android/release/libchurch_core.so"
|
||||
else
|
||||
# Find any available library
|
||||
LIB_FILE=$(find target -name "libchurch_core.so" -type f | head -1)
|
||||
fi
|
||||
|
||||
# Generate Kotlin bindings using uniffi_bindgen
|
||||
uniffi-bindgen generate \
|
||||
src/church_core.udl \
|
||||
--language kotlin \
|
||||
--out-dir bindings/android \
|
||||
--lib-file "$LIB_FILE"
|
||||
|
||||
echo "📁 Organizing Android libraries..."
|
||||
# Create JNI library structure
|
||||
mkdir -p bindings/android/jniLibs/arm64-v8a
|
||||
mkdir -p bindings/android/jniLibs/armeabi-v7a
|
||||
mkdir -p bindings/android/jniLibs/x86_64
|
||||
mkdir -p bindings/android/jniLibs/x86
|
||||
|
||||
# Copy libraries to JNI structure (cargo-ndk puts them in different locations)
|
||||
if [ -f "target/aarch64-linux-android/release/libchurch_core.so" ]; then
|
||||
cp target/aarch64-linux-android/release/libchurch_core.so bindings/android/jniLibs/arm64-v8a/
|
||||
fi
|
||||
if [ -f "target/armv7-linux-androideabi/release/libchurch_core.so" ]; then
|
||||
cp target/armv7-linux-androideabi/release/libchurch_core.so bindings/android/jniLibs/armeabi-v7a/
|
||||
fi
|
||||
if [ -f "target/x86_64-linux-android/release/libchurch_core.so" ]; then
|
||||
cp target/x86_64-linux-android/release/libchurch_core.so bindings/android/jniLibs/x86_64/
|
||||
fi
|
||||
if [ -f "target/i686-linux-android/release/libchurch_core.so" ]; then
|
||||
cp target/i686-linux-android/release/libchurch_core.so bindings/android/jniLibs/x86/
|
||||
fi
|
||||
|
||||
echo "📦 Creating Android README..."
|
||||
cat > bindings/android/README.md << EOF
|
||||
# Church Core Android Bindings
|
||||
|
||||
This directory contains the generated Kotlin bindings and native libraries for the church-core Rust crate.
|
||||
|
||||
## Files:
|
||||
- \`uniffi/\` - Generated Kotlin bindings
|
||||
- \`jniLibs/\` - Native libraries for Android architectures
|
||||
- \`arm64-v8a/\` - ARM64 devices (modern phones)
|
||||
- \`armeabi-v7a/\` - ARM32 devices (older phones)
|
||||
- \`x86_64/\` - x86_64 emulator
|
||||
- \`x86/\` - x86 emulator
|
||||
|
||||
## Integration:
|
||||
1. Copy the Kotlin files from \`uniffi/\` to your Android project's \`src/main/java/\` directory
|
||||
2. Copy the \`jniLibs/\` directory to your Android project's \`src/main/\` directory
|
||||
3. Add JNA dependency to your \`build.gradle\`:
|
||||
```gradle
|
||||
implementation 'net.java.dev.jna:jna:5.13.0@aar'
|
||||
```
|
||||
|
||||
## Usage:
|
||||
```kotlin
|
||||
import uniffi.church_core.*
|
||||
|
||||
// Fetch events
|
||||
val eventsJson = fetchEventsJson()
|
||||
|
||||
// Fetch sermons
|
||||
val sermonsJson = fetchSermonsJson()
|
||||
|
||||
// All other functions from the UDL file are available
|
||||
```
|
||||
EOF
|
||||
|
||||
echo "✅ UniFFI Android library build complete!"
|
||||
echo "📁 Generated files in bindings/android/:"
|
||||
echo " - uniffi/ (Kotlin bindings)"
|
||||
echo " - jniLibs/ (Native libraries for all Android architectures)"
|
||||
echo " - README.md (Integration instructions)"
|
||||
echo ""
|
||||
echo "🎯 Ready for Android integration!"
|
||||
echo "💡 Follow the README.md instructions to integrate into your Android project"
|
114
build_ios.sh
Executable file
114
build_ios.sh
Executable file
|
@ -0,0 +1,114 @@
|
|||
#!/bin/bash
|
||||
|
||||
# UniFFI iOS Build Script
|
||||
# Generates Swift bindings and universal iOS framework without Python dependencies
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔨 Building UniFFI iOS Framework..."
|
||||
|
||||
# Clean previous builds
|
||||
echo "🧹 Cleaning previous builds..."
|
||||
rm -rf target/
|
||||
rm -rf bindings/
|
||||
mkdir -p bindings/ios
|
||||
|
||||
# Install uniffi_bindgen if not present
|
||||
if ! command -v uniffi-bindgen &> /dev/null; then
|
||||
echo "📦 Installing uniffi_bindgen..."
|
||||
cargo install uniffi_bindgen --bin uniffi-bindgen
|
||||
fi
|
||||
|
||||
# iOS and Mac Catalyst targets
|
||||
IOS_TARGETS=(
|
||||
"aarch64-apple-ios" # iOS device (ARM64)
|
||||
"aarch64-apple-ios-sim" # iOS simulator (Apple Silicon)
|
||||
"aarch64-apple-ios-macabi" # Mac Catalyst (Apple Silicon)
|
||||
"x86_64-apple-ios-macabi" # Mac Catalyst (Intel)
|
||||
)
|
||||
|
||||
echo "📱 Installing iOS targets..."
|
||||
for target in "${IOS_TARGETS[@]}"; do
|
||||
rustup target add "$target"
|
||||
done
|
||||
|
||||
echo "🔧 Building Rust library for iOS targets..."
|
||||
for target in "${IOS_TARGETS[@]}"; do
|
||||
echo "Building for $target..."
|
||||
cargo build --release --target "$target" --features uniffi
|
||||
done
|
||||
|
||||
echo "⚡ Generating Swift bindings..."
|
||||
# Generate Swift bindings using uniffi_bindgen
|
||||
uniffi-bindgen generate \
|
||||
src/church_core.udl \
|
||||
--language swift \
|
||||
--out-dir bindings/ios \
|
||||
--lib-file target/aarch64-apple-ios/release/libchurch_core.a
|
||||
|
||||
echo "📋 Generating module map..."
|
||||
# Create module map for Swift integration
|
||||
cat > bindings/ios/church_coreFFI.modulemap << EOF
|
||||
module church_coreFFI {
|
||||
header "church_coreFFI.h"
|
||||
export *
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "🔗 Preparing libraries..."
|
||||
# Simulator library (Apple Silicon only)
|
||||
cp target/aarch64-apple-ios-sim/release/libchurch_core.a bindings/ios/libchurch_core_sim.a
|
||||
|
||||
# Device library (ARM64 device)
|
||||
cp target/aarch64-apple-ios/release/libchurch_core.a bindings/ios/libchurch_core_device.a
|
||||
|
||||
# Mac Catalyst libraries
|
||||
cp target/aarch64-apple-ios-macabi/release/libchurch_core.a bindings/ios/libchurch_core_mac_arm64.a
|
||||
cp target/x86_64-apple-ios-macabi/release/libchurch_core.a bindings/ios/libchurch_core_mac_x86_64.a
|
||||
|
||||
# Create universal Mac Catalyst library
|
||||
lipo -create \
|
||||
bindings/ios/libchurch_core_mac_arm64.a \
|
||||
bindings/ios/libchurch_core_mac_x86_64.a \
|
||||
-output bindings/ios/libchurch_core_mac_catalyst.a
|
||||
|
||||
echo "🔗 Creating XCFramework..."
|
||||
# Create separate header directories for each library
|
||||
mkdir -p bindings/ios/device-headers
|
||||
mkdir -p bindings/ios/sim-headers
|
||||
mkdir -p bindings/ios/mac-arm64-headers
|
||||
mkdir -p bindings/ios/mac-x86_64-headers
|
||||
cp bindings/ios/church_coreFFI.h bindings/ios/device-headers/
|
||||
cp bindings/ios/church_coreFFI.h bindings/ios/sim-headers/
|
||||
cp bindings/ios/church_coreFFI.h bindings/ios/mac-arm64-headers/
|
||||
cp bindings/ios/church_coreFFI.h bindings/ios/mac-x86_64-headers/
|
||||
|
||||
# Create XCFramework with Mac Catalyst support
|
||||
# Using universal Mac Catalyst library built from -macabi targets
|
||||
xcodebuild -create-xcframework \
|
||||
-library bindings/ios/libchurch_core_device.a \
|
||||
-headers bindings/ios/device-headers \
|
||||
-library bindings/ios/libchurch_core_sim.a \
|
||||
-headers bindings/ios/sim-headers \
|
||||
-library bindings/ios/libchurch_core_mac_catalyst.a \
|
||||
-headers bindings/ios/mac-arm64-headers \
|
||||
-output bindings/ios/ChurchCore.xcframework
|
||||
|
||||
echo "📦 Moving files to iOS project..."
|
||||
# Copy generated files to iOS project
|
||||
IOS_PROJECT_DIR="../RTSDA-iOS/RTSDA"
|
||||
cp bindings/ios/church_core.swift "$IOS_PROJECT_DIR/"
|
||||
cp bindings/ios/church_coreFFI.h "$IOS_PROJECT_DIR/"
|
||||
cp bindings/ios/church_coreFFI.modulemap "$IOS_PROJECT_DIR/"
|
||||
cp -r bindings/ios/ChurchCore.xcframework "$IOS_PROJECT_DIR/"
|
||||
|
||||
echo "✅ UniFFI iOS framework build complete!"
|
||||
echo "📁 Generated files:"
|
||||
echo " - church_core.swift (Swift bindings)"
|
||||
echo " - church_coreFFI.h (C header)"
|
||||
echo " - church_coreFFI.modulemap (Module map)"
|
||||
echo " - ChurchCore.xcframework (Universal XCFramework)"
|
||||
echo ""
|
||||
echo "🎯 Files copied to: $IOS_PROJECT_DIR"
|
||||
echo "💡 Add ChurchCore.xcframework to your Xcode project and you're good to go!"
|
||||
echo "📱 Supports: iOS Device (ARM64), iOS Simulator (Apple Silicon), Mac Catalyst (Intel + Apple Silicon)"
|
124
build_ios_tvos.sh
Executable file
124
build_ios_tvos.sh
Executable file
|
@ -0,0 +1,124 @@
|
|||
#!/bin/bash
|
||||
|
||||
# UniFFI iOS and tvOS Build Script
|
||||
# Generates Swift bindings and universal framework for both iOS and tvOS
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔨 Building UniFFI iOS and tvOS Framework..."
|
||||
|
||||
# Clean previous builds
|
||||
echo "🧹 Cleaning previous builds..."
|
||||
rm -rf target/
|
||||
rm -rf bindings/
|
||||
mkdir -p bindings/ios
|
||||
|
||||
# Install nightly toolchain for tvOS support
|
||||
echo "🌙 Installing nightly toolchain for tvOS support..."
|
||||
rustup toolchain install nightly
|
||||
rustup component add rust-std --toolchain nightly --target aarch64-apple-tvos
|
||||
rustup component add rust-std --toolchain nightly --target aarch64-apple-tvos-sim
|
||||
rustup component add rust-std --toolchain nightly --target x86_64-apple-tvos
|
||||
|
||||
# Install uniffi_bindgen if not present
|
||||
if ! command -v uniffi-bindgen &> /dev/null; then
|
||||
echo "📦 Installing uniffi_bindgen..."
|
||||
cargo install uniffi_bindgen --bin uniffi-bindgen
|
||||
fi
|
||||
|
||||
# iOS targets
|
||||
IOS_TARGETS=(
|
||||
"aarch64-apple-ios" # iOS device (ARM64)
|
||||
"x86_64-apple-ios" # iOS simulator (Intel)
|
||||
)
|
||||
|
||||
# tvOS targets
|
||||
TVOS_TARGETS=(
|
||||
"aarch64-apple-tvos" # tvOS device (ARM64)
|
||||
"aarch64-apple-tvos-sim" # tvOS simulator (Apple Silicon)
|
||||
"x86_64-apple-tvos" # tvOS simulator (Intel)
|
||||
)
|
||||
|
||||
echo "📱 Installing iOS targets..."
|
||||
for target in "${IOS_TARGETS[@]}"; do
|
||||
rustup target add "$target"
|
||||
done
|
||||
|
||||
echo "📺 Installing tvOS targets..."
|
||||
for target in "${TVOS_TARGETS[@]}"; do
|
||||
rustup target add "$target" --toolchain nightly
|
||||
done
|
||||
|
||||
echo "🔧 Building Rust library for iOS targets..."
|
||||
for target in "${IOS_TARGETS[@]}"; do
|
||||
echo "Building for $target..."
|
||||
cargo build --release --target "$target" --features uniffi
|
||||
done
|
||||
|
||||
echo "🔧 Building Rust library for tvOS targets..."
|
||||
for target in "${TVOS_TARGETS[@]}"; do
|
||||
echo "Building for $target..."
|
||||
cargo +nightly build --release --target "$target" --features uniffi
|
||||
done
|
||||
|
||||
echo "🔗 Creating universal libraries..."
|
||||
|
||||
# Create iOS universal library
|
||||
lipo -create \
|
||||
target/aarch64-apple-ios/release/libchurch_core.a \
|
||||
target/x86_64-apple-ios/release/libchurch_core.a \
|
||||
-output bindings/ios/libchurch_core_ios.a
|
||||
|
||||
# Create tvOS universal library
|
||||
lipo -create \
|
||||
target/aarch64-apple-tvos/release/libchurch_core.a \
|
||||
target/aarch64-apple-tvos-sim/release/libchurch_core.a \
|
||||
target/x86_64-apple-tvos/release/libchurch_core.a \
|
||||
-output bindings/ios/libchurch_core_tvos.a
|
||||
|
||||
# Create combined universal library for Xcode (it will pick the right slice)
|
||||
lipo -create \
|
||||
target/aarch64-apple-ios/release/libchurch_core.a \
|
||||
target/x86_64-apple-ios/release/libchurch_core.a \
|
||||
target/aarch64-apple-tvos/release/libchurch_core.a \
|
||||
target/x86_64-apple-tvos/release/libchurch_core.a \
|
||||
-output bindings/ios/libchurch_core_universal.a
|
||||
|
||||
echo "⚡ Generating Swift bindings..."
|
||||
# Generate Swift bindings using uniffi_bindgen
|
||||
uniffi-bindgen generate \
|
||||
src/church_core.udl \
|
||||
--language swift \
|
||||
--out-dir bindings/ios \
|
||||
--lib-file target/aarch64-apple-ios/release/libchurch_core.a
|
||||
|
||||
echo "📋 Generating module map..."
|
||||
# Create module map for Swift integration
|
||||
cat > bindings/ios/church_coreFFI.modulemap << EOF
|
||||
module church_coreFFI {
|
||||
header "church_coreFFI.h"
|
||||
export *
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "📦 Moving files to iOS project..."
|
||||
# Copy generated files to iOS project
|
||||
IOS_PROJECT_DIR="../RTSDA-iOS/RTSDA"
|
||||
cp bindings/ios/church_core.swift "$IOS_PROJECT_DIR/"
|
||||
cp bindings/ios/church_coreFFI.h "$IOS_PROJECT_DIR/"
|
||||
cp bindings/ios/church_coreFFI.modulemap "$IOS_PROJECT_DIR/"
|
||||
cp bindings/ios/libchurch_core_universal.a "$IOS_PROJECT_DIR/libchurch_core.a"
|
||||
|
||||
echo "✅ UniFFI iOS and tvOS framework build complete!"
|
||||
echo "📁 Generated files:"
|
||||
echo " - church_core.swift (Swift bindings)"
|
||||
echo " - church_coreFFI.h (C header)"
|
||||
echo " - church_coreFFI.modulemap (Module map)"
|
||||
echo " - libchurch_core.a (Universal static library - iOS + tvOS)"
|
||||
echo ""
|
||||
echo "🎯 Files copied to: $IOS_PROJECT_DIR"
|
||||
echo "💡 You can now build your iOS/tvOS project in Xcode!"
|
||||
echo ""
|
||||
echo "📺 Libraries also available separately:"
|
||||
echo " - bindings/ios/libchurch_core_ios.a (iOS only)"
|
||||
echo " - bindings/ios/libchurch_core_tvos.a (tvOS only)"
|
105
build_tvos_zbuild.sh
Executable file
105
build_tvos_zbuild.sh
Executable file
|
@ -0,0 +1,105 @@
|
|||
#!/bin/bash
|
||||
|
||||
# UniFFI iOS and tvOS Build Script using -Zbuild-std
|
||||
# Uses nightly cargo with -Zbuild-std to build tvOS targets
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔨 Building UniFFI iOS and tvOS Framework with -Zbuild-std..."
|
||||
|
||||
# Clean previous builds
|
||||
echo "🧹 Cleaning previous builds..."
|
||||
rm -rf target/
|
||||
rm -rf bindings/
|
||||
mkdir -p bindings/ios
|
||||
|
||||
# Install nightly toolchain
|
||||
echo "🌙 Installing nightly toolchain..."
|
||||
rustup toolchain install nightly
|
||||
rustup component add rust-src --toolchain nightly
|
||||
|
||||
# Install uniffi_bindgen if not present
|
||||
if ! command -v uniffi-bindgen &> /dev/null; then
|
||||
echo "📦 Installing uniffi_bindgen..."
|
||||
cargo install uniffi_bindgen --bin uniffi-bindgen
|
||||
fi
|
||||
|
||||
# iOS targets (stable)
|
||||
IOS_TARGETS=(
|
||||
"aarch64-apple-ios" # iOS device (ARM64)
|
||||
"x86_64-apple-ios" # iOS simulator (Intel)
|
||||
)
|
||||
|
||||
# tvOS targets (require -Zbuild-std)
|
||||
TVOS_TARGETS=(
|
||||
"aarch64-apple-tvos" # tvOS device (ARM64)
|
||||
"x86_64-apple-tvos" # tvOS simulator (Intel)
|
||||
)
|
||||
|
||||
echo "📱 Installing iOS targets..."
|
||||
for target in "${IOS_TARGETS[@]}"; do
|
||||
rustup target add "$target"
|
||||
done
|
||||
|
||||
echo "🔧 Building Rust library for iOS targets..."
|
||||
for target in "${IOS_TARGETS[@]}"; do
|
||||
echo "Building for $target..."
|
||||
cargo build --release --target "$target" --features uniffi
|
||||
done
|
||||
|
||||
echo "📺 Building Rust library for tvOS targets with -Zbuild-std..."
|
||||
for target in "${TVOS_TARGETS[@]}"; do
|
||||
echo "Building for $target..."
|
||||
cargo +nightly build --release --target "$target" --features uniffi -Zbuild-std
|
||||
done
|
||||
|
||||
echo "🔗 Creating separate libraries..."
|
||||
|
||||
# iOS universal library
|
||||
lipo -create \
|
||||
target/aarch64-apple-ios/release/libchurch_core.a \
|
||||
target/x86_64-apple-ios/release/libchurch_core.a \
|
||||
-output bindings/ios/libchurch_core_ios.a
|
||||
|
||||
# tvOS universal library
|
||||
lipo -create \
|
||||
target/aarch64-apple-tvos/release/libchurch_core.a \
|
||||
target/x86_64-apple-tvos/release/libchurch_core.a \
|
||||
-output bindings/ios/libchurch_core_tvos.a
|
||||
|
||||
# Default to iOS library
|
||||
cp bindings/ios/libchurch_core_ios.a bindings/ios/libchurch_core_universal.a
|
||||
|
||||
echo "⚡ Generating Swift bindings..."
|
||||
uniffi-bindgen generate \
|
||||
src/church_core.udl \
|
||||
--language swift \
|
||||
--out-dir bindings/ios \
|
||||
--lib-file target/aarch64-apple-ios/release/libchurch_core.a
|
||||
|
||||
echo "📋 Generating module map..."
|
||||
cat > bindings/ios/church_coreFFI.modulemap << EOF
|
||||
module church_coreFFI {
|
||||
header "church_coreFFI.h"
|
||||
export *
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "📦 Moving files to iOS project..."
|
||||
IOS_PROJECT_DIR="../RTSDA-iOS/RTSDA"
|
||||
cp bindings/ios/church_core.swift "$IOS_PROJECT_DIR/"
|
||||
cp bindings/ios/church_coreFFI.h "$IOS_PROJECT_DIR/"
|
||||
cp bindings/ios/church_coreFFI.modulemap "$IOS_PROJECT_DIR/"
|
||||
cp bindings/ios/libchurch_core_universal.a "$IOS_PROJECT_DIR/libchurch_core.a"
|
||||
|
||||
# Also copy the separate libraries for manual swapping if needed
|
||||
cp bindings/ios/libchurch_core_ios.a "$IOS_PROJECT_DIR/"
|
||||
cp bindings/ios/libchurch_core_tvos.a "$IOS_PROJECT_DIR/"
|
||||
|
||||
echo "✅ UniFFI iOS and tvOS framework build complete!"
|
||||
echo "📁 Generated separate libraries:"
|
||||
echo " - libchurch_core.a (default: iOS)"
|
||||
echo " - libchurch_core_ios.a (iOS only)"
|
||||
echo " - libchurch_core_tvos.a (tvOS only)"
|
||||
echo "🎯 Files copied to: $IOS_PROJECT_DIR"
|
||||
echo "💡 If tvOS build fails, manually swap to libchurch_core_tvos.a"
|
24
generate_bindings.rs
Normal file
24
generate_bindings.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
use std::path::Path;
|
||||
use uniffi_bindgen::library_mode::generate_bindings;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let lib_path = "target/aarch64-apple-ios/release/libchurch_core.a";
|
||||
let out_dir = Path::new("bindings/ios");
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
std::fs::create_dir_all(out_dir)?;
|
||||
|
||||
// Generate Swift bindings
|
||||
generate_bindings(
|
||||
&uniffi_bindgen::bindings::swift::SwiftBindingGenerator,
|
||||
lib_path,
|
||||
None, // no UDL file
|
||||
None, // default config
|
||||
None, // no config file
|
||||
out_dir,
|
||||
false, // not a library mode
|
||||
)?;
|
||||
|
||||
println!("Swift bindings generated in {:?}", out_dir);
|
||||
Ok(())
|
||||
}
|
144
generate_kotlin_bindings.sh
Executable file
144
generate_kotlin_bindings.sh
Executable file
|
@ -0,0 +1,144 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Generate Kotlin bindings only (without native compilation)
|
||||
# This gives you the Kotlin interface code to use when you set up Android development
|
||||
|
||||
set -e
|
||||
|
||||
echo "🤖 Generating Kotlin bindings for Android..."
|
||||
|
||||
# Clean previous Android bindings
|
||||
echo "🧹 Cleaning previous Android bindings..."
|
||||
rm -rf bindings/android/
|
||||
mkdir -p bindings/android
|
||||
|
||||
# Install uniffi_bindgen if not present
|
||||
if ! command -v uniffi-bindgen &> /dev/null; then
|
||||
echo "📦 Installing uniffi_bindgen..."
|
||||
cargo install uniffi_bindgen --bin uniffi-bindgen
|
||||
fi
|
||||
|
||||
echo "⚡ Generating Kotlin bindings..."
|
||||
# Use an existing iOS library file for binding generation (they're compatible)
|
||||
if [ -f "bindings/ios/libchurch_core_device.a" ]; then
|
||||
LIB_FILE="bindings/ios/libchurch_core_device.a"
|
||||
elif [ -f "target/aarch64-apple-ios/release/libchurch_core.a" ]; then
|
||||
LIB_FILE="target/aarch64-apple-ios/release/libchurch_core.a"
|
||||
else
|
||||
echo "❌ No existing library found. Please run ./build_ios.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate Kotlin bindings using uniffi_bindgen
|
||||
uniffi-bindgen generate \
|
||||
src/church_core.udl \
|
||||
--language kotlin \
|
||||
--out-dir bindings/android \
|
||||
--lib-file "$LIB_FILE"
|
||||
|
||||
echo "📦 Creating Android integration README..."
|
||||
cat > bindings/android/README.md << 'EOF'
|
||||
# Church Core Android Bindings
|
||||
|
||||
This directory contains the generated Kotlin bindings for the church-core Rust crate.
|
||||
|
||||
## Files:
|
||||
- `uniffi/church_core/` - Generated Kotlin bindings
|
||||
|
||||
## What's Missing:
|
||||
- Native libraries (.so files) - You need to compile these with Android NDK
|
||||
- JNI library structure - Will be created when you compile native libraries
|
||||
|
||||
## To Complete Android Setup:
|
||||
|
||||
### 1. Install Android Development Tools:
|
||||
```bash
|
||||
# Install Android SDK/NDK (via Android Studio or command line tools)
|
||||
# Set environment variables:
|
||||
export ANDROID_SDK_ROOT=/path/to/android/sdk
|
||||
export ANDROID_NDK_HOME=$ANDROID_SDK_ROOT/ndk/[version]
|
||||
```
|
||||
|
||||
### 2. Install cargo-ndk:
|
||||
```bash
|
||||
cargo install cargo-ndk
|
||||
```
|
||||
|
||||
### 3. Build native libraries:
|
||||
```bash
|
||||
# From church-core directory
|
||||
cargo ndk --target arm64-v8a --platform 21 build --release --features uniffi
|
||||
cargo ndk --target armeabi-v7a --platform 21 build --release --features uniffi
|
||||
cargo ndk --target x86_64 --platform 21 build --release --features uniffi
|
||||
cargo ndk --target x86 --platform 21 build --release --features uniffi
|
||||
```
|
||||
|
||||
### 4. Create JNI structure:
|
||||
```bash
|
||||
mkdir -p jniLibs/{arm64-v8a,armeabi-v7a,x86_64,x86}
|
||||
cp target/aarch64-linux-android/release/libchurch_core.so jniLibs/arm64-v8a/
|
||||
cp target/armv7-linux-androideabi/release/libchurch_core.so jniLibs/armeabi-v7a/
|
||||
cp target/x86_64-linux-android/release/libchurch_core.so jniLibs/x86_64/
|
||||
cp target/i686-linux-android/release/libchurch_core.so jniLibs/x86/
|
||||
```
|
||||
|
||||
## Integration in Android Project:
|
||||
|
||||
### 1. Add JNA dependency to your `build.gradle`:
|
||||
```gradle
|
||||
implementation 'net.java.dev.jna:jna:5.13.0@aar'
|
||||
```
|
||||
|
||||
### 2. Copy files to your Android project:
|
||||
- Copy `uniffi/church_core/` to `src/main/java/`
|
||||
- Copy `jniLibs/` to `src/main/`
|
||||
|
||||
### 3. Usage in Kotlin:
|
||||
```kotlin
|
||||
import uniffi.church_core.*
|
||||
|
||||
class ChurchRepository {
|
||||
fun fetchEvents(): String {
|
||||
return fetchEventsJson()
|
||||
}
|
||||
|
||||
fun fetchSermons(): String {
|
||||
return fetchSermonsJson()
|
||||
}
|
||||
|
||||
fun fetchBulletins(): String {
|
||||
return fetchBulletinsJson()
|
||||
}
|
||||
|
||||
// All other functions from the UDL file are available
|
||||
}
|
||||
```
|
||||
|
||||
## Functions Available:
|
||||
All functions defined in `src/church_core.udl` are available in Kotlin:
|
||||
- `fetchEventsJson()`
|
||||
- `fetchSermonsJson()`
|
||||
- `fetchBulletinsJson()`
|
||||
- `fetchBibleVerseJson(query: String)`
|
||||
- `fetchRandomBibleVerseJson()`
|
||||
- `submitContactV2Json(...)`
|
||||
- `fetchCachedImageBase64(url: String)`
|
||||
- `getOptimalStreamingUrl(mediaId: String)`
|
||||
- `parseEventsFromJson(eventsJson: String)`
|
||||
- `parseSermonsFromJson(sermonsJson: String)`
|
||||
- And many more...
|
||||
|
||||
## Architecture Notes:
|
||||
- All business logic is in Rust (networking, parsing, validation, etc.)
|
||||
- Kotlin only handles UI and calls Rust functions
|
||||
- Same RTSDA architecture as iOS version
|
||||
- JSON responses from Rust, parse to data classes in Kotlin
|
||||
EOF
|
||||
|
||||
echo "✅ Kotlin bindings generated!"
|
||||
echo "📁 Generated files in bindings/android/:"
|
||||
ls -la bindings/android/
|
||||
echo ""
|
||||
echo "📖 See bindings/android/README.md for integration instructions"
|
||||
echo "💡 You'll need Android SDK/NDK to compile the native libraries"
|
||||
echo "🎯 But the Kotlin interface code is ready to use!"
|
40
simple_build.sh
Executable file
40
simple_build.sh
Executable file
|
@ -0,0 +1,40 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🔨 Simple UniFFI iOS build..."
|
||||
|
||||
# Add targets if needed
|
||||
rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
|
||||
|
||||
# Build for all iOS targets
|
||||
echo "Building for iOS..."
|
||||
cargo build --release --target aarch64-apple-ios --features uniffi
|
||||
cargo build --release --target aarch64-apple-ios-sim --features uniffi
|
||||
cargo build --release --target x86_64-apple-ios --features uniffi
|
||||
|
||||
# Create fat binary (iOS device + Intel simulator, skip ARM sim for now)
|
||||
echo "Creating universal library..."
|
||||
lipo -create \
|
||||
target/aarch64-apple-ios/release/libchurch_core.a \
|
||||
target/x86_64-apple-ios/release/libchurch_core.a \
|
||||
-output libchurch_core_universal.a
|
||||
|
||||
# Generate bindings directly with uniffi_bindgen
|
||||
echo "Generating Swift bindings..."
|
||||
uniffi-bindgen generate src/church_core.udl --language swift --out-dir .
|
||||
|
||||
# Copy everything to iOS project
|
||||
echo "Copying to iOS project..."
|
||||
cp church_core.swift ../RTSDA-iOS/RTSDA/
|
||||
cp church_coreFFI.h ../RTSDA-iOS/RTSDA/
|
||||
cp libchurch_core_universal.a ../RTSDA-iOS/RTSDA/libchurch_core.a
|
||||
|
||||
# Create modulemap
|
||||
cat > ../RTSDA-iOS/RTSDA/church_coreFFI.modulemap << EOF
|
||||
module church_coreFFI {
|
||||
header "church_coreFFI.h"
|
||||
export *
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "✅ Done! Build your iOS app in Xcode."
|
4
src/auth/mod.rs
Normal file
4
src/auth/mod.rs
Normal file
|
@ -0,0 +1,4 @@
|
|||
// Authentication modules placeholder
|
||||
// This contains authentication implementations
|
||||
|
||||
pub use crate::models::AuthToken;
|
36
src/bin/test-date-submission.rs
Normal file
36
src/bin/test-date-submission.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
use church_core::{
|
||||
client::{ChurchApiClient, events::submit_event},
|
||||
models::EventSubmission,
|
||||
config::ChurchCoreConfig,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let config = ChurchCoreConfig::new();
|
||||
let client = ChurchApiClient::new(config).unwrap();
|
||||
|
||||
let submission = EventSubmission {
|
||||
title: "Test Event".to_string(),
|
||||
description: "Testing date submission".to_string(),
|
||||
start_time: "2025-06-28T23:00".to_string(), // The problematic format
|
||||
end_time: "2025-06-29T00:00".to_string(),
|
||||
location: "Test Location".to_string(),
|
||||
location_url: None,
|
||||
category: "Other".to_string(),
|
||||
is_featured: false,
|
||||
recurring_type: None,
|
||||
bulletin_week: None,
|
||||
submitter_email: "test@example.com".to_string(),
|
||||
};
|
||||
|
||||
println!("Testing date validation:");
|
||||
println!("Can parse start_time: {}", submission.parse_start_time().is_some());
|
||||
println!("Can parse end_time: {}", submission.parse_end_time().is_some());
|
||||
println!("Validation passes: {}", submission.validate_times());
|
||||
|
||||
println!("\nAttempting to submit event...");
|
||||
match submit_event(&client, submission).await {
|
||||
Ok(id) => println!("✅ Success! Event ID: {}", id),
|
||||
Err(e) => println!("❌ Error: {}", e),
|
||||
}
|
||||
}
|
94
src/bin/test.rs
Normal file
94
src/bin/test.rs
Normal file
|
@ -0,0 +1,94 @@
|
|||
use church_core::{ChurchApiClient, ChurchCoreConfig, DeviceCapabilities, StreamingCapability};
|
||||
use chrono::TimeZone;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Initialize the client with default configuration
|
||||
let config = ChurchCoreConfig::default();
|
||||
let client = ChurchApiClient::new(config)?;
|
||||
|
||||
println!("Church Core API Client Test");
|
||||
println!("==========================");
|
||||
|
||||
// Test health check
|
||||
match client.health_check().await {
|
||||
Ok(true) => println!("✅ Health check passed"),
|
||||
Ok(false) => println!("❌ Health check failed"),
|
||||
Err(e) => println!("❌ Health check error: {}", e),
|
||||
}
|
||||
|
||||
// Test upcoming events
|
||||
match client.get_upcoming_events(Some(5)).await {
|
||||
Ok(events) => {
|
||||
println!("✅ Retrieved {} upcoming events", events.len());
|
||||
for event in events.iter().take(3) {
|
||||
println!(" - {}: {}", event.title, event.start_time.format("%Y-%m-%d %H:%M"));
|
||||
}
|
||||
}
|
||||
Err(e) => println!("❌ Failed to get events: {}", e),
|
||||
}
|
||||
|
||||
// Test current bulletin
|
||||
match client.get_current_bulletin().await {
|
||||
Ok(Some(bulletin)) => {
|
||||
println!("✅ Retrieved current bulletin: {}", bulletin.title);
|
||||
}
|
||||
Ok(None) => println!("ℹ️ No current bulletin found"),
|
||||
Err(e) => println!("❌ Failed to get bulletin: {}", e),
|
||||
}
|
||||
|
||||
// Test configuration
|
||||
match client.get_config().await {
|
||||
Ok(config) => {
|
||||
println!("✅ Retrieved church config");
|
||||
if let Some(name) = &config.church_name {
|
||||
println!(" Church: {}", name);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("❌ Failed to get config: {}", e),
|
||||
}
|
||||
|
||||
// Test sermons
|
||||
match client.get_recent_sermons(Some(5)).await {
|
||||
Ok(sermons) => {
|
||||
println!("✅ Retrieved {} recent sermons", sermons.len());
|
||||
for sermon in sermons.iter().take(2) {
|
||||
println!(" - {}: {}", sermon.title, sermon.speaker);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("❌ Failed to get sermons: {}", e),
|
||||
}
|
||||
|
||||
// Test livestreams
|
||||
match client.get_livestreams().await {
|
||||
Ok(streams) => {
|
||||
println!("✅ Retrieved {} livestream archives", streams.len());
|
||||
for stream in streams.iter().take(2) {
|
||||
println!(" - {}: {}", stream.title, stream.speaker);
|
||||
}
|
||||
}
|
||||
Err(e) => println!("❌ Failed to get livestreams: {}", e),
|
||||
}
|
||||
|
||||
// Test cache stats
|
||||
let (cache_size, max_size) = client.get_cache_stats().await;
|
||||
println!("📊 Cache: {}/{} items", cache_size, max_size);
|
||||
|
||||
// Test streaming URL generation
|
||||
println!("\n🎬 Testing Streaming URLs:");
|
||||
let media_id = "test-id-123";
|
||||
let base_url = "https://api.rockvilletollandsda.church";
|
||||
|
||||
let av1_url = DeviceCapabilities::get_streaming_url(base_url, media_id, StreamingCapability::AV1);
|
||||
println!(" AV1: {}", av1_url.url);
|
||||
|
||||
let hls_url = DeviceCapabilities::get_streaming_url(base_url, media_id, StreamingCapability::HLS);
|
||||
println!(" HLS: {}", hls_url.url);
|
||||
|
||||
let optimal_url = DeviceCapabilities::get_optimal_streaming_url(base_url, media_id);
|
||||
println!(" Optimal: {} ({:?})", optimal_url.url, optimal_url.capability);
|
||||
|
||||
println!("\nTest completed!");
|
||||
|
||||
Ok(())
|
||||
}
|
339
src/cache/mod.rs
vendored
Normal file
339
src/cache/mod.rs
vendored
Normal file
|
@ -0,0 +1,339 @@
|
|||
use serde::{de::DeserializeOwned, Serialize, Deserialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
path::PathBuf,
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::fs;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CachedHttpResponse {
|
||||
pub data: Vec<u8>,
|
||||
pub content_type: String,
|
||||
pub headers: HashMap<String, String>,
|
||||
pub status_code: u16,
|
||||
#[serde(with = "instant_serde")]
|
||||
pub cached_at: Instant,
|
||||
#[serde(with = "instant_serde")]
|
||||
pub expires_at: Instant,
|
||||
}
|
||||
|
||||
// Custom serializer for Instant (can't be serialized directly)
|
||||
mod instant_serde {
|
||||
use super::*;
|
||||
use serde::{Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S>(instant: &Instant, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
// Convert to duration since app start - this is approximate but works for our use case
|
||||
let duration_since_start = instant.elapsed();
|
||||
serializer.serialize_u64(duration_since_start.as_secs())
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Instant, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let secs = <u64 as Deserialize>::deserialize(deserializer)?;
|
||||
// For loaded items, set as if they were cached "now" minus the stored duration
|
||||
// This isn't perfect but works for expiration checking
|
||||
Ok(Instant::now() - Duration::from_secs(secs))
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified cache interface - removed trait object complexity
|
||||
// Each cache type will implement these methods directly
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CacheEntry {
|
||||
data: Vec<u8>,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
impl CacheEntry {
|
||||
fn new(data: Vec<u8>, ttl: Duration) -> Self {
|
||||
Self {
|
||||
data,
|
||||
expires_at: Instant::now() + ttl,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_expired(&self) -> bool {
|
||||
Instant::now() > self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MemoryCache {
|
||||
store: Arc<RwLock<HashMap<String, CacheEntry>>>,
|
||||
http_store: Arc<RwLock<HashMap<String, CachedHttpResponse>>>,
|
||||
max_size: usize,
|
||||
cache_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl MemoryCache {
|
||||
pub fn new(max_size: usize) -> Self {
|
||||
Self {
|
||||
store: Arc::new(RwLock::new(HashMap::new())),
|
||||
http_store: Arc::new(RwLock::new(HashMap::new())),
|
||||
max_size,
|
||||
cache_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_disk_cache(mut self, cache_dir: PathBuf) -> Self {
|
||||
self.cache_dir = Some(cache_dir);
|
||||
self
|
||||
}
|
||||
|
||||
fn get_cache_file_path(&self, url: &str) -> Option<PathBuf> {
|
||||
self.cache_dir.as_ref().map(|dir| {
|
||||
// Create a safe filename from URL
|
||||
let hash = {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = DefaultHasher::new();
|
||||
url.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
};
|
||||
dir.join(format!("cache_{}.json", hash))
|
||||
})
|
||||
}
|
||||
|
||||
async fn cleanup_expired(&self) {
|
||||
let mut store = self.store.write().await;
|
||||
let now = Instant::now();
|
||||
store.retain(|_, entry| entry.expires_at > now);
|
||||
}
|
||||
|
||||
async fn ensure_capacity(&self) {
|
||||
let mut store = self.store.write().await;
|
||||
|
||||
if store.len() >= self.max_size {
|
||||
// Remove oldest entries if we're at capacity
|
||||
// Collect keys to remove to avoid borrow issues
|
||||
let mut to_remove: Vec<String> = Vec::new();
|
||||
{
|
||||
let entries: Vec<_> = store.iter().collect();
|
||||
let mut sorted_entries = entries;
|
||||
sorted_entries.sort_by_key(|(_, entry)| entry.expires_at);
|
||||
|
||||
let remove_count = sorted_entries.len().saturating_sub(self.max_size / 2);
|
||||
for (key, _) in sorted_entries.into_iter().take(remove_count) {
|
||||
to_remove.push(key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Now remove the keys
|
||||
for key in to_remove {
|
||||
store.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryCache {
|
||||
pub async fn get<T>(&self, key: &str) -> Option<T>
|
||||
where
|
||||
T: DeserializeOwned + Send + 'static,
|
||||
{
|
||||
// Clean up expired entries periodically
|
||||
if rand::random::<f32>() < 0.1 {
|
||||
self.cleanup_expired().await;
|
||||
}
|
||||
|
||||
let store = self.store.read().await;
|
||||
if let Some(entry) = store.get(key) {
|
||||
if !entry.is_expired() {
|
||||
if let Ok(value) = serde_json::from_slice(&entry.data) {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn set<T>(&self, key: &str, value: &T, ttl: Duration)
|
||||
where
|
||||
T: Serialize + Send + Sync,
|
||||
{
|
||||
if let Ok(data) = serde_json::to_vec(value) {
|
||||
self.ensure_capacity().await;
|
||||
|
||||
let mut store = self.store.write().await;
|
||||
store.insert(key.to_string(), CacheEntry::new(data, ttl));
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove(&self, key: &str) {
|
||||
let mut store = self.store.write().await;
|
||||
store.remove(key);
|
||||
}
|
||||
|
||||
pub async fn clear(&self) {
|
||||
let mut store = self.store.write().await;
|
||||
store.clear();
|
||||
}
|
||||
|
||||
pub async fn len(&self) -> usize {
|
||||
let store = self.store.read().await;
|
||||
store.len()
|
||||
}
|
||||
|
||||
pub async fn invalidate_prefix(&self, prefix: &str) {
|
||||
let mut store = self.store.write().await;
|
||||
store.retain(|key, _| !key.starts_with(prefix));
|
||||
|
||||
let mut http_store = self.http_store.write().await;
|
||||
http_store.retain(|key, _| !key.starts_with(prefix));
|
||||
}
|
||||
|
||||
// HTTP Response Caching Methods
|
||||
|
||||
pub async fn get_http_response(&self, url: &str) -> Option<CachedHttpResponse> {
|
||||
// Clean up expired entries periodically
|
||||
if rand::random::<f32>() < 0.1 {
|
||||
self.cleanup_expired_http().await;
|
||||
}
|
||||
|
||||
// 1. Check memory cache first (fastest)
|
||||
{
|
||||
let store = self.http_store.read().await;
|
||||
println!("🔍 Memory cache lookup for: {}", url);
|
||||
println!("🔍 Memory cache has {} entries", store.len());
|
||||
|
||||
if let Some(response) = store.get(url) {
|
||||
if !response.is_expired() {
|
||||
println!("🔍 Memory cache HIT - found valid entry");
|
||||
return Some(response.clone());
|
||||
} else {
|
||||
println!("🔍 Memory cache entry expired");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check disk cache (persistent)
|
||||
if let Some(cache_path) = self.get_cache_file_path(url) {
|
||||
println!("🔍 Checking disk cache at: {:?}", cache_path);
|
||||
|
||||
if let Ok(file_content) = fs::read(&cache_path).await {
|
||||
if let Ok(cached_response) = serde_json::from_slice::<CachedHttpResponse>(&file_content) {
|
||||
if !cached_response.is_expired() {
|
||||
println!("🔍 Disk cache HIT - loading into memory");
|
||||
|
||||
// Load back into memory cache for faster future access
|
||||
let mut store = self.http_store.write().await;
|
||||
store.insert(url.to_string(), cached_response.clone());
|
||||
|
||||
return Some(cached_response);
|
||||
} else {
|
||||
println!("🔍 Disk cache entry expired, removing file");
|
||||
let _ = fs::remove_file(&cache_path).await;
|
||||
}
|
||||
} else {
|
||||
println!("🔍 Failed to parse disk cache file");
|
||||
}
|
||||
} else {
|
||||
println!("🔍 No disk cache file found");
|
||||
}
|
||||
}
|
||||
|
||||
println!("🔍 Cache MISS - no valid entry found");
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn set_http_response(&self, url: &str, response: CachedHttpResponse) {
|
||||
self.ensure_http_capacity().await;
|
||||
|
||||
// Store in memory cache
|
||||
let mut store = self.http_store.write().await;
|
||||
println!("🔍 Storing in memory cache: {}", url);
|
||||
println!("🔍 Memory cache will have {} entries after insert", store.len() + 1);
|
||||
store.insert(url.to_string(), response.clone());
|
||||
drop(store); // Release the lock before async disk operation
|
||||
|
||||
// Store in disk cache (async, non-blocking)
|
||||
if let Some(cache_path) = self.get_cache_file_path(url) {
|
||||
println!("🔍 Storing to disk cache: {:?}", cache_path);
|
||||
|
||||
// Ensure cache directory exists
|
||||
if let Some(parent) = cache_path.parent() {
|
||||
let _ = fs::create_dir_all(parent).await;
|
||||
}
|
||||
|
||||
// Serialize and save to disk
|
||||
match serde_json::to_vec(&response) {
|
||||
Ok(serialized) => {
|
||||
if let Err(e) = fs::write(&cache_path, serialized).await {
|
||||
println!("🔍 Failed to write disk cache: {}", e);
|
||||
} else {
|
||||
println!("🔍 Successfully saved to disk cache");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("🔍 Failed to serialize for disk cache: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup_expired_http(&self) {
|
||||
let mut store = self.http_store.write().await;
|
||||
let now = Instant::now();
|
||||
store.retain(|_, response| response.expires_at > now);
|
||||
}
|
||||
|
||||
async fn ensure_http_capacity(&self) {
|
||||
let mut store = self.http_store.write().await;
|
||||
|
||||
if store.len() >= self.max_size {
|
||||
// Remove oldest entries if we're at capacity
|
||||
let mut to_remove: Vec<String> = Vec::new();
|
||||
{
|
||||
let entries: Vec<_> = store.iter().collect();
|
||||
let mut sorted_entries = entries;
|
||||
sorted_entries.sort_by_key(|(_, response)| response.cached_at);
|
||||
|
||||
let remove_count = sorted_entries.len().saturating_sub(self.max_size / 2);
|
||||
for (key, _) in sorted_entries.into_iter().take(remove_count) {
|
||||
to_remove.push(key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Now remove the keys
|
||||
for key in to_remove {
|
||||
store.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CachedHttpResponse {
|
||||
pub fn new(
|
||||
data: Vec<u8>,
|
||||
content_type: String,
|
||||
headers: HashMap<String, String>,
|
||||
status_code: u16,
|
||||
ttl: Duration
|
||||
) -> Self {
|
||||
let now = Instant::now();
|
||||
Self {
|
||||
data,
|
||||
content_type,
|
||||
headers,
|
||||
status_code,
|
||||
cached_at: now,
|
||||
expires_at: now + ttl,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Instant::now() > self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
// Add rand dependency for periodic cleanup
|
||||
// This is a simple implementation - in production you might want to use a more sophisticated cache like moka
|
76
src/church_core.udl
Normal file
76
src/church_core.udl
Normal file
|
@ -0,0 +1,76 @@
|
|||
namespace church_core {
|
||||
string fetch_events_json();
|
||||
string fetch_bulletins_json();
|
||||
string fetch_sermons_json();
|
||||
string fetch_bible_verse_json(string query);
|
||||
string fetch_random_bible_verse_json();
|
||||
string fetch_scripture_verses_for_sermon_json(string sermon_id);
|
||||
string fetch_config_json();
|
||||
string fetch_current_bulletin_json();
|
||||
string fetch_featured_events_json();
|
||||
string fetch_stream_status_json();
|
||||
boolean get_stream_live_status();
|
||||
string get_livestream_url();
|
||||
string fetch_live_stream_json();
|
||||
string fetch_livestream_archive_json();
|
||||
string submit_contact_json(string name, string email, string message);
|
||||
string submit_contact_v2_json(string name, string email, string subject, string message, string phone);
|
||||
string submit_contact_v2_json_legacy(string first_name, string last_name, string email, string subject, string message);
|
||||
string fetch_cached_image_base64(string url);
|
||||
string get_optimal_streaming_url(string media_id);
|
||||
boolean device_supports_av1();
|
||||
string get_av1_streaming_url(string media_id);
|
||||
string get_hls_streaming_url(string media_id);
|
||||
|
||||
// Scripture formatting utilities
|
||||
string format_scripture_text_json(string scripture_text);
|
||||
string extract_scripture_references_string(string scripture_text);
|
||||
string create_sermon_share_items_json(string title, string speaker, string? video_url, string? audio_url);
|
||||
|
||||
// Form validation functions
|
||||
string validate_contact_form_json(string form_json);
|
||||
boolean validate_email_address(string email);
|
||||
boolean validate_phone_number(string phone);
|
||||
|
||||
// Event formatting functions
|
||||
string format_event_for_display_json(string event_json);
|
||||
string format_time_range_string(string start_time, string end_time);
|
||||
boolean is_multi_day_event_check(string date);
|
||||
|
||||
// Home feed aggregation
|
||||
string generate_home_feed_json(string events_json, string sermons_json, string bulletins_json, string verse_json);
|
||||
|
||||
// Media type management
|
||||
string get_media_type_display_name(string media_type_str);
|
||||
string get_media_type_icon(string media_type_str);
|
||||
string filter_sermons_by_media_type(string sermons_json, string media_type_str);
|
||||
|
||||
// Individual config getter functions (RTSDA architecture compliant)
|
||||
string get_church_name();
|
||||
string get_contact_phone();
|
||||
string get_contact_email();
|
||||
string get_brand_color();
|
||||
string get_about_text();
|
||||
string get_donation_url();
|
||||
string get_church_address();
|
||||
sequence<f64> get_coordinates();
|
||||
string get_website_url();
|
||||
string get_facebook_url();
|
||||
string get_youtube_url();
|
||||
string get_instagram_url();
|
||||
string get_mission_statement();
|
||||
|
||||
// Calendar event parsing (RTSDA architecture compliant)
|
||||
string create_calendar_event_data(string event_json);
|
||||
|
||||
// JSON parsing functions (RTSDA architecture compliance)
|
||||
string parse_events_from_json(string events_json);
|
||||
string parse_sermons_from_json(string sermons_json);
|
||||
string parse_bulletins_from_json(string bulletins_json);
|
||||
string parse_bible_verse_from_json(string verse_json);
|
||||
string parse_contact_result_from_json(string result_json);
|
||||
string generate_verse_description(string verses_json);
|
||||
string extract_full_verse_text(string verses_json);
|
||||
string extract_stream_url_from_status(string status_json);
|
||||
string parse_calendar_event_data(string calendar_json);
|
||||
};
|
109
src/client/admin.rs
Normal file
109
src/client/admin.rs
Normal file
|
@ -0,0 +1,109 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
models::{
|
||||
NewBulletin, BulletinUpdate,
|
||||
NewEvent, EventUpdate, PendingEvent,
|
||||
User, Schedule, NewSchedule, ScheduleUpdate,
|
||||
ApiVersion,
|
||||
},
|
||||
};
|
||||
|
||||
// Admin Bulletin Management
|
||||
pub async fn create_bulletin(client: &ChurchApiClient, bulletin: NewBulletin) -> Result<String> {
|
||||
client.post_api_with_version("/admin/bulletins", &bulletin, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub async fn update_bulletin(client: &ChurchApiClient, id: &str, update: BulletinUpdate) -> Result<()> {
|
||||
let path = format!("/admin/bulletins/{}", id);
|
||||
client.put_api(&path, &update).await
|
||||
}
|
||||
|
||||
pub async fn delete_bulletin(client: &ChurchApiClient, id: &str) -> Result<()> {
|
||||
let path = format!("/admin/bulletins/{}", id);
|
||||
client.delete_api(&path).await
|
||||
}
|
||||
|
||||
// Admin Event Management
|
||||
pub async fn create_admin_event(client: &ChurchApiClient, event: NewEvent) -> Result<String> {
|
||||
client.post_api_with_version("/admin/events", &event, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub async fn update_admin_event(client: &ChurchApiClient, id: &str, update: EventUpdate) -> Result<()> {
|
||||
let path = format!("/admin/events/{}", id);
|
||||
client.put_api(&path, &update).await
|
||||
}
|
||||
|
||||
pub async fn delete_admin_event(client: &ChurchApiClient, id: &str) -> Result<()> {
|
||||
let path = format!("/admin/events/{}", id);
|
||||
client.delete_api(&path).await
|
||||
}
|
||||
|
||||
// Admin Pending Events Management
|
||||
pub async fn get_pending_events(client: &ChurchApiClient) -> Result<Vec<PendingEvent>> {
|
||||
client.get_api("/admin/events/pending").await
|
||||
}
|
||||
|
||||
pub async fn approve_pending_event(client: &ChurchApiClient, id: &str) -> Result<()> {
|
||||
let path = format!("/admin/events/pending/{}/approve", id);
|
||||
let response: crate::models::ApiResponse<()> = client.post(&path, &()).await?;
|
||||
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Failed to approve pending event".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reject_pending_event(client: &ChurchApiClient, id: &str) -> Result<()> {
|
||||
let path = format!("/admin/events/pending/{}/reject", id);
|
||||
let response: crate::models::ApiResponse<()> = client.post(&path, &()).await?;
|
||||
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Failed to reject pending event".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_pending_event(client: &ChurchApiClient, id: &str) -> Result<()> {
|
||||
let path = format!("/admin/events/pending/{}", id);
|
||||
client.delete_api(&path).await
|
||||
}
|
||||
|
||||
// Admin User Management
|
||||
pub async fn get_users(client: &ChurchApiClient) -> Result<Vec<User>> {
|
||||
client.get_api("/admin/users").await
|
||||
}
|
||||
|
||||
// Admin Schedule Management
|
||||
pub async fn create_schedule(client: &ChurchApiClient, schedule: NewSchedule) -> Result<String> {
|
||||
client.post_api("/admin/schedule", &schedule).await
|
||||
}
|
||||
|
||||
pub async fn update_schedule(client: &ChurchApiClient, date: &str, update: ScheduleUpdate) -> Result<()> {
|
||||
let path = format!("/admin/schedule/{}", date);
|
||||
client.put_api(&path, &update).await
|
||||
}
|
||||
|
||||
pub async fn delete_schedule(client: &ChurchApiClient, date: &str) -> Result<()> {
|
||||
let path = format!("/admin/schedule/{}", date);
|
||||
client.delete_api(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_all_schedules(client: &ChurchApiClient) -> Result<Vec<Schedule>> {
|
||||
client.get_api("/admin/schedule").await
|
||||
}
|
||||
|
||||
// Admin Config Management
|
||||
pub async fn get_admin_config(client: &ChurchApiClient) -> Result<crate::models::ChurchConfig> {
|
||||
client.get_api("/admin/config").await
|
||||
}
|
169
src/client/bible.rs
Normal file
169
src/client/bible.rs
Normal file
|
@ -0,0 +1,169 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
models::{BibleVerse, VerseOfTheDay, VerseCategory, PaginationParams, ApiListResponse, ApiVersion},
|
||||
};
|
||||
|
||||
pub async fn get_random_verse(client: &ChurchApiClient) -> Result<BibleVerse> {
|
||||
// The response format is {success: bool, data: Verse}
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct VerseResponse {
|
||||
success: bool,
|
||||
data: ApiVerse,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct ApiVerse {
|
||||
id: String,
|
||||
reference: String,
|
||||
text: String,
|
||||
#[serde(rename = "is_active")]
|
||||
is_active: bool,
|
||||
}
|
||||
|
||||
let url = client.build_url("/bible_verses/random");
|
||||
let raw_response = client.client.get(&url).send().await?;
|
||||
let response_text = raw_response.text().await?;
|
||||
let response: VerseResponse = serde_json::from_str(&response_text)
|
||||
.map_err(|e| crate::error::ChurchApiError::Json(e))?;
|
||||
|
||||
if response.success {
|
||||
Ok(BibleVerse::new(response.data.text, response.data.reference))
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api("Bible verse API returned success=false".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_verse_of_the_day(client: &ChurchApiClient) -> Result<VerseOfTheDay> {
|
||||
client.get_api("/bible/verse-of-the-day").await
|
||||
}
|
||||
|
||||
pub async fn get_verse_by_reference(client: &ChurchApiClient, reference: &str) -> Result<Option<BibleVerse>> {
|
||||
let path = format!("/bible/verse?reference={}", urlencoding::encode(reference));
|
||||
|
||||
match client.get_api(&path).await {
|
||||
Ok(verse) => Ok(Some(verse)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_verses_by_category(client: &ChurchApiClient, category: VerseCategory, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
|
||||
let mut path = format!("/bible/category/{}", category.display_name().to_lowercase());
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
let response: crate::models::ApiListResponse<BibleVerse> = client.get_api_list(&path).await?;
|
||||
Ok(response.data.items)
|
||||
}
|
||||
|
||||
pub async fn search_verses(client: &ChurchApiClient, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
|
||||
let mut path = format!("/bible_verses/search?q={}", urlencoding::encode(query));
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("&limit={}", limit));
|
||||
}
|
||||
|
||||
// The bible_verses/search endpoint returns a custom format with additional fields
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct ApiBibleVerse {
|
||||
id: String,
|
||||
reference: String,
|
||||
text: String,
|
||||
is_active: bool,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct BibleSearchResponse {
|
||||
success: bool,
|
||||
data: Vec<ApiBibleVerse>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
let url = client.build_url(&path);
|
||||
let raw_response = client.client.get(&url).send().await?;
|
||||
let response_text = raw_response.text().await?;
|
||||
let response: BibleSearchResponse = serde_json::from_str(&response_text)
|
||||
.map_err(|e| crate::error::ChurchApiError::Json(e))?;
|
||||
if response.success {
|
||||
// Convert ApiBibleVerse to BibleVerse
|
||||
let verses = response.data.into_iter()
|
||||
.map(|api_verse| BibleVerse::new(api_verse.text, api_verse.reference))
|
||||
.collect();
|
||||
Ok(verses)
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
pub async fn get_random_verse_v2(client: &ChurchApiClient) -> Result<BibleVerse> {
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct VerseResponse {
|
||||
success: bool,
|
||||
data: ApiVerse,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct ApiVerse {
|
||||
id: String,
|
||||
reference: String,
|
||||
text: String,
|
||||
#[serde(rename = "is_active")]
|
||||
is_active: bool,
|
||||
}
|
||||
|
||||
let url = client.build_url_with_version("/bible_verses/random", ApiVersion::V2);
|
||||
let raw_response = client.client.get(&url).send().await?;
|
||||
let response_text = raw_response.text().await?;
|
||||
let response: VerseResponse = serde_json::from_str(&response_text)
|
||||
.map_err(|e| crate::error::ChurchApiError::Json(e))?;
|
||||
|
||||
if response.success {
|
||||
Ok(BibleVerse::new(response.data.text, response.data.reference))
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api("Bible verse API returned success=false".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bible_verses_v2(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<BibleVerse>> {
|
||||
let mut path = "/bible_verses".to_string();
|
||||
|
||||
if let Some(params) = params {
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
if let Some(page) = params.page {
|
||||
query_params.push(("page", page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(per_page) = params.per_page {
|
||||
query_params.push(("per_page", per_page.to_string()));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
}
|
||||
|
||||
client.get_api_list_with_version(&path, ApiVersion::V2).await
|
||||
}
|
||||
|
||||
pub async fn search_verses_v2(client: &ChurchApiClient, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
|
||||
let mut path = format!("/bible_verses/search?q={}", urlencoding::encode(query));
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("&limit={}", limit));
|
||||
}
|
||||
|
||||
let response: crate::models::ApiListResponse<BibleVerse> = client.get_api_list_with_version(&path, ApiVersion::V2).await?;
|
||||
Ok(response.data.items)
|
||||
}
|
101
src/client/bulletins.rs
Normal file
101
src/client/bulletins.rs
Normal file
|
@ -0,0 +1,101 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
models::{Bulletin, NewBulletin, PaginationParams, ApiListResponse, ApiVersion},
|
||||
};
|
||||
|
||||
pub async fn get_bulletins(client: &ChurchApiClient, active_only: bool) -> Result<Vec<Bulletin>> {
|
||||
let path = if active_only {
|
||||
"/bulletins?active=true"
|
||||
} else {
|
||||
"/bulletins"
|
||||
};
|
||||
|
||||
let response: ApiListResponse<Bulletin> = client.get_api_list(path).await?;
|
||||
Ok(response.data.items)
|
||||
}
|
||||
|
||||
pub async fn get_current_bulletin(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
|
||||
match client.get_api("/bulletins/current").await {
|
||||
Ok(bulletin) => Ok(Some(bulletin)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_bulletin(client: &ChurchApiClient, id: &str) -> Result<Option<Bulletin>> {
|
||||
let path = format!("/bulletins/{}", id);
|
||||
|
||||
match client.get_api(&path).await {
|
||||
Ok(bulletin) => Ok(Some(bulletin)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_bulletin(client: &ChurchApiClient, bulletin: NewBulletin) -> Result<String> {
|
||||
client.post_api("/bulletins", &bulletin).await
|
||||
}
|
||||
|
||||
pub async fn get_next_bulletin(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
|
||||
match client.get_api("/bulletins/next").await {
|
||||
Ok(bulletin) => Ok(Some(bulletin)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
pub async fn get_bulletins_v2(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Bulletin>> {
|
||||
let mut path = "/bulletins".to_string();
|
||||
|
||||
if let Some(params) = params {
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
if let Some(page) = params.page {
|
||||
query_params.push(("page", page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(per_page) = params.per_page {
|
||||
query_params.push(("per_page", per_page.to_string()));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
}
|
||||
|
||||
let url = client.build_url_with_version(&path, ApiVersion::V2);
|
||||
let response: ApiListResponse<Bulletin> = client.get(&url).await?;
|
||||
|
||||
if response.success {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Unknown API error".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_current_bulletin_v2(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
|
||||
match client.get_api_with_version("/bulletins/current", ApiVersion::V2).await {
|
||||
Ok(bulletin) => Ok(Some(bulletin)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_next_bulletin_v2(client: &ChurchApiClient) -> Result<Option<Bulletin>> {
|
||||
match client.get_api_with_version("/bulletins/next", ApiVersion::V2).await {
|
||||
Ok(bulletin) => Ok(Some(bulletin)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
50
src/client/config.rs
Normal file
50
src/client/config.rs
Normal file
|
@ -0,0 +1,50 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
models::{ChurchConfig, Schedule, ConferenceData, ApiVersion},
|
||||
};
|
||||
|
||||
pub async fn get_config(client: &ChurchApiClient) -> Result<ChurchConfig> {
|
||||
client.get("/config").await
|
||||
}
|
||||
|
||||
pub async fn get_config_by_id(client: &ChurchApiClient, record_id: &str) -> Result<ChurchConfig> {
|
||||
let path = format!("/config/records/{}", record_id);
|
||||
client.get_api(&path).await
|
||||
}
|
||||
|
||||
pub async fn update_config(client: &ChurchApiClient, config: ChurchConfig) -> Result<()> {
|
||||
client.put_api("/config", &config).await
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
pub async fn get_config_v2(client: &ChurchApiClient) -> Result<ChurchConfig> {
|
||||
client.get_api_with_version("/config", ApiVersion::V2).await
|
||||
}
|
||||
|
||||
// Schedule endpoints
|
||||
pub async fn get_schedule(client: &ChurchApiClient, date: Option<&str>) -> Result<Schedule> {
|
||||
let path = if let Some(date) = date {
|
||||
format!("/schedule?date={}", date)
|
||||
} else {
|
||||
"/schedule".to_string()
|
||||
};
|
||||
client.get_api(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_schedule_v2(client: &ChurchApiClient, date: Option<&str>) -> Result<Schedule> {
|
||||
let path = if let Some(date) = date {
|
||||
format!("/schedule?date={}", date)
|
||||
} else {
|
||||
"/schedule".to_string()
|
||||
};
|
||||
client.get_api_with_version(&path, ApiVersion::V2).await
|
||||
}
|
||||
|
||||
pub async fn get_conference_data(client: &ChurchApiClient) -> Result<ConferenceData> {
|
||||
client.get_api("/conference-data").await
|
||||
}
|
||||
|
||||
pub async fn get_conference_data_v2(client: &ChurchApiClient) -> Result<ConferenceData> {
|
||||
client.get_api_with_version("/conference-data", ApiVersion::V2).await
|
||||
}
|
125
src/client/contact.rs
Normal file
125
src/client/contact.rs
Normal file
|
@ -0,0 +1,125 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
models::{ContactForm, ContactSubmission, ContactStatus, PaginationParams, ApiListResponse, ApiVersion},
|
||||
};
|
||||
|
||||
pub async fn submit_contact_form(client: &ChurchApiClient, form: ContactForm) -> Result<String> {
|
||||
// Create payload matching the expected format from iOS app
|
||||
let payload = serde_json::json!({
|
||||
"first_name": form.name.split_whitespace().next().unwrap_or(&form.name),
|
||||
"last_name": form.name.split_whitespace().nth(1).unwrap_or(""),
|
||||
"email": form.email,
|
||||
"phone": form.phone.unwrap_or_default(),
|
||||
"message": form.message
|
||||
});
|
||||
|
||||
// Use the main API subdomain for consistency
|
||||
let contact_url = client.build_url("/contact");
|
||||
|
||||
let response = client.client
|
||||
.post(contact_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok("Contact form submitted successfully".to_string())
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api(format!("Contact form submission failed with status: {}", response.status())))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_contact_submissions(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<ContactSubmission>> {
|
||||
let mut path = "/contact/submissions".to_string();
|
||||
|
||||
if let Some(params) = params {
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
if let Some(page) = params.page {
|
||||
query_params.push(("page", page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(per_page) = params.per_page {
|
||||
query_params.push(("per_page", per_page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(sort) = ¶ms.sort {
|
||||
query_params.push(("sort", sort.clone()));
|
||||
}
|
||||
|
||||
if let Some(filter) = ¶ms.filter {
|
||||
query_params.push(("filter", filter.clone()));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
}
|
||||
|
||||
client.get_api_list(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_contact_submission(client: &ChurchApiClient, id: &str) -> Result<Option<ContactSubmission>> {
|
||||
let path = format!("/contact/submissions/{}", id);
|
||||
|
||||
match client.get_api(&path).await {
|
||||
Ok(submission) => Ok(Some(submission)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_contact_submission(
|
||||
client: &ChurchApiClient,
|
||||
id: &str,
|
||||
status: ContactStatus,
|
||||
response: Option<String>
|
||||
) -> Result<()> {
|
||||
let path = format!("/contact/submissions/{}", id);
|
||||
|
||||
let update_data = serde_json::json!({
|
||||
"status": status,
|
||||
"response": response
|
||||
});
|
||||
|
||||
client.put_api(&path, &update_data).await
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
pub async fn submit_contact_form_v2(client: &ChurchApiClient, form: ContactForm) -> Result<String> {
|
||||
let mut payload = serde_json::json!({
|
||||
"name": form.name,
|
||||
"email": form.email,
|
||||
"subject": form.subject,
|
||||
"message": form.message
|
||||
});
|
||||
|
||||
// Add phone field if provided
|
||||
if let Some(phone) = &form.phone {
|
||||
if !phone.trim().is_empty() {
|
||||
payload["phone"] = serde_json::json!(phone);
|
||||
}
|
||||
}
|
||||
|
||||
let url = client.build_url_with_version("/contact", ApiVersion::V2);
|
||||
|
||||
let response = client.client
|
||||
.post(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok("Contact form submitted successfully".to_string())
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api(format!("Contact form submission failed with status: {}", response.status())))
|
||||
}
|
||||
}
|
192
src/client/events.rs
Normal file
192
src/client/events.rs
Normal file
|
@ -0,0 +1,192 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
models::{Event, NewEvent, EventUpdate, EventSubmission, PaginationParams, ApiListResponse, ApiVersion},
|
||||
};
|
||||
|
||||
pub async fn get_events(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
|
||||
let mut path = "/events".to_string();
|
||||
|
||||
if let Some(params) = params {
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
if let Some(page) = params.page {
|
||||
query_params.push(("page", page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(per_page) = params.per_page {
|
||||
query_params.push(("per_page", per_page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(sort) = ¶ms.sort {
|
||||
query_params.push(("sort", sort.clone()));
|
||||
}
|
||||
|
||||
if let Some(filter) = ¶ms.filter {
|
||||
query_params.push(("filter", filter.clone()));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
}
|
||||
|
||||
client.get_api_list(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_upcoming_events(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
let mut path = "/events/upcoming".to_string();
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
client.get_api(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_event(client: &ChurchApiClient, id: &str) -> Result<Option<Event>> {
|
||||
let path = format!("/events/{}", id);
|
||||
|
||||
match client.get_api(&path).await {
|
||||
Ok(event) => Ok(Some(event)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_event(client: &ChurchApiClient, event: NewEvent) -> Result<String> {
|
||||
client.post_api("/events", &event).await
|
||||
}
|
||||
|
||||
pub async fn update_event(client: &ChurchApiClient, id: &str, update: EventUpdate) -> Result<()> {
|
||||
let path = format!("/events/{}", id);
|
||||
client.put_api(&path, &update).await
|
||||
}
|
||||
|
||||
pub async fn delete_event(client: &ChurchApiClient, id: &str) -> Result<()> {
|
||||
let path = format!("/events/{}", id);
|
||||
client.delete_api(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_featured_events(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
let mut path = "/events/featured".to_string();
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
client.get_api(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_events_by_category(client: &ChurchApiClient, category: &str, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
let mut path = format!("/events/category/{}", category);
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
client.get_api(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_events_by_date_range(
|
||||
client: &ChurchApiClient,
|
||||
start_date: &str,
|
||||
end_date: &str
|
||||
) -> Result<Vec<Event>> {
|
||||
let path = format!("/events/range?start={}&end={}",
|
||||
urlencoding::encode(start_date),
|
||||
urlencoding::encode(end_date)
|
||||
);
|
||||
|
||||
client.get_api(&path).await
|
||||
}
|
||||
|
||||
pub async fn search_events(client: &ChurchApiClient, query: &str, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
let mut path = format!("/events/search?q={}", urlencoding::encode(query));
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("&limit={}", limit));
|
||||
}
|
||||
|
||||
client.get_api(&path).await
|
||||
}
|
||||
|
||||
pub async fn upload_event_image(client: &ChurchApiClient, event_id: &str, image_data: Vec<u8>, filename: String) -> Result<String> {
|
||||
let path = format!("/events/{}/image", event_id);
|
||||
client.upload_file(&path, image_data, filename, "image".to_string()).await
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
pub async fn get_events_v2(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
|
||||
let mut path = "/events".to_string();
|
||||
|
||||
if let Some(params) = params {
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
if let Some(page) = params.page {
|
||||
query_params.push(("page", page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(per_page) = params.per_page {
|
||||
query_params.push(("per_page", per_page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(sort) = ¶ms.sort {
|
||||
query_params.push(("sort", sort.clone()));
|
||||
}
|
||||
|
||||
if let Some(filter) = ¶ms.filter {
|
||||
query_params.push(("filter", filter.clone()));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
}
|
||||
|
||||
client.get_api_list_with_version(&path, ApiVersion::V2).await
|
||||
}
|
||||
|
||||
pub async fn get_upcoming_events_v2(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
let mut path = "/events/upcoming".to_string();
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
client.get_api_with_version(&path, ApiVersion::V2).await
|
||||
}
|
||||
|
||||
pub async fn get_featured_events_v2(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
let mut path = "/events/featured".to_string();
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
client.get_api_with_version(&path, ApiVersion::V2).await
|
||||
}
|
||||
|
||||
pub async fn get_event_v2(client: &ChurchApiClient, id: &str) -> Result<Option<Event>> {
|
||||
let path = format!("/events/{}", id);
|
||||
|
||||
match client.get_api_with_version(&path, ApiVersion::V2).await {
|
||||
Ok(event) => Ok(Some(event)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn submit_event(client: &ChurchApiClient, submission: EventSubmission) -> Result<String> {
|
||||
client.post_api("/events/submit", &submission).await
|
||||
}
|
402
src/client/http.rs
Normal file
402
src/client/http.rs
Normal file
|
@ -0,0 +1,402 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::{ChurchApiError, Result},
|
||||
models::{ApiResponse, ApiListResponse, ApiVersion},
|
||||
cache::CachedHttpResponse,
|
||||
};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
impl ChurchApiClient {
|
||||
pub(crate) async fn get<T>(&self, path: &str) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
|
||||
{
|
||||
self.get_with_version(path, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_with_version<T>(&self, path: &str, version: ApiVersion) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
|
||||
{
|
||||
let cache_key = format!("GET:{}:{:?}", path, version);
|
||||
|
||||
// Check cache first
|
||||
if self.config.enable_offline_mode {
|
||||
if let Some(cached) = self.cache.get::<T>(&cache_key).await {
|
||||
return Ok(cached);
|
||||
}
|
||||
}
|
||||
|
||||
let url = self.build_url_with_version(path, version);
|
||||
let request = self.client.get(&url);
|
||||
let request = self.add_auth_header(request).await;
|
||||
|
||||
let response = self.send_with_retry(request).await?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await?;
|
||||
return Err(crate::error::ChurchApiError::Api(format!("HTTP {}: {}", status, error_text)));
|
||||
}
|
||||
|
||||
let response_text = response.text().await?;
|
||||
|
||||
let data: T = serde_json::from_str(&response_text).map_err(|e| {
|
||||
crate::error::ChurchApiError::Json(e)
|
||||
})?;
|
||||
|
||||
// Cache the result
|
||||
if self.config.enable_offline_mode {
|
||||
self.cache.set(&cache_key, &data, self.config.cache_ttl).await;
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_api<T>(&self, path: &str) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
|
||||
{
|
||||
self.get_api_with_version(path, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_api_with_version<T>(&self, path: &str, version: ApiVersion) -> Result<T>
|
||||
where
|
||||
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
|
||||
{
|
||||
let response: ApiResponse<T> = self.get_with_version(path, version).await?;
|
||||
|
||||
if response.success {
|
||||
response.data.ok_or_else(|| {
|
||||
ChurchApiError::Api("API returned success but no data".to_string())
|
||||
})
|
||||
} else {
|
||||
Err(ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Unknown API error".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn get_api_list<T>(&self, path: &str) -> Result<ApiListResponse<T>>
|
||||
where
|
||||
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
|
||||
{
|
||||
self.get_api_list_with_version(path, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_api_list_with_version<T>(&self, path: &str, version: ApiVersion) -> Result<ApiListResponse<T>>
|
||||
where
|
||||
T: DeserializeOwned + Send + Sync + serde::Serialize + 'static,
|
||||
{
|
||||
let response: ApiListResponse<T> = self.get_with_version(path, version).await?;
|
||||
|
||||
if response.success {
|
||||
Ok(response)
|
||||
} else {
|
||||
Err(ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Unknown API error".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn post<T, R>(&self, path: &str, data: &T) -> Result<R>
|
||||
where
|
||||
T: Serialize,
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
self.post_with_version(path, data, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub(crate) async fn post_with_version<T, R>(&self, path: &str, data: &T, version: ApiVersion) -> Result<R>
|
||||
where
|
||||
T: Serialize,
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
let url = self.build_url_with_version(path, version);
|
||||
let request = self.client.post(&url).json(data);
|
||||
let request = self.add_auth_header(request).await;
|
||||
|
||||
let response = self.send_with_retry(request).await?;
|
||||
let result: R = response.json().await?;
|
||||
|
||||
// Invalidate related cache entries
|
||||
self.invalidate_cache_prefix(&format!("GET:{}", path.split('?').next().unwrap_or(path))).await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) async fn post_api<T, R>(&self, path: &str, data: &T) -> Result<R>
|
||||
where
|
||||
T: Serialize,
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
self.post_api_with_version(path, data, ApiVersion::V1).await
|
||||
}
|
||||
|
||||
pub(crate) async fn post_api_with_version<T, R>(&self, path: &str, data: &T, version: ApiVersion) -> Result<R>
|
||||
where
|
||||
T: Serialize,
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
let response: ApiResponse<R> = self.post_with_version(path, data, version).await?;
|
||||
|
||||
if response.success {
|
||||
response.data.ok_or_else(|| {
|
||||
ChurchApiError::Api("API returned success but no data".to_string())
|
||||
})
|
||||
} else {
|
||||
Err(ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Unknown API error".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn put<T, R>(&self, path: &str, data: &T) -> Result<R>
|
||||
where
|
||||
T: Serialize,
|
||||
R: DeserializeOwned,
|
||||
{
|
||||
let url = self.build_url(path);
|
||||
let request = self.client.put(&url).json(data);
|
||||
let request = self.add_auth_header(request).await;
|
||||
|
||||
let response = self.send_with_retry(request).await?;
|
||||
let result: R = response.json().await?;
|
||||
|
||||
// Invalidate related cache entries
|
||||
self.invalidate_cache_prefix(&format!("GET:{}", path.split('?').next().unwrap_or(path))).await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) async fn put_api<T>(&self, path: &str, data: &T) -> Result<()>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let response: ApiResponse<()> = self.put(path, data).await?;
|
||||
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Unknown API error".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn delete(&self, path: &str) -> Result<()> {
|
||||
let url = self.build_url(path);
|
||||
let request = self.client.delete(&url);
|
||||
let request = self.add_auth_header(request).await;
|
||||
|
||||
let response = self.send_with_retry(request).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(ChurchApiError::Http(
|
||||
reqwest::Error::from(response.error_for_status().unwrap_err())
|
||||
));
|
||||
}
|
||||
|
||||
// Invalidate related cache entries
|
||||
self.invalidate_cache_prefix(&format!("GET:{}", path.split('?').next().unwrap_or(path))).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_api(&self, path: &str) -> Result<()> {
|
||||
let response: ApiResponse<()> = {
|
||||
let url = self.build_url(path);
|
||||
let request = self.client.delete(&url);
|
||||
let request = self.add_auth_header(request).await;
|
||||
|
||||
let response = self.send_with_retry(request).await?;
|
||||
response.json().await?
|
||||
};
|
||||
|
||||
if response.success {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ChurchApiError::Api(
|
||||
response.error
|
||||
.or(response.message)
|
||||
.unwrap_or_else(|| "Unknown API error".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_with_retry(&self, request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
|
||||
let mut attempts = 0;
|
||||
let max_attempts = self.config.retry_attempts;
|
||||
|
||||
loop {
|
||||
attempts += 1;
|
||||
|
||||
// Clone the request for potential retry
|
||||
let cloned_request = request.try_clone()
|
||||
.ok_or_else(|| ChurchApiError::Internal("Failed to clone request".to_string()))?;
|
||||
|
||||
match cloned_request.send().await {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
return Ok(response);
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Err(ChurchApiError::Auth("Unauthorized".to_string()));
|
||||
} else if status == reqwest::StatusCode::FORBIDDEN {
|
||||
return Err(ChurchApiError::PermissionDenied);
|
||||
} else if status == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(ChurchApiError::NotFound);
|
||||
} else if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
if attempts < max_attempts {
|
||||
// Exponential backoff for rate limiting
|
||||
let delay = std::time::Duration::from_millis(100 * 2_u64.pow(attempts - 1));
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
} else {
|
||||
return Err(ChurchApiError::RateLimit);
|
||||
}
|
||||
} else if status.is_server_error() && attempts < max_attempts {
|
||||
// Retry on server errors
|
||||
let delay = std::time::Duration::from_millis(500 * attempts as u64);
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
} else {
|
||||
return Err(ChurchApiError::Http(
|
||||
reqwest::Error::from(response.error_for_status().unwrap_err())
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if attempts < max_attempts && (e.is_timeout() || e.is_connect()) {
|
||||
// Retry on timeout and connection errors
|
||||
let delay = std::time::Duration::from_millis(500 * attempts as u64);
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
} else {
|
||||
return Err(ChurchApiError::Http(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn invalidate_cache_prefix(&self, prefix: &str) {
|
||||
self.cache.invalidate_prefix(prefix).await;
|
||||
}
|
||||
|
||||
pub(crate) fn build_query_string(&self, params: &[(&str, &str)]) -> String {
|
||||
if params.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let query: Vec<String> = params
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{}={}", urlencoding::encode(key), urlencoding::encode(value)))
|
||||
.collect();
|
||||
|
||||
format!("?{}", query.join("&"))
|
||||
}
|
||||
|
||||
pub(crate) async fn upload_file(&self, path: &str, file_data: Vec<u8>, filename: String, field_name: String) -> Result<String> {
|
||||
let url = self.build_url(path);
|
||||
|
||||
let part = reqwest::multipart::Part::bytes(file_data)
|
||||
.file_name(filename)
|
||||
.mime_str("application/octet-stream")
|
||||
.map_err(|e| ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part(field_name, part);
|
||||
|
||||
let request = self.client.post(&url).multipart(form);
|
||||
let request = self.add_auth_header(request).await;
|
||||
|
||||
let response = self.send_with_retry(request).await?;
|
||||
let result: ApiResponse<String> = response.json().await?;
|
||||
|
||||
if result.success {
|
||||
result.data.ok_or_else(|| {
|
||||
ChurchApiError::Api("File upload succeeded but no URL returned".to_string())
|
||||
})
|
||||
} else {
|
||||
Err(ChurchApiError::Api(
|
||||
result.error
|
||||
.or(result.message)
|
||||
.unwrap_or_else(|| "File upload failed".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch an image with HTTP caching support
|
||||
pub async fn get_cached_image(&self, url: &str) -> Result<CachedHttpResponse> {
|
||||
// Check cache first
|
||||
if let Some(cached) = self.cache.get_http_response(url).await {
|
||||
println!("📸 Cache HIT for image: {}", url);
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
println!("📸 Cache MISS for image: {}", url);
|
||||
|
||||
// Make HTTP request
|
||||
let request = self.client.get(url);
|
||||
let response = self.send_with_retry(request).await?;
|
||||
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(ChurchApiError::Http(
|
||||
reqwest::Error::from(response.error_for_status().unwrap_err())
|
||||
));
|
||||
}
|
||||
|
||||
// Extract headers we care about
|
||||
let mut header_map = HashMap::new();
|
||||
for (name, value) in headers.iter() {
|
||||
if let Ok(value_str) = value.to_str() {
|
||||
header_map.insert(name.to_string(), value_str.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
// Get response body
|
||||
let data = response.bytes().await?.to_vec();
|
||||
|
||||
// Determine cache TTL based on content type
|
||||
let ttl = if content_type.starts_with("image/") {
|
||||
Duration::from_secs(24 * 60 * 60) // 24 hours for images
|
||||
} else {
|
||||
Duration::from_secs(5 * 60) // 5 minutes for other content
|
||||
};
|
||||
|
||||
// Create cached response
|
||||
let cached_response = CachedHttpResponse::new(
|
||||
data,
|
||||
content_type,
|
||||
header_map,
|
||||
status.as_u16(),
|
||||
ttl,
|
||||
);
|
||||
|
||||
// Store in cache
|
||||
self.cache.set_http_response(url, cached_response.clone()).await;
|
||||
|
||||
Ok(cached_response)
|
||||
}
|
||||
}
|
35
src/client/livestream.rs
Normal file
35
src/client/livestream.rs
Normal file
|
@ -0,0 +1,35 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamStatus {
|
||||
pub is_live: bool,
|
||||
pub last_connect_time: Option<DateTime<Utc>>,
|
||||
pub last_disconnect_time: Option<DateTime<Utc>>,
|
||||
pub stream_title: Option<String>,
|
||||
pub stream_url: Option<String>,
|
||||
pub viewer_count: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LiveStream {
|
||||
pub last_connect_time: Option<DateTime<Utc>>,
|
||||
pub last_disconnect_time: Option<DateTime<Utc>>,
|
||||
pub viewer_count: Option<u32>,
|
||||
pub stream_title: Option<String>,
|
||||
pub is_live: bool,
|
||||
}
|
||||
|
||||
/// Get current stream status from Owncast
|
||||
pub async fn get_stream_status(client: &ChurchApiClient) -> Result<StreamStatus> {
|
||||
client.get("/stream/status").await
|
||||
}
|
||||
|
||||
/// Get live stream info from Owncast
|
||||
pub async fn get_live_stream(client: &ChurchApiClient) -> Result<LiveStream> {
|
||||
client.get("/stream/live").await
|
||||
}
|
412
src/client/mod.rs
Normal file
412
src/client/mod.rs
Normal file
|
@ -0,0 +1,412 @@
|
|||
pub mod http;
|
||||
pub mod events;
|
||||
pub mod bulletins;
|
||||
pub mod config;
|
||||
pub mod contact;
|
||||
pub mod sermons;
|
||||
pub mod bible;
|
||||
pub mod admin;
|
||||
pub mod uploads;
|
||||
pub mod livestream;
|
||||
|
||||
use crate::{
|
||||
cache::MemoryCache,
|
||||
config::ChurchCoreConfig,
|
||||
error::Result,
|
||||
models::*,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub struct ChurchApiClient {
|
||||
pub(crate) client: reqwest::Client,
|
||||
pub(crate) config: ChurchCoreConfig,
|
||||
pub(crate) auth_token: Arc<RwLock<Option<AuthToken>>>,
|
||||
pub(crate) cache: Arc<MemoryCache>,
|
||||
}
|
||||
|
||||
impl ChurchApiClient {
|
||||
pub fn new(config: ChurchCoreConfig) -> Result<Self> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(config.timeout)
|
||||
.connect_timeout(config.connect_timeout)
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(90))
|
||||
.user_agent(&config.user_agent)
|
||||
.build()?;
|
||||
|
||||
let cache = Arc::new(MemoryCache::new(config.max_cache_size));
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
config,
|
||||
auth_token: Arc::new(RwLock::new(None)),
|
||||
cache,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_cache(mut self, cache: Arc<MemoryCache>) -> Self {
|
||||
self.cache = cache;
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn set_auth_token(&self, token: AuthToken) {
|
||||
let mut auth = self.auth_token.write().await;
|
||||
*auth = Some(token);
|
||||
}
|
||||
|
||||
pub async fn clear_auth_token(&self) {
|
||||
let mut auth = self.auth_token.write().await;
|
||||
*auth = None;
|
||||
}
|
||||
|
||||
pub async fn get_auth_token(&self) -> Option<AuthToken> {
|
||||
let auth = self.auth_token.read().await;
|
||||
auth.clone()
|
||||
}
|
||||
|
||||
pub async fn is_authenticated(&self) -> bool {
|
||||
if let Some(token) = self.get_auth_token().await {
|
||||
token.is_valid()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_url(&self, path: &str) -> String {
|
||||
self.build_url_with_version(path, crate::models::ApiVersion::V1)
|
||||
}
|
||||
|
||||
pub(crate) fn build_url_with_version(&self, path: &str, version: crate::models::ApiVersion) -> String {
|
||||
if path.starts_with("http") {
|
||||
path.to_string()
|
||||
} else {
|
||||
let base = self.config.api_base_url.trim_end_matches('/');
|
||||
let path = path.trim_start_matches('/');
|
||||
let version_prefix = version.path_prefix();
|
||||
|
||||
if base.ends_with("/api") {
|
||||
format!("{}/{}{}", base, version_prefix, path)
|
||||
} else {
|
||||
format!("{}/api/{}{}", base, version_prefix, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn add_auth_header(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some(token) = self.get_auth_token().await {
|
||||
if token.is_valid() {
|
||||
return builder.header("Authorization", format!("{} {}", token.token_type, token.token));
|
||||
}
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
// Event operations
|
||||
pub async fn get_upcoming_events(&self, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
events::get_upcoming_events(self, limit).await
|
||||
}
|
||||
|
||||
pub async fn get_events(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
|
||||
events::get_events(self, params).await
|
||||
}
|
||||
|
||||
pub async fn get_event(&self, id: &str) -> Result<Option<Event>> {
|
||||
events::get_event(self, id).await
|
||||
}
|
||||
|
||||
pub async fn create_event(&self, event: NewEvent) -> Result<String> {
|
||||
events::create_event(self, event).await
|
||||
}
|
||||
|
||||
pub async fn update_event(&self, id: &str, update: EventUpdate) -> Result<()> {
|
||||
events::update_event(self, id, update).await
|
||||
}
|
||||
|
||||
pub async fn delete_event(&self, id: &str) -> Result<()> {
|
||||
events::delete_event(self, id).await
|
||||
}
|
||||
|
||||
// Bulletin operations
|
||||
pub async fn get_bulletins(&self, active_only: bool) -> Result<Vec<Bulletin>> {
|
||||
bulletins::get_bulletins(self, active_only).await
|
||||
}
|
||||
|
||||
pub async fn get_current_bulletin(&self) -> Result<Option<Bulletin>> {
|
||||
bulletins::get_current_bulletin(self).await
|
||||
}
|
||||
|
||||
pub async fn get_next_bulletin(&self) -> Result<Option<Bulletin>> {
|
||||
bulletins::get_next_bulletin(self).await
|
||||
}
|
||||
|
||||
pub async fn get_bulletin(&self, id: &str) -> Result<Option<Bulletin>> {
|
||||
bulletins::get_bulletin(self, id).await
|
||||
}
|
||||
|
||||
pub async fn create_bulletin(&self, bulletin: NewBulletin) -> Result<String> {
|
||||
bulletins::create_bulletin(self, bulletin).await
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
pub async fn get_bulletins_v2(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Bulletin>> {
|
||||
bulletins::get_bulletins_v2(self, params).await
|
||||
}
|
||||
|
||||
pub async fn get_current_bulletin_v2(&self) -> Result<Option<Bulletin>> {
|
||||
bulletins::get_current_bulletin_v2(self).await
|
||||
}
|
||||
|
||||
pub async fn get_next_bulletin_v2(&self) -> Result<Option<Bulletin>> {
|
||||
bulletins::get_next_bulletin_v2(self).await
|
||||
}
|
||||
|
||||
// Configuration
|
||||
pub async fn get_config(&self) -> Result<ChurchConfig> {
|
||||
config::get_config(self).await
|
||||
}
|
||||
|
||||
pub async fn get_config_by_id(&self, record_id: &str) -> Result<ChurchConfig> {
|
||||
config::get_config_by_id(self, record_id).await
|
||||
}
|
||||
|
||||
pub async fn update_config(&self, config: ChurchConfig) -> Result<()> {
|
||||
config::update_config(self, config).await
|
||||
}
|
||||
|
||||
// Contact operations
|
||||
pub async fn submit_contact_form(&self, form: ContactForm) -> Result<String> {
|
||||
contact::submit_contact_form(self, form).await
|
||||
}
|
||||
|
||||
pub async fn get_contact_submissions(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<ContactSubmission>> {
|
||||
contact::get_contact_submissions(self, params).await
|
||||
}
|
||||
|
||||
pub async fn get_contact_submission(&self, id: &str) -> Result<Option<ContactSubmission>> {
|
||||
contact::get_contact_submission(self, id).await
|
||||
}
|
||||
|
||||
pub async fn update_contact_submission(&self, id: &str, status: ContactStatus, response: Option<String>) -> Result<()> {
|
||||
contact::update_contact_submission(self, id, status, response).await
|
||||
}
|
||||
|
||||
// Sermon operations
|
||||
pub async fn get_sermons(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Sermon>> {
|
||||
sermons::get_sermons(self, params).await
|
||||
}
|
||||
|
||||
pub async fn search_sermons(&self, search: SermonSearch, params: Option<PaginationParams>) -> Result<ApiListResponse<Sermon>> {
|
||||
sermons::search_sermons(self, search, params).await
|
||||
}
|
||||
|
||||
pub async fn get_sermon(&self, id: &str) -> Result<Option<Sermon>> {
|
||||
sermons::get_sermon(self, id).await
|
||||
}
|
||||
|
||||
pub async fn get_featured_sermons(&self, limit: Option<u32>) -> Result<Vec<Sermon>> {
|
||||
sermons::get_featured_sermons(self, limit).await
|
||||
}
|
||||
|
||||
pub async fn get_recent_sermons(&self, limit: Option<u32>) -> Result<Vec<Sermon>> {
|
||||
sermons::get_recent_sermons(self, limit).await
|
||||
}
|
||||
|
||||
pub async fn create_sermon(&self, sermon: NewSermon) -> Result<String> {
|
||||
sermons::create_sermon(self, sermon).await
|
||||
}
|
||||
|
||||
// Bible verse operations
|
||||
pub async fn get_random_verse(&self) -> Result<BibleVerse> {
|
||||
bible::get_random_verse(self).await
|
||||
}
|
||||
|
||||
pub async fn get_verse_of_the_day(&self) -> Result<VerseOfTheDay> {
|
||||
bible::get_verse_of_the_day(self).await
|
||||
}
|
||||
|
||||
pub async fn get_verse_by_reference(&self, reference: &str) -> Result<Option<BibleVerse>> {
|
||||
bible::get_verse_by_reference(self, reference).await
|
||||
}
|
||||
|
||||
pub async fn get_verses_by_category(&self, category: VerseCategory, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
|
||||
bible::get_verses_by_category(self, category, limit).await
|
||||
}
|
||||
|
||||
pub async fn search_verses(&self, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
|
||||
bible::search_verses(self, query, limit).await
|
||||
}
|
||||
|
||||
// V2 API methods
|
||||
|
||||
// Events V2
|
||||
pub async fn get_events_v2(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<Event>> {
|
||||
events::get_events_v2(self, params).await
|
||||
}
|
||||
|
||||
pub async fn get_upcoming_events_v2(&self, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
events::get_upcoming_events_v2(self, limit).await
|
||||
}
|
||||
|
||||
pub async fn get_featured_events_v2(&self, limit: Option<u32>) -> Result<Vec<Event>> {
|
||||
events::get_featured_events_v2(self, limit).await
|
||||
}
|
||||
|
||||
pub async fn get_event_v2(&self, id: &str) -> Result<Option<Event>> {
|
||||
events::get_event_v2(self, id).await
|
||||
}
|
||||
|
||||
pub async fn submit_event(&self, submission: EventSubmission) -> Result<String> {
|
||||
events::submit_event(self, submission).await
|
||||
}
|
||||
|
||||
// Bible V2
|
||||
pub async fn get_random_verse_v2(&self) -> Result<BibleVerse> {
|
||||
bible::get_random_verse_v2(self).await
|
||||
}
|
||||
|
||||
pub async fn get_bible_verses_v2(&self, params: Option<PaginationParams>) -> Result<ApiListResponse<BibleVerse>> {
|
||||
bible::get_bible_verses_v2(self, params).await
|
||||
}
|
||||
|
||||
pub async fn search_verses_v2(&self, query: &str, limit: Option<u32>) -> Result<Vec<BibleVerse>> {
|
||||
bible::search_verses_v2(self, query, limit).await
|
||||
}
|
||||
|
||||
// Contact V2
|
||||
pub async fn submit_contact_form_v2(&self, form: ContactForm) -> Result<String> {
|
||||
contact::submit_contact_form_v2(self, form).await
|
||||
}
|
||||
|
||||
// Config and Schedule V2
|
||||
pub async fn get_config_v2(&self) -> Result<ChurchConfig> {
|
||||
config::get_config_v2(self).await
|
||||
}
|
||||
|
||||
pub async fn get_schedule(&self, date: Option<&str>) -> Result<Schedule> {
|
||||
config::get_schedule(self, date).await
|
||||
}
|
||||
|
||||
pub async fn get_schedule_v2(&self, date: Option<&str>) -> Result<Schedule> {
|
||||
config::get_schedule_v2(self, date).await
|
||||
}
|
||||
|
||||
pub async fn get_conference_data(&self) -> Result<ConferenceData> {
|
||||
config::get_conference_data(self).await
|
||||
}
|
||||
|
||||
pub async fn get_conference_data_v2(&self) -> Result<ConferenceData> {
|
||||
config::get_conference_data_v2(self).await
|
||||
}
|
||||
|
||||
pub async fn get_livestreams(&self) -> Result<Vec<Sermon>> {
|
||||
sermons::get_livestreams(self).await
|
||||
}
|
||||
|
||||
// Owncast Live Streaming
|
||||
pub async fn get_stream_status(&self) -> Result<livestream::StreamStatus> {
|
||||
livestream::get_stream_status(self).await
|
||||
}
|
||||
|
||||
pub async fn get_live_stream(&self) -> Result<livestream::LiveStream> {
|
||||
livestream::get_live_stream(self).await
|
||||
}
|
||||
|
||||
// Admin operations
|
||||
|
||||
// Admin Bulletins
|
||||
pub async fn create_admin_bulletin(&self, bulletin: NewBulletin) -> Result<String> {
|
||||
admin::create_bulletin(self, bulletin).await
|
||||
}
|
||||
|
||||
pub async fn update_admin_bulletin(&self, id: &str, update: BulletinUpdate) -> Result<()> {
|
||||
admin::update_bulletin(self, id, update).await
|
||||
}
|
||||
|
||||
pub async fn delete_admin_bulletin(&self, id: &str) -> Result<()> {
|
||||
admin::delete_bulletin(self, id).await
|
||||
}
|
||||
|
||||
// Admin Events
|
||||
pub async fn create_admin_event(&self, event: NewEvent) -> Result<String> {
|
||||
admin::create_admin_event(self, event).await
|
||||
}
|
||||
|
||||
pub async fn update_admin_event(&self, id: &str, update: EventUpdate) -> Result<()> {
|
||||
admin::update_admin_event(self, id, update).await
|
||||
}
|
||||
|
||||
pub async fn delete_admin_event(&self, id: &str) -> Result<()> {
|
||||
admin::delete_admin_event(self, id).await
|
||||
}
|
||||
|
||||
// Admin Pending Events
|
||||
pub async fn get_pending_events(&self) -> Result<Vec<PendingEvent>> {
|
||||
admin::get_pending_events(self).await
|
||||
}
|
||||
|
||||
pub async fn approve_pending_event(&self, id: &str) -> Result<()> {
|
||||
admin::approve_pending_event(self, id).await
|
||||
}
|
||||
|
||||
pub async fn reject_pending_event(&self, id: &str) -> Result<()> {
|
||||
admin::reject_pending_event(self, id).await
|
||||
}
|
||||
|
||||
pub async fn delete_pending_event(&self, id: &str) -> Result<()> {
|
||||
admin::delete_pending_event(self, id).await
|
||||
}
|
||||
|
||||
// Admin Users
|
||||
pub async fn get_admin_users(&self) -> Result<Vec<User>> {
|
||||
admin::get_users(self).await
|
||||
}
|
||||
|
||||
// Admin Schedule
|
||||
pub async fn create_admin_schedule(&self, schedule: NewSchedule) -> Result<String> {
|
||||
admin::create_schedule(self, schedule).await
|
||||
}
|
||||
|
||||
pub async fn update_admin_schedule(&self, date: &str, update: ScheduleUpdate) -> Result<()> {
|
||||
admin::update_schedule(self, date, update).await
|
||||
}
|
||||
|
||||
pub async fn delete_admin_schedule(&self, date: &str) -> Result<()> {
|
||||
admin::delete_schedule(self, date).await
|
||||
}
|
||||
|
||||
pub async fn get_all_admin_schedules(&self) -> Result<Vec<Schedule>> {
|
||||
admin::get_all_schedules(self).await
|
||||
}
|
||||
|
||||
pub async fn get_admin_config(&self) -> Result<ChurchConfig> {
|
||||
admin::get_admin_config(self).await
|
||||
}
|
||||
|
||||
// File Upload operations
|
||||
pub async fn upload_bulletin_pdf(&self, bulletin_id: &str, file_data: Vec<u8>, filename: String) -> Result<UploadResponse> {
|
||||
uploads::upload_bulletin_pdf(self, bulletin_id, file_data, filename).await
|
||||
}
|
||||
|
||||
pub async fn upload_bulletin_cover(&self, bulletin_id: &str, file_data: Vec<u8>, filename: String) -> Result<UploadResponse> {
|
||||
uploads::upload_bulletin_cover(self, bulletin_id, file_data, filename).await
|
||||
}
|
||||
|
||||
pub async fn upload_event_image(&self, event_id: &str, file_data: Vec<u8>, filename: String) -> Result<UploadResponse> {
|
||||
uploads::upload_event_image(self, event_id, file_data, filename).await
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
pub async fn health_check(&self) -> Result<bool> {
|
||||
let url = self.build_url("/health");
|
||||
let response = self.client.get(&url).send().await?;
|
||||
Ok(response.status().is_success())
|
||||
}
|
||||
|
||||
pub async fn clear_cache(&self) {
|
||||
self.cache.clear().await;
|
||||
}
|
||||
|
||||
pub async fn get_cache_stats(&self) -> (usize, usize) {
|
||||
(self.cache.len().await, self.config.max_cache_size)
|
||||
}
|
||||
}
|
237
src/client/sermons.rs
Normal file
237
src/client/sermons.rs
Normal file
|
@ -0,0 +1,237 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
models::{Sermon, ApiSermon, NewSermon, SermonSearch, PaginationParams, ApiListResponse, DeviceCapabilities},
|
||||
};
|
||||
|
||||
pub async fn get_sermons(client: &ChurchApiClient, params: Option<PaginationParams>) -> Result<ApiListResponse<Sermon>> {
|
||||
let mut path = "/sermons".to_string();
|
||||
|
||||
if let Some(params) = params {
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
if let Some(page) = params.page {
|
||||
query_params.push(("page", page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(per_page) = params.per_page {
|
||||
query_params.push(("per_page", per_page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(sort) = ¶ms.sort {
|
||||
query_params.push(("sort", sort.clone()));
|
||||
}
|
||||
|
||||
if let Some(filter) = ¶ms.filter {
|
||||
query_params.push(("filter", filter.clone()));
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
}
|
||||
|
||||
client.get_api_list(&path).await
|
||||
}
|
||||
|
||||
pub async fn search_sermons(
|
||||
client: &ChurchApiClient,
|
||||
search: SermonSearch,
|
||||
params: Option<PaginationParams>
|
||||
) -> Result<ApiListResponse<Sermon>> {
|
||||
let mut path = "/sermons/search".to_string();
|
||||
let mut query_params = Vec::new();
|
||||
|
||||
if let Some(query) = &search.query {
|
||||
query_params.push(("q", query.clone()));
|
||||
}
|
||||
|
||||
if let Some(speaker) = &search.speaker {
|
||||
query_params.push(("speaker", speaker.clone()));
|
||||
}
|
||||
|
||||
if let Some(category) = &search.category {
|
||||
query_params.push(("category", format!("{:?}", category).to_lowercase()));
|
||||
}
|
||||
|
||||
if let Some(series) = &search.series {
|
||||
query_params.push(("series", series.clone()));
|
||||
}
|
||||
|
||||
if let Some(featured_only) = search.featured_only {
|
||||
if featured_only {
|
||||
query_params.push(("featured", "true".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(has_video) = search.has_video {
|
||||
if has_video {
|
||||
query_params.push(("has_video", "true".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(has_audio) = search.has_audio {
|
||||
if has_audio {
|
||||
query_params.push(("has_audio", "true".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(params) = params {
|
||||
if let Some(page) = params.page {
|
||||
query_params.push(("page", page.to_string()));
|
||||
}
|
||||
|
||||
if let Some(per_page) = params.per_page {
|
||||
query_params.push(("per_page", per_page.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if !query_params.is_empty() {
|
||||
let query_string = query_params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
path.push_str(&format!("?{}", query_string));
|
||||
}
|
||||
|
||||
client.get_api_list(&path).await
|
||||
}
|
||||
|
||||
pub async fn get_sermon(client: &ChurchApiClient, id: &str) -> Result<Option<Sermon>> {
|
||||
let path = format!("/sermons/{}", id);
|
||||
|
||||
match client.get_api(&path).await {
|
||||
Ok(sermon) => Ok(Some(sermon)),
|
||||
Err(crate::error::ChurchApiError::NotFound) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_featured_sermons(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Sermon>> {
|
||||
let mut path = "/sermons/featured".to_string();
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
client.get_api(&path).await
|
||||
}
|
||||
|
||||
// Helper function to convert seconds to human readable duration
|
||||
fn format_duration_seconds(seconds: u32) -> String {
|
||||
let hours = seconds / 3600;
|
||||
let minutes = (seconds % 3600) / 60;
|
||||
let remaining_seconds = seconds % 60;
|
||||
|
||||
if hours > 0 {
|
||||
format!("{}:{:02}:{:02}", hours, minutes, remaining_seconds)
|
||||
} else {
|
||||
format!("{}:{:02}", minutes, remaining_seconds)
|
||||
}
|
||||
}
|
||||
|
||||
// Shared function to convert API sermon/livestream data to Sermon model
|
||||
fn convert_api_sermon_to_sermon(api_sermon: ApiSermon, category: crate::models::sermon::SermonCategory) -> Sermon {
|
||||
// Parse date string to DateTime if available
|
||||
let date = if let Some(date_str) = &api_sermon.date {
|
||||
chrono::DateTime::parse_from_str(&format!("{} 00:00:00 +0000", date_str), "%Y-%m-%d %H:%M:%S %z")
|
||||
.unwrap_or_else(|_| chrono::Utc::now().into())
|
||||
.with_timezone(&chrono::Utc)
|
||||
} else {
|
||||
chrono::Utc::now()
|
||||
};
|
||||
|
||||
// Duration is already in string format from the API, so use it directly
|
||||
let duration_string = Some(api_sermon.duration.clone());
|
||||
|
||||
// Generate optimal streaming URL for the device
|
||||
let media_url = if !api_sermon.id.is_empty() {
|
||||
let base_url = "https://api.rockvilletollandsda.church"; // TODO: Get from config
|
||||
let streaming_url = DeviceCapabilities::get_optimal_streaming_url(base_url, &api_sermon.id);
|
||||
Some(streaming_url.url)
|
||||
} else {
|
||||
api_sermon.video_url.clone()
|
||||
};
|
||||
|
||||
Sermon {
|
||||
id: api_sermon.id.clone(),
|
||||
title: api_sermon.title,
|
||||
speaker: api_sermon.speaker.unwrap_or("Unknown".to_string()),
|
||||
description: api_sermon.description.unwrap_or_default(),
|
||||
date,
|
||||
scripture_reference: api_sermon.scripture_reading.unwrap_or_default(),
|
||||
series: None,
|
||||
duration_string,
|
||||
media_url,
|
||||
audio_url: api_sermon.audio_url,
|
||||
video_url: api_sermon.video_url,
|
||||
transcript: None,
|
||||
thumbnail: api_sermon.thumbnail,
|
||||
tags: None,
|
||||
category,
|
||||
is_featured: false,
|
||||
view_count: 0,
|
||||
download_count: 0,
|
||||
created_at: date,
|
||||
updated_at: date,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_recent_sermons(client: &ChurchApiClient, limit: Option<u32>) -> Result<Vec<Sermon>> {
|
||||
let mut path = "/sermons".to_string();
|
||||
|
||||
if let Some(limit) = limit {
|
||||
path.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
|
||||
// The new API returns a wrapper with "sermons" array
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct SermonsResponse {
|
||||
success: bool,
|
||||
data: Vec<ApiSermon>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
let response: SermonsResponse = client.get(&path).await?;
|
||||
|
||||
// Convert using shared logic
|
||||
let sermons = response.data.into_iter()
|
||||
.map(|api_sermon| convert_api_sermon_to_sermon(api_sermon, crate::models::sermon::SermonCategory::Regular))
|
||||
.collect();
|
||||
|
||||
Ok(sermons)
|
||||
}
|
||||
|
||||
pub async fn create_sermon(client: &ChurchApiClient, sermon: NewSermon) -> Result<String> {
|
||||
client.post_api("/sermons", &sermon).await
|
||||
}
|
||||
|
||||
// Livestreams endpoint - reuses ApiSermon since format is identical
|
||||
|
||||
pub async fn get_livestreams(client: &ChurchApiClient) -> Result<Vec<Sermon>> {
|
||||
// Use the new API endpoint for livestreams
|
||||
let path = "/livestreams";
|
||||
|
||||
// The new API returns a wrapper with "data" array (same format as sermons endpoint)
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct LivestreamsResponse {
|
||||
success: bool,
|
||||
data: Vec<ApiSermon>,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
let response: LivestreamsResponse = client.get(path).await?;
|
||||
|
||||
// Convert using shared logic - same as regular sermons but different category
|
||||
let sermons = response.data.into_iter()
|
||||
.map(|api_sermon| convert_api_sermon_to_sermon(api_sermon, crate::models::sermon::SermonCategory::LivestreamArchive))
|
||||
.collect();
|
||||
|
||||
Ok(sermons)
|
||||
}
|
119
src/client/uploads.rs
Normal file
119
src/client/uploads.rs
Normal file
|
@ -0,0 +1,119 @@
|
|||
use crate::{
|
||||
client::ChurchApiClient,
|
||||
error::Result,
|
||||
models::UploadResponse,
|
||||
};
|
||||
|
||||
/// Upload PDF file for a bulletin
|
||||
pub async fn upload_bulletin_pdf(
|
||||
client: &ChurchApiClient,
|
||||
bulletin_id: &str,
|
||||
file_data: Vec<u8>,
|
||||
filename: String,
|
||||
) -> Result<UploadResponse> {
|
||||
let path = format!("/upload/bulletins/{}/pdf", bulletin_id);
|
||||
|
||||
let url = client.build_url(&path);
|
||||
|
||||
let part = reqwest::multipart::Part::bytes(file_data)
|
||||
.file_name(filename)
|
||||
.mime_str("application/pdf")
|
||||
.map_err(|e| crate::error::ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("file", part);
|
||||
|
||||
let request = client.client.post(&url).multipart(form);
|
||||
let request = client.add_auth_header(request).await;
|
||||
|
||||
let response = request.send().await?;
|
||||
let result: crate::models::ApiResponse<UploadResponse> = response.json().await?;
|
||||
|
||||
if result.success {
|
||||
result.data.ok_or_else(|| {
|
||||
crate::error::ChurchApiError::Api("File upload succeeded but no response returned".to_string())
|
||||
})
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api(
|
||||
result.error
|
||||
.or(result.message)
|
||||
.unwrap_or_else(|| "File upload failed".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload cover image for a bulletin
|
||||
pub async fn upload_bulletin_cover(
|
||||
client: &ChurchApiClient,
|
||||
bulletin_id: &str,
|
||||
file_data: Vec<u8>,
|
||||
filename: String,
|
||||
) -> Result<UploadResponse> {
|
||||
let path = format!("/upload/bulletins/{}/cover", bulletin_id);
|
||||
|
||||
let url = client.build_url(&path);
|
||||
|
||||
let part = reqwest::multipart::Part::bytes(file_data)
|
||||
.file_name(filename)
|
||||
.mime_str("image/jpeg")
|
||||
.map_err(|e| crate::error::ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("file", part);
|
||||
|
||||
let request = client.client.post(&url).multipart(form);
|
||||
let request = client.add_auth_header(request).await;
|
||||
|
||||
let response = request.send().await?;
|
||||
let result: crate::models::ApiResponse<UploadResponse> = response.json().await?;
|
||||
|
||||
if result.success {
|
||||
result.data.ok_or_else(|| {
|
||||
crate::error::ChurchApiError::Api("File upload succeeded but no response returned".to_string())
|
||||
})
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api(
|
||||
result.error
|
||||
.or(result.message)
|
||||
.unwrap_or_else(|| "File upload failed".to_string())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload image for an event
|
||||
pub async fn upload_event_image(
|
||||
client: &ChurchApiClient,
|
||||
event_id: &str,
|
||||
file_data: Vec<u8>,
|
||||
filename: String,
|
||||
) -> Result<UploadResponse> {
|
||||
let path = format!("/upload/events/{}/image", event_id);
|
||||
|
||||
let url = client.build_url(&path);
|
||||
|
||||
let part = reqwest::multipart::Part::bytes(file_data)
|
||||
.file_name(filename)
|
||||
.mime_str("image/jpeg")
|
||||
.map_err(|e| crate::error::ChurchApiError::Internal(format!("Failed to create multipart: {}", e)))?;
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("file", part);
|
||||
|
||||
let request = client.client.post(&url).multipart(form);
|
||||
let request = client.add_auth_header(request).await;
|
||||
|
||||
let response = request.send().await?;
|
||||
let result: crate::models::ApiResponse<UploadResponse> = response.json().await?;
|
||||
|
||||
if result.success {
|
||||
result.data.ok_or_else(|| {
|
||||
crate::error::ChurchApiError::Api("File upload succeeded but no response returned".to_string())
|
||||
})
|
||||
} else {
|
||||
Err(crate::error::ChurchApiError::Api(
|
||||
result.error
|
||||
.or(result.message)
|
||||
.unwrap_or_else(|| "File upload failed".to_string())
|
||||
))
|
||||
}
|
||||
}
|
69
src/config.rs
Normal file
69
src/config.rs
Normal file
|
@ -0,0 +1,69 @@
|
|||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChurchCoreConfig {
|
||||
pub api_base_url: String,
|
||||
pub cache_ttl: Duration,
|
||||
pub timeout: Duration,
|
||||
pub connect_timeout: Duration,
|
||||
pub retry_attempts: u32,
|
||||
pub enable_offline_mode: bool,
|
||||
pub max_cache_size: usize,
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
||||
impl Default for ChurchCoreConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
api_base_url: "https://api.rockvilletollandsda.church".to_string(),
|
||||
cache_ttl: Duration::from_secs(300), // 5 minutes
|
||||
timeout: Duration::from_secs(10),
|
||||
connect_timeout: Duration::from_secs(5),
|
||||
retry_attempts: 3,
|
||||
enable_offline_mode: true,
|
||||
max_cache_size: 1000,
|
||||
user_agent: format!("church-core/{}", env!("CARGO_PKG_VERSION")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChurchCoreConfig {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
|
||||
self.api_base_url = url.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_cache_ttl(mut self, ttl: Duration) -> Self {
|
||||
self.cache_ttl = ttl;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_retry_attempts(mut self, attempts: u32) -> Self {
|
||||
self.retry_attempts = attempts;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_offline_mode(mut self, enabled: bool) -> Self {
|
||||
self.enable_offline_mode = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_cache_size(mut self, size: usize) -> Self {
|
||||
self.max_cache_size = size;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_user_agent(mut self, agent: impl Into<String>) -> Self {
|
||||
self.user_agent = agent.into();
|
||||
self
|
||||
}
|
||||
}
|
62
src/error.rs
Normal file
62
src/error.rs
Normal file
|
@ -0,0 +1,62 @@
|
|||
use thiserror::Error;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ChurchApiError>;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ChurchApiError {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
Http(#[from] reqwest::Error),
|
||||
|
||||
#[error("JSON parsing failed: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("Date parsing failed: {0}")]
|
||||
DateParse(String),
|
||||
|
||||
#[error("API returned error: {0}")]
|
||||
Api(String),
|
||||
|
||||
#[error("Authentication failed: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("Cache error: {0}")]
|
||||
Cache(String),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("Network error: {0}")]
|
||||
Network(String),
|
||||
|
||||
#[error("Timeout error: operation took too long")]
|
||||
Timeout,
|
||||
|
||||
#[error("Rate limit exceeded")]
|
||||
RateLimit,
|
||||
|
||||
#[error("Resource not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("Permission denied")]
|
||||
PermissionDenied,
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl ChurchApiError {
|
||||
pub fn is_network_error(&self) -> bool {
|
||||
matches!(self, Self::Http(_) | Self::Network(_) | Self::Timeout)
|
||||
}
|
||||
|
||||
pub fn is_auth_error(&self) -> bool {
|
||||
matches!(self, Self::Auth(_) | Self::PermissionDenied)
|
||||
}
|
||||
|
||||
pub fn is_temporary(&self) -> bool {
|
||||
matches!(self, Self::Timeout | Self::RateLimit | Self::Network(_))
|
||||
}
|
||||
}
|
12
src/ffi.rs
Normal file
12
src/ffi.rs
Normal file
|
@ -0,0 +1,12 @@
|
|||
// FFI module for church-core
|
||||
// This module is only compiled when the ffi feature is enabled
|
||||
|
||||
use crate::{ChurchApiClient, ChurchCoreConfig, ChurchApiError};
|
||||
|
||||
// Re-export for UniFFI
|
||||
pub use crate::{
|
||||
models::*,
|
||||
ChurchApiClient,
|
||||
ChurchCoreConfig,
|
||||
ChurchApiError,
|
||||
};
|
24
src/lib.rs
Normal file
24
src/lib.rs
Normal file
|
@ -0,0 +1,24 @@
|
|||
pub mod client;
|
||||
pub mod models;
|
||||
pub mod auth;
|
||||
pub mod cache;
|
||||
pub mod utils;
|
||||
pub mod error;
|
||||
pub mod config;
|
||||
pub use client::ChurchApiClient;
|
||||
pub use config::ChurchCoreConfig;
|
||||
pub use error::{ChurchApiError, Result};
|
||||
pub use models::*;
|
||||
pub use cache::*;
|
||||
|
||||
#[cfg(feature = "wasm")]
|
||||
pub mod wasm;
|
||||
|
||||
#[cfg(feature = "uniffi")]
|
||||
pub mod uniffi_wrapper;
|
||||
|
||||
#[cfg(feature = "uniffi")]
|
||||
pub use uniffi_wrapper::*;
|
||||
|
||||
#[cfg(feature = "uniffi")]
|
||||
uniffi::include_scaffolding!("church_core");
|
92
src/models/admin.rs
Normal file
92
src/models/admin.rs
Normal file
|
@ -0,0 +1,92 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// User information for admin user management
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub role: AdminUserRole,
|
||||
pub is_active: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum AdminUserRole {
|
||||
#[serde(rename = "admin")]
|
||||
Admin,
|
||||
#[serde(rename = "moderator")]
|
||||
Moderator,
|
||||
#[serde(rename = "user")]
|
||||
User,
|
||||
}
|
||||
|
||||
/// Schedule data
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Schedule {
|
||||
pub date: String, // YYYY-MM-DD format
|
||||
pub sabbath_school: Option<String>,
|
||||
pub divine_worship: Option<String>,
|
||||
pub scripture_reading: Option<String>,
|
||||
pub sunset: Option<String>,
|
||||
pub special_notes: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Conference data
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ConferenceData {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub website: Option<String>,
|
||||
pub contact_info: Option<String>,
|
||||
pub leadership: Option<Vec<ConferenceLeader>>,
|
||||
pub announcements: Option<Vec<String>>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ConferenceLeader {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub email: Option<String>,
|
||||
pub phone: Option<String>,
|
||||
}
|
||||
|
||||
/// New schedule creation
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct NewSchedule {
|
||||
pub date: String, // YYYY-MM-DD format
|
||||
pub sabbath_school: Option<String>,
|
||||
pub divine_worship: Option<String>,
|
||||
pub scripture_reading: Option<String>,
|
||||
pub sunset: Option<String>,
|
||||
pub special_notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Schedule update
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ScheduleUpdate {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sabbath_school: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub divine_worship: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scripture_reading: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sunset: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub special_notes: Option<String>,
|
||||
}
|
||||
|
||||
/// File upload response
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UploadResponse {
|
||||
pub file_path: String,
|
||||
pub pdf_path: Option<String>, // Full URL to the uploaded file
|
||||
pub message: String,
|
||||
}
|
276
src/models/auth.rs
Normal file
276
src/models/auth.rs
Normal file
|
@ -0,0 +1,276 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AuthToken {
|
||||
pub token: String,
|
||||
pub token_type: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub user_id: Option<String>,
|
||||
pub user_name: Option<String>,
|
||||
pub user_email: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct LoginRequest {
|
||||
pub identity: String, // email or username
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub user: AuthUser,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub username: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub verified: bool,
|
||||
pub role: UserRole,
|
||||
pub permissions: Vec<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RefreshTokenRequest {
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RefreshTokenResponse {
|
||||
pub token: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PasswordResetRequest {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PasswordResetConfirm {
|
||||
pub token: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub current_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct RegisterRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub name: String,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct EmailVerificationRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum UserRole {
|
||||
#[serde(rename = "admin")]
|
||||
Admin,
|
||||
#[serde(rename = "pastor")]
|
||||
Pastor,
|
||||
#[serde(rename = "elder")]
|
||||
Elder,
|
||||
#[serde(rename = "deacon")]
|
||||
Deacon,
|
||||
#[serde(rename = "ministry_leader")]
|
||||
MinistryLeader,
|
||||
#[serde(rename = "member")]
|
||||
Member,
|
||||
#[serde(rename = "visitor")]
|
||||
Visitor,
|
||||
#[serde(rename = "guest")]
|
||||
Guest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PocketBaseAuthResponse {
|
||||
pub token: String,
|
||||
pub record: PocketBaseUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PocketBaseUser {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
pub username: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub verified: bool,
|
||||
pub created: DateTime<Utc>,
|
||||
pub updated: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
||||
|
||||
impl AuthToken {
|
||||
pub fn is_expired(&self) -> bool {
|
||||
Utc::now() > self.expires_at
|
||||
}
|
||||
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.is_expired() && !self.token.is_empty()
|
||||
}
|
||||
|
||||
pub fn expires_in_seconds(&self) -> i64 {
|
||||
(self.expires_at - Utc::now()).num_seconds().max(0)
|
||||
}
|
||||
|
||||
pub fn expires_in_minutes(&self) -> i64 {
|
||||
(self.expires_at - Utc::now()).num_minutes().max(0)
|
||||
}
|
||||
|
||||
pub fn has_permission(&self, permission: &str) -> bool {
|
||||
self.permissions.contains(&permission.to_string())
|
||||
}
|
||||
|
||||
pub fn has_any_permission(&self, permissions: &[&str]) -> bool {
|
||||
permissions.iter().any(|p| self.has_permission(p))
|
||||
}
|
||||
|
||||
pub fn has_all_permissions(&self, permissions: &[&str]) -> bool {
|
||||
permissions.iter().all(|p| self.has_permission(p))
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthUser {
|
||||
pub fn is_admin(&self) -> bool {
|
||||
matches!(self.role, UserRole::Admin)
|
||||
}
|
||||
|
||||
pub fn is_pastor(&self) -> bool {
|
||||
matches!(self.role, UserRole::Pastor)
|
||||
}
|
||||
|
||||
pub fn is_leadership(&self) -> bool {
|
||||
matches!(
|
||||
self.role,
|
||||
UserRole::Admin | UserRole::Pastor | UserRole::Elder | UserRole::Deacon
|
||||
)
|
||||
}
|
||||
|
||||
pub fn is_member(&self) -> bool {
|
||||
matches!(
|
||||
self.role,
|
||||
UserRole::Admin
|
||||
| UserRole::Pastor
|
||||
| UserRole::Elder
|
||||
| UserRole::Deacon
|
||||
| UserRole::MinistryLeader
|
||||
| UserRole::Member
|
||||
)
|
||||
}
|
||||
|
||||
pub fn can_edit_content(&self) -> bool {
|
||||
matches!(
|
||||
self.role,
|
||||
UserRole::Admin | UserRole::Pastor | UserRole::Elder | UserRole::MinistryLeader
|
||||
)
|
||||
}
|
||||
|
||||
pub fn can_moderate(&self) -> bool {
|
||||
matches!(
|
||||
self.role,
|
||||
UserRole::Admin | UserRole::Pastor | UserRole::Elder | UserRole::Deacon
|
||||
)
|
||||
}
|
||||
|
||||
pub fn display_name(&self) -> String {
|
||||
if !self.name.is_empty() {
|
||||
self.name.clone()
|
||||
} else if let Some(username) = &self.username {
|
||||
username.clone()
|
||||
} else {
|
||||
self.email.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserRole {
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
UserRole::Admin => "Administrator",
|
||||
UserRole::Pastor => "Pastor",
|
||||
UserRole::Elder => "Elder",
|
||||
UserRole::Deacon => "Deacon",
|
||||
UserRole::MinistryLeader => "Ministry Leader",
|
||||
UserRole::Member => "Member",
|
||||
UserRole::Visitor => "Visitor",
|
||||
UserRole::Guest => "Guest",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn permissions(&self) -> Vec<&'static str> {
|
||||
match self {
|
||||
UserRole::Admin => vec![
|
||||
"admin.*",
|
||||
"events.*",
|
||||
"bulletins.*",
|
||||
"sermons.*",
|
||||
"contacts.*",
|
||||
"users.*",
|
||||
"config.*",
|
||||
],
|
||||
UserRole::Pastor => vec![
|
||||
"events.*",
|
||||
"bulletins.*",
|
||||
"sermons.*",
|
||||
"contacts.read",
|
||||
"contacts.respond",
|
||||
"users.read",
|
||||
],
|
||||
UserRole::Elder => vec![
|
||||
"events.read",
|
||||
"events.create",
|
||||
"bulletins.read",
|
||||
"sermons.read",
|
||||
"contacts.read",
|
||||
"contacts.respond",
|
||||
],
|
||||
UserRole::Deacon => vec![
|
||||
"events.read",
|
||||
"bulletins.read",
|
||||
"sermons.read",
|
||||
"contacts.read",
|
||||
],
|
||||
UserRole::MinistryLeader => vec![
|
||||
"events.read",
|
||||
"events.create",
|
||||
"bulletins.read",
|
||||
"sermons.read",
|
||||
],
|
||||
UserRole::Member => vec![
|
||||
"events.read",
|
||||
"bulletins.read",
|
||||
"sermons.read",
|
||||
],
|
||||
UserRole::Visitor => vec![
|
||||
"events.read",
|
||||
"bulletins.read",
|
||||
"sermons.read",
|
||||
],
|
||||
UserRole::Guest => vec![
|
||||
"events.read",
|
||||
"bulletins.read",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
136
src/models/bible.rs
Normal file
136
src/models/bible.rs
Normal file
|
@ -0,0 +1,136 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BibleVerse {
|
||||
pub text: String,
|
||||
pub reference: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub book: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub chapter: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub verse: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<VerseCategory>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct VerseOfTheDay {
|
||||
pub verse: BibleVerse,
|
||||
pub date: chrono::NaiveDate,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub commentary: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum VerseCategory {
|
||||
#[serde(rename = "comfort")]
|
||||
Comfort,
|
||||
#[serde(rename = "hope")]
|
||||
Hope,
|
||||
#[serde(rename = "faith")]
|
||||
Faith,
|
||||
#[serde(rename = "love")]
|
||||
Love,
|
||||
#[serde(rename = "peace")]
|
||||
Peace,
|
||||
#[serde(rename = "strength")]
|
||||
Strength,
|
||||
#[serde(rename = "wisdom")]
|
||||
Wisdom,
|
||||
#[serde(rename = "guidance")]
|
||||
Guidance,
|
||||
#[serde(rename = "forgiveness")]
|
||||
Forgiveness,
|
||||
#[serde(rename = "salvation")]
|
||||
Salvation,
|
||||
#[serde(rename = "prayer")]
|
||||
Prayer,
|
||||
#[serde(rename = "praise")]
|
||||
Praise,
|
||||
#[serde(rename = "thanksgiving")]
|
||||
Thanksgiving,
|
||||
#[serde(rename = "other")]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl BibleVerse {
|
||||
pub fn new(text: String, reference: String) -> Self {
|
||||
Self {
|
||||
text,
|
||||
reference,
|
||||
version: None,
|
||||
book: None,
|
||||
chapter: None,
|
||||
verse: None,
|
||||
category: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_version(mut self, version: String) -> Self {
|
||||
self.version = Some(version);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_book(mut self, book: String) -> Self {
|
||||
self.book = Some(book);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_location(mut self, chapter: u32, verse: u32) -> Self {
|
||||
self.chapter = Some(chapter);
|
||||
self.verse = Some(verse);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category: VerseCategory) -> Self {
|
||||
self.category = Some(category);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl VerseOfTheDay {
|
||||
pub fn new(verse: BibleVerse, date: chrono::NaiveDate) -> Self {
|
||||
Self {
|
||||
verse,
|
||||
date,
|
||||
commentary: None,
|
||||
theme: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_commentary(mut self, commentary: String) -> Self {
|
||||
self.commentary = Some(commentary);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_theme(mut self, theme: String) -> Self {
|
||||
self.theme = Some(theme);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl VerseCategory {
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
VerseCategory::Comfort => "Comfort",
|
||||
VerseCategory::Hope => "Hope",
|
||||
VerseCategory::Faith => "Faith",
|
||||
VerseCategory::Love => "Love",
|
||||
VerseCategory::Peace => "Peace",
|
||||
VerseCategory::Strength => "Strength",
|
||||
VerseCategory::Wisdom => "Wisdom",
|
||||
VerseCategory::Guidance => "Guidance",
|
||||
VerseCategory::Forgiveness => "Forgiveness",
|
||||
VerseCategory::Salvation => "Salvation",
|
||||
VerseCategory::Prayer => "Prayer",
|
||||
VerseCategory::Praise => "Praise",
|
||||
VerseCategory::Thanksgiving => "Thanksgiving",
|
||||
VerseCategory::Other => "Other",
|
||||
}
|
||||
}
|
||||
}
|
240
src/models/bulletin.rs
Normal file
240
src/models/bulletin.rs
Normal file
|
@ -0,0 +1,240 @@
|
|||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Bulletin {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub date: NaiveDate,
|
||||
pub sabbath_school: String,
|
||||
pub divine_worship: String,
|
||||
pub scripture_reading: String,
|
||||
pub sunset: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pdf_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_image: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_active: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub announcements: Option<Vec<Announcement>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hymns: Option<Vec<BulletinHymn>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub special_music: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub offering_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sermon_title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub speaker: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub liturgy: Option<Vec<LiturgyItem>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct NewBulletin {
|
||||
pub title: String,
|
||||
pub date: NaiveDate,
|
||||
pub sabbath_school: String,
|
||||
pub divine_worship: String,
|
||||
pub scripture_reading: String,
|
||||
pub sunset: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pdf_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_image: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_active: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub announcements: Option<Vec<Announcement>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hymns: Option<Vec<BulletinHymn>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub special_music: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub offering_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sermon_title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub speaker: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub liturgy: Option<Vec<LiturgyItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BulletinUpdate {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub date: Option<NaiveDate>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sabbath_school: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub divine_worship: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scripture_reading: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sunset: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pdf_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cover_image: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_active: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub announcements: Option<Vec<Announcement>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hymns: Option<Vec<BulletinHymn>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub special_music: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub offering_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sermon_title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub speaker: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub liturgy: Option<Vec<LiturgyItem>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Announcement {
|
||||
pub id: Option<String>,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<AnnouncementCategory>,
|
||||
#[serde(default)]
|
||||
pub is_urgent: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_info: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BulletinHymn {
|
||||
pub number: u32,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<HymnCategory>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub verses: Option<Vec<u32>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct LiturgyItem {
|
||||
pub order: u32,
|
||||
pub item_type: LiturgyType,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub leader: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scripture_reference: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hymn_number: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum AnnouncementCategory {
|
||||
#[serde(rename = "general")]
|
||||
General,
|
||||
#[serde(rename = "ministry")]
|
||||
Ministry,
|
||||
#[serde(rename = "social")]
|
||||
Social,
|
||||
#[serde(rename = "urgent")]
|
||||
Urgent,
|
||||
#[serde(rename = "prayer")]
|
||||
Prayer,
|
||||
#[serde(rename = "community")]
|
||||
Community,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum HymnCategory {
|
||||
#[serde(rename = "opening")]
|
||||
Opening,
|
||||
#[serde(rename = "closing")]
|
||||
Closing,
|
||||
#[serde(rename = "offertory")]
|
||||
Offertory,
|
||||
#[serde(rename = "communion")]
|
||||
Communion,
|
||||
#[serde(rename = "special")]
|
||||
Special,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum LiturgyType {
|
||||
#[serde(rename = "prelude")]
|
||||
Prelude,
|
||||
#[serde(rename = "welcome")]
|
||||
Welcome,
|
||||
#[serde(rename = "opening_hymn")]
|
||||
OpeningHymn,
|
||||
#[serde(rename = "prayer")]
|
||||
Prayer,
|
||||
#[serde(rename = "scripture")]
|
||||
Scripture,
|
||||
#[serde(rename = "children_story")]
|
||||
ChildrenStory,
|
||||
#[serde(rename = "hymn")]
|
||||
Hymn,
|
||||
#[serde(rename = "offertory")]
|
||||
Offertory,
|
||||
#[serde(rename = "sermon")]
|
||||
Sermon,
|
||||
#[serde(rename = "closing_hymn")]
|
||||
ClosingHymn,
|
||||
#[serde(rename = "benediction")]
|
||||
Benediction,
|
||||
#[serde(rename = "postlude")]
|
||||
Postlude,
|
||||
#[serde(rename = "announcements")]
|
||||
Announcements,
|
||||
#[serde(rename = "special_music")]
|
||||
SpecialMusic,
|
||||
}
|
||||
|
||||
impl Bulletin {
|
||||
pub fn has_pdf(&self) -> bool {
|
||||
self.pdf_path.is_some()
|
||||
}
|
||||
|
||||
pub fn has_cover_image(&self) -> bool {
|
||||
self.cover_image.is_some()
|
||||
}
|
||||
|
||||
pub fn active_announcements(&self) -> Vec<&Announcement> {
|
||||
self.announcements
|
||||
.as_ref()
|
||||
.map(|announcements| {
|
||||
announcements
|
||||
.iter()
|
||||
.filter(|announcement| {
|
||||
announcement.expires_at
|
||||
.map_or(true, |expires| expires > Utc::now())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn urgent_announcements(&self) -> Vec<&Announcement> {
|
||||
self.announcements
|
||||
.as_ref()
|
||||
.map(|announcements| {
|
||||
announcements
|
||||
.iter()
|
||||
.filter(|announcement| announcement.is_urgent)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
343
src/models/client_models.rs
Normal file
343
src/models/client_models.rs
Normal file
|
@ -0,0 +1,343 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use crate::models::event::{Event, RecurringType};
|
||||
use crate::models::bulletin::Bulletin;
|
||||
use crate::models::sermon::Sermon;
|
||||
use chrono::{DateTime, Utc, Local, Timelike};
|
||||
|
||||
/// Client-facing Event model with both raw timestamps and formatted display strings
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ClientEvent {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
|
||||
// Raw ISO timestamps for calendar/system APIs
|
||||
#[serde(rename = "start_time")]
|
||||
pub start_time: String, // ISO timestamp like "2025-08-13T05:00:00-04:00"
|
||||
#[serde(rename = "end_time")]
|
||||
pub end_time: String, // ISO timestamp like "2025-08-13T06:00:00-04:00"
|
||||
|
||||
// Formatted display strings for UI
|
||||
#[serde(rename = "formatted_time")]
|
||||
pub formatted_time: String, // "6:00 PM - 8:00 PM"
|
||||
#[serde(rename = "formatted_date")]
|
||||
pub formatted_date: String, // "Friday, August 15, 2025"
|
||||
#[serde(rename = "formatted_date_time")]
|
||||
pub formatted_date_time: String, // "Friday, August 15, 2025 at 6:00 PM"
|
||||
|
||||
// Additional display fields for UI components
|
||||
#[serde(rename = "day_of_month")]
|
||||
pub day_of_month: String, // "15"
|
||||
#[serde(rename = "month_abbreviation")]
|
||||
pub month_abbreviation: String, // "AUG"
|
||||
#[serde(rename = "time_string")]
|
||||
pub time_string: String, // "6:00 PM - 8:00 PM" (alias for formatted_time)
|
||||
#[serde(rename = "is_multi_day")]
|
||||
pub is_multi_day: bool, // true if event spans multiple days
|
||||
#[serde(rename = "detailed_time_display")]
|
||||
pub detailed_time_display: String, // Full time range for detail views
|
||||
|
||||
pub location: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "location_url")]
|
||||
pub location_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumbnail: Option<String>,
|
||||
pub category: String,
|
||||
#[serde(rename = "is_featured")]
|
||||
pub is_featured: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "recurring_type")]
|
||||
pub recurring_type: Option<String>,
|
||||
#[serde(rename = "created_at")]
|
||||
pub created_at: String, // ISO timestamp
|
||||
#[serde(rename = "updated_at")]
|
||||
pub updated_at: String, // ISO timestamp
|
||||
}
|
||||
|
||||
/// Helper function to format time range from DateTime objects in local timezone
|
||||
fn format_time_range_from_datetime(start_time: &DateTime<Utc>, end_time: &DateTime<Utc>) -> String {
|
||||
// Convert UTC to local timezone for display
|
||||
let start_local = start_time.with_timezone(&Local);
|
||||
let end_local = end_time.with_timezone(&Local);
|
||||
|
||||
// Use consistent formatting: always show hour without leading zero, include minutes, use PM/AM
|
||||
let start_formatted = if start_local.minute() == 0 {
|
||||
start_local.format("%l %p").to_string().trim().to_string()
|
||||
} else {
|
||||
start_local.format("%l:%M %p").to_string().trim().to_string()
|
||||
};
|
||||
|
||||
let end_formatted = if end_local.minute() == 0 {
|
||||
end_local.format("%l %p").to_string().trim().to_string()
|
||||
} else {
|
||||
end_local.format("%l:%M %p").to_string().trim().to_string()
|
||||
};
|
||||
|
||||
// If start and end times are the same, just show one time
|
||||
if start_formatted == end_formatted {
|
||||
start_formatted
|
||||
} else {
|
||||
format!("{} - {}", start_formatted, end_formatted)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Event> for ClientEvent {
|
||||
fn from(event: Event) -> Self {
|
||||
let description = event.clean_description();
|
||||
let category = event.category.to_string();
|
||||
let recurring_type = event.recurring_type.as_ref().map(|rt| rt.to_string());
|
||||
|
||||
// Raw ISO timestamps for calendar/system APIs
|
||||
let start_time = event.start_time.format("%Y-%m-%dT%H:%M:%S%z").to_string();
|
||||
let end_time = event.end_time.format("%Y-%m-%dT%H:%M:%S%z").to_string();
|
||||
|
||||
// Generate formatted display strings in local timezone
|
||||
let start_local = event.start_time.with_timezone(&Local);
|
||||
let end_local = event.end_time.with_timezone(&Local);
|
||||
|
||||
// Check if event spans multiple days
|
||||
let is_multi_day = start_local.date_naive() != end_local.date_naive();
|
||||
|
||||
let (formatted_time, formatted_date, formatted_date_time, day_of_month, month_abbreviation, time_string, detailed_time_display) = if is_multi_day {
|
||||
// Multi-day event: show date range for formatted_date, but start time for simplified views
|
||||
let start_date = start_local.format("%B %d, %Y").to_string();
|
||||
let end_date = end_local.format("%B %d, %Y").to_string();
|
||||
let formatted_date = format!("{} - {}", start_date, end_date);
|
||||
|
||||
// For detailed view: show full date range with full time range
|
||||
let time_range = format_time_range_from_datetime(&event.start_time, &event.end_time);
|
||||
let formatted_time = format!("{} - {}, {}",
|
||||
start_local.format("%b %d").to_string(),
|
||||
end_local.format("%b %d").to_string(),
|
||||
time_range
|
||||
);
|
||||
|
||||
// For HomeFeed simplified view: just show start time
|
||||
let start_time_formatted = if start_local.minute() == 0 {
|
||||
start_local.format("%l %p").to_string().trim().to_string()
|
||||
} else {
|
||||
start_local.format("%l:%M %p").to_string().trim().to_string()
|
||||
};
|
||||
let time_string = start_time_formatted;
|
||||
|
||||
// For detail views: use the same time_range that eliminates redundancy
|
||||
let detailed_time_display = time_range.clone();
|
||||
|
||||
let formatted_date_time = format!("{} - {}", start_date, end_date);
|
||||
|
||||
// Use start date for calendar display
|
||||
let day_of_month = start_local.format("%d").to_string().trim_start_matches('0').to_string();
|
||||
let month_abbreviation = start_local.format("%b").to_string().to_uppercase();
|
||||
|
||||
(formatted_time, formatted_date, formatted_date_time, day_of_month, month_abbreviation, time_string, detailed_time_display)
|
||||
} else {
|
||||
// Single day event: show time range
|
||||
let formatted_time = format_time_range_from_datetime(&event.start_time, &event.end_time);
|
||||
let formatted_date = start_local.format("%B %d, %Y").to_string();
|
||||
// Use consistent time formatting for single events too
|
||||
let time_formatted = if start_local.minute() == 0 {
|
||||
start_local.format("%l %p").to_string().trim().to_string()
|
||||
} else {
|
||||
start_local.format("%l:%M %p").to_string().trim().to_string()
|
||||
};
|
||||
let formatted_date_time = format!("{} at {}", formatted_date, time_formatted);
|
||||
|
||||
let day_of_month = start_local.format("%d").to_string().trim_start_matches('0').to_string();
|
||||
let month_abbreviation = start_local.format("%b").to_string().to_uppercase();
|
||||
|
||||
// For single events, time_string should just be start time for HomeFeed
|
||||
let time_string = time_formatted;
|
||||
|
||||
// For single events, detailed_time_display is same as formatted_time
|
||||
let detailed_time_display = formatted_time.clone();
|
||||
|
||||
(formatted_time, formatted_date, formatted_date_time, day_of_month, month_abbreviation, time_string, detailed_time_display)
|
||||
};
|
||||
|
||||
let created_at = event.created_at.format("%Y-%m-%dT%H:%M:%S%z").to_string();
|
||||
let updated_at = event.updated_at.format("%Y-%m-%dT%H:%M:%S%z").to_string();
|
||||
|
||||
Self {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
description,
|
||||
start_time,
|
||||
end_time,
|
||||
formatted_time,
|
||||
formatted_date,
|
||||
formatted_date_time,
|
||||
day_of_month,
|
||||
month_abbreviation,
|
||||
time_string,
|
||||
is_multi_day,
|
||||
detailed_time_display,
|
||||
location: event.location,
|
||||
location_url: event.location_url,
|
||||
image: event.image,
|
||||
thumbnail: event.thumbnail,
|
||||
category,
|
||||
is_featured: event.is_featured,
|
||||
recurring_type,
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Client-facing Bulletin model with formatted dates
|
||||
/// Serializes to camelCase JSON for iOS compatibility
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ClientBulletin {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub date: String, // Pre-formatted date string
|
||||
#[serde(rename = "sabbathSchool")]
|
||||
pub sabbath_school: String,
|
||||
#[serde(rename = "divineWorship")]
|
||||
pub divine_worship: String,
|
||||
#[serde(rename = "scriptureReading")]
|
||||
pub scripture_reading: String,
|
||||
pub sunset: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "pdfPath")]
|
||||
pub pdf_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "coverImage")]
|
||||
pub cover_image: Option<String>,
|
||||
#[serde(rename = "isActive")]
|
||||
pub is_active: bool,
|
||||
// Add other fields as needed
|
||||
}
|
||||
|
||||
impl From<Bulletin> for ClientBulletin {
|
||||
fn from(bulletin: Bulletin) -> Self {
|
||||
Self {
|
||||
id: bulletin.id,
|
||||
title: bulletin.title,
|
||||
date: bulletin.date.format("%A, %B %d, %Y").to_string(), // Format NaiveDate to string
|
||||
sabbath_school: bulletin.sabbath_school,
|
||||
divine_worship: bulletin.divine_worship,
|
||||
scripture_reading: bulletin.scripture_reading,
|
||||
sunset: bulletin.sunset,
|
||||
pdf_path: bulletin.pdf_path,
|
||||
cover_image: bulletin.cover_image,
|
||||
is_active: bulletin.is_active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Client-facing Sermon model with pre-formatted dates and cleaned data
|
||||
/// Serializes to camelCase JSON for iOS compatibility
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ClientSermon {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub speaker: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub date: Option<String>, // Pre-formatted date string
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "audioUrl")]
|
||||
pub audio_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "videoUrl")]
|
||||
pub video_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<String>, // Pre-formatted duration
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "mediaType")]
|
||||
pub media_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumbnail: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", rename = "scriptureReading")]
|
||||
pub scripture_reading: Option<String>,
|
||||
}
|
||||
|
||||
impl ClientSermon {
|
||||
/// Create a ClientSermon from a Sermon with URL conversion using base API URL
|
||||
pub fn from_sermon_with_base_url(sermon: Sermon, base_url: &str) -> Self {
|
||||
let date = sermon.date.format("%B %d, %Y").to_string();
|
||||
let media_type = if sermon.has_video() {
|
||||
Some("Video".to_string())
|
||||
} else if sermon.has_audio() {
|
||||
Some("Audio".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Helper function to convert relative URLs to full URLs
|
||||
let make_full_url = |url: Option<String>| -> Option<String> {
|
||||
url.map(|u| {
|
||||
if u.starts_with("http://") || u.starts_with("https://") {
|
||||
// Already a full URL
|
||||
u
|
||||
} else if u.starts_with("/") {
|
||||
// Relative URL starting with /
|
||||
let base = base_url.trim_end_matches('/');
|
||||
format!("{}{}", base, u)
|
||||
} else {
|
||||
// Relative URL not starting with /
|
||||
let base = base_url.trim_end_matches('/');
|
||||
format!("{}/{}", base, u)
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
Self {
|
||||
id: sermon.id,
|
||||
title: sermon.title,
|
||||
speaker: sermon.speaker,
|
||||
description: Some(sermon.description),
|
||||
date: Some(date),
|
||||
audio_url: make_full_url(sermon.audio_url),
|
||||
video_url: make_full_url(sermon.video_url),
|
||||
duration: sermon.duration_string, // Use raw duration string from API
|
||||
media_type,
|
||||
thumbnail: make_full_url(sermon.thumbnail),
|
||||
image: None, // Sermons don't have separate image field
|
||||
scripture_reading: if sermon.scripture_reference.is_empty() { None } else { Some(sermon.scripture_reference.clone()) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Sermon> for ClientSermon {
|
||||
fn from(sermon: Sermon) -> Self {
|
||||
let date = sermon.date.format("%B %d, %Y").to_string();
|
||||
let media_type = if sermon.has_video() {
|
||||
Some("Video".to_string())
|
||||
} else if sermon.has_audio() {
|
||||
Some("Audio".to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Self {
|
||||
id: sermon.id,
|
||||
title: sermon.title,
|
||||
speaker: sermon.speaker,
|
||||
description: Some(sermon.description),
|
||||
date: Some(date),
|
||||
audio_url: sermon.audio_url,
|
||||
video_url: sermon.video_url,
|
||||
duration: sermon.duration_string, // Use raw duration string from API
|
||||
media_type,
|
||||
thumbnail: sermon.thumbnail,
|
||||
image: None, // Sermons don't have separate image field
|
||||
scripture_reading: if sermon.scripture_reference.is_empty() { None } else { Some(sermon.scripture_reference.clone()) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add ToString implementations for enums if not already present
|
||||
impl ToString for RecurringType {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
RecurringType::Daily => "Daily".to_string(),
|
||||
RecurringType::Weekly => "Weekly".to_string(),
|
||||
RecurringType::Biweekly => "Bi-weekly".to_string(),
|
||||
RecurringType::Monthly => "Monthly".to_string(),
|
||||
RecurringType::FirstTuesday => "First Tuesday".to_string(),
|
||||
RecurringType::FirstSabbath => "First Sabbath".to_string(),
|
||||
RecurringType::LastSabbath => "Last Sabbath".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
73
src/models/common.rs
Normal file
73
src/models/common.rs
Normal file
|
@ -0,0 +1,73 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ApiResponse<T> {
|
||||
pub success: bool,
|
||||
pub data: Option<T>,
|
||||
pub message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ApiListResponse<T> {
|
||||
pub success: bool,
|
||||
pub data: ApiListData<T>,
|
||||
pub message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ApiListData<T> {
|
||||
pub items: Vec<T>,
|
||||
pub total: u32,
|
||||
pub page: u32,
|
||||
pub per_page: u32,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PaginationParams {
|
||||
pub page: Option<u32>,
|
||||
pub per_page: Option<u32>,
|
||||
pub sort: Option<String>,
|
||||
pub filter: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for PaginationParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
page: Some(1),
|
||||
per_page: Some(50),
|
||||
sort: None,
|
||||
filter: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PaginationParams {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_page(mut self, page: u32) -> Self {
|
||||
self.page = Some(page);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_per_page(mut self, per_page: u32) -> Self {
|
||||
self.per_page = Some(per_page);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_sort(mut self, sort: impl Into<String>) -> Self {
|
||||
self.sort = Some(sort.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_filter(mut self, filter: impl Into<String>) -> Self {
|
||||
self.filter = Some(filter.into());
|
||||
self
|
||||
}
|
||||
}
|
253
src/models/config.rs
Normal file
253
src/models/config.rs
Normal file
|
@ -0,0 +1,253 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Coordinates {
|
||||
pub lat: f64,
|
||||
pub lng: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ChurchConfig {
|
||||
pub church_name: Option<String>,
|
||||
pub church_address: Option<String>,
|
||||
pub po_box: Option<String>,
|
||||
pub contact_phone: Option<String>,
|
||||
pub contact_email: Option<String>,
|
||||
pub website_url: Option<String>,
|
||||
pub google_maps_url: Option<String>,
|
||||
pub facebook_url: Option<String>,
|
||||
pub youtube_url: Option<String>,
|
||||
pub instagram_url: Option<String>,
|
||||
pub about_text: Option<String>,
|
||||
pub mission_statement: Option<String>,
|
||||
pub tagline: Option<String>,
|
||||
pub brand_color: Option<String>,
|
||||
pub donation_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service_times: Option<Vec<ServiceTime>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pastoral_staff: Option<Vec<StaffMember>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ministries: Option<Vec<Ministry>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub app_settings: Option<AppSettings>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub emergency_contacts: Option<Vec<EmergencyContact>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub coordinates: Option<Coordinates>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ServiceTime {
|
||||
pub day: String,
|
||||
pub service: String,
|
||||
pub time: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ServiceTimes {
|
||||
pub sabbath_school: Option<String>,
|
||||
pub divine_worship: Option<String>,
|
||||
pub prayer_meeting: Option<String>,
|
||||
pub youth_service: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub special_services: Option<Vec<SpecialService>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SpecialService {
|
||||
pub name: String,
|
||||
pub time: String,
|
||||
pub frequency: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct StaffMember {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub photo: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bio: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub responsibilities: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Ministry {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub leader: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meeting_time: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub meeting_location: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub website: Option<String>,
|
||||
pub category: MinistryCategory,
|
||||
#[serde(default)]
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct EmergencyContact {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub phone: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
pub priority: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub availability: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppSettings {
|
||||
pub enable_notifications: bool,
|
||||
pub enable_calendar_sync: bool,
|
||||
pub enable_offline_mode: bool,
|
||||
pub theme: AppTheme,
|
||||
pub default_language: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub owncast_server: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bible_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hymnal_version: Option<String>,
|
||||
pub cache_duration_minutes: u32,
|
||||
pub auto_refresh_interval_minutes: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum MinistryCategory {
|
||||
#[serde(rename = "worship")]
|
||||
Worship,
|
||||
#[serde(rename = "education")]
|
||||
Education,
|
||||
#[serde(rename = "youth")]
|
||||
Youth,
|
||||
#[serde(rename = "children")]
|
||||
Children,
|
||||
#[serde(rename = "outreach")]
|
||||
Outreach,
|
||||
#[serde(rename = "health")]
|
||||
Health,
|
||||
#[serde(rename = "music")]
|
||||
Music,
|
||||
#[serde(rename = "fellowship")]
|
||||
Fellowship,
|
||||
#[serde(rename = "prayer")]
|
||||
Prayer,
|
||||
#[serde(rename = "stewardship")]
|
||||
Stewardship,
|
||||
#[serde(rename = "other")]
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum AppTheme {
|
||||
#[serde(rename = "light")]
|
||||
Light,
|
||||
#[serde(rename = "dark")]
|
||||
Dark,
|
||||
#[serde(rename = "system")]
|
||||
System,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_notifications: true,
|
||||
enable_calendar_sync: true,
|
||||
enable_offline_mode: true,
|
||||
theme: AppTheme::System,
|
||||
default_language: "en".to_string(),
|
||||
owncast_server: None,
|
||||
bible_version: Some("KJV".to_string()),
|
||||
hymnal_version: Some("1985".to_string()),
|
||||
cache_duration_minutes: 60,
|
||||
auto_refresh_interval_minutes: 15,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChurchConfig {
|
||||
pub fn get_display_name(&self) -> String {
|
||||
self.church_name
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Church".to_string())
|
||||
}
|
||||
|
||||
pub fn has_social_media(&self) -> bool {
|
||||
self.facebook_url.is_some() || self.youtube_url.is_some() || self.instagram_url.is_some()
|
||||
}
|
||||
|
||||
pub fn get_contact_info(&self) -> Vec<(String, String)> {
|
||||
let mut contacts = Vec::new();
|
||||
|
||||
if let Some(phone) = &self.contact_phone {
|
||||
contacts.push(("Phone".to_string(), phone.clone()));
|
||||
}
|
||||
|
||||
if let Some(email) = &self.contact_email {
|
||||
contacts.push(("Email".to_string(), email.clone()));
|
||||
}
|
||||
|
||||
if let Some(address) = &self.church_address {
|
||||
contacts.push(("Address".to_string(), address.clone()));
|
||||
}
|
||||
|
||||
if let Some(po_box) = &self.po_box {
|
||||
contacts.push(("PO Box".to_string(), po_box.clone()));
|
||||
}
|
||||
|
||||
contacts
|
||||
}
|
||||
|
||||
pub fn active_ministries(&self) -> Vec<&Ministry> {
|
||||
self.ministries
|
||||
.as_ref()
|
||||
.map(|ministries| {
|
||||
ministries
|
||||
.iter()
|
||||
.filter(|ministry| ministry.is_active)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn ministries_by_category(&self, category: MinistryCategory) -> Vec<&Ministry> {
|
||||
self.ministries
|
||||
.as_ref()
|
||||
.map(|ministries| {
|
||||
ministries
|
||||
.iter()
|
||||
.filter(|ministry| ministry.category == category && ministry.is_active)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn emergency_contacts_by_priority(&self) -> Vec<&EmergencyContact> {
|
||||
self.emergency_contacts
|
||||
.as_ref()
|
||||
.map(|contacts| {
|
||||
let mut sorted = contacts.iter().collect::<Vec<_>>();
|
||||
sorted.sort_by_key(|contact| contact.priority);
|
||||
sorted
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
339
src/models/contact.rs
Normal file
339
src/models/contact.rs
Normal file
|
@ -0,0 +1,339 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContactForm {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
pub subject: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<ContactCategory>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preferred_contact_method: Option<ContactMethod>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub urgent: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub visitor_info: Option<VisitorInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ContactSubmission {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
pub subject: String,
|
||||
pub message: String,
|
||||
pub category: ContactCategory,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preferred_contact_method: Option<ContactMethod>,
|
||||
#[serde(default)]
|
||||
pub urgent: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub visitor_info: Option<VisitorInfo>,
|
||||
pub status: ContactStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assigned_to: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub responded_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct VisitorInfo {
|
||||
pub is_first_time: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub how_heard_about_us: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub interests: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub family_members: Option<Vec<FamilyMember>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prayer_requests: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub address: Option<Address>,
|
||||
#[serde(default)]
|
||||
pub wants_follow_up: bool,
|
||||
#[serde(default)]
|
||||
pub wants_newsletter: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FamilyMember {
|
||||
pub name: String,
|
||||
pub relationship: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub age_group: Option<AgeGroup>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub interests: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Address {
|
||||
pub street: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub city: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub zip_code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub country: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PrayerRequest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
pub request: String,
|
||||
pub category: PrayerCategory,
|
||||
#[serde(default)]
|
||||
pub is_public: bool,
|
||||
#[serde(default)]
|
||||
pub is_urgent: bool,
|
||||
#[serde(default)]
|
||||
pub is_confidential: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub follow_up_requested: Option<bool>,
|
||||
pub status: PrayerStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub assigned_to: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub notes: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub answered_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum ContactCategory {
|
||||
#[serde(rename = "general")]
|
||||
General,
|
||||
#[serde(rename = "pastoral_care")]
|
||||
PastoralCare,
|
||||
#[serde(rename = "prayer_request")]
|
||||
PrayerRequest,
|
||||
#[serde(rename = "visitor")]
|
||||
Visitor,
|
||||
#[serde(rename = "ministry")]
|
||||
Ministry,
|
||||
#[serde(rename = "event")]
|
||||
Event,
|
||||
#[serde(rename = "technical")]
|
||||
Technical,
|
||||
#[serde(rename = "feedback")]
|
||||
Feedback,
|
||||
#[serde(rename = "donation")]
|
||||
Donation,
|
||||
#[serde(rename = "membership")]
|
||||
Membership,
|
||||
#[serde(rename = "baptism")]
|
||||
Baptism,
|
||||
#[serde(rename = "wedding")]
|
||||
Wedding,
|
||||
#[serde(rename = "funeral")]
|
||||
Funeral,
|
||||
#[serde(rename = "other")]
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum ContactMethod {
|
||||
#[serde(rename = "email")]
|
||||
Email,
|
||||
#[serde(rename = "phone")]
|
||||
Phone,
|
||||
#[serde(rename = "text")]
|
||||
Text,
|
||||
#[serde(rename = "mail")]
|
||||
Mail,
|
||||
#[serde(rename = "no_preference")]
|
||||
NoPreference,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum ContactStatus {
|
||||
#[serde(rename = "new")]
|
||||
New,
|
||||
#[serde(rename = "assigned")]
|
||||
Assigned,
|
||||
#[serde(rename = "in_progress")]
|
||||
InProgress,
|
||||
#[serde(rename = "responded")]
|
||||
Responded,
|
||||
#[serde(rename = "follow_up")]
|
||||
FollowUp,
|
||||
#[serde(rename = "completed")]
|
||||
Completed,
|
||||
#[serde(rename = "closed")]
|
||||
Closed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum PrayerCategory {
|
||||
#[serde(rename = "health")]
|
||||
Health,
|
||||
#[serde(rename = "family")]
|
||||
Family,
|
||||
#[serde(rename = "finances")]
|
||||
Finances,
|
||||
#[serde(rename = "relationships")]
|
||||
Relationships,
|
||||
#[serde(rename = "spiritual")]
|
||||
Spiritual,
|
||||
#[serde(rename = "work")]
|
||||
Work,
|
||||
#[serde(rename = "travel")]
|
||||
Travel,
|
||||
#[serde(rename = "community")]
|
||||
Community,
|
||||
#[serde(rename = "praise")]
|
||||
Praise,
|
||||
#[serde(rename = "other")]
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum PrayerStatus {
|
||||
#[serde(rename = "new")]
|
||||
New,
|
||||
#[serde(rename = "praying")]
|
||||
Praying,
|
||||
#[serde(rename = "answered")]
|
||||
Answered,
|
||||
#[serde(rename = "ongoing")]
|
||||
Ongoing,
|
||||
#[serde(rename = "closed")]
|
||||
Closed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum AgeGroup {
|
||||
#[serde(rename = "infant")]
|
||||
Infant,
|
||||
#[serde(rename = "toddler")]
|
||||
Toddler,
|
||||
#[serde(rename = "child")]
|
||||
Child,
|
||||
#[serde(rename = "youth")]
|
||||
Youth,
|
||||
#[serde(rename = "adult")]
|
||||
Adult,
|
||||
#[serde(rename = "senior")]
|
||||
Senior,
|
||||
}
|
||||
|
||||
impl ContactForm {
|
||||
pub fn new(name: String, email: String, subject: String, message: String) -> Self {
|
||||
Self {
|
||||
name,
|
||||
email,
|
||||
phone: None,
|
||||
subject,
|
||||
message,
|
||||
category: None,
|
||||
preferred_contact_method: None,
|
||||
urgent: None,
|
||||
visitor_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category: ContactCategory) -> Self {
|
||||
self.category = Some(category);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_phone(mut self, phone: String) -> Self {
|
||||
self.phone = Some(phone);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_preferred_method(mut self, method: ContactMethod) -> Self {
|
||||
self.preferred_contact_method = Some(method);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn mark_urgent(mut self) -> Self {
|
||||
self.urgent = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_visitor_info(mut self, visitor_info: VisitorInfo) -> Self {
|
||||
self.visitor_info = Some(visitor_info);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_urgent(&self) -> bool {
|
||||
self.urgent.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn is_visitor(&self) -> bool {
|
||||
self.visitor_info.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl ContactSubmission {
|
||||
pub fn is_urgent(&self) -> bool {
|
||||
self.urgent
|
||||
}
|
||||
|
||||
pub fn is_visitor(&self) -> bool {
|
||||
self.visitor_info.is_some()
|
||||
}
|
||||
|
||||
pub fn is_open(&self) -> bool {
|
||||
!matches!(self.status, ContactStatus::Completed | ContactStatus::Closed)
|
||||
}
|
||||
|
||||
pub fn needs_response(&self) -> bool {
|
||||
matches!(self.status, ContactStatus::New | ContactStatus::Assigned)
|
||||
}
|
||||
|
||||
pub fn response_time(&self) -> Option<chrono::Duration> {
|
||||
self.responded_at.map(|responded| responded - self.created_at)
|
||||
}
|
||||
|
||||
pub fn age_days(&self) -> i64 {
|
||||
(Utc::now() - self.created_at).num_days()
|
||||
}
|
||||
}
|
||||
|
||||
impl PrayerRequest {
|
||||
pub fn is_urgent(&self) -> bool {
|
||||
self.is_urgent
|
||||
}
|
||||
|
||||
pub fn is_confidential(&self) -> bool {
|
||||
self.is_confidential
|
||||
}
|
||||
|
||||
pub fn is_public(&self) -> bool {
|
||||
self.is_public && !self.is_confidential
|
||||
}
|
||||
|
||||
pub fn is_open(&self) -> bool {
|
||||
!matches!(self.status, PrayerStatus::Answered | PrayerStatus::Closed)
|
||||
}
|
||||
|
||||
pub fn is_answered(&self) -> bool {
|
||||
matches!(self.status, PrayerStatus::Answered)
|
||||
}
|
||||
|
||||
pub fn age_days(&self) -> i64 {
|
||||
(Utc::now() - self.created_at).num_days()
|
||||
}
|
||||
}
|
343
src/models/event.rs
Normal file
343
src/models/event.rs
Normal file
|
@ -0,0 +1,343 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize, Deserializer};
|
||||
use std::fmt;
|
||||
|
||||
/// Timezone-aware timestamp from v2 API
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct TimezoneTimestamp {
|
||||
pub utc: DateTime<Utc>,
|
||||
pub local: String, // "2025-08-13T05:00:00-04:00"
|
||||
pub timezone: String, // "America/New_York"
|
||||
}
|
||||
|
||||
/// Custom deserializer that handles both v1 (simple string) and v2 (timezone object) formats
|
||||
fn deserialize_flexible_datetime<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
use serde::de::{self, Visitor};
|
||||
|
||||
struct FlexibleDateTimeVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for FlexibleDateTimeVisitor {
|
||||
type Value = DateTime<Utc>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("a string timestamp or timezone object")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
||||
where
|
||||
E: de::Error,
|
||||
{
|
||||
// v1 format: simple ISO string
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.map_err(de::Error::custom)
|
||||
}
|
||||
|
||||
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
|
||||
where
|
||||
M: de::MapAccess<'de>,
|
||||
{
|
||||
// v2 format: timezone object - extract UTC field
|
||||
let mut utc_value: Option<DateTime<Utc>> = None;
|
||||
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
match key.as_str() {
|
||||
"utc" => {
|
||||
utc_value = Some(map.next_value()?);
|
||||
}
|
||||
_ => {
|
||||
// Skip other fields (local, timezone)
|
||||
let _: serde_json::Value = map.next_value()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
utc_value.ok_or_else(|| de::Error::missing_field("utc"))
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_any(FlexibleDateTimeVisitor)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Event {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "deserialize_flexible_datetime")]
|
||||
pub start_time: DateTime<Utc>,
|
||||
#[serde(deserialize_with = "deserialize_flexible_datetime")]
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub location: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub location_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thumbnail: Option<String>,
|
||||
pub category: EventCategory,
|
||||
#[serde(default)]
|
||||
pub is_featured: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_type: Option<RecurringType>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub registration_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_attendees: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub current_attendees: Option<u32>,
|
||||
#[serde(deserialize_with = "deserialize_flexible_datetime")]
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(deserialize_with = "deserialize_flexible_datetime")]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct NewEvent {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub start_time: DateTime<Utc>,
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub location: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub location_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
pub category: EventCategory,
|
||||
#[serde(default)]
|
||||
pub is_featured: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_type: Option<RecurringType>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub registration_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_attendees: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct EventUpdate {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub start_time: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub end_time: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub location: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub location_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub category: Option<EventCategory>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_featured: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_type: Option<RecurringType>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_email: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub contact_phone: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub registration_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_attendees: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum EventCategory {
|
||||
#[serde(rename = "service", alias = "Service")]
|
||||
Service,
|
||||
#[serde(rename = "ministry", alias = "Ministry")]
|
||||
Ministry,
|
||||
#[serde(rename = "social", alias = "Social")]
|
||||
Social,
|
||||
#[serde(rename = "education", alias = "Education")]
|
||||
Education,
|
||||
#[serde(rename = "outreach", alias = "Outreach")]
|
||||
Outreach,
|
||||
#[serde(rename = "youth", alias = "Youth")]
|
||||
Youth,
|
||||
#[serde(rename = "music", alias = "Music")]
|
||||
Music,
|
||||
#[serde(rename = "other", alias = "Other")]
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum RecurringType {
|
||||
#[serde(rename = "daily", alias = "DAILY")]
|
||||
Daily,
|
||||
#[serde(rename = "weekly", alias = "WEEKLY")]
|
||||
Weekly,
|
||||
#[serde(rename = "biweekly", alias = "BIWEEKLY")]
|
||||
Biweekly,
|
||||
#[serde(rename = "monthly", alias = "MONTHLY")]
|
||||
Monthly,
|
||||
#[serde(rename = "first_tuesday", alias = "FIRST_TUESDAY")]
|
||||
FirstTuesday,
|
||||
#[serde(rename = "first_sabbath", alias = "FIRST_SABBATH")]
|
||||
FirstSabbath,
|
||||
#[serde(rename = "last_sabbath", alias = "LAST_SABBATH")]
|
||||
LastSabbath,
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn duration_minutes(&self) -> i64 {
|
||||
(self.end_time - self.start_time).num_minutes()
|
||||
}
|
||||
|
||||
pub fn has_registration(&self) -> bool {
|
||||
self.registration_url.is_some()
|
||||
}
|
||||
|
||||
pub fn is_full(&self) -> bool {
|
||||
match (self.max_attendees, self.current_attendees) {
|
||||
(Some(max), Some(current)) => current >= max,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spots_remaining(&self) -> Option<u32> {
|
||||
match (self.max_attendees, self.current_attendees) {
|
||||
(Some(max), Some(current)) => Some(max.saturating_sub(current)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EventCategory {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
EventCategory::Service => write!(f, "Service"),
|
||||
EventCategory::Ministry => write!(f, "Ministry"),
|
||||
EventCategory::Social => write!(f, "Social"),
|
||||
EventCategory::Education => write!(f, "Education"),
|
||||
EventCategory::Outreach => write!(f, "Outreach"),
|
||||
EventCategory::Youth => write!(f, "Youth"),
|
||||
EventCategory::Music => write!(f, "Music"),
|
||||
EventCategory::Other => write!(f, "Other"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn formatted_date(&self) -> String {
|
||||
self.start_time.format("%A, %B %d, %Y").to_string()
|
||||
}
|
||||
|
||||
/// Returns formatted date range for multi-day events, single date for same-day events
|
||||
pub fn formatted_date_range(&self) -> String {
|
||||
let start_date = self.start_time.date_naive();
|
||||
let end_date = self.end_time.date_naive();
|
||||
|
||||
if start_date == end_date {
|
||||
// Same day event
|
||||
self.start_time.format("%A, %B %d, %Y").to_string()
|
||||
} else {
|
||||
// Multi-day event
|
||||
let start_formatted = self.start_time.format("%A, %B %d, %Y").to_string();
|
||||
let end_formatted = self.end_time.format("%A, %B %d, %Y").to_string();
|
||||
format!("{} - {}", start_formatted, end_formatted)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn formatted_start_time(&self) -> String {
|
||||
// Convert UTC to user's local timezone automatically
|
||||
let local_time = self.start_time.with_timezone(&chrono::Local);
|
||||
local_time.format("%I:%M %p").to_string().trim_start_matches('0').to_string()
|
||||
}
|
||||
|
||||
pub fn formatted_end_time(&self) -> String {
|
||||
// Convert UTC to user's local timezone automatically
|
||||
let local_time = self.end_time.with_timezone(&chrono::Local);
|
||||
local_time.format("%I:%M %p").to_string().trim_start_matches('0').to_string()
|
||||
}
|
||||
|
||||
pub fn clean_description(&self) -> String {
|
||||
html2text::from_read(self.description.as_bytes(), 80)
|
||||
.replace('\n', " ")
|
||||
.split_whitespace()
|
||||
.collect::<Vec<&str>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Event submission for public submission endpoint
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct EventSubmission {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub start_time: String, // ISO string format
|
||||
pub end_time: String, // ISO string format
|
||||
pub location: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub location_url: Option<String>,
|
||||
pub category: String, // String to match API exactly
|
||||
#[serde(default)]
|
||||
pub is_featured: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bulletin_week: Option<String>, // Date string in YYYY-MM-DD format
|
||||
pub submitter_email: String,
|
||||
}
|
||||
|
||||
impl EventSubmission {
|
||||
/// Parse start_time string to DateTime<Utc>
|
||||
pub fn parse_start_time(&self) -> Option<DateTime<Utc>> {
|
||||
crate::utils::parse_datetime_flexible(&self.start_time)
|
||||
}
|
||||
|
||||
/// Parse end_time string to DateTime<Utc>
|
||||
pub fn parse_end_time(&self) -> Option<DateTime<Utc>> {
|
||||
crate::utils::parse_datetime_flexible(&self.end_time)
|
||||
}
|
||||
|
||||
/// Validate that both start and end times can be parsed
|
||||
pub fn validate_times(&self) -> bool {
|
||||
self.parse_start_time().is_some() && self.parse_end_time().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pending event for admin management
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PendingEvent {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub start_time: DateTime<Utc>,
|
||||
pub end_time: DateTime<Utc>,
|
||||
pub location: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub location_url: Option<String>,
|
||||
pub category: EventCategory,
|
||||
#[serde(default)]
|
||||
pub is_featured: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recurring_type: Option<RecurringType>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bulletin_week: Option<String>,
|
||||
pub submitter_email: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
28
src/models/mod.rs
Normal file
28
src/models/mod.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
pub mod common;
|
||||
pub mod event;
|
||||
pub mod bulletin;
|
||||
pub mod config;
|
||||
pub mod contact;
|
||||
pub mod sermon;
|
||||
pub mod streaming;
|
||||
pub mod auth;
|
||||
pub mod bible;
|
||||
pub mod client_models;
|
||||
pub mod v2;
|
||||
pub mod admin;
|
||||
|
||||
pub use common::*;
|
||||
pub use event::*;
|
||||
pub use bulletin::*;
|
||||
pub use config::*;
|
||||
pub use contact::*;
|
||||
pub use sermon::*;
|
||||
pub use streaming::*;
|
||||
pub use auth::*;
|
||||
pub use bible::*;
|
||||
pub use client_models::*;
|
||||
pub use v2::*;
|
||||
pub use admin::*;
|
||||
|
||||
// Re-export livestream types from client module for convenience
|
||||
pub use crate::client::livestream::{StreamStatus, LiveStream};
|
376
src/models/sermon.rs
Normal file
376
src/models/sermon.rs
Normal file
|
@ -0,0 +1,376 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// API response structure for sermons from the external API
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ApiSermon {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub speaker: Option<String>,
|
||||
pub date: Option<String>,
|
||||
pub duration: String, // Duration as string like "1:13:01"
|
||||
pub description: Option<String>,
|
||||
pub audio_url: Option<String>,
|
||||
pub video_url: Option<String>,
|
||||
pub media_type: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
pub scripture_reading: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct Sermon {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub speaker: String,
|
||||
pub description: String,
|
||||
pub date: DateTime<Utc>,
|
||||
pub scripture_reference: String,
|
||||
pub series: Option<String>,
|
||||
pub duration_string: Option<String>, // Raw duration from API (e.g., "2:34:49")
|
||||
pub media_url: Option<String>,
|
||||
pub audio_url: Option<String>,
|
||||
pub video_url: Option<String>,
|
||||
pub transcript: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub category: SermonCategory,
|
||||
pub is_featured: bool,
|
||||
pub view_count: u32,
|
||||
pub download_count: u32,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct NewSermon {
|
||||
pub title: String,
|
||||
pub speaker: String,
|
||||
pub description: String,
|
||||
pub date: DateTime<Utc>,
|
||||
pub scripture_reference: String,
|
||||
pub series: Option<String>,
|
||||
pub duration_string: Option<String>,
|
||||
pub media_url: Option<String>,
|
||||
pub audio_url: Option<String>,
|
||||
pub video_url: Option<String>,
|
||||
pub transcript: Option<String>,
|
||||
pub thumbnail: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub category: SermonCategory,
|
||||
pub is_featured: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SermonSeries {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub speaker: String,
|
||||
pub start_date: DateTime<Utc>,
|
||||
pub end_date: Option<DateTime<Utc>>,
|
||||
pub thumbnail: Option<String>,
|
||||
pub sermons: Vec<Sermon>,
|
||||
pub is_active: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SermonNote {
|
||||
pub id: String,
|
||||
pub sermon_id: String,
|
||||
pub user_id: String,
|
||||
pub content: String,
|
||||
pub timestamp_seconds: Option<u32>,
|
||||
pub is_private: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SermonFeedback {
|
||||
pub id: String,
|
||||
pub sermon_id: String,
|
||||
pub user_name: Option<String>,
|
||||
pub user_email: Option<String>,
|
||||
pub rating: Option<u8>, // 1-5 stars
|
||||
pub comment: Option<String>,
|
||||
pub is_public: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub enum SermonCategory {
|
||||
#[serde(rename = "regular")]
|
||||
Regular,
|
||||
#[serde(rename = "evangelistic")]
|
||||
Evangelistic,
|
||||
#[serde(rename = "youth")]
|
||||
Youth,
|
||||
#[serde(rename = "children")]
|
||||
Children,
|
||||
#[serde(rename = "special")]
|
||||
Special,
|
||||
#[serde(rename = "prophecy")]
|
||||
Prophecy,
|
||||
#[serde(rename = "health")]
|
||||
Health,
|
||||
#[serde(rename = "stewardship")]
|
||||
Stewardship,
|
||||
#[serde(rename = "testimony")]
|
||||
Testimony,
|
||||
#[serde(rename = "holiday")]
|
||||
Holiday,
|
||||
#[serde(rename = "communion")]
|
||||
Communion,
|
||||
#[serde(rename = "baptism")]
|
||||
Baptism,
|
||||
#[serde(rename = "wedding")]
|
||||
Wedding,
|
||||
#[serde(rename = "funeral")]
|
||||
Funeral,
|
||||
#[serde(rename = "other")]
|
||||
Other,
|
||||
#[serde(rename = "livestream_archive")]
|
||||
LivestreamArchive,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SermonSearch {
|
||||
pub query: Option<String>,
|
||||
pub speaker: Option<String>,
|
||||
pub category: Option<SermonCategory>,
|
||||
pub series: Option<String>,
|
||||
pub date_from: Option<DateTime<Utc>>,
|
||||
pub date_to: Option<DateTime<Utc>>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub featured_only: Option<bool>,
|
||||
pub has_video: Option<bool>,
|
||||
pub has_audio: Option<bool>,
|
||||
pub has_transcript: Option<bool>,
|
||||
pub min_duration: Option<u32>,
|
||||
pub max_duration: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for SermonSearch {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
query: None,
|
||||
speaker: None,
|
||||
category: None,
|
||||
series: None,
|
||||
date_from: None,
|
||||
date_to: None,
|
||||
tags: None,
|
||||
featured_only: None,
|
||||
has_video: None,
|
||||
has_audio: None,
|
||||
has_transcript: None,
|
||||
min_duration: None,
|
||||
max_duration: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SermonSearch {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_query(mut self, query: String) -> Self {
|
||||
self.query = Some(query);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_speaker(mut self, speaker: String) -> Self {
|
||||
self.speaker = Some(speaker);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_category(mut self, category: SermonCategory) -> Self {
|
||||
self.category = Some(category);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_series(mut self, series: String) -> Self {
|
||||
self.series = Some(series);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_date_range(mut self, from: DateTime<Utc>, to: DateTime<Utc>) -> Self {
|
||||
self.date_from = Some(from);
|
||||
self.date_to = Some(to);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn featured_only(mut self) -> Self {
|
||||
self.featured_only = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_video(mut self) -> Self {
|
||||
self.has_video = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_audio(mut self) -> Self {
|
||||
self.has_audio = Some(true);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_transcript(mut self) -> Self {
|
||||
self.has_transcript = Some(true);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sermon {
|
||||
pub fn duration_formatted(&self) -> String {
|
||||
self.duration_string.clone().unwrap_or_else(|| "Unknown".to_string())
|
||||
}
|
||||
|
||||
pub fn has_media(&self) -> bool {
|
||||
self.media_url.is_some() || self.audio_url.is_some() || self.video_url.is_some()
|
||||
}
|
||||
|
||||
pub fn has_video(&self) -> bool {
|
||||
self.video_url.is_some() || self.media_url.is_some()
|
||||
}
|
||||
|
||||
pub fn has_audio(&self) -> bool {
|
||||
self.audio_url.is_some() || self.media_url.is_some()
|
||||
}
|
||||
|
||||
pub fn has_transcript(&self) -> bool {
|
||||
self.transcript.is_some()
|
||||
}
|
||||
|
||||
pub fn is_recent(&self) -> bool {
|
||||
let thirty_days_ago = Utc::now() - chrono::Duration::days(30);
|
||||
self.date > thirty_days_ago
|
||||
}
|
||||
|
||||
pub fn is_popular(&self) -> bool {
|
||||
self.view_count > 100 || self.download_count > 50
|
||||
}
|
||||
|
||||
pub fn get_tags(&self) -> Vec<String> {
|
||||
self.tags.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn matches_search(&self, search: &SermonSearch) -> bool {
|
||||
if let Some(query) = &search.query {
|
||||
let query_lower = query.to_lowercase();
|
||||
if !self.title.to_lowercase().contains(&query_lower)
|
||||
&& !self.description.to_lowercase().contains(&query_lower)
|
||||
&& !self.speaker.to_lowercase().contains(&query_lower)
|
||||
&& !self.scripture_reference.to_lowercase().contains(&query_lower) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(speaker) = &search.speaker {
|
||||
if !self.speaker.to_lowercase().contains(&speaker.to_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(category) = &search.category {
|
||||
if self.category != *category {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(series) = &search.series {
|
||||
match &self.series {
|
||||
Some(sermon_series) => {
|
||||
if !sermon_series.to_lowercase().contains(&series.to_lowercase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(date_from) = search.date_from {
|
||||
if self.date < date_from {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(date_to) = search.date_to {
|
||||
if self.date > date_to {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(true) = search.featured_only {
|
||||
if !self.is_featured {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(true) = search.has_video {
|
||||
if !self.has_video() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(true) = search.has_audio {
|
||||
if !self.has_audio() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(true) = search.has_transcript {
|
||||
if !self.has_transcript() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl SermonSeries {
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.is_active && self.end_date.map_or(true, |end| end > Utc::now())
|
||||
}
|
||||
|
||||
pub fn sermon_count(&self) -> usize {
|
||||
self.sermons.len()
|
||||
}
|
||||
|
||||
pub fn total_duration(&self) -> Option<u32> {
|
||||
if self.sermons.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Since we now use duration_string, we can't easily sum durations
|
||||
// Return None for now - this would need proper duration parsing if needed
|
||||
None
|
||||
}
|
||||
|
||||
pub fn latest_sermon(&self) -> Option<&Sermon> {
|
||||
self.sermons
|
||||
.iter()
|
||||
.max_by_key(|s| s.date)
|
||||
}
|
||||
|
||||
pub fn duration_formatted(&self) -> String {
|
||||
match self.total_duration() {
|
||||
Some(seconds) => {
|
||||
let minutes = seconds / 60;
|
||||
let hours = minutes / 60;
|
||||
let remaining_minutes = minutes % 60;
|
||||
|
||||
if hours > 0 {
|
||||
format!("{}h {}m", hours, remaining_minutes)
|
||||
} else {
|
||||
format!("{}m", minutes)
|
||||
}
|
||||
}
|
||||
None => "Unknown".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
157
src/models/streaming.rs
Normal file
157
src/models/streaming.rs
Normal file
|
@ -0,0 +1,157 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Device streaming capabilities
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum StreamingCapability {
|
||||
/// Device supports AV1 codec (direct stream)
|
||||
AV1,
|
||||
/// Device needs HLS H.264 fallback
|
||||
HLS,
|
||||
}
|
||||
|
||||
/// Streaming URL configuration
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StreamingUrl {
|
||||
pub url: String,
|
||||
pub capability: StreamingCapability,
|
||||
}
|
||||
|
||||
/// Device capability detection
|
||||
pub struct DeviceCapabilities;
|
||||
|
||||
impl DeviceCapabilities {
|
||||
/// Detect device streaming capability
|
||||
/// For now, this is a simple implementation that can be expanded
|
||||
#[cfg(target_os = "ios")]
|
||||
pub fn detect_capability() -> StreamingCapability {
|
||||
// Use sysctlbyname to get device model on iOS
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::mem;
|
||||
|
||||
unsafe {
|
||||
let name = CString::new("hw.model").unwrap();
|
||||
let mut size: libc::size_t = 0;
|
||||
|
||||
// First call to get the size
|
||||
if libc::sysctlbyname(
|
||||
name.as_ptr(),
|
||||
std::ptr::null_mut(),
|
||||
&mut size,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
) != 0 {
|
||||
println!("🎬 DEBUG: Failed to get model size, defaulting to HLS");
|
||||
return StreamingCapability::HLS;
|
||||
}
|
||||
|
||||
// Allocate buffer and get the actual value
|
||||
let mut buffer = vec![0u8; size];
|
||||
if libc::sysctlbyname(
|
||||
name.as_ptr(),
|
||||
buffer.as_mut_ptr() as *mut libc::c_void,
|
||||
&mut size,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
) != 0 {
|
||||
println!("🎬 DEBUG: Failed to get model value, defaulting to HLS");
|
||||
return StreamingCapability::HLS;
|
||||
}
|
||||
|
||||
// Convert to string
|
||||
if let Ok(model_cstr) = CStr::from_bytes_with_nul(&buffer[..size]) {
|
||||
if let Ok(model) = model_cstr.to_str() {
|
||||
let model = model.to_lowercase();
|
||||
println!("🎬 DEBUG: Detected device model: {}", model);
|
||||
|
||||
// iPhone models with AV1 hardware decoding support:
|
||||
// Marketing names: iPhone16,x = iPhone 15 Pro/Pro Max, iPhone17,x = iPhone 16 series
|
||||
// Internal codenames: d9xap = iPhone 16 series, d8xap = iPhone 15 Pro series
|
||||
if model.starts_with("iphone16,") || model.starts_with("iphone17,") ||
|
||||
model.starts_with("d94ap") || model.starts_with("d93ap") ||
|
||||
model.starts_with("d84ap") || model.starts_with("d83ap") {
|
||||
println!("🎬 DEBUG: Device {} supports AV1 hardware decoding", model);
|
||||
return StreamingCapability::AV1;
|
||||
}
|
||||
|
||||
println!("🎬 DEBUG: Device {} does not support AV1, using HLS fallback", model);
|
||||
return StreamingCapability::HLS;
|
||||
}
|
||||
}
|
||||
|
||||
println!("🎬 DEBUG: Failed to parse model string, defaulting to HLS");
|
||||
StreamingCapability::HLS
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
pub fn detect_capability() -> StreamingCapability {
|
||||
// Default to HLS for other platforms for now
|
||||
StreamingCapability::HLS
|
||||
}
|
||||
|
||||
/// Generate streaming URL based on capability and media ID
|
||||
pub fn get_streaming_url(base_url: &str, media_id: &str, capability: StreamingCapability) -> StreamingUrl {
|
||||
// Add timestamp for cache busting to ensure fresh streams
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let url = match capability {
|
||||
StreamingCapability::AV1 => {
|
||||
format!("{}/api/media/stream/{}?t={}", base_url.trim_end_matches('/'), media_id, timestamp)
|
||||
}
|
||||
StreamingCapability::HLS => {
|
||||
format!("{}/api/media/stream/{}/playlist.m3u8?t={}", base_url.trim_end_matches('/'), media_id, timestamp)
|
||||
}
|
||||
};
|
||||
|
||||
StreamingUrl { url, capability }
|
||||
}
|
||||
|
||||
/// Get optimal streaming URL for current device
|
||||
pub fn get_optimal_streaming_url(base_url: &str, media_id: &str) -> StreamingUrl {
|
||||
let capability = Self::detect_capability();
|
||||
Self::get_streaming_url(base_url, media_id, capability)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_av1_url_generation() {
|
||||
let url = DeviceCapabilities::get_streaming_url(
|
||||
"https://api.rockvilletollandsda.church",
|
||||
"test-id-123",
|
||||
StreamingCapability::AV1
|
||||
);
|
||||
|
||||
assert_eq!(url.url, "https://api.rockvilletollandsda.church/api/media/stream/test-id-123");
|
||||
assert_eq!(url.capability, StreamingCapability::AV1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hls_url_generation() {
|
||||
let url = DeviceCapabilities::get_streaming_url(
|
||||
"https://api.rockvilletollandsda.church",
|
||||
"test-id-123",
|
||||
StreamingCapability::HLS
|
||||
);
|
||||
|
||||
assert_eq!(url.url, "https://api.rockvilletollandsda.church/api/media/stream/test-id-123/playlist.m3u8");
|
||||
assert_eq!(url.capability, StreamingCapability::HLS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_base_url_trimming() {
|
||||
let url = DeviceCapabilities::get_streaming_url(
|
||||
"https://api.rockvilletollandsda.church/",
|
||||
"test-id-123",
|
||||
StreamingCapability::HLS
|
||||
);
|
||||
|
||||
assert_eq!(url.url, "https://api.rockvilletollandsda.church/api/media/stream/test-id-123/playlist.m3u8");
|
||||
}
|
||||
}
|
15
src/models/v2.rs
Normal file
15
src/models/v2.rs
Normal file
|
@ -0,0 +1,15 @@
|
|||
/// API version enum to specify which API version to use
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ApiVersion {
|
||||
V1,
|
||||
V2,
|
||||
}
|
||||
|
||||
impl ApiVersion {
|
||||
pub fn path_prefix(&self) -> &'static str {
|
||||
match self {
|
||||
ApiVersion::V1 => "",
|
||||
ApiVersion::V2 => "v2/",
|
||||
}
|
||||
}
|
||||
}
|
1726
src/uniffi_wrapper.rs
Normal file
1726
src/uniffi_wrapper.rs
Normal file
File diff suppressed because it is too large
Load diff
310
src/utils/feed.rs
Normal file
310
src/utils/feed.rs
Normal file
|
@ -0,0 +1,310 @@
|
|||
use crate::models::{ClientEvent, Sermon, Bulletin, BibleVerse};
|
||||
use chrono::{DateTime, Utc, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FeedItem {
|
||||
pub id: String,
|
||||
pub feed_type: FeedItemType,
|
||||
pub timestamp: String, // ISO8601 format
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum FeedItemType {
|
||||
#[serde(rename = "event")]
|
||||
Event {
|
||||
event: ClientEvent,
|
||||
},
|
||||
#[serde(rename = "sermon")]
|
||||
Sermon {
|
||||
sermon: Sermon,
|
||||
},
|
||||
#[serde(rename = "bulletin")]
|
||||
Bulletin {
|
||||
bulletin: Bulletin,
|
||||
},
|
||||
#[serde(rename = "verse")]
|
||||
Verse {
|
||||
verse: BibleVerse,
|
||||
},
|
||||
}
|
||||
|
||||
/// Parse date string to DateTime<Utc>, with fallback to current time
|
||||
fn parse_date_with_fallback(date_str: &str) -> DateTime<Utc> {
|
||||
// Try ISO8601 format first
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
|
||||
return dt.with_timezone(&Utc);
|
||||
}
|
||||
|
||||
// Try naive datetime parsing
|
||||
if let Ok(naive) = NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S") {
|
||||
return DateTime::from_naive_utc_and_offset(naive, Utc);
|
||||
}
|
||||
|
||||
// Fallback to current time
|
||||
Utc::now()
|
||||
}
|
||||
|
||||
/// Calculate priority for feed items based on type and recency
|
||||
fn calculate_priority(feed_type: &FeedItemType, timestamp: &DateTime<Utc>) -> i32 {
|
||||
let now = Utc::now();
|
||||
let age_days = (now - *timestamp).num_days().max(0);
|
||||
|
||||
match feed_type {
|
||||
FeedItemType::Event { .. } => {
|
||||
// Events get highest priority, especially upcoming ones
|
||||
if *timestamp > now {
|
||||
1000 // Future events (upcoming)
|
||||
} else {
|
||||
800 - (age_days as i32) // Recent past events
|
||||
}
|
||||
},
|
||||
FeedItemType::Sermon { .. } => {
|
||||
// Sermons get high priority when recent
|
||||
600 - (age_days as i32)
|
||||
},
|
||||
FeedItemType::Bulletin { .. } => {
|
||||
// Bulletins get medium priority
|
||||
400 - (age_days as i32)
|
||||
},
|
||||
FeedItemType::Verse { .. } => {
|
||||
// Daily verse always gets consistent priority
|
||||
300
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate and sort home feed items
|
||||
pub fn aggregate_home_feed(
|
||||
events: &[ClientEvent],
|
||||
sermons: &[Sermon],
|
||||
bulletins: &[Bulletin],
|
||||
daily_verse: Option<&BibleVerse>
|
||||
) -> Vec<FeedItem> {
|
||||
let mut feed_items = Vec::new();
|
||||
|
||||
// Add recent sermons (limit to 3)
|
||||
for sermon in sermons.iter().take(3) {
|
||||
let timestamp = sermon.date; // Already a DateTime<Utc>
|
||||
let feed_type = FeedItemType::Sermon { sermon: sermon.clone() };
|
||||
let priority = calculate_priority(&feed_type, ×tamp);
|
||||
|
||||
feed_items.push(FeedItem {
|
||||
id: format!("sermon_{}", sermon.id),
|
||||
feed_type,
|
||||
timestamp: timestamp.to_rfc3339(),
|
||||
priority,
|
||||
});
|
||||
}
|
||||
|
||||
// Add upcoming events (limit to 2)
|
||||
for event in events.iter().take(2) {
|
||||
let timestamp = parse_date_with_fallback(&event.created_at);
|
||||
let feed_type = FeedItemType::Event { event: event.clone() };
|
||||
let priority = calculate_priority(&feed_type, ×tamp);
|
||||
|
||||
feed_items.push(FeedItem {
|
||||
id: format!("event_{}", event.id),
|
||||
feed_type,
|
||||
timestamp: timestamp.to_rfc3339(),
|
||||
priority,
|
||||
});
|
||||
}
|
||||
|
||||
// Add most recent bulletin
|
||||
if let Some(bulletin) = bulletins.first() {
|
||||
let timestamp = parse_date_with_fallback(&bulletin.date.to_string());
|
||||
let feed_type = FeedItemType::Bulletin { bulletin: bulletin.clone() };
|
||||
let priority = calculate_priority(&feed_type, ×tamp);
|
||||
|
||||
feed_items.push(FeedItem {
|
||||
id: format!("bulletin_{}", bulletin.id),
|
||||
feed_type,
|
||||
timestamp: timestamp.to_rfc3339(),
|
||||
priority,
|
||||
});
|
||||
}
|
||||
|
||||
// Add daily verse
|
||||
if let Some(verse) = daily_verse {
|
||||
let timestamp = Utc::now();
|
||||
let feed_type = FeedItemType::Verse { verse: verse.clone() };
|
||||
let priority = calculate_priority(&feed_type, ×tamp);
|
||||
|
||||
feed_items.push(FeedItem {
|
||||
id: format!("verse_{}", verse.reference),
|
||||
feed_type,
|
||||
timestamp: timestamp.to_rfc3339(),
|
||||
priority,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by priority (highest first), then by timestamp (newest first)
|
||||
feed_items.sort_by(|a, b| {
|
||||
b.priority.cmp(&a.priority)
|
||||
.then_with(|| b.timestamp.cmp(&a.timestamp))
|
||||
});
|
||||
|
||||
feed_items
|
||||
}
|
||||
|
||||
/// Media type enumeration for content categorization
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum MediaType {
|
||||
Sermons,
|
||||
LiveStreams,
|
||||
}
|
||||
|
||||
impl MediaType {
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
MediaType::Sermons => "Sermons",
|
||||
MediaType::LiveStreams => "Live Archives",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon_name(&self) -> &'static str {
|
||||
match self {
|
||||
MediaType::Sermons => "play.rectangle.fill",
|
||||
MediaType::LiveStreams => "dot.radiowaves.left.and.right",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get sermons or livestreams based on media type
|
||||
pub fn get_media_content(sermons: &[Sermon], media_type: &MediaType) -> Vec<Sermon> {
|
||||
match media_type {
|
||||
MediaType::Sermons => {
|
||||
// Filter for regular sermons (non-livestream)
|
||||
sermons.iter()
|
||||
.filter(|sermon| !sermon.title.to_lowercase().contains("livestream"))
|
||||
.cloned()
|
||||
.collect()
|
||||
},
|
||||
MediaType::LiveStreams => {
|
||||
// Filter for livestream archives
|
||||
sermons.iter()
|
||||
.filter(|sermon| sermon.title.to_lowercase().contains("livestream"))
|
||||
.cloned()
|
||||
.collect()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{ClientEvent, Sermon, Bulletin, BibleVerse};
|
||||
|
||||
fn create_sample_event(id: &str, title: &str) -> ClientEvent {
|
||||
ClientEvent {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
description: "Sample description".to_string(),
|
||||
date: "2025-01-15".to_string(),
|
||||
start_time: "6:00 PM".to_string(),
|
||||
end_time: "8:00 PM".to_string(),
|
||||
location: "Sample Location".to_string(),
|
||||
location_url: None,
|
||||
image: None,
|
||||
thumbnail: None,
|
||||
category: "Social".to_string(),
|
||||
is_featured: false,
|
||||
recurring_type: None,
|
||||
tags: None,
|
||||
contact_email: None,
|
||||
contact_phone: None,
|
||||
registration_url: None,
|
||||
max_attendees: None,
|
||||
current_attendees: None,
|
||||
created_at: "2025-01-10T10:00:00Z".to_string(),
|
||||
updated_at: "2025-01-10T10:00:00Z".to_string(),
|
||||
duration_minutes: 120,
|
||||
has_registration: false,
|
||||
is_full: false,
|
||||
spots_remaining: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_sample_sermon(id: &str, title: &str) -> Sermon {
|
||||
Sermon {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
description: Some("Sample sermon".to_string()),
|
||||
date: Some("2025-01-10T10:00:00Z".to_string()),
|
||||
video_url: Some("https://example.com/video".to_string()),
|
||||
audio_url: None,
|
||||
thumbnail_url: None,
|
||||
duration: None,
|
||||
speaker: Some("Pastor Smith".to_string()),
|
||||
series: None,
|
||||
scripture_references: None,
|
||||
tags: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aggregate_home_feed() {
|
||||
let events = vec![
|
||||
create_sample_event("1", "Event 1"),
|
||||
create_sample_event("2", "Event 2"),
|
||||
];
|
||||
|
||||
let sermons = vec![
|
||||
create_sample_sermon("1", "Sermon 1"),
|
||||
create_sample_sermon("2", "Sermon 2"),
|
||||
];
|
||||
|
||||
let bulletins = vec![
|
||||
Bulletin {
|
||||
id: "1".to_string(),
|
||||
title: "Weekly Bulletin".to_string(),
|
||||
date: "2025-01-12T10:00:00Z".to_string(),
|
||||
pdf_url: "https://example.com/bulletin.pdf".to_string(),
|
||||
description: Some("This week's bulletin".to_string()),
|
||||
thumbnail_url: None,
|
||||
}
|
||||
];
|
||||
|
||||
let verse = BibleVerse {
|
||||
text: "For God so loved the world...".to_string(),
|
||||
reference: "John 3:16".to_string(),
|
||||
version: Some("KJV".to_string()),
|
||||
};
|
||||
|
||||
let feed = aggregate_home_feed(&events, &sermons, &bulletins, Some(&verse));
|
||||
|
||||
assert!(feed.len() >= 4); // Should have events, sermons, bulletin, and verse
|
||||
|
||||
// Check that items are sorted by priority
|
||||
for i in 1..feed.len() {
|
||||
assert!(feed[i-1].priority >= feed[i].priority);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_media_type_display() {
|
||||
assert_eq!(MediaType::Sermons.display_name(), "Sermons");
|
||||
assert_eq!(MediaType::LiveStreams.display_name(), "Live Archives");
|
||||
assert_eq!(MediaType::Sermons.icon_name(), "play.rectangle.fill");
|
||||
assert_eq!(MediaType::LiveStreams.icon_name(), "dot.radiowaves.left.and.right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_media_content() {
|
||||
let sermons = vec![
|
||||
create_sample_sermon("1", "Regular Sermon"),
|
||||
create_sample_sermon("2", "Livestream Service"),
|
||||
create_sample_sermon("3", "Another Sermon"),
|
||||
];
|
||||
|
||||
let regular_sermons = get_media_content(&sermons, &MediaType::Sermons);
|
||||
assert_eq!(regular_sermons.len(), 2);
|
||||
|
||||
let livestreams = get_media_content(&sermons, &MediaType::LiveStreams);
|
||||
assert_eq!(livestreams.len(), 1);
|
||||
assert!(livestreams[0].title.contains("Livestream"));
|
||||
}
|
||||
}
|
159
src/utils/formatting.rs
Normal file
159
src/utils/formatting.rs
Normal file
|
@ -0,0 +1,159 @@
|
|||
use crate::models::ClientEvent;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FormattedEvent {
|
||||
pub formatted_time: String,
|
||||
pub formatted_date_time: String,
|
||||
pub is_multi_day: bool,
|
||||
pub formatted_date_range: String,
|
||||
}
|
||||
|
||||
/// Format time range for display
|
||||
pub fn format_time_range(start_time: &str, end_time: &str) -> String {
|
||||
format!("{} - {}", start_time, end_time)
|
||||
}
|
||||
|
||||
/// Check if event appears to be multi-day based on date format
|
||||
pub fn is_multi_day_event(date: &str) -> bool {
|
||||
date.contains(" - ")
|
||||
}
|
||||
|
||||
/// Format date and time for display, handling multi-day events
|
||||
pub fn format_date_time(date: &str, start_time: &str, end_time: &str) -> String {
|
||||
if is_multi_day_event(date) {
|
||||
// For multi-day events, integrate times with their respective dates
|
||||
let components: Vec<&str> = date.split(" - ").collect();
|
||||
if components.len() == 2 {
|
||||
format!("{} at {} - {} at {}", components[0], start_time, components[1], end_time)
|
||||
} else {
|
||||
date.to_string() // Fallback to original date
|
||||
}
|
||||
} else {
|
||||
// Single day events: return just the date (time displayed separately)
|
||||
date.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a client event with all display formatting logic
|
||||
pub fn format_event_for_display(event: &ClientEvent) -> FormattedEvent {
|
||||
let start_time = &event.start_time;
|
||||
let end_time = &event.end_time;
|
||||
|
||||
// Derive formatted date from start_time since ClientEvent no longer has date field
|
||||
let formatted_date = format_date_from_timestamp(start_time);
|
||||
|
||||
FormattedEvent {
|
||||
formatted_time: format_time_range(start_time, end_time),
|
||||
formatted_date_time: format_date_time(&formatted_date, start_time, end_time),
|
||||
is_multi_day: is_multi_day_event(&formatted_date),
|
||||
formatted_date_range: formatted_date,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract formatted date from ISO timestamp
|
||||
fn format_date_from_timestamp(timestamp: &str) -> String {
|
||||
use chrono::{DateTime, FixedOffset};
|
||||
|
||||
if let Ok(dt) = DateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%z") {
|
||||
dt.format("%A, %B %d, %Y").to_string()
|
||||
} else {
|
||||
"Date TBD".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format duration in minutes to human readable format
|
||||
pub fn format_duration_minutes(minutes: i64) -> String {
|
||||
if minutes < 60 {
|
||||
format!("{} min", minutes)
|
||||
} else {
|
||||
let hours = minutes / 60;
|
||||
let remaining_minutes = minutes % 60;
|
||||
if remaining_minutes == 0 {
|
||||
format!("{} hr", hours)
|
||||
} else {
|
||||
format!("{} hr {} min", hours, remaining_minutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format spots remaining for events
|
||||
pub fn format_spots_remaining(current: Option<u32>, max: Option<u32>) -> Option<String> {
|
||||
match (current, max) {
|
||||
(Some(current), Some(max)) => {
|
||||
let remaining = max.saturating_sub(current);
|
||||
if remaining == 0 {
|
||||
Some("Event Full".to_string())
|
||||
} else {
|
||||
Some(format!("{} spots remaining", remaining))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if event registration is full
|
||||
pub fn is_event_full(current: Option<u32>, max: Option<u32>) -> bool {
|
||||
match (current, max) {
|
||||
(Some(current), Some(max)) => current >= max,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_time_range() {
|
||||
assert_eq!(format_time_range("9:00 AM", "5:00 PM"), "9:00 AM - 5:00 PM");
|
||||
assert_eq!(format_time_range("", ""), " - ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_multi_day_event() {
|
||||
assert!(is_multi_day_event("Saturday, Aug 30, 2025 - Sunday, Aug 31, 2025"));
|
||||
assert!(!is_multi_day_event("Saturday, Aug 30, 2025"));
|
||||
assert!(!is_multi_day_event(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_date_time() {
|
||||
// Single day event
|
||||
let result = format_date_time("Saturday, Aug 30, 2025", "6:00 PM", "8:00 PM");
|
||||
assert_eq!(result, "Saturday, Aug 30, 2025");
|
||||
|
||||
// Multi-day event
|
||||
let result = format_date_time(
|
||||
"Saturday, Aug 30, 2025 - Sunday, Aug 31, 2025",
|
||||
"6:00 PM",
|
||||
"6:00 AM"
|
||||
);
|
||||
assert_eq!(result, "Saturday, Aug 30, 2025 at 6:00 PM - Sunday, Aug 31, 2025 at 6:00 AM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_duration_minutes() {
|
||||
assert_eq!(format_duration_minutes(30), "30 min");
|
||||
assert_eq!(format_duration_minutes(60), "1 hr");
|
||||
assert_eq!(format_duration_minutes(90), "1 hr 30 min");
|
||||
assert_eq!(format_duration_minutes(120), "2 hr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_spots_remaining() {
|
||||
assert_eq!(format_spots_remaining(Some(8), Some(10)), Some("2 spots remaining".to_string()));
|
||||
assert_eq!(format_spots_remaining(Some(10), Some(10)), Some("Event Full".to_string()));
|
||||
assert_eq!(format_spots_remaining(None, Some(10)), None);
|
||||
assert_eq!(format_spots_remaining(Some(5), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_event_full() {
|
||||
assert!(is_event_full(Some(10), Some(10)));
|
||||
assert!(is_event_full(Some(11), Some(10))); // Over capacity
|
||||
assert!(!is_event_full(Some(9), Some(10)));
|
||||
assert!(!is_event_full(None, Some(10)));
|
||||
assert!(!is_event_full(Some(5), None));
|
||||
}
|
||||
}
|
9
src/utils/mod.rs
Normal file
9
src/utils/mod.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
pub mod scripture;
|
||||
pub mod validation;
|
||||
pub mod formatting;
|
||||
pub mod feed;
|
||||
|
||||
pub use scripture::*;
|
||||
pub use validation::*;
|
||||
pub use formatting::*;
|
||||
pub use feed::*;
|
164
src/utils/scripture.rs
Normal file
164
src/utils/scripture.rs
Normal file
|
@ -0,0 +1,164 @@
|
|||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScriptureSection {
|
||||
pub verse: String,
|
||||
pub reference: String,
|
||||
}
|
||||
|
||||
/// Format raw scripture text into structured sections with verses and references
|
||||
pub fn format_scripture_text(text: &str) -> Vec<ScriptureSection> {
|
||||
// Handle single-line format where verse and reference are together
|
||||
if text.contains(" KJV") && !text.contains('\n') {
|
||||
// Single line format: "verse text. Book chapter:verse KJV"
|
||||
if let Some(kjv_pos) = text.rfind(" KJV") {
|
||||
let before_kjv = &text[..kjv_pos];
|
||||
// Find the last period or other punctuation that separates verse from reference
|
||||
if let Some(last_period) = before_kjv.rfind('.') {
|
||||
if let Some(reference_start) = before_kjv[last_period..].find(char::is_alphabetic) {
|
||||
let actual_start = last_period + reference_start;
|
||||
let verse_text = format!("{}.", &before_kjv[..last_period]);
|
||||
let reference = format!("{} KJV", &before_kjv[actual_start..]);
|
||||
return vec![ScriptureSection {
|
||||
verse: verse_text.trim().to_string(),
|
||||
reference: reference.trim().to_string(),
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: treat entire text as verse with no separate reference
|
||||
return vec![ScriptureSection {
|
||||
verse: text.to_string(),
|
||||
reference: String::new(),
|
||||
}];
|
||||
}
|
||||
|
||||
// Multi-line format (original logic)
|
||||
let sections: Vec<&str> = text.split('\n').collect();
|
||||
let mut formatted_sections = Vec::new();
|
||||
let mut current_verse = String::new();
|
||||
|
||||
for section in sections {
|
||||
let trimmed = section.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this line is a reference (contains "KJV" at the end)
|
||||
if trimmed.ends_with("KJV") {
|
||||
// This is a reference for the verse we just accumulated
|
||||
if !current_verse.is_empty() {
|
||||
formatted_sections.push(ScriptureSection {
|
||||
verse: current_verse.clone(),
|
||||
reference: trimmed.to_string(),
|
||||
});
|
||||
current_verse.clear(); // Reset for next verse
|
||||
}
|
||||
} else {
|
||||
// This is verse text
|
||||
if !current_verse.is_empty() {
|
||||
current_verse.push(' ');
|
||||
}
|
||||
current_verse.push_str(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining verse without a reference
|
||||
if !current_verse.is_empty() {
|
||||
formatted_sections.push(ScriptureSection {
|
||||
verse: current_verse,
|
||||
reference: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
formatted_sections
|
||||
}
|
||||
|
||||
/// Extract scripture references from text (e.g., "Joel 2:28 KJV" patterns)
|
||||
pub fn extract_scripture_references(text: &str) -> String {
|
||||
let pattern = r"([1-3]?\s*[A-Za-z]+\s+\d+:\d+(?:-\d+)?)\s+KJV";
|
||||
|
||||
match Regex::new(pattern) {
|
||||
Ok(regex) => {
|
||||
let references: Vec<String> = regex
|
||||
.captures_iter(text)
|
||||
.filter_map(|cap| cap.get(1).map(|m| m.as_str().trim().to_string()))
|
||||
.collect();
|
||||
|
||||
if references.is_empty() {
|
||||
"Scripture Reading".to_string()
|
||||
} else {
|
||||
references.join(", ")
|
||||
}
|
||||
}
|
||||
Err(_) => "Scripture Reading".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create standardized share text for sermons
|
||||
pub fn create_sermon_share_text(title: &str, speaker: &str, video_url: Option<&str>, audio_url: Option<&str>) -> Vec<String> {
|
||||
let mut items = Vec::new();
|
||||
|
||||
// Create share text
|
||||
let share_text = format!("Check out this sermon: \"{}\" by {}", title, speaker);
|
||||
items.push(share_text);
|
||||
|
||||
// Add video URL if available, otherwise audio URL
|
||||
if let Some(url) = video_url {
|
||||
items.push(url.to_string());
|
||||
} else if let Some(url) = audio_url {
|
||||
items.push(url.to_string());
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_single_line_scripture_format() {
|
||||
let input = "And it shall come to pass afterward, that I will pour out my spirit upon all flesh. Joel 2:28 KJV";
|
||||
let result = format_scripture_text(input);
|
||||
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].verse, "And it shall come to pass afterward, that I will pour out my spirit upon all flesh.");
|
||||
assert_eq!(result[0].reference, "Joel 2:28 KJV");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_line_scripture_format() {
|
||||
let input = "And it shall come to pass afterward, that I will pour out my spirit upon all flesh\nJoel 2:28 KJV\nQuench not the Spirit. Despise not prophesyings.\n1 Thessalonians 5:19-21 KJV";
|
||||
let result = format_scripture_text(input);
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].verse, "And it shall come to pass afterward, that I will pour out my spirit upon all flesh");
|
||||
assert_eq!(result[0].reference, "Joel 2:28 KJV");
|
||||
assert_eq!(result[1].verse, "Quench not the Spirit. Despise not prophesyings.");
|
||||
assert_eq!(result[1].reference, "1 Thessalonians 5:19-21 KJV");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_scripture_references() {
|
||||
let input = "Some text with Joel 2:28 KJV and 1 Thessalonians 5:19-21 KJV references";
|
||||
let result = extract_scripture_references(input);
|
||||
|
||||
assert_eq!(result, "Joel 2:28, 1 Thessalonians 5:19-21");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_sermon_share_text() {
|
||||
let result = create_sermon_share_text(
|
||||
"Test Sermon",
|
||||
"John Doe",
|
||||
Some("https://example.com/video"),
|
||||
Some("https://example.com/audio")
|
||||
);
|
||||
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0], "Check out this sermon: \"Test Sermon\" by John Doe");
|
||||
assert_eq!(result[1], "https://example.com/video");
|
||||
}
|
||||
}
|
249
src/utils/validation.rs
Normal file
249
src/utils/validation.rs
Normal file
|
@ -0,0 +1,249 @@
|
|||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::{DateTime, Utc, NaiveDateTime};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ValidationResult {
|
||||
pub is_valid: bool,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl ValidationResult {
|
||||
pub fn valid() -> Self {
|
||||
Self {
|
||||
is_valid: true,
|
||||
errors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn invalid(errors: Vec<String>) -> Self {
|
||||
Self {
|
||||
is_valid: false,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_error(&mut self, error: String) {
|
||||
self.errors.push(error);
|
||||
self.is_valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContactFormData {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub phone: String,
|
||||
pub message: String,
|
||||
pub subject: String,
|
||||
}
|
||||
|
||||
/// Validate email address using regex
|
||||
pub fn is_valid_email(email: &str) -> bool {
|
||||
let email_regex = Regex::new(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$").unwrap();
|
||||
email_regex.is_match(email)
|
||||
}
|
||||
|
||||
/// Validate phone number - must be exactly 10 digits
|
||||
pub fn is_valid_phone(phone: &str) -> bool {
|
||||
let digits_only: String = phone.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
digits_only.len() == 10
|
||||
}
|
||||
|
||||
/// Validate contact form with all business rules
|
||||
pub fn validate_contact_form(form_data: &ContactFormData) -> ValidationResult {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let trimmed_name = form_data.name.trim();
|
||||
let trimmed_email = form_data.email.trim();
|
||||
let trimmed_phone = form_data.phone.trim();
|
||||
let trimmed_message = form_data.message.trim();
|
||||
|
||||
// Name validation
|
||||
if trimmed_name.is_empty() || trimmed_name.len() < 2 {
|
||||
errors.push("Name must be at least 2 characters".to_string());
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if trimmed_email.is_empty() {
|
||||
errors.push("Email is required".to_string());
|
||||
} else if !is_valid_email(trimmed_email) {
|
||||
errors.push("Please enter a valid email address".to_string());
|
||||
}
|
||||
|
||||
// Phone validation (optional, but if provided must be valid)
|
||||
if !trimmed_phone.is_empty() && !is_valid_phone(trimmed_phone) {
|
||||
errors.push("Please enter a valid phone number".to_string());
|
||||
}
|
||||
|
||||
// Message validation
|
||||
if trimmed_message.is_empty() {
|
||||
errors.push("Message is required".to_string());
|
||||
} else if trimmed_message.len() < 10 {
|
||||
errors.push("Message must be at least 10 characters".to_string());
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
ValidationResult::valid()
|
||||
} else {
|
||||
ValidationResult::invalid(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize and trim form input
|
||||
pub fn sanitize_form_input(input: &str) -> String {
|
||||
input.trim().to_string()
|
||||
}
|
||||
|
||||
/// Parse date from various frontend formats
|
||||
/// Supports: "2025-06-28T23:00", "2025-06-28 23:00", and RFC3339 formats
|
||||
pub fn parse_datetime_flexible(date_str: &str) -> Option<DateTime<Utc>> {
|
||||
let trimmed = date_str.trim();
|
||||
|
||||
// First try RFC3339/ISO 8601 with timezone info
|
||||
if trimmed.contains('Z') || trimmed.contains('+') || trimmed.contains('-') && trimmed.rfind('-').map_or(false, |i| i > 10) {
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(trimmed) {
|
||||
return Some(dt.with_timezone(&Utc));
|
||||
}
|
||||
|
||||
// Try ISO 8601 with 'Z' suffix
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.3fZ") {
|
||||
return Some(dt.with_timezone(&Utc));
|
||||
}
|
||||
|
||||
// Try ISO 8601 without milliseconds but with Z
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%SZ") {
|
||||
return Some(dt.with_timezone(&Utc));
|
||||
}
|
||||
}
|
||||
|
||||
// Try local datetime formats (no timezone info) - treat as UTC
|
||||
let local_formats = [
|
||||
"%Y-%m-%dT%H:%M:%S%.3f", // ISO 8601 with milliseconds, no timezone
|
||||
"%Y-%m-%dT%H:%M:%S", // ISO 8601 no milliseconds, no timezone
|
||||
"%Y-%m-%dT%H:%M", // ISO 8601 no seconds, no timezone (frontend format)
|
||||
"%Y-%m-%d %H:%M:%S", // Space separated with seconds
|
||||
"%Y-%m-%d %H:%M", // Space separated no seconds
|
||||
"%m/%d/%Y %H:%M:%S", // US format with seconds
|
||||
"%m/%d/%Y %H:%M", // US format no seconds
|
||||
];
|
||||
|
||||
for format in &local_formats {
|
||||
if let Ok(naive_dt) = NaiveDateTime::parse_from_str(trimmed, format) {
|
||||
return Some(DateTime::from_naive_utc_and_offset(naive_dt, Utc));
|
||||
}
|
||||
}
|
||||
|
||||
// Try date-only formats (no time) - set time to midnight UTC
|
||||
let date_formats = [
|
||||
"%Y-%m-%d", // ISO date
|
||||
"%m/%d/%Y", // US date
|
||||
"%d/%m/%Y", // European date
|
||||
];
|
||||
|
||||
for format in &date_formats {
|
||||
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(trimmed, format) {
|
||||
return Some(DateTime::from_naive_utc_and_offset(
|
||||
naive_date.and_hms_opt(0, 0, 0).unwrap(),
|
||||
Utc,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Validate datetime string can be parsed
|
||||
pub fn is_valid_datetime(datetime_str: &str) -> bool {
|
||||
parse_datetime_flexible(datetime_str).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_email() {
|
||||
assert!(is_valid_email("test@example.com"));
|
||||
assert!(is_valid_email("user.name+tag@domain.co.uk"));
|
||||
assert!(!is_valid_email("invalid.email"));
|
||||
assert!(!is_valid_email("@domain.com"));
|
||||
assert!(!is_valid_email("user@"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_valid_phone() {
|
||||
assert!(is_valid_phone("1234567890"));
|
||||
assert!(is_valid_phone("(123) 456-7890"));
|
||||
assert!(is_valid_phone("123-456-7890"));
|
||||
assert!(!is_valid_phone("12345"));
|
||||
assert!(!is_valid_phone("12345678901"));
|
||||
assert!(!is_valid_phone("abc1234567"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contact_form_validation() {
|
||||
let valid_form = ContactFormData {
|
||||
name: "John Doe".to_string(),
|
||||
email: "john@example.com".to_string(),
|
||||
phone: "1234567890".to_string(),
|
||||
message: "This is a test message with enough characters.".to_string(),
|
||||
subject: "Test Subject".to_string(),
|
||||
};
|
||||
|
||||
let result = validate_contact_form(&valid_form);
|
||||
assert!(result.is_valid);
|
||||
assert!(result.errors.is_empty());
|
||||
|
||||
let invalid_form = ContactFormData {
|
||||
name: "A".to_string(), // Too short
|
||||
email: "invalid-email".to_string(), // Invalid email
|
||||
phone: "123".to_string(), // Invalid phone
|
||||
message: "Short".to_string(), // Too short message
|
||||
subject: "".to_string(),
|
||||
};
|
||||
|
||||
let result = validate_contact_form(&invalid_form);
|
||||
assert!(!result.is_valid);
|
||||
assert_eq!(result.errors.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_datetime_flexible() {
|
||||
// Test frontend format (the main case we're solving)
|
||||
assert!(parse_datetime_flexible("2025-06-28T23:00").is_some());
|
||||
|
||||
// Test RFC3339 with Z
|
||||
assert!(parse_datetime_flexible("2024-01-15T14:30:00Z").is_some());
|
||||
|
||||
// Test RFC3339 with timezone offset
|
||||
assert!(parse_datetime_flexible("2024-01-15T14:30:00-05:00").is_some());
|
||||
|
||||
// Test ISO 8601 without timezone (should work as local time)
|
||||
assert!(parse_datetime_flexible("2024-01-15T14:30:00").is_some());
|
||||
|
||||
// Test with milliseconds
|
||||
assert!(parse_datetime_flexible("2024-01-15T14:30:00.000Z").is_some());
|
||||
|
||||
// Test space separated
|
||||
assert!(parse_datetime_flexible("2024-01-15 14:30:00").is_some());
|
||||
|
||||
// Test date only
|
||||
assert!(parse_datetime_flexible("2024-01-15").is_some());
|
||||
|
||||
// Test US format
|
||||
assert!(parse_datetime_flexible("01/15/2024 14:30").is_some());
|
||||
|
||||
// Test invalid format
|
||||
assert!(parse_datetime_flexible("invalid-date").is_none());
|
||||
assert!(parse_datetime_flexible("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_datetime() {
|
||||
assert!(is_valid_datetime("2025-06-28T23:00"));
|
||||
assert!(is_valid_datetime("2024-01-15T14:30:00Z"));
|
||||
assert!(!is_valid_datetime("invalid-date"));
|
||||
assert!(!is_valid_datetime(""));
|
||||
}
|
||||
}
|
138
swift_usage_example.swift
Normal file
138
swift_usage_example.swift
Normal file
|
@ -0,0 +1,138 @@
|
|||
// Swift Usage Example for New Config Functions
|
||||
// This shows how to use the new RTSDA-compliant config functions in iOS
|
||||
|
||||
import Foundation
|
||||
|
||||
// WRONG - Old way (violates RTSDA architecture)
|
||||
/*
|
||||
private func loadChurchConfig() {
|
||||
let configJson = fetchConfigJson() // Gets raw JSON from Rust
|
||||
|
||||
// THIS IS BUSINESS LOGIC AND SHOULD BE IN RUST!
|
||||
if let configData = configJson.data(using: .utf8),
|
||||
let config = try? JSONDecoder().decode(ChurchConfig.self, from: configData) {
|
||||
self.churchConfig = config
|
||||
} else {
|
||||
print("Failed to load church config")
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// RIGHT - New way (follows RTSDA architecture)
|
||||
class ChurchConfigManager: ObservableObject {
|
||||
@Published var churchName: String = ""
|
||||
@Published var contactPhone: String = ""
|
||||
@Published var contactEmail: String = ""
|
||||
@Published var brandColor: String = ""
|
||||
@Published var aboutText: String = ""
|
||||
@Published var donationUrl: String = ""
|
||||
@Published var churchAddress: String = ""
|
||||
@Published var coordinates: [Double] = [0.0, 0.0]
|
||||
@Published var websiteUrl: String = ""
|
||||
@Published var facebookUrl: String = ""
|
||||
@Published var youtubeUrl: String = ""
|
||||
@Published var instagramUrl: String = ""
|
||||
@Published var missionStatement: String = ""
|
||||
|
||||
func loadConfig() {
|
||||
// ALL business logic (JSON parsing, error handling, fallbacks) is in Rust
|
||||
// Swift just calls Rust functions and gets parsed values directly
|
||||
self.churchName = getChurchName()
|
||||
self.contactPhone = getContactPhone()
|
||||
self.contactEmail = getContactEmail()
|
||||
self.brandColor = getBrandColor()
|
||||
self.aboutText = getAboutText()
|
||||
self.donationUrl = getDonationUrl()
|
||||
self.churchAddress = getChurchAddress()
|
||||
self.coordinates = getCoordinates()
|
||||
self.websiteUrl = getWebsiteUrl()
|
||||
self.facebookUrl = getFacebookUrl()
|
||||
self.youtubeUrl = getYoutubeUrl()
|
||||
self.instagramUrl = getInstagramUrl()
|
||||
self.missionStatement = getMissionStatement()
|
||||
}
|
||||
}
|
||||
|
||||
// Example SwiftUI View
|
||||
struct ConfigDisplayView: View {
|
||||
@StateObject private var configManager = ChurchConfigManager()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(configManager.churchName)
|
||||
.font(.title)
|
||||
.foregroundColor(Color(hex: configManager.brandColor))
|
||||
|
||||
Text(configManager.aboutText)
|
||||
.font(.body)
|
||||
|
||||
Text("Contact: \(configManager.contactPhone)")
|
||||
Text("Email: \(configManager.contactEmail)")
|
||||
Text("Address: \(configManager.churchAddress)")
|
||||
|
||||
if !configManager.donationUrl.isEmpty {
|
||||
Link("Donate", destination: URL(string: configManager.donationUrl)!)
|
||||
.foregroundColor(Color(hex: configManager.brandColor))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.onAppear {
|
||||
configManager.loadConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
var int: UInt64 = 0
|
||||
Scanner(string: hex).scanHexInt64(&int)
|
||||
let a, r, g, b: UInt64
|
||||
switch hex.count {
|
||||
case 3: // RGB (12-bit)
|
||||
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||
case 6: // RGB (24-bit)
|
||||
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||
case 8: // ARGB (32-bit)
|
||||
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||
default:
|
||||
(a, r, g, b) = (1, 1, 1, 0)
|
||||
}
|
||||
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double(r) / 255,
|
||||
green: Double(g) / 255,
|
||||
blue: Double(b) / 255,
|
||||
opacity: Double(a) / 255
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Benefits of this approach:
|
||||
|
||||
1. ✅ RTSDA Compliant - ALL business logic in Rust
|
||||
2. ✅ No JSON parsing in Swift - Rust handles it all
|
||||
3. ✅ Proper error handling with fallbacks in Rust
|
||||
4. ✅ Consistent behavior across iOS and Android
|
||||
5. ✅ Easy to test - just call Rust functions
|
||||
6. ✅ No model synchronization issues
|
||||
7. ✅ Automatic fallback values if config fails to load
|
||||
8. ✅ Cleaner Swift code - just UI logic
|
||||
|
||||
Functions available:
|
||||
- getChurchName() -> String
|
||||
- getContactPhone() -> String
|
||||
- getContactEmail() -> String
|
||||
- getBrandColor() -> String
|
||||
- getAboutText() -> String
|
||||
- getDonationUrl() -> String
|
||||
- getChurchAddress() -> String
|
||||
- getCoordinates() -> [Double] // [latitude, longitude]
|
||||
- getWebsiteUrl() -> String
|
||||
- getFacebookUrl() -> String
|
||||
- getYoutubeUrl() -> String
|
||||
- getInstagramUrl() -> String
|
||||
- getMissionStatement() -> String
|
||||
*/
|
39
test_config_functions.rs
Normal file
39
test_config_functions.rs
Normal file
|
@ -0,0 +1,39 @@
|
|||
// Test script for new config functions
|
||||
use church_core::uniffi_wrapper::{
|
||||
get_church_name,
|
||||
get_contact_phone,
|
||||
get_contact_email,
|
||||
get_brand_color,
|
||||
get_about_text,
|
||||
get_donation_url,
|
||||
get_church_address,
|
||||
get_coordinates,
|
||||
get_website_url,
|
||||
get_facebook_url,
|
||||
get_youtube_url,
|
||||
get_instagram_url,
|
||||
get_mission_statement,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
println!("🔍 Testing church-core config functions...\n");
|
||||
|
||||
println!("Church Name: {}", get_church_name());
|
||||
println!("Contact Phone: {}", get_contact_phone());
|
||||
println!("Contact Email: {}", get_contact_email());
|
||||
println!("Brand Color: {}", get_brand_color());
|
||||
println!("About Text: {}", get_about_text());
|
||||
println!("Donation URL: {}", get_donation_url());
|
||||
println!("Church Address: {}", get_church_address());
|
||||
|
||||
let coords = get_coordinates();
|
||||
println!("Coordinates: [{}, {}]", coords[0], coords[1]);
|
||||
|
||||
println!("Website URL: {}", get_website_url());
|
||||
println!("Facebook URL: {}", get_facebook_url());
|
||||
println!("YouTube URL: {}", get_youtube_url());
|
||||
println!("Instagram URL: {}", get_instagram_url());
|
||||
println!("Mission Statement: {}", get_mission_statement());
|
||||
|
||||
println!("\n✅ All config functions working properly with fallback values!");
|
||||
}
|
31
test_sermon_json.rs
Normal file
31
test_sermon_json.rs
Normal file
|
@ -0,0 +1,31 @@
|
|||
use church_core::{ChurchApiClient, ChurchCoreConfig};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let config = ChurchCoreConfig::new();
|
||||
|
||||
match ChurchApiClient::new(config) {
|
||||
Ok(client) => {
|
||||
match client.get_recent_sermons(Some(5)).await {
|
||||
Ok(sermons) => {
|
||||
let client_sermons: Vec<church_core::ClientSermon> = sermons
|
||||
.into_iter()
|
||||
.map(church_core::ClientSermon::from)
|
||||
.collect();
|
||||
|
||||
println!("🎬 Raw JSON output:");
|
||||
match serde_json::to_string_pretty(&client_sermons) {
|
||||
Ok(json) => println!("{}", json),
|
||||
Err(e) => println!("❌ JSON serialization error: {}", e),
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
println!("❌ Failed to get sermons: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("❌ Failed to create client: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
73
tests/calendar_test.rs
Normal file
73
tests/calendar_test.rs
Normal file
|
@ -0,0 +1,73 @@
|
|||
use church_core::create_calendar_event_data;
|
||||
|
||||
#[test]
|
||||
fn test_calendar_event_parsing() {
|
||||
// Test with various timestamp formats that might come from the iOS app
|
||||
|
||||
// Test 1: ISO 8601 with timezone
|
||||
let event_with_tz = r#"{
|
||||
"title": "Test Event",
|
||||
"description": "Test Description",
|
||||
"location": "Test Location",
|
||||
"start_time": "2025-01-15T14:30:00-05:00",
|
||||
"end_time": "2025-01-15T16:00:00-05:00"
|
||||
}"#;
|
||||
|
||||
println!("🧪 Testing ISO 8601 with timezone:");
|
||||
let result1 = create_calendar_event_data(event_with_tz.to_string());
|
||||
println!("Result: {}", result1);
|
||||
|
||||
// Test 2: ISO 8601 without timezone
|
||||
let event_no_tz = r#"{
|
||||
"title": "Test Event",
|
||||
"description": "Test Description",
|
||||
"location": "Test Location",
|
||||
"start_time": "2025-01-15T14:30:00",
|
||||
"end_time": "2025-01-15T16:00:00"
|
||||
}"#;
|
||||
|
||||
println!("\n🧪 Testing ISO 8601 without timezone:");
|
||||
let result2 = create_calendar_event_data(event_no_tz.to_string());
|
||||
println!("Result: {}", result2);
|
||||
|
||||
// Test 3: Date with space separator
|
||||
let event_space = r#"{
|
||||
"title": "Test Event",
|
||||
"description": "Test Description",
|
||||
"location": "Test Location",
|
||||
"start_time": "2025-01-15 14:30:00",
|
||||
"end_time": "2025-01-15 16:00:00"
|
||||
}"#;
|
||||
|
||||
println!("\n🧪 Testing space-separated format:");
|
||||
let result3 = create_calendar_event_data(event_space.to_string());
|
||||
println!("Result: {}", result3);
|
||||
|
||||
// Test 4: camelCase field names (Swift encoding style)
|
||||
let event_camel_case = r#"{
|
||||
"title": "Test Event",
|
||||
"description": "Test Description",
|
||||
"location": "Test Location",
|
||||
"startTime": "2025-01-15T14:30:00",
|
||||
"endTime": "2025-01-15T16:00:00"
|
||||
}"#;
|
||||
|
||||
println!("\n🧪 Testing camelCase field names:");
|
||||
let result4 = create_calendar_event_data(event_camel_case.to_string());
|
||||
println!("Result: {}", result4);
|
||||
|
||||
// Test 5: What might actually come from iOS encoding of ChurchEvent
|
||||
let event_ios_style = r#"{
|
||||
"id": "test123",
|
||||
"title": "Test Event",
|
||||
"description": "Test Description",
|
||||
"startTime": "2025-01-15T14:30:00Z",
|
||||
"endTime": "2025-01-15T16:00:00Z",
|
||||
"location": "Test Location",
|
||||
"category": "general"
|
||||
}"#;
|
||||
|
||||
println!("\n🧪 Testing iOS-style encoding:");
|
||||
let result5 = create_calendar_event_data(event_ios_style.to_string());
|
||||
println!("Result: {}", result5);
|
||||
}
|
71
tests/config_functions_test.rs
Normal file
71
tests/config_functions_test.rs
Normal file
|
@ -0,0 +1,71 @@
|
|||
use church_core::uniffi_wrapper::{
|
||||
get_church_name,
|
||||
get_contact_phone,
|
||||
get_contact_email,
|
||||
get_brand_color,
|
||||
get_about_text,
|
||||
get_donation_url,
|
||||
get_church_address,
|
||||
get_coordinates,
|
||||
get_website_url,
|
||||
get_facebook_url,
|
||||
get_youtube_url,
|
||||
get_instagram_url,
|
||||
get_mission_statement,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_config_functions_return_fallback_values() {
|
||||
println!("🔍 Testing church-core config functions...");
|
||||
|
||||
// Test that all functions return non-empty strings (fallback values)
|
||||
let church_name = get_church_name();
|
||||
assert!(!church_name.is_empty(), "Church name should have fallback");
|
||||
println!("✅ Church Name: {}", church_name);
|
||||
|
||||
let contact_phone = get_contact_phone();
|
||||
assert!(!contact_phone.is_empty(), "Contact phone should have fallback");
|
||||
println!("✅ Contact Phone: {}", contact_phone);
|
||||
|
||||
let contact_email = get_contact_email();
|
||||
assert!(!contact_email.is_empty(), "Contact email should have fallback");
|
||||
println!("✅ Contact Email: {}", contact_email);
|
||||
|
||||
let brand_color = get_brand_color();
|
||||
assert!(!brand_color.is_empty(), "Brand color should have fallback");
|
||||
assert!(brand_color.starts_with("#"), "Brand color should be hex");
|
||||
println!("✅ Brand Color: {}", brand_color);
|
||||
|
||||
let about_text = get_about_text();
|
||||
assert!(!about_text.is_empty(), "About text should have fallback");
|
||||
println!("✅ About Text: {}", about_text);
|
||||
|
||||
let donation_url = get_donation_url();
|
||||
assert!(!donation_url.is_empty(), "Donation URL should have fallback");
|
||||
assert!(donation_url.starts_with("http"), "Donation URL should be valid");
|
||||
println!("✅ Donation URL: {}", donation_url);
|
||||
|
||||
let church_address = get_church_address();
|
||||
assert!(!church_address.is_empty(), "Church address should have fallback");
|
||||
println!("✅ Church Address: {}", church_address);
|
||||
|
||||
let coords = get_coordinates();
|
||||
// Coordinates should be empty when no coordinates are configured
|
||||
assert_eq!(coords.len(), 0, "Coordinates should be empty when not configured");
|
||||
println!("✅ Coordinates: {:?} (empty when not configured)", coords);
|
||||
|
||||
let website_url = get_website_url();
|
||||
assert!(!website_url.is_empty(), "Website URL should have fallback");
|
||||
println!("✅ Website URL: {}", website_url);
|
||||
|
||||
let mission_statement = get_mission_statement();
|
||||
assert!(!mission_statement.is_empty(), "Mission statement should have fallback");
|
||||
println!("✅ Mission Statement: {}", mission_statement);
|
||||
|
||||
// Social media URLs can be empty
|
||||
let _facebook_url = get_facebook_url();
|
||||
let _youtube_url = get_youtube_url();
|
||||
let _instagram_url = get_instagram_url();
|
||||
|
||||
println!("🎉 All config functions working properly!");
|
||||
}
|
208
tests/error_handling_tests.rs
Normal file
208
tests/error_handling_tests.rs
Normal file
|
@ -0,0 +1,208 @@
|
|||
use church_core::{
|
||||
ChurchApiClient, ChurchCoreConfig,
|
||||
error::ChurchApiError,
|
||||
};
|
||||
use mockito::{self, mock};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(test)]
|
||||
mod error_handling_tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_client() -> ChurchApiClient {
|
||||
let config = ChurchCoreConfig::new()
|
||||
.with_base_url(&mockito::server_url())
|
||||
.with_timeout(Duration::from_millis(100))
|
||||
.with_retry_attempts(1)
|
||||
.with_offline_mode(false);
|
||||
|
||||
ChurchApiClient::new(config).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_404_error() {
|
||||
let _m = mock("GET", "/events/nonexistent")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": false,
|
||||
"error": "Not found"
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let result = client.get_event("nonexistent").await;
|
||||
|
||||
// For get_event, 404 should return None, not an error
|
||||
assert!(result.is_ok());
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_401_unauthorized() {
|
||||
let _m = mock("POST", "/events")
|
||||
.with_status(401)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": false,
|
||||
"error": "Unauthorized access"
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let new_event = church_core::models::NewEvent {
|
||||
title: "Protected Event".to_string(),
|
||||
description: "Requires authentication".to_string(),
|
||||
start_time: chrono::Utc::now(),
|
||||
end_time: chrono::Utc::now() + chrono::Duration::hours(1),
|
||||
location: "Secure Location".to_string(),
|
||||
location_url: None,
|
||||
image: None,
|
||||
category: church_core::models::EventCategory::Other,
|
||||
is_featured: false,
|
||||
recurring_type: None,
|
||||
tags: None,
|
||||
contact_email: None,
|
||||
contact_phone: None,
|
||||
registration_url: None,
|
||||
max_attendees: None,
|
||||
};
|
||||
|
||||
let result = client.create_event(new_event).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
if let Err(error) = result {
|
||||
assert!(matches!(error, ChurchApiError::Auth(_)));
|
||||
assert!(error.is_auth_error());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_403_forbidden() {
|
||||
let _m = mock("PUT", "/config")
|
||||
.with_status(403)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": false,
|
||||
"error": "Forbidden - insufficient permissions"
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let config = church_core::models::ChurchConfig {
|
||||
church_name: Some("Test Church".to_string()),
|
||||
church_address: None,
|
||||
contact_phone: None,
|
||||
contact_email: None,
|
||||
website_url: None,
|
||||
google_maps_url: None,
|
||||
facebook_url: None,
|
||||
youtube_url: None,
|
||||
instagram_url: None,
|
||||
about_text: None,
|
||||
mission_statement: None,
|
||||
service_times: None,
|
||||
pastoral_staff: None,
|
||||
ministries: None,
|
||||
app_settings: None,
|
||||
emergency_contacts: None,
|
||||
};
|
||||
|
||||
let result = client.update_config(config).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
if let Err(error) = result {
|
||||
assert!(matches!(error, ChurchApiError::PermissionDenied));
|
||||
assert!(error.is_auth_error());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_http_500_server_error() {
|
||||
let _m = mock("GET", "/events/upcoming")
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": false,
|
||||
"error": "Internal server error"
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let result = client.get_upcoming_events(None).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
if let Err(error) = result {
|
||||
assert!(matches!(error, ChurchApiError::Http(_)));
|
||||
assert!(error.is_network_error());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_invalid_json_response() {
|
||||
let _m = mock("GET", "/events/upcoming")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("invalid json {")
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let result = client.get_upcoming_events(None).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
// Invalid JSON will likely cause an HTTP parsing error instead of JSON error
|
||||
// since the response can't be properly parsed as JSON
|
||||
if let Err(error) = result {
|
||||
// Could be either JSON or HTTP error depending on how reqwest handles it
|
||||
assert!(matches!(error, ChurchApiError::Json(_)) || matches!(error, ChurchApiError::Http(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_error_response() {
|
||||
let _m = mock("GET", "/events/upcoming")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": false,
|
||||
"data": null,
|
||||
"error": "Custom API error message"
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let result = client.get_upcoming_events(None).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
if let Err(error) = result {
|
||||
assert!(matches!(error, ChurchApiError::Api(_)));
|
||||
if let ChurchApiError::Api(message) = error {
|
||||
assert_eq!(message, "Custom API error message");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_classification() {
|
||||
let auth_error = ChurchApiError::Auth("Invalid token".to_string());
|
||||
assert!(!auth_error.is_network_error());
|
||||
assert!(!auth_error.is_temporary());
|
||||
assert!(auth_error.is_auth_error());
|
||||
|
||||
let rate_limit_error = ChurchApiError::RateLimit;
|
||||
assert!(!rate_limit_error.is_network_error());
|
||||
assert!(rate_limit_error.is_temporary());
|
||||
assert!(!rate_limit_error.is_auth_error());
|
||||
|
||||
let permission_error = ChurchApiError::PermissionDenied;
|
||||
assert!(!permission_error.is_network_error());
|
||||
assert!(!permission_error.is_temporary());
|
||||
assert!(permission_error.is_auth_error());
|
||||
|
||||
let not_found_error = ChurchApiError::NotFound;
|
||||
assert!(!not_found_error.is_network_error());
|
||||
assert!(!not_found_error.is_temporary());
|
||||
assert!(!not_found_error.is_auth_error());
|
||||
}
|
||||
}
|
367
tests/integration_tests.rs
Normal file
367
tests/integration_tests.rs
Normal file
|
@ -0,0 +1,367 @@
|
|||
use church_core::{
|
||||
ChurchApiClient, ChurchCoreConfig,
|
||||
models::*,
|
||||
error::ChurchApiError,
|
||||
};
|
||||
use mockito::{self, mock};
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tokio_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_client() -> ChurchApiClient {
|
||||
let config = ChurchCoreConfig::new()
|
||||
.with_base_url(&mockito::server_url())
|
||||
.with_timeout(Duration::from_secs(5))
|
||||
.with_retry_attempts(1)
|
||||
.with_offline_mode(false);
|
||||
|
||||
ChurchApiClient::new(config).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_upcoming_events_success() {
|
||||
let _m = mock("GET", "/events/upcoming")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": true,
|
||||
"data": [{
|
||||
"id": "event-1",
|
||||
"title": "Sunday Service",
|
||||
"description": "Weekly worship service",
|
||||
"start_time": "2024-07-14T11:00:00Z",
|
||||
"end_time": "2024-07-14T12:30:00Z",
|
||||
"location": "Main Sanctuary",
|
||||
"location_url": null,
|
||||
"image": null,
|
||||
"thumbnail": null,
|
||||
"category": "service",
|
||||
"is_featured": true,
|
||||
"recurring_type": "weekly",
|
||||
"tags": ["worship", "sunday"],
|
||||
"contact_email": null,
|
||||
"contact_phone": null,
|
||||
"registration_url": null,
|
||||
"max_attendees": null,
|
||||
"current_attendees": null,
|
||||
"created_at": "2024-07-01T10:00:00Z",
|
||||
"updated_at": "2024-07-01T10:00:00Z"
|
||||
}]
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let events = client.get_upcoming_events(None).await.unwrap();
|
||||
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].id, "event-1");
|
||||
assert_eq!(events[0].title, "Sunday Service");
|
||||
assert_eq!(events[0].category, EventCategory::Service);
|
||||
assert!(events[0].is_featured);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_upcoming_events_with_limit() {
|
||||
let _m = mock("GET", "/events/upcoming?limit=5")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": true,
|
||||
"data": []
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let events = client.get_upcoming_events(Some(5)).await.unwrap();
|
||||
assert_eq!(events.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_event_success() {
|
||||
let _m = mock("GET", "/events/event-123")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "event-123",
|
||||
"title": "Bible Study",
|
||||
"description": "Weekly Bible study group",
|
||||
"start_time": "2024-07-15T19:00:00Z",
|
||||
"end_time": "2024-07-15T21:00:00Z",
|
||||
"location": "Fellowship Hall",
|
||||
"location_url": null,
|
||||
"image": null,
|
||||
"thumbnail": null,
|
||||
"category": "education",
|
||||
"is_featured": false,
|
||||
"recurring_type": null,
|
||||
"tags": ["bible", "study"],
|
||||
"contact_email": "study@church.org",
|
||||
"contact_phone": null,
|
||||
"registration_url": null,
|
||||
"max_attendees": 20,
|
||||
"current_attendees": 12,
|
||||
"created_at": "2024-07-01T10:00:00Z",
|
||||
"updated_at": "2024-07-01T10:00:00Z"
|
||||
}
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let event = client.get_event("event-123").await.unwrap();
|
||||
|
||||
assert!(event.is_some());
|
||||
let event = event.unwrap();
|
||||
assert_eq!(event.id, "event-123");
|
||||
assert_eq!(event.title, "Bible Study");
|
||||
assert_eq!(event.category, EventCategory::Education);
|
||||
assert_eq!(event.spots_remaining(), Some(8));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_event_not_found() {
|
||||
let _m = mock("GET", "/events/nonexistent")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": false,
|
||||
"error": "Event not found"
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let event = client.get_event("nonexistent").await.unwrap();
|
||||
assert!(event.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_event_success() {
|
||||
let _m = mock("POST", "/events")
|
||||
.with_status(201)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": true,
|
||||
"data": "new-event-id"
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let new_event = NewEvent {
|
||||
title: "New Event".to_string(),
|
||||
description: "A new event".to_string(),
|
||||
start_time: chrono::Utc::now(),
|
||||
end_time: chrono::Utc::now() + chrono::Duration::hours(2),
|
||||
location: "Main Hall".to_string(),
|
||||
location_url: None,
|
||||
image: None,
|
||||
category: EventCategory::Ministry,
|
||||
is_featured: false,
|
||||
recurring_type: None,
|
||||
tags: None,
|
||||
contact_email: None,
|
||||
contact_phone: None,
|
||||
registration_url: None,
|
||||
max_attendees: None,
|
||||
};
|
||||
|
||||
let event_id = client.create_event(new_event).await.unwrap();
|
||||
assert_eq!(event_id, "new-event-id");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_current_bulletin_success() {
|
||||
let _m = mock("GET", "/bulletins/current")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "bulletin-current",
|
||||
"title": "This Week's Bulletin",
|
||||
"date": "2024-07-13",
|
||||
"sabbath_school": "9:30 AM",
|
||||
"divine_worship": "11:00 AM",
|
||||
"scripture_reading": "Psalm 23",
|
||||
"sunset": "7:45 PM",
|
||||
"pdf_path": "/bulletins/current.pdf",
|
||||
"cover_image": "/images/cover.jpg",
|
||||
"is_active": true,
|
||||
"announcements": [{
|
||||
"id": "ann-1",
|
||||
"title": "Potluck Next Week",
|
||||
"content": "Join us for fellowship after service",
|
||||
"category": "social",
|
||||
"is_urgent": false,
|
||||
"contact_info": null,
|
||||
"expires_at": "2025-12-31T00:00:00Z"
|
||||
}],
|
||||
"hymns": [{
|
||||
"number": 1,
|
||||
"title": "Holy, Holy, Holy",
|
||||
"category": "opening",
|
||||
"verses": [1, 2, 3]
|
||||
}],
|
||||
"special_music": null,
|
||||
"offering_type": "Local Church Budget",
|
||||
"sermon_title": "Walking by Faith",
|
||||
"speaker": "Pastor Smith",
|
||||
"liturgy": null,
|
||||
"created_at": "2024-07-01T10:00:00Z",
|
||||
"updated_at": "2024-07-13T08:00:00Z"
|
||||
}
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let bulletin = client.get_current_bulletin().await.unwrap();
|
||||
|
||||
assert!(bulletin.is_some());
|
||||
let bulletin = bulletin.unwrap();
|
||||
assert_eq!(bulletin.id, "bulletin-current");
|
||||
assert_eq!(bulletin.title, "This Week's Bulletin");
|
||||
assert!(bulletin.has_pdf());
|
||||
assert!(bulletin.has_cover_image());
|
||||
assert_eq!(bulletin.active_announcements().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_current_bulletin_not_found() {
|
||||
let _m = mock("GET", "/bulletins/current")
|
||||
.with_status(404)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": false,
|
||||
"error": "No current bulletin found"
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let bulletin = client.get_current_bulletin().await.unwrap();
|
||||
assert!(bulletin.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_config_success() {
|
||||
let _m = mock("GET", "/config")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"church_name": "Test Church",
|
||||
"church_address": "123 Faith Street, Hometown, ST 12345",
|
||||
"contact_phone": "555-123-4567",
|
||||
"contact_email": "info@testchurch.org",
|
||||
"website_url": "https://testchurch.org",
|
||||
"google_maps_url": "https://maps.google.com/test",
|
||||
"facebook_url": "https://facebook.com/testchurch",
|
||||
"youtube_url": null,
|
||||
"instagram_url": null,
|
||||
"about_text": "A welcoming church community",
|
||||
"mission_statement": "Spreading God's love",
|
||||
"service_times": {
|
||||
"sabbath_school": "9:30 AM",
|
||||
"divine_worship": "11:00 AM",
|
||||
"prayer_meeting": "7:00 PM Wednesday",
|
||||
"youth_service": null,
|
||||
"special_services": null
|
||||
},
|
||||
"pastoral_staff": [{
|
||||
"name": "Pastor John Smith",
|
||||
"title": "Senior Pastor",
|
||||
"email": "pastor@testchurch.org",
|
||||
"phone": "555-123-4568",
|
||||
"photo": null,
|
||||
"bio": "Leading our church with love",
|
||||
"responsibilities": ["Preaching", "Pastoral Care"]
|
||||
}],
|
||||
"ministries": null,
|
||||
"app_settings": {
|
||||
"enable_notifications": true,
|
||||
"enable_calendar_sync": true,
|
||||
"enable_offline_mode": true,
|
||||
"theme": "system",
|
||||
"default_language": "en",
|
||||
"jellyfin_server": null,
|
||||
"owncast_server": null,
|
||||
"bible_version": "KJV",
|
||||
"hymnal_version": "1985",
|
||||
"cache_duration_minutes": 60,
|
||||
"auto_refresh_interval_minutes": 15
|
||||
},
|
||||
"emergency_contacts": null
|
||||
}
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let config = client.get_config().await.unwrap();
|
||||
|
||||
assert_eq!(config.church_name, Some("Test Church".to_string()));
|
||||
assert_eq!(config.contact_phone, Some("555-123-4567".to_string()));
|
||||
assert_eq!(config.get_display_name(), "Test Church");
|
||||
assert!(config.has_social_media());
|
||||
assert_eq!(config.get_contact_info().len(), 3); // phone, email, address
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_submit_contact_form_success() {
|
||||
let _m = mock("POST", "/contact")
|
||||
.with_status(201)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": true,
|
||||
"data": "contact-submission-123"
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let contact_form = ContactForm::new(
|
||||
"Jane Doe".to_string(),
|
||||
"jane@example.com".to_string(),
|
||||
"Question about baptism".to_string(),
|
||||
"I'm interested in learning about baptism.".to_string(),
|
||||
).with_category(ContactCategory::Baptism);
|
||||
|
||||
let submission_id = client.submit_contact_form(contact_form).await.unwrap();
|
||||
assert_eq!(submission_id, "contact-submission-123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_error_handling() {
|
||||
let _m = mock("GET", "/events/upcoming")
|
||||
.with_status(500)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body(json!({
|
||||
"success": false,
|
||||
"error": "Internal server error"
|
||||
}).to_string())
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let result = client.get_upcoming_events(None).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
if let Err(error) = result {
|
||||
assert!(matches!(error, ChurchApiError::Http(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check() {
|
||||
let _m = mock("GET", "/health")
|
||||
.with_status(200)
|
||||
.with_header("content-type", "application/json")
|
||||
.with_body("OK")
|
||||
.create();
|
||||
|
||||
let client = create_test_client();
|
||||
let is_healthy = client.health_check().await.unwrap();
|
||||
assert!(is_healthy);
|
||||
}
|
||||
}
|
81
tests/simple_calendar_test.rs
Normal file
81
tests/simple_calendar_test.rs
Normal file
|
@ -0,0 +1,81 @@
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use church_core::create_calendar_event_data;
|
||||
|
||||
#[test]
|
||||
fn test_calendar_event_parsing() {
|
||||
// Test with various timestamp formats that might come from the iOS app
|
||||
|
||||
// Test 1: ISO 8601 with timezone
|
||||
let event_with_tz = r#"{
|
||||
"title": "Test Event",
|
||||
"description": "Test Description",
|
||||
"location": "Test Location",
|
||||
"start_time": "2025-01-15T14:30:00-05:00",
|
||||
"end_time": "2025-01-15T16:00:00-05:00"
|
||||
}"#;
|
||||
|
||||
println!("🧪 Testing ISO 8601 with timezone:");
|
||||
let result1 = create_calendar_event_data(event_with_tz.to_string());
|
||||
println!("Result: {}", result1);
|
||||
|
||||
// Test 2: ISO 8601 without timezone
|
||||
let event_no_tz = r#"{
|
||||
"title": "Test Event",
|
||||
"description": "Test Description",
|
||||
"location": "Test Location",
|
||||
"start_time": "2025-01-15T14:30:00",
|
||||
"end_time": "2025-01-15T16:00:00"
|
||||
}"#;
|
||||
|
||||
println!("\n🧪 Testing ISO 8601 without timezone:");
|
||||
let result2 = create_calendar_event_data(event_no_tz.to_string());
|
||||
println!("Result: {}", result2);
|
||||
|
||||
// Test 3: Date with space separator
|
||||
let event_space = r#"{
|
||||
"title": "Test Event",
|
||||
"description": "Test Description",
|
||||
"location": "Test Location",
|
||||
"start_time": "2025-01-15 14:30:00",
|
||||
"end_time": "2025-01-15 16:00:00"
|
||||
}"#;
|
||||
|
||||
println!("\n🧪 Testing space-separated format:");
|
||||
let result3 = create_calendar_event_data(event_space.to_string());
|
||||
println!("Result: {}", result3);
|
||||
|
||||
// Test 4: camelCase field names (Swift encoding style)
|
||||
let event_camel_case = r#"{
|
||||
"title": "Test Event",
|
||||
"description": "Test Description",
|
||||
"location": "Test Location",
|
||||
"startTime": "2025-01-15T14:30:00",
|
||||
"endTime": "2025-01-15T16:00:00"
|
||||
}"#;
|
||||
|
||||
println!("\n🧪 Testing camelCase field names:");
|
||||
let result4 = create_calendar_event_data(event_camel_case.to_string());
|
||||
println!("Result: {}", result4);
|
||||
|
||||
// Test 5: What might actually come from iOS encoding of ChurchEvent
|
||||
let event_ios_style = r#"{
|
||||
"id": "test123",
|
||||
"title": "Test Event",
|
||||
"description": "Test Description",
|
||||
"startTime": "2025-01-15T14:30:00Z",
|
||||
"endTime": "2025-01-15T16:00:00Z",
|
||||
"location": "Test Location",
|
||||
"category": "general"
|
||||
}"#;
|
||||
|
||||
println!("\n🧪 Testing iOS-style encoding:");
|
||||
let result5 = create_calendar_event_data(event_ios_style.to_string());
|
||||
println!("Result: {}", result5);
|
||||
|
||||
// Check that at least one format works
|
||||
assert!(result1.contains("\"success\": true") || result2.contains("\"success\": true") ||
|
||||
result3.contains("\"success\": true") || result4.contains("\"success\": true") ||
|
||||
result5.contains("\"success\": true"), "At least one timestamp format should work");
|
||||
}
|
||||
}
|
489
tests/unit_tests.rs
Normal file
489
tests/unit_tests.rs
Normal file
|
@ -0,0 +1,489 @@
|
|||
use church_core::{
|
||||
models::*,
|
||||
cache::MemoryCache,
|
||||
ChurchCoreConfig,
|
||||
ChurchApiClient,
|
||||
};
|
||||
use chrono::{DateTime, Utc, NaiveDate};
|
||||
use std::time::Duration;
|
||||
use tokio_test;
|
||||
|
||||
#[cfg(test)]
|
||||
mod model_tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_event_creation() {
|
||||
let event = Event {
|
||||
id: "test-id".to_string(),
|
||||
title: "Bible Study".to_string(),
|
||||
description: "Weekly study".to_string(),
|
||||
start_time: Utc::now(),
|
||||
end_time: Utc::now() + chrono::Duration::hours(2),
|
||||
location: "Fellowship Hall".to_string(),
|
||||
location_url: Some("https://maps.google.com".to_string()),
|
||||
image: None,
|
||||
thumbnail: None,
|
||||
category: EventCategory::Education,
|
||||
is_featured: true,
|
||||
recurring_type: Some(RecurringType::Weekly),
|
||||
tags: Some(vec!["bible".to_string(), "study".to_string()]),
|
||||
contact_email: Some("contact@church.org".to_string()),
|
||||
contact_phone: None,
|
||||
registration_url: None,
|
||||
max_attendees: Some(30),
|
||||
current_attendees: Some(15),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
assert_eq!(event.title, "Bible Study");
|
||||
assert_eq!(event.category, EventCategory::Education);
|
||||
assert!(event.is_featured);
|
||||
assert_eq!(event.spots_remaining(), Some(15));
|
||||
assert!(!event.is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_event_time_methods() {
|
||||
let now = Utc::now();
|
||||
let past_time = now - chrono::Duration::hours(1);
|
||||
let future_time = now + chrono::Duration::hours(1);
|
||||
|
||||
// Test upcoming event
|
||||
let upcoming_event = Event {
|
||||
id: "upcoming".to_string(),
|
||||
title: "Future Event".to_string(),
|
||||
description: "".to_string(),
|
||||
start_time: future_time,
|
||||
end_time: future_time + chrono::Duration::hours(1),
|
||||
location: "".to_string(),
|
||||
location_url: None,
|
||||
image: None,
|
||||
thumbnail: None,
|
||||
category: EventCategory::Service,
|
||||
is_featured: false,
|
||||
recurring_type: None,
|
||||
tags: None,
|
||||
contact_email: None,
|
||||
contact_phone: None,
|
||||
registration_url: None,
|
||||
max_attendees: None,
|
||||
current_attendees: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// Test duration calculation
|
||||
assert_eq!(upcoming_event.duration_minutes(), 60);
|
||||
|
||||
// Test past event
|
||||
let past_event = Event {
|
||||
id: "past".to_string(),
|
||||
title: "Past Event".to_string(),
|
||||
description: "".to_string(),
|
||||
start_time: past_time - chrono::Duration::hours(1),
|
||||
end_time: past_time,
|
||||
location: "".to_string(),
|
||||
location_url: None,
|
||||
image: None,
|
||||
thumbnail: None,
|
||||
category: EventCategory::Service,
|
||||
is_featured: false,
|
||||
recurring_type: None,
|
||||
tags: None,
|
||||
contact_email: None,
|
||||
contact_phone: None,
|
||||
registration_url: None,
|
||||
max_attendees: None,
|
||||
current_attendees: None,
|
||||
created_at: past_time,
|
||||
updated_at: past_time,
|
||||
};
|
||||
|
||||
// Test duration calculation for past event
|
||||
assert_eq!(past_event.duration_minutes(), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bulletin_creation() {
|
||||
let bulletin = Bulletin {
|
||||
id: "bulletin-1".to_string(),
|
||||
title: "Weekly Bulletin".to_string(),
|
||||
date: NaiveDate::from_ymd_opt(2024, 1, 6).unwrap(),
|
||||
sabbath_school: "9:30 AM".to_string(),
|
||||
divine_worship: "11:00 AM".to_string(),
|
||||
scripture_reading: "John 3:16".to_string(),
|
||||
sunset: "5:45 PM".to_string(),
|
||||
pdf_path: Some("/bulletins/2024-01-06.pdf".to_string()),
|
||||
cover_image: Some("/images/cover.jpg".to_string()),
|
||||
is_active: true,
|
||||
announcements: Some(vec![
|
||||
Announcement {
|
||||
id: Some("ann-1".to_string()),
|
||||
title: "Potluck Dinner".to_string(),
|
||||
content: "Join us for fellowship".to_string(),
|
||||
category: Some(AnnouncementCategory::Social),
|
||||
is_urgent: false,
|
||||
contact_info: None,
|
||||
expires_at: Some(Utc::now() + chrono::Duration::days(7)),
|
||||
}
|
||||
]),
|
||||
hymns: Some(vec![
|
||||
BulletinHymn {
|
||||
number: 15,
|
||||
title: "Amazing Grace".to_string(),
|
||||
category: Some(HymnCategory::Opening),
|
||||
verses: Some(vec![1, 2, 4]),
|
||||
}
|
||||
]),
|
||||
special_music: Some("Choir Special".to_string()),
|
||||
offering_type: Some("Local Church Budget".to_string()),
|
||||
sermon_title: Some("Walking in Faith".to_string()),
|
||||
speaker: Some("Pastor Smith".to_string()),
|
||||
liturgy: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
assert_eq!(bulletin.title, "Weekly Bulletin");
|
||||
assert!(bulletin.has_pdf());
|
||||
assert!(bulletin.has_cover_image());
|
||||
assert_eq!(bulletin.active_announcements().len(), 1);
|
||||
assert_eq!(bulletin.urgent_announcements().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_contact_form_creation() {
|
||||
let contact = ContactForm::new(
|
||||
"John Doe".to_string(),
|
||||
"john@example.com".to_string(),
|
||||
"Prayer Request".to_string(),
|
||||
"Please pray for my family.".to_string(),
|
||||
)
|
||||
.with_category(ContactCategory::PrayerRequest)
|
||||
.with_phone("555-123-4567".to_string())
|
||||
.mark_urgent();
|
||||
|
||||
assert_eq!(contact.name, "John Doe");
|
||||
assert_eq!(contact.email, "john@example.com");
|
||||
assert!(contact.is_urgent());
|
||||
assert!(!contact.is_visitor());
|
||||
assert_eq!(contact.category, Some(ContactCategory::PrayerRequest));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sermon_search() {
|
||||
let sermon = Sermon {
|
||||
id: "sermon-1".to_string(),
|
||||
title: "The Power of Prayer".to_string(),
|
||||
speaker: "Pastor Johnson".to_string(),
|
||||
description: "Learning to pray effectively".to_string(),
|
||||
date: Utc::now() - chrono::Duration::days(7),
|
||||
scripture_reference: "Matthew 6:9-13".to_string(),
|
||||
series: Some("Prayer Series".to_string()),
|
||||
duration_seconds: Some(2400), // 40 minutes
|
||||
media_url: Some("https://media.church.org/sermon1.mp4".to_string()),
|
||||
audio_url: Some("https://media.church.org/sermon1.mp3".to_string()),
|
||||
video_url: Some("https://video.church.org/sermon1.mp4".to_string()),
|
||||
transcript: Some("Today we explore...".to_string()),
|
||||
thumbnail: Some("https://media.church.org/thumb1.jpg".to_string()),
|
||||
tags: Some(vec!["prayer".to_string(), "spiritual".to_string()]),
|
||||
category: SermonCategory::Regular,
|
||||
is_featured: true,
|
||||
view_count: 150,
|
||||
download_count: 45,
|
||||
created_at: Utc::now() - chrono::Duration::days(8),
|
||||
updated_at: Utc::now() - chrono::Duration::days(7),
|
||||
};
|
||||
|
||||
assert_eq!(sermon.duration_formatted(), "40m");
|
||||
assert!(sermon.has_media());
|
||||
assert!(sermon.has_audio());
|
||||
assert!(sermon.has_video());
|
||||
assert!(sermon.has_transcript());
|
||||
assert!(sermon.is_popular());
|
||||
|
||||
// Test search matching
|
||||
let search = SermonSearch::new()
|
||||
.with_query("prayer".to_string())
|
||||
.with_speaker("Johnson".to_string())
|
||||
.featured_only();
|
||||
|
||||
assert!(sermon.matches_search(&search));
|
||||
|
||||
let non_matching_search = SermonSearch::new()
|
||||
.with_query("baptism".to_string());
|
||||
|
||||
assert!(!sermon.matches_search(&non_matching_search));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_token() {
|
||||
let token = AuthToken {
|
||||
token: "abc123".to_string(),
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_at: Utc::now() + chrono::Duration::hours(1),
|
||||
user_id: Some("user-1".to_string()),
|
||||
user_name: Some("John Doe".to_string()),
|
||||
user_email: Some("john@church.org".to_string()),
|
||||
permissions: vec!["events.read".to_string(), "bulletins.read".to_string()],
|
||||
};
|
||||
|
||||
assert!(token.is_valid());
|
||||
assert!(!token.is_expired());
|
||||
assert!(token.has_permission("events.read"));
|
||||
assert!(!token.has_permission("admin.write"));
|
||||
assert!(token.has_any_permission(&["events.read", "admin.write"]));
|
||||
assert!(!token.has_all_permissions(&["events.read", "admin.write"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_roles() {
|
||||
let admin_user = AuthUser {
|
||||
id: "admin-1".to_string(),
|
||||
email: "admin@church.org".to_string(),
|
||||
name: "Admin User".to_string(),
|
||||
username: Some("admin".to_string()),
|
||||
avatar: None,
|
||||
verified: true,
|
||||
role: UserRole::Admin,
|
||||
permissions: vec!["admin.*".to_string()],
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
last_login: Some(Utc::now()),
|
||||
};
|
||||
|
||||
assert!(admin_user.is_admin());
|
||||
assert!(admin_user.is_leadership());
|
||||
assert!(admin_user.can_edit_content());
|
||||
assert!(admin_user.can_moderate());
|
||||
assert_eq!(admin_user.display_name(), "Admin User");
|
||||
|
||||
let member_user = AuthUser {
|
||||
id: "member-1".to_string(),
|
||||
email: "member@church.org".to_string(),
|
||||
name: "".to_string(),
|
||||
username: Some("member123".to_string()),
|
||||
avatar: None,
|
||||
verified: true,
|
||||
role: UserRole::Member,
|
||||
permissions: vec!["events.read".to_string()],
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
last_login: Some(Utc::now()),
|
||||
};
|
||||
|
||||
assert!(!member_user.is_admin());
|
||||
assert!(!member_user.is_leadership());
|
||||
assert!(!member_user.can_edit_content());
|
||||
assert!(!member_user.can_moderate());
|
||||
assert!(member_user.is_member());
|
||||
assert_eq!(member_user.display_name(), "member123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_response_models() {
|
||||
let response: ApiResponse<String> = ApiResponse {
|
||||
success: true,
|
||||
data: Some("test data".to_string()),
|
||||
message: Some("Success".to_string()),
|
||||
error: None,
|
||||
};
|
||||
|
||||
assert!(response.success);
|
||||
assert_eq!(response.data, Some("test data".to_string()));
|
||||
|
||||
let error_response: ApiResponse<String> = ApiResponse {
|
||||
success: false,
|
||||
data: None,
|
||||
message: None,
|
||||
error: Some("Not found".to_string()),
|
||||
};
|
||||
|
||||
assert!(!error_response.success);
|
||||
assert_eq!(error_response.data, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pagination_params() {
|
||||
let params = PaginationParams::new()
|
||||
.with_page(2)
|
||||
.with_per_page(25)
|
||||
.with_sort("created_at".to_string())
|
||||
.with_filter("active=true".to_string());
|
||||
|
||||
assert_eq!(params.page, Some(2));
|
||||
assert_eq!(params.per_page, Some(25));
|
||||
assert_eq!(params.sort, Some("created_at".to_string()));
|
||||
assert_eq!(params.filter, Some("active=true".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cache_tests {
|
||||
use super::*;
|
||||
use tokio_test;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_memory_cache_basic_operations() {
|
||||
let cache = MemoryCache::new(100);
|
||||
let ttl = Duration::from_secs(60);
|
||||
|
||||
// Test set and get
|
||||
cache.set("key1", &"value1", ttl).await;
|
||||
let result: Option<String> = cache.get("key1").await;
|
||||
assert_eq!(result, Some("value1".to_string()));
|
||||
|
||||
// Test get non-existent key
|
||||
let result: Option<String> = cache.get("nonexistent").await;
|
||||
assert_eq!(result, None);
|
||||
|
||||
// Test remove
|
||||
cache.remove("key1").await;
|
||||
let result: Option<String> = cache.get("key1").await;
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_memory_cache_ttl_expiration() {
|
||||
let cache = MemoryCache::new(100);
|
||||
let short_ttl = Duration::from_millis(50);
|
||||
|
||||
// Set value with short TTL
|
||||
cache.set("expire_key", &"expire_value", short_ttl).await;
|
||||
|
||||
// Should be available immediately
|
||||
let result: Option<String> = cache.get("expire_key").await;
|
||||
assert_eq!(result, Some("expire_value".to_string()));
|
||||
|
||||
// Wait for expiration
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Should be expired
|
||||
let result: Option<String> = cache.get("expire_key").await;
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_memory_cache_clear() {
|
||||
let cache = MemoryCache::new(100);
|
||||
let ttl = Duration::from_secs(60);
|
||||
|
||||
// Set multiple values
|
||||
cache.set("key1", &"value1", ttl).await;
|
||||
cache.set("key2", &"value2", ttl).await;
|
||||
cache.set("key3", &"value3", ttl).await;
|
||||
|
||||
assert_eq!(cache.len().await, 3);
|
||||
|
||||
// Clear cache
|
||||
cache.clear().await;
|
||||
assert_eq!(cache.len().await, 0);
|
||||
|
||||
// Verify values are gone
|
||||
let result: Option<String> = cache.get("key1").await;
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_memory_cache_invalidate_prefix() {
|
||||
let cache = MemoryCache::new(100);
|
||||
let ttl = Duration::from_secs(60);
|
||||
|
||||
// Set values with different prefixes
|
||||
cache.set("user:1", &"user1", ttl).await;
|
||||
cache.set("user:2", &"user2", ttl).await;
|
||||
cache.set("event:1", &"event1", ttl).await;
|
||||
cache.set("event:2", &"event2", ttl).await;
|
||||
|
||||
assert_eq!(cache.len().await, 4);
|
||||
|
||||
// Invalidate user prefix
|
||||
cache.invalidate_prefix("user:").await;
|
||||
assert_eq!(cache.len().await, 2);
|
||||
|
||||
// Verify user keys are gone but event keys remain
|
||||
let result: Option<String> = cache.get("user:1").await;
|
||||
assert_eq!(result, None);
|
||||
|
||||
let result: Option<String> = cache.get("event:1").await;
|
||||
assert_eq!(result, Some("event1".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_memory_cache_complex_types() {
|
||||
let cache = MemoryCache::new(100);
|
||||
let ttl = Duration::from_secs(60);
|
||||
|
||||
let event = Event {
|
||||
id: "test-event".to_string(),
|
||||
title: "Test Event".to_string(),
|
||||
description: "A test event".to_string(),
|
||||
start_time: Utc::now(),
|
||||
end_time: Utc::now() + chrono::Duration::hours(1),
|
||||
location: "Test Location".to_string(),
|
||||
location_url: None,
|
||||
image: None,
|
||||
thumbnail: None,
|
||||
category: EventCategory::Service,
|
||||
is_featured: false,
|
||||
recurring_type: None,
|
||||
tags: None,
|
||||
contact_email: None,
|
||||
contact_phone: None,
|
||||
registration_url: None,
|
||||
max_attendees: None,
|
||||
current_attendees: None,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
|
||||
// Cache complex object
|
||||
cache.set("event:test", &event, ttl).await;
|
||||
|
||||
// Retrieve and verify
|
||||
let result: Option<Event> = cache.get("event:test").await;
|
||||
assert!(result.is_some());
|
||||
let cached_event = result.unwrap();
|
||||
assert_eq!(cached_event.id, "test-event");
|
||||
assert_eq!(cached_event.title, "Test Event");
|
||||
assert_eq!(cached_event.category, EventCategory::Service);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_church_core_config() {
|
||||
let config = ChurchCoreConfig::new()
|
||||
.with_base_url("https://api.test.church")
|
||||
.with_cache_ttl(Duration::from_secs(600))
|
||||
.with_timeout(Duration::from_secs(15))
|
||||
.with_retry_attempts(5)
|
||||
.with_offline_mode(false)
|
||||
.with_max_cache_size(2000);
|
||||
|
||||
assert_eq!(config.api_base_url, "https://api.test.church");
|
||||
assert_eq!(config.cache_ttl, Duration::from_secs(600));
|
||||
assert_eq!(config.timeout, Duration::from_secs(15));
|
||||
assert_eq!(config.retry_attempts, 5);
|
||||
assert!(!config.enable_offline_mode);
|
||||
assert_eq!(config.max_cache_size, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = ChurchCoreConfig::default();
|
||||
|
||||
assert_eq!(config.api_base_url, "https://api.rockvilletollandsda.church/api");
|
||||
assert_eq!(config.cache_ttl, Duration::from_secs(300));
|
||||
assert_eq!(config.timeout, Duration::from_secs(10));
|
||||
assert_eq!(config.retry_attempts, 3);
|
||||
assert!(config.enable_offline_mode);
|
||||
assert_eq!(config.max_cache_size, 1000);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue