From 1e418619f194fe702a9eacbcf9d1a11825c9d960 Mon Sep 17 00:00:00 2001 From: gmnsii Date: Fri, 24 Mar 2023 17:41:26 -0700 Subject: [PATCH] Feat: search for comments within posts Add the ability to search for specific comments within posts. Known issues: - Just like on reddit, this does not work with comment sorting. The sorting order is ignored during the search and changing the sorting order after the search does not change anything. I do not think we can fix this before reddit does, since in my understanding we rely on them for the sorting. However we could implement a default sorting method ourselves by taking the vector of comments returned from the search and sorting it manually. - The UI could be improved on mobile. On screens with a max width inferior to 480 pixels, the comment search bar is displayed below the comment sorting form. It would be great if we could make the search bar have the same width as the whole comment sorting form but I do not have the willpower to write any more css. --- build.rs | 4 +- src/post.rs | 233 +++++++++++++++++++++++++---------------- static/style.css | 17 ++- templates/comment.html | 2 +- templates/post.html | 32 ++++-- templates/search.html | 2 +- 6 files changed, 187 insertions(+), 103 deletions(-) diff --git a/build.rs b/build.rs index 3ee44a4..26b1ed9 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,4 @@ -use std::{ - process::{Command, ExitStatus, Output}, -}; +use std::process::{Command, ExitStatus, Output}; #[cfg(not(target_os = "windows"))] use std::os::unix::process::ExitStatusExt; diff --git a/src/post.rs b/src/post.rs index f2f5eaf..12b4a6f 100644 --- a/src/post.rs +++ b/src/post.rs @@ -8,6 +8,8 @@ use crate::utils::{ use hyper::{Body, Request, Response}; use askama::Template; +use once_cell::sync::Lazy; +use regex::Regex; use std::collections::HashSet; // STRUCTS @@ -20,13 +22,18 @@ struct PostTemplate { prefs: Preferences, single_thread: bool, url: String, + url_without_query: String, + comment_query: String, } +static COMMENT_SEARCH_CAPTURE: Lazy = Lazy::new(|| Regex::new(r#"\?q=(.*)&type=comment"#).unwrap()); + pub async fn item(req: Request) -> Result, String> { // Build Reddit API path let mut path: String = format!("{}.json?{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default()); let sub = req.param("sub").unwrap_or_default(); let quarantined = can_access_quarantine(&req, &sub); + let url = req.uri().to_string(); // Set sort to sort query parameter let sort = param(&path, "sort").unwrap_or_else(|| { @@ -63,17 +70,26 @@ pub async fn item(req: Request) -> Result, String> { return Ok(nsfw_landing(req).await.unwrap_or_default()); } - let comments = parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req), &req); - let url = req.uri().to_string(); + let query = match COMMENT_SEARCH_CAPTURE.captures(&url) { + Some(captures) => captures.get(1).unwrap().as_str().replace("%20", " ").replace("+", " "), + None => String::new(), + }; + + let comments = match query.as_str() { + "" => parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req), &req), + _ => query_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req), &query, &req), + }; // Use the Post and Comment structs to generate a website to show users template(PostTemplate { comments, post, + url_without_query: url.clone().trim_end_matches(&format!("?q={query}&type=comment")).to_string(), sort, prefs: Preferences::new(&req), single_thread, url, + comment_query: query, }) } // If the Reddit API returns an error, exit and send error page to user @@ -88,7 +104,8 @@ pub async fn item(req: Request) -> Result, String> { } } -// COMMENTS +/* COMMENTS */ + fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str, highlighted_comment: &str, filters: &HashSet, req: &Request) -> Vec { // Parse the comment JSON into a Vector of Comments let comments = json["data"]["children"].as_array().map_or(Vec::new(), std::borrow::ToOwned::to_owned); @@ -97,96 +114,136 @@ fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str, comments .into_iter() .map(|comment| { - let kind = comment["kind"].as_str().unwrap_or_default().to_string(); let data = &comment["data"]; - - let unix_time = data["created_utc"].as_f64().unwrap_or_default(); - let (rel_time, created) = time(unix_time); - - let edited = data["edited"].as_f64().map_or((String::new(), String::new()), time); - - let score = data["score"].as_i64().unwrap_or(0); - - // The JSON API only provides comments up to some threshold. - // Further comments have to be loaded by subsequent requests. - // The "kind" value will be "more" and the "count" - // shows how many more (sub-)comments exist in the respective nesting level. - // Note that in certain (seemingly random) cases, the count is simply wrong. - let more_count = data["count"].as_i64().unwrap_or_default(); - - // If this comment contains replies, handle those too let replies: Vec = if data["replies"].is_object() { parse_comments(&data["replies"], post_link, post_author, highlighted_comment, filters, req) } else { - Vec::new() + Vec::with_capacity(0) }; - - let awards: Awards = Awards::parse(&data["all_awardings"]); - - let parent_kind_and_id = val(&comment, "parent_id"); - let parent_info = parent_kind_and_id.split('_').collect::>(); - - let id = val(&comment, "id"); - let highlighted = id == highlighted_comment; - - let body = if (val(&comment, "author") == "[deleted]" && val(&comment, "body") == "[removed]") || val(&comment, "body") == "[ Removed by Reddit ]" { - format!( - "

[removed] — view removed comment

", - post_link, id - ) - } else { - rewrite_urls(&val(&comment, "body_html")) - }; - - let author = Author { - name: val(&comment, "author"), - flair: Flair { - flair_parts: FlairPart::parse( - data["author_flair_type"].as_str().unwrap_or_default(), - data["author_flair_richtext"].as_array(), - data["author_flair_text"].as_str(), - ), - text: val(&comment, "link_flair_text"), - background_color: val(&comment, "author_flair_background_color"), - foreground_color: val(&comment, "author_flair_text_color"), - }, - distinguished: val(&comment, "distinguished"), - }; - let is_filtered = filters.contains(&["u_", author.name.as_str()].concat()); - - // Many subreddits have a default comment posted about the sub's rules etc. - // Many libreddit users do not wish to see this kind of comment by default. - // Reddit does not tell us which users are "bots", so a good heuristic is to - // collapse stickied moderator comments. - let is_moderator_comment = data["distinguished"].as_str().unwrap_or_default() == "moderator"; - let is_stickied = data["stickied"].as_bool().unwrap_or_default(); - let collapsed = (is_moderator_comment && is_stickied) || is_filtered; - - Comment { - id, - kind, - parent_id: parent_info[1].to_string(), - parent_kind: parent_info[0].to_string(), - post_link: post_link.to_string(), - post_author: post_author.to_string(), - body, - author, - score: if data["score_hidden"].as_bool().unwrap_or_default() { - ("\u{2022}".to_string(), "Hidden".to_string()) - } else { - format_num(score) - }, - rel_time, - created, - edited, - replies, - highlighted, - awards, - collapsed, - is_filtered, - more_count, - prefs: Preferences::new(req), - } + build_comment(&comment, data, replies, post_link, post_author, highlighted_comment, filters, req) }) .collect() } + +fn query_comments( + json: &serde_json::Value, + post_link: &str, + post_author: &str, + highlighted_comment: &str, + filters: &HashSet, + query: &str, + req: &Request, +) -> Vec { + let comments = json["data"]["children"].as_array().map_or(Vec::new(), std::borrow::ToOwned::to_owned); + let mut results = Vec::new(); + + comments.into_iter().for_each(|comment| { + let data = &comment["data"]; + + // If this comment contains replies, handle those too + if data["replies"].is_object() { + results.append(&mut query_comments(&data["replies"], post_link, post_author, highlighted_comment, filters, query, req)) + } + + let c = build_comment(&comment, data, Vec::with_capacity(0), post_link, post_author, highlighted_comment, filters, req); + if c.body.to_lowercase().contains(&query.to_lowercase()) { + results.push(c); + } + }); + + results +} + +fn build_comment( + comment: &serde_json::Value, + data: &serde_json::Value, + replies: Vec, + post_link: &str, + post_author: &str, + highlighted_comment: &str, + filters: &HashSet, + req: &Request, +) -> Comment { + let id = val(&comment, "id"); + + let body = if (val(&comment, "author") == "[deleted]" && val(&comment, "body") == "[removed]") || val(&comment, "body") == "[ Removed by Reddit ]" { + format!( + "

[removed] — view removed comment

", + post_link, id + ) + } else { + rewrite_urls(&val(&comment, "body_html")) + }; + let kind = comment["kind"].as_str().unwrap_or_default().to_string(); + + let unix_time = data["created_utc"].as_f64().unwrap_or_default(); + let (rel_time, created) = time(unix_time); + + let edited = data["edited"].as_f64().map_or((String::new(), String::new()), time); + + let score = data["score"].as_i64().unwrap_or(0); + + // The JSON API only provides comments up to some threshold. + // Further comments have to be loaded by subsequent requests. + // The "kind" value will be "more" and the "count" + // shows how many more (sub-)comments exist in the respective nesting level. + // Note that in certain (seemingly random) cases, the count is simply wrong. + let more_count = data["count"].as_i64().unwrap_or_default(); + + let awards: Awards = Awards::parse(&data["all_awardings"]); + + let parent_kind_and_id = val(&comment, "parent_id"); + let parent_info = parent_kind_and_id.split('_').collect::>(); + + let highlighted = id == highlighted_comment; + + let author = Author { + name: val(&comment, "author"), + flair: Flair { + flair_parts: FlairPart::parse( + data["author_flair_type"].as_str().unwrap_or_default(), + data["author_flair_richtext"].as_array(), + data["author_flair_text"].as_str(), + ), + text: val(&comment, "link_flair_text"), + background_color: val(&comment, "author_flair_background_color"), + foreground_color: val(&comment, "author_flair_text_color"), + }, + distinguished: val(&comment, "distinguished"), + }; + let is_filtered = filters.contains(&["u_", author.name.as_str()].concat()); + + // Many subreddits have a default comment posted about the sub's rules etc. + // Many libreddit users do not wish to see this kind of comment by default. + // Reddit does not tell us which users are "bots", so a good heuristic is to + // collapse stickied moderator comments. + let is_moderator_comment = data["distinguished"].as_str().unwrap_or_default() == "moderator"; + let is_stickied = data["stickied"].as_bool().unwrap_or_default(); + let collapsed = (is_moderator_comment && is_stickied) || is_filtered; + + return Comment { + id, + kind, + parent_id: parent_info[1].to_string(), + parent_kind: parent_info[0].to_string(), + post_link: post_link.to_string(), + post_author: post_author.to_string(), + body, + author, + score: if data["score_hidden"].as_bool().unwrap_or_default() { + ("\u{2022}".to_string(), "Hidden".to_string()) + } else { + format_num(score) + }, + rel_time, + created, + edited, + replies, + highlighted, + awards, + collapsed, + is_filtered, + more_count, + prefs: Preferences::new(&req), + }; +} diff --git a/static/style.css b/static/style.css index d64ad67..751d906 100644 --- a/static/style.css +++ b/static/style.css @@ -629,6 +629,15 @@ button.submit:hover > svg { stroke: var(--accent); } background: transparent; } +#commentQueryForms { + display: flex; + justify-content: space-between; +} + +#allCommentsLink { + color: var(--green); +} + #sort, #search_sort { display: flex; align-items: center; @@ -1550,6 +1559,7 @@ td, th { #user, #sidebar { margin: 20px 0; } #logo, #links { margin-bottom: 5px; } #searchbox { width: calc(100vw - 35px); } + } @media screen and (max-width: 480px) { @@ -1623,4 +1633,9 @@ td, th { .popup-inner { max-width: 80%; } -} \ No newline at end of file + + #commentQueryForms { + display: initial; + justify-content: initial; + } +} diff --git a/templates/comment.html b/templates/comment.html index f3d0f27..e75b888 100644 --- a/templates/comment.html +++ b/templates/comment.html @@ -35,7 +35,7 @@
{{ body|safe }}
{% endif %}
{% for c in replies -%}{{ c.render().unwrap()|safe }}{%- endfor %} -
+ {% endif %} diff --git a/templates/post.html b/templates/post.html index c79a18d..a996bb4 100644 --- a/templates/post.html +++ b/templates/post.html @@ -43,18 +43,32 @@ {% call utils::post(post) %} +

{{post.comments.0}} {% if post.comments.0 == "1" %}comment{% else %}comments{% endif %} sorted by

- {% call utils::options(sort, ["confidence", "top", "new", "controversial", "old"], "confidence") %} - -
+ + + + +
+ + +
+
+ +
+ {% if comment_query != "" %} + Comments containing "{{ comment_query }}" | All comments + {% endif %} +
{% for c in comments -%} diff --git a/templates/search.html b/templates/search.html index 4fc0c4d..53528e7 100644 --- a/templates/search.html +++ b/templates/search.html @@ -29,7 +29,7 @@ → - + {% if !is_filtered %} {% if subreddits.len() > 0 || params.typed == "sr_user" %}