Merge pull request #283 from Plume-org/hashtags

Support hashtags
This commit is contained in:
Baptiste Gelez 2018-10-21 13:53:15 +01:00 committed by GitHub
commit fc5acac861
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 215 additions and 66 deletions

View File

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE tags RENAME COLUMN is_hashtag TO is_hastag;

View File

@ -0,0 +1 @@
ALTER TABLE tags RENAME COLUMN is_hastag TO is_hashtag;

View File

@ -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;

View File

@ -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;

View File

@ -20,58 +20,117 @@ pub fn requires_login(message: &str, url: Uri) -> Flash<Redirect> {
Flash::new(Redirect::to(format!("/login?m={}", gettext(message.to_string()))), "callback", url.to_string()) Flash::new(Redirect::to(format!("/login?m={}", gettext(message.to_string()))), "callback", url.to_string())
} }
/// Returns (HTML, mentions) #[derive(Debug)]
pub fn md_to_html(md: &str) -> (String, Vec<String>) { enum State {
Mention,
Hashtag,
Word,
Ready,
}
/// Returns (HTML, mentions, hashtags)
pub fn md_to_html(md: &str) -> (String, Vec<String>, Vec<String>) {
let parser = Parser::new_ext(md, Options::all()); let parser = Parser::new_ext(md, Options::all());
let (parser, mentions): (Vec<Vec<Event>>, Vec<Vec<String>>) = parser.map(|evt| match evt { let (parser, mentions, hashtags): (Vec<Vec<Event>>, Vec<Vec<String>>, Vec<Vec<String>>) = parser.map(|evt| match evt {
Event::Text(txt) => { 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| { 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| {
if in_mention { match state {
let char_matches = c.is_alphanumeric() || c == '@' || c == '.' || c == '-' || c == '_'; State::Mention => {
if char_matches && (n < (txt.chars().count() - 1)) { let char_matches = c.is_alphanumeric() || c == '@' || c == '.' || c == '-' || c == '_';
(events, in_mention, text_acc + c.to_string().as_ref(), n + 1, mentions) if char_matches && (n < (txt.chars().count() - 1)) {
} else { (events, State::Mention, text_acc + c.to_string().as_ref(), n + 1, mentions, hashtags)
let mention = if char_matches {
text_acc + c.to_string().as_ref()
} else { } else {
text_acc let mention = if char_matches {
}; text_acc + c.to_string().as_ref()
let short_mention = mention.clone(); } else {
let short_mention = short_mention.splitn(1, '@').nth(0).unwrap_or(""); text_acc
let link = Tag::Link(format!("/@/{}/", mention).into(), short_mention.to_string().into()); };
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); mentions.push(mention);
events.push(Event::Start(link.clone())); events.push(Event::Start(link.clone()));
events.push(Event::Text(format!("@{}", short_mention).into())); events.push(Event::Text(format!("@{}", short_mention).into()));
events.push(Event::End(link)); events.push(Event::End(link));
(events, false, c.to_string(), n + 1, mentions) (events, State::Ready, c.to_string(), n + 1, mentions, hashtags)
} }
} else { }
if c == '@' { State::Hashtag => {
events.push(Event::Text(text_acc.into())); let char_matches = c.is_alphanumeric();
(events, true, String::new(), n + 1, mentions) if char_matches && (n < (txt.chars().count() -1)) {
} else { (events, State::Hashtag, text_acc + c.to_string().as_ref(), n+1, mentions, hashtags)
if n >= (txt.chars().count() - 1) { // Add the text after at the end, even if it is not followed by a mention. } else {
events.push(Event::Text((text_acc.clone() + c.to_string().as_ref()).into())) 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![]) _ => (vec![evt], vec![], vec![])
}).unzip(); }).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 parser = parser.into_iter().flatten();
let mentions = mentions.into_iter().flatten().map(|m| String::from(m.trim())); 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 // TODO: fetch mentionned profiles in background, if needed
let mut buf = String::new(); let mut buf = String::new();
html::push_html(&mut buf, parser); html::push_html(&mut buf, parser);
(buf, mentions.collect()) let hashtags = hashtags.collect();
(buf, mentions.collect(), hashtags)
} }
#[cfg(test)] #[cfg(test)]
@ -90,10 +149,30 @@ mod tests {
("between parenthesis (@test)", vec!["test"]), ("between parenthesis (@test)", vec!["test"]),
("with some punctuation @test!", vec!["test"]), ("with some punctuation @test!", vec!["test"]),
(" @spaces ", vec!["spaces"]), (" @spaces ", vec!["spaces"]),
("not_a@mention", vec![]),
]; ];
for (md, mentions) in tests { for (md, mentions) in tests {
assert_eq!(md_to_html(md).1, mentions.into_iter().map(|s| s.to_string()).collect::<Vec<String>>()); assert_eq!(md_to_html(md).1, mentions.into_iter().map(|s| s.to_string()).collect::<Vec<String>>());
} }
} }
#[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::<Vec<String>>());
}
}
} }

View File

@ -100,7 +100,7 @@ impl Comment {
} }
pub fn into_activity(&self, conn: &Connection) -> Note { 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 author = User::get(conn, self.author_id).expect("Comment::into_activity: author error");
let mut note = Note::default(); let mut note = Note::default();

View File

@ -117,8 +117,8 @@ impl Instance {
} }
pub fn update(&self, conn: &Connection, name: String, open_registrations: bool, short_description: SafeString, long_description: SafeString) { 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 (sd, _, _) = md_to_html(short_description.as_ref());
let (ld, _) = md_to_html(long_description.as_ref()); let (ld, _, _) = md_to_html(long_description.as_ref());
diesel::update(self) diesel::update(self)
.set(( .set((
instances::name.eq(name), instances::name.eq(name),

View File

@ -144,7 +144,7 @@ table! {
tags (id) { tags (id) {
id -> Int4, id -> Int4,
tag -> Text, tag -> Text,
is_hastag -> Bool, is_hashtag -> Bool,
post_id -> Int4, post_id -> Int4,
} }
} }

View File

@ -9,7 +9,7 @@ use schema::tags;
pub struct Tag { pub struct Tag {
pub id: i32, pub id: i32,
pub tag: String, pub tag: String,
pub is_hastag: bool, pub is_hashtag: bool,
pub post_id: i32 pub post_id: i32
} }
@ -17,7 +17,7 @@ pub struct Tag {
#[table_name = "tags"] #[table_name = "tags"]
pub struct NewTag { pub struct NewTag {
pub tag: String, pub tag: String,
pub is_hastag: bool, pub is_hashtag: bool,
pub post_id: i32 pub post_id: i32
} }
@ -40,7 +40,7 @@ impl Tag {
pub fn from_activity(conn: &Connection, tag: Hashtag, post: i32) -> Tag { pub fn from_activity(conn: &Connection, tag: Hashtag, post: i32) -> Tag {
Tag::insert(conn, NewTag { Tag::insert(conn, NewTag {
tag: tag.name_string().expect("Tag::from_activity: name error"), tag: tag.name_string().expect("Tag::from_activity: name error"),
is_hastag: false, is_hashtag: false,
post_id: post post_id: post
}) })
} }

View File

@ -611,3 +611,6 @@ msgstr ""
msgid "This post isn't published yet." msgid "This post isn't published yet."
msgstr "" msgstr ""
msgid "There is currently no article with that tag"
msgstr ""

View File

@ -627,3 +627,6 @@ msgstr "Utilisateurs"
msgid "This post isn't published yet." msgid "This post isn't published yet."
msgstr "Cet article nest pas encore publié." msgstr "Cet article nest pas encore publié."
msgid "There is currently no article with that tag"
msgstr "Il n'y a pas encore d'article avec ce tag"

View File

@ -614,3 +614,6 @@ msgstr "Usuarias"
#, fuzzy #, fuzzy
msgid "This post isn't published yet." msgid "This post isn't published yet."
msgstr "Esto é un borrador, non publicar por agora." msgstr "Esto é un borrador, non publicar por agora."
msgid "There is currently no article with that tag"
msgstr ""

View File

@ -636,6 +636,9 @@ msgstr "Brukernavn"
msgid "This post isn't published yet." msgid "This post isn't published yet."
msgstr "" msgstr ""
msgid "There is currently no article with that tag"
msgstr ""
#~ msgid "One reshare" #~ msgid "One reshare"
#~ msgid_plural "{{ count }} reshares" #~ msgid_plural "{{ count }} reshares"
#~ msgstr[0] "Én deling" #~ msgstr[0] "Én deling"

View File

@ -626,6 +626,9 @@ msgstr "Użytkownicy"
msgid "This post isn't published yet." msgid "This post isn't published yet."
msgstr "Ten wpis nie został jeszcze opublikowany." msgstr "Ten wpis nie został jeszcze opublikowany."
msgid "There is currently no article with that tag"
msgstr ""
#~ msgid "One reshare" #~ msgid "One reshare"
#~ msgid_plural "{{ count }} reshares" #~ msgid_plural "{{ count }} reshares"
#~ msgstr[0] "Jedno udostępnienie" #~ msgstr[0] "Jedno udostępnienie"

View File

@ -594,3 +594,6 @@ msgstr ""
msgid "This post isn't published yet." msgid "This post isn't published yet."
msgstr "" msgstr ""
msgid "There is currently no article with that tag"
msgstr ""

View File

@ -35,7 +35,7 @@ fn create(blog_name: String, slug: String, data: LenientForm<NewCommentForm>, us
let form = data.get(); let form = data.get();
form.validate() form.validate()
.map(|_| { .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 { let comm = Comment::insert(&*conn, NewComment {
content: SafeString::new(html.as_ref()), content: SafeString::new(html.as_ref()),
in_response_to_id: form.responding_to.clone(), in_response_to_id: form.responding_to.clone(),

View File

@ -139,7 +139,7 @@ fn edit(blog: String, slug: String, user: User, conn: DbConn) -> Option<Template
content: source, content: source,
tags: Tag::for_post(&*conn, post.id) tags: Tag::for_post(&*conn, post.id)
.into_iter() .into_iter()
.map(|t| t.tag) .filter_map(|t| if !t.is_hashtag {Some(t.tag)} else {None})
.collect::<Vec<String>>() .collect::<Vec<String>>()
.join(", "), .join(", "),
license: post.license.clone(), license: post.license.clone(),
@ -183,7 +183,7 @@ fn update(blog: String, slug: String, user: User, conn: DbConn, data: LenientFor
// actually it's not "Ok"… // actually it's not "Ok"…
Ok(Redirect::to(uri!(super::blogs::details: name = blog))) Ok(Redirect::to(uri!(super::blogs::details: name = blog)))
} else { } else {
let (content, mentions) = utils::md_to_html(form.content.to_string().as_ref()); let (content, mentions, hashtags) = utils::md_to_html(form.content.to_string().as_ref());
let license = if form.license.len() > 0 { let license = if form.license.len() > 0 {
form.license.to_string() form.license.to_string()
@ -215,16 +215,32 @@ fn update(blog: String, slug: String, user: User, conn: DbConn, data: LenientFor
let old_tags = Tag::for_post(&*conn, post.id).into_iter().collect::<Vec<_>>(); let old_tags = Tag::for_post(&*conn, post.id).into_iter().collect::<Vec<_>>();
let tags = form.tags.split(",").map(|t| t.trim().to_camel_case()).filter(|t| t.len() > 0).collect::<Vec<_>>(); let tags = form.tags.split(",").map(|t| t.trim().to_camel_case()).filter(|t| t.len() > 0).collect::<Vec<_>>();
for tag in tags.iter() { for tag in tags.iter() {
if old_tags.iter().all(|ot| &ot.tag!=tag) { if old_tags.iter().all(|ot| &ot.tag!=tag || ot.is_hashtag) {
Tag::insert(&*conn, NewTag { Tag::insert(&*conn, NewTag {
tag: tag.clone(), tag: tag.clone(),
is_hastag: false, is_hashtag: false,
post_id: post.id post_id: post.id
}); });
} }
} }
for ot in old_tags.iter() {
if !tags.contains(&ot.tag) && !ot.is_hashtag {
ot.delete(&conn);
}
}
let hashtags = hashtags.into_iter().map(|h| h.to_camel_case()).collect::<Vec<_>>();
for hashtag in hashtags.iter() {
if old_tags.iter().all(|ot| &ot.tag!=hashtag || !ot.is_hashtag) {
Tag::insert(&*conn, NewTag {
tag: hashtag.clone(),
is_hashtag: true,
post_id: post.id,
});
}
}
for ot in old_tags { for ot in old_tags {
if !tags.contains(&ot.tag) { if !hashtags.contains(&ot.tag) && ot.is_hashtag {
ot.delete(&conn); ot.delete(&conn);
} }
} }
@ -294,7 +310,7 @@ fn create(blog_name: String, data: LenientForm<NewPostForm>, user: User, conn: D
// actually it's not "Ok"… // actually it's not "Ok"…
Ok(Redirect::to(uri!(super::blogs::details: name = blog_name))) Ok(Redirect::to(uri!(super::blogs::details: name = blog_name)))
} else { } else {
let (content, mentions) = utils::md_to_html(form.content.to_string().as_ref()); let (content, mentions, hashtags) = utils::md_to_html(form.content.to_string().as_ref());
let post = Post::insert(&*conn, NewPost { let post = Post::insert(&*conn, NewPost {
blog_id: blog.id, blog_id: blog.id,
@ -322,7 +338,14 @@ fn create(blog_name: String, data: LenientForm<NewPostForm>, user: User, conn: D
for tag in tags { for tag in tags {
Tag::insert(&*conn, NewTag { Tag::insert(&*conn, NewTag {
tag: tag, tag: tag,
is_hastag: false, is_hashtag: false,
post_id: post.id
});
}
for hashtag in hashtags {
Tag::insert(&*conn, NewTag {
tag: hashtag.to_camel_case(),
is_hashtag: true,
post_id: post.id post_id: post.id
}); });
} }

View File

@ -4,25 +4,23 @@ use serde_json;
use plume_models::{ use plume_models::{
db_conn::DbConn, db_conn::DbConn,
posts::Post, posts::Post,
tags::Tag,
users::User, users::User,
}; };
use routes::Page; use routes::Page;
#[get("/tag/<name>")] #[get("/tag/<name>")]
fn tag(user: Option<User>, conn: DbConn, name: String) -> Option<Template> { fn tag(user: Option<User>, conn: DbConn, name: String) -> Template {
paginated_tag(user, conn, name, Page::first()) paginated_tag(user, conn, name, Page::first())
} }
#[get("/tag/<name>?<page>")] #[get("/tag/<name>?<page>")]
fn paginated_tag(user: Option<User>, conn: DbConn, name: String, page: Page) -> Option<Template> { fn paginated_tag(user: Option<User>, conn: DbConn, name: String, page: Page) -> Template {
let tag = Tag::find_by_name(&*conn, name)?; let posts = Post::list_by_tag(&*conn, name.clone(), page.limits());
let posts = Post::list_by_tag(&*conn, tag.tag.clone(), page.limits()); Template::render("tags/index", json!({
Some(Template::render("tags/index", json!({ "tag": name.clone(),
"tag": tag.clone(),
"account": user.map(|u| u.to_json(&*conn)), "account": user.map(|u| u.to_json(&*conn)),
"articles": posts.into_iter().map(|p| p.to_json(&*conn)).collect::<Vec<serde_json::Value>>(), "articles": posts.into_iter().map(|p| p.to_json(&*conn)).collect::<Vec<serde_json::Value>>(),
"page": page.page, "page": page.page,
"n_pages": Page::total(Post::count_for_tag(&*conn, tag.tag) as i32) "n_pages": Page::total(Post::count_for_tag(&*conn, name) as i32)
}))) }))
} }

View File

@ -43,7 +43,9 @@
<p>{{ "This article is under the {{ license }} license." | _(license=article.post.license) }}</p> <p>{{ "This article is under the {{ license }} license." | _(license=article.post.license) }}</p>
<ul class="tags"> <ul class="tags">
{% for tag in article.tags %} {% for tag in article.tags %}
<li><a href="/tag/{{ tag.tag }}">{{ tag.tag }}</a></li> {% if not tag.is_hashtag %}
<li><a href="/tag/{{ tag.tag }}">{{ tag.tag }}</a></li>
{% endif %}
{% endfor %} {% endfor %}
</ul> </ul>
<div class="flex"> <div class="flex">

View File

@ -2,16 +2,22 @@
{% import "macros" as macros %} {% import "macros" as macros %}
{% block title %} {% block title %}
{{ 'Articles tagged "{{ tag }}"' | _(tag=tag.tag) }} {{ 'Articles tagged "{{ tag }}"' | _(tag=tag) }}
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<h1>{{ 'Articles tagged "{{ tag }}"' | _(tag=tag.tag) }}</h1> <h1>{{ 'Articles tagged "{{ tag }}"' | _(tag=tag) }}</h1>
<div class="cards"> {% if articles| length != 0 %}
{% for article in articles %} <div class="cards">
{{ macros::post_card(article=article) }} {% for article in articles %}
{% endfor %} {{ macros::post_card(article=article) }}
</div> {% endfor %}
</div>
{% else %}
<section>
<h2>{{ "There is currently no article with that tag" | _ }}</h2>
</section>
{% endif %}
{{ macros::paginate(page=page, total=n_pages) }} {{ macros::paginate(page=page, total=n_pages) }}
{% endblock content %} {% endblock content %}