summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorSam Tay <samctay@pm.me>2022-08-21 17:57:55 -0700
committerSam Tay <samctay@pm.me>2022-08-21 18:05:30 -0700
commit7377525de6cf78946b01dcb69aae7bb52a9bf74b (patch)
tree8eeedc26c0acc84807b34682be62e910b8dc8456 /src
parent1484f6763b9a7dd782a002764886c053477f2181 (diff)
Add key to open SE answer in browser
Diffstat (limited to 'src')
-rw-r--r--src/main.rs20
-rw-r--r--src/stackexchange/api.rs20
-rw-r--r--src/stackexchange/mod.rs2
-rw-r--r--src/stackexchange/search.rs9
-rw-r--r--src/tui/app.rs286
-rw-r--r--src/tui/mod.rs2
-rw-r--r--src/tui/views.rs21
7 files changed, 217 insertions, 143 deletions
diff --git a/src/main.rs b/src/main.rs
index c2e8f51..e732d5a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13,17 +13,16 @@ use tokio::task;
use config::Config;
use error::{Error, Result};
-use stackexchange::{LocalStorage, Question, Search};
+use stackexchange::{LocalStorage, Search};
use term::Term;
-use tui::markdown::Markdown;
fn main() -> Result<()> {
// Tokio runtime
Runtime::new()?
.block_on(run())
- .map(|qs| {
+ .map(|app| {
// Run TUI
- qs.map(|(qs, cfg)| tui::run(qs, cfg));
+ app.map(tui::App::run);
})
.or_else(|e: Error| {
// Handle errors
@@ -33,7 +32,7 @@ fn main() -> Result<()> {
/// Runs the CLI and, if the user wishes to enter the TUI, returns
/// question/answer data
-async fn run() -> Result<Option<(Vec<Question<Markdown>>, Config)>> {
+async fn run() -> Result<Option<tui::App>> {
// Get CLI opts
let opts = cli::get_opts()?;
let config = opts.config;
@@ -88,18 +87,17 @@ async fn run() -> Result<Option<(Vec<Question<Markdown>>, Config)>> {
term.print("\nPress **[SPACE]** to see more results, or any other key to exit");
// Kick off the rest of the search in the background
- let qs = task::spawn(async move { search.search_md().await });
+ let app = task::spawn(async move { tui::App::from_search(search).await });
if !Term::wait_for_char(' ')? {
return Ok(None);
}
// Get the rest of the questions
- return Ok(Some((Term::wrap_spinner(qs).await?.unwrap()?, config)));
+ return Ok(Some(Term::wrap_spinner(app).await?.unwrap()?));
} else {
- return Ok(Some((
- Term::wrap_spinner(search.search_md()).await??,
- config,
- )));
+ return Ok(Some(
+ Term::wrap_spinner(tui::App::from_search(search)).await??,
+ ));
}
}
Ok(None)
diff --git a/src/stackexchange/api.rs b/src/stackexchange/api.rs
index cf326f2..57f8f88 100644
--- a/src/stackexchange/api.rs
+++ b/src/stackexchange/api.rs
@@ -21,12 +21,14 @@ const SE_FILTER: &str = ".DND5X2VHHUH8HyJzpjo)5NvdHI3w6auG";
/// Pagesize when fetching all SE sites. Should be good for many years...
const SE_SITES_PAGESIZE: u16 = 10000;
+pub type Id = u32;
+
/// Represents a StackExchange answer with a custom selection of fields from
/// the [StackExchange docs](https://api.stackexchange.com/docs/types/answer)
#[derive(Clone, Deserialize, Debug)]
pub struct Answer<S> {
#[serde(rename = "answer_id")]
- pub id: u32,
+ pub id: Id,
pub score: i32,
#[serde(rename = "body_markdown")]
pub body: S,
@@ -35,19 +37,21 @@ pub struct Answer<S> {
/// Represents a StackExchange question with a custom selection of fields from
/// the [StackExchange docs](https://api.stackexchange.com/docs/types/question)
-// TODO container over answers should be generic iterator
#[derive(Clone, Deserialize, Debug)]
pub struct Question<S> {
#[serde(rename = "question_id")]
- pub id: u32,
+ pub id: Id,
pub score: i32,
- #[serde(default = "Vec::new")]
// N.B. empty vector default needed because questions endpoint cannot filter
// answers >= 1
+ #[serde(default = "Vec::new")]
pub answers: Vec<Answer<S>>,
pub title: String,
#[serde(rename = "body_markdown")]
pub body: S,
+ // This is the only field that doesn't actually come back from SE; we add
+ // this site code to which the question belongs
+ pub site: Option<String>,
}
/// Internal struct that represents the boilerplate response wrapper from SE API.
@@ -98,7 +102,7 @@ impl Api {
.into_iter()
.filter(|q| !q.answers.is_empty())
.collect();
- Ok(Self::preprocess(qs))
+ Ok(Self::preprocess(site, qs))
}
/// Search against the SE site's /search/advanced endpoint with a given query.
@@ -126,7 +130,7 @@ impl Api {
.json::<ResponseWrapper<Question<String>>>()
.await?
.items;
- Ok(Self::preprocess(qs))
+ Ok(Self::preprocess(site, qs))
}
pub async fn sites(&self) -> Result<Vec<Site>> {
@@ -159,9 +163,10 @@ impl Api {
}
/// Sorts answers by score
+ /// Add the site code to which the question belongs
/// Preprocess SE markdown to "cmark" markdown (or something closer to it)
/// This markdown preprocess _always_ happens.
- fn preprocess(qs: Vec<Question<String>>) -> Vec<Question<String>> {
+ fn preprocess(site: &str, qs: Vec<Question<String>>) -> Vec<Question<String>> {
qs.into_par_iter()
.map(|q| {
let mut answers = q.answers;
@@ -175,6 +180,7 @@ impl Api {
.collect();
Question {
answers,
+ site: Some(site.to_string()),
body: markdown::preprocess(q.body),
..q
}
diff --git a/src/stackexchange/mod.rs b/src/stackexchange/mod.rs
index b0e1345..387ae54 100644
--- a/src/stackexchange/mod.rs
+++ b/src/stackexchange/mod.rs
@@ -4,6 +4,6 @@ mod search;
// Exposed for benchmarking
pub mod scraper;
-pub use api::{Answer, Question};
+pub use api::{Answer, Id, Question};
pub use local_storage::LocalStorage;
pub use search::Search;
diff --git a/src/stackexchange/search.rs b/src/stackexchange/search.rs
index 1357c80..d705867 100644
--- a/src/stackexchange/search.rs
+++ b/src/stackexchange/search.rs
@@ -26,10 +26,10 @@ const USER_AGENT: &str =
// TODO this really needs a better name...
#[derive(Clone)]
pub struct Search {
- api: Api,
- config: Config,
- query: String,
- sites: HashMap<String, String>,
+ pub api: Api,
+ pub config: Config,
+ pub query: String,
+ pub sites: HashMap<String, String>,
}
impl Search {
@@ -190,6 +190,7 @@ fn parse_markdown(qs: Vec<Question<String>>) -> Vec<Question<Markdown>> {
id: q.id,
score: q.score,
title: q.title,
+ site: q.site,
}
})
.collect::<Vec<_>>()
diff --git a/src/tui/app.rs b/src/tui/app.rs
index 6658f16..3c2fedf 100644
--- a/src/tui/app.rs
+++ b/src/tui/app.rs
@@ -20,134 +20,181 @@ use super::views::{
};
use crate::config::Config;
use crate::error::Result;
-use crate::stackexchange::{Answer, Question};
+use crate::stackexchange::{Answer, Id, Question, Search};
pub const NAME_HELP_VIEW: &str = "help_view";
-// TODO an Arc<Mutex> app state that gets auto updated with new selections would
-// be convenient
-
-pub fn run(qs: Vec<Question<Markdown>>, cfg: Config) -> Result<()> {
- let mut siv = cursive::default();
- siv.load_theme_file(Config::theme_file_path()?).unwrap(); // TODO dont unwrap
-
- let question_map: HashMap<u32, Question<Markdown>> =
- qs.clone().into_iter().map(|q| (q.id, q)).collect();
- let question_map = Arc::new(question_map);
- let answer_map: HashMap<u32, Answer<Markdown>> = qs
- .clone()
- .into_iter()
- .flat_map(|q| q.answers.into_iter().map(|a| (a.id, a)))
- .collect();
- let answer_map = Arc::new(answer_map);
-
- let question_view = MdView::new(Name::QuestionView);
- let answer_view = MdView::new(Name::AnswerView);
-
- let question_list_view = ListView::new_with_items(
- Name::QuestionList,
- qs.into_iter().map(|q| (preview_question(&q), q.id)),
- move |s, qid| question_selected_callback(question_map.clone(), s, *qid),
- );
-
- let answer_list_view = ListView::new(Name::AnswerList, move |s, aid| {
- let a = answer_map.get(aid).unwrap();
- s.call_on_name(NAME_ANSWER_VIEW, |v: &mut MdView| v.set_content(&a.body));
- });
-
- siv.add_layer(
- LayoutView::new(
- 1,
- question_list_view,
- question_view,
- answer_list_view,
- answer_view,
- )
- .add_vim_bindings(),
- );
-
- let cb = siv.call_on_name(NAME_QUESTION_LIST, |v: &mut ListView| v.select(0));
- if let Some(cb) = cb {
- cb(&mut siv)
+pub struct App {
+ questions: HashMap<Id, Question<Markdown>>,
+ answers: HashMap<Id, Answer<Markdown>>,
+ config: Config,
+ sites: HashMap<String, String>,
+}
+
+impl App {
+ pub async fn from_search(search: Search) -> Result<Self> {
+ let qs = search.search_md().await?;
+ let questions: HashMap<u32, Question<Markdown>> =
+ qs.clone().into_iter().map(|q| (q.id, q)).collect();
+ let answers: HashMap<u32, Answer<Markdown>> = qs
+ .into_iter()
+ .flat_map(|q| q.answers.into_iter().map(|a| (a.id, a)))
+ .collect();
+ Ok(Self {
+ config: search.config,
+ sites: search.sites,
+ questions,
+ answers,
+ })
}
- // Help / View keymappings
- siv.add_global_callback('?', |s| {
- if let Some(pos) = s.screen_mut().find_layer_from_name(NAME_HELP_VIEW) {
- s.screen_mut().remove_layer(pos);
- } else {
- s.add_layer(help());
- }
- });
-
- // Reload theme
- siv.add_global_callback(Event::CtrlChar('r'), |s| {
- s.load_theme_file(Config::theme_file_path().unwrap())
- .unwrap()
- });
-
- // Copy contents to sys clipboard
- siv.add_global_callback('y', move |s| {
- let mut v: ViewRef<LayoutView> = s
- .find_name(NAME_FULL_LAYOUT)
- .expect("bug: layout view should exist");
- let md = v.get_focused_content();
- if let Some(mut copy_cmd) = cfg.get_copy_cmd() {
- let res = (|| {
- let mut child = copy_cmd.spawn().map_err(|e| {
- if e.kind() == io::ErrorKind::NotFound {
- io::Error::new(
- io::ErrorKind::Other,
- "couldn't exec copy cmd; you may need to configure it manually",
- )
- } else {
- e
- }
- })?;
- let mut stdin = child.stdin.take().ok_or_else(|| {
- io::Error::new(io::ErrorKind::Other, "couldn't get stdin of copy cmd")
- })?;
- stdin.write_all(md.source().as_bytes())?;
- Ok("copied to clipboard!".to_string())
- })();
- temp_feedback_msg(s, res);
- }
- });
+ // TODO a <Mutex> app field that gets auto updated with new selections would be convenient
+ pub fn run(self) -> Result<()> {
+ // The underlying fields of self are just static data that we
+ // borrow from various places and callbacks; wrap in Arc to just have
+ // one allocation that gets referenced from wherever.
+ let arc = Arc::new(self);
- siv.add_global_callback(Event::Key(Key::Esc), |s| {
- if let Some(pos) = s.screen_mut().find_layer_from_name(NAME_HELP_VIEW) {
- s.screen_mut().remove_layer(pos);
- }
- if let Some(pos) = s.screen_mut().find_layer_from_name(NAME_TEMP_MSG) {
- s.screen_mut().remove_layer(pos);
+ let mut siv = cursive::default();
+ siv.load_theme_file(Config::theme_file_path()?).unwrap(); // TODO dont unwrap
+
+ let question_view = MdView::new(Name::QuestionView);
+ let answer_view = MdView::new(Name::AnswerView);
+
+ let arc2 = arc.clone();
+ let question_list_view = ListView::new_with_items(
+ Name::QuestionList,
+ arc.questions
+ .clone()
+ .into_values()
+ .map(|q| (preview_question(&q), q.id)),
+ move |s, qid| arc2.question_selected_callback(s, *qid),
+ );
+
+ let arc2 = arc.clone();
+ let answer_list_view = ListView::new(Name::AnswerList, move |s, aid| {
+ let a = arc2.answers.get(aid).unwrap();
+ s.call_on_name(NAME_ANSWER_VIEW, |v: &mut MdView| v.set_content(&a.body));
+ });
+
+ siv.add_layer(
+ LayoutView::new(
+ 1,
+ question_list_view,
+ question_view,
+ answer_list_view,
+ answer_view,
+ )
+ .add_vim_bindings(),
+ );
+
+ let cb = siv.call_on_name(NAME_QUESTION_LIST, |v: &mut ListView| v.select(0));
+ if let Some(cb) = cb {
+ cb(&mut siv)
}
- });
- // Run the app
- siv.run();
- Ok(())
-}
+ // Help / View keymappings
+ siv.add_global_callback('?', |s| {
+ if let Some(pos) = s.screen_mut().find_layer_from_name(NAME_HELP_VIEW) {
+ s.screen_mut().remove_layer(pos);
+ } else {
+ s.add_layer(help());
+ }
+ });
+
+ // Reload theme
+ siv.add_global_callback(Event::CtrlChar('r'), |s| {
+ s.load_theme_file(Config::theme_file_path().unwrap())
+ .unwrap()
+ });
+
+ // Copy contents to sys clipboard
+ let arc2 = arc.clone();
+ siv.add_global_callback('y', move |s| {
+ let mut v: ViewRef<LayoutView> = s
+ .find_name(NAME_FULL_LAYOUT)
+ .expect("bug: layout view should exist");
+ let md = v.get_focused_content();
+ if let Some(mut copy_cmd) = arc2.config.get_copy_cmd() {
+ let res = (|| {
+ let mut child = copy_cmd.spawn().map_err(|e| {
+ if e.kind() == io::ErrorKind::NotFound {
+ io::Error::new(
+ io::ErrorKind::Other,
+ "couldn't exec copy cmd; you may need to configure it manually",
+ )
+ } else {
+ e
+ }
+ })?;
+ let mut stdin = child.stdin.take().ok_or_else(|| {
+ io::Error::new(io::ErrorKind::Other, "couldn't get stdin of copy cmd")
+ })?;
+ stdin.write_all(md.source().as_bytes())?;
+ Ok("copied to clipboard!".to_string())
+ })();
+ temp_feedback_msg(s, res);
+ }
+ });
+
+ // Open in browser
+ let arc2 = arc;
+ siv.add_global_callback('o', move |s| {
+ 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))
+ .map(|_| "opened stackexchange in the browser!".to_string());
+ dbg!(&res);
+ temp_feedback_msg(s, res);
+ }
+ });
+
+ siv.add_global_callback(Event::Key(Key::Esc), |s| {
+ if let Some(pos) = s.screen_mut().find_layer_from_name(NAME_HELP_VIEW) {
+ s.screen_mut().remove_layer(pos);
+ }
+ if let Some(pos) = s.screen_mut().find_layer_from_name(NAME_TEMP_MSG) {
+ s.screen_mut().remove_layer(pos);
+ }
+ });
-fn question_selected_callback(
- question_map: Arc<HashMap<u32, Question<Markdown>>>,
- s: &mut Cursive,
- qid: u32,
-) {
- let q = question_map.get(&qid).unwrap();
- let body = &q.body;
- let XY { x, y: _y } = s.screen_size();
- // Update question view
- s.call_on_name(NAME_QUESTION_VIEW, |v: &mut MdView| {
- v.set_content(body);
- })
- .expect("Panic: setting question view content failed");
- // Update answer list view
- let cb = s
- .call_on_name(NAME_ANSWER_LIST, |v: &mut ListView| {
- v.reset_with_all(q.answers.iter().map(|a| (preview_answer(x, a), a.id)))
+ // Run the app
+ siv.run();
+ 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;
+ let XY { x, y: _y } = s.screen_size();
+ // Update question view
+ s.call_on_name(NAME_QUESTION_VIEW, |v: &mut MdView| {
+ v.set_content(body);
})
- .expect("Panic: setting answer list content failed");
- cb(s)
+ .expect("Panic: setting question view content failed");
+ // Update answer list view
+ let cb = s
+ .call_on_name(NAME_ANSWER_LIST, |v: &mut ListView| {
+ v.reset_with_all(q.answers.iter().map(|a| (preview_answer(x, a), a.id)))
+ })
+ .expect("Panic: setting answer list content failed");
+ cb(s)
+ }
}
fn preview_question(q: &Question<Markdown>) -> StyledString {
@@ -201,6 +248,7 @@ pub fn help() -> Dialog {
**G**: Scroll To Bottom
## Misc
+**o**: Open current q/a in the browser
**y**: Copy current q/a to the clipboard
**q, ZZ, Ctrl<c>**: Exit
**Ctrl<r>**: Reload theme
@@ -216,7 +264,7 @@ pub fn help() -> Dialog {
}
pub fn temp_feedback_msg(siv: &mut Cursive, msg: io::Result<String>) {
- // TODO semaphore to close existing msg before displaying new one
+ // TODO semaphore to close existing msg before displaying new one?
let style = if msg.is_ok() {
Color::Light(BaseColor::Green)
} else {
diff --git a/src/tui/mod.rs b/src/tui/mod.rs
index 9bdd9da..07b8421 100644
--- a/src/tui/mod.rs
+++ b/src/tui/mod.rs
@@ -2,4 +2,4 @@ mod app;
pub mod markdown;
mod views;
-pub use app::run;
+pub use app::App;
diff --git a/src/tui/views.rs b/src/tui/views.rs
index 0e7971d..ef96a6a 100644
--- a/src/tui/views.rs
+++ b/src/tui/views.rs
@@ -155,6 +155,10 @@ impl ListView {
view.with_name(name)
}
+ pub fn get_current_selection(&mut self) -> Option<u32> {
+ self.call_on_inner(|sv| sv.selection().as_deref().copied())
+ }
+
pub fn reset_with_all<S, I>(&mut self, iter: I) -> Callback
where
S: Into<StyledString>,
@@ -399,6 +403,23 @@ impl LayoutView {
.expect("call on md view failed")
}
+ // There may be no questions and there may be no answers? There should be answers but w/e
+ pub fn get_focused_ids(&mut self) -> Option<(u32, Option<u32>)> {
+ let curr_question = self
+ .view
+ .call_on_name(NAME_QUESTION_LIST, |v: &mut ListView| {
+ v.get_current_selection()
+ })
+ .flatten()?;
+ let curr_answer = self
+ .view
+ .call_on_name(NAME_ANSWER_LIST, |v: &mut ListView| {
+ v.get_current_selection()
+ })
+ .flatten();
+ Some((curr_question, curr_answer))
+ }
+
fn get_constraints(&self, screen_size: Vec2) -> LayoutViewSizing {
let heuristic = 1;
let width = SizeConstraint::Fixed(screen_size.x / 2 - heuristic);