diff options
author | Thang Pham <phamducthang1234@gmail.com> | 2023-04-24 17:45:48 -0400 |
---|---|---|
committer | Thang Pham <phamducthang1234@gmail.com> | 2023-04-24 18:17:33 -0400 |
commit | eb0fcb0c953ab26382a04520b891a1cfd130e662 (patch) | |
tree | 385d3b41cdd453d7c8accf522080fc320d6f26ba | |
parent | 902ae40f4a1fdff948cd5f15480f7353aec4e7b7 (diff) |
Support opening specific comment view on startup (#90)
Resolves #86
- added `--start_id` cli option to open the application in a specific comment view
- updated comment view codes to
+ not require a story to construct a comment view
+ allow constructing a comment view of either a comment or a story
-rw-r--r-- | hackernews_tui/src/client/mod.rs | 115 | ||||
-rw-r--r-- | hackernews_tui/src/client/model.rs | 17 | ||||
-rw-r--r-- | hackernews_tui/src/main.rs | 14 | ||||
-rw-r--r-- | hackernews_tui/src/model.rs | 18 | ||||
-rw-r--r-- | hackernews_tui/src/view/async_view.rs | 11 | ||||
-rw-r--r-- | hackernews_tui/src/view/comment_view.rs | 59 | ||||
-rw-r--r-- | hackernews_tui/src/view/fn_view_wrapper.rs | 2 | ||||
-rw-r--r-- | hackernews_tui/src/view/mod.rs | 32 | ||||
-rw-r--r-- | hackernews_tui/src/view/story_view.rs | 4 |
9 files changed, 174 insertions, 98 deletions
diff --git a/hackernews_tui/src/client/mod.rs b/hackernews_tui/src/client/mod.rs index 9d10e94..ea70888 100644 --- a/hackernews_tui/src/client/mod.rs +++ b/hackernews_tui/src/client/mod.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; // re-export pub use query::{StoryNumericFilters, StorySortMode}; -use crate::prelude::*; +use crate::{prelude::*, utils::decode_html}; use model::*; use rayon::prelude::*; @@ -64,50 +64,101 @@ impl HNClient { Ok(item) } - pub fn get_story_hidden_data(&self, story_id: u32) -> Result<StoryHiddenData> { + pub fn get_page_data(&self, item_id: u32) -> Result<PageData> { + // get the root item in the page + let request_url = format!("{HN_OFFICIAL_PREFIX}/item/{item_id}.json"); + let item = log!( + self.client + .get(&request_url) + .call()? + .into_json::<ItemResponse>()?, + format!("get item (id={item_id}) using {request_url}") + ); + + // The item's text returned from HN official APIs may have `<p>` tags representing + // paragraph breaks. Convert `<p>` tags to newlines to make the text easier to read. + let text = decode_html(&item.text.unwrap_or_default()).replace("<p>", "\n\n"); + + // Construct the shortened text to represent the page's title if not exist + let chars = text.replace('\n', " ").chars().collect::<Vec<_>>(); + let limit = 64; + let shortened_text = if chars.len() > limit { + String::from_iter(chars[..limit].iter()) + "..." + } else { + text.to_string() + }; + + let url = item + .url + .unwrap_or(format!("{HN_HOST_URL}/item?id={item_id}")); + let title = item.title.unwrap_or(shortened_text); + + // parse the root item of the page + let root_item: HnItem = match item.typ.as_str() { + "story" => Story { + id: item_id, + url: url.clone(), + author: item.by.unwrap_or_default(), + points: item.score.unwrap_or_default(), + num_comments: item.descendants.unwrap_or_default(), + time: item.time, + title: title.clone(), + content: text, + } + .into(), + "comment" => Comment { + id: item_id, + level: 0, + n_children: 0, + author: item.by.unwrap_or_default(), + time: item.time, + content: text, + } + .into(), + typ => { + anyhow::bail!("unknown item type: {typ}"); + } + }; + // Parallelize two tasks using [`rayon::join`](https://docs.rs/rayon/latest/rayon/fn.join.html) - let (content, comment_receiver) = rayon::join( + let (vote_state, comment_receiver) = rayon::join( || { + // get the page's vote state log!( - self.get_story_page_content(story_id), - format!("get story (id={story_id}) page content") + { + let content = self.get_page_content(item_id)?; + self.parse_vote_data(&content) + }, + format!("get page's vote state of item (id={item_id}) ") ) }, - || self.lazy_load_story_comments(story_id), + // lazily load the page's top comments + || self.lazy_load_comments(item.kids), ); - let content = content?; + let vote_state = vote_state?; let comment_receiver = comment_receiver?; - let vote_state = self.parse_story_vote_data(&content)?; - - Ok(StoryHiddenData { + Ok(PageData { + title, + url, + root_item, comment_receiver, vote_state, }) } - pub fn lazy_load_story_comments(&self, story_id: u32) -> Result<CommentReceiver> { - // retrieve the top comments of a story - let request_url = format!("{HN_OFFICIAL_PREFIX}/item/{story_id}.json"); - let mut ids = log!( - self.client - .get(&request_url) - .call()? - .into_json::<HNStoryResponse>()? - .kids, - format!("get story (id={story_id}) using {request_url}") - ); - + /// lazily loads comments of a Hacker News item + fn lazy_load_comments(&self, mut comment_ids: Vec<u32>) -> Result<CommentReceiver> { let (sender, receiver) = crossbeam_channel::bounded(32); // loads the first 5 top comments to ensure the corresponding `CommentView` has data to render - self.load_comments(&sender, &mut ids, 5)?; + self.load_comments(&sender, &mut comment_ids, 5)?; std::thread::spawn({ let client = self.clone(); let sleep_dur = std::time::Duration::from_millis(1000); move || { - while !ids.is_empty() { - if let Err(err) = client.load_comments(&sender, &mut ids, 5) { + while !comment_ids.is_empty() { + if let Err(err) = client.load_comments(&sender, &mut comment_ids, 5) { warn!("encountered an error when loading comments: {}", err); break; } @@ -369,18 +420,18 @@ impl HNClient { } } - pub fn get_story_page_content(&self, story_id: u32) -> Result<String> { + /// gets the HTML page content of a Hacker News item + pub fn get_page_content(&self, item_id: u32) -> Result<String> { let morelink_rg = regex::Regex::new("<a.*?href='(?P<link>.*?)'.*class='morelink'.*?>")?; let mut content = self .client - .get(&format!("{HN_HOST_URL}/item?id={story_id}")) + .get(&format!("{HN_HOST_URL}/item?id={item_id}")) .call()? .into_string()?; - // The story returned by HN can have multiple pages, - // we need to make additional requests for each page and - // concatenate all the responses to get the story's whole content. + // A Hacker News item can have multiple pages, so + // we need to make additional requests for each page and concatenate all the responses. let mut curr_page_content = content.clone(); while let Some(cap) = morelink_rg.captures(&curr_page_content) { @@ -399,12 +450,12 @@ impl HNClient { Ok(content) } - /// Parse a story's vote data + /// Parse vote data of items in a page. /// /// The vote data is represented by a hashmap from `id` to a struct consisting of /// `auth` and `upvoted` (false=no vote, true=has vote), in which `id` is /// is an item's id and `auth` is a string for authentication purpose when voting. - pub fn parse_story_vote_data(&self, page_content: &str) -> Result<HashMap<String, VoteData>> { + pub fn parse_vote_data(&self, page_content: &str) -> Result<HashMap<String, VoteData>> { let upvote_rg = regex::Regex::new("<a.*?id='up_(?P<id>.*?)'.*?auth=(?P<auth>[0-9a-z]*).*?>")?; let unvote_rg = diff --git a/hackernews_tui/src/client/model.rs b/hackernews_tui/src/client/model.rs index 82ba8c3..7ae04fe 100644 --- a/hackernews_tui/src/client/model.rs +++ b/hackernews_tui/src/client/model.rs @@ -60,8 +60,21 @@ pub struct StoryResponse { } #[derive(Debug, Deserialize)] -/// HNStoryResponse represents the story data received from the official HackerNews APIs -pub struct HNStoryResponse { +/// ItemResponse represents the item data received from the official HackerNews APIs +pub struct ItemResponse { + pub id: u32, + pub by: Option<String>, + pub text: Option<String>, + pub title: Option<String>, + pub url: Option<String>, + + #[serde(rename(deserialize = "type"))] + pub typ: String, + + pub descendants: Option<usize>, + pub score: Option<u32>, + pub time: u64, + #[serde(default)] pub kids: Vec<u32>, } diff --git a/hackernews_tui/src/main.rs b/hackernews_tui/src/main.rs index 9ac251d..927886b 100644 --- a/hackernews_tui/src/main.rs +++ b/hackernews_tui/src/main.rs @@ -14,7 +14,7 @@ const DEFAULT_LOG_FILE: &str = "hn-tui.log"; use clap::*; use prelude::*; -fn run(auth: Option<config::Auth>) { +fn run(auth: Option<config::Auth>, start_id: Option<u32>) { // setup HN Client let client = client::init_client(); @@ -26,7 +26,7 @@ fn run(auth: Option<config::Auth>) { } // setup the application's UI - let s = view::init_ui(client); + let s = view::init_ui(client, start_id); // use `cursive_buffered_backend` crate to fix the flickering issue // when using `cursive` with `crossterm_backend` (See https://github.com/gyscos/Cursive/issues/142) @@ -95,6 +95,13 @@ fn parse_args(config_dir: std::path::PathBuf, cache_dir: std::path::PathBuf) -> .help("Path to a folder to store application's logs") .next_line_help(true), ) + .arg( + Arg::new("start_id") + .short('i') + .value_parser(clap::value_parser!(u32)) + .help("The Hacker News item's id to start the application with") + .next_line_help(true), + ) .get_matches() } @@ -140,5 +147,6 @@ fn main() { args.get_one::<String>("auth") .expect("`auth` argument should have a default value"), ); - run(auth); + let start_id = args.get_one::<u32>("start_id").cloned(); + run(auth, start_id); } diff --git a/hackernews_tui/src/model.rs b/hackernews_tui/src/model.rs index f18a75e..175c27e 100644 --- a/hackernews_tui/src/model.rs +++ b/hackernews_tui/src/model.rs @@ -36,8 +36,19 @@ pub struct Comment { pub content: String, } -pub struct StoryHiddenData { +/// A Hacker News page data. +/// +/// The page data is mainly used to construct a comment view. +pub struct PageData { + pub title: String, + pub url: String, + + /// the root item in the page + pub root_item: HnItem, + + /// a channel to lazily load items/comments in the page pub comment_receiver: CommentReceiver, + /// the voting state of items in the page pub vote_state: HashMap<String, VoteData>, } @@ -96,10 +107,7 @@ impl From<Story> for HnItem { ), ]); - // The HTML story text returned by HN Algolia APIs doesn't wrap a paragraph inside a `<p><\p>` tag pair. - // Instead, it seems to use `<p>` to represent a paragraph break. - // Replace `<p>` with linebreaks to make the text easier to parse. - let mut story_text = story.content.replace("<p>", "\n\n"); + let mut story_text = story.content; let minimized_text = if story_text.is_empty() { metadata.clone() diff --git a/hackernews_tui/src/view/async_view.rs b/hackernews_tui/src/view/async_view.rs index 738322d..ca23ad3 100644 --- a/hackernews_tui/src/view/async_view.rs +++ b/hackernews_tui/src/view/async_view.rs @@ -7,16 +7,13 @@ use cursive_async_view::AsyncView; pub fn construct_comment_view_async( siv: &mut Cursive, client: &'static client::HNClient, - story: &Story, + item_id: u32, ) -> impl View { - let id = story.id; - - AsyncView::new_with_bg_creator(siv, move || Ok(client.get_story_hidden_data(id)), { - let story = story.clone(); + AsyncView::new_with_bg_creator(siv, move || Ok(client.get_page_data(item_id)), { move |result: Result<_>| { ResultView::new( - result.with_context(|| format!("failed to load comments from story (id={id})")), - |receiver| comment_view::construct_comment_view(client, &story, receiver), + result.with_context(|| format!("failed to load comments from item (id={item_id})")), + |data| comment_view::construct_comment_view(client, data), ) } }) diff --git a/hackernews_tui/src/view/comment_view.rs b/hackernews_tui/src/view/comment_view.rs index 3dbb189..38b398f 100644 --- a/hackernews_tui/src/view/comment_view.rs +++ b/hackernews_tui/src/view/comment_view.rs @@ -8,7 +8,7 @@ type SingleItemView = HideableView<PaddedView<text_view::TextView>>; pub struct CommentView { view: ScrollView<LinearLayout>, items: Vec<HnItem>, - data: StoryHiddenData, + data: PageData, raw_command: String, } @@ -23,23 +23,24 @@ impl ViewWrapper for CommentView { } impl CommentView { - pub fn new(story: &Story, data: StoryHiddenData) -> Self { - // story as the first item in the comment view - let item: HnItem = story.clone().into(); - + pub fn new(data: PageData) -> Self { let mut view = CommentView { view: LinearLayout::vertical() .child(HideableView::new(PaddedView::lrtb( - item.level * 2 + 1, + 1, 1, 0, 1, text_view::TextView::new( - item.text(data.vote_state.get(&item.id.to_string()).map(|v| v.upvoted)), + data.root_item.text( + data.vote_state + .get(&data.root_item.id.to_string()) + .map(|v| v.upvoted), + ), ), ))) .scrollable(), - items: vec![item], + items: vec![data.root_item.clone()], raw_command: String::new(), data, }; @@ -255,11 +256,7 @@ impl ScrollViewContainer for CommentView { } } -fn construct_comment_main_view( - client: &'static client::HNClient, - story: &Story, - data: StoryHiddenData, -) -> impl View { +fn construct_comment_main_view(client: &'static client::HNClient, data: PageData) -> impl View { let is_suffix_key = |c: &Event| -> bool { let comment_view_keymap = config::get_comment_view_keymap(); comment_view_keymap.open_link_in_browser.has_event(c) @@ -268,7 +265,10 @@ fn construct_comment_main_view( let comment_view_keymap = config::get_comment_view_keymap().clone(); - OnEventView::new(CommentView::new(story, data)) + let article_url = data.url.clone(); + let page_url = format!("{}/item?id={}", client::HN_HOST_URL, data.root_item.id); + + OnEventView::new(CommentView::new(data)) .on_pre_event_inner(EventTrigger::from_fn(|_| true), move |s, e| { s.try_update_comments(); @@ -389,13 +389,13 @@ fn construct_comment_main_view( Some(EventResult::Consumed(None)) }) .on_pre_event(comment_view_keymap.open_article_in_browser, { - let url = story.get_url().into_owned(); + let url = article_url.clone(); move |_| { utils::open_url_in_browser(&url); } }) .on_pre_event(comment_view_keymap.open_article_in_article_view, { - let url = story.url.clone(); + let url = article_url; move |s| { if !url.is_empty() { article_view::construct_and_add_new_article_view(s, &url) @@ -403,7 +403,7 @@ fn construct_comment_main_view( } }) .on_pre_event(comment_view_keymap.open_story_in_browser, { - let url = story.story_url(); + let url = page_url; move |_| { utils::open_url_in_browser(&url); } @@ -415,23 +415,12 @@ fn construct_comment_main_view( .full_height() } -/// Construct a comment view of a given story. -/// -/// # Arguments: -/// * `story`: a Hacker News story -/// * `receiver`: a "subscriber" channel that gets comments asynchronously from another thread -pub fn construct_comment_view( - client: &'static client::HNClient, - story: &Story, - data: StoryHiddenData, -) -> impl View { - let main_view = construct_comment_main_view(client, story, data); +pub fn construct_comment_view(client: &'static client::HNClient, data: PageData) -> impl View { + let title = format!("Comment View - {}", data.title,); + let main_view = construct_comment_main_view(client, data); let mut view = LinearLayout::vertical() - .child(utils::construct_view_title_bar(&format!( - "Comment View - {}", - story.plain_title() - ))) + .child(utils::construct_view_title_bar(&title)) .child(main_view) .child(utils::construct_footer_view::<CommentView>()); view.set_focus_index(1) @@ -440,14 +429,14 @@ pub fn construct_comment_view( view } -/// Retrieve comments of a story and construct a comment view of that story +/// Retrieve comments in a Hacker News item and construct a comment view of that item pub fn construct_and_add_new_comment_view( s: &mut Cursive, client: &'static client::HNClient, - story: &Story, + item_id: u32, pop_layer: bool, ) { - let async_view = async_view::construct_comment_view_async(s, client, story); + let async_view = async_view::construct_comment_view_async(s, client, item_id); if pop_layer { s.pop_layer(); } diff --git a/hackernews_tui/src/view/fn_view_wrapper.rs b/hackernews_tui/src/view/fn_view_wrapper.rs index 710e72a..dfc0e8e 100644 --- a/hackernews_tui/src/view/fn_view_wrapper.rs +++ b/hackernews_tui/src/view/fn_view_wrapper.rs @@ -31,7 +31,7 @@ macro_rules! impl_view_fns_for_fn_view_wrapper { self.get_view_mut().take_focus(source) } - fn call_on_any<'a>(&mut self, selector: &Selector<'_>, callback: AnyCb<'a>) { + fn call_on_any(&mut self, selector: &Selector<'_>, callback: AnyCb<'_>) { self.get_view_mut().call_on_any(selector, callback) } diff --git a/hackernews_tui/src/view/mod.rs b/hackernews_tui/src/view/mod.rs index bd21c60..7f01ee4 100644 --- a/hackernews_tui/src/view/mod.rs +++ b/hackernews_tui/src/view/mod.rs @@ -112,7 +112,10 @@ fn set_up_global_callbacks(s: &mut Cursive, client: &'static client::HNClient) { } /// Initialize the application's UI -pub fn init_ui(client: &'static client::HNClient) -> cursive::CursiveRunnable { +pub fn init_ui( + client: &'static client::HNClient, + start_id: Option<u32>, +) -> cursive::CursiveRunnable { let mut s = cursive::default(); // initialize `cursive` color palette which is determined by the application's theme @@ -131,16 +134,23 @@ pub fn init_ui(client: &'static client::HNClient) -> cursive::CursiveRunnable { set_up_global_callbacks(&mut s, client); - // render `front_page` story view as the application's startup view - story_view::construct_and_add_new_story_view( - &mut s, - client, - "front_page", - client::StorySortMode::None, - 0, - client::StoryNumericFilters::default(), - false, - ); + match start_id { + Some(id) => { + comment_view::construct_and_add_new_comment_view(&mut s, client, id, false); + } + None => { + // render `front_page` story view as the application's startup view if no start id is specified + story_view::construct_and_add_new_story_view( + &mut s, + client, + "front_page", + client::StorySortMode::None, + 0, + client::StoryNumericFilters::default(), + false, + ); + } + } s } diff --git a/hackernews_tui/src/view/story_view.rs b/hackernews_tui/src/view/story_view.rs index 2bb9b41..a00a347 100644 --- a/hackernews_tui/src/view/story_view.rs +++ b/hackernews_tui/src/view/story_view.rs @@ -168,9 +168,9 @@ pub fn construct_story_main_view( let id = s.get_focus_index(); // the story struct hasn't had any comments inside yet, // so it can be cloned without greatly affecting performance - let story = s.stories[id].clone(); + let item_id = s.stories[id].id; Some(EventResult::with_cb({ - move |s| comment_view::construct_and_add_new_comment_view(s, client, &story, false) + move |s| comment_view::construct_and_add_new_comment_view(s, client, item_id, false) })) }) // open external link shortcuts |