Adding a search page

- Fixes # 70
This commit is contained in:
Dessalines 2019-04-23 15:05:50 -07:00
parent c96aa4819c
commit 73b9266580
9 changed files with 513 additions and 18 deletions

View file

@ -3,7 +3,7 @@ use diesel::*;
use diesel::result::Error;
use diesel::dsl::*;
use serde::{Deserialize, Serialize};
use { SortType, limit_and_offset };
use { SortType, limit_and_offset, fuzzy_search };
// The faked schema since diesel doesn't do views
table! {
@ -60,6 +60,7 @@ impl CommentView {
sort: &SortType,
for_post_id: Option<i32>,
for_creator_id: Option<i32>,
search_term: Option<String>,
my_user_id: Option<i32>,
saved_only: bool,
page: Option<i64>,
@ -86,6 +87,10 @@ impl CommentView {
if let Some(for_post_id) = for_post_id {
query = query.filter(post_id.eq(for_post_id));
};
if let Some(search_term) = search_term {
query = query.filter(content.ilike(fuzzy_search(&search_term)));
};
if saved_only {
query = query.filter(saved.eq(true));
@ -353,8 +358,26 @@ mod tests {
saved: None,
};
let read_comment_views_no_user = CommentView::list(&conn, &SortType::New, Some(inserted_post.id), None, None, false, None, None).unwrap();
let read_comment_views_with_user = CommentView::list(&conn, &SortType::New, Some(inserted_post.id), None, Some(inserted_user.id), false, None, None).unwrap();
let read_comment_views_no_user = CommentView::list(
&conn,
&SortType::New,
Some(inserted_post.id),
None,
None,
None,
false,
None,
None).unwrap();
let read_comment_views_with_user = CommentView::list(
&conn,
&SortType::New,
Some(inserted_post.id),
None,
None,
Some(inserted_user.id),
false,
None,
None).unwrap();
let like_removed = CommentLike::remove(&conn, &comment_like_form).unwrap();
let num_deleted = Comment::delete(&conn, inserted_comment.id).unwrap();
Post::delete(&conn, inserted_post.id).unwrap();

View file

@ -3,7 +3,7 @@ use diesel::*;
use diesel::result::Error;
use diesel::dsl::*;
use serde::{Deserialize, Serialize};
use { SortType, limit_and_offset };
use { SortType, limit_and_offset, fuzzy_search };
#[derive(EnumString,ToString,Debug, Serialize, Deserialize)]
pub enum PostListingType {
@ -74,6 +74,7 @@ impl PostView {
sort: &SortType,
for_community_id: Option<i32>,
for_creator_id: Option<i32>,
search_term: Option<String>,
my_user_id: Option<i32>,
saved_only: bool,
unread_only: bool,
@ -94,6 +95,10 @@ impl PostView {
query = query.filter(creator_id.eq(for_creator_id));
};
if let Some(search_term) = search_term {
query = query.filter(name.ilike(fuzzy_search(&search_term)));
};
// TODO these are wrong, bc they'll only show saved for your logged in user, not theirs
if saved_only {
query = query.filter(saved.eq(true));
@ -295,8 +300,27 @@ mod tests {
};
let read_post_listings_with_user = PostView::list(&conn, PostListingType::Community, &SortType::New, Some(inserted_community.id), None, Some(inserted_user.id), false, false, None, None).unwrap();
let read_post_listings_no_user = PostView::list(&conn, PostListingType::Community, &SortType::New, Some(inserted_community.id), None, None, false, false, None, None).unwrap();
let read_post_listings_with_user = PostView::list(&conn,
PostListingType::Community,
&SortType::New, Some(inserted_community.id),
None,
None,
Some(inserted_user.id),
false,
false,
None,
None).unwrap();
let read_post_listings_no_user = PostView::list(&conn,
PostListingType::Community,
&SortType::New,
Some(inserted_community.id),
None,
None,
None,
false,
false,
None,
None).unwrap();
let read_post_listing_no_user = PostView::read(&conn, inserted_post.id, None).unwrap();
let read_post_listing_with_user = PostView::read(&conn, inserted_post.id, Some(inserted_user.id)).unwrap();

View file

@ -97,6 +97,11 @@ pub enum SortType {
Hot, New, TopDay, TopWeek, TopMonth, TopYear, TopAll
}
#[derive(EnumString,ToString,Debug, Serialize, Deserialize)]
pub enum SearchType {
Both, Comments, Posts
}
pub fn to_datetime_utc(ndt: NaiveDateTime) -> DateTime<Utc> {
DateTime::<Utc>::from_utc(ndt, Utc)
}
@ -121,6 +126,11 @@ pub fn has_slurs(test: &str) -> bool {
SLUR_REGEX.is_match(test)
}
pub fn fuzzy_search(q: &str) -> String {
let replaced = q.replace(" ", "%");
format!("%{}%", replaced)
}
pub fn limit_and_offset(page: Option<i64>, limit: Option<i64>) -> (i64, i64) {
let page = page.unwrap_or(1);
let limit = limit.unwrap_or(10);
@ -130,7 +140,7 @@ pub fn limit_and_offset(page: Option<i64>, limit: Option<i64>) -> (i64, i64) {
#[cfg(test)]
mod tests {
use {Settings, is_email_regex, remove_slurs, has_slurs};
use {Settings, is_email_regex, remove_slurs, has_slurs, fuzzy_search};
#[test]
fn test_api() {
assert_eq!(Settings::get().api_endpoint(), "http://0.0.0.0/api/v1");
@ -148,9 +158,15 @@ mod tests {
assert!(has_slurs(&test));
assert!(!has_slurs(slur_free));
}
#[test] fn test_fuzzy_search() {
let test = "This is a fuzzy search";
assert_eq!(fuzzy_search(test), "%This%is%a%fuzzy%search%".to_string());
}
}
lazy_static! {
static ref EMAIL_REGEX: Regex = Regex::new(r"^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$").unwrap();
static ref SLUR_REGEX: Regex = Regex::new(r"(fag(g|got|tard)?|maricos?|cock\s?sucker(s|ing)?|\bnig(\b|g?(a|er)?s?)\b|dindu(s?)|mudslime?s?|kikes?|mongoloids?|towel\s*heads?|\bspi(c|k)s?\b|\bchinks?|niglets?|beaners?|\bnips?\b|\bcoons?\b|jungle\s*bunn(y|ies?)|jigg?aboo?s?|\bpakis?\b|rag\s*heads?|gooks?|cunts?|bitch(es|ing|y)?|puss(y|ies?)|twats?|feminazis?|whor(es?|ing)|\bslut(s|t?y)?|\btrann?(y|ies?)|ladyboy(s?))").unwrap();

View file

@ -12,7 +12,7 @@ use std::str::FromStr;
use diesel::PgConnection;
use failure::Error;
use {Crud, Joinable, Likeable, Followable, Bannable, Saveable, establish_connection, naive_now, naive_from_unix, SortType, has_slurs, remove_slurs};
use {Crud, Joinable, Likeable, Followable, Bannable, Saveable, establish_connection, naive_now, naive_from_unix, SortType, SearchType, has_slurs, remove_slurs};
use actions::community::*;
use actions::user::*;
use actions::post::*;
@ -27,7 +27,7 @@ use actions::moderator::*;
#[derive(EnumString,ToString,Debug)]
pub enum UserOperation {
Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, SaveComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, SavePost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails, GetReplies, GetModlog, BanFromCommunity, AddModToCommunity, CreateSite, EditSite, GetSite, AddAdmin, BanUser
Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, SaveComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, SavePost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails, GetReplies, GetModlog, BanFromCommunity, AddModToCommunity, CreateSite, EditSite, GetSite, AddAdmin, BanUser, Search
}
#[derive(Fail, Debug)]
@ -458,6 +458,23 @@ pub struct GetRepliesResponse {
replies: Vec<ReplyView>,
}
#[derive(Serialize, Deserialize)]
pub struct Search {
q: String,
type_: String,
community_id: Option<i32>,
sort: String,
page: Option<i64>,
limit: Option<i64>,
}
#[derive(Serialize, Deserialize)]
pub struct SearchResponse {
op: String,
comments: Vec<CommentView>,
posts: Vec<PostView>,
}
/// `ChatServer` manages chat rooms and responsible for coordinating chat
/// session. implementation is super primitive
pub struct ChatServer {
@ -500,6 +517,7 @@ impl ChatServer {
Some(community_id),
None,
None,
None,
false,
false,
None,
@ -703,6 +721,10 @@ fn parse_json_message(chat: &mut ChatServer, msg: StandardMessage) -> Result<Str
let get_replies: GetReplies = serde_json::from_str(data)?;
get_replies.perform(chat, msg.id)
},
UserOperation::Search => {
let search: Search = serde_json::from_str(data)?;
search.perform(chat, msg.id)
},
}
}
@ -1106,7 +1128,7 @@ impl Perform for GetPost {
chat.rooms.get_mut(&self.id).unwrap().insert(addr);
let comments = CommentView::list(&conn, &SortType::New, Some(self.id), None, user_id, false, None, Some(9999))?;
let comments = CommentView::list(&conn, &SortType::New, Some(self.id), None, None, user_id, false, None, Some(9999))?;
let community = CommunityView::read(&conn, post_view.community_id, user_id)?;
@ -1537,7 +1559,17 @@ impl Perform for GetPosts {
let type_ = PostListingType::from_str(&self.type_)?;
let sort = SortType::from_str(&self.sort)?;
let posts = match PostView::list(&conn, type_, &sort, self.community_id, None, user_id, false, false, self.page, self.limit) {
let posts = match PostView::list(&conn,
type_,
&sort,
self.community_id,
None,
None,
user_id,
false,
false,
self.page,
self.limit) {
Ok(posts) => posts,
Err(_e) => {
return Err(self.error("Couldn't get posts"))?
@ -2006,15 +2038,52 @@ impl Perform for GetUserDetails {
let sort = SortType::from_str(&self.sort)?;
let user_view = UserView::read(&conn, self.user_id)?;
// If its saved only, you don't care what creator it was
let posts = if self.saved_only {
PostView::list(&conn, PostListingType::All, &sort, self.community_id, None, Some(self.user_id), self.saved_only, false, self.page, self.limit)?
PostView::list(&conn,
PostListingType::All,
&sort,
self.community_id,
None,
None,
Some(self.user_id),
self.saved_only,
false,
self.page,
self.limit)?
} else {
PostView::list(&conn, PostListingType::All, &sort, self.community_id, Some(self.user_id), None, self.saved_only, false, self.page, self.limit)?
PostView::list(&conn,
PostListingType::All,
&sort,
self.community_id,
Some(self.user_id),
None,
None,
self.saved_only,
false,
self.page,
self.limit)?
};
let comments = if self.saved_only {
CommentView::list(&conn, &sort, None, None, Some(self.user_id), self.saved_only, self.page, self.limit)?
CommentView::list(&conn,
&sort,
None,
None,
None,
Some(self.user_id),
self.saved_only,
self.page,
self.limit)?
} else {
CommentView::list(&conn, &sort, None, Some(self.user_id), None, self.saved_only, self.page, self.limit)?
CommentView::list(&conn,
&sort,
None,
Some(self.user_id),
None,
None,
self.saved_only,
self.page,
self.limit)?
};
let follows = CommunityFollowerView::for_user(&conn, self.user_id)?;
@ -2539,3 +2608,81 @@ impl Perform for BanUser {
}
}
impl Perform for Search {
fn op_type(&self) -> UserOperation {
UserOperation::Search
}
fn perform(&self, _chat: &mut ChatServer, _addr: usize) -> Result<String, Error> {
let conn = establish_connection();
let sort = SortType::from_str(&self.sort)?;
let type_ = SearchType::from_str(&self.type_)?;
let mut posts = Vec::new();
let mut comments = Vec::new();
match type_ {
SearchType::Posts => {
posts = PostView::list(&conn,
PostListingType::All,
&sort,
self.community_id,
None,
Some(self.q.to_owned()),
None,
false,
false,
self.page,
self.limit)?;
},
SearchType::Comments => {
comments = CommentView::list(&conn,
&sort,
None,
None,
Some(self.q.to_owned()),
None,
false,
self.page,
self.limit)?;
},
SearchType::Both => {
posts = PostView::list(&conn,
PostListingType::All,
&sort,
self.community_id,
None,
Some(self.q.to_owned()),
None,
false,
false,
self.page,
self.limit)?;
comments = CommentView::list(&conn,
&sort,
None,
None,
Some(self.q.to_owned()),
None,
false,
self.page,
self.limit)?;
}
};
// Return the jwt
Ok(
serde_json::to_string(
&SearchResponse {
op: self.op_type().to_string(),
comments: comments,
posts: posts,
}
)?
)
}
}

View file

@ -76,7 +76,7 @@ export class Navbar extends Component<any, NavbarState> {
<Link class="nav-link" to="/communities">Forums</Link>
</li>
<li class="nav-item">
<Link class="nav-link" to="/modlog">Modlog</Link>
<Link class="nav-link" to="/search">Search</Link>
</li>
<li class="nav-item">
<Link class="nav-link" to="/create_post">Create Post</Link>

View file

@ -0,0 +1,259 @@
import { Component, linkEvent } from 'inferno';
import { Subscription } from "rxjs";
import { retryWhen, delay, take } from 'rxjs/operators';
import { UserOperation, Post, Comment, SortType, SearchForm, SearchResponse, SearchType } from '../interfaces';
import { WebSocketService } from '../services';
import { msgOp, fetchLimit } from '../utils';
import { PostListing } from './post-listing';
import { CommentNodes } from './comment-nodes';
interface SearchState {
q: string,
type_: SearchType,
sort: SortType,
page: number,
searchResponse: SearchResponse;
loading: boolean;
}
export class Search extends Component<any, SearchState> {
private subscription: Subscription;
private emptyState: SearchState = {
q: undefined,
type_: SearchType.Both,
sort: SortType.TopAll,
page: 1,
searchResponse: {
op: null,
posts: [],
comments: [],
},
loading: false,
}
constructor(props: any, context: any) {
super(props, context);
this.state = this.emptyState;
this.subscription = WebSocketService.Instance.subject
.pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
.subscribe(
(msg) => this.parseMessage(msg),
(err) => console.error(err),
() => console.log('complete')
);
}
componentWillUnmount() {
this.subscription.unsubscribe();
}
componentDidMount() {
document.title = "Search - Lemmy";
}
render() {
return (
<div class="container">
<div class="row">
<div class="col-12">
<h5>Search</h5>
{this.selects()}
{this.searchForm()}
{this.state.type_ == SearchType.Both &&
this.both()
}
{this.state.type_ == SearchType.Comments &&
this.comments()
}
{this.state.type_ == SearchType.Posts &&
this.posts()
}
{this.noResults()}
{this.paginator()}
</div>
</div>
</div>
)
}
searchForm() {
return (
<form class="form-inline" onSubmit={linkEvent(this, this.handleSearchSubmit)}>
<input type="text" class="form-control mr-2" value={this.state.q} placeholder="Search..." onInput={linkEvent(this, this.handleQChange)} required minLength={3} />
<button type="submit" class="btn btn-secondary mr-2">
{this.state.loading ?
<svg class="icon icon-spinner spin"><use xlinkHref="#icon-spinner"></use></svg> :
<span>Search</span>
}
</button>
</form>
)
}
selects() {
return (
<div className="mb-2">
<select value={this.state.type_} onChange={linkEvent(this, this.handleTypeChange)} class="custom-select w-auto">
<option disabled>Type</option>
<option value={SearchType.Both}>Both</option>
<option value={SearchType.Comments}>Comments</option>
<option value={SearchType.Posts}>Posts</option>
</select>
<select value={this.state.sort} onChange={linkEvent(this, this.handleSortChange)} class="custom-select w-auto ml-2">
<option disabled>Sort Type</option>
<option value={SortType.New}>New</option>
<option value={SortType.TopDay}>Top Day</option>
<option value={SortType.TopWeek}>Week</option>
<option value={SortType.TopMonth}>Month</option>
<option value={SortType.TopYear}>Year</option>
<option value={SortType.TopAll}>All</option>
</select>
</div>
)
}
both() {
let combined: Array<{type_: string, data: Comment | Post}> = [];
let comments = this.state.searchResponse.comments.map(e => {return {type_: "comments", data: e}});
let posts = this.state.searchResponse.posts.map(e => {return {type_: "posts", data: e}});
combined.push(...comments);
combined.push(...posts);
// Sort it
if (this.state.sort == SortType.New) {
combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
} else {
combined.sort((a, b) => b.data.score - a.data.score);
}
return (
<div>
{combined.map(i =>
<div>
{i.type_ == "posts"
? <PostListing post={i.data as Post} showCommunity viewOnly />
: <CommentNodes nodes={[{comment: i.data as Comment}]} viewOnly noIndent />
}
</div>
)
}
</div>
)
}
comments() {
return (
<div>
{this.state.searchResponse.comments.map(comment =>
<CommentNodes nodes={[{comment: comment}]} noIndent viewOnly />
)}
</div>
);
}
posts() {
return (
<div>
{this.state.searchResponse.posts.map(post =>
<PostListing post={post} showCommunity viewOnly />
)}
</div>
);
}
paginator() {
return (
<div class="mt-2">
{this.state.page > 1 &&
<button class="btn btn-sm btn-secondary mr-1" onClick={linkEvent(this, this.prevPage)}>Prev</button>
}
<button class="btn btn-sm btn-secondary" onClick={linkEvent(this, this.nextPage)}>Next</button>
</div>
);
}
noResults() {
let res = this.state.searchResponse;
return (
<div>
{res && res.op && res.posts.length == 0 && res.comments.length == 0 &&
<span>No Results</span>
}
</div>
)
}
nextPage(i: Search) {
i.state.page++;
i.setState(i.state);
i.search();
}
prevPage(i: Search) {
i.state.page--;
i.setState(i.state);
i.search();
}
search() {
// TODO community
let form: SearchForm = {
q: this.state.q,
type_: SearchType[this.state.type_],
sort: SortType[this.state.sort],
page: this.state.page,
limit: fetchLimit,
};
WebSocketService.Instance.search(form);
}
handleSortChange(i: Search, event: any) {
i.state.sort = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.search();
}
handleTypeChange(i: Search, event: any) {
i.state.type_ = Number(event.target.value);
i.state.page = 1;
i.setState(i.state);
i.search();
}
handleSearchSubmit(i: Search, event: any) {
event.preventDefault();
i.state.loading = true;
i.search();
i.setState(i.state);
}
handleQChange(i: Search, event: any) {
i.state.q = event.target.value;
i.setState(i.state);
}
parseMessage(msg: any) {
console.log(msg);
let op: UserOperation = msgOp(msg);
if (msg.error) {
alert(msg.error);
return;
} else if (op == UserOperation.Search) {
let res: SearchResponse = msg;
this.state.searchResponse = res;
this.state.loading = false;
document.title = `Search - ${this.state.q} - Lemmy`;
this.setState(this.state);
}
}
}

View file

@ -14,6 +14,7 @@ import { User } from './components/user';
import { Modlog } from './components/modlog';
import { Setup } from './components/setup';
import { Inbox } from './components/inbox';
import { Search } from './components/search';
import { Symbols } from './components/symbols';
import './css/bootstrap.min.css';
@ -52,6 +53,7 @@ class Index extends Component<any, any> {
<Route path={`/modlog/community/:community_id`} component={Modlog} />
<Route path={`/modlog`} component={Modlog} />
<Route path={`/setup`} component={Setup} />
<Route path={`/search`} component={Search} />
</Switch>
<Symbols />
</div>

View file

@ -1,5 +1,5 @@
export enum UserOperation {
Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, SaveComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, SavePost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails, GetReplies, GetModlog, BanFromCommunity, AddModToCommunity, CreateSite, EditSite, GetSite, AddAdmin, BanUser
Login, Register, CreateCommunity, CreatePost, ListCommunities, ListCategories, GetPost, GetCommunity, CreateComment, EditComment, SaveComment, CreateCommentLike, GetPosts, CreatePostLike, EditPost, SavePost, EditCommunity, FollowCommunity, GetFollowedCommunities, GetUserDetails, GetReplies, GetModlog, BanFromCommunity, AddModToCommunity, CreateSite, EditSite, GetSite, AddAdmin, BanUser, Search
}
export enum CommentSortType {
@ -14,6 +14,10 @@ export enum SortType {
Hot, New, TopDay, TopWeek, TopMonth, TopYear, TopAll
}
export enum SearchType {
Both, Comments, Posts
}
export interface User {
id: number;
iss: string;
@ -517,3 +521,18 @@ export interface AddAdminResponse {
op: string;
admins: Array<UserView>;
}
export interface SearchForm {
q: string;
type_: string;
community_id?: number;
sort: string;
page?: number;
limit?: number;
}
export interface SearchResponse {
op: string;
posts?: Array<Post>;
comments?: Array<Comment>;
}

View file

@ -1,5 +1,5 @@
import { wsUri } from '../env';
import { LoginForm, RegisterForm, UserOperation, CommunityForm, PostForm, SavePostForm, CommentForm, SaveCommentForm, CommentLikeForm, GetPostsForm, CreatePostLikeForm, FollowCommunityForm, GetUserDetailsForm, ListCommunitiesForm, GetModlogForm, BanFromCommunityForm, AddModToCommunityForm, AddAdminForm, BanUserForm, SiteForm, Site, UserView, GetRepliesForm } from '../interfaces';
import { LoginForm, RegisterForm, UserOperation, CommunityForm, PostForm, SavePostForm, CommentForm, SaveCommentForm, CommentLikeForm, GetPostsForm, CreatePostLikeForm, FollowCommunityForm, GetUserDetailsForm, ListCommunitiesForm, GetModlogForm, BanFromCommunityForm, AddModToCommunityForm, AddAdminForm, BanUserForm, SiteForm, Site, UserView, GetRepliesForm, SearchForm } from '../interfaces';
import { webSocket } from 'rxjs/webSocket';
import { Subject } from 'rxjs';
import { retryWhen, delay, take } from 'rxjs/operators';
@ -163,10 +163,15 @@ export class WebSocketService {
this.setAuth(siteForm);
this.subject.next(this.wsSendWrapper(UserOperation.EditSite, siteForm));
}
public getSite() {
this.subject.next(this.wsSendWrapper(UserOperation.GetSite, {}));
}
public search(form: SearchForm) {
this.subject.next(this.wsSendWrapper(UserOperation.Search, form));
}
private wsSendWrapper(op: UserOperation, data: any) {
let send = { op: UserOperation[op], data: data };
console.log(send);