diff --git a/.vscode/launch.json b/.vscode/launch.json index 8f05f72..afdc3e3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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" } }, diff --git a/Cargo.lock b/Cargo.lock index 05fcab5..0365510 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3103,7 +3103,7 @@ dependencies = [ ] [[package]] -name = "quiksearch" +name = "quicksearch" version = "0.1.0" dependencies = [ "ctrlc", diff --git a/Cargo.toml b/Cargo.toml index 532a32c..6bea883 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "quiksearch" +name = "quicksearch" version = "0.1.0" edition = "2021" diff --git a/config_example.toml b/config_example.toml index b8e0aeb..cca9e9a 100644 --- a/config_example.toml +++ b/config_example.toml @@ -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) diff --git a/src/frontend.rs b/src/frontend.rs index 8cad342..15d9e4f 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -78,6 +78,7 @@ pub fn App(props: AppProps) -> Element { let mut show_config_dialog = use_signal(|| false); let mut config_changes = use_signal(|| Vec::::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(); @@ -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 diff --git a/src/indexing.rs b/src/indexing.rs index ca59c25..b003cbb 100644 --- a/src/indexing.rs +++ b/src/indexing.rs @@ -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, +} + +#[derive(Debug, Clone)] +pub struct SearchResult { + pub columns: Vec, + pub rows: Vec, +} + #[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, 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 = (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>, String> { let conn = Connection::open(db_path) diff --git a/src/main.rs b/src/main.rs index f2bca0a..5b9b5d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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> = 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 , "", "", "...", 64) as "snip" FROM searchabletext WHERE text MATCH 'Terrasound' LIMIT 100; +Full text search: +SELECT name, path, text, snippet(searchabletext, 2 , "", "", "...", 64) as "snip" FROM searchabletext WHERE text MATCH 'searchstring' + +Filename search: +SELECT name, path FROM files WHERE name LIKE '%searchstring%'; */ \ No newline at end of file diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000..c828c6e --- /dev/null +++ b/src/search.rs @@ -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, + 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::::new()); + let mut search_error = use_signal(|| None::); + + 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, '', '', '...', 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}" + } + } + } + } + } + } + } + } + } + } + } +}