commit 4d6b23beb3e3167f439de3bfdc138277679fd9c0 Author: RTSDA Date: Sat Aug 16 19:25:01 2025 -0400 Initial commit: Church Core Rust library Add church management API library with cross-platform support for iOS, Android, and WASM. Features include event management, bulletin handling, contact forms, and authentication. diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml new file mode 100644 index 0000000..92339d2 --- /dev/null +++ b/.github/workflows/ios-build.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f6e61b --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..102875e --- /dev/null +++ b/Cargo.toml @@ -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 "] +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" + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..43d885a --- /dev/null +++ b/Makefile @@ -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" \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7822a00 --- /dev/null +++ b/README.md @@ -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> { + // 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 \ No newline at end of file diff --git a/RTSDA/Info.plist b/RTSDA/Info.plist new file mode 100644 index 0000000..9bef431 --- /dev/null +++ b/RTSDA/Info.plist @@ -0,0 +1,66 @@ + + + + + AvailableLibraries + + + BinaryPath + libchurch_core_device.a + HeadersPath + Headers + LibraryIdentifier + ios-arm64 + LibraryPath + libchurch_core_device.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + + + BinaryPath + libchurch_core_sim.a + HeadersPath + Headers + LibraryIdentifier + ios-arm64-simulator + LibraryPath + libchurch_core_sim.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + BinaryPath + libchurch_core_mac_catalyst.a + HeadersPath + Headers + LibraryIdentifier + ios-arm64_x86_64-maccatalyst + LibraryPath + libchurch_core_mac_catalyst.a + SupportedArchitectures + + arm64 + x86_64 + + SupportedPlatform + ios + SupportedPlatformVariant + maccatalyst + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + diff --git a/bindings/android/README.md b/bindings/android/README.md new file mode 100644 index 0000000..4d3e5a4 --- /dev/null +++ b/bindings/android/README.md @@ -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 diff --git a/bindings/android/uniffi/church_core/church_core.kt b/bindings/android/uniffi/church_core/church_core.kt new file mode 100644 index 0000000..48241c7 --- /dev/null +++ b/bindings/android/uniffi/church_core/church_core.kt @@ -0,0 +1,2068 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! + +@file:Suppress("NAME_SHADOWING") + +package uniffi.church_core + +// Common helper code. +// +// Ideally this would live in a separate .kt file where it can be unittested etc +// in isolation, and perhaps even published as a re-useable package. +// +// However, it's important that the details of how this helper code works (e.g. the +// way that different builtin types are passed across the FFI) exactly match what's +// expected by the Rust code on the other side of the interface. In practice right +// now that means coming from the exact some version of `uniffi` that was used to +// compile the Rust component. The easiest way to ensure this is to bundle the Kotlin +// helpers directly inline like we're doing here. + +import com.sun.jna.Library +import com.sun.jna.IntegerType +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.Structure +import com.sun.jna.Callback +import com.sun.jna.ptr.* +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.CharBuffer +import java.nio.charset.CodingErrorAction +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.ConcurrentHashMap + +// This is a helper for safely working with byte buffers returned from the Rust code. +// A rust-owned buffer is represented by its capacity, its current length, and a +// pointer to the underlying data. + +@Structure.FieldOrder("capacity", "len", "data") +open class RustBuffer : Structure() { + // Note: `capacity` and `len` are actually `ULong` values, but JVM only supports signed values. + // When dealing with these fields, make sure to call `toULong()`. + @JvmField var capacity: Long = 0 + @JvmField var len: Long = 0 + @JvmField var data: Pointer? = null + + class ByValue: RustBuffer(), Structure.ByValue + class ByReference: RustBuffer(), Structure.ByReference + + internal fun setValue(other: RustBuffer) { + capacity = other.capacity + len = other.len + data = other.data + } + + companion object { + internal fun alloc(size: ULong = 0UL) = uniffiRustCall() { status -> + // Note: need to convert the size to a `Long` value to make this work with JVM. + UniffiLib.INSTANCE.ffi_church_core_rustbuffer_alloc(size.toLong(), status) + }.also { + if(it.data == null) { + throw RuntimeException("RustBuffer.alloc() returned null data pointer (size=${size})") + } + } + + internal fun create(capacity: ULong, len: ULong, data: Pointer?): RustBuffer.ByValue { + var buf = RustBuffer.ByValue() + buf.capacity = capacity.toLong() + buf.len = len.toLong() + buf.data = data + return buf + } + + internal fun free(buf: RustBuffer.ByValue) = uniffiRustCall() { status -> + UniffiLib.INSTANCE.ffi_church_core_rustbuffer_free(buf, status) + } + } + + @Suppress("TooGenericExceptionThrown") + fun asByteBuffer() = + this.data?.getByteBuffer(0, this.len.toLong())?.also { + it.order(ByteOrder.BIG_ENDIAN) + } +} + +/** + * The equivalent of the `*mut RustBuffer` type. + * Required for callbacks taking in an out pointer. + * + * Size is the sum of all values in the struct. + */ +class RustBufferByReference : ByReference(16) { + /** + * Set the pointed-to `RustBuffer` to the given value. + */ + fun setValue(value: RustBuffer.ByValue) { + // NOTE: The offsets are as they are in the C-like struct. + val pointer = getPointer() + pointer.setLong(0, value.capacity) + pointer.setLong(8, value.len) + pointer.setPointer(16, value.data) + } + + /** + * Get a `RustBuffer.ByValue` from this reference. + */ + fun getValue(): RustBuffer.ByValue { + val pointer = getPointer() + val value = RustBuffer.ByValue() + value.writeField("capacity", pointer.getLong(0)) + value.writeField("len", pointer.getLong(8)) + value.writeField("data", pointer.getLong(16)) + + return value + } +} + +// This is a helper for safely passing byte references into the rust code. +// It's not actually used at the moment, because there aren't many things that you +// can take a direct pointer to in the JVM, and if we're going to copy something +// then we might as well copy it into a `RustBuffer`. But it's here for API +// completeness. + +@Structure.FieldOrder("len", "data") +open class ForeignBytes : Structure() { + @JvmField var len: Int = 0 + @JvmField var data: Pointer? = null + + class ByValue : ForeignBytes(), Structure.ByValue +} +// The FfiConverter interface handles converter types to and from the FFI +// +// All implementing objects should be public to support external types. When a +// type is external we need to import it's FfiConverter. +public interface FfiConverter { + // Convert an FFI type to a Kotlin type + fun lift(value: FfiType): KotlinType + + // Convert an Kotlin type to an FFI type + fun lower(value: KotlinType): FfiType + + // Read a Kotlin type from a `ByteBuffer` + fun read(buf: ByteBuffer): KotlinType + + // Calculate bytes to allocate when creating a `RustBuffer` + // + // This must return at least as many bytes as the write() function will + // write. It can return more bytes than needed, for example when writing + // Strings we can't know the exact bytes needed until we the UTF-8 + // encoding, so we pessimistically allocate the largest size possible (3 + // bytes per codepoint). Allocating extra bytes is not really a big deal + // because the `RustBuffer` is short-lived. + fun allocationSize(value: KotlinType): ULong + + // Write a Kotlin type to a `ByteBuffer` + fun write(value: KotlinType, buf: ByteBuffer) + + // Lower a value into a `RustBuffer` + // + // This method lowers a value into a `RustBuffer` rather than the normal + // FfiType. It's used by the callback interface code. Callback interface + // returns are always serialized into a `RustBuffer` regardless of their + // normal FFI type. + fun lowerIntoRustBuffer(value: KotlinType): RustBuffer.ByValue { + val rbuf = RustBuffer.alloc(allocationSize(value)) + try { + val bbuf = rbuf.data!!.getByteBuffer(0, rbuf.capacity).also { + it.order(ByteOrder.BIG_ENDIAN) + } + write(value, bbuf) + rbuf.writeField("len", bbuf.position().toLong()) + return rbuf + } catch (e: Throwable) { + RustBuffer.free(rbuf) + throw e + } + } + + // Lift a value from a `RustBuffer`. + // + // This here mostly because of the symmetry with `lowerIntoRustBuffer()`. + // It's currently only used by the `FfiConverterRustBuffer` class below. + fun liftFromRustBuffer(rbuf: RustBuffer.ByValue): KotlinType { + val byteBuf = rbuf.asByteBuffer()!! + try { + val item = read(byteBuf) + if (byteBuf.hasRemaining()) { + throw RuntimeException("junk remaining in buffer after lifting, something is very wrong!!") + } + return item + } finally { + RustBuffer.free(rbuf) + } + } +} + +// FfiConverter that uses `RustBuffer` as the FfiType +public interface FfiConverterRustBuffer: FfiConverter { + override fun lift(value: RustBuffer.ByValue) = liftFromRustBuffer(value) + override fun lower(value: KotlinType) = lowerIntoRustBuffer(value) +} +// A handful of classes and functions to support the generated data structures. +// This would be a good candidate for isolating in its own ffi-support lib. + +internal const val UNIFFI_CALL_SUCCESS = 0.toByte() +internal const val UNIFFI_CALL_ERROR = 1.toByte() +internal const val UNIFFI_CALL_UNEXPECTED_ERROR = 2.toByte() + +@Structure.FieldOrder("code", "error_buf") +internal open class UniffiRustCallStatus : Structure() { + @JvmField var code: Byte = 0 + @JvmField var error_buf: RustBuffer.ByValue = RustBuffer.ByValue() + + class ByValue: UniffiRustCallStatus(), Structure.ByValue + + fun isSuccess(): Boolean { + return code == UNIFFI_CALL_SUCCESS + } + + fun isError(): Boolean { + return code == UNIFFI_CALL_ERROR + } + + fun isPanic(): Boolean { + return code == UNIFFI_CALL_UNEXPECTED_ERROR + } + + companion object { + fun create(code: Byte, errorBuf: RustBuffer.ByValue): UniffiRustCallStatus.ByValue { + val callStatus = UniffiRustCallStatus.ByValue() + callStatus.code = code + callStatus.error_buf = errorBuf + return callStatus + } + } +} + +class InternalException(message: String) : kotlin.Exception(message) + +// Each top-level error class has a companion object that can lift the error from the call status's rust buffer +interface UniffiRustCallStatusErrorHandler { + fun lift(error_buf: RustBuffer.ByValue): E; +} + +// Helpers for calling Rust +// In practice we usually need to be synchronized to call this safely, so it doesn't +// synchronize itself + +// Call a rust function that returns a Result<>. Pass in the Error class companion that corresponds to the Err +private inline fun uniffiRustCallWithError(errorHandler: UniffiRustCallStatusErrorHandler, callback: (UniffiRustCallStatus) -> U): U { + var status = UniffiRustCallStatus() + val return_value = callback(status) + uniffiCheckCallStatus(errorHandler, status) + return return_value +} + +// Check UniffiRustCallStatus and throw an error if the call wasn't successful +private fun uniffiCheckCallStatus(errorHandler: UniffiRustCallStatusErrorHandler, status: UniffiRustCallStatus) { + if (status.isSuccess()) { + return + } else if (status.isError()) { + throw errorHandler.lift(status.error_buf) + } else if (status.isPanic()) { + // when the rust code sees a panic, it tries to construct a rustbuffer + // with the message. but if that code panics, then it just sends back + // an empty buffer. + if (status.error_buf.len > 0) { + throw InternalException(FfiConverterString.lift(status.error_buf)) + } else { + throw InternalException("Rust panic") + } + } else { + throw InternalException("Unknown rust call status: $status.code") + } +} + +// UniffiRustCallStatusErrorHandler implementation for times when we don't expect a CALL_ERROR +object UniffiNullRustCallStatusErrorHandler: UniffiRustCallStatusErrorHandler { + override fun lift(error_buf: RustBuffer.ByValue): InternalException { + RustBuffer.free(error_buf) + return InternalException("Unexpected CALL_ERROR") + } +} + +// Call a rust function that returns a plain value +private inline fun uniffiRustCall(callback: (UniffiRustCallStatus) -> U): U { + return uniffiRustCallWithError(UniffiNullRustCallStatusErrorHandler, callback) +} + +internal inline fun uniffiTraitInterfaceCall( + callStatus: UniffiRustCallStatus, + makeCall: () -> T, + writeReturn: (T) -> Unit, +) { + try { + writeReturn(makeCall()) + } catch(e: kotlin.Exception) { + callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR + callStatus.error_buf = FfiConverterString.lower(e.toString()) + } +} + +internal inline fun uniffiTraitInterfaceCallWithError( + callStatus: UniffiRustCallStatus, + makeCall: () -> T, + writeReturn: (T) -> Unit, + lowerError: (E) -> RustBuffer.ByValue +) { + try { + writeReturn(makeCall()) + } catch(e: kotlin.Exception) { + if (e is E) { + callStatus.code = UNIFFI_CALL_ERROR + callStatus.error_buf = lowerError(e) + } else { + callStatus.code = UNIFFI_CALL_UNEXPECTED_ERROR + callStatus.error_buf = FfiConverterString.lower(e.toString()) + } + } +} +// Map handles to objects +// +// This is used pass an opaque 64-bit handle representing a foreign object to the Rust code. +internal class UniffiHandleMap { + private val map = ConcurrentHashMap() + private val counter = java.util.concurrent.atomic.AtomicLong(0) + + val size: Int + get() = map.size + + // Insert a new object into the handle map and get a handle for it + fun insert(obj: T): Long { + val handle = counter.getAndAdd(1) + map.put(handle, obj) + return handle + } + + // Get an object from the handle map + fun get(handle: Long): T { + return map.get(handle) ?: throw InternalException("UniffiHandleMap.get: Invalid handle") + } + + // Remove an entry from the handlemap and get the Kotlin object back + fun remove(handle: Long): T { + return map.remove(handle) ?: throw InternalException("UniffiHandleMap: Invalid handle") + } +} + +// Contains loading, initialization code, +// and the FFI Function declarations in a com.sun.jna.Library. +@Synchronized +private fun findLibraryName(componentName: String): String { + val libOverride = System.getProperty("uniffi.component.$componentName.libraryOverride") + if (libOverride != null) { + return libOverride + } + return "uniffi_church_core" +} + +private inline fun loadIndirect( + componentName: String +): Lib { + return Native.load(findLibraryName(componentName), Lib::class.java) +} + +// Define FFI callback types +internal interface UniffiRustFutureContinuationCallback : com.sun.jna.Callback { + fun callback(`data`: Long,`pollResult`: Byte,) +} +internal interface UniffiForeignFutureFree : com.sun.jna.Callback { + fun callback(`handle`: Long,) +} +internal interface UniffiCallbackInterfaceFree : com.sun.jna.Callback { + fun callback(`handle`: Long,) +} +@Structure.FieldOrder("handle", "free") +internal open class UniffiForeignFuture( + @JvmField internal var `handle`: Long = 0.toLong(), + @JvmField internal var `free`: UniffiForeignFutureFree? = null, +) : Structure() { + class UniffiByValue( + `handle`: Long = 0.toLong(), + `free`: UniffiForeignFutureFree? = null, + ): UniffiForeignFuture(`handle`,`free`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFuture) { + `handle` = other.`handle` + `free` = other.`free` + } + +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructU8( + @JvmField internal var `returnValue`: Byte = 0.toByte(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Byte = 0.toByte(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructU8(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructU8) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU8 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU8.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructI8( + @JvmField internal var `returnValue`: Byte = 0.toByte(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Byte = 0.toByte(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructI8(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructI8) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI8 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI8.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructU16( + @JvmField internal var `returnValue`: Short = 0.toShort(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Short = 0.toShort(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructU16(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructU16) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU16 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU16.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructI16( + @JvmField internal var `returnValue`: Short = 0.toShort(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Short = 0.toShort(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructI16(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructI16) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI16 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI16.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructU32( + @JvmField internal var `returnValue`: Int = 0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Int = 0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructU32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructU32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructI32( + @JvmField internal var `returnValue`: Int = 0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Int = 0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructI32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructI32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructU64( + @JvmField internal var `returnValue`: Long = 0.toLong(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Long = 0.toLong(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructU64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructU64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteU64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructU64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructI64( + @JvmField internal var `returnValue`: Long = 0.toLong(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Long = 0.toLong(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructI64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructI64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteI64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructI64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructF32( + @JvmField internal var `returnValue`: Float = 0.0f, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Float = 0.0f, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructF32(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructF32) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteF32 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructF32.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructF64( + @JvmField internal var `returnValue`: Double = 0.0, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Double = 0.0, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructF64(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructF64) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteF64 : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructF64.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructPointer( + @JvmField internal var `returnValue`: Pointer = Pointer.NULL, + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: Pointer = Pointer.NULL, + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructPointer(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructPointer) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompletePointer : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructPointer.UniffiByValue,) +} +@Structure.FieldOrder("returnValue", "callStatus") +internal open class UniffiForeignFutureStructRustBuffer( + @JvmField internal var `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `returnValue`: RustBuffer.ByValue = RustBuffer.ByValue(), + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructRustBuffer(`returnValue`,`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructRustBuffer) { + `returnValue` = other.`returnValue` + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteRustBuffer : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructRustBuffer.UniffiByValue,) +} +@Structure.FieldOrder("callStatus") +internal open class UniffiForeignFutureStructVoid( + @JvmField internal var `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), +) : Structure() { + class UniffiByValue( + `callStatus`: UniffiRustCallStatus.ByValue = UniffiRustCallStatus.ByValue(), + ): UniffiForeignFutureStructVoid(`callStatus`,), Structure.ByValue + + internal fun uniffiSetValue(other: UniffiForeignFutureStructVoid) { + `callStatus` = other.`callStatus` + } + +} +internal interface UniffiForeignFutureCompleteVoid : com.sun.jna.Callback { + fun callback(`callbackData`: Long,`result`: UniffiForeignFutureStructVoid.UniffiByValue,) +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +// A JNA Library to expose the extern-C FFI definitions. +// This is an implementation detail which will be called internally by the public API. + +internal interface UniffiLib : Library { + companion object { + internal val INSTANCE: UniffiLib by lazy { + loadIndirect(componentName = "church_core") + .also { lib: UniffiLib -> + uniffiCheckContractApiVersion(lib) + uniffiCheckApiChecksums(lib) + } + } + + } + + fun uniffi_church_core_fn_func_create_calendar_event_data(`eventJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_create_sermon_share_items_json(`title`: RustBuffer.ByValue,`speaker`: RustBuffer.ByValue,`videoUrl`: RustBuffer.ByValue,`audioUrl`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_device_supports_av1(uniffi_out_err: UniffiRustCallStatus, + ): Byte + fun uniffi_church_core_fn_func_extract_full_verse_text(`versesJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_extract_scripture_references_string(`scriptureText`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_extract_stream_url_from_status(`statusJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_bible_verse_json(`query`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_bulletins_json(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_cached_image_base64(`url`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_config_json(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_current_bulletin_json(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_events_json(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_featured_events_json(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_live_stream_json(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_livestream_archive_json(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_random_bible_verse_json(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_scripture_verses_for_sermon_json(`sermonId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_sermons_json(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_fetch_stream_status_json(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_filter_sermons_by_media_type(`sermonsJson`: RustBuffer.ByValue,`mediaTypeStr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_format_event_for_display_json(`eventJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_format_scripture_text_json(`scriptureText`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_format_time_range_string(`startTime`: RustBuffer.ByValue,`endTime`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_generate_home_feed_json(`eventsJson`: RustBuffer.ByValue,`sermonsJson`: RustBuffer.ByValue,`bulletinsJson`: RustBuffer.ByValue,`verseJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_generate_verse_description(`versesJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_about_text(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_av1_streaming_url(`mediaId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_brand_color(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_church_address(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_church_name(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_contact_email(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_contact_phone(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_coordinates(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_donation_url(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_facebook_url(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_hls_streaming_url(`mediaId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_instagram_url(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_livestream_url(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_media_type_display_name(`mediaTypeStr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_media_type_icon(`mediaTypeStr`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_mission_statement(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_optimal_streaming_url(`mediaId`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_stream_live_status(uniffi_out_err: UniffiRustCallStatus, + ): Byte + fun uniffi_church_core_fn_func_get_website_url(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_get_youtube_url(uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_is_multi_day_event_check(`date`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Byte + fun uniffi_church_core_fn_func_parse_bible_verse_from_json(`verseJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_parse_bulletins_from_json(`bulletinsJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_parse_calendar_event_data(`calendarJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_parse_contact_result_from_json(`resultJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_parse_events_from_json(`eventsJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_parse_sermons_from_json(`sermonsJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_submit_contact_json(`name`: RustBuffer.ByValue,`email`: RustBuffer.ByValue,`message`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_submit_contact_v2_json(`name`: RustBuffer.ByValue,`email`: RustBuffer.ByValue,`subject`: RustBuffer.ByValue,`message`: RustBuffer.ByValue,`phone`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_submit_contact_v2_json_legacy(`firstName`: RustBuffer.ByValue,`lastName`: RustBuffer.ByValue,`email`: RustBuffer.ByValue,`subject`: RustBuffer.ByValue,`message`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_validate_contact_form_json(`formJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun uniffi_church_core_fn_func_validate_email_address(`email`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Byte + fun uniffi_church_core_fn_func_validate_phone_number(`phone`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Byte + fun ffi_church_core_rustbuffer_alloc(`size`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun ffi_church_core_rustbuffer_from_bytes(`bytes`: ForeignBytes.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun ffi_church_core_rustbuffer_free(`buf`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): Unit + fun ffi_church_core_rustbuffer_reserve(`buf`: RustBuffer.ByValue,`additional`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun ffi_church_core_rust_future_poll_u8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_u8(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_u8(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_u8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Byte + fun ffi_church_core_rust_future_poll_i8(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_i8(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_i8(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_i8(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Byte + fun ffi_church_core_rust_future_poll_u16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_u16(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_u16(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_u16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Short + fun ffi_church_core_rust_future_poll_i16(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_i16(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_i16(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_i16(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Short + fun ffi_church_core_rust_future_poll_u32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_u32(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_u32(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_u32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Int + fun ffi_church_core_rust_future_poll_i32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_i32(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_i32(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_i32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Int + fun ffi_church_core_rust_future_poll_u64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_u64(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_u64(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_u64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + fun ffi_church_core_rust_future_poll_i64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_i64(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_i64(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_i64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Long + fun ffi_church_core_rust_future_poll_f32(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_f32(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_f32(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_f32(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Float + fun ffi_church_core_rust_future_poll_f64(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_f64(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_f64(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_f64(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Double + fun ffi_church_core_rust_future_poll_pointer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_pointer(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_pointer(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_pointer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Pointer + fun ffi_church_core_rust_future_poll_rust_buffer(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_rust_buffer(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_rust_buffer(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_rust_buffer(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue + fun ffi_church_core_rust_future_poll_void(`handle`: Long,`callback`: UniffiRustFutureContinuationCallback,`callbackData`: Long, + ): Unit + fun ffi_church_core_rust_future_cancel_void(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_free_void(`handle`: Long, + ): Unit + fun ffi_church_core_rust_future_complete_void(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit + fun uniffi_church_core_checksum_func_create_calendar_event_data( + ): Short + fun uniffi_church_core_checksum_func_create_sermon_share_items_json( + ): Short + fun uniffi_church_core_checksum_func_device_supports_av1( + ): Short + fun uniffi_church_core_checksum_func_extract_full_verse_text( + ): Short + fun uniffi_church_core_checksum_func_extract_scripture_references_string( + ): Short + fun uniffi_church_core_checksum_func_extract_stream_url_from_status( + ): Short + fun uniffi_church_core_checksum_func_fetch_bible_verse_json( + ): Short + fun uniffi_church_core_checksum_func_fetch_bulletins_json( + ): Short + fun uniffi_church_core_checksum_func_fetch_cached_image_base64( + ): Short + fun uniffi_church_core_checksum_func_fetch_config_json( + ): Short + fun uniffi_church_core_checksum_func_fetch_current_bulletin_json( + ): Short + fun uniffi_church_core_checksum_func_fetch_events_json( + ): Short + fun uniffi_church_core_checksum_func_fetch_featured_events_json( + ): Short + fun uniffi_church_core_checksum_func_fetch_live_stream_json( + ): Short + fun uniffi_church_core_checksum_func_fetch_livestream_archive_json( + ): Short + fun uniffi_church_core_checksum_func_fetch_random_bible_verse_json( + ): Short + fun uniffi_church_core_checksum_func_fetch_scripture_verses_for_sermon_json( + ): Short + fun uniffi_church_core_checksum_func_fetch_sermons_json( + ): Short + fun uniffi_church_core_checksum_func_fetch_stream_status_json( + ): Short + fun uniffi_church_core_checksum_func_filter_sermons_by_media_type( + ): Short + fun uniffi_church_core_checksum_func_format_event_for_display_json( + ): Short + fun uniffi_church_core_checksum_func_format_scripture_text_json( + ): Short + fun uniffi_church_core_checksum_func_format_time_range_string( + ): Short + fun uniffi_church_core_checksum_func_generate_home_feed_json( + ): Short + fun uniffi_church_core_checksum_func_generate_verse_description( + ): Short + fun uniffi_church_core_checksum_func_get_about_text( + ): Short + fun uniffi_church_core_checksum_func_get_av1_streaming_url( + ): Short + fun uniffi_church_core_checksum_func_get_brand_color( + ): Short + fun uniffi_church_core_checksum_func_get_church_address( + ): Short + fun uniffi_church_core_checksum_func_get_church_name( + ): Short + fun uniffi_church_core_checksum_func_get_contact_email( + ): Short + fun uniffi_church_core_checksum_func_get_contact_phone( + ): Short + fun uniffi_church_core_checksum_func_get_coordinates( + ): Short + fun uniffi_church_core_checksum_func_get_donation_url( + ): Short + fun uniffi_church_core_checksum_func_get_facebook_url( + ): Short + fun uniffi_church_core_checksum_func_get_hls_streaming_url( + ): Short + fun uniffi_church_core_checksum_func_get_instagram_url( + ): Short + fun uniffi_church_core_checksum_func_get_livestream_url( + ): Short + fun uniffi_church_core_checksum_func_get_media_type_display_name( + ): Short + fun uniffi_church_core_checksum_func_get_media_type_icon( + ): Short + fun uniffi_church_core_checksum_func_get_mission_statement( + ): Short + fun uniffi_church_core_checksum_func_get_optimal_streaming_url( + ): Short + fun uniffi_church_core_checksum_func_get_stream_live_status( + ): Short + fun uniffi_church_core_checksum_func_get_website_url( + ): Short + fun uniffi_church_core_checksum_func_get_youtube_url( + ): Short + fun uniffi_church_core_checksum_func_is_multi_day_event_check( + ): Short + fun uniffi_church_core_checksum_func_parse_bible_verse_from_json( + ): Short + fun uniffi_church_core_checksum_func_parse_bulletins_from_json( + ): Short + fun uniffi_church_core_checksum_func_parse_calendar_event_data( + ): Short + fun uniffi_church_core_checksum_func_parse_contact_result_from_json( + ): Short + fun uniffi_church_core_checksum_func_parse_events_from_json( + ): Short + fun uniffi_church_core_checksum_func_parse_sermons_from_json( + ): Short + fun uniffi_church_core_checksum_func_submit_contact_json( + ): Short + fun uniffi_church_core_checksum_func_submit_contact_v2_json( + ): Short + fun uniffi_church_core_checksum_func_submit_contact_v2_json_legacy( + ): Short + fun uniffi_church_core_checksum_func_validate_contact_form_json( + ): Short + fun uniffi_church_core_checksum_func_validate_email_address( + ): Short + fun uniffi_church_core_checksum_func_validate_phone_number( + ): Short + fun ffi_church_core_uniffi_contract_version( + ): Int + +} + +private fun uniffiCheckContractApiVersion(lib: UniffiLib) { + // Get the bindings contract version from our ComponentInterface + val bindings_contract_version = 26 + // Get the scaffolding contract version by calling the into the dylib + val scaffolding_contract_version = lib.ffi_church_core_uniffi_contract_version() + if (bindings_contract_version != scaffolding_contract_version) { + throw RuntimeException("UniFFI contract version mismatch: try cleaning and rebuilding your project") + } +} + +@Suppress("UNUSED_PARAMETER") +private fun uniffiCheckApiChecksums(lib: UniffiLib) { + if (lib.uniffi_church_core_checksum_func_create_calendar_event_data() != 18038.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_create_sermon_share_items_json() != 7165.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_device_supports_av1() != 2798.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_extract_full_verse_text() != 33299.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_extract_scripture_references_string() != 54242.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_extract_stream_url_from_status() != 7333.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_bible_verse_json() != 62434.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_bulletins_json() != 51697.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_cached_image_base64() != 56508.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_config_json() != 22720.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_current_bulletin_json() != 15976.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_events_json() != 55699.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_featured_events_json() != 40496.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_live_stream_json() != 8362.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_livestream_archive_json() != 39665.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_random_bible_verse_json() != 24962.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_scripture_verses_for_sermon_json() != 54526.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_sermons_json() != 35127.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_fetch_stream_status_json() != 11864.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_filter_sermons_by_media_type() != 55463.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_format_event_for_display_json() != 9802.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_format_scripture_text_json() != 33940.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_format_time_range_string() != 30520.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_generate_home_feed_json() != 33935.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_generate_verse_description() != 57052.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_about_text() != 63404.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_av1_streaming_url() != 15580.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_brand_color() != 38100.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_church_address() != 9838.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_church_name() != 51038.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_contact_email() != 3208.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_contact_phone() != 48541.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_coordinates() != 64388.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_donation_url() != 24711.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_facebook_url() != 16208.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_hls_streaming_url() != 7230.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_instagram_url() != 24193.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_livestream_url() != 60946.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_media_type_display_name() != 34144.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_media_type_icon() != 5231.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_mission_statement() != 37182.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_optimal_streaming_url() != 37505.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_stream_live_status() != 5029.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_website_url() != 56118.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_get_youtube_url() != 37371.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_is_multi_day_event_check() != 59258.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_parse_bible_verse_from_json() != 56853.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_parse_bulletins_from_json() != 49597.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_parse_calendar_event_data() != 53928.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_parse_contact_result_from_json() != 10921.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_parse_events_from_json() != 6684.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_parse_sermons_from_json() != 46352.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_submit_contact_json() != 14960.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_submit_contact_v2_json() != 24485.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_submit_contact_v2_json_legacy() != 19210.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_validate_contact_form_json() != 44651.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_validate_email_address() != 14406.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } + if (lib.uniffi_church_core_checksum_func_validate_phone_number() != 58095.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } +} + +// Async support + +// Public interface members begin here. + + +// Interface implemented by anything that can contain an object reference. +// +// Such types expose a `destroy()` method that must be called to cleanly +// dispose of the contained objects. Failure to call this method may result +// in memory leaks. +// +// The easiest way to ensure this method is called is to use the `.use` +// helper method to execute a block and destroy the object at the end. +interface Disposable { + fun destroy() + companion object { + fun destroy(vararg args: Any?) { + args.filterIsInstance() + .forEach(Disposable::destroy) + } + } +} + +inline fun T.use(block: (T) -> R) = + try { + block(this) + } finally { + try { + // N.B. our implementation is on the nullable type `Disposable?`. + this?.destroy() + } catch (e: Throwable) { + // swallow + } + } + +/** Used to instantiate an interface without an actual pointer, for fakes in tests, mostly. */ +object NoPointer + +public object FfiConverterDouble: FfiConverter { + override fun lift(value: Double): Double { + return value + } + + override fun read(buf: ByteBuffer): Double { + return buf.getDouble() + } + + override fun lower(value: Double): Double { + return value + } + + override fun allocationSize(value: Double) = 8UL + + override fun write(value: Double, buf: ByteBuffer) { + buf.putDouble(value) + } +} + +public object FfiConverterBoolean: FfiConverter { + override fun lift(value: Byte): Boolean { + return value.toInt() != 0 + } + + override fun read(buf: ByteBuffer): Boolean { + return lift(buf.get()) + } + + override fun lower(value: Boolean): Byte { + return if (value) 1.toByte() else 0.toByte() + } + + override fun allocationSize(value: Boolean) = 1UL + + override fun write(value: Boolean, buf: ByteBuffer) { + buf.put(lower(value)) + } +} + +public object FfiConverterString: FfiConverter { + // Note: we don't inherit from FfiConverterRustBuffer, because we use a + // special encoding when lowering/lifting. We can use `RustBuffer.len` to + // store our length and avoid writing it out to the buffer. + override fun lift(value: RustBuffer.ByValue): String { + try { + val byteArr = ByteArray(value.len.toInt()) + value.asByteBuffer()!!.get(byteArr) + return byteArr.toString(Charsets.UTF_8) + } finally { + RustBuffer.free(value) + } + } + + override fun read(buf: ByteBuffer): String { + val len = buf.getInt() + val byteArr = ByteArray(len) + buf.get(byteArr) + return byteArr.toString(Charsets.UTF_8) + } + + fun toUtf8(value: String): ByteBuffer { + // Make sure we don't have invalid UTF-16, check for lone surrogates. + return Charsets.UTF_8.newEncoder().run { + onMalformedInput(CodingErrorAction.REPORT) + encode(CharBuffer.wrap(value)) + } + } + + override fun lower(value: String): RustBuffer.ByValue { + val byteBuf = toUtf8(value) + // Ideally we'd pass these bytes to `ffi_bytebuffer_from_bytes`, but doing so would require us + // to copy them into a JNA `Memory`. So we might as well directly copy them into a `RustBuffer`. + val rbuf = RustBuffer.alloc(byteBuf.limit().toULong()) + rbuf.asByteBuffer()!!.put(byteBuf) + return rbuf + } + + // We aren't sure exactly how many bytes our string will be once it's UTF-8 + // encoded. Allocate 3 bytes per UTF-16 code unit which will always be + // enough. + override fun allocationSize(value: String): ULong { + val sizeForLength = 4UL + val sizeForString = value.length.toULong() * 3UL + return sizeForLength + sizeForString + } + + override fun write(value: String, buf: ByteBuffer) { + val byteBuf = toUtf8(value) + buf.putInt(byteBuf.limit()) + buf.put(byteBuf) + } +} + + + + +public object FfiConverterOptionalString: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): kotlin.String? { + if (buf.get().toInt() == 0) { + return null + } + return FfiConverterString.read(buf) + } + + override fun allocationSize(value: kotlin.String?): ULong { + if (value == null) { + return 1UL + } else { + return 1UL + FfiConverterString.allocationSize(value) + } + } + + override fun write(value: kotlin.String?, buf: ByteBuffer) { + if (value == null) { + buf.put(0) + } else { + buf.put(1) + FfiConverterString.write(value, buf) + } + } +} + + + + +public object FfiConverterSequenceDouble: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterDouble.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterDouble.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterDouble.write(it, buf) + } + } +} fun `createCalendarEventData`(`eventJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_create_calendar_event_data( + FfiConverterString.lower(`eventJson`),_status) +} + ) + } + + fun `createSermonShareItemsJson`(`title`: kotlin.String, `speaker`: kotlin.String, `videoUrl`: kotlin.String?, `audioUrl`: kotlin.String?): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_create_sermon_share_items_json( + FfiConverterString.lower(`title`),FfiConverterString.lower(`speaker`),FfiConverterOptionalString.lower(`videoUrl`),FfiConverterOptionalString.lower(`audioUrl`),_status) +} + ) + } + + fun `deviceSupportsAv1`(): kotlin.Boolean { + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_device_supports_av1( + _status) +} + ) + } + + fun `extractFullVerseText`(`versesJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_extract_full_verse_text( + FfiConverterString.lower(`versesJson`),_status) +} + ) + } + + fun `extractScriptureReferencesString`(`scriptureText`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_extract_scripture_references_string( + FfiConverterString.lower(`scriptureText`),_status) +} + ) + } + + fun `extractStreamUrlFromStatus`(`statusJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_extract_stream_url_from_status( + FfiConverterString.lower(`statusJson`),_status) +} + ) + } + + fun `fetchBibleVerseJson`(`query`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_bible_verse_json( + FfiConverterString.lower(`query`),_status) +} + ) + } + + fun `fetchBulletinsJson`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_bulletins_json( + _status) +} + ) + } + + fun `fetchCachedImageBase64`(`url`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_cached_image_base64( + FfiConverterString.lower(`url`),_status) +} + ) + } + + fun `fetchConfigJson`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_config_json( + _status) +} + ) + } + + fun `fetchCurrentBulletinJson`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_current_bulletin_json( + _status) +} + ) + } + + fun `fetchEventsJson`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_events_json( + _status) +} + ) + } + + fun `fetchFeaturedEventsJson`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_featured_events_json( + _status) +} + ) + } + + fun `fetchLiveStreamJson`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_live_stream_json( + _status) +} + ) + } + + fun `fetchLivestreamArchiveJson`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_livestream_archive_json( + _status) +} + ) + } + + fun `fetchRandomBibleVerseJson`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_random_bible_verse_json( + _status) +} + ) + } + + fun `fetchScriptureVersesForSermonJson`(`sermonId`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_scripture_verses_for_sermon_json( + FfiConverterString.lower(`sermonId`),_status) +} + ) + } + + fun `fetchSermonsJson`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_sermons_json( + _status) +} + ) + } + + fun `fetchStreamStatusJson`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_fetch_stream_status_json( + _status) +} + ) + } + + fun `filterSermonsByMediaType`(`sermonsJson`: kotlin.String, `mediaTypeStr`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_filter_sermons_by_media_type( + FfiConverterString.lower(`sermonsJson`),FfiConverterString.lower(`mediaTypeStr`),_status) +} + ) + } + + fun `formatEventForDisplayJson`(`eventJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_format_event_for_display_json( + FfiConverterString.lower(`eventJson`),_status) +} + ) + } + + fun `formatScriptureTextJson`(`scriptureText`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_format_scripture_text_json( + FfiConverterString.lower(`scriptureText`),_status) +} + ) + } + + fun `formatTimeRangeString`(`startTime`: kotlin.String, `endTime`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_format_time_range_string( + FfiConverterString.lower(`startTime`),FfiConverterString.lower(`endTime`),_status) +} + ) + } + + fun `generateHomeFeedJson`(`eventsJson`: kotlin.String, `sermonsJson`: kotlin.String, `bulletinsJson`: kotlin.String, `verseJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_generate_home_feed_json( + FfiConverterString.lower(`eventsJson`),FfiConverterString.lower(`sermonsJson`),FfiConverterString.lower(`bulletinsJson`),FfiConverterString.lower(`verseJson`),_status) +} + ) + } + + fun `generateVerseDescription`(`versesJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_generate_verse_description( + FfiConverterString.lower(`versesJson`),_status) +} + ) + } + + fun `getAboutText`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_about_text( + _status) +} + ) + } + + fun `getAv1StreamingUrl`(`mediaId`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_av1_streaming_url( + FfiConverterString.lower(`mediaId`),_status) +} + ) + } + + fun `getBrandColor`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_brand_color( + _status) +} + ) + } + + fun `getChurchAddress`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_church_address( + _status) +} + ) + } + + fun `getChurchName`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_church_name( + _status) +} + ) + } + + fun `getContactEmail`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_contact_email( + _status) +} + ) + } + + fun `getContactPhone`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_contact_phone( + _status) +} + ) + } + + fun `getCoordinates`(): List { + return FfiConverterSequenceDouble.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_coordinates( + _status) +} + ) + } + + fun `getDonationUrl`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_donation_url( + _status) +} + ) + } + + fun `getFacebookUrl`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_facebook_url( + _status) +} + ) + } + + fun `getHlsStreamingUrl`(`mediaId`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_hls_streaming_url( + FfiConverterString.lower(`mediaId`),_status) +} + ) + } + + fun `getInstagramUrl`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_instagram_url( + _status) +} + ) + } + + fun `getLivestreamUrl`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_livestream_url( + _status) +} + ) + } + + fun `getMediaTypeDisplayName`(`mediaTypeStr`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_media_type_display_name( + FfiConverterString.lower(`mediaTypeStr`),_status) +} + ) + } + + fun `getMediaTypeIcon`(`mediaTypeStr`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_media_type_icon( + FfiConverterString.lower(`mediaTypeStr`),_status) +} + ) + } + + fun `getMissionStatement`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_mission_statement( + _status) +} + ) + } + + fun `getOptimalStreamingUrl`(`mediaId`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_optimal_streaming_url( + FfiConverterString.lower(`mediaId`),_status) +} + ) + } + + fun `getStreamLiveStatus`(): kotlin.Boolean { + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_stream_live_status( + _status) +} + ) + } + + fun `getWebsiteUrl`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_website_url( + _status) +} + ) + } + + fun `getYoutubeUrl`(): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_get_youtube_url( + _status) +} + ) + } + + fun `isMultiDayEventCheck`(`date`: kotlin.String): kotlin.Boolean { + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_is_multi_day_event_check( + FfiConverterString.lower(`date`),_status) +} + ) + } + + fun `parseBibleVerseFromJson`(`verseJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_parse_bible_verse_from_json( + FfiConverterString.lower(`verseJson`),_status) +} + ) + } + + fun `parseBulletinsFromJson`(`bulletinsJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_parse_bulletins_from_json( + FfiConverterString.lower(`bulletinsJson`),_status) +} + ) + } + + fun `parseCalendarEventData`(`calendarJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_parse_calendar_event_data( + FfiConverterString.lower(`calendarJson`),_status) +} + ) + } + + fun `parseContactResultFromJson`(`resultJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_parse_contact_result_from_json( + FfiConverterString.lower(`resultJson`),_status) +} + ) + } + + fun `parseEventsFromJson`(`eventsJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_parse_events_from_json( + FfiConverterString.lower(`eventsJson`),_status) +} + ) + } + + fun `parseSermonsFromJson`(`sermonsJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_parse_sermons_from_json( + FfiConverterString.lower(`sermonsJson`),_status) +} + ) + } + + fun `submitContactJson`(`name`: kotlin.String, `email`: kotlin.String, `message`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_submit_contact_json( + FfiConverterString.lower(`name`),FfiConverterString.lower(`email`),FfiConverterString.lower(`message`),_status) +} + ) + } + + fun `submitContactV2Json`(`name`: kotlin.String, `email`: kotlin.String, `subject`: kotlin.String, `message`: kotlin.String, `phone`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_submit_contact_v2_json( + FfiConverterString.lower(`name`),FfiConverterString.lower(`email`),FfiConverterString.lower(`subject`),FfiConverterString.lower(`message`),FfiConverterString.lower(`phone`),_status) +} + ) + } + + fun `submitContactV2JsonLegacy`(`firstName`: kotlin.String, `lastName`: kotlin.String, `email`: kotlin.String, `subject`: kotlin.String, `message`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_submit_contact_v2_json_legacy( + FfiConverterString.lower(`firstName`),FfiConverterString.lower(`lastName`),FfiConverterString.lower(`email`),FfiConverterString.lower(`subject`),FfiConverterString.lower(`message`),_status) +} + ) + } + + fun `validateContactFormJson`(`formJson`: kotlin.String): kotlin.String { + return FfiConverterString.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_validate_contact_form_json( + FfiConverterString.lower(`formJson`),_status) +} + ) + } + + fun `validateEmailAddress`(`email`: kotlin.String): kotlin.Boolean { + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_validate_email_address( + FfiConverterString.lower(`email`),_status) +} + ) + } + + fun `validatePhoneNumber`(`phone`: kotlin.String): kotlin.Boolean { + return FfiConverterBoolean.lift( + uniffiRustCall() { _status -> + UniffiLib.INSTANCE.uniffi_church_core_fn_func_validate_phone_number( + FfiConverterString.lower(`phone`),_status) +} + ) + } + + + diff --git a/bindings/ios/ChurchCore.xcframework/Info.plist b/bindings/ios/ChurchCore.xcframework/Info.plist new file mode 100644 index 0000000..25e47e9 --- /dev/null +++ b/bindings/ios/ChurchCore.xcframework/Info.plist @@ -0,0 +1,66 @@ + + + + + AvailableLibraries + + + BinaryPath + libchurch_core_device.a + HeadersPath + Headers + LibraryIdentifier + ios-arm64 + LibraryPath + libchurch_core_device.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + + + BinaryPath + libchurch_core_mac_catalyst.a + HeadersPath + Headers + LibraryIdentifier + ios-arm64_x86_64-maccatalyst + LibraryPath + libchurch_core_mac_catalyst.a + SupportedArchitectures + + arm64 + x86_64 + + SupportedPlatform + ios + SupportedPlatformVariant + maccatalyst + + + BinaryPath + libchurch_core_sim.a + HeadersPath + Headers + LibraryIdentifier + ios-arm64-simulator + LibraryPath + libchurch_core_sim.a + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..ecbdb17 --- /dev/null +++ b/build.rs @@ -0,0 +1,6 @@ +fn main() { + #[cfg(feature = "uniffi")] + { + uniffi::generate_scaffolding("src/church_core.udl").unwrap(); + } +} \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..4c204f9 --- /dev/null +++ b/build.sh @@ -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." \ No newline at end of file diff --git a/build_android.sh b/build_android.sh new file mode 100755 index 0000000..7a76e50 --- /dev/null +++ b/build_android.sh @@ -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" \ No newline at end of file diff --git a/build_ios.sh b/build_ios.sh new file mode 100755 index 0000000..2b30308 --- /dev/null +++ b/build_ios.sh @@ -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)" \ No newline at end of file diff --git a/build_ios_tvos.sh b/build_ios_tvos.sh new file mode 100755 index 0000000..2f946b4 --- /dev/null +++ b/build_ios_tvos.sh @@ -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)" \ No newline at end of file diff --git a/build_tvos_zbuild.sh b/build_tvos_zbuild.sh new file mode 100755 index 0000000..b44326f --- /dev/null +++ b/build_tvos_zbuild.sh @@ -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" \ No newline at end of file diff --git a/generate_bindings.rs b/generate_bindings.rs new file mode 100644 index 0000000..f621195 --- /dev/null +++ b/generate_bindings.rs @@ -0,0 +1,24 @@ +use std::path::Path; +use uniffi_bindgen::library_mode::generate_bindings; + +fn main() -> Result<(), Box> { + 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(()) +} \ No newline at end of file diff --git a/generate_kotlin_bindings.sh b/generate_kotlin_bindings.sh new file mode 100755 index 0000000..25e083f --- /dev/null +++ b/generate_kotlin_bindings.sh @@ -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!" \ No newline at end of file diff --git a/simple_build.sh b/simple_build.sh new file mode 100755 index 0000000..cc68c47 --- /dev/null +++ b/simple_build.sh @@ -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." \ No newline at end of file diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..81968e1 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,4 @@ +// Authentication modules placeholder +// This contains authentication implementations + +pub use crate::models::AuthToken; \ No newline at end of file diff --git a/src/bin/test-date-submission.rs b/src/bin/test-date-submission.rs new file mode 100644 index 0000000..090076a --- /dev/null +++ b/src/bin/test-date-submission.rs @@ -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), + } +} diff --git a/src/bin/test.rs b/src/bin/test.rs new file mode 100644 index 0000000..6d35d17 --- /dev/null +++ b/src/bin/test.rs @@ -0,0 +1,94 @@ +use church_core::{ChurchApiClient, ChurchCoreConfig, DeviceCapabilities, StreamingCapability}; +use chrono::TimeZone; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // 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(()) +} \ No newline at end of file diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..396fdb3 --- /dev/null +++ b/src/cache/mod.rs @@ -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, + pub content_type: String, + pub headers: HashMap, + 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(instant: &Instant, serializer: S) -> Result + 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 + where + D: Deserializer<'de>, + { + let secs = ::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, + expires_at: Instant, +} + +impl CacheEntry { + fn new(data: Vec, 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>>, + http_store: Arc>>, + max_size: usize, + cache_dir: Option, +} + +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 { + 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 = 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(&self, key: &str) -> Option + where + T: DeserializeOwned + Send + 'static, + { + // Clean up expired entries periodically + if rand::random::() < 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(&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 { + // Clean up expired entries periodically + if rand::random::() < 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::(&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 = 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, + content_type: String, + headers: HashMap, + 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 \ No newline at end of file diff --git a/src/church_core.udl b/src/church_core.udl new file mode 100644 index 0000000..f7e89f3 --- /dev/null +++ b/src/church_core.udl @@ -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 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); +}; \ No newline at end of file diff --git a/src/client/admin.rs b/src/client/admin.rs new file mode 100644 index 0000000..f8590c6 --- /dev/null +++ b/src/client/admin.rs @@ -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 { + 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 { + 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> { + 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> { + client.get_api("/admin/users").await +} + +// Admin Schedule Management +pub async fn create_schedule(client: &ChurchApiClient, schedule: NewSchedule) -> Result { + 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> { + client.get_api("/admin/schedule").await +} + +// Admin Config Management +pub async fn get_admin_config(client: &ChurchApiClient) -> Result { + client.get_api("/admin/config").await +} \ No newline at end of file diff --git a/src/client/bible.rs b/src/client/bible.rs new file mode 100644 index 0000000..be32176 --- /dev/null +++ b/src/client/bible.rs @@ -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 { + // 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 { + client.get_api("/bible/verse-of-the-day").await +} + +pub async fn get_verse_by_reference(client: &ChurchApiClient, reference: &str) -> Result> { + 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) -> Result> { + 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 = client.get_api_list(&path).await?; + Ok(response.data.items) +} + +pub async fn search_verses(client: &ChurchApiClient, query: &str, limit: Option) -> Result> { + 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, + message: Option, + } + + 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 { + #[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) -> Result> { + 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::>() + .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) -> Result> { + 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 = client.get_api_list_with_version(&path, ApiVersion::V2).await?; + Ok(response.data.items) +} \ No newline at end of file diff --git a/src/client/bulletins.rs b/src/client/bulletins.rs new file mode 100644 index 0000000..427f2b1 --- /dev/null +++ b/src/client/bulletins.rs @@ -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> { + let path = if active_only { + "/bulletins?active=true" + } else { + "/bulletins" + }; + + let response: ApiListResponse = client.get_api_list(path).await?; + Ok(response.data.items) +} + +pub async fn get_current_bulletin(client: &ChurchApiClient) -> Result> { + 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> { + 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 { + client.post_api("/bulletins", &bulletin).await +} + +pub async fn get_next_bulletin(client: &ChurchApiClient) -> Result> { + 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) -> Result> { + 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::>() + .join("&"); + path.push_str(&format!("?{}", query_string)); + } + } + + let url = client.build_url_with_version(&path, ApiVersion::V2); + let response: ApiListResponse = 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> { + 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> { + 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), + } +} \ No newline at end of file diff --git a/src/client/config.rs b/src/client/config.rs new file mode 100644 index 0000000..f95f861 --- /dev/null +++ b/src/client/config.rs @@ -0,0 +1,50 @@ +use crate::{ + client::ChurchApiClient, + error::Result, + models::{ChurchConfig, Schedule, ConferenceData, ApiVersion}, +}; + +pub async fn get_config(client: &ChurchApiClient) -> Result { + client.get("/config").await +} + +pub async fn get_config_by_id(client: &ChurchApiClient, record_id: &str) -> Result { + 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 { + client.get_api_with_version("/config", ApiVersion::V2).await +} + +// Schedule endpoints +pub async fn get_schedule(client: &ChurchApiClient, date: Option<&str>) -> Result { + 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 { + 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 { + client.get_api("/conference-data").await +} + +pub async fn get_conference_data_v2(client: &ChurchApiClient) -> Result { + client.get_api_with_version("/conference-data", ApiVersion::V2).await +} \ No newline at end of file diff --git a/src/client/contact.rs b/src/client/contact.rs new file mode 100644 index 0000000..042b2bc --- /dev/null +++ b/src/client/contact.rs @@ -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 { + // 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) -> Result> { + 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::>() + .join("&"); + path.push_str(&format!("?{}", query_string)); + } + } + + client.get_api_list(&path).await +} + +pub async fn get_contact_submission(client: &ChurchApiClient, id: &str) -> Result> { + 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 +) -> 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 { + 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()))) + } +} \ No newline at end of file diff --git a/src/client/events.rs b/src/client/events.rs new file mode 100644 index 0000000..0678941 --- /dev/null +++ b/src/client/events.rs @@ -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) -> Result> { + 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::>() + .join("&"); + path.push_str(&format!("?{}", query_string)); + } + } + + client.get_api_list(&path).await +} + +pub async fn get_upcoming_events(client: &ChurchApiClient, limit: Option) -> Result> { + 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> { + 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 { + 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) -> Result> { + 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) -> Result> { + 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> { + 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) -> Result> { + 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, filename: String) -> Result { + 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) -> Result> { + 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::>() + .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) -> Result> { + 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) -> Result> { + 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> { + 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 { + client.post_api("/events/submit", &submission).await +} \ No newline at end of file diff --git a/src/client/http.rs b/src/client/http.rs new file mode 100644 index 0000000..8dfc08b --- /dev/null +++ b/src/client/http.rs @@ -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(&self, path: &str) -> Result + where + T: DeserializeOwned + Send + Sync + serde::Serialize + 'static, + { + self.get_with_version(path, ApiVersion::V1).await + } + + pub(crate) async fn get_with_version(&self, path: &str, version: ApiVersion) -> Result + 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::(&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(&self, path: &str) -> Result + 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(&self, path: &str, version: ApiVersion) -> Result + where + T: DeserializeOwned + Send + Sync + serde::Serialize + 'static, + { + let response: ApiResponse = 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(&self, path: &str) -> Result> + 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(&self, path: &str, version: ApiVersion) -> Result> + where + T: DeserializeOwned + Send + Sync + serde::Serialize + 'static, + { + let response: ApiListResponse = 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(&self, path: &str, data: &T) -> Result + where + T: Serialize, + R: DeserializeOwned, + { + self.post_with_version(path, data, ApiVersion::V1).await + } + + pub(crate) async fn post_with_version(&self, path: &str, data: &T, version: ApiVersion) -> Result + 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(&self, path: &str, data: &T) -> Result + where + T: Serialize, + R: DeserializeOwned, + { + self.post_api_with_version(path, data, ApiVersion::V1).await + } + + pub(crate) async fn post_api_with_version(&self, path: &str, data: &T, version: ApiVersion) -> Result + where + T: Serialize, + R: DeserializeOwned, + { + let response: ApiResponse = 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(&self, path: &str, data: &T) -> Result + 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(&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 { + 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 = 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, filename: String, field_name: String) -> Result { + 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 = 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 { + // 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) + } +} \ No newline at end of file diff --git a/src/client/livestream.rs b/src/client/livestream.rs new file mode 100644 index 0000000..30c2fc6 --- /dev/null +++ b/src/client/livestream.rs @@ -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>, + pub last_disconnect_time: Option>, + pub stream_title: Option, + pub stream_url: Option, + pub viewer_count: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiveStream { + pub last_connect_time: Option>, + pub last_disconnect_time: Option>, + pub viewer_count: Option, + pub stream_title: Option, + pub is_live: bool, +} + +/// Get current stream status from Owncast +pub async fn get_stream_status(client: &ChurchApiClient) -> Result { + client.get("/stream/status").await +} + +/// Get live stream info from Owncast +pub async fn get_live_stream(client: &ChurchApiClient) -> Result { + client.get("/stream/live").await +} \ No newline at end of file diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..e8a1557 --- /dev/null +++ b/src/client/mod.rs @@ -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>>, + pub(crate) cache: Arc, +} + +impl ChurchApiClient { + pub fn new(config: ChurchCoreConfig) -> Result { + 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) -> 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 { + 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) -> Result> { + events::get_upcoming_events(self, limit).await + } + + pub async fn get_events(&self, params: Option) -> Result> { + events::get_events(self, params).await + } + + pub async fn get_event(&self, id: &str) -> Result> { + events::get_event(self, id).await + } + + pub async fn create_event(&self, event: NewEvent) -> Result { + 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> { + bulletins::get_bulletins(self, active_only).await + } + + pub async fn get_current_bulletin(&self) -> Result> { + bulletins::get_current_bulletin(self).await + } + + pub async fn get_next_bulletin(&self) -> Result> { + bulletins::get_next_bulletin(self).await + } + + pub async fn get_bulletin(&self, id: &str) -> Result> { + bulletins::get_bulletin(self, id).await + } + + pub async fn create_bulletin(&self, bulletin: NewBulletin) -> Result { + bulletins::create_bulletin(self, bulletin).await + } + + // V2 API methods + pub async fn get_bulletins_v2(&self, params: Option) -> Result> { + bulletins::get_bulletins_v2(self, params).await + } + + pub async fn get_current_bulletin_v2(&self) -> Result> { + bulletins::get_current_bulletin_v2(self).await + } + + pub async fn get_next_bulletin_v2(&self) -> Result> { + bulletins::get_next_bulletin_v2(self).await + } + + // Configuration + pub async fn get_config(&self) -> Result { + config::get_config(self).await + } + + pub async fn get_config_by_id(&self, record_id: &str) -> Result { + 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 { + contact::submit_contact_form(self, form).await + } + + pub async fn get_contact_submissions(&self, params: Option) -> Result> { + contact::get_contact_submissions(self, params).await + } + + pub async fn get_contact_submission(&self, id: &str) -> Result> { + contact::get_contact_submission(self, id).await + } + + pub async fn update_contact_submission(&self, id: &str, status: ContactStatus, response: Option) -> Result<()> { + contact::update_contact_submission(self, id, status, response).await + } + + // Sermon operations + pub async fn get_sermons(&self, params: Option) -> Result> { + sermons::get_sermons(self, params).await + } + + pub async fn search_sermons(&self, search: SermonSearch, params: Option) -> Result> { + sermons::search_sermons(self, search, params).await + } + + pub async fn get_sermon(&self, id: &str) -> Result> { + sermons::get_sermon(self, id).await + } + + pub async fn get_featured_sermons(&self, limit: Option) -> Result> { + sermons::get_featured_sermons(self, limit).await + } + + pub async fn get_recent_sermons(&self, limit: Option) -> Result> { + sermons::get_recent_sermons(self, limit).await + } + + pub async fn create_sermon(&self, sermon: NewSermon) -> Result { + sermons::create_sermon(self, sermon).await + } + + // Bible verse operations + pub async fn get_random_verse(&self) -> Result { + bible::get_random_verse(self).await + } + + pub async fn get_verse_of_the_day(&self) -> Result { + bible::get_verse_of_the_day(self).await + } + + pub async fn get_verse_by_reference(&self, reference: &str) -> Result> { + bible::get_verse_by_reference(self, reference).await + } + + pub async fn get_verses_by_category(&self, category: VerseCategory, limit: Option) -> Result> { + bible::get_verses_by_category(self, category, limit).await + } + + pub async fn search_verses(&self, query: &str, limit: Option) -> Result> { + bible::search_verses(self, query, limit).await + } + + // V2 API methods + + // Events V2 + pub async fn get_events_v2(&self, params: Option) -> Result> { + events::get_events_v2(self, params).await + } + + pub async fn get_upcoming_events_v2(&self, limit: Option) -> Result> { + events::get_upcoming_events_v2(self, limit).await + } + + pub async fn get_featured_events_v2(&self, limit: Option) -> Result> { + events::get_featured_events_v2(self, limit).await + } + + pub async fn get_event_v2(&self, id: &str) -> Result> { + events::get_event_v2(self, id).await + } + + pub async fn submit_event(&self, submission: EventSubmission) -> Result { + events::submit_event(self, submission).await + } + + // Bible V2 + pub async fn get_random_verse_v2(&self) -> Result { + bible::get_random_verse_v2(self).await + } + + pub async fn get_bible_verses_v2(&self, params: Option) -> Result> { + bible::get_bible_verses_v2(self, params).await + } + + pub async fn search_verses_v2(&self, query: &str, limit: Option) -> Result> { + bible::search_verses_v2(self, query, limit).await + } + + // Contact V2 + pub async fn submit_contact_form_v2(&self, form: ContactForm) -> Result { + contact::submit_contact_form_v2(self, form).await + } + + // Config and Schedule V2 + pub async fn get_config_v2(&self) -> Result { + config::get_config_v2(self).await + } + + pub async fn get_schedule(&self, date: Option<&str>) -> Result { + config::get_schedule(self, date).await + } + + pub async fn get_schedule_v2(&self, date: Option<&str>) -> Result { + config::get_schedule_v2(self, date).await + } + + pub async fn get_conference_data(&self) -> Result { + config::get_conference_data(self).await + } + + pub async fn get_conference_data_v2(&self) -> Result { + config::get_conference_data_v2(self).await + } + +pub async fn get_livestreams(&self) -> Result> { + sermons::get_livestreams(self).await + } + + // Owncast Live Streaming + pub async fn get_stream_status(&self) -> Result { + livestream::get_stream_status(self).await + } + + pub async fn get_live_stream(&self) -> Result { + livestream::get_live_stream(self).await + } + + // Admin operations + + // Admin Bulletins + pub async fn create_admin_bulletin(&self, bulletin: NewBulletin) -> Result { + 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 { + 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> { + 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> { + admin::get_users(self).await + } + + // Admin Schedule + pub async fn create_admin_schedule(&self, schedule: NewSchedule) -> Result { + 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> { + admin::get_all_schedules(self).await + } + + pub async fn get_admin_config(&self) -> Result { + admin::get_admin_config(self).await + } + + // File Upload operations + pub async fn upload_bulletin_pdf(&self, bulletin_id: &str, file_data: Vec, filename: String) -> Result { + uploads::upload_bulletin_pdf(self, bulletin_id, file_data, filename).await + } + + pub async fn upload_bulletin_cover(&self, bulletin_id: &str, file_data: Vec, filename: String) -> Result { + uploads::upload_bulletin_cover(self, bulletin_id, file_data, filename).await + } + + pub async fn upload_event_image(&self, event_id: &str, file_data: Vec, filename: String) -> Result { + uploads::upload_event_image(self, event_id, file_data, filename).await + } + + // Utility methods + pub async fn health_check(&self) -> Result { + 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) + } +} \ No newline at end of file diff --git a/src/client/sermons.rs b/src/client/sermons.rs new file mode 100644 index 0000000..237264e --- /dev/null +++ b/src/client/sermons.rs @@ -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) -> Result> { + 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::>() + .join("&"); + path.push_str(&format!("?{}", query_string)); + } + } + + client.get_api_list(&path).await +} + +pub async fn search_sermons( + client: &ChurchApiClient, + search: SermonSearch, + params: Option +) -> Result> { + 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::>() + .join("&"); + path.push_str(&format!("?{}", query_string)); + } + + client.get_api_list(&path).await +} + +pub async fn get_sermon(client: &ChurchApiClient, id: &str) -> Result> { + 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) -> Result> { + 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) -> Result> { + 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, + message: Option, + } + + 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 { + client.post_api("/sermons", &sermon).await +} + +// Livestreams endpoint - reuses ApiSermon since format is identical + +pub async fn get_livestreams(client: &ChurchApiClient) -> Result> { + // 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, + message: Option, + } + + 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) +} \ No newline at end of file diff --git a/src/client/uploads.rs b/src/client/uploads.rs new file mode 100644 index 0000000..0919cd7 --- /dev/null +++ b/src/client/uploads.rs @@ -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, + filename: String, +) -> Result { + 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 = 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, + filename: String, +) -> Result { + 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 = 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, + filename: String, +) -> Result { + 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 = 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()) + )) + } +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..d57a0fa --- /dev/null +++ b/src/config.rs @@ -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) -> 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) -> Self { + self.user_agent = agent.into(); + self + } +} \ No newline at end of file diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..2020826 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,62 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[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(_)) + } +} \ No newline at end of file diff --git a/src/ffi.rs b/src/ffi.rs new file mode 100644 index 0000000..fb839df --- /dev/null +++ b/src/ffi.rs @@ -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, +}; \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..29d00fd --- /dev/null +++ b/src/lib.rs @@ -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"); \ No newline at end of file diff --git a/src/models/admin.rs b/src/models/admin.rs new file mode 100644 index 0000000..9f43801 --- /dev/null +++ b/src/models/admin.rs @@ -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, + pub role: AdminUserRole, + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_login: Option>, +} + +#[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, + pub divine_worship: Option, + pub scripture_reading: Option, + pub sunset: Option, + pub special_notes: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Conference data +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConferenceData { + pub id: String, + pub name: String, + pub website: Option, + pub contact_info: Option, + pub leadership: Option>, + pub announcements: Option>, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ConferenceLeader { + pub name: String, + pub title: String, + pub email: Option, + pub phone: Option, +} + +/// New schedule creation +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NewSchedule { + pub date: String, // YYYY-MM-DD format + pub sabbath_school: Option, + pub divine_worship: Option, + pub scripture_reading: Option, + pub sunset: Option, + pub special_notes: Option, +} + +/// Schedule update +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ScheduleUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub sabbath_school: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub divine_worship: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scripture_reading: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sunset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub special_notes: Option, +} + +/// File upload response +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UploadResponse { + pub file_path: String, + pub pdf_path: Option, // Full URL to the uploaded file + pub message: String, +} \ No newline at end of file diff --git a/src/models/auth.rs b/src/models/auth.rs new file mode 100644 index 0000000..6e22728 --- /dev/null +++ b/src/models/auth.rs @@ -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, + pub user_id: Option, + pub user_name: Option, + pub user_email: Option, + pub permissions: Vec, +} + +#[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, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AuthUser { + pub id: String, + pub email: String, + pub name: String, + pub username: Option, + pub avatar: Option, + pub verified: bool, + pub role: UserRole, + pub permissions: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, + pub last_login: Option>, +} + +#[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, +} + +#[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, +} + +#[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, + pub avatar: Option, + pub verified: bool, + pub created: DateTime, + pub updated: DateTime, +} + + + +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", + ], + } + } +} \ No newline at end of file diff --git a/src/models/bible.rs b/src/models/bible.rs new file mode 100644 index 0000000..8b4b14e --- /dev/null +++ b/src/models/bible.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub book: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub chapter: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub verse: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, +} + +#[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", + } + } +} \ No newline at end of file diff --git a/src/models/bulletin.rs b/src/models/bulletin.rs new file mode 100644 index 0000000..c18ad35 --- /dev/null +++ b/src/models/bulletin.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_image: Option, + #[serde(default)] + pub is_active: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub announcements: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub hymns: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub special_music: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub offering_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sermon_title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub speaker: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub liturgy: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_image: Option, + #[serde(default)] + pub is_active: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub announcements: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub hymns: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub special_music: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub offering_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sermon_title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub speaker: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub liturgy: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BulletinUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub date: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sabbath_school: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub divine_worship: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scripture_reading: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sunset: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pdf_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cover_image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_active: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub announcements: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub hymns: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub special_music: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub offering_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sermon_title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub speaker: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub liturgy: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Announcement { + pub id: Option, + pub title: String, + pub content: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(default)] + pub is_urgent: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct BulletinHymn { + pub number: u32, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub verses: Option>, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub leader: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scripture_reference: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hymn_number: Option, +} + +#[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() + } +} \ No newline at end of file diff --git a/src/models/client_models.rs b/src/models/client_models.rs new file mode 100644 index 0000000..3f34fcd --- /dev/null +++ b/src/models/client_models.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail: Option, + 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, + #[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, end_time: &DateTime) -> 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 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, + #[serde(skip_serializing_if = "Option::is_none", rename = "coverImage")] + pub cover_image: Option, + #[serde(rename = "isActive")] + pub is_active: bool, + // Add other fields as needed +} + +impl From 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub date: Option, // Pre-formatted date string + #[serde(skip_serializing_if = "Option::is_none", rename = "audioUrl")] + pub audio_url: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "videoUrl")] + pub video_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, // Pre-formatted duration + #[serde(skip_serializing_if = "Option::is_none", rename = "mediaType")] + pub media_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "scriptureReading")] + pub scripture_reading: Option, +} + +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| -> Option { + 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 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(), + } + } +} \ No newline at end of file diff --git a/src/models/common.rs b/src/models/common.rs new file mode 100644 index 0000000..eff2d62 --- /dev/null +++ b/src/models/common.rs @@ -0,0 +1,73 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ApiListResponse { + pub success: bool, + pub data: ApiListData, + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ApiListData { + pub items: Vec, + 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, + pub per_page: Option, + pub sort: Option, + pub filter: Option, +} + +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) -> Self { + self.sort = Some(sort.into()); + self + } + + pub fn with_filter(mut self, filter: impl Into) -> Self { + self.filter = Some(filter.into()); + self + } +} \ No newline at end of file diff --git a/src/models/config.rs b/src/models/config.rs new file mode 100644 index 0000000..1af7eeb --- /dev/null +++ b/src/models/config.rs @@ -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, + pub church_address: Option, + pub po_box: Option, + pub contact_phone: Option, + pub contact_email: Option, + pub website_url: Option, + pub google_maps_url: Option, + pub facebook_url: Option, + pub youtube_url: Option, + pub instagram_url: Option, + pub about_text: Option, + pub mission_statement: Option, + pub tagline: Option, + pub brand_color: Option, + pub donation_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_times: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub pastoral_staff: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ministries: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub emergency_contacts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub coordinates: Option, +} + +#[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, + pub divine_worship: Option, + pub prayer_meeting: Option, + pub youth_service: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub special_services: Option>, +} + +#[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, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct StaffMember { + pub name: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub photo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bio: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub responsibilities: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Ministry { + pub name: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub leader: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub meeting_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub meeting_location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, + 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, + pub priority: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub availability: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub bible_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hymnal_version: Option, + 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::>(); + sorted.sort_by_key(|contact| contact.priority); + sorted + }) + .unwrap_or_default() + } +} \ No newline at end of file diff --git a/src/models/contact.rs b/src/models/contact.rs new file mode 100644 index 0000000..fe3c441 --- /dev/null +++ b/src/models/contact.rs @@ -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, + pub subject: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_contact_method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub urgent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub visitor_info: Option, +} + +#[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, + pub subject: String, + pub message: String, + pub category: ContactCategory, + #[serde(skip_serializing_if = "Option::is_none")] + pub preferred_contact_method: Option, + #[serde(default)] + pub urgent: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub visitor_info: Option, + pub status: ContactStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub assigned_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub response: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub responded_at: Option>, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub interests: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub family_members: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub prayer_requests: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option
, + #[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub interests: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Address { + pub street: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub zip_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PrayerRequest { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone: Option, + 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, + pub status: PrayerStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub assigned_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub answered_at: Option>, +} + +#[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 { + 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() + } +} \ No newline at end of file diff --git a/src/models/event.rs b/src/models/event.rs new file mode 100644 index 0000000..1077346 --- /dev/null +++ b/src/models/event.rs @@ -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, + 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, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::{self, Visitor}; + + struct FlexibleDateTimeVisitor; + + impl<'de> Visitor<'de> for FlexibleDateTimeVisitor { + type Value = DateTime; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string timestamp or timezone object") + } + + fn visit_str(self, value: &str) -> Result + 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(self, mut map: M) -> Result + where + M: de::MapAccess<'de>, + { + // v2 format: timezone object - extract UTC field + let mut utc_value: Option> = None; + + while let Some(key) = map.next_key::()? { + 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, + #[serde(deserialize_with = "deserialize_flexible_datetime")] + pub end_time: DateTime, + pub location: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub location_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thumbnail: Option, + pub category: EventCategory, + #[serde(default)] + pub is_featured: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub recurring_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_attendees: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_attendees: Option, + #[serde(deserialize_with = "deserialize_flexible_datetime")] + pub created_at: DateTime, + #[serde(deserialize_with = "deserialize_flexible_datetime")] + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NewEvent { + pub title: String, + pub description: String, + pub start_time: DateTime, + pub end_time: DateTime, + pub location: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub location_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + pub category: EventCategory, + #[serde(default)] + pub is_featured: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub recurring_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_attendees: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct EventUpdate { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_time: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_time: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_featured: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub recurring_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact_phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_attendees: Option, +} + +#[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 { + 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::>() + .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, + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub bulletin_week: Option, // Date string in YYYY-MM-DD format + pub submitter_email: String, +} + +impl EventSubmission { + /// Parse start_time string to DateTime + pub fn parse_start_time(&self) -> Option> { + crate::utils::parse_datetime_flexible(&self.start_time) + } + + /// Parse end_time string to DateTime + pub fn parse_end_time(&self) -> Option> { + 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, + pub end_time: DateTime, + pub location: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub location_url: Option, + pub category: EventCategory, + #[serde(default)] + pub is_featured: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub recurring_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bulletin_week: Option, + pub submitter_email: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} \ No newline at end of file diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..de0fefa --- /dev/null +++ b/src/models/mod.rs @@ -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}; \ No newline at end of file diff --git a/src/models/sermon.rs b/src/models/sermon.rs new file mode 100644 index 0000000..8badb6b --- /dev/null +++ b/src/models/sermon.rs @@ -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, + pub date: Option, + pub duration: String, // Duration as string like "1:13:01" + pub description: Option, + pub audio_url: Option, + pub video_url: Option, + pub media_type: Option, + pub thumbnail: Option, + pub scripture_reading: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Sermon { + pub id: String, + pub title: String, + pub speaker: String, + pub description: String, + pub date: DateTime, + pub scripture_reference: String, + pub series: Option, + pub duration_string: Option, // Raw duration from API (e.g., "2:34:49") + pub media_url: Option, + pub audio_url: Option, + pub video_url: Option, + pub transcript: Option, + pub thumbnail: Option, + pub tags: Option>, + pub category: SermonCategory, + pub is_featured: bool, + pub view_count: u32, + pub download_count: u32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct NewSermon { + pub title: String, + pub speaker: String, + pub description: String, + pub date: DateTime, + pub scripture_reference: String, + pub series: Option, + pub duration_string: Option, + pub media_url: Option, + pub audio_url: Option, + pub video_url: Option, + pub transcript: Option, + pub thumbnail: Option, + pub tags: Option>, + 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, + pub end_date: Option>, + pub thumbnail: Option, + pub sermons: Vec, + pub is_active: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[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, + pub is_private: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SermonFeedback { + pub id: String, + pub sermon_id: String, + pub user_name: Option, + pub user_email: Option, + pub rating: Option, // 1-5 stars + pub comment: Option, + pub is_public: bool, + pub created_at: DateTime, +} + +#[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, + pub speaker: Option, + pub category: Option, + pub series: Option, + pub date_from: Option>, + pub date_to: Option>, + pub tags: Option>, + pub featured_only: Option, + pub has_video: Option, + pub has_audio: Option, + pub has_transcript: Option, + pub min_duration: Option, + pub max_duration: Option, +} + +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, to: DateTime) -> 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 { + 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 { + 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(), + } + } +} \ No newline at end of file diff --git a/src/models/streaming.rs b/src/models/streaming.rs new file mode 100644 index 0000000..25703fe --- /dev/null +++ b/src/models/streaming.rs @@ -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"); + } +} \ No newline at end of file diff --git a/src/models/v2.rs b/src/models/v2.rs new file mode 100644 index 0000000..f3ec572 --- /dev/null +++ b/src/models/v2.rs @@ -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/", + } + } +} \ No newline at end of file diff --git a/src/uniffi_wrapper.rs b/src/uniffi_wrapper.rs new file mode 100644 index 0000000..a303f95 --- /dev/null +++ b/src/uniffi_wrapper.rs @@ -0,0 +1,1726 @@ +// UniFFI wrapper for church-core +// All configuration is handled internally by the crate + +use crate::{ChurchApiClient, ChurchCoreConfig, ContactForm, ClientEvent, ClientSermon, Sermon, DeviceCapabilities, StreamingCapability}; +use crate::utils::scripture::{format_scripture_text, extract_scripture_references, create_sermon_share_text}; +use crate::utils::{ValidationResult, ContactFormData, validate_contact_form, format_event_for_display, aggregate_home_feed, MediaType, get_media_content}; +use crate::models::{Bulletin, BibleVerse, config::Coordinates}; +use std::sync::{Arc, OnceLock}; +use base64::prelude::*; +use chrono::{DateTime, FixedOffset, NaiveDateTime, NaiveDate, NaiveTime, TimeZone, Local}; + +// Global client instance for caching +static GLOBAL_CLIENT: OnceLock> = OnceLock::new(); + +// Global runtime instance to avoid creating/dropping runtimes +static GLOBAL_RUNTIME: OnceLock = OnceLock::new(); + +fn get_runtime() -> &'static tokio::runtime::Runtime { + GLOBAL_RUNTIME.get_or_init(|| { + tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime") + }) +} + +fn get_or_create_client() -> Arc { + GLOBAL_CLIENT.get_or_init(|| { + let config = ChurchCoreConfig::new(); + + // Create client with disk caching enabled + let client = ChurchApiClient::new(config).expect("Failed to create client"); + + // Try to get app cache directory for disk caching + #[cfg(target_os = "ios")] + let cache_dir = std::env::var("HOME") + .map(|home| std::path::PathBuf::from(home).join("Library/Caches/church_core")) + .unwrap_or_else(|_| std::path::PathBuf::from("/tmp/church_core_cache")); + + #[cfg(not(target_os = "ios"))] + let cache_dir = std::path::PathBuf::from("/tmp/church_core_cache"); + + // Create cache with disk support + let cache = crate::cache::MemoryCache::new(100).with_disk_cache(cache_dir); + let client = client.with_cache(std::sync::Arc::new(cache)); + + Arc::new(client) + }).clone() +} + +// Shared helper function to convert Sermon objects to JSON with proper formatting +fn sermons_to_json(sermons: Vec, content_type: &str, base_url: &str) -> String { + let client_sermons: Vec = sermons + .into_iter() + .map(|sermon| ClientSermon::from_sermon_with_base_url(sermon, base_url)) + .collect(); + + println!("๐ŸŽฌ Successfully loaded {} {}", client_sermons.len(), content_type); + + serde_json::to_string(&client_sermons).unwrap_or_else(|_| "[]".to_string()) +} + +// Static storage for original events - used for calendar operations +static ORIGINAL_EVENTS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +pub fn fetch_events_json() -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); // Uses default URL and settings + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + match client.get_upcoming_events_v2(None).await { + Ok(events) => { + // Store original events for calendar operations + if let Ok(mut stored_events) = ORIGINAL_EVENTS.lock() { + *stored_events = events.clone(); + } + + // Convert server events to client events with formatted dates + let client_events: Vec = events + .into_iter() + .map(ClientEvent::from) + .collect(); + + // Create iOS-compatible response structure that matches EventsApiResponse + let response = serde_json::json!({ + "items": client_events, + "total": client_events.len() as u32, + "page": 1, + "perPage": 50, // camelCase for iOS Swift properties + "hasMore": false // camelCase for iOS Swift properties + }); + + serde_json::to_string(&response).unwrap_or_else(|_| r#"{"items":[],"total":0,"page":1,"perPage":50,"hasMore":false}"#.to_string()) + }, + Err(_) => r#"{"items":[],"total":0,"page":1,"perPage":50,"hasMore":false}"#.to_string(), + } + }) + } + Err(_) => r#"{"items":[],"total":0,"page":1,"perPage":50,"hasMore":false}"#.to_string() + } +} + +pub fn fetch_bulletins_json() -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); // Uses default URL and settings + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + match client.get_bulletins(true).await { + Ok(bulletins) => serde_json::to_string(&bulletins).unwrap_or_else(|_| "[]".to_string()), + Err(_) => "[]".to_string(), + } + }) + } + Err(_) => "[]".to_string() + } +} + +pub fn fetch_sermons_json() -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); + let base_url = config.api_base_url.clone(); + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + // Use the fixed get_recent_sermons method that calls /sermons directly + match client.get_recent_sermons(Some(50)).await { + Ok(sermons) => sermons_to_json(sermons, "sermons", &base_url), + Err(e) => { + println!("โŒ Failed to get sermons from church-core: {}", e); + "[]".to_string() + } + } + }) + } + Err(e) => { + println!("โŒ Failed to create church client: {}", e); + "[]".to_string() + } + } +} + +pub fn fetch_bible_verse_json(query: String) -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + match client.search_verses(&query, Some(10)).await { + Ok(verses) => serde_json::to_string(&verses).unwrap_or_else(|_| "[]".to_string()), + Err(_) => "[]".to_string(), + } + }) + } + Err(_) => "[]".to_string() + } +} + + +pub fn fetch_random_bible_verse_json() -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); // Uses default URL and settings + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + match client.get_random_verse().await { + Ok(verse) => serde_json::to_string(&verse).unwrap_or_else(|_| "{}".to_string()), + Err(_) => "{}".to_string(), + } + }) + } + Err(_) => "{}".to_string() + } +} + +pub fn fetch_scripture_verses_for_sermon_json(sermon_id: String) -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + // First get the sermons to find the one with matching ID + match client.get_recent_sermons(Some(100)).await { + Ok(sermons) => { + println!("๐Ÿ” Looking for sermon ID: {}", sermon_id); + println!("๐Ÿ” Found {} sermons", sermons.len()); + if let Some(sermon) = sermons.iter().find(|s| s.id == sermon_id) { + println!("๐Ÿ” Found sermon: {}", sermon.title); + let scripture_text = &sermon.scripture_reference; + println!("๐Ÿ” Scripture reference: '{}'", scripture_text); + if !scripture_text.is_empty() { + // Return the scripture text directly - no API calls needed + scripture_text.clone() + } else { + println!("โš ๏ธ Scripture reference is empty"); + "No scripture reading available".to_string() + } + } else { + println!("โš ๏ธ Sermon not found with ID: {}", sermon_id); + "Sermon not found".to_string() + } + }, + Err(_) => "[]".to_string(), + } + }) + } + Err(_) => "[]".to_string() + } +} + +pub fn submit_contact_json(name: String, email: String, message: String) -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); // Uses default URL and settings + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + let contact = ContactForm::new(name, email, "General Inquiry".to_string(), message); + + match client.submit_contact_form(contact).await { + Ok(_) => r#"{"success": true}"#.to_string(), + Err(_) => r#"{"success": false}"#.to_string(), + } + }) + } + Err(_) => r#"{"success": false}"#.to_string() + } +} + +pub fn submit_contact_v2_json(name: String, email: String, subject: String, message: String, phone: String) -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + let mut contact = ContactForm::new(name, email, subject, message); + if !phone.trim().is_empty() { + contact = contact.with_phone(phone); + } + + // Use V2 API - the proper way + match crate::client::contact::submit_contact_form_v2(&client, contact).await { + Ok(_) => r#"{"success": true}"#.to_string(), + Err(e) => { + println!("Contact form submission error: {}", e); + r#"{"success": false}"#.to_string() + } + } + }) + } + Err(e) => { + println!("Failed to create client: {}", e); + r#"{"success": false}"#.to_string() + } + } +} + +// Keep the old function for backwards compatibility during transition +pub fn submit_contact_v2_json_legacy(first_name: String, last_name: String, email: String, subject: String, message: String) -> String { + let full_name = if last_name.trim().is_empty() { + first_name + } else { + format!("{} {}", first_name, last_name) + }; + + submit_contact_v2_json(full_name, email, subject, message, "".to_string()) +} + +// Submit event for approval +pub fn submit_event_json( + title: String, + description: String, + start_time: String, + end_time: String, + location: String, + location_url: Option, + category: String, + recurring_type: Option, + submitter_email: Option +) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + let submission = crate::models::EventSubmission { + title, + description, + start_time, + end_time, + location, + location_url, + category, + is_featured: false, + recurring_type, + bulletin_week: None, + submitter_email: submitter_email.unwrap_or_default(), + }; + + rt.block_on(async { + match crate::client::events::submit_event(&client, submission).await { + Ok(event_id) => { + serde_json::json!({ + "success": true, + "event_id": event_id, + "message": "Event submitted successfully" + }).to_string() + }, + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string(), + "message": "Failed to submit event" + }).to_string() + } + } + }) +} + +// Get configuration for dynamic URL and settings +pub fn fetch_config_json() -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + match client.get_config().await { + Ok(config) => serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_string()), + Err(_) => "{}".to_string(), + } + }) + } + Err(_) => "{}".to_string() + } +} + +// Get current bulletin specifically (better than all bulletins) +pub fn fetch_current_bulletin_json() -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + match client.get_current_bulletin().await { + Ok(Some(bulletin)) => serde_json::to_string(&bulletin).unwrap_or_else(|_| "{}".to_string()), + Ok(None) => "{}".to_string(), + Err(_) => "{}".to_string(), + } + }) + } + Err(_) => "{}".to_string() + } +} + +// Get featured events for homepage display +pub fn fetch_featured_events_json() -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + match client.get_featured_events_v2(Some(5)).await { + Ok(events) => { + let client_events: Vec = events + .into_iter() + .map(ClientEvent::from) + .collect(); + + serde_json::to_string(&client_events).unwrap_or_else(|_| "[]".to_string()) + }, + Err(_) => "[]".to_string(), + } + }) + } + Err(_) => "[]".to_string() + } +} + +// Get current live stream status from Owncast +pub fn fetch_stream_status_json() -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + match client.get_stream_status().await { + Ok(status) => serde_json::to_string(&status).unwrap_or_else(|_| r#"{"is_live":false}"#.to_string()), + Err(_) => r#"{"is_live":false}"#.to_string(), + } + }) + } + Err(_) => r#"{"is_live":false}"#.to_string() + } +} + +// Get live stream status as boolean (RTSDA Architecture compliance) +pub fn get_stream_live_status() -> bool { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + match client.get_stream_status().await { + Ok(status) => status.is_live, + Err(_) => false, + } + }) + } + Err(_) => false + } +} + +// Get livestream URL if available (RTSDA Architecture compliance) +pub fn get_livestream_url() -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + match client.get_stream_status().await { + Ok(status) => status.stream_url.unwrap_or_default(), + Err(_) => String::new(), + } + }) +} + +// RTSDA Architecture Compliance: JSON Parsing Functions +// These functions move all JSON parsing from Swift to Rust + +// Parse events response JSON into events array (for both featured and all events) +pub fn parse_events_from_json(events_json: String) -> String { + // Simple extraction of events array from response + match serde_json::from_str::(&events_json) { + Ok(parsed) => { + // Extract events array from common response structures + if let Some(items) = parsed.get("items") { + serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string()) + } else if let Some(data) = parsed.get("data") { + serde_json::to_string(data).unwrap_or_else(|_| "[]".to_string()) + } else if let Some(events) = parsed.get("events") { + serde_json::to_string(events).unwrap_or_else(|_| "[]".to_string()) + } else { + "[]".to_string() + } + } + Err(_) => "[]".to_string() + } +} + +// Parse sermons JSON into sermon array +pub fn parse_sermons_from_json(sermons_json: String) -> String { + match serde_json::from_str::(&sermons_json) { + Ok(value) => { + // Check if this is the API response format with "data" field + if let Some(data_array) = value.get("data").and_then(|d| d.as_array()) { + // Convert API response format to iOS-compatible format + let converted_sermons: Vec = data_array + .iter() + .filter_map(|sermon| convert_sermon_fields(sermon)) + .collect(); + + serde_json::to_string(&converted_sermons).unwrap_or_else(|_| "[]".to_string()) + } else if let Some(array) = value.as_array() { + // Already an array, convert each sermon + let converted_sermons: Vec = array + .iter() + .filter_map(|sermon| convert_sermon_fields(sermon)) + .collect(); + + serde_json::to_string(&converted_sermons).unwrap_or_else(|_| "[]".to_string()) + } else { + "[]".to_string() // Not in expected format + } + } + Err(_) => "[]".to_string() // Invalid JSON, return empty array + } +} + +// Helper function to convert sermon field names from snake_case to camelCase +fn convert_sermon_fields(sermon: &serde_json::Value) -> Option { + if let Some(obj) = sermon.as_object() { + let mut converted = serde_json::Map::new(); + + for (key, value) in obj { + let new_key = match key.as_str() { + "audio_url" => "audioUrl", + "video_url" => "videoUrl", + "media_type" => "mediaType", + "scripture_reading" => "scriptureReading", + _ => key, + }; + converted.insert(new_key.to_string(), value.clone()); + } + + Some(serde_json::Value::Object(converted)) + } else { + None + } +} + +// Parse bulletins JSON into bulletin array +pub fn parse_bulletins_from_json(bulletins_json: String) -> String { + // Already returning bulletin array, just validate and pass through + match serde_json::from_str::(&bulletins_json) { + Ok(_) => bulletins_json, // Valid JSON, return as-is + Err(_) => "[]".to_string() // Invalid JSON, return empty array + } +} + +// Parse bible verse JSON into verse object +pub fn parse_bible_verse_from_json(verse_json: String) -> String { + // Validate and pass through bible verse JSON + match serde_json::from_str::(&verse_json) { + Ok(_) => verse_json, // Valid JSON, return as-is + Err(_) => r#"{"text":"","reference":"","error":true}"#.to_string() // Invalid JSON, return error verse + } +} + +// Parse contact submission result from JSON +pub fn parse_contact_result_from_json(result_json: String) -> String { + // Validate and pass through contact result JSON + match serde_json::from_str::(&result_json) { + Ok(_) => result_json, // Valid JSON, return as-is + Err(_) => r#"{"success":false,"error":"Invalid response"}"#.to_string() // Invalid JSON, return error + } +} + +// Generate truncated description from Bible verses JSON (RTSDA Architecture compliance) +pub fn generate_verse_description(verses_json: String) -> String { + match serde_json::from_str::(&verses_json) { + Ok(parsed) => { + if let Some(verses_array) = parsed.as_array() { + // Extract text from verses and join + let verse_texts: Vec = verses_array + .iter() + .filter_map(|verse| verse.get("text")?.as_str()) + .map(|s| s.to_string()) + .collect(); + + let full_text = verse_texts.join(" "); + + // Truncate to ~80 characters at word boundary + if full_text.len() <= 80 { + full_text + } else { + let truncated = &full_text[..80]; + if let Some(last_space) = truncated.rfind(' ') { + format!("{}...", &truncated[..last_space]) + } else { + format!("{}...", truncated) + } + } + } else { + "Proclaiming the everlasting gospel".to_string() + } + } + Err(_) => "Proclaiming the everlasting gospel".to_string() + } +} + +// Extract and combine full verse text from Bible verses JSON (RTSDA Architecture compliance) +pub fn extract_full_verse_text(verses_json: String) -> String { + match serde_json::from_str::(&verses_json) { + Ok(parsed) => { + if let Some(verses_array) = parsed.as_array() { + // Extract text from verses and join with space + let verse_texts: Vec = verses_array + .iter() + .filter_map(|verse| verse.get("text")?.as_str()) + .map(|s| s.to_string()) + .collect(); + + verse_texts.join(" ") + } else { + String::new() + } + } + Err(_) => String::new() + } +} + +// Extract stream URL from stream status JSON (RTSDA Architecture compliance) +pub fn extract_stream_url_from_status(status_json: String) -> String { + match serde_json::from_str::(&status_json) { + Ok(parsed) => { + if let Some(stream_url) = parsed.get("stream_url") { + if let Some(url_str) = stream_url.as_str() { + return url_str.to_string(); + } + } + String::new() + } + Err(_) => String::new() + } +} + +// Get live stream info from Owncast +pub fn fetch_live_stream_json() -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + match client.get_live_stream().await { + Ok(stream) => serde_json::to_string(&stream).unwrap_or_else(|_| r#"{"is_live":false}"#.to_string()), + Err(_) => r#"{"is_live":false}"#.to_string(), + } + }) + } + Err(_) => r#"{"is_live":false}"#.to_string() + } +} + +pub fn fetch_livestream_archive_json() -> String { + let rt = tokio::runtime::Runtime::new().unwrap(); + let config = ChurchCoreConfig::new(); + let base_url = config.api_base_url.clone(); + + match ChurchApiClient::new(config) { + Ok(client) => { + rt.block_on(async { + match client.get_livestreams().await { + Ok(streams) => { + sermons_to_json(streams, "livestream archives", &base_url) + }, + Err(_) => { + "[]".to_string() + }, + } + }) + } + Err(_) => { + "[]".to_string() + } + } +} + +/// Fetch an image with caching support - returns base64 encoded data +pub fn fetch_cached_image_base64(url: String) -> String { + // Use the shared global runtime instead of creating one per request + let rt = get_runtime(); + + rt.block_on(async { + let client = get_or_create_client(); + println!("๐Ÿ” Starting image fetch for: {}", url); + + match client.get_cached_image(&url).await { + Ok(cached_response) => { + println!("๐Ÿ“ธ Successfully fetched cached image: {} bytes", cached_response.data.len()); + + // Return JSON with base64 data and metadata + let response = serde_json::json!({ + "success": true, + "data": base64::prelude::BASE64_STANDARD.encode(&cached_response.data), + "content_type": cached_response.content_type, + "cached": true + }); + + serde_json::to_string(&response).unwrap_or_else(|_| r#"{"success": false, "error": "JSON encode failed"}"#.to_string()) + }, + Err(e) => { + println!("โŒ Failed to fetch cached image: {}", e); + serde_json::json!({ + "success": false, + "error": format!("Failed to fetch image: {}", e) + }).to_string() + } + } + }) +} + +// Streaming capability detection and URL generation + +/// Get optimal streaming URL for the current device +pub fn get_optimal_streaming_url(media_id: String) -> String { + let config = ChurchCoreConfig::new(); + let base_url = config.api_base_url; + + let streaming_url = DeviceCapabilities::get_optimal_streaming_url(&base_url, &media_id); + streaming_url.url +} + +/// Get AV1 streaming URL (direct stream) +pub fn get_av1_streaming_url(media_id: String) -> String { + let config = ChurchCoreConfig::new(); + let base_url = config.api_base_url; + + let streaming_url = DeviceCapabilities::get_streaming_url(&base_url, &media_id, StreamingCapability::AV1); + streaming_url.url +} + +/// Get HLS streaming URL (H.264 playlist) +pub fn get_hls_streaming_url(media_id: String) -> String { + let config = ChurchCoreConfig::new(); + let base_url = config.api_base_url; + + let streaming_url = DeviceCapabilities::get_streaming_url(&base_url, &media_id, StreamingCapability::HLS); + streaming_url.url +} + +/// Check if current device supports AV1 +pub fn device_supports_av1() -> bool { + match DeviceCapabilities::detect_capability() { + StreamingCapability::AV1 => true, + StreamingCapability::HLS => false, + } +} + +// Scripture formatting utilities + +/// Format raw scripture text into structured sections with verses and references +pub fn format_scripture_text_json(scripture_text: String) -> String { + let sections = format_scripture_text(&scripture_text); + serde_json::to_string(§ions).unwrap_or_else(|_| "[]".to_string()) +} + +/// Extract scripture references from text (e.g., "Joel 2:28 KJV" patterns) +pub fn extract_scripture_references_string(scripture_text: String) -> String { + extract_scripture_references(&scripture_text) +} + +/// Create standardized share items for sermons +pub fn create_sermon_share_items_json(title: String, speaker: String, video_url: Option, audio_url: Option) -> String { + let video_ref = video_url.as_deref(); + let audio_ref = audio_url.as_deref(); + let items = create_sermon_share_text(&title, &speaker, video_ref, audio_ref); + serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string()) +} + +// MARK: - Form Validation Functions + +/// Validate contact form data and return validation result as JSON +pub fn validate_contact_form_json(form_json: String) -> String { + match serde_json::from_str::(&form_json) { + Ok(form_data) => { + let result = validate_contact_form(&form_data); + serde_json::to_string(&result).unwrap_or_else(|_| r#"{"is_valid":false,"errors":["JSON serialization failed"]}"#.to_string()) + } + Err(_) => { + let result = ValidationResult::invalid(vec!["Invalid form data format".to_string()]); + serde_json::to_string(&result).unwrap_or_else(|_| r#"{"is_valid":false,"errors":["Invalid form data format"]}"#.to_string()) + } + } +} + +/// Quick email validation +pub fn validate_email_address(email: String) -> bool { + crate::utils::is_valid_email(&email) +} + +/// Quick phone validation +pub fn validate_phone_number(phone: String) -> bool { + crate::utils::is_valid_phone(&phone) +} + +// MARK: - Event Formatting Functions + +/// Format event for display with all computed properties +pub fn format_event_for_display_json(event_json: String) -> String { + match serde_json::from_str::(&event_json) { + Ok(event) => { + let formatted = format_event_for_display(&event); + serde_json::to_string(&formatted).unwrap_or_else(|_| r#"{"formatted_time":"","formatted_date_time":"","is_multi_day":false,"formatted_date_range":""}"#.to_string()) + } + Err(_) => r#"{"formatted_time":"","formatted_date_time":"","is_multi_day":false,"formatted_date_range":""}"#.to_string() + } +} + +/// Format time range for events +pub fn format_time_range_string(start_time: String, end_time: String) -> String { + crate::utils::format_time_range(&start_time, &end_time) +} + +/// Check if event is multi-day +pub fn is_multi_day_event_check(date: String) -> bool { + crate::utils::is_multi_day_event(&date) +} + +// MARK: - Home Feed Aggregation + +/// Generate aggregated home feed from individual content types +pub fn generate_home_feed_json(events_json: String, sermons_json: String, bulletins_json: String, verse_json: String) -> String { + // Parse each content type + let events: Vec = serde_json::from_str(&events_json).unwrap_or_default(); + let sermons: Vec = serde_json::from_str(&sermons_json).unwrap_or_default(); + let bulletins: Vec = serde_json::from_str(&bulletins_json).unwrap_or_default(); + let verse: Option = serde_json::from_str(&verse_json).ok(); + + // Generate feed + let feed_items = aggregate_home_feed(&events, &sermons, &bulletins, verse.as_ref()); + + serde_json::to_string(&feed_items).unwrap_or_else(|_| "[]".to_string()) +} + +// MARK: - Media Type Management + +/// Get display name for media type +pub fn get_media_type_display_name(media_type_str: String) -> String { + match media_type_str.as_str() { + "sermons" => MediaType::Sermons.display_name().to_string(), + "livestreams" => MediaType::LiveStreams.display_name().to_string(), + _ => "Unknown".to_string(), + } +} + +/// Get icon name for media type +pub fn get_media_type_icon(media_type_str: String) -> String { + match media_type_str.as_str() { + "sermons" => MediaType::Sermons.icon_name().to_string(), + "livestreams" => MediaType::LiveStreams.icon_name().to_string(), + _ => "questionmark".to_string(), + } +} + +/// Filter sermons by media type +pub fn filter_sermons_by_media_type(sermons_json: String, media_type_str: String) -> String { + let sermons: Vec = serde_json::from_str(&sermons_json).unwrap_or_default(); + + let media_type = match media_type_str.as_str() { + "sermons" => MediaType::Sermons, + "livestreams" => MediaType::LiveStreams, + _ => MediaType::Sermons, + }; + + let filtered = get_media_content(&sermons, &media_type); + serde_json::to_string(&filtered).unwrap_or_else(|_| "[]".to_string()) +} + +// MARK: - Individual Config Getter Functions + +/// Helper function to load config with caching +fn get_cached_config() -> Option { + let rt = get_runtime(); + rt.block_on(async { + let client = get_or_create_client(); + match client.get_config().await { + Ok(config) => Some(config), + Err(e) => { + println!("โŒ Failed to load config: {}", e); + None + } + } + }) +} + +/// Get church name with fallback +pub fn get_church_name() -> String { + get_cached_config() + .and_then(|config| config.church_name) + .unwrap_or_else(|| "Rockville Tolland SDA Church".to_string()) +} + +/// Get contact phone with fallback +pub fn get_contact_phone() -> String { + get_cached_config() + .and_then(|config| config.contact_phone) + .unwrap_or_else(|| "(860) 872-3030".to_string()) +} + +/// Get contact email with fallback +pub fn get_contact_email() -> String { + get_cached_config() + .and_then(|config| config.contact_email) + .unwrap_or_else(|| "info@rockvilletollandsda.church".to_string()) +} + +/// Get brand color with fallback +pub fn get_brand_color() -> String { + get_cached_config() + .and_then(|config| config.brand_color) + .unwrap_or_else(|| "#2C5F41".to_string()) // Default church green +} + +/// Get about text with fallback +pub fn get_about_text() -> String { + get_cached_config() + .and_then(|config| config.about_text) + .unwrap_or_else(|| "Welcome to our church family! We are a caring community committed to sharing God's love and growing in faith together.".to_string()) +} + +/// Get donation URL with fallback +pub fn get_donation_url() -> String { + get_cached_config() + .and_then(|config| config.donation_url) + .unwrap_or_else(|| "https://adventistgiving.org/donate/ANRTOL".to_string()) +} + +/// Get church address with fallback +pub fn get_church_address() -> String { + get_cached_config() + .and_then(|config| config.church_address) + .unwrap_or_else(|| "115 Snipsic Lake Road, Tolland, CT 06084".to_string()) +} + +/// Get coordinates from config coordinates object or return empty vector +pub fn get_coordinates() -> Vec { + if let Some(config) = get_cached_config() { + // First check for direct coordinates object + if let Some(coords) = config.coordinates { + return vec![coords.lat, coords.lng]; + } + } + + // Return empty vector if no coordinates found + vec![] +} + + +/// Get website URL with fallback +pub fn get_website_url() -> String { + get_cached_config() + .and_then(|config| config.website_url) + .unwrap_or_else(|| "https://rockvilletollandsda.church".to_string()) +} + +/// Get Facebook URL with fallback +pub fn get_facebook_url() -> String { + get_cached_config() + .and_then(|config| config.facebook_url) + .unwrap_or_else(|| "".to_string()) +} + +/// Get YouTube URL with fallback +pub fn get_youtube_url() -> String { + get_cached_config() + .and_then(|config| config.youtube_url) + .unwrap_or_else(|| "".to_string()) +} + +/// Get Instagram URL with fallback +pub fn get_instagram_url() -> String { + get_cached_config() + .and_then(|config| config.instagram_url) + .unwrap_or_else(|| "".to_string()) +} + +/// Get mission statement with fallback +pub fn get_mission_statement() -> String { + get_cached_config() + .and_then(|config| config.mission_statement) + .unwrap_or_else(|| "To share God's love and prepare people for Jesus' return.".to_string()) +} + +/// Parse time-only formats like "5:00 AM" and "6:00 PM" - assumes today's date +fn parse_time_only_format(time_str: &str) -> Result, Box> { + use chrono::{Local, NaiveTime, Timelike}; + + let time_formats = [ + "%l:%M %p", // "5:00 AM" or "6:00 PM" (space-padded hour) + "%I:%M %p", // "05:00 AM" or "06:00 PM" (zero-padded hour) + "%l:%M%p", // "5:00AM" or "6:00PM" (no space) + "%I:%M%p", // "05:00AM" or "06:00PM" (no space) + "%H:%M", // "17:00" (24-hour format) + ]; + + let eastern_tz = chrono::FixedOffset::west_opt(5 * 3600).unwrap(); // EST (UTC-5) + + for format in &time_formats { + if let Ok(naive_time) = NaiveTime::parse_from_str(time_str.trim(), format) { + // Use today's date in Eastern Time + let today = Local::now().date_naive(); + let naive_dt = today.and_time(naive_time); + + // Convert to Eastern Time + if let Some(dt_with_tz) = eastern_tz.from_local_datetime(&naive_dt).single() { + return Ok(dt_with_tz); + } + } + } + + Err(format!("Unable to parse time-only format: {}", time_str).into()) +} + +/// Parse timestamps with multiple format support - handles ISO 8601 and other common formats +fn parse_flexible_timestamp(timestamp_str: &str) -> Result, Box> { + if timestamp_str.is_empty() { + return Err("Empty timestamp string".into()); + } + + // Try RFC 3339/ISO 8601 first (most common format) + if let Ok(date) = chrono::DateTime::parse_from_rfc3339(timestamp_str) { + return Ok(date); + } + + // Try parsing without timezone and assume Eastern Time (church location) + let formats = [ + "%Y-%m-%dT%H:%M:%S", // 2025-01-15T14:30:00 + "%Y-%m-%d %H:%M:%S", // 2025-01-15 14:30:00 + "%Y-%m-%dT%H:%M:%S%.f", // 2025-01-15T14:30:00.123 + "%Y-%m-%d %H:%M:%S%.f", // 2025-01-15 14:30:00.123 + "%Y-%m-%dT%H:%M", // 2025-01-15T14:30 + "%Y-%m-%d %H:%M", // 2025-01-15 14:30 + ]; + + // Handle time-only formats like "5:00 AM" or "6:00 PM" + if let Ok(time) = parse_time_only_format(timestamp_str) { + return Ok(time); + } + + // Try parsing with Eastern Time zone (UTC-5/-4) + let eastern_tz = chrono::FixedOffset::west_opt(5 * 3600).unwrap(); // EST (UTC-5) + + for format in &formats { + if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(timestamp_str, format) { + // Convert to Eastern Time + let dt_with_tz = eastern_tz.from_local_datetime(&naive_dt).single(); + if let Some(dt) = dt_with_tz { + return Ok(dt); + } + } + } + + // If all else fails, try parsing as date only and set time to start of day + if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(timestamp_str, "%Y-%m-%d") { + let naive_dt = naive_date.and_hms_opt(9, 0, 0).unwrap(); // Default to 9 AM + let dt_with_tz = eastern_tz.from_local_datetime(&naive_dt).single(); + if let Some(dt) = dt_with_tz { + return Ok(dt); + } + } + + Err(format!("Unable to parse timestamp: {}", timestamp_str).into()) +} + +/// Parse time with date context - combines a formatted date like "Saturday, July 12, 2025" +/// with a time like "5:00 AM" to create a proper DateTime +fn parse_time_with_date_context(time_str: &str, date_str: &str) -> Result, Box> { + use chrono::{NaiveTime, NaiveDate, Timelike}; + + // If we have a time-only format and a date, combine them + if !time_str.is_empty() && !date_str.is_empty() { + // Parse the time part + let time_formats = [ + "%l:%M %p", // "5:00 AM" or "6:00 PM" (space-padded hour) + "%I:%M %p", // "05:00 AM" or "06:00 PM" (zero-padded hour) + "%l:%M%p", // "5:00AM" or "6:00PM" (no space) + "%I:%M%p", // "05:00AM" or "06:00PM" (no space) + "%H:%M", // "17:00" (24-hour format) + ]; + + let mut parsed_time: Option = None; + for format in &time_formats { + if let Ok(naive_time) = NaiveTime::parse_from_str(time_str.trim(), format) { + parsed_time = Some(naive_time); + break; + } + } + + if let Some(time) = parsed_time { + // Handle date ranges - extract start date if it's a range like "Saturday, July 12, 2025 - Sunday, July 13, 2025" + let date_to_parse = if date_str.contains(" - ") { + let start_date = date_str.split(" - ").next().unwrap_or(date_str).trim(); + println!("๐Ÿ” DEBUG: Found date range '{}', using start date: '{}'", date_str, start_date); + start_date + } else { + println!("๐Ÿ” DEBUG: Single date format: '{}'", date_str); + date_str.trim() + }; + + // Parse the date part - handle formats like "Saturday, July 12, 2025" + let date_formats = [ + "%A, %B %d, %Y", // "Saturday, July 12, 2025" + "%A, %B %e, %Y", // "Saturday, July 2, 2025" (space-padded day) + "%B %d, %Y", // "July 12, 2025" + "%B %e, %Y", // "July 2, 2025" (space-padded day) + "%Y-%m-%d", // "2025-07-12" + "%m/%d/%Y", // "7/12/2025" + "%d/%m/%Y", // "12/7/2025" + ]; + + let mut parsed_date: Option = None; + for format in &date_formats { + if let Ok(naive_date) = NaiveDate::parse_from_str(date_to_parse, format) { + println!("๐Ÿ” DEBUG: Successfully parsed date '{}' with format '{}'", date_to_parse, format); + parsed_date = Some(naive_date); + break; + } + } + + if parsed_date.is_none() { + println!("โš ๏ธ DEBUG: Failed to parse date '{}' with any format", date_to_parse); + } + + if let Some(date) = parsed_date { + // Combine date and time + let naive_dt = date.and_time(time); + + // Convert to Eastern Time (church location) + let eastern_tz = chrono::FixedOffset::west_opt(5 * 3600).unwrap(); // EST (UTC-5) + let dt_with_tz = eastern_tz.from_local_datetime(&naive_dt).single(); + if let Some(dt) = dt_with_tz { + return Ok(dt); + } + } + } + } + + // Fallback to the original flexible timestamp parsing + parse_flexible_timestamp(time_str) +} + +/// Calculate next occurrence for recurring events +fn calculate_next_occurrence( + start_time: &chrono::DateTime, + end_time: &chrono::DateTime, + recurring_type: &crate::models::event::RecurringType +) -> Result<(chrono::DateTime, chrono::DateTime), Box> { + use chrono::{Duration, Utc, Datelike, Weekday}; + + let now = Utc::now(); + let duration = *end_time - *start_time; + + match recurring_type { + crate::models::event::RecurringType::Daily => { + // Daily Prayer Meeting: Sunday-Friday (skip Saturday) + let event_time = start_time.time(); + let mut next_date = now.date_naive(); + + // If it's still today and the time hasn't passed, use today + if now.time() < event_time { + // Check if today is Saturday - if so, skip to Sunday + if next_date.weekday() == Weekday::Sat { + next_date = next_date + Duration::days(1); // Sunday + } + } else { + // Move to next day + next_date = next_date + Duration::days(1); + + // Skip Saturday - move to Sunday + if next_date.weekday() == Weekday::Sat { + next_date = next_date + Duration::days(1); // Sunday + } + } + + let next_start = next_date.and_time(event_time).and_utc(); + let next_end = next_start + duration; + + Ok((next_start, next_end)) + }, + crate::models::event::RecurringType::Weekly => { + // Find next week occurrence + let mut next_start = *start_time; + while next_start <= now { + next_start = next_start + Duration::weeks(1); + } + let next_end = next_start + duration; + Ok((next_start, next_end)) + }, + crate::models::event::RecurringType::Biweekly => { + // Find next bi-weekly occurrence + let mut next_start = *start_time; + while next_start <= now { + next_start = next_start + Duration::weeks(2); + } + let next_end = next_start + duration; + Ok((next_start, next_end)) + }, + crate::models::event::RecurringType::Monthly => { + // Find next monthly occurrence - same day of month + let mut next_start = *start_time; + while next_start <= now { + // Add one month - handle month overflow + let next_month = if next_start.month() == 12 { + next_start.with_year(next_start.year() + 1).unwrap().with_month(1).unwrap() + } else { + next_start.with_month(next_start.month() + 1).unwrap() + }; + next_start = next_month; + } + let next_end = next_start + duration; + Ok((next_start, next_end)) + }, + crate::models::event::RecurringType::FirstTuesday => { + // First Tuesday of each month + let mut search_date = now.date_naive(); + loop { + let first_of_month = search_date.with_day(1).unwrap(); + let first_tuesday = first_of_month + Duration::days((Weekday::Tue.num_days_from_monday() as i64 - first_of_month.weekday().num_days_from_monday() as i64 + 7) % 7); + + let next_start = first_tuesday.and_time(start_time.time()).and_utc(); + if next_start > now { + let next_end = next_start + duration; + return Ok((next_start, next_end)); + } + + // Move to next month + search_date = if search_date.month() == 12 { + search_date.with_year(search_date.year() + 1).unwrap().with_month(1).unwrap() + } else { + search_date.with_month(search_date.month() + 1).unwrap() + }; + } + }, + _ => { + // For other types, just return the original times + Ok((*start_time, *end_time)) + } + } +} + +/// Get event with proper timestamps for calendar - uses stored original events +pub fn get_event_for_calendar_json(event_id: String) -> String { + if let Ok(stored_events) = ORIGINAL_EVENTS.lock() { + if let Some(event) = stored_events.iter().find(|e| e.id == event_id) { + serde_json::to_string(event).unwrap_or_else(|_| "{}".to_string()) + } else { + println!("โš ๏ธ Event ID '{}' not found in stored events", event_id); + "{}".to_string() + } + } else { + println!("โš ๏ธ Could not access stored events"); + "{}".to_string() + } +} + +/// Create calendar event data from event JSON - handles all date/time parsing +pub fn create_calendar_event_data(event_json: String) -> String { + match serde_json::from_str::(&event_json) { + Ok(event_value) => { + // Check if this is a ClientEvent with an ID - if so, try to get original Event data + if let Some(event_id) = event_value.get("id").and_then(|v| v.as_str()) { + if let Ok(stored_events) = ORIGINAL_EVENTS.lock() { + if let Some(original_event) = stored_events.iter().find(|e| e.id == event_id) { + println!("๐Ÿ” DEBUG: Found original Event for ID '{}', using proper timestamps", event_id); + + // For recurring events, calculate next occurrence + let (final_start_time, final_end_time) = if let Some(recurring_type) = &original_event.recurring_type { + match calculate_next_occurrence(&original_event.start_time, &original_event.end_time, recurring_type) { + Ok((next_start, next_end)) => { + println!("๐Ÿ” DEBUG: Recurring event '{:?}' - calculated next occurrence", recurring_type); + (next_start, next_end) + }, + Err(e) => { + println!("โš ๏ธ DEBUG: Failed to calculate next occurrence for {:?}: {}", recurring_type, e); + (original_event.start_time, original_event.end_time) + } + } + } else { + (original_event.start_time, original_event.end_time) + }; + + // Provide raw UTC timestamps - let iOS handle timezone conversion + let start_timestamp = final_start_time.timestamp(); + let end_timestamp = final_end_time.timestamp(); + + let response = serde_json::json!({ + "success": true, + "title": original_event.title, + "description": original_event.clean_description(), + "location": original_event.location, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "recurring_type": original_event.recurring_type.as_ref().map(|rt| { + match rt { + crate::models::event::RecurringType::Daily => "DAILY", + crate::models::event::RecurringType::Weekly => "WEEKLY", + crate::models::event::RecurringType::Biweekly => "BIWEEKLY", + crate::models::event::RecurringType::Monthly => "MONTHLY", + crate::models::event::RecurringType::FirstTuesday => "FIRST_TUESDAY", + crate::models::event::RecurringType::FirstSabbath => "FIRST_SABBATH", + crate::models::event::RecurringType::LastSabbath => "LAST_SABBATH", + }.to_string() + }), + "has_recurrence": original_event.recurring_type.is_some() + }); + + return serde_json::to_string(&response).unwrap_or_else(|_| r#"{"success": false, "error": "JSON serialize failed"}"#.to_string()); + } + } + } + + // Fallback to parsing ClientEvent formatted timestamps + println!("๐Ÿ” DEBUG: Using fallback ClientEvent parsing"); + + // Extract timestamp and date information (handle both API format and Swift encoded format) + let start_time = event_value.get("start_time") + .or_else(|| event_value.get("startTime")) + .and_then(|v| v.as_str()).unwrap_or(""); + let end_time = event_value.get("end_time") + .or_else(|| event_value.get("endTime")) + .and_then(|v| v.as_str()).unwrap_or(""); + let date = event_value.get("date") + .and_then(|v| v.as_str()).unwrap_or(""); + let title = event_value.get("title").and_then(|v| v.as_str()).unwrap_or("Event"); + let description = event_value.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let location = event_value.get("location").and_then(|v| v.as_str()).unwrap_or(""); + let recurring_type = event_value.get("recurring_type") + .or_else(|| event_value.get("recurringType")) + .and_then(|v| v.as_str()); + + // Debug: Print the actual timestamp strings we're trying to parse + println!("๐Ÿ” DEBUG: title = '{}'", title); + println!("๐Ÿ” DEBUG: start_time = '{}'", start_time); + println!("๐Ÿ” DEBUG: end_time = '{}'", end_time); + println!("๐Ÿ” DEBUG: date = '{}'", date); + + // Parse timestamps with multiple format support + let start_date = parse_time_with_date_context(start_time, date); + let end_date = parse_time_with_date_context(end_time, date); + + // Debug: Print parsing results + println!("๐Ÿ” DEBUG: start_date parsing result = {:?}", start_date); + println!("๐Ÿ” DEBUG: end_date parsing result = {:?}", end_date); + + match (start_date, end_date) { + (Ok(start), Ok(end)) => { + // Convert to Unix timestamps (seconds since epoch) + let start_timestamp = start.timestamp(); + let end_timestamp = end.timestamp(); + + // Create response with parsed data + let response = serde_json::json!({ + "success": true, + "title": title, + "description": description, + "location": location, + "start_timestamp": start_timestamp, + "end_timestamp": end_timestamp, + "recurring_type": recurring_type, + "has_recurrence": recurring_type.is_some() + }); + + serde_json::to_string(&response).unwrap_or_else(|_| r#"{"success": false, "error": "JSON serialize failed"}"#.to_string()) + }, + _ => { + serde_json::json!({ + "success": false, + "error": "Failed to parse ISO 8601 timestamps" + }).to_string() + } + } + }, + Err(_) => { + serde_json::json!({ + "success": false, + "error": "Invalid event JSON" + }).to_string() + } + } +} + +// Parse calendar event data JSON response (for EventDetailShared RTSDA compliance) +pub fn parse_calendar_event_data(calendar_json: String) -> String { + match serde_json::from_str::(&calendar_json) { + Ok(data) => { + // Return the same data but ensure it's properly formatted + serde_json::to_string(&data).unwrap_or_else(|_| r#"{"success": false, "error": "JSON serialize failed"}"#.to_string()) + } + Err(_) => { + serde_json::json!({ + "success": false, + "error": "Invalid calendar JSON" + }).to_string() + } + } +} + +// ========== ADMIN FUNCTIONS ========== +// Admin functions temporarily commented out due to API changes + +/* +// Admin authentication +pub fn admin_login_json(username: String, password: String) -> String { + use crate::models::auth::LoginRequest; + + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + let login_request = LoginRequest { + identity: username, + password, + }; + + match client.post_api("/auth/login", &login_request).await { + Ok(response) => { + serde_json::json!({ + "success": true, + "data": response + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + }) +} + +// Validate admin token +pub fn admin_validate_token_json(token: String) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + match client.get_api_with_auth("/auth/validate", &token).await { + Ok(response) => { + serde_json::json!({ + "success": true, + "data": response + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + }) +} + +// Admin - Get pending events +pub fn admin_fetch_pending_events_json(token: String) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + match client.get_api_with_auth("/admin/events/pending", &token).await { + Ok(response) => { + serde_json::json!({ + "success": true, + "data": response + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + }) +} + +// Admin - Approve pending event +pub fn admin_approve_event_json(token: String, event_id: String, admin_notes: String) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + let path = format!("/admin/events/pending/{}/approve", event_id); + let payload = if admin_notes.is_empty() { + serde_json::json!({}) + } else { + serde_json::json!({ "admin_notes": admin_notes }) + }; + + match client.post_api_with_auth(&path, &payload, &token).await { + Ok(_) => { + serde_json::json!({ + "success": true + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + }) +} + +// Admin - Reject pending event +pub fn admin_reject_event_json(token: String, event_id: String, admin_notes: String) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + let path = format!("/admin/events/pending/{}/reject", event_id); + let payload = serde_json::json!({ "admin_notes": admin_notes }); + + match client.post_api_with_auth(&path, &payload, &token).await { + Ok(_) => { + serde_json::json!({ + "success": true + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + }) +} + +// Admin - Delete pending event +pub fn admin_delete_pending_event_json(token: String, event_id: String) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + let path = format!("/admin/events/pending/{}", event_id); + + match client.delete_api_with_auth(&path, &token).await { + Ok(_) => { + serde_json::json!({ + "success": true + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + }) +} + +// Admin - Get all schedules +pub fn admin_fetch_schedules_json(token: String) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + match client.get_api_with_auth("/admin/schedule", &token).await { + Ok(response) => { + serde_json::json!({ + "success": true, + "data": response + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + }) +} + +// Admin - Create schedule +pub fn admin_create_schedule_json(token: String, schedule_data: String) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + match serde_json::from_str::(&schedule_data) { + Ok(payload) => { + match client.post_api_with_auth("/admin/schedule", &payload, &token).await { + Ok(response) => { + serde_json::json!({ + "success": true, + "data": response + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": format!("Invalid JSON: {}", e) + }).to_string() + } + } + }) +} + +// Admin - Update schedule +pub fn admin_update_schedule_json(token: String, schedule_date: String, schedule_data: String) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + match serde_json::from_str::(&schedule_data) { + Ok(payload) => { + let path = format!("/admin/schedule/{}", schedule_date); + match client.put_api_with_auth(&path, &payload, &token).await { + Ok(_) => { + serde_json::json!({ + "success": true + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": format!("Invalid JSON: {}", e) + }).to_string() + } + } + }) +} + +// Admin - Delete schedule +pub fn admin_delete_schedule_json(token: String, schedule_date: String) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + let path = format!("/admin/schedule/{}", schedule_date); + + match client.delete_api_with_auth(&path, &token).await { + Ok(_) => { + serde_json::json!({ + "success": true + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + }) +} + +// Admin - Create bulletin +pub fn admin_create_bulletin_json(token: String, bulletin_data: String) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + match serde_json::from_str::(&bulletin_data) { + Ok(payload) => { + match client.post_api_with_auth("/admin/bulletins", &payload, &token).await { + Ok(response) => { + serde_json::json!({ + "success": true, + "data": response + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": format!("Invalid JSON: {}", e) + }).to_string() + } + } + }) +} + +// Admin - Update bulletin +pub fn admin_update_bulletin_json(token: String, bulletin_id: String, bulletin_data: String) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + match serde_json::from_str::(&bulletin_data) { + Ok(payload) => { + let path = format!("/admin/bulletins/{}", bulletin_id); + match client.put_api_with_auth(&path, &payload, &token).await { + Ok(_) => { + serde_json::json!({ + "success": true + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": format!("Invalid JSON: {}", e) + }).to_string() + } + } + }) +} + +// Admin - Delete bulletin +pub fn admin_delete_bulletin_json(token: String, bulletin_id: String) -> String { + let rt = get_runtime(); + let client = get_or_create_client(); + + rt.block_on(async { + let path = format!("/admin/bulletins/{}", bulletin_id); + + match client.delete_api_with_auth(&path, &token).await { + Ok(_) => { + serde_json::json!({ + "success": true + }).to_string() + } + Err(e) => { + serde_json::json!({ + "success": false, + "error": e.to_string() + }).to_string() + } + } + }) +} +*/ \ No newline at end of file diff --git a/src/utils/feed.rs b/src/utils/feed.rs new file mode 100644 index 0000000..cb12590 --- /dev/null +++ b/src/utils/feed.rs @@ -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, with fallback to current time +fn parse_date_with_fallback(date_str: &str) -> DateTime { + // 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) -> 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 { + 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 + 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 { + 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")); + } +} \ No newline at end of file diff --git a/src/utils/formatting.rs b/src/utils/formatting.rs new file mode 100644 index 0000000..ad8ddc4 --- /dev/null +++ b/src/utils/formatting.rs @@ -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, max: Option) -> Option { + 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, max: Option) -> 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)); + } +} \ No newline at end of file diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..07c6c91 --- /dev/null +++ b/src/utils/mod.rs @@ -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::*; \ No newline at end of file diff --git a/src/utils/scripture.rs b/src/utils/scripture.rs new file mode 100644 index 0000000..5a1fa0f --- /dev/null +++ b/src/utils/scripture.rs @@ -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 { + // 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 = 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 { + 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"); + } +} \ No newline at end of file diff --git a/src/utils/validation.rs b/src/utils/validation.rs new file mode 100644 index 0000000..af22e52 --- /dev/null +++ b/src/utils/validation.rs @@ -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, +} + +impl ValidationResult { + pub fn valid() -> Self { + Self { + is_valid: true, + errors: Vec::new(), + } + } + + pub fn invalid(errors: Vec) -> 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> { + 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("")); + } +} \ No newline at end of file diff --git a/swift_usage_example.swift b/swift_usage_example.swift new file mode 100644 index 0000000..5154813 --- /dev/null +++ b/swift_usage_example.swift @@ -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 +*/ \ No newline at end of file diff --git a/test_config_functions.rs b/test_config_functions.rs new file mode 100644 index 0000000..b4786ae --- /dev/null +++ b/test_config_functions.rs @@ -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!"); +} \ No newline at end of file diff --git a/test_sermon_json.rs b/test_sermon_json.rs new file mode 100644 index 0000000..4d4dfd6 --- /dev/null +++ b/test_sermon_json.rs @@ -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 = 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); + } + } +} \ No newline at end of file diff --git a/tests/calendar_test.rs b/tests/calendar_test.rs new file mode 100644 index 0000000..d61e11b --- /dev/null +++ b/tests/calendar_test.rs @@ -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); +} \ No newline at end of file diff --git a/tests/config_functions_test.rs b/tests/config_functions_test.rs new file mode 100644 index 0000000..1886422 --- /dev/null +++ b/tests/config_functions_test.rs @@ -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!"); +} \ No newline at end of file diff --git a/tests/error_handling_tests.rs b/tests/error_handling_tests.rs new file mode 100644 index 0000000..8fad304 --- /dev/null +++ b/tests/error_handling_tests.rs @@ -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()); + } +} \ No newline at end of file diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..a45107a --- /dev/null +++ b/tests/integration_tests.rs @@ -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); + } +} \ No newline at end of file diff --git a/tests/simple_calendar_test.rs b/tests/simple_calendar_test.rs new file mode 100644 index 0000000..12b3c82 --- /dev/null +++ b/tests/simple_calendar_test.rs @@ -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"); + } +} \ No newline at end of file diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs new file mode 100644 index 0000000..35b96a4 --- /dev/null +++ b/tests/unit_tests.rs @@ -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 = 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 = 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 = cache.get("key1").await; + assert_eq!(result, Some("value1".to_string())); + + // Test get non-existent key + let result: Option = cache.get("nonexistent").await; + assert_eq!(result, None); + + // Test remove + cache.remove("key1").await; + let result: Option = 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 = 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 = 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 = 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 = cache.get("user:1").await; + assert_eq!(result, None); + + let result: Option = 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 = 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); + } +} \ No newline at end of file