Add support for generic timeline (#525)
* Begin adding support for timeline * fix some bugs with parser * fmt * add error reporting for parser * add tests for timeline query parser * add rejection tests for parse * begin adding support for lists also run migration before compiling, so schema.rs is up to date * add sqlite migration * end adding lists still miss tests and query integration * cargo fmt * try to add some tests * Add some constraint to db, and fix list test and refactor other tests to use begin_transaction * add more tests for lists * add support for lists in query executor * add keywords for including/excluding boosts and likes * cargo fmt * add function to list lists used by query will make it easier to warn users when creating timeline with unknown lists * add lang support * add timeline creation error message when using unexisting lists * Update .po files * WIP: interface for timelines * don't use diesel for migrations not sure how it passed the ci on the other branch * add some tests for timeline add an int representing the order of timelines (first one will be on top, second just under...) use first() instead of limit(1).get().into_iter().nth(0) remove migrations from build artifacts as they are now compiled in * cargo fmt * remove timeline order * fix tests * add tests for timeline creation failure * cargo fmt * add tests for timelines * add test for matching direct lists and keywords * add test for language filtering * Add a more complex test for Timeline::matches, and fix TQ::matches for TQ::Or * Make the main crate compile + FMT * Use the new timeline system - Replace the old "feed" system with timelines - Display all timelines someone can access on their home page (either their personal ones, or instance timelines) - Remove functions that were used to get user/local/federated feed - Add new posts to timelines - Create a default timeline called "My feed" for everyone, and "Local feed"/"Federated feed" with timelines @fdb-hiroshima I don't know if that's how you pictured it? If you imagined it differently I can of course make changes. I hope I didn't forgot anything… * Cargo fmt * Try to fix the migration * Fix tests * Fix the test (for real this time ?) * Fix the tests ? + fmt * Use Kind::Like and Kind::Reshare when needed * Forgot to run cargo fmt once again * revert translations * fix reviewed stuff * reduce code duplication by macros * cargo fmt
This commit is contained in:
@@ -0,0 +1,845 @@
|
||||
use diesel::{self, BoolExpressionMethods, ExpressionMethods, QueryDsl, RunQueryDsl};
|
||||
|
||||
use lists::List;
|
||||
use posts::Post;
|
||||
use schema::{posts, timeline, timeline_definition};
|
||||
use std::ops::Deref;
|
||||
use {Connection, Error, PlumeRocket, Result};
|
||||
|
||||
pub(crate) mod query;
|
||||
|
||||
use self::query::{QueryError, TimelineQuery};
|
||||
|
||||
pub use self::query::Kind;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Queryable, Identifiable, AsChangeset)]
|
||||
#[table_name = "timeline_definition"]
|
||||
pub struct Timeline {
|
||||
pub id: i32,
|
||||
pub user_id: Option<i32>,
|
||||
pub name: String,
|
||||
pub query: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Insertable)]
|
||||
#[table_name = "timeline_definition"]
|
||||
pub struct NewTimeline {
|
||||
user_id: Option<i32>,
|
||||
name: String,
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Insertable)]
|
||||
#[table_name = "timeline"]
|
||||
struct TimelineEntry {
|
||||
pub post_id: i32,
|
||||
pub timeline_id: i32,
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
insert!(timeline_definition, NewTimeline);
|
||||
get!(timeline_definition);
|
||||
|
||||
pub fn find_for_user_by_name(
|
||||
conn: &Connection,
|
||||
user_id: Option<i32>,
|
||||
name: &str,
|
||||
) -> Result<Self> {
|
||||
if let Some(user_id) = user_id {
|
||||
timeline_definition::table
|
||||
.filter(timeline_definition::user_id.eq(user_id))
|
||||
.filter(timeline_definition::name.eq(name))
|
||||
.first(conn)
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
timeline_definition::table
|
||||
.filter(timeline_definition::user_id.is_null())
|
||||
.filter(timeline_definition::name.eq(name))
|
||||
.first(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_for_user(conn: &Connection, user_id: Option<i32>) -> Result<Vec<Self>> {
|
||||
if let Some(user_id) = user_id {
|
||||
timeline_definition::table
|
||||
.filter(timeline_definition::user_id.eq(user_id))
|
||||
.load::<Self>(conn)
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
timeline_definition::table
|
||||
.filter(timeline_definition::user_id.is_null())
|
||||
.load::<Self>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as `list_for_user`, but also includes instance timelines if `user_id` is `Some`.
|
||||
pub fn list_all_for_user(conn: &Connection, user_id: Option<i32>) -> Result<Vec<Self>> {
|
||||
if let Some(user_id) = user_id {
|
||||
timeline_definition::table
|
||||
.filter(
|
||||
timeline_definition::user_id
|
||||
.eq(user_id)
|
||||
.or(timeline_definition::user_id.is_null()),
|
||||
)
|
||||
.load::<Self>(conn)
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
timeline_definition::table
|
||||
.filter(timeline_definition::user_id.is_null())
|
||||
.load::<Self>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_for_user(
|
||||
conn: &Connection,
|
||||
user_id: i32,
|
||||
name: String,
|
||||
query_string: String,
|
||||
) -> Result<Timeline> {
|
||||
{
|
||||
let query = TimelineQuery::parse(&query_string)?; // verify the query is valid
|
||||
if let Some(err) =
|
||||
query
|
||||
.list_used_lists()
|
||||
.into_iter()
|
||||
.find_map(|(name, kind)| {
|
||||
let list = List::find_for_user_by_name(conn, Some(user_id), &name)
|
||||
.map(|l| l.kind() == kind);
|
||||
match list {
|
||||
Ok(true) => None,
|
||||
Ok(false) => Some(Error::TimelineQuery(QueryError::RuntimeError(
|
||||
format!("list '{}' has the wrong type for this usage", name),
|
||||
))),
|
||||
Err(_) => Some(Error::TimelineQuery(QueryError::RuntimeError(
|
||||
format!("list '{}' was not found", name),
|
||||
))),
|
||||
}
|
||||
})
|
||||
{
|
||||
Err(err)?;
|
||||
}
|
||||
}
|
||||
Self::insert(
|
||||
conn,
|
||||
NewTimeline {
|
||||
user_id: Some(user_id),
|
||||
name,
|
||||
query: query_string,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_for_instance(
|
||||
conn: &Connection,
|
||||
name: String,
|
||||
query_string: String,
|
||||
) -> Result<Timeline> {
|
||||
{
|
||||
let query = TimelineQuery::parse(&query_string)?; // verify the query is valid
|
||||
if let Some(err) =
|
||||
query
|
||||
.list_used_lists()
|
||||
.into_iter()
|
||||
.find_map(|(name, kind)| {
|
||||
let list = List::find_for_user_by_name(conn, None, &name)
|
||||
.map(|l| l.kind() == kind);
|
||||
match list {
|
||||
Ok(true) => None,
|
||||
Ok(false) => Some(Error::TimelineQuery(QueryError::RuntimeError(
|
||||
format!("list '{}' has the wrong type for this usage", name),
|
||||
))),
|
||||
Err(_) => Some(Error::TimelineQuery(QueryError::RuntimeError(
|
||||
format!("list '{}' was not found", name),
|
||||
))),
|
||||
}
|
||||
})
|
||||
{
|
||||
Err(err)?;
|
||||
}
|
||||
}
|
||||
Self::insert(
|
||||
conn,
|
||||
NewTimeline {
|
||||
user_id: None,
|
||||
name,
|
||||
query: query_string,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn update(&self, conn: &Connection) -> Result<Self> {
|
||||
diesel::update(self).set(self).execute(conn)?;
|
||||
let timeline = Self::get(conn, self.id)?;
|
||||
Ok(timeline)
|
||||
}
|
||||
|
||||
pub fn delete(&self, conn: &Connection) -> Result<()> {
|
||||
diesel::delete(self)
|
||||
.execute(conn)
|
||||
.map(|_| ())
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn get_latest(&self, conn: &Connection, count: i32) -> Result<Vec<Post>> {
|
||||
self.get_page(conn, (0, count))
|
||||
}
|
||||
|
||||
pub fn get_page(&self, conn: &Connection, (min, max): (i32, i32)) -> Result<Vec<Post>> {
|
||||
timeline::table
|
||||
.filter(timeline::timeline_id.eq(self.id))
|
||||
.inner_join(posts::table)
|
||||
.order(posts::creation_date.desc())
|
||||
.offset(min.into())
|
||||
.limit((max - min).into())
|
||||
.select(posts::all_columns)
|
||||
.load::<Post>(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn count_posts(&self, conn: &Connection) -> Result<i64> {
|
||||
timeline::table
|
||||
.filter(timeline::timeline_id.eq(self.id))
|
||||
.inner_join(posts::table)
|
||||
.count()
|
||||
.get_result(conn)
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn add_to_all_timelines(rocket: &PlumeRocket, post: &Post, kind: Kind) -> Result<()> {
|
||||
let timelines = timeline_definition::table
|
||||
.load::<Self>(rocket.conn.deref())
|
||||
.map_err(Error::from)?;
|
||||
|
||||
for t in timelines {
|
||||
if t.matches(rocket, post, kind)? {
|
||||
t.add_post(&rocket.conn, post)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_post(&self, conn: &Connection, post: &Post) -> Result<()> {
|
||||
diesel::insert_into(timeline::table)
|
||||
.values(TimelineEntry {
|
||||
post_id: post.id,
|
||||
timeline_id: self.id,
|
||||
})
|
||||
.execute(conn)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn matches(&self, rocket: &PlumeRocket, post: &Post, kind: Kind) -> Result<bool> {
|
||||
let query = TimelineQuery::parse(&self.query)?;
|
||||
query.matches(rocket, self, post, kind)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use blogs::tests as blogTests;
|
||||
use diesel::Connection;
|
||||
use follows::*;
|
||||
use lists::ListType;
|
||||
use post_authors::{NewPostAuthor, PostAuthor};
|
||||
use posts::NewPost;
|
||||
use safe_string::SafeString;
|
||||
use tags::Tag;
|
||||
use tests::{db, rockets};
|
||||
use users::tests as userTests;
|
||||
|
||||
#[test]
|
||||
fn test_timeline() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let users = userTests::fill_database(conn);
|
||||
|
||||
let mut tl1_u1 = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"my timeline".to_owned(),
|
||||
"all".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
List::new(conn, "languages I speak", Some(&users[1]), ListType::Prefix).unwrap();
|
||||
let tl2_u1 = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"another timeline".to_owned(),
|
||||
"followed".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
let tl1_u2 = Timeline::new_for_user(
|
||||
conn,
|
||||
users[1].id,
|
||||
"english posts".to_owned(),
|
||||
"lang in \"languages I speak\"".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
let tl1_instance = Timeline::new_for_instance(
|
||||
conn,
|
||||
"english posts".to_owned(),
|
||||
"license in [cc]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(tl1_u1, Timeline::get(conn, tl1_u1.id).unwrap());
|
||||
assert_eq!(
|
||||
tl2_u1,
|
||||
Timeline::find_for_user_by_name(conn, Some(users[0].id), "another timeline")
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
tl1_instance,
|
||||
Timeline::find_for_user_by_name(conn, None, "english posts").unwrap()
|
||||
);
|
||||
|
||||
let tl_u1 = Timeline::list_for_user(conn, Some(users[0].id)).unwrap();
|
||||
assert_eq!(3, tl_u1.len()); // it is not 2 because there is a "Your feed" tl created for each user automatically
|
||||
assert!(tl_u1.iter().fold(false, |res, tl| { res || *tl == tl1_u1 }));
|
||||
assert!(tl_u1.iter().fold(false, |res, tl| { res || *tl == tl2_u1 }));
|
||||
|
||||
let tl_instance = Timeline::list_for_user(conn, None).unwrap();
|
||||
assert_eq!(3, tl_instance.len()); // there are also the local and federated feed by default
|
||||
assert!(tl_instance
|
||||
.iter()
|
||||
.fold(false, |res, tl| { res || *tl == tl1_instance }));
|
||||
|
||||
tl1_u1.name = "My Super TL".to_owned();
|
||||
let new_tl1_u2 = tl1_u2.update(conn).unwrap();
|
||||
|
||||
let tl_u2 = Timeline::list_for_user(conn, Some(users[1].id)).unwrap();
|
||||
assert_eq!(2, tl_u2.len()); // same here
|
||||
assert!(tl_u2
|
||||
.iter()
|
||||
.fold(false, |res, tl| { res || *tl == new_tl1_u2 }));
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeline_creation_error() {
|
||||
let conn = &db();
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let users = userTests::fill_database(conn);
|
||||
|
||||
assert!(Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"my timeline".to_owned(),
|
||||
"invalid keyword".to_owned(),
|
||||
)
|
||||
.is_err());
|
||||
assert!(Timeline::new_for_instance(
|
||||
conn,
|
||||
"my timeline".to_owned(),
|
||||
"invalid keyword".to_owned(),
|
||||
)
|
||||
.is_err());
|
||||
|
||||
assert!(Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"my timeline".to_owned(),
|
||||
"author in non_existant_list".to_owned(),
|
||||
)
|
||||
.is_err());
|
||||
assert!(Timeline::new_for_instance(
|
||||
conn,
|
||||
"my timeline".to_owned(),
|
||||
"lang in dont-exist".to_owned(),
|
||||
)
|
||||
.is_err());
|
||||
|
||||
List::new(conn, "friends", Some(&users[0]), ListType::User).unwrap();
|
||||
List::new(conn, "idk", None, ListType::Blog).unwrap();
|
||||
|
||||
assert!(Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"my timeline".to_owned(),
|
||||
"blog in friends".to_owned(),
|
||||
)
|
||||
.is_err());
|
||||
assert!(Timeline::new_for_instance(
|
||||
conn,
|
||||
"my timeline".to_owned(),
|
||||
"not author in idk".to_owned(),
|
||||
)
|
||||
.is_err());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_match() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
let gnu_tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"GNU timeline".to_owned(),
|
||||
"license in [AGPL, LGPL, GPL]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let gnu_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug".to_string(),
|
||||
title: "About Linux".to_string(),
|
||||
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(gnu_tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
|
||||
let non_free_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug2".to_string(),
|
||||
title: "Private is bad".to_string(),
|
||||
content: SafeString::new("so is Microsoft"),
|
||||
published: true,
|
||||
license: "all right reserved".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
source: "so is Microsoft".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!gnu_tl.matches(r, &non_free_post, Kind::Original).unwrap());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_match() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
Follow::insert(
|
||||
conn,
|
||||
NewFollow {
|
||||
follower_id: users[0].id,
|
||||
following_id: users[1].id,
|
||||
ap_url: String::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let fav_blogs_list =
|
||||
List::new(conn, "fav_blogs", Some(&users[0]), ListType::Blog).unwrap();
|
||||
fav_blogs_list.add_blogs(conn, &[blogs[0].id]).unwrap();
|
||||
|
||||
let my_tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"My timeline".to_owned(),
|
||||
"blog in fav_blogs and not has_cover or local and followed exclude likes"
|
||||
.to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "about-linux".to_string(),
|
||||
title: "About Linux".to_string(),
|
||||
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(my_tl.matches(r, &post, Kind::Original).unwrap()); // matches because of "blog in fav_blogs" (and there is no cover)
|
||||
|
||||
let post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[1].id,
|
||||
slug: "about-linux-2".to_string(),
|
||||
title: "About Linux (2)".to_string(),
|
||||
content: SafeString::new(
|
||||
"Actually, GNU+Linux, GNU×Linux, or GNU¿Linux are better.",
|
||||
),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
source: "Actually, GNU+Linux, GNU×Linux, or GNU¿Linux are better."
|
||||
.to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!my_tl.matches(r, &post, Kind::Like(&users[1])).unwrap());
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_to_all_timelines() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
let gnu_tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"GNU timeline".to_owned(),
|
||||
"license in [AGPL, LGPL, GPL]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
let non_gnu_tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Stallman disapproved timeline".to_owned(),
|
||||
"not license in [AGPL, LGPL, GPL]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let gnu_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug".to_string(),
|
||||
title: "About Linux".to_string(),
|
||||
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let non_free_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug2".to_string(),
|
||||
title: "Private is bad".to_string(),
|
||||
content: SafeString::new("so is Microsoft"),
|
||||
published: true,
|
||||
license: "all right reserved".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
source: "so is Microsoft".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Timeline::add_to_all_timelines(r, &gnu_post, Kind::Original).unwrap();
|
||||
Timeline::add_to_all_timelines(r, &non_free_post, Kind::Original).unwrap();
|
||||
|
||||
let res = gnu_tl.get_latest(conn, 2).unwrap();
|
||||
assert_eq!(res.len(), 1);
|
||||
assert_eq!(res[0].id, gnu_post.id);
|
||||
let res = non_gnu_tl.get_latest(conn, 2).unwrap();
|
||||
assert_eq!(res.len(), 1);
|
||||
assert_eq!(res[0].id, non_free_post.id);
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_matches_lists_direct() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
let gnu_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug".to_string(),
|
||||
title: "About Linux".to_string(),
|
||||
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
gnu_post
|
||||
.update_tags(conn, vec![Tag::build_activity("free".to_owned()).unwrap()])
|
||||
.unwrap();
|
||||
PostAuthor::insert(
|
||||
conn,
|
||||
NewPostAuthor {
|
||||
post_id: gnu_post.id,
|
||||
author_id: blogs[0].list_authors(conn).unwrap()[0].id,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"blog timeline".to_owned(),
|
||||
format!("blog in [{}]", blogs[0].fqn),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"blog timeline".to_owned(),
|
||||
"blog in [no_one@nowhere]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"author timeline".to_owned(),
|
||||
format!(
|
||||
"author in [{}]",
|
||||
blogs[0].list_authors(conn).unwrap()[0].fqn
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"author timeline".to_owned(),
|
||||
format!("author in [{}]", users[2].fqn),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Reshare(&users[2])).unwrap());
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Like(&users[2])).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"author timeline".to_owned(),
|
||||
format!(
|
||||
"author in [{}] include likes exclude reshares",
|
||||
users[2].fqn
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Reshare(&users[2])).unwrap());
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Like(&users[2])).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"tag timeline".to_owned(),
|
||||
"tags in [free]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"tag timeline".to_owned(),
|
||||
"tags in [private]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"english timeline".to_owned(),
|
||||
"lang in [en]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"franco-italian timeline".to_owned(),
|
||||
"lang in [fr, it]".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
#[test]
|
||||
fn test_matches_lists_saved() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
let gnu_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug".to_string(),
|
||||
title: "About Linux".to_string(),
|
||||
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "".to_string(),
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
gnu_post.update_tags(conn, vec![Tag::build_activity("free".to_owned()).unwrap()]).unwrap();
|
||||
PostAuthor::insert(conn, NewPostAuthor {post_id: gnu_post.id, author_id: blogs[0].list_authors(conn).unwrap()[0].id}).unwrap();
|
||||
|
||||
unimplemented!();
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}*/
|
||||
|
||||
#[test]
|
||||
fn test_matches_keyword() {
|
||||
let r = &rockets();
|
||||
let conn = &r.conn;
|
||||
conn.test_transaction::<_, (), _>(|| {
|
||||
let (users, blogs) = blogTests::fill_database(conn);
|
||||
|
||||
let gnu_post = Post::insert(
|
||||
conn,
|
||||
NewPost {
|
||||
blog_id: blogs[0].id,
|
||||
slug: "slug".to_string(),
|
||||
title: "About Linux".to_string(),
|
||||
content: SafeString::new("you must say GNU/Linux, not Linux!!!"),
|
||||
published: true,
|
||||
license: "GPL".to_string(),
|
||||
ap_url: "".to_string(),
|
||||
creation_date: None,
|
||||
subtitle: "Stallman is our god".to_string(),
|
||||
source: "you must say GNU/Linux, not Linux!!!".to_string(),
|
||||
cover_id: None,
|
||||
},
|
||||
&r.searcher,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Linux title".to_owned(),
|
||||
"title contains Linux".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Microsoft title".to_owned(),
|
||||
"title contains Microsoft".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Linux subtitle".to_owned(),
|
||||
"subtitle contains Stallman".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Microsoft subtitle".to_owned(),
|
||||
"subtitle contains Nadella".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Linux content".to_owned(),
|
||||
"content contains Linux".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
let tl = Timeline::new_for_user(
|
||||
conn,
|
||||
users[0].id,
|
||||
"Microsoft content".to_owned(),
|
||||
"subtitle contains Windows".to_owned(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!tl.matches(r, &gnu_post, Kind::Original).unwrap());
|
||||
tl.delete(conn).unwrap();
|
||||
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
use blogs::Blog;
|
||||
use lists::{self, ListType};
|
||||
use plume_common::activity_pub::inbox::AsActor;
|
||||
use posts::Post;
|
||||
use tags::Tag;
|
||||
use users::User;
|
||||
use whatlang::{self, Lang};
|
||||
|
||||
use {PlumeRocket, Result};
|
||||
|
||||
use super::Timeline;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum QueryError {
|
||||
SyntaxError(usize, usize, String),
|
||||
UnexpectedEndOfQuery,
|
||||
RuntimeError(String),
|
||||
}
|
||||
|
||||
impl From<std::option::NoneError> for QueryError {
|
||||
fn from(_: std::option::NoneError) -> Self {
|
||||
QueryError::UnexpectedEndOfQuery
|
||||
}
|
||||
}
|
||||
|
||||
pub type QueryResult<T> = std::result::Result<T, QueryError>;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Kind<'a> {
|
||||
Original,
|
||||
Reshare(&'a User),
|
||||
Like(&'a User),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Token<'a> {
|
||||
LParent(usize),
|
||||
RParent(usize),
|
||||
LBracket(usize),
|
||||
RBracket(usize),
|
||||
Comma(usize),
|
||||
Word(usize, usize, &'a str),
|
||||
}
|
||||
|
||||
impl<'a> Token<'a> {
|
||||
fn get_text(&self) -> &'a str {
|
||||
match self {
|
||||
Token::Word(_, _, s) => s,
|
||||
Token::LParent(_) => "(",
|
||||
Token::RParent(_) => ")",
|
||||
Token::LBracket(_) => "[",
|
||||
Token::RBracket(_) => "]",
|
||||
Token::Comma(_) => ",",
|
||||
}
|
||||
}
|
||||
|
||||
fn get_pos(&self) -> (usize, usize) {
|
||||
match self {
|
||||
Token::Word(a, b, _) => (*a, *b),
|
||||
Token::LParent(a)
|
||||
| Token::RParent(a)
|
||||
| Token::LBracket(a)
|
||||
| Token::RBracket(a)
|
||||
| Token::Comma(a) => (*a, 1),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_error<T>(&self, token: Token) -> QueryResult<T> {
|
||||
let (b, e) = self.get_pos();
|
||||
let message = format!(
|
||||
"Syntax Error: Expected {}, got {}",
|
||||
token.to_string(),
|
||||
self.to_string()
|
||||
);
|
||||
Err(QueryError::SyntaxError(b, e, message))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ToString for Token<'a> {
|
||||
fn to_string(&self) -> String {
|
||||
if let Token::Word(0, 0, v) = self {
|
||||
return v.to_string();
|
||||
}
|
||||
format!(
|
||||
"'{}'",
|
||||
match self {
|
||||
Token::Word(_, _, v) => v,
|
||||
Token::LParent(_) => "(",
|
||||
Token::RParent(_) => ")",
|
||||
Token::LBracket(_) => "[",
|
||||
Token::RBracket(_) => "]",
|
||||
Token::Comma(_) => ",",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! gen_tokenizer {
|
||||
( ($c:ident,$i:ident), $state:ident, $quote:ident; $([$char:tt, $variant:tt]),*) => {
|
||||
match $c {
|
||||
space if !*$quote && space.is_whitespace() => match $state.take() {
|
||||
Some(v) => vec![v],
|
||||
None => vec![],
|
||||
},
|
||||
$(
|
||||
$char if !*$quote => match $state.take() {
|
||||
Some(v) => vec![v, Token::$variant($i)],
|
||||
None => vec![Token::$variant($i)],
|
||||
},
|
||||
)*
|
||||
'"' => {
|
||||
*$quote = !*$quote;
|
||||
vec![]
|
||||
},
|
||||
_ => match $state.take() {
|
||||
Some(Token::Word(b, l, _)) => {
|
||||
*$state = Some(Token::Word(b, l+1, &""));
|
||||
vec![]
|
||||
},
|
||||
None => {
|
||||
*$state = Some(Token::Word($i,1,&""));
|
||||
vec![]
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lex(stream: &str) -> Vec<Token> {
|
||||
stream
|
||||
.chars()
|
||||
.chain(" ".chars()) // force a last whitespace to empty scan's state
|
||||
.zip(0..)
|
||||
.scan((None, false), |(state, quote), (c, i)| {
|
||||
Some(gen_tokenizer!((c,i), state, quote;
|
||||
['(', LParent], [')', RParent],
|
||||
['[', LBracket], [']', RBracket],
|
||||
[',', Comma]))
|
||||
})
|
||||
.flatten()
|
||||
.map(|t| {
|
||||
if let Token::Word(b, e, _) = t {
|
||||
Token::Word(b, e, &stream[b..b + e])
|
||||
} else {
|
||||
t
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Private internals of TimelineQuery
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum TQ<'a> {
|
||||
Or(Vec<TQ<'a>>),
|
||||
And(Vec<TQ<'a>>),
|
||||
Arg(Arg<'a>, bool),
|
||||
}
|
||||
|
||||
impl<'a> TQ<'a> {
|
||||
fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
kind: Kind,
|
||||
) -> Result<bool> {
|
||||
match self {
|
||||
TQ::Or(inner) => inner.iter().try_fold(false, |s, e| {
|
||||
e.matches(rocket, timeline, post, kind).map(|r| s || r)
|
||||
}),
|
||||
TQ::And(inner) => inner.iter().try_fold(true, |s, e| {
|
||||
e.matches(rocket, timeline, post, kind).map(|r| s && r)
|
||||
}),
|
||||
TQ::Arg(inner, invert) => Ok(inner.matches(rocket, timeline, post, kind)? ^ invert),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_used_lists(&self) -> Vec<(String, ListType)> {
|
||||
match self {
|
||||
TQ::Or(inner) => inner.iter().flat_map(TQ::list_used_lists).collect(),
|
||||
TQ::And(inner) => inner.iter().flat_map(TQ::list_used_lists).collect(),
|
||||
TQ::Arg(Arg::In(typ, List::List(name)), _) => vec![(
|
||||
name.to_string(),
|
||||
match typ {
|
||||
WithList::Blog => ListType::Blog,
|
||||
WithList::Author { .. } => ListType::User,
|
||||
WithList::License => ListType::Word,
|
||||
WithList::Tags => ListType::Word,
|
||||
WithList::Lang => ListType::Prefix,
|
||||
},
|
||||
)],
|
||||
TQ::Arg(_, _) => vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Arg<'a> {
|
||||
In(WithList, List<'a>),
|
||||
Contains(WithContains, &'a str),
|
||||
Boolean(Bool),
|
||||
}
|
||||
|
||||
impl<'a> Arg<'a> {
|
||||
pub fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
kind: Kind,
|
||||
) -> Result<bool> {
|
||||
match self {
|
||||
Arg::In(t, l) => t.matches(rocket, timeline, post, l, kind),
|
||||
Arg::Contains(t, v) => t.matches(post, v),
|
||||
Arg::Boolean(t) => t.matches(rocket, timeline, post, kind),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum WithList {
|
||||
Blog,
|
||||
Author { boosts: bool, likes: bool },
|
||||
License,
|
||||
Tags,
|
||||
Lang,
|
||||
}
|
||||
|
||||
impl WithList {
|
||||
pub fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
list: &List,
|
||||
kind: Kind,
|
||||
) -> Result<bool> {
|
||||
match list {
|
||||
List::List(name) => {
|
||||
let list =
|
||||
lists::List::find_for_user_by_name(&rocket.conn, timeline.user_id, &name)?;
|
||||
match (self, list.kind()) {
|
||||
(WithList::Blog, ListType::Blog) => {
|
||||
list.contains_blog(&rocket.conn, post.blog_id)
|
||||
}
|
||||
(WithList::Author { boosts, likes }, ListType::User) => match kind {
|
||||
Kind::Original => Ok(list
|
||||
.list_users(&rocket.conn)?
|
||||
.iter()
|
||||
.any(|a| post.is_author(&rocket.conn, a.id).unwrap_or(false))),
|
||||
Kind::Reshare(u) => {
|
||||
if *boosts {
|
||||
list.contains_user(&rocket.conn, u.id)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Kind::Like(u) => {
|
||||
if *likes {
|
||||
list.contains_user(&rocket.conn, u.id)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
(WithList::License, ListType::Word) => {
|
||||
list.contains_word(&rocket.conn, &post.license)
|
||||
}
|
||||
(WithList::Tags, ListType::Word) => {
|
||||
let tags = Tag::for_post(&rocket.conn, post.id)?;
|
||||
Ok(list
|
||||
.list_words(&rocket.conn)?
|
||||
.iter()
|
||||
.any(|s| tags.iter().any(|t| s == &t.tag)))
|
||||
}
|
||||
(WithList::Lang, ListType::Prefix) => {
|
||||
let lang = whatlang::detect(post.content.get())
|
||||
.and_then(|i| {
|
||||
if i.is_reliable() {
|
||||
Some(i.lang())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(Lang::Eng)
|
||||
.name();
|
||||
list.contains_prefix(&rocket.conn, lang)
|
||||
}
|
||||
(_, _) => Err(QueryError::RuntimeError(format!(
|
||||
"The list '{}' is of the wrong type for this usage",
|
||||
name
|
||||
)))?,
|
||||
}
|
||||
}
|
||||
List::Array(list) => match self {
|
||||
WithList::Blog => Ok(list
|
||||
.iter()
|
||||
.filter_map(|b| Blog::find_by_fqn(rocket, b).ok())
|
||||
.any(|b| b.id == post.blog_id)),
|
||||
WithList::Author { boosts, likes } => match kind {
|
||||
Kind::Original => Ok(list
|
||||
.iter()
|
||||
.filter_map(|a| User::find_by_fqn(rocket, a).ok())
|
||||
.any(|a| post.is_author(&rocket.conn, a.id).unwrap_or(false))),
|
||||
Kind::Reshare(u) => {
|
||||
if *boosts {
|
||||
Ok(list.iter().any(|user| &u.fqn == user))
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Kind::Like(u) => {
|
||||
if *likes {
|
||||
Ok(list.iter().any(|user| &u.fqn == user))
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
WithList::License => Ok(list.iter().any(|s| s == &post.license)),
|
||||
WithList::Tags => {
|
||||
let tags = Tag::for_post(&rocket.conn, post.id)?;
|
||||
Ok(list.iter().any(|s| tags.iter().any(|t| s == &t.tag)))
|
||||
}
|
||||
WithList::Lang => {
|
||||
let lang = whatlang::detect(post.content.get())
|
||||
.and_then(|i| {
|
||||
if i.is_reliable() {
|
||||
Some(i.lang())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(Lang::Eng)
|
||||
.name()
|
||||
.to_lowercase();
|
||||
Ok(list.iter().any(|s| lang.starts_with(&s.to_lowercase())))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum WithContains {
|
||||
Title,
|
||||
Subtitle,
|
||||
Content,
|
||||
}
|
||||
|
||||
impl WithContains {
|
||||
pub fn matches(&self, post: &Post, value: &str) -> Result<bool> {
|
||||
match self {
|
||||
WithContains::Title => Ok(post.title.contains(value)),
|
||||
WithContains::Subtitle => Ok(post.subtitle.contains(value)),
|
||||
WithContains::Content => Ok(post.content.contains(value)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum Bool {
|
||||
Followed { boosts: bool, likes: bool },
|
||||
HasCover,
|
||||
Local,
|
||||
All,
|
||||
}
|
||||
|
||||
impl Bool {
|
||||
pub fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
kind: Kind,
|
||||
) -> Result<bool> {
|
||||
match self {
|
||||
Bool::Followed { boosts, likes } => {
|
||||
if timeline.user_id.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let user = timeline.user_id.unwrap();
|
||||
match kind {
|
||||
Kind::Original => post
|
||||
.get_authors(&rocket.conn)?
|
||||
.iter()
|
||||
.try_fold(false, |s, a| {
|
||||
a.is_followed_by(&rocket.conn, user).map(|r| s || r)
|
||||
}),
|
||||
Kind::Reshare(u) => {
|
||||
if *boosts {
|
||||
u.is_followed_by(&rocket.conn, user)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Kind::Like(u) => {
|
||||
if *likes {
|
||||
u.is_followed_by(&rocket.conn, user)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Bool::HasCover => Ok(post.cover_id.is_some()),
|
||||
Bool::Local => Ok(post.get_blog(&rocket.conn)?.is_local()),
|
||||
Bool::All => Ok(true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
enum List<'a> {
|
||||
List(&'a str),
|
||||
Array(Vec<&'a str>),
|
||||
}
|
||||
|
||||
fn parse_s<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
|
||||
let mut res = Vec::new();
|
||||
let (left, token) = parse_a(&stream)?;
|
||||
res.push(token);
|
||||
stream = left;
|
||||
while !stream.is_empty() {
|
||||
match stream[0] {
|
||||
Token::Word(_, _, and) if and == "or" => {}
|
||||
_ => break,
|
||||
}
|
||||
let (left, token) = parse_a(&stream[1..])?;
|
||||
res.push(token);
|
||||
stream = left;
|
||||
}
|
||||
|
||||
if res.len() == 1 {
|
||||
Ok((stream, res.remove(0)))
|
||||
} else {
|
||||
Ok((stream, TQ::Or(res)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_a<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
|
||||
let mut res = Vec::new();
|
||||
let (left, token) = parse_b(&stream)?;
|
||||
res.push(token);
|
||||
stream = left;
|
||||
while !stream.is_empty() {
|
||||
match stream[0] {
|
||||
Token::Word(_, _, and) if and == "and" => {}
|
||||
_ => break,
|
||||
}
|
||||
let (left, token) = parse_b(&stream[1..])?;
|
||||
res.push(token);
|
||||
stream = left;
|
||||
}
|
||||
|
||||
if res.len() == 1 {
|
||||
Ok((stream, res.remove(0)))
|
||||
} else {
|
||||
Ok((stream, TQ::And(res)))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_b<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
|
||||
match stream.get(0) {
|
||||
Some(Token::LParent(_)) => {
|
||||
let (left, token) = parse_s(&stream[1..])?;
|
||||
match left.get(0) {
|
||||
Some(Token::RParent(_)) => Ok((&left[1..], token)),
|
||||
Some(t) => t.get_error(Token::RParent(0)),
|
||||
None => None?,
|
||||
}
|
||||
}
|
||||
_ => parse_c(stream),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_c<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], TQ<'a>)> {
|
||||
match stream.get(0) {
|
||||
Some(Token::Word(_, _, not)) if not == &"not" => {
|
||||
let (left, token) = parse_d(&stream[1..])?;
|
||||
Ok((left, TQ::Arg(token, true)))
|
||||
}
|
||||
_ => {
|
||||
let (left, token) = parse_d(stream)?;
|
||||
Ok((left, TQ::Arg(token, false)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_d<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], Arg<'a>)> {
|
||||
match stream.get(0).map(Token::get_text)? {
|
||||
s @ "blog" | s @ "author" | s @ "license" | s @ "tags" | s @ "lang" => {
|
||||
match stream.get(1)? {
|
||||
Token::Word(_, _, r#in) if r#in == &"in" => {
|
||||
let (mut left, list) = parse_l(&stream[2..])?;
|
||||
let kind = match s {
|
||||
"blog" => WithList::Blog,
|
||||
"author" => {
|
||||
let mut boosts = true;
|
||||
let mut likes = false;
|
||||
while let Some(Token::Word(s, e, clude)) = left.get(0) {
|
||||
if *clude != "include" && *clude != "exclude" {
|
||||
break;
|
||||
}
|
||||
match (*clude, left.get(1).map(Token::get_text)?) {
|
||||
("include", "reshares") | ("include", "reshare") => {
|
||||
boosts = true
|
||||
}
|
||||
("exclude", "reshares") | ("exclude", "reshare") => {
|
||||
boosts = false
|
||||
}
|
||||
("include", "likes") | ("include", "like") => likes = true,
|
||||
("exclude", "likes") | ("exclude", "like") => likes = false,
|
||||
(_, w) => {
|
||||
return Token::Word(*s, *e, w).get_error(Token::Word(
|
||||
0,
|
||||
0,
|
||||
"one of 'likes' or 'reshares'",
|
||||
))
|
||||
}
|
||||
}
|
||||
left = &left[2..];
|
||||
}
|
||||
WithList::Author { boosts, likes }
|
||||
}
|
||||
"license" => WithList::License,
|
||||
"tags" => WithList::Tags,
|
||||
"lang" => WithList::Lang,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
Ok((left, Arg::In(kind, list)))
|
||||
}
|
||||
t => t.get_error(Token::Word(0, 0, "'in'")),
|
||||
}
|
||||
}
|
||||
s @ "title" | s @ "subtitle" | s @ "content" => match (stream.get(1)?, stream.get(2)?) {
|
||||
(Token::Word(_, _, contains), Token::Word(_, _, w)) if contains == &"contains" => Ok((
|
||||
&stream[3..],
|
||||
Arg::Contains(
|
||||
match s {
|
||||
"title" => WithContains::Title,
|
||||
"subtitle" => WithContains::Subtitle,
|
||||
"content" => WithContains::Content,
|
||||
_ => unreachable!(),
|
||||
},
|
||||
w,
|
||||
),
|
||||
)),
|
||||
(Token::Word(_, _, contains), t) if contains == &"contains" => {
|
||||
t.get_error(Token::Word(0, 0, "any word"))
|
||||
}
|
||||
(t, _) => t.get_error(Token::Word(0, 0, "'contains'")),
|
||||
},
|
||||
s @ "followed" | s @ "has_cover" | s @ "local" | s @ "all" => match s {
|
||||
"followed" => {
|
||||
let mut boosts = true;
|
||||
let mut likes = false;
|
||||
while let Some(Token::Word(s, e, clude)) = stream.get(1) {
|
||||
if *clude != "include" && *clude != "exclude" {
|
||||
break;
|
||||
}
|
||||
match (*clude, stream.get(2).map(Token::get_text)?) {
|
||||
("include", "reshares") | ("include", "reshare") => boosts = true,
|
||||
("exclude", "reshares") | ("exclude", "reshare") => boosts = false,
|
||||
("include", "likes") | ("include", "like") => likes = true,
|
||||
("exclude", "likes") | ("exclude", "like") => likes = false,
|
||||
(_, w) => {
|
||||
return Token::Word(*s, *e, w).get_error(Token::Word(
|
||||
0,
|
||||
0,
|
||||
"one of 'likes' or 'boosts'",
|
||||
))
|
||||
}
|
||||
}
|
||||
stream = &stream[2..];
|
||||
}
|
||||
Ok((&stream[1..], Arg::Boolean(Bool::Followed { boosts, likes })))
|
||||
}
|
||||
"has_cover" => Ok((&stream[1..], Arg::Boolean(Bool::HasCover))),
|
||||
"local" => Ok((&stream[1..], Arg::Boolean(Bool::Local))),
|
||||
"all" => Ok((&stream[1..], Arg::Boolean(Bool::All))),
|
||||
_ => unreachable!(),
|
||||
},
|
||||
_ => stream.get(0)?.get_error(Token::Word(
|
||||
0,
|
||||
0,
|
||||
"one of 'blog', 'author', 'license', 'tags', 'lang', \
|
||||
'title', 'subtitle', 'content', 'followed', 'has_cover', 'local' or 'all'",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_l<'a, 'b>(stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], List<'a>)> {
|
||||
match stream.get(0)? {
|
||||
Token::LBracket(_) => {
|
||||
let (left, list) = parse_m(&stream[1..])?;
|
||||
match left.get(0)? {
|
||||
Token::RBracket(_) => Ok((&left[1..], List::Array(list))),
|
||||
t => t.get_error(Token::Word(0, 0, "one of ']' or ','")),
|
||||
}
|
||||
}
|
||||
Token::Word(_, _, list) => Ok((&stream[1..], List::List(list))),
|
||||
t => t.get_error(Token::Word(0, 0, "one of [list, of, words] or list_name")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_m<'a, 'b>(mut stream: &'b [Token<'a>]) -> QueryResult<(&'b [Token<'a>], Vec<&'a str>)> {
|
||||
let mut res: Vec<&str> = Vec::new();
|
||||
res.push(match stream.get(0)? {
|
||||
Token::Word(_, _, w) => w,
|
||||
t => return t.get_error(Token::Word(0, 0, "any word")),
|
||||
});
|
||||
stream = &stream[1..];
|
||||
while let Token::Comma(_) = stream[0] {
|
||||
res.push(match stream.get(1)? {
|
||||
Token::Word(_, _, w) => w,
|
||||
t => return t.get_error(Token::Word(0, 0, "any word")),
|
||||
});
|
||||
stream = &stream[2..];
|
||||
}
|
||||
|
||||
Ok((stream, res))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimelineQuery<'a>(TQ<'a>);
|
||||
|
||||
impl<'a> TimelineQuery<'a> {
|
||||
pub fn parse(query: &'a str) -> QueryResult<Self> {
|
||||
parse_s(&lex(query))
|
||||
.and_then(|(left, res)| {
|
||||
if left.is_empty() {
|
||||
Ok(res)
|
||||
} else {
|
||||
left[0].get_error(Token::Word(0, 0, "on of 'or' or 'and'"))
|
||||
}
|
||||
})
|
||||
.map(TimelineQuery)
|
||||
}
|
||||
|
||||
pub fn matches(
|
||||
&self,
|
||||
rocket: &PlumeRocket,
|
||||
timeline: &Timeline,
|
||||
post: &Post,
|
||||
kind: Kind,
|
||||
) -> Result<bool> {
|
||||
self.0.matches(rocket, timeline, post, kind)
|
||||
}
|
||||
|
||||
pub fn list_used_lists(&self) -> Vec<(String, ListType)> {
|
||||
self.0.list_used_lists()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_lexer() {
|
||||
assert_eq!(
|
||||
lex("()[ ],two words \"something quoted with , and [\""),
|
||||
vec![
|
||||
Token::LParent(0),
|
||||
Token::RParent(1),
|
||||
Token::LBracket(2),
|
||||
Token::RBracket(4),
|
||||
Token::Comma(5),
|
||||
Token::Word(6, 3, "two"),
|
||||
Token::Word(10, 5, "words"),
|
||||
Token::Word(17, 29, "something quoted with , and ["),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parser() {
|
||||
let q = TimelineQuery::parse(r#"lang in [fr, en] and (license in my_fav_lic or not followed) or title contains "Plume is amazing""#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
q.0,
|
||||
TQ::Or(vec![
|
||||
TQ::And(vec![
|
||||
TQ::Arg(
|
||||
Arg::In(WithList::Lang, List::Array(vec!["fr", "en"]),),
|
||||
false
|
||||
),
|
||||
TQ::Or(vec![
|
||||
TQ::Arg(Arg::In(WithList::License, List::List("my_fav_lic"),), false),
|
||||
TQ::Arg(
|
||||
Arg::Boolean(Bool::Followed {
|
||||
boosts: true,
|
||||
likes: false
|
||||
}),
|
||||
true
|
||||
),
|
||||
]),
|
||||
]),
|
||||
TQ::Arg(
|
||||
Arg::Contains(WithContains::Title, "Plume is amazing",),
|
||||
false
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
let lists = TimelineQuery::parse(
|
||||
r#"blog in a or author in b include likes or license in c or tags in d or lang in e "#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
lists.0,
|
||||
TQ::Or(vec![
|
||||
TQ::Arg(Arg::In(WithList::Blog, List::List("a"),), false),
|
||||
TQ::Arg(
|
||||
Arg::In(
|
||||
WithList::Author {
|
||||
boosts: true,
|
||||
likes: true
|
||||
},
|
||||
List::List("b"),
|
||||
),
|
||||
false
|
||||
),
|
||||
TQ::Arg(Arg::In(WithList::License, List::List("c"),), false),
|
||||
TQ::Arg(Arg::In(WithList::Tags, List::List("d"),), false),
|
||||
TQ::Arg(Arg::In(WithList::Lang, List::List("e"),), false),
|
||||
])
|
||||
);
|
||||
|
||||
let contains = TimelineQuery::parse(
|
||||
r#"title contains a or subtitle contains b or content contains c"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
contains.0,
|
||||
TQ::Or(vec![
|
||||
TQ::Arg(Arg::Contains(WithContains::Title, "a"), false),
|
||||
TQ::Arg(Arg::Contains(WithContains::Subtitle, "b"), false),
|
||||
TQ::Arg(Arg::Contains(WithContains::Content, "c"), false),
|
||||
])
|
||||
);
|
||||
|
||||
let booleans = TimelineQuery::parse(
|
||||
r#"followed include like exclude reshares and has_cover and local and all"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
booleans.0,
|
||||
TQ::And(vec![
|
||||
TQ::Arg(
|
||||
Arg::Boolean(Bool::Followed {
|
||||
boosts: false,
|
||||
likes: true
|
||||
}),
|
||||
false
|
||||
),
|
||||
TQ::Arg(Arg::Boolean(Bool::HasCover), false),
|
||||
TQ::Arg(Arg::Boolean(Bool::Local), false),
|
||||
TQ::Arg(Arg::Boolean(Bool::All), false),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejection_parser() {
|
||||
let missing_and_or = TimelineQuery::parse(r#"followed or has_cover local"#).unwrap_err();
|
||||
assert_eq!(
|
||||
missing_and_or,
|
||||
QueryError::SyntaxError(
|
||||
22,
|
||||
5,
|
||||
"Syntax Error: Expected on of 'or' or 'and', got 'local'".to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let unbalanced_parent =
|
||||
TimelineQuery::parse(r#"followed and (has_cover or local"#).unwrap_err();
|
||||
assert_eq!(unbalanced_parent, QueryError::UnexpectedEndOfQuery);
|
||||
|
||||
let missing_and_or_in_par =
|
||||
TimelineQuery::parse(r#"(title contains "abc def" followed)"#).unwrap_err();
|
||||
assert_eq!(
|
||||
missing_and_or_in_par,
|
||||
QueryError::SyntaxError(
|
||||
26,
|
||||
8,
|
||||
"Syntax Error: Expected ')', got 'followed'".to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let expect_in = TimelineQuery::parse(r#"lang contains abc"#).unwrap_err();
|
||||
assert_eq!(
|
||||
expect_in,
|
||||
QueryError::SyntaxError(
|
||||
5,
|
||||
8,
|
||||
"Syntax Error: Expected 'in', got 'contains'".to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let expect_contains = TimelineQuery::parse(r#"title in abc"#).unwrap_err();
|
||||
assert_eq!(
|
||||
expect_contains,
|
||||
QueryError::SyntaxError(
|
||||
6,
|
||||
2,
|
||||
"Syntax Error: Expected 'contains', got 'in'".to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let expect_keyword = TimelineQuery::parse(r#"not_a_field contains something"#).unwrap_err();
|
||||
assert_eq!(expect_keyword, QueryError::SyntaxError(0, 11, "Syntax Error: Expected one of 'blog', \
|
||||
'author', 'license', 'tags', 'lang', 'title', 'subtitle', 'content', 'followed', 'has_cover', \
|
||||
'local' or 'all', got 'not_a_field'".to_owned()));
|
||||
|
||||
let expect_bracket_or_comma = TimelineQuery::parse(r#"lang in [en ["#).unwrap_err();
|
||||
assert_eq!(
|
||||
expect_bracket_or_comma,
|
||||
QueryError::SyntaxError(
|
||||
12,
|
||||
1,
|
||||
"Syntax Error: Expected one of ']' or ',', \
|
||||
got '['"
|
||||
.to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let expect_bracket = TimelineQuery::parse(r#"lang in )abc"#).unwrap_err();
|
||||
assert_eq!(
|
||||
expect_bracket,
|
||||
QueryError::SyntaxError(
|
||||
8,
|
||||
1,
|
||||
"Syntax Error: Expected one of [list, of, words] or list_name, \
|
||||
got ')'"
|
||||
.to_owned()
|
||||
)
|
||||
);
|
||||
|
||||
let expect_word = TimelineQuery::parse(r#"title contains ,"#).unwrap_err();
|
||||
assert_eq!(
|
||||
expect_word,
|
||||
QueryError::SyntaxError(15, 1, "Syntax Error: Expected any word, got ','".to_owned())
|
||||
);
|
||||
|
||||
let got_bracket = TimelineQuery::parse(r#"lang in []"#).unwrap_err();
|
||||
assert_eq!(
|
||||
got_bracket,
|
||||
QueryError::SyntaxError(9, 1, "Syntax Error: Expected any word, got ']'".to_owned())
|
||||
);
|
||||
|
||||
let got_par = TimelineQuery::parse(r#"lang in [a, ("#).unwrap_err();
|
||||
assert_eq!(
|
||||
got_par,
|
||||
QueryError::SyntaxError(12, 1, "Syntax Error: Expected any word, got '('".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_used_lists() {
|
||||
let q = TimelineQuery::parse(r#"lang in [fr, en] and blog in blogs or author in my_fav_authors or tags in hashtag and lang in spoken or license in copyleft"#)
|
||||
.unwrap();
|
||||
let used_lists = q.list_used_lists();
|
||||
assert_eq!(
|
||||
used_lists,
|
||||
vec![
|
||||
("blogs".to_owned(), ListType::Blog),
|
||||
("my_fav_authors".to_owned(), ListType::User),
|
||||
("hashtag".to_owned(), ListType::Word),
|
||||
("spoken".to_owned(), ListType::Prefix),
|
||||
("copyleft".to_owned(), ListType::Word),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user