summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSam Tay <samctay@pm.me>2022-08-24 12:04:44 -0700
committerSam Tay <samctay@pm.me>2022-08-24 13:40:01 -0700
commit10ed5b0e873cc5db17cb2387d758b2f3dea4f5ab (patch)
tree7645ad47849aa4e169e08a129e645a8b55738d70
parent29815c2fabc4e691fcf94fd26dea22a0ca81aaf4 (diff)
Allow opening browser from lucky prompt
And fix hardcoded stackoverflow link bug
-rw-r--r--CHANGELOG.md6
-rw-r--r--src/main.rs29
-rw-r--r--src/stackexchange/api.rs2
-rw-r--r--src/stackexchange/local_storage.rs57
-rw-r--r--src/stackexchange/mod.rs2
-rw-r--r--src/stackexchange/search.rs43
-rw-r--r--src/term.rs36
-rw-r--r--src/tui/app.rs28
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;