diff options
Diffstat (limited to 'src/commands.rs')
| -rw-r--r-- | src/commands.rs | 311 |
1 files changed, 311 insertions, 0 deletions
diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..d82970e --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,311 @@ +//! The commands, and the path a lookup follows: +//! cache → rae-api.com → dle.rae.es → offline suggestions. + +use anyhow::{bail, Context, Result}; + +use crate::cache::Cache; +use crate::cli::Source; +use crate::client::{percent_encode, Http, BROWSER_UA}; +use crate::config::Config; +use crate::model::Entry; +use crate::output::{self, Style}; +use crate::{dle, raeapi, suggest, wiktionary}; + +const DLE_BASE: &str = "https://dle.rae.es"; + +pub struct Ctx { + pub cfg: Config, + pub http: Http, + pub cache: Option<Cache>, + pub style: Style, + pub json: bool, + pub expand_abbr: bool, + pub refresh: bool, + pub source: Source, +} + +impl Ctx { + /// Looks a word up. If it does not exist, returns a ready-to-print error + /// with the closest suggestions from the local dictionary. + pub fn lookup(&self, word: &str) -> Result<Entry> { + let word = word.trim(); + if word.is_empty() { + bail!("hay que indicar una palabra"); + } + + if !self.refresh { + if let Some(c) = &self.cache { + if let Some(hit) = c.get(word)? { + return Ok(hit); + } + } + } + + let entry = self.fetch(word)?; + match entry { + Some(e) => { + if let Some(c) = &self.cache { + // A failed write must not stop the word from being shown. + let _ = c.put(word, &e); + } + Ok(e) + } + None => bail!("{}", self.not_found_message(word)), + } + } + + /// Downloads the entry from the chosen source. `Ok(None)` = no such word. + /// + /// The default order is rae-api first: it serves the DLE content as JSON + /// and accepts automated clients. dle.rae.es is the fallback because it + /// answers with a Cloudflare challenge to anything but a browser, so most + /// of the time it does not respond. + fn fetch(&self, word: &str) -> Result<Option<Entry>> { + match self.source { + Source::Dle => self.fetch_dle(word), + Source::RaeApi => raeapi::lookup(&self.http, word), + Source::Auto => match raeapi::lookup(&self.http, word) { + Ok(Some(e)) => Ok(Some(e)), + Ok(None) => Ok(None), + // The API failed: try the site, in case this client gets through. + Err(first) => self.fetch_dle(word).map_err(|_| first), + }, + } + } + + fn fetch_dle(&self, word: &str) -> Result<Option<Entry>> { + let url = format!("{DLE_BASE}/{}", percent_encode(word)); + let (status, body) = self.http.get(&url, BROWSER_UA)?; + if status == 403 { + bail!( + "dle.rae.es rechazó la petición con un desafío de Cloudflare, \ + que solo resuelve un navegador" + ); + } + if status != 200 { + bail!("dle.rae.es respondió {status}"); + } + if dle::is_not_found(&body) { + return Ok(None); + } + match dle::parse(&body) { + Some(e) if !e.senses.is_empty() => Ok(Some(e)), + // A valid page we cannot read: let the caller decide. + _ => bail!("no se pudo interpretar la página del DLE para «{word}»"), + } + } + + fn not_found_message(&self, word: &str) -> String { + let mut msg = format!("«{word}» no está en el Diccionario"); + for s in self.suggestions(word) { + msg.push_str(&format!("\n ¿quiso decir {}?", self.style.cyan(&s))); + } + msg + } + + /// Similar words, from the system hunspell dictionary. + fn suggestions(&self, word: &str) -> Vec<String> { + let path = self + .cfg + .dictionary + .clone() + .or_else(suggest::find_dictionary); + let Some(path) = path else { + return vec![]; + }; + let Ok(words) = suggest::load_words(&path) else { + return vec![]; + }; + suggest::closest(word, &words, 4) + } + + fn print_json<T: serde::Serialize>(&self, v: &T) -> Result<()> { + println!("{}", serde_json::to_string_pretty(v)?); + Ok(()) + } +} + +pub fn define(ctx: &Ctx, word: &str) -> Result<()> { + let e = ctx.lookup(word)?; + if ctx.json { + return ctx.print_json(&e); + } + print!("{}", output::entry(&e, &ctx.style, ctx.expand_abbr)); + Ok(()) +} + +pub fn synonyms(ctx: &Ctx, word: &str) -> Result<()> { + let e = ctx.lookup(word)?; + if ctx.json { + #[derive(serde::Serialize)] + struct Out<'a> { + word: &'a str, + synonyms: Vec<String>, + antonyms: Vec<String>, + } + return ctx.print_json(&Out { + word: &e.word, + synonyms: e.all_synonyms(), + antonyms: e.all_antonyms(), + }); + } + + if !e.has_synonyms() && !e.has_antonyms() { + println!( + "{}", + ctx.style + .dim(&format!("«{}» no tiene sinónimos registrados", e.word)) + ); + return Ok(()); + } + + println!("{}\n", ctx.style.heading(&e.word)); + // Shown per sense: the synonyms of «casa» are not the same for + // «building» as for «lineage». + for s in &e.senses { + if s.synonyms.is_empty() && s.antonyms.is_empty() { + continue; + } + let num = s.number.clone().unwrap_or_default(); + let head = format!( + "{}. {}", + num, + output::truncate(&s.text, ctx.style.width - 6) + ); + println!("{}", ctx.style.dim(&head)); + if !s.synonyms.is_empty() { + let joined = output::superscript_homographs(&s.synonyms.join(", ")); + let line = format!("{} {joined}", ctx.style.green("Sin.:")); + println!( + "{}", + output::wrap(&line, ctx.style.width, " ", " ") + ); + } + if !s.antonyms.is_empty() { + let joined = output::superscript_homographs(&s.antonyms.join(", ")); + let line = format!("{} {joined}", ctx.style.red("Ant.:")); + println!( + "{}", + output::wrap(&line, ctx.style.width, " ", " ") + ); + } + println!(); + } + Ok(()) +} + +pub fn conjugate(ctx: &Ctx, verb: &str, mood: Option<&str>) -> Result<()> { + let Some(mut c) = wiktionary::conjugation(&ctx.http, verb)? else { + bail!("«{verb}» no tiene tabla de conjugación (¿no es un verbo?)"); + }; + + if let Some(m) = mood { + let needle = suggest::fold(m); + c.groups + .retain(|g| suggest::fold(&g.title).contains(&needle)); + if c.groups.is_empty() { + bail!("no hay ningún modo que se parezca a «{m}»"); + } + } + + if ctx.json { + return ctx.print_json(&c); + } + print!("{}", output::conjugation(&c, &ctx.style)); + Ok(()) +} + +/// The word of the day and the random word come from the rae-api index and +/// are then defined through the normal path. +pub fn featured(ctx: &Ctx, daily: bool) -> Result<()> { + let word = if daily { + raeapi::word_of_the_day(&ctx.http).context("no se pudo obtener la palabra del día")? + } else { + raeapi::random_word(&ctx.http).context("no se pudo obtener una palabra al azar")? + }; + define(ctx, &word) +} + +pub fn search(ctx: &Ctx, text: &str, max: usize) -> Result<()> { + let Some(cache) = &ctx.cache else { + bail!("`buscar` mira dentro de la caché, y está desactivada con --sin-cache"); + }; + let hits = cache.search(text, max)?; + if hits.is_empty() { + println!( + "{}", + ctx.style.dim(&format!( + "nada con «{text}» entre las {} palabras guardadas", + cache.count()? + )) + ); + return Ok(()); + } + + if ctx.json { + let words: Vec<&str> = hits.iter().map(|(w, _)| w.as_str()).collect(); + return ctx.print_json(&words); + } + + let needle = suggest::fold(text); + for (word, json) in &hits { + let entry: Entry = match serde_json::from_str(json) { + Ok(e) => e, + Err(_) => continue, + }; + // Show the sense where the searched text appears. + let sense = entry + .senses + .iter() + .find(|s| suggest::fold(&s.text).contains(&needle)) + .or_else(|| entry.senses.first()); + let gloss = sense + .map(|s| { + let text = output::superscript_homographs(&s.text); + output::truncate(&text, ctx.style.width.saturating_sub(word.len() + 4)) + }) + .unwrap_or_default(); + println!("{} {}", ctx.style.cyan(word), ctx.style.dim(&gloss)); + } + Ok(()) +} + +pub fn cache_info(ctx: &Ctx) -> Result<()> { + let Some(cache) = &ctx.cache else { + bail!("la caché está desactivada con --sin-cache"); + }; + println!("entradas: {}", cache.count()?); + println!("archivo: {}", ctx.cfg.cache.display()); + let dict = ctx.cfg.dictionary.clone().or_else(suggest::find_dictionary); + println!( + "sugerencias: {}", + dict.map(|p| p.display().to_string()) + .unwrap_or_else(|| "sin diccionario de hunspell instalado".into()) + ); + Ok(()) +} + +pub fn cache_list(ctx: &Ctx, max: usize) -> Result<()> { + let Some(cache) = &ctx.cache else { + bail!("la caché está desactivada con --sin-cache"); + }; + let rows = cache.list(max)?; + if rows.is_empty() { + println!("{}", ctx.style.dim("la caché está vacía")); + return Ok(()); + } + for (word, source, ts) in rows { + let meta = format!("{} {}", crate::cache::format_date(ts), source); + println!("{:24} {}", word, ctx.style.dim(&meta)); + } + Ok(()) +} + +pub fn cache_clear(ctx: &Ctx) -> Result<()> { + let Some(cache) = &ctx.cache else { + bail!("la caché está desactivada con --sin-cache"); + }; + let n = cache.clear()?; + println!("borradas {n} entradas"); + Ok(()) +} |