From 99b665915b5e2914784503e98f6dce6e56143c7a Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 2 Mar 2021 16:18:27 +0100 Subject: [PATCH] MOBILE-3664 siteplugins: Implement services --- .../services/siteplugins-helper.ts | 1135 +++++++++++++++++ .../siteplugins/services/siteplugins.ts | 922 +++++++++++++ 2 files changed, 2057 insertions(+) create mode 100644 src/core/features/siteplugins/services/siteplugins-helper.ts create mode 100644 src/core/features/siteplugins/services/siteplugins.ts diff --git a/src/core/features/siteplugins/services/siteplugins-helper.ts b/src/core/features/siteplugins/services/siteplugins-helper.ts new file mode 100644 index 000000000..3c4c06eeb --- /dev/null +++ b/src/core/features/siteplugins/services/siteplugins-helper.ts @@ -0,0 +1,1135 @@ +// (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 { AddonMessageOutputDelegate } from '@addons/messageoutput/services/messageoutput-delegate'; +import { AddonModAssignFeedbackDelegate } from '@addons/mod/assign/services/feedback-delegate'; +import { AddonModAssignSubmissionDelegate } from '@addons/mod/assign/services/submission-delegate'; +import { AddonModQuizAccessRuleDelegate } from '@addons/mod/quiz/services/access-rules-delegate'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreError } from '@classes/errors/error'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreBlockDelegate } from '@features/block/services/block-delegate'; +import { CoreCompile } from '@features/compile/services/compile'; +import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; +import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreCoursesMyCoursesChangedEventData, CoreCoursesProvider } from '@features/courses/services/courses'; +import { CoreMainMenuDelegate } from '@features/mainmenu/services/mainmenu-delegate'; +import { CoreQuestionBehaviourDelegate } from '@features/question/services/behaviour-delegate'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { CoreSettingsDelegate } from '@features/settings/services/settings-delegate'; +import { CoreUserDelegate } from '@features/user/services/user-delegate'; +import { CoreUserProfileFieldDelegate } from '@features/user/services/user-profile-field-delegate'; +import { CoreFilepool } from '@services/filepool'; +import { CoreLang } from '@services/lang'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWS } from '@services/ws'; +import { CoreEvents } from '@singletons/events'; +import { CoreLogger } from '@singletons/logger'; +import { CoreSitePluginsAssignFeedbackHandler } from '../classes/handlers/assign-feedback-handler'; +import { CoreSitePluginsAssignSubmissionHandler } from '../classes/handlers/assign-submission-handler'; +import { CoreSitePluginsBlockHandler } from '../classes/handlers/block-handler'; +import { CoreSitePluginsCourseFormatHandler } from '../classes/handlers/course-format-handler'; +import { CoreSitePluginsCourseOptionHandler } from '../classes/handlers/course-option-handler'; +import { CoreSitePluginsMainMenuHandler } from '../classes/handlers/main-menu-handler'; +import { CoreSitePluginsMessageOutputHandler } from '../classes/handlers/message-output-handler'; +import { CoreSitePluginsModuleHandler } from '../classes/handlers/module-handler'; +import { CoreSitePluginsModulePrefetchHandler } from '../classes/handlers/module-prefetch-handler'; +import { CoreSitePluginsQuestionBehaviourHandler } from '../classes/handlers/question-behaviour-handler'; +import { CoreSitePluginsQuestionHandler } from '../classes/handlers/question-handler'; +import { CoreSitePluginsQuizAccessRuleHandler } from '../classes/handlers/quiz-access-rule-handler'; +import { CoreSitePluginsSettingsHandler } from '../classes/handlers/settings-handler'; +import { CoreSitePluginsUserProfileHandler } from '../classes/handlers/user-handler'; +import { CoreSitePluginsUserProfileFieldHandler } from '../classes/handlers/user-profile-field-handler'; +import { + CoreSitePlugins, + CoreSitePluginsContent, + CoreSitePluginsPlugin, + CoreSitePluginsHandlerData, + CoreSitePluginsProvider, + CoreSitePluginsCourseOptionHandlerData, + CoreSitePluginsMainMenuHandlerData, + CoreSitePluginsCourseModuleHandlerData, + CoreSitePluginsCourseFormatHandlerData, + CoreSitePluginsUserHandlerData, + CoreSitePluginsSettingsHandlerData, + CoreSitePluginsMessageOutputHandlerData, + CoreSitePluginsBlockHandlerData, + CoreSitePluginsHandlerCommonData, + CoreSitePluginsInitHandlerData, +} from './siteplugins'; +import { makeSingleton } from '@singletons'; + +const HANDLER_DISABLED = 'core_site_plugins_helper_handler_disabled'; + +/** + * Helper service to provide functionalities regarding site plugins. It basically has the features to load and register site + * plugin. + * + * This code is split from CoreSitePluginsProvider to prevent circular dependencies. + * + * @todo: Support ViewChild and similar in site plugins. Possible solution: make components and directives inject the instance + * inside the host DOM element? + */ +@Injectable({ providedIn: 'root' }) +export class CoreSitePluginsHelperProvider { + + protected logger: CoreLogger; + protected courseRestrictHandlers: Record = {}; + + constructor() { + this.logger = CoreLogger.getInstance('CoreSitePluginsHelperProvider'); + } + + /** + * Initialize. + */ + initialize(): void { + // Fetch the plugins on login. + CoreEvents.on(CoreEvents.LOGIN, async (data) => { + try { + const plugins = await CoreUtils.ignoreErrors(CoreSitePlugins.getPlugins(data.siteId)); + + // Plugins fetched, check that site hasn't changed. + if (data.siteId != CoreSites.getCurrentSiteId() || !plugins?.length) { + return; + } + + // Site is still the same. Load the plugins and trigger the event. + try { + await this.loadSitePlugins(plugins); + } finally { + CoreEvents.trigger(CoreEvents.SITE_PLUGINS_LOADED, {}, data.siteId); + } + } catch (error) { + this.logger.error(error); + } finally { + CoreSitePlugins.setPluginsFetched(); + } + }); + + // Unload plugins on logout if any. + CoreEvents.on(CoreEvents.LOGOUT, () => { + if (CoreSitePlugins.hasSitePluginsLoaded) { + // Temporary fix. Reload the page to unload all plugins. + window.location.reload(); + } + }); + + // Re-load plugins restricted for courses when the list of user courses changes. + CoreEvents.on(CoreCoursesProvider.EVENT_MY_COURSES_CHANGED, (data) => { + if (data && data.siteId && data.siteId == CoreSites.getCurrentSiteId() && data.added && data.added.length) { + this.reloadCourseRestrictHandlers(); + } + }); + } + + /** + * Download the styles for a handler (if any). + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @param siteId Site ID. If not provided, current site. + * @return Promise resolved with the CSS code. + */ + async downloadStyles( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsHandlerData, + siteId?: string, + ): Promise { + const site = await CoreSites.getSite(siteId); + + // Get the absolute URL. If it's a relative URL, add the site URL to it. + let url = handlerSchema.styles?.url; + if (url && !CoreUrlUtils.isAbsoluteURL(url)) { + url = CoreTextUtils.concatenatePaths(site.getURL(), url); + } + + if (url && handlerSchema.styles?.version) { + // Add the version to the URL to prevent getting a cached file. + url += (url.indexOf('?') != -1 ? '&' : '?') + 'version=' + handlerSchema.styles.version; + } + + const uniqueName = CoreSitePlugins.getHandlerUniqueName(plugin, handlerName); + const componentId = uniqueName + '#main'; + + // Remove the CSS files for this handler that aren't used anymore. Don't block the call for this. + const files = await CoreUtils.ignoreErrors( + CoreFilepool.getFilesByComponent(site.getId(), CoreSitePluginsProvider.COMPONENT, componentId), + ); + + files?.forEach((file) => { + if (file.url != url) { + // It's not the current file, delete it. + CoreUtils.ignoreErrors(CoreFilepool.removeFileByUrl(site.getId(), file.url)); + } + }); + + if (!url) { + // No styles. + return ''; + } + + // Download the file if not downloaded or the version changed. + const path = await CoreFilepool.downloadUrl( + site.getId(), + url, + false, + CoreSitePluginsProvider.COMPONENT, + componentId, + 0, + undefined, + undefined, + undefined, + handlerSchema.styles!.version, + ); + + // File is downloaded, get the contents. + return CoreWS.getText(path); + } + + /** + * Execute a handler's init method if it has any. + * + * @param plugin Data of the plugin. + * @param handlerSchema Data about the handler. + * @return Promise resolved when done. It returns the results of the getContent call and the data returned by + * the init JS (if any). + */ + protected async executeHandlerInit( + plugin: CoreSitePluginsPlugin, + handlerSchema: CoreSitePluginsHandlerData, + ): Promise { + if (!handlerSchema.init) { + return null; + } + + return this.executeMethodAndJS(plugin, handlerSchema.init, true); + } + + /** + * Execute a get_content method and run its javascript (if any). + * + * @param plugin Data of the plugin. + * @param method The method to call. + * @param isInit Whether it's the init method. + * @return Promise resolved with the results of the getContent call and the data returned by the JS (if any). + */ + protected async executeMethodAndJS( + plugin: CoreSitePluginsPlugin, + method: string, + isInit?: boolean, + ): Promise { + const siteId = CoreSites.getCurrentSiteId(); + const preSets: CoreSiteWSPreSets = { + getFromCache: false, // Try to ignore cache. + deleteCacheIfWSError: isInit, // If the init WS call returns an exception we won't use cached data. + }; + + const result = await CoreSitePlugins.getContent(plugin.component, method, {}, preSets); + + if (!result.javascript || CoreSites.getCurrentSiteId() != siteId) { + // No javascript or site has changed, stop. + return result; + } + + // Create a "fake" instance to hold all the libraries. + const instance = { + // eslint-disable-next-line @typescript-eslint/naming-convention + HANDLER_DISABLED: HANDLER_DISABLED, + }; + CoreCompile.injectLibraries(instance); + + // Add some data of the WS call result. + const jsData = CoreSitePlugins.createDataForJS(result); + for (const name in jsData) { + instance[name] = jsData[name]; + } + + // Now execute the javascript using this instance. + result.jsResult = CoreCompile.executeJavascript(instance, result.javascript); + + if (result.jsResult == HANDLER_DISABLED) { + // The "disabled" field was added in 3.8, this is a workaround for previous versions. + result.disabled = true; + } + + return result; + } + + /** + * Fetch site plugins. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. Returns the list of plugins to load. + * @deprecated since 3.9.5. The function was moved to CoreSitePlugins.getPlugins. + */ + async fetchSitePlugins(siteId?: string): Promise { + return CoreSitePlugins.getPlugins(siteId); + } + + /** + * Given an addon name, return the prefix to add to its string keys. + * + * @param addon Name of the addon (plugin.addon). + * @return Prefix. + */ + protected getPrefixForStrings(addon: string): string { + if (addon) { + return 'plugin.' + addon + '.'; + } + + return ''; + } + + /** + * Given an addon name and the key of a string, return the full string key (prefixed). + * + * @param addon Name of the addon (plugin.addon). + * @param key The key of the string. + * @return Full string key. + */ + protected getPrefixedString(addon: string, key: string): string { + return this.getPrefixForStrings(addon) + key; + } + + /** + * 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. + * @deprecated since 3.9.5. The function was moved to CoreSitePlugins.isSitePluginEnabled. + */ + isSitePluginEnabled(plugin: CoreSitePluginsPlugin, site: CoreSite): boolean { + return CoreSitePlugins.isSitePluginEnabled(plugin, site); + } + + /** + * Load the lang strings for a plugin. + * + * @param plugin Data of the plugin. + */ + loadLangStrings(plugin: CoreSitePluginsPlugin): void { + if (!plugin.parsedLang) { + return; + } + + for (const lang in plugin.parsedLang) { + const prefix = this.getPrefixForStrings(plugin.addon); + + CoreLang.addSitePluginsStrings(lang, plugin.parsedLang[lang], prefix); + } + } + + /** + * Load a site plugin. + * + * @param plugin Data of the plugin. + * @return Promise resolved when loaded. + */ + async loadSitePlugin(plugin: CoreSitePluginsPlugin): Promise { + this.logger.debug('Load site plugin:', plugin); + + if (!plugin.parsedHandlers && plugin.handlers) { + plugin.parsedHandlers = CoreTextUtils.parseJSON( + plugin.handlers, + null, + this.logger.error.bind(this.logger, 'Error parsing site plugin handlers'), + ); + } + + if (!plugin.parsedLang && plugin.lang) { + plugin.parsedLang = CoreTextUtils.parseJSON( + plugin.lang, + null, + this.logger.error.bind(this.logger, 'Error parsing site plugin lang'), + ); + } + + CoreSitePlugins.setPluginsLoaded(true); + + // Register lang strings. + this.loadLangStrings(plugin); + + if (plugin.parsedHandlers) { + // Register all the handlers. + await CoreUtils.allPromises(Object.keys(plugin.parsedHandlers).map(async (name) => { + await this.registerHandler(plugin, name, plugin.parsedHandlers![name]); + })); + } + } + + /** + * Load site plugins. + * + * @param plugins The plugins to load. + * @return Promise resolved when loaded. + */ + async loadSitePlugins(plugins: CoreSitePluginsPlugin[]): Promise { + this.courseRestrictHandlers = {}; + + await CoreUtils.allPromises(plugins.map(async (plugin) => { + const pluginPromise = this.loadSitePlugin(plugin); + CoreSitePlugins.registerSitePluginPromise(plugin.component, pluginPromise); + + await pluginPromise; + })); + } + + /** + * Load the styles for a handler. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param fileUrl CSS file URL. + * @param cssCode CSS code. + * @param version Styles version. + * @param siteId Site ID. If not provided, current site. + */ + loadStyles( + plugin: CoreSitePluginsPlugin, + handlerName: string, + fileUrl: string, + cssCode: string, + version?: number, + siteId?: string, + ): void { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Create the style and add it to the header. + const styleEl = document.createElement('style'); + const uniqueName = CoreSitePlugins.getHandlerUniqueName(plugin, handlerName); + + styleEl.setAttribute('id', 'siteplugin-' + uniqueName); + styleEl.innerHTML = cssCode; + + document.head.appendChild(styleEl); + + // Styles have been loaded, now treat the CSS. + CoreUtils.ignoreErrors( + CoreFilepool.treatCSSCode(siteId, fileUrl, cssCode, CoreSitePluginsProvider.COMPONENT, uniqueName, version), + ); + } + + /** + * Register a site plugin handler in the right delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @return Promise resolved when done. + */ + async registerHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsHandlerData, + ): Promise { + // Wait for the init JS to be executed and for the styles to be downloaded. + const siteId = CoreSites.getCurrentSiteId(); + + try { + const [initResult, cssCode] = await Promise.all([ + this.executeHandlerInit(plugin, handlerSchema), + this.downloadStyles(plugin, handlerName, handlerSchema, siteId).catch((error) => { + this.logger.error('Error getting styles for plugin', handlerName, handlerSchema, error); + }), + ]); + + if (initResult?.disabled) { + // This handler is disabled for the current user, stop. + this.logger.warn('Handler disabled by init function', plugin, handlerSchema); + + return; + } + + if (cssCode) { + // Load the styles. + this.loadStyles(plugin, handlerName, handlerSchema.styles!.url!, cssCode, handlerSchema.styles!.version, siteId); + } + + let uniqueName: string | undefined; + + switch (handlerSchema.delegate) { + case 'CoreMainMenuDelegate': + uniqueName = await this.registerMainMenuHandler(plugin, handlerName, handlerSchema, initResult); + break; + + case 'CoreCourseModuleDelegate': + uniqueName = await this.registerModuleHandler(plugin, handlerName, handlerSchema, initResult); + break; + + case 'CoreUserDelegate': + uniqueName = await this.registerUserProfileHandler(plugin, handlerName, handlerSchema, initResult); + break; + + case 'CoreCourseOptionsDelegate': + uniqueName = await this.registerCourseOptionHandler(plugin, handlerName, handlerSchema, initResult); + break; + + case 'CoreCourseFormatDelegate': + uniqueName = await this.registerCourseFormatHandler(plugin, handlerName, handlerSchema); + break; + + case 'CoreUserProfileFieldDelegate': + uniqueName = await this.registerUserProfileFieldHandler(plugin, handlerName, handlerSchema); + break; + + case 'CoreSettingsDelegate': + uniqueName = await this.registerSettingsHandler(plugin, handlerName, handlerSchema, initResult); + break; + + case 'CoreQuestionDelegate': + uniqueName = await this.registerQuestionHandler(plugin, handlerName, handlerSchema); + break; + + case 'CoreQuestionBehaviourDelegate': + uniqueName = await this.registerQuestionBehaviourHandler(plugin, handlerName, handlerSchema); + break; + + case 'CoreBlockDelegate': + uniqueName = await this.registerBlockHandler(plugin, handlerName, handlerSchema, initResult); + break; + + case 'AddonMessageOutputDelegate': + uniqueName = await this.registerMessageOutputHandler(plugin, handlerName, handlerSchema, initResult); + break; + + case 'AddonModQuizAccessRuleDelegate': + uniqueName = await this.registerQuizAccessRuleHandler(plugin, handlerName, handlerSchema); + break; + + case 'AddonModAssignFeedbackDelegate': + uniqueName = await this.registerAssignFeedbackHandler(plugin, handlerName, handlerSchema); + break; + + case 'AddonModAssignSubmissionDelegate': + uniqueName = await this.registerAssignSubmissionHandler(plugin, handlerName, handlerSchema); + break; + + case 'AddonWorkshopAssessmentStrategyDelegate': + uniqueName = await this.registerWorkshopAssessmentStrategyHandler(plugin, handlerName, handlerSchema); + break; + + default: + // Nothing to do. + } + + if (uniqueName) { + // Store the handler data. + CoreSitePlugins.setSitePluginHandler(uniqueName, { + plugin: plugin, + handlerName: handlerName, + handlerSchema: handlerSchema, + initResult, + }); + } + } catch (error) { + throw new CoreError('Error executing init method ' + handlerSchema.init + ': ' + error.message); + } + } + + /** + * Register a handler that relies in a "componentInit" function in a certain delegate. + * These type of handlers will return a generic template and its JS in the main method, so it will be called + * before registering the handler. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @return A promise resolved with a string to identify the handler. + */ + protected async registerComponentInitHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsInitHandlerData, + delegate: CoreDelegate, + createHandlerFn: (uniqueName: string, result: CoreSitePluginsContent) => T, + ): Promise { + + if (!handlerSchema.method) { + // Required data not provided, stop. + this.logger.warn('Ignore site plugin because it doesn\'t provide method', plugin, handlerSchema); + + return; + } + + this.logger.debug('Register site plugin', plugin, handlerSchema); + + try { + // Execute the main method and its JS. The template returned will be used in the right component. + const result = await this.executeMethodAndJS(plugin, handlerSchema.method); + + // Create and register the handler. + const uniqueName = CoreSitePlugins.getHandlerUniqueName(plugin, handlerName); + const handler = createHandlerFn(uniqueName, result); + + // Store in handlerSchema some data required by the component. + handlerSchema.methodTemplates = result.templates; + handlerSchema.methodJSResult = result.jsResult; + handlerSchema.methodOtherdata = result.otherdata; + + if (result.jsResult) { + // Override default handler functions with the result of the method JS. + const jsResult = > result.jsResult; + for (const property in handler) { + if (property != 'constructor' && typeof handler[property] == 'function' && + typeof jsResult[property] == 'function') { + // eslint-disable-next-line @typescript-eslint/ban-types + handler[property] = ( jsResult[property]).bind(handler); + } + } + } + + delegate.registerHandler(handler); + + return uniqueName; + } catch (error) { + this.logger.error('Error executing main method', plugin.component, handlerSchema.method, error); + } + } + + /** + * Given a handler in a plugin, register it in the assign feedback delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @return Promise resolved with a string to identify the handler. + */ + protected registerAssignFeedbackHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsHandlerCommonData, + ): Promise { + + return this.registerComponentInitHandler( + plugin, + handlerName, + handlerSchema, + AddonModAssignFeedbackDelegate.instance, + (uniqueName) => { + const type = (handlerSchema.moodlecomponent || plugin.component).replace('assignfeedback_', ''); + const prefix = this.getPrefixForStrings(plugin.addon); + + return new CoreSitePluginsAssignFeedbackHandler(uniqueName, type, prefix); + }, + ); + } + + /** + * Given a handler in a plugin, register it in the assign submission delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @return Promise resolved with a string to identify the handler. + */ + protected registerAssignSubmissionHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsHandlerCommonData, + ): Promise { + + return this.registerComponentInitHandler( + plugin, + handlerName, + handlerSchema, + AddonModAssignSubmissionDelegate.instance, + (uniqueName) => { + const type = (handlerSchema.moodlecomponent || plugin.component).replace('assignsubmission_', ''); + const prefix = this.getPrefixForStrings(plugin.addon); + + return new CoreSitePluginsAssignSubmissionHandler(uniqueName, type, prefix); + }, + ); + } + + /** + * Given a handler in a plugin, register it in the block delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @param initResult Result of init function. + * @return A string to identify the handler. + */ + protected registerBlockHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsBlockHandlerData, + initResult: CoreSitePluginsContent | null, + ): string | undefined { + + const uniqueName = CoreSitePlugins.getHandlerUniqueName(plugin, handlerName); + const blockName = (handlerSchema.moodlecomponent || plugin.component).replace('block_', ''); + const titleString = handlerSchema.displaydata?.title ?? 'pluginname'; + const prefixedTitle = this.getPrefixedString(plugin.addon, titleString); + + CoreBlockDelegate.registerHandler( + new CoreSitePluginsBlockHandler(uniqueName, prefixedTitle, blockName, handlerSchema, initResult), + ); + + return uniqueName; + } + + /** + * Given a handler in a plugin, register it in the course format delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @return A string to identify the handler. + */ + protected registerCourseFormatHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsCourseFormatHandlerData, + ): string { + this.logger.debug('Register site plugin in course format delegate:', plugin, handlerSchema); + + // Create and register the handler. + const uniqueName = CoreSitePlugins.getHandlerUniqueName(plugin, handlerName); + const formatName = (handlerSchema.moodlecomponent || plugin.component).replace('format_', ''); + CoreCourseFormatDelegate.registerHandler( + new CoreSitePluginsCourseFormatHandler(uniqueName, formatName, handlerSchema), + ); + + return uniqueName; + } + + /** + * Given a handler in a plugin, register it in the course options delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @param initResult Result of the init WS call. + * @return A string to identify the handler. + */ + protected registerCourseOptionHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsCourseOptionHandlerData, + initResult: CoreSitePluginsContent | null, + ): string | undefined { + if (!handlerSchema.displaydata) { + // Required data not provided, stop. + this.logger.warn('Ignore site plugin because it doesn\'t provide displaydata', plugin, handlerSchema); + + return; + } + + this.logger.debug('Register site plugin in course option delegate:', plugin, handlerSchema, initResult); + + // Create and register the handler. + const uniqueName = CoreSitePlugins.getHandlerUniqueName(plugin, handlerName); + const prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title || 'pluginname'); + const handler = new CoreSitePluginsCourseOptionHandler( + uniqueName, + prefixedTitle, + plugin, + handlerSchema, + initResult, + ); + + CoreCourseOptionsDelegate.registerHandler(handler); + + if (initResult?.restrict?.courses) { + // This handler is restricted to certan courses, store it in the list. + this.courseRestrictHandlers[uniqueName] = { + plugin, + handlerName, + handlerSchema, + handler, + }; + } + + return uniqueName; + } + + /** + * Given a handler in a plugin, register it in the main menu delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @param initResult Result of the init WS call. + * @return A string to identify the handler. + */ + protected registerMainMenuHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsMainMenuHandlerData, + initResult: CoreSitePluginsContent | null, + ): string | undefined { + if (!handlerSchema.displaydata) { + // Required data not provided, stop. + this.logger.warn('Ignore site plugin because it doesn\'t provide displaydata', plugin, handlerSchema); + + return; + } + + this.logger.debug('Register site plugin in main menu delegate:', plugin, handlerSchema, initResult); + + // Create and register the handler. + const uniqueName = CoreSitePlugins.getHandlerUniqueName(plugin, handlerName); + const prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title || 'pluginname'); + + CoreMainMenuDelegate.registerHandler( + new CoreSitePluginsMainMenuHandler(uniqueName, prefixedTitle, plugin, handlerSchema, initResult), + ); + + return uniqueName; + } + + /** + * Given a handler in a plugin, register it in the message output delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @param initResult Result of the init WS call. + * @return A string to identify the handler. + */ + protected registerMessageOutputHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsMessageOutputHandlerData, + initResult: CoreSitePluginsContent | null, + ): string | undefined { + if (!handlerSchema.displaydata) { + // Required data not provided, stop. + this.logger.warn('Ignore site plugin because it doesn\'t provide displaydata', plugin, handlerSchema); + + return; + } + + this.logger.debug('Register site plugin in message output delegate:', plugin, handlerSchema, initResult); + + // Create and register the handler. + const uniqueName = CoreSitePlugins.getHandlerUniqueName(plugin, handlerName); + const prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title || 'pluginname'); + const processorName = (handlerSchema.moodlecomponent || plugin.component).replace('message_', ''); + + AddonMessageOutputDelegate.registerHandler( + new CoreSitePluginsMessageOutputHandler(uniqueName, processorName, prefixedTitle, plugin, handlerSchema, initResult), + ); + + return uniqueName; + } + + /** + * Given a handler in a plugin, register it in the module delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @param initResult Result of the init WS call. + * @return A string to identify the handler. + */ + protected registerModuleHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsCourseModuleHandlerData, + initResult: CoreSitePluginsContent | null, + ): string | undefined { + if (!handlerSchema.displaydata) { + // Required data not provided, stop. + this.logger.warn('Ignore site plugin because it doesn\'t provide displaydata', plugin, handlerSchema); + + return; + } + + this.logger.debug('Register site plugin in module delegate:', plugin, handlerSchema, initResult); + + // Create and register the handler. + const uniqueName = CoreSitePlugins.getHandlerUniqueName(plugin, handlerName); + const modName = (handlerSchema.moodlecomponent || plugin.component).replace('mod_', ''); + + CoreCourseModuleDelegate.registerHandler( + new CoreSitePluginsModuleHandler(uniqueName, modName, plugin, handlerSchema, initResult), + ); + + if (handlerSchema.offlinefunctions && Object.keys(handlerSchema.offlinefunctions).length) { + // Register the prefetch handler. + CoreCourseModulePrefetchDelegate.registerHandler( + new CoreSitePluginsModulePrefetchHandler(plugin.component, uniqueName, modName, handlerSchema), + ); + } + + return uniqueName; + } + + /** + * Given a handler in a plugin, register it in the question delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @return Promise resolved with a string to identify the handler. + */ + protected registerQuestionHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsHandlerCommonData, + ): Promise { + const component = handlerSchema.moodlecomponent || plugin.component; + + return this.registerComponentInitHandler( + plugin, + handlerName, + handlerSchema, + CoreQuestionDelegate.instance, + (uniqueName) => new CoreSitePluginsQuestionHandler(uniqueName, component), + ); + } + + /** + * Given a handler in a plugin, register it in the question behaviour delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @return Promise resolved with a string to identify the handler. + */ + protected registerQuestionBehaviourHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsHandlerCommonData, + ): Promise { + + return this.registerComponentInitHandler( + plugin, + handlerName, + handlerSchema, + CoreQuestionBehaviourDelegate.instance, + (uniqueName, result) => { + const type = (handlerSchema.moodlecomponent || plugin.component).replace('qbehaviour_', ''); + + return new CoreSitePluginsQuestionBehaviourHandler(uniqueName, type, !!result.templates.length); + }, + ); + } + + /** + * Given a handler in a plugin, register it in the quiz access rule delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @return Promise resolved with a string to identify the handler. + */ + protected registerQuizAccessRuleHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsHandlerCommonData, + ): Promise { + const component = handlerSchema.moodlecomponent || plugin.component; + + return this.registerComponentInitHandler( + plugin, + handlerName, + handlerSchema, + AddonModQuizAccessRuleDelegate.instance, + (uniqueName, result) => new CoreSitePluginsQuizAccessRuleHandler(uniqueName, component, !!result.templates.length), + ); + } + + /** + * Given a handler in a plugin, register it in the settings delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @param initResult Result of the init WS call. + * @return A string to identify the handler. + */ + protected registerSettingsHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsSettingsHandlerData, + initResult: CoreSitePluginsContent | null, + ): string | undefined { + if (!handlerSchema.displaydata) { + // Required data not provided, stop. + this.logger.warn('Ignore site plugin because it doesn\'t provide displaydata', plugin, handlerSchema); + + return; + } + + this.logger.debug('Register site plugin in settings delegate:', plugin, handlerSchema, initResult); + + // Create and register the handler. + const uniqueName = CoreSitePlugins.getHandlerUniqueName(plugin, handlerName); + const prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title || 'pluginname'); + + CoreSettingsDelegate.registerHandler( + new CoreSitePluginsSettingsHandler(uniqueName, prefixedTitle, plugin, handlerSchema, initResult), + ); + + return uniqueName; + } + + /** + * Given a handler in a plugin, register it in the user profile delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @param initResult Result of the init WS call. + * @return A string to identify the handler. + */ + protected registerUserProfileHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsUserHandlerData, + initResult: CoreSitePluginsContent | null, + ): string | undefined { + if (!handlerSchema.displaydata) { + // Required data not provided, stop. + this.logger.warn('Ignore site plugin because it doesn\'t provide displaydata', plugin, handlerSchema); + + return; + } + + this.logger.debug('Register site plugin in user profile delegate:', plugin, handlerSchema, initResult); + + // Create and register the handler. + const uniqueName = CoreSitePlugins.getHandlerUniqueName(plugin, handlerName); + const prefixedTitle = this.getPrefixedString(plugin.addon, handlerSchema.displaydata.title || 'pluginname'); + const handler = new CoreSitePluginsUserProfileHandler(uniqueName, prefixedTitle, plugin, handlerSchema, initResult); + + CoreUserDelegate.registerHandler(handler); + + if (initResult && initResult.restrict && initResult.restrict.courses) { + // This handler is restricted to certan courses, store it in the list. + this.courseRestrictHandlers[uniqueName] = { + plugin, + handlerName, + handlerSchema, + handler, + }; + } + + return uniqueName; + } + + /** + * Given a handler in a plugin, register it in the user profile field delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @return Promise resolved with a string to identify the handler. + */ + protected registerUserProfileFieldHandler( + plugin: CoreSitePluginsPlugin, + handlerName: string, + handlerSchema: CoreSitePluginsHandlerCommonData, + ): Promise { + + return this.registerComponentInitHandler( + plugin, + handlerName, + handlerSchema, + CoreUserProfileFieldDelegate.instance, + (uniqueName) => { + const fieldType = (handlerSchema.moodlecomponent || plugin.component).replace('profilefield_', ''); + + return new CoreSitePluginsUserProfileFieldHandler(uniqueName, fieldType); + }, + ); + } + + /** + * Given a handler in a plugin, register it in the workshop assessment strategy delegate. + * + * @param plugin Data of the plugin. + * @param handlerName Name of the handler in the plugin. + * @param handlerSchema Data about the handler. + * @return Promise resolved with a string to identify the handler. + * @todo + */ + protected registerWorkshopAssessmentStrategyHandler( + plugin: CoreSitePluginsPlugin, // eslint-disable-line @typescript-eslint/no-unused-vars + handlerName: string, // eslint-disable-line @typescript-eslint/no-unused-vars + handlerSchema: CoreSitePluginsHandlerCommonData, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + // @todo + return Promise.resolve(''); + + // return this.registerComponentInitHandler( + // plugin, + // handlerName, + // handlerSchema, + // this.workshopAssessmentStrategyDelegate, + // (uniqueName, result) => { + // const strategyName = (handlerSchema.moodlecomponent || plugin.component).replace('workshopform_', ''); + + // return new CoreSitePluginsWorkshopAssessmentStrategyHandler(uniqueName, strategyName); + // }, + // ); + } + + /** + * Reload the handlers that are restricted to certain courses. + * + * @return Promise resolved when done. + */ + protected async reloadCourseRestrictHandlers(): Promise { + if (!Object.keys(this.courseRestrictHandlers).length) { + // No course restrict handlers, nothing to do. + return; + } + + await Promise.all(Object.keys(this.courseRestrictHandlers).map(async (name) => { + const data = this.courseRestrictHandlers[name]; + + if (!data.handler || !data.handler.setInitResult) { + // No handler or it doesn't implement a required function, ignore it. + return; + } + + // Mark the handler as being updated. + data.handler.updatingInit && data.handler.updatingInit(); + + try { + const initResult = await this.executeHandlerInit(data.plugin, data.handlerSchema); + + data.handler.setInitResult(initResult); + } catch (error) { + this.logger.error('Error reloading course restrict handler', error, data.plugin); + } + })); + + CoreEvents.trigger(CoreEvents.SITE_PLUGINS_COURSE_RESTRICT_UPDATED, {}); + } + +} + +export const CoreSitePluginsHelper = makeSingleton(CoreSitePluginsHelperProvider); diff --git a/src/core/features/siteplugins/services/siteplugins.ts b/src/core/features/siteplugins/services/siteplugins.ts new file mode 100644 index 000000000..1110755ef --- /dev/null +++ b/src/core/features/siteplugins/services/siteplugins.ts @@ -0,0 +1,922 @@ +// (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; + +/** + * 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; +};