diff options
-rw-r--r-- | CHANGELOG.md | 6 | ||||
-rw-r--r-- | src/main.rs | 29 | ||||
-rw-r--r-- | src/stackexchange/api.rs | 2 | ||||
-rw-r--r-- | src/stackexchange/local_storage.rs | 57 | ||||
-rw-r--r-- | src/stackexchange/mod.rs | 2 | ||||
-rw-r--r-- | src/stackexchange/search.rs | 43 | ||||
-rw-r--r-- | src/term.rs | 36 | ||||
-rw-r--r-- | src/tui/app.rs | 28 |
8 files changed, 130 insertions, 73 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index c44c66a..d2ed537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +#### Added +- Allow `o` keybinding from the lucky prompt. + +#### Fixed +- Hardcoded stackoverflow link + ## [0.4.8] #### Added diff --git a/src/main.rs b/src/main.rs index 40a1e51..032fee3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,9 @@ mod term; mod tui; mod utils; -use std::fmt::Write; +use std::{fmt::Write, sync::Arc}; +use crossterm::event::{KeyCode, KeyEvent}; use tokio::runtime::Runtime; use tokio::task; @@ -79,17 +80,31 @@ async fn run() -> Result<Option<tui::App>> { } if let Some(q) = opts.query { - let mut search = Search::new(config.clone(), ls, q); + let site_map = Arc::new(ls.get_site_map(&config.sites)); + let mut search = Search::new(config.clone(), Arc::clone(&site_map), q); if lucky { // Show top answer - let md = Term::wrap_spinner(search.search_lucky()).await??; - term.print(&md); - term.print("\nPress **[SPACE]** to see more results, or any other key to exit"); + let lucky_answer = Term::wrap_spinner(search.search_lucky()).await??; + term.print(&lucky_answer.answer.body); + term.print("\nPress **[SPACE]** to see more results, **[o]** to open in the browser, or any other key to exit"); // Kick off the rest of the search in the background let app = task::spawn(async move { tui::App::from_search(search).await }); - if !Term::wait_for_char(' ').await? { - return Ok(None); + + match Term::wait_for_key().await? { + KeyEvent { + code: KeyCode::Char(' '), + .. + } => (), + KeyEvent { + code: KeyCode::Char('o'), + .. + } => { + let url = site_map.answer_url(&lucky_answer.question, lucky_answer.answer.id); + webbrowser::open(&url)?; + return Ok(None); + } + _ => return Ok(None), } // Get the rest of the questions diff --git a/src/stackexchange/api.rs b/src/stackexchange/api.rs index 57f8f88..0f07d71 100644 --- a/src/stackexchange/api.rs +++ b/src/stackexchange/api.rs @@ -66,7 +66,7 @@ pub struct Site { pub site_url: String, } -#[derive(Clone)] +#[derive(Debug, Clone)] pub struct Api { client: Client, api_key: Option<String>, diff --git a/src/stackexchange/local_storage.rs b/src/stackexchange/local_storage.rs index 5874611..1d0af2c 100644 --- a/src/stackexchange/local_storage.rs +++ b/src/stackexchange/local_storage.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::fs; +use std::ops::Deref; use std::path::Path; use crate::config::Config; @@ -7,6 +8,7 @@ use crate::error::{Error, Result}; use crate::utils; use super::api::{Api, Site}; +use super::Question; /// This structure allows interacting with locally cached StackExchange metadata. pub struct LocalStorage { @@ -62,8 +64,9 @@ impl LocalStorage { site_codes.iter().find(|s| !hm.contains_key(&s.as_str())) } - pub fn get_urls(&self, site_codes: &[String]) -> HashMap<String, String> { - self.sites + pub fn get_site_map(&self, site_codes: &[String]) -> SiteMap { + let inner = self + .sites .iter() .filter_map(move |site| { let _ = site_codes @@ -71,6 +74,54 @@ impl LocalStorage { .find(|&sc| *sc == site.api_site_parameter)?; Some((site.api_site_parameter.to_owned(), site.site_url.to_owned())) }) - .collect() + .collect(); + SiteMap { inner } + } +} + +/// Just a map of site codes to site URLs, shareable across the app. These are +/// only the sites relevant to the configuration / query, not all cached SE +/// sites. +#[derive(Debug)] +pub struct SiteMap { + inner: HashMap<String, String>, +} + +impl Deref for SiteMap { + type Target = HashMap<String, String>; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl SiteMap { + /// Get SE answer url. Panics if site was not set on the question, or + /// site code not found in the map. + pub fn answer_url<S>(&self, question: &Question<S>, answer_id: u32) -> String { + // answer link actually doesn't need question id + let site_url = self.site_url(question); + format!("https://{site_url}/a/{answer_id}") + } + + /// Get SE question url. Panics if site was not set on the question, or + /// site code not found in the map. + pub fn question_url<S>(&self, question: &Question<S>) -> String { + // answer link actually doesn't need question id + let question_id = question.id; + let site_url = self.site_url(question); + format!("https://{site_url}/q/{question_id}") + } + + fn site_url<S>(&self, question: &Question<S>) -> String { + self.inner + .get( + question + .site + .as_ref() + .expect("bug: site not attached to question"), + ) + .cloned() + .expect("bug: lost a site") } } diff --git a/src/stackexchange/mod.rs b/src/stackexchange/mod.rs index 387ae54..1002201 100644 --- a/src/stackexchange/mod.rs +++ b/src/stackexchange/mod.rs @@ -5,5 +5,5 @@ mod search; pub mod scraper; pub use api::{Answer, Id, Question}; -pub use local_storage::LocalStorage; +pub use local_storage::{LocalStorage, SiteMap}; pub use search::Search; diff --git a/src/stackexchange/search.rs b/src/stackexchange/search.rs index d705867..4a0abea 100644 --- a/src/stackexchange/search.rs +++ b/src/stackexchange/search.rs @@ -2,7 +2,7 @@ use futures::stream::StreamExt; use rayon::prelude::*; use reqwest::header; use reqwest::Client; -use std::collections::HashMap; +use std::sync::Arc; use crate::config::{Config, SearchEngine}; use crate::error::{Error, Result}; @@ -10,7 +10,7 @@ use crate::tui::markdown; use crate::tui::markdown::Markdown; use super::api::{Answer, Api, Question}; -use super::local_storage::LocalStorage; +use super::local_storage::SiteMap; use super::scraper::{DuckDuckGo, Google, ScrapedData, Scraper}; /// Limit on concurrent requests (gets passed to `buffer_unordered`) @@ -24,23 +24,30 @@ const USER_AGENT: &str = /// This structure provides methods to search queries and get StackExchange /// questions/answers in return. // TODO this really needs a better name... -#[derive(Clone)] +#[derive(Debug, Clone)] pub struct Search { pub api: Api, pub config: Config, pub query: String, - pub sites: HashMap<String, String>, + pub site_map: Arc<SiteMap>, +} + +#[derive(Debug, Clone)] +pub struct LuckyAnswer { + /// Preprocessed markdown content + pub answer: Answer<String>, + /// Parent question + pub question: Question<String>, } impl Search { - pub fn new(config: Config, local_storage: LocalStorage, query: String) -> Self { + pub fn new(config: Config, site_map: Arc<SiteMap>, query: String) -> Self { let api = Api::new(config.api_key.clone()); - let sites = local_storage.get_urls(&config.sites); Search { api, config, query, - sites, + site_map, } } @@ -51,7 +58,7 @@ impl Search { /// executing first, because there's less data to retrieve. /// /// Needs mut because it temporarily changes self.config - pub async fn search_lucky(&mut self) -> Result<String> { + pub async fn search_lucky(&mut self) -> Result<LuckyAnswer> { let original_config = self.config.clone(); // Temp set lucky config self.config.limit = 1; @@ -63,15 +70,13 @@ impl Search { // Reset config self.config = original_config; - Ok(result? - .into_iter() - .next() - .ok_or(Error::NoResults)? - .answers - .into_iter() - .next() - .ok_or_else(|| Error::StackExchange(String::from("Received question with no answers")))? - .body) + let question = result?.into_iter().next().ok_or(Error::NoResults)?; + + let answer = question.answers.first().cloned().ok_or_else(|| { + Error::StackExchange(String::from("Received question with no answers")) + })?; + + Ok(LuckyAnswer { answer, question }) } /// Search and parse to Markdown for TUI @@ -97,7 +102,7 @@ impl Search { /// Search query at duckduckgo and then fetch the resulting questions from SE. async fn search_by_scraper(&self, scraper: impl Scraper) -> Result<Vec<Question<String>>> { - let url = scraper.get_url(&self.query, self.sites.values()); + let url = scraper.get_url(&self.query, self.site_map.values()); let html = Client::new() .get(url) .header(header::USER_AGENT, USER_AGENT) @@ -105,7 +110,7 @@ impl Search { .await? .text() .await?; - let data = scraper.parse(&html, &self.sites, self.config.limit)?; + let data = scraper.parse(&html, self.site_map.as_ref(), self.config.limit)?; self.parallel_questions(data).await } diff --git a/src/term.rs b/src/term.rs index e986bf7..ddfb600 100644 --- a/src/term.rs +++ b/src/term.rs @@ -1,5 +1,5 @@ use anyhow::Context; -use crossterm::event::{read, Event, KeyCode, KeyEvent}; +use crossterm::event::{read, Event, KeyEvent}; use crossterm::style::{Color, Print}; use crossterm::terminal::ClearType; use crossterm::{cursor, execute, terminal}; @@ -84,31 +84,21 @@ impl Term { Ok(()) } - /// Waits for the user to press any key. Returns whether or not that key is the - /// character key `c`. - pub async fn wait_for_char(c: char) -> Result<bool> { - let (tx, rx) = oneshot::channel(); - - tokio::task::spawn_blocking(move || { - let mut pressed = false; + /// Waits for the user to press any key and returns it + pub async fn wait_for_key() -> Result<KeyEvent> { + let res = tokio::task::spawn_blocking(move || { terminal::enable_raw_mode().unwrap(); - loop { - match read().unwrap() { - Event::Key(KeyEvent { - code: KeyCode::Char(ch), - .. - }) if ch == c => { - pressed = true; - break; - } - Event::Key(_) => break, - _ => (), + let k = loop { + if let Event::Key(k) = read().unwrap() { + break k; } - } + }; terminal::disable_raw_mode().unwrap(); - tx.send(pressed).unwrap(); - }); - Ok(rx.await.context("failed to get key event")?) + k + }) + .await + .context("failed to get key event")?; + Ok(res) } /// As it sounds, takes a future and shows a CLI spinner until it's output is ready diff --git a/src/tui/app.rs b/src/tui/app.rs index 3c2fedf..95bf089 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -20,7 +20,7 @@ use super::views::{ }; use crate::config::Config; use crate::error::Result; -use crate::stackexchange::{Answer, Id, Question, Search}; +use crate::stackexchange::{Answer, Id, Question, Search, SiteMap}; pub const NAME_HELP_VIEW: &str = "help_view"; @@ -28,7 +28,7 @@ pub struct App { questions: HashMap<Id, Question<Markdown>>, answers: HashMap<Id, Answer<Markdown>>, config: Config, - sites: HashMap<String, String>, + site_map: Arc<SiteMap>, } impl App { @@ -42,7 +42,7 @@ impl App { .collect(); Ok(Self { config: search.config, - sites: search.sites, + site_map: search.site_map, questions, answers, }) @@ -143,10 +143,13 @@ impl App { let mut v: ViewRef<LayoutView> = s .find_name(NAME_FULL_LAYOUT) .expect("bug: layout view should exist"); - if let Some((qid, aid)) = v.get_focused_ids() { - let res = webbrowser::open(&arc2.mk_url("stackoverflow".to_string(), qid, aid)) + if let Some((qid, aid_opt)) = v.get_focused_ids() { + let question = arc2.questions.get(&qid).expect("bug: lost a question?!"); + let url = aid_opt + .map(|aid| arc2.site_map.answer_url(question, aid)) + .unwrap_or_else(|| arc2.site_map.question_url(question)); + let res = webbrowser::open(&url) .map(|_| "opened stackexchange in the browser!".to_string()); - dbg!(&res); temp_feedback_msg(s, res); } }); @@ -165,19 +168,6 @@ impl App { Ok(()) } - fn mk_url(&self, site: String, question_id: Id, answer_id_opt: Option<Id>) -> String { - // answer link actually doesn't need question id - let site_url = self - .sites - .get(&site) - .expect("we lost track of a site?!") - .to_string(); - match answer_id_opt { - Some(answer_id) => format!("https://{site_url}/a/{answer_id}"), - None => format!("https://{site_url}/a/{question_id}"), - } - } - pub fn question_selected_callback(&self, s: &mut Cursive, qid: u32) { let q = self.questions.get(&qid).unwrap(); let body = &q.body; |