mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat: server-side search with structured prefix query language
This commit is contained in:
parent
fa2968a21a
commit
83f909585f
4 changed files with 453 additions and 32 deletions
|
|
@ -303,6 +303,144 @@ pub fn resolve_artifact_path(
|
||||||
Ok(canonical_artifact)
|
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<String>,
|
||||||
|
/// Exact match on e.source_kind
|
||||||
|
pub source_kind: Option<String>,
|
||||||
|
/// Exact match on e.entity_kind
|
||||||
|
pub entity_kind: Option<String>,
|
||||||
|
/// LIKE-matched against si.canonical_url
|
||||||
|
pub url: Option<String>,
|
||||||
|
/// LIKE-matched against e.title
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// e.archived_at >= after (inclusive, ISO 8601)
|
||||||
|
pub after: Option<String>,
|
||||||
|
/// e.archived_at < before (exclusive, ISO 8601)
|
||||||
|
pub before: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<SearchEntriesQuery, String> {
|
||||||
|
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<Vec<EntrySummary>> {
|
||||||
|
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<String> = 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::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -494,4 +632,177 @@ mod tests {
|
||||||
assert!(resolve_artifact_path(&store_path, &artifact).is_err());
|
assert!(resolve_artifact_path(&store_path, &artifact).is_err());
|
||||||
let _ = fs::remove_dir_all(&root);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::{path::PathBuf, sync::Arc};
|
||||||
use archivr_core::{archive, database};
|
use archivr_core::{archive, database};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{Path, Request, State},
|
extract::{Path, Query, Request, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
|
|
@ -18,6 +18,11 @@ pub struct AppState {
|
||||||
registry: Arc<ServerRegistry>,
|
registry: Arc<ServerRegistry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, Default)]
|
||||||
|
pub struct EntrySearchParams {
|
||||||
|
pub q: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn app(registry: ServerRegistry) -> Router {
|
pub fn app(registry: ServerRegistry) -> Router {
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
registry: Arc::new(registry),
|
registry: Arc::new(registry),
|
||||||
|
|
@ -28,6 +33,7 @@ pub fn app(registry: ServerRegistry) -> Router {
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.route("/api/archives", get(list_archives))
|
.route("/api/archives", get(list_archives))
|
||||||
.route("/api/archives/:archive_id/entries", get(list_entries))
|
.route("/api/archives/:archive_id/entries", get(list_entries))
|
||||||
|
.route("/api/archives/:archive_id/entries/search", get(search_entries_handler))
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/entries/:entry_uid",
|
"/api/archives/:archive_id/entries/:entry_uid",
|
||||||
get(entry_detail),
|
get(entry_detail),
|
||||||
|
|
@ -61,6 +67,23 @@ async fn list_entries(
|
||||||
Ok(Json(archive::list_root_entries(&conn)?))
|
Ok(Json(archive::list_root_entries(&conn)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn search_entries_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(archive_id): Path<String>,
|
||||||
|
Query(params): Query<EntrySearchParams>,
|
||||||
|
) -> Result<Json<Vec<archive::EntrySummary>>, 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(
|
async fn entry_detail(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((archive_id, entry_uid)): Path<(String, String)>,
|
Path((archive_id, entry_uid)): Path<(String, String)>,
|
||||||
|
|
@ -130,6 +153,13 @@ impl ApiError {
|
||||||
message: message.to_string(),
|
message: message.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bad_request(message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
status: StatusCode::BAD_REQUEST,
|
||||||
|
message: message.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<E> From<E> for ApiError
|
impl<E> From<E> for ApiError
|
||||||
|
|
@ -359,4 +389,78 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ const state = {
|
||||||
archives: [],
|
archives: [],
|
||||||
archiveId: null,
|
archiveId: null,
|
||||||
entries: [],
|
entries: [],
|
||||||
filteredEntries: [],
|
|
||||||
selectedEntryUid: null,
|
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() {
|
function renderEntries() {
|
||||||
entriesBody.innerHTML = "";
|
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");
|
const row = document.createElement("tr");
|
||||||
row.tabIndex = 0;
|
row.tabIndex = 0;
|
||||||
row.dataset.entryUid = entry.entry_uid;
|
row.dataset.entryUid = entry.entry_uid;
|
||||||
|
|
@ -233,12 +215,21 @@ async function loadRuns() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEntries() {
|
async function loadEntries(q = "") {
|
||||||
state.entries = await getJson(`/api/archives/${state.archiveId}/entries`);
|
const trimmed = q.trim();
|
||||||
state.selectedEntryUid = null;
|
const url = trimmed
|
||||||
applyEntryFilter();
|
? `/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();
|
renderEntries();
|
||||||
contextBody.textContent = "Select an entry.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadArchives() {
|
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 () => {
|
archiveSwitcher.addEventListener("change", async () => {
|
||||||
state.archiveId = archiveSwitcher.value;
|
state.archiveId = archiveSwitcher.value;
|
||||||
await loadEntries();
|
await loadEntries();
|
||||||
await loadRuns();
|
await loadRuns();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const debouncedSearch = debounce((q) => {
|
||||||
|
if (state.archiveId) loadEntries(q);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
searchInput.addEventListener("input", () => {
|
searchInput.addEventListener("input", () => {
|
||||||
applyEntryFilter();
|
debouncedSearch(searchInput.value);
|
||||||
renderEntries();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
navButtons.forEach((button) => {
|
navButtons.forEach((button) => {
|
||||||
|
|
|
||||||
|
|
@ -370,3 +370,7 @@ select {
|
||||||
background: var(--line);
|
background: var(--line);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
/* Search loading state */
|
||||||
|
.search-input[aria-busy="true"] {
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue