From dd43b9460bbc44e2abffd074f3053f12e074308f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pau=20Ferrer=20Oca=C3=B1a?= Date: Thu, 12 Nov 2020 10:09:32 +0100 Subject: [PATCH] MOBILE-3565 contentlinks: Add content links delegate structure --- .../core/contentlinks/classes/base-handler.ts | 116 +++++++ .../classes/module-grade-handler.ts | 114 +++++++ .../classes/module-index-handler.ts | 106 ++++++ .../classes/module-list-handler.ts | 73 +++++ src/app/core/contentlinks/lang/en.json | 8 + .../pages/choose-site/choose-site.html | 31 ++ .../choose-site/choose-site.page.module.ts | 47 +++ .../pages/choose-site/choose-site.page.ts | 122 +++++++ .../services/contentlinks.delegate.ts | 309 ++++++++++++++++++ .../services/contentlinks.helper.ts | 246 ++++++++++++++ .../core/settings/services/settings.helper.ts | 9 +- src/app/directives/external-content.ts | 2 +- src/assets/lang/en.json | 6 + 13 files changed, 1184 insertions(+), 5 deletions(-) create mode 100644 src/app/core/contentlinks/classes/base-handler.ts create mode 100644 src/app/core/contentlinks/classes/module-grade-handler.ts create mode 100644 src/app/core/contentlinks/classes/module-index-handler.ts create mode 100644 src/app/core/contentlinks/classes/module-list-handler.ts create mode 100644 src/app/core/contentlinks/lang/en.json create mode 100644 src/app/core/contentlinks/pages/choose-site/choose-site.html create mode 100644 src/app/core/contentlinks/pages/choose-site/choose-site.page.module.ts create mode 100644 src/app/core/contentlinks/pages/choose-site/choose-site.page.ts create mode 100644 src/app/core/contentlinks/services/contentlinks.delegate.ts create mode 100644 src/app/core/contentlinks/services/contentlinks.helper.ts diff --git a/src/app/core/contentlinks/classes/base-handler.ts b/src/app/core/contentlinks/classes/base-handler.ts new file mode 100644 index 000000000..8cf153fda --- /dev/null +++ b/src/app/core/contentlinks/classes/base-handler.ts @@ -0,0 +1,116 @@ +// (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 { Params } from '@angular/router'; +import { CoreContentLinksHandler, CoreContentLinksAction } from '../services/contentlinks.delegate'; + +/** + * Base handler to be registered in CoreContentLinksHandler. It is useful to minimize the amount of + * functions that handlers need to implement. + * + * It allows you to specify a "pattern" (RegExp) that will be used to check if the handler handles a URL and to get its site URL. + */ +export class CoreContentLinksHandlerBase implements CoreContentLinksHandler { + + /** + * A name to identify the handler. + */ + name = 'CoreContentLinksHandlerBase'; + + /** + * Handler's priority. The highest priority is treated first. + */ + priority = 0; + + /** + * Whether the isEnabled function should be called for all the users in a site. It should be true only if the isEnabled call + * can return different values for different users in same site. + */ + checkAllUsers = false; + + /** + * Name of the feature this handler is related to. + * It will be used to check if the feature is disabled (@see CoreSite.isFeatureDisabled). + */ + featureName = ''; + + /** + * A pattern to use to detect if the handler handles a URL and to get its site URL. Required if "handles" and + * "getSiteUrl" functions aren't overridden. + */ + pattern?: RegExp; + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return List of (or promise resolved with list of) actions. + */ + getActions( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + siteIds: string[], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + url: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + params: Params, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + courseId?: number, + ): CoreContentLinksAction[] | Promise { + return []; + } + + /** + * Check if a URL is handled by this handler. + * + * @param url The URL to check. + * @return Whether the URL is handled by this handler + */ + handles(url: string): boolean { + return !!this.pattern && url.search(this.pattern) >= 0; + } + + /** + * If the URL is handled by this handler, return the site URL. + * + * @param url The URL to check. + * @return Site URL if it is handled, undefined otherwise. + */ + getSiteUrl(url: string): string | undefined { + if (this.pattern) { + const position = url.search(this.pattern); + if (position > -1) { + return url.substr(0, position); + } + } + } + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return Whether the handler is enabled for the URL and site. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isEnabled(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise { + return true; + } + +} diff --git a/src/app/core/contentlinks/classes/module-grade-handler.ts b/src/app/core/contentlinks/classes/module-grade-handler.ts new file mode 100644 index 000000000..d48da8311 --- /dev/null +++ b/src/app/core/contentlinks/classes/module-grade-handler.ts @@ -0,0 +1,114 @@ +// (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 { CoreContentLinksAction } from '../services/contentlinks.delegate'; +import { CoreContentLinksHandlerBase } from './base-handler'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +// import { CoreCourseHelperProvider } from '@core/course/providers/helper'; +import { Params } from '@angular/router'; + +/** + * Handler to handle URLs pointing to the grade of a module. + */ +export class CoreContentLinksModuleGradeHandler extends CoreContentLinksHandlerBase { + + /** + * Whether the module can be reviewed in the app. If true, the handler needs to implement the goToReview function. + */ + canReview = false; + + /** + * If this boolean is set to true, the app will retrieve all modules with this modName with a single WS call. + * This reduces the number of WS calls, but it isn't recommended for modules that can return a lot of contents. + */ + protected useModNameToGetModule = false; + + /** + * Construct the handler. + * + * @param addon Name of the addon as it's registered in course delegate. It'll be used to check if it's disabled. + * @param modName Name of the module (assign, book, ...). + */ + constructor( + public addon: string, + public modName: string, + ) { + super(); + + // Match the grade.php URL with an id param. + this.pattern = new RegExp('/mod/' + modName + '/grade.php.*([&?]id=\\d+)'); + this.featureName = 'CoreCourseModuleDelegate_' + addon; + } + + /** + * Get the list of actions for a link (url). + * + * @param siteIds Unused. List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return List of (or promise resolved with list of) actions. + */ + getActions( + siteIds: string[], + url: string, + params: Params, + courseId?: number, + ): CoreContentLinksAction[] | Promise { + + courseId = courseId || params.courseid || params.cid; + + return [{ + action: async (siteId): Promise => { + // Check if userid is the site's current user. + const modal = await CoreDomUtils.instance.showModalLoading(); + const site = await CoreSites.instance.getSite(siteId); + if (!params.userid || params.userid == site.getUserId()) { + // No user specified or current user. Navigate to module. + // @todo this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined, + // this.useModNameToGetModule ? this.modName : undefined, undefined, navCtrl); + } else if (this.canReview) { + // Use the goToReview function. + this.goToReview(url, params, courseId!, siteId); + } else { + // Not current user and cannot review it in the app, open it in browser. + site.openInBrowserWithAutoLogin(url); + } + + modal.dismiss(); + }, + }]; + } + + /** + * Go to the page to review. + * + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. + * @param siteId Site to use. + * @return Promise resolved when done. + */ + protected async goToReview( + url: string, // eslint-disable-line @typescript-eslint/no-unused-vars + params: Params, // eslint-disable-line @typescript-eslint/no-unused-vars + courseId: number, // eslint-disable-line @typescript-eslint/no-unused-vars + siteId: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + // This function should be overridden. + return; + } + +} diff --git a/src/app/core/contentlinks/classes/module-index-handler.ts b/src/app/core/contentlinks/classes/module-index-handler.ts new file mode 100644 index 000000000..a6fee0916 --- /dev/null +++ b/src/app/core/contentlinks/classes/module-index-handler.ts @@ -0,0 +1,106 @@ +// (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 { CoreContentLinksHandlerBase } from './base-handler'; +import { Params } from '@angular/router'; +import { CoreContentLinksAction } from '../services/contentlinks.delegate'; + +/** + * Handler to handle URLs pointing to the index of a module. + */ +export class CoreContentLinksModuleIndexHandler extends CoreContentLinksHandlerBase { + + /** + * If this boolean is set to true, the app will retrieve all modules with this modName with a single WS call. + * This reduces the number of WS calls, but it isn't recommended for modules that can return a lot of contents. + */ + protected useModNameToGetModule = false; + + /** + * Construct the handler. + * + * @param addon Name of the addon as it's registered in course delegate. It'll be used to check if it's disabled. + * @param modName Name of the module (assign, book, ...). + * @param instanceIdParam Param name for instance ID gathering. Only if set. + */ + constructor( + public addon: string, + public modName: string, + protected instanceIdParam?: string, + ) { + super(); + + const pattern = instanceIdParam ? + '/mod/' + modName + '/view.php.*([&?](' + instanceIdParam + '|id)=\\d+)' : + '/mod/' + modName + '/view.php.*([&?]id=\\d+)'; + + // Match the view.php URL with an id param. + this.pattern = new RegExp(pattern); + this.featureName = 'CoreCourseModuleDelegate_' + addon; + } + + /** + * Get the mod params necessary to open an activity. + * + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return List of params to pass to navigateToModule / navigateToModuleByInstance. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getPageParams(url: string, params: Params, courseId?: number): Params { + return []; + } + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return List of (or promise resolved with list of) actions. + */ + getActions( + siteIds: string[], // eslint-disable-line @typescript-eslint/no-unused-vars + url: string, // eslint-disable-line @typescript-eslint/no-unused-vars + params: Params, // eslint-disable-line @typescript-eslint/no-unused-vars + courseId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars + ): CoreContentLinksAction[] | Promise { + return []; + /* + courseId = courseId || params.courseid || params.cid; + const pageParams = this.getPageParams(url, params, courseId); + + if (this.instanceIdParam && typeof params[this.instanceIdParam] != 'undefined') { + const instanceId = parseInt(params[this.instanceIdParam], 10); + + return [{ + action: (siteId): void => { + this.courseHelper.navigateToModuleByInstance(instanceId, this.modName, siteId, courseId, undefined, + this.useModNameToGetModule, pageParams); + }, + }]; + } + + return [{ + action: (siteId): void => { + this.courseHelper.navigateToModule(parseInt(params.id, 10), siteId, courseId, undefined, + this.useModNameToGetModule ? this.modName : undefined, pageParams); + }, + }]; + */ + } + +} diff --git a/src/app/core/contentlinks/classes/module-list-handler.ts b/src/app/core/contentlinks/classes/module-list-handler.ts new file mode 100644 index 000000000..b206af783 --- /dev/null +++ b/src/app/core/contentlinks/classes/module-list-handler.ts @@ -0,0 +1,73 @@ +// (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 { CoreContentLinksHelper } from '../services/contentlinks.helper'; +import { CoreContentLinksHandlerBase } from './base-handler'; +import { Translate } from '@/app/singletons/core.singletons'; +import { Params } from '@angular/router'; +import { CoreContentLinksAction } from '../services/contentlinks.delegate'; + +/** + * Handler to handle URLs pointing to a list of a certain type of modules. + */ +export class CoreContentLinksModuleListHandler extends CoreContentLinksHandlerBase { + + /** + * The title to use in the new page. If not defined, the app will try to calculate it. + */ + protected title = ''; + + /** + * Construct the handler. + * + * @param linkHelper The CoreContentLinksHelperProvider instance. + * @param translate The TranslateService instance. + * @param addon Name of the addon as it's registered in course delegate. It'll be used to check if it's disabled. + * @param modName Name of the module (assign, book, ...). + */ + constructor( + public addon: string, + public modName: string, + ) { + super(); + + // Match the view.php URL with an id param. + this.pattern = new RegExp('/mod/' + modName + '/index.php.*([&?]id=\\d+)'); + this.featureName = 'CoreCourseModuleDelegate_' + addon; + } + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @return List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise { + + return [{ + action: (siteId): void => { + const stateParams = { + courseId: params.id, + modName: this.modName, + title: this.title || Translate.instance.instant('addon.mod_' + this.modName + '.modulenameplural'), + }; + + CoreContentLinksHelper.instance.goInSite('CoreCourseListModTypePage @todo', stateParams, siteId); + }, + }]; + } + +} diff --git a/src/app/core/contentlinks/lang/en.json b/src/app/core/contentlinks/lang/en.json new file mode 100644 index 000000000..460c6acac --- /dev/null +++ b/src/app/core/contentlinks/lang/en.json @@ -0,0 +1,8 @@ +{ + "chooseaccount": "Choose account", + "chooseaccounttoopenlink": "Choose an account to open the link with.", + "confirmurlothersite": "This link belongs to another site. Do you want to open it?", + "errornoactions": "Couldn't find an action to perform with this link.", + "errornosites": "Couldn't find any site to handle this link.", + "errorredirectothersite": "The redirect URL cannot point to a different site." +} \ No newline at end of file diff --git a/src/app/core/contentlinks/pages/choose-site/choose-site.html b/src/app/core/contentlinks/pages/choose-site/choose-site.html new file mode 100644 index 000000000..c88916023 --- /dev/null +++ b/src/app/core/contentlinks/pages/choose-site/choose-site.html @@ -0,0 +1,31 @@ + + + + + + {{ 'core.contentlinks.chooseaccount' | translate }} + + + + + + +

{{ 'core.contentlinks.chooseaccounttoopenlink' | translate }}

+

{{ url }}

+
+ + + {{ 'core.pictureof' | translate:{$a: site.fullName} }} + +

{{site.fullName}}

+

+

{{site.siteUrl}}

+
+ + {{ 'core.login.cancel' | translate }} + +
+
+
diff --git a/src/app/core/contentlinks/pages/choose-site/choose-site.page.module.ts b/src/app/core/contentlinks/pages/choose-site/choose-site.page.module.ts new file mode 100644 index 000000000..8f9983f31 --- /dev/null +++ b/src/app/core/contentlinks/pages/choose-site/choose-site.page.module.ts @@ -0,0 +1,47 @@ +// (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 { NgModule } from '@angular/core'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; + +import { CoreComponentsModule } from '@components/components.module'; +import { CoreDirectivesModule } from '@directives/directives.module'; + +import { CoreContentLinksChooseSitePage } from './choose-site.page'; + +const routes: Routes = [ + { + path: '', + component: CoreContentLinksChooseSitePage, + }, +]; + +@NgModule({ + declarations: [ + CoreContentLinksChooseSitePage, + ], + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + ], +}) +export class CoreContentLinksChooseSitePageModule {} diff --git a/src/app/core/contentlinks/pages/choose-site/choose-site.page.ts b/src/app/core/contentlinks/pages/choose-site/choose-site.page.ts new file mode 100644 index 000000000..d5802ab1c --- /dev/null +++ b/src/app/core/contentlinks/pages/choose-site/choose-site.page.ts @@ -0,0 +1,122 @@ +// (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 { Component, OnInit } from '@angular/core'; +import { NavController } from '@ionic/angular'; +import { CoreSiteBasicInfo, CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { Translate } from '@singletons/core.singletons'; +import { CoreLoginHelper } from '@core/login/services/helper'; +import { CoreContentLinksAction } from '../../services/contentlinks.delegate'; +import { CoreContentLinksHelper } from '../../services/contentlinks.helper'; +import { ActivatedRoute } from '@angular/router'; +import { CoreError } from '@classes/errors/error'; + +/** + * Page to display the list of sites to choose one to perform a content link action. + * + * @todo Include routing and testing. + */ +@Component({ + selector: 'page-core-content-links-choose-site', + templateUrl: 'choose-site.html', +}) +export class CoreContentLinksChooseSitePage implements OnInit { + + url: string; + sites: CoreSiteBasicInfo[] = []; + loaded = false; + protected action?: CoreContentLinksAction; + protected isRootURL = false; + + constructor( + route: ActivatedRoute, + protected navCtrl: NavController, + ) { + this.url = route.snapshot.queryParamMap.get('url')!; + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + if (!this.url) { + return this.leaveView(); + } + + let siteIds: string[] | undefined = []; + + try { + // Check if it's the root URL. + const data = await CoreSites.instance.isStoredRootURL(this.url); + if (data.site) { + // It's the root URL. + this.isRootURL = true; + + siteIds = data.siteIds; + } else if (data.siteIds.length) { + // Not root URL, but the URL belongs to at least 1 site. Check if there is any action to treat the link. + this.action = await CoreContentLinksHelper.instance.getFirstValidActionFor(this.url); + if (!this.action) { + throw new CoreError(Translate.instance.instant('core.contentlinks.errornoactions')); + } + + siteIds = this.action.sites; + } else { + // No sites to treat the URL. + throw new CoreError(Translate.instance.instant('core.contentlinks.errornosites')); + } + + // Get the sites that can perform the action. + this.sites = await CoreSites.instance.getSites(siteIds); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.contentlinks.errornosites', true); + this.leaveView(); + } + + this.loaded = true; + } + + /** + * Cancel. + */ + cancel(): void { + this.leaveView(); + } + + /** + * Perform the action on a certain site. + * + * @param siteId Site ID. + */ + siteClicked(siteId: string): void { + if (this.isRootURL) { + CoreLoginHelper.instance.redirect('', {}, siteId); + } else if (this.action) { + this.action.action(siteId); + } + } + + /** + * Cancel and leave the view. + */ + protected async leaveView(): Promise { + try { + await CoreSites.instance.logout(); + } finally { + await this.navCtrl.navigateRoot('/login/sites'); + } + } + +} diff --git a/src/app/core/contentlinks/services/contentlinks.delegate.ts b/src/app/core/contentlinks/services/contentlinks.delegate.ts new file mode 100644 index 000000000..3ed6d250a --- /dev/null +++ b/src/app/core/contentlinks/services/contentlinks.delegate.ts @@ -0,0 +1,309 @@ +// (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 { CoreLogger } from '@singletons/logger'; +import { CoreSites } from '@services/sites'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { Params } from '@angular/router'; + +/** + * Interface that all handlers must implement. + */ +export interface CoreContentLinksHandler { + /** + * A name to identify the handler. + */ + name: string; + + /** + * Handler's priority. The highest priority is treated first. + */ + priority?: number; + + /** + * Whether the isEnabled function should be called for all the users in a site. It should be true only if the isEnabled call + * can return different values for different users in same site. + */ + checkAllUsers?: boolean; + + /** + * Name of the feature this handler is related to. + * It will be used to check if the feature is disabled (@see CoreSite.isFeatureDisabled). + */ + featureName?: string; + + /** + * Get the list of actions for a link (url). + * + * @param siteIds List of sites the URL belongs to. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @param data Extra data to handle the URL. + * @return List of (or promise resolved with list of) actions. + */ + getActions(siteIds: string[], url: string, params: Params, courseId?: number, data?: unknown): + CoreContentLinksAction[] | Promise; + + /** + * Check if a URL is handled by this handler. + * + * @param url The URL to check. + * @return Whether the URL is handled by this handler + */ + handles(url: string): boolean; + + /** + * If the URL is handled by this handler, return the site URL. + * + * @param url The URL to check. + * @return Site URL if it is handled, undefined otherwise. + */ + getSiteUrl(url: string): string | undefined; + + /** + * Check if the handler is enabled for a certain site (site + user) and a URL. + * If not defined, defaults to true. + * + * @param siteId The site ID. + * @param url The URL to treat. + * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1} + * @param courseId Course ID related to the URL. Optional but recommended. + * @return Whether the handler is enabled for the URL and site. + */ + isEnabled?(siteId: string, url: string, params: Params, courseId?: number): boolean | Promise; +} + +/** + * Action to perform when a link is clicked. + */ +export interface CoreContentLinksAction { + /** + * A message to identify the action. Default: 'core.view'. + */ + message?: string; + + /** + * Name of the icon of the action. Default: 'fas-eye'. + */ + icon?: string; + + /** + * IDs of the sites that support the action. + */ + sites?: string[]; + + /** + * Action to perform when the link is clicked. + * + * @param siteId The site ID. + */ + action(siteId: string): void; +} + +/** + * Actions and priority for a handler and URL. + */ +export interface CoreContentLinksHandlerActions { + /** + * Handler's priority. + */ + priority: number; + + /** + * List of actions. + */ + actions: CoreContentLinksAction[]; +} + +/** + * Delegate to register handlers to handle links. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreContentLinksDelegate { + + protected logger: CoreLogger; + protected handlers: { [s: string]: CoreContentLinksHandler } = {}; // All registered handlers. + + constructor() { + this.logger = CoreLogger.getInstance('CoreContentLinksDelegate'); + } + + /** + * Get the list of possible actions to do for a URL. + * + * @param url URL to handle. + * @param courseId Course ID related to the URL. Optional but recommended. + * @param username Username to use to filter sites. + * @param data Extra data to handle the URL. + * @return Promise resolved with the actions. + */ + async getActionsFor(url: string, courseId?: number, username?: string, data?: unknown): Promise { + if (!url) { + return []; + } + + // Get the list of sites the URL belongs to. + const siteIds = await CoreSites.instance.getSiteIdsFromUrl(url, true, username); + const linkActions: CoreContentLinksHandlerActions[] = []; + const promises: Promise[] = []; + const params = CoreUrlUtils.instance.extractUrlParams(url); + for (const name in this.handlers) { + const handler = this.handlers[name]; + const checkAll = handler.checkAllUsers; + const isEnabledFn = this.isHandlerEnabled.bind(this, handler, url, params, courseId); + + if (!handler.handles(url)) { + // Invalid handler or it doesn't handle the URL. Stop. + continue; + } + + // Filter the site IDs using the isEnabled function. + promises.push(CoreUtils.instance.filterEnabledSites(siteIds, isEnabledFn, checkAll).then(async (siteIds) => { + if (!siteIds.length) { + // No sites supported, no actions. + return; + } + + const actions = await handler.getActions(siteIds, url, params, courseId, data); + + if (actions && actions.length) { + // Set default values if any value isn't supplied. + actions.forEach((action) => { + action.message = action.message || 'core.view'; + action.icon = action.icon || 'fas-eye'; + action.sites = action.sites || siteIds; + }); + + // Add them to the list. + linkActions.push({ + priority: handler.priority || 0, + actions: actions, + }); + } + + return; + })); + } + try { + await CoreUtils.instance.allPromises(promises); + } catch { + // Ignore errors. + } + + // Sort link actions by priority. + return this.sortActionsByPriority(linkActions); + } + + /** + * Get the site URL if the URL is supported by any handler. + * + * @param url URL to handle. + * @return Site URL if the URL is supported by any handler, undefined otherwise. + */ + getSiteUrl(url: string): string | void { + if (!url) { + return; + } + + // Check if any handler supports this URL. + for (const name in this.handlers) { + const handler = this.handlers[name]; + const siteUrl = handler.getSiteUrl(url); + + if (siteUrl) { + return siteUrl; + } + } + } + + /** + * Check if a handler is enabled for a certain site and URL. + * + * @param handler Handler to check. + * @param url The URL to check. + * @param params The params of the URL + * @param courseId Course ID the URL belongs to (can be undefined). + * @param siteId The site ID to check. + * @return Promise resolved with boolean: whether the handler is enabled. + */ + protected async isHandlerEnabled( + handler: CoreContentLinksHandler, + url: string, + params: Params, + courseId: number, + siteId: string, + ): Promise { + + let disabled = false; + if (handler.featureName) { + // Check if the feature is disabled. + disabled = await CoreSites.instance.isFeatureDisabled(handler.featureName, siteId); + } + + if (disabled) { + return false; + } + + if (!handler.isEnabled) { + // Handler doesn't implement isEnabled, assume it's enabled. + return true; + } + + return handler.isEnabled(siteId, url, params, courseId); + } + + /** + * Register a handler. + * + * @param handler The handler to register. + * @return True if registered successfully, false otherwise. + */ + registerHandler(handler: CoreContentLinksHandler): boolean { + if (typeof this.handlers[handler.name] !== 'undefined') { + this.logger.log(`Addon '${handler.name}' already registered`); + + return false; + } + this.logger.log(`Registered addon '${handler.name}'`); + this.handlers[handler.name] = handler; + + return true; + } + + /** + * Sort actions by priority. + * + * @param actions Actions to sort. + * @return Sorted actions. + */ + protected sortActionsByPriority(actions: CoreContentLinksHandlerActions[]): CoreContentLinksAction[] { + let sorted: CoreContentLinksAction[] = []; + + // Sort by priority. + actions = actions.sort((a, b) => (a.priority || 0) <= (b.priority || 0) ? 1 : -1); + + // Fill result array. + actions.forEach((entry) => { + sorted = sorted.concat(entry.actions); + }); + + return sorted; + } + +} diff --git a/src/app/core/contentlinks/services/contentlinks.helper.ts b/src/app/core/contentlinks/services/contentlinks.helper.ts new file mode 100644 index 000000000..ab6ab9cbc --- /dev/null +++ b/src/app/core/contentlinks/services/contentlinks.helper.ts @@ -0,0 +1,246 @@ +// (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 { NavController } from '@ionic/angular'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreLoginHelper } from '@core/login/services/helper'; +import { CoreContentLinksDelegate, CoreContentLinksAction } from './contentlinks.delegate'; +import { CoreSite } from '@classes/site'; +import { CoreMainMenu } from '@core/mainmenu/services/mainmenu'; +import { makeSingleton, NgZone, Translate } from '@singletons/core.singletons'; +import { Params } from '@angular/router'; + +/** + * Service that provides some features regarding content links. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreContentLinksHelperProvider { + + constructor( + protected contentLinksDelegate: CoreContentLinksDelegate, + protected navCtrl: NavController, + ) { } + + /** + * Check whether a link can be handled by the app. + * + * @param url URL to handle. + * @param courseId Unused param: Course ID related to the URL. + * @param username Username to use to filter sites. + * @param checkRoot Whether to check if the URL is the root URL of a site. + * @return Promise resolved with a boolean: whether the URL can be handled. + */ + async canHandleLink(url: string, courseId?: number, username?: string, checkRoot?: boolean): Promise { + try { + if (checkRoot) { + const data = await CoreSites.instance.isStoredRootURL(url, username); + + if (data.site) { + // URL is the root of the site, can handle it. + return true; + } + } + + const action = await this.getFirstValidActionFor(url, undefined, username); + + return !!action; + } catch { + return false; + } + } + + /** + * Get the first valid action in the list of possible actions to do for a URL. + * + * @param url URL to handle. + * @param courseId Course ID related to the URL. Optional but recommended. + * @param username Username to use to filter sites. + * @param data Extra data to handle the URL. + * @return Promise resolved with the first valid action. Returns undefined if no valid action found.. + */ + async getFirstValidActionFor( + url: string, + courseId?: number, + username?: string, + data?: unknown, + ): Promise { + const actions = await this.contentLinksDelegate.getActionsFor(url, courseId, username, data); + if (!actions) { + return; + } + + return actions.find((action) => action && action.sites && action.sites.length); + } + + /** + * Goes to a certain page in a certain site. If the site is current site it will perform a regular navigation, + * otherwise it will 'redirect' to the other site. + * + * @param pageName Name of the page to go. + * @param pageParams Params to send to the page. + * @param siteId Site ID. If not defined, current site. + * @param checkMenu If true, check if the root page of a main menu tab. Only the page name will be checked. + * @return Promise resolved when done. + */ + goInSite( + pageName: string, + pageParams: Params, + siteId?: string, + checkMenu?: boolean, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const deferred = CoreUtils.instance.promiseDefer(); + + // Execute the code in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(async () => { + try { + if (siteId == CoreSites.instance.getCurrentSiteId()) { + if (checkMenu) { + let isInMenu = false; + // Check if the page is in the main menu. + try { + isInMenu = await CoreMainMenu.instance.isCurrentMainMenuHandler(pageName); + } catch { + isInMenu = false; + } + + if (isInMenu) { + // Just select the tab. @todo test. + CoreLoginHelper.instance.loadPageInMainMenu(pageName, pageParams); + } else { + await this.navCtrl.navigateForward(pageName, { queryParams: pageParams }); + } + } else { + await this.navCtrl.navigateForward(pageName, { queryParams: pageParams }); + } + } else { + await CoreLoginHelper.instance.redirect(pageName, pageParams, siteId); + } + + deferred.resolve(); + } catch (error) { + deferred.reject(error); + } + }); + + return deferred.promise; + } + + /** + * Go to the page to choose a site. + * + * @param url URL to treat. + * @todo set correct root. + */ + async goToChooseSite(url: string): Promise { + await this.navCtrl.navigateRoot('CoreContentLinksChooseSitePage @todo', { queryParams: { url } }); + } + + /** + * Handle a link. + * + * @param url URL to handle. + * @param username Username related with the URL. E.g. in 'http://myuser@m.com', url would be 'http://m.com' and + * the username 'myuser'. Don't use it if you don't want to filter by username. + * @param checkRoot Whether to check if the URL is the root URL of a site. + * @param openBrowserRoot Whether to open in browser if it's root URL and it belongs to current site. + * @return Promise resolved with a boolean: true if URL was treated, false otherwise. + */ + async handleLink( + url: string, + username?: string, + checkRoot?: boolean, + openBrowserRoot?: boolean, + ): Promise { + try { + if (checkRoot) { + const data = await CoreSites.instance.isStoredRootURL(url, username); + + if (data.site) { + // URL is the root of the site. + this.handleRootURL(data.site, openBrowserRoot); + + return true; + } + } + + // Check if the link should be treated by some component/addon. + const action = await this.getFirstValidActionFor(url, undefined, username); + if (!action) { + return false; + } + if (!CoreSites.instance.isLoggedIn()) { + // No current site. Perform the action if only 1 site found, choose the site otherwise. + if (action.sites?.length == 1) { + action.action(action.sites[0]); + } else { + this.goToChooseSite(url); + } + } else if (action.sites?.length == 1 && action.sites[0] == CoreSites.instance.getCurrentSiteId()) { + // Current site. + action.action(action.sites[0]); + } else { + try { + // Not current site or more than one site. Ask for confirmation. + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.contentlinks.confirmurlothersite')); + if (action.sites?.length == 1) { + action.action(action.sites[0]); + } else { + this.goToChooseSite(url); + } + } catch { + // User canceled. + } + } + + return true; + } catch { + // Ignore errors. + } + + return false; + } + + /** + * Handle a root URL of a site. + * + * @param site Site to handle. + * @param openBrowserRoot Whether to open in browser if it's root URL and it belongs to current site. + * @param checkToken Whether to check that token is the same to verify it's current site. If false or not defined, + * only the URL will be checked. + * @return Promise resolved when done. + */ + async handleRootURL(site: CoreSite, openBrowserRoot?: boolean, checkToken?: boolean): Promise { + const currentSite = CoreSites.instance.getCurrentSite(); + + if (currentSite && currentSite.getURL() == site.getURL() && (!checkToken || currentSite.getToken() == site.getToken())) { + // Already logged in. + if (openBrowserRoot) { + return site.openInBrowserWithAutoLogin(site.getURL()); + } + } else { + // Login in the site. + return CoreLoginHelper.instance.redirect('', {}, site.getId()); + } + } + +} + +export class CoreContentLinksHelper extends makeSingleton(CoreContentLinksHelperProvider) {} diff --git a/src/app/core/settings/services/settings.helper.ts b/src/app/core/settings/services/settings.helper.ts index dc6094397..ecef516b3 100644 --- a/src/app/core/settings/services/settings.helper.ts +++ b/src/app/core/settings/services/settings.helper.ts @@ -26,6 +26,7 @@ import { CoreConfig } from '@services/config'; import { CoreDomUtils } from '@services/utils/dom'; // import { CoreCourseProvider } from '@core/course/providers/course'; import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { CoreError } from '@classes/errors/error'; /** * Object with space usage and cache entries that can be erased. @@ -281,12 +282,12 @@ export class CoreSettingsHelperProvider { if (site.isLoggedOut()) { // Cannot sync logged out sites. - throw Translate.instance.instant('core.settings.cannotsyncloggedout'); + throw new CoreError(Translate.instance.instant('core.settings.cannotsyncloggedout')); } else if (hasSyncHandlers && !CoreApp.instance.isOnline()) { // We need connection to execute sync. - throw Translate.instance.instant('core.settings.cannotsyncoffline'); + throw new CoreError(Translate.instance.instant('core.settings.cannotsyncoffline')); } else if (hasSyncHandlers && syncOnlyOnWifi && CoreApp.instance.isNetworkAccessLimited()) { - throw Translate.instance.instant('core.settings.cannotsyncwithoutwifi'); + throw new CoreError(Translate.instance.instant('core.settings.cannotsyncwithoutwifi')); } const syncPromise = Promise.all([ @@ -329,7 +330,7 @@ export class CoreSettingsHelperProvider { // Local mobile was added. Throw invalid session to force reconnect and create a new token. CoreEvents.trigger(CoreEvents.SESSION_EXPIRED, {}, site.getId()); - throw Translate.instance.instant('core.lostconnection'); + throw new CoreError(Translate.instance.instant('core.lostconnection')); } /** diff --git a/src/app/directives/external-content.ts b/src/app/directives/external-content.ts index dac1bbfd3..5af8ae90d 100644 --- a/src/app/directives/external-content.ts +++ b/src/app/directives/external-content.ts @@ -231,7 +231,7 @@ export class CoreExternalContentDirective implements AfterViewInit, OnChanges { if (!site.canDownloadFiles() && CoreUrlUtils.instance.isPluginFileUrl(url)) { this.element.parentElement?.removeChild(this.element); // Remove element since it'll be broken. - throw 'Site doesn\'t allow downloading files.'; + throw new CoreError('Site doesn\'t allow downloading files.'); } // Download images, tracks and posters if size is unknown. diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 494c43ebf..0e1c8e0aa 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -353,6 +353,12 @@ "core.considereddigitalminor": "You are too young to create an account on this site.", "core.content": "Content", "core.contenteditingsynced": "The content you are editing has been synced.", + "core.contentlinks.chooseaccount": "Choose account", + "core.contentlinks.chooseaccounttoopenlink": "Choose an account to open the link with.", + "core.contentlinks.confirmurlothersite": "This link belongs to another site. Do you want to open it?", + "core.contentlinks.errornoactions": "Couldn't find an action to perform with this link.", + "core.contentlinks.errornosites": "Couldn't find any site to handle this link.", + "core.contentlinks.errorredirectothersite": "The redirect URL cannot point to a different site.", "core.continue": "Continue", "core.copiedtoclipboard": "Text copied to clipboard", "core.copytoclipboard": "Copy to clipboard",