From 4fa3a0f6ee309ac568aa5136d0713e15579e62e3 Mon Sep 17 00:00:00 2001 From: Trinity Pointard Date: Sat, 20 Oct 2018 16:38:16 +0200 Subject: [PATCH 1/3] Add support for hashtags in md parser --- plume-common/src/utils.rs | 147 +++++++++++++++++++++++++++-------- plume-models/src/comments.rs | 2 +- plume-models/src/instance.rs | 4 +- src/routes/comments.rs | 2 +- src/routes/posts.rs | 4 +- 5 files changed, 119 insertions(+), 40 deletions(-) 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, From 95ea2485184712f4646754fe8c8f481deef301e7 Mon Sep 17 00:00:00 2001 From: Trinity Pointard Date: Sat, 20 Oct 2018 19:27:49 +0200 Subject: [PATCH 2/3] Add support for hashtag on user interface Add migration to fix typo Add support for linking hashtags with posts Rework tag search page so it says a nicer message than page not found when no post use that tag Add new string to translation --- .../down.sql | 2 + .../2018-10-20-164036_fix_hastag_typo/up.sql | 1 + .../down.sql | 10 +++++ .../2018-10-20-164036_fix_hastag_typo/up.sql | 10 +++++ plume-common/src/utils.rs | 2 +- plume-models/src/schema.rs | 2 +- plume-models/src/tags.rs | 6 +-- po/en.po | 3 ++ po/fr.po | 3 ++ po/gl.po | 3 ++ po/nb.po | 3 ++ po/pl.po | 3 ++ po/plume.pot | 3 ++ src/routes/posts.rs | 37 +++++++++++++++---- src/routes/tags.rs | 16 ++++---- templates/tags/index.html.tera | 20 ++++++---- 16 files changed, 96 insertions(+), 28 deletions(-) create mode 100644 migrations/postgres/2018-10-20-164036_fix_hastag_typo/down.sql create mode 100644 migrations/postgres/2018-10-20-164036_fix_hastag_typo/up.sql create mode 100644 migrations/sqlite/2018-10-20-164036_fix_hastag_typo/down.sql create mode 100644 migrations/sqlite/2018-10-20-164036_fix_hastag_typo/up.sql diff --git a/migrations/postgres/2018-10-20-164036_fix_hastag_typo/down.sql b/migrations/postgres/2018-10-20-164036_fix_hastag_typo/down.sql new file mode 100644 index 0000000..e96261d --- /dev/null +++ b/migrations/postgres/2018-10-20-164036_fix_hastag_typo/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE tags RENAME COLUMN is_hashtag TO is_hastag; diff --git a/migrations/postgres/2018-10-20-164036_fix_hastag_typo/up.sql b/migrations/postgres/2018-10-20-164036_fix_hastag_typo/up.sql new file mode 100644 index 0000000..32914c2 --- /dev/null +++ b/migrations/postgres/2018-10-20-164036_fix_hastag_typo/up.sql @@ -0,0 +1 @@ +ALTER TABLE tags RENAME COLUMN is_hastag TO is_hashtag; diff --git a/migrations/sqlite/2018-10-20-164036_fix_hastag_typo/down.sql b/migrations/sqlite/2018-10-20-164036_fix_hastag_typo/down.sql new file mode 100644 index 0000000..47965c1 --- /dev/null +++ b/migrations/sqlite/2018-10-20-164036_fix_hastag_typo/down.sql @@ -0,0 +1,10 @@ +CREATE TABLE tags2 ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + tag TEXT NOT NULL DEFAULT '', + is_hastag BOOLEAN NOT NULL DEFAULT 'f', + post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE NOT NULL +); + +INSERT INTO tags2 SELECT * FROM tags; +DROP TABLE tags; +ALTER TABLE tags2 RENAME TO tags; diff --git a/migrations/sqlite/2018-10-20-164036_fix_hastag_typo/up.sql b/migrations/sqlite/2018-10-20-164036_fix_hastag_typo/up.sql new file mode 100644 index 0000000..5993b3c --- /dev/null +++ b/migrations/sqlite/2018-10-20-164036_fix_hastag_typo/up.sql @@ -0,0 +1,10 @@ +CREATE TABLE tags2 ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + tag TEXT NOT NULL DEFAULT '', + is_hashtag BOOLEAN NOT NULL DEFAULT 'f', + post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE NOT NULL +); + +INSERT INTO tags2 SELECT * FROM tags; +DROP TABLE tags; +ALTER TABLE tags2 RENAME TO tags; diff --git a/plume-common/src/utils.rs b/plume-common/src/utils.rs index 1f06b44..36f9558 100644 --- a/plume-common/src/utils.rs +++ b/plume-common/src/utils.rs @@ -68,7 +68,7 @@ pub fn md_to_html(md: &str) -> (String, Vec, Vec) { } else { text_acc }; - let link = Tag::Link(format!("/tag/{}", hashtag).into(), hashtag.to_string().into()); + let link = Tag::Link(format!("/tag/{}", hashtag.to_camel_case()).into(), hashtag.to_string().into()); hashtags.push(hashtag.clone()); events.push(Event::Start(link.clone())); diff --git a/plume-models/src/schema.rs b/plume-models/src/schema.rs index 7d51d67..b2a0108 100644 --- a/plume-models/src/schema.rs +++ b/plume-models/src/schema.rs @@ -144,7 +144,7 @@ table! { tags (id) { id -> Int4, tag -> Text, - is_hastag -> Bool, + is_hashtag -> Bool, post_id -> Int4, } } diff --git a/plume-models/src/tags.rs b/plume-models/src/tags.rs index c15dd14..99e6cb6 100644 --- a/plume-models/src/tags.rs +++ b/plume-models/src/tags.rs @@ -9,7 +9,7 @@ use schema::tags; pub struct Tag { pub id: i32, pub tag: String, - pub is_hastag: bool, + pub is_hashtag: bool, pub post_id: i32 } @@ -17,7 +17,7 @@ pub struct Tag { #[table_name = "tags"] pub struct NewTag { pub tag: String, - pub is_hastag: bool, + pub is_hashtag: bool, pub post_id: i32 } @@ -40,7 +40,7 @@ impl Tag { pub fn from_activity(conn: &Connection, tag: Hashtag, post: i32) -> Tag { Tag::insert(conn, NewTag { tag: tag.name_string().expect("Tag::from_activity: name error"), - is_hastag: false, + is_hashtag: false, post_id: post }) } diff --git a/po/en.po b/po/en.po index 7586ad9..646250b 100644 --- a/po/en.po +++ b/po/en.po @@ -608,3 +608,6 @@ msgstr "" msgid "This post isn't published yet." msgstr "" + +msgid "There is currently no article with that tag" +msgstr "" diff --git a/po/fr.po b/po/fr.po index c858f2d..8e1a5b4 100644 --- a/po/fr.po +++ b/po/fr.po @@ -624,3 +624,6 @@ msgstr "Utilisateurs" msgid "This post isn't published yet." msgstr "Cet article n’est pas encore publié." + +msgid "There is currently no article with that tag" +msgstr "Il n'y a pas encore d'article avec ce tag" diff --git a/po/gl.po b/po/gl.po index fd219f7..a9782da 100644 --- a/po/gl.po +++ b/po/gl.po @@ -611,3 +611,6 @@ msgstr "Usuarias" #, fuzzy msgid "This post isn't published yet." msgstr "Esto é un borrador, non publicar por agora." + +msgid "There is currently no article with that tag" +msgstr "" diff --git a/po/nb.po b/po/nb.po index b636990..136f23d 100644 --- a/po/nb.po +++ b/po/nb.po @@ -633,6 +633,9 @@ msgstr "Brukernavn" msgid "This post isn't published yet." msgstr "" +msgid "There is currently no article with that tag" +msgstr "" + #~ msgid "One reshare" #~ msgid_plural "{{ count }} reshares" #~ msgstr[0] "Én deling" diff --git a/po/pl.po b/po/pl.po index 9e28d73..839fe52 100644 --- a/po/pl.po +++ b/po/pl.po @@ -623,6 +623,9 @@ msgstr "Użytkownicy" msgid "This post isn't published yet." msgstr "Ten wpis nie został jeszcze opublikowany." +msgid "There is currently no article with that tag" +msgstr "" + #~ msgid "One reshare" #~ msgid_plural "{{ count }} reshares" #~ msgstr[0] "Jedno udostępnienie" diff --git a/po/plume.pot b/po/plume.pot index 423338e..99ee663 100644 --- a/po/plume.pot +++ b/po/plume.pot @@ -591,3 +591,6 @@ msgstr "" msgid "This post isn't published yet." msgstr "" + +msgid "There is currently no article with that tag" +msgstr "" diff --git a/src/routes/posts.rs b/src/routes/posts.rs index 518c83a..b7692f5 100644 --- a/src/routes/posts.rs +++ b/src/routes/posts.rs @@ -139,7 +139,7 @@ fn edit(blog: String, slug: String, user: User, conn: DbConn) -> Option