Added basic query functionality to frontend.

This commit is contained in:
Jeremy Karst 2025-09-11 23:15:50 -04:00
parent c39dd9cc03
commit 0d941e82d2
8 changed files with 224 additions and 14 deletions

16
.vscode/launch.json vendored
View file

@ -7,15 +7,15 @@
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'quiksearch'",
"name": "Debug executable 'quicksearch'",
"cargo": {
"args": [
"build",
"--bin=quiksearch",
"--package=quiksearch"
"--bin=quicksearch",
"--package=quicksearch"
],
"filter": {
"name": "quiksearch",
"name": "quicksearch",
"kind": "bin"
}
},
@ -25,16 +25,16 @@
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'quiksearch'",
"name": "Debug unit tests in executable 'quicksearch'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=quiksearch",
"--package=quiksearch"
"--bin=quicksearch",
"--package=quicksearch"
],
"filter": {
"name": "quiksearch",
"name": "quicksearch",
"kind": "bin"
}
},

2
Cargo.lock generated
View file

@ -3103,7 +3103,7 @@ dependencies = [
]
[[package]]
name = "quiksearch"
name = "quicksearch"
version = "0.1.0"
dependencies = [
"ctrlc",

View file

@ -1,5 +1,5 @@
[package]
name = "quiksearch"
name = "quicksearch"
version = "0.1.0"
edition = "2021"

View file

@ -3,8 +3,8 @@ default_indexing_path = "C:\\"
database_path = "QuickSearch.db"
[processing]
# Size of hash blocks read from start/end of files (bytes)
hash_length = 4096
# Amount of data in bytes read from start/end of files used to calculate hash
hash_length = 8192
# Maximum text content to index per file (bytes)
maximum_text_size = 524288
# Maximum file size to process for text extraction (bytes)

View file

@ -79,6 +79,7 @@ pub fn App(props: AppProps) -> Element {
let mut config_changes = use_signal(|| Vec::<String>::new());
let speed_tracker = use_signal(|| SpeedTracker::new());
let indexing_service_for_start = props.indexing_service.clone();
let indexing_service_for_start_dialog = props.indexing_service.clone();
let indexing_service_for_stop = props.indexing_service.clone();
@ -281,6 +282,11 @@ pub fn App(props: AppProps) -> Element {
"{status_text}"
}
}
crate::search::Search {
indexing_service: props.indexing_service.clone(),
db_path: db_path().clone()
}
}
// Configuration validation dialog

View file

@ -7,6 +7,17 @@ use rusqlite::{Connection, params};
use crate::file_handling::{load_existing_files, analyze_files_for_batch_update, process_batch_updates_files_only, process_batch_inserts_files_only, process_text_indexing};
use crate::config::Config;
#[derive(Debug, Clone)]
pub struct SearchResultRow {
pub values: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SearchResult {
pub columns: Vec<String>,
pub rows: Vec<SearchResultRow>,
}
#[derive(Debug, Clone)]
pub enum IndexingStatus {
Idle,
@ -149,6 +160,49 @@ impl IndexingService {
self.stop_indexing()
}
/// Execute a search query against the database
pub fn execute_search(&self, db_path: &str, query: &str) -> Result<Vec<SearchResult>, String> {
let conn = Connection::open(db_path)
.map_err(|e| format!("Failed to open database: {}", e))?;
let mut stmt = conn.prepare(query)
.map_err(|e| format!("Failed to prepare query: {}", e))?;
let column_count = stmt.column_count();
let column_names: Vec<String> = (0..column_count)
.map(|i| stmt.column_name(i).unwrap_or("").to_string())
.collect();
let rows = stmt.query_map([], |row| {
let mut values = Vec::new();
for i in 0..column_count {
let value = match row.get_ref(i)? {
rusqlite::types::ValueRef::Null => "NULL".to_string(),
rusqlite::types::ValueRef::Integer(i) => i.to_string(),
rusqlite::types::ValueRef::Real(f) => f.to_string(),
rusqlite::types::ValueRef::Text(t) => String::from_utf8_lossy(t).to_string(),
rusqlite::types::ValueRef::Blob(b) => format!("BLOB({} bytes)", b.len()),
};
values.push(value);
}
Ok(SearchResultRow { values })
})
.map_err(|e| format!("Failed to execute query: {}", e))?;
let mut results = Vec::new();
for row in rows {
match row {
Ok(search_row) => results.push(search_row),
Err(e) => return Err(format!("Error reading row: {}", e)),
}
}
Ok(vec![SearchResult {
columns: column_names,
rows: results,
}])
}
/// Check if configuration changes require index recreation
pub fn check_config_validation(&self, db_path: &str, config: &Config, indexing_path: &str) -> Result<Option<Vec<String>>, String> {
let conn = Connection::open(db_path)

View file

@ -6,6 +6,7 @@ mod file_handling;
mod document_extraction;
mod indexing;
mod config;
mod search;
// Global indexing service for signal handling
static INDEXING_SERVICE: OnceLock<Arc<indexing::IndexingService>> = OnceLock::new();
@ -49,7 +50,12 @@ fn app() -> Element {
}
/*
SELECT name, hash, count(*) as cnt FROM files GROUP BY hash ORDER BY cnt DESC;
Duplicate files:
SELECT name, count(*) as cnt, path FROM files GROUP BY hash HAVING cnt > 1 ORDER BY cnt DESC;
SELECT name, path, text, snippet(searchabletext, 2 , "<b>", "</b>", "...", 64) as "snip" FROM searchabletext WHERE text MATCH 'Terrasound' LIMIT 100;
Full text search:
SELECT name, path, text, snippet(searchabletext, 2 , "<b>", "</b>", "<b>...</b>", 64) as "snip" FROM searchabletext WHERE text MATCH 'searchstring'
Filename search:
SELECT name, path FROM files WHERE name LIKE '%searchstring%';
*/

144
src/search.rs Normal file
View file

@ -0,0 +1,144 @@
#![allow(non_snake_case)]
use std::sync::Arc;
use dioxus::prelude::*;
use crate::indexing::{IndexingService, SearchResult};
#[derive(Props, Clone)]
pub struct SearchProps {
pub indexing_service: Arc<IndexingService>,
pub db_path: String,
}
impl PartialEq for SearchProps {
fn eq(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.indexing_service, &other.indexing_service) && self.db_path == other.db_path
}
}
pub fn Search(props: SearchProps) -> Element {
let mut search_type = use_signal(|| "fulltext".to_string());
let mut search_term = use_signal(|| String::new());
let mut search_results = use_signal(|| Vec::<SearchResult>::new());
let mut search_error = use_signal(|| None::<String>);
let service = props.indexing_service.clone();
let db_path = props.db_path.clone();
rsx! {
div {
style: "margin-top: 30px;",
h2 { "Search Database" }
div {
style: "margin-bottom: 10px;",
label { "Search Type: " }
select {
value: "{search_type}",
onchange: move |evt| search_type.set(evt.value()),
option { value: "fulltext", "Full Text Search" }
option { value: "filename", "Filename Search" }
option { value: "duplicates", "Find Duplicate Files" }
}
}
if search_type() != "duplicates" {
div {
style: "margin-bottom: 10px;",
label { "Search Term: " }
input {
r#type: "text",
value: "{search_term}",
oninput: move |evt| search_term.set(evt.value())
}
}
}
button {
style: "padding: 10px 20px; background-color: #2196F3; color: white; border: none; cursor: pointer;",
onclick: move |_| {
let service_clone = service.clone();
let db_clone = db_path.clone();
let search_type_val = search_type().clone();
let search_term_val = search_term().clone();
let mut search_results_clone = search_results.clone();
let mut search_error_clone = search_error.clone();
spawn(async move {
search_error_clone.set(None);
let query = match search_type_val.as_str() {
"fulltext" => {
if search_term_val.trim().is_empty() {
search_error_clone.set(Some("Please enter a search term".to_string()));
return;
}
format!("SELECT name, path, snippet(searchabletext, 2, '<b>', '</b>', '<b>...</b>', 64) as snippet FROM searchabletext WHERE text MATCH '{}'", search_term_val.replace("'", "''"))
},
"filename" => {
if search_term_val.trim().is_empty() {
search_error_clone.set(Some("Please enter a filename pattern".to_string()));
return;
}
format!("SELECT name, path FROM files WHERE name LIKE '%{}%'", search_term_val.replace("'", "''"))
},
"duplicates" => "SELECT name, count(*) as cnt, path FROM files GROUP BY hash HAVING cnt > 1 ORDER BY cnt DESC".to_string(),
_ => return
};
match service_clone.execute_search(&db_clone, &query) {
Ok(results) => search_results_clone.set(results),
Err(e) => search_error_clone.set(Some(e))
}
});
},
"Search"
}
if let Some(error) = search_error() {
div {
style: "color: red; margin-top: 10px;",
"Error: {error}"
}
}
if !search_results().is_empty() {
div {
style: "margin-top: 20px;",
h3 { "Search Results ({search_results()[0].rows.len()} rows)" }
div {
style: "max-height: 400px; overflow: auto; border: 1px solid #ddd;",
table {
style: "width: 100%; border-collapse: collapse; font-size: 12px;",
thead {
style: "background-color: #f5f5f5; position: sticky; top: 0;",
tr {
for column in search_results()[0].columns.iter() {
th {
style: "padding: 8px; border: 1px solid #ddd; text-align: left;",
"{column}"
}
}
}
}
tbody {
for (i, row) in search_results()[0].rows.iter().enumerate() {
tr {
style: if i % 2 == 0 { "background-color: #f9f9f9;" } else { "" },
for value in row.values.iter() {
td {
style: "padding: 8px; border: 1px solid #ddd; word-break: break-all;",
dangerous_inner_html: "{value}"
}
}
}
}
}
}
}
}
}
}
}
}