From f3b429926b619eba62272a0840ddf5ffb0f0169f Mon Sep 17 00:00:00 2001 From: CrabeDeFrance Date: Sun, 3 Dec 2023 17:42:40 +0100 Subject: [PATCH 1/2] Issue #183 : Add keyword pages Add new pages to list all keywords and search for a given keyword It is possible to go here using the "Keyword" button or using links in each crate's view --- crates/alexandrie/src/frontend/keywords.rs | 146 ++++++++ .../alexandrie/src/frontend/keywords_index.rs | 82 +++++ .../src/frontend/keywords_search.rs | 123 +++++++ crates/alexandrie/src/frontend/mod.rs | 9 + crates/alexandrie/src/main.rs | 3 + templates/crate.hbs | 2 +- templates/keywords.hbs | 313 ++++++++++++++++++ templates/keywords_index.hbs | 290 ++++++++++++++++ templates/keywords_search.hbs | 303 +++++++++++++++++ templates/partials/navbar.hbs | 6 + 10 files changed, 1276 insertions(+), 1 deletion(-) create mode 100644 crates/alexandrie/src/frontend/keywords.rs create mode 100644 crates/alexandrie/src/frontend/keywords_index.rs create mode 100644 crates/alexandrie/src/frontend/keywords_search.rs create mode 100644 templates/keywords.hbs create mode 100644 templates/keywords_index.hbs create mode 100644 templates/keywords_search.hbs diff --git a/crates/alexandrie/src/frontend/keywords.rs b/crates/alexandrie/src/frontend/keywords.rs new file mode 100644 index 00000000..fa1c2c1c --- /dev/null +++ b/crates/alexandrie/src/frontend/keywords.rs @@ -0,0 +1,146 @@ +use std::num::NonZeroU32; +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::response::Redirect; +use axum_extra::either::Either; +use axum_extra::response::Html; +use diesel::dsl as sql; +use diesel::prelude::*; + +use json::json; +use serde::{Deserialize, Serialize}; + +use alexandrie_index::Indexer; + +use crate::config::AppState; +use crate::db::models::Crate; +use crate::db::schema::*; +use crate::db::DATETIME_FORMAT; +use crate::error::{Error, FrontendError}; +use crate::frontend::helpers; +use crate::utils::auth::frontend::Auth; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct QueryParams { + pub page: Option, +} + +fn paginated_url(keyword: &str, page_number: u32, page_count: u32) -> Option { + if page_number >= 1 && page_number <= page_count { + Some(format!("/keywords/{0}?page={1}", keyword, page_number)) + } else { + None + } +} + +pub(crate) async fn get( + State(state): State>, + Query(params): Query, + Path(keyword): Path, + user: Option, +) -> Result, Redirect>, FrontendError> { + let page_number = params.page.map_or_else(|| 1, |page| page.get()); + + if state.is_login_required() && user.is_none() { + return Ok(Either::E2(Redirect::to("/account/login"))); + } + + let db = &state.db; + let state = Arc::clone(&state); + + let transaction = db.transaction(move |conn| { + + //? Get the total count of search results. + let total_results = crate_keywords::table + .inner_join(keywords::table) + .inner_join(crates::table) + .filter(keywords::name.eq(&keyword)) + .select(sql::count(crates::id)) + .first::(conn)?; + + //? Get the search results for the given page number. + //? First get all ids of crates with given keywords + let results = crate_keywords::table + .inner_join(keywords::table) + .inner_join(crates::table) + .filter(keywords::name.eq(&keyword)) + .select(crates::id) + .order_by(crates::downloads.desc()) + .limit(15) + .offset(15 * i64::from(page_number - 1)) + .load::(conn)?; + + let results = results + .into_iter() + .map(|crate_ids| { + let keywords = crate_keywords::table + .inner_join(keywords::table) + .select(keywords::name) + .filter(crate_keywords::crate_id.eq(crate_ids)) + .load::(conn)?; + + let keyword_crate: Crate = crates::table + .filter(crates::id.eq(crate_ids)) + .first(conn)?; + + Ok((keyword_crate, keywords)) + }) + .collect::)>, Error>>()?; + + //? Make page number starts counting from 1 (instead of 0). + let page_count = (total_results / 15 + + if total_results > 0 && total_results % 15 == 0 { + 0 + } else { + 1 + }) as u32; + + let next_page = paginated_url(&keyword, page_number + 1, page_count); + let prev_page = paginated_url(&keyword, page_number - 1, page_count); + + let auth = &state.frontend.config.auth; + let engine = &state.frontend.handlebars; + let context = json!({ + "auth_disabled": !auth.enabled(), + "registration_disabled": !auth.allow_registration(), + "name": keyword, + "user": user.map(|it| it.into_inner()), + "instance": &state.frontend.config, + "total_results": total_results, + "pagination": { + "current": page_number, + "total_count": page_count, + "next": next_page, + "prev": prev_page, + }, + "results": results.into_iter().map(|(krate, keywords)| { + let record = state.index.latest_record(&krate.name)?; + let created_at = + chrono::NaiveDateTime::parse_from_str(krate.created_at.as_str(), DATETIME_FORMAT) + .unwrap(); + let updated_at = + chrono::NaiveDateTime::parse_from_str(krate.updated_at.as_str(), DATETIME_FORMAT) + .unwrap(); + Ok(json!({ + "id": krate.id, + "name": krate.name, + "version": record.vers, + "description": krate.description, + "created_at": helpers::humanize_datetime(created_at), + "updated_at": helpers::humanize_datetime(updated_at), + "downloads": helpers::humanize_number(krate.downloads), + "documentation": krate.documentation, + "repository": krate.repository, + "keywords": keywords, + "yanked": record.yanked, + })) + }).collect::, Error>>()?, + }); + Ok(Either::E1(Html( + engine.render("keywords", &context).unwrap(), + ))) + }); + + transaction.await +} diff --git a/crates/alexandrie/src/frontend/keywords_index.rs b/crates/alexandrie/src/frontend/keywords_index.rs new file mode 100644 index 00000000..a72d9ebf --- /dev/null +++ b/crates/alexandrie/src/frontend/keywords_index.rs @@ -0,0 +1,82 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::response::Redirect; +use axum_extra::either::Either; +use axum_extra::response::Html; +use diesel::dsl as sql; +use diesel::prelude::*; + +use json::json; + +use crate::config::AppState; +use crate::db::models::Crate; +use crate::db::schema::*; +use crate::error::{Error, FrontendError}; +use crate::utils::auth::frontend::Auth; + +pub(crate) async fn get( + State(state): State>, + user: Option, +) -> Result, Redirect>, FrontendError> { + if state.is_login_required() && user.is_none() { + return Ok(Either::E2(Redirect::to("/account/login"))); + } + + let db = &state.db; + let state = Arc::clone(&state); + + let transaction = db.transaction(move |conn| { + //? Get the total count of search results. + let total_results = keywords::table + .select(sql::count(keywords::id)) + .first::(conn)?; + + let keywords = keywords::table + .select(keywords::name) + .load::(conn)?; + + let auth = &state.frontend.config.auth; + let engine = &state.frontend.handlebars; + let context = json!({ + "auth_disabled": !auth.enabled(), + "registration_disabled": !auth.allow_registration(), + "user": user.map(|it| it.into_inner()), + "instance": &state.frontend.config, + "total_results": total_results, + "keywords": keywords.into_iter().map(|keyword| { + let crates = crate_keywords::table + .inner_join(keywords::table) + .inner_join(crates::table) + .filter(keywords::name.eq(&keyword)) + .order_by(crates::downloads.desc()) + .select(crates::id) + .limit(10) + .load::(conn)? + .into_iter() + .map(|crate_ids| { + let keyword_crate: Crate = crates::table + .filter(crates::id.eq(crate_ids)) + .first(conn)?; + Ok(keyword_crate) + }) + .collect::, Error>>()?; + let count = crate_keywords::table + .inner_join(keywords::table) + .inner_join(crates::table) + .filter(keywords::name.eq(&keyword)) + .select(sql::count(crates::id)) + .first::(conn)?; + Ok(json!({ + "name":&keyword, + "crates": crates, + "count": count + }))}).collect::, Error>>()?, + }); + Ok(Either::E1(Html( + engine.render("keywords_index", &context).unwrap(), + ))) + }); + + transaction.await +} diff --git a/crates/alexandrie/src/frontend/keywords_search.rs b/crates/alexandrie/src/frontend/keywords_search.rs new file mode 100644 index 00000000..c7e64f24 --- /dev/null +++ b/crates/alexandrie/src/frontend/keywords_search.rs @@ -0,0 +1,123 @@ +use std::num::NonZeroU32; +use std::sync::Arc; + +use axum::extract::{Query, State}; +use axum::response::Redirect; +use axum_extra::either::Either; +use axum_extra::response::Html; +use diesel::dsl as sql; +use diesel::prelude::*; + +use json::json; +use serde::{Deserialize, Serialize}; + +use crate::config::AppState; +use crate::db::models::Keyword; +use crate::db::schema::*; +use crate::error::{Error, FrontendError}; +use crate::utils::auth::frontend::Auth; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct QueryParams { + pub q: String, + pub page: Option, +} + +fn paginated_url(query: &str, page_number: u32, page_count: u32) -> Option { + let query = query.as_bytes(); + let encoded_q = percent_encoding::percent_encode(query, percent_encoding::NON_ALPHANUMERIC); + if page_number >= 1 && page_number <= page_count { + Some(format!( + "/keywords_search?q={0}&page={1}", + encoded_q, page_number + )) + } else { + None + } +} + +pub(crate) async fn get( + State(state): State>, + Query(params): Query, + user: Option, +) -> Result, Redirect>, FrontendError> { + let page_number = params.page.map_or_else(|| 1, |page| page.get()); + let searched_text = params.q.clone(); + + if state.is_login_required() && user.is_none() { + return Ok(Either::E2(Redirect::to("/account/login"))); + } + + let db = &state.db; + let state = Arc::clone(&state); + + let transaction = db.transaction(move |conn| { + let escaped_like_query = params.q.replace('\\', "\\\\").replace('%', "\\%"); + let escaped_like_query = format!("%{escaped_like_query}%"); + + //? Get the total count of search results. + let total_results = keywords::table + .select(sql::count(keywords::id)) + .filter(keywords::name.like(escaped_like_query.as_str())) + .first::(conn)?; + + //? Get the search results for the given page number. + let results: Vec = keywords::table + .filter(keywords::name.like(escaped_like_query.as_str())) + .limit(15) + .offset(15 * i64::from(page_number - 1)) + .load(conn)?; + + let results = results + .into_iter() + .map(|result| { + let crates = crate_keywords::table + .inner_join(crates::table) + .select(crates::name) + .filter(crate_keywords::keyword_id.eq(result.id)) + .limit(5) + .load::(conn)?; + Ok((result.name, crates)) + }) + .collect::)>, Error>>()?; + + //? Make page number starts counting from 1 (instead of 0). + let page_count = (total_results / 15 + + if total_results > 0 && total_results % 15 == 0 { + 0 + } else { + 1 + }) as u32; + + let next_page = paginated_url(¶ms.q, page_number + 1, page_count); + let prev_page = paginated_url(¶ms.q, page_number - 1, page_count); + + let auth = &state.frontend.config.auth; + let engine = &state.frontend.handlebars; + let context = json!({ + "auth_disabled": !auth.enabled(), + "registration_disabled": !auth.allow_registration(), + "user": user.map(|it| it.into_inner()), + "instance": &state.frontend.config, + "searched_text": searched_text, + "total_results": total_results, + "pagination": { + "current": page_number, + "total_count": page_count, + "next": next_page, + "prev": prev_page, + }, + "results": results.into_iter().map(|(keyword, crates)| { + Ok(json!({ + "name": keyword, + "crates": crates, + })) + }).collect::, Error>>()?, + }); + Ok(Either::E1(Html( + engine.render("keywords_search", &context).unwrap(), + ))) + }); + + transaction.await +} diff --git a/crates/alexandrie/src/frontend/mod.rs b/crates/alexandrie/src/frontend/mod.rs index 78f4f0c2..a67998e0 100644 --- a/crates/alexandrie/src/frontend/mod.rs +++ b/crates/alexandrie/src/frontend/mod.rs @@ -14,3 +14,12 @@ pub mod me; pub mod most_downloaded; /// Search pages (eg. "/search?q=\"). pub mod search; + +/// Keywords index page (eq. "/keywords"). +pub mod keywords_index; + +/// Keywords page (eq. "/keywords/\"). +pub mod keywords; + +/// Keywords search page (eq. "/keywords_search?q=\"). +pub mod keywords_search; diff --git a/crates/alexandrie/src/main.rs b/crates/alexandrie/src/main.rs index 45310609..64c2e205 100644 --- a/crates/alexandrie/src/main.rs +++ b/crates/alexandrie/src/main.rs @@ -96,6 +96,9 @@ fn frontend_routes(state: Arc, frontend_config: FrontendConfig) -> Rou .route("/most-downloaded", get(frontend::most_downloaded::get)) .route("/last-updated", get(frontend::last_updated::get)) .route("/crates/:crate", get(frontend::krate::get)) + .route("/keywords", get(frontend::keywords_index::get)) + .route("/keywords/:keyword", get(frontend::keywords::get)) + .route("/keywords_search", get(frontend::keywords_search::get)) .route( "/account/login", get(frontend::account::login::get).post(frontend::account::login::post), diff --git a/templates/crate.hbs b/templates/crate.hbs index f644e9ed..26594ac6 100644 --- a/templates/crate.hbs +++ b/templates/crate.hbs @@ -248,7 +248,7 @@ {{#if keywords}}
{{#each keywords}} -
#{{ this.name }}
+ {{/each}}
{{/if}} diff --git a/templates/keywords.hbs b/templates/keywords.hbs new file mode 100644 index 00000000..28585985 --- /dev/null +++ b/templates/keywords.hbs @@ -0,0 +1,313 @@ + + + + + {{> partials/head}} + + + + + {{> partials/navbar}} +
+
+
+
# {{name}}
+
Crates with keyword {{name}}
+
+
+
+
+
+
{{ total_results }} total results
+
+
+
+ + + + diff --git a/templates/keywords_index.hbs b/templates/keywords_index.hbs new file mode 100644 index 00000000..4c008ddb --- /dev/null +++ b/templates/keywords_index.hbs @@ -0,0 +1,290 @@ + + + + + {{> partials/head}} + + + + + {{> partials/navbar}} +
+
+
+
Keywords
+
Keywords and their most downloaded crates
+
+
+
+
+
+ +
+
+
+
+ {{#if keywords}} + {{#each keywords}} +
+ + + {{#if this.crates}} +
+ {{#each this.crates}}{{#if @index}}, {{/if}}{{ this.name }}{{/each}} +
+ {{else}} +
Empty list.
+ {{/if}} + +
+ {{/each}} + {{else}} +
+
No crates...
+
+ {{/if}} +
+ + + diff --git a/templates/keywords_search.hbs b/templates/keywords_search.hbs new file mode 100644 index 00000000..31a7202c --- /dev/null +++ b/templates/keywords_search.hbs @@ -0,0 +1,303 @@ + + + + + {{> partials/head}} + + + + + {{> partials/navbar}} +
+
+
+
Keywords search results
+
Searched for: {{ searched_text }}
+
+
+
+
+
+
{{ total_results }} total results
+
+
+
+
+ {{> partials/pagination pagination}} + + {{> partials/pagination pagination}} +
+ + + diff --git a/templates/partials/navbar.hbs b/templates/partials/navbar.hbs index 1cee87b2..660cce9a 100644 --- a/templates/partials/navbar.hbs +++ b/templates/partials/navbar.hbs @@ -294,6 +294,9 @@ {{ instance.title }} + + Keywords + {{/if}} diff --git a/templates/partials/navbar.hbs b/templates/partials/navbar.hbs index 660cce9a..ac0c21dd 100644 --- a/templates/partials/navbar.hbs +++ b/templates/partials/navbar.hbs @@ -294,6 +294,9 @@ {{ instance.title }} + + Categories + Keywords @@ -333,6 +336,9 @@ {{ instance.title }} + + Categories + Keywords