aboutsummaryrefslogtreecommitdiffstats
path: root/src/commands.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/commands.rs')
-rw-r--r--src/commands.rs311
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(())
+}