use anyhow::Result;
use std::fs;
use std::path::{Path, PathBuf};
use crate::browser::Browser;
use crate::deduplication::MergeStrategy;
use crate::exporter::export_data;
use crate::processor::{BookmarkProcessor, ProcessingConfig};
use crate::{config, deduplication, exporter, graph, graph_output, organization, utils};
#[derive(Debug)]
pub struct GraphParams {
pub min_threshold: usize,
pub detail: String,
pub max_per_domain: Option<usize>,
pub max_total: Option<usize>,
pub domain_only: bool,
pub since: Option<String>,
}
impl GraphParams {
pub fn to_config(&self) -> Result<graph::GraphConfig> {
let detail_level = match self.detail.to_lowercase().as_str() {
"overview" => graph::DetailLevel::Overview,
"standard" => graph::DetailLevel::Standard,
"detailed" => graph::DetailLevel::Detailed,
_ => {
return Err(anyhow::anyhow!(
"Invalid detail level: {}. Use overview, standard, or detailed",
self.detail
))
}
};
let min_date = if let Some(ref date_str) = self.since {
Some(
chrono::DateTime::parse_from_rfc3339(date_str)
.map_err(|_| {
anyhow::anyhow!(
"Invalid date format: {}. Use ISO 8601 format (e.g., 2024-01-01T00:00:00Z)",
date_str
)
})?
.with_timezone(&chrono::Utc),
)
} else {
None
};
Ok(graph::GraphConfig {
min_domain_threshold: self.min_threshold,
detail_level,
max_bookmarks_per_domain: self.max_per_domain,
max_total_bookmarks: self.max_total,
domain_only: self.domain_only,
min_date,
..Default::default()
})
}
fn print_summary(&self, detail_level: &graph::DetailLevel, min_date: &Option<chrono::DateTime<chrono::Utc>>) {
println!("Graph configuration:");
println!(" Detail level: {:?}", detail_level);
println!(" Min domain threshold: {}", self.min_threshold);
if let Some(max_per) = self.max_per_domain {
println!(" Max bookmarks per domain: {}", max_per);
}
if let Some(max_tot) = self.max_total {
println!(" Max total bookmarks: {}", max_tot);
}
if self.domain_only {
println!(" Domain-only mode: enabled");
}
if let Some(date) = min_date {
println!(" Only bookmarks newer than: {}", date);
}
}
}
pub fn export_all_browsers(
data_type: &str,
output_dir: Option<PathBuf>,
profile_dir: Option<PathBuf>,
) -> Result<()> {
let browsers = ["Chrome", "Firefox", "Safari", "Edge"];
let output_dir = output_dir.unwrap_or_else(|| PathBuf::from("."));
fs::create_dir_all(&output_dir)?;
println!("Scanning browsers...");
let mut found = Vec::new();
let export_types = match data_type {
"both" => vec!["bookmarks", "history"],
_ => vec![data_type],
};
for browser_name in browsers {
if let Ok(browser) = Browser::from_str(browser_name) {
if let Ok(profiles) = browser.find_profiles(profile_dir.as_deref()) {
if !profiles.is_empty() {
found.push(browser_name);
for export_type in &export_types {
let output_file = output_dir.join(format!(
"{}-{}.yaml",
browser_name.to_lowercase(),
export_type
));
println!("Exporting {} ({})...", browser_name, export_type);
match export_data(
browser_name,
export_type,
Some(output_file),
profile_dir.clone(),
) {
Ok(_) => println!(" ✓ Success"),
Err(e) => println!(" ✗ Failed: {}", e),
}
}
}
}
}
}
if found.is_empty() {
println!("No browsers found");
} else {
println!("\nExported: {}", found.join(", "));
}
Ok(())
}
pub fn process_bookmarks(
input: &PathBuf,
output: &PathBuf,
mode: &str,
strategy: &str,
_org_strategy: &str,
preview: bool,
backup: bool,
) -> Result<()> {
println!("Loading {}...", input.display());
let content = fs::read_to_string(input)?;
let browser_data: Vec<exporter::BrowserData> = serde_yaml::from_str(&content)?;
let mut all_bookmarks = Vec::new();
for data in browser_data {
if let Some(bookmarks) = data.bookmarks {
all_bookmarks.extend(bookmarks);
}
}
println!("Loaded {} bookmarks", all_bookmarks.len());
let merge_strategy = match strategy {
"first" => MergeStrategy::KeepFirst,
"last" => MergeStrategy::KeepLast,
"recent" => MergeStrategy::KeepMostRecent,
"merge" => MergeStrategy::MergeMetadata,
_ => return Err(anyhow::anyhow!("Invalid strategy: {}", strategy)),
};
let dedupe_enabled = mode == "dedupe" || mode == "both";
let config = ProcessingConfig {
deduplication_config: deduplication::DeduplicationConfig {
merge_strategy,
normalize_urls: dedupe_enabled,
..Default::default()
},
organization_config: organization::OrganizationConfig {
organize_by_domain: _org_strategy == "domain" || _org_strategy == "custom",
organize_by_category: _org_strategy == "category" || _org_strategy == "custom",
..Default::default()
},
dry_run: preview,
backup_original: backup,
};
let processor = BookmarkProcessor::new(config);
let result = processor.process_bookmarks(&all_bookmarks)?;
if !preview {
processor.export_processed_bookmarks(&result.processed_bookmarks, output)?;
}
println!(
"Original: {} | Final: {} | Duplicates removed: {}",
result.processing_summary.original_count,
result.processing_summary.final_count,
result.processing_summary.duplicates_removed
);
Ok(())
}
pub fn generate_graph(
browser: &str,
data_type: &str,
format: &str,
output: PathBuf,
params: GraphParams,
) -> Result<()> {
println!("Generating knowledge graph...");
let (bookmarks, history) = exporter::load_browser_data(browser, data_type)?;
let config = params.to_config()?;
let detail_level = config.detail_level;
let min_date = config.min_date;
params.print_summary(&detail_level, &min_date);
let mut builder = graph::GraphBuilder::new(config);
let graph = match data_type {
"bookmarks" => builder.from_bookmarks(&bookmarks)?,
"history" => builder.from_history(&history)?,
"both" => builder.from_both(&bookmarks, &history)?,
_ => return Err(anyhow::anyhow!("Invalid data type")),
};
if format == "html" {
let (html_path, data_path) = graph_output::write_html_output(&graph, &output)?;
graph_output::print_output_summary(&html_path, &data_path, &graph);
utils::open_file(&html_path)?;
} else {
write_simple_format(&graph, &output, format)?;
}
println!("✓ Graph generated: {}", output.display());
println!(
" Nodes: {} (bookmarks: {}, domains: {}, folders: {})",
graph.metadata.total_nodes,
graph.metadata.bookmark_count,
graph.metadata.domain_count,
graph.metadata.folder_count
);
println!(" Edges: {}", graph.metadata.total_edges);
Ok(())
}
fn write_simple_format(graph: &graph::KnowledgeGraph, output: &Path, format: &str) -> Result<()> {
let content = match format {
"dot" => graph::formats::to_dot(graph),
"json" => graph::formats::to_json(graph),
"gexf" => graph::formats::to_gexf(graph),
_ => {
return Err(anyhow::anyhow!(
"Invalid format: {}. Use dot, json, gexf, or html",
format
))
}
};
fs::write(output, content)?;
Ok(())
}
pub fn handle_config(show: bool, create_sample: Option<PathBuf>, list_rules: bool) -> Result<()> {
if let Some(path) = create_sample {
config::AppConfig::create_sample_config(&path)?;
println!("Created: {}", path.display());
return Ok(());
}
if show {
let config = config::AppConfig::load_or_create()?;
println!("{}", serde_yaml::to_string(&config)?);
return Ok(());
}
if list_rules {
let config = config::AppConfig::load_or_create()?;
let rules = config.list_rules();
if rules.is_empty() {
println!("No custom rules");
} else {
for rule in rules {
println!("{}: {} -> {}", rule.name, rule.pattern, rule.folder);
}
}
return Ok(());
}
println!("Config commands:");
println!(" --show Show current configuration");
println!(" --create-sample Create sample config file");
println!(" --list-rules List custom organization rules");
Ok(())
}
pub fn list_all_browsers() -> Result<()> {
println!("Available browsers:");
for browser_name in &["Chrome", "Firefox", "Safari", "Edge"] {
if let Ok(browser) = Browser::from_str(browser_name) {
if let Ok(profiles) = browser.find_profiles(None) {
println!(" {}: {} profile(s)", browser_name, profiles.len());
}
}
}
Ok(())
}
pub fn list_browser_profiles(browser_name: &str) -> Result<()> {
let browser = Browser::from_str(browser_name)?;
let profiles = browser.find_profiles(None)?;
if profiles.is_empty() {
println!("No profiles found for {}", browser_name);
} else {
println!("Profiles for {}:", browser_name);
for (i, p) in profiles.iter().enumerate() {
println!(" {}: {}", i + 1, p.display());
}
}
Ok(())
}