Skip to content

Commit 3704253

Browse files
feat: add search
Added search functionality. The design is inspired by Simon Willison's search on his blog.
1 parent 894efbf commit 3704253

11 files changed

Lines changed: 302 additions & 4 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ edition = "2021"
77
anyhow = "1.0.98"
88
axum = "0.7.7"
99
axum-embed = "0.1.0"
10+
chrono = { version = "0.4.41", features = ["serde"] }
1011
dotenvy = "0.15.7"
1112
include_dir = "0.7.4"
1213
lazy_static = "1.5.0"
1314
r2d2 = "0.8.10"
1415
r2d2_sqlite = { version = "0.30.0", features = ["bundled"] }
15-
rusqlite = { version = "0.36.0", features = ["bundled"] }
16+
regex = "1.11.1"
17+
rusqlite = { version = "0.36.0", features = ["bundled", "unlock_notify"] }
1618
rust-embed = "8.5.0"
1719
serde = { version = "1.0.217", features = ["serde_derive"] }
1820
serde_json = { version = "1.0.140", features = ["raw_value"] }

src/app.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@ use tera::{Context, Tera};
88
#[derive(Debug, Clone)]
99
pub struct AppState {
1010
pub post_service: crate::services::post::PostService,
11+
pub search_service: crate::services::search::SearchService,
1112
pub tera: Tera,
1213
}
1314

1415
impl AppState {
1516
pub fn new(pool: Pool<SqliteConnectionManager>) -> Self {
1617
let tera = Self::load_templates().unwrap();
1718
AppState {
18-
post_service: crate::services::post::PostService::new(pool),
19+
post_service: crate::services::post::PostService::new(pool.clone()),
20+
search_service: crate::services::search::SearchService::new(pool),
1921
tera,
2022
}
2123
}

src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ mod rss;
55
mod services;
66

77
use crate::app::AppState;
8-
use crate::routes::{about, contact, main_page, post, posts_index, Static, WellKnown};
8+
use crate::routes::{about, contact, main_page, post, posts_index, search, Static, WellKnown};
99
use crate::rss::feed;
1010
use std::env;
1111

@@ -59,6 +59,7 @@ async fn main() {
5959

6060
let app = Router::new()
6161
.route("/", get(main_page))
62+
.route("/search", get(search))
6263
.route("/posts", get(posts_index))
6364
.route("/about", get(about))
6465
.route("/contact", get(contact))

src/routes.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::app::AppState;
1+
use crate::{app::AppState, services::search_query::SearchQuery};
22
use axum::{
33
extract::{Path, Query, State},
44
http::StatusCode,
@@ -20,6 +20,12 @@ pub async fn main_page(state: State<AppState>) -> Response {
2020
}
2121
}
2222

23+
#[derive(Debug, Deserialize)]
24+
pub struct SearchParams {
25+
q: Option<String>,
26+
page: Option<usize>,
27+
}
28+
2329
#[derive(Deserialize)]
2430
pub struct Pagination {
2531
page: Option<usize>,
@@ -73,6 +79,37 @@ pub async fn post(Path(id): Path<String>, state: State<AppState>) -> Response {
7379
}
7480
}
7581

82+
pub async fn search(Query(params): Query<SearchParams>, state: State<AppState>) -> Response {
83+
let query_str = params.q.unwrap_or_default();
84+
let page = params.page.unwrap_or(1);
85+
let per_page = 10;
86+
87+
let search_query = SearchQuery::from_raw(&query_str);
88+
match state
89+
.search_service
90+
.search(&search_query, page, per_page)
91+
.await
92+
{
93+
Ok((posts, total)) => {
94+
let mut context = Context::new();
95+
context.insert("query", &query_str);
96+
context.insert("posts", &posts);
97+
context.insert("current_page", &page);
98+
99+
let total_pages = (total as f64 / per_page as f64).ceil() as usize;
100+
context.insert("total_pages", &total_pages);
101+
context.insert("per_page", &per_page);
102+
context.insert("total_results", &total);
103+
104+
state.render("search.html", &context).unwrap()
105+
}
106+
Err(err) => {
107+
tracing::error!("Search failed: {:?}", err);
108+
StatusCode::INTERNAL_SERVER_ERROR.into_response()
109+
}
110+
}
111+
}
112+
76113
#[derive(RustEmbed, Clone)]
77114
#[folder = "static/"]
78115
pub struct Static;

src/services/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
pub mod post;
2+
pub mod search;
3+
pub mod search_query;

src/services/search.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
use super::{post::PostService, search_query::SearchQuery};
2+
use anyhow::Context;
3+
use r2d2::Pool;
4+
use r2d2_sqlite::SqliteConnectionManager;
5+
use rusqlite::Row;
6+
use tokio::task;
7+
8+
#[derive(Clone, Debug)]
9+
pub struct SearchService {
10+
pool: Pool<SqliteConnectionManager>,
11+
}
12+
13+
impl SearchService {
14+
pub fn new(pool: Pool<SqliteConnectionManager>) -> Self {
15+
Self { pool }
16+
}
17+
18+
fn row_to_post(row: &Row) -> rusqlite::Result<crate::post::Post> {
19+
PostService::row_to_post(row)
20+
}
21+
22+
pub async fn search(
23+
&self,
24+
query: &SearchQuery,
25+
page: usize,
26+
per_page: usize,
27+
) -> anyhow::Result<(Vec<crate::post::Post>, usize)> {
28+
// Create a full clone of the query data to move into the thread
29+
let owned_query = SearchQuery {
30+
text_query: query.text_query.clone(),
31+
tags: query.tags.clone(),
32+
from_date: query.from_date.clone(),
33+
to_date: query.to_date.clone(),
34+
};
35+
let offset = (page - 1) * per_page;
36+
let pool = self.pool.clone();
37+
38+
let (posts, total) = task::spawn_blocking(move || {
39+
let conn = pool.get()?;
40+
let base_query = if owned_query.text_query.is_empty() {
41+
"FROM posts".to_string()
42+
} else {
43+
"FROM posts INNER JOIN posts_fts ON posts.rowid = posts_fts.rowid".to_string()
44+
};
45+
46+
let mut conditions = vec![];
47+
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![];
48+
49+
if !owned_query.text_query.is_empty() {
50+
conditions.push("posts_fts MATCH ?".to_string());
51+
params.push(Box::new(owned_query.text_query.clone()));
52+
}
53+
54+
for tag in &owned_query.tags {
55+
conditions.push("EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE value = ?)".to_string());
56+
params.push(Box::new(tag));
57+
}
58+
59+
if let Some(date) = &owned_query.from_date {
60+
conditions.push("posts.date >= ?".to_string());
61+
params.push(Box::new(date));
62+
}
63+
if let Some(date) = &owned_query.to_date {
64+
conditions.push("posts.date <= ?".to_string());
65+
params.push(Box::new(date));
66+
}
67+
68+
let where_clause = if !conditions.is_empty() {
69+
format!("WHERE {}", conditions.join(" AND "))
70+
} else {
71+
"".to_string()
72+
};
73+
74+
let order_clause = if owned_query.text_query.is_empty() {
75+
"ORDER BY date DESC".to_string()
76+
} else {
77+
"ORDER BY rank".to_string()
78+
};
79+
80+
// Prepare count query first (borrows params immutably)
81+
let count_query = format!(
82+
"SELECT COUNT(*)
83+
{}
84+
{}",
85+
base_query, where_clause
86+
);
87+
let total: i64 = conn.query_row(
88+
&count_query,
89+
rusqlite::params_from_iter(params.iter().map(|p| &**p)),
90+
|r| r.get(0),
91+
)?;
92+
93+
// Main query to fetch posts (takes ownership of params)
94+
let posts_query = format!(
95+
"SELECT posts.*
96+
{}
97+
{}
98+
{}
99+
LIMIT ? OFFSET ?",
100+
base_query, where_clause, order_clause
101+
);
102+
103+
let mut stmt = conn.prepare(&posts_query)?;
104+
params.push(Box::new(per_page as i64));
105+
params.push(Box::new(offset as i64));
106+
107+
// Execute query and collect results
108+
let iter = stmt.query_map(
109+
rusqlite::params_from_iter(params.iter().map(|p| &**p)),
110+
Self::row_to_post,
111+
)?;
112+
let mut posts = Vec::new();
113+
for post in iter {
114+
posts.push(post?);
115+
}
116+
117+
Ok::<_, anyhow::Error>((posts, total as usize))
118+
})
119+
.await?
120+
.context("Search execution failed")?;
121+
122+
let post_service = PostService::new(self.pool.clone());
123+
let posts_with_commits = post_service.bulk_convert_to_posts(posts).await?;
124+
125+
Ok((posts_with_commits, total))
126+
}
127+
}

src/services/search_query.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
use regex::Regex;
2+
3+
#[derive(Debug, Default)]
4+
pub struct SearchQuery {
5+
pub text_query: String,
6+
pub tags: Vec<String>,
7+
pub from_date: Option<String>,
8+
pub to_date: Option<String>,
9+
}
10+
11+
impl SearchQuery {
12+
pub fn from_raw(raw: &str) -> Self {
13+
let mut result = SearchQuery::default();
14+
let tag_re = Regex::new(r"tag:([^\s]+)").unwrap();
15+
let date_re = Regex::new(r"(from|to):(\d{4}-\d{2}-\d{2})").unwrap();
16+
17+
// Extract tags
18+
for cap in tag_re.captures_iter(raw) {
19+
if let Some(tag) = cap.get(1).map(|m| m.as_str().to_string()) {
20+
result.tags.push(tag);
21+
}
22+
}
23+
24+
// Extract dates
25+
for cap in date_re.captures_iter(raw) {
26+
if let (Some(typ), Some(date)) = (cap.get(1), cap.get(2)) {
27+
match typ.as_str() {
28+
"from" => result.from_date = Some(date.as_str().to_string()),
29+
"to" => result.to_date = Some(date.as_str().to_string()),
30+
_ => (),
31+
}
32+
}
33+
}
34+
35+
// Clean text query
36+
result.text_query = tag_re.replace_all(raw, "").to_string();
37+
result.text_query = date_re.replace_all(&result.text_query, "").to_string();
38+
result.text_query = result.text_query.trim().to_string();
39+
40+
result
41+
}
42+
}

static/css/style.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ header {
2020
box-sizing: border-box;
2121
}
2222

23+
/* Special case adjustment */
24+
form:not(.nav-search-form) .search-box {
25+
max-width: 400px;
26+
}
27+
2328
/* Use a clean sans-serif for headings */
2429
h1, h2, h3 {
2530
font-family: 'Lato', sans-serif;
@@ -168,12 +173,39 @@ footer p {
168173
margin: 0; /* Remove margin from footer text */
169174
}
170175

176+
171177
/* Style for permalinks */
172178
p > a[href^="/post/"] {
173179
font-size: 0.9em;
174180
color: #666;
175181
}
176182

183+
/* Search Form Styles */
184+
.search-box {
185+
padding: 12px 15px;
186+
border: 1px solid #ddd;
187+
border-radius: 30px;
188+
font-size: 16px;
189+
width: 100%;
190+
max-width: 100%;
191+
margin: 15px 0;
192+
background: #f5f5f5;
193+
}
194+
195+
.search-btn {
196+
background-color: #0056b3;
197+
color: white;
198+
border: none;
199+
padding: 8px 16px;
200+
border-radius: 4px;
201+
cursor: pointer;
202+
font-size: 16px;
203+
}
204+
205+
.search-btn:hover {
206+
background-color: #004494;
207+
}
208+
177209
/* Pagination Styles */
178210
.pagination {
179211
text-align: center;

templates/base.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ <h1><a href="/">Jonathan's Blog</a></h1>
2727
<a href="/posts">Archive</a>
2828
<a href="/about">About</a>
2929
<a href="/contact">Contact</a>
30+
<a href="/search">Search</a>
3031
</nav>
3132
</header>
3233
{% block content %} {% endblock %}

0 commit comments

Comments
 (0)