diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..e5598fb --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,532 @@ +#![allow(clippy::struct_excessive_bools)] + +use crossterm::{ + cursor::MoveTo, + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, KeyModifiers, + }, + execute, + style::{ + Color, Print, ResetColor, SetBackgroundColor, SetForegroundColor, Stylize, + }, + terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType}, +}; +use std::io::{self, Write}; +use std::time::Instant; + +#[derive(Debug, Clone)] +pub struct TuiConfig { + pub start_url: String, + pub output_dir: String, + pub subdomains: bool, + pub single: bool, + pub no_scope: bool, + pub no_doc_conversion: bool, + pub delay_ms: u64, + pub types: Option, + pub exclude_types: Option, +} + +impl Default for TuiConfig { + fn default() -> Self { + Self { + start_url: String::new(), + output_dir: String::new(), + subdomains: false, + single: false, + no_scope: false, + no_doc_conversion: false, + delay_ms: 1000, + types: None, + exclude_types: None, + } + } +} + +impl TuiConfig { + pub fn to_cli_args(&self) -> Vec { + let mut args = vec![self.start_url.clone()]; + + if !self.output_dir.is_empty() { + args.extend(["-o".to_string(), self.output_dir.clone()]); + } + + if self.subdomains { + args.push("--subdomains".to_string()); + } + + if self.single { + args.push("--single".to_string()); + } + + if self.no_scope { + args.push("--no-scope".to_string()); + } + + if self.no_doc_conversion { + args.push("--no-doc-conversion".to_string()); + } + + if self.delay_ms != 1000 { + args.extend(["--delay-ms".to_string(), self.delay_ms.to_string()]); + } + + if let Some(ref t) = self.types { + args.extend(["--types".to_string(), t.clone()]); + } + + if let Some(ref t) = self.exclude_types { + args.extend(["--exclude-types".to_string(), t.clone()]); + } + + args + } + + pub fn build_command(&self) -> String { + format!("web-scraper {}", self.to_cli_args().join(" ")) + } +} + +fn validate_comma_list(input: &str) -> Result<(), String> { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Ok(()); + } + + let items: Vec<&str> = trimmed + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .collect(); + + if items.is_empty() { + return Err("List cannot be empty".to_string()); + } + + for item in &items { + let check = item.strip_prefix('.').unwrap_or(item); + if check.is_empty() || !check.chars().all(char::is_alphanumeric) { + return Err(format!("Invalid item '{item}': use extensions like 'pdf' or '.pdf'")); + } + } + + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FieldIndex { + StartUrl, + OutputDir, + Subdomains, + Single, + NoScope, + NoDocConversion, + DelayMs, + TypesInclude, + TypesExclude, + Submit, +} + +impl FieldIndex { + fn next(self) -> Self { + match self { + FieldIndex::StartUrl => FieldIndex::OutputDir, + FieldIndex::OutputDir => FieldIndex::Subdomains, + FieldIndex::Subdomains => FieldIndex::Single, + FieldIndex::Single => FieldIndex::NoScope, + FieldIndex::NoScope => FieldIndex::NoDocConversion, + FieldIndex::NoDocConversion => FieldIndex::DelayMs, + FieldIndex::DelayMs => FieldIndex::TypesInclude, + FieldIndex::TypesInclude => FieldIndex::TypesExclude, + FieldIndex::TypesExclude => FieldIndex::Submit, + FieldIndex::Submit => FieldIndex::StartUrl, + } + } + + fn prev(self) -> Self { + match self { + FieldIndex::StartUrl => FieldIndex::Submit, + FieldIndex::OutputDir => FieldIndex::StartUrl, + FieldIndex::Subdomains => FieldIndex::OutputDir, + FieldIndex::Single => FieldIndex::Subdomains, + FieldIndex::NoScope => FieldIndex::Single, + FieldIndex::NoDocConversion => FieldIndex::NoScope, + FieldIndex::DelayMs => FieldIndex::NoDocConversion, + FieldIndex::TypesInclude => FieldIndex::DelayMs, + FieldIndex::TypesExclude => FieldIndex::TypesInclude, + FieldIndex::Submit => FieldIndex::TypesExclude, + } + } + + fn label(self) -> &'static str { + match self { + FieldIndex::StartUrl => "Start URL", + FieldIndex::OutputDir => "Output directory", + FieldIndex::Subdomains => "Include subdomains?", + FieldIndex::Single => "Single page mode?", + FieldIndex::NoScope => "No path scoping?", + FieldIndex::NoDocConversion => "Skip document conversion?", + FieldIndex::DelayMs => "Delay (ms)", + FieldIndex::TypesInclude => "Include types", + FieldIndex::TypesExclude => "Exclude types", + FieldIndex::Submit => "Submit", + } + } + + fn hint(self) -> &'static str { + match self { + FieldIndex::StartUrl => "https://example.com/docs", + FieldIndex::OutputDir => "_scraped", + FieldIndex::Subdomains + | FieldIndex::Single + | FieldIndex::NoScope + | FieldIndex::NoDocConversion => "[y/N]", + FieldIndex::DelayMs => "1000", + FieldIndex::TypesInclude => ".pdf,docx,.html", + FieldIndex::TypesExclude => "png,jpg,.gif", + FieldIndex::Submit => "", + } + } + + fn is_text_field(self) -> bool { + matches!( + self, + FieldIndex::StartUrl + | FieldIndex::OutputDir + | FieldIndex::DelayMs + | FieldIndex::TypesInclude + | FieldIndex::TypesExclude + ) + } + + fn is_boolean_field(self) -> bool { + matches!( + self, + FieldIndex::Subdomains + | FieldIndex::Single + | FieldIndex::NoScope + | FieldIndex::NoDocConversion + ) + } +} + +const ALL_FIELDS: [FieldIndex; 10] = [ + FieldIndex::StartUrl, + FieldIndex::OutputDir, + FieldIndex::Subdomains, + FieldIndex::Single, + FieldIndex::NoScope, + FieldIndex::NoDocConversion, + FieldIndex::DelayMs, + FieldIndex::TypesInclude, + FieldIndex::TypesExclude, + FieldIndex::Submit, +]; + +pub fn run_tui() -> io::Result> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnableMouseCapture)?; + + let mut config = TuiConfig::default(); + let mut current_field = FieldIndex::StartUrl; + let mut text_input = String::new(); + let mut errors: Vec<(FieldIndex, String)> = Vec::new(); + let mut last_render = Instant::now(); + + load_field_into_input(&mut text_input, &config, current_field); + + loop { + if last_render.elapsed().as_millis() > 50 { + render_ui(&mut stdout, &config, current_field, &errors)?; + last_render = Instant::now(); + } + + if event::poll(std::time::Duration::from_millis(50))? + && let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + { + handle_key_event( + &mut config, + &mut current_field, + &mut text_input, + &mut errors, + key.code, + key.modifiers, + )?; + } + } +} + +fn render_ui(stdout: &mut io::Stdout, config: &TuiConfig, current_field: FieldIndex, errors: &[(FieldIndex, String)]) -> io::Result<()> { + execute!(stdout, Clear(ClearType::All), MoveTo(0, 0))?; + + writeln!(stdout, "{}", "Interactive Web Scraper Configuration".cyan().bold())?; + writeln!(stdout, "{}", "=".repeat(60).blue())?; + writeln!(stdout)?; + + for &field in &ALL_FIELDS { + let marker = if current_field == field { ">" } else { " " }; + let value_text = get_field_display(config, field); + let hint = field.hint(); + + if current_field == field { + execute!( + stdout, + SetBackgroundColor(Color::DarkGrey), + Print(format!("{marker} {:30}: ", field.label())), + ResetColor + )?; + } else { + write!(stdout, "{marker} {:30}: ", field.label())?; + } + + if !value_text.is_empty() { + write!(stdout, "{value_text}")?; + } else if !hint.is_empty() { + write!(stdout, "{}", hint.dark_grey())?; + } + + if let Some((_, err)) = errors.iter().find(|(f, _)| *f == field) { + execute!( + stdout, + SetForegroundColor(Color::Red), + Print(format!(" [err: {err}]")), + ResetColor + )?; + } + + writeln!(stdout)?; + + if field.is_boolean_field() { + let enabled = get_bool_field(config, field); + writeln!(stdout, " [{}]", + if enabled { "enabled" } else { "disabled" })?; + } else if field == FieldIndex::DelayMs { + writeln!(stdout, " [enter positive integer]")?; + } else if field == FieldIndex::TypesInclude || field == FieldIndex::TypesExclude { + writeln!(stdout, " [comma-separated, dots optional e.g. .pdf,xlsx]")?; + } + } + + writeln!(stdout)?; + writeln!(stdout, "{}", "Controls:".dark_grey())?; + writeln!(stdout, " Enter : Next field / Toggle boolean / Submit")?; + writeln!(stdout, " Tab : Next field")?; + writeln!(stdout, " Shift+Tab: Previous field")?; + writeln!(stdout, " Ctrl+C : Exit without running")?; + writeln!(stdout, " Esc : Exit without running")?; + + let cmd_preview = config.build_command(); + writeln!(stdout)?; + writeln!(stdout, "{}", "Command preview:".yellow())?; + writeln!(stdout, " {cmd_preview}", cmd_preview = cmd_preview.green())?; + + stdout.flush()?; + + Ok(()) +} + +fn get_bool_field(config: &TuiConfig, field: FieldIndex) -> bool { + match field { + FieldIndex::Subdomains => config.subdomains, + FieldIndex::Single => config.single, + FieldIndex::NoScope => config.no_scope, + FieldIndex::NoDocConversion => config.no_doc_conversion, + _ => false, + } +} + +fn get_field_display(config: &TuiConfig, field: FieldIndex) -> String { + match field { + FieldIndex::StartUrl => config.start_url.clone(), + FieldIndex::OutputDir => { + if config.output_dir.is_empty() { + String::new() + } else { + config.output_dir.clone() + } + } + FieldIndex::DelayMs => config.delay_ms.to_string(), + FieldIndex::TypesInclude => config.types.clone().unwrap_or_default(), + FieldIndex::TypesExclude => config.exclude_types.clone().unwrap_or_default(), + _ => String::new(), + } +} + +fn load_field_into_input(text_input: &mut String, config: &TuiConfig, field: FieldIndex) { + *text_input = match field { + FieldIndex::StartUrl => config.start_url.clone(), + FieldIndex::OutputDir => config.output_dir.clone(), + FieldIndex::DelayMs => config.delay_ms.to_string(), + FieldIndex::TypesInclude => config.types.clone().unwrap_or_default(), + FieldIndex::TypesExclude => config.exclude_types.clone().unwrap_or_default(), + _ => String::new(), + }; +} + +fn save_input_to_field(config: &mut TuiConfig, field: FieldIndex, text_input: &str) { + match field { + FieldIndex::StartUrl => { config.start_url = text_input.to_string(); } + FieldIndex::OutputDir => { config.output_dir = text_input.to_string(); } + FieldIndex::DelayMs => { + if let Ok(val) = text_input.trim().parse::() { + config.delay_ms = val; + } + } + FieldIndex::TypesInclude => { + let trimmed = text_input.trim(); + config.types = if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }; + } + FieldIndex::TypesExclude => { + let trimmed = text_input.trim(); + config.exclude_types = if trimmed.is_empty() { None } else { Some(trimmed.to_string()) }; + } + _ => {} + } +} + +fn handle_key_event( + config: &mut TuiConfig, + current_field: &mut FieldIndex, + text_input: &mut String, + errors: &mut Vec<(FieldIndex, String)>, + code: KeyCode, + modifiers: KeyModifiers, +) -> io::Result { + match code { + KeyCode::Esc + | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + disable_raw_mode()?; + execute!(io::stdout(), DisableMouseCapture)?; + return Ok(false); + } + KeyCode::Tab => { + save_input_to_field(config, *current_field, text_input); + *current_field = current_field.next(); + load_field_into_input(text_input, config, *current_field); + errors.clear(); + } + KeyCode::BackTab => { + save_input_to_field(config, *current_field, text_input); + *current_field = current_field.prev(); + load_field_into_input(text_input, config, *current_field); + errors.clear(); + } + KeyCode::Enter => { + process_enter(config, current_field, text_input, errors); + } + KeyCode::Char(ch) if current_field.is_text_field() => { + text_input.push(ch); + } + KeyCode::Backspace if current_field.is_text_field() => { + text_input.pop(); + } + KeyCode::Delete if current_field.is_text_field() => { + text_input.clear(); + } + _ => {} + } + + Ok(true) +} + +fn process_enter( + config: &mut TuiConfig, + current_field: &mut FieldIndex, + text_input: &mut String, + errors: &mut Vec<(FieldIndex, String)>, +) { + match *current_field { + FieldIndex::Subdomains => { + config.subdomains = !config.subdomains; + } + FieldIndex::Single => { + config.single = !config.single; + } + FieldIndex::NoScope => { + config.no_scope = !config.no_scope; + } + FieldIndex::NoDocConversion => { + config.no_doc_conversion = !config.no_doc_conversion; + } + FieldIndex::StartUrl => { + save_input_to_field(config, *current_field, text_input); + if config.start_url.trim().is_empty() { + errors.retain(|(f, _)| *f != FieldIndex::StartUrl); + errors.push((FieldIndex::StartUrl, "Required".into())); + } else { + errors.clear(); + *current_field = current_field.next(); + load_field_into_input(text_input, config, *current_field); + } + } + FieldIndex::OutputDir | FieldIndex::DelayMs => { + save_input_to_field(config, *current_field, text_input); + errors.clear(); + *current_field = current_field.next(); + load_field_into_input(text_input, config, *current_field); + } + FieldIndex::TypesInclude => { + save_input_to_field(config, *current_field, text_input); + let val = config.types.as_deref().unwrap_or(""); + match validate_comma_list(val) { + Ok(()) => { + errors.clear(); + *current_field = current_field.next(); + load_field_into_input(text_input, config, *current_field); + } + Err(e) => { + errors.retain(|(f, _)| *f != FieldIndex::TypesInclude); + errors.push((FieldIndex::TypesInclude, e)); + } + } + } + FieldIndex::TypesExclude => { + save_input_to_field(config, *current_field, text_input); + let val = config.exclude_types.as_deref().unwrap_or(""); + match validate_comma_list(val) { + Ok(()) => { + errors.clear(); + *current_field = current_field.next(); + load_field_into_input(text_input, config, *current_field); + } + Err(e) => { + errors.retain(|(f, _)| *f != FieldIndex::TypesExclude); + errors.push((FieldIndex::TypesExclude, e)); + } + } + } + FieldIndex::Submit => { + if config.start_url.trim().is_empty() { + errors.retain(|(f, _)| *f != FieldIndex::StartUrl); + errors.push((FieldIndex::StartUrl, "Required".into())); + return; + } + + if let Some(ref t) = config.types + && let Err(e) = validate_comma_list(t) + { + errors.retain(|(f, _)| *f != FieldIndex::TypesInclude); + errors.push((FieldIndex::TypesInclude, e)); + return; + } + if let Some(ref t) = config.exclude_types + && let Err(e) = validate_comma_list(t) + { + errors.retain(|(f, _)| *f != FieldIndex::TypesExclude); + errors.push((FieldIndex::TypesExclude, e)); + return; + } + + if config.types.is_some() && config.exclude_types.is_some() { + errors.push((FieldIndex::Submit, "types and exclude-types are mutually exclusive".into())); + return; + } + + disable_raw_mode().unwrap(); + execute!(io::stdout(), DisableMouseCapture).unwrap(); + std::process::exit(0); + } + } +}