diff --git a/public/language/en-GB/search.json b/public/language/en-GB/search.json index 0a9201e00e..b404ae1fc8 100644 --- a/public/language/en-GB/search.json +++ b/public/language/en-GB/search.json @@ -7,6 +7,7 @@ "in-titles": "In titles", "in-titles-posts": "In titles and posts", "in-posts": "In posts", + "in-bookmarks": "In bookmarks", "in-categories": "In categories", "in-users": "In users", "in-tags": "In tags", diff --git a/public/src/client/search.js b/public/src/client/search.js index 4ca01b80f9..5bf52a1ef9 100644 --- a/public/src/client/search.js +++ b/public/src/client/search.js @@ -119,7 +119,7 @@ define('forum/search', [ in: $('#search-in').val(), }; searchData.term = $('#search-input').val(); - if (searchData.in === 'posts' || searchData.in === 'titlesposts' || searchData.in === 'titles') { + if (['posts', 'titlesposts', 'titles', 'bookmarks'].includes(searchData.in)) { searchData.matchWords = form.find('#match-words-filter').val(); searchData.by = selectedUsers.length ? selectedUsers.map(u => u.username) : undefined; searchData.categories = selectedCids.length ? selectedCids : undefined; @@ -143,7 +143,7 @@ define('forum/search', [ } function updateFormItemVisiblity(searchIn) { - const hideTitlePostFilters = !searchIn.includes('posts') && !searchIn.includes('titles'); + const hideTitlePostFilters = !['posts', 'titles', 'bookmarks'].some(token => searchIn.includes(token)); $('.post-search-item').toggleClass('hidden', hideTitlePostFilters); } @@ -252,6 +252,7 @@ define('forum/search', [ function categoryFilterDropdown(_selectedCids) { ajaxify.data.allCategoriesUrl = ''; + selectedCids = _selectedCids || []; const dropdownEl = $('[component="category/filter"]'); categoryFilter.init(dropdownEl, { selectedCids: _selectedCids, @@ -290,6 +291,7 @@ define('forum/search', [ } function userFilterDropdown(el, _selectedUsers) { + selectedUsers = _selectedUsers || []; userFilter.init(el, { selectedUsers: _selectedUsers, template: 'partials/search-filters', diff --git a/src/controllers/search.js b/src/controllers/search.js index a12c433edd..8b21189e7d 100644 --- a/src/controllers/search.js +++ b/src/controllers/search.js @@ -36,7 +36,7 @@ searchController.search = async function (req, res, next) { let allowed = (req.query.in === 'users' && userPrivileges['search:users']) || (req.query.in === 'tags' && userPrivileges['search:tags']) || (req.query.in === 'categories') || - (['titles', 'titlesposts', 'posts'].includes(req.query.in) && userPrivileges['search:content']); + (['titles', 'titlesposts', 'posts', 'bookmarks'].includes(req.query.in) && userPrivileges['search:content']); ({ allowed } = await plugins.hooks.fire('filter:search.isAllowed', { uid: req.uid, query: req.query, diff --git a/src/posts/user.js b/src/posts/user.js index 4d9ab4d21e..850ed4c613 100644 --- a/src/posts/user.js +++ b/src/posts/user.js @@ -261,4 +261,21 @@ module.exports = function (Posts) { } }); } + + Posts.filterPidsByUid = async function (pids, uids) { + if (!uids) { + return pids; + } + + if (!Array.isArray(uids) || uids.length === 1) { + return await filterPidsBySingleUid(pids, uids); + } + const pidsArr = await Promise.all(uids.map(uid => Posts.filterPidsByUid(pids, uid))); + return _.union(...pidsArr); + }; + + async function filterPidsBySingleUid(pids, uid) { + const isMembers = await db.isSortedSetMembers(`uid:${parseInt(uid, 10)}:posts`, pids); + return pids.filter((pid, index) => pid && isMembers[index]); + } }; diff --git a/src/search.js b/src/search.js index cbd4e4f40c..1ab7063398 100644 --- a/src/search.js +++ b/src/search.js @@ -3,6 +3,7 @@ const _ = require('lodash'); const db = require('./database'); +const batch = require('./batch'); const posts = require('./posts'); const topics = require('./topics'); const categories = require('./categories'); @@ -18,7 +19,7 @@ search.search = async function (data) { data.sortBy = data.sortBy || 'relevance'; let result; - if (data.searchIn === 'posts' || data.searchIn === 'titles' || data.searchIn === 'titlesposts') { + if (['posts', 'titles', 'titlesposts', 'bookmarks'].includes(data.searchIn)) { result = await searchInContent(data); } else if (data.searchIn === 'users') { result = await user.search(data); @@ -68,6 +69,8 @@ async function searchInContent(data) { const tid = inTopic[1]; const cleanedTerm = data.query.replace(inTopic[0], ''); pids = await topics.search(tid, cleanedTerm); + } else if (data.searchIn === 'bookmarks') { + pids = await searchInBookmarks(data, searchCids, searchUids); } else { [pids, tids] = await Promise.all([ doSearch('post', ['posts', 'titlesposts']), @@ -115,8 +118,42 @@ async function searchInContent(data) { return Object.assign(returnData, metadata); } +async function searchInBookmarks(data, searchCids, searchUids) { + const { uid, query, matchWords } = data; + const allPids = []; + await batch.processSortedSet(`uid:${uid}:bookmarks`, async (pids) => { + if (Array.isArray(searchCids) && searchCids.length) { + pids = await posts.filterPidsByCid(pids, searchCids); + } + if (Array.isArray(searchUids) && searchUids.length) { + pids = await posts.filterPidsByUid(pids, searchUids); + } + if (query) { + const tokens = String(query).split(' '); + const postData = await db.getObjectsFields(pids.map(pid => `post:${pid}`), ['content']); + pids = pids.filter((pid, i) => { + const content = String(postData[i].content); + if (matchWords === 'any') { + return tokens.some(t => content.includes(t)); + } + return tokens.every(t => content.includes(t)); + }); + } + allPids.push(...pids); + }, { + batch: 500, + }); + + return allPids; +} + async function filterAndSort(pids, data) { - if (data.sortBy === 'relevance' && !data.replies && !data.timeRange && !data.hasTags && !plugins.hooks.hasListeners('filter:search.filterAndSort')) { + if (data.sortBy === 'relevance' && + !data.replies && + !data.timeRange && + !data.hasTags && + data.searchIn !== 'bookmarks' && + !plugins.hooks.hasListeners('filter:search.filterAndSort')) { return pids; } let postsData = await getMatchedPosts(pids, data);