use std::borrow::Cow;
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
use std::{fs, str};
use anyhow::{anyhow, Context};
use clap::{crate_version, App, Arg};
use directories::ProjectDirs;
use indicatif::{ProgressBar, ProgressStyle};
use isahc::prelude::*;
use regex::Regex;
use rusqlite::{Connection, Result};
use scraper::{ElementRef, Html, Selector};
use tempfile::NamedTempFile;
#[derive(Debug)]
struct Entry {
word: String,
content: String,
}
fn main() -> Result<(), anyhow::Error> {
let matches = App::new("gloss-word")
.version(crate_version!())
.author("Theo Beers <theo.beers@fu-berlin.de>")
.about("A simple English dictionary lookup utility")
.arg(
Arg::with_name("clear-cache")
.long("clear-cache")
.help("Delete cache directory and its contents"),
)
.arg(
Arg::with_name("etymology")
.short("e")
.long("etymology")
.help("Search for etymology instead of definition"),
)
.arg(
Arg::with_name("fetch-update")
.short("f")
.long("fetch-update")
.help("Fetch new data; update cache if applicable"),
)
.arg(
Arg::with_name("INPUT")
.help("The word or phrase to look up")
.required_unless("clear-cache")
.index(1),
)
.get_matches();
let clear_cache = matches.is_present("clear-cache");
let etym_mode = matches.is_present("etymology");
let force_fetch = matches.is_present("fetch-update");
let mut desired_word = String::new();
if !clear_cache {
desired_word = matches.value_of("INPUT").unwrap().to_lowercase();
}
let mut db_path = PathBuf::new();
let mut db_available = false;
let mut cache_hit = false;
if let Some(proj_dirs) = ProjectDirs::from("com", "theobeers", "gloss-word") {
let cache_dir = proj_dirs.cache_dir();
if clear_cache && cache_dir.exists() {
trash::delete(cache_dir)?;
eprintln!("Cache directory deleted");
return Ok(());
} else if clear_cache {
return Err(anyhow!("Cache directory not found"));
}
if !cache_dir.exists() {
let _ = fs::create_dir_all(cache_dir);
}
db_path.push(cache_dir);
db_path.push("entries.sqlite");
}
if let Ok(db_conn) = Connection::open(&db_path) {
db_available = true;
let _ = db_conn.execute(
"CREATE TABLE IF NOT EXISTS dictionary (
word TEXT UNIQUE NOT NULL,
content TEXT NOT NULL
)",
[],
);
let _ = db_conn.execute(
"CREATE TABLE IF NOT EXISTS etymology (
word TEXT UNIQUE NOT NULL,
content TEXT NOT NULL
)",
[],
);
if let Ok(entry) = query_db(db_conn, &desired_word, etym_mode) {
if force_fetch {
cache_hit = true
} else {
print!("{}", entry);
return Ok(());
}
}
}
let pb = ProgressBar::new_spinner();
pb.enable_steady_tick(80);
pb.set_style(
ProgressStyle::default_spinner()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
.template("{spinner} {msg}"),
);
pb.set_message("Fetching...");
let mut lookup_url: String;
if etym_mode {
lookup_url = "https://www.etymonline.com/word/".to_string();
lookup_url += &desired_word.replace(" ", "%20");
} else {
lookup_url = "https://www.thefreedictionary.com/".to_string();
lookup_url += &desired_word.replace(" ", "+");
}
let response_text = isahc::get(lookup_url)
.context("Failed to complete HTTP request")?
.text()
.context("Failed to read HTTP response body to string")?;
let re_thesaurus = Regex::new(r#"<div id="Thesaurus">"#).unwrap();
let chunks: Vec<&str> = re_thesaurus.split(&response_text).collect();
let parsed_chunk = Html::parse_fragment(chunks[0]);
let section_selector = match etym_mode {
true => Selector::parse(r#"div[class^="word--"]"#).unwrap(),
_ => Selector::parse(r#"div#Definition section[data-src="hm"]"#).unwrap(),
};
let section_vec: Vec<ElementRef> = parsed_chunk.select(§ion_selector).collect();
if !section_vec.is_empty() {
let mut results = String::new();
if etym_mode {
for section in section_vec.iter() {
results.push_str(§ion.html());
}
} else {
let element_selectors = Selector::parse("div.pseg, h2, hr.hmsep").unwrap();
for element in section_vec[0].select(&element_selectors) {
results.push_str(&element.html());
}
}
let final_output = pandoc_primary(etym_mode, results)?;
if db_available {
let _ = update_cache(
cache_hit,
db_path,
&desired_word,
etym_mode,
&final_output,
force_fetch,
);
}
pb.finish_and_clear();
print!("{}", final_output);
return Ok(());
}
if etym_mode {
pb.finish_and_clear();
return Err(anyhow!("Etymology not found"));
}
let suggestions_selector = Selector::parse("ul.suggestions li").unwrap();
let suggestions_vec: Vec<ElementRef> = parsed_chunk.select(&suggestions_selector).collect();
if !suggestions_vec.is_empty() {
let mut results = String::new();
for element in suggestions_vec.iter() {
results.push_str(&element.html());
}
let pandoc_output = pandoc_fallback(results)?;
pb.finish_and_clear();
println!("Did you mean:\n");
print!("{}", pandoc_output);
return Ok(());
}
pb.finish_and_clear();
Err(anyhow!("Definition not found"))
}
fn pandoc_fallback(results: String) -> Result<String, anyhow::Error> {
let mut pandoc_input = NamedTempFile::new().context("Failed to create tempfile")?;
write!(pandoc_input, "{}", results).context("Failed to write to tempfile")?;
let pandoc = Command::new("pandoc")
.arg(pandoc_input.path())
.arg("-f")
.arg("html+smart-native_divs")
.arg("-t")
.arg("plain")
.output()
.context("Failed to execute Pandoc")?;
let pandoc_output = str::from_utf8(&pandoc.stdout)
.context("Failed to convert Pandoc output to string")?
.to_string();
Ok(pandoc_output)
}
fn pandoc_plain(input: Cow<str>) -> Result<String, anyhow::Error> {
let mut input_file = NamedTempFile::new().context("Failed to create tempfile")?;
write!(input_file, "{}", input).context("Failed to write to tempfile")?;
let pandoc = Command::new("pandoc")
.arg(input_file.path())
.arg("-t")
.arg("plain")
.output()
.context("Failed to execute Pandoc")?;
let output = str::from_utf8(&pandoc.stdout)
.context("Failed to convert Pandoc output to string")?
.to_string();
Ok(output)
}
fn pandoc_primary(etym_mode: bool, results: String) -> Result<String, anyhow::Error> {
let final_output: String;
let mut input_file_1 = NamedTempFile::new().context("Failed to create tempfile")?;
write!(input_file_1, "{}", results).context("Failed to write to tempfile")?;
let pandoc_1 = Command::new("pandoc")
.arg(input_file_1.path())
.arg("-f")
.arg("html+smart-native_divs")
.arg("-t")
.arg("markdown")
.arg("--wrap=none")
.output()
.context("Failed to execute Pandoc")?;
let output_1 =
str::from_utf8(&pandoc_1.stdout).context("Failed to convert Pandoc output to string")?;
if etym_mode {
let re_quotes = Regex::new(r#"\\""#).unwrap();
let after_1 = re_quotes.replace_all(output_1, r#"""#);
let re_figures = Regex::new(r#"(?m)\n\n!\[.+$"#).unwrap();
let after_2 = re_figures.replace_all(&after_1, "");
final_output = pandoc_plain(after_2)?;
} else {
let re_list_1 = Regex::new(r"\n\*\*(?P<a>\d+\.)\*\*").unwrap();
let after_1 = re_list_1.replace_all(output_1, "\n$a");
let re_list_2 = Regex::new(r"\n\*\*(?P<b>[a-z]\.)\*\*").unwrap();
let after_2 = re_list_2.replace_all(&after_1, "\n $b");
final_output = pandoc_plain(after_2)?;
}
Ok(final_output)
}
fn query_db(
db_conn: Connection,
desired_word: &str,
etym_mode: bool,
) -> Result<String, rusqlite::Error> {
let mut query = String::new();
if etym_mode {
query.push_str("SELECT * FROM etymology WHERE word = '");
} else {
query.push_str("SELECT * FROM dictionary WHERE word = '");
}
query.push_str(desired_word);
query.push('\'');
let mut stmt = db_conn.prepare(&query)?;
let entry = stmt.query_row([], |row| {
Ok(Entry {
word: row.get(0)?,
content: row.get(1)?,
})
})?;
Ok(entry.content)
}
fn update_cache(
cache_hit: bool,
db_path: PathBuf,
desired_word: &str,
etym_mode: bool,
final_output: &str,
force_fetch: bool,
) -> Result<(), rusqlite::Error> {
let db_conn = Connection::open(db_path)?;
if force_fetch && cache_hit {
if etym_mode {
db_conn.execute(
"UPDATE etymology SET content = (?1) WHERE word = (?2)",
[final_output, desired_word],
)?;
} else {
db_conn.execute(
"UPDATE dictionary SET content = (?1) WHERE word = (?2)",
[final_output, desired_word],
)?;
}
} else if etym_mode {
db_conn.execute(
"INSERT INTO etymology (word, content) VALUES (?1, ?2)",
[desired_word, final_output],
)?;
} else {
db_conn.execute(
"INSERT INTO dictionary (word, content) VALUES (?1, ?2)",
[desired_word, final_output],
)?;
}
Ok(())
}