// (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 { CoreConstants } from '@/core/constants'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreCourseAnyModuleData } from '@features/course/services/course'; import { CoreCourses } from '@features/courses/services/courses'; import { CoreApp } from '@services/app'; import { CoreFilepool } from '@services/filepool'; import { CoreLang } from '@services/lang'; import { CoreSites } from '@services/sites'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils, PromiseDefer } from '@services/utils/utils'; import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; import { makeSingleton } from '@singletons'; import { CoreEvents } from '@singletons/events'; import { CoreLogger } from '@singletons/logger'; const ROOT_CACHE_KEY = 'CoreSitePlugins:'; /** * Service to provide functionalities regarding site plugins. */ @Injectable({ providedIn: 'root' }) export class CoreSitePluginsProvider { static readonly COMPONENT = 'CoreSitePlugins'; protected logger: CoreLogger; protected sitePlugins: {[name: string]: CoreSitePluginsHandler} = {}; // Site plugins registered. protected sitePluginPromises: {[name: string]: Promise} = {}; // Promises of loading plugins. protected fetchPluginsDeferred: PromiseDefer; hasSitePluginsLoaded = false; sitePluginsFinishedLoading = false; constructor() { this.logger = CoreLogger.getInstance('CoreSitePluginsProvider'); const observer = CoreEvents.on(CoreEvents.SITE_PLUGINS_LOADED, () => { this.sitePluginsFinishedLoading = true; observer?.off(); }); // Initialize deferred at start and on logout. this.fetchPluginsDeferred = CoreUtils.promiseDefer(); CoreEvents.on(CoreEvents.LOGOUT, () => { this.fetchPluginsDeferred = CoreUtils.promiseDefer(); }); } /** * Add some params that will always be sent for get content. * * @param args Original params. * @param site Site. If not defined, current site. * @return Promise resolved with the new params. */ protected async addDefaultArgs = Record>( args: T, site?: CoreSite, ): Promise { args = args || {}; site = site || CoreSites.getCurrentSite(); const lang = await CoreLang.getCurrentLanguage(); // Clone the object so the original one isn't modified. // const argsToSend = CoreUtils.clone(args); const defaultArgs: CoreSitePluginsDefaultArgs = { userid: args.userid ?? site?.getUserId(), appid: CoreConstants.CONFIG.app_id, appversioncode: CoreConstants.CONFIG.versioncode, appversionname: CoreConstants.CONFIG.versionname, applang: lang, appcustomurlscheme: CoreConstants.CONFIG.customurlscheme, appisdesktop: false, appismobile: CoreApp.isMobile(), appiswide: CoreApp.isWide(), appplatform: 'browser', }; if (args.appismobile) { defaultArgs.appplatform = CoreApp.isIOS() ? 'ios' : 'android'; } return { ...args, ...defaultArgs, }; } /** * Call a WS for a site plugin. * * @param method WS method to use. * @param data Data to send to the WS. * @param preSets Extra options. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the response. */ async callWS( method: string, data: Record, preSets?: CoreSiteWSPreSets, siteId?: string, ): Promise { const site = await CoreSites.getSite(siteId); preSets = preSets || {}; preSets.cacheKey = preSets.cacheKey || this.getCallWSCacheKey(method, data); return site.read(method, data, preSets); } /** * Given the result of a init get_content and, optionally, the result of another get_content, * build an object with the data to pass to the JS of the get_content. * * @param initResult Result of the init WS call. * @param contentResult Result of the content WS call (if any). * @return An object with the data to pass to the JS. */ createDataForJS( initResult?: CoreSitePluginsContent | null, contentResult?: CoreSitePluginsContent | null, ): Record { let data: Record = {}; if (initResult) { // First of all, add the data returned by the init JS (if any). data = Object.assign(data, initResult.jsResult || {}); // Now add some data returned by the init WS call. data.INIT_TEMPLATES = CoreUtils.objectToKeyValueMap(initResult.templates, 'id', 'html'); data.INIT_OTHERDATA = initResult.otherdata; } if (contentResult) { // Now add the data returned by the content WS call. data.CONTENT_TEMPLATES = CoreUtils.objectToKeyValueMap(contentResult.templates, 'id', 'html'); data.CONTENT_OTHERDATA = contentResult.otherdata; } return data; } /** * Get cache key for a WS call. * * @param method Name of the method. * @param data Data to identify the WS call. * @return Cache key. */ getCallWSCacheKey(method: string, data: Record): string { return this.getCallWSCommonCacheKey(method) + ':' + CoreUtils.sortAndStringify(data); } /** * Get common cache key for a WS call. * * @param method Name of the method. * @return Cache key. */ protected getCallWSCommonCacheKey(method: string): string { return ROOT_CACHE_KEY + 'ws:' + method; } /** * Get a certain content for a site plugin. * * @param component Component where the class is. E.g. mod_assign. * @param method Method to execute in the class. * @param args The params for the method. * @param preSets Extra options. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the result. */ async getContent( component: string, method: string, args?: Record, preSets?: CoreSiteWSPreSets, siteId?: string, ): Promise { this.logger.debug(`Get content for component '${component}' and method '${method}'`); const site = await CoreSites.getSite(siteId); // Add some params that will always be sent. args = args || {}; const argsToSend = await this.addDefaultArgs(args, site); // Now call the WS. const data: CoreSitePluginsGetContentWSParams = { component: component, method: method, args: CoreUtils.objectToArrayOfObjects(argsToSend, 'name', 'value', true), }; preSets = preSets || {}; preSets.cacheKey = this.getContentCacheKey(component, method, args); preSets.updateFrequency = preSets.updateFrequency ?? CoreSite.FREQUENCY_OFTEN; const result = await site.read('tool_mobile_get_content', data, preSets); let otherData: Record = {}; if (result.otherdata) { otherData = > CoreUtils.objectToKeyValueMap(result.otherdata, 'name', 'value'); // Try to parse all properties that could be JSON encoded strings. for (const name in otherData) { const value = otherData[name]; if (typeof value == 'string' && (value[0] == '{' || value[0] == '[')) { otherData[name] = CoreTextUtils.parseJSON(value); } } } return Object.assign(result, { otherdata: otherData }); } /** * Get cache key for get content WS calls. * * @param component Component where the class is. E.g. mod_assign. * @param method Method to execute in the class. * @param args The params for the method. * @return Cache key. */ protected getContentCacheKey(component: string, method: string, args: Record): string { return ROOT_CACHE_KEY + 'content:' + component + ':' + method + ':' + CoreUtils.sortAndStringify(args); } /** * Get the value of a WS param for prefetch. * * @param component The component of the handler. * @param paramName Name of the param as defined by the handler. * @param courseId Course ID (if prefetching a course). * @param module The module object returned by WS (if prefetching a module). * @return The value. */ protected getDownloadParam( component: string, paramName: string, courseId?: number, module?: CoreCourseAnyModuleData, ): [number] | number | undefined { switch (paramName) { case 'courseids': // The WS needs the list of course IDs. Create the list. return [courseId!]; case component + 'id': // The WS needs the instance id. return module && module.instance; default: // No more params supported for now. } } /** * Get the unique name of a handler (plugin + handler). * * @param plugin Data of the plugin. * @param handlerName Name of the handler inside the plugin. * @return Unique name. */ getHandlerUniqueName(plugin: CoreSitePluginsPlugin, handlerName: string): string { return plugin.addon + '_' + handlerName; } /** * Get site plugins for site. * * @param siteId Site ID. * @return Promise resolved with the plugins. */ async getPlugins(siteId?: string): Promise { const site = await CoreSites.getSite(siteId); if (!CoreSitePlugins.isGetContentAvailable(site)) { // Cannot load site plugins, so there's no point to fetch them. return []; } // Get the list of plugins. Try not to use cache. const data = await site.read( 'tool_mobile_get_plugins_supporting_mobile', {}, { getFromCache: false }, ); // Return enabled plugins. return data.plugins.filter((plugin) => this.isSitePluginEnabled(plugin, site)); } /** * Get a site plugin handler. * * @param name Unique name of the handler. * @return Handler. */ getSitePluginHandler(name: string): CoreSitePluginsHandler | undefined { return this.sitePlugins[name]; } /** * Invalidate all WS call to a certain method. * * @param method WS method to use. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when the data is invalidated. */ async invalidateAllCallWSForMethod(method: string, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); await site.invalidateWsCacheForKeyStartingWith(this.getCallWSCommonCacheKey(method)); } /** * Invalidate a WS call. * * @param method WS method to use. * @param data Data to send to the WS. * @param preSets Extra options. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when the data is invalidated. */ async invalidateCallWS( method: string, data: Record, preSets?: CoreSiteWSPreSets, siteId?: string, ): Promise { const site = await CoreSites.getSite(siteId); preSets = preSets || {}; await site.invalidateWsCacheForKey(preSets.cacheKey || this.getCallWSCacheKey(method, data)); } /** * Invalidate a page content. * * @param component Component where the class is. E.g. mod_assign. * @param method Method to execute in the class. * @param args The params for the method. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when the data is invalidated. */ async invalidateContent(component: string, callback: string, args?: Record, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); await site.invalidateWsCacheForKey(this.getContentCacheKey(component, callback, args || {})); } /** * Check if the get content WS is available. * * @param site The site to check. If not defined, current site. */ isGetContentAvailable(site?: CoreSite): boolean { site = site || CoreSites.getCurrentSite(); return !!site?.wsAvailable('tool_mobile_get_content'); } /** * Check if a handler is enabled for a certain course. * * @param courseId Course ID to check. * @param restrictEnrolled If true or undefined, handler is only enabled for courses the user is enrolled in. * @param restrict Users and courses the handler is restricted to. * @return Whether the handler is enabled. */ async isHandlerEnabledForCourse( courseId: number, restrictEnrolled?: boolean, restrict?: CoreSitePluginsContentRestrict, ): Promise { if (restrict?.courses?.indexOf(courseId) == -1) { // Course is not in the list of restricted courses. return false; } if (restrictEnrolled || typeof restrictEnrolled == 'undefined') { // Only enabled for courses the user is enrolled to. Check if the user is enrolled in the course. try { await CoreCourses.getUserCourse(courseId, true); } catch { return false; } } return true; } /** * Check if a handler is enabled for a certain user. * * @param userId User ID to check. * @param restrictCurrent Whether handler is only enabled for current user. * @param restrict Users and courses the handler is restricted to. * @return Whether the handler is enabled. */ isHandlerEnabledForUser(userId: number, restrictCurrent?: boolean, restrict?: CoreSitePluginsContentRestrict): boolean { if (restrictCurrent && userId != CoreSites.getCurrentSite()?.getUserId()) { // Only enabled for current user. return false; } if (restrict?.users?.indexOf(userId) == -1) { // User is not in the list of restricted users. return false; } return true; } /** * Check if a certain plugin is a site plugin and it's enabled in a certain site. * * @param plugin Data of the plugin. * @param site Site affected. * @return Whether it's a site plugin and it's enabled. */ isSitePluginEnabled(plugin: CoreSitePluginsPlugin, site: CoreSite): boolean { if (site.isFeatureDisabled('sitePlugin_' + plugin.component + '_' + plugin.addon) || !plugin.handlers) { return false; } // Site plugin not disabled. Check if it has handlers. if (!plugin.parsedHandlers) { plugin.parsedHandlers = CoreTextUtils.parseJSON( plugin.handlers, null, this.logger.error.bind(this.logger, 'Error parsing site plugin handlers'), ); } return !!(plugin.parsedHandlers && Object.keys(plugin.parsedHandlers).length); } /** * Load other data into args as determined by useOtherData list. * If useOtherData is undefined, it won't add any data. * If useOtherData is an array, it will only copy the properties whose names are in the array. * If useOtherData is any other value, it will copy all the data from otherData to args. * * @param args The current args. * @param otherData All the other data. * @param useOtherData Names of the attributes to include. * @return New args. */ loadOtherDataInArgs( args: Record | undefined, otherData?: Record, useOtherData?: string[] | unknown, ): Record { if (!args) { args = {}; } else { args = CoreUtils.clone(args); } otherData = otherData || {}; if (typeof useOtherData == 'undefined') { // No need to add other data, return args as they are. return args; } else if (Array.isArray(useOtherData)) { // Include only the properties specified in the array. for (const i in useOtherData) { const name = useOtherData[i]; if (typeof otherData[name] == 'object' && otherData[name] !== null) { // Stringify objects. args[name] = JSON.stringify(otherData[name]); } else { args[name] = otherData[name]; } } } else { // Add all the data to args. for (const name in otherData) { if (typeof otherData[name] == 'object' && otherData[name] !== null) { // Stringify objects. args[name] = JSON.stringify(otherData[name]); } else { args[name] = otherData[name]; } } } return args; } /** * Prefetch offline functions for a site plugin handler. * * @param component The component of the handler. * @param args Params to send to the get_content calls. * @param handlerSchema The handler schema. * @param courseId Course ID (if prefetching a course). * @param module The module object returned by WS (if prefetching a module). * @param prefetch True to prefetch, false to download right away. * @param dirPath Path of the directory where to store all the content files. * @param site Site. If not defined, current site. * @return Promise resolved when done. */ async prefetchFunctions( component: string, args: Record, handlerSchema: CoreSitePluginsCourseModuleHandlerData, courseId?: number, module?: CoreCourseAnyModuleData, prefetch?: boolean, dirPath?: string, site?: CoreSite, ): Promise { site = site || CoreSites.getCurrentSite(); if (!site || !handlerSchema.offlinefunctions) { return; } await Promise.all(Object.keys(handlerSchema.offlinefunctions).map(async(method) => { if (site!.wsAvailable(method)) { // The method is a WS. const paramsList = handlerSchema.offlinefunctions![method]; const cacheKey = this.getCallWSCacheKey(method, args); let params: Record = {}; if (!paramsList.length) { // No params defined, send the default ones. params = args; } else { for (const i in paramsList) { const paramName = paramsList[i]; if (typeof args[paramName] != 'undefined') { params[paramName] = args[paramName]; } else { // The param is not one of the default ones. Try to calculate the param to use. const value = this.getDownloadParam(component, paramName, courseId, module); if (typeof value != 'undefined') { params[paramName] = value; } } } } await this.callWS(method, params, { cacheKey }); return; } // It's a method to get content. const preSets: CoreSiteWSPreSets = { component: component, }; if (module) { preSets.componentId = module.id; } const result = await this.getContent(component, method, args, preSets); // Prefetch the files in the content. if (result.files.length) { await CoreFilepool.downloadOrPrefetchFiles( site!.getId(), result.files, !!prefetch, false, component, module?.id, dirPath, ); } })); } /** * Store a site plugin handler. * * @param name A unique name to identify the handler. * @param handler Handler to set. */ setSitePluginHandler(name: string, handler: CoreSitePluginsHandler): void { this.sitePlugins[name] = handler; } /** * Store the promise for a plugin that is being initialised. * * @param component * @param promise */ registerSitePluginPromise(component: string, promise: Promise): void { this.sitePluginPromises[component] = promise; } /** * Set plugins fetched. */ setPluginsFetched(): void { this.fetchPluginsDeferred.resolve(); } /** * Set plugins fetched. */ setPluginsLoaded(loaded?: boolean): void { this.hasSitePluginsLoaded = !!loaded; } /** * Is a plugin being initialised for the specified component? * * @param component */ sitePluginPromiseExists(component: string): boolean { return !!this.sitePluginPromises[component]; } /** * Get the promise for a plugin that is being initialised. * * @param component */ sitePluginLoaded(component: string): Promise | undefined { return this.sitePluginPromises[component]; } /** * Wait for fetch plugins to be done. * * @return Promise resolved when site plugins have been fetched. */ waitFetchPlugins(): Promise { return this.fetchPluginsDeferred.promise; } } export const CoreSitePlugins = makeSingleton(CoreSitePluginsProvider, ['sitePluginsFinishedLoading', 'hasSitePluginsLoaded']); /** * Handler of a site plugin. */ export type CoreSitePluginsHandler = { plugin: CoreSitePluginsPlugin; // Site plugin data. handlerName: string; // Name of the handler. handlerSchema: CoreSitePluginsHandlerData; // Handler's data. initResult?: CoreSitePluginsContent | null; // Result of the init WS call (if any). }; /** * Default args added to site plugins calls. */ export type CoreSitePluginsDefaultArgs = { userid?: number; appid: string; appversioncode: number; appversionname: string; applang: string; appcustomurlscheme: string; appisdesktop: boolean; appismobile: boolean; appiswide: boolean; appplatform: string; }; /** * Params of tool_mobile_get_content WS. */ export type CoreSitePluginsGetContentWSParams = { component: string; // Component where the class is e.g. mod_assign. method: string; // Method to execute in class \$component\output\mobile. args?: { // Args for the method are optional. name: string; // Param name. value: string; // Param value. }[]; }; /** * Data returned by tool_mobile_get_content WS. */ export type CoreSitePluginsGetContentWSResponse = { templates: CoreSitePluginsContentTemplate[]; // Templates required by the generated content. javascript: string; // JavaScript code. otherdata: { // Other data that can be used or manipulated by the template via 2-way data-binding. name: string; // Field name. value: string; // Field value. }[]; files: CoreWSExternalFile[]; restrict: CoreSitePluginsContentRestrict; // Restrict this content to certain users or courses. disabled?: boolean; // Whether we consider this disabled or not. }; /** * Template data returned by tool_mobile_get_content WS. */ export type CoreSitePluginsContentTemplate = { id: string; // ID of the template. html: string; // HTML code. }; /** * Template data returned by tool_mobile_get_content WS. */ export type CoreSitePluginsContentRestrict = { users?: number[]; // List of allowed users. courses?: number[]; // List of allowed courses. }; /** * Data returned by tool_mobile_get_content WS with calculated data. */ export type CoreSitePluginsContentParsed = Omit & { otherdata: Record; // Other data that can be used or manipulated by the template via 2-way data-binding. }; /** * Data returned by tool_mobile_get_content WS with calculated data. */ export type CoreSitePluginsContent = CoreSitePluginsContentParsed & { disabled?: boolean; jsResult?: any; // eslint-disable-line @typescript-eslint/no-explicit-any }; /** * Data returned by tool_mobile_get_plugins_supporting_mobile WS. */ export type CoreSitePluginsGetPluginsSupportingMobileWSResponse = { plugins: CoreSitePluginsWSPlugin[]; warnings?: CoreWSExternalWarning[]; }; /** * Plugin data returned by tool_mobile_get_plugins_supporting_mobile WS. */ export type CoreSitePluginsWSPlugin = { component: string; // The plugin component name. version: string; // The plugin version number. addon: string; // The Mobile addon (package) name. dependencies: string[]; // The list of Mobile addons this addon depends on. fileurl: string; // The addon package url for download or empty if it doesn't exist. filehash: string; // The addon package hash or empty if it doesn't exist. filesize: number; // The addon package size or empty if it doesn't exist. handlers?: string; // Handlers definition (JSON). lang?: string; // Language strings used by the handlers (JSON). }; /** * Plugin data with some calculated data. */ export type CoreSitePluginsPlugin = CoreSitePluginsWSPlugin & { parsedHandlers?: Record | null; parsedLang?: Record | null; }; /** * Plugin handler data. */ export type CoreSitePluginsHandlerData = CoreSitePluginsInitHandlerData | CoreSitePluginsCourseOptionHandlerData | CoreSitePluginsMainMenuHandlerData | CoreSitePluginsCourseModuleHandlerData | CoreSitePluginsCourseFormatHandlerData | CoreSitePluginsUserHandlerData | CoreSitePluginsSettingsHandlerData | CoreSitePluginsMessageOutputHandlerData | CoreSitePluginsBlockHandlerData | CoreSitePluginsMainMenuHomeHandlerData; /** * Plugin handler data common to all delegates. */ export type CoreSitePluginsHandlerCommonData = { delegate?: string; method?: string; init?: string; restricttocurrentuser?: boolean; restricttoenrolledcourses?: boolean; styles?: { url?: string; version?: number; }; moodlecomponent?: string; }; /** * Course option handler specific data. */ export type CoreSitePluginsCourseOptionHandlerData = CoreSitePluginsHandlerCommonData & { displaydata?: { title?: string; class?: string; icon?: string; }; priority?: number; ismenuhandler?: boolean; ptrenabled?: boolean; }; /** * Main menu handler specific data. */ export type CoreSitePluginsMainMenuHandlerData = CoreSitePluginsHandlerCommonData & { displaydata?: { title?: string; icon?: string; class?: string; }; priority?: number; ptrenabled?: boolean; }; /** * Course module handler specific data. */ export type CoreSitePluginsCourseModuleHandlerData = CoreSitePluginsHandlerCommonData & { displaydata?: { icon?: string; class?: string; }; method?: string; offlinefunctions?: Record; downloadbutton?: boolean; isresource?: boolean; updatesnames?: string; displayopeninbrowser?: boolean; displaydescription?: boolean; displayrefresh?: boolean; displayprefetch?: boolean; displaysize?: boolean; coursepagemethod?: string; ptrenabled?: boolean; supportedfeatures?: Record; }; /** * Course format handler specific data. */ export type CoreSitePluginsCourseFormatHandlerData = CoreSitePluginsHandlerCommonData & { canviewallsections?: boolean; displayenabledownload?: boolean; displaysectionselector?: boolean; }; /** * User handler specific data. */ export type CoreSitePluginsUserHandlerData = CoreSitePluginsHandlerCommonData & { displaydata?: { title?: string; icon?: string; class?: string; }; type?: string; priority?: number; ptrenabled?: boolean; }; /** * Settings handler specific data. */ export type CoreSitePluginsSettingsHandlerData = CoreSitePluginsHandlerCommonData & { displaydata?: { title?: string; icon?: string; class?: string; }; priority?: number; ptrenabled?: boolean; }; /** * Message output handler specific data. */ export type CoreSitePluginsMessageOutputHandlerData = CoreSitePluginsHandlerCommonData & { displaydata?: { title?: string; icon?: string; }; priority?: number; ptrenabled?: boolean; }; /** * Block handler specific data. */ export type CoreSitePluginsBlockHandlerData = CoreSitePluginsHandlerCommonData & { displaydata?: { title?: string; class?: string; type?: string; }; fallback?: string; }; /** * Common handler data with some data from the init method. */ export type CoreSitePluginsInitHandlerData = CoreSitePluginsHandlerCommonData & { methodTemplates?: CoreSitePluginsContentTemplate[]; methodJSResult?: any; // eslint-disable-line @typescript-eslint/no-explicit-any methodOtherdata?: Record; }; /** * Main menu home handler specific data. */ export type CoreSitePluginsMainMenuHomeHandlerData = CoreSitePluginsHandlerCommonData & { displaydata?: { title?: string; class?: string; }; priority?: number; ptrenabled?: boolean; };