// (C) Copyright 2015 Moodle Pty Ltd. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Injectable } from '@angular/core'; import { makeSingleton } from '@singletons'; import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; import { CoreWSExternalWarning } from '@services/ws'; import { CoreCourseListItem, CoreCourses } from '@features/courses/services/courses'; import { CoreUserWithAvatar } from '@components/user-avatar/user-avatar'; import { CoreUser } from '@features/user/services/user'; import { CoreSite } from '@classes/site'; declare module '@singletons/events' { /** * Augment CoreEventsData interface with events specific to this service. * * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation */ export interface CoreEventsData { [CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED]: CoreSearchGlobalSearchFilters; } } export const CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH = 10; export const CORE_SEARCH_GLOBAL_SEARCH_FILTERS_UPDATED = 'core-search-global-search-filters-updated'; export type CoreSearchGlobalSearchResult = { id: number; title: string; url: string; content?: string; context?: CoreSearchGlobalSearchResultContext; module?: CoreSearchGlobalSearchResultModule; component?: CoreSearchGlobalSearchResultComponent; course?: CoreCourseListItem; user?: CoreUserWithAvatar; }; export type CoreSearchGlobalSearchResultContext = { userName?: string; courseName?: string; }; export type CoreSearchGlobalSearchResultModule = { name: string; iconurl: string; area: string; }; export type CoreSearchGlobalSearchResultComponent = { name: string; iconurl: string; }; export type CoreSearchGlobalSearchSearchAreaCategory = { id: string; name: string; }; export type CoreSearchGlobalSearchSearchArea = { id: string; name: string; category: CoreSearchGlobalSearchSearchAreaCategory; }; export interface CoreSearchGlobalSearchFilters { searchAreaCategoryIds?: string[]; courseIds?: number[]; } /** * Service to perform global searches. */ @Injectable({ providedIn: 'root' }) export class CoreSearchGlobalSearchService { private static readonly SEARCH_AREAS_CACHE_KEY = 'CoreSearchGlobalSearch:SearchAreas'; /** * Check whether global search is enabled or not. * * @returns Whether global search is enabled or not. */ async isEnabled(siteId?: string): Promise { const site = siteId ? await CoreSites.getSite(siteId) : CoreSites.getRequiredCurrentSite(); return !site?.isFeatureDisabled('CoreNoDelegate_GlobalSearch') && site?.wsAvailable('core_search_get_results') // @since 4.3 && site?.canUseAdvancedFeature('enableglobalsearch'); } /** * Get results. * * @param query Search query. * @param filters Search filters. * @param page Page. * @returns Search results. */ async getResults( query: string, filters: CoreSearchGlobalSearchFilters, page: number, ): Promise<{ results: CoreSearchGlobalSearchResult[]; canLoadMore: boolean }> { if (this.filtersYieldEmptyResults(filters)) { return { results: [], canLoadMore: false, }; } const site = CoreSites.getRequiredCurrentSite(); const params: CoreSearchGetResultsWSParams = { query, page, filters: await this.prepareWSFilters(filters), }; const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.PREFER_NETWORK); const { totalcount, results } = await site.read('core_search_get_results', params, preSets); return { results: await Promise.all((results ?? []).map(result => this.formatWSResult(result))), canLoadMore: totalcount > (page + 1) * CORE_SEARCH_GLOBAL_SEARCH_PAGE_LENGTH, }; } /** * Get top results. * * @param query Search query. * @param filters Search filters. * @returns Top search results. */ async getTopResults(query: string, filters: CoreSearchGlobalSearchFilters): Promise { if (this.filtersYieldEmptyResults(filters)) { return []; } const site = CoreSites.getRequiredCurrentSite(); const params: CoreSearchGetTopResultsWSParams = { query, filters: await this.prepareWSFilters(filters), }; const preSets = CoreSites.getReadingStrategyPreSets(CoreSitesReadingStrategy.PREFER_NETWORK); const { results } = await site.read('core_search_get_top_results', params, preSets); return await Promise.all((results ?? []).map(result => this.formatWSResult(result))); } /** * Get available search areas. * * @returns Search areas. */ async getSearchAreas(): Promise { const site = CoreSites.getRequiredCurrentSite(); const params: CoreSearchGetSearchAreasListWSParams = {}; const { areas } = await site.read('core_search_get_search_areas_list', params, { updateFrequency: CoreSite.FREQUENCY_RARELY, cacheKey: CoreSearchGlobalSearchService.SEARCH_AREAS_CACHE_KEY, }); return areas.map(area => ({ id: area.id, name: area.name, category: { id: area.categoryid, name: area.categoryname, }, })); } /** * Invalidate search areas cache. */ async invalidateSearchAreas(): Promise { const site = CoreSites.getRequiredCurrentSite(); await site.invalidateWsCacheForKey(CoreSearchGlobalSearchService.SEARCH_AREAS_CACHE_KEY); } /** * Log event for viewing results. * * @param query Search query. * @param filters Search filters. */ async logViewResults(query: string, filters: CoreSearchGlobalSearchFilters): Promise { const site = CoreSites.getRequiredCurrentSite(); const params: CoreSearchViewResultsWSParams = { query, filters: await this.prepareWSFilters(filters), }; await site.write('core_search_view_results', params); } /** * Format a WS result to be used in the app. * * @param wsResult WS result. * @returns App result. */ protected async formatWSResult(wsResult: CoreSearchWSResult): Promise { const result: CoreSearchGlobalSearchResult = { id: wsResult.itemid, title: wsResult.title, url: wsResult.docurl, content: wsResult.content, }; if (wsResult.componentname === 'core_user') { const user = await CoreUser.getProfile(wsResult.itemid); result.user = user; } else if (wsResult.componentname === 'core_course' && wsResult.areaname === 'course') { const course = await CoreCourses.getCourseByField('id', wsResult.itemid); result.course = course; } else { if (wsResult.userfullname || wsResult.coursefullname) { result.context = { userName: wsResult.userfullname, courseName: wsResult.coursefullname, }; } if (wsResult.iconurl) { if (wsResult.componentname.startsWith('mod_')) { result.module = { name: wsResult.componentname.substring(4), iconurl: wsResult.iconurl, area: wsResult.areaname, }; } else { result.component = { name: wsResult.componentname, iconurl: wsResult.iconurl, }; } } } return result; } /** * Check whether the given filter will necessarily yield an empty list of results. * * @param filters Filters. * @returns Whether the given filters will return 0 results. */ protected filtersYieldEmptyResults(filters: CoreSearchGlobalSearchFilters): boolean { return filters.courseIds?.length === 0 || filters.searchAreaCategoryIds?.length === 0; } /** * Prepare search filters before sending to WS. * * @param filters App filters. * @returns WS filters. */ protected async prepareWSFilters(filters: CoreSearchGlobalSearchFilters): Promise { const wsFilters: CoreSearchBasicWSFilters = {}; if (filters.courseIds) { wsFilters.courseids = filters.courseIds; } if (filters.searchAreaCategoryIds) { const searchAreas = await this.getSearchAreas(); wsFilters.areaids = searchAreas .filter(({ category }) => filters.searchAreaCategoryIds?.includes(category.id)) .map(({ id }) => id); } return wsFilters; } } export const CoreSearchGlobalSearch = makeSingleton(CoreSearchGlobalSearchService); /** * Params of core_search_get_results WS. */ type CoreSearchGetResultsWSParams = { query: string; // The search query. filters?: CoreSearchAdvancedWSFilters; // Filters to apply. page?: number; // Results page number starting from 0, defaults to the first page. }; /** * Params of core_search_get_search_areas_list WS. */ type CoreSearchGetSearchAreasListWSParams = { cat?: string; // Category to filter areas. }; /** * Params of core_search_view_results WS. */ type CoreSearchViewResultsWSParams = { query: string; // The search query. filters?: CoreSearchBasicWSFilters; // Filters to apply. page?: number; // Results page number starting from 0, defaults to the first page. }; /** * Params of core_search_get_top_results WS. */ type CoreSearchGetTopResultsWSParams = { query: string; // The search query. filters?: CoreSearchAdvancedWSFilters; // Filters to apply. }; /** * Search result returned in WS. */ type CoreSearchWSResult = { // Search results. itemid: number; // Unique id in the search area scope. componentname: string; // Component name. areaname: string; // Search area name. courseurl: string; // Result course url. coursefullname: string; // Result course fullname. timemodified: number; // Result modified time. title: string; // Result title. docurl: string; // Result url. iconurl?: string; // Icon url. content?: string; // Result contents. contextid: number; // Result context id. contexturl: string; // Result context url. description1?: string; // Extra result contents, depends on the search area. description2?: string; // Extra result contents, depends on the search area. multiplefiles?: number; // Whether multiple files are returned or not. filenames?: string[]; // Result file names if present. filename?: string; // Result file name if present. userid?: number; // User id. userurl?: string; // User url. userfullname?: string; // User fullname. textformat: number; // Text fields format, it is the same for all of them. }; /** * Basic search filters used in WS. */ type CoreSearchBasicWSFilters = { title?: string; // Result title. areaids?: string[]; // Restrict results to these areas. courseids?: number[]; // Restrict results to these courses. timestart?: number; // Docs modified after this date. timeend?: number; // Docs modified before this date. }; /** * Advanced search filters used in WS. */ type CoreSearchAdvancedWSFilters = CoreSearchBasicWSFilters & { contextids?: number[]; // Restrict results to these contexts. cat?: string; // Category to filter areas. userids?: number[]; // Restrict results to these users. groupids?: number[]; // Restrict results to these groups. mycoursesonly?: boolean; // Only results from my courses. order?: string; // How to order. }; /** * Data returned by core_search_get_results WS. */ type CoreSearchGetResultsWSResponse = { totalcount: number; // Total number of results. results?: CoreSearchWSResult[]; }; /** * Data returned by core_search_get_search_areas_list WS. */ type CoreSearchGetSearchAreasListWSResponse = { areas: { // Search areas. id: string; // Search area id. categoryid: string; // Category id. categoryname: string; // Category name. name: string; // Search area name. }[]; warnings?: CoreWSExternalWarning[]; }; /** * Data returned by core_search_view_results WS. */ type CoreSearchViewResultsWSResponse = { status: boolean; // Status: true if success. warnings?: CoreWSExternalWarning[]; }; /** * Data returned by core_search_get_top_results WS. */ type CoreSearchGetTopResultsWSResponse = { results?: CoreSearchWSResult[]; };