1
Fork 0
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:
TheGeneralist 2026-06-21 21:16:02 +02:00
parent fa2968a21a
commit 83f909585f
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
4 changed files with 453 additions and 32 deletions

View file

@ -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<ServerRegistry>,
}
#[derive(Debug, serde::Deserialize, Default)]
pub struct EntrySearchParams {
pub q: Option<String>,
}
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<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(
State(state): State<AppState>,
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<E> From<E> 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);
}
}

View file

@ -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) => {

View file

@ -370,3 +370,7 @@ select {
background: var(--line);
text-decoration: underline;
}
/* Search loading state */
.search-input[aria-busy="true"] {
cursor: progress;
}