diff --git a/crates/archivr-core/src/archive.rs b/crates/archivr-core/src/archive.rs index 0e52846..7a5f18e 100644 --- a/crates/archivr-core/src/archive.rs +++ b/crates/archivr-core/src/archive.rs @@ -303,6 +303,144 @@ pub fn resolve_artifact_path( Ok(canonical_artifact) } +#[derive(Debug, Clone, Default)] +pub struct SearchEntriesQuery { + /// Free-text term: LIKE-matched against title, canonical_url, entry_uid, source_kind, entity_kind, visibility + pub q: Option, + /// Exact match on e.source_kind + pub source_kind: Option, + /// Exact match on e.entity_kind + pub entity_kind: Option, + /// LIKE-matched against si.canonical_url + pub url: Option, + /// LIKE-matched against e.title + pub title: Option, + /// e.archived_at >= after (inclusive, ISO 8601) + pub after: Option, + /// e.archived_at < before (exclusive, ISO 8601) + pub before: Option, +} + +/// Parses a raw search string into a [`SearchEntriesQuery`]. +/// +/// Recognized prefixes: `source:`, `type:`, `url:`, `title:`, `after:`, `before:`. +/// Tokens with an unrecognized `prefix:` return `Err(prefix)`. +/// Remaining non-prefix tokens are joined as the free-text `q`. +/// Quoted values (`title:"resume templates"`) are supported for single-word values +/// after the colon; leading/trailing double quotes are stripped. +pub fn parse_search_query(raw: &str) -> Result { + let mut query = SearchEntriesQuery::default(); + let mut free_text_tokens: Vec<&str> = Vec::new(); + + for token in raw.split_whitespace() { + if let Some(colon_pos) = token.find(':') { + let prefix = &token[..colon_pos]; + let value_raw = &token[colon_pos + 1..]; + // Strip surrounding double quotes if present + let value = value_raw.trim_matches('"').to_string(); + + match prefix { + "source" => query.source_kind = Some(value), + "type" => query.entity_kind = Some(value), + "url" => query.url = Some(value), + "title" => query.title = Some(value), + "after" => query.after = Some(value), + "before" => query.before = Some(value), + other => return Err(other.to_string()), + } + } else { + free_text_tokens.push(token); + } + } + + let q = free_text_tokens.join(" "); + query.q = if q.is_empty() { None } else { Some(q) }; + + Ok(query) +} + +/// Searches archived entries matching all non-`None` fields in `query`. +/// +/// Returns root entries only (same scope as [`list_root_entries`]). +pub fn search_entries( + conn: &rusqlite::Connection, + query: &SearchEntriesQuery, +) -> Result> { + let mut sql = String::from( + "SELECT e.entry_uid, e.archived_at, e.source_kind, e.entity_kind, e.title, \ + e.visibility, si.canonical_url, COUNT(ea.id) AS artifact_count, \ + COALESCE(SUM(b.byte_size), 0) AS total_artifact_bytes \ + FROM archived_entries e \ + JOIN source_identities si ON si.id = e.source_identity_id \ + LEFT JOIN entry_artifacts ea ON ea.entry_id = e.id \ + LEFT JOIN blobs b ON b.id = ea.blob_id \ + WHERE e.parent_entry_id IS NULL", + ); + + let mut params: Vec = Vec::new(); + + if let Some(q) = &query.q { + let term = format!("%{}%", q.to_lowercase()); + let n = params.len() + 1; + sql.push_str(&format!( + " AND (LOWER(e.title) LIKE ?{n} OR LOWER(si.canonical_url) LIKE ?{n} \ + OR LOWER(e.entry_uid) LIKE ?{n} OR LOWER(e.source_kind) LIKE ?{n} \ + OR LOWER(e.entity_kind) LIKE ?{n} OR LOWER(e.visibility) LIKE ?{n})" + )); + params.push(term); + } + if let Some(sk) = &query.source_kind { + let n = params.len() + 1; + sql.push_str(&format!(" AND e.source_kind = ?{n}")); + params.push(sk.clone()); + } + if let Some(ek) = &query.entity_kind { + let n = params.len() + 1; + sql.push_str(&format!(" AND e.entity_kind = ?{n}")); + params.push(ek.clone()); + } + if let Some(u) = &query.url { + let n = params.len() + 1; + sql.push_str(&format!(" AND LOWER(si.canonical_url) LIKE ?{n}")); + params.push(format!("%{}%", u.to_lowercase())); + } + if let Some(t) = &query.title { + let n = params.len() + 1; + sql.push_str(&format!(" AND LOWER(e.title) LIKE ?{n}")); + params.push(format!("%{}%", t.to_lowercase())); + } + if let Some(a) = &query.after { + let n = params.len() + 1; + sql.push_str(&format!(" AND e.archived_at >= ?{n}")); + params.push(a.clone()); + } + if let Some(b) = &query.before { + let n = params.len() + 1; + sql.push_str(&format!(" AND e.archived_at < ?{n}")); + params.push(b.clone()); + } + + sql.push_str(" GROUP BY e.id ORDER BY e.archived_at DESC, e.id DESC"); + + let mut stmt = conn.prepare(&sql)?; + let entries = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |row| { + Ok(EntrySummary { + entry_uid: row.get(0)?, + archived_at: row.get(1)?, + source_kind: row.get(2)?, + entity_kind: row.get(3)?, + title: row.get(4)?, + visibility: row.get(5)?, + original_url: row.get(6)?, + artifact_count: row.get(7)?, + total_artifact_bytes: row.get(8)?, + }) + })? + .collect::>>()?; + Ok(entries) +} + #[cfg(test)] mod tests { use super::*; @@ -494,4 +632,177 @@ mod tests { assert!(resolve_artifact_path(&store_path, &artifact).is_err()); let _ = fs::remove_dir_all(&root); } + + // ---- parse_search_query tests ---- + + #[test] + fn parse_empty_query_returns_default() { + let q = parse_search_query("").unwrap(); + assert!(q.q.is_none()); + assert!(q.source_kind.is_none()); + assert!(q.entity_kind.is_none()); + } + + #[test] + fn parse_plain_text_sets_q() { + let q = parse_search_query("polymarket").unwrap(); + assert_eq!(q.q.as_deref(), Some("polymarket")); + } + + #[test] + fn parse_prefix_source_sets_source_kind() { + let q = parse_search_query("source:x").unwrap(); + assert_eq!(q.source_kind.as_deref(), Some("x")); + assert!(q.q.is_none()); + } + + #[test] + fn parse_prefix_type_sets_entity_kind() { + let q = parse_search_query("type:tweet").unwrap(); + assert_eq!(q.entity_kind.as_deref(), Some("tweet")); + } + + #[test] + fn parse_mixed_plain_and_prefix() { + let q = parse_search_query("polymarket type:tweet").unwrap(); + assert_eq!(q.q.as_deref(), Some("polymarket")); + assert_eq!(q.entity_kind.as_deref(), Some("tweet")); + } + + #[test] + fn parse_unknown_prefix_returns_err() { + let result = parse_search_query("foo:bar"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "foo"); + } + + #[test] + fn parse_after_before_dates() { + let q = parse_search_query("after:2026-01-01 before:2026-04-01").unwrap(); + assert_eq!(q.after.as_deref(), Some("2026-01-01")); + assert_eq!(q.before.as_deref(), Some("2026-04-01")); + } + + // ---- search_entries tests ---- + + fn make_test_db_with_entries() -> rusqlite::Connection { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + database::initialize_schema(&conn).unwrap(); + let user_id = database::ensure_default_user(&conn).unwrap(); + let run = database::create_archive_run(&conn, user_id, 2).unwrap(); + + // Entry 1: tweet by source x + let si1 = database::upsert_source_identity( + &conn, "x", "tweet", Some("t-1"), + Some("https://x.com/user/status/1"), + "https://x.com/user/status/1", + ).unwrap(); + database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: si1, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "x".to_string(), + entity_kind: "tweet".to_string(), + title: Some("Polymarket tweet".to_string()), + visibility: "private".to_string(), + representation_kind: "json".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ).unwrap(); + + // Entry 2: web page + let si2 = database::upsert_source_identity( + &conn, "web", "page", Some("page-1"), + Some("https://medium.com/article"), + "https://medium.com/article", + ).unwrap(); + database::create_archived_entry( + &conn, + &database::NewEntry { + source_identity_id: si2, + archive_run_id: run.id, + parent_entry_id: None, + root_entry_id: None, + created_by_user_id: user_id, + owned_by_user_id: user_id, + source_kind: "web".to_string(), + entity_kind: "page".to_string(), + title: Some("Resume Templates".to_string()), + visibility: "private".to_string(), + representation_kind: "html".to_string(), + source_metadata_json: "{}".to_string(), + display_metadata_json: None, + }, + ).unwrap(); + + conn + } + + #[test] + fn search_empty_query_returns_all_root_entries() { + let conn = make_test_db_with_entries(); + let all = list_root_entries(&conn).unwrap(); + let searched = search_entries(&conn, &SearchEntriesQuery::default()).unwrap(); + assert_eq!(all.len(), searched.len()); + } + + #[test] + fn search_q_filters_on_title() { + let conn = make_test_db_with_entries(); + let results = search_entries(&conn, &SearchEntriesQuery { + q: Some("polymarket".to_string()), + ..Default::default() + }).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].entity_kind, "tweet"); + } + + #[test] + fn search_entity_kind_exact_match() { + let conn = make_test_db_with_entries(); + let results = search_entries(&conn, &SearchEntriesQuery { + entity_kind: Some("page".to_string()), + ..Default::default() + }).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].source_kind, "web"); + } + + #[test] + fn search_url_like_filter() { + let conn = make_test_db_with_entries(); + let results = search_entries(&conn, &SearchEntriesQuery { + url: Some("medium.com".to_string()), + ..Default::default() + }).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].title.as_deref(), Some("Resume Templates")); + } + + #[test] + fn search_no_match_returns_empty() { + let conn = make_test_db_with_entries(); + let results = search_entries(&conn, &SearchEntriesQuery { + q: Some("zzznonexistent".to_string()), + ..Default::default() + }).unwrap(); + assert!(results.is_empty()); + } + + #[test] + fn search_multiple_filters_compound() { + let conn = make_test_db_with_entries(); + let results = search_entries(&conn, &SearchEntriesQuery { + source_kind: Some("x".to_string()), + entity_kind: Some("tweet".to_string()), + ..Default::default() + }).unwrap(); + assert_eq!(results.len(), 1); + } } diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 9b1eb1f..332e5c3 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -3,7 +3,7 @@ use std::{path::PathBuf, sync::Arc}; use archivr_core::{archive, database}; use axum::{ Json, Router, - extract::{Path, Request, State}, + extract::{Path, Query, Request, State}, http::StatusCode, response::{IntoResponse, Response}, routing::get, @@ -18,6 +18,11 @@ pub struct AppState { registry: Arc, } +#[derive(Debug, serde::Deserialize, Default)] +pub struct EntrySearchParams { + pub q: Option, +} + pub fn app(registry: ServerRegistry) -> Router { let state = AppState { registry: Arc::new(registry), @@ -28,6 +33,7 @@ pub fn app(registry: ServerRegistry) -> Router { .route("/health", get(|| async { "ok" })) .route("/api/archives", get(list_archives)) .route("/api/archives/:archive_id/entries", get(list_entries)) + .route("/api/archives/:archive_id/entries/search", get(search_entries_handler)) .route( "/api/archives/:archive_id/entries/:entry_uid", get(entry_detail), @@ -61,6 +67,23 @@ async fn list_entries( Ok(Json(archive::list_root_entries(&conn)?)) } +async fn search_entries_handler( + State(state): State, + Path(archive_id): Path, + Query(params): Query, +) -> Result>, ApiError> { + let mounted = mounted_archive(&state, &archive_id)?; + let conn = database::open_or_initialize(&mounted.archive_path)?; + let raw = params.q.as_deref().unwrap_or(""); + let search_query = archive::parse_search_query(raw) + .map_err(|prefix| ApiError::bad_request(&format!("unknown search prefix: {prefix}"))); + let search_query = match search_query { + Ok(q) => q, + Err(e) => return Err(e), + }; + Ok(Json(archive::search_entries(&conn, &search_query)?)) +} + async fn entry_detail( State(state): State, Path((archive_id, entry_uid)): Path<(String, String)>, @@ -130,6 +153,13 @@ impl ApiError { message: message.to_string(), } } + + fn bad_request(message: &str) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.to_string(), + } + } } impl From for ApiError @@ -359,4 +389,78 @@ mod tests { .unwrap(); assert_eq!(response.status(), StatusCode::OK); } + + #[tokio::test] + async fn search_missing_archive_returns_404() { + let response = app(ServerRegistry::default()) + .oneshot( + Request::builder() + .uri("/api/archives/nope/entries/search?q=anything") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn search_empty_q_returns_ok() { + let dir = tempfile::tempdir().unwrap(); + archivr_core::archive::initialize_archive( + dir.path(), + &dir.path().join("store"), + "test", + false, + ) + .unwrap(); + let archive_path = dir.path().join(".archivr"); + let registry = ServerRegistry { + archives: vec![MountedArchive { + id: "test".to_string(), + label: "Test".to_string(), + archive_path, + }], + }; + let response = app(registry) + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/search") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn search_unknown_prefix_returns_400() { + let dir = tempfile::tempdir().unwrap(); + archivr_core::archive::initialize_archive( + dir.path(), + &dir.path().join("store"), + "test", + false, + ) + .unwrap(); + let archive_path = dir.path().join(".archivr"); + let registry = ServerRegistry { + archives: vec![MountedArchive { + id: "test".to_string(), + label: "Test".to_string(), + archive_path, + }], + }; + let response = app(registry) + .oneshot( + Request::builder() + .uri("/api/archives/test/entries/search?q=unknownprefix%3Aval") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } } diff --git a/crates/archivr-server/static/app.js b/crates/archivr-server/static/app.js index cab3dd8..148f1d9 100644 --- a/crates/archivr-server/static/app.js +++ b/crates/archivr-server/static/app.js @@ -2,7 +2,6 @@ const state = { archives: [], archiveId: null, entries: [], - filteredEntries: [], selectedEntryUid: null, }; @@ -71,33 +70,16 @@ function renderArchives() { } } -function applyEntryFilter() { - const query = searchInput.value.trim().toLowerCase(); - if (!query) { - state.filteredEntries = state.entries; - return; - } - state.filteredEntries = state.entries.filter((entry) => { - const haystack = [ - entry.title, - entry.original_url, - entry.entry_uid, - entry.source_kind, - entry.entity_kind, - entry.visibility, - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - return haystack.includes(query); - }); -} function renderEntries() { entriesBody.innerHTML = ""; - resultCount.textContent = `${state.filteredEntries.length} entries`; + if (state.entries.length === 0 && searchInput.value.trim()) { + resultCount.textContent = "No results."; + } else { + resultCount.textContent = `${state.entries.length} entries`; + } - for (const entry of state.filteredEntries) { + for (const entry of state.entries) { const row = document.createElement("tr"); row.tabIndex = 0; row.dataset.entryUid = entry.entry_uid; @@ -233,12 +215,21 @@ async function loadRuns() { } } -async function loadEntries() { - state.entries = await getJson(`/api/archives/${state.archiveId}/entries`); - state.selectedEntryUid = null; - applyEntryFilter(); +async function loadEntries(q = "") { + const trimmed = q.trim(); + const url = trimmed + ? `/api/archives/${state.archiveId}/entries/search?q=${encodeURIComponent(trimmed)}` + : `/api/archives/${state.archiveId}/entries`; + searchInput.setAttribute("aria-busy", "true"); + try { + state.entries = await getJson(url); + } catch (err) { + resultCount.textContent = "Search failed. Try again."; + state.entries = []; + } finally { + searchInput.removeAttribute("aria-busy"); + } renderEntries(); - contextBody.textContent = "Select an entry."; } async function loadArchives() { @@ -254,15 +245,26 @@ async function loadArchives() { } } +function debounce(fn, ms) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; +} + archiveSwitcher.addEventListener("change", async () => { state.archiveId = archiveSwitcher.value; await loadEntries(); await loadRuns(); }); +const debouncedSearch = debounce((q) => { + if (state.archiveId) loadEntries(q); +}, 300); + searchInput.addEventListener("input", () => { - applyEntryFilter(); - renderEntries(); + debouncedSearch(searchInput.value); }); navButtons.forEach((button) => { diff --git a/crates/archivr-server/static/styles.css b/crates/archivr-server/static/styles.css index 8e338e6..a1b1395 100644 --- a/crates/archivr-server/static/styles.css +++ b/crates/archivr-server/static/styles.css @@ -370,3 +370,7 @@ select { background: var(--line); text-decoration: underline; } +/* Search loading state */ +.search-input[aria-busy="true"] { + cursor: progress; +}