//! 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, 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 { 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> { 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> { 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 { 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(&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, antonyms: Vec, } 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(()) }