105 lines
3.1 KiB
Rust
105 lines
3.1 KiB
Rust
use pulldown_cmark::{Event, LinkType, Parser, Tag, TagEnd};
|
||
use serde::Serialize;
|
||
use std::collections::HashMap;
|
||
use std::env;
|
||
use std::fs;
|
||
use std::io::Write;
|
||
use std::process::{Command, Stdio};
|
||
|
||
#[derive(Serialize, Clone)]
|
||
struct Link {
|
||
url: String,
|
||
title: String,
|
||
}
|
||
|
||
fn main() -> std::io::Result<()> {
|
||
let path = env::args().nth(1).unwrap_or_else(|| {
|
||
eprintln!("❌ Usage: select_bookmark <input.md>");
|
||
std::process::exit(1);
|
||
});
|
||
|
||
let markdown = fs::read_to_string(path)?;
|
||
let parser = Parser::new(&markdown);
|
||
|
||
let mut links_by_heading: HashMap<String, Vec<Link>> = HashMap::new();
|
||
let mut current_heading = "No Heading".to_string();
|
||
let mut heading_buf = String::new();
|
||
let mut current_link: Option<Link> = None;
|
||
|
||
for event in parser {
|
||
match event {
|
||
Event::Start(Tag::Heading { .. }) => heading_buf.clear(),
|
||
Event::End(TagEnd::Heading(_)) => {
|
||
if !heading_buf.trim().is_empty() {
|
||
current_heading = heading_buf.trim().to_string();
|
||
}
|
||
}
|
||
Event::Start(Tag::Link {
|
||
link_type: LinkType::Inline,
|
||
dest_url,
|
||
..
|
||
}) => {
|
||
current_link = Some(Link {
|
||
url: dest_url.into_string(),
|
||
title: String::new(),
|
||
});
|
||
}
|
||
Event::End(TagEnd::Link) => {
|
||
if let Some(link) = current_link.take() {
|
||
links_by_heading
|
||
.entry(current_heading.clone())
|
||
.or_default()
|
||
.push(link);
|
||
}
|
||
}
|
||
Event::Text(text) => {
|
||
if let Some(link) = current_link.as_mut() {
|
||
link.title.push_str(&text);
|
||
} else {
|
||
heading_buf.push_str(&text);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
let entries: Vec<(String, String)> = links_by_heading
|
||
.into_iter()
|
||
.flat_map(|(heading, links)| {
|
||
links.into_iter().map(move |link| {
|
||
let display = format!("{heading}: {} ({})", link.title, link.url);
|
||
(display, link.url)
|
||
})
|
||
})
|
||
.collect();
|
||
|
||
if entries.is_empty() {
|
||
println!("ℹ️ No links found.");
|
||
return Ok(());
|
||
}
|
||
|
||
let mut child = Command::new("bemenu")
|
||
.stdin(Stdio::piped())
|
||
.stdout(Stdio::piped())
|
||
.spawn()
|
||
.expect("Failed to launch bemenu");
|
||
|
||
{
|
||
let stdin = child.stdin.as_mut().expect("Couldn't write to bemenu");
|
||
for (display, _) in &entries {
|
||
writeln!(stdin, "{}", display)?;
|
||
}
|
||
}
|
||
|
||
let output = child.wait_with_output()?;
|
||
let selected = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||
|
||
if let Some((_, url)) = entries.iter().find(|(label, _)| label == &selected) {
|
||
println!("🌐 Opening: {}", url);
|
||
Command::new("xdg-open").arg(url).spawn()?;
|
||
} else {
|
||
println!("❌ Selection not found or canceled.");
|
||
}
|
||
|
||
Ok(())
|
||
}
|