Skip to content

Commit

Permalink
Issue #183 : Add keyword pages
Browse files Browse the repository at this point in the history
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
  • Loading branch information
CrabeDeFrance committed Dec 4, 2023
1 parent 4f94ae9 commit f3b4299
Show file tree
Hide file tree
Showing 10 changed files with 1,276 additions and 1 deletion.
146 changes: 146 additions & 0 deletions crates/alexandrie/src/frontend/keywords.rs
Original file line number Diff line number Diff line change
@@ -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<NonZeroU32>,
}

fn paginated_url(keyword: &str, page_number: u32, page_count: u32) -> Option<String> {
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<Arc<AppState>>,
Query(params): Query<QueryParams>,
Path(keyword): Path<String>,
user: Option<Auth>,
) -> Result<Either<Html<String>, 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::<i64>(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::<i64>(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::<String>(conn)?;

let keyword_crate: Crate = crates::table
.filter(crates::id.eq(crate_ids))
.first(conn)?;

Ok((keyword_crate, keywords))
})
.collect::<Result<Vec<(Crate, Vec<String>)>, 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::<Result<Vec<_>, Error>>()?,
});
Ok(Either::E1(Html(
engine.render("keywords", &context).unwrap(),
)))
});

transaction.await
}
82 changes: 82 additions & 0 deletions crates/alexandrie/src/frontend/keywords_index.rs
Original file line number Diff line number Diff line change
@@ -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<Arc<AppState>>,
user: Option<Auth>,
) -> Result<Either<Html<String>, 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::<i64>(conn)?;

let keywords = keywords::table
.select(keywords::name)
.load::<String>(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::<i64>(conn)?
.into_iter()
.map(|crate_ids| {
let keyword_crate: Crate = crates::table
.filter(crates::id.eq(crate_ids))
.first(conn)?;
Ok(keyword_crate)
})
.collect::<Result<Vec<_>, 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::<i64>(conn)?;
Ok(json!({
"name":&keyword,
"crates": crates,
"count": count
}))}).collect::<Result<Vec<_>, Error>>()?,
});
Ok(Either::E1(Html(
engine.render("keywords_index", &context).unwrap(),
)))
});

transaction.await
}
123 changes: 123 additions & 0 deletions crates/alexandrie/src/frontend/keywords_search.rs
Original file line number Diff line number Diff line change
@@ -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<NonZeroU32>,
}

fn paginated_url(query: &str, page_number: u32, page_count: u32) -> Option<String> {
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<Arc<AppState>>,
Query(params): Query<QueryParams>,
user: Option<Auth>,
) -> Result<Either<Html<String>, 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::<i64>(conn)?;

//? Get the search results for the given page number.
let results: Vec<Keyword> = 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::<String>(conn)?;
Ok((result.name, crates))
})
.collect::<Result<Vec<(String, Vec<String>)>, 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(&params.q, page_number + 1, page_count);
let prev_page = paginated_url(&params.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::<Result<Vec<_>, Error>>()?,
});
Ok(Either::E1(Html(
engine.render("keywords_search", &context).unwrap(),
)))
});

transaction.await
}
9 changes: 9 additions & 0 deletions crates/alexandrie/src/frontend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,12 @@ pub mod me;
pub mod most_downloaded;
/// Search pages (eg. "/search?q=\<term\>").
pub mod search;

/// Keywords index page (eq. "/keywords").
pub mod keywords_index;

/// Keywords page (eq. "/keywords/\<name\>").
pub mod keywords;

/// Keywords search page (eq. "/keywords_search?q=\<term\>").
pub mod keywords_search;
3 changes: 3 additions & 0 deletions crates/alexandrie/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ fn frontend_routes(state: Arc<AppState>, 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),
Expand Down
2 changes: 1 addition & 1 deletion templates/crate.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@
{{#if keywords}}
<div class="keywords-container">
{{#each keywords}}
<div class="keyword">#{{ this.name }}</div>
<div class="keyword"><a href="/keywords/{{ this.name }}">#{{ this.name }}</a></div>
{{/each}}
</div>
{{/if}}
Expand Down
Loading

0 comments on commit f3b4299

Please sign in to comment.