use bhdump::browsers::{self, Browser}; use bhdump::filter::{FilterConfig, SortKey, WhereExpr}; use bhdump::format::{self, OutputFormat}; use bhdump::timestamp; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::Shell; use std::process::ExitCode; /// Export browser history in JSON, CSV, and other formats. /// /// When no subcommand is given, bhdump exports history (same as "bhdump dump"). #[derive(Parser, Debug)] #[command(name = "bhdump", version, about, after_long_help = FILTER_HELP)] #[command(args_conflicts_with_subcommands = true)] struct Cli { #[command(subcommand)] command: Option, #[command(flatten)] dump: DumpArgs, } #[derive(Subcommand, Debug)] enum Command { /// Export browser history #[command(after_long_help = FILTER_HELP)] Dump(DumpArgs), /// List detected browsers and profiles Browsers, /// Generate shell completions Completions { /// Shell to generate completions for shell: Shell, }, /// Validate a CEL filter expression Validate { /// CEL expression to validate (e.g. 'url.contains("github")') #[arg(value_name = "EXPR")] expression: String, }, } #[derive(Parser, Debug, Default)] struct DumpArgs { /// Include only these browsers (can be repeated) #[arg(short, long = "browser", value_parser = parse_browser)] browsers: Vec, /// Include only these profiles (can be repeated, case-insensitive) #[arg(short, long = "profile")] profiles: Vec, /// Output format: json, jsonl, csv, tsv #[arg(short, long, default_value = "json", value_parser = parse_format)] format: OutputFormat, /// Output file (default: stdout) #[arg(short, long)] output: Option, /// Only entries after this time (ISO 8601, relative, or natural language) #[arg(long)] since: Option, /// Only entries before this time (ISO 8601, relative, or natural language) #[arg(long)] before: Option, /// Filter entries with a CEL expression (e.g. 'url.contains("github")') #[arg(short, long = "where", value_name = "EXPR")] where_expr: Option, /// Sort order: field name, prefix with - for descending (e.g. -visit_count) #[arg(short, long, value_parser = parse_sort)] sort: Option, /// Maximum number of entries to return #[arg(long)] limit: Option, /// Deduplicate by URL (keep most recent) #[arg(long)] dedup: bool, /// Include browser-internal URLs (chrome://, about:, etc.) #[arg(long)] include_internal: bool, /// Include noise domains (auth, tracking, redirects, CDNs, etc.) #[arg(long)] include_noise: bool, /// Output individual visits instead of per-URL summary #[arg(long)] visits: bool, /// Increase verbosity #[arg(short, long, action = clap::ArgAction::Count)] verbose: u8, /// Shorthand for --since (e.g. "today", "3 days ago", "last friday", "7d") #[arg(value_name = "SINCE")] since_positional: Option, } fn parse_browser(s: &str) -> Result { s.parse() } fn parse_format(s: &str) -> Result { s.parse() } fn parse_sort(s: &str) -> Result { s.parse() } const FILTER_HELP: &str = "\ \x1b[1;4mFilter Expressions\x1b[0m The --where flag accepts an expression in CEL (Common Expression Language) that is evaluated against each history entry. Only entries where the expression returns true are included in the output. \x1b[1mAvailable fields:\x1b[0m url string Full URL of the page domain string Hostname extracted from the URL title string Page title (empty string if absent) visit_count int Aggregate visit count (0 if absent) browser string Browser name (chrome, firefox, safari, ...) profile string Profile name visit_time timestamp When the page was visited \x1b[1mString methods:\x1b[0m s.contains(\"sub\") true if s contains the substring s.startsWith(\"prefix\") true if s starts with the prefix s.endsWith(\"suffix\") true if s ends with the suffix s.matches(\"regex\") true if s matches the regular expression s.size() length of the string \x1b[1mOperators:\x1b[0m == != < <= > >= comparison && || ! logical and, or, not + string concatenation in membership (e.g. browser in [\"chrome\", \"edge\"]) \x1b[1mTimestamp functions:\x1b[0m timestamp(\"2024-01-15T00:00:00Z\") parse an RFC 3339 timestamp \x1b[1mExamples:\x1b[0m --where 'url.contains(\"github.com\")' --where 'domain == \"github.com\"' --where 'title.matches(\"(?i)rust\") && visit_count > 5' --where 'browser == \"firefox\" || browser == \"safari\"' --where '!url.matches(\"reddit\\\\.com\")' --where 'visit_time > timestamp(\"2024-06-01T00:00:00Z\")' Use \"bhdump validate \" to check an expression without running a query. See https://cel-spec.dev for the full CEL specification. \x1b[1;4mSort Order\x1b[0m The --sort flag takes a field name, optionally prefixed with - for descending or + for ascending (ascending is the default). \x1b[1mSort fields:\x1b[0m url, title, time, count, browser, profile, domain \x1b[1mExamples:\x1b[0m --sort url alphabetical by URL --sort -count most-visited first --sort domain group by domain --sort -time most recent first (same as default) Without --sort, entries are ordered by visit_time descending (most recent first). Sorting is applied after filtering but before --limit. \x1b[1;4mTime Shorthands\x1b[0m A positional argument can be used as shorthand for --since. All of these also work with the --since and --before flags. \x1b[1mKeywords:\x1b[0m bhdump today entries from today (midnight UTC) bhdump yesterday entries from yesterday onward \x1b[1mCompact:\x1b[0m bhdump 7d 7 days ago bhdump 2w 2 weeks ago bhdump 3mo 3 months ago bhdump 1y 1 year ago bhdump 12h 12 hours ago \x1b[1mNatural language:\x1b[0m bhdump last-week last 7 days (midnight UTC) bhdump last-month last 30 days (midnight UTC) bhdump \"last friday\" last Friday bhdump \"3 days ago\" 3 days ago bhdump \"2 hours ago\" 2 hours ago \x1b[1mAbsolute dates:\x1b[0m bhdump 2024-01-01 January 1, 2024 bhdump \"April 1, 2024\" same, with month name bhdump 2024-01-15T10:30:00Z ISO 8601 with time"; fn main() -> ExitCode { let cli = Cli::parse(); match cli.command { Some(Command::Completions { shell }) => cmd_completions(shell), Some(Command::Browsers) => cmd_browsers(), Some(Command::Validate { expression }) => cmd_validate(&expression), Some(Command::Dump(args)) => cmd_dump(args), None => cmd_dump(cli.dump), } } fn cmd_completions(shell: Shell) -> ExitCode { let mut cmd = Cli::command(); clap_complete::generate(shell, &mut cmd, "bhdump", &mut std::io::stdout()); ExitCode::SUCCESS } fn cmd_browsers() -> ExitCode { let all_sources = browsers::discover(); if all_sources.is_empty() { eprintln!("No browsers detected."); return ExitCode::from(1); } for source in &all_sources { println!( "{}\t{}\t{}", source.browser, source.profile, source.db_path.display() ); } ExitCode::SUCCESS } const KNOWN_VARIABLES: &[&str] = &[ "browser", "domain", "profile", "title", "url", "visit_count", "visit_time", ]; fn cmd_validate(expression: &str) -> ExitCode { let compiled = match WhereExpr::compile(expression) { Ok(c) => c, Err(e) => { eprintln!("Invalid expression: {e}"); return ExitCode::from(2); } }; let (vars, _funcs) = compiled.references(); let unknown: Vec<_> = vars .iter() .filter(|v| !KNOWN_VARIABLES.contains(&v.as_str())) .collect(); if unknown.is_empty() { println!("Expression is valid."); } else { println!("Expression is valid, but references unknown variables:"); for var in &unknown { eprintln!( " Warning: unknown variable \"{var}\" (available: {})", KNOWN_VARIABLES.join(", ") ); } } ExitCode::SUCCESS } fn cmd_dump(args: DumpArgs) -> ExitCode { // Discover browsers let all_sources = browsers::discover(); // Filter sources by browser and profile selection let profiles_lower: Vec = args.profiles.iter().map(|p| p.to_lowercase()).collect(); let sources: Vec<_> = all_sources .into_iter() .filter(|s| { if !args.browsers.is_empty() && !args.browsers.contains(&s.browser) { return false; } if !profiles_lower.is_empty() && !profiles_lower.contains(&s.profile.to_lowercase()) { return false; } true }) .collect(); if sources.is_empty() { eprintln!("No matching browsers found."); return ExitCode::from(1); } // Resolve --since vs positional shorthand let since_raw = match (&args.since, &args.since_positional) { (Some(_), Some(_)) => { eprintln!("Error: cannot use both --since and a positional time argument"); return ExitCode::from(2); } (Some(s), None) | (None, Some(s)) => Some(s.as_str()), (None, None) => None, }; // Parse date filters let since = match since_raw.map(timestamp::parse_user_datetime) { Some(Ok(dt)) => Some(dt), Some(Err(e)) => { eprintln!("Error parsing --since: {e}"); return ExitCode::from(2); } None => None, }; let before = match args.before.as_deref().map(timestamp::parse_user_datetime) { Some(Ok(dt)) => Some(dt), Some(Err(e)) => { eprintln!("Error parsing --before: {e}"); return ExitCode::from(2); } None => None, }; // Read history from all sources let (entries, errors) = browsers::read_all(&sources, since, before, args.visits); // Report errors on stderr for err in &errors { eprintln!("Warning: {err}"); } if entries.is_empty() && errors.len() == sources.len() { eprintln!("All browsers failed to read."); return ExitCode::from(1); } // Build filter config let filter_config = match build_filter(&args) { Ok(f) => f, Err(e) => { eprintln!("Error: {e}"); return ExitCode::from(2); } }; // Apply filters let filtered = match filter_config.apply(entries) { Ok(f) => f, Err(e) => { eprintln!("Error evaluating --where expression: {e}"); return ExitCode::from(2); } }; // Write output let result = if let Some(ref path) = args.output { let file = match std::fs::File::create(path) { Ok(f) => f, Err(e) => { eprintln!("Error creating output file: {e}"); return ExitCode::from(1); } }; let mut writer = std::io::BufWriter::new(file); format::write_entries(&mut writer, &filtered, args.format) } else { let mut stdout = std::io::stdout().lock(); format::write_entries(&mut stdout, &filtered, args.format) }; if let Err(e) = result { eprintln!("Error writing output: {e}"); return ExitCode::from(1); } ExitCode::SUCCESS } fn build_filter(args: &DumpArgs) -> Result { let where_expr = args .where_expr .as_deref() .map(WhereExpr::compile) .transpose() .map_err(|e| format!("Invalid --where expression: {e}"))?; Ok(FilterConfig { where_expr, limit: args.limit, deduplicate: args.dedup, include_internal: args.include_internal, include_noise: args.include_noise, sort: args.sort, }) }