1 parent
96a32ea8fb
commit
a643e7e3ab
106 files changed
+2223
-4669
No files matched your search
+21
-80
@@ -11,11 +11,6 @@ use crate::db;
|
||||
use crate::error::AppError;
|
||||
use crate::search::engine::{SearchEngine, SearchQuery};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search query parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Intermediate result of parsing structured operators out of a query string.
|
||||
struct ParsedQuery {
|
||||
text: String,
|
||||
subject_only: Option<String>,
|
||||
@@ -29,11 +24,6 @@ struct ParsedQuery {
|
||||
flagged: Option<bool>,
|
||||
}
|
||||
|
||||
/// Parse a search query string extracting structured operators like `from:`,
|
||||
/// `to:`, `subject:`, `in:`/`folder:`, `date:`, `after:`, `before:`, and
|
||||
/// `has:attachment`. Remaining text becomes the free-text query.
|
||||
///
|
||||
/// Supports quoted values: `from:"John Doe"`, `subject:"meeting notes"`.
|
||||
fn parse_search_query(input: &str) -> ParsedQuery {
|
||||
let mut text_parts: Vec<String> = Vec::new();
|
||||
let mut subject_only: Option<String> = None;
|
||||
@@ -88,7 +78,6 @@ fn parse_search_query(input: &str) -> ParsedQuery {
|
||||
flagged = Some(false)
|
||||
}
|
||||
_ => {
|
||||
// Unknown operator, treat as plain text
|
||||
text_parts.push(token);
|
||||
}
|
||||
}
|
||||
@@ -111,9 +100,6 @@ fn parse_search_query(input: &str) -> ParsedQuery {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tokenize a query string, respecting quoted values attached to operators.
|
||||
/// For example: `from:"John Doe" hello world` yields
|
||||
/// `["from:John Doe", "hello", "world"]`.
|
||||
fn tokenize_query(input: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = input.chars().peekable();
|
||||
@@ -126,11 +112,10 @@ fn tokenize_query(input: &str) -> Vec<String> {
|
||||
}
|
||||
chars.next();
|
||||
} else if ch == '"' {
|
||||
// Start of a quoted section
|
||||
chars.next(); // consume opening quote
|
||||
chars.next();
|
||||
while let Some(&qch) = chars.peek() {
|
||||
if qch == '"' {
|
||||
chars.next(); // consume closing quote
|
||||
chars.next();
|
||||
break;
|
||||
}
|
||||
current.push(qch);
|
||||
@@ -149,12 +134,11 @@ fn tokenize_query(input: &str) -> Vec<String> {
|
||||
tokens
|
||||
}
|
||||
|
||||
/// Split a token into (operator, value) if it matches `operator:value` pattern.
|
||||
fn split_operator(token: &str) -> Option<(String, String)> {
|
||||
if let Some(colon_pos) = token.find(':') {
|
||||
let op = &token[..colon_pos];
|
||||
let val = &token[colon_pos + 1..];
|
||||
// Only treat as operator if the operator part is alphabetic and value is non-empty
|
||||
|
||||
if !op.is_empty() && op.chars().all(|c| c.is_ascii_alphabetic()) && !val.is_empty() {
|
||||
return Some((op.to_string(), val.to_string()));
|
||||
}
|
||||
@@ -162,15 +146,12 @@ fn split_operator(token: &str) -> Option<(String, String)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse a `YYYY-MM-DD` date string into a start-of-day and end-of-day epoch
|
||||
/// (UTC) pair.
|
||||
fn parse_date_range(date_str: &str) -> Option<(i64, i64)> {
|
||||
let start = parse_date_start(date_str)?;
|
||||
let end = start + 86_400 - 1; // end of day
|
||||
let end = start + 86_400 - 1;
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// Parse a `YYYY-MM-DD` date string into a start-of-day epoch (UTC).
|
||||
fn parse_date_start(date_str: &str) -> Option<i64> {
|
||||
let parts: Vec<&str> = date_str.split('-').collect();
|
||||
if parts.len() != 3 {
|
||||
@@ -184,16 +165,12 @@ fn parse_date_start(date_str: &str) -> Option<i64> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Convert to Unix epoch using a simplified calculation (UTC).
|
||||
// Days from year 1970 to the given date.
|
||||
let mut total_days: i64 = 0;
|
||||
|
||||
// Days from full years
|
||||
for y in 1970..year {
|
||||
total_days += if is_leap_year(y) { 366 } else { 365 };
|
||||
}
|
||||
|
||||
// Days from full months in the target year
|
||||
let days_in_months = [
|
||||
31,
|
||||
28 + i64::from(is_leap_year(year)),
|
||||
@@ -221,44 +198,37 @@ fn is_leap_year(y: i64) -> bool {
|
||||
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query parameters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Query parameters for `GET /api/search`.
|
||||
#[derive(Deserialize)]
|
||||
pub struct SearchParams {
|
||||
/// The search text (required, must not be empty).
|
||||
pub q: Option<String>,
|
||||
/// Optional folder filter.
|
||||
|
||||
pub folder: Option<String>,
|
||||
/// Optional subject filter.
|
||||
|
||||
pub subject: Option<String>,
|
||||
/// Optional from address filter.
|
||||
|
||||
pub from: Option<String>,
|
||||
/// Optional to address filter.
|
||||
|
||||
pub to: Option<String>,
|
||||
/// Optional date range start (Unix epoch seconds).
|
||||
|
||||
pub date_from: Option<i64>,
|
||||
/// Optional date range end (Unix epoch seconds).
|
||||
|
||||
pub date_to: Option<i64>,
|
||||
/// Optional attachment filter.
|
||||
|
||||
pub has_attachment: Option<bool>,
|
||||
/// Optional unread/read filter.
|
||||
|
||||
pub unread: Option<bool>,
|
||||
/// Optional flagged/unflagged filter.
|
||||
|
||||
pub flagged: Option<bool>,
|
||||
/// Maximum number of results (default 50).
|
||||
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: usize,
|
||||
/// Offset for pagination (default 0).
|
||||
|
||||
#[serde(default)]
|
||||
pub offset: usize,
|
||||
/// Sort order: "date_desc" (default) or "date_asc".
|
||||
|
||||
#[serde(default = "default_sort")]
|
||||
pub sort: String,
|
||||
/// Materialize every matching cached result. Intended for explicit
|
||||
/// "select all search results" bulk actions, not normal rendering.
|
||||
|
||||
#[serde(default)]
|
||||
pub all: bool,
|
||||
}
|
||||
@@ -271,11 +241,6 @@ fn default_limit() -> usize {
|
||||
10_000
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Response envelope for `GET /api/search`.
|
||||
#[derive(Serialize)]
|
||||
pub struct SearchResponse {
|
||||
pub results: Vec<SearchResultItem>,
|
||||
@@ -283,7 +248,6 @@ pub struct SearchResponse {
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
/// A single search result item enriched with message metadata from SQLite.
|
||||
#[derive(Serialize)]
|
||||
pub struct SearchResultItem {
|
||||
pub uid: u32,
|
||||
@@ -299,21 +263,12 @@ pub struct SearchResultItem {
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `GET /api/search?q=text&folder=INBOX&from=alice&date_from=...&date_to=...&has_attachment=true&limit=50&offset=0`
|
||||
///
|
||||
/// Searches the user's messages using both SQLite (for header fields) and
|
||||
/// Tantivy (for body text). Results are merged and deduplicated.
|
||||
pub async fn search_messages(
|
||||
Extension(session): Extension<SessionState>,
|
||||
Extension(config): Extension<Arc<AppConfig>>,
|
||||
Extension(search_engine): Extension<Arc<SearchEngine>>,
|
||||
Query(params): Query<SearchParams>,
|
||||
) -> Result<Response, AppError> {
|
||||
// Validate that `q` is provided and non-empty.
|
||||
let query_text = params.q.as_deref().unwrap_or("").trim().to_string();
|
||||
|
||||
let has_direct_filters = params.folder.is_some()
|
||||
@@ -336,7 +291,6 @@ pub async fn search_messages(
|
||||
let requested_limit = params.limit.clamp(1, 10_000);
|
||||
let search_offset = params.offset;
|
||||
|
||||
// Parse structured operators from the query string.
|
||||
let parsed = parse_search_query(&query_text);
|
||||
|
||||
let folder = params.folder.or(parsed.folder);
|
||||
@@ -349,7 +303,6 @@ pub async fn search_messages(
|
||||
let unread = params.unread.or(parsed.unread);
|
||||
let flagged = params.flagged.or(parsed.flagged);
|
||||
|
||||
// Run SQLite queries in spawn_blocking to avoid blocking the Tokio executor.
|
||||
let data_dir = config.data_dir.clone();
|
||||
let user_hash = session.user_hash.clone();
|
||||
let text_clone = parsed.text.clone();
|
||||
@@ -385,7 +338,6 @@ pub async fn search_messages(
|
||||
.map_err(|e| AppError::InternalError(format!("Task error: {e}")))?
|
||||
.map_err(|e| AppError::InternalError(format!("Search error: {e}")))?;
|
||||
|
||||
// Collect SQLite results as SearchResultItems.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut results: Vec<SearchResultItem> = sqlite_results
|
||||
.into_iter()
|
||||
@@ -407,8 +359,6 @@ pub async fn search_messages(
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Secondary search: Tantivy for body text matches not found by SQLite.
|
||||
// Only add results up to the limit cap.
|
||||
if !parsed.text.is_empty()
|
||||
&& results.len() < limit
|
||||
&& let Ok(user_index) = search_engine.open_user_index(&session.user_hash)
|
||||
@@ -425,7 +375,6 @@ pub async fn search_messages(
|
||||
offset: if select_all { 0 } else { search_offset },
|
||||
})
|
||||
{
|
||||
// Resolve Tantivy UIDs via SQLite in spawn_blocking.
|
||||
let data_dir2 = config.data_dir.clone();
|
||||
let user_hash2 = session.user_hash.clone();
|
||||
let remaining = limit.saturating_sub(results.len());
|
||||
@@ -480,7 +429,6 @@ pub async fn search_messages(
|
||||
}
|
||||
}
|
||||
|
||||
// Sort results by date.
|
||||
let sort_asc = sort_order == "date_asc";
|
||||
results.sort_by(|a, b| {
|
||||
let da = crate::db::messages::parse_date_to_epoch_public(&a.date);
|
||||
@@ -492,7 +440,6 @@ pub async fn search_messages(
|
||||
}
|
||||
});
|
||||
|
||||
// Cap to requested limit after sorting.
|
||||
results.truncate(limit);
|
||||
|
||||
Ok(Json(SearchResponse {
|
||||
@@ -566,10 +513,6 @@ pub async fn delete_saved_search(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -628,7 +571,7 @@ mod tests {
|
||||
assert_eq!(p.text, "news");
|
||||
assert!(p.date_from.is_some());
|
||||
assert!(p.date_to.is_some());
|
||||
// 2024-01-15 00:00:00 UTC = 1705276800
|
||||
|
||||
assert_eq!(p.date_from, Some(1_705_276_800));
|
||||
assert_eq!(p.date_to, Some(1_705_276_800 + 86_399));
|
||||
}
|
||||
@@ -637,9 +580,9 @@ mod tests {
|
||||
fn parse_after_before() {
|
||||
let p = parse_search_query("after:2024-01-01 before:2024-06-01 hello");
|
||||
assert_eq!(p.text, "hello");
|
||||
// 2024-01-01 = 1704067200
|
||||
|
||||
assert_eq!(p.date_from, Some(1_704_067_200));
|
||||
// 2024-06-01 = 1717200000
|
||||
|
||||
assert_eq!(p.date_to, Some(1_717_200_000));
|
||||
}
|
||||
|
||||
@@ -688,7 +631,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_empty_value_not_operator() {
|
||||
// "from:" with no value should not be treated as an operator
|
||||
let p = parse_search_query("hello from:");
|
||||
assert_eq!(p.text, "hello from:");
|
||||
assert!(p.from.is_none());
|
||||
@@ -696,9 +638,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn date_parsing_basic() {
|
||||
// 2024-01-01 00:00:00 UTC
|
||||
assert_eq!(parse_date_start("2024-01-01"), Some(1_704_067_200));
|
||||
// Invalid date
|
||||
|
||||
assert_eq!(parse_date_start("not-a-date"), None);
|
||||
assert_eq!(parse_date_start("2024-13-01"), None);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user