diff --git a/frontend/src/components/search/ai-search.js b/frontend/src/components/search/ai-search.js index 10f05d46c5..0e30b4e022 100644 --- a/frontend/src/components/search/ai-search.js +++ b/frontend/src/components/search/ai-search.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import isHotkey from 'is-hotkey'; import MediaQuery from 'react-responsive'; import { seafileAPI } from '../../utils/seafile-api'; +import searchAPI from '../../utils/search-api'; import Icon from '../icon'; import { gettext, siteRoot, username } from '../../utils/constants'; import SearchResultItem from './search-result-item'; @@ -274,6 +275,7 @@ export default class AISearch extends Component { } this.setState({ value: newValue }); setTimeout(() => { + const trimmedValue = newValue.trim(); if (this.isChineseInput === false && this.state.inputValue !== newValue) { this.setState({ inputValue: newValue, @@ -281,11 +283,38 @@ export default class AISearch extends Component { highlightIndex: 0, resultItems: [], isResultGetted: false, + }, () => { + if (trimmedValue !== '') { + this.getRepoSearchResult(newValue); + } }); } }, 1); }; + getRepoSearchResult = (query_str) => { + if (this.source) { + this.source.cancel('prev request is cancelled'); + } + + this.source = seafileAPI.getSource(); + + const query_type = 'library'; + let results = []; + searchAPI.searchItems(query_str, query_type, this.source.token).then(res => { + results = [...results, ...this.formatResultItems(res.data.results)]; + this.setState({ + resultItems: results, + isLoading: false, + hasMore: false, + }); + }).catch(error => { + // eslint-disable-next-line no-console + console.log(error); + this.setState({ isLoading: false }); + }); + }; + getSearchResult = (queryData) => { if (this.source) { this.source.cancel('prev request is cancelled'); @@ -417,14 +446,41 @@ export default class AISearch extends Component { renderSearchTypes = (inputValue) => { const highlightIndex = this.state.highlightSearchTypesIndex; + const { resultItems } = this.state; if (!this.props.repoID) { return ( -
-
- - {inputValue} - {gettext('in all libraries')} +
+
+
+ + {inputValue} + {gettext('in all libraries')} +
+ {resultItems.length > 0 && ( +
+
+
{gettext('Libraries')}
+
    + {resultItems.map((item, index) => { + return ( + + ); + })} +
+
+ )}
); } diff --git a/frontend/src/components/search/search.js b/frontend/src/components/search/search.js index 46ddc3a942..106767588a 100644 --- a/frontend/src/components/search/search.js +++ b/frontend/src/components/search/search.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import isHotkey from 'is-hotkey'; import MediaQuery from 'react-responsive'; import { seafileAPI } from '../../utils/seafile-api'; +import searchAPI from '../../utils/search-api'; import { gettext, siteRoot } from '../../utils/constants'; import SearchResultItem from './search-result-item'; import { Utils } from '../../utils/utils'; @@ -236,6 +237,7 @@ class Search extends Component { } this.setState({ value: newValue }); setTimeout(() => { + const trimmedValue = newValue.trim(); if (this.isChineseInput === false && this.state.inputValue !== newValue) { this.setState({ inputValue: newValue, @@ -243,11 +245,38 @@ class Search extends Component { highlightIndex: 0, resultItems: [], isResultGetted: false, + }, () => { + if (trimmedValue !== '') { + this.getRepoSearchResult(newValue); + } }); } }, 1); }; + getRepoSearchResult = (query_str) => { + if (this.source) { + this.source.cancel('prev request is cancelled'); + } + + this.source = seafileAPI.getSource(); + + const query_type = 'library'; + let results = []; + searchAPI.searchItems(query_str, query_type, this.source.token).then(res => { + results = [...results, ...this.formatResultItems(res.data.results)]; + this.setState({ + resultItems: results, + isLoading: false, + hasMore: false, + }); + }).catch(error => { + // eslint-disable-next-line no-console + console.log(error); + this.setState({ isLoading: false }); + }); + }; + getSearchResult = (queryData) => { if (this.source) { this.source.cancel('prev request is cancelled'); @@ -416,14 +445,41 @@ class Search extends Component { renderSearchTypes = (inputValue) => { const highlightIndex = this.state.highlightSearchTypesIndex; + const { resultItems } = this.state; if (!this.props.repoID) { return ( -
-
- - {inputValue} - {gettext('in all libraries')} +
+
+
+ + {inputValue} + {gettext('in all libraries')} +
+ {resultItems.length > 0 && ( +
+
+
{gettext('Libraries')}
+
    + {resultItems.map((item, index) => { + return ( + + ); + })} +
+
+ )}
); } diff --git a/frontend/src/css/search.css b/frontend/src/css/search.css index 0e45b07f6c..dd24f6f5f2 100644 --- a/frontend/src/css/search.css +++ b/frontend/src/css/search.css @@ -410,3 +410,17 @@ margin-left: 6px; color: #666; } + +.library-result-container { + margin-bottom: 16px; +} + +.library-result-divider { + margin: 10px 0 16px; +} + +.library-result-header { + color: #666666; + font-size: 14px; + margin-bottom: 10px; +} diff --git a/frontend/src/utils/search-api.js b/frontend/src/utils/search-api.js new file mode 100644 index 0000000000..fd7e5e06ad --- /dev/null +++ b/frontend/src/utils/search-api.js @@ -0,0 +1,58 @@ +import cookie from 'react-cookies'; +import axios from 'axios'; +import { siteRoot } from './constants'; + +class SearchAPI { + + init({ server, username, password, token }) { + this.server = server; + this.username = username; + this.password = password; + this.token = token; + if (this.token && this.server) { + this.req = axios.create({ + baseURL: this.server, + headers: { 'Authorization': 'Token ' + this.token }, + }); + } + return this; + } + + initForSeahubUsage({ siteRoot, xcsrfHeaders }) { + if (siteRoot && siteRoot.charAt(siteRoot.length-1) === '/') { + var server = siteRoot.substring(0, siteRoot.length-1); + this.server = server; + } else { + this.server = siteRoot; + } + + this.req = axios.create({ + headers: { + 'X-CSRFToken': xcsrfHeaders, + } + }); + return this; + } + + _sendPostRequest(url, form) { + if (form.getHeaders) { + return this.req.post(url, form, { + headers:form.getHeaders() + }); + } else { + return this.req.post(url, form); + } + } + + searchItems(query_str, query_type, cancelToken) { + let url = this.server + '/api2/items-search/?query_str=' + query_str + '&query_type=' + query_type; + return this.req.get(url, {cancelToken: cancelToken}); + } + +} + +let searchAPI = new SearchAPI(); +let xcsrfHeaders = cookie.load('sfcsrftoken'); +searchAPI.initForSeahubUsage({ siteRoot, xcsrfHeaders }); + +export default searchAPI; diff --git a/seahub/api2/urls.py b/seahub/api2/urls.py index 82856bdfb0..b8b07f9d06 100644 --- a/seahub/api2/urls.py +++ b/seahub/api2/urls.py @@ -34,6 +34,7 @@ urlpatterns = [ path('account/info/', AccountInfo.as_view()), path('regdevice/', RegDevice.as_view(), name="regdevice"), path('search/', Search.as_view(), name='api_search'), + path('items-search/', ItemsSearch.as_view(), name='api-items-search'), path('search-user/', SearchUser.as_view(), name='search-user'), path('repos/', Repos.as_view(), name="api2-repos"), path('repos/public/', PubRepos.as_view(), name="api2-pub-repos"), diff --git a/seahub/api2/utils.py b/seahub/api2/utils.py index c69d33d4ca..5cd255bf45 100644 --- a/seahub/api2/utils.py +++ b/seahub/api2/utils.py @@ -27,6 +27,7 @@ from seahub.group.utils import is_group_member from seahub.api2.models import Token, TokenV2, DESKTOP_PLATFORMS from seahub.avatar.settings import AVATAR_DEFAULT_SIZE from seahub.avatar.templatetags.avatar_tags import api_avatar_url +from seahub.utils import get_user_repos logger = logging.getLogger(__name__) @@ -282,4 +283,21 @@ def is_web_request(request): def is_wiki_repo(repo): return repo.repo_type == REPO_TYPE_WIKI - + +def get_search_repos(username, org_id): + repos = [] + owned_repos, shared_repos, group_repos, public_repos = get_user_repos(username, org_id=org_id) + repo_list = owned_repos + public_repos + shared_repos + group_repos + + repo_id_set = set() + for repo in repo_list: + repo_id = repo.id + if repo.origin_repo_id: + repo_id = repo.origin_repo_id + + if repo_id in repo_id_set: + continue + repo_id_set.add(repo_id) + repos.append((repo.id, repo.origin_repo_id, repo.origin_path, repo.name)) + + return repos diff --git a/seahub/api2/views.py b/seahub/api2/views.py index 9448496b90..afbe8e8b32 100644 --- a/seahub/api2/views.py +++ b/seahub/api2/views.py @@ -19,6 +19,7 @@ from rest_framework.permissions import IsAuthenticated, IsAdminUser from rest_framework.reverse import reverse from rest_framework.response import Response +from django.core.cache import cache from django.conf import settings as dj_settings from django.contrib.auth.hashers import check_password from django.contrib.sites.shortcuts import get_current_site @@ -39,6 +40,7 @@ from seahub.wopi.utils import get_wopi_dict from seahub.api2.base import APIView from seahub.api2.models import TokenV2, DESKTOP_PLATFORMS from seahub.api2.endpoints.group_owned_libraries import get_group_id_by_repo_owner +from seahub.api2.utils import get_search_repos from seahub.avatar.templatetags.avatar_tags import api_avatar_url, avatar from seahub.avatar.templatetags.group_avatar_tags import api_grp_avatar_url, \ grp_avatar @@ -71,7 +73,8 @@ from seahub.utils import gen_file_get_url, gen_token, gen_file_upload_url, \ gen_file_share_link, gen_dir_share_link, is_org_context, gen_shared_link, \ calculate_repos_last_modify, send_perm_audit_msg, \ gen_shared_upload_link, convert_cmmt_desc_link, is_valid_dirent_name, \ - normalize_file_path, get_no_duplicate_obj_name, normalize_dir_path + normalize_file_path, get_no_duplicate_obj_name, normalize_dir_path, \ + normalize_cache_key from seahub.tags.models import FileUUIDMap from seahub.seadoc.models import SeadocHistoryName, SeadocDraft, SeadocCommentReply @@ -641,6 +644,58 @@ class Search(APIView): has_more = True if total > current_page * per_page else False return Response({"total":total, "results":results, "has_more":has_more}) + +class ItemsSearch(APIView): + authentication_classes = (TokenAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated, ) + throttle_classes = (UserRateThrottle, ) + + USER_REPOS_CACHE_PREFIX = 'user_repos_' + USER_REPOS_CACHE_TIMEOUT = 2 * 60 * 60 + + def get(self, request): + """search items""" + QUERY_TYPES = [ + 'library', + ] + + query_str = request.GET.get('query_str', '') + query_type = request.GET.get('query_type', '') + + if not query_str: + error_msg = 'query invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + if query_type not in QUERY_TYPES: + error_msg = 'query type invalid.' + return api_error(status.HTTP_400_BAD_REQUEST, error_msg) + + username = request.user.username + org_id = request.user.org.org_id if is_org_context(request) else None + + if query_type == 'library': + cache_key = normalize_cache_key(username, self.USER_REPOS_CACHE_PREFIX) + all_repos = cache.get(cache_key) + if not all_repos: + all_repos = get_search_repos(username, org_id) + cache.set(cache_key, all_repos, self.USER_REPOS_CACHE_TIMEOUT) + + query_result = [] + # Iterator avoids loading all memory at once + query_result = [ + { + "fullpath": "/", + "is_dir": True, + "repo_name": repo_info[3], + "repo_id": repo_info[0], + "name": repo_info[3] + } + for repo_info in all_repos + if query_str in repo_info[3] + ] + return Response({'results': query_result}) + + ########## Repo related def repo_download_info(request, repo_id, gen_sync_token=True): repo = get_repo(repo_id)