feat: ship public ops features and cache docker builds
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
Some checks failed
docker-images / build-and-push (admin, admin, termi-astro-admin, admin/Dockerfile) (push) Failing after 13s
docker-images / build-and-push (frontend, frontend, termi-astro-frontend, frontend/Dockerfile) (push) Has been cancelled
docker-images / build-and-push (backend, backend, termi-astro-backend, backend/Dockerfile) (push) Has been cancelled
This commit is contained in:
@@ -274,6 +274,71 @@ fn is_preview_search(query: &SearchQuery, headers: &HeaderMap) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn normalize_search_sort_by(value: Option<&str>) -> String {
|
||||
match value
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"newest" | "created_at" => "newest".to_string(),
|
||||
"oldest" => "oldest".to_string(),
|
||||
"title" => "title".to_string(),
|
||||
_ => "relevance".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_sort_order(value: Option<&str>, sort_by: &str) -> String {
|
||||
match value
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"asc" => "asc".to_string(),
|
||||
"desc" => "desc".to_string(),
|
||||
_ if sort_by == "title" => "asc".to_string(),
|
||||
_ => "desc".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_search_results(items: &mut [SearchResult], sort_by: &str, sort_order: &str) {
|
||||
items.sort_by(|left, right| {
|
||||
let ordering = match sort_by {
|
||||
"newest" => right.created_at.cmp(&left.created_at),
|
||||
"oldest" => left.created_at.cmp(&right.created_at),
|
||||
"title" => left
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or(&left.slug)
|
||||
.to_ascii_lowercase()
|
||||
.cmp(
|
||||
&right
|
||||
.title
|
||||
.as_deref()
|
||||
.unwrap_or(&right.slug)
|
||||
.to_ascii_lowercase(),
|
||||
),
|
||||
_ => right
|
||||
.rank
|
||||
.partial_cmp(&left.rank)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| right.created_at.cmp(&left.created_at)),
|
||||
};
|
||||
|
||||
if sort_by == "relevance" || sort_by == "newest" || sort_by == "oldest" {
|
||||
return ordering;
|
||||
}
|
||||
|
||||
let ordering = if sort_order == "asc" {
|
||||
ordering
|
||||
} else {
|
||||
ordering.reverse()
|
||||
};
|
||||
ordering.then_with(|| left.slug.cmp(&right.slug))
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct SearchQuery {
|
||||
pub q: Option<String>,
|
||||
@@ -286,6 +351,17 @@ pub struct SearchQuery {
|
||||
pub preview: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct SearchPageQuery {
|
||||
#[serde(flatten)]
|
||||
pub search: SearchQuery,
|
||||
pub page: Option<u64>,
|
||||
#[serde(alias = "page_size")]
|
||||
pub page_size: Option<u64>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SearchResult {
|
||||
pub id: i32,
|
||||
@@ -296,37 +372,47 @@ pub struct SearchResult {
|
||||
pub category: Option<String>,
|
||||
pub tags: Option<Value>,
|
||||
pub post_type: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub pinned: Option<bool>,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
pub rank: f64,
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn search(
|
||||
Query(query): Query<SearchQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response> {
|
||||
let started_at = Instant::now();
|
||||
let preview_search = is_preview_search(&query, &headers);
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PagedSearchResponse {
|
||||
pub query: String,
|
||||
pub items: Vec<SearchResult>,
|
||||
pub page: u64,
|
||||
pub page_size: u64,
|
||||
pub total: usize,
|
||||
pub total_pages: u64,
|
||||
pub sort_by: String,
|
||||
pub sort_order: String,
|
||||
}
|
||||
|
||||
let q = query.q.unwrap_or_default().trim().to_string();
|
||||
async fn build_search_results(
|
||||
ctx: &AppContext,
|
||||
query: &SearchQuery,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<(String, bool, Vec<SearchResult>)> {
|
||||
let preview_search = is_preview_search(query, headers);
|
||||
content::sync_markdown_posts(ctx).await?;
|
||||
|
||||
let q = query.q.clone().unwrap_or_default().trim().to_string();
|
||||
if q.is_empty() {
|
||||
return format::json(Vec::<SearchResult>::new());
|
||||
return Ok((q, preview_search, Vec::new()));
|
||||
}
|
||||
|
||||
if !preview_search {
|
||||
abuse_guard::enforce_public_scope(
|
||||
"search",
|
||||
abuse_guard::detect_client_ip(&headers).as_deref(),
|
||||
abuse_guard::detect_client_ip(headers).as_deref(),
|
||||
Some(&q),
|
||||
)?;
|
||||
}
|
||||
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100) as usize;
|
||||
let settings = site_settings::load_current(&ctx).await.ok();
|
||||
let settings = site_settings::load_current(ctx).await.ok();
|
||||
let synonym_groups = settings
|
||||
.as_ref()
|
||||
.map(|item| parse_synonym_groups(&item.search_synonyms))
|
||||
@@ -342,7 +428,12 @@ pub async fn search(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(category) = query.category.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if let Some(category) = query
|
||||
.category
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
all_posts.retain(|post| {
|
||||
post.category
|
||||
.as_deref()
|
||||
@@ -355,7 +446,12 @@ pub async fn search(
|
||||
all_posts.retain(|post| post_has_tag(post, tag));
|
||||
}
|
||||
|
||||
if let Some(post_type) = query.post_type.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if let Some(post_type) = query
|
||||
.post_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
all_posts.retain(|post| {
|
||||
post.post_type
|
||||
.as_deref()
|
||||
@@ -378,6 +474,7 @@ pub async fn search(
|
||||
category: post.category.clone(),
|
||||
tags: post.tags.clone(),
|
||||
post_type: post.post_type.clone(),
|
||||
image: post.image.clone(),
|
||||
pinned: post.pinned,
|
||||
created_at: post.created_at.into(),
|
||||
updated_at: post.updated_at.into(),
|
||||
@@ -401,6 +498,7 @@ pub async fn search(
|
||||
category: post.category.clone(),
|
||||
tags: post.tags.clone(),
|
||||
post_type: post.post_type.clone(),
|
||||
image: post.image.clone(),
|
||||
pinned: post.pinned,
|
||||
created_at: post.created_at.into(),
|
||||
updated_at: post.updated_at.into(),
|
||||
@@ -410,13 +508,22 @@ pub async fn search(
|
||||
}
|
||||
}
|
||||
|
||||
results.sort_by(|left, right| {
|
||||
right
|
||||
.rank
|
||||
.partial_cmp(&left.rank)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| right.created_at.cmp(&left.created_at))
|
||||
});
|
||||
sort_search_results(&mut results, "relevance", "desc");
|
||||
Ok((q, preview_search, results))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn search(
|
||||
Query(query): Query<SearchQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response> {
|
||||
let started_at = Instant::now();
|
||||
let limit = query.limit.unwrap_or(20).clamp(1, 100) as usize;
|
||||
let (q, preview_search, mut results) = build_search_results(&ctx, &query, &headers).await?;
|
||||
if q.is_empty() {
|
||||
return format::json(Vec::<SearchResult>::new());
|
||||
}
|
||||
results.truncate(limit);
|
||||
|
||||
if !preview_search {
|
||||
@@ -433,6 +540,70 @@ pub async fn search(
|
||||
format::json(results)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new().prefix("api/search/").add("/", get(search))
|
||||
#[debug_handler]
|
||||
pub async fn search_page(
|
||||
Query(query): Query<SearchPageQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response> {
|
||||
let started_at = Instant::now();
|
||||
let page_size = query.page_size.unwrap_or(20).clamp(1, 100);
|
||||
let sort_by = normalize_search_sort_by(query.sort_by.as_deref());
|
||||
let sort_order = normalize_sort_order(query.sort_order.as_deref(), &sort_by);
|
||||
let (q, preview_search, mut results) = build_search_results(&ctx, &query.search, &headers).await?;
|
||||
|
||||
if q.is_empty() {
|
||||
return format::json(PagedSearchResponse {
|
||||
query: q,
|
||||
items: Vec::new(),
|
||||
page: 1,
|
||||
page_size,
|
||||
total: 0,
|
||||
total_pages: 1,
|
||||
sort_by,
|
||||
sort_order,
|
||||
});
|
||||
}
|
||||
|
||||
sort_search_results(&mut results, &sort_by, &sort_order);
|
||||
|
||||
let total = results.len();
|
||||
let total_pages = std::cmp::max(1, ((total as u64) + page_size - 1) / page_size);
|
||||
let page = query.page.unwrap_or(1).clamp(1, total_pages);
|
||||
let start = ((page - 1) * page_size) as usize;
|
||||
let end = std::cmp::min(start + page_size as usize, total);
|
||||
let items = if start >= total {
|
||||
Vec::new()
|
||||
} else {
|
||||
results[start..end].to_vec()
|
||||
};
|
||||
|
||||
if !preview_search {
|
||||
analytics::record_search_event(
|
||||
&ctx,
|
||||
&q,
|
||||
total,
|
||||
&headers,
|
||||
started_at.elapsed().as_millis() as i64,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
format::json(PagedSearchResponse {
|
||||
query: q,
|
||||
items,
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
total_pages,
|
||||
sort_by,
|
||||
sort_order,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/search/")
|
||||
.add("page", get(search_page))
|
||||
.add("/", get(search))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user