From 0f214b50785fe6285196ab0ded4731321bcd3de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bar=C4=B1=C5=9F=20Soner=20U=C5=9Fakl=C4=B1?= Date: Sat, 21 Jan 2023 02:08:04 -0500 Subject: [PATCH] feat: new search WIP --- public/language/en-GB/search.json | 64 ++++++++- public/src/client/search.js | 207 ++++++++++++++++++++++++------ src/controllers/search.js | 44 ++++++- 3 files changed, 267 insertions(+), 48 deletions(-) diff --git a/public/language/en-GB/search.json b/public/language/en-GB/search.json index ab710be4d1..7a92a965fb 100644 --- a/public/language/en-GB/search.json +++ b/public/language/en-GB/search.json @@ -1,21 +1,39 @@ { + "type-to-search": "Type to search", "results_matching": "%1 result(s) matching \"%2\", (%3 seconds)", "no-matches": "No matches found", "advanced-search": "Advanced Search", "in": "In", - "titles": "Titles", - "titles-posts": "Titles and Posts", + "in-titles": "In titles", + "in-titles-posts": "In titles and posts", + "in-posts": "In post", + "in-categories": "In categories", + "in-users": "In users", + "in-tags": "In tags", + "categories": "Categories", + "categories-x": "Categories: %!", + "type-a-category": "Type a category", + "tags": "Tags", + "tags-x": "Tags: %1", + "type-a-tag": "Type a tag", "match-words": "Match words", + "match-all-words": "Match all words", + "match-any-word": "Match any word", "all": "All", "any": "Any", "posted-by": "Posted by", - "in-categories": "In Categories", + "posted-by-usernames": "Posted by: %1", + "type-a-username": "Type a username", "search-child-categories": "Search child categories", "has-tags": "Has tags", "reply-count": "Reply Count", + "replies": "Replies", + "replies-atleast-count": "Replies: At least %1", + "replies-atmost-count": "Replies: At most %1", "at-least": "At least", "at-most": "At most", "relevance": "Relevance", + "time": "Time", "post-time": "Post time", "votes": "Votes", "newer-than": "Newer than", @@ -28,7 +46,22 @@ "three-months": "Three months", "six-months": "Six months", "one-year": "One year", + "time-newer-than-86400": "Time: Newer than yesterday", + "time-older-than-86400": "Time: Older than yesterday", + "time-newer-than-604800": "Time: Newer than one week", + "time-older-than-604800": "Time: Older than one week", + "time-newer-than-1209600": "Time: Newer than two weeks", + "time-older-than-1209600": "Time: Older than two weeks", + "time-newer-than-2592000": "Time: Newer than one month", + "time-older-than-2592000": "Time: Older than one month", + "time-newer-than-7776000": "Time: Newer than three months", + "time-older-than-7776000": "Time: Older than three months", + "time-newer-than-15552000": "Time: Newer than six months", + "time-older-than-15552000": "Time: Older than six months", + "time-newer-than-31104000": "Time: Newer than one year", + "time-older-than-31104000": "Time: Older than one year", "sort-by": "Sort by", + "sort": "Sort", "last-reply-time": "Last reply time", "topic-title": "Topic title", "topic-votes": "Topic votes", @@ -39,11 +72,36 @@ "category": "Category", "descending": "In descending order", "ascending": "In ascending order", + "sort-by-relevance-desc": "Sort by: Relevance in descending order", + "sort-by-relevance-asc": "Sort by: Relevance in ascending order ", + "sort-by-timestamp-desc": "Sort by: Post time in descending order", + "sort-by-timestamp-asc": "Sort by: Post time in ascending order ", + "sort-by-votes-desc": "Sort by: Votes in descending order", + "sort-by-votes-asc": "Sort by: Votes in ascending order ", + "sort-by-topic.lastposttime-desc": "Sort by: Last reply time in descending order", + "sort-by-topic.lastposttime-asc": "Sort by: Last reply time in ascending order ", + "sort-by-topic.title-desc": "Sort by: Topic title in descending order", + "sort-by-topic.title-asc": "Sort by: Topic title in ascending order ", + "sort-by-topic.postcount-desc": "Sort by: Number of replies in descending order", + "sort-by-topic.postcount-asc": "Sort by: Number of replies in ascending order ", + "sort-by-topic.viewcount-desc": "Sort by: Number of views in descending order", + "sort-by-topic.viewcount-asc": "Sort by: Number of views in ascending order ", + "sort-by-topic.votes-desc": "Sort by: Topic votes in descending order", + "sort-by-topic.votes-asc": "Sort by: Topic votes in ascending order ", + "sort-by-topic.timestamp-desc": "Sort by: Topic start date in descending order", + "sort-by-topic.timestamp-asc": "Sort by: Topic start date in ascending order ", + "sort-by-user.username-desc": "Sort by: Username in descending order", + "sort-by-user.username-asc": "Sort by: Username in ascending order ", + "sort-by-category.name-desc": "Sort by: Category in descending order", + "sort-by-category.name-asc": "Sort by: Category in ascending order ", + "save": "Save", "save-preferences": "Save preferences", "clear-preferences": "Clear preferences", "search-preferences-saved": "Search preferences saved", "search-preferences-cleared": "Search preferences cleared", "show-results-as": "Show results as", + "show-results-as-topics": "Show results as topics", + "show-results-as-posts": "Show results as posts", "see-more-results": "See more results (%1)", "search-in-category": "Search in \"%1\"" } diff --git a/public/src/client/search.js b/public/src/client/search.js index ed4cba6dac..0887e09efe 100644 --- a/public/src/client/search.js +++ b/public/src/client/search.js @@ -3,25 +3,29 @@ define('forum/search', [ 'search', - 'autocomplete', 'storage', 'hooks', 'alerts', -], function (searchModule, autocomplete, storage, hooks, alerts) { + 'api', + 'translator', + 'slugify', +], function (searchModule, storage, hooks, alerts, api, translator, slugify) { const Search = {}; + let selectedUsers = []; Search.init = function () { - const searchQuery = $('#results').attr('data-search-query'); - const searchIn = $('#search-in'); - searchIn.on('change', function () { updateFormItemVisiblity(searchIn.val()); }); - searchModule.highlightMatches(searchQuery, $('.search-result-text p, .search-result-text.search-result-title a')); + const searchQuery = $('#results').attr('data-search-query'); + searchModule.highlightMatches( + searchQuery, + $('.search-results .content p, .search-results .topic-title') + ); - $('#advanced-search').off('submit').on('submit', function (e) { + $('#advanced-search form').off('submit').on('submit', function (e) { e.preventDefault(); searchModule.query(getSearchDataFromDOM(), function () { $('#search-input').val(''); @@ -33,9 +37,70 @@ define('forum/search', [ enableAutoComplete(); + userFilterDropdown($('[component="user/filter"]'), ajaxify.data.userFilterSelected); + + $('[component="search/filters"]').on('hidden.bs.dropdown', '.dropdown', function () { + const updateFns = { + replies: updateReplyCountFilter, + time: updateTimeFilter, + sort: updateSortFilter, + user: updateUserFilter, + }; + + if (updateFns[$(this).attr('data-filter-name')]) { + updateFns[$(this).attr('data-filter-name')](); + } + }); + fillOutForm(); }; + function updateUserFilter() { + const isActive = selectedUsers.length > 0; + let labelText = '[[search:posted-by]]'; + if (selectedUsers.length) { + labelText = translator.compile( + 'search:posted-by-usernames', selectedUsers.map(u => u.username).join(', ') + ); + } + $('[component="user/filter/button"]').toggleClass( + 'active-filter', isActive + ).find('.filter-label').translateText(labelText); + } + + function updateTimeFilter() { + const isActive = $('#post-time-range').val() > 0; + $('#post-time-button').toggleClass( + 'active-filter', isActive + ).find('.filter-label').translateText( + isActive ? + `[[search:time-${$('#post-time-filter').val()}-than-${$('#post-time-range').val()}]]` : + `[[search:time]]` + ); + } + + function updateSortFilter() { + const isActive = $('#post-sort-by').val() !== 'relevance' || $('#post-sort-direction').val() !== 'desc'; + $('#sort-by-button').toggleClass( + 'active-filter', isActive + ).find('.filter-label').translateText( + isActive ? + `[[search:sort-by-${$('#post-sort-by').val()}-${$('#post-sort-direction').val()}]]` : + `[[search:sort]]` + ); + } + + function updateReplyCountFilter() { + const isActive = $('#reply-count').val() > 0; + $('#reply-count-button').toggleClass( + 'active-filter', isActive + ).find('.filter-label').translateText( + isActive ? + `[[search:replies-${$('#reply-count-filter').val()}-count, ${$('#reply-count').val()}]]` : + `[[search:replies]]` + ); + } + function getSearchDataFromDOM() { const form = $('#advanced-search'); const searchData = { @@ -44,7 +109,7 @@ define('forum/search', [ searchData.term = $('#search-input').val(); if (searchData.in === 'posts' || searchData.in === 'titlesposts' || searchData.in === 'titles') { searchData.matchWords = form.find('#match-words-filter').val(); - searchData.by = form.find('#posted-by-user').tagsinput('items'); + searchData.by = selectedUsers.length ? selectedUsers.map(u => u.username) : undefined; searchData.categories = form.find('#posted-in-categories').val(); searchData.searchChildren = form.find('#search-children').is(':checked'); searchData.hasTags = form.find('#has-tags').tagsinput('items'); @@ -54,7 +119,7 @@ define('forum/search', [ searchData.timeRange = form.find('#post-time-range').val(); searchData.sortBy = form.find('#post-sort-by').val(); searchData.sortDirection = form.find('#post-sort-direction').val(); - searchData.showAs = form.find('#show-as-topics').is(':checked') ? 'topics' : 'posts'; + searchData.showAs = form.find('#show-results-as').val(); } hooks.fire('action:search.getSearchDataFromDOM', { @@ -66,8 +131,8 @@ define('forum/search', [ } function updateFormItemVisiblity(searchIn) { - const hide = searchIn.indexOf('posts') === -1 && searchIn.indexOf('titles') === -1; - $('.post-search-item').toggleClass('hide', hide); + const hideTitlePostFilters = !searchIn.includes('posts') && !searchIn.includes('titles'); + $('.post-search-item').toggleClass('hidden', hideTitlePostFilters); } function fillOutForm() { @@ -90,6 +155,10 @@ define('forum/search', [ $('#match-words-filter').val(formData.matchWords); } + if (formData.showAs) { + $('#show-results-as').val(formData.showAs); + } + if (formData.by) { formData.by = Array.isArray(formData.by) ? formData.by : [formData.by]; formData.by.forEach(function (by) { @@ -127,13 +196,6 @@ define('forum/search', [ } $('#post-sort-direction').val(formData.sortDirection || 'desc'); - if (formData.showAs) { - const isTopic = formData.showAs === 'topics'; - const isPost = formData.showAs === 'posts'; - $('#show-as-topics').prop('checked', isTopic).parent().toggleClass('active', isTopic); - $('#show-as-posts').prop('checked', isPost).parent().toggleClass('active', isPost); - } - hooks.fire('action:search.fillOutForm', { form: formData, }); @@ -147,36 +209,103 @@ define('forum/search', [ return false; }); - $('#clear-preferences').on('click', function () { + $('#clear-preferences').on('click', async function () { storage.removeItem('search-preferences'); - const query = $('#search-input').val(); - $('#advanced-search')[0].reset(); - $('#search-input').val(query); + const html = await app.parseAndTranslate('partials/search-filters', {}); + $('[component="search/filters"]').replaceWith(html); + // clearing dom removes all event handlers, reinitialize + userFilterDropdown($('[component="user/filter"]'), []); alerts.success('[[search:search-preferences-cleared]]'); return false; }); } - function enableAutoComplete() { - const userEl = $('#posted-by-user'); - userEl.tagsinput({ - tagClass: 'badge bg-info', - confirmKeys: [13, 44], - trimValue: true, - }); - if (app.user.privileges['search:users']) { - autocomplete.user(userEl.siblings('.bootstrap-tagsinput').find('input')); + function userFilterDropdown(el, _selectedUsers) { + selectedUsers = _selectedUsers; + async function renderSelectedUsers() { + const html = await app.parseAndTranslate('partials/search-filters', 'userFilterSelected', { + userFilterSelected: selectedUsers, + }); + el.find('[component="user/filter/selected"]').html(html); } - const tagEl = $('#has-tags'); - tagEl.tagsinput({ - tagClass: 'badge bg-info', - confirmKeys: [13, 44], - trimValue: true, - }); - if (app.user.privileges['search:tags']) { - autocomplete.tag(tagEl.siblings('.bootstrap-tagsinput').find('input')); + async function doSearch() { + let result = { users: [] }; + const query = el.find('[component="user/filter/search"]').val(); + if (query && query.length > 1) { + if (app.user.privileges['search:users']) { + result = await api.get('/api/users', { query: query }); + } else { + try { + const userData = await api.get(`/api/user/${slugify(query)}`); + result.users.push(userData); + } catch (err) {} + } + } + if (!result.users.length) { + el.find('[component="user/filter/results"]').translateHtml( + '[[users:no-users-found]]' + ); + return; + } + result.users = result.users.slice(0, 20); + const html = await app.parseAndTranslate('partials/search-filters', 'userFilterResults', { + userFilterResults: result.users, + }); + const uidToUser = {}; + result.users.forEach((user) => { + uidToUser[user.uid] = user; + }); + el.find('[component="user/filter/results"]').html(html); + el.find('[component="user/filter/results"] [data-uid]').on('click', async function () { + selectedUsers.push(uidToUser[$(this).attr('data-uid')]); + renderSelectedUsers(); + }); } + + el.find('[component="user/filter/search"]').on('keyup', utils.debounce(function () { + if (app.user.privileges['search:users']) { + doSearch(); + } + }, 1000)); + + el.on('click', '[component="user/filter/delete"]', function () { + const uid = $(this).attr('data-uid'); + selectedUsers = selectedUsers.filter(u => parseInt(u.uid, 10) !== parseInt(uid, 10)); + renderSelectedUsers(); + }); + + el.find('[component="user/filter/search"]').on('keyup', (e) => { + if (e.key === 'Enter' && !app.user.privileges['search:users']) { + doSearch(); + } + }); + + el.on('shown.bs.dropdown', function () { + el.find('[component="user/filter/search"]').trigger('focus'); + }); + } + + function enableAutoComplete() { + // const userEl = $('#posted-by-user'); + // userEl.tagsinput({ + // tagClass: 'badge bg-info', + // confirmKeys: [13, 44], + // trimValue: true, + // }); + // if (app.user.privileges['search:users']) { + // autocomplete.user(userEl.siblings('.bootstrap-tagsinput').find('input')); + // } + + // const tagEl = $('#has-tags'); + // tagEl.tagsinput({ + // tagClass: 'badge bg-info', + // confirmKeys: [13, 44], + // trimValue: true, + // }); + // if (app.user.privileges['search:tags']) { + // autocomplete.tag(tagEl.siblings('.bootstrap-tagsinput').find('input')); + // } } return Search; diff --git a/src/controllers/search.js b/src/controllers/search.js index 75e0fc60a1..b6bc924aac 100644 --- a/src/controllers/search.js +++ b/src/controllers/search.js @@ -8,8 +8,10 @@ const meta = require('../meta'); const plugins = require('../plugins'); const search = require('../search'); const categories = require('../categories'); +const user = require('../user'); const pagination = require('../pagination'); const privileges = require('../privileges'); +const translator = require('../translator'); const utils = require('../utils'); const helpers = require('./helpers'); @@ -57,12 +59,12 @@ searchController.search = async function (req, res, next) { categories: req.query.categories, searchChildren: req.query.searchChildren, hasTags: req.query.hasTags, - replies: req.query.replies, - repliesFilter: req.query.repliesFilter, - timeRange: req.query.timeRange, - timeFilter: req.query.timeFilter, - sortBy: req.query.sortBy || meta.config.searchDefaultSortBy || '', - sortDirection: req.query.sortDirection, + replies: validator.escape(String(req.query.replies || '')), + repliesFilter: validator.escape(String(req.query.repliesFilter || '')), + timeRange: validator.escape(String(req.query.timeRange || '')), + timeFilter: validator.escape(String(req.query.timeFilter || '')), + sortBy: validator.escape(String(req.query.sortBy || '')) || meta.config.searchDefaultSortBy || '', + sortDirection: validator.escape(String(req.query.sortDirection || '')), page: page, itemsPerPage: req.query.itemsPerPage, uid: req.uid, @@ -94,6 +96,28 @@ searchController.search = async function (req, res, next) { searchData.showAsTopics = req.query.showAs === 'topics'; searchData.title = '[[global:header.search]]'; + searchData.filters = { + replies: { + active: !!data.repliesFilter, + label: `[[search:replies-${data.repliesFilter}-count, ${data.replies}]]`, + }, + time: { + active: !!(data.timeFilter && data.timeRange), + label: `[[search:time-${data.timeFilter}-than-${data.timeRange}]]`, + }, + sort: { + active: !!(data.sortBy && data.sortBy !== 'relevance'), + label: `[[search:sort-by-${data.sortBy}-${data.sortDirection}]]`, + }, + users: { + active: !!(data.postedBy), + label: translator.compile( + 'search:posted-by-usernames', + (Array.isArray(data.postedBy) ? data.postedBy : []).map(u => validator.escape(String(u))).join(', ') + ), + }, + }; + searchData.userFilterSelected = await getSelectedUsers(data.postedBy); searchData.searchDefaultSortBy = meta.config.searchDefaultSortBy || ''; searchData.searchDefaultIn = meta.config.searchDefaultIn || 'titlesposts'; searchData.privileges = userPrivileges; @@ -127,6 +151,14 @@ async function recordSearch(data) { } } +async function getSelectedUsers(postedBy) { + if (!Array.isArray(postedBy)) { + return []; + } + const uids = await user.getUidsByUsernames(postedBy); + return await user.getUsersFields(uids, ['username', 'userslug', 'picture']); +} + async function buildCategories(uid, searchOnly) { if (searchOnly) { return [];