2023-09-14 16:19:53 +02:00

421 lines
14 KiB
TypeScript

// (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<boolean> {
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<CoreSearchGetResultsWSResponse>('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<CoreSearchGlobalSearchResult[]> {
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<CoreSearchGetTopResultsWSResponse>('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<CoreSearchGlobalSearchSearchArea[]> {
const site = CoreSites.getRequiredCurrentSite();
const params: CoreSearchGetSearchAreasListWSParams = {};
const { areas } = await site.read<CoreSearchGetSearchAreasListWSResponse>('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<void> {
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<void> {
const site = CoreSites.getRequiredCurrentSite();
const params: CoreSearchViewResultsWSParams = {
query,
filters: await this.prepareWSFilters(filters),
};
await site.write<CoreSearchViewResultsWSResponse>('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<CoreSearchGlobalSearchResult> {
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<CoreSearchBasicWSFilters> {
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[];
};