diff --git a/plume-common/src/utils.rs b/plume-common/src/utils.rs index d425fa5..1f06b44 100644 --- a/plume-common/src/utils.rs +++ b/plume-common/src/utils.rs @@ -20,58 +20,117 @@ pub fn requires_login(message: &str, url: Uri) -> Flash { Flash::new(Redirect::to(format!("/login?m={}", gettext(message.to_string()))), "callback", url.to_string()) } -/// Returns (HTML, mentions) -pub fn md_to_html(md: &str) -> (String, Vec) { +#[derive(Debug)] +enum State { + Mention, + Hashtag, + Word, + Ready, +} + +/// Returns (HTML, mentions, hashtags) +pub fn md_to_html(md: &str) -> (String, Vec, Vec) { let parser = Parser::new_ext(md, Options::all()); - let (parser, mentions): (Vec>, Vec>) = parser.map(|evt| match evt { + let (parser, mentions, hashtags): (Vec>, Vec>, Vec>) = parser.map(|evt| match evt { Event::Text(txt) => { - let (evts, _, _, _, new_mentions) = txt.chars().fold((vec![], false, String::new(), 0, vec![]), |(mut events, in_mention, text_acc, n, mut mentions), c| { - if in_mention { - let char_matches = c.is_alphanumeric() || c == '@' || c == '.' || c == '-' || c == '_'; - if char_matches && (n < (txt.chars().count() - 1)) { - (events, in_mention, text_acc + c.to_string().as_ref(), n + 1, mentions) - } else { - let mention = if char_matches { - text_acc + c.to_string().as_ref() + let (evts, _, _, _, new_mentions, new_hashtags) = txt.chars().fold((vec![], State::Ready, String::new(), 0, vec![], vec![]), |(mut events, state, text_acc, n, mut mentions, mut hashtags), c| { + match state { + State::Mention => { + let char_matches = c.is_alphanumeric() || c == '@' || c == '.' || c == '-' || c == '_'; + if char_matches && (n < (txt.chars().count() - 1)) { + (events, State::Mention, text_acc + c.to_string().as_ref(), n + 1, mentions, hashtags) } else { - text_acc - }; - let short_mention = mention.clone(); - let short_mention = short_mention.splitn(1, '@').nth(0).unwrap_or(""); - let link = Tag::Link(format!("/@/{}/", mention).into(), short_mention.to_string().into()); + let mention = if char_matches { + text_acc + c.to_string().as_ref() + } else { + text_acc + }; + let short_mention = mention.clone(); + let short_mention = short_mention.splitn(1, '@').nth(0).unwrap_or(""); + let link = Tag::Link(format!("/@/{}/", mention).into(), short_mention.to_string().into()); - mentions.push(mention); - events.push(Event::Start(link.clone())); - events.push(Event::Text(format!("@{}", short_mention).into())); - events.push(Event::End(link)); + mentions.push(mention); + events.push(Event::Start(link.clone())); + events.push(Event::Text(format!("@{}", short_mention).into())); + events.push(Event::End(link)); - (events, false, c.to_string(), n + 1, mentions) - } - } else { - if c == '@' { - events.push(Event::Text(text_acc.into())); - (events, true, String::new(), n + 1, mentions) - } else { - if n >= (txt.chars().count() - 1) { // Add the text after at the end, even if it is not followed by a mention. - events.push(Event::Text((text_acc.clone() + c.to_string().as_ref()).into())) + (events, State::Ready, c.to_string(), n + 1, mentions, hashtags) + } + } + State::Hashtag => { + let char_matches = c.is_alphanumeric(); + if char_matches && (n < (txt.chars().count() -1)) { + (events, State::Hashtag, text_acc + c.to_string().as_ref(), n+1, mentions, hashtags) + } else { + let hashtag = if char_matches { + text_acc + c.to_string().as_ref() + } else { + text_acc + }; + let link = Tag::Link(format!("/tag/{}", hashtag).into(), hashtag.to_string().into()); + + hashtags.push(hashtag.clone()); + events.push(Event::Start(link.clone())); + events.push(Event::Text(format!("#{}", hashtag).into())); + events.push(Event::End(link)); + + (events, State::Ready, c.to_string(), n + 1, mentions, hashtags) + } + } + State::Ready => { + if c == '@' { + events.push(Event::Text(text_acc.into())); + (events, State::Mention, String::new(), n + 1, mentions, hashtags) + } else if c == '#' { + events.push(Event::Text(text_acc.into())); + (events, State::Hashtag, String::new(), n + 1, mentions, hashtags) + } else if c.is_alphanumeric() { + if n >= (txt.chars().count() - 1) { // Add the text after at the end, even if it is not followed by a mention. + events.push(Event::Text((text_acc.clone() + c.to_string().as_ref()).into())) + } + (events, State::Word, text_acc + c.to_string().as_ref(), n + 1, mentions, hashtags) + } else { + if n >= (txt.chars().count() - 1) { // Add the text after at the end, even if it is not followed by a mention. + events.push(Event::Text((text_acc.clone() + c.to_string().as_ref()).into())) + } + (events, State::Ready, text_acc + c.to_string().as_ref(), n + 1, mentions, hashtags) + } + } + State::Word => { + if c.is_alphanumeric() { + if n >= (txt.chars().count() - 1) { // Add the text after at the end, even if it is not followed by a mention. + events.push(Event::Text((text_acc.clone() + c.to_string().as_ref()).into())) + } + (events, State::Word, text_acc + c.to_string().as_ref(), n + 1, mentions, hashtags) + } else { + if n >= (txt.chars().count() - 1) { // Add the text after at the end, even if it is not followed by a mention. + events.push(Event::Text((text_acc.clone() + c.to_string().as_ref()).into())) + } + (events, State::Ready, text_acc + c.to_string().as_ref(), n + 1, mentions, hashtags) } - (events, in_mention, text_acc + c.to_string().as_ref(), n + 1, mentions) } } }); - (evts, new_mentions) + (evts, new_mentions, new_hashtags) }, - _ => (vec![evt], vec![]) - }).unzip(); + _ => (vec![evt], vec![], vec![]) + }).fold((vec![],vec![],vec![]), |(mut parser, mut mention, mut hashtag), (p, m, h)| { + parser.push(p); + mention.push(m); + hashtag.push(h); + (parser, mention, hashtag) + }); let parser = parser.into_iter().flatten(); let mentions = mentions.into_iter().flatten().map(|m| String::from(m.trim())); + let hashtags = hashtags.into_iter().flatten().map(|h| String::from(h.trim())); // TODO: fetch mentionned profiles in background, if needed let mut buf = String::new(); html::push_html(&mut buf, parser); - (buf, mentions.collect()) + let hashtags = hashtags.collect(); + (buf, mentions.collect(), hashtags) } #[cfg(test)] @@ -90,10 +149,30 @@ mod tests { ("between parenthesis (@test)", vec!["test"]), ("with some punctuation @test!", vec!["test"]), (" @spaces ", vec!["spaces"]), + ("not_a@mention", vec![]), ]; for (md, mentions) in tests { assert_eq!(md_to_html(md).1, mentions.into_iter().map(|s| s.to_string()).collect::>()); } } + + #[test] + fn test_hashtags() { + let tests = vec![ + ("nothing", vec![]), + ("#hashtag", vec!["hashtag"]), + ("#many #hashtags", vec!["many", "hashtags"]), + ("#start with a hashtag", vec!["start"]), + ("hashtag at #end", vec!["end"]), + ("between parenthesis (#test)", vec!["test"]), + ("with some punctuation #test!", vec!["test"]), + (" #spaces ", vec!["spaces"]), + ("not_a#hashtag", vec![]), + ]; + + for (md, mentions) in tests { + assert_eq!(md_to_html(md).2, mentions.into_iter().map(|s| s.to_string()).collect::>()); + } + } } diff --git a/plume-models/src/comments.rs b/plume-models/src/comments.rs index 0376f48..d70908f 100644 --- a/plume-models/src/comments.rs +++ b/plume-models/src/comments.rs @@ -100,7 +100,7 @@ impl Comment { } pub fn into_activity(&self, conn: &Connection) -> Note { - let (html, mentions) = utils::md_to_html(self.content.get().as_ref()); + let (html, mentions, _hashtags) = utils::md_to_html(self.content.get().as_ref()); let author = User::get(conn, self.author_id).expect("Comment::into_activity: author error"); let mut note = Note::default(); diff --git a/plume-models/src/instance.rs b/plume-models/src/instance.rs index 21c332e..2e4bfac 100644 --- a/plume-models/src/instance.rs +++ b/plume-models/src/instance.rs @@ -117,8 +117,8 @@ impl Instance { } pub fn update(&self, conn: &Connection, name: String, open_registrations: bool, short_description: SafeString, long_description: SafeString) { - let (sd, _) = md_to_html(short_description.as_ref()); - let (ld, _) = md_to_html(long_description.as_ref()); + let (sd, _, _) = md_to_html(short_description.as_ref()); + let (ld, _, _) = md_to_html(long_description.as_ref()); diesel::update(self) .set(( instances::name.eq(name), diff --git a/src/routes/comments.rs b/src/routes/comments.rs index da88e55..1ccfc9d 100644 --- a/src/routes/comments.rs +++ b/src/routes/comments.rs @@ -35,7 +35,7 @@ fn create(blog_name: String, slug: String, data: LenientForm, us let form = data.get(); form.validate() .map(|_| { - let (html, mentions) = utils::md_to_html(form.content.as_ref()); + let (html, mentions, _hashtags) = utils::md_to_html(form.content.as_ref()); let comm = Comment::insert(&*conn, NewComment { content: SafeString::new(html.as_ref()), in_response_to_id: form.responding_to.clone(), diff --git a/src/routes/posts.rs b/src/routes/posts.rs index 567d611..518c83a 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -183,7 +183,7 @@ fn update(blog: String, slug: String, user: User, conn: DbConn, data: LenientFor // actually it's not "Ok"… Ok(Redirect::to(uri!(super::blogs::details: name = blog))) } else { - let (content, mentions) = utils::md_to_html(form.content.to_string().as_ref()); + let (content, mentions, _hashtag) = utils::md_to_html(form.content.to_string().as_ref());//TODO do something with hashtags let license = if form.license.len() > 0 { form.license.to_string() @@ -294,7 +294,7 @@ fn create(blog_name: String, data: LenientForm, user: User, conn: D // actually it's not "Ok"… Ok(Redirect::to(uri!(super::blogs::details: name = blog_name))) } else { - let (content, mentions) = utils::md_to_html(form.content.to_string().as_ref()); + let (content, mentions, _hashtag) = utils::md_to_html(form.content.to_string().as_ref());//TODO do something with hashtags let post = Post::insert(&*conn, NewPost { blog_id: blog.id,