From eaf0d5e637e0fd5be62b7ccf940ba1bcebeba786 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Thu, 21 Dec 2017 04:01:44 +0900
Subject: [PATCH] #1017 #155

---
 src/api/endpoints/posts/search.ts             | 96 ++++++++++++++++---
 .../app/common/scripts/parse-search-query.ts  | 41 ++++++++
 src/web/app/desktop/router.ts                 |  4 +-
 src/web/app/desktop/tags/search-posts.tag     |  6 +-
 src/web/app/desktop/tags/ui.tag               |  2 +-
 src/web/app/mobile/router.ts                  |  4 +-
 src/web/app/mobile/tags/search-posts.tag      |  6 +-
 src/web/app/mobile/tags/ui.tag                |  2 +-
 src/web/docs/search.ja.pug                    | 38 ++++++++
 9 files changed, 172 insertions(+), 27 deletions(-)
 create mode 100644 src/web/app/common/scripts/parse-search-query.ts
 create mode 100644 src/web/docs/search.ja.pug

diff --git a/src/api/endpoints/posts/search.ts b/src/api/endpoints/posts/search.ts
index b434f64342..dba7a53b5f 100644
--- a/src/api/endpoints/posts/search.ts
+++ b/src/api/endpoints/posts/search.ts
@@ -5,6 +5,7 @@ import * as mongo from 'mongodb';
 import $ from 'cafy';
 const escapeRegexp = require('escape-regexp');
 import Post from '../../models/post';
+import User from '../../models/user';
 import serialize from '../../serializers/post';
 import config from '../../../conf';
 
@@ -16,33 +17,98 @@ import config from '../../../conf';
  * @return {Promise<any>}
  */
 module.exports = (params, me) => new Promise(async (res, rej) => {
-	// Get 'query' parameter
-	const [query, queryError] = $(params.query).string().pipe(x => x != '').$;
-	if (queryError) return rej('invalid query param');
+	// Get 'text' parameter
+	const [text, textError] = $(params.text).optional.string().$;
+	if (textError) return rej('invalid text param');
+
+	// Get 'user_id' parameter
+	const [userId, userIdErr] = $(params.user_id).optional.id().$;
+	if (userIdErr) return rej('invalid user_id param');
+
+	// Get 'username' parameter
+	const [username, usernameErr] = $(params.username).optional.string().$;
+	if (usernameErr) return rej('invalid username param');
+
+	// Get 'include_replies' parameter
+	const [includeReplies = true, includeRepliesErr] = $(params.include_replies).optional.boolean().$;
+	if (includeRepliesErr) return rej('invalid include_replies param');
+
+	// Get 'with_media' parameter
+	const [withMedia = false, withMediaErr] = $(params.with_media).optional.boolean().$;
+	if (withMediaErr) return rej('invalid with_media param');
+
+	// Get 'since_date' parameter
+	const [sinceDate, sinceDateErr] = $(params.since_date).optional.number().$;
+	if (sinceDateErr) throw 'invalid since_date param';
+
+	// Get 'until_date' parameter
+	const [untilDate, untilDateErr] = $(params.until_date).optional.number().$;
+	if (untilDateErr) throw 'invalid until_date param';
 
 	// Get 'offset' parameter
 	const [offset = 0, offsetErr] = $(params.offset).optional.number().min(0).$;
 	if (offsetErr) return rej('invalid offset param');
 
-	// Get 'max' parameter
-	const [max = 10, maxErr] = $(params.max).optional.number().range(1, 30).$;
-	if (maxErr) return rej('invalid max param');
+	// Get 'limit' parameter
+	const [limit = 10, limitErr] = $(params.limit).optional.number().range(1, 30).$;
+	if (limitErr) return rej('invalid limit param');
 
-	// If Elasticsearch is available, search by $
+	let user = userId;
+
+	if (user == null && username != null) {
+		const _user = await User.findOne({
+			username_lower: username.toLowerCase()
+		});
+		if (_user) {
+			user = _user._id;
+		}
+	}
+
+	// If Elasticsearch is available, search by it
 	// If not, search by MongoDB
 	(config.elasticsearch.enable ? byElasticsearch : byNative)
-		(res, rej, me, query, offset, max);
+		(res, rej, me, text, user, includeReplies, withMedia, sinceDate, untilDate, offset, limit);
 });
 
 // Search by MongoDB
-async function byNative(res, rej, me, query, offset, max) {
-	const escapedQuery = escapeRegexp(query);
+async function byNative(res, rej, me, text, userId, includeReplies, withMedia, sinceDate, untilDate, offset, max) {
+	const q: any = {};
+
+	if (text) {
+		q.$and = text.split(' ').map(x => ({
+			text: new RegExp(escapeRegexp(x))
+		}));
+	}
+
+	if (userId) {
+		q.user_id = userId;
+	}
+
+	if (!includeReplies) {
+		q.reply_id = null;
+	}
+
+	if (withMedia) {
+		q.media_ids = {
+			$exists: true,
+			$ne: null
+		};
+	}
+
+	if (sinceDate) {
+		q.created_at = {
+			$gt: new Date(sinceDate)
+		};
+	}
+
+	if (untilDate) {
+		if (q.created_at == undefined) q.created_at = {};
+		q.created_at.$lt = new Date(untilDate);
+	}
 
 	// Search posts
 	const posts = await Post
-		.find({
-			text: new RegExp(escapedQuery)
-		}, {
+		.find(q, {
 			sort: {
 				_id: -1
 			},
@@ -56,7 +122,7 @@ async function byNative(res, rej, me, query, offset, max) {
 }
 
 // Search by Elasticsearch
-async function byElasticsearch(res, rej, me, query, offset, max) {
+async function byElasticsearch(res, rej, me, text, userId, includeReplies, withMedia, sinceDate, untilDate, offset, max) {
 	const es = require('../../db/elasticsearch');
 
 	es.search({
@@ -68,7 +134,7 @@ async function byElasticsearch(res, rej, me, query, offset, max) {
 			query: {
 				simple_query_string: {
 					fields: ['text'],
-					query: query,
+					query: text,
 					default_operator: 'and'
 				}
 			},
diff --git a/src/web/app/common/scripts/parse-search-query.ts b/src/web/app/common/scripts/parse-search-query.ts
new file mode 100644
index 0000000000..adcbfbb8fe
--- /dev/null
+++ b/src/web/app/common/scripts/parse-search-query.ts
@@ -0,0 +1,41 @@
+export default function(qs: string) {
+	const q = {
+		text: ''
+	};
+
+	qs.split(' ').forEach(x => {
+		if (/^([a-z_]+?):(.+?)$/.test(x)) {
+			const [key, value] = x.split(':');
+			switch (key) {
+				case 'user':
+					q['username'] = value;
+					break;
+				case 'reply':
+					q['include_replies'] = value == 'true';
+					break;
+				case 'media':
+					q['with_media'] = value == 'true';
+					break;
+				case 'until':
+				case 'since':
+					// YYYY-MM-DD
+					if (/^[0-9]+\-[0-9]+\-[0-9]+$/) {
+						const [yyyy, mm, dd] = value.split('-');
+						q[`${key}_date`] = (new Date(parseInt(yyyy, 10), parseInt(mm, 10) - 1, parseInt(dd, 10))).getTime();
+					}
+					break;
+				default:
+					q[key] = value;
+					break;
+			}
+		} else {
+			q.text += x + ' ';
+		}
+	});
+
+	if (q.text) {
+		q.text = q.text.trim();
+	}
+
+	return q;
+}
diff --git a/src/web/app/desktop/router.ts b/src/web/app/desktop/router.ts
index 27b63ab2ef..ce68c4f2d1 100644
--- a/src/web/app/desktop/router.ts
+++ b/src/web/app/desktop/router.ts
@@ -16,7 +16,7 @@ export default (mios: MiOS) => {
 	route('/i/messaging/:user',      messaging);
 	route('/i/mentions',             mentions);
 	route('/post::post',             post);
-	route('/search::query',          search);
+	route('/search',                 search);
 	route('/:user',                  user.bind(null, 'home'));
 	route('/:user/graphs',           user.bind(null, 'graphs'));
 	route('/:user/:post',            post);
@@ -47,7 +47,7 @@ export default (mios: MiOS) => {
 
 	function search(ctx) {
 		const el = document.createElement('mk-search-page');
-		el.setAttribute('query', ctx.params.query);
+		el.setAttribute('query', ctx.querystring.substr(2));
 		mount(el);
 	}
 
diff --git a/src/web/app/desktop/tags/search-posts.tag b/src/web/app/desktop/tags/search-posts.tag
index 52f765d1a1..c6b24837d2 100644
--- a/src/web/app/desktop/tags/search-posts.tag
+++ b/src/web/app/desktop/tags/search-posts.tag
@@ -33,6 +33,8 @@
 
 	</style>
 	<script>
+		import parse from '../../common/scripts/parse-search-query';
+
 		this.mixin('api');
 
 		this.query = this.opts.query;
@@ -45,9 +47,7 @@
 			document.addEventListener('keydown', this.onDocumentKeydown);
 			window.addEventListener('scroll', this.onScroll);
 
-			this.api('posts/search', {
-				query: this.query
-			}).then(posts => {
+			this.api('posts/search', parse(this.query)).then(posts => {
 				this.update({
 					isLoading: false,
 					isEmpty: posts.length == 0
diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag
index 059d88528d..3dfdeec01c 100644
--- a/src/web/app/desktop/tags/ui.tag
+++ b/src/web/app/desktop/tags/ui.tag
@@ -180,7 +180,7 @@
 
 		this.onsubmit = e => {
 			e.preventDefault();
-			this.page('/search:' + this.refs.q.value);
+			this.page('/search?q=' + encodeURIComponent(this.refs.q.value));
 		};
 	</script>
 </mk-ui-header-search>
diff --git a/src/web/app/mobile/router.ts b/src/web/app/mobile/router.ts
index d0c6add0b8..afb9aa6201 100644
--- a/src/web/app/mobile/router.ts
+++ b/src/web/app/mobile/router.ts
@@ -23,7 +23,7 @@ export default (mios: MiOS) => {
 	route('/i/settings/authorized-apps', settingsAuthorizedApps);
 	route('/post/new',                   newPost);
 	route('/post::post',                 post);
-	route('/search::query',              search);
+	route('/search',                     search);
 	route('/:user',                      user.bind(null, 'overview'));
 	route('/:user/graphs',               user.bind(null, 'graphs'));
 	route('/:user/followers',            userFollowers);
@@ -83,7 +83,7 @@ export default (mios: MiOS) => {
 
 	function search(ctx) {
 		const el = document.createElement('mk-search-page');
-		el.setAttribute('query', ctx.params.query);
+		el.setAttribute('query', ctx.querystring.substr(2));
 		mount(el);
 	}
 
diff --git a/src/web/app/mobile/tags/search-posts.tag b/src/web/app/mobile/tags/search-posts.tag
index 967764bc2c..023a35bf62 100644
--- a/src/web/app/mobile/tags/search-posts.tag
+++ b/src/web/app/mobile/tags/search-posts.tag
@@ -15,6 +15,8 @@
 				width calc(100% - 32px)
 	</style>
 	<script>
+		import parse from '../../common/scripts/parse-search-query';
+
 		this.mixin('api');
 
 		this.max = 30;
@@ -24,9 +26,7 @@
 		this.withMedia = this.opts.withMedia;
 
 		this.init = new Promise((res, rej) => {
-			this.api('posts/search', {
-				query: this.query
-			}).then(posts => {
+			this.api('posts/search', parse(this.query)).then(posts => {
 				res(posts);
 				this.trigger('loaded');
 			});
diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag
index 621f89f336..77ad14530d 100644
--- a/src/web/app/mobile/tags/ui.tag
+++ b/src/web/app/mobile/tags/ui.tag
@@ -413,7 +413,7 @@
 		this.search = () => {
 			const query = window.prompt('%i18n:mobile.tags.mk-ui-nav.search%');
 			if (query == null || query == '') return;
-			this.page('/search:' + query);
+			this.page('/search?q=' + encodeURIComponent(query));
 		};
 	</script>
 </mk-ui-nav>
diff --git a/src/web/docs/search.ja.pug b/src/web/docs/search.ja.pug
new file mode 100644
index 0000000000..f7ec9519f5
--- /dev/null
+++ b/src/web/docs/search.ja.pug
@@ -0,0 +1,38 @@
+h1 検索
+
+p 投稿を検索することができます。
+p
+	| キーワードを半角スペースで区切ると、and検索になります。
+	| 例えば、「git コミット」と検索すると、「gitで編集したファイルの特定の行だけコミットする方法がわからない」などがマッチします。
+
+section
+	h2 オプション
+	p
+		| オプションを使用して、より高度な検索をすることもできます。
+		| オプションを指定するには、「オプション名:値」という形式でクエリに含めます。
+	p 利用可能なオプション一覧です:
+
+	table
+		thead
+			tr
+				th 名前
+				th 説明
+		tbody
+			tr
+				td user
+				td ユーザー名。投稿者を限定します。
+			tr
+				td reply
+				td 返信を含めるか否か。(trueかfalse)
+			tr
+				td media
+				td メディアが添付されているか。(trueかfalse)
+			tr
+				td until
+				td 上限の日時。(YYYY-MM-DD)
+			tr
+				td since
+				td 下限の日時。(YYYY-MM-DD)
+
+	p 例えば、「@syuiloの2017年11月1日から2017年12月31日までの『Misskey』というテキストを含む返信ではない投稿」を検索したい場合、クエリは以下のようになります:
+	code user:syuilo since:2017-11-01 until:2017-12-31 reply:false Misskey