diff --git a/src/core/classes/delegate.ts b/src/core/classes/delegate.ts index 190391180..c9d0183b4 100644 --- a/src/core/classes/delegate.ts +++ b/src/core/classes/delegate.ts @@ -124,7 +124,7 @@ export class CoreDelegate { * @return Function returned value or default value. */ protected executeFunction(handlerName: string, fnName: string, params?: unknown[]): T | undefined { - return this.execute(this.handlers[handlerName], fnName, params); + return this.execute(this.handlers[handlerName], fnName, params); } /** diff --git a/src/core/features/filter/services/filter-delegate.ts b/src/core/features/filter/services/filter-delegate.ts new file mode 100644 index 000000000..dcd394f0b --- /dev/null +++ b/src/core/features/filter/services/filter-delegate.ts @@ -0,0 +1,290 @@ +// (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, ViewContainerRef } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreFilterFilter, CoreFilterFormatTextOptions } from './filter'; +import { CoreFilterDefaultHandler } from './handlers/default-filter'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreSite } from '@classes/site'; +import { makeSingleton } from '@singletons'; + +/** + * Interface that all filter handlers must implement. + */ +export interface CoreFilterHandler extends CoreDelegateHandler { + /** + * Name of the filter. It should match the "filter" field returned in core_filters_get_available_in_context. + */ + filterName: string; + + /** + * Filter some text. + * + * @param text The text to filter. + * @param filter The filter. + * @param options Options passed to the filters. + * @param siteId Site ID. If not defined, current site. + * @return Filtered text (or promise resolved with the filtered text). + */ + filter(text: string, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, siteId?: string): string | Promise; + + /** + * Handle HTML. This function is called after "filter", and it will receive an HTMLElement containing the text that was + * filtered. + * + * @param container The HTML container to handle. + * @param filter The filter. + * @param options Options passed to the filters. + * @param viewContainerRef The ViewContainerRef where the container is. + * @param component Component. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + handleHtml?( + container: HTMLElement, + filter: CoreFilterFilter, + options: CoreFilterFormatTextOptions, + viewContainerRef: ViewContainerRef, + component?: string, + componentId?: string | number, + siteId?: string, + ): void | Promise; + + /** + * Check if the filter should be applied in a certain site based on some filter options. + * + * @param options Options. + * @param site Site. + * @return Whether filter should be applied. + */ + shouldBeApplied(options: CoreFilterFormatTextOptions, site?: CoreSite): boolean; +} + +/** + * Delegate to register filters. + */ +@Injectable({ providedIn: 'root' }) +export class CoreFilterDelegateService extends CoreDelegate { + + protected featurePrefix = 'CoreFilterDelegate_'; + protected handlerNameProperty = 'filterName'; + + constructor(protected defaultHandler: CoreFilterDefaultHandler) { + super('CoreFilterDelegate', true); + } + + /** + * Apply a list of filters to some content. + * + * @param text The text to filter. + * @param filters Filters to apply. + * @param options Options passed to the filters. + * @param skipFilters Names of filters that shouldn't be applied. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the filtered text. + */ + async filterText( + text: string, + filters?: CoreFilterFilter[], + options?: CoreFilterFormatTextOptions, + skipFilters?: string[], + siteId?: string, + ): Promise { + + // Wait for filters to be initialized. + await this.handlersInitPromise; + + const site = await CoreSites.instance.getSite(siteId); + + filters = filters || []; + options = options || {}; + + for (let i = 0; i < filters.length; i++) { + const filter = filters[i]; + if (!this.isEnabledAndShouldApply(filter, options, site, skipFilters)) { + continue; + } + + try { + const newText = await this.executeFunctionOnEnabled( + filter.filter, + 'filter', + [text, filter, options, siteId], + ); + + text = newText || text; + } catch (error) { + this.logger.error('Error applying filter' + filter.filter, error); + } + } + + // Remove tags for XHTML compatibility. + text = text.replace(/<\/?nolink>/gi, ''); + + return text; + } + + /** + * Get filters that have an enabled handler. + * + * @param contextLevel Context level of the filters. + * @param instanceId Instance ID. + * @return Filters. + */ + getEnabledFilters(contextLevel: string, instanceId: number): CoreFilterFilter[] { + const filters: CoreFilterFilter[] = []; + + for (const name in this.enabledHandlers) { + const handler = this.enabledHandlers[name]; + + filters.push({ + contextid: -1, + contextlevel: contextLevel, + filter: handler.filterName, + inheritedstate: 1, + instanceid: instanceId, + localstate: 1, + }); + } + + return filters; + } + + /** + * Let filters handle an HTML element. + * + * @param container The HTML container to handle. + * @param filters Filters to apply. + * @param viewContainerRef The ViewContainerRef where the container is. + * @param options Options passed to the filters. + * @param skipFilters Names of filters that shouldn't be applied. + * @param component Component. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async handleHtml( + container: HTMLElement, + filters: CoreFilterFilter[], + viewContainerRef?: ViewContainerRef, + options?: CoreFilterFormatTextOptions, + skipFilters?: string[], + component?: string, + componentId?: string | number, + siteId?: string, + ): Promise { + + // Wait for filters to be initialized. + await this.handlersInitPromise; + + const site = await CoreSites.instance.getSite(siteId); + + filters = filters || []; + options = options || {}; + + for (let i = 0; i < filters.length; i++) { + const filter = filters[i]; + if (!this.isEnabledAndShouldApply(filter, options, site, skipFilters)) { + continue; + } + + try { + await this.executeFunctionOnEnabled( + filter.filter, + 'handleHtml', + [container, filter, options, viewContainerRef, component, componentId, siteId], + ); + } catch (error) { + this.logger.error('Error handling HTML' + filter.filter, error); + } + } + } + + /** + * Check if a filter is enabled and should be applied. + * + * @param filters Filters to apply. + * @param options Options passed to the filters. + * @param site Site. + * @param skipFilters Names of filters that shouldn't be applied. + * @return Whether the filter is enabled and should be applied. + */ + isEnabledAndShouldApply( + filter: CoreFilterFilter, + options: CoreFilterFormatTextOptions, + site: CoreSite, + skipFilters?: string[], + ): boolean { + + if (filter.localstate == -1 || (filter.localstate == 0 && filter.inheritedstate == -1)) { + // Filter is disabled, ignore it. + return false; + } + + if (!this.shouldFilterBeApplied(filter, options, site)) { + // Filter shouldn't be applied. + return false; + } + + if (skipFilters && skipFilters.indexOf(filter.filter) != -1) { + // Skip this filter. + return false; + } + + return true; + } + + /** + * Check if at least 1 filter should be applied in a certain site and with certain options. + * + * @param filter Filter to check. + * @param options Options passed to the filters. + * @param site Site. If not defined, current site. + * @return Promise resolved with true: whether the filter should be applied. + */ + async shouldBeApplied(filters: CoreFilterFilter[], options: CoreFilterFormatTextOptions, site?: CoreSite): Promise { + // Wait for filters to be initialized. + await this.handlersInitPromise; + + for (let i = 0; i < filters.length; i++) { + if (this.shouldFilterBeApplied(filters[i], options, site)) { + return true; + } + } + + return false; + } + + /** + * Check whether a filter should be applied in a certain site and with certain options. + * + * @param filter Filter to check. + * @param options Options passed to the filters. + * @param site Site. If not defined, current site. + * @return Whether the filter should be applied. + */ + protected shouldFilterBeApplied(filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, site?: CoreSite): boolean { + if (!this.hasHandler(filter.filter, true)) { + return false; + } + + return !!(this.executeFunctionOnEnabled(filter.filter, 'shouldBeApplied', [options, site])); + } + +} + +export class CoreFilterDelegate extends makeSingleton(CoreFilterDelegateService) {} diff --git a/src/core/features/filter/services/filter-helper.ts b/src/core/features/filter/services/filter-helper.ts new file mode 100644 index 000000000..ec273ccc5 --- /dev/null +++ b/src/core/features/filter/services/filter-helper.ts @@ -0,0 +1,358 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreFilterDelegate } from './filter-delegate'; +import { + CoreFilter, + CoreFilterFilter, + CoreFilterFormatTextOptions, + CoreFilterClassifiedFilters, + CoreFiltersGetAvailableInContextWSParamContext, +} from './filter'; +// import { CoreCourseProvider } from '@features/course/providers/course'; +// import { CoreCoursesProvider } from '@features/courses/providers/courses'; +import { makeSingleton } from '@singletons'; +import { CoreEvents, CoreEventSiteData } from '@singletons/events'; +import { CoreLogger } from '@singletons/logger'; +import { CoreSite } from '@classes/site'; + +/** + * Helper service to provide filter functionalities. + */ +@Injectable({ providedIn: 'root' }) +export class CoreFilterHelperProvider { + + protected logger: CoreLogger; + + /** + * When a module context is requested, we request all the modules in a course to decrease WS calls. If there are a lot of + * modules, checking the cache of all contexts can be really slow, so we use this memory cache to speed up the process. + */ + protected moduleContextsCache: { + [siteId: string]: { + [courseId: number]: { + [contextLevel: string]: { + contexts: CoreFilterClassifiedFilters; + time: number; + }; + }; + }; + } = {}; + + constructor() { + this.logger = CoreLogger.getInstance('CoreFilterHelperProvider'); + + CoreEvents.on(CoreEvents.WS_CACHE_INVALIDATED, (data: CoreEventSiteData) => { + delete this.moduleContextsCache[data.siteId || '']; + }); + + CoreEvents.on(CoreEvents.SITE_STORAGE_DELETED, (data: CoreEventSiteData) => { + delete this.moduleContextsCache[data.siteId || '']; + }); + } + + /** + * Get the contexts of all blocks in a course. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the contexts. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getBlocksContexts(courseId: number, siteId?: string): Promise { + return []; + // @todo + // const blocks = await this.courseProvider.getCourseBlocks(courseId, siteId); + + // const contexts: CoreFiltersGetAvailableInContextWSParamContext[] = []; + + // blocks.forEach((block) => { + // contexts.push({ + // contextlevel: 'block', + // instanceid: block.instanceid, + // }); + // }); + + // return contexts; + } + + /** + * Get some filters from memory cache. If not in cache, get them and store them in cache. + * + * @param contextLevel The context level. + * @param instanceId Instance ID related to the context. + * @param options Options for format text. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the filters. + */ + protected async getCacheableFilters( + contextLevel: string, + instanceId: number, + getFilters: () => Promise, + options: CoreFilterFormatTextOptions, + site: CoreSite, + ): Promise { + + // Check the memory cache first. + const result = this.getFromMemoryCache(options.courseId ?? -1, contextLevel, instanceId, site); + if (result) { + return result; + } + + const siteId = site.getId(); + + const contexts = await getFilters(); + + const filters = await CoreFilter.instance.getAvailableInContexts(contexts, siteId); + + this.storeInMemoryCache(options.courseId ?? -1, contextLevel, filters, siteId); + + return filters[contextLevel][instanceId] || []; + } + + /** + * If user is enrolled in the course, return contexts of all enrolled courses to decrease number of WS requests. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the contexts. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getCourseContexts(courseId: number, siteId?: string): Promise { + // @todo + return []; + // const courseIds = await this.coursesProvider.getCourseIdsIfEnrolled(courseId, siteId); + + // const contexts: CoreFiltersGetAvailableInContextWSParamContext[] = []; + + // courseIds.forEach((courseId) => { + // contexts.push({ + // contextlevel: 'course', + // instanceid: courseId + // }); + // }); + + // return contexts; + } + + /** + * Get the contexts of all course modules in a course. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the contexts. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getCourseModulesContexts(courseId: number, siteId?: string): Promise { + // @todo + return []; + + // const sections = await this.courseProvider.getSections(courseId, false, true, undefined, siteId); + + // const contexts: CoreFiltersGetAvailableInContextWSParamContext[] = []; + + // sections.forEach((section) => { + // if (section.modules) { + // section.modules.forEach((module) => { + // if (module.uservisible) { + // contexts.push({ + // contextlevel: 'module', + // instanceid: module.id + // }); + // } + // }); + // } + // }); + + // return contexts; + } + + /** + * Get the filters in a certain context, performing some checks like the site version. + * It's recommended to use this function instead of canGetFilters + getEnabledFilters because this function will check if + * it's really needed to call the WS. + * + * @param contextLevel The context level. + * @param instanceId Instance ID related to the context. + * @param options Options for format text. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the filters. + */ + async getFilters( + contextLevel: string, + instanceId: number, + options?: CoreFilterFormatTextOptions, + siteId?: string, + ): Promise { + options = options || {}; + options.contextLevel = contextLevel; + options.instanceId = instanceId; + options.filter = false; + + try { + const site = await CoreSites.instance.getSite(siteId); + + siteId = site.getId(); + + const canGet = await CoreFilter.instance.canGetFilters(siteId); + if (!canGet) { + options.filter = true; + + // We cannot check which filters are available, apply them all. + return CoreFilterDelegate.instance.getEnabledFilters(contextLevel, instanceId); + } + + let hasFilters = true; + + if (contextLevel == 'system' || (contextLevel == 'course' && instanceId == site.getSiteHomeId())) { + // No need to check the site filters because we're requesting the same context, so we'd do the same twice. + } else { + // Check if site has any filter to treat. + hasFilters = await this.siteHasFiltersToTreat(options, siteId); + } + + if (!hasFilters) { + return []; + } + + options.filter = true; + + if (contextLevel == 'module' && options.courseId) { + // Get all the modules filters with a single call to decrease the number of WS calls. + const getFilters = this.getCourseModulesContexts.bind(this, options.courseId, siteId); + + return this.getCacheableFilters(contextLevel, instanceId, getFilters, options, site); + + } else if (contextLevel == 'course') { + // If enrolled, get all enrolled courses filters with a single call to decrease number of WS calls. + const getFilters = this.getCourseContexts.bind(this, instanceId, siteId); + + return this.getCacheableFilters(contextLevel, instanceId, getFilters, options, site); + } else if (contextLevel == 'block' && options.courseId) { // @todo && this.courseProvider.canGetCourseBlocks(site) + // Get all the course blocks filters with a single call to decrease number of WS calls. + const getFilters = this.getBlocksContexts.bind(this, options.courseId, siteId); + + return this.getCacheableFilters(contextLevel, instanceId, getFilters, options, site); + } + + return CoreFilter.instance.getAvailableInContext(contextLevel, instanceId, siteId); + } catch (error) { + this.logger.error('Error getting filters, return an empty array', error, contextLevel, instanceId); + + return []; + } + } + + /** + * Get filters and format text. + * + * @param text Text to filter. + * @param contextLevel The context level. + * @param instanceId Instance ID related to the context. + * @param options Options for format text. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the formatted text and the filters. + */ + async getFiltersAndFormatText( + text: string, + contextLevel: string, + instanceId: number, + options?: CoreFilterFormatTextOptions, + siteId?: string, + ): Promise<{text: string; filters: CoreFilterFilter[]}> { + + const filters = await this.getFilters(contextLevel, instanceId, options, siteId); + + text = await CoreFilter.instance.formatText(text, options, filters, siteId); + + return { text, filters: filters }; + } + + /** + * Get module context filters from the memory cache. + * + * @param courseId Course the module belongs to. + * @param contextLevel Context level. + * @param instanceId Instance ID. + * @param site Site. + * @return The filters, undefined if not found. + */ + protected getFromMemoryCache( + courseId: number, + contextLevel: string, + instanceId: number, + site: CoreSite, + ): CoreFilterFilter[] | undefined { + + const siteId = site.getId(); + + // Check if we have the context in the memory cache. + if (!this.moduleContextsCache[siteId]?.[courseId]?.[contextLevel]) { + return; + } + + const cachedData = this.moduleContextsCache[siteId][courseId][contextLevel]; + + if (!CoreApp.instance.isOnline() || Date.now() <= cachedData.time + site.getExpirationDelay(CoreSite.FREQUENCY_RARELY)) { + // We can use cache, return the filters if found. + return cachedData.contexts[contextLevel] && cachedData.contexts[contextLevel][instanceId]; + } + } + + /** + * Check if site has available any filter that should be treated by the app. + * + * @param options Options passed to the filters. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it has filters to treat. + */ + async siteHasFiltersToTreat(options?: CoreFilterFormatTextOptions, siteId?: string): Promise { + options = options || {}; + + const site = await CoreSites.instance.getSite(siteId); + + // Get filters at site level. + const filters = await CoreFilter.instance.getAvailableInContext('system', 0, site.getId()); + + return CoreFilterDelegate.instance.shouldBeApplied(filters, options, site); + } + + /** + * Store filters in the memory cache. + * + * @param contexts Filters to store, classified by contextlevel and instanceid + * @param siteId Site ID. + */ + protected storeInMemoryCache( + courseId: number, + contextLevel: string, + contexts: CoreFilterClassifiedFilters, + siteId: string, + ): void { + + this.moduleContextsCache[siteId] = this.moduleContextsCache[siteId] || {}; + this.moduleContextsCache[siteId][courseId] = this.moduleContextsCache[siteId][courseId] || {}; + this.moduleContextsCache[siteId][courseId][contextLevel] = { + contexts: contexts, + time: Date.now(), + }; + } + +} + +export class CoreFilterHelper extends makeSingleton(CoreFilterHelperProvider) {} diff --git a/src/core/features/filter/services/filter.ts b/src/core/features/filter/services/filter.ts new file mode 100644 index 000000000..fc973a02e --- /dev/null +++ b/src/core/features/filter/services/filter.ts @@ -0,0 +1,543 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreSite } from '@classes/site'; +import { CoreWSExternalWarning } from '@services/ws'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreFilterDelegate } from './filter-delegate'; +import { makeSingleton } from '@singletons'; +import { CoreEvents, CoreEventSiteData } from '@singletons/events'; +import { CoreLogger } from '@singletons/logger'; + +/** + * Service to provide filter functionalities. + */ +@Injectable({ providedIn: 'root' }) +export class CoreFilterProvider { + + protected readonly ROOT_CACHE_KEY = 'mmFilter:'; + + protected logger: CoreLogger; + + /** + * Store the contexts in memory to speed up the process, it can take a lot of time otherwise. + */ + protected contextsCache: { + [siteId: string]: { + [contextlevel: string]: { + [instanceid: number]: { + filters: CoreFilterFilter[]; + time: number; + }; + }; + }; + } = {}; + + constructor() { + this.logger = CoreLogger.getInstance('CoreFilterProvider'); + + CoreEvents.on(CoreEvents.WS_CACHE_INVALIDATED, (data: CoreEventSiteData) => { + delete this.contextsCache[data.siteId || '']; + }); + + CoreEvents.on(CoreEvents.SITE_STORAGE_DELETED, (data: CoreEventSiteData) => { + delete this.contextsCache[data.siteId || '']; + }); + } + + /** + * Returns whether or not WS get available in context is available. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if ws is available, false otherwise. + * @since 3.4 + */ + async canGetAvailableInContext(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.canGetAvailableInContextInSite(site); + } + + /** + * Returns whether or not WS get available in context is available in a certain site. + * + * @param site Site. If not defined, current site. + * @return Promise resolved with true if ws is available, false otherwise. + * @since 3.4 + */ + canGetAvailableInContextInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!(site?.wsAvailable('core_filters_get_available_in_context')); + } + + /** + * Returns whether or not we can get the available filters: the WS is available and the feature isn't disabled. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whethe can get filters. + */ + async canGetFilters(siteId?: string): Promise { + const wsAvailable = await this.canGetAvailableInContext(siteId); + const disabled = await this.checkFiltersDisabled(siteId); + + return wsAvailable && !disabled; + } + + /** + * Returns whether or not we can get the available filters: the WS is available and the feature isn't disabled. + * + * @param site Site. If not defined, current site. + * @return Promise resolved with boolean: whethe can get filters. + */ + canGetFiltersInSite(site?: CoreSite): boolean { + return this.canGetAvailableInContextInSite(site) && this.checkFiltersDisabledInSite(site); + } + + /** + * Returns whether or not checking the available filters is disabled in the site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it's disabled. + */ + async checkFiltersDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.checkFiltersDisabledInSite(site); + } + + /** + * Returns whether or not checking the available filters is disabled in the site. + * + * @param site Site. If not defined, current site. + * @return Whether it's disabled. + */ + checkFiltersDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!(site?.isFeatureDisabled('CoreFilterDelegate')); + } + + /** + * Classify a list of filters into each context. + * + * @param contexts List of contexts. + * @param filters List of filters. + * @param hadSystemContext Whether the list of contexts originally had system context. + * @param hadSiteHomeContext Whether the list of contexts originally had site home context. + * @param site Site instance. + * @return Classified filters. + */ + protected classifyFilters( + contexts: CoreFiltersGetAvailableInContextWSParamContext[], + filters: CoreFilterFilter[], + hadSystemContext: boolean, + hadSiteHomeContext: boolean, + site: CoreSite, + ): CoreFilterClassifiedFilters { + const classified: CoreFilterClassifiedFilters = {}; + + // Initialize all contexts. + contexts.forEach((context) => { + classified[context.contextlevel] = classified[context.contextlevel] || {}; + classified[context.contextlevel][context.instanceid] = []; + }); + + if (contexts.length == 1 && !hadSystemContext) { + // Only 1 context, no need to iterate over the filters. + classified[contexts[0].contextlevel][contexts[0].instanceid] = filters; + + return classified; + } + + filters.forEach((filter) => { + if (hadSystemContext && filter.contextlevel == 'course' && filter.instanceid == site.getSiteHomeId()) { + if (hadSiteHomeContext) { + // We need to return both site home and system. Add site home first. + classified[filter.contextlevel][filter.instanceid].push(filter); + + // Now copy the object so it can be modified. + filter = Object.assign({}, filter); + } + + // Simulate the system context based on the inherited data. + filter.contextlevel = 'system'; + filter.instanceid = 0; + filter.contextid = -1; + filter.localstate = filter.inheritedstate; + } + + classified[filter.contextlevel][filter.instanceid].push(filter); + }); + + return classified; + } + + /** + * Given some HTML code, this function returns the text as safe HTML. + * + * @param text The text to be formatted. + * @param options Formatting options. + * @param filters The filters to apply. Required if filter is set to true. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the formatted text. + */ + async formatText( + text: string, + options?: CoreFilterFormatTextOptions, + filters?: CoreFilterFilter[], + siteId?: string, + ): Promise { + + if (!text || typeof text != 'string') { + // No need to do any filters and cleaning. + return ''; + } + + // Clone object if needed so we can modify it. + options = options ? Object.assign({}, options) : {}; + + if (typeof options.clean == 'undefined') { + options.clean = false; + } + + if (typeof options.filter == 'undefined') { + options.filter = true; + } + + if (!options.contextLevel) { + options.filter = false; + } + + if (options.filter) { + text = await CoreFilterDelegate.instance.filterText(text, filters, options, [], siteId); + } + + if (options.clean) { + text = CoreTextUtils.instance.cleanTags(text, options.singleLine); + } + + if (options.shortenLength && options.shortenLength > 0) { + text = CoreTextUtils.instance.shortenText(text, options.shortenLength); + } + + if (options.highlight) { + text = CoreTextUtils.instance.highlightText(text, options.highlight); + } + + return text; + } + + /** + * Get cache key for available in contexts WS calls. + * + * @param contexts The contexts to check. + * @return Cache key. + */ + protected getAvailableInContextsCacheKey(contexts: CoreFiltersGetAvailableInContextWSParamContext[]): string { + return this.getAvailableInContextsPrefixCacheKey() + JSON.stringify(contexts); + } + + /** + * Get prefixed cache key for available in contexts WS calls. + * + * @return Cache key. + */ + protected getAvailableInContextsPrefixCacheKey(): string { + return this.ROOT_CACHE_KEY + 'availableInContexts:'; + } + + /** + * Get the filters available in several contexts. + * + * @param contexts The contexts to check. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the filters classified by context. + */ + async getAvailableInContexts( + contexts: CoreFiltersGetAvailableInContextWSParamContext[], + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + siteId = site.getId(); + + const cacheResult = this.getFromMemoryCache(contexts, site); + + if (cacheResult) { + return cacheResult; + } + + const contextsToSend = contexts.slice(); // Copy the contexts array to be able to modify it. + + const { hadSystemContext, hadSiteHomeContext } = this.replaceSystemContext(contextsToSend, site); + + const data: CoreFiltersGetAvailableInContextWSParams = { + contexts: contextsToSend, + }; + const preSets = { + cacheKey: this.getAvailableInContextsCacheKey(contextsToSend), + updateFrequency: CoreSite.FREQUENCY_RARELY, + splitRequest: { + param: 'contexts', + maxLength: 300, + }, + }; + + const result = await site.read( + 'core_filters_get_available_in_context', + data, + preSets, + ); + + const classified = this.classifyFilters(contexts, result.filters, hadSystemContext, hadSiteHomeContext, site); + + this.storeInMemoryCache(classified, siteId); + + return classified; + } + + /** + * Get the filters available in a certain context. + * + * @param contextLevel The context level to check: system, user, coursecat, course, module, block, ... + * @param instanceId The instance ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the filters. + */ + async getAvailableInContext(contextLevel: string, instanceId: number, siteId?: string): Promise { + const result = await this.getAvailableInContexts([{ contextlevel: contextLevel, instanceid: instanceId }], siteId); + + return result[contextLevel][instanceId] || []; + } + + /** + * Get contexts filters from the memory cache. + * + * @param contexts Contexts to get. + * @param site Site. + * @return The filters classified by context and instance. + */ + protected getFromMemoryCache( + contexts: CoreFiltersGetAvailableInContextWSParamContext[], + site: CoreSite, + ): CoreFilterClassifiedFilters | undefined { + + if (!this.contextsCache[site.getId()]) { + return; + } + + // Check if we have the contexts in the memory cache. + const siteContexts = this.contextsCache[site.getId()]; + const isOnline = CoreApp.instance.isOnline(); + const result: CoreFilterClassifiedFilters = {}; + let allFound = true; + + for (let i = 0; i < contexts.length; i++) { + const context = contexts[i]; + const cachedCtxt = siteContexts[context.contextlevel]?.[context.instanceid]; + + // Check the context isn't "expired". The time stored in this cache will not match the one in the site cache. + if (cachedCtxt && (!isOnline || + Date.now() <= cachedCtxt.time + site.getExpirationDelay(CoreSite.FREQUENCY_RARELY))) { + + result[context.contextlevel] = result[context.contextlevel] || {}; + result[context.contextlevel][context.instanceid] = cachedCtxt.filters; + } else { + allFound = false; + break; + } + } + + if (allFound) { + return result; + } + } + + /** + * Invalidates all available in context WS calls. + * + * @param siteId Site ID (empty for current site). + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllAvailableInContext(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getAvailableInContextsPrefixCacheKey()); + } + + /** + * Invalidates available in context WS call. + * + * @param contexts The contexts to check. + * @param siteId Site ID (empty for current site). + * @return Promise resolved when the data is invalidated. + */ + async invalidateAvailableInContexts( + contexts: CoreFiltersGetAvailableInContextWSParamContext[], + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAvailableInContextsCacheKey(contexts)); + } + + /** + * Invalidates available in context WS call. + * + * @param contextLevel The context level to check. + * @param instanceId The instance ID. + * @param siteId Site ID (empty for current site). + * @return Promise resolved when the data is invalidated. + */ + async invalidateAvailableInContext(contextLevel: string, instanceId: number, siteId?: string): Promise { + await this.invalidateAvailableInContexts([{ contextlevel: contextLevel, instanceid: instanceId }], siteId); + } + + /** + * Given a list of context to send to core_filters_get_available_in_context, search if the system context is in the list + * and, if so, replace it with a workaround. + * + * @param contexts The contexts to check. + * @param site Site instance. + * @return Whether the filters had system context and whether they had the site home context. + */ + protected replaceSystemContext( + contexts: CoreFiltersGetAvailableInContextWSParamContext[], + site: CoreSite, + ): { hadSystemContext: boolean; hadSiteHomeContext: boolean } { + const result = { + hadSystemContext: false, + hadSiteHomeContext: false, + }; + + // Check if any of the contexts is "system". We cannot use system context, so we'll have to use a wrokaround. + for (let i = 0; i < contexts.length; i++) { + const context = contexts[i]; + + if (context.contextlevel != 'system') { + continue; + } + + result.hadSystemContext = true; + + // Use course site home instead. Check if it's already in the list. + result.hadSiteHomeContext = contexts.some((context) => + context.contextlevel == 'course' && context.instanceid == site.getSiteHomeId()); + + if (result.hadSiteHomeContext) { + // Site home is already in list, remove this context from the list. + contexts.splice(i, 1); + } else { + // Site home not in list, use it instead of system. + contexts[i] = { + contextlevel: 'course', + instanceid: site.getSiteHomeId(), + }; + } + + break; + } + + return result; + } + + /** + * Store filters in the memory cache. + * + * @param filters Filters to store, classified by contextlevel and instanceid + * @param siteId Site ID. + */ + protected storeInMemoryCache(filters: CoreFilterClassifiedFilters, siteId: string): void { + this.contextsCache[siteId] = this.contextsCache[siteId] || {}; + + for (const contextLevel in filters) { + this.contextsCache[siteId][contextLevel] = this.contextsCache[siteId][contextLevel] || {}; + + for (const instanceId in filters[contextLevel]) { + this.contextsCache[siteId][contextLevel][instanceId] = { + filters: filters[contextLevel][instanceId], + time: Date.now(), + }; + } + } + } + +} + +export class CoreFilter extends makeSingleton(CoreFilterProvider) {} + +/** + * Params of core_filters_get_available_in_context WS. + */ +export type CoreFiltersGetAvailableInContextWSParams = { + contexts: CoreFiltersGetAvailableInContextWSParamContext[]; // The list of contexts to check. +}; + +/** + * Data about a context sent to core_filters_get_available_in_context. + */ +export type CoreFiltersGetAvailableInContextWSParamContext = { + contextlevel: string; // The context level where the filters are: (coursecat, course, module). + instanceid: number; // The instance id of item associated with the context. +}; + +/** + * Filter object returned by core_filters_get_available_in_context. + */ +export type CoreFilterFilter = { + contextlevel: string; // The context level where the filters are: (coursecat, course, module). + instanceid: number; // The instance id of item associated with the context. + contextid: number; // The context id. + filter: string; // Filter plugin name. + localstate: number; // Filter state: 1 for on, -1 for off, 0 if inherit. + inheritedstate: number; // 1 or 0 to use when localstate is set to inherit. +}; + +/** + * Result of core_filters_get_available_in_context. + */ +export type CoreFilterGetAvailableInContextResult = { + filters: CoreFilterFilter[]; // Available filters. + warnings: CoreWSExternalWarning[]; // List of warnings. +}; + +/** + * Options that can be passed to format text. + */ +export type CoreFilterFormatTextOptions = { + contextLevel?: string; // The context level where the text is. + instanceId?: number; // The instance id related to the context. + clean?: boolean; // If true all HTML will be removed. Default false. + filter?: boolean; // If true the string will be run through applicable filters as well. Default true. + singleLine?: boolean; // If true then new lines will be removed (all the text in a single line). + shortenLength?: number; // Number of characters to shorten the text. + highlight?: string; // Text to highlight. + wsNotFiltered?: boolean; // If true it means the WS didn't filter the text for some reason. + courseId?: number; // Course ID the text belongs to. It can be used to improve performance. +}; + +/** + * Filters classified by context and instance. + */ +export type CoreFilterClassifiedFilters = { + [contextlevel: string]: { + [instanceid: number]: CoreFilterFilter[]; + }; +}; diff --git a/src/core/features/filter/services/handlers/default-filter.ts b/src/core/features/filter/services/handlers/default-filter.ts new file mode 100644 index 000000000..dbee116d2 --- /dev/null +++ b/src/core/features/filter/services/handlers/default-filter.ts @@ -0,0 +1,94 @@ +// (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, ViewContainerRef } from '@angular/core'; + +import { CoreFilterHandler } from '../filter-delegate'; +import { CoreFilterFilter, CoreFilterFormatTextOptions } from '../filter'; +import { CoreSite } from '@classes/site'; + +/** + * Default handler used when the module doesn't have a specific implementation. + */ +@Injectable({ providedIn: 'root' }) +export class CoreFilterDefaultHandler implements CoreFilterHandler { + + name = 'CoreFilterDefaultHandler'; + filterName = 'default'; + + /** + * Filter some text. + * + * @param text The text to filter. + * @param filter The filter. + * @param options Options passed to the filters. + * @param siteId Site ID. If not defined, current site. + * @return Filtered text (or promise resolved with the filtered text). + */ + filter( + text: string, + filter: CoreFilterFilter, // eslint-disable-line @typescript-eslint/no-unused-vars + options: CoreFilterFormatTextOptions, // eslint-disable-line @typescript-eslint/no-unused-vars + siteId?: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): string | Promise { + return text; + } + + /** + * Handle HTML. This function is called after "filter", and it will receive an HTMLElement containing the text that was + * filtered. + * + * @param container The HTML container to handle. + * @param filter The filter. + * @param options Options passed to the filters. + * @param viewContainerRef The ViewContainerRef where the container is. + * @param component Component. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + handleHtml( + container: HTMLElement, // eslint-disable-line @typescript-eslint/no-unused-vars + filter: CoreFilterFilter, // eslint-disable-line @typescript-eslint/no-unused-vars + options: CoreFilterFormatTextOptions, // eslint-disable-line @typescript-eslint/no-unused-vars + viewContainerRef: ViewContainerRef, // eslint-disable-line @typescript-eslint/no-unused-vars + component?: string, // eslint-disable-line @typescript-eslint/no-unused-vars + componentId?: string | number, // eslint-disable-line @typescript-eslint/no-unused-vars + siteId?: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): void | Promise { + // To be overridden. + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Check if the filter should be applied in a certain site based on some filter options. + * + * @param options Options. + * @param site Site. + * @return Whether filter should be applied. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + shouldBeApplied(options: CoreFilterFormatTextOptions, site?: CoreSite): boolean { + return true; + } + +}