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 00000000..e96261d7 --- /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 00000000..32914c21 --- /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 00000000..47965c12 --- /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 00000000..5993b3c4 --- /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 d425fa5c..36f9558a 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.to_camel_case()).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 0376f48b..d70908f7 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 21c332e8..2e4bfac8 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/plume-models/src/schema.rs b/plume-models/src/schema.rs index 7d51d678..b2a01083 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 c15dd14e..99e6cb6c 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 ec68bff0..53f1fea6 100644 --- a/po/en.po +++ b/po/en.po @@ -611,3 +611,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 435ac9c3..5598b100 100644 --- a/po/fr.po +++ b/po/fr.po @@ -627,3 +627,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 8098f336..6d3be754 100644 --- a/po/gl.po +++ b/po/gl.po @@ -614,3 +614,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 04f7feb8..f8bdc537 100644 --- a/po/nb.po +++ b/po/nb.po @@ -636,6 +636,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 94ff56ad..32277f09 100644 --- a/po/pl.po +++ b/po/pl.po @@ -626,6 +626,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 75bc55a1..c3ab6764 100644 --- a/po/plume.pot +++ b/po/plume.pot @@ -594,3 +594,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/comments.rs b/src/routes/comments.rs index da88e556..1ccfc9dd 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 567d6111..b7692f5e 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