diff --git a/scripts/langindex.json b/scripts/langindex.json index bd312bc51..0f4a9586e 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -684,6 +684,7 @@ "addon.mod_h5pactivity.no_compatible_track": "h5pactivity", "addon.mod_h5pactivity.offlinedisabledwarning": "local_moodlemobileapp", "addon.mod_h5pactivity.outcome": "h5pactivity", + "addon.mod_h5pactivity.previewmode": "h5pactivity", "addon.mod_h5pactivity.result_fill-in": "h5pactivity", "addon.mod_h5pactivity.result_other": "h5pactivity", "addon.mod_h5pactivity.review_my_attempts": "h5pactivity", @@ -1405,6 +1406,7 @@ "core.confirmdeletefile": "repository", "core.confirmgotabroot": "local_moodlemobileapp", "core.confirmgotabrootdefault": "local_moodlemobileapp", + "core.confirmleaveunknownchanges": "local_moodlemobileapp", "core.confirmloss": "local_moodlemobileapp", "core.confirmopeninbrowser": "local_moodlemobileapp", "core.considereddigitalminor": "moodle", @@ -1858,6 +1860,7 @@ "core.mod_folder": "folder/pluginname", "core.mod_forum": "forum/pluginname", "core.mod_glossary": "glossary/pluginname", + "core.mod_h5pactivity": "h5pactivity/pluginname", "core.mod_ims": "imscp/pluginname", "core.mod_imscp": "imscp/pluginname", "core.mod_label": "label/pluginname", diff --git a/src/addon/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html b/src/addon/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html index f2acd6230..23df4f6ad 100644 --- a/src/addon/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html +++ b/src/addon/mod/h5pactivity/components/index/addon-mod-h5pactivity-index.html @@ -5,7 +5,8 @@ - + + @@ -16,11 +17,21 @@ + + + {{ 'core.hasdatatosync' | translate:{$a: moduleName} }} + + {{ 'core.h5p.offlinedisabled' | translate }} {{ 'addon.mod_h5pactivity.offlinedisabledwarning' | translate }} + + + {{ 'addon.mod_h5pactivity.previewmode' | translate }} + + {{ stateMessage | translate }} @@ -39,5 +50,5 @@ - + diff --git a/src/addon/mod/h5pactivity/components/index/index.ts b/src/addon/mod/h5pactivity/components/index/index.ts index bf879ebe0..0244d338f 100644 --- a/src/addon/mod/h5pactivity/components/index/index.ts +++ b/src/addon/mod/h5pactivity/components/index/index.ts @@ -24,12 +24,15 @@ import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main import { CoreH5P } from '@core/h5p/providers/h5p'; import { CoreH5PDisplayOptions } from '@core/h5p/classes/core'; import { CoreH5PHelper } from '@core/h5p/classes/helper'; +import { CoreXAPI } from '@core/xapi/providers/xapi'; +import { CoreXAPIOffline } from '@core/xapi/providers/offline'; import { CoreConstants } from '@core/constants'; import { CoreSite } from '@classes/site'; import { AddonModH5PActivity, AddonModH5PActivityProvider, AddonModH5PActivityData, AddonModH5PActivityAccessInfo } from '../../providers/h5pactivity'; +import { AddonModH5PActivitySyncProvider, AddonModH5PActivitySync } from '../../providers/sync'; /** * Component that displays an H5P activity entry page. @@ -57,10 +60,15 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv fileUrl: string; // The fileUrl to use to play the package. state: string; // State of the file. siteCanDownload: boolean; + trackComponent: string; // Component for tracking. + hasOffline: boolean; + isOpeningPage: boolean; protected fetchContentDefaultError = 'addon.mod_h5pactivity.errorgetactivity'; + protected syncEventName = AddonModH5PActivitySyncProvider.AUTO_SYNCED; protected site: CoreSite; protected observer; + protected messageListenerFunction: (event: MessageEvent) => Promise; constructor(injector: Injector, @Optional() protected content: Content) { @@ -68,6 +76,10 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv this.site = this.sitesProvider.getCurrentSite(); this.siteCanDownload = this.site.canDownloadFiles() && !CoreH5P.instance.isOfflineDisabledInSite(); + + // Listen for messages from the iframe. + this.messageListenerFunction = this.onIframeMessage.bind(this); + window.addEventListener('message', this.messageListenerFunction); } /** @@ -96,23 +108,30 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv */ protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { try { - this.h5pActivity = await AddonModH5PActivity.instance.getH5PActivity(this.courseId, this.module.id); + this.h5pActivity = await AddonModH5PActivity.instance.getH5PActivity(this.courseId, this.module.id, false, this.siteId); this.dataRetrieved.emit(this.h5pActivity); this.description = this.h5pActivity.intro; this.displayOptions = CoreH5PHelper.decodeDisplayOptions(this.h5pActivity.displayoptions); - if (this.h5pActivity.package && this.h5pActivity.package[0]) { - // The online player should use the original file, not the trusted one. - this.onlinePlayerUrl = CoreH5P.instance.h5pPlayer.calculateOnlinePlayerUrl( - this.site.getURL(), this.h5pActivity.package[0].fileurl, this.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.instance.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(); @@ -127,13 +146,22 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv } } + /** + * Fetch the access info and store it in the right variables. + * + * @return Promise resolved when done. + */ + protected async checkHasOffline(): Promise { + this.hasOffline = await CoreXAPIOffline.instance.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.instance.getAccessInformation(this.h5pActivity.id); + this.accessInfo = await AddonModH5PActivity.instance.getAccessInformation(this.h5pActivity.id, false, this.siteId); } /** @@ -322,8 +350,114 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv /** * Go to view user events. */ - viewMyAttempts(): void { - this.navCtrl.push('AddonModH5PActivityUserAttemptsPage', {courseId: this.courseId, h5pActivityId: this.h5pActivity.id}); + async viewMyAttempts(): Promise { + this.isOpeningPage = true; + + try { + await this.navCtrl.push('AddonModH5PActivityUserAttemptsPage', { + courseId: this.courseId, + h5pActivityId: this.h5pActivity.id, + }); + } 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.instance.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.instance.postStatements(this.h5pActivity.context, event.data.component, + JSON.stringify(event.data.statements), options); + + this.hasOffline = !sent; + + if (sent) { + try { + // Invalidate attempts. + await AddonModH5PActivity.instance.invalidateUserAttempts(this.h5pActivity.id, undefined, this.siteId); + } catch (error) { + // Ignore errors. + } + } + } catch (error) { + CoreDomUtils.instance.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. + */ + protected isCurrentXAPIPost(data: any): boolean { + if (data.context != 'moodleapp' || 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; + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected sync(): Promise { + return AddonModH5PActivitySync.instance.syncActivity(this.h5pActivity.context, this.site.getId()); + } + + /** + * An autosync event has been received. + * + * @param syncEventData Data receiven on sync observer. + */ + protected autoSyncEventReceived(syncEventData: any): void { + this.checkHasOffline(); + } + + /** + * Go to blog posts. + * + * @param event Event. + */ + async gotoBlog(event: any): Promise { + this.isOpeningPage = true; + + try { + await super.gotoBlog(event); + } finally { + this.isOpeningPage = false; + } } /** @@ -331,5 +465,6 @@ export class AddonModH5PActivityIndexComponent extends CoreCourseModuleMainActiv */ ngOnDestroy(): void { this.observer && this.observer.off(); + window.removeEventListener('message', this.messageListenerFunction); } } diff --git a/src/addon/mod/h5pactivity/h5pactivity.module.ts b/src/addon/mod/h5pactivity/h5pactivity.module.ts index 3053c1168..85cce71a2 100644 --- a/src/addon/mod/h5pactivity/h5pactivity.module.ts +++ b/src/addon/mod/h5pactivity/h5pactivity.module.ts @@ -14,6 +14,7 @@ import { NgModule } from '@angular/core'; +import { CoreCronDelegate } from '@providers/cron'; import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; @@ -21,13 +22,16 @@ import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module- import { AddonModH5PActivityComponentsModule } from './components/components.module'; import { AddonModH5PActivityModuleHandler } from './providers/module-handler'; import { AddonModH5PActivityProvider } from './providers/h5pactivity'; +import { AddonModH5PActivitySyncProvider } from './providers/sync'; import { AddonModH5PActivityPrefetchHandler } from './providers/prefetch-handler'; import { AddonModH5PActivityIndexLinkHandler } from './providers/index-link-handler'; import { AddonModH5PActivityReportLinkHandler } from './providers/report-link-handler'; +import { AddonModH5PActivitySyncCronHandler } from './providers/sync-cron-handler'; // List of providers (without handlers). export const ADDON_MOD_H5P_ACTIVITY_PROVIDERS: any[] = [ AddonModH5PActivityProvider, + AddonModH5PActivitySyncProvider, ]; @NgModule({ @@ -38,10 +42,12 @@ export const ADDON_MOD_H5P_ACTIVITY_PROVIDERS: any[] = [ ], providers: [ AddonModH5PActivityProvider, + AddonModH5PActivitySyncProvider, AddonModH5PActivityModuleHandler, AddonModH5PActivityPrefetchHandler, AddonModH5PActivityIndexLinkHandler, AddonModH5PActivityReportLinkHandler, + AddonModH5PActivitySyncCronHandler, ] }) export class AddonModH5PActivityModule { @@ -51,11 +57,14 @@ export class AddonModH5PActivityModule { prefetchHandler: AddonModH5PActivityPrefetchHandler, linksDelegate: CoreContentLinksDelegate, indexHandler: AddonModH5PActivityIndexLinkHandler, - reportLinkHandler: AddonModH5PActivityReportLinkHandler) { + reportLinkHandler: AddonModH5PActivityReportLinkHandler, + cronDelegate: CoreCronDelegate, + syncHandler: AddonModH5PActivitySyncCronHandler) { moduleDelegate.registerHandler(moduleHandler); prefetchDelegate.registerHandler(prefetchHandler); linksDelegate.registerHandler(indexHandler); linksDelegate.registerHandler(reportLinkHandler); + cronDelegate.register(syncHandler); } } diff --git a/src/addon/mod/h5pactivity/lang/en.json b/src/addon/mod/h5pactivity/lang/en.json index 21bb9eecd..af3034dda 100644 --- a/src/addon/mod/h5pactivity/lang/en.json +++ b/src/addon/mod/h5pactivity/lang/en.json @@ -24,6 +24,7 @@ "no_compatible_track": "This interaction ({{$a}}) does not provide tracking information or the tracking provided is not compatible with the current activity version.", "offlinedisabledwarning": "You will 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": "Unkown interaction type", "review_my_attempts": "View my attempts", diff --git a/src/addon/mod/h5pactivity/pages/index/index.ts b/src/addon/mod/h5pactivity/pages/index/index.ts index 7435400a6..e3bc7e304 100644 --- a/src/addon/mod/h5pactivity/pages/index/index.ts +++ b/src/addon/mod/h5pactivity/pages/index/index.ts @@ -14,9 +14,12 @@ import { Component, ViewChild } from '@angular/core'; import { IonicPage, NavParams } from 'ionic-angular'; +import { CoreDomUtils } from '@providers/utils/dom'; import { AddonModH5PActivityIndexComponent } from '../../components/index/index'; import { AddonModH5PActivityData } from '../../providers/h5pactivity'; +import { Translate } from '@singletons/core.singletons'; + /** * Page that displays an H5P activity. */ @@ -46,4 +49,17 @@ export class AddonModH5PActivityIndexPage { updateData(h5p: AddonModH5PActivityData): void { this.title = h5p.name || this.title; } + + /** + * Check if we can leave the page or not. + * + * @return Resolved if we can leave it, rejected if not. + */ + ionViewCanLeave(): Promise { + if (!this.h5pComponent.playing || this.h5pComponent.isOpeningPage) { + return; + } + + return CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.confirmleaveunknownchanges')); + } } diff --git a/src/addon/mod/h5pactivity/providers/h5pactivity.ts b/src/addon/mod/h5pactivity/providers/h5pactivity.ts index dd9ff494d..5e1fabc42 100644 --- a/src/addon/mod/h5pactivity/providers/h5pactivity.ts +++ b/src/addon/mod/h5pactivity/providers/h5pactivity.ts @@ -31,6 +31,7 @@ import { makeSingleton, Translate } from '@singletons/core.singletons'; @Injectable() export class AddonModH5PActivityProvider { static COMPONENT = 'mmaModH5PActivity'; + static TRACK_COMPONENT = 'mod_h5pactivity'; // Component for tracking. protected ROOT_CACHE_KEY = 'mmaModH5PActivity:'; @@ -384,6 +385,20 @@ export class AddonModH5PActivityProvider { return this.getH5PActivityByField(courseId, 'coursemodule', cmId, forceCache, siteId); } + /** + * Get an H5P activity by context ID. + * + * @param courseId Course ID. + * @param contextId Context ID. + * @param forceCache Whether it should always return cached data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the activity data. + */ + getH5PActivityByContextId(courseId: number, contextId: number, forceCache?: boolean, siteId?: string) + : Promise { + return this.getH5PActivityByField(courseId, 'context', contextId, forceCache, siteId); + } + /** * Get an H5P activity by instance ID. * @@ -595,6 +610,7 @@ export type AddonModH5PActivityData = { 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?: { diff --git a/src/addon/mod/h5pactivity/providers/sync-cron-handler.ts b/src/addon/mod/h5pactivity/providers/sync-cron-handler.ts new file mode 100644 index 000000000..5ffccb86b --- /dev/null +++ b/src/addon/mod/h5pactivity/providers/sync-cron-handler.ts @@ -0,0 +1,46 @@ +// (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 '@providers/cron'; +import { AddonModH5PActivitySync } from './sync'; + +/** + * Synchronization cron handler. + */ +@Injectable() +export class AddonModH5PActivitySyncCronHandler 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.instance.syncAllActivities(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModH5PActivitySync.instance.syncInterval; + } +} diff --git a/src/addon/mod/h5pactivity/providers/sync.ts b/src/addon/mod/h5pactivity/providers/sync.ts new file mode 100644 index 000000000..dafa44842 --- /dev/null +++ b/src/addon/mod/h5pactivity/providers/sync.ts @@ -0,0 +1,223 @@ +// (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 { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEvents } from '@providers/events'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreUtils } from '@providers/utils/utils'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreCourse } from '@core/course/providers/course'; +import { CoreCourseLogHelper } from '@core/course/providers/log-helper'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CoreCourseActivitySyncBaseProvider } from '@core/course/classes/activity-sync'; +import { CoreXAPI } from '@core/xapi/providers/xapi'; +import { CoreXAPIOffline } from '@core/xapi/providers/offline'; +import { AddonModH5PActivity, AddonModH5PActivityProvider } from './h5pactivity'; +import { AddonModH5PActivityPrefetchHandler } from './prefetch-handler'; + +import { makeSingleton } from '@singletons/core.singletons'; + +/** + * Service to sync H5P activities. + */ +@Injectable() +export class AddonModH5PActivitySyncProvider extends CoreCourseActivitySyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_h5pactivity_autom_synced'; + protected componentTranslate: string; + + constructor(sitesProvider: CoreSitesProvider, + loggerProvider: CoreLoggerProvider, + appProvider: CoreAppProvider, + translate: TranslateService, + textUtils: CoreTextUtilsProvider, + syncProvider: CoreSyncProvider, + timeUtils: CoreTimeUtilsProvider, + prefetchHandler: AddonModH5PActivityPrefetchHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate) { + + super('AddonModH5PActivitySyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate, + timeUtils, prefetchDelegate, prefetchHandler); + + this.componentTranslate = CoreCourse.instance.translateModuleName('h5pactivity'); + } + + /** + * 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 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. + */ + protected async syncAllActivitiesFunc(siteId?: string, force?: boolean): Promise { + const entries = await CoreXAPIOffline.instance.getAllStatements(siteId); + + // Sync all responses. + const promises = entries.map((response) => { + const promise = force ? this.syncActivity(response.contextid, siteId) : + this.syncActivityIfNeeded(response.contextid, siteId); + + return promise.then((result) => { + if (result && result.updated) { + // Sync successful, send event. + CoreEvents.instance.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 || this.sitesProvider.getCurrentSiteId(); + + if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + throw this.translate.instant('core.networkerrormsg'); + } + + 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<{warnings: string[], updated: boolean}> { + + this.logger.debug(`Try to sync H5P activity with context ID '${contextId}'`); + + const result = { + warnings: [], + updated: false, + }; + + // Get all the statements stored for the activity. + const entries = await CoreXAPIOffline.instance.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.instance.getH5PActivityByContextId(courseId, contextId, false, siteId); + + // Sync offline logs. + try { + await CoreCourseLogHelper.instance.syncIfNeeded(AddonModH5PActivityProvider.COMPONENT, h5pActivity.id, siteId); + } catch (error) { + // Ignore errors. + } + + // Send the statements in order. + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + + try { + await CoreXAPI.instance.postStatementsOnline(entry.component, entry.statements, siteId); + + result.updated = true; + + await CoreXAPIOffline.instance.deleteStatements(entry.id, siteId); + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // The WebService has thrown an error, this means that statements cannot be submitted. Delete them. + result.updated = true; + + await CoreXAPIOffline.instance.deleteStatements(entry.id, siteId); + + // Responses deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: entry.extra, + error: this.textUtils.getErrorMessageFromError(error), + })); + } else { + // Stop synchronizing. + throw error; + } + } + } + + if (result.updated) { + try { + // Data has been sent to server, invalidate attempts. + await AddonModH5PActivity.instance.invalidateUserAttempts(h5pActivity.id, undefined, siteId); + } catch (error) { + // Ignore errors. + } + } + + // Sync finished, set sync time. + await this.setSyncTime(contextId, siteId); + + return result; + } +} + +export class AddonModH5PActivitySync extends makeSingleton(AddonModH5PActivitySyncProvider) {} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d78d45edb..d5080dccb 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -88,6 +88,7 @@ import { CoreFilterModule } from '@core/filter/filter.module'; import { CoreH5PModule } from '@core/h5p/h5p.module'; import { CoreSearchModule } from '@core/search/search.module'; import { CoreEditorModule } from '@core/editor/editor.module'; +import { CoreXAPIModule } from '@core/xapi/xapi.module'; // Addon modules. import { AddonBadgesModule } from '@addon/badges/badges.module'; @@ -241,6 +242,7 @@ export const WP_PROVIDER: any = null; CoreH5PModule, CoreSearchModule, CoreEditorModule, + CoreXAPIModule, AddonBadgesModule, AddonBlogModule, AddonCalendarModule, diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index edb8dfe7a..8f89970e1 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -684,6 +684,7 @@ "addon.mod_h5pactivity.no_compatible_track": "This interaction ({{$a}}) does not provide tracking information or the tracking provided is not compatible with the current activity version.", "addon.mod_h5pactivity.offlinedisabledwarning": "You will need to be online to view the H5P package.", "addon.mod_h5pactivity.outcome": "Outcome", + "addon.mod_h5pactivity.previewmode": "This content is displayed in preview mode. No attempt tracking will be stored.", "addon.mod_h5pactivity.result_fill-in": "Fill-in text", "addon.mod_h5pactivity.result_other": "Unkown interaction type", "addon.mod_h5pactivity.review_my_attempts": "View my attempts", @@ -1406,6 +1407,7 @@ "core.confirmdeletefile": "Are you sure you want to delete this file?", "core.confirmgotabroot": "Are you sure you want to go back to {{name}}?", "core.confirmgotabrootdefault": "Are you sure you want to go to the initial page of the current tab?", + "core.confirmleaveunknownchanges": "Are you sure you want to leave this page? If you have some unsaved changes they will be lost.", "core.confirmloss": "Are you sure? All changes will be lost.", "core.confirmopeninbrowser": "Do you want to open it in a web browser?", "core.considereddigitalminor": "You are too young to create an account on this site.", @@ -1859,6 +1861,7 @@ "core.mod_folder": "Folder", "core.mod_forum": "Forum", "core.mod_glossary": "Glossary", + "core.mod_h5pactivity": "H5P", "core.mod_ims": "IMS content package", "core.mod_imscp": "IMS content package", "core.mod_label": "Label", diff --git a/src/classes/site.ts b/src/classes/site.ts index a3dda67a2..463b5a027 100644 --- a/src/classes/site.ts +++ b/src/classes/site.ts @@ -1820,7 +1820,7 @@ export class CoreSite { this.lastAutoLogin = this.timeUtils.timestamp(); - return data.autologinurl + '?userid=' + userId + '&key=' + data.key + '&urltogo=' + url; + return data.autologinurl + '?userid=' + userId + '&key=' + data.key + '&urltogo=' + encodeURIComponent(url); }).catch(() => { // Couldn't get autologin key, return the same URL. diff --git a/src/core/compile/providers/compile.ts b/src/core/compile/providers/compile.ts index 0c9b84f9a..85d063b14 100644 --- a/src/core/compile/providers/compile.ts +++ b/src/core/compile/providers/compile.ts @@ -41,6 +41,7 @@ import { CORE_PUSHNOTIFICATIONS_PROVIDERS } from '@core/pushnotifications/pushno import { IONIC_NATIVE_PROVIDERS } from '@core/emulator/emulator.module'; import { CORE_EDITOR_PROVIDERS } from '@core/editor/editor.module'; import { CORE_SEARCH_PROVIDERS } from '@core/search/search.module'; +import { CORE_XAPI_PROVIDERS } from '@core/xapi/xapi.module'; // Import only this provider to prevent circular dependencies. import { CoreSitePluginsProvider } from '@core/siteplugins/providers/siteplugins'; @@ -243,7 +244,7 @@ export class CoreCompileProvider { .concat(ADDON_MOD_WORKSHOP_PROVIDERS).concat(ADDON_NOTES_PROVIDERS).concat(ADDON_NOTIFICATIONS_PROVIDERS) .concat(CORE_PUSHNOTIFICATIONS_PROVIDERS).concat(ADDON_REMOTETHEMES_PROVIDERS).concat(CORE_BLOCK_PROVIDERS) .concat(CORE_FILTER_PROVIDERS).concat(CORE_H5P_PROVIDERS).concat(CORE_EDITOR_PROVIDERS) - .concat(CORE_SEARCH_PROVIDERS).concat(ADDON_MOD_H5P_ACTIVITY_PROVIDERS); + .concat(CORE_SEARCH_PROVIDERS).concat(ADDON_MOD_H5P_ACTIVITY_PROVIDERS).concat(CORE_XAPI_PROVIDERS); // We cannot inject anything to this constructor. Use the Injector to inject all the providers into the instance. for (const i in providers) { diff --git a/src/core/course/classes/main-resource-component.ts b/src/core/course/classes/main-resource-component.ts index 0a333639b..67a5985d3 100644 --- a/src/core/course/classes/main-resource-component.ts +++ b/src/core/course/classes/main-resource-component.ts @@ -280,8 +280,8 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, * * @param event Event. */ - gotoBlog(event: any): void { - this.linkHelper.goInSite(this.navCtrl, 'AddonBlogEntriesPage', { cmId: this.module.id }); + gotoBlog(event: any): Promise { + return this.linkHelper.goInSite(this.navCtrl, 'AddonBlogEntriesPage', { cmId: this.module.id }); } /** diff --git a/src/core/course/providers/module-prefetch-delegate.ts b/src/core/course/providers/module-prefetch-delegate.ts index 38a5cc3d0..917a7bc4f 100644 --- a/src/core/course/providers/module-prefetch-delegate.ts +++ b/src/core/course/providers/module-prefetch-delegate.ts @@ -244,6 +244,7 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { protected ROOT_CACHE_KEY = 'mmCourse:'; protected statusCache = new CoreCache(); + protected featurePrefix = 'CoreCourseModuleDelegate_'; protected handlerNameProperty = 'modName'; // Promises for check updates, to prevent performing the same request twice at the same time. diff --git a/src/core/h5p/assets/moodle/js/displayoptions.js b/src/core/h5p/assets/moodle/js/displayoptions.js deleted file mode 100644 index 59088886d..000000000 --- a/src/core/h5p/assets/moodle/js/displayoptions.js +++ /dev/null @@ -1,35 +0,0 @@ -// (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. - -/** - * Handle display options included in the URL and put them in the H5PIntegration object if it exists. - */ - -if (window.H5PIntegration && window.H5PIntegration.contents && location.search) { - var contentData = window.H5PIntegration.contents[Object.keys(window.H5PIntegration.contents)[0]]; - - if (contentData) { - contentData.displayOptions = contentData.displayOptions || {}; - - var search = location.search.replace(/^\?/, ''), - split = search.split('&'); - - split.forEach(function(param) { - var nameAndValue = param.split('='); - if (nameAndValue.length == 2) { - contentData.displayOptions[nameAndValue[0]] = nameAndValue[1] === '1' || nameAndValue[1] === 'true'; - } - }); - } -} diff --git a/src/core/h5p/assets/moodle/js/embed.js b/src/core/h5p/assets/moodle/js/embed.js index 6135db2e5..8df9b26a3 100644 --- a/src/core/h5p/assets/moodle/js/embed.js +++ b/src/core/h5p/assets/moodle/js/embed.js @@ -71,6 +71,21 @@ H5PEmbedCommunicator = (function() { // Parent origin can be anything. window.parent.postMessage(data, '*'); }; + + /** + * Send a xAPI statement to LMS. + * + * @param {string} component + * @param {Object} statements + */ + self.post = function(component, statements) { + window.parent.postMessage({ + context: 'moodleapp', + action: 'xapi_post_statement', + component: component, + statements: statements, + }, '*'); + }; } return (window.postMessage && window.addEventListener ? new Communicator() : undefined); @@ -150,6 +165,38 @@ document.onreadystatechange = function() { }, 0); }); + // Get emitted xAPI data. + H5P.externalDispatcher.on('xAPI', function(event) { + var moodlecomponent = H5P.getMoodleComponent(); + if (moodlecomponent == undefined) { + return; + } + // Skip malformed events. + var hasStatement = event && event.data && event.data.statement; + if (!hasStatement) { + return; + } + + var statement = event.data.statement; + var validVerb = statement.verb && statement.verb.id; + if (!validVerb) { + return; + } + + var isCompleted = statement.verb.id === 'http://adlnet.gov/expapi/verbs/answered' + || statement.verb.id === 'http://adlnet.gov/expapi/verbs/completed'; + + var isChild = statement.context && statement.context.contextActivities && + statement.context.contextActivities.parent && + statement.context.contextActivities.parent[0] && + statement.context.contextActivities.parent[0].id; + + if (isCompleted && !isChild) { + var statements = H5P.getXAPIStatements(this.contentId, statement); + H5PEmbedCommunicator.post(moodlecomponent, statements); + } + }); + // Trigger initial resize for instance. H5P.trigger(instance, 'resize'); }; diff --git a/src/core/h5p/assets/moodle/js/h5p_overrides.js b/src/core/h5p/assets/moodle/js/h5p_overrides.js new file mode 100644 index 000000000..c9350e204 --- /dev/null +++ b/src/core/h5p/assets/moodle/js/h5p_overrides.js @@ -0,0 +1,55 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +H5P.findInstanceFromId = function (contentId) { + if (!contentId) { + return H5P.instances[0]; + } + if (H5P.instances !== undefined) { + for (var i = 0; i < H5P.instances.length; i++) { + if (H5P.instances[i].contentId === contentId) { + return H5P.instances[i]; + } + } + } + return undefined; +}; +H5P.getXAPIStatements = function (contentId, statement) { + var statements = []; + var instance = H5P.findInstanceFromId(contentId); + if (!instance){ + return statements; + } + if (instance.getXAPIData == undefined) { + var xAPIData = { + statement: statement + }; + } else { + var xAPIData = instance.getXAPIData(); + } + if (xAPIData.statement != undefined) { + statements.push(xAPIData.statement); + } + if (xAPIData.children != undefined) { + statements = statements.concat(xAPIData.children.map(a => a.statement)); + } + return statements; +}; +H5P.getMoodleComponent = function () { + if (H5PIntegration.moodleComponent) { + return H5PIntegration.moodleComponent; + } + return undefined; +}; \ No newline at end of file diff --git a/src/core/h5p/assets/moodle/js/params.js b/src/core/h5p/assets/moodle/js/params.js new file mode 100644 index 000000000..87722aacd --- /dev/null +++ b/src/core/h5p/assets/moodle/js/params.js @@ -0,0 +1,46 @@ +// (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. + +/** + * Handle params included in the URL and put them in the H5PIntegration object if it exists. + */ + +if (window.H5PIntegration && window.H5PIntegration.contents && location.search) { + var contentData = window.H5PIntegration.contents[Object.keys(window.H5PIntegration.contents)[0]]; + + var search = location.search.replace(/^\?/, ''); + var split = search.split('&'); + + split.forEach(function(param) { + var nameAndValue = param.split('='); + + if (nameAndValue[0] == 'displayOptions' && contentData) { + try { + contentData.displayOptions = contentData.displayOptions || {}; + + var displayOptions = JSON.parse(decodeURIComponent(nameAndValue[1])); + + if (displayOptions && typeof displayOptions == 'object') { + Object.assign(contentData.displayOptions, displayOptions); + } + } catch (error) { + console.error('Error parsing display options', decodeURIComponent(nameAndValue[1])); + } + } else if (nameAndValue[0] == 'component') { + window.H5PIntegration.moodleComponent = nameAndValue[1]; + } else if (nameAndValue[0] == 'trackingUrl' && contentData) { + contentData.url = nameAndValue[1]; + } + }); +} diff --git a/src/core/h5p/classes/core.ts b/src/core/h5p/classes/core.ts index 20984c8b2..7e65b67c7 100644 --- a/src/core/h5p/classes/core.ts +++ b/src/core/h5p/classes/core.ts @@ -615,6 +615,8 @@ export class CoreH5PCore { urls.push(libUrl + script); }); + urls.push(CoreTextUtils.instance.concatenatePaths(libUrl, 'moodle/js/h5p_overrides.js')); + return urls; } diff --git a/src/core/h5p/classes/helper.ts b/src/core/h5p/classes/helper.ts index 1d434308c..30f3807fd 100644 --- a/src/core/h5p/classes/helper.ts +++ b/src/core/h5p/classes/helper.ts @@ -17,6 +17,7 @@ import { CoreSites } from '@providers/sites'; import { CoreMimetypeUtils } from '@providers/utils/mimetype'; import { CoreTextUtils } from '@providers/utils/text'; import { CoreUtils } from '@providers/utils/utils'; +import { CoreUser } from '@core/user/providers/user'; import { CoreH5P } from '../providers/h5p'; import { CoreH5PCore, CoreH5PDisplayOptions } from './core'; import { FileEntry } from '@ionic-native/file'; @@ -90,6 +91,13 @@ export class CoreH5PHelper { static async getCoreSettings(siteId?: string): Promise { const site = await CoreSites.instance.getSite(siteId); + let user; + + try { + user = await CoreUser.instance.getProfile(site.getUserId(), undefined, true); + } catch (error) { + // Ignore errors. + } const basePath = CoreFile.instance.getBasePathInstant(); const ajaxPaths = { @@ -110,7 +118,7 @@ export class CoreH5PHelper { l10n: { H5P: CoreH5P.instance.h5pCore.getLocalization(), }, - user: [], + user: {name: site.getInfo().fullname, mail: user && user.email}, hubIsEnabled: false, reportingIsEnabled: false, crossorigin: null, diff --git a/src/core/h5p/classes/player.ts b/src/core/h5p/classes/player.ts index cefa413b6..adede11e4 100644 --- a/src/core/h5p/classes/player.ts +++ b/src/core/h5p/classes/player.ts @@ -17,6 +17,7 @@ import { CoreSites } from '@providers/sites'; import { CoreTextUtils } from '@providers/utils/text'; import { CoreUrlUtils } from '@providers/utils/url'; import { CoreUtils } from '@providers/utils/utils'; +import { CoreXAPI } from '@core/xapi/providers/xapi'; import { CoreH5P } from '../providers/h5p'; import { CoreH5PCore, CoreH5PDisplayOptions, CoreH5PContentData, CoreH5PDependenciesFiles } from './core'; import { CoreH5PHelper } from './helper'; @@ -81,7 +82,7 @@ export class CoreH5PPlayer { resizeCode: this.getResizeCode(), title: content.slug, displayOptions: {}, - url: this.getEmbedUrl(site.getURL(), h5pUrl), + url: '', // It will be filled using dynamic params if needed. contentUrl: contentUrl, metadata: content.metadata, contentUserData: [ @@ -109,9 +110,9 @@ export class CoreH5PPlayer { html += ''; - // Add our own script to handle the display options. + // Add our own script to handle the params. html += ''; + this.h5pCore.h5pFS.getCoreH5PPath(), 'moodle/js/params.js') + '">'; html += ''; @@ -241,20 +242,34 @@ export class CoreH5PPlayer { * * @param fileUrl URL of the H5P package. * @param displayOptions Display options. + * @param component Component to send xAPI events to. + * @param contextId Context ID where the H5P is. Required for tracking. * @param siteId The site ID. If not defined, current site. * @return Promise resolved with the file URL if exists, rejected otherwise. */ - async getContentIndexFileUrl(fileUrl: string, displayOptions?: CoreH5PDisplayOptions, siteId?: string): Promise { + async getContentIndexFileUrl(fileUrl: string, displayOptions?: CoreH5PDisplayOptions, component?: string, contextId?: number, + siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); const path = await this.h5pCore.h5pFS.getContentIndexFileUrl(fileUrl, siteId); - // Add display options to the URL. + // Add display options and component to the URL. const data = await this.h5pCore.h5pFramework.getContentDataByUrl(fileUrl, siteId); displayOptions = this.h5pCore.fixDisplayOptions(displayOptions, data.id); - return CoreUrlUtils.instance.addParamsToUrl(path, displayOptions, undefined, true); + const params = { + displayOptions: JSON.stringify(displayOptions), + component: component || '', + trackingUrl: undefined, + }; + + if (contextId) { + params.trackingUrl = await CoreXAPI.instance.getUrl(contextId, 'activity', siteId); + } + + return CoreUrlUtils.instance.addParamsToUrl(path, params); } /** diff --git a/src/core/h5p/components/h5p-iframe/h5p-iframe.ts b/src/core/h5p/components/h5p-iframe/h5p-iframe.ts index 8d9a74ca2..3f9c4b3c6 100644 --- a/src/core/h5p/components/h5p-iframe/h5p-iframe.ts +++ b/src/core/h5p/components/h5p-iframe/h5p-iframe.ts @@ -38,6 +38,8 @@ export class CoreH5PIframeComponent implements OnChanges { @Input() fileUrl?: string; // The URL of the H5P file. If not supplied, onlinePlayerUrl is required. @Input() displayOptions?: CoreH5PDisplayOptions; // Display options. @Input() onlinePlayerUrl?: string; // The URL of the online player to display the H5P package. + @Input() trackComponent?: string; // Component to send xAPI events to. + @Input() contextId?: number; // Context ID. Required for tracking. @Output() onIframeUrlSet = new EventEmitter<{src: string, online: boolean}>(); @Output() onIframeLoaded = new EventEmitter(); @@ -93,7 +95,7 @@ export class CoreH5PIframeComponent implements OnChanges { this.iframeSrc = localUrl; } else { this.onlinePlayerUrl = this.onlinePlayerUrl || CoreH5P.instance.h5pPlayer.calculateOnlinePlayerUrl( - this.site.getURL(), this.fileUrl, this.displayOptions); + this.site.getURL(), this.fileUrl, this.displayOptions, this.trackComponent); // Never allow downloading in the app. This will only work if the user is allowed to change the params. const src = this.onlinePlayerUrl.replace(CoreH5PCore.DISPLAY_OPTION_DOWNLOAD + '=1', @@ -121,7 +123,8 @@ export class CoreH5PIframeComponent implements OnChanges { */ protected async getLocalUrl(): Promise { try { - const url = await CoreH5P.instance.h5pPlayer.getContentIndexFileUrl(this.fileUrl, this.displayOptions, this.siteId); + const url = await CoreH5P.instance.h5pPlayer.getContentIndexFileUrl(this.fileUrl, this.displayOptions, + this.trackComponent, this.contextId, this.siteId); return url; } catch (error) { @@ -135,7 +138,7 @@ export class CoreH5PIframeComponent implements OnChanges { // File treated. Try to get the index file URL again. const url = await CoreH5P.instance.h5pPlayer.getContentIndexFileUrl(this.fileUrl, this.displayOptions, - this.siteId); + this.trackComponent, this.contextId, this.siteId); return url; } catch (error) { diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts index dc62ec2dd..cdccc72be 100644 --- a/src/core/h5p/providers/h5p.ts +++ b/src/core/h5p/providers/h5p.ts @@ -391,7 +391,7 @@ export class CoreH5PProvider { * @param siteId Site ID (empty for current site). * @return Promise resolved when the data is invalidated. */ - async invalidateAvailableInContexts(url: string, siteId?: string): Promise { + async invalidateGetTrustedH5PFile(url: string, siteId?: string): Promise { const site = await CoreSites.instance.getSite(siteId); await site.invalidateWsCacheForKey(this.getTrustedH5PFileCacheKey(url)); diff --git a/src/core/xapi/providers/offline.ts b/src/core/xapi/providers/offline.ts new file mode 100644 index 000000000..26005962c --- /dev/null +++ b/src/core/xapi/providers/offline.ts @@ -0,0 +1,201 @@ +// (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, CoreSiteSchema } from '@providers/sites'; + +import { makeSingleton } from '@singletons/core.singletons'; + +/** + * Service to handle offline xAPI. + */ +@Injectable() +export class CoreXAPIOfflineProvider { + + // Variables for database. + static STATEMENTS_TABLE = 'core_xapi_statements'; + + protected siteSchema: CoreSiteSchema = { + name: 'CoreXAPIOfflineProvider', + version: 1, + tables: [ + { + name: CoreXAPIOfflineProvider.STATEMENTS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true, + }, + { + name: 'contextid', + type: 'INTEGER' + }, + { + name: 'component', + type: 'TEXT' + }, + { + name: 'statements', + type: 'TEXT' + }, + { + name: 'timecreated', + type: 'INTEGER' + }, + { + name: 'courseid', + type: 'INTEGER' + }, + { + name: 'extra', + type: 'TEXT' + }, + ], + } + ] + }; + + constructor() { + CoreSites.instance.registerSiteSchema(this.siteSchema); + } + + /** + * Check if there are offline statements to send for a context. + * + * @param contextId Context ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if has offline statements, false otherwise. + */ + async contextHasStatements(contextId: number, siteId?: string): Promise { + const statementsList = await this.getContextStatements(contextId, siteId); + + return statementsList && statementsList.length > 0; + } + + /** + * Delete certain statements. + * + * @param id ID of the statements. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteStatements(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(CoreXAPIOfflineProvider.STATEMENTS_TABLE, {id}); + } + + /** + * Delete all statements of a certain context. + * + * @param contextId Context ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if stored, rejected if failure. + */ + async deleteStatementsForContext(contextId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(CoreXAPIOfflineProvider.STATEMENTS_TABLE, {contextid: contextId}); + } + + /** + * Get all offline statements. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with all the data. + */ + async getAllStatements(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecords(CoreXAPIOfflineProvider.STATEMENTS_TABLE, undefined, 'timecreated ASC'); + } + + /** + * Get statements for a context. + * + * @param contextId Context ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the data. + */ + async getContextStatements(contextId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecords(CoreXAPIOfflineProvider.STATEMENTS_TABLE, {contextid: contextId}, 'timecreated ASC'); + } + + /** + * Get certain statements. + * + * @param id ID of the statements. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the data. + */ + async getStatements(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getRecord(CoreXAPIOfflineProvider.STATEMENTS_TABLE, {id}); + } + + /** + * Save statements. + * + * @param contextId Context ID. + * @param component Component to send the statements to. + * @param statements Statements (JSON-encoded). + * @param options Options. + * @return Promise resolved when statements are successfully saved. + */ + async saveStatements(contextId: number, component: string, statements: string, options?: CoreXAPIOfflineSaveStatementsOptions) + : Promise { + + const site = await CoreSites.instance.getSite(options.siteId); + + const entry = { + contextid: contextId, + component: component, + statements: statements, + timecreated: Date.now(), + courseid: options.courseId, + extra: options.extra, + }; + + await site.getDb().insertRecord(CoreXAPIOfflineProvider.STATEMENTS_TABLE, entry); + } +} + +export class CoreXAPIOffline extends makeSingleton(CoreXAPIOfflineProvider) {} + +/** + * DB data stored for statements. + */ +export type CoreXAPIOfflineStatementsDBData = { + id: number; // ID. + contextid: number; // Context ID of the statements. + component: string; // Component to send the statements to. + statements: string; // Statements (JSON-encoded). + timecreated: number; // When were the statements created. + courseid?: number; // Course ID if the context is inside a course. + extra?: string; // Extra data. +}; + +/** + * Options to pass to saveStatements function. + */ +export type CoreXAPIOfflineSaveStatementsOptions = { + courseId?: number; // Course ID if the context is inside a course. + extra?: string; // Extra data to store. + siteId?: string; // Site ID. If not defined, current site. +}; diff --git a/src/core/xapi/providers/xapi.ts b/src/core/xapi/providers/xapi.ts new file mode 100644 index 000000000..997d02763 --- /dev/null +++ b/src/core/xapi/providers/xapi.ts @@ -0,0 +1,143 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreApp } from '@providers/app'; +import { CoreSites } from '@providers/sites'; +import { CoreTextUtils } from '@providers/utils/text'; +import { CoreUtils } from '@providers/utils/utils'; +import { CoreSite } from '@classes/site'; +import { CoreXAPIOffline, CoreXAPIOfflineSaveStatementsOptions } from './offline'; + +import { makeSingleton } from '@singletons/core.singletons'; + +/** + * Service to provide XAPI functionalities. + */ +@Injectable() +export class CoreXAPIProvider { + + protected ROOT_CACHE_KEY = 'CoreXAPI:'; + + /** + * Returns whether or not WS to post XAPI statement is available. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if ws is available, false otherwise. + * @since 3.9 + */ + async canPostStatements(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.canPostStatementsInSite(site); + } + + /** + * Returns whether or not WS to post XAPI statement is available in a certain site. + * + * @param site Site. If not defined, current site. + * @return Promise resolved with true if ws is available, false otherwise. + * @since 3.9 + */ + canPostStatementsInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return site.wsAvailable('core_xapi_statement_post'); + } + + /** + * Get URL for XAPI events. + * + * @param contextId Context ID. + * @param type Type (e.g. 'activity'). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async getUrl(contextId: number, type: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return CoreTextUtils.instance.concatenatePaths(site.getURL(), `xapi/${type}/${contextId}`); + } + + /** + * Post statements. + * + * @param contextId Context ID. + * @param component Component. + * @param json JSON string to send. + * @param options Options. + * @return Promise resolved with boolean: true if response was sent to server, false if stored in device. + */ + async postStatements(contextId: number, component: string, json: string, options?: CoreXAPIPostStatementsOptions) + : Promise { + + options = options || {}; + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + // Convenience function to store a message to be synchronized later. + const storeOffline = async (): Promise => { + await CoreXAPIOffline.instance.saveStatements(contextId, component, json, options); + + return false; + }; + + if (!CoreApp.instance.isOnline() || options.offline) { + // App is offline, store the action. + return storeOffline(); + } + + try { + await this.postStatementsOnline(component, json, options.siteId); + + return true; + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } else { + // Couldn't connect to server, store it offline. + return storeOffline(); + } + } + } + + /** + * Post statements. It will fail if offline or cannot connect. + * + * @param component Component. + * @param json JSON string to send. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async postStatementsOnline(component: string, json: string, siteId?: string): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const data = { + component: component, + requestjson: json, + }; + + return site.write('core_xapi_statement_post', data); + } +} + +export class CoreXAPI extends makeSingleton(CoreXAPIProvider) {} + +/** + * Options to pass to postStatements function. + */ +export type CoreXAPIPostStatementsOptions = CoreXAPIOfflineSaveStatementsOptions & { + offline?: boolean; // Whether to force storing it in offline. +}; diff --git a/src/core/xapi/xapi.module.ts b/src/core/xapi/xapi.module.ts new file mode 100644 index 000000000..7a955bffe --- /dev/null +++ b/src/core/xapi/xapi.module.ts @@ -0,0 +1,34 @@ +// (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 { CoreXAPIProvider } from './providers/xapi'; +import { CoreXAPIOfflineProvider } from './providers/offline'; + +// List of providers (without handlers). +export const CORE_XAPI_PROVIDERS: any[] = [ + CoreXAPIProvider, + CoreXAPIOfflineProvider, +]; + +@NgModule({ + declarations: [], + imports: [], + providers: [ + CoreXAPIProvider, + CoreXAPIOfflineProvider, + ], + exports: [] +}) +export class CoreXAPIModule { } diff --git a/src/lang/en.json b/src/lang/en.json index 929f5f8aa..5048707e4 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -43,6 +43,7 @@ "confirmdeletefile": "Are you sure you want to delete this file?", "confirmgotabroot": "Are you sure you want to go back to {{name}}?", "confirmgotabrootdefault": "Are you sure you want to go to the initial page of the current tab?", + "confirmleaveunknownchanges": "Are you sure you want to leave this page? If you have some unsaved changes they will be lost.", "confirmloss": "Are you sure? All changes will be lost.", "confirmopeninbrowser": "Do you want to open it in a web browser?", "considereddigitalminor": "You are too young to create an account on this site.", @@ -153,6 +154,7 @@ "mod_folder": "Folder", "mod_forum": "Forum", "mod_glossary": "Glossary", + "mod_h5pactivity": "H5P", "mod_ims": "IMS content package", "mod_imscp": "IMS content package", "mod_label": "Label", diff --git a/src/providers/utils/text.ts b/src/providers/utils/text.ts index 5ba186d9b..9cb111747 100644 --- a/src/providers/utils/text.ts +++ b/src/providers/utils/text.ts @@ -66,6 +66,7 @@ export class CoreTextUtilsProvider { {old: /_mmaModFolder/g, new: '_AddonModFolder'}, {old: /_mmaModForum/g, new: '_AddonModForum'}, {old: /_mmaModGlossary/g, new: '_AddonModGlossary'}, + {old: /_mmaModH5pactivity/g, new: '_AddonModH5PActivity'}, {old: /_mmaModImscp/g, new: '_AddonModImscp'}, {old: /_mmaModLabel/g, new: '_AddonModLabel'}, {old: /_mmaModLesson/g, new: '_AddonModLesson'},
{{ stateMessage | translate }}