diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/cache.rs | 244 | ||||
| -rw-r--r-- | src/cli.rs | 141 | ||||
| -rw-r--r-- | src/client.rs | 85 | ||||
| -rw-r--r-- | src/commands.rs | 311 | ||||
| -rw-r--r-- | src/config.rs | 134 | ||||
| -rw-r--r-- | src/dle.rs | 309 | ||||
| -rw-r--r-- | src/html.rs | 235 | ||||
| -rw-r--r-- | src/main.rs | 96 | ||||
| -rw-r--r-- | src/model.rs | 150 | ||||
| -rw-r--r-- | src/output.rs | 404 | ||||
| -rw-r--r-- | src/raeapi.rs | 441 | ||||
| -rw-r--r-- | src/suggest.rs | 221 | ||||
| -rw-r--r-- | src/wiktionary.rs | 270 |
13 files changed, 3041 insertions, 0 deletions
diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..45d66a4 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,244 @@ +//! Local SQLite cache: anything looked up once is read back without network. +//! +//! Lives in `~/.cache/drae/entries.db` (or `$XDG_CACHE_HOME`). It stores the +//! already-normalized entry as JSON, so it survives changes to the DLE markup +//! and lets the user search inside definitions already downloaded. + +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::model::Entry; + +pub struct Cache { + conn: Connection, +} + +impl Cache { + pub fn open(path: &Path) -> Result<Self> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir) + .with_context(|| format!("no se pudo crear {}", dir.display()))?; + } + let conn = Connection::open(path) + .with_context(|| format!("no se pudo abrir la caché en {}", path.display()))?; + conn.execute_batch( + "PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS entries ( + key TEXT PRIMARY KEY, + word TEXT NOT NULL, + json TEXT NOT NULL, + source TEXT NOT NULL, + fetched_at INTEGER NOT NULL + );", + ) + .context("no se pudo preparar la caché")?; + Ok(Cache { conn }) + } + + /// Default path: `$XDG_CACHE_HOME/drae/entries.db`. + pub fn default_path() -> PathBuf { + let base = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache"))) + .unwrap_or_else(|| PathBuf::from(".cache")); + base.join("drae").join("entries.db") + } + + pub fn get(&self, word: &str) -> Result<Option<Entry>> { + let json: Option<String> = self + .conn + .query_row( + "SELECT json FROM entries WHERE key = ?1", + params![key(word)], + |r| r.get(0), + ) + .optional()?; + // An entry written by an older version of the model is silently + // ignored and downloaded again. + Ok(json.and_then(|j| serde_json::from_str(&j).ok())) + } + + pub fn put(&self, word: &str, entry: &Entry) -> Result<()> { + self.conn.execute( + "INSERT INTO entries (key, word, json, source, fetched_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(key) DO UPDATE SET + json = excluded.json, + source = excluded.source, + fetched_at = excluded.fetched_at", + params![ + key(word), + entry.word, + serde_json::to_string(entry)?, + entry.source, + now(), + ], + )?; + Ok(()) + } + + /// Stored words, most recent first. + pub fn list(&self, limit: usize) -> Result<Vec<(String, String, i64)>> { + let mut stmt = self.conn.prepare( + "SELECT word, source, fetched_at FROM entries + ORDER BY fetched_at DESC LIMIT ?1", + )?; + let rows = stmt + .query_map(params![limit as i64], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + })? + .collect::<rusqlite::Result<Vec<_>>>()?; + Ok(rows) + } + + /// Searches for a text inside the definitions already downloaded. + pub fn search(&self, needle: &str, limit: usize) -> Result<Vec<(String, String)>> { + let mut stmt = self.conn.prepare( + "SELECT word, json FROM entries + WHERE json LIKE ?1 ESCAPE '\\' + ORDER BY word LIMIT ?2", + )?; + let pattern = format!("%{}%", escape_like(needle)); + let rows = stmt + .query_map(params![pattern, limit as i64], |r| { + Ok((r.get(0)?, r.get(1)?)) + })? + .collect::<rusqlite::Result<Vec<_>>>()?; + Ok(rows) + } + + pub fn count(&self) -> Result<i64> { + Ok(self + .conn + .query_row("SELECT COUNT(*) FROM entries", [], |r| r.get(0))?) + } + + pub fn clear(&self) -> Result<usize> { + let n = self.conn.execute("DELETE FROM entries", [])?; + self.conn.execute_batch("VACUUM;")?; + Ok(n) + } +} + +/// Lookup key: lowercase, so «Casa» and «casa» share an entry. +fn key(word: &str) -> String { + word.trim().to_lowercase() +} + +fn escape_like(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") +} + +/// Formats a Unix timestamp as `YYYY-MM-DD` without a date library, using +/// Howard Hinnant's civil-from-days algorithm. +pub fn format_date(ts: i64) -> String { + let days = ts.div_euclid(86_400); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!("{y:04}-{m:02}-{d:02}") +} + +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Sense; + + fn entry(word: &str, text: &str) -> Entry { + Entry { + word: word.into(), + etymology: None, + senses: vec![Sense { + text: text.into(), + ..Default::default() + }], + locutions: vec![], + conjugation: None, + source: "dle".into(), + } + } + + fn cache() -> Cache { + Cache::open(Path::new(":memory:")).unwrap() + } + + #[test] + fn stores_and_reads_back_an_entry() { + let c = cache(); + c.put("casa", &entry("casa", "Edificio para habitar.")) + .unwrap(); + let got = c.get("casa").unwrap().expect("should be in the cache"); + assert_eq!(got.word, "casa"); + assert_eq!(c.count().unwrap(), 1); + } + + #[test] + fn lookup_is_case_insensitive() { + let c = cache(); + c.put("Casa", &entry("casa", "Edificio.")).unwrap(); + assert!(c.get("casa").unwrap().is_some()); + assert!(c.get("CASA").unwrap().is_some()); + } + + #[test] + fn writing_the_same_word_twice_replaces_it() { + let c = cache(); + c.put("casa", &entry("casa", "vieja")).unwrap(); + c.put("casa", &entry("casa", "nueva")).unwrap(); + assert_eq!(c.count().unwrap(), 1); + assert_eq!(c.get("casa").unwrap().unwrap().senses[0].text, "nueva"); + } + + #[test] + fn searches_inside_stored_definitions() { + let c = cache(); + c.put("casa", &entry("casa", "Edificio para habitar.")) + .unwrap(); + c.put("perro", &entry("perro", "Mamífero doméstico.")) + .unwrap(); + let hits = c.search("Edificio", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].0, "casa"); + } + + #[test] + fn like_wildcards_in_the_query_are_literal() { + let c = cache(); + c.put("casa", &entry("casa", "Edificio.")).unwrap(); + // Unescaped, "%" would match everything. + assert!(c.search("%", 10).unwrap().is_empty()); + } + + #[test] + fn formats_a_unix_timestamp_as_a_date() { + assert_eq!(format_date(0), "1970-01-01"); + assert_eq!(format_date(1_757_000_000), "2025-09-04"); + } + + #[test] + fn clear_empties_the_cache() { + let c = cache(); + c.put("casa", &entry("casa", "Edificio.")).unwrap(); + c.clear().unwrap(); + assert_eq!(c.count().unwrap(), 0); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..9f23838 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,141 @@ +//! Command-line definition. +//! +//! The tool is a Spanish dictionary for Spanish speakers, so every command, +//! flag and help text the user sees is in Spanish. Rust names are in English +//! and mapped explicitly (`name = "..."`, `long = "..."`). + +use std::path::PathBuf; + +use clap::{Parser, Subcommand, ValueEnum}; + +#[derive(Parser)] +#[command( + name = "drae", + version, + about = "Diccionario de la lengua española en la terminal", + long_about = "drae — consulta el Diccionario de la lengua española (RAE) sin salir de la terminal.\n\ + Sin orden, define la palabra que se le pase: `drae casa`.\n\ + Lo consultado queda en caché, así que la segunda vez responde sin red.", + args_conflicts_with_subcommands = true +)] +pub struct Cli { + /// La palabra a definir (equivale a `drae definir <palabra>`) + #[arg(value_name = "PALABRA")] + pub word: Option<String>, + + #[command(subcommand)] + pub cmd: Option<Cmd>, + + /// Archivo de configuración (por defecto ~/.config/drae/config.toml) + #[arg(long, global = true, value_name = "ARCHIVO")] + pub config: Option<PathBuf>, + + /// Imprime JSON en lugar del texto formateado + #[arg(long, global = true)] + pub json: bool, + + /// Cuándo colorear la salida + #[arg(long, global = true, value_enum, default_value_t = Color::Auto)] + pub color: Color, + + /// Ancho de salida en columnas (por defecto, el de la terminal) + #[arg(long = "ancho", global = true, value_name = "N")] + pub width: Option<usize>, + + /// Despliega el significado de las abreviaturas (f. = nombre femenino) + #[arg(long = "abreviaturas", short = 'a', global = true)] + pub expand_abbr: bool, + + /// Ignora la caché y vuelve a descargar + #[arg(long = "actualizar", short = 'A', global = true)] + pub refresh: bool, + + /// No leer ni escribir la caché + #[arg(long = "sin-cache", global = true)] + pub no_cache: bool, + + /// De dónde leer: auto (rae-api con respaldo), dle o rae-api + #[arg(long = "fuente", global = true, value_enum)] + pub source: Option<Source>, +} + +#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Color { + Auto, + Always, + Never, +} + +#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum Source { + Auto, + Dle, + #[value(name = "rae-api")] + RaeApi, +} + +#[derive(Subcommand)] +pub enum Cmd { + /// Define una palabra. Úsalo cuando la palabra coincida con el nombre de + /// una orden: `drae definir sin` + #[command(name = "definir", alias = "def")] + Define { + #[arg(value_name = "PALABRA")] + word: String, + }, + + /// Sinónimos y antónimos de una palabra + #[command(name = "sinonimos", alias = "sin")] + Synonyms { + #[arg(value_name = "PALABRA")] + word: String, + }, + + /// Conjugación completa de un verbo + #[command(name = "conjugar", alias = "conj")] + Conjugate { + #[arg(value_name = "VERBO")] + verb: String, + /// Solo este modo: indicativo, subjuntivo, imperativo… + #[arg(long = "modo", value_name = "MODO")] + mood: Option<String>, + }, + + /// La palabra del día que publica la RAE + #[command(name = "dia")] + WordOfTheDay, + + /// Una palabra al azar del diccionario + #[command(name = "azar")] + Random, + + /// Busca un texto dentro de las definiciones ya guardadas en caché + #[command(name = "buscar")] + Search { + #[arg(value_name = "TEXTO")] + text: String, + #[arg(long, default_value_t = 20)] + max: usize, + }, + + /// Gestiona la caché local + Cache { + #[command(subcommand)] + action: CacheCmd, + }, +} + +#[derive(Subcommand)] +pub enum CacheCmd { + /// Cuántas entradas hay y dónde viven + Info, + /// Las últimas palabras consultadas + #[command(name = "listar")] + List { + #[arg(long, default_value_t = 30)] + max: usize, + }, + /// Borra todo lo guardado + #[command(name = "limpiar")] + Clear, +} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..37205bf --- /dev/null +++ b/src/client.rs @@ -0,0 +1,85 @@ +//! HTTP client. +//! +//! dle.rae.es answers 403 to short or missing user agents, so requests to it +//! identify as a desktop browser. + +use std::time::Duration; + +use anyhow::{Context, Result}; +use ureq::Agent; + +/// Browser user agent, for dle.rae.es, which rejects clients that do not +/// identify as one. +pub const BROWSER_UA: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \ + (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + +/// Honest identification for APIs that want to know who is calling, as the +/// Wikimedia policy asks. +pub const CLIENT_UA: &str = concat!( + "drae-cli/", + env!("CARGO_PKG_VERSION"), + " (terminal dictionary)" +); + +pub struct Http { + agent: Agent, +} + +impl Http { + pub fn new(timeout_secs: u64) -> Self { + let config = Agent::config_builder() + .timeout_global(Some(Duration::from_secs(timeout_secs))) + // A 404 is an answer we want to read, not a network error. + .http_status_as_error(false) + .build(); + Http { + agent: config.new_agent(), + } + } + + /// Returns the status code and the body. + pub fn get(&self, url: &str, user_agent: &str) -> Result<(u16, String)> { + let mut res = self + .agent + .get(url) + .header("User-Agent", user_agent) + .header("Accept-Language", "es-ES,es;q=0.9") + .header("Accept", "text/html,application/json;q=0.9,*/*;q=0.8") + .call() + .with_context(|| format!("no se pudo conectar con {url}"))?; + let status = res.status().as_u16(); + let body = res + .body_mut() + .read_to_string() + .with_context(|| format!("respuesta ilegible de {url}"))?; + Ok((status, body)) + } +} + +/// Encodes a word for a URL path. The RAE needs `%C3%B1` for ñ; other safe +/// characters pass through unchanged. +pub fn percent_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.as_bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(*b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_spanish_characters() { + assert_eq!(percent_encode("año"), "a%C3%B1o"); + assert_eq!(percent_encode("café"), "caf%C3%A9"); + assert_eq!(percent_encode("perro"), "perro"); + assert_eq!(percent_encode("dar de sí"), "dar%20de%20s%C3%AD"); + } +} 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(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..4e81bbe --- /dev/null +++ b/src/config.rs @@ -0,0 +1,134 @@ +//! Optional configuration in `~/.config/drae/config.toml`. +//! +//! Everything has a sensible default: the file is only needed to change +//! something. Command-line flags always win over it. The keys are in Spanish +//! because the users are Spanish speakers. +//! +//! ```toml +//! fuente = "auto" # auto | dle | rae-api +//! timeout = 15 +//! # cache = "~/.cache/drae/entries.db" +//! # diccionario = "/usr/share/hunspell/es_AR.dic" +//! ``` + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::Deserialize; + +use crate::cache::Cache; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ConfigFile { + /// Where to read from: `auto` (rae-api with fallback), `dle` or `rae-api`. + #[serde(rename = "fuente")] + pub source: Option<String>, + /// Seconds to wait for each request. + pub timeout: Option<u64>, + /// Cache database. + pub cache: Option<String>, + /// Hunspell `.dic` used for suggestions. + #[serde(rename = "diccionario")] + pub dictionary: Option<String>, + /// Fixed output width; defaults to the terminal width. + #[serde(rename = "ancho")] + pub width: Option<usize>, +} + +#[derive(Debug, Clone)] +pub struct Config { + pub source: String, + pub timeout: u64, + pub cache: PathBuf, + pub dictionary: Option<PathBuf>, + pub width: Option<usize>, +} + +impl Default for Config { + fn default() -> Self { + Config { + source: "auto".into(), + timeout: 15, + cache: Cache::default_path(), + dictionary: None, + width: None, + } + } +} + +pub fn default_path() -> PathBuf { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(|| PathBuf::from(".config")); + base.join("drae").join("config.toml") +} + +/// Loads the file if it exists. A missing file is not an error. +pub fn load(explicit: Option<&Path>) -> Result<Config> { + let path = explicit.map(PathBuf::from).unwrap_or_else(default_path); + let mut cfg = Config::default(); + let Ok(raw) = std::fs::read_to_string(&path) else { + return Ok(cfg); + }; + let file: ConfigFile = toml::from_str(&raw) + .with_context(|| format!("no se pudo leer la configuración {}", path.display()))?; + + if let Some(s) = file.source { + cfg.source = s; + } + if let Some(t) = file.timeout { + cfg.timeout = t; + } + if let Some(c) = file.cache { + cfg.cache = expand_tilde(&c); + } + if let Some(d) = file.dictionary { + cfg.dictionary = Some(expand_tilde(&d)); + } + cfg.width = file.width.or(cfg.width); + Ok(cfg) +} + +fn expand_tilde(s: &str) -> PathBuf { + match s.strip_prefix("~/") { + Some(rest) => match std::env::var_os("HOME") { + Some(home) => PathBuf::from(home).join(rest), + None => PathBuf::from(s), + }, + None => PathBuf::from(s), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_file_yields_defaults() { + let cfg = load(Some(Path::new("/does/not/exist/config.toml"))).unwrap(); + assert_eq!(cfg.source, "auto"); + assert_eq!(cfg.timeout, 15); + } + + #[test] + fn parses_a_config_file() { + let dir = std::env::temp_dir().join(format!("drae-cfg-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("config.toml"); + std::fs::write(&p, "fuente = \"dle\"\ntimeout = 30\nancho = 72\n").unwrap(); + let cfg = load(Some(&p)).unwrap(); + assert_eq!(cfg.source, "dle"); + assert_eq!(cfg.timeout, 30); + assert_eq!(cfg.width, Some(72)); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn expands_a_leading_tilde() { + std::env::set_var("HOME", "/home/test"); + assert_eq!(expand_tilde("~/x/y.db"), PathBuf::from("/home/test/x/y.db")); + assert_eq!(expand_tilde("/abs/x.db"), PathBuf::from("/abs/x.db")); + } +} diff --git a/src/dle.rs b/src/dle.rs new file mode 100644 index 0000000..ed3d0e5 --- /dev/null +++ b/src/dle.rs @@ -0,0 +1,309 @@ +//! Reader for the Diccionario de la lengua española (dle.rae.es). +//! +//! Each word page is laid out like this: +//! +//! ```text +//! <article> +//! <h1 class="c-page-header__title">hablar</h1> +//! <div class="n2 c-text-intro">Del lat. fabulāri.</div> ← etymology +//! <ol class="c-definitions"> <li class="j"> … </li> … </ol> ← senses +//! <h3>ni hablar</h3> <ol class="c-definitions"> … </ol> ← set phrases +//! ``` +//! +//! Inside each sense, synonyms live in a nested list, so cutting uses +//! [`html::balanced`] instead of looking for the next `</li>`. + +use crate::html::{attr, balanced, collapse_ws, find_open, open_tag, strip_tags}; +use crate::model::{Abbr, Entry, Locution, Sense}; + +const CLASS_DEFS: &str = r#"class="c-definitions""#; + +/// `true` if the DLE answered with its "word not found" page. It returns 200 +/// just like a hit, so the text has to be checked. +pub fn is_not_found(html: &str) -> bool { + html.contains("no está en el Diccionario") || html.contains("no está en el Diccionario") +} + +/// Extracts the entry from a DLE page. `None` if the word is not there. +pub fn parse(html: &str) -> Option<Entry> { + if is_not_found(html) { + return None; + } + let art_start = find_open(html, 0, "article", Some("o-main__article"))?; + // The page never closes `<article>`, so the balanced cut would also take + // the footer. Cut where the page footer starts. + let article = balanced(html, art_start, "article"); + let article = match article.find("<footer") { + Some(f) => &article[..f], + None => article, + }; + + let word = text_of(article, "h1", Some("c-page-header__title"))?; + + // The conjugation and the final synonym lists are separate sections; + // the definitions are everything before them. + let conj_at = article.find(r#"id="conjugacion"#); + let defs_region = &article[..conj_at.unwrap_or(article.len())]; + + let etymology = find_open(defs_region, 0, "div", Some("c-text-intro")) + .map(|i| strip_tags(balanced(defs_region, i, "div"))) + .filter(|s| !s.is_empty()); + + let mut senses = Vec::new(); + let mut locutions = Vec::new(); + let mut cursor = 0usize; + let mut first = true; + while let Some(ol_at) = find_open(defs_region, cursor, "ol", Some(CLASS_DEFS)) { + let ol = balanced(defs_region, ol_at, "ol"); + let parsed = parse_senses(ol); + if first { + senses = parsed; + first = false; + } else if let Some(phrase) = heading_before(defs_region, cursor, ol_at) { + locutions.push(Locution { + phrase, + senses: parsed, + }); + } + cursor = ol_at + ol.len(); + } + + Some(Entry { + word, + etymology, |