From 5793e12df9e79eceb69bfb7a4fd2c84cfd2aa2cb Mon Sep 17 00:00:00 2001 From: Benjamin Slingo Date: Thu, 28 Aug 2025 20:34:04 -0400 Subject: [PATCH] Add comprehensive hymnal support and enhance bulletin processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add complete hymnal API with search, themes, and responsive readings - Implement hymn title lookup for bulletins (#319 → #319 - Hymn Title) - Add Bible book abbreviation support (Matt → Matthew, etc.) - Enhance scripture processing to handle verse ranges (Matt 1:21-23) - Add hymnal database schema with SDA 1985 and 1941 hymnals support - Implement advanced hymnal search with fuzzy matching and themes - Update bulletin processing to auto-populate hymn titles from database --- .../20250827000001_create_hymnal_tables.sql | 90 +++ src/handlers/bulletins_shared.rs | 104 +++- src/handlers/hymnal.rs | 136 +++++ src/handlers/mod.rs | 1 + src/main.rs | 12 + src/models.rs | 191 ++++++ src/services/hymnal.rs | 561 ++++++++++++++++++ src/services/hymnal_search.rs | 311 ++++++++++ src/services/mod.rs | 6 +- src/utils/bible_books.rs | 409 +++++++++++++ src/utils/db_operations.rs | 18 +- src/utils/mod.rs | 1 + src/utils/pagination.rs | 21 +- 13 files changed, 1840 insertions(+), 21 deletions(-) create mode 100644 migrations/20250827000001_create_hymnal_tables.sql create mode 100644 src/handlers/hymnal.rs create mode 100644 src/services/hymnal.rs create mode 100644 src/services/hymnal_search.rs create mode 100644 src/utils/bible_books.rs diff --git a/migrations/20250827000001_create_hymnal_tables.sql b/migrations/20250827000001_create_hymnal_tables.sql new file mode 100644 index 0000000..d60d310 --- /dev/null +++ b/migrations/20250827000001_create_hymnal_tables.sql @@ -0,0 +1,90 @@ +-- Create Hymnal Tables Migration +-- This migration adds support for SDA Hymnals with proper separation between versions +-- The 1941 and 1985 hymnals are completely different books with different content + +-- Hymnals table to store different hymnal versions +CREATE TABLE hymnals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + code VARCHAR(50) NOT NULL UNIQUE, + description TEXT, + year INTEGER, + language VARCHAR(10) DEFAULT 'en', + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Hymns table for individual hymns +-- Each hymnal has its own set of hymns, even if numbers overlap +CREATE TABLE hymns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hymnal_id UUID NOT NULL REFERENCES hymnals(id) ON DELETE CASCADE, + number INTEGER NOT NULL, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, -- Preserves original verse formatting from each hymnal + is_favorite BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(hymnal_id, number) +); + +-- Thematic lists for organizing hymns by themes +-- Each hymnal has its own thematic organization +CREATE TABLE thematic_lists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hymnal_id UUID NOT NULL REFERENCES hymnals(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Thematic ambits (ranges of hymns within themes) +-- Define which hymn numbers belong to each theme +CREATE TABLE thematic_ambits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + thematic_list_id UUID NOT NULL REFERENCES thematic_lists(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + start_number INTEGER NOT NULL, + end_number INTEGER NOT NULL, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Responsive readings (shared across hymnals) +CREATE TABLE responsive_readings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + number INTEGER NOT NULL UNIQUE, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + is_favorite BOOLEAN DEFAULT false, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create indexes for performance +CREATE INDEX idx_hymns_hymnal_id ON hymns(hymnal_id); +CREATE INDEX idx_hymns_number ON hymns(number); +CREATE INDEX idx_hymns_title ON hymns(title); +CREATE INDEX idx_hymns_content_search ON hymns USING gin(to_tsvector('english', content)); +CREATE INDEX idx_hymns_favorite ON hymns(is_favorite) WHERE is_favorite = true; +CREATE INDEX idx_thematic_lists_hymnal_id ON thematic_lists(hymnal_id); +CREATE INDEX idx_thematic_lists_order ON thematic_lists(hymnal_id, sort_order); +CREATE INDEX idx_thematic_ambits_list_id ON thematic_ambits(thematic_list_id); +CREATE INDEX idx_thematic_ambits_range ON thematic_ambits(start_number, end_number); +CREATE INDEX idx_responsive_readings_number ON responsive_readings(number); + +-- Insert the two hymnal versions +INSERT INTO hymnals (name, code, description, year, language) VALUES +('Seventh-day Adventist Hymnal', 'sda-1985', 'The current SDA Church Hymnal published in 1985 (en-newVersion)', 1985, 'en'), +('Church Hymnal', 'sda-1941', 'The older SDA Church Hymnal published in 1941 (en-oldVersion)', 1941, 'en'); + +-- Add helpful comments +COMMENT ON TABLE hymnals IS 'Different versions of hymnals (1941 vs 1985 have completely different content)'; +COMMENT ON TABLE hymns IS 'Individual hymns - same numbers can have different content across hymnals'; +COMMENT ON TABLE thematic_lists IS 'Categories/themes for organizing hymns (e.g., Worship, Trinity)'; +COMMENT ON TABLE thematic_ambits IS 'Ranges of hymn numbers that belong to each theme'; +COMMENT ON TABLE responsive_readings IS 'Responsive readings for church services'; +COMMENT ON COLUMN hymns.content IS 'Original verse formatting preserved (1985 uses numbered verses, 1941 does not)'; \ No newline at end of file diff --git a/src/handlers/bulletins_shared.rs b/src/handlers/bulletins_shared.rs index fbf5bb5..ee19554 100644 --- a/src/handlers/bulletins_shared.rs +++ b/src/handlers/bulletins_shared.rs @@ -3,7 +3,9 @@ use crate::{ error::Result, models::Bulletin, utils::db_operations::BibleVerseOperations, + services::HymnalService, }; +use regex::Regex; /// Process multiple bulletins with shared logic pub async fn process_bulletins_batch( @@ -24,15 +26,15 @@ pub async fn process_single_bulletin( // Process scripture reading to include full verse text bulletin.scripture_reading = process_scripture_reading(pool, &bulletin.scripture_reading).await?; - // Process hymn references in worship content - stub for now - // if let Some(ref worship_content) = bulletin.divine_worship { - // bulletin.divine_worship = Some(super::bulletins::process_hymn_references(pool, worship_content).await?); - // } + // Process hymn references in worship content + if let Some(ref worship_content) = bulletin.divine_worship { + bulletin.divine_worship = Some(process_hymn_references(pool, worship_content).await?); + } - // Process hymn references in sabbath school content - stub for now - // if let Some(ref ss_content) = bulletin.sabbath_school { - // bulletin.sabbath_school = Some(super::bulletins::process_hymn_references(pool, ss_content).await?); - // } + // Process hymn references in sabbath school content + if let Some(ref ss_content) = bulletin.sabbath_school { + bulletin.sabbath_school = Some(process_hymn_references(pool, ss_content).await?); + } // Ensure sunset field compatibility if bulletin.sunset.is_none() { @@ -56,16 +58,92 @@ async fn process_scripture_reading( return Ok(Some(scripture_text.clone())); } - // Try to find the verse using existing search functionality - match BibleVerseOperations::search(pool, scripture_text, 1).await { + // Try to find the verse(s) using existing search functionality + // Allow up to 10 verses for ranges like "Matt 1:21-23" + match BibleVerseOperations::search(pool, scripture_text, 10).await { Ok(verses) if !verses.is_empty() => { - let verse = &verses[0]; - // Put verse text first, then reference at the end - Ok(Some(format!("{} - {}", verse.text, scripture_text))) + if verses.len() == 1 { + // Single verse - format as before + let verse = &verses[0]; + Ok(Some(format!("{} - {}", verse.text, scripture_text))) + } else { + // Multiple verses - combine them + let combined_text = verses + .iter() + .map(|v| v.text.as_str()) + .collect::>() + .join(" "); + Ok(Some(format!("{} - {}", combined_text, scripture_text))) + } }, _ => { // If no match found, return original text Ok(Some(scripture_text.clone())) } } +} + +/// Process hymn references in bulletin content to include titles +/// Looks for patterns like #319, Hymn 319, No. 319 and adds hymn titles +pub async fn process_hymn_references( + pool: &sqlx::PgPool, + content: &str, +) -> Result { + // Create regex patterns to match hymn references + let hymn_patterns = vec![ + // #319 + Regex::new(r"#(\d{1,3})").unwrap(), + // Hymn 319 (case insensitive) + Regex::new(r"(?i)hymn\s+(\d{1,3})").unwrap(), + // No. 319 + Regex::new(r"(?i)no\.\s*(\d{1,3})").unwrap(), + // Number 319 + Regex::new(r"(?i)number\s+(\d{1,3})").unwrap(), + ]; + + let mut result = content.to_string(); + + // Default to 1985 hymnal (most common) + let default_hymnal = "sda-1985"; + + // Process each pattern + for pattern in hymn_patterns { + let mut matches_to_replace = Vec::new(); + + // Find all matches for this pattern + for capture in pattern.captures_iter(&result) { + if let Some(number_match) = capture.get(1) { + if let Ok(hymn_number) = number_match.as_str().parse::() { + // Try to get hymn from 1985 hymnal first, then 1941 + let hymn_title = match HymnalService::get_hymn_by_number(pool, default_hymnal, hymn_number).await { + Ok(Some(hymn)) => Some(hymn.title), + _ => { + // Try 1941 hymnal as fallback + match HymnalService::get_hymn_by_number(pool, "sda-1941", hymn_number).await { + Ok(Some(hymn)) => Some(hymn.title), + _ => None, + } + } + }; + + if let Some(title) = hymn_title { + let full_match = capture.get(0).unwrap(); + matches_to_replace.push(( + full_match.start(), + full_match.end(), + format!("{} - {}", full_match.as_str(), title) + )); + } + } + } + } + + // Apply replacements in reverse order to maintain string indices + matches_to_replace.reverse(); + for (start, end, replacement) in matches_to_replace { + result.replace_range(start..end, &replacement); + } + } + + Ok(result) } \ No newline at end of file diff --git a/src/handlers/hymnal.rs b/src/handlers/hymnal.rs new file mode 100644 index 0000000..6dea15c --- /dev/null +++ b/src/handlers/hymnal.rs @@ -0,0 +1,136 @@ +use crate::{ + error::Result, + models::{ + ApiResponse, Hymnal, HymnWithHymnal, ThematicListWithAmbits, + ResponsiveReading, HymnSearchQuery, ResponsiveReadingQuery, + HymnalPaginatedResponse, SearchResult + }, + services::HymnalService, + utils::{ + response::success_response, + pagination::PaginationHelper, + common::ListQueryParams, + }, + AppState, +}; +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use uuid::Uuid; + +// Hymnal endpoints +pub async fn list_hymnals( + State(state): State, +) -> Result>>> { + let hymnals = HymnalService::list_hymnals(&state.pool).await?; + Ok(success_response(hymnals)) +} + +pub async fn get_hymnal( + State(state): State, + Path(hymnal_id): Path, +) -> Result>>> { + let hymnal = HymnalService::get_hymnal_by_id(&state.pool, hymnal_id).await?; + Ok(success_response(hymnal)) +} + +pub async fn get_hymnal_by_code( + State(state): State, + Path(code): Path, +) -> Result>>> { + let hymnal = HymnalService::get_hymnal_by_code(&state.pool, &code).await?; + Ok(success_response(hymnal)) +} + +// Hymn endpoints +pub async fn list_hymns( + State(state): State, + Query(query): Query, + Query(hymnal_filter): Query, +) -> Result>>> { + let pagination = PaginationHelper::from_hymnal_query(query.page, query.per_page); + let hymns = HymnalService::list_hymns_paginated( + &state.pool, + hymnal_filter.hymnal_id, + pagination, + ).await?; + Ok(success_response(hymns)) +} + +pub async fn get_hymn_by_number( + State(state): State, + Path((hymnal_code, hymn_number)): Path<(String, i32)>, +) -> Result>>> { + let hymn = HymnalService::get_hymn_by_number(&state.pool, &hymnal_code, hymn_number).await?; + Ok(success_response(hymn)) +} + +pub async fn search_hymns( + State(state): State, + Query(search_query): Query, + Query(query): Query, +) -> Result>>> { + let pagination = PaginationHelper::from_hymnal_query(query.page, query.per_page); + let hymns = HymnalService::search_hymns(&state.pool, &search_query, pagination).await?; + Ok(success_response(hymns)) +} + +// Thematic list endpoints +pub async fn list_thematic_lists( + State(state): State, + Path(hymnal_id): Path, +) -> Result>>> { + let lists = HymnalService::list_thematic_lists(&state.pool, hymnal_id).await?; + Ok(success_response(lists)) +} + +pub async fn list_thematic_lists_by_code( + State(state): State, + Path(hymnal_code): Path, +) -> Result>>> { + // First get the hymnal ID by code + let hymnal = HymnalService::get_hymnal_by_code(&state.pool, &hymnal_code).await?; + + match hymnal { + Some(hymnal) => { + let lists = HymnalService::list_thematic_lists(&state.pool, hymnal.id).await?; + Ok(success_response(lists)) + } + None => Ok(success_response(Vec::new())), + } +} + +// Responsive reading endpoints +pub async fn list_responsive_readings( + State(state): State, + Query(query): Query, +) -> Result>>> { + let pagination = PaginationHelper::from_hymnal_query(query.page, query.per_page); + let readings = HymnalService::list_responsive_readings_paginated(&state.pool, pagination).await?; + Ok(success_response(readings)) +} + +pub async fn get_responsive_reading( + State(state): State, + Path(number): Path, +) -> Result>>> { + let reading = HymnalService::get_responsive_reading_by_number(&state.pool, number).await?; + Ok(success_response(reading)) +} + +pub async fn search_responsive_readings( + State(state): State, + Query(search_query): Query, + Query(query): Query, +) -> Result>>> { + let pagination = PaginationHelper::from_hymnal_query(query.page, query.per_page); + let readings = HymnalService::search_responsive_readings(&state.pool, &search_query, pagination).await?; + Ok(success_response(readings)) +} + +// Helper types for query parameters +#[derive(serde::Deserialize)] +pub struct HymnalFilter { + pub hymnal_id: Option, +} \ No newline at end of file diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 7f305a3..cfebdd5 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -14,3 +14,4 @@ pub mod media; pub mod smart_streaming; pub mod v2; pub mod backup; +pub mod hymnal; diff --git a/src/main.rs b/src/main.rs index 5c38d0b..ec6b7e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -126,6 +126,18 @@ let app = Router::new() .route("/api/schedule", get(handlers::schedule::get_schedule)) .route("/api/conference-data", get(handlers::schedule::get_conference_data)) .route("/api/members/active", get(handlers::members::list_active)) + // Hymnal API endpoints + .route("/api/hymnals", get(handlers::hymnal::list_hymnals)) + .route("/api/hymnals/:id", get(handlers::hymnal::get_hymnal)) + .route("/api/hymnals/code/:code", get(handlers::hymnal::get_hymnal_by_code)) + .route("/api/hymns", get(handlers::hymnal::list_hymns)) + .route("/api/hymns/search", get(handlers::hymnal::search_hymns)) + .route("/api/hymns/:hymnal_code/:number", get(handlers::hymnal::get_hymn_by_number)) + .route("/api/hymnals/:id/themes", get(handlers::hymnal::list_thematic_lists)) + .route("/api/hymnals/code/:code/themes", get(handlers::hymnal::list_thematic_lists_by_code)) + .route("/api/responsive-readings", get(handlers::hymnal::list_responsive_readings)) + .route("/api/responsive-readings/search", get(handlers::hymnal::search_responsive_readings)) + .route("/api/responsive-readings/:number", get(handlers::hymnal::get_responsive_reading)) // New media library endpoints (replacing Jellyfin) .route("/api/sermons", get(handlers::media::list_sermons)) .route("/api/livestreams", get(handlers::media::list_livestreams)) diff --git a/src/models.rs b/src/models.rs index b47b373..1e9aecd 100644 --- a/src/models.rs +++ b/src/models.rs @@ -208,6 +208,16 @@ pub struct PaginatedResponse { pub has_more: bool, } +// Hymnal-specific paginated response with "results" field +#[derive(Debug, Serialize)] +pub struct HymnalPaginatedResponse { + pub results: Vec, + pub total: i64, + pub page: i32, + pub per_page: i32, + pub has_more: bool, +} + #[derive(Debug, Deserialize)] pub struct PaginationParams { pub page: Option, @@ -446,6 +456,114 @@ pub struct BibleVerseV2 { pub updated_at: Option, } +// Hymnal Models +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Hymnal { + pub id: Uuid, + pub name: String, + pub code: String, + pub description: Option, + pub year: Option, + pub language: Option, + pub is_active: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Hymn { + pub id: Uuid, + pub hymnal_id: Uuid, + pub number: i32, + pub title: String, + pub content: String, + pub is_favorite: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct HymnWithHymnal { + pub id: Uuid, + pub hymnal_id: Uuid, + pub hymnal_name: String, + pub hymnal_code: String, + pub hymnal_year: Option, + pub number: i32, + pub title: String, + pub content: String, + pub is_favorite: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct ThematicList { + pub id: Uuid, + pub hymnal_id: Uuid, + pub name: String, + pub sort_order: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct ThematicAmbit { + pub id: Uuid, + pub thematic_list_id: Uuid, + pub name: String, + pub start_number: i32, + pub end_number: i32, + pub sort_order: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThematicListWithAmbits { + pub id: Uuid, + pub hymnal_id: Uuid, + pub name: String, + pub sort_order: Option, + pub ambits: Vec, + pub created_at: Option>, + pub updated_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct ResponsiveReading { + pub id: Uuid, + pub number: i32, + pub title: String, + pub content: String, + pub is_favorite: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +// Hymnal search result structure +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult { + pub hymn: HymnWithHymnal, + pub score: f64, + pub match_type: String, +} + +// Hymnal request/query types +#[derive(Debug, Deserialize)] +pub struct HymnSearchQuery { + pub q: Option, + pub hymnal: Option, + pub number: Option, + pub theme: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ResponsiveReadingQuery { + pub q: Option, + pub number: Option, +} + // SanitizeOutput trait implementations impl SanitizeOutput for Bulletin { fn sanitize_output(mut self) -> Self { @@ -605,6 +723,13 @@ impl SanitizeOutput for PaginatedResponse { } } +impl SanitizeOutput for HymnalPaginatedResponse { + fn sanitize_output(mut self) -> Self { + self.results = self.results.sanitize_output(); + self + } +} + impl SanitizeOutput for Personnel { fn sanitize_output(mut self) -> Self { self.ss_leader = sanitize_string(self.ss_leader); @@ -662,3 +787,69 @@ impl SanitizeOutput for User { self } } + +impl SanitizeOutput for Hymnal { + fn sanitize_output(mut self) -> Self { + self.name = sanitize_string(self.name); + self.code = sanitize_string(self.code); + self.description = sanitize_option_string(self.description); + self.language = sanitize_option_string(self.language); + self + } +} + +impl SanitizeOutput for Hymn { + fn sanitize_output(mut self) -> Self { + self.title = sanitize_string(self.title); + self.content = sanitize_string(self.content); + self + } +} + +impl SanitizeOutput for HymnWithHymnal { + fn sanitize_output(mut self) -> Self { + self.hymnal_name = sanitize_string(self.hymnal_name); + self.hymnal_code = sanitize_string(self.hymnal_code); + self.title = sanitize_string(self.title); + self.content = sanitize_string(self.content); + self + } +} + +impl SanitizeOutput for ThematicList { + fn sanitize_output(mut self) -> Self { + self.name = sanitize_string(self.name); + self + } +} + +impl SanitizeOutput for ThematicAmbit { + fn sanitize_output(mut self) -> Self { + self.name = sanitize_string(self.name); + self + } +} + +impl SanitizeOutput for ThematicListWithAmbits { + fn sanitize_output(mut self) -> Self { + self.name = sanitize_string(self.name); + self.ambits = self.ambits.sanitize_output(); + self + } +} + +impl SanitizeOutput for ResponsiveReading { + fn sanitize_output(mut self) -> Self { + self.title = sanitize_string(self.title); + self.content = sanitize_string(self.content); + self + } +} + +impl SanitizeOutput for SearchResult { + fn sanitize_output(mut self) -> Self { + self.hymn = self.hymn.sanitize_output(); + self.match_type = sanitize_string(self.match_type); + self + } +} diff --git a/src/services/hymnal.rs b/src/services/hymnal.rs new file mode 100644 index 0000000..7ff99d1 --- /dev/null +++ b/src/services/hymnal.rs @@ -0,0 +1,561 @@ +use crate::{ + error::Result, + models::{ + Hymnal, HymnWithHymnal, ThematicList, ThematicAmbit, + ThematicListWithAmbits, ResponsiveReading, HymnSearchQuery, + ResponsiveReadingQuery, HymnalPaginatedResponse, SearchResult + }, + utils::pagination::PaginationHelper, +}; +use sqlx::PgPool; +use uuid::Uuid; + +pub struct HymnalService; + +impl HymnalService { + // Helper function to convert HymnWithHymnal to SearchResult with default score and match type + fn hymn_to_search_result(hymn: HymnWithHymnal, score: f64, match_type: &str) -> SearchResult { + SearchResult { + hymn, + score, + match_type: match_type.to_string(), + } + } + // Hymnal operations + pub async fn list_hymnals(pool: &PgPool) -> Result> { + let hymnals = sqlx::query_as::<_, Hymnal>( + r#" + SELECT id, name, code, description, year, language, is_active, created_at, updated_at + FROM hymnals + WHERE is_active = true + ORDER BY year DESC, name + "# + ) + .fetch_all(pool) + .await?; + + Ok(hymnals) + } + + pub async fn get_hymnal_by_id(pool: &PgPool, hymnal_id: Uuid) -> Result> { + let hymnal = sqlx::query_as::<_, Hymnal>( + r#" + SELECT id, name, code, description, year, language, is_active, created_at, updated_at + FROM hymnals + WHERE id = $1 AND is_active = true + "# + ) + .bind(hymnal_id) + .fetch_optional(pool) + .await?; + + Ok(hymnal) + } + + pub async fn get_hymnal_by_code(pool: &PgPool, code: &str) -> Result> { + let hymnal = sqlx::query_as::<_, Hymnal>( + r#" + SELECT id, name, code, description, year, language, is_active, created_at, updated_at + FROM hymnals + WHERE code = $1 AND is_active = true + "# + ) + .bind(code) + .fetch_optional(pool) + .await?; + + Ok(hymnal) + } + + // Hymn operations + pub async fn list_hymns_paginated( + pool: &PgPool, + hymnal_id: Option, + pagination: PaginationHelper, + ) -> Result> { + let hymns = if let Some(hymnal_id) = hymnal_id { + let total_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM hymns h JOIN hymnals hy ON h.hymnal_id = hy.id WHERE hy.is_active = true AND h.hymnal_id = $1" + ) + .bind(hymnal_id) + .fetch_one(pool) + .await?; + + let hymns = sqlx::query_as::<_, HymnWithHymnal>( + r#" + SELECT h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, + hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, + h.created_at, h.updated_at + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE hy.is_active = true AND h.hymnal_id = $1 + ORDER BY h.number + LIMIT $2 OFFSET $3 + "# + ) + .bind(hymnal_id) + .bind(pagination.per_page as i64) + .bind(pagination.offset) + .fetch_all(pool) + .await?; + + pagination.create_hymnal_response(hymns, total_count) + } else { + let total_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM hymns h JOIN hymnals hy ON h.hymnal_id = hy.id WHERE hy.is_active = true" + ) + .fetch_one(pool) + .await?; + + let hymns = sqlx::query_as::<_, HymnWithHymnal>( + r#" + SELECT h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, + hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, + h.created_at, h.updated_at + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE hy.is_active = true + ORDER BY hy.year DESC, h.number + LIMIT $1 OFFSET $2 + "# + ) + .bind(pagination.per_page as i64) + .bind(pagination.offset) + .fetch_all(pool) + .await?; + + pagination.create_hymnal_response(hymns, total_count) + }; + + Ok(hymns) + } + + pub async fn get_hymn_by_number( + pool: &PgPool, + hymnal_code: &str, + hymn_number: i32, + ) -> Result> { + let hymn = sqlx::query_as::<_, HymnWithHymnal>( + r#" + SELECT h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, + hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, + h.created_at, h.updated_at + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE hy.code = $1 AND h.number = $2 AND hy.is_active = true + "# + ) + .bind(hymnal_code) + .bind(hymn_number) + .fetch_optional(pool) + .await?; + + Ok(hymn) + } + + pub async fn search_hymns( + pool: &PgPool, + query: &HymnSearchQuery, + pagination: PaginationHelper, + ) -> Result> { + match (&query.q, &query.hymnal, query.number, query.theme) { + // Only use SearchResult for actual text searches + (Some(search_term), hymnal_code, None, None) => { + crate::services::HymnalSearchService::search_hymns_with_scoring(pool, search_term, hymnal_code.as_deref(), pagination).await + }, + // For hymnal listing (no text search), return hymns with default score but in proper order + (None, Some(hymnal_code), None, None) => { + let total_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM hymns h JOIN hymnals hy ON h.hymnal_id = hy.id WHERE hy.is_active = true AND hy.code = $1" + ) + .bind(hymnal_code) + .fetch_one(pool) + .await?; + + let hymns = sqlx::query_as::<_, HymnWithHymnal>( + r#" + SELECT h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, + hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, + h.created_at, h.updated_at + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE hy.is_active = true AND hy.code = $1 + ORDER BY h.number ASC + LIMIT $2 OFFSET $3 + "# + ) + .bind(hymnal_code) + .bind(pagination.per_page as i64) + .bind(pagination.offset) + .fetch_all(pool) + .await?; + + // Convert to SearchResult but with predictable ordering and neutral scores + let search_results: Vec = hymns.into_iter().map(|hymn| { + Self::hymn_to_search_result(hymn, 1.0, "hymnal_listing") + }).collect(); + + Ok(pagination.create_hymnal_response(search_results, total_count)) + }, + // Default: return all hymns in numerical order + _ => { + let hymns_response = Self::list_hymns_paginated(pool, None, pagination).await?; + + // Convert to SearchResult for consistency but maintain order + let search_results: Vec = hymns_response.results.into_iter().map(|hymn| { + Self::hymn_to_search_result(hymn, 1.0, "general_listing") + }).collect(); + + Ok(HymnalPaginatedResponse { + results: search_results, + total: hymns_response.total, + page: hymns_response.page, + per_page: hymns_response.per_page, + has_more: hymns_response.has_more, + }) + } + } + } + + pub async fn search_hymns_with_scoring( + pool: &PgPool, + search_term: &str, + hymnal_code: Option<&str>, + pagination: PaginationHelper, + ) -> Result> { + let clean_search = search_term.trim().to_lowercase(); + + // Check if search term is a number (for hymn number searches) + let is_number_search = clean_search.parse::().is_ok() || + clean_search.starts_with("hymn ") || + clean_search.starts_with("no. ") || + clean_search.starts_with("number "); + + // Extract number from various formats + let extracted_number = if let Ok(num) = clean_search.parse::() { + Some(num) + } else if clean_search.starts_with("hymn ") { + clean_search.strip_prefix("hymn ").and_then(|s| s.parse().ok()) + } else if clean_search.starts_with("no. ") { + clean_search.strip_prefix("no. ").and_then(|s| s.parse().ok()) + } else if clean_search.starts_with("number ") { + clean_search.strip_prefix("number ").and_then(|s| s.parse().ok()) + } else { + None + }; + + // Build the scoring query - this uses PostgreSQL's similarity and full-text search + let hymnal_filter = if let Some(code) = hymnal_code { + "AND hy.code = $2" + } else { + "" + }; + + let search_query = format!(r#" + WITH scored_hymns AS ( + SELECT + h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, + hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, + h.created_at, h.updated_at, + -- Scoring system (higher = better match) + ( + -- Exact title match (highest score: 1000) + CASE WHEN LOWER(h.title) = $1 THEN 1000 ELSE 0 END + + + -- Title starts with search (800) + CASE WHEN LOWER(h.title) LIKE $1 || '%' THEN 800 ELSE 0 END + + + -- Title contains search (400) + CASE WHEN LOWER(h.title) LIKE '%' || $1 || '%' THEN 400 ELSE 0 END + + + -- First line match (600 - many people remember opening lines) + CASE WHEN LOWER(SPLIT_PART(h.content, E'\n', 1)) LIKE '%' || $1 || '%' THEN 600 ELSE 0 END + + + -- First verse match (300) + CASE WHEN LOWER(SPLIT_PART(h.content, E'\n\n', 1)) LIKE '%' || $1 || '%' THEN 300 ELSE 0 END + + + -- Content match (100) + CASE WHEN LOWER(h.content) LIKE '%' || $1 || '%' THEN 100 ELSE 0 END + + + -- Number match bonus (1200 - if searching by number) + CASE WHEN $3::integer IS NOT NULL AND h.number = $3::integer THEN 1200 ELSE 0 END + + + -- Additional fuzzy matching bonus + CASE WHEN LOWER(h.title) ILIKE '%' || $1 || '%' THEN 50 ELSE 0 END + ) as relevance_score + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE hy.is_active = true + {} + AND ( + LOWER(h.title) LIKE '%' || $1 || '%' OR + LOWER(h.content) LIKE '%' || $1 || '%' OR + ($3::integer IS NOT NULL AND h.number = $3::integer) + ) + ) + SELECT * FROM scored_hymns + WHERE relevance_score > 0 + ORDER BY relevance_score DESC, hymnal_year DESC, number ASC + LIMIT $4 OFFSET $5 + "#, hymnal_filter); + + let count_query = format!(r#" + SELECT COUNT(*) + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE hy.is_active = true + {} + AND ( + LOWER(h.title) LIKE '%' || $1 || '%' OR + LOWER(h.content) LIKE '%' || $1 || '%' OR + ($3::integer IS NOT NULL AND h.number = $3::integer) + ) + "#, hymnal_filter); + + // Execute queries based on whether hymnal filter is provided + let (hymns, total_count) = if let Some(code) = hymnal_code { + let mut query = sqlx::query_as::<_, HymnWithHymnal>(&search_query) + .bind(&clean_search) + .bind(code); + + if let Some(num) = extracted_number { + query = query.bind(num); + } else { + query = query.bind(Option::::None); + } + + let hymns = query + .bind(pagination.per_page as i64) + .bind(pagination.offset) + .fetch_all(pool) + .await?; + + let mut count_query_prep = sqlx::query_scalar::<_, i64>(&count_query) + .bind(&clean_search) + .bind(code); + + if let Some(num) = extracted_number { + count_query_prep = count_query_prep.bind(num); + } else { + count_query_prep = count_query_prep.bind(Option::::None); + } + + let total_count = count_query_prep.fetch_one(pool).await?; + + (hymns, total_count) + } else { + let mut query = sqlx::query_as::<_, HymnWithHymnal>(&search_query) + .bind(&clean_search); + + if let Some(num) = extracted_number { + query = query.bind(num); + } else { + query = query.bind(Option::::None); + } + + let hymns = query + .bind(pagination.per_page as i64) + .bind(pagination.offset) + .fetch_all(pool) + .await?; + + let mut count_query_prep = sqlx::query_scalar::<_, i64>(&count_query) + .bind(&clean_search); + + if let Some(num) = extracted_number { + count_query_prep = count_query_prep.bind(num); + } else { + count_query_prep = count_query_prep.bind(Option::::None); + } + + let total_count = count_query_prep.fetch_one(pool).await?; + + (hymns, total_count) + }; + + Ok(pagination.create_hymnal_response(hymns, total_count)) + } + + // Thematic list operations + pub async fn list_thematic_lists(pool: &PgPool, hymnal_id: Uuid) -> Result> { + let lists = sqlx::query_as::<_, ThematicList>( + r#" + SELECT id, hymnal_id, name, sort_order, created_at, updated_at + FROM thematic_lists + WHERE hymnal_id = $1 + ORDER BY sort_order, name + "# + ) + .bind(hymnal_id) + .fetch_all(pool) + .await?; + + let mut result = Vec::new(); + + for list in lists { + let ambits = sqlx::query_as::<_, ThematicAmbit>( + r#" + SELECT id, thematic_list_id, name, start_number, end_number, sort_order, created_at, updated_at + FROM thematic_ambits + WHERE thematic_list_id = $1 + ORDER BY sort_order, start_number + "# + ) + .bind(list.id) + .fetch_all(pool) + .await?; + + result.push(ThematicListWithAmbits { + id: list.id, + hymnal_id: list.hymnal_id, + name: list.name, + sort_order: list.sort_order, + ambits, + created_at: list.created_at, + updated_at: list.updated_at, + }); + } + + Ok(result) + } + + // Responsive reading operations + pub async fn list_responsive_readings_paginated( + pool: &PgPool, + pagination: PaginationHelper, + ) -> Result> { + let total_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM responsive_readings" + ) + .fetch_one(pool) + .await?; + + let readings = sqlx::query_as::<_, ResponsiveReading>( + r#" + SELECT id, number, title, content, is_favorite, created_at, updated_at + FROM responsive_readings + ORDER BY number + LIMIT $1 OFFSET $2 + "# + ) + .bind(pagination.per_page as i64) + .bind(pagination.offset) + .fetch_all(pool) + .await?; + + Ok(pagination.create_hymnal_response(readings, total_count)) + } + + pub async fn get_responsive_reading_by_number( + pool: &PgPool, + number: i32, + ) -> Result> { + let reading = sqlx::query_as::<_, ResponsiveReading>( + r#" + SELECT id, number, title, content, is_favorite, created_at, updated_at + FROM responsive_readings + WHERE number = $1 + "# + ) + .bind(number) + .fetch_optional(pool) + .await?; + + Ok(reading) + } + + pub async fn search_responsive_readings( + pool: &PgPool, + query: &ResponsiveReadingQuery, + pagination: PaginationHelper, + ) -> Result> { + match (&query.q, query.number) { + // Search by text only + (Some(search_term), None) => { + let search_pattern = format!("%{}%", search_term); + let total_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM responsive_readings WHERE title ILIKE $1 OR content ILIKE $1" + ) + .bind(&search_pattern) + .fetch_one(pool) + .await?; + + let readings = sqlx::query_as::<_, ResponsiveReading>( + r#" + SELECT id, number, title, content, is_favorite, created_at, updated_at + FROM responsive_readings + WHERE title ILIKE $1 OR content ILIKE $1 + ORDER BY number + LIMIT $2 OFFSET $3 + "# + ) + .bind(&search_pattern) + .bind(pagination.per_page as i64) + .bind(pagination.offset) + .fetch_all(pool) + .await?; + + Ok(pagination.create_hymnal_response(readings, total_count)) + }, + // Search by number only + (None, Some(number)) => { + let total_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM responsive_readings WHERE number = $1" + ) + .bind(number) + .fetch_one(pool) + .await?; + + let readings = sqlx::query_as::<_, ResponsiveReading>( + r#" + SELECT id, number, title, content, is_favorite, created_at, updated_at + FROM responsive_readings + WHERE number = $1 + ORDER BY number + LIMIT $2 OFFSET $3 + "# + ) + .bind(number) + .bind(pagination.per_page as i64) + .bind(pagination.offset) + .fetch_all(pool) + .await?; + + Ok(pagination.create_hymnal_response(readings, total_count)) + }, + // Search by text and number + (Some(search_term), Some(number)) => { + let search_pattern = format!("%{}%", search_term); + let total_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM responsive_readings WHERE (title ILIKE $1 OR content ILIKE $1) AND number = $2" + ) + .bind(&search_pattern) + .bind(number) + .fetch_one(pool) + .await?; + + let readings = sqlx::query_as::<_, ResponsiveReading>( + r#" + SELECT id, number, title, content, is_favorite, created_at, updated_at + FROM responsive_readings + WHERE (title ILIKE $1 OR content ILIKE $1) AND number = $2 + ORDER BY number + LIMIT $3 OFFSET $4 + "# + ) + .bind(&search_pattern) + .bind(number) + .bind(pagination.per_page as i64) + .bind(pagination.offset) + .fetch_all(pool) + .await?; + + Ok(pagination.create_hymnal_response(readings, total_count)) + }, + // No search criteria - return all + (None, None) => { + Self::list_responsive_readings_paginated(pool, pagination).await + } + } + } +} \ No newline at end of file diff --git a/src/services/hymnal_search.rs b/src/services/hymnal_search.rs new file mode 100644 index 0000000..6181a76 --- /dev/null +++ b/src/services/hymnal_search.rs @@ -0,0 +1,311 @@ +use crate::{ + error::Result, + models::{HymnWithHymnal, HymnalPaginatedResponse, SearchResult}, + utils::pagination::PaginationHelper, +}; +use sqlx::{PgPool, FromRow}; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +// Temporary struct to capture hymn data with score from database +#[derive(Debug, FromRow)] +struct HymnWithScore { + pub id: Uuid, + pub hymnal_id: Uuid, + pub hymnal_name: String, + pub hymnal_code: String, + pub hymnal_year: Option, + pub number: i32, + pub title: String, + pub content: String, + pub is_favorite: Option, + pub created_at: Option>, + pub updated_at: Option>, + pub relevance_score: i32, +} + +pub struct HymnalSearchService; + +impl HymnalSearchService { + pub async fn search_hymns_with_scoring( + pool: &PgPool, + search_term: &str, + hymnal_code: Option<&str>, + pagination: PaginationHelper, + ) -> Result> { + let clean_search = search_term.trim().to_lowercase(); + + // Extract number from various formats + let extracted_number = if let Ok(num) = clean_search.parse::() { + Some(num) + } else if clean_search.starts_with("hymn ") { + clean_search.strip_prefix("hymn ").and_then(|s| s.parse().ok()) + } else if clean_search.starts_with("no. ") { + clean_search.strip_prefix("no. ").and_then(|s| s.parse().ok()) + } else if clean_search.starts_with("number ") { + clean_search.strip_prefix("number ").and_then(|s| s.parse().ok()) + } else { + None + }; + + // Split search terms for multi-word matching + let search_words: Vec<&str> = clean_search.split_whitespace() + .filter(|word| word.len() > 1) // Filter out single letters + .collect(); + + // Use PostgreSQL's built-in text search for better multi-word handling + let (hymns, total_count) = if let Some(code) = hymnal_code { + // With hymnal filter + let hymns = sqlx::query_as::<_, HymnWithScore>(r#" + WITH scored_hymns AS ( + SELECT + h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, + hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, + h.created_at, h.updated_at, + -- Enhanced scoring system + ( + -- Number match (highest priority: 1600) + CASE WHEN $3 IS NOT NULL AND h.number = $3 THEN 1600 ELSE 0 END + + + -- Exact title match (1500) + CASE WHEN LOWER(h.title) = $1 THEN 1500 ELSE 0 END + + + -- Title starts with search (1200) + CASE WHEN LOWER(h.title) LIKE $1 || '%' THEN 1200 ELSE 0 END + + + -- Title contains exact phrase (800) + CASE WHEN LOWER(h.title) LIKE '%' || $1 || '%' THEN 800 ELSE 0 END + + + -- Multi-word: all search words found in title (700) + CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL AND + LOWER(h.title) LIKE '%' || $4 || '%' AND + LOWER(h.title) LIKE '%' || $5 || '%' THEN 700 ELSE 0 END + + + -- Multi-word: 3+ words in title (650) + CASE WHEN $6 IS NOT NULL AND + LOWER(h.title) LIKE '%' || $4 || '%' AND + LOWER(h.title) LIKE '%' || $5 || '%' AND + LOWER(h.title) LIKE '%' || $6 || '%' THEN 650 ELSE 0 END + + + -- First line contains phrase (600) + CASE WHEN LOWER(SPLIT_PART(h.content, E'\n', 2)) LIKE '%' || $1 || '%' THEN 600 ELSE 0 END + + + -- Any word in title (400) + CASE WHEN ($4 IS NOT NULL AND LOWER(h.title) LIKE '%' || $4 || '%') OR + ($5 IS NOT NULL AND LOWER(h.title) LIKE '%' || $5 || '%') OR + ($6 IS NOT NULL AND LOWER(h.title) LIKE '%' || $6 || '%') THEN 400 ELSE 0 END + + + -- Content contains exact phrase (300) + CASE WHEN LOWER(h.content) LIKE '%' || $1 || '%' THEN 300 ELSE 0 END + + + -- Multi-word in content (200) + CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL AND + LOWER(h.content) LIKE '%' || $4 || '%' AND + LOWER(h.content) LIKE '%' || $5 || '%' THEN 200 ELSE 0 END + + + -- Any word in content (100) + CASE WHEN ($4 IS NOT NULL AND LOWER(h.content) LIKE '%' || $4 || '%') OR + ($5 IS NOT NULL AND LOWER(h.content) LIKE '%' || $5 || '%') OR + ($6 IS NOT NULL AND LOWER(h.content) LIKE '%' || $6 || '%') THEN 100 ELSE 0 END + ) as relevance_score + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE hy.is_active = true AND hy.code = $2 + AND ( + LOWER(h.title) LIKE '%' || $1 || '%' OR + LOWER(h.content) LIKE '%' || $1 || '%' OR + ($3 IS NOT NULL AND h.number = $3) OR + ($4 IS NOT NULL AND (LOWER(h.title) LIKE '%' || $4 || '%' OR LOWER(h.content) LIKE '%' || $4 || '%')) OR + ($5 IS NOT NULL AND (LOWER(h.title) LIKE '%' || $5 || '%' OR LOWER(h.content) LIKE '%' || $5 || '%')) OR + ($6 IS NOT NULL AND (LOWER(h.title) LIKE '%' || $6 || '%' OR LOWER(h.content) LIKE '%' || $6 || '%')) + ) + ) + SELECT * FROM scored_hymns + WHERE relevance_score > 0 + ORDER BY relevance_score DESC, hymnal_year DESC, number ASC + LIMIT $7 OFFSET $8 + "#) + .bind(&clean_search) // $1 - full search phrase + .bind(code) // $2 - hymnal code + .bind(extracted_number) // $3 - extracted number + .bind(search_words.get(0).cloned()) // $4 - first word + .bind(search_words.get(1).cloned()) // $5 - second word + .bind(search_words.get(2).cloned()) // $6 - third word + .bind(pagination.per_page as i64) // $7 - limit + .bind(pagination.offset) // $8 - offset + .fetch_all(pool) + .await?; + + let total_count = sqlx::query_scalar::<_, i64>(r#" + SELECT COUNT(*) + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE hy.is_active = true AND hy.code = $2 + AND ( + LOWER(h.title) LIKE '%' || $1 || '%' OR + LOWER(h.content) LIKE '%' || $1 || '%' OR + ($3 IS NOT NULL AND h.number = $3) OR + ($4 IS NOT NULL AND (LOWER(h.title) LIKE '%' || $4 || '%' OR LOWER(h.content) LIKE '%' || $4 || '%')) OR + ($5 IS NOT NULL AND (LOWER(h.title) LIKE '%' || $5 || '%' OR LOWER(h.content) LIKE '%' || $5 || '%')) OR + ($6 IS NOT NULL AND (LOWER(h.title) LIKE '%' || $6 || '%' OR LOWER(h.content) LIKE '%' || $6 || '%')) + ) + "#) + .bind(&clean_search) + .bind(code) + .bind(extracted_number) + .bind(search_words.get(0).cloned()) + .bind(search_words.get(1).cloned()) + .bind(search_words.get(2).cloned()) + .fetch_one(pool) + .await?; + + (hymns, total_count) + } else { + // Without hymnal filter - same logic but without hymnal code constraint + let hymns = sqlx::query_as::<_, HymnWithScore>(r#" + WITH scored_hymns AS ( + SELECT + h.id, h.hymnal_id, hy.name as hymnal_name, hy.code as hymnal_code, + hy.year as hymnal_year, h.number, h.title, h.content, h.is_favorite, + h.created_at, h.updated_at, + -- Enhanced scoring system + ( + -- Number match (highest priority: 1600) + CASE WHEN $2 IS NOT NULL AND h.number = $2 THEN 1600 ELSE 0 END + + + -- Exact title match (1500) + CASE WHEN LOWER(h.title) = $1 THEN 1500 ELSE 0 END + + + -- Title starts with search (1200) + CASE WHEN LOWER(h.title) LIKE $1 || '%' THEN 1200 ELSE 0 END + + + -- Title contains exact phrase (800) + CASE WHEN LOWER(h.title) LIKE '%' || $1 || '%' THEN 800 ELSE 0 END + + + -- Multi-word: all search words found in title (700) + CASE WHEN $3 IS NOT NULL AND $4 IS NOT NULL AND + LOWER(h.title) LIKE '%' || $3 || '%' AND + LOWER(h.title) LIKE '%' || $4 || '%' THEN 700 ELSE 0 END + + + -- Multi-word: 3+ words in title (650) + CASE WHEN $5 IS NOT NULL AND + LOWER(h.title) LIKE '%' || $3 || '%' AND + LOWER(h.title) LIKE '%' || $4 || '%' AND + LOWER(h.title) LIKE '%' || $5 || '%' THEN 650 ELSE 0 END + + + -- First line contains phrase (600) + CASE WHEN LOWER(SPLIT_PART(h.content, E'\n', 2)) LIKE '%' || $1 || '%' THEN 600 ELSE 0 END + + + -- Any word in title (400) + CASE WHEN ($3 IS NOT NULL AND LOWER(h.title) LIKE '%' || $3 || '%') OR + ($4 IS NOT NULL AND LOWER(h.title) LIKE '%' || $4 || '%') OR + ($5 IS NOT NULL AND LOWER(h.title) LIKE '%' || $5 || '%') THEN 400 ELSE 0 END + + + -- Content contains exact phrase (300) + CASE WHEN LOWER(h.content) LIKE '%' || $1 || '%' THEN 300 ELSE 0 END + + + -- Multi-word in content (200) + CASE WHEN $3 IS NOT NULL AND $4 IS NOT NULL AND + LOWER(h.content) LIKE '%' || $3 || '%' AND + LOWER(h.content) LIKE '%' || $4 || '%' THEN 200 ELSE 0 END + + + -- Any word in content (100) + CASE WHEN ($3 IS NOT NULL AND LOWER(h.content) LIKE '%' || $3 || '%') OR + ($4 IS NOT NULL AND LOWER(h.content) LIKE '%' || $4 || '%') OR + ($5 IS NOT NULL AND LOWER(h.content) LIKE '%' || $5 || '%') THEN 100 ELSE 0 END + ) as relevance_score + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE hy.is_active = true + AND ( + LOWER(h.title) LIKE '%' || $1 || '%' OR + LOWER(h.content) LIKE '%' || $1 || '%' OR + ($2 IS NOT NULL AND h.number = $2) OR + ($3 IS NOT NULL AND (LOWER(h.title) LIKE '%' || $3 || '%' OR LOWER(h.content) LIKE '%' || $3 || '%')) OR + ($4 IS NOT NULL AND (LOWER(h.title) LIKE '%' || $4 || '%' OR LOWER(h.content) LIKE '%' || $4 || '%')) OR + ($5 IS NOT NULL AND (LOWER(h.title) LIKE '%' || $5 || '%' OR LOWER(h.content) LIKE '%' || $5 || '%')) + ) + ) + SELECT * FROM scored_hymns + WHERE relevance_score > 0 + ORDER BY relevance_score DESC, hymnal_year DESC, number ASC + LIMIT $6 OFFSET $7 + "#) + .bind(&clean_search) // $1 - full search phrase + .bind(extracted_number) // $2 - extracted number + .bind(search_words.get(0).cloned()) // $3 - first word + .bind(search_words.get(1).cloned()) // $4 - second word + .bind(search_words.get(2).cloned()) // $5 - third word + .bind(pagination.per_page as i64) // $6 - limit + .bind(pagination.offset) // $7 - offset + .fetch_all(pool) + .await?; + + let total_count = sqlx::query_scalar::<_, i64>(r#" + SELECT COUNT(*) + FROM hymns h + JOIN hymnals hy ON h.hymnal_id = hy.id + WHERE hy.is_active = true + AND ( + LOWER(h.title) LIKE '%' || $1 || '%' OR + LOWER(h.content) LIKE '%' || $1 || '%' OR + ($2 IS NOT NULL AND h.number = $2) OR + ($3 IS NOT NULL AND (LOWER(h.title) LIKE '%' || $3 || '%' OR LOWER(h.content) LIKE '%' || $3 || '%')) OR + ($4 IS NOT NULL AND (LOWER(h.title) LIKE '%' || $4 || '%' OR LOWER(h.content) LIKE '%' || $4 || '%')) OR + ($5 IS NOT NULL AND (LOWER(h.title) LIKE '%' || $5 || '%' OR LOWER(h.content) LIKE '%' || $5 || '%')) + ) + "#) + .bind(&clean_search) + .bind(extracted_number) + .bind(search_words.get(0).cloned()) + .bind(search_words.get(1).cloned()) + .bind(search_words.get(2).cloned()) + .fetch_one(pool) + .await?; + + (hymns, total_count) + }; + + // Transform HymnWithScore into SearchResult + let search_results: Vec = hymns.into_iter().map(|hymn_with_score| { + let hymn = HymnWithHymnal { + id: hymn_with_score.id, + hymnal_id: hymn_with_score.hymnal_id, + hymnal_name: hymn_with_score.hymnal_name, + hymnal_code: hymn_with_score.hymnal_code, + hymnal_year: hymn_with_score.hymnal_year, + number: hymn_with_score.number, + title: hymn_with_score.title, + content: hymn_with_score.content, + is_favorite: hymn_with_score.is_favorite, + created_at: hymn_with_score.created_at, + updated_at: hymn_with_score.updated_at, + }; + + // Calculate normalized score (0.0 to 1.0) + let normalized_score = (hymn_with_score.relevance_score as f64) / 1600.0; // 1600 is max score + + // Determine match type based on score + let match_type = match hymn_with_score.relevance_score { + score if score >= 1600 => "number_match".to_string(), + score if score >= 1500 => "exact_title_match".to_string(), + score if score >= 1200 => "title_start_match".to_string(), + score if score >= 800 => "title_contains_match".to_string(), + score if score >= 700 => "multi_word_title_match".to_string(), + score if score >= 600 => "first_line_match".to_string(), + score if score >= 400 => "title_word_match".to_string(), + score if score >= 300 => "content_phrase_match".to_string(), + score if score >= 200 => "multi_word_content_match".to_string(), + _ => "content_word_match".to_string(), + }; + + SearchResult { + hymn, + score: normalized_score, + match_type, + } + }).collect(); + + Ok(pagination.create_hymnal_response(search_results, total_count)) + } +} \ No newline at end of file diff --git a/src/services/mod.rs b/src/services/mod.rs index b76d4f9..71be567 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -8,6 +8,8 @@ pub mod owncast; pub mod media_scanner; pub mod thumbnail_generator; pub mod backup_scheduler; +pub mod hymnal; +pub mod hymnal_search; pub use events::EventService; pub use bulletins::BulletinService; @@ -18,4 +20,6 @@ pub use config::ConfigService; pub use owncast::OwncastService; pub use media_scanner::MediaScanner; pub use thumbnail_generator::ThumbnailGenerator; -pub use backup_scheduler::BackupScheduler; \ No newline at end of file +pub use backup_scheduler::BackupScheduler; +pub use hymnal::HymnalService; +pub use hymnal_search::HymnalSearchService; \ No newline at end of file diff --git a/src/utils/bible_books.rs b/src/utils/bible_books.rs new file mode 100644 index 0000000..584134f --- /dev/null +++ b/src/utils/bible_books.rs @@ -0,0 +1,409 @@ +use std::collections::HashMap; +use once_cell::sync::Lazy; + +/// Comprehensive Bible book abbreviation mappings +/// Supports multiple common abbreviation formats for each book +static BIBLE_BOOK_ABBREVIATIONS: Lazy> = Lazy::new(|| { + let mut map = HashMap::new(); + + // Old Testament + map.insert("gen", "Genesis"); + map.insert("genesis", "Genesis"); + map.insert("ge", "Genesis"); + + map.insert("exod", "Exodus"); + map.insert("exodus", "Exodus"); + map.insert("ex", "Exodus"); + map.insert("exo", "Exodus"); + + map.insert("lev", "Leviticus"); + map.insert("leviticus", "Leviticus"); + map.insert("le", "Leviticus"); + + map.insert("num", "Numbers"); + map.insert("numbers", "Numbers"); + map.insert("nu", "Numbers"); + + map.insert("deut", "Deuteronomy"); + map.insert("deuteronomy", "Deuteronomy"); + map.insert("de", "Deuteronomy"); + map.insert("dt", "Deuteronomy"); + + map.insert("josh", "Joshua"); + map.insert("joshua", "Joshua"); + map.insert("jos", "Joshua"); + + map.insert("judg", "Judges"); + map.insert("judges", "Judges"); + map.insert("jud", "Judges"); + + map.insert("ruth", "Ruth"); + map.insert("ru", "Ruth"); + + map.insert("1sam", "1 Samuel"); + map.insert("1 sam", "1 Samuel"); + map.insert("1 samuel", "1 Samuel"); + map.insert("1samuel", "1 Samuel"); + map.insert("i sam", "1 Samuel"); + map.insert("i samuel", "1 Samuel"); + + map.insert("2sam", "2 Samuel"); + map.insert("2 sam", "2 Samuel"); + map.insert("2 samuel", "2 Samuel"); + map.insert("2samuel", "2 Samuel"); + map.insert("ii sam", "2 Samuel"); + map.insert("ii samuel", "2 Samuel"); + + map.insert("1kings", "1 Kings"); + map.insert("1 kings", "1 Kings"); + map.insert("1 ki", "1 Kings"); + map.insert("1ki", "1 Kings"); + map.insert("i kings", "1 Kings"); + map.insert("i ki", "1 Kings"); + + map.insert("2kings", "2 Kings"); + map.insert("2 kings", "2 Kings"); + map.insert("2 ki", "2 Kings"); + map.insert("2ki", "2 Kings"); + map.insert("ii kings", "2 Kings"); + map.insert("ii ki", "2 Kings"); + + map.insert("1chron", "1 Chronicles"); + map.insert("1 chron", "1 Chronicles"); + map.insert("1 chronicles", "1 Chronicles"); + map.insert("1chronicles", "1 Chronicles"); + map.insert("1 chr", "1 Chronicles"); + map.insert("1chr", "1 Chronicles"); + map.insert("i chron", "1 Chronicles"); + map.insert("i chr", "1 Chronicles"); + + map.insert("2chron", "2 Chronicles"); + map.insert("2 chron", "2 Chronicles"); + map.insert("2 chronicles", "2 Chronicles"); + map.insert("2chronicles", "2 Chronicles"); + map.insert("2 chr", "2 Chronicles"); + map.insert("2chr", "2 Chronicles"); + map.insert("ii chron", "2 Chronicles"); + map.insert("ii chr", "2 Chronicles"); + + map.insert("ezra", "Ezra"); + map.insert("ez", "Ezra"); + + map.insert("neh", "Nehemiah"); + map.insert("nehemiah", "Nehemiah"); + map.insert("ne", "Nehemiah"); + + map.insert("esth", "Esther"); + map.insert("esther", "Esther"); + map.insert("es", "Esther"); + + map.insert("job", "Job"); + + map.insert("psalm", "Psalms"); + map.insert("psalms", "Psalms"); + map.insert("ps", "Psalms"); + map.insert("psa", "Psalms"); + + map.insert("prov", "Proverbs"); + map.insert("proverbs", "Proverbs"); + map.insert("pr", "Proverbs"); + + map.insert("eccl", "Ecclesiastes"); + map.insert("ecclesiastes", "Ecclesiastes"); + map.insert("ec", "Ecclesiastes"); + map.insert("ecc", "Ecclesiastes"); + + map.insert("song", "Song of Solomon"); + map.insert("songs", "Song of Solomon"); + map.insert("song of solomon", "Song of Solomon"); + map.insert("song of songs", "Song of Solomon"); + map.insert("so", "Song of Solomon"); + map.insert("sos", "Song of Solomon"); + + map.insert("isa", "Isaiah"); + map.insert("isaiah", "Isaiah"); + map.insert("is", "Isaiah"); + + map.insert("jer", "Jeremiah"); + map.insert("jeremiah", "Jeremiah"); + map.insert("je", "Jeremiah"); + + map.insert("lam", "Lamentations"); + map.insert("lamentations", "Lamentations"); + map.insert("la", "Lamentations"); + + map.insert("ezek", "Ezekiel"); + map.insert("ezekiel", "Ezekiel"); + map.insert("eze", "Ezekiel"); + + map.insert("dan", "Daniel"); + map.insert("daniel", "Daniel"); + map.insert("da", "Daniel"); + + map.insert("hos", "Hosea"); + map.insert("hosea", "Hosea"); + map.insert("ho", "Hosea"); + + map.insert("joel", "Joel"); + map.insert("joe", "Joel"); + + map.insert("amos", "Amos"); + map.insert("am", "Amos"); + + map.insert("obad", "Obadiah"); + map.insert("obadiah", "Obadiah"); + map.insert("ob", "Obadiah"); + + map.insert("jonah", "Jonah"); + map.insert("jon", "Jonah"); + + map.insert("mic", "Micah"); + map.insert("micah", "Micah"); + map.insert("mi", "Micah"); + + map.insert("nah", "Nahum"); + map.insert("nahum", "Nahum"); + map.insert("na", "Nahum"); + + map.insert("hab", "Habakkuk"); + map.insert("habakkuk", "Habakkuk"); + + map.insert("zeph", "Zephaniah"); + map.insert("zephaniah", "Zephaniah"); + map.insert("zep", "Zephaniah"); + + map.insert("hag", "Haggai"); + map.insert("haggai", "Haggai"); + + map.insert("zech", "Zechariah"); + map.insert("zechariah", "Zechariah"); + map.insert("zec", "Zechariah"); + + map.insert("mal", "Malachi"); + map.insert("malachi", "Malachi"); + + // New Testament + map.insert("matt", "Matthew"); + map.insert("matthew", "Matthew"); + map.insert("mt", "Matthew"); + + map.insert("mark", "Mark"); + map.insert("mk", "Mark"); + map.insert("mar", "Mark"); + + map.insert("luke", "Luke"); + map.insert("lk", "Luke"); + map.insert("luk", "Luke"); + + map.insert("john", "John"); + map.insert("jn", "John"); + map.insert("joh", "John"); + + map.insert("acts", "Acts"); + map.insert("act", "Acts"); + map.insert("ac", "Acts"); + + map.insert("rom", "Romans"); + map.insert("romans", "Romans"); + map.insert("ro", "Romans"); + + map.insert("1cor", "1 Corinthians"); + map.insert("1 cor", "1 Corinthians"); + map.insert("1 corinthians", "1 Corinthians"); + map.insert("1corinthians", "1 Corinthians"); + map.insert("i cor", "1 Corinthians"); + map.insert("i corinthians", "1 Corinthians"); + + map.insert("2cor", "2 Corinthians"); + map.insert("2 cor", "2 Corinthians"); + map.insert("2 corinthians", "2 Corinthians"); + map.insert("2corinthians", "2 Corinthians"); + map.insert("ii cor", "2 Corinthians"); + map.insert("ii corinthians", "2 Corinthians"); + + map.insert("gal", "Galatians"); + map.insert("galatians", "Galatians"); + map.insert("ga", "Galatians"); + + map.insert("eph", "Ephesians"); + map.insert("ephesians", "Ephesians"); + map.insert("ep", "Ephesians"); + + map.insert("phil", "Philippians"); + map.insert("philippians", "Philippians"); + map.insert("php", "Philippians"); + map.insert("pp", "Philippians"); + + map.insert("col", "Colossians"); + map.insert("colossians", "Colossians"); + + map.insert("1thess", "1 Thessalonians"); + map.insert("1 thess", "1 Thessalonians"); + map.insert("1 thessalonians", "1 Thessalonians"); + map.insert("1thessalonians", "1 Thessalonians"); + map.insert("1 th", "1 Thessalonians"); + map.insert("1th", "1 Thessalonians"); + map.insert("i thess", "1 Thessalonians"); + map.insert("i th", "1 Thessalonians"); + + map.insert("2thess", "2 Thessalonians"); + map.insert("2 thess", "2 Thessalonians"); + map.insert("2 thessalonians", "2 Thessalonians"); + map.insert("2thessalonians", "2 Thessalonians"); + map.insert("2 th", "2 Thessalonians"); + map.insert("2th", "2 Thessalonians"); + map.insert("ii thess", "2 Thessalonians"); + map.insert("ii th", "2 Thessalonians"); + + map.insert("1tim", "1 Timothy"); + map.insert("1 tim", "1 Timothy"); + map.insert("1 timothy", "1 Timothy"); + map.insert("1timothy", "1 Timothy"); + map.insert("i tim", "1 Timothy"); + map.insert("i timothy", "1 Timothy"); + + map.insert("2tim", "2 Timothy"); + map.insert("2 tim", "2 Timothy"); + map.insert("2 timothy", "2 Timothy"); + map.insert("2timothy", "2 Timothy"); + map.insert("ii tim", "2 Timothy"); + map.insert("ii timothy", "2 Timothy"); + + map.insert("titus", "Titus"); + map.insert("tit", "Titus"); + + map.insert("philem", "Philemon"); + map.insert("philemon", "Philemon"); + map.insert("phm", "Philemon"); + + map.insert("heb", "Hebrews"); + map.insert("hebrews", "Hebrews"); + + map.insert("james", "James"); + map.insert("jas", "James"); + map.insert("jam", "James"); + + map.insert("1pet", "1 Peter"); + map.insert("1 pet", "1 Peter"); + map.insert("1 peter", "1 Peter"); + map.insert("1peter", "1 Peter"); + map.insert("i pet", "1 Peter"); + map.insert("i peter", "1 Peter"); + + map.insert("2pet", "2 Peter"); + map.insert("2 pet", "2 Peter"); + map.insert("2 peter", "2 Peter"); + map.insert("2peter", "2 Peter"); + map.insert("ii pet", "2 Peter"); + map.insert("ii peter", "2 Peter"); + + map.insert("1john", "1 John"); + map.insert("1 john", "1 John"); + map.insert("1 jn", "1 John"); + map.insert("1jn", "1 John"); + map.insert("i john", "1 John"); + map.insert("i jn", "1 John"); + + map.insert("2john", "2 John"); + map.insert("2 john", "2 John"); + map.insert("2 jn", "2 John"); + map.insert("2jn", "2 John"); + map.insert("ii john", "2 John"); + map.insert("ii jn", "2 John"); + + map.insert("3john", "3 John"); + map.insert("3 john", "3 John"); + map.insert("3 jn", "3 John"); + map.insert("3jn", "3 John"); + map.insert("iii john", "3 John"); + map.insert("iii jn", "3 John"); + + map.insert("jude", "Jude"); + map.insert("jud", "Jude"); + + map.insert("rev", "Revelation"); + map.insert("revelation", "Revelation"); + map.insert("re", "Revelation"); + + map +}); + +/// Resolve Bible book abbreviation to full book name +/// Returns the full book name if found, otherwise returns the original input +pub fn resolve_bible_book_abbreviation(abbreviation: &str) -> String { + let normalized = abbreviation.trim().to_lowercase(); + + // Check if it's an abbreviation we recognize + if let Some(&full_name) = BIBLE_BOOK_ABBREVIATIONS.get(normalized.as_str()) { + full_name.to_string() + } else { + // Return the original input (might already be a full name) + abbreviation.to_string() + } +} + +/// Normalize a Bible reference by resolving abbreviations +/// Example: "Matt 1:21-23" -> "Matthew 1:21-23" +pub fn normalize_bible_reference(reference: &str) -> String { + // Find the first space or number to separate book from chapter:verse + let mut book_end = reference.len(); + + for (i, c) in reference.char_indices() { + if c.is_ascii_digit() || c == ' ' { + book_end = i; + break; + } + } + + let book_part = &reference[..book_end].trim(); + let remainder = &reference[book_end..]; + + let resolved_book = resolve_bible_book_abbreviation(book_part); + + format!("{}{}", resolved_book, remainder) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_common_abbreviations() { + assert_eq!(resolve_bible_book_abbreviation("Matt"), "Matthew"); + assert_eq!(resolve_bible_book_abbreviation("matt"), "Matthew"); + assert_eq!(resolve_bible_book_abbreviation("MATT"), "Matthew"); + assert_eq!(resolve_bible_book_abbreviation("Gen"), "Genesis"); + assert_eq!(resolve_bible_book_abbreviation("Ps"), "Psalms"); + assert_eq!(resolve_bible_book_abbreviation("Rom"), "Romans"); + assert_eq!(resolve_bible_book_abbreviation("Rev"), "Revelation"); + } + + #[test] + fn test_full_names_unchanged() { + assert_eq!(resolve_bible_book_abbreviation("Matthew"), "Matthew"); + assert_eq!(resolve_bible_book_abbreviation("Genesis"), "Genesis"); + } + + #[test] + fn test_unknown_abbreviations() { + assert_eq!(resolve_bible_book_abbreviation("xyz"), "xyz"); + assert_eq!(resolve_bible_book_abbreviation("Unknown"), "Unknown"); + } + + #[test] + fn test_normalize_references() { + assert_eq!(normalize_bible_reference("Matt 1:21-23"), "Matthew 1:21-23"); + assert_eq!(normalize_bible_reference("Gen 1:1"), "Genesis 1:1"); + assert_eq!(normalize_bible_reference("Ps 23:1-6"), "Psalms 23:1-6"); + assert_eq!(normalize_bible_reference("1 Cor 13:4-8"), "1 Corinthians 13:4-8"); + assert_eq!(normalize_bible_reference("Matthew 5:1-12"), "Matthew 5:1-12"); + } + + #[test] + fn test_numbered_books() { + assert_eq!(resolve_bible_book_abbreviation("1 Cor"), "1 Corinthians"); + assert_eq!(resolve_bible_book_abbreviation("2 Pet"), "2 Peter"); + assert_eq!(resolve_bible_book_abbreviation("1John"), "1 John"); + assert_eq!(resolve_bible_book_abbreviation("2 Sam"), "2 Samuel"); + } +} \ No newline at end of file diff --git a/src/utils/db_operations.rs b/src/utils/db_operations.rs index 7cbf704..e0ef503 100644 --- a/src/utils/db_operations.rs +++ b/src/utils/db_operations.rs @@ -414,11 +414,15 @@ impl BibleVerseOperations { } /// Parse verse range format (e.g., "John 3:16-18" or "2 Peter 1:20-21") + /// Also handles abbreviations like "Matt 1:21-23" fn parse_verse_range(query: &str) -> Option<(String, i32, i32)> { + // First normalize the query to resolve any Bible book abbreviations + let normalized_query = crate::utils::bible_books::normalize_bible_reference(query); + // Look for pattern: "Book Chapter:StartVerse-EndVerse" - if let Some(dash_pos) = query.rfind('-') { - let before_dash = &query[..dash_pos]; - let after_dash = &query[dash_pos + 1..]; + if let Some(dash_pos) = normalized_query.rfind('-') { + let before_dash = &normalized_query[..dash_pos]; + let after_dash = &normalized_query[dash_pos + 1..]; // Parse end verse if let Ok(end_verse) = after_dash.parse::() { @@ -438,10 +442,12 @@ impl BibleVerseOperations { /// Search verses by text or reference (supports comma-separated references and verse ranges) pub async fn search(pool: &PgPool, query_text: &str, limit: i64) -> Result> { + // First normalize the query to resolve any Bible book abbreviations + let normalized_query = crate::utils::bible_books::normalize_bible_reference(query_text); // Check if query contains comma (multiple references) - if query_text.contains(',') { + if normalized_query.contains(',') { let mut all_verses = Vec::new(); - let references: Vec<&str> = query_text.split(',').map(|s| s.trim()).collect(); + let references: Vec<&str> = normalized_query.split(',').map(|s| s.trim()).collect(); for reference in references { if !reference.is_empty() { @@ -457,7 +463,7 @@ impl BibleVerseOperations { Ok(all_verses) } else { - Self::search_single_reference(pool, query_text, limit).await + Self::search_single_reference(pool, &normalized_query, limit).await } } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ca73ab2..fd935b1 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -14,3 +14,4 @@ pub mod codec_detection; pub mod media_parsing; pub mod backup; pub mod common; +pub mod bible_books; diff --git a/src/utils/pagination.rs b/src/utils/pagination.rs index 4a9bd00..f3efe32 100644 --- a/src/utils/pagination.rs +++ b/src/utils/pagination.rs @@ -1,4 +1,4 @@ -use crate::models::PaginatedResponse; +use crate::models::{PaginatedResponse, HymnalPaginatedResponse}; #[derive(Debug, Clone)] pub struct PaginationHelper { @@ -25,4 +25,23 @@ impl PaginationHelper { has_more: (self.page as i64 * self.per_page as i64) < total, } } + + pub fn create_hymnal_response(&self, items: Vec, total: i64) -> HymnalPaginatedResponse { + HymnalPaginatedResponse { + results: items, + total, + page: self.page, + per_page: self.per_page, + has_more: (self.page as i64 * self.per_page as i64) < total, + } + } + + // Hymnal-specific pagination with higher limits for downloading complete hymnals + pub fn from_hymnal_query(page: Option, per_page: Option) -> Self { + let page = page.unwrap_or(1).max(1); + let per_page = per_page.unwrap_or(25).min(1000); // Allow up to 1000 for complete hymnals + let offset = ((page - 1) as i64) * (per_page as i64); + + Self { page, per_page, offset } + } } \ No newline at end of file