diff --git a/src/addons/mod/h5pactivity/components/components.module.ts b/src/addons/mod/h5pactivity/components/components.module.ts new file mode 100644 index 000000000..f0dc5a0ca --- /dev/null +++ b/src/addons/mod/h5pactivity/components/components.module.ts @@ -0,0 +1,37 @@ +// (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 { CoreSharedModule } from '@/core/shared.module'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { AddonModH5PActivityIndexComponent } from './index'; +import { CoreH5PComponentsModule } from '@features/h5p/components/components.module'; + +@NgModule({ + declarations: [ + AddonModH5PActivityIndexComponent, + ], + imports: [ + CoreSharedModule, + CoreCourseComponentsModule, + CoreH5PComponentsModule, + ], + providers: [ + ], + exports: [ + AddonModH5PActivityIndexComponent, + ], +}) +export class AddonModH5PActivityComponentsModule {} diff --git a/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html b/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html new file mode 100644 index 000000000..38ded23d6 --- /dev/null +++ b/src/addons/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} + + + + + + + + + {{ 'core.h5p.offlinedisabled' | translate }} {{ 'addon.mod_h5pactivity.offlinedisabledwarning' | translate }} + + + + + + + + + + {{ 'addon.mod_h5pactivity.previewmode' | translate }} + + + + + + + {{ stateMessage | translate }} + + + + + {{ 'addon.mod_h5pactivity.downloadh5pfile' | translate }} + + + + + + +

{{ progressMessage | translate }}

+ +
+
+
+ + + +
diff --git a/src/addons/mod/h5pactivity/components/index/index.ts b/src/addons/mod/h5pactivity/components/index/index.ts new file mode 100644 index 000000000..43c57aaa8 --- /dev/null +++ b/src/addons/mod/h5pactivity/components/index/index.ts @@ -0,0 +1,495 @@ +// (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, Optional, OnInit, OnDestroy } from '@angular/core'; +import { IonContent } from '@ionic/angular'; + +import { CoreConstants } from '@/core/constants'; +import { CoreSite } from '@classes/site'; +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreH5PDisplayOptions } from '@features/h5p/classes/core'; +import { CoreH5PHelper } from '@features/h5p/classes/helper'; +import { CoreH5P } from '@features/h5p/services/h5p'; +import { CoreXAPIOffline } from '@features/xapi/services/offline'; +import { CoreXAPI } from '@features/xapi/services/xapi'; +import { CoreApp } from '@services/app'; +import { CoreFilepool } from '@services/filepool'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreWSExternalFile } from '@services/ws'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { + AddonModH5PActivity, + AddonModH5PActivityAccessInfo, + AddonModH5PActivityData, + AddonModH5PActivityProvider, +} from '../../services/h5pactivity'; +import { + AddonModH5PActivitySync, + AddonModH5PActivitySyncProvider, + AddonModH5PActivitySyncResult, +} from '../../services/h5pactivity-sync'; + +/** + * Component that displays an H5P activity entry page. + */ +@Component({ + selector: 'addon-mod-h5pactivity-index', + templateUrl: 'addon-mod-h5pactivity-index.html', +}) +export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit, OnDestroy { + + component = AddonModH5PActivityProvider.COMPONENT; + moduleName = 'h5pactivity'; + + h5pActivity?: AddonModH5PActivityData; // The H5P activity object. + accessInfo?: AddonModH5PActivityAccessInfo; // Info about the user capabilities. + deployedFile?: CoreWSExternalFile; // The H5P deployed file. + + stateMessage?: string; // Message about the file state. + downloading = false; // Whether the H5P file is being downloaded. + needsDownload = false; // Whether the file needs to be downloaded. + percentage?: string; // Download/unzip percentage. + showPercentage = false; // Whether to show the percentage. + progressMessage?: string; // Message about download/unzip. + playing = false; // Whether the package is being played. + displayOptions?: CoreH5PDisplayOptions; // Display options for the package. + onlinePlayerUrl?: string; // URL to play the package in online. + fileUrl?: string; // The fileUrl to use to play the package. + state?: string; // State of the file. + siteCanDownload = false; + trackComponent?: string; // Component for tracking. + hasOffline = false; + isOpeningPage = false; + + protected fetchContentDefaultError = 'addon.mod_h5pactivity.errorgetactivity'; + protected syncEventName = AddonModH5PActivitySyncProvider.AUTO_SYNCED; + protected site: CoreSite; + protected observer?: CoreEventObserver; + protected messageListenerFunction: (event: MessageEvent) => Promise; + + constructor( + protected content?: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModH5PActivityIndexComponent', content, courseContentsPage); + + this.site = CoreSites.getCurrentSite()!; + this.siteCanDownload = this.site.canDownloadFiles() && !CoreH5P.isOfflineDisabledInSite(); + + // Listen for messages from the iframe. + this.messageListenerFunction = this.onIframeMessage.bind(this); + window.addEventListener('message', this.messageListenerFunction); + } + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + this.loadContent(); + } + + /** + * @inheritdoc + */ + protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + try { + this.h5pActivity = await AddonModH5PActivity.getH5PActivity(this.courseId, this.module.id, { + siteId: this.siteId, + }); + + this.dataRetrieved.emit(this.h5pActivity); + this.description = this.h5pActivity.intro; + this.displayOptions = CoreH5PHelper.decodeDisplayOptions(this.h5pActivity.displayoptions); + + if (sync) { + await this.syncActivity(showErrors); + } + + await Promise.all([ + this.checkHasOffline(), + this.fetchAccessInfo(), + this.fetchDeployedFileData(), + ]); + + this.trackComponent = this.accessInfo?.cansubmit ? AddonModH5PActivityProvider.TRACK_COMPONENT : ''; + + if (this.h5pActivity.package && this.h5pActivity.package[0]) { + // The online player should use the original file, not the trusted one. + this.onlinePlayerUrl = CoreH5P.h5pPlayer.calculateOnlinePlayerUrl( + this.site.getURL(), + this.h5pActivity.package[0].fileurl, + this.displayOptions, + this.trackComponent, + ); + } + + if (!this.siteCanDownload || this.state == CoreConstants.DOWNLOADED) { + // Cannot download the file or already downloaded, play the package directly. + this.play(); + + } else if ((this.state == CoreConstants.NOT_DOWNLOADED || this.state == CoreConstants.OUTDATED) && CoreApp.isOnline() && + this.deployedFile?.filesize && CoreFilepool.shouldDownload(this.deployedFile.filesize)) { + // Package is small, download it automatically. Don't block this function for this. + this.downloadAutomatically(); + } + } finally { + this.fillContextMenu(refresh); + } + } + + /** + * Fetch the access info and store it in the right variables. + * + * @return Promise resolved when done. + */ + protected async checkHasOffline(): Promise { + this.hasOffline = await CoreXAPIOffline.contextHasStatements(this.h5pActivity!.context, this.siteId); + } + + /** + * Fetch the access info and store it in the right variables. + * + * @return Promise resolved when done. + */ + protected async fetchAccessInfo(): Promise { + this.accessInfo = await AddonModH5PActivity.getAccessInformation(this.h5pActivity!.id, { + cmId: this.module.id, + siteId: this.siteId, + }); + } + + /** + * Fetch the deployed file data if needed and store it in the right variables. + * + * @return Promise resolved when done. + */ + protected async fetchDeployedFileData(): Promise { + if (!this.siteCanDownload) { + // Cannot download the file, no need to fetch the file data. + return; + } + + this.deployedFile = await AddonModH5PActivity.getDeployedFile(this.h5pActivity!, { + displayOptions: this.displayOptions, + siteId: this.siteId, + }); + + this.fileUrl = this.deployedFile.fileurl; + + // Listen for changes in the state. + const eventName = await CoreFilepool.getFileEventNameByUrl(this.site.getId(), this.deployedFile.fileurl); + + if (!this.observer) { + this.observer = CoreEvents.on(eventName, () => { + this.calculateFileState(); + }); + } + + await this.calculateFileState(); + } + + /** + * Calculate the state of the deployed file. + * + * @return Promise resolved when done. + */ + protected async calculateFileState(): Promise { + this.state = await CoreFilepool.getFileStateByUrl( + this.site.getId(), + this.deployedFile!.fileurl, + this.deployedFile!.timemodified, + ); + + this.showFileState(); + } + + /** + * @inheritdoc + */ + protected invalidateContent(): Promise { + return AddonModH5PActivity.invalidateActivityData(this.courseId); + } + + /** + * Displays some data based on the state of the main file. + */ + protected async showFileState(): Promise { + if (this.state == CoreConstants.OUTDATED) { + this.stateMessage = 'addon.mod_h5pactivity.filestateoutdated'; + this.needsDownload = true; + } else if (this.state == CoreConstants.NOT_DOWNLOADED) { + this.stateMessage = 'addon.mod_h5pactivity.filestatenotdownloaded'; + this.needsDownload = true; + } else if (this.state == CoreConstants.DOWNLOADING) { + this.stateMessage = ''; + + if (!this.downloading) { + // It's being downloaded right now but the view isn't tracking it. "Restore" the download. + await this.downloadDeployedFile(); + + this.play(); + } + } else { + this.stateMessage = ''; + this.needsDownload = false; + } + } + + /** + * Download the file and play it. + * + * @param event Click event. + * @return Promise resolved when done. + */ + async downloadAndPlay(event?: MouseEvent): Promise { + event?.preventDefault(); + event?.stopPropagation(); + + if (!CoreApp.isOnline()) { + CoreDomUtils.showErrorModal('core.networkerrormsg', true); + + return; + } + + try { + // Confirm the download if needed. + await CoreDomUtils.confirmDownloadSize({ size: this.deployedFile!.filesize!, total: true }); + + await this.downloadDeployedFile(); + + if (!this.isDestroyed) { + this.play(); + } + + } catch (error) { + if (CoreDomUtils.isCanceledError(error) || this.isDestroyed) { + // User cancelled or view destroyed, stop. + return; + } + + CoreDomUtils.showErrorModalDefault(error, 'core.errordownloading', true); + } + } + + /** + * Download the file automatically. + * + * @return Promise resolved when done. + */ + protected async downloadAutomatically(): Promise { + try { + await this.downloadDeployedFile(); + + if (!this.isDestroyed) { + this.play(); + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'core.errordownloading', true); + } + } + + /** + * Download athe H5P deployed file or restores an ongoing download. + * + * @return Promise resolved when done. + */ + protected async downloadDeployedFile(): Promise { + this.downloading = true; + this.progressMessage = 'core.downloading'; + + try { + await CoreFilepool.downloadUrl( + this.site.getId(), + this.deployedFile!.fileurl, + false, + this.component, + this.componentId, + this.deployedFile!.timemodified, + (data: DownloadProgressData) => { + if (!data) { + return; + } + + this.percentage = undefined; + this.showPercentage = false; + + if (data.message) { + // Show a message. + this.progressMessage = data.message; + } else if (data.loaded !== undefined) { + // Downloading or unzipping. + const totalSize = this.progressMessage == 'core.downloading' ? this.deployedFile!.filesize : data.total; + + if (totalSize !== undefined) { + const percentageNumber = (Number(data.loaded / totalSize) * 100); + this.percentage = percentageNumber.toFixed(1); + this.showPercentage = percentageNumber >= 0 && percentageNumber <= 100; + } + } + }, + ); + + } finally { + this.progressMessage = undefined; + this.percentage = undefined; + this.showPercentage = false; + this.downloading = false; + } + } + + /** + * Play the package. + */ + play(): void { + this.playing = true; + + // Mark the activity as viewed. + AddonModH5PActivity.logView(this.h5pActivity!.id, this.h5pActivity!.name, this.siteId); + + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } + + /** + * Go to view user events. + */ + async viewMyAttempts(): Promise { + this.isOpeningPage = true; + const userId = CoreSites.getCurrentSiteUserId(); + + try { + await CoreNavigator.navigate(`userattempts/${userId}`); + } finally { + this.isOpeningPage = false; + } + } + + /** + * Treat an iframe message event. + * + * @param event Event. + * @return Promise resolved when done. + */ + protected async onIframeMessage(event: MessageEvent): Promise { + if (!event.data || !CoreXAPI.canPostStatementsInSite(this.site) || !this.isCurrentXAPIPost(event.data)) { + return; + } + + try { + const options = { + offline: this.hasOffline, + courseId: this.courseId, + extra: this.h5pActivity!.name, + siteId: this.site.getId(), + }; + + const sent = await CoreXAPI.postStatements( + this.h5pActivity!.context, + event.data.component, + JSON.stringify(event.data.statements), + options, + ); + + this.hasOffline = !sent; + + if (sent) { + try { + // Invalidate attempts. + await AddonModH5PActivity.invalidateUserAttempts(this.h5pActivity!.id, undefined, this.siteId); + } catch (error) { + // Ignore errors. + } + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error sending tracking data.'); + } + } + + /** + * Check if an event is an XAPI post statement of the current activity. + * + * @param data Event data. + * @return Whether it's an XAPI post statement of the current activity. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected isCurrentXAPIPost(data: any): boolean { + if (data.environment != 'moodleapp' || data.context != 'h5p' || data.action != 'xapi_post_statement' || !data.statements) { + return false; + } + + // Check the event belongs to this activity. + const trackingUrl = data.statements[0] && data.statements[0].object && data.statements[0].object.id; + if (!trackingUrl) { + return false; + } + + if (!this.site.containsUrl(trackingUrl)) { + // The event belongs to another site, weird scenario. Maybe some JS running in background. + return false; + } + + const match = trackingUrl.match(/xapi\/activity\/(\d+)/); + + return match && match[1] == this.h5pActivity!.context; + } + + /** + * @inheritdoc + */ + protected sync(): Promise { + return AddonModH5PActivitySync.syncActivity(this.h5pActivity!.context, this.site.getId()); + } + + /** + * @inheritdoc + */ + protected autoSyncEventReceived(): void { + this.checkHasOffline(); + } + + /** + * @inheritdoc + */ + async gotoBlog(): Promise { + this.isOpeningPage = true; + + try { + await super.gotoBlog(); + } finally { + this.isOpeningPage = false; + } + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.observer?.off(); + window.removeEventListener('message', this.messageListenerFunction); + } + +} + +type DownloadProgressData = { + message?: string; + loaded?: number; + total?: number; +}; diff --git a/src/addons/mod/h5pactivity/h5pactivity-lazy.module.ts b/src/addons/mod/h5pactivity/h5pactivity-lazy.module.ts new file mode 100644 index 000000000..475e80f52 --- /dev/null +++ b/src/addons/mod/h5pactivity/h5pactivity-lazy.module.ts @@ -0,0 +1,41 @@ +// (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 { RouterModule, Routes } from '@angular/router'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CanLeaveGuard } from '@guards/can-leave'; +import { AddonModH5PActivityComponentsModule } from './components/components.module'; +import { AddonModH5PActivityIndexPage } from './pages/index/index'; + +const routes: Routes = [ + { + path: ':courseId/:cmId', + component: AddonModH5PActivityIndexPage, + canDeactivate: [CanLeaveGuard], + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModH5PActivityComponentsModule, + ], + declarations: [ + AddonModH5PActivityIndexPage, + ], +}) +export class AddonModH5PActivityLazyModule {} diff --git a/src/addons/mod/h5pactivity/h5pactivity.module.ts b/src/addons/mod/h5pactivity/h5pactivity.module.ts new file mode 100644 index 000000000..516b54977 --- /dev/null +++ b/src/addons/mod/h5pactivity/h5pactivity.module.ts @@ -0,0 +1,64 @@ +// (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 { APP_INITIALIZER, NgModule, Type } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreCronDelegate } from '@services/cron'; +import { AddonModH5PActivityComponentsModule } from './components/components.module'; +import { AddonModH5PActivityProvider } from './services/h5pactivity'; +import { AddonModH5PActivitySyncProvider } from './services/h5pactivity-sync'; +import { AddonModH5PActivityIndexLinkHandler } from './services/handlers/index-link'; +import { AddonModH5PActivityModuleHandler, AddonModH5PActivityModuleHandlerService } from './services/handlers/module'; +import { AddonModH5PActivityPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModH5PActivityReportLinkHandler } from './services/handlers/report-link'; +import { AddonModH5PActivitySyncCronHandler } from './services/handlers/sync-cron'; + +// List of providers (without handlers). +export const ADDON_MOD_H5P_ACTIVITY_SERVICES: Type[] = [ + AddonModH5PActivityProvider, + AddonModH5PActivitySyncProvider, +]; + +const routes: Routes = [ + { + path: AddonModH5PActivityModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./h5pactivity-lazy.module').then(m => m.AddonModH5PActivityLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + AddonModH5PActivityComponentsModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModuleDelegate.registerHandler(AddonModH5PActivityModuleHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModH5PActivityIndexLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModH5PActivityReportLinkHandler.instance); + CoreCourseModulePrefetchDelegate.registerHandler(AddonModH5PActivityPrefetchHandler.instance); + CoreCronDelegate.register(AddonModH5PActivitySyncCronHandler.instance); + }, + }, + ], +}) +export class AddonModH5PActivityModule {} diff --git a/src/addons/mod/h5pactivity/lang.json b/src/addons/mod/h5pactivity/lang.json new file mode 100644 index 000000000..3ac109635 --- /dev/null +++ b/src/addons/mod/h5pactivity/lang.json @@ -0,0 +1,36 @@ +{ + "all_attempts": "All user attempts", + "answer_checked": "Answer checked", + "answer_correct": "Your answer is correct", + "answer_fail": "Incorrect answer", + "answer_incorrect": "Your answer is incorrect", + "answer_pass": "Correct answer", + "attempt": "Attempt", + "attempt_completion_no": "This attempt is not marked as completed", + "attempt_completion_yes": "This attempt is completed", + "attempt_success_fail": "Fail", + "attempt_success_pass": "Pass", + "attempt_success_unknown": "Not reported", + "attempts_none": "This user has no attempts to display.", + "completion": "Completion", + "downloadh5pfile": "Download H5P file", + "duration": "Duration", + "errorgetactivity": "Error getting H5P activity data.", + "filestatenotdownloaded": "The H5P package is not downloaded. You need to download it to be able to use it.", + "filestateoutdated": "The H5P package has been modified since the last download. You need to download it again to be able to use it.", + "maxscore": "Max score", + "modulenameplural": "H5P", + "myattempts": "My attempts", + "no_compatible_track": "This interaction ({{$a}}) does not provide tracking information or the tracking\n provided is not compatible with the current activity version.", + "offlinedisabledwarning": "You need to be online to view the H5P package.", + "outcome": "Outcome", + "previewmode": "This content is displayed in preview mode. No attempt tracking will be stored.", + "result_fill-in": "Fill-in text", + "result_other": "Unknown interaction type", + "review_my_attempts": "View my attempts", + "score": "Score", + "score_out_of": "{{$a.rawscore}} out of {{$a.maxscore}}", + "startdate": "Start date", + "totalscore": "Total score", + "viewattempt": "View attempt {{$a}}" +} \ No newline at end of file diff --git a/src/addons/mod/h5pactivity/pages/index/index.html b/src/addons/mod/h5pactivity/pages/index/index.html new file mode 100644 index 000000000..9f00ce622 --- /dev/null +++ b/src/addons/mod/h5pactivity/pages/index/index.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/h5pactivity/pages/index/index.ts b/src/addons/mod/h5pactivity/pages/index/index.ts new file mode 100644 index 000000000..db3836c8a --- /dev/null +++ b/src/addons/mod/h5pactivity/pages/index/index.ts @@ -0,0 +1,52 @@ +// (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, ViewChild } from '@angular/core'; + +import { CoreCourseModuleMainActivityPage } from '@features/course/classes/main-activity-page'; +import { CanLeave } from '@guards/can-leave'; +import { CoreDomUtils } from '@services/utils/dom'; +import { Translate } from '@singletons'; +import { AddonModH5PActivityIndexComponent } from '../../components/index'; + +/** + * Page that displays an H5P activity. + */ +@Component({ + selector: 'page-addon-mod-h5pactivity-index', + templateUrl: 'index.html', +}) +export class AddonModH5PActivityIndexPage extends CoreCourseModuleMainActivityPage + implements CanLeave { + + @ViewChild(AddonModH5PActivityIndexComponent) activityComponent?: AddonModH5PActivityIndexComponent; + + /** + * @inheritdoc + */ + async canLeave(): Promise { + if (!this.activityComponent || !this.activityComponent.playing || this.activityComponent.isOpeningPage) { + return true; + } + + try { + await CoreDomUtils.showConfirm(Translate.instant('core.confirmleaveunknownchanges')); + + return true; + } catch { + return false; + } + } + +} diff --git a/src/addons/mod/h5pactivity/services/h5pactivity-sync.ts b/src/addons/mod/h5pactivity/services/h5pactivity-sync.ts new file mode 100644 index 000000000..5a7c4f0d1 --- /dev/null +++ b/src/addons/mod/h5pactivity/services/h5pactivity-sync.ts @@ -0,0 +1,228 @@ +// (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 { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreXAPIOffline } from '@features/xapi/services/offline'; +import { CoreXAPI } from '@features/xapi/services/xapi'; +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModH5PActivity, AddonModH5PActivityProvider } from './h5pactivity'; + +/** + * Service to sync H5P activities. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModH5PActivitySyncProvider extends CoreCourseActivitySyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_h5pactivity_autom_synced'; + + protected componentTranslate?: string; + + constructor() { + super('AddonModH5PActivitySyncProvider'); + } + + /** + * Get component name translated. + * + * @return Component name translated. + */ + protected getComponentTranslate(): string { + if (!this.componentTranslate) { + this.componentTranslate = CoreCourse.translateModuleName('h5pactivity'); + } + + return this.componentTranslate; + } + + /** + * Try to synchronize all the H5P activities in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllActivities(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('H5P activities', this.syncAllActivitiesFunc.bind(this, !!force), siteId); + } + + /** + * Sync all H5P activities on a site. + * + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID to sync. If not defined, sync all sites. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllActivitiesFunc(force: boolean, siteId?: string): Promise { + const entries = await CoreXAPIOffline.getAllStatements(siteId); + + // Sync all responses. + const promises = entries.map(async (response) => { + const result = await (force ? this.syncActivity(response.contextid, siteId) : + this.syncActivityIfNeeded(response.contextid, siteId)); + + if (result?.updated) { + // Sync successful, send event. + CoreEvents.trigger(AddonModH5PActivitySyncProvider.AUTO_SYNCED, { + contextId: response.contextid, + warnings: result.warnings, + }, siteId); + } + }); + + await Promise.all(promises); + } + + /** + * Sync an H5P activity only if a certain time has passed since the last time. + * + * @param contextId Context ID of the activity. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the activity is synced or it doesn't need to be synced. + */ + async syncActivityIfNeeded(contextId: number, siteId?: string): Promise { + const needed = await this.isSyncNeeded(contextId, siteId); + + if (needed) { + return this.syncActivity(contextId, siteId); + } + } + + /** + * Synchronize an H5P activity. If it's already being synced it will reuse the same promise. + * + * @param contextId Context ID of the activity. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + syncActivity(contextId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + if (!CoreApp.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + if (this.isSyncing(contextId, siteId)) { + // There's already a sync ongoing for this discussion, return the promise. + return this.getOngoingSync(contextId, siteId)!; + } + + return this.addOngoingSync(contextId, this.syncActivityData(contextId, siteId), siteId); + } + + /** + * Synchronize an H5P activity. + * + * @param contextId Context ID of the activity. + * @param siteId Site ID. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + protected async syncActivityData(contextId: number, siteId: string): Promise { + + this.logger.debug(`Try to sync H5P activity with context ID '${contextId}'`); + + const result: AddonModH5PActivitySyncResult = { + warnings: [], + updated: false, + }; + + // Get all the statements stored for the activity. + const entries = await CoreXAPIOffline.getContextStatements(contextId, siteId); + + if (!entries || !entries.length) { + // Nothing to sync. + await this.setSyncTime(contextId, siteId); + + return result; + } + + // Get the activity instance. + const courseId = entries[0].courseid!; + + const h5pActivity = await AddonModH5PActivity.getH5PActivityByContextId(courseId, contextId, { siteId }); + + // Sync offline logs. + await CoreUtils.ignoreErrors( + CoreCourseLogHelper.syncActivity(AddonModH5PActivityProvider.COMPONENT, h5pActivity.id, siteId), + ); + + // Send the statements in order. + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + + try { + await CoreXAPI.postStatementsOnline(entry.component, entry.statements, siteId); + + result.updated = true; + + await CoreXAPIOffline.deleteStatements(entry.id, siteId); + } catch (error) { + if (!CoreUtils.isWebServiceError(error)) { + throw error; + } + + // The WebService has thrown an error, this means that statements cannot be submitted. Delete them. + result.updated = true; + + await CoreXAPIOffline.deleteStatements(entry.id, siteId); + + // Responses deleted, add a warning. + result.warnings.push(Translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: entry.extra, + error: CoreTextUtils.getErrorMessageFromError(error), + })); + } + } + + if (result.updated) { + // Data has been sent to server, invalidate attempts. + await CoreUtils.ignoreErrors(AddonModH5PActivity.invalidateUserAttempts(h5pActivity.id, undefined, siteId)); + } + + // Sync finished, set sync time. + await this.setSyncTime(contextId, siteId); + + return result; + } + +} + +export const AddonModH5PActivitySync = makeSingleton(AddonModH5PActivitySyncProvider); + +/** + * Sync result. + */ +export type AddonModH5PActivitySyncResult = { + updated: boolean; + warnings: string[]; +}; + +/** + * Data passed to AUTO_SYNC event. + */ +export type AddonModH5PActivityAutoSyncData = { + contextId: number; + warnings: string[]; +}; diff --git a/src/addons/mod/h5pactivity/services/h5pactivity.ts b/src/addons/mod/h5pactivity/services/h5pactivity.ts new file mode 100644 index 000000000..3b9d8c7d3 --- /dev/null +++ b/src/addons/mod/h5pactivity/services/h5pactivity.ts @@ -0,0 +1,858 @@ +// (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 { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreWSExternalWarning, CoreWSExternalFile } from '@services/ws'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreH5P } from '@features/h5p/services/h5p'; +import { CoreH5PDisplayOptions } from '@features/h5p/classes/core'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { makeSingleton, Translate } from '@singletons/index'; +import { CoreWSError } from '@classes/errors/wserror'; +import { CoreError } from '@classes/errors/error'; +import { AddonModH5PActivityAutoSyncData, AddonModH5PActivitySyncProvider } from './h5pactivity-sync'; + +const ROOT_CACHE_KEY = 'mmaModH5PActivity:'; + +/** + * Service that provides some features for H5P activity. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModH5PActivityProvider { + + static readonly COMPONENT = 'mmaModH5PActivity'; + static readonly TRACK_COMPONENT = 'mod_h5pactivity'; // Component for tracking. + + /** + * Format an attempt's data. + * + * @param attempt Attempt to format. + * @return Formatted attempt. + */ + protected formatAttempt(attempt: AddonModH5PActivityWSAttempt): AddonModH5PActivityAttempt { + const formattedAttempt: AddonModH5PActivityAttempt = attempt; + + formattedAttempt.timecreated = attempt.timecreated * 1000; // Convert to milliseconds. + formattedAttempt.timemodified = attempt.timemodified * 1000; // Convert to milliseconds. + formattedAttempt.success = formattedAttempt.success ?? null; + + if (!attempt.duration) { + formattedAttempt.durationReadable = '-'; + formattedAttempt.durationCompact = '-'; + } else { + formattedAttempt.durationReadable = CoreTimeUtils.formatTime(attempt.duration); + formattedAttempt.durationCompact = CoreTimeUtils.formatDurationShort(attempt.duration); + } + + return formattedAttempt; + } + + /** + * Format attempt data and results. + * + * @param attempt Attempt and results to format. + */ + protected formatAttemptResults(attempt: AddonModH5PActivityWSAttemptResults): AddonModH5PActivityAttemptResults { + const formattedAttempt: AddonModH5PActivityAttemptResults = this.formatAttempt(attempt); + + formattedAttempt.results = formattedAttempt.results?.map((result) => this.formatResult(result)); + + return formattedAttempt; + } + + /** + * Format the attempts of a user. + * + * @param data Data to format. + * @return Formatted data. + */ + protected formatUserAttempts(data: AddonModH5PActivityWSUserAttempts): AddonModH5PActivityUserAttempts { + const formatted: AddonModH5PActivityUserAttempts = data; + + formatted.attempts = formatted.attempts.map((attempt) => this.formatAttempt(attempt)); + + if (formatted.scored) { + formatted.scored.attempts = formatted.scored.attempts.map((attempt) => this.formatAttempt(attempt)); + } + + return formatted; + } + + /** + * Format an attempt's result. + * + * @param result Result to format. + */ + protected formatResult(result: AddonModH5PActivityWSResult): AddonModH5PActivityWSResult { + result.timecreated = result.timecreated * 1000; // Convert to milliseconds. + + return result; + } + + /** + * Get cache key for access information WS calls. + * + * @param id H5P activity ID. + * @return Cache key. + */ + protected getAccessInformationCacheKey(id: number): string { + return ROOT_CACHE_KEY + 'accessInfo:' + id; + } + + /** + * Get access information for a given H5P activity. + * + * @param id H5P activity ID. + * @param options Other options. + * @return Promise resolved with the data. + */ + async getAccessInformation(id: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModH5pactivityGetH5pactivityAccessInformationWSParams = { + h5pactivityid: id, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAccessInformationCacheKey(id), + updateFrequency: CoreSite.FREQUENCY_OFTEN, + component: AddonModH5PActivityProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_h5pactivity_get_h5pactivity_access_information', params, preSets); + } + + /** + * Get attempt results for all user attempts. + * + * @param id Activity ID. + * @param options Other options. + * @return Promise resolved with the results of the attempt. + */ + async getAllAttemptsResults( + id: number, + options?: AddonModH5PActivityGetAttemptResultsOptions, + ): Promise { + + const userAttempts = await this.getUserAttempts(id, options); + + const attemptIds = userAttempts.attempts.map((attempt) => attempt.id); + + if (attemptIds.length) { + // Get all the attempts with a single call. + return this.getAttemptsResults(id, attemptIds, options); + } else { + // No attempts. + return { + activityid: id, + attempts: [], + warnings: [], + }; + } + } + + /** + * Get cache key for results WS calls. + * + * @param id Instance ID. + * @param attemptsIds Attempts IDs. + * @return Cache key. + */ + protected getAttemptResultsCacheKey(id: number, attemptsIds: number[]): string { + return this.getAttemptResultsCommonCacheKey(id) + ':' + JSON.stringify(attemptsIds); + } + + /** + * Get common cache key for results WS calls. + * + * @param id Instance ID. + * @return Cache key. + */ + protected getAttemptResultsCommonCacheKey(id: number): string { + return ROOT_CACHE_KEY + 'results:' + id; + } + + /** + * Get attempt results. + * + * @param id Activity ID. + * @param attemptId Attempt ID. + * @param options Other options. + * @return Promise resolved with the results of the attempt. + */ + async getAttemptResults( + id: number, + attemptId: number, + options?: AddonModH5PActivityGetAttemptResultsOptions, + ): Promise { + + options = options || {}; + + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModH5pactivityGetResultsWSParams = { + h5pactivityid: id, + attemptids: [attemptId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAttemptResultsCacheKey(id, params.attemptids!), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModH5PActivityProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + try { + const response = await site.read( + 'mod_h5pactivity_get_results', + params, + preSets, + ); + + if (response.warnings?.[0]) { + throw new CoreWSError(response.warnings[0]); // Cannot view attempt. + } + + return this.formatAttemptResults(response.attempts[0]); + } catch (error) { + if (CoreUtils.isWebServiceError(error)) { + throw error; + } + + // Check if the full list of results is cached. If so, get the results from there. + const cacheOptions: AddonModH5PActivityGetAttemptResultsOptions = { + ...options, // Include all the original options. + readingStrategy: CoreSitesReadingStrategy.OnlyCache, + }; + + const attemptsResults = await AddonModH5PActivity.getAllAttemptsResults(id, cacheOptions); + + const attempt = attemptsResults.attempts.find((attempt) => attempt.id == attemptId); + + if (!attempt) { + throw error; + } + + return attempt; + } + } + + /** + * Get attempts results. + * + * @param id Activity ID. + * @param attemptsIds Attempts IDs. + * @param options Other options. + * @return Promise resolved with all the attempts. + */ + async getAttemptsResults( + id: number, + attemptsIds: number[], + options?: AddonModH5PActivityGetAttemptResultsOptions, + ): Promise { + + options = options || {}; + + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModH5pactivityGetResultsWSParams = { + h5pactivityid: id, + attemptids: attemptsIds, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAttemptResultsCommonCacheKey(id), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModH5PActivityProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_h5pactivity_get_results', + params, + preSets, + ); + + response.attempts = response.attempts.map((attempt) => this.formatAttemptResults(attempt)); + + return response; + } + + /** + * Get deployed file from an H5P activity instance. + * + * @param h5pActivity Activity instance. + * @param options Options + * @return Promise resolved with the file. + */ + async getDeployedFile( + h5pActivity: AddonModH5PActivityData, + options?: AddonModH5PActivityGetDeployedFileOptions, + ): Promise { + + if (h5pActivity.deployedfile) { + // File already deployed and still valid, use this one. + return h5pActivity.deployedfile; + } + + if (!h5pActivity.package || !h5pActivity.package[0]) { + // Shouldn't happen. + throw new CoreError('No H5P package found.'); + } + + options = options || {}; + + // Deploy the file in the server. + return CoreH5P.getTrustedH5PFile( + h5pActivity.package[0].fileurl, + options.displayOptions, + options.ignoreCache, + options.siteId, + ); + } + + /** + * Get cache key for H5P activity data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getH5PActivityDataCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'h5pactivity:' + courseId; + } + + /** + * Get an H5P activity with key=value. If more than one is found, only the first will be returned. + * + * @param courseId Course ID. + * @param key Name of the property to check. + * @param value Value to search. + * @param options Other options. + * @return Promise resolved with the activity data. + */ + protected async getH5PActivityByField( + courseId: number, + key: string, + value: unknown, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModH5pactivityGetByCoursesWSParams = { + courseids: [courseId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getH5PActivityDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModH5PActivityProvider.COMPONENT, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_h5pactivity_get_h5pactivities_by_courses', + params, + preSets, + ); + + const currentActivity = response.h5pactivities.find((h5pActivity) => h5pActivity[key] == value); + + if (currentActivity) { + return currentActivity; + } + + throw new CoreError(Translate.instant('addon.mod_h5pactivity.errorgetactivity')); + } + + /** + * Get an H5P activity by module ID. + * + * @param courseId Course ID. + * @param cmId Course module ID. + * @param options Other options. + * @return Promise resolved with the activity data. + */ + getH5PActivity(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getH5PActivityByField(courseId, 'coursemodule', cmId, options); + } + + /** + * Get an H5P activity by context ID. + * + * @param courseId Course ID. + * @param contextId Context ID. + * @param options Other options. + * @return Promise resolved with the activity data. + */ + getH5PActivityByContextId( + courseId: number, + contextId: number, + options: CoreSitesCommonWSOptions = {}, + ): Promise { + return this.getH5PActivityByField(courseId, 'context', contextId, options); + } + + /** + * Get an H5P activity by instance ID. + * + * @param courseId Course ID. + * @param id Instance ID. + * @param options Other options. + * @return Promise resolved with the activity data. + */ + getH5PActivityById(courseId: number, id: number, options: CoreSitesCommonWSOptions = {}): Promise { + return this.getH5PActivityByField(courseId, 'id', id, options); + } + + /** + * Get cache key for attemps WS calls. + * + * @param id Instance ID. + * @param userIds User IDs. + * @return Cache key. + */ + protected getUserAttemptsCacheKey(id: number, userIds: number[]): string { + return this.getUserAttemptsCommonCacheKey(id) + ':' + JSON.stringify(userIds); + } + + /** + * Get common cache key for attempts WS calls. + * + * @param id Instance ID. + * @return Cache key. + */ + protected getUserAttemptsCommonCacheKey(id: number): string { + return ROOT_CACHE_KEY + 'attempts:' + id; + } + + /** + * Get attempts of a certain user. + * + * @param id Activity ID. + * @param options Other options. + * @return Promise resolved with the attempts of the user. + */ + async getUserAttempts( + id: number, + options: AddonModH5PActivityGetAttemptsOptions = {}, + ): Promise { + + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModH5pactivityGetAttemptsWSParams = { + h5pactivityid: id, + userids: [options.userId || site.getUserId()], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserAttemptsCacheKey(id, params.userids!), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModH5PActivityProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_h5pactivity_get_attempts', params, preSets); + + if (response.warnings?.[0]) { + throw new CoreWSError(response.warnings[0]); // Cannot view user attempts. + } + + return this.formatUserAttempts(response.usersattempts[0]); + } + + /** + * Invalidates access information. + * + * @param id H5P activity ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAccessInformation(id: number, siteId?: string): Promise { + + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(id)); + } + + /** + * Invalidates H5P activity data. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateActivityData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getH5PActivityDataCacheKey(courseId)); + } + + /** + * Invalidates all attempts results for H5P activity. + * + * @param id Activity ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllResults(id: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAttemptResultsCommonCacheKey(id)); + } + + /** + * Invalidates results of a certain attempt for H5P activity. + * + * @param id Activity ID. + * @param attemptId Attempt ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptResults(id: number, attemptId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAttemptResultsCacheKey(id, [attemptId])); + } + + /** + * Invalidates all users attempts for H5P activity. + * + * @param id Activity ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllUserAttempts(id: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserAttemptsCommonCacheKey(id)); + } + + /** + * Invalidates attempts of a certain user for H5P activity. + * + * @param id Activity ID. + * @param userId User ID. If not defined, current user in the site. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserAttempts(id: number, userId?: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + userId = userId || site.getUserId(); + + await site.invalidateWsCacheForKey(this.getUserAttemptsCacheKey(id, [userId])); + } + + /** + * Delete launcher. + * + * @return Promise resolved when the launcher file is deleted. + */ + async isPluginEnabled(siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + return site.wsAvailable('mod_h5pactivity_get_h5pactivities_by_courses'); + } + + /** + * Report an H5P activity as being viewed. + * + * @param id H5P activity ID. + * @param name Name of the activity. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logView(id: number, name?: string, siteId?: string): Promise { + const params: AddonModH5pactivityViewH5pactivityWSParams = { + h5pactivityid: id, + }; + + return CoreCourseLogHelper.logSingle( + 'mod_h5pactivity_view_h5pactivity', + params, + AddonModH5PActivityProvider.COMPONENT, + id, + name, + 'h5pactivity', + {}, + siteId, + ); + } + +} + +export const AddonModH5PActivity = makeSingleton(AddonModH5PActivityProvider); + +/** + * Basic data for an H5P activity, exported by Moodle class h5pactivity_summary_exporter. + */ +export type AddonModH5PActivityData = { + id: number; // The primary key of the record. + course: number; // Course id this h5p activity is part of. + name: string; // The name of the activity module instance. + timecreated?: number; // Timestamp of when the instance was added to the course. + timemodified?: number; // Timestamp of when the instance was last modified. + intro: string; // H5P activity description. + introformat: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + grade?: number; // The maximum grade for submission. + displayoptions: number; // H5P Button display options. + enabletracking: number; // Enable xAPI tracking. + grademethod: number; // Which H5P attempt is used for grading. + contenthash?: string; // Sha1 hash of file content. + coursemodule: number; // Coursemodule. + context: number; // Context ID. + introfiles: CoreWSExternalFile[]; + package: CoreWSExternalFile[]; + deployedfile?: { + filename?: string; // File name. + filepath?: string; // File path. + filesize?: number; // File size. + fileurl: string; // Downloadable file url. + timemodified?: number; // Time modified. + mimetype?: string; // File mime type. + }; +}; + +/** + * Params of mod_h5pactivity_get_h5pactivities_by_courses WS. + */ +export type AddonModH5pactivityGetByCoursesWSParams = { + courseids?: number[]; // Array of course ids. +}; + +/** + * Data returned by mod_h5pactivity_get_h5pactivities_by_courses WS. + */ +export type AddonModH5pactivityGetByCoursesWSResponse = { + h5pactivities: AddonModH5PActivityData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_h5pactivity_get_h5pactivity_access_information WS. + */ +export type AddonModH5pactivityGetH5pactivityAccessInformationWSParams = { + h5pactivityid: number; // H5p activity instance id. +}; + +/** + * Data returned by mod_h5pactivity_get_h5pactivity_access_information WS. + */ +export type AddonModH5pactivityGetH5pactivityAccessInformationWSResponse = { + warnings?: CoreWSExternalWarning[]; + canview?: boolean; // Whether the user has the capability mod/h5pactivity:view allowed. + canaddinstance?: boolean; // Whether the user has the capability mod/h5pactivity:addinstance allowed. + cansubmit?: boolean; // Whether the user has the capability mod/h5pactivity:submit allowed. + canreviewattempts?: boolean; // Whether the user has the capability mod/h5pactivity:reviewattempts allowed. +}; + +/** + * Result of WS mod_h5pactivity_get_h5pactivity_access_information. + */ +export type AddonModH5PActivityAccessInfo = AddonModH5pactivityGetH5pactivityAccessInformationWSResponse; + +/** + * Params of mod_h5pactivity_get_attempts WS. + */ +export type AddonModH5pactivityGetAttemptsWSParams = { + h5pactivityid: number; // H5p activity instance id. + userids?: number[]; // User ids. +}; + +/** + * Data returned by mod_h5pactivity_get_attempts WS. + */ +export type AddonModH5pactivityGetAttemptsWSResponse = { + activityid: number; // Activity course module ID. + usersattempts: AddonModH5PActivityWSUserAttempts[]; // The complete users attempts list. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_h5pactivity_get_results WS. + */ +export type AddonModH5pactivityGetResultsWSParams = { + h5pactivityid: number; // H5p activity instance id. + attemptids?: number[]; // Attempt ids. +}; + +/** + * Data returned by mod_h5pactivity_get_results WS. + */ +export type AddonModH5pactivityGetResultsWSResponse = { + activityid: number; // Activity course module ID. + attempts: AddonModH5PActivityWSAttemptResults[]; // The complete attempts list. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Attempts results with some calculated data. + */ +export type AddonModH5PActivityAttemptsResults = Omit & { + attempts: AddonModH5PActivityAttemptResults[]; // The complete attempts list. +}; + +/** + * Attempts data for a user as returned by the WS mod_h5pactivity_get_attempts. + */ +export type AddonModH5PActivityWSUserAttempts = { + userid: number; // The user id. + attempts: AddonModH5PActivityWSAttempt[]; // The complete attempts list. + scored?: { // Attempts used to grade the activity. + title: string; // Scored attempts title. + grademethod: string; // Scored attempts title. + attempts: AddonModH5PActivityWSAttempt[]; // List of the grading attempts. + }; +}; + +/** + * Attempt data as returned by the WS mod_h5pactivity_get_attempts. + */ +export type AddonModH5PActivityWSAttempt = { + id: number; // ID of the context. + h5pactivityid: number; // ID of the H5P activity. + userid: number; // ID of the user. + timecreated: number; // Attempt creation. + timemodified: number; // Attempt modified. + attempt: number; // Attempt number. + rawscore: number; // Attempt score value. + maxscore: number; // Attempt max score. + duration: number; // Attempt duration in seconds. + completion?: number; // Attempt completion. + success?: number | null; // Attempt success. + scaled: number; // Attempt scaled. +}; + +/** + * Attempt and results data as returned by the WS mod_h5pactivity_get_results. + */ +export type AddonModH5PActivityWSAttemptResults = AddonModH5PActivityWSAttempt & { + results?: AddonModH5PActivityWSResult[]; // The results of the attempt. +}; + +/** + * Attempt result data as returned by the WS mod_h5pactivity_get_results. + */ +export type AddonModH5PActivityWSResult = { + id: number; // ID of the context. + attemptid: number; // ID of the H5P attempt. + subcontent: string; // Subcontent identifier. + timecreated: number; // Result creation. + interactiontype: string; // Interaction type. + description: string; // Result description. + content?: string; // Result extra content. + rawscore: number; // Result score value. + maxscore: number; // Result max score. + duration?: number; // Result duration in seconds. + completion?: number; // Result completion. + success?: number | null; // Result success. + optionslabel?: string; // Label used for result options. + correctlabel?: string; // Label used for correct answers. + answerlabel?: string; // Label used for user answers. + track?: boolean; // If the result has valid track information. + options?: { // The statement options. + description: string; // Option description. + id: number; // Option identifier. + correctanswer: AddonModH5PActivityWSResultAnswer; // The option correct answer. + useranswer: AddonModH5PActivityWSResultAnswer; // The option user answer. + }[]; +}; + +/** + * Result answer as returned by the WS mod_h5pactivity_get_results. + */ +export type AddonModH5PActivityWSResultAnswer = { + answer?: string; // Option text value. + correct?: boolean; // If has to be displayed as correct. + incorrect?: boolean; // If has to be displayed as incorrect. + text?: boolean; // If has to be displayed as simple text. + checked?: boolean; // If has to be displayed as a checked option. + unchecked?: boolean; // If has to be displayed as a unchecked option. + pass?: boolean; // If has to be displayed as passed. + fail?: boolean; // If has to be displayed as failed. +}; + +/** + * User attempts data with some calculated data. + */ +export type AddonModH5PActivityUserAttempts = Omit & { + attempts: AddonModH5PActivityAttempt[]; // The complete attempts list. + scored?: { // Attempts used to grade the activity. + title: string; // Scored attempts title. + grademethod: string; // Scored attempts title. + attempts: AddonModH5PActivityAttempt[]; // List of the grading attempts. + }; +}; + +/** + * Attempt with some calculated data. + */ +export type AddonModH5PActivityAttempt = AddonModH5PActivityWSAttempt & { + durationReadable?: string; // Duration in a human readable format. + durationCompact?: string; // Duration in a "short" human readable format. +}; + +/** + * Attempt and results data with some calculated data. + */ +export type AddonModH5PActivityAttemptResults = AddonModH5PActivityAttempt & { + results?: AddonModH5PActivityWSResult[]; // The results of the attempt. +}; + +/** + * Options to pass to getDeployedFile function. + */ +export type AddonModH5PActivityGetDeployedFileOptions = { + displayOptions?: CoreH5PDisplayOptions; // Display options + ignoreCache?: boolean; // Whether to ignore cache. Will fail if offline or server down. + siteId?: string; // Site ID. If not defined, current site. +}; + +/** + * Options to pass to getAttemptResults function. + */ +export type AddonModH5PActivityGetAttemptResultsOptions = CoreCourseCommonModWSOptions & { + userId?: number; // User ID. If not defined, user of the site. +}; + +/** + * Options to pass to getAttempts function. + */ +export type AddonModH5PActivityGetAttemptsOptions = AddonModH5PActivityGetAttemptResultsOptions; + +/** + * Params of mod_h5pactivity_view_h5pactivity WS. + */ +export type AddonModH5pactivityViewH5pactivityWSParams = { + h5pactivityid: number; // H5P activity instance id. +}; + +declare module '@singletons/events' { + + /** + * Augment CoreEventsData interface with events specific to this service. + * + * @see https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation + */ + export interface CoreEventsData { + [AddonModH5PActivitySyncProvider.AUTO_SYNCED]: AddonModH5PActivityAutoSyncData; + } + +} diff --git a/src/addons/mod/h5pactivity/services/handlers/index-link.ts b/src/addons/mod/h5pactivity/services/handlers/index-link.ts new file mode 100644 index 000000000..e791a0b3a --- /dev/null +++ b/src/addons/mod/h5pactivity/services/handlers/index-link.ts @@ -0,0 +1,33 @@ +// (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 { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to H5P activity index. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModH5PActivityIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModH5PActivityIndexLinkHandler'; + + constructor() { + super('AddonModH5PActivity', 'h5pactivity'); + } + +} + +export const AddonModH5PActivityIndexLinkHandler = makeSingleton(AddonModH5PActivityIndexLinkHandlerService); diff --git a/src/addons/mod/h5pactivity/services/handlers/module.ts b/src/addons/mod/h5pactivity/services/handlers/module.ts new file mode 100644 index 000000000..1875bed1f --- /dev/null +++ b/src/addons/mod/h5pactivity/services/handlers/module.ts @@ -0,0 +1,85 @@ +// (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 { CoreConstants } from '@/core/constants'; +import { Injectable, Type } from '@angular/core'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseModule } from '@features/course/services/course-helper'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; +import { makeSingleton } from '@singletons'; +import { AddonModH5PActivityIndexComponent } from '../../components/index'; +import { AddonModH5PActivity } from '../h5pactivity'; + +/** + * Handler to support H5P activities. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModH5PActivityModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_h5pactivity'; + + name = 'AddonModH5PActivity'; + modName = 'h5pactivity'; + + supportedFeatures = { + [CoreConstants.FEATURE_GROUPS]: true, + [CoreConstants.FEATURE_GROUPINGS]: true, + [CoreConstants.FEATURE_MOD_INTRO]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true, + [CoreConstants.FEATURE_MODEDIT_DEFAULT_COMPLETION]: true, + [CoreConstants.FEATURE_GRADE_HAS_GRADE]: true, + [CoreConstants.FEATURE_GRADE_OUTCOMES]: true, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + }; + + /** + * @inheritdoc + */ + isEnabled(): Promise { + return AddonModH5PActivity.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData { + + return { + icon: CoreCourse.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_h5pactivity-handler', + showDownloadButton: true, + action(event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions) { + options = options || {}; + options.params = options.params || {}; + Object.assign(options.params, { module }); + const routeParams = '/' + courseId + '/' + module.id; + + CoreNavigator.navigateToSitePath(AddonModH5PActivityModuleHandlerService.PAGE_NAME + routeParams, options); + }, + }; + } + + /** + * @inheritdoc + */ + async getMainComponent(): Promise> { + return AddonModH5PActivityIndexComponent; + } + +} + +export const AddonModH5PActivityModuleHandler = makeSingleton(AddonModH5PActivityModuleHandlerService); diff --git a/src/addons/mod/h5pactivity/services/handlers/prefetch.ts b/src/addons/mod/h5pactivity/services/handlers/prefetch.ts new file mode 100644 index 000000000..03a41dead --- /dev/null +++ b/src/addons/mod/h5pactivity/services/handlers/prefetch.ts @@ -0,0 +1,171 @@ +// (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 { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreH5PHelper } from '@features/h5p/classes/helper'; +import { CoreH5P } from '@features/h5p/services/h5p'; +import { CoreUser } from '@features/user/services/user'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModH5PActivity, AddonModH5PActivityData, AddonModH5PActivityProvider } from '../h5pactivity'; + +/** + * Handler to prefetch h5p activity. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModH5PActivityPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModH5PActivity'; + modName = 'h5pactivity'; + component = AddonModH5PActivityProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^tracks$|^usertracks$/; + + /** + * @inheritdoc + */ + async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + + const h5pActivity = await AddonModH5PActivity.getH5PActivity(courseId, module.id); + + const displayOptions = CoreH5PHelper.decodeDisplayOptions(h5pActivity.displayoptions); + + const deployedFile = await AddonModH5PActivity.getDeployedFile(h5pActivity, { + displayOptions, + }); + + return [deployedFile].concat(this.getIntroFilesFromInstance(module, h5pActivity)); + } + + /** + * @inheritdoc + */ + async invalidateModule(): Promise { + // No need to invalidate anything. + } + + /** + * @inheritdoc + */ + async isDownloadable(): Promise { + return !!CoreSites.getCurrentSite()?.canDownloadFiles() && !CoreH5P.isOfflineDisabledInSite(); + } + + /** + * @inheritdoc + */ + isEnabled(): Promise { + return AddonModH5PActivity.isPluginEnabled(); + } + + /** + * @inheritdoc + */ + prefetch(module: CoreCourseAnyModuleData, courseId?: number): Promise { + return this.prefetchPackage(module, courseId, this.prefetchActivity.bind(this, module, courseId)); + } + + /** + * Prefetch an H5P activity. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected async prefetchActivity( + module: CoreCourseAnyModuleData, + courseId: number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const h5pActivity = await AddonModH5PActivity.getH5PActivity(courseId, module.id, { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + + const introFiles = this.getIntroFilesFromInstance(module, h5pActivity); + + await Promise.all([ + this.prefetchWSData(h5pActivity, siteId), + CoreFilepool.addFilesToQueue(siteId, introFiles, AddonModH5PActivityProvider.COMPONENT, module.id), + this.prefetchMainFile(module, h5pActivity, siteId), + ]); + } + + /** + * Prefetch the deployed file of the activity. + * + * @param module Module. + * @param h5pActivity Activity instance. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async prefetchMainFile( + module: CoreCourseAnyModuleData, + h5pActivity: AddonModH5PActivityData, + siteId: string, + ): Promise { + + const displayOptions = CoreH5PHelper.decodeDisplayOptions(h5pActivity.displayoptions); + + const deployedFile = await AddonModH5PActivity.getDeployedFile(h5pActivity, { + displayOptions: displayOptions, + ignoreCache: true, + siteId: siteId, + }); + + await CoreFilepool.addFilesToQueue(siteId, [deployedFile], AddonModH5PActivityProvider.COMPONENT, module.id); + } + + /** + * Prefetch all the WebService data. + * + * @param h5pActivity Activity instance. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async prefetchWSData(h5pActivity: AddonModH5PActivityData, siteId: string): Promise { + + const accessInfo = await AddonModH5PActivity.getAccessInformation(h5pActivity.id, { + cmId: h5pActivity.coursemodule, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId, + }); + + if (!accessInfo.canreviewattempts) { + // Not a teacher, prefetch user attempts and the current user profile. + const site = await CoreSites.getSite(siteId); + + const options = { + cmId: h5pActivity.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId: siteId, + }; + + await Promise.all([ + AddonModH5PActivity.getAllAttemptsResults(h5pActivity.id, options), + CoreUser.prefetchProfiles([site.getUserId()], h5pActivity.course, siteId), + ]); + } + } + +} + +export const AddonModH5PActivityPrefetchHandler = makeSingleton(AddonModH5PActivityPrefetchHandlerService); diff --git a/src/addons/mod/h5pactivity/services/handlers/report-link.ts b/src/addons/mod/h5pactivity/services/handlers/report-link.ts new file mode 100644 index 000000000..ee6b8c9ab --- /dev/null +++ b/src/addons/mod/h5pactivity/services/handlers/report-link.ts @@ -0,0 +1,136 @@ +// (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 { CoreContentLinksHandlerBase } from '@features/contentlinks/classes/base-handler'; +import { CoreContentLinksAction } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { makeSingleton } from '@singletons'; +import { AddonModH5PActivity } from '../h5pactivity'; +import { AddonModH5PActivityModuleHandlerService } from './module'; + +/** + * Handler to treat links to H5P activity report. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModH5PActivityReportLinkHandlerService extends CoreContentLinksHandlerBase { + + name = 'AddonModH5PActivityReportLinkHandler'; + featureName = 'CoreCourseModuleDelegate_AddonModH5PActivity'; + pattern = /\/mod\/h5pactivity\/report\.php.*([&?]a=\d+)/; + + /** + * @inheritdoc + */ + getActions( + siteIds: string[], + url: string, + params: Record, + courseId?: number, + ): CoreContentLinksAction[] | Promise { + courseId = courseId || Number(params.courseid) || Number(params.cid); + + return [{ + action: async (siteId) => { + try { + const instanceId = Number(params.a); + + if (!courseId) { + courseId = await this.getCourseId(instanceId, siteId); + } + + const module = await CoreCourse.getModuleBasicInfoByInstance(instanceId, 'h5pactivity', siteId); + + if (typeof params.attemptid != 'undefined') { + this.openAttemptResults(module.id, Number(params.attemptid), courseId, siteId); + } else { + const userId = params.userid ? Number(params.userid) : undefined; + + this.openUserAttempts(module.id, courseId, siteId, userId); + } + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, 'Error processing link.'); + } + }, + }]; + } + + /** + * Get course Id for an activity. + * + * @param id Activity ID. + * @param siteId Site ID. + * @return Promise resolved with course ID. + */ + protected async getCourseId(id: number, siteId: string): Promise { + const modal = await CoreDomUtils.showModalLoading(); + + try { + const module = await CoreCourse.getModuleBasicInfoByInstance(id, 'h5pactivity', siteId); + + return module.course; + } finally { + modal.dismiss(); + } + } + + /** + * @inheritdoc + */ + isEnabled(): Promise { + return AddonModH5PActivity.isPluginEnabled(); + } + + /** + * Open attempt results. + * + * @param cmId Module ID. + * @param attemptId Attempt ID. + * @param courseId Course ID. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected openAttemptResults(cmId: number, attemptId: number, courseId: number, siteId: string): void { + const path = AddonModH5PActivityModuleHandlerService.PAGE_NAME + `/${courseId}/${cmId}/attemptresults/${attemptId}`; + + CoreNavigator.navigateToSitePath(path, { + siteId, + }); + } + + /** + * Open user attempts. + * + * @param cmId Module ID. + * @param courseId Course ID. + * @param siteId Site ID. + * @param userId User ID. If not defined, current user in site. + * @return Promise resolved when done. + */ + protected openUserAttempts(cmId: number, courseId: number, siteId: string, userId?: number): void { + userId = userId || CoreSites.getCurrentSiteUserId(); + const path = AddonModH5PActivityModuleHandlerService.PAGE_NAME + `/${courseId}/${cmId}/userattempts/${userId}`; + + CoreNavigator.navigateToSitePath(path, { + siteId, + }); + } + +} + +export const AddonModH5PActivityReportLinkHandler = makeSingleton(AddonModH5PActivityReportLinkHandlerService); diff --git a/src/addons/mod/h5pactivity/services/handlers/sync-cron.ts b/src/addons/mod/h5pactivity/services/handlers/sync-cron.ts new file mode 100644 index 000000000..9742b4245 --- /dev/null +++ b/src/addons/mod/h5pactivity/services/handlers/sync-cron.ts @@ -0,0 +1,52 @@ +// (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 { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonModH5PActivitySync } from '../h5pactivity-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModH5PActivitySyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModH5PActivitySyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModH5PActivitySync.syncAllActivities(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModH5PActivitySync.syncInterval; + } + +} + +export const AddonModH5PActivitySyncCronHandler = makeSingleton(AddonModH5PActivitySyncCronHandlerService); diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index bf3163e78..95fdd0b31 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -26,6 +26,7 @@ import { AddonModQuizModule } from './quiz/quiz.module'; import { AddonModResourceModule } from './resource/resource.module'; import { AddonModUrlModule } from './url/url.module'; import { AddonModLtiModule } from './lti/lti.module'; +import { AddonModH5PActivityModule } from './h5pactivity/h5pactivity.module'; @NgModule({ declarations: [], @@ -42,6 +43,7 @@ import { AddonModLtiModule } from './lti/lti.module'; AddonModFolderModule, AddonModImscpModule, AddonModLtiModule, + AddonModH5PActivityModule, ], providers: [], exports: [], diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index 8a10e06a7..e07d3e619 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -129,7 +129,7 @@ import { ADDON_MOD_BOOK_SERVICES } from '@addons/mod/book/book.module'; import { ADDON_MOD_FOLDER_SERVICES } from '@addons/mod/folder/folder.module'; import { ADDON_MOD_FORUM_SERVICES } from '@addons/mod/forum/forum.module'; // @todo import { ADDON_MOD_GLOSSARY_SERVICES } from '@addons/mod/glossary/glossary.module'; -// @todo import { ADDON_MOD_H5P_ACTIVITY_SERVICES } from '@addons/mod/h5pactivity/h5pactivity.module'; +import { ADDON_MOD_H5P_ACTIVITY_SERVICES } from '@addons/mod/h5pactivity/h5pactivity.module'; import { ADDON_MOD_IMSCP_SERVICES } from '@addons/mod/imscp/imscp.module'; import { ADDON_MOD_LESSON_SERVICES } from '@addons/mod/lesson/lesson.module'; import { ADDON_MOD_LTI_SERVICES } from '@addons/mod/lti/lti.module'; @@ -294,7 +294,7 @@ export class CoreCompileProvider { ...ADDON_MOD_FOLDER_SERVICES, ...ADDON_MOD_FORUM_SERVICES, // @todo ...ADDON_MOD_GLOSSARY_SERVICES, - // @todo ...ADDON_MOD_H5P_ACTIVITY_SERVICES, + ...ADDON_MOD_H5P_ACTIVITY_SERVICES, ...ADDON_MOD_IMSCP_SERVICES, ...ADDON_MOD_LESSON_SERVICES, ...ADDON_MOD_LTI_SERVICES, diff --git a/src/core/features/course/classes/main-resource-component.ts b/src/core/features/course/classes/main-resource-component.ts index 239ae758b..e39121412 100644 --- a/src/core/features/course/classes/main-resource-component.ts +++ b/src/core/features/course/classes/main-resource-component.ts @@ -226,7 +226,7 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, async gotoBlog(): Promise { const params: Params = { cmId: this.module.id }; - CoreNavigator.navigateToSitePath(AddonBlogMainMenuHandlerService.PAGE_NAME, { params }); + await CoreNavigator.navigateToSitePath(AddonBlogMainMenuHandlerService.PAGE_NAME, { params }); } /** diff --git a/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts b/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts index a05f4cdcc..9cbc3878b 100644 --- a/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts +++ b/src/core/features/h5p/components/h5p-iframe/h5p-iframe.ts @@ -68,7 +68,6 @@ export class CoreH5PIframeComponent implements OnChanges, OnDestroy { this.siteCanDownload = this.site.canDownloadFiles() && !CoreH5P.isOfflineDisabledInSite(); // Send resize events when the page holding this component is re-entered. - // @todo: Check that this works as expected. this.currentPageRoute = router.url; this.subscription = router.events .pipe(filter(event => event instanceof NavigationEnd)) diff --git a/src/core/services/file.ts b/src/core/services/file.ts index 94b608103..7e903a46d 100644 --- a/src/core/services/file.ts +++ b/src/core/services/file.ts @@ -1215,7 +1215,12 @@ export class CoreFileProvider { * @return Path. */ getWWWPath(): string { - const position = window.location.href.indexOf('index.html'); + // Use current URL, removing the path. + if (!window.location.pathname || window.location.pathname == '/') { + return window.location.href; + } + + const position = window.location.href.indexOf(window.location.pathname); if (position != -1) { return window.location.href.substr(0, position);