Plume/plume-models/src/lists.rs

555 lines
17 KiB
Rust
Raw Normal View History

2020-01-21 07:02:03 +01:00
use crate::{
blogs::Blog,
schema::{blogs, list_elems, lists, users},
users::User,
Connection, Error, Result,
};
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
2019-10-07 19:08:20 +02:00
use diesel::{self, ExpressionMethods, QueryDsl, RunQueryDsl};
use std::convert::{TryFrom, TryInto};
/// Represent what a list is supposed to store. Represented in database as an integer
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ListType {
User,
Blog,
Word,
Prefix,
}
impl TryFrom<i32> for ListType {
type Error = ();
fn try_from(i: i32) -> std::result::Result<Self, ()> {
match i {
0 => Ok(ListType::User),
1 => Ok(ListType::Blog),
2 => Ok(ListType::Word),
3 => Ok(ListType::Prefix),
_ => Err(()),
}
}
}
impl Into<i32> for ListType {
fn into(self) -> i32 {
match self {
ListType::User => 0,
ListType::Blog => 1,
ListType::Word => 2,
ListType::Prefix => 3,
}
}
}
#[derive(Clone, Queryable, Identifiable)]
pub struct List {
pub id: i32,
pub name: String,
pub user_id: Option<i32>,
type_: i32,
}
#[derive(Default, Insertable)]
#[table_name = "lists"]
struct NewList<'a> {
pub name: &'a str,
pub user_id: Option<i32>,
type_: i32,
}
macro_rules! func {
(@elem User $id:expr, $value:expr) => {
NewListElem {
list_id: $id,
user_id: Some(*$value),
blog_id: None,
word: None,
}
};
(@elem Blog $id:expr, $value:expr) => {
NewListElem {
list_id: $id,
user_id: None,
blog_id: Some(*$value),
word: None,
}
};
(@elem Word $id:expr, $value:expr) => {
NewListElem {
list_id: $id,
user_id: None,
blog_id: None,
word: Some($value),
}
};
(@elem Prefix $id:expr, $value:expr) => {
NewListElem {
list_id: $id,
user_id: None,
blog_id: None,
word: Some($value),
}
};
(@in_type User) => { i32 };
(@in_type Blog) => { i32 };
(@in_type Word) => { &str };
(@in_type Prefix) => { &str };
(@out_type User) => { User };
(@out_type Blog) => { Blog };
(@out_type Word) => { String };
(@out_type Prefix) => { String };
(add: $fn:ident, $kind:ident) => {
pub fn $fn(&self, conn: &Connection, vals: &[func!(@in_type $kind)]) -> Result<()> {
if self.kind() != ListType::$kind {
return Err(Error::InvalidValue);
}
diesel::insert_into(list_elems::table)
.values(
vals
.iter()
.map(|u| func!(@elem $kind self.id, u))
.collect::<Vec<_>>(),
)
.execute(conn)?;
Ok(())
}
};
(list: $fn:ident, $kind:ident, $table:ident) => {
pub fn $fn(&self, conn: &Connection) -> Result<Vec<func!(@out_type $kind)>> {
if self.kind() != ListType::$kind {
return Err(Error::InvalidValue);
}
list_elems::table
.filter(list_elems::list_id.eq(self.id))
.inner_join($table::table)
.select($table::all_columns)
.load(conn)
.map_err(Error::from)
}
};
(set: $fn:ident, $kind:ident, $add:ident) => {
pub fn $fn(&self, conn: &Connection, val: &[func!(@in_type $kind)]) -> Result<()> {
if self.kind() != ListType::$kind {
return Err(Error::InvalidValue);
}
self.clear(conn)?;
self.$add(conn, val)
}
}
}
#[derive(Clone, Queryable, Identifiable)]
struct ListElem {
pub id: i32,
pub list_id: i32,
pub user_id: Option<i32>,
pub blog_id: Option<i32>,
pub word: Option<String>,
}
#[derive(Default, Insertable)]
#[table_name = "list_elems"]
struct NewListElem<'a> {
pub list_id: i32,
pub user_id: Option<i32>,
pub blog_id: Option<i32>,
pub word: Option<&'a str>,
}
impl List {
last!(lists);
get!(lists);
2020-01-21 07:02:03 +01:00
fn insert(conn: &Connection, val: NewList<'_>) -> Result<Self> {
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
2019-10-07 19:08:20 +02:00
diesel::insert_into(lists::table)
.values(val)
.execute(conn)?;
List::last(conn)
}
pub fn list_for_user(conn: &Connection, user_id: Option<i32>) -> Result<Vec<Self>> {
if let Some(user_id) = user_id {
lists::table
.filter(lists::user_id.eq(user_id))
.load::<Self>(conn)
.map_err(Error::from)
} else {
lists::table
.filter(lists::user_id.is_null())
.load::<Self>(conn)
.map_err(Error::from)
}
}
pub fn find_for_user_by_name(
conn: &Connection,
user_id: Option<i32>,
name: &str,
) -> Result<Self> {
if let Some(user_id) = user_id {
lists::table
.filter(lists::user_id.eq(user_id))
.filter(lists::name.eq(name))
.first(conn)
.map_err(Error::from)
} else {
lists::table
.filter(lists::user_id.is_null())
.filter(lists::name.eq(name))
.first(conn)
.map_err(Error::from)
}
}
pub fn new(conn: &Connection, name: &str, user: Option<&User>, kind: ListType) -> Result<Self> {
Self::insert(
conn,
NewList {
name,
user_id: user.map(|u| u.id),
type_: kind.into(),
},
)
}
/// Returns the kind of a list
pub fn kind(&self) -> ListType {
self.type_.try_into().expect("invalid list was constructed")
}
/// Return Ok(true) if the list contain the given user, Ok(false) otherwiser,
/// and Err(_) on error
pub fn contains_user(&self, conn: &Connection, user: i32) -> Result<bool> {
private::ListElem::user_in_list(conn, self, user)
}
/// Return Ok(true) if the list contain the given blog, Ok(false) otherwiser,
/// and Err(_) on error
pub fn contains_blog(&self, conn: &Connection, blog: i32) -> Result<bool> {
private::ListElem::blog_in_list(conn, self, blog)
}
/// Return Ok(true) if the list contain the given word, Ok(false) otherwiser,
/// and Err(_) on error
pub fn contains_word(&self, conn: &Connection, word: &str) -> Result<bool> {
private::ListElem::word_in_list(conn, self, word)
}
/// Return Ok(true) if the list match the given prefix, Ok(false) otherwiser,
/// and Err(_) on error
pub fn contains_prefix(&self, conn: &Connection, word: &str) -> Result<bool> {
private::ListElem::prefix_in_list(conn, self, word)
}
/// Insert new users in a list
func! {add: add_users, User}
/// Insert new blogs in a list
func! {add: add_blogs, Blog}
/// Insert new words in a list
func! {add: add_words, Word}
/// Insert new prefixes in a list
func! {add: add_prefixes, Prefix}
/// Get all users in the list
func! {list: list_users, User, users}
/// Get all blogs in the list
func! {list: list_blogs, Blog, blogs}
/// Get all words in the list
pub fn list_words(&self, conn: &Connection) -> Result<Vec<String>> {
self.list_stringlike(conn, ListType::Word)
}
/// Get all prefixes in the list
pub fn list_prefixes(&self, conn: &Connection) -> Result<Vec<String>> {
self.list_stringlike(conn, ListType::Prefix)
}
#[inline(always)]
fn list_stringlike(&self, conn: &Connection, t: ListType) -> Result<Vec<String>> {
if self.kind() != t {
return Err(Error::InvalidValue);
}
list_elems::table
.filter(list_elems::list_id.eq(self.id))
.filter(list_elems::word.is_not_null())
.select(list_elems::word)
.load::<Option<String>>(conn)
.map_err(Error::from)
.map(|r| r.into_iter().filter_map(|o| o).collect::<Vec<String>>())
}
pub fn clear(&self, conn: &Connection) -> Result<()> {
diesel::delete(list_elems::table.filter(list_elems::list_id.eq(self.id)))
.execute(conn)
.map(|_| ())
.map_err(Error::from)
}
func! {set: set_users, User, add_users}
func! {set: set_blogs, Blog, add_blogs}
func! {set: set_words, Word, add_words}
func! {set: set_prefixes, Prefix, add_prefixes}
}
mod private {
pub use super::*;
use diesel::{
dsl,
sql_types::{Nullable, Text},
IntoSql, TextExpressionMethods,
};
impl ListElem {
2020-01-21 07:02:03 +01:00
insert!(list_elems, NewListElem<'_>);
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
2019-10-07 19:08:20 +02:00
pub fn user_in_list(conn: &Connection, list: &List, user: i32) -> Result<bool> {
dsl::select(dsl::exists(
list_elems::table
.filter(list_elems::list_id.eq(list.id))
.filter(list_elems::user_id.eq(Some(user))),
))
.get_result(conn)
.map_err(Error::from)
}
pub fn blog_in_list(conn: &Connection, list: &List, blog: i32) -> Result<bool> {
dsl::select(dsl::exists(
list_elems::table
.filter(list_elems::list_id.eq(list.id))
.filter(list_elems::blog_id.eq(Some(blog))),
))
.get_result(conn)
.map_err(Error::from)
}
pub fn word_in_list(conn: &Connection, list: &List, word: &str) -> Result<bool> {
dsl::select(dsl::exists(
list_elems::table
.filter(list_elems::list_id.eq(list.id))
.filter(list_elems::word.eq(word)),
))
.get_result(conn)
.map_err(Error::from)
}
pub fn prefix_in_list(conn: &Connection, list: &List, word: &str) -> Result<bool> {
dsl::select(dsl::exists(
list_elems::table
.filter(
word.into_sql::<Nullable<Text>>()
.like(list_elems::word.concat("%")),
)
.filter(list_elems::list_id.eq(list.id)),
))
.get_result(conn)
.map_err(Error::from)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
2020-01-21 07:02:03 +01:00
use crate::{blogs::tests as blog_tests, tests::db};
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
2019-10-07 19:08:20 +02:00
use diesel::Connection;
#[test]
fn list_type() {
for i in 0..4 {
assert_eq!(i, Into::<i32>::into(ListType::try_from(i).unwrap()));
}
ListType::try_from(4).unwrap_err();
}
#[test]
fn list_lists() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let (users, _) = blog_tests::fill_database(conn);
let l1 = List::new(conn, "list1", None, ListType::User).unwrap();
let l2 = List::new(conn, "list2", None, ListType::Blog).unwrap();
let l1u = List::new(conn, "list1", Some(&users[0]), ListType::Word).unwrap();
let l_eq = |l1: &List, l2: &List| {
assert_eq!(l1.id, l2.id);
assert_eq!(l1.user_id, l2.user_id);
assert_eq!(l1.name, l2.name);
assert_eq!(l1.type_, l2.type_);
};
let l1bis = List::get(conn, l1.id).unwrap();
l_eq(&l1, &l1bis);
let l_inst = List::list_for_user(conn, None).unwrap();
let l_user = List::list_for_user(conn, Some(users[0].id)).unwrap();
assert_eq!(2, l_inst.len());
assert_eq!(1, l_user.len());
assert!(l_inst.iter().all(|l| l.id != l1u.id));
l_eq(&l1u, &l_user[0]);
if l_inst[0].id == l1.id {
l_eq(&l1, &l_inst[0]);
l_eq(&l2, &l_inst[1]);
} else {
l_eq(&l1, &l_inst[1]);
l_eq(&l2, &l_inst[0]);
}
l_eq(
&l1,
&List::find_for_user_by_name(conn, l1.user_id, &l1.name).unwrap(),
);
l_eq(
&&l1u,
&List::find_for_user_by_name(conn, l1u.user_id, &l1u.name).unwrap(),
);
Ok(())
});
}
#[test]
fn test_user_list() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let (users, blogs) = blog_tests::fill_database(conn);
let l = List::new(conn, "list", None, ListType::User).unwrap();
assert_eq!(l.kind(), ListType::User);
assert!(l.list_users(conn).unwrap().is_empty());
assert!(!l.contains_user(conn, users[0].id).unwrap());
assert!(l.add_users(conn, &[users[0].id]).is_ok());
assert!(l.contains_user(conn, users[0].id).unwrap());
assert!(l.add_users(conn, &[users[1].id]).is_ok());
assert!(l.contains_user(conn, users[0].id).unwrap());
assert!(l.contains_user(conn, users[1].id).unwrap());
assert_eq!(2, l.list_users(conn).unwrap().len());
assert!(l.set_users(conn, &[users[0].id]).is_ok());
assert!(l.contains_user(conn, users[0].id).unwrap());
assert!(!l.contains_user(conn, users[1].id).unwrap());
assert_eq!(1, l.list_users(conn).unwrap().len());
assert!(users[0] == l.list_users(conn).unwrap()[0]);
l.clear(conn).unwrap();
assert!(l.list_users(conn).unwrap().is_empty());
assert!(l.add_blogs(conn, &[blogs[0].id]).is_err());
Ok(())
});
}
#[test]
fn test_blog_list() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let (users, blogs) = blog_tests::fill_database(conn);
let l = List::new(conn, "list", None, ListType::Blog).unwrap();
assert_eq!(l.kind(), ListType::Blog);
assert!(l.list_blogs(conn).unwrap().is_empty());
assert!(!l.contains_blog(conn, blogs[0].id).unwrap());
assert!(l.add_blogs(conn, &[blogs[0].id]).is_ok());
assert!(l.contains_blog(conn, blogs[0].id).unwrap());
assert!(l.add_blogs(conn, &[blogs[1].id]).is_ok());
assert!(l.contains_blog(conn, blogs[0].id).unwrap());
assert!(l.contains_blog(conn, blogs[1].id).unwrap());
assert_eq!(2, l.list_blogs(conn).unwrap().len());
assert!(l.set_blogs(conn, &[blogs[0].id]).is_ok());
assert!(l.contains_blog(conn, blogs[0].id).unwrap());
assert!(!l.contains_blog(conn, blogs[1].id).unwrap());
assert_eq!(1, l.list_blogs(conn).unwrap().len());
assert_eq!(blogs[0].id, l.list_blogs(conn).unwrap()[0].id);
l.clear(conn).unwrap();
assert!(l.list_blogs(conn).unwrap().is_empty());
assert!(l.add_users(conn, &[users[0].id]).is_err());
Ok(())
});
}
#[test]
fn test_word_list() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let l = List::new(conn, "list", None, ListType::Word).unwrap();
assert_eq!(l.kind(), ListType::Word);
assert!(l.list_words(conn).unwrap().is_empty());
assert!(!l.contains_word(conn, "plume").unwrap());
assert!(l.add_words(conn, &["plume"]).is_ok());
assert!(l.contains_word(conn, "plume").unwrap());
assert!(!l.contains_word(conn, "plumelin").unwrap());
assert!(l.add_words(conn, &["amsterdam"]).is_ok());
assert!(l.contains_word(conn, "plume").unwrap());
assert!(l.contains_word(conn, "amsterdam").unwrap());
assert_eq!(2, l.list_words(conn).unwrap().len());
assert!(l.set_words(conn, &["plume"]).is_ok());
assert!(l.contains_word(conn, "plume").unwrap());
assert!(!l.contains_word(conn, "amsterdam").unwrap());
assert_eq!(1, l.list_words(conn).unwrap().len());
assert_eq!("plume", l.list_words(conn).unwrap()[0]);
l.clear(conn).unwrap();
assert!(l.list_words(conn).unwrap().is_empty());
assert!(l.add_prefixes(conn, &["something"]).is_err());
Ok(())
});
}
#[test]
fn test_prefix_list() {
let conn = &db();
conn.test_transaction::<_, (), _>(|| {
let l = List::new(conn, "list", None, ListType::Prefix).unwrap();
assert_eq!(l.kind(), ListType::Prefix);
assert!(l.list_prefixes(conn).unwrap().is_empty());
assert!(!l.contains_prefix(conn, "plume").unwrap());
assert!(l.add_prefixes(conn, &["plume"]).is_ok());
assert!(l.contains_prefix(conn, "plume").unwrap());
assert!(l.contains_prefix(conn, "plumelin").unwrap());
assert!(l.add_prefixes(conn, &["amsterdam"]).is_ok());
assert!(l.contains_prefix(conn, "plume").unwrap());
assert!(l.contains_prefix(conn, "amsterdam").unwrap());
assert_eq!(2, l.list_prefixes(conn).unwrap().len());
assert!(l.set_prefixes(conn, &["plume"]).is_ok());
assert!(l.contains_prefix(conn, "plume").unwrap());
assert!(!l.contains_prefix(conn, "amsterdam").unwrap());
assert_eq!(1, l.list_prefixes(conn).unwrap().len());
assert_eq!("plume", l.list_prefixes(conn).unwrap()[0]);
l.clear(conn).unwrap();
assert!(l.list_prefixes(conn).unwrap().is_empty());
assert!(l.add_words(conn, &["something"]).is_err());
Ok(())
});
}
}