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:
parent
916a54caa2
commit
5793e12df9
90
migrations/20250827000001_create_hymnal_tables.sql
Normal file
90
migrations/20250827000001_create_hymnal_tables.sql
Normal 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)';
|
|
@ -3,7 +3,9 @@ use crate::{
|
||||||
error::Result,
|
error::Result,
|
||||||
models::Bulletin,
|
models::Bulletin,
|
||||||
utils::db_operations::BibleVerseOperations,
|
utils::db_operations::BibleVerseOperations,
|
||||||
|
services::HymnalService,
|
||||||
};
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
/// Process multiple bulletins with shared logic
|
/// Process multiple bulletins with shared logic
|
||||||
pub async fn process_bulletins_batch(
|
pub async fn process_bulletins_batch(
|
||||||
|
@ -24,15 +26,15 @@ pub async fn process_single_bulletin(
|
||||||
// Process scripture reading to include full verse text
|
// Process scripture reading to include full verse text
|
||||||
bulletin.scripture_reading = process_scripture_reading(pool, &bulletin.scripture_reading).await?;
|
bulletin.scripture_reading = process_scripture_reading(pool, &bulletin.scripture_reading).await?;
|
||||||
|
|
||||||
// Process hymn references in worship content - stub for now
|
// Process hymn references in worship content
|
||||||
// if let Some(ref worship_content) = bulletin.divine_worship {
|
if let Some(ref worship_content) = bulletin.divine_worship {
|
||||||
// bulletin.divine_worship = Some(super::bulletins::process_hymn_references(pool, worship_content).await?);
|
bulletin.divine_worship = Some(process_hymn_references(pool, worship_content).await?);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// Process hymn references in sabbath school content - stub for now
|
// Process hymn references in sabbath school content
|
||||||
// if let Some(ref ss_content) = bulletin.sabbath_school {
|
if let Some(ref ss_content) = bulletin.sabbath_school {
|
||||||
// bulletin.sabbath_school = Some(super::bulletins::process_hymn_references(pool, ss_content).await?);
|
bulletin.sabbath_school = Some(process_hymn_references(pool, ss_content).await?);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// Ensure sunset field compatibility
|
// Ensure sunset field compatibility
|
||||||
if bulletin.sunset.is_none() {
|
if bulletin.sunset.is_none() {
|
||||||
|
@ -56,12 +58,23 @@ async fn process_scripture_reading(
|
||||||
return Ok(Some(scripture_text.clone()));
|
return Ok(Some(scripture_text.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find the verse using existing search functionality
|
// Try to find the verse(s) using existing search functionality
|
||||||
match BibleVerseOperations::search(pool, scripture_text, 1).await {
|
// 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() => {
|
Ok(verses) if !verses.is_empty() => {
|
||||||
|
if verses.len() == 1 {
|
||||||
|
// Single verse - format as before
|
||||||
let verse = &verses[0];
|
let verse = &verses[0];
|
||||||
// Put verse text first, then reference at the end
|
|
||||||
Ok(Some(format!("{} - {}", verse.text, scripture_text)))
|
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
|
// 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
136
src/handlers/hymnal.rs
Normal 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>,
|
||||||
|
}
|
|
@ -14,3 +14,4 @@ pub mod media;
|
||||||
pub mod smart_streaming;
|
pub mod smart_streaming;
|
||||||
pub mod v2;
|
pub mod v2;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
|
pub mod hymnal;
|
||||||
|
|
12
src/main.rs
12
src/main.rs
|
@ -126,6 +126,18 @@ let app = Router::new()
|
||||||
.route("/api/schedule", get(handlers::schedule::get_schedule))
|
.route("/api/schedule", get(handlers::schedule::get_schedule))
|
||||||
.route("/api/conference-data", get(handlers::schedule::get_conference_data))
|
.route("/api/conference-data", get(handlers::schedule::get_conference_data))
|
||||||
.route("/api/members/active", get(handlers::members::list_active))
|
.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)
|
// New media library endpoints (replacing Jellyfin)
|
||||||
.route("/api/sermons", get(handlers::media::list_sermons))
|
.route("/api/sermons", get(handlers::media::list_sermons))
|
||||||
.route("/api/livestreams", get(handlers::media::list_livestreams))
|
.route("/api/livestreams", get(handlers::media::list_livestreams))
|
||||||
|
|
191
src/models.rs
191
src/models.rs
|
@ -208,6 +208,16 @@ pub struct PaginatedResponse<T> {
|
||||||
pub has_more: bool,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct PaginationParams {
|
pub struct PaginationParams {
|
||||||
pub page: Option<i64>,
|
pub page: Option<i64>,
|
||||||
|
@ -446,6 +456,114 @@ pub struct BibleVerseV2 {
|
||||||
pub updated_at: Option<DateTimeWithTimezone>,
|
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
|
// SanitizeOutput trait implementations
|
||||||
impl SanitizeOutput for Bulletin {
|
impl SanitizeOutput for Bulletin {
|
||||||
fn sanitize_output(mut self) -> Self {
|
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 {
|
impl SanitizeOutput for Personnel {
|
||||||
fn sanitize_output(mut self) -> Self {
|
fn sanitize_output(mut self) -> Self {
|
||||||
self.ss_leader = sanitize_string(self.ss_leader);
|
self.ss_leader = sanitize_string(self.ss_leader);
|
||||||
|
@ -662,3 +787,69 @@ impl SanitizeOutput for User {
|
||||||
self
|
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
561
src/services/hymnal.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
311
src/services/hymnal_search.rs
Normal file
311
src/services/hymnal_search.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,8 @@ pub mod owncast;
|
||||||
pub mod media_scanner;
|
pub mod media_scanner;
|
||||||
pub mod thumbnail_generator;
|
pub mod thumbnail_generator;
|
||||||
pub mod backup_scheduler;
|
pub mod backup_scheduler;
|
||||||
|
pub mod hymnal;
|
||||||
|
pub mod hymnal_search;
|
||||||
|
|
||||||
pub use events::EventService;
|
pub use events::EventService;
|
||||||
pub use bulletins::BulletinService;
|
pub use bulletins::BulletinService;
|
||||||
|
@ -19,3 +21,5 @@ pub use owncast::OwncastService;
|
||||||
pub use media_scanner::MediaScanner;
|
pub use media_scanner::MediaScanner;
|
||||||
pub use thumbnail_generator::ThumbnailGenerator;
|
pub use thumbnail_generator::ThumbnailGenerator;
|
||||||
pub use backup_scheduler::BackupScheduler;
|
pub use backup_scheduler::BackupScheduler;
|
||||||
|
pub use hymnal::HymnalService;
|
||||||
|
pub use hymnal_search::HymnalSearchService;
|
409
src/utils/bible_books.rs
Normal file
409
src/utils/bible_books.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
|
@ -414,11 +414,15 @@ impl BibleVerseOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse verse range format (e.g., "John 3:16-18" or "2 Peter 1:20-21")
|
/// 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)> {
|
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"
|
// Look for pattern: "Book Chapter:StartVerse-EndVerse"
|
||||||
if let Some(dash_pos) = query.rfind('-') {
|
if let Some(dash_pos) = normalized_query.rfind('-') {
|
||||||
let before_dash = &query[..dash_pos];
|
let before_dash = &normalized_query[..dash_pos];
|
||||||
let after_dash = &query[dash_pos + 1..];
|
let after_dash = &normalized_query[dash_pos + 1..];
|
||||||
|
|
||||||
// Parse end verse
|
// Parse end verse
|
||||||
if let Ok(end_verse) = after_dash.parse::<i32>() {
|
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)
|
/// 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>> {
|
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)
|
// Check if query contains comma (multiple references)
|
||||||
if query_text.contains(',') {
|
if normalized_query.contains(',') {
|
||||||
let mut all_verses = Vec::new();
|
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 {
|
for reference in references {
|
||||||
if !reference.is_empty() {
|
if !reference.is_empty() {
|
||||||
|
@ -457,7 +463,7 @@ impl BibleVerseOperations {
|
||||||
|
|
||||||
Ok(all_verses)
|
Ok(all_verses)
|
||||||
} else {
|
} else {
|
||||||
Self::search_single_reference(pool, query_text, limit).await
|
Self::search_single_reference(pool, &normalized_query, limit).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,3 +14,4 @@ pub mod codec_detection;
|
||||||
pub mod media_parsing;
|
pub mod media_parsing;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
|
pub mod bible_books;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::models::PaginatedResponse;
|
use crate::models::{PaginatedResponse, HymnalPaginatedResponse};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PaginationHelper {
|
pub struct PaginationHelper {
|
||||||
|
@ -25,4 +25,23 @@ impl PaginationHelper {
|
||||||
has_more: (self.page as i64 * self.per_page as i64) < total,
|
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 }
|
||||||
|
}
|
||||||
}
|
}
|
Loading…
Reference in a new issue