Add comprehensive hymnal support and enhance bulletin processing

- 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
This commit is contained in:
Benjamin Slingo 2025-08-28 20:34:04 -04:00
parent 916a54caa2
commit 5793e12df9
13 changed files with 1840 additions and 21 deletions

View file

@ -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)';

View file

@ -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,12 +58,23 @@ 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() => {
if verses.len() == 1 {
// Single verse - format as before
let verse = &verses[0];
// Put verse text first, then reference at the end
Ok(Some(format!("{} - {}", verse.text, scripture_text)))
} else {
// Multiple verses - combine them
let combined_text = verses
.iter()
.map(|v| v.text.as_str())
.collect::<Vec<&str>>()
.join(" ");
Ok(Some(format!("{} - {}", combined_text, scripture_text)))
}
},
_ => {
// If no match found, return original text
@ -69,3 +82,68 @@ async fn process_scripture_reading(
}
}
}
/// 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<String> {
// 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::<i32>() {
// 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)
}

136
src/handlers/hymnal.rs Normal file
View file

@ -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<AppState>,
) -> Result<Json<ApiResponse<Vec<Hymnal>>>> {
let hymnals = HymnalService::list_hymnals(&state.pool).await?;
Ok(success_response(hymnals))
}
pub async fn get_hymnal(
State(state): State<AppState>,
Path(hymnal_id): Path<Uuid>,
) -> Result<Json<ApiResponse<Option<Hymnal>>>> {
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<AppState>,
Path(code): Path<String>,
) -> Result<Json<ApiResponse<Option<Hymnal>>>> {
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<AppState>,
Query(query): Query<ListQueryParams>,
Query(hymnal_filter): Query<HymnalFilter>,
) -> Result<Json<ApiResponse<HymnalPaginatedResponse<HymnWithHymnal>>>> {
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<AppState>,
Path((hymnal_code, hymn_number)): Path<(String, i32)>,
) -> Result<Json<ApiResponse<Option<HymnWithHymnal>>>> {
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<AppState>,
Query(search_query): Query<HymnSearchQuery>,
Query(query): Query<ListQueryParams>,
) -> Result<Json<ApiResponse<HymnalPaginatedResponse<SearchResult>>>> {
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<AppState>,
Path(hymnal_id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<ThematicListWithAmbits>>>> {
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<AppState>,
Path(hymnal_code): Path<String>,
) -> Result<Json<ApiResponse<Vec<ThematicListWithAmbits>>>> {
// 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<AppState>,
Query(query): Query<ListQueryParams>,
) -> Result<Json<ApiResponse<HymnalPaginatedResponse<ResponsiveReading>>>> {
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<AppState>,
Path(number): Path<i32>,
) -> Result<Json<ApiResponse<Option<ResponsiveReading>>>> {
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<AppState>,
Query(search_query): Query<ResponsiveReadingQuery>,
Query(query): Query<ListQueryParams>,
) -> Result<Json<ApiResponse<HymnalPaginatedResponse<ResponsiveReading>>>> {
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<Uuid>,
}

View file

@ -14,3 +14,4 @@ pub mod media;
pub mod smart_streaming;
pub mod v2;
pub mod backup;
pub mod hymnal;

View file

@ -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))

View file

@ -208,6 +208,16 @@ pub struct PaginatedResponse<T> {
pub has_more: bool,
}
// Hymnal-specific paginated response with "results" field
#[derive(Debug, Serialize)]
pub struct HymnalPaginatedResponse<T> {
pub results: Vec<T>,
pub total: i64,
pub page: i32,
pub per_page: i32,
pub has_more: bool,
}
#[derive(Debug, Deserialize)]
pub struct PaginationParams {
pub page: Option<i64>,
@ -446,6 +456,114 @@ pub struct BibleVerseV2 {
pub updated_at: Option<DateTimeWithTimezone>,
}
// Hymnal Models
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Hymnal {
pub id: Uuid,
pub name: String,
pub code: String,
pub description: Option<String>,
pub year: Option<i32>,
pub language: Option<String>,
pub is_active: Option<bool>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[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<bool>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[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<i32>,
pub number: i32,
pub title: String,
pub content: String,
pub is_favorite: Option<bool>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct ThematicList {
pub id: Uuid,
pub hymnal_id: Uuid,
pub name: String,
pub sort_order: Option<i32>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[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<i32>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThematicListWithAmbits {
pub id: Uuid,
pub hymnal_id: Uuid,
pub name: String,
pub sort_order: Option<i32>,
pub ambits: Vec<ThematicAmbit>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
#[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<bool>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
}
// 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<String>,
pub hymnal: Option<String>,
pub number: Option<i32>,
pub theme: Option<Uuid>,
}
#[derive(Debug, Deserialize)]
pub struct ResponsiveReadingQuery {
pub q: Option<String>,
pub number: Option<i32>,
}
// SanitizeOutput trait implementations
impl SanitizeOutput for Bulletin {
fn sanitize_output(mut self) -> Self {
@ -605,6 +723,13 @@ impl<T: SanitizeOutput> SanitizeOutput for PaginatedResponse<T> {
}
}
impl<T: SanitizeOutput> SanitizeOutput for HymnalPaginatedResponse<T> {
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
}
}

561
src/services/hymnal.rs Normal file
View file

@ -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<Vec<Hymnal>> {
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<Option<Hymnal>> {
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<Option<Hymnal>> {
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<Uuid>,
pagination: PaginationHelper,
) -> Result<HymnalPaginatedResponse<HymnWithHymnal>> {
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<Option<HymnWithHymnal>> {
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<HymnalPaginatedResponse<SearchResult>> {
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<SearchResult> = 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<SearchResult> = 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<HymnalPaginatedResponse<HymnWithHymnal>> {
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::<i32>().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::<i32>() {
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::<i32>::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::<i32>::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::<i32>::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::<i32>::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<Vec<ThematicListWithAmbits>> {
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<HymnalPaginatedResponse<ResponsiveReading>> {
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<Option<ResponsiveReading>> {
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<HymnalPaginatedResponse<ResponsiveReading>> {
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
}
}
}
}

View file

@ -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<i32>,
pub number: i32,
pub title: String,
pub content: String,
pub is_favorite: Option<bool>,
pub created_at: Option<DateTime<Utc>>,
pub updated_at: Option<DateTime<Utc>>,
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<HymnalPaginatedResponse<SearchResult>> {
let clean_search = search_term.trim().to_lowercase();
// Extract number from various formats
let extracted_number = if let Ok(num) = clean_search.parse::<i32>() {
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<SearchResult> = 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))
}
}

View file

@ -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;
@ -19,3 +21,5 @@ pub use owncast::OwncastService;
pub use media_scanner::MediaScanner;
pub use thumbnail_generator::ThumbnailGenerator;
pub use backup_scheduler::BackupScheduler;
pub use hymnal::HymnalService;
pub use hymnal_search::HymnalSearchService;

409
src/utils/bible_books.rs Normal file
View file

@ -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<HashMap<&'static str, &'static str>> = 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");
}
}

View file

@ -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::<i32>() {
@ -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<Vec<BibleVerse>> {
// 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
}
}

View file

@ -14,3 +14,4 @@ pub mod codec_detection;
pub mod media_parsing;
pub mod backup;
pub mod common;
pub mod bible_books;

View file

@ -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<T>(&self, items: Vec<T>, total: i64) -> HymnalPaginatedResponse<T> {
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<i32>, per_page: Option<i32>) -> 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 }
}
}