diff --git a/crates/api/src/routes/songs.rs b/crates/api/src/routes/songs.rs index 3a5c55f..369669d 100644 --- a/crates/api/src/routes/songs.rs +++ b/crates/api/src/routes/songs.rs @@ -3,7 +3,7 @@ use axum::{ http::StatusCode, Json, }; -use domain::RepositoryError; +use domain::{ChordTransposer, RepositoryError, SortField, SortOrder}; use serde::Deserialize; use std::sync::Arc; use uuid::Uuid; @@ -11,6 +11,8 @@ use uuid::Uuid; #[derive(Deserialize)] pub struct ListQuery { pub q: Option, + pub sort: Option, + pub order: Option, } use crate::routes::tabs::{AppState, ErrorResponse, ParseRequest, resolve_html}; @@ -38,10 +40,19 @@ pub async fn list_songs( State(state): State>, Query(params): Query, ) -> Result>, (StatusCode, Json)> { + let sort = match params.sort.as_deref() { + Some("title") => SortField::Title, + Some("artist") => SortField::Artist, + _ => SortField::Date, + }; + let order = match params.order.as_deref() { + Some("asc") => SortOrder::Asc, + _ => SortOrder::Desc, + }; let result = if let Some(q) = params.q.filter(|s| !s.is_empty()) { - state.search.search(&q).await + state.search.search(&q, sort, order).await } else { - state.songs.list().await + state.songs.list(sort, order).await }; result .map(Json) @@ -80,19 +91,37 @@ pub async fn update_song( }) } +#[derive(Deserialize)] +pub struct GetSongQuery { + pub apply_capo: Option, +} + pub async fn get_song( State(state): State>, Path(id): Path, + Query(params): Query, ) -> Result, (StatusCode, Json)> { let uuid = Uuid::parse_str(&id).map_err(|_| { (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: "Invalid ID".into() })) })?; - match state.songs.get(uuid).await { - Ok(Some(song)) => Ok(Json(song)), - Ok(None) => Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))), - Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))), - } + let song = match state.songs.get(uuid).await { + Ok(Some(s)) => s, + Ok(None) => return Err((StatusCode::NOT_FOUND, Json(ErrorResponse { error: "Not found".into() }))), + Err(e) => return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e.to_string() }))), + }; + + let song = if params.apply_capo.unwrap_or(false) { + if let Some(capo) = song.meta.capo { + ChordTransposer.transpose_song(&song, capo as i8) + } else { + song + } + } else { + song + }; + + Ok(Json(song)) } pub async fn delete_song( diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index ff9e033..d514a6a 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,4 +1,4 @@ -use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong}; +use domain::{RepositoryError, Song, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, SortField, SortOrder}; use uuid::Uuid; pub struct SongService { @@ -14,8 +14,8 @@ impl SongService { self.repo.save(song).await } - pub async fn list(&self) -> Result, RepositoryError> { - self.repo.list().await + pub async fn list(&self, sort: SortField, order: SortOrder) -> Result, RepositoryError> { + self.repo.list(sort, order).await } pub async fn get(&self, id: Uuid) -> Result, RepositoryError> { @@ -46,7 +46,7 @@ impl SongSearchService { Self { search } } - pub async fn search(&self, query: &str) -> Result, domain::RepositoryError> { - self.search.search(query).await + pub async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result, domain::RepositoryError> { + self.search.search(query, sort, order).await } } diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index c7fcb18..72e426b 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -9,5 +9,5 @@ pub use chord::Chord; pub use song::{ChordPosition, LyricLine, Section, SectionKind, SongMeta, Song}; pub use song::{song_preview_chords, StoredSong, SongSummary}; pub use ports::{FetchError, ParseError, TabFetcherPort, TabParserPort, TabSource}; -pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort}; +pub use ports::{RepositoryError, SongRepositoryPort, SongSearchPort, SortField, SortOrder}; pub use transposer::{ChordTransposer, TransposeError}; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index a4bcc08..27fd8d3 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -39,6 +39,21 @@ pub trait TabParserPort: Send + Sync { use uuid::Uuid; use crate::song::{StoredSong, SongSummary}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SortField { + #[default] + Date, + Title, + Artist, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SortOrder { + #[default] + Desc, + Asc, +} + #[derive(Debug, Error)] pub enum RepositoryError { #[error("Song not found")] @@ -50,7 +65,7 @@ pub enum RepositoryError { #[async_trait] pub trait SongRepositoryPort: Send + Sync { async fn save(&self, song: &Song) -> Result; - async fn list(&self) -> Result, RepositoryError>; + async fn list(&self, sort: SortField, order: SortOrder) -> Result, RepositoryError>; async fn get(&self, id: Uuid) -> Result, RepositoryError>; async fn delete(&self, id: Uuid) -> Result<(), RepositoryError>; async fn update_meta( @@ -64,5 +79,5 @@ pub trait SongRepositoryPort: Send + Sync { #[async_trait] pub trait SongSearchPort: Send + Sync { - async fn search(&self, query: &str) -> Result, RepositoryError>; + async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result, RepositoryError>; } diff --git a/crates/infrastructure/persistence/src/lib.rs b/crates/infrastructure/persistence/src/lib.rs index 74faed7..b302433 100644 --- a/crates/infrastructure/persistence/src/lib.rs +++ b/crates/infrastructure/persistence/src/lib.rs @@ -1,11 +1,23 @@ use async_trait::async_trait; use domain::{ RepositoryError, Song, SongMeta, SongRepositoryPort, SongSearchPort, SongSummary, StoredSong, + SortField, SortOrder, song_preview_chords, }; use sqlx::SqlitePool; use uuid::Uuid; +fn sort_clause(field: SortField, order: SortOrder) -> &'static str { + match (field, order) { + (SortField::Title, SortOrder::Asc) => "ORDER BY title ASC", + (SortField::Title, SortOrder::Desc) => "ORDER BY title DESC", + (SortField::Artist, SortOrder::Asc) => "ORDER BY artist ASC", + (SortField::Artist, SortOrder::Desc) => "ORDER BY artist DESC", + (SortField::Date, SortOrder::Asc) => "ORDER BY created_at ASC", + (SortField::Date, SortOrder::Desc) => "ORDER BY created_at DESC", + } +} + #[derive(Clone)] pub struct SqliteSongRepository { pool: SqlitePool, @@ -57,13 +69,15 @@ impl SongRepositoryPort for SqliteSongRepository { Ok(StoredSong { id, song: song.clone() }) } - async fn list(&self) -> Result, RepositoryError> { - let rows = sqlx::query_as::<_, SongRow>( - "SELECT id, title, artist, original_key, preview_chords, body FROM songs ORDER BY created_at DESC" - ) - .fetch_all(&self.pool) - .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + async fn list(&self, sort: SortField, order: SortOrder) -> Result, RepositoryError> { + let sql = format!( + "SELECT id, title, artist, original_key, preview_chords, body FROM songs {}", + sort_clause(sort, order) + ); + let rows = sqlx::query_as::<_, SongRow>(&sql) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))?; rows.into_iter().map(row_to_summary).collect() } @@ -158,18 +172,20 @@ impl SongRepositoryPort for SqliteSongRepository { #[async_trait] impl SongSearchPort for SqliteSongRepository { - async fn search(&self, query: &str) -> Result, RepositoryError> { + async fn search(&self, query: &str, sort: SortField, order: SortOrder) -> Result, RepositoryError> { let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); let pattern = format!("%{}%", escaped); - let rows = sqlx::query_as::<_, SongRow>( + let sql = format!( "SELECT id, title, artist, original_key, preview_chords, body FROM songs \ - WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') ORDER BY created_at DESC" - ) - .bind(&pattern) - .bind(&pattern) - .fetch_all(&self.pool) - .await - .map_err(|e| RepositoryError::Internal(e.to_string()))?; + WHERE (title LIKE ? ESCAPE '\\' OR artist LIKE ? ESCAPE '\\') {}", + sort_clause(sort, order) + ); + let rows = sqlx::query_as::<_, SongRow>(&sql) + .bind(&pattern) + .bind(&pattern) + .fetch_all(&self.pool) + .await + .map_err(|e| RepositoryError::Internal(e.to_string()))?; rows.into_iter().map(row_to_summary).collect() }