use chrono::{Datelike, TimeZone, Utc}; use diesel::prelude::*; use diesel::sql_types::Text; use crate::db::DbConnection; use crate::db::schema::media; use crate::model::Media; use crate::util::calendar_range_unix_ms; diesel::define_sql_function!(fn instr(haystack: Text, needle: Text) -> Integer); pub fn insert_media(conn: &DbConnection, m: &Media) -> QueryResult<()> { conn.with(|c| { diesel::insert_into(media::table) .values(m.clone()) .execute(c) .map(|_| ()) }) } pub fn get_media_by_id(conn: &DbConnection, id: &str) -> QueryResult { conn.with(|c| { media::table .filter(media::id.eq(id)) .select(Media::as_select()) .first(c) }) } pub fn list_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResult> { conn.with(|c| { media::table .filter(media::project_id.eq(project_id)) .order(media::created_at.desc()) .select(Media::as_select()) .load(c) }) } pub fn count_media_by_project(conn: &DbConnection, project_id: &str) -> QueryResult { conn.with(|c| { media::table .filter(media::project_id.eq(project_id)) .count() .get_result(c) }) } pub fn update_media(conn: &DbConnection, m: &Media) -> QueryResult<()> { conn.with(|c| { diesel::update(media::table.filter(media::id.eq(&m.id))) .set(m.clone()) .execute(c) .map(|_| ()) }) } pub fn delete_media(conn: &DbConnection, id: &str) -> QueryResult<()> { conn.with(|c| { diesel::delete(media::table.filter(media::id.eq(id))) .execute(c) .map(|_| ()) }) } // ── Filtered queries (per sidebar_views.allium MediaView) ─── /// Parameters for filtered media listing. #[derive(Debug, Clone, Default)] pub struct MediaFilterParams { /// FTS search query (empty = no search filter). pub search_query: String, /// Year filter from calendar archive. pub year: Option, /// Month filter (1-12) from calendar archive. pub month: Option, /// Tag filter (media must have ALL of these tags). pub tags: Vec, } impl MediaFilterParams { pub fn has_active_filters(&self) -> bool { !self.search_query.is_empty() || self.year.is_some() || !self.tags.is_empty() } } /// List media with optional filters applied. pub fn list_media_filtered( conn: &DbConnection, project_id: &str, filters: &MediaFilterParams, limit: i64, offset: i64, ) -> QueryResult> { conn.with(|c| { let mut query = media::table .filter(media::project_id.eq(project_id)) .into_boxed(); if !filters.search_query.is_empty() { query = query.filter( media::title .is_not_null() .and(instr(media::title.assume_not_null(), &filters.search_query).gt(0)) .or(media::title .is_null() .and(instr(media::original_name, &filters.search_query).gt(0))), ); } if let Some(year) = filters.year { let (start, end) = calendar_range_unix_ms(year, filters.month).ok_or_else(|| { diesel::result::Error::SerializationError("invalid calendar range".into()) })?; query = query.filter(media::created_at.ge(start).and(media::created_at.lt(end))); } for tag in &filters.tags { query = query.filter(instr(media::tags, serde_json::to_string(tag).unwrap()).gt(0)); } query .order(media::created_at.desc()) .limit(limit) .offset(offset) .select(Media::as_select()) .load(c) }) } /// Year/month counts for the media calendar archive widget. pub fn media_calendar_counts( conn: &DbConnection, project_id: &str, ) -> QueryResult> { conn.with(|c| { let timestamps = media::table .filter(media::project_id.eq(project_id)) .select(media::created_at) .load::(c)?; let mut counts = std::collections::BTreeMap::new(); for timestamp in timestamps { let date = Utc .timestamp_millis_opt(timestamp) .single() .ok_or_else(|| { diesel::result::Error::DeserializationError("invalid timestamp".into()) })?; *counts.entry((date.year(), date.month())).or_insert(0) += 1; } Ok(counts .into_iter() .rev() .map(|((year, month), count)| (year, month, count)) .collect()) }) } /// Collect all distinct tag values across media for a project. pub fn distinct_media_tags(conn: &DbConnection, project_id: &str) -> QueryResult> { let rows = conn.with(|c| { media::table .filter(media::project_id.eq(project_id)) .filter(media::tags.ne("[]")) .select(media::tags) .distinct() .load::(c) })?; let mut all_tags = std::collections::BTreeSet::new(); for json_str in rows { if let Ok(tags) = serde_json::from_str::>(&json_str) { all_tags.extend(tags); } } Ok(all_tags.into_iter().collect()) } /// Test helper: create a minimal Media value (available to sibling test modules). #[cfg(test)] pub fn make_test_media(id: &str, project_id: &str) -> Media { Media { id: id.to_string(), project_id: project_id.to_string(), filename: format!("{id}.jpg"), original_name: "photo.jpg".to_string(), mime_type: "image/jpeg".to_string(), size: 50000, width: Some(1920), height: Some(1080), title: None, alt: None, caption: None, author: None, language: None, file_path: format!("/media/{id}.jpg"), sidecar_path: format!("/media/{id}.jpg.meta"), checksum: None, tags: vec![], created_at: 1000, updated_at: 2000, } } #[cfg(test)] mod tests { use super::*; use crate::db::Database; use crate::db::queries::project::{insert_project, make_test_project}; fn setup() -> Database { let db = Database::open_in_memory().unwrap(); db.migrate().unwrap(); insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap(); db } fn make_media_item(id: &str) -> Media { Media { id: id.to_string(), project_id: "p1".to_string(), filename: "abc.jpg".to_string(), original_name: "photo.jpg".to_string(), mime_type: "image/jpeg".to_string(), size: 50000, width: Some(1920), height: Some(1080), title: Some("Sunset".into()), alt: Some("A sunset".into()), caption: None, author: Some("Bob".into()), language: Some("en".into()), file_path: "/media/abc.jpg".to_string(), sidecar_path: "/media/abc.jpg.meta".to_string(), checksum: Some("hash1".into()), tags: vec!["nature".into()], created_at: 1000, updated_at: 2000, } } #[test] fn insert_and_get_by_id() { let db = setup(); let m = make_media_item("m1"); insert_media(db.conn(), &m).unwrap(); let fetched = get_media_by_id(db.conn(), "m1").unwrap(); assert_eq!(fetched.original_name, "photo.jpg"); assert_eq!(fetched.width, Some(1920)); assert_eq!(fetched.height, Some(1080)); assert_eq!(fetched.tags, vec!["nature"]); assert_eq!(fetched.size, 50000); } #[test] fn list_by_project() { let db = setup(); let mut m1 = make_media_item("m1"); m1.created_at = 1000; let mut m2 = make_media_item("m2"); m2.filename = "def.jpg".into(); m2.created_at = 2000; insert_media(db.conn(), &m1).unwrap(); insert_media(db.conn(), &m2).unwrap(); let list = list_media_by_project(db.conn(), "p1").unwrap(); assert_eq!(list.len(), 2); assert_eq!(list[0].id, "m2"); } #[test] fn update_media_fields() { let db = setup(); let mut m = make_media_item("m1"); insert_media(db.conn(), &m).unwrap(); m.title = Some("New Title".into()); m.tags = vec!["updated".into()]; m.updated_at = 9999; update_media(db.conn(), &m).unwrap(); let fetched = get_media_by_id(db.conn(), "m1").unwrap(); assert_eq!(fetched.title.as_deref(), Some("New Title")); assert_eq!(fetched.tags, vec!["updated"]); } #[test] fn delete_removes_media() { let db = setup(); insert_media(db.conn(), &make_media_item("m1")).unwrap(); delete_media(db.conn(), "m1").unwrap(); assert!(get_media_by_id(db.conn(), "m1").is_err()); } }