diff --git a/src/api/mod.rs b/src/api/mod.rs index 541a76f..1e74aaf 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -50,4 +50,5 @@ pub fn api_router() -> Router { // quotes .route("/api/quotes", post(quotes::create)) .route("/api/quotes/{id}", get(quotes::get_by_id)) + .route("/api/quotes/search", get(quotes::get_by_query)) } diff --git a/src/api/quotes.rs b/src/api/quotes.rs index 708becc..f22d257 100644 --- a/src/api/quotes.rs +++ b/src/api/quotes.rs @@ -30,6 +30,16 @@ pub async fn get_by_id( Ok(Json(Quote::get_by_id(&mut conn, id).await?).into_response()) } +pub async fn get_by_query( + State(state): State, + headers: HeaderMap, + Json(q): Json, +) -> Result { + let mut conn = state.pool.acquire().await?; + User::authenticate(&mut conn, &headers).await?.required()?; + Ok(Json(Quote::get_by_search_query(&mut conn, &q, 20, 0).await?).into_response()) +} + #[derive(Deserialize)] pub struct QuoteLineForm { pub content: String, diff --git a/src/database/migrations/0001_init.sql b/src/database/migrations/0001_init.sql index 54ce564..02ac9b0 100644 --- a/src/database/migrations/0001_init.sql +++ b/src/database/migrations/0001_init.sql @@ -1,4 +1,6 @@ CREATE EXTENSION citext; +CREATE EXTENSION pg_trgm; + CREATE TABLE users( id UUID NOT NULL PRIMARY KEY, handle CITEXT NOT NULL UNIQUE, @@ -35,9 +37,10 @@ CREATE TABLE quotes ( context TEXT DEFAULT NULL, created_by UUID REFERENCES users(id), public BOOLEAN DEFAULT FALSE, - fts TSVECTOR NOT NULL DEFAULT ''::tsvector -- TODO: trigger?? + fts TEXT NOT NULL DEFAULT '' ); CREATE INDEX quotes_by_creation_user ON quotes(created_by); +CREATE INDEX quotes_fts_trgm_idx ON quotes USING gin (fts gin_trgm_ops); CREATE TABLE persons ( id UUID NOT NULL PRIMARY KEY, bio TEXT DEFAULT NULL, @@ -96,3 +99,36 @@ CREATE TABLE logs ( ); CREATE INDEX logs_by_actor ON logs(actor); CREATE INDEX logs_by_target ON logs(target); + +CREATE OR REPLACE FUNCTION update_quote_fts_from_lines() +RETURNS TRIGGER AS $$ +DECLARE + affected_quote_id UUID; + quote_lines_content TEXT; +BEGIN + IF TG_OP = 'DELETE' THEN + affected_quote_id := OLD.quote_id; + ELSE + affected_quote_id := NEW.quote_id; + END IF; + + SELECT string_agg(content, ' ' ORDER BY ordering) + INTO quote_lines_content + FROM lines + WHERE quote_id = affected_quote_id; + + UPDATE quotes + SET fts = + COALESCE(quote_lines_content, '') || ' ' || + COALESCE(context, '') || ' ' || + COALESCE(location, '') + WHERE id = affected_quote_id; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_lines_update_quote_fts + AFTER INSERT OR UPDATE OR DELETE ON lines + FOR EACH ROW + EXECUTE FUNCTION update_quote_fts_from_lines(); diff --git a/src/quotes/mod.rs b/src/quotes/mod.rs index 434a0a3..234286d 100644 --- a/src/quotes/mod.rs +++ b/src/quotes/mod.rs @@ -174,6 +174,29 @@ impl Quote { Ok(quotes) } + pub async fn get_by_search_query( + conn: &mut PgConnection, + query: &str, + offset: i64, + limit: i64, + ) -> Result, QuoteError> { + let ids: Vec = sqlx::query_scalar( + "SELECT id FROM quotes WHERE fts ILIKE '%' || $1 || '%' LIMIT $2 OFFSET $3", + ) + .bind(query) + .bind(limit) + .bind(offset) + .fetch_all(&mut *conn) + .await?; + + let mut quotes = Vec::with_capacity(ids.len()); + for id in ids { + quotes.push(Self::get_by_id(&mut *conn, id).await?); + } + + Ok(quotes) + } + pub async fn create( conn: &mut PgConnection, lines: Vec<(String, Vec)>, diff --git a/src/web/pages/quotes.rs b/src/web/pages/quotes.rs index 2c4452e..b4a2323 100644 --- a/src/web/pages/quotes.rs +++ b/src/web/pages/quotes.rs @@ -22,6 +22,7 @@ pub mod add; #[derive(Deserialize)] pub struct PageQuery { page: Option, + s: Option, } pub async fn page( @@ -39,10 +40,20 @@ pub async fn page( let per_page = 10; let offset = (page - 1) * per_page; - let quotes = Quote::get_chronological_offset(&mut *conn, offset, per_page).await?; + let search = query.s.as_deref().unwrap_or(""); + let quotes = match search { + "" => Quote::get_chronological_offset(&mut *conn, offset, per_page).await?, + _ => Quote::get_by_search_query(&mut *conn, search, offset, per_page).await?, + }; let total_quotes = Quote::total_count(&mut *conn).await?; let total_pages = (total_quotes as f64 / per_page as f64).ceil() as i64; + let s_qs = if search.is_empty() { + String::new() + } else { + format!("&s={}", search) + }; + Ok(base( "Quotes | Mnemosyne", html!( @@ -59,8 +70,10 @@ pub async fn page( span class="text-neutral-300 group-hover:text-neutral-200" {"Add quote"} } } - input class="border w-full border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-950/50 p-2 rounded" - placeholder="Search not yet implemented."; + form method="get" action="/quotes" { + input type="text" name="s" class="border w-full border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-950/50 p-2 rounded" + placeholder="Search quotes..." value={(search)}; + } div class="my-2 w-full" { p class="ml-auto w-fit text-neutral-500 text-sm flex items-center" { span class="scale-[.6]" {(PreEscaped(icons::CALENDAR_ARROW_DOWN))} @@ -74,7 +87,7 @@ pub async fn page( div class="flex justify-between items-center mt-4 text-neutral-400" { @if page > 1 { - a href=(format!("/quotes?page={}", (page - 1).max(1))) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { + a href=(format!("/quotes?page={}{}", (page - 1).max(1), s_qs)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { "Previous" } } @else { @@ -86,7 +99,7 @@ pub async fn page( } @if page < total_pages { - a href=(format!("/quotes?page={}", page + 1)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { + a href=(format!("/quotes?page={}{}", page + 1, s_qs)) class="px-4 py-2 border border-neutral-200/25 hover:border-neutral-200/45 bg-neutral-200/5 hover:bg-neutral-200/15 rounded" { "Next" } } @else {