diff --git a/src/addons/mod/forum/services/forum-sync.ts b/src/addons/mod/forum/services/forum-sync.ts index 479fe33a7..2cec0080d 100644 --- a/src/addons/mod/forum/services/forum-sync.ts +++ b/src/addons/mod/forum/services/forum-sync.ts @@ -208,7 +208,7 @@ export class AddonModForumSyncProvider extends CoreCourseActivitySyncBaseProvide if (CoreSync.isBlocked(AddonModForumProvider.COMPONENT, syncId, siteId)) { this.logger.debug('Cannot sync forum ' + forumId + ' because it is blocked.'); - return Promise.reject(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + throw new Error(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate })); } this.logger.debug('Try to sync forum ' + forumId + ' for user ' + userId); diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts index 60fea5102..0ee59c1c3 100644 --- a/src/addons/mod/mod.module.ts +++ b/src/addons/mod/mod.module.ts @@ -28,6 +28,7 @@ import { AddonModUrlModule } from './url/url.module'; import { AddonModLtiModule } from './lti/lti.module'; import { AddonModH5PActivityModule } from './h5pactivity/h5pactivity.module'; import { AddonModSurveyModule } from './survey/survey.module'; +import { AddonModScormModule } from './scorm/scorm.module'; @NgModule({ declarations: [], @@ -46,6 +47,7 @@ import { AddonModSurveyModule } from './survey/survey.module'; AddonModLtiModule, AddonModH5PActivityModule, AddonModSurveyModule, + AddonModScormModule, ], providers: [], exports: [], diff --git a/src/addons/mod/scorm/lang.json b/src/addons/mod/scorm/lang.json new file mode 100644 index 000000000..3e234713d --- /dev/null +++ b/src/addons/mod/scorm/lang.json @@ -0,0 +1,52 @@ +{ + "asset": "Asset", + "assetlaunched": "Asset - Viewed", + "attempts": "Attempts", + "averageattempt": "Average attempts", + "browse": "Preview", + "browsed": "Browsed", + "browsemode": "Preview mode", + "cannotcalculategrade": "Grade couldn't be calculated.", + "completed": "Completed", + "contents": "Contents", + "dataattemptshown": "This data belongs to the attempt number {{number}}.", + "enter": "Enter", + "errorcreateofflineattempt": "An error occurred while creating a new offline attempt. Please try again.", + "errordownloadscorm": "Error downloading SCORM: \"{{name}}\".", + "errorgetscorm": "Error getting SCORM data.", + "errorinvalidversion": "Sorry, the application only supports SCORM 1.2.", + "errornotdownloadable": "The download of SCORM packages is disabled. Please contact your site administrator.", + "errornovalidsco": "This SCORM package doesn't have a visible SCO to load.", + "errorpackagefile": "Sorry, the application only supports ZIP packages.", + "errorsyncscorm": "An error occurred while synchronising. Please try again.", + "exceededmaxattempts": "You have reached the maximum number of attempts.", + "failed": "Failed", + "firstattempt": "First attempt", + "gradeaverage": "Average grade", + "gradeforattempt": "Grade for attempt", + "gradehighest": "Highest grade", + "grademethod": "Grading method", + "gradereported": "Grade reported", + "gradescoes": "Learning objects", + "gradesum": "Sum grade", + "highestattempt": "Highest attempt", + "incomplete": "Incomplete", + "lastattempt": "Last completed attempt", + "modulenameplural": "SCORM packages", + "newattempt": "Start a new attempt", + "noattemptsallowed": "Number of attempts allowed", + "noattemptsmade": "Number of attempts you have made", + "notattempted": "Not attempted", + "offlineattemptnote": "This attempt has data that hasn't been synchronised.", + "offlineattemptovermax": "This attempt cannot be sent because you exceeded the maximum number of attempts.", + "organizations": "Organisations", + "passed": "Passed", + "reviewmode": "Review mode", + "score": "Score", + "scormstatusnotdownloaded": "This SCORM package is not downloaded. It will be automatically downloaded when you open it.", + "scormstatusoutdated": "This SCORM package has been modified since the last download. It will be automatically downloaded when you open it.", + "suspended": "Suspended", + "toc": "TOC", + "warningofflinedatadeleted": "Some offline data from attempt {{number}} has been discarded because it couldn't be counted as a new attempt.", + "warningsynconlineincomplete": "Some attempts couldn't be synchronised with the site because the last online attempt is not yet finished. Please finish the online attempt first." +} \ No newline at end of file diff --git a/src/addons/mod/scorm/scorm.module.ts b/src/addons/mod/scorm/scorm.module.ts new file mode 100644 index 000000000..c8303d57f --- /dev/null +++ b/src/addons/mod/scorm/scorm.module.ts @@ -0,0 +1,57 @@ +// (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 { 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 { CoreCronDelegate } from '@services/cron'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { OFFLINE_SITE_SCHEMA } from './services/database/scorm'; +import { AddonModScormGradeLinkHandler } from './services/handlers/grade-link'; +import { AddonModScormIndexLinkHandler } from './services/handlers/index-link'; +import { AddonModScormListLinkHandler } from './services/handlers/list-link'; +import { AddonModScormModuleHandler } from './services/handlers/module'; +import { AddonModScormPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModScormSyncCronHandler } from './services/handlers/sync-cron'; + +export const ADDON_MOD_SCORM_SERVICES: Type[] = [ + +]; + +@NgModule({ + imports: [ + ], + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [OFFLINE_SITE_SCHEMA], + multi: true, + }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModuleDelegate.registerHandler(AddonModScormModuleHandler.instance); + CoreCourseModulePrefetchDelegate.registerHandler(AddonModScormPrefetchHandler.instance); + CoreCronDelegate.register(AddonModScormSyncCronHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModScormGradeLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModScormIndexLinkHandler.instance); + CoreContentLinksDelegate.registerHandler(AddonModScormListLinkHandler.instance); + }, + }, + ], +}) +export class AddonModScormModule {} diff --git a/src/addons/mod/scorm/services/database/scorm.ts b/src/addons/mod/scorm/services/database/scorm.ts new file mode 100644 index 000000000..e6e356fe6 --- /dev/null +++ b/src/addons/mod/scorm/services/database/scorm.ts @@ -0,0 +1,137 @@ +// (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 { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for AddonModScormOfflineProvider. + */ +export const ATTEMPTS_TABLE_NAME = 'addon_mod_scorm_offline_attempts'; +export const TRACKS_TABLE_NAME = 'addon_mod_scorm_offline_scos_tracks'; +export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModScormOfflineProvider', + version: 1, + tables: [ + { + name: ATTEMPTS_TABLE_NAME, + columns: [ + { + name: 'scormid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'attempt', // Attempt number. + type: 'INTEGER', + notNull: true, + }, + { + name: 'userid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'timecreated', + type: 'INTEGER', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'snapshot', + type: 'TEXT', + }, + ], + primaryKeys: ['scormid', 'userid', 'attempt'], + }, + { + name: TRACKS_TABLE_NAME, + columns: [ + { + name: 'scormid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'attempt', // Attempt number. + type: 'INTEGER', + notNull: true, + }, + { + name: 'userid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'scoid', + type: 'INTEGER', + notNull: true, + }, + { + name: 'element', + type: 'TEXT', + notNull: true, + }, + { + name: 'value', + type: 'TEXT', + }, + { + name: 'timemodified', + type: 'INTEGER', + }, + { + name: 'synced', + type: 'INTEGER', + }, + ], + primaryKeys: ['scormid', 'userid', 'attempt', 'scoid', 'element'], + }, + ], +}; + +/** + * Offline common data. + */ +export type AddonModScormOfflineDBCommonData = { + scormid: number; + attempt: number; + userid: number; +}; + +/** + * SCORM attempt data. + */ +export type AddonModScormAttemptDBRecord = AddonModScormOfflineDBCommonData & { + courseid: number; + timecreated: number; + timemodified: number; + snapshot?: string | null; +}; + +/** + * SCORM track data. + */ +export type AddonModScormTrackDBRecord = AddonModScormOfflineDBCommonData & { + scoid: number; + element: string; + value?: string | null; + timemodified: number; + synced: number; +}; diff --git a/src/addons/mod/scorm/services/handlers/grade-link.ts b/src/addons/mod/scorm/services/handlers/grade-link.ts new file mode 100644 index 000000000..d74b748ef --- /dev/null +++ b/src/addons/mod/scorm/services/handlers/grade-link.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 { Injectable } from '@angular/core'; +import { CoreContentLinksModuleGradeHandler } from '@features/contentlinks/classes/module-grade-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to SCORM grade. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModScormGradeLinkHandlerService extends CoreContentLinksModuleGradeHandler { + + name = 'AddonModScormGradeLinkHandler'; + canReview = false; + + constructor() { + super('AddonModScorm', 'scorm'); + } + +} + +export const AddonModScormGradeLinkHandler = makeSingleton(AddonModScormGradeLinkHandlerService); diff --git a/src/addons/mod/scorm/services/handlers/index-link.ts b/src/addons/mod/scorm/services/handlers/index-link.ts new file mode 100644 index 000000000..f392b8fbc --- /dev/null +++ b/src/addons/mod/scorm/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 SCORM index. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModScormIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler { + + name = 'AddonModScormIndexLinkHandler'; + + constructor() { + super('AddonModScorm', 'scorm', 'a'); + } + +} + +export const AddonModScormIndexLinkHandler = makeSingleton(AddonModScormIndexLinkHandlerService); diff --git a/src/addons/mod/scorm/services/handlers/list-link.ts b/src/addons/mod/scorm/services/handlers/list-link.ts new file mode 100644 index 000000000..ac64ddc00 --- /dev/null +++ b/src/addons/mod/scorm/services/handlers/list-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 { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat links to SCORM list page. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModScormListLinkHandlerService extends CoreContentLinksModuleListHandler { + + name = 'AddonModScormListLinkHandler'; + + constructor() { + super('AddonModScorm', 'scorm'); + } + +} + +export const AddonModScormListLinkHandler = makeSingleton(AddonModScormListLinkHandlerService); diff --git a/src/addons/mod/scorm/services/handlers/module.ts b/src/addons/mod/scorm/services/handlers/module.ts new file mode 100644 index 000000000..4017f1c3c --- /dev/null +++ b/src/addons/mod/scorm/services/handlers/module.ts @@ -0,0 +1,83 @@ +// (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 { AddonModScormIndexComponent } from '../../components/index'; + +/** + * Handler to support SCORM modules. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModScormModuleHandlerService implements CoreCourseModuleHandler { + + static readonly PAGE_NAME = 'mod_scorm'; + + name = 'AddonModScorm'; + modName = 'scorm'; + + supportedFeatures = { + [CoreConstants.FEATURE_GROUPS]: true, + [CoreConstants.FEATURE_GROUPINGS]: true, + [CoreConstants.FEATURE_MOD_INTRO]: true, + [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true, + [CoreConstants.FEATURE_COMPLETION_HAS_RULES]: true, + [CoreConstants.FEATURE_GRADE_HAS_GRADE]: true, + [CoreConstants.FEATURE_GRADE_OUTCOMES]: true, + [CoreConstants.FEATURE_BACKUP_MOODLE2]: true, + [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true, + }; + + /** + * @inheritdoc + */ + async isEnabled(): Promise { + return true; + } + + /** + * @inheritdoc + */ + getData(module: CoreCourseAnyModuleData): CoreCourseModuleHandlerData { + return { + icon: CoreCourse.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'addon-mod_scorm-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(AddonModScormModuleHandlerService.PAGE_NAME + routeParams, options); + }, + }; + } + + /** + * @inheritdoc + */ + async getMainComponent(): Promise> { + return AddonModScormIndexComponent; + } + +} + +export const AddonModScormModuleHandler = makeSingleton(AddonModScormModuleHandlerService); diff --git a/src/addons/mod/scorm/services/handlers/pluginfile.ts b/src/addons/mod/scorm/services/handlers/pluginfile.ts new file mode 100644 index 000000000..500fd6ec1 --- /dev/null +++ b/src/addons/mod/scorm/services/handlers/pluginfile.ts @@ -0,0 +1,58 @@ +// (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 { CorePluginFileHandler } from '@services/plugin-file-delegate'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to treat file URLs in SCORM. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModScormPluginFileHandlerService implements CorePluginFileHandler { + + name = 'AddonModScormPluginFileHandler'; + component = 'mod_scorm'; + + /** + * @inheritdoc + */ + getComponentRevisionRegExp(args: string[]): RegExp | undefined { + // Check filearea. + if (args[2] == 'content') { + // Component + Filearea + Revision + return new RegExp('/mod_scorm/content/([0-9]+)/'); + } + } + + /** + * @inheritdoc + */ + getComponentRevisionReplace(): string { + // Component + Filearea + Revision + return '/mod_scorm/content/0/'; + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return true; + } + +} + +export const AddonModScormPluginFileHandler = makeSingleton(AddonModScormPluginFileHandlerService); diff --git a/src/addons/mod/scorm/services/handlers/prefetch.ts b/src/addons/mod/scorm/services/handlers/prefetch.ts new file mode 100644 index 000000000..6c174cf11 --- /dev/null +++ b/src/addons/mod/scorm/services/handlers/prefetch.ts @@ -0,0 +1,439 @@ +// (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 { CoreError } from '@classes/errors/error'; +import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourseAnyModuleData, CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreApp } from '@services/app'; +import { CoreFile } from '@services/file'; +import { CoreFilepool } from '@services/filepool'; +import { CoreFileSizeSum } from '@services/plugin-file-delegate'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { AddonModScorm, AddonModScormProvider, AddonModScormScorm } from '../scorm'; +import { AddonModScormSync } from '../scorm-sync'; + +/** + * Handler to prefetch SCORMs. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModScormPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModScorm'; + modName = 'scorm'; + component = AddonModScormProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^tracks$/; + + /** + * @inheritdoc + */ + download( + module: CoreCourseAnyModuleData, + courseId: number, + dirPath?: string, + onProgress?: AddonModScormProgressCallback, + ): Promise { + const siteId = CoreSites.getCurrentSiteId(); + + return this.prefetchPackage( + module, + courseId, + this.downloadOrPrefetchScorm.bind(this, module, courseId, true, siteId, false, onProgress), + ); + } + + /** + * Download or prefetch a SCORM. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @param siteId Site ID. + * @param prefetch True to prefetch, false to download right away. + * @param onProgress Function to call on progress. + * @return Promise resolved with the "extra" data to store: the hash of the file. + */ + protected async downloadOrPrefetchScorm( + module: CoreCourseAnyModuleData, + courseId: number, + single: boolean, + siteId: string, + prefetch: boolean, + onProgress?: AddonModScormProgressCallback, + ): Promise { + + const scorm = await this.getScorm(module, courseId, siteId); + + const files = this.getIntroFilesFromInstance(module, scorm); + + await Promise.all([ + // Download the SCORM file. + this.downloadOrPrefetchMainFileIfNeeded(scorm, prefetch, onProgress, siteId), + // Download WS data. If it fails we don't want to fail the whole download, so we'll ignore the error for now. + // @todo Implement a warning system so the user knows which SCORMs have failed. + CoreUtils.ignoreErrors(this.fetchWSData(scorm, siteId)), + // Download intro files, ignoring errors. + CoreUtils.ignoreErrors(CoreFilepool.downloadOrPrefetchFiles(siteId, files, prefetch, false, this.component, module.id)), + ]); + + // Success, return the hash. + return scorm.sha1hash!; + } + + /** + * Downloads/Prefetches and unzips the SCORM package. + * + * @param scorm SCORM object. + * @param prefetch True if prefetch, false otherwise. + * @param onProgress Function to call on progress. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the file is downloaded and unzipped. + */ + protected async downloadOrPrefetchMainFile( + scorm: AddonModScormScorm, + prefetch?: boolean, + onProgress?: AddonModScormProgressCallback, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const packageUrl = AddonModScorm.getPackageUrl(scorm); + + // Get the folder where the unzipped files will be. + const dirPath = await AddonModScorm.getScormFolder(scorm.moduleurl!); + + // Notify that the download is starting. + onProgress && onProgress({ message: 'core.downloading' }); + + // Download the ZIP file to the filepool. + if (prefetch) { + await CoreFilepool.addToQueueByUrl( + siteId, + packageUrl, + this.component, + scorm.coursemodule, + undefined, + undefined, + this.downloadProgress.bind(this, true, onProgress), + ); + } else { + await CoreFilepool.downloadUrl( + siteId, + packageUrl, + true, + this.component, + scorm.coursemodule, + undefined, + this.downloadProgress.bind(this, true, onProgress), + ); + } + + // Get the ZIP file path. + const zipPath = await CoreFilepool.getFilePathByUrl(siteId, packageUrl); + + // Notify that the unzip is starting. + onProgress && onProgress({ message: 'core.unzipping' }); + + // Unzip and delete the zip when finished. + await CoreFile.unzipFile(zipPath, dirPath, this.downloadProgress.bind(this, false, onProgress)); + + await CoreUtils.ignoreErrors(CoreFilepool.removeFileByUrl(siteId, packageUrl)); + } + + /** + * Downloads/Prefetches and unzips the SCORM package if it should be downloaded. + * + * @param scorm SCORM object. + * @param prefetch True if prefetch, false otherwise. + * @param onProgress Function to call on progress. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the file is downloaded and unzipped. + */ + protected async downloadOrPrefetchMainFileIfNeeded( + scorm: AddonModScormScorm, + prefetch?: boolean, + onProgress?: AddonModScormProgressCallback, + siteId?: string, + ): Promise { + + siteId = siteId || CoreSites.getCurrentSiteId(); + + const result = AddonModScorm.isScormUnsupported(scorm); + + if (result) { + throw new CoreError(Translate.instant(result)); + } + + // First verify that the file needs to be downloaded. + // It needs to be checked manually because the ZIP file is deleted after unzipped, so the filepool will always download it. + const download = await AddonModScorm.shouldDownloadMainFile(scorm, undefined, siteId); + + if (download) { + await this.downloadOrPrefetchMainFile(scorm, prefetch, onProgress, siteId); + } + } + + /** + * Function that converts a regular ProgressEvent into a AddonModScormProgressEvent. + * + * @param downloading True when downloading, false when unzipping. + * @param onProgress Function to call on progress. + * @param progress Event returned by the download function. + */ + protected downloadProgress(downloading: boolean, onProgress?: AddonModScormProgressCallback, progress?: ProgressEvent): void { + if (onProgress && progress && progress.loaded) { + onProgress({ downloading, progress }); + } + } + + /** + * Get WS data for SCORM. + * + * @param scorm SCORM object. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is prefetched. + */ + async fetchWSData(scorm: AddonModScormScorm, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const modOptions: CoreCourseCommonModWSOptions = { + cmId: scorm.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + await Promise.all([ + // Prefetch number of attempts (including not completed). + this.fetchAttempts(scorm, modOptions), + // Prefetch SCOs. + AddonModScorm.getScos(scorm.id, modOptions), + // Prefetch access information. + AddonModScorm.getAccessInformation(scorm.id, modOptions), + ]); + } + + /** + * Fetch attempts WS data. + * + * @param scorm SCORM object. + * @param modOptions Options. + * @returns Promise resolved when done. + */ + async fetchAttempts(scorm: AddonModScormScorm, modOptions: CoreCourseCommonModWSOptions): Promise { + // If it fails, assume we have no attempts. + const numAttempts = await CoreUtils.ignoreErrors(AddonModScorm.getAttemptCountOnline(scorm.id, modOptions), 0); + + if (numAttempts <= 0) { + // No attempts. We'll still try to get user data to be able to identify SCOs not visible and so. + await AddonModScorm.getScormUserDataOnline(scorm.id, 0, modOptions); + + return; + } + + // Get user data for each attempt. + const promises: Promise[] = []; + + for (let i = 1; i <= numAttempts; i++) { + promises.push(AddonModScorm.getScormUserDataOnline(scorm.id, i, modOptions).catch((error) => { + // Ignore failures of all the attempts that aren't the last one. + if (i == numAttempts) { + throw error; + } + })); + } + + await Promise.all(promises); + } + + /** + * @inheritdoc + */ + async getDownloadSize(module: CoreCourseAnyModuleData, courseId: number): Promise { + const scorm = await this.getScorm(module, courseId); + + if (AddonModScorm.isScormUnsupported(scorm)) { + return { size: -1, total: false }; + } else if (!scorm.packagesize) { + // We don't have package size, try to calculate it. + const size = await AddonModScorm.calculateScormSize(scorm); + + return { size: size, total: true }; + } else { + return { size: scorm.packagesize, total: true }; + } + } + + /** + * @inheritdoc + */ + async getDownloadedSize(module: CoreCourseAnyModuleData, courseId: number): Promise { + const scorm = await this.getScorm(module, courseId); + + // Get the folder where SCORM should be unzipped. + const path = await AddonModScorm.getScormFolder(scorm.moduleurl!); + + return CoreFile.getDirectorySize(path); + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @return Promise resolved with the list of files. + */ + async getFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + try { + const scorm = await this.getScorm(module, courseId); + + return AddonModScorm.getScormFileList(scorm); + } catch { + // SCORM not found, return empty list. + return []; + } + } + + /** + * Get the SCORM instance from a module instance. + * + * @param module Module. + * @param courseId Course ID. + * @param siteId Site ID. + * @returns Promise resolved with the SCORM. + */ + protected getScorm(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise { + const moduleUrl = 'url' in module ? module.url : undefined; + + return AddonModScorm.getScorm(courseId, module.id, { moduleUrl, siteId }); + } + + /** + * @inheritdoc + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return AddonModScorm.invalidateContent(moduleId, courseId); + } + + /** + * @inheritdoc + */ + invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise { + // Invalidate the calls required to check if a SCORM is downloadable. + return AddonModScorm.invalidateScormData(courseId); + } + + /** + * @inheritdoc + */ + async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise { + const scorm = await this.getScorm(module, courseId); + + if (scorm.warningMessage) { + // SCORM closed or not opened yet. + return false; + } + + if (AddonModScorm.isScormUnsupported(scorm)) { + return false; + } + + return true; + } + + /** + * @inheritdoc + */ + prefetch( + module: CoreCourseAnyModuleData, + courseId: number, + single?: boolean, + dirPath?: string, + onProgress?: AddonModScormProgressCallback, + ): Promise { + const siteId = CoreSites.getCurrentSiteId(); + + return this.prefetchPackage( + module, + courseId, + this.downloadOrPrefetchScorm.bind(this, module, courseId, single, siteId, true, onProgress), + ); + } + + /** + * Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow). + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + async removeFiles(module: CoreCourseAnyModuleData, courseId: number): Promise { + const siteId = CoreSites.getCurrentSiteId(); + + const scorm = await this.getScorm(module, courseId, siteId); + + // Get the folder where SCORM should be unzipped. + const path = await AddonModScorm.getScormFolder(scorm.moduleurl!); + + const promises: Promise[] = []; + + // Remove the unzipped folder. + promises.push(CoreFile.removeDir(path).catch((error) => { + if (error && (error.code == 1 || !CoreApp.isMobile())) { + // Not found, ignore error. + } else { + throw error; + } + })); + + // Delete other files. + promises.push(CoreFilepool.removeFilesByComponent(siteId, this.component, module.id)); + + await Promise.all(promises); + } + + /** + * Sync a module. + * + * @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. + */ + async sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise { + const scorm = await this.getScorm(module, courseId, siteId); + + return AddonModScormSync.syncScorm(scorm, siteId); + } + +} + +export const AddonModScormPrefetchHandler = makeSingleton(AddonModScormPrefetchHandlerService); + +/** + * Progress event used when downloading a SCORM. + */ +export type AddonModScormProgressEvent = { + downloading?: boolean; // Whether the event is due to the download of a chunk of data. + progress?: ProgressEvent; // Progress event sent by the download. + message?: string; // A message related to the progress, used to notify that a certain step of the download has started. +}; + +/** + * Progress callback when downloading a SCORM. + */ +export type AddonModScormProgressCallback = (event: AddonModScormProgressEvent) => void; diff --git a/src/addons/mod/scorm/services/handlers/sync-cron.ts b/src/addons/mod/scorm/services/handlers/sync-cron.ts new file mode 100644 index 000000000..ee0477cd9 --- /dev/null +++ b/src/addons/mod/scorm/services/handlers/sync-cron.ts @@ -0,0 +1,44 @@ +// (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 { AddonModScormSync } from '../scorm-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModScormSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModScormSyncCronHandler'; + + /** + * @inheritdoc + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModScormSync.syncAllScorms(siteId, force); + } + + /** + * @inheritdoc + */ + getInterval(): number { + return AddonModScormSync.syncInterval; + } + +} + +export const AddonModScormSyncCronHandler = makeSingleton(AddonModScormSyncCronHandlerService); diff --git a/src/addons/mod/scorm/services/scorm-helper.ts b/src/addons/mod/scorm/services/scorm-helper.ts new file mode 100644 index 000000000..d62311a2c --- /dev/null +++ b/src/addons/mod/scorm/services/scorm-helper.ts @@ -0,0 +1,411 @@ +// (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 { CoreError } from '@classes/errors/error'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { + AddonModScorm, + AddonModScormAttempt, + AddonModScormAttemptCountResult, + AddonModScormDataValue, + AddonModScormGetScosWithDataOptions, + AddonModScormProvider, + AddonModScormScoIcon, + AddonModScormScorm, + AddonModScormScoWithData, + AddonModScormTOCListSco, + AddonModScormUserDataMap, +} from './scorm'; +import { AddonModScormOffline } from './scorm-offline'; + +// List of elements we want to ignore when copying attempts (they're calculated). +const elementsToIgnore = [ + 'status', 'score_raw', 'total_time', 'session_time', 'student_id', 'student_name', 'credit', 'mode', 'entry', +]; + +/** + * Helper service that provides some features for SCORM. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModScormHelperProvider { + + /** + * Show a confirm dialog if needed. If SCORM doesn't have size, try to calculate it. + * + * @param scorm SCORM to download. + * @param isOutdated True if package outdated, false if not outdated, undefined to calculate it. + * @return Promise resolved if the user confirms or no confirmation needed. + */ + async confirmDownload(scorm: AddonModScormScorm, isOutdated?: boolean): Promise { + // Check if file should be downloaded. + const download = await AddonModScorm.shouldDownloadMainFile(scorm, isOutdated); + + if (!download) { + // No need to download main file, no need to confirm. + return; + } + + let size = scorm.packagesize; + + if (!size) { + // We don't have package size, try to calculate it. + size = await AddonModScorm.calculateScormSize(scorm); + + // Store it so we don't have to calculate it again when using the same object. + scorm.packagesize = size; + } + + return CoreDomUtils.confirmDownloadSize({ size: size, total: true }); + } + + /** + * Creates a new offline attempt based on an existing online attempt. + * + * @param scorm SCORM. + * @param attempt Number of the online attempt. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the attempt is created. + */ + async convertAttemptToOffline(scorm: AddonModScormScorm, attempt: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Get data from the online attempt. + const onlineData = await CoreUtils.ignoreErrors( + AddonModScorm.getScormUserData(scorm.id, attempt, { cmId: scorm.coursemodule, siteId }), + ); + + if (!onlineData) { + // Shouldn't happen. + throw new CoreError(Translate.instant('addon.mod_scorm.errorcreateofflineattempt')); + } + + // The SCORM API might have written some data to the offline attempt already. + // We don't want to override it with cached online data. + const offlineData = await CoreUtils.ignoreErrors( + AddonModScormOffline.getScormUserData(scorm.id, attempt, undefined, siteId), + ); + + const dataToStore = CoreUtils.clone(onlineData); + + // Filter the data to copy. + for (const scoId in dataToStore) { + const sco = dataToStore[scoId]; + + // Delete calculated data. + elementsToIgnore.forEach((el) => { + delete sco.userdata[el]; + }); + + // Don't override offline data. + if (offlineData && offlineData[sco.scoid] && offlineData[sco.scoid].userdata) { + const scoUserData: Record = {}; + + for (const element in sco.userdata) { + if (!offlineData[sco.scoid].userdata[element]) { + // This element is not stored in offline, we can save it. + scoUserData[element] = sco.userdata[element]; + } + } + + sco.userdata = scoUserData; + } + } + + await AddonModScormOffline.createNewAttempt(scorm, attempt, dataToStore, onlineData, siteId); + } + + /** + * Creates a new offline attempt. + * + * @param scorm SCORM. + * @param newAttempt Number of the new attempt. + * @param lastOnline Number of the last online attempt. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the attempt is created. + */ + async createOfflineAttempt(scorm: AddonModScormScorm, newAttempt: number, lastOnline: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Try to get data from online attempts. + const userData = await CoreUtils.ignoreErrors( + this.searchOnlineAttemptUserData(scorm.id, lastOnline, { cmId: scorm.coursemodule, siteId }), + ); + + if (!userData) { + throw new CoreError(Translate.instant('addon.mod_scorm.errorcreateofflineattempt')); + } + + // We're creating a new attempt, remove all the user data that is not needed for a new attempt. + for (const scoId in userData) { + const sco = userData[scoId]; + const filtered: Record = {}; + + for (const element in sco.userdata) { + if (element.indexOf('.') == -1 && elementsToIgnore.indexOf(element) == -1) { + // The element doesn't use a dot notation, probably SCO data. + filtered[element] = sco.userdata[element]; + } + } + + sco.userdata = filtered; + } + + return AddonModScormOffline.createNewAttempt(scorm, newAttempt, userData, undefined, siteId); + } + + /** + * Determines the attempt to continue/review. It will be: + * - The last incomplete online attempt if it hasn't been continued in offline and all offline attempts are complete. + * - The attempt with highest number without surpassing max attempts otherwise. + * + * @param scorm SCORM object. + * @param attempts Attempts count. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the attempt data. + */ + async determineAttemptToContinue( + scorm: AddonModScormScorm, + attempts: AddonModScormAttemptCountResult, + siteId?: string, + ): Promise { + + let lastOnline: number | undefined; + + // Get last online attempt. + if (attempts.online.length) { + lastOnline = Math.max.apply(Math, attempts.online); + } + + if (!lastOnline) { + return this.getLastBeforeMax(scorm, attempts); + } + + // Check if last online incomplete. + const hasOffline = attempts.offline.indexOf(lastOnline) > -1; + + const incomplete = await AddonModScorm.isAttemptIncomplete(scorm.id, lastOnline, { + offline: hasOffline, + cmId: scorm.coursemodule, + siteId, + }); + + if (incomplete) { + return { + num: lastOnline, + offline: hasOffline, + }; + } else { + return this.getLastBeforeMax(scorm, attempts); + } + } + + /** + * Get the first SCO to load in a SCORM: the first valid and incomplete SCO. + * + * @param scormId Scorm ID. + * @param attempt Attempt number. + * @param options Other options. + * @return Promise resolved with the first SCO. + */ + async getFirstSco( + scormId: number, + attempt: number, + options: AddonModScormGetFirstScoOptions = {}, + ): Promise { + + const mode = options.mode || AddonModScormProvider.MODENORMAL; + const isNormalMode = mode === AddonModScormProvider.MODENORMAL; + + let scos = options.toc; + if (!scos || !scos.length) { + // SCORM doesn't have a TOC. Get all the scos. + scos = await AddonModScorm.getScosWithData(scormId, attempt, options); + } + + // Search the first valid SCO. + // In browse/review mode return the first visible sco. In normal mode, first incomplete sco. + const sco = scos.find(sco => sco.isvisible && sco.launch && sco.prereq && + (!isNormalMode || AddonModScorm.isStatusIncomplete(sco.status))); + + // If no "valid" SCO, load the first one. In web it loads the first child because the toc contains the organization SCO. + return sco || scos[0]; + } + + /** + * Get the last attempt (number and whether it's offline). + * It'll be the highest number as long as it doesn't surpass the max number of attempts. + * + * @param scorm SCORM object. + * @param attempts Attempts count. + * @return Last attempt data. + */ + protected getLastBeforeMax( + scorm: AddonModScormScorm, + attempts: AddonModScormAttemptCountResult, + ): AddonModScormAttempt { + if (scorm.maxattempt && attempts.lastAttempt.num > scorm.maxattempt) { + return { + num: scorm.maxattempt, + offline: attempts.offline.indexOf(scorm.maxattempt) > -1, + }; + } else { + return { + num: attempts.lastAttempt.num, + offline: attempts.lastAttempt.offline, + }; + } + } + + /** + * Given a TOC in array format and a scoId, return the next available SCO. + * + * @param toc SCORM's TOC. + * @param scoId SCO ID. + * @return Next SCO. + */ + getNextScoFromToc(toc: AddonModScormScoWithData[], scoId: number): AddonModScormScoWithData | undefined { + for (let i = 0; i < toc.length; i++) { + if (toc[i].id != scoId) { + continue; + } + + // We found the current SCO. Now search the next visible SCO with fulfilled prerequisites. + for (let j = i + 1; j < toc.length; j++) { + if (toc[j].isvisible && toc[j].prereq && toc[j].launch) { + return toc[j]; + } + } + break; + } + } + + /** + * Given a TOC in array format and a scoId, return the previous available SCO. + * + * @param toc SCORM's TOC. + * @param scoId SCO ID. + * @return Previous SCO. + */ + getPreviousScoFromToc(toc: AddonModScormScoWithData[], scoId: number): AddonModScormScoWithData | undefined { + for (let i = 0; i < toc.length; i++) { + if (toc[i].id != scoId) { + continue; + } + + // We found the current SCO. Now let's search the previous visible SCO with fulfilled prerequisites. + for (let j = i - 1; j >= 0; j--) { + if (toc[j].isvisible && toc[j].prereq && toc[j].launch) { + return toc[j]; + } + } + break; + } + } + + /** + * Given a TOC in array format and a scoId, return the SCO. + * + * @param toc SCORM's TOC. + * @param scoId SCO ID. + * @return SCO. + */ + getScoFromToc(toc: AddonModScormScoWithData[], scoId: number): AddonModScormScoWithData | undefined { + return toc.find(sco => sco.id == scoId); + } + + /** + * Get SCORM TOC, formatted. + * + * @param scormId Scorm ID. + * @param lastAttempt Last attempt number. + * @param incomplete Whether last attempt is incomplete. + * @param options Options. + * @return Promise resolved with the TOC. + */ + async getToc( + scormId: number, + lastAttempt: number, + incomplete: boolean, + options: AddonModScormGetScosWithDataOptions = {}, + ): Promise { + const toc = await AddonModScorm.getOrganizationToc(scormId, lastAttempt, options); + + const tocArray = AddonModScorm.formatTocToArray(toc); + + // Get images for each SCO. + tocArray.forEach((sco) => { + sco.icon = AddonModScorm.getScoStatusIcon(sco, incomplete); + }); + + return tocArray; + } + + /** + * Searches user data for an online attempt. If the data can't be retrieved, re-try with the previous online attempt. + * + * @param scormId SCORM ID. + * @param attempt Online attempt to get the data. + * @param options Other options. + * @return Promise resolved with user data. + */ + async searchOnlineAttemptUserData( + scormId: number, + attempt: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + try { + return await AddonModScorm.getScormUserData(scormId, attempt, options); + } catch (error) { + if (attempt <= 0) { + // No more attempts to try. + throw error; + } + + try { + // We couldn't retrieve the data. Try again with the previous online attempt. + return await this.searchOnlineAttemptUserData(scormId, attempt - 1, options); + } catch { + // Couldn't retrieve previous attempts data either. + throw error; + } + } + } + +} + +export const AddonModScormHelper = makeSingleton(AddonModScormHelperProvider); + +/** + * Options to pass to getFirstSco. + */ +export type AddonModScormGetFirstScoOptions = CoreCourseCommonModWSOptions & { + toc?: AddonModScormScoWithData[]; // SCORM's TOC. If not provided, it will be calculated. + organization?: string; // Organization to use. + mode?: string; // Mode. + offline?: boolean; // Whether the attempt is offline. +}; + +/** + * TOC SCO with icon. + */ +export type AddonModScormTOCScoWithIcon = AddonModScormTOCListSco & { + icon?: AddonModScormScoIcon; +}; diff --git a/src/addons/mod/scorm/services/scorm-offline.ts b/src/addons/mod/scorm/services/scorm-offline.ts new file mode 100644 index 000000000..58b3f349d --- /dev/null +++ b/src/addons/mod/scorm/services/scorm-offline.ts @@ -0,0 +1,997 @@ +// (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 { SQLiteDB } from '@classes/sqlitedb'; +import { CoreUser } from '@features/user/services/user'; +import { CoreSites } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton } from '@singletons'; +import { CoreLogger } from '@singletons/logger'; +import { + AddonModScormAttemptDBRecord, + AddonModScormOfflineDBCommonData, + AddonModScormTrackDBRecord, + ATTEMPTS_TABLE_NAME, + TRACKS_TABLE_NAME, +} from './database/scorm'; +import { + AddonModScormDataEntry, + AddonModScormDataValue, + AddonModScormProvider, + AddonModScormScorm, + AddonModScormScoUserData, + AddonModScormUserDataMap, + AddonModScormWSSco, +} from './scorm'; + +/** + * Service to handle offline SCORM. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModScormOfflineProvider { + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('AddonModScormOfflineProvider'); + } + + /** + * Changes an attempt number in the data stored in offline. + * This function is used to convert attempts into new attempts, so the stored snapshot will be removed and + * entries will be marked as not synced. + * + * @param scormId SCORM ID. + * @param attempt Number of the attempt to change. + * @param newAttempt New attempt number. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the attempt number changes. + */ + async changeAttemptNumber( + scormId: number, + attempt: number, + newAttempt: number, + siteId?: string, + userId?: number, + ): Promise { + + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + this.logger.debug(`Change attempt number from ${attempt} to ${newAttempt} in SCORM ${scormId}`); + + // Update the attempt number. + const db = site.getDb(); + const currentAttemptConditions: AddonModScormOfflineDBCommonData = { + scormid: scormId, + userid: userId, + attempt, + }; + const newAttemptConditions: AddonModScormOfflineDBCommonData = { + scormid: scormId, + userid: userId, + attempt: newAttempt, + }; + const newAttemptData: Partial = { + attempt: newAttempt, + timemodified: CoreTimeUtils.timestamp(), + }; + + // Block the SCORM so it can't be synced. + CoreSync.blockOperation(AddonModScormProvider.COMPONENT, scormId, 'changeAttemptNumber', site.id); + + try { + await db.updateRecords(ATTEMPTS_TABLE_NAME, newAttemptData, currentAttemptConditions); + + try { + // Now update the attempt number of all the tracks and mark them as not synced. + const newTrackData: Partial = { + attempt: newAttempt, + synced: 0, + }; + + await db.updateRecords(TRACKS_TABLE_NAME, newTrackData, currentAttemptConditions); + } catch (error) { + // Failed to update the tracks, restore the old attempt number. + await db.updateRecords(ATTEMPTS_TABLE_NAME, { attempt }, newAttemptConditions); + + throw error; + } + } finally { + // Unblock the SCORM. + CoreSync.unblockOperation(AddonModScormProvider.COMPONENT, scormId, 'changeAttemptNumber', site.id); + } + } + + /** + * Creates a new offline attempt. It can be created from scratch or as a copy of another attempt. + * + * @param scorm SCORM. + * @param attempt Number of the new attempt. + * @param userData User data to store in the attempt. + * @param snapshot Optional. Snapshot to store in the attempt. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the new attempt is created. + */ + async createNewAttempt( + scorm: AddonModScormScorm, + attempt: number, + userData: AddonModScormUserDataMap, + snapshot?: AddonModScormUserDataMap, + siteId?: string, + userId?: number, + ): Promise { + + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + this.logger.debug(`Creating new offline attempt ${attempt} in SCORM ${scorm.id}`); + + // Block the SCORM so it can't be synced. + CoreSync.blockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'createNewAttempt', site.id); + + // Create attempt in DB. + const db = site.getDb(); + const entry: AddonModScormAttemptDBRecord = { + scormid: scorm.id, + userid: userId, + attempt, + courseid: scorm.course, + timecreated: CoreTimeUtils.timestamp(), + timemodified: CoreTimeUtils.timestamp(), + snapshot: null, + }; + + if (snapshot) { + // Save a snapshot of the data we had when we created the attempt. + // Remove the default data, we don't want to store it. + entry.snapshot = JSON.stringify(this.removeDefaultData(snapshot)); + } + + try { + await db.insertRecord(ATTEMPTS_TABLE_NAME, entry); + + // Store all the data in userData. + const promises: Promise[] = []; + + for (const key in userData) { + const sco = userData[key]; + const tracks: AddonModScormDataEntry[] = []; + + for (const element in sco.userdata) { + tracks.push({ element, value: sco.userdata[element] }); + } + + promises.push(this.saveTracks(scorm, sco.scoid, attempt, tracks, userData, site.id, userId)); + } + + await Promise.all(promises); + } finally { + // Unblock the SCORM. + CoreSync.unblockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'createNewAttempt', site.id); + } + } + + /** + * Delete all the stored data from an attempt. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when all the data has been deleted. + */ + async deleteAttempt(scormId: number, attempt: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + this.logger.debug(`Delete offline attempt ${attempt} in SCORM ${scormId}`); + + const db = site.getDb(); + const conditions: AddonModScormOfflineDBCommonData = { + scormid: scormId, + userid: userId, + attempt, + }; + + await Promise.all([ + db.deleteRecords(ATTEMPTS_TABLE_NAME, conditions), + db.deleteRecords(TRACKS_TABLE_NAME, conditions), + ]); + } + + /** + * Helper function to return a formatted list of interactions for reports. + * This function is based in Moodle's scorm_format_interactions. + * + * @param scoUserData Userdata from a certain SCO. + * @return Formatted userdata. + */ + protected formatInteractions(scoUserData: Record): Record { + const formatted: Record = {}; + + // Defined in order to unify scorm1.2 and scorm2004. + formatted.score_raw = ''; + formatted.status = ''; + formatted.total_time = '00:00:00'; + formatted.session_time = '00:00:00'; + + for (const element in scoUserData) { + let value = scoUserData[element]; + + // Ignore elements that are calculated. + if (element == 'score_raw' || element == 'status' || element == 'total_time' || element == 'session_time') { + continue; + } + + formatted[element] = value; + switch (element) { + case 'cmi.core.lesson_status': + case 'cmi.completion_status': + if (value == 'not attempted') { + value = 'notattempted'; + } + formatted.status = value; + break; + + case 'cmi.core.score.raw': + case 'cmi.score.raw': + formatted.score_raw = CoreTextUtils.roundToDecimals(Number(value), 2); // Round to 2 decimals max. + break; + + case 'cmi.core.session_time': + case 'cmi.session_time': + formatted.session_time = value; + break; + + case 'cmi.core.total_time': + case 'cmi.total_time': + formatted.total_time = value; + break; + default: + // Nothing to do. + } + } + + return formatted; + } + + /** + * Get all the offline attempts in a certain site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the offline attempts are retrieved. + */ + async getAllAttempts(siteId?: string): Promise { + const db = await CoreSites.getSiteDb(siteId); + + const attempts = await db.getAllRecords(ATTEMPTS_TABLE_NAME); + + return attempts.map((attempt) => this.parseAttempt(attempt)); + } + + /** + * Get an offline attempt. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved with the attempt. + */ + async getAttempt(scormId: number, attempt: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + const attemptRecord = await site.getDb().getRecord(ATTEMPTS_TABLE_NAME, { + scormid: scormId, + userid: userId, + attempt, + }); + + return this.parseAttempt(attemptRecord); + } + + /** + * Get the creation time of an attempt. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved with time the attempt was created, undefined if attempt not found. + */ + async getAttemptCreationTime(scormId: number, attempt: number, siteId?: string, userId?: number): Promise { + try { + const attemptRecord = await this.getAttempt(scormId, attempt, siteId, userId); + + return attemptRecord.timecreated; + } catch { + return; + } + } + + /** + * Get the offline attempts done by a user in the given SCORM. + * + * @param scormId SCORM ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the offline attempts are retrieved. + */ + async getAttempts(scormId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + const attempts = await site.getDb().getRecords(ATTEMPTS_TABLE_NAME, { + scormid: scormId, + userid: userId, + }); + + return attempts.map((attempt) => this.parseAttempt(attempt)); + } + + /** + * Get the snapshot of an attempt. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved with the snapshot or undefined if no snapshot. + */ + async getAttemptSnapshot( + scormId: number, + attempt: number, + siteId?: string, + userId?: number, + ): Promise { + try { + const attemptRecord = await this.getAttempt(scormId, attempt, siteId, userId); + + return attemptRecord.snapshot || undefined; + } catch { + return; + } + } + + /** + * Get launch URLs from a list of SCOs, indexing them by SCO ID. + * + * @param scos List of SCOs. + * @return Launch URLs indexed by SCO ID. + */ + protected getLaunchUrlsFromScos(scos: AddonModScormWSSco[]): Record { + scos = scos || []; + + const response: Record = {}; + + scos.forEach((sco) => { + response[sco.id] = sco.launch; + }); + + return response; + } + + /** + * Get data stored in local DB for a certain scorm and attempt. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param excludeSynced Whether it should only return not synced entries. + * @param excludeNotSynced Whether it should only return synced entries. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved with the entries. + */ + async getScormStoredData( + scormId: number, + attempt: number, + excludeSynced?: boolean, + excludeNotSynced?: boolean, + siteId?: string, + userId?: number, + ): Promise { + if (excludeSynced && excludeNotSynced) { + return []; + } + + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + const conditions: Partial = { + scormid: scormId, + userid: userId, + attempt, + }; + + if (excludeSynced) { + conditions.synced = 0; + } else if (excludeNotSynced) { + conditions.synced = 1; + } + + const tracks = await site.getDb().getRecords(TRACKS_TABLE_NAME, conditions); + + return this.parseTracks(tracks); + } + + /** + * Get the user data for a certain SCORM and offline attempt. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param scos SCOs returned by AddonModScormProvider.getScos. If not supplied, this function will only return the + * SCOs that have something stored and cmi.launch_data will be undefined. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the user data is retrieved. + */ + async getScormUserData( + scormId: number, + attempt: number, + scos?: AddonModScormWSSco[], + siteId?: string, + userId?: number, + ): Promise { + scos = scos || []; + + let fullName = ''; + let userName = ''; + + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + // Get username and fullname. + if (userId == site.getUserId()) { + fullName = site.getInfo()?.fullname || ''; + userName = site.getInfo()?.username || ''; + } else { + const profile = await CoreUtils.ignoreErrors(CoreUser.getProfile(userId)); + + fullName = profile?.fullname || ''; + userName = profile?.username || ''; + } + + // Get user data. + const entries = await this.getScormStoredData(scormId, attempt, false, false, siteId, userId); + const response: AddonModScormUserDataMap = {}; + const launchUrls = this.getLaunchUrlsFromScos(scos); + + // Gather user data retrieved from DB, grouping it by scoid. + entries.forEach((entry) => { + const scoId = entry.scoid; + + if (!response[scoId]) { + // Initialize SCO. + response[scoId] = { + scoid: scoId, + userdata: { + userid: userId!, + scoid: scoId, + timemodified: 0, + }, + defaultdata: {}, + }; + } + + response[scoId].userdata[entry.element] = entry.value!; + if (entry.timemodified > Number(response[scoId].userdata.timemodified)) { + response[scoId].userdata.timemodified = entry.timemodified; + } + }); + + // Format each user data retrieved. + for (const scoId in response) { + const sco = response[scoId]; + sco.userdata = this.formatInteractions(sco.userdata); + } + + // Create empty entries for the SCOs without user data stored. + scos.forEach((sco) => { + if (!response[sco.id]) { + response[sco.id] = { + scoid: sco.id, + userdata: { + status: '', + score_raw: '', // eslint-disable-line @typescript-eslint/naming-convention + }, + defaultdata: {}, + }; + } + }); + + // Calculate defaultdata. + for (const scoId in response) { + const sco = response[scoId]; + + sco.defaultdata = {}; + sco.defaultdata['cmi.core.student_id'] = userName; + sco.defaultdata['cmi.core.student_name'] = fullName; + sco.defaultdata['cmi.core.lesson_mode'] = 'normal'; // Overridden in player. + sco.defaultdata['cmi.core.credit'] = 'credit'; // Overridden in player. + + if (sco.userdata.status === '') { + sco.defaultdata['cmi.core.entry'] = 'ab-initio'; + } else if (sco.userdata['cmi.core.exit'] === 'suspend') { + sco.defaultdata['cmi.core.entry'] = 'resume'; + } else { + sco.defaultdata['cmi.core.entry'] = ''; + } + + sco.defaultdata['cmi.student_data.mastery_score'] = this.scormIsset(sco.userdata, 'masteryscore'); + sco.defaultdata['cmi.student_data.max_time_allowed'] = this.scormIsset(sco.userdata, 'max_time_allowed'); + sco.defaultdata['cmi.student_data.time_limit_action'] = this.scormIsset(sco.userdata, 'time_limit_action'); + sco.defaultdata['cmi.core.total_time'] = this.scormIsset(sco.userdata, 'cmi.core.total_time', '00:00:00'); + sco.defaultdata['cmi.launch_data'] = launchUrls[sco.scoid]; + + // Now handle standard userdata items. + sco.defaultdata['cmi.core.lesson_location'] = this.scormIsset(sco.userdata, 'cmi.core.lesson_location'); + sco.defaultdata['cmi.core.lesson_status'] = this.scormIsset(sco.userdata, 'cmi.core.lesson_status'); + sco.defaultdata['cmi.core.score.raw'] = this.scormIsset(sco.userdata, 'cmi.core.score.raw'); + sco.defaultdata['cmi.core.score.max'] = this.scormIsset(sco.userdata, 'cmi.core.score.max'); + sco.defaultdata['cmi.core.score.min'] = this.scormIsset(sco.userdata, 'cmi.core.score.min'); + sco.defaultdata['cmi.core.exit'] = this.scormIsset(sco.userdata, 'cmi.core.exit'); + sco.defaultdata['cmi.suspend_data'] = this.scormIsset(sco.userdata, 'cmi.suspend_data'); + sco.defaultdata['cmi.comments'] = this.scormIsset(sco.userdata, 'cmi.comments'); + sco.defaultdata['cmi.student_preference.language'] = this.scormIsset(sco.userdata, 'cmi.student_preference.language'); + sco.defaultdata['cmi.student_preference.audio'] = this.scormIsset(sco.userdata, 'cmi.student_preference.audio', '0'); + sco.defaultdata['cmi.student_preference.speed'] = this.scormIsset(sco.userdata, 'cmi.student_preference.speed', '0'); + sco.defaultdata['cmi.student_preference.text'] = this.scormIsset(sco.userdata, 'cmi.student_preference.text', '0'); + + // Some data needs to be both in default data and user data. + sco.userdata.student_id = userName; + sco.userdata.student_name = fullName; + sco.userdata.mode = sco.defaultdata['cmi.core.lesson_mode']; + sco.userdata.credit = sco.defaultdata['cmi.core.credit']; + sco.userdata.entry = sco.defaultdata['cmi.core.entry']; + } + + return response; + } + + /** + * Insert a track in the offline tracks store. + * This function is based on Moodle's scorm_insert_track. + * + * @param scormId SCORM ID. + * @param scoId SCO ID. + * @param attempt Attempt number. + * @param element Name of the element to insert. + * @param value Value to insert. + * @param forceCompleted True if SCORM forces completed. + * @param scoData User data for the given SCO. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not set use site's current user. + * @return Promise resolved when the insert is done. + */ + protected async insertTrack( + scormId: number, + scoId: number, + attempt: number, + element: string, + value?: string | number, + forceCompleted?: boolean, + scoData?: AddonModScormScoUserData, + siteId?: string, + userId?: number, + ): Promise { + const site = await CoreSites.getSite(siteId); + + userId = userId || site.getUserId(); + + const scoUserData = scoData?.userdata || {}; + const db = site.getDb(); + let lessonStatusInserted = false; + + if (forceCompleted) { + if (element == 'cmi.core.lesson_status' && value == 'incomplete') { + if (scoUserData['cmi.core.score.raw']) { + value = 'completed'; + } + } + if (element == 'cmi.core.score.raw') { + if (scoUserData['cmi.core.lesson_status'] == 'incomplete') { + lessonStatusInserted = true; + + await this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', 'completed'); + } + } + } + + if (scoUserData[element] && element == 'x.start.time') { + // Don't update x.start.time, keep the original value. + return; + } + + try { + await this.insertTrackToDB(db, userId, scormId, scoId, attempt, element, value); + } catch (error) { + if (lessonStatusInserted) { + // Rollback previous insert. + await this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', 'incomplete'); + } + + throw error; + } + } + + /** + * Insert a track in the DB. + * + * @param db Site's DB. + * @param userId User ID. + * @param scormId SCORM ID. + * @param scoId SCO ID. + * @param attempt Attempt number. + * @param element Name of the element to insert. + * @param value Value of the element to insert. + * @param synchronous True if insert should NOT return a promise. Please use it only if synchronous is a must. + * @return Returns a promise if synchronous=false, otherwise returns a boolean. + */ + protected insertTrackToDB( + db: SQLiteDB, + userId: number, + scormId: number, + scoId: number, + attempt: number, + element: string, + value: AddonModScormDataValue | undefined, + synchronous: true, + ): boolean; + protected insertTrackToDB( + db: SQLiteDB, + userId: number, + scormId: number, + scoId: number, + attempt: number, + element: string, + value?: AddonModScormDataValue, + synchronous?: false, + ): Promise; + protected insertTrackToDB( + db: SQLiteDB, + userId: number, + scormId: number, + scoId: number, + attempt: number, + element: string, + value?: AddonModScormDataValue, + synchronous?: boolean, + ): boolean | Promise { + const entry: AddonModScormTrackDBRecord = { + userid: userId, + scormid: scormId, + scoid: scoId, + attempt, + element: element, + value: typeof value == 'undefined' ? null : JSON.stringify(value), + timemodified: CoreTimeUtils.timestamp(), + synced: 0, + }; + + if (synchronous) { + // The insert operation is always asynchronous, always return true. + db.insertRecord(TRACKS_TABLE_NAME, entry); + + return true; + } else { + return db.insertRecord(TRACKS_TABLE_NAME, entry); + } + } + + /** + * Insert a track in the offline tracks store, returning a synchronous value. + * Please use this function only if synchronous is a must. It's recommended to use insertTrack. + * This function is based on Moodle's scorm_insert_track. + * + * @param scormId SCORM ID. + * @param scoId SCO ID. + * @param attempt Attempt number. + * @param element Name of the element to insert. + * @param value Value of the element to insert. + * @param forceCompleted True if SCORM forces completed. + * @param scoData User data for the given SCO. + * @param userId User ID. If not set use current user. + * @return Promise resolved when the insert is done. + */ + protected insertTrackSync( + scormId: number, + scoId: number, + attempt: number, + element: string, + value?: AddonModScormDataValue, + forceCompleted?: boolean, + scoData?: AddonModScormScoUserData, + userId?: number, + ): boolean { + userId = userId || CoreSites.getCurrentSiteUserId(); + + if (!CoreSites.isLoggedIn()) { + // Not logged in, we can't get the site DB. User logged out or session expired while an operation was ongoing. + return false; + } + + const scoUserData = scoData?.userdata || {}; + const db = CoreSites.getCurrentSite()!.getDb(); + let lessonStatusInserted = false; + + if (forceCompleted) { + if (element == 'cmi.core.lesson_status' && value == 'incomplete') { + if (scoUserData['cmi.core.score.raw']) { + value = 'completed'; + } + } + if (element == 'cmi.core.score.raw') { + if (scoUserData['cmi.core.lesson_status'] == 'incomplete') { + lessonStatusInserted = true; + + if (!this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', 'completed', true)) { + return false; + } + } + } + } + + if (scoUserData[element] && element == 'x.start.time') { + // Don't update x.start.time, keep the original value. + return true; + } + + if (!this.insertTrackToDB(db, userId, scormId, scoId, attempt, element, value, true)) { + // Insert failed. + if (lessonStatusInserted) { + // Rollback previous insert. + this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', 'incomplete', true); + } + + return false; + } + + return true; + } + + /** + * Mark all the entries from a SCO and attempt as synced. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param scoId SCO ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when marked. + */ + async markAsSynced(scormId: number, attempt: number, scoId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + this.logger.debug(`Mark SCO ${scoId} as synced for attempt ${attempt} in SCORM ${scormId}`); + + await site.getDb().updateRecords(TRACKS_TABLE_NAME, { synced: 1 }, > { + scormid: scormId, + userid: userId, + attempt, + scoid: scoId, + synced: 0, + }); + } + + /** + * Parse an attempt. + * + * @param attempt Attempt to parse. + * @returns Parsed attempt. + */ + protected parseAttempt(attempt: AddonModScormAttemptDBRecord): AddonModScormOfflineAttempt { + return { + ...attempt, + snapshot: attempt.snapshot ? CoreTextUtils.parseJSON(attempt.snapshot) : null, + }; + } + + /** + * Parse tracks. + * + * @param tracks Tracks to parse. + * @returns Parsed tracks. + */ + protected parseTracks(tracks: AddonModScormTrackDBRecord[]): AddonModScormOfflineTrack[] { + return tracks.map((track) => ({ + ...track, + value: track.value ? CoreTextUtils.parseJSON(track.value) : null, + })); + } + + /** + * Removes the default data form user data. + * + * @param userData User data. + * @return User data without default data. + */ + protected removeDefaultData(userData: AddonModScormUserDataMap): AddonModScormUserDataMap { + const result: AddonModScormUserDataMap = CoreUtils.clone(userData); + + for (const key in result) { + result[key].defaultdata = {}; + } + + return result; + } + + /** + * Saves a SCORM tracking record in offline. + * + * @param scorm SCORM. + * @param scoId Sco ID. + * @param attempt Attempt number. + * @param tracks Tracking data to store. + * @param userData User data for this attempt and SCO. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when data is saved. + */ + async saveTracks( + scorm: AddonModScormScorm, + scoId: number, + attempt: number, + tracks: AddonModScormDataEntry[], + userData: AddonModScormUserDataMap, + siteId?: string, + userId?: number, + ): Promise { + + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + // Block the SCORM so it can't be synced. + CoreSync.blockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'saveTracksOffline', siteId); + + try { + // Insert all the tracks. + await Promise.all(tracks.map((track) => this.insertTrack( + scorm.id, + scoId, + attempt, + track.element, + track.value, + scorm.forcecompleted, + userData[scoId], + siteId, + userId, + ))); + } finally { + // Unblock the SCORM operation. + CoreSync.unblockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'saveTracksOffline', siteId); + } + } + + /** + * Saves a SCORM tracking record in offline returning a synchronous value. + * Please use this function only if synchronous is a must. It's recommended to use saveTracks. + * + * @param scorm SCORM. + * @param scoId Sco ID. + * @param attempt Attempt number. + * @param tracks Tracking data to store. + * @param userData User data for this attempt and SCO. + * @return True if data to insert is valid, false otherwise. Returning true doesn't mean that the data + * has been stored, this function can return true but the insertion can still fail somehow. + */ + saveTracksSync( + scorm: AddonModScormScorm, + scoId: number, + attempt: number, + tracks: AddonModScormDataEntry[], + userData: AddonModScormUserDataMap, + userId?: number, + ): boolean { + userId = userId || CoreSites.getCurrentSiteUserId(); + let success = true; + + tracks.forEach((track) => { + const trackSuccess = this.insertTrackSync( + scorm.id, + scoId, + attempt, + track.element, + track.value, + scorm.forcecompleted, + userData[scoId], + userId, + ); + + success = success && trackSuccess; + }); + + return success; + } + + /** + * Check for a parameter in userData and return it if it's set or return 'ifempty' if it's empty. + * Based on Moodle's scorm_isset function. + * + * @param userData Contains user's data. + * @param param Name of parameter that should be checked. + * @param ifEmpty Value to be replaced with if param is not set. + * @return Value from userData[param] if set, ifEmpty otherwise. + */ + protected scormIsset( + userData: Record, + param: string, + ifEmpty: AddonModScormDataValue = '', + ): AddonModScormDataValue { + if (typeof userData[param] != 'undefined') { + return userData[param]; + } + + return ifEmpty; + } + + /** + * Set an attempt's snapshot. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param userData User data to store as snapshot. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when snapshot has been stored. + */ + async setAttemptSnapshot( + scormId: number, + attempt: number, + userData: AddonModScormUserDataMap, + siteId?: string, + userId?: number, + ): Promise { + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + this.logger.debug(`Set snapshot for attempt ${attempt} in SCORM ${scormId}`); + + const newData: Partial = { + timemodified: CoreTimeUtils.timestamp(), + snapshot: JSON.stringify(this.removeDefaultData(userData)), + }; + + await site.getDb().updateRecords(ATTEMPTS_TABLE_NAME, newData, > { + scormid: scormId, + userid: userId, + attempt, + }); + } + +} + +export const AddonModScormOffline = makeSingleton(AddonModScormOfflineProvider); + +/** + * SCORM offline attempt data. + */ +export type AddonModScormOfflineAttempt = Omit & { + snapshot?: AddonModScormUserDataMap | null; +}; + +/** + * SCORM offline track data. + */ +export type AddonModScormOfflineTrack = Omit & { + value?: string | number | null; +}; diff --git a/src/addons/mod/scorm/services/scorm-sync.ts b/src/addons/mod/scorm/services/scorm-sync.ts new file mode 100644 index 000000000..43bdaeb38 --- /dev/null +++ b/src/addons/mod/scorm/services/scorm-sync.ts @@ -0,0 +1,857 @@ +// (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 { CoreError } from '@classes/errors/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 { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModScormPrefetchHandler } from './handlers/prefetch'; +import { + AddonModScorm, + AddonModScormAttemptCountResult, + AddonModScormDataEntry, + AddonModScormProvider, + AddonModScormScorm, + AddonModScormUserDataMap, +} from './scorm'; +import { AddonModScormOffline } from './scorm-offline'; + +/** + * Service to sync SCORMs. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModScormSyncProvider extends CoreCourseActivitySyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_scorm_autom_synced'; + + protected componentTranslatableString = 'scorm'; + + constructor() { + super('AddonModScormSyncProvider'); + } + + /** + * Add an offline attempt to the right of the new attempts array if possible. + * If the attempt cannot be created as a new attempt then it will be deleted. + * + * @param scormId SCORM ID. + * @param attempt The offline attempt to treat. + * @param lastOffline Last offline attempt number. + * @param newAttemptsSameOrder Attempts that'll be created as new attempts but keeping the current order. + * @param newAttemptsAtEnd Object with attempts that'll be created at the end of the list (should be max 1). + * @param lastOfflineCreated Time when the last offline attempt was created. + * @param lastOfflineIncomplete Whether the last offline attempt is incomplete. + * @param warnings Array where to add the warnings. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async addToNewOrDelete( + scormId: number, + attempt: number, + lastOffline: number, + newAttemptsSameOrder: number[], + newAttemptsAtEnd: Record, + lastOfflineCreated: number, + lastOfflineIncomplete: boolean, + warnings: string[], + siteId: string, + ): Promise { + if (attempt == lastOffline) { + newAttemptsSameOrder.push(attempt); + + return; + } + + // Check if the attempt can be created. + const time = await AddonModScormOffline.getAttemptCreationTime(scormId, attempt, siteId); + + if (!time || time <= lastOfflineCreated) { + newAttemptsSameOrder.push(attempt); + + return; + } + + // This attempt was created after the last offline attempt, we'll add it to the end of the list if possible. + if (lastOfflineIncomplete) { + // It can't be added because the last offline attempt is incomplete, delete it. + this.logger.debug(`Try to delete attempt ${attempt} because it cannot be created as a new attempt.`); + + await CoreUtils.ignoreErrors(AddonModScormOffline.deleteAttempt(scormId, attempt, siteId)); + + // eslint-disable-next-line id-blacklist + warnings.push(Translate.instant('addon.mod_scorm.warningofflinedatadeleted', { number: attempt })); + } else { + // Add the attempt at the end. + newAttemptsAtEnd[time] = attempt; + } + } + + /** + * Check if can retry an attempt synchronization. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param lastOnline Last online attempt number. + * @param cmId Module ID. + * @param siteId Site ID. + * @return Promise resolved if can retry the synchronization, rejected otherwise. + */ + protected async canRetrySync( + scormId: number, + attempt: number, + lastOnline: number, + cmId: number, + siteId: string, + ): Promise { + // If it's the last attempt we don't need to ignore cache because we already did it. + const refresh = lastOnline != attempt; + + const siteData = await AddonModScorm.getScormUserData(scormId, attempt, { + cmId, + readingStrategy: refresh ? CoreSitesReadingStrategy.OnlyNetwork : undefined, + siteId, + }); + + // Get synchronization snapshot (if sync fails it should store a snapshot). + const snapshot = await AddonModScormOffline.getAttemptSnapshot(scormId, attempt, siteId); + + if (!snapshot || !Object.keys(snapshot).length || !this.snapshotEquals(snapshot, siteData)) { + // No snapshot or it doesn't match, we can't retry the synchronization. + return false; + } + + return true; + } + + /** + * Create new attempts at the end of the offline attempts list. + * + * @param scormId SCORM ID. + * @param newAttempts Object with the attempts to create. The keys are the timecreated, the values are the attempt number. + * @param lastOffline Number of last offline attempt. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async createNewAttemptsAtEnd( + scormId: number, + newAttempts: Record, + lastOffline: number, + siteId: string, + ): Promise { + const times = Object.keys(newAttempts).sort(); // Sort in ASC order. + + if (!times.length) { + return; + } + + await CoreUtils.allPromises(times.map((time, index) => { + const attempt = newAttempts[time]; + + return AddonModScormOffline.changeAttemptNumber(scormId, attempt, lastOffline + index + 1, siteId); + })); + } + + /** + * Finish a sync process: remove offline data if needed, prefetch SCORM data, set sync time and return the result. + * + * @param siteId Site ID. + * @param scorm SCORM. + * @param warnings List of warnings generated by the sync. + * @param lastOnline Last online attempt number before the sync. + * @param lastOnlineWasFinished Whether the last online attempt was finished before the sync. + * @param initialCount Attempt count before the sync. + * @param updated Whether some data was sent to the site. + * @return Promise resolved on success. + */ + protected async finishSync( + siteId: string, + scorm: AddonModScormScorm, + warnings: string[], + lastOnline?: number, + lastOnlineWasFinished?: boolean, + initialCount?: AddonModScormAttemptCountResult, + updated?: boolean, + ): Promise { + const result: AddonModScormSyncResult = { + warnings: warnings, + attemptFinished: false, + updated: !!updated, + }; + + if (updated) { + try { + // Update downloaded data. + const module = await CoreCourse.getModuleBasicInfoByInstance(scorm.id, 'scorm', siteId); + + await this.prefetchAfterUpdate(AddonModScormPrefetchHandler.instance, module, scorm.course, undefined, siteId); + } catch { + // Ignore errors. + } + } + + await CoreUtils.ignoreErrors(this.setSyncTime(scorm.id, siteId)); + + if (!initialCount) { + return result; + } + + // Check if an attempt was finished in Moodle. + // Get attempt count again to check if an attempt was finished. + const attemptsData = await AddonModScorm.getAttemptCount(scorm.id, { cmId: scorm.coursemodule, siteId }); + + if (attemptsData.online.length > initialCount.online.length) { + result.attemptFinished = true; + } else if (!lastOnlineWasFinished && lastOnline) { + // Last online attempt wasn't finished, let's check if it is now. + const incomplete = await AddonModScorm.isAttemptIncomplete(scorm.id, lastOnline, { + cmId: scorm.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + + result.attemptFinished = !incomplete; + } + + return result; + } + + /** + * Get the creation time and the status (complete/incomplete) of an offline attempt. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param cmId Module ID. + * @param siteId Site ID. + * @return Promise resolved with the data. + */ + protected async getOfflineAttemptData( + scormId: number, + attempt: number, + cmId: number, + siteId: string, + ): Promise<{incomplete: boolean; timecreated?: number}> { + + // Check if last offline attempt is incomplete. + const incomplete = await AddonModScorm.isAttemptIncomplete(scormId, attempt, { + offline: true, + cmId, + siteId, + }); + + const timecreated = await AddonModScormOffline.getAttemptCreationTime(scormId, attempt, siteId); + + return { + incomplete, + timecreated, + }; + } + + /** + * Change the number of some offline attempts. We need to move all offline attempts after the collisions + * too, otherwise we would overwrite data. + * Example: We have offline attempts 1, 2 and 3. #1 and #2 have collisions. #1 can be synced, but #2 needs + * to be a new attempt. #3 will now be #4, and #2 will now be #3. + * + * @param scormId SCORM ID. + * @param newAttempts Attempts that need to be converted into new attempts. + * @param lastOnline Last online attempt. + * @param lastCollision Last attempt with collision (exists in online and offline). + * @param offlineAttempts Numbers of offline attempts. + * @param siteId Site ID. + * @return Promise resolved when attempts have been moved. + */ + protected async moveNewAttempts( + scormId: number, + newAttempts: number[], + lastOnline: number, + lastCollision: number, + offlineAttempts: number[], + siteId: string, + ): Promise { + if (!newAttempts.length) { + return; + } + + let lastSuccessful: number | undefined; + + try { + // Sort offline attempts in DESC order. + offlineAttempts = offlineAttempts.sort((a, b) => Number(a) <= Number(b) ? 1 : -1); + + // First move the offline attempts after the collisions. Move them 1 by 1 in order. + for (const i in offlineAttempts) { + const attempt = offlineAttempts[i]; + + if (attempt > lastCollision) { + const newNumber = attempt + newAttempts.length; + + await AddonModScormOffline.changeAttemptNumber(scormId, attempt, newNumber, siteId); + + lastSuccessful = attempt; + } + } + + const successful: number[] = []; + + try { + // Sort newAttempts in ASC order. + newAttempts = newAttempts.sort((a, b) => Number(a) >= Number(b) ? 1 : -1); + + // Now move the attempts in newAttempts. + await Promise.all(newAttempts.map(async (attempt, index) => { + // No need to use chain of promises. + const newNumber = lastOnline + index + 1; + + await AddonModScormOffline.changeAttemptNumber(scormId, attempt, newNumber, siteId); + + successful.push(attempt); + })); + + } catch (error) { + // Moving the new attempts failed (it shouldn't happen). Let's undo the new attempts move. + await CoreUtils.allPromises(successful.map((attempt) => { + const newNumber = lastOnline + newAttempts.indexOf(attempt) + 1; + + return AddonModScormOffline.changeAttemptNumber(scormId, newNumber, attempt, siteId); + })); + + throw error; // It will now enter the catch that moves offline attempts after collisions. + } + } catch (error) { + // Moving offline attempts after collisions failed (it shouldn't happen). Let's undo the changes. + if (!lastSuccessful) { + throw error; + } + + for (let attempt = lastSuccessful; offlineAttempts.indexOf(attempt) != -1; attempt++) { + // Move it back. + await AddonModScormOffline.changeAttemptNumber(scormId, attempt + newAttempts.length, attempt, siteId); + } + + throw error; + } + } + + /** + * Save a snapshot from a synchronization. + * + * @param scormId SCORM ID. + * @param attempt Attemot number. + * @param cmId Module ID. + * @param siteId Site ID. + * @return Promise resolved when the snapshot is stored. + */ + protected async saveSyncSnapshot(scormId: number, attempt: number, cmId: number, siteId: string): Promise { + // Try to get current state from the site. + let userData: AddonModScormUserDataMap; + + try { + userData = await AddonModScorm.getScormUserData(scormId, attempt, { + cmId, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + } catch { + // Error getting user data from the site. We'll have to build it ourselves. + // Let's try to get cached data about the attempt. + userData = await CoreUtils.ignoreErrors( + AddonModScorm.getScormUserData(scormId, attempt, { cmId, siteId }), + {}, + ); + + // We need to add the synced data to the snapshot. + const syncedData = await AddonModScormOffline.getScormStoredData(scormId, attempt, false, true, siteId); + + syncedData.forEach((entry) => { + if (!userData[entry.scoid]) { + userData[entry.scoid] = { + scoid: entry.scoid, + userdata: {}, + defaultdata: {}, + }; + } + userData[entry.scoid].userdata[entry.element] = entry.value || ''; + }); + + } + + return AddonModScormOffline.setAttemptSnapshot(scormId, attempt, userData, siteId); + + } + + /** + * Compares an attempt's snapshot with the data retrieved from the site. + * It only compares elements with dot notation. This means that, if some SCO has been added to Moodle web + * but the user hasn't generated data for it, then the snapshot will be detected as equal. + * + * @param snapshot Attempt's snapshot. + * @param userData Data retrieved from the site. + * @return True if snapshot is equal to the user data, false otherwise. + */ + protected snapshotEquals(snapshot: AddonModScormUserDataMap, userData: AddonModScormUserDataMap): boolean { + // Check that snapshot contains the data from the site. + for (const scoId in userData) { + const siteSco = userData[scoId]; + const snapshotSco = snapshot[scoId]; + + for (const element in siteSco.userdata) { + if (element.indexOf('.') > -1) { + if (!snapshotSco || siteSco.userdata[element] !== snapshotSco.userdata[element]) { + return false; + } + } + } + } + + // Now check the opposite way: site userData contains the data from the snapshot. + for (const scoId in snapshot) { + const siteSco = userData[scoId]; + const snapshotSco = snapshot[scoId]; + + for (const element in snapshotSco.userdata) { + if (element.indexOf('.') > -1) { + if (!siteSco || siteSco.userdata[element] !== snapshotSco.userdata[element]) { + return false; + } + } + } + } + + return true; + } + + /** + * Try to synchronize all the SCORMs 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. + */ + syncAllScorms(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all SCORMs', this.syncAllScormsFunc.bind(this, !!force), siteId); + } + + /** + * Sync all SCORMs on a site. + * + * @param force Wether to force sync or not. + * @param siteId Site ID to sync. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllScormsFunc(force: boolean, siteId: string): Promise { + + // Get all offline attempts. + const attempts = await AddonModScormOffline.getAllAttempts(siteId); + + const treated: Record = {}; // To prevent duplicates. + + // Sync all SCORMs that haven't been synced for a while and that aren't attempted right now. + await Promise.all(attempts.map(async (attempt) => { + if (treated[attempt.scormid] || CoreSync.isBlocked(AddonModScormProvider.COMPONENT, attempt.scormid, siteId)) { + return; + } + + treated[attempt.scormid] = true; + + const scorm = await AddonModScorm.getScormById(attempt.courseid, attempt.scormid, { siteId }); + + const data = force ? + await this.syncScorm(scorm, siteId) : + await this.syncScormIfNeeded(scorm, siteId); + + if (typeof data != 'undefined') { + // We tried to sync. Send event. + CoreEvents.trigger(AddonModScormSyncProvider.AUTO_SYNCED, { + scormId: scorm.id, + attemptFinished: data.attemptFinished, + warnings: data.warnings, + updated: data.updated, + }, siteId); + } + })); + } + + /** + * Send data from a SCORM offline attempt to the site. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param cmId Module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the attempt is successfully synced. + */ + protected async syncAttempt(scormId: number, attempt: number, cmId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + this.logger.debug(`Try to sync attempt ${attempt} in SCORM ${scormId} and site ${siteId}`); + + // Get only not synced entries. + const tracks = await AddonModScormOffline.getScormStoredData(scormId, attempt, true, false, siteId); + + const scos: Record = {}; + let somethingSynced = false; + + // Get data to send (only elements with dots like cmi.core.exit, in Mobile we store more data to make offline work). + tracks.forEach((track) => { + if (track.element.indexOf('.') > -1) { + if (!scos[track.scoid]) { + scos[track.scoid] = []; + } + + scos[track.scoid].push({ + element: track.element, + value: track.value || '', + }); + } + }); + + try { + // Send the data in each SCO. + const promises = Object.entries(scos).map(async ([key, tracks]) => { + const scoId = Number(key); + + await AddonModScorm.saveTracksOnline(scormId, scoId, attempt, tracks, siteId); + + // Sco data successfully sent. Mark them as synced. This is needed because some SCOs sync might fail. + await CoreUtils.ignoreErrors(AddonModScormOffline.markAsSynced(scormId, attempt, scoId, siteId)); + + somethingSynced = true; + }); + + await CoreUtils.allPromises(promises); + } catch (error) { + if (somethingSynced) { + // Some SCOs have been synced and some not. + // Try to store a snapshot of the current state to be able to re-try the synchronization later. + this.logger.error(`Error synchronizing some SCOs for attempt ${attempt} in SCORM ${scormId}. Saving snapshot.`); + + await this.saveSyncSnapshot(scormId, attempt, cmId, siteId); + } else { + this.logger.error(`Error synchronizing attempt ${attempt} in SCORM ${scormId}`); + } + + throw error; + } + + // Attempt has been sent. Let's delete it from local. + await CoreUtils.ignoreErrors(AddonModScormOffline.deleteAttempt(scormId, attempt, siteId)); + } + + /** + * Sync a SCORM only if a certain time has passed since the last time. + * + * @param scorm SCORM. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the SCORM is synced or if it doesn't need to be synced. + */ + async syncScormIfNeeded(scorm: AddonModScormScorm, siteId?: string): Promise { + const needed = await this.isSyncNeeded(scorm.id, siteId); + + if (needed) { + return this.syncScorm(scorm, siteId); + } + } + + /** + * Try to synchronize a SCORM. + * + * @param scorm SCORM. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + syncScorm(scorm: AddonModScormScorm, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + if (this.isSyncing(scorm.id, siteId)) { + // There's already a sync ongoing for this SCORM, return the promise. + return this.getOngoingSync(scorm.id, siteId)!; + } + + // Verify that SCORM isn't blocked. + if (CoreSync.isBlocked(AddonModScormProvider.COMPONENT, scorm.id, siteId)) { + this.logger.debug('Cannot sync SCORM ' + scorm.id + ' because it is blocked.'); + + throw new CoreError(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + this.logger.debug(`Try to sync SCORM ${scorm.id} in site ${siteId}`); + + const syncPromise = this.performSyncScorm(scorm, siteId); + + return this.addOngoingSync(scorm.id, syncPromise, siteId); + } + + /** + * Try to synchronize a SCORM. + * + * @param scorm SCORM. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + protected async performSyncScorm(scorm: AddonModScormScorm, siteId: string): Promise { + let warnings: string[] = []; + let lastOnline = 0; + let lastOnlineWasFinished = false; + + // Sync offline logs. + await CoreUtils.ignoreErrors(CoreCourseLogHelper.syncIfNeeded(AddonModScormProvider.COMPONENT, scorm.id, siteId)); + + // Get attempts data. We ignore cache for online attempts, so this call will fail if offline or server down. + const attemptsData = await AddonModScorm.getAttemptCount(scorm.id, { + cmId: scorm.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + + if (!attemptsData.offline || !attemptsData.offline.length) { + // Nothing to sync. + return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished); + } + + const initialCount = attemptsData; + const collisions: number[] = []; + + // Check if there are collisions between offline and online attempts (same number). + attemptsData.online.forEach((attempt) => { + lastOnline = Math.max(lastOnline, attempt); + if (attemptsData.offline.indexOf(attempt) > -1) { + collisions.push(attempt); + } + }); + + // Check if last online attempt is finished. Ignore cache. + let incomplete = lastOnline <= 0 ? + false : + await AddonModScorm.isAttemptIncomplete(scorm.id, lastOnline, { + cmId: scorm.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + + lastOnlineWasFinished = !incomplete; + + if (!collisions.length) { + if (incomplete) { + // No collisions, but last online attempt is incomplete so we can't send offline attempts. + warnings.push(Translate.instant('addon.mod_scorm.warningsynconlineincomplete')); + + return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished, initialCount, false); + } + + // No collisions and last attempt is complete. Send offline attempts to Moodle. + await Promise.all(attemptsData.offline.map(async (attempt) => { + if (!scorm.maxattempt || attempt <= scorm.maxattempt) { + await this.syncAttempt(scorm.id, attempt, scorm.coursemodule, siteId); + } + })); + + // All data synced, finish. + return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished, initialCount, true); + } + + // We have collisions, treat them. + warnings = await this.treatCollisions(scorm.id, collisions, lastOnline, attemptsData.offline, scorm.coursemodule, siteId); + + // The offline attempts might have changed since some collisions can be converted to new attempts. + const entries = await AddonModScormOffline.getAttempts(scorm.id, siteId); + + let cannotSyncSome = false; + + // Get only the attempt number. + const attempts = entries.map((entry) => entry.attempt); + + if (incomplete && attempts.indexOf(lastOnline) > -1) { + // Last online was incomplete, but it was continued in offline. + incomplete = false; + } + + await Promise.all(attempts.map(async (attempt) => { + // We'll always sync attempts previous to lastOnline (failed sync or continued in offline). + // We'll only sync new attemps if last online attempt is completed. + if (!incomplete || attempt <= lastOnline) { + if (!scorm.maxattempt || attempt <= scorm.maxattempt) { + await this.syncAttempt(scorm.id, attempt, scorm.coursemodule, siteId); + } + } else { + cannotSyncSome = true; + } + })); + + if (cannotSyncSome) { + warnings.push(Translate.instant('addon.mod_scorm.warningsynconlineincomplete')); + } + + return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished, initialCount, true); + } + + /** + * Treat collisions found in a SCORM synchronization process. + * + * @param scormId SCORM ID. + * @param collisions Numbers of attempts that exist both in online and offline. + * @param lastOnline Last online attempt. + * @param offlineAttempts Numbers of offline attempts. + * @param cmId Module ID. + * @param siteId Site ID. + * @return Promise resolved when the collisions have been treated. It returns warnings array. + * @description + * + * Treat collisions found in a SCORM synchronization process. A collision is when an attempt exists both in offline + * and online. A collision can be: + * + * - Two different attempts. + * - An online attempt continued in offline. + * - A failure in a previous sync. + * + * This function will move into new attempts the collisions that can't be merged. It will usually keep the order of the + * offline attempts EXCEPT if the offline attempt was created after the last offline attempt (edge case). + * + * Edge case: A user creates offline attempts and when he syncs we retrieve an incomplete online attempt, so the offline + * attempts cannot be synced. Then the user continues that online attempt and goes offline, so a collision is created. + * When we perform the next sync we detect that this collision cannot be merged, so this offline attempt needs to be + * created as a new attempt. Since this attempt was created after the last offline attempt, it will be added ot the end + * of the list if the last attempt is completed. If the last attempt is not completed then the offline data will de deleted + * because we can't create a new attempt. + */ + protected async treatCollisions( + scormId: number, + collisions: number[], + lastOnline: number, + offlineAttempts: number[], + cmId: number, + siteId: string, + ): Promise { + + const warnings: string[] = []; + const newAttemptsSameOrder: number[] = []; // Attempts that will be created as new attempts but keeping the current order. + const newAttemptsAtEnd: Record = {}; // Attempts that'll be created at the end of list (should be max 1). + const lastCollision = Math.max.apply(Math, collisions); + let lastOffline = Math.max.apply(Math, offlineAttempts); + + // Get needed data from the last offline attempt. + const lastOfflineData = await this.getOfflineAttemptData(scormId, lastOffline, cmId, siteId); + + const promises = collisions.map(async (attempt) => { + // First get synced entries to detect if it was a failed synchronization. + const synced = await AddonModScormOffline.getScormStoredData(scormId, attempt, false, true, siteId); + + if (synced.length) { + // The attempt has synced entries, it seems to be a failed synchronization. + // Let's get the entries that haven't been synced, maybe it just failed to delete the attempt. + const tracks = await AddonModScormOffline.getScormStoredData(scormId, attempt, true, false, siteId); + + // Check if there are elements to sync. + const hasDataToSend = tracks.find(track => track.element.indexOf('.') > -1); + + if (!hasDataToSend) { + // Nothing to sync, delete the attempt. + return CoreUtils.ignoreErrors(AddonModScormOffline.deleteAttempt(scormId, attempt, siteId)); + } + + // There are elements to sync. We need to check if it's possible to sync them or not. + const canRetry = await this.canRetrySync(scormId, attempt, lastOnline, cmId, siteId); + + if (!canRetry) { + // Cannot retry sync, we'll create a new offline attempt if possible. + return this.addToNewOrDelete( + scormId, + attempt, + lastOffline, + newAttemptsSameOrder, + newAttemptsAtEnd, + lastOfflineData.timecreated!, + lastOfflineData.incomplete, + warnings, + siteId, + ); + } + } else { + // It's not a failed synchronization. Check if it's an attempt continued in offline. + const snapshot = await AddonModScormOffline.getAttemptSnapshot(scormId, attempt, siteId); + + if (!snapshot || !Object.keys(snapshot).length) { + // No snapshot, it's a different attempt. + newAttemptsSameOrder.push(attempt); + + return; + } + + // It has a snapshot, it means it continued an online attempt. We need to check if they've diverged. + // If it's the last attempt we don't need to ignore cache because we already did it. + const refresh = lastOnline != attempt; + + const userData = await AddonModScorm.getScormUserData(scormId, attempt, { + cmId, + readingStrategy: refresh ? CoreSitesReadingStrategy.OnlyNetwork : undefined, + siteId, + }); + + if (!this.snapshotEquals(snapshot, userData)) { + // Snapshot has diverged, it will be converted into a new attempt if possible. + return this.addToNewOrDelete( + scormId, + attempt, + lastOffline, + newAttemptsSameOrder, + newAttemptsAtEnd, + lastOfflineData.timecreated!, + lastOfflineData.incomplete, + warnings, + siteId, + ); + } + } + }); + + await Promise.all(promises); + + await this.moveNewAttempts(scormId, newAttemptsSameOrder, lastOnline, lastCollision, offlineAttempts, siteId); + + // The new attempts that need to keep the order have been created. + // Now create the new attempts at the end of the list of offline attempts. It should only be 1 attempt max. + lastOffline = lastOffline + newAttemptsSameOrder.length; + + await this.createNewAttemptsAtEnd(scormId, newAttemptsAtEnd, lastOffline, siteId); + + return warnings; + } + +} + +export const AddonModScormSync = makeSingleton(AddonModScormSyncProvider); + +/** + * Data returned by a SCORM sync. + */ +export type AddonModScormSyncResult = { + warnings: string[]; // List of warnings. + attemptFinished: boolean; // Whether an attempt was finished in the site due to the sync, + updated: boolean; // Whether some data was sent to the site. +}; + +/** + * Auto sync event data. + */ +export type AddonModScormAutoSyncEventData = { + scormId: number; + attemptFinished: boolean; + warnings: string[]; + updated: boolean; +}; diff --git a/src/addons/mod/scorm/services/scorm.ts b/src/addons/mod/scorm/services/scorm.ts new file mode 100644 index 000000000..5920b727f --- /dev/null +++ b/src/addons/mod/scorm/services/scorm.ts @@ -0,0 +1,2080 @@ +// (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 } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites, CoreSitesCommonWSOptions, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWS, CoreWSExternalFile, CoreWSExternalWarning, CoreWSPreSets } from '@services/ws'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents } from '@singletons/events'; +import { AddonModScormOffline } from './scorm-offline'; +import { AddonModScormAutoSyncEventData, AddonModScormSyncProvider } from './scorm-sync'; + +// Private constants. +const VALID_STATUSES = ['notattempted', 'passed', 'completed', 'failed', 'incomplete', 'browsed', 'suspend']; +const STATUSES = { + 'passed': 'passed', + 'completed': 'completed', + 'failed': 'failed', + 'incomplete': 'incomplete', + 'browsed': 'browsed', + 'not attempted': 'notattempted', + 'p': 'passed', + 'c': 'completed', + 'f': 'failed', + 'i': 'incomplete', + 'b': 'browsed', + 'n': 'notattempted', +}; +const STATUS_TO_ICON = { + assetc: 'far-file-archive', + asset: 'far-file-archive', + browsed: 'fas-book', + completed: 'far-check-square', + failed: 'fas-times', + incomplete: 'far-edit', + minus: 'fas-minus', + notattempted: 'far-square', + passed: 'fas-check', + plus: 'fas-plus', + popdown: 'far-window-close', + popup: 'fas-window-restore', + suspend: 'fas-pause', + wait: 'far-clock', +}; +const ROOT_CACHE_KEY = 'mmaModScorm:'; + +/** + * Service that provides some features for SCORM. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModScormProvider { + + static readonly COMPONENT = 'mmaModScorm'; + + // Public constants. + static readonly GRADESCOES = 0; + static readonly GRADEHIGHEST = 1; + static readonly GRADEAVERAGE = 2; + static readonly GRADESUM = 3; + + static readonly HIGHESTATTEMPT = 0; + static readonly AVERAGEATTEMPT = 1; + static readonly FIRSTATTEMPT = 2; + static readonly LASTATTEMPT = 3; + + static readonly MODEBROWSE = 'browse'; + static readonly MODENORMAL = 'normal'; + static readonly MODEREVIEW = 'review'; + + static readonly SCORM_FORCEATTEMPT_NO = 0; + static readonly SCORM_FORCEATTEMPT_ONCOMPLETE = 1; + static readonly SCORM_FORCEATTEMPT_ALWAYS = 2; + + static readonly SKIPVIEW_NEVER = 0; + static readonly SKIPVIEW_FIRST = 1; + static readonly SKIPVIEW_ALWAYS = 2; + + // Events. + static readonly LAUNCH_NEXT_SCO_EVENT = 'addon_mod_scorm_launch_next_sco'; + static readonly LAUNCH_PREV_SCO_EVENT = 'addon_mod_scorm_launch_prev_sco'; + static readonly UPDATE_TOC_EVENT = 'addon_mod_scorm_update_toc'; + static readonly GO_OFFLINE_EVENT = 'addon_mod_scorm_go_offline'; + static readonly DATA_SENT_EVENT = 'addon_mod_scorm_data_sent'; + + /** + * Calculates the SCORM grade based on the grading method and the list of attempts scores. + * We only treat online attempts to calculate a SCORM grade. + * + * @param scorm SCORM. + * @param onlineAttempts Object with the online attempts. + * @return Grade. -1 if no grade. + */ + calculateScormGrade(scorm: AddonModScormScorm, onlineAttempts: Record): number { + if (!onlineAttempts || !Object.keys(onlineAttempts).length) { + return -1; + } + + switch (scorm.whatgrade) { + case AddonModScormProvider.FIRSTATTEMPT: + return onlineAttempts[1] ? onlineAttempts[1].grade : -1; + + case AddonModScormProvider.LASTATTEMPT: { + // Search the last attempt number. + let max = 0; + Object.keys(onlineAttempts).forEach((attemptNumber) => { + max = Math.max(Number(attemptNumber), max); + }); + + if (max > 0) { + return onlineAttempts[max].grade; + } + + return -1; + } + + case AddonModScormProvider.HIGHESTATTEMPT: { + // Search the highest grade. + let grade = 0; + for (const attemptNumber in onlineAttempts) { + grade = Math.max(onlineAttempts[attemptNumber].grade, grade); + } + + return grade; + } + + case AddonModScormProvider.AVERAGEATTEMPT: { + // Calculate the average. + let sumGrades = 0; + let total = 0; + + for (const attemptNumber in onlineAttempts) { + sumGrades += onlineAttempts[attemptNumber].grade; + total++; + } + + return Math.round(sumGrades / total); + } + + default: + return -1; + } + } + + /** + * Calculates the size of a SCORM. + * + * @param scorm SCORM. + * @return Promise resolved with the SCORM size. + */ + async calculateScormSize(scorm: AddonModScormScorm): Promise { + if (scorm.packagesize) { + return scorm.packagesize; + } + + return CoreWS.getRemoteFileSize(this.getPackageUrl(scorm)); + } + + /** + * Count the attempts left for the given scorm. + * + * @param scorm SCORM. + * @param attemptsCount Number of attempts performed. + * @return Number of attempts left. + */ + countAttemptsLeft(scorm: AddonModScormScorm, attemptsCount: number): number { + if (!scorm.maxattempt) { + return Number.MAX_VALUE; // Unlimited attempts. + } + + attemptsCount = Number(attemptsCount); // Make sure it's a number. + if (isNaN(attemptsCount)) { + return -1; + } + + return Math.max(scorm.maxattempt - attemptsCount, 0); + } + + /** + * Returns the mode and attempt number to use based on mode selected and SCORM data. + * This function is based on Moodle's scorm_check_mode. + * + * @param scorm SCORM. + * @param mode Selected mode. + * @param attempt Current attempt. + * @param newAttempt Whether it should start a new attempt. + * @param incomplete Whether current attempt is incomplete. + * @return Mode, attempt number and whether to start a new attempt. + */ + determineAttemptAndMode( + scorm: AddonModScormScorm, + mode: string, + attempt: number, + newAttempt?: boolean, + incomplete?: boolean, + ): {mode: string; attempt: number; newAttempt: boolean} { + + if (mode == AddonModScormProvider.MODEBROWSE) { + if (scorm.hidebrowse) { + // Prevent Browse mode if hidebrowse is set. + mode = AddonModScormProvider.MODENORMAL; + } else { + // We don't need to check attempts as browse mode is set. + if (attempt == 0) { + attempt = 1; + newAttempt = true; + } + + return { + mode: mode, + attempt: attempt, + newAttempt: !!newAttempt, + }; + } + } + + if (scorm.forcenewattempt == AddonModScormProvider.SCORM_FORCEATTEMPT_ALWAYS) { + // This SCORM is configured to force a new attempt on every re-entry. + return { + mode: AddonModScormProvider.MODENORMAL, + attempt: attempt + 1, + newAttempt: true, + }; + } + + // Validate user request to start a new attempt. + if (attempt == 0) { + newAttempt = true; + } else if (incomplete) { + // The option to start a new attempt should never have been presented. Force false. + newAttempt = false; + } else if (scorm.forcenewattempt) { + // A new attempt should be forced for already completed attempts. + newAttempt = true; + } + + if (newAttempt && (!scorm.maxattempt || attempt < scorm.maxattempt)) { + // Create a new attempt. Force mode normal. + attempt++; + mode = AddonModScormProvider.MODENORMAL; + } else { + if (incomplete) { + // We can't review an incomplete attempt. + mode = AddonModScormProvider.MODENORMAL; + } else { + // We aren't starting a new attempt and the current one is complete, force review mode. + mode = AddonModScormProvider.MODEREVIEW; + } + } + + return { + mode: mode, + attempt: attempt, + newAttempt: !!newAttempt, + }; + } + + /** + * Check if TOC should be displayed in the player. + * + * @param scorm SCORM. + * @return Whether it should display TOC. + */ + displayTocInPlayer(scorm: AddonModScormScorm): boolean { + return scorm.hidetoc !== 3; + } + + /** + * This is a little language parser for AICC_SCRIPT. + * Evaluates the expression and returns a boolean answer. + * See 2.3.2.5.1. Sequencing/Navigation Today - from the SCORM 1.2 spec (CAM). + * + * @param prerequisites The AICC_SCRIPT prerequisites expression. + * @param trackData The tracked user data of each SCO. + * @return Whether the prerequisites are fulfilled. + */ + evalPrerequisites(prerequisites: string, trackData: Record>): boolean { + const stack: string[] = []; // List of prerequisites. + + // Expand the amp entities. + prerequisites = prerequisites.replace(/&/gi, '&'); + // Find all my parsable tokens. + prerequisites = prerequisites.replace(/(&|\||\(|\)|~)/gi, '\t$1\t'); + // Expand operators. + prerequisites = prerequisites.replace(/&/gi, '&&'); + prerequisites = prerequisites.replace(/\|/gi, '||'); + + // Now - grab all the tokens. + const elements = prerequisites.trim().split('\t'); + + // Process each token to build an expression to be evaluated. + elements.forEach((element) => { + element = element.trim(); + if (!element) { + return; + } + + if (!element.match(/^(&&|\|\||\(|\))$/gi)) { + // Create each individual expression. + // Search for ~ = <> X*{} . + + const re = /^(\d+)\*\{(.+)\}$/; // Sets like 3*{S34, S36, S37, S39}. + const reOther = /^(.+)(=|<>)(.+)$/; // Other symbols. + let matches = element.match(re); + + if (matches) { + const repeat = Number(matches[1]); + const set = matches[2].split(',') || []; + let count = 0; + + set.forEach((setElement) => { + setElement = setElement.trim(); + + if (typeof trackData[setElement] != 'undefined' && + (trackData[setElement].status == 'completed' || trackData[setElement].status == 'passed')) { + count++; + } + }); + + if (count >= repeat) { + element = 'true'; + } else { + element = 'false'; + } + } else if (element == '~') { + // Not maps ~. + element = '!'; + } else if (reOther.test(element)) { + // Other symbols = | <> . + matches = element.match(reOther)!; + element = matches[1].trim(); + + if (typeof trackData[element] != 'undefined') { + let value = matches[3].trim().replace(/('|")/gi, ''); + let oper: string; + + if (typeof STATUSES[value] != 'undefined') { + value = STATUSES[value]; + } + + if (matches[2] == '<>') { + oper = '!='; + } else { + oper = '=='; + } + + element = '(\'' + trackData[element].status + '\' ' + oper + ' \'' + value + '\')'; + } else { + element = 'false'; + } + } else { + // Everything else must be an element defined like S45 ... + if (typeof trackData[element] != 'undefined' && + (trackData[element].status == 'completed' || trackData[element].status == 'passed')) { + element = 'true'; + } else { + element = 'false'; + } + } + } + + // Add the element to the list of prerequisites. + stack.push(' ' + element + ' '); + }); + + // eslint-disable-next-line no-eval + return eval(stack.join('') + ';'); + } + + /** + * Formats a grade to be displayed. + * + * @param scorm SCORM. + * @param grade Grade. + * @return Grade to display. + */ + formatGrade(scorm: AddonModScormScorm, grade: number): string { + if (typeof grade == 'undefined' || grade == -1) { + return Translate.instant('core.none'); + } + + if (scorm.grademethod !== AddonModScormProvider.GRADESCOES && scorm.maxgrade) { + grade = (grade / scorm.maxgrade) * 100; + + return Translate.instant('core.percentagenumber', { $a: CoreTextUtils.roundToDecimals(grade, 2) }); + } + + return String(grade); + } + + /** + * Formats a tree-like TOC into an array. + * + * @param toc SCORM's TOC (tree format). + * @param level The level of the TOC we're right now. 0 by default. + * @return SCORM's TOC (array format). + */ + formatTocToArray(toc: AddonModScormTOCTreeSco[], level: number = 0): AddonModScormTOCListSco[] { + if (!toc || !toc.length) { + return []; + } + + let formatted: AddonModScormTOCListSco[] = []; + + toc.forEach((node) => { + const sco = node; + sco.level = level; + formatted.push(sco); + + formatted = formatted.concat(this.formatTocToArray(node.children, level + 1)); + }); + + return formatted; + } + + /** + * Get access information for a given SCORM. + * + * @param scormId SCORM ID. + * @param options Other options. + * @return Object with access information. + * @since 3.7 + */ + async getAccessInformation( + scormId: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + if (!site.wsAvailable('mod_scorm_get_scorm_access_information')) { + // Access information not available for 3.6 or older sites. + return {}; + } + + const params: AddonModScormGetScormAccessInformationWSParams = { + scormid: scormId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAccessInformationCacheKey(scormId), + component: AddonModScormProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + return site.read('mod_scorm_get_scorm_access_information', params, preSets); + } + + /** + * Get cache key for access information WS calls. + * + * @param scormId SCORM ID. + * @return Cache key. + */ + protected getAccessInformationCacheKey(scormId: number): string { + return ROOT_CACHE_KEY + 'accessInfo:' + scormId; + } + + /** + * Get the number of attempts done by a user in the given SCORM. + * + * @param scormId SCORM ID. + * @param options Other options. + * @return Promise resolved when done. + */ + async getAttemptCount( + scormId: number, + options: AddonModScormGetAttemptCountOptions = {}, + ): Promise { + + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + const site = await CoreSites.getSite(options.siteId); + const userId = options.userId || site.getUserId(); + + const [onlineCount, offlineAttempts] = await Promise.all([ + this.getAttemptCountOnline(scormId, options), + AddonModScormOffline.getAttempts(scormId, options.siteId, userId), + ]); + + const result: AddonModScormAttemptCountResult = { + online: [], + offline: [], + total: onlineCount, + lastAttempt: { + num: onlineCount, + offline: false, + }, + }; + + // Fill online attempts array. + for (let i = 1; i <= onlineCount; i++) { + result.online.push(i); + } + + // Get only attempt numbers for offline attempts. + result.offline = offlineAttempts.map((entry) => { + // Calculate last attempt. We use >= to prioritize offline events if an attempt is both online and offline. + if (entry.attempt >= result.lastAttempt.num) { + result.lastAttempt.num = entry.attempt; + result.lastAttempt.offline = true; + } + + return entry.attempt; + }); + + // Calculate the total. + result.offline.forEach((attempt) => { + // Check if this attempt also exists in online, it might have been copied to local. + if (result.online.indexOf(attempt) == -1) { + result.total++; + } + }); + + return result; + } + + /** + * Get cache key for SCORM attempt count WS calls. + * + * @param scormId SCORM ID. + * @param userId User ID. If not defined, current user. + * @return Cache key. + */ + protected getAttemptCountCacheKey(scormId: number, userId: number): string { + return ROOT_CACHE_KEY + 'attemptcount:' + scormId + ':' + userId; + } + + /** + * Get the number of attempts done by a user in the given SCORM in online. + * + * @param scormId SCORM ID. + * @param options Other options. + * @return Promise resolved when the attempt count is retrieved. + */ + async getAttemptCountOnline(scormId: number, options: AddonModScormGetAttemptCountOptions = {}): Promise { + const site = await CoreSites.getSite(options.siteId); + + const userId = options.userId || site.getUserId(); + const params: AddonModScormGetScormAttemptCountWSParams = { + scormid: scormId, + userid: userId, + ignoremissingcompletion: options.ignoreMissing, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getAttemptCountCacheKey(scormId, userId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModScormProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_scorm_get_scorm_attempt_count', + params, + preSets, + ); + + return response.attemptscount; + } + + /** + * Get the grade for a certain SCORM and attempt. + * Based on Moodle's scorm_grade_user_attempt. + * + * @param scorm SCORM. + * @param attempt Attempt number. + * @param offline Whether the attempt is offline. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the grade. If the attempt hasn't reported grade/completion, it will be -1. + */ + async getAttemptGrade(scorm: AddonModScormScorm, attempt: number, offline?: boolean, siteId?: string): Promise { + const attemptScore = { + scos: 0, + values: 0, + max: 0, + sum: 0, + }; + + // Get the user data and use it to calculate the grade. + const data = await this.getScormUserData(scorm.id, attempt, { offline, cmId: scorm.coursemodule, siteId }); + + for (const scoId in data) { + const sco = data[scoId]; + const userData = sco.userdata; + + if (userData.status == 'completed' || userData.status == 'passed') { + attemptScore.scos++; + } + + if (userData.score_raw || (typeof scorm.scormtype != 'undefined' && + scorm.scormtype == 'sco' && typeof userData.score_raw != 'undefined')) { + + const scoreRaw = parseFloat( userData.score_raw); + attemptScore.values++; + attemptScore.sum += scoreRaw; + attemptScore.max = Math.max(scoreRaw, attemptScore.max); + } + } + + let score = 0; + + switch (scorm.grademethod) { + case AddonModScormProvider.GRADEHIGHEST: + score = attemptScore.max; + break; + + case AddonModScormProvider.GRADEAVERAGE: + if (attemptScore.values > 0) { + score = attemptScore.sum / attemptScore.values; + } else { + score = 0; + } + break; + + case AddonModScormProvider.GRADESUM: + score = attemptScore.sum; + break; + + case AddonModScormProvider.GRADESCOES: + score = attemptScore.scos; + break; + + default: + score = attemptScore.max; // Remote Learner GRADEHIGHEST is default. + } + + return score; + } + + /** + * Get the list of a organizations defined in a SCORM package. + * + * @param scormId SCORM ID. + * @param options Other options. + * @return Promise resolved with the list of organizations. + */ + async getOrganizations(scormId: number, options: CoreCourseCommonModWSOptions = {}): Promise { + const scos = await this.getScos(scormId, options); + + const organizations: AddonModScormOrganization[] = []; + + scos.forEach((sco) => { + // Is an organization entry? + if (sco.organization == '' && sco.parent == '/' && sco.scormtype == '') { + organizations.push({ + identifier: sco.identifier, + title: sco.title, + sortorder: sco.sortorder, + }); + } + }); + + return organizations; + } + + /** + * Get the organization Toc any + * + * @param scormId SCORM ID. + * @param attempt The attempt number (to populate SCO track data). + * @param options Other options. + * @return Promise resolved with the toc object. + */ + async getOrganizationToc( + scormId: number, + attempt: number, + options: AddonModScormGetScosWithDataOptions = {}, + ): Promise { + + const scos = await this.getScosWithData(scormId, attempt, options); + + const map: Record = {}; + const rootScos: AddonModScormTOCTreeSco[] = []; + + scos.forEach((sco, index) => { + sco.children = []; + map[sco.identifier] = index; + + if (sco.parent !== '/') { + if (sco.parent == options.organization) { + // It's a root SCO, add it to the root array. + rootScos.push(sco); + } else { + // Add this sco to the parent. + scos[map[sco.parent]].children.push(sco); + } + } + }); + + return rootScos; + } + + /** + * Get the package URL of a given SCORM. + * + * @param scorm SCORM. + * @return Package URL. + */ + getPackageUrl(scorm: AddonModScormScorm): string { + if (scorm.packageurl) { + return scorm.packageurl; + } + if (scorm.reference) { + return scorm.reference; + } + + return ''; + } + + /** + * Get the user data for a certain SCORM and attempt. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param options Other options. + * @return Promise resolved when the user data is retrieved. + */ + async getScormUserData( + scormId: number, + attempt: number, + options: AddonModScormGetUserDataOptions = {}, + ): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + if (!options.offline) { + return this.getScormUserDataOnline(scormId, attempt, options); + } + + // Get SCOs if not provided. + if (!options.scos) { + options.scos = await this.getScos(scormId, options); + } + + return AddonModScormOffline.getScormUserData(scormId, attempt, options.scos, options.siteId); + } + + /** + * Get cache key for SCORM user data WS calls. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @return Cache key. + */ + protected getScormUserDataCacheKey(scormId: number, attempt: number): string { + return this.getScormUserDataCommonCacheKey(scormId) + ':' + attempt; + } + + /** + * Get common cache key for SCORM user data WS calls. + * + * @param scormId SCORM ID. + * @return Cache key. + */ + protected getScormUserDataCommonCacheKey(scormId: number): string { + return ROOT_CACHE_KEY + 'userdata:' + scormId; + } + + /** + * Get the user data for a certain SCORM and attempt in online. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param options Other options. + * @return Promise resolved when the user data is retrieved. + */ + async getScormUserDataOnline( + scormId: number, + attempt: number, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModScormGetScormUserDataWSParams = { + scormid: scormId, + attempt: attempt, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getScormUserDataCacheKey(scormId, attempt), + component: AddonModScormProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_scorm_get_scorm_user_data', params, preSets); + + // Format the response. + const data: AddonModScormUserDataMap = {}; + + response.data.forEach((sco) => { + data[sco.scoid] = { + scoid: sco.scoid, + defaultdata: > CoreUtils.objectToKeyValueMap( + sco.defaultdata, + 'element', + 'value', + ), + userdata: > CoreUtils.objectToKeyValueMap(sco.userdata, 'element', 'value'), + }; + + }); + + return data; + } + + /** + * Get cache key for get SCORM scos WS calls. + * + * @param scormId SCORM ID. + * @return Cache key. + */ + protected getScosCacheKey(scormId: number): string { + return ROOT_CACHE_KEY + 'scos:' + scormId; + } + + /** + * Retrieves the list of SCO objects for a given SCORM and organization. + * + * @param scormId SCORM ID. + * @param options Other options. + * @return Promise resolved with a list of SCO. + */ + async getScos(scormId: number, options: AddonModScormOrganizationOptions = {}): Promise { + options.siteId = options.siteId || CoreSites.getCurrentSiteId(); + + const site = await CoreSites.getSite(options.siteId); + + // Don't send the organization to the WS, we'll filter them locally. + const params: AddonModScormGetScormScoesWSParams = { + scormid: scormId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getScosCacheKey(scormId), + updateFrequency: CoreSite.FREQUENCY_SOMETIMES, + component: AddonModScormProvider.COMPONENT, + componentId: options.cmId, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read('mod_scorm_get_scorm_scoes', params, preSets); + + if (options.organization) { + // Filter SCOs by organization. + return response.scoes.filter((sco) => sco.organization == options.organization); + } + + return response.scoes; + } + + /** + * Retrieves the list of SCO objects for a given SCORM and organization, including data about + * a certain attempt (status, isvisible, ...). + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param options Other options. + * @return Promise resolved with a list of SCO objects. + */ + async getScosWithData( + scormId: number, + attempt: number, + options: AddonModScormGetScosWithDataOptions = {}, + ): Promise { + + // Get organization SCOs. + const scos = await this.getScos(scormId, options); + + // Get the track data for all the SCOs in the organization for the given attempt. + // We'll use this data to set SCO data like isvisible, status and so. + const userDataOptions: AddonModScormGetUserDataOptions = { + scos, + ...options, // Include all options. + }; + + const data = await this.getScormUserData(scormId, attempt, userDataOptions); + + const trackDataBySCO: Record> = {}; + + // First populate trackDataBySCO to index by SCO identifier. + // We want the full list first because it's needed by evalPrerequisites. + scos.forEach((sco) => { + trackDataBySCO[sco.identifier] = data[sco.id].userdata; + }); + + const scosWithData: AddonModScormScoWithData[] = scos; + + scosWithData.forEach((sco) => { + // Add specific SCO information (related to tracked data). + const scoData = data[sco.id].userdata; + + if (!scoData) { + return; + } + + // Check isvisible attribute. + sco.isvisible = typeof scoData.isvisible == 'undefined' || (!!scoData.isvisible && scoData.isvisible !== 'false'); + // Check pre-requisites status. + sco.prereq = typeof scoData.prerequisites == 'undefined' || + this.evalPrerequisites( scoData.prerequisites, trackDataBySCO); + // Add status. + sco.status = (typeof scoData.status == 'undefined' || scoData.status === '') ? 'notattempted' : scoData.status; + // Exit var. + sco.exitvar = typeof scoData.exitvar == 'undefined' ? 'cmi.core.exit' : scoData.exitvar; + sco.exitvalue = scoData[sco.exitvar]; + // Copy score. + sco.scoreraw = scoData.score_raw; + }); + + return scosWithData; + } + + /** + * Given a SCORM and a SCO, returns the full launch URL for the SCO. + * + * @param scorm SCORM. + * @param sco SCO. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the URL. + */ + async getScoSrc(scorm: AddonModScormScorm, sco: AddonModScormWSSco, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + // Build the launch URL. Moodle web checks SCORM version, we don't need to, it's always SCORM 1.2. + let launchUrl = sco.launch; + const parametersEntry = sco.extradata?.find((entry) => entry.element == 'parameters'); + let parameters = parametersEntry?.value; + + if (parameters) { + const connector = launchUrl.indexOf('?') > -1 ? '&' : '?'; + if (parameters.charAt(0) == '?') { + parameters = parameters.substr(1); + } + + launchUrl += connector + parameters; + } + + if (this.isExternalLink(launchUrl)) { + // It's an online URL. + return launchUrl; + } + + const dirPath = await CoreFilepool.getPackageDirUrlByUrl(siteId, scorm.moduleurl!); + + return CoreTextUtils.concatenatePaths(dirPath, launchUrl); + } + + /** + * Get the path to the folder where a SCORM is downloaded. + * + * @param moduleUrl Module URL (returned by get_course_contents). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the folder path. + */ + getScormFolder(moduleUrl: string, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + return CoreFilepool.getPackageDirPathByUrl(siteId, moduleUrl); + } + + /** + * Gets a list of files to downlaod for a SCORM, using a format similar to module.contents from get_course_contents. + * It will only return one file: the ZIP package. + * + * @param scorm SCORM. + * @return File list. + */ + getScormFileList(scorm: AddonModScormScorm): CoreWSExternalFile[] { + const files: CoreWSExternalFile[] = []; + + if (!this.isScormUnsupported(scorm) && !scorm.warningMessage) { + files.push({ + fileurl: this.getPackageUrl(scorm), + filepath: '/', + filename: scorm.reference, + filesize: scorm.packagesize, + timemodified: 0, + }); + } + + return files; + } + + /** + * Get the URL and description of the status icon. + * + * @param sco SCO. + * @param incomplete Whether the SCORM is incomplete. + * @return Image URL and description. + */ + getScoStatusIcon(sco: AddonModScormScoWithData, incomplete?: boolean): AddonModScormScoIcon { + let imageName = ''; + let descName = ''; + let suspendedStr = ''; + + const status = sco.status || ''; + + if (sco.isvisible) { + if (VALID_STATUSES.indexOf(status) >= 0) { + if (sco.scormtype == 'sco') { + imageName = status; + descName = status; + } else { + imageName = 'asset'; + descName = 'assetlaunched'; + } + + if (!incomplete) { + // Check if SCO is completed or not. If SCORM is incomplete there's no need to check SCO. + incomplete = this.isStatusIncomplete(status); + } + + if (incomplete && sco.exitvalue == 'suspend') { + imageName = 'suspend'; + suspendedStr = ' - ' + Translate.instant('addon.mod_scorm.suspended'); + } + } else { + incomplete = true; + + if (sco.scormtype == 'sco') { + // Status empty or not valid, use 'notattempted'. + imageName = 'notattempted'; + } else { + imageName = 'asset'; + } + descName = imageName; + } + } + + if (imageName == '') { + imageName = 'notattempted'; + descName = 'notattempted'; + suspendedStr = ''; + } + + sco.incomplete = incomplete; + + return { + icon: STATUS_TO_ICON[imageName], + description: Translate.instant('addon.mod_scorm.' + descName) + suspendedStr, + }; + } + + /** + * Get cache key for SCORM data WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getScormDataCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'scorm:' + courseId; + } + + /** + * Get a SCORM 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 when the SCORM is retrieved. + */ + protected async getScormByField( + courseId: number, + key: string, + value: unknown, + options: AddonModScormGetScormOptions = {}, + ): Promise { + + const site = await CoreSites.getSite(options.siteId); + + const params: AddonModScormGetScormsByCoursesWSParams = { + courseids: [courseId], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getScormDataCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + component: AddonModScormProvider.COMPONENT, + ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), // Include reading strategy preSets. + }; + + const response = await site.read( + 'mod_scorm_get_scorms_by_courses', + params, + preSets, + ); + + const currentScorm = response.scorms.find(scorm => scorm[key] == value); + if (!currentScorm) { + throw new CoreError('SCORM not found.'); + } + + // If the SCORM isn't available the WS returns a warning and it doesn't return timeopen and timeclosed. + if (typeof currentScorm.timeopen == 'undefined') { + const warning = response.warnings?.find(warning => warning.itemid === currentScorm.id); + currentScorm.warningMessage = warning?.message; + } + + currentScorm.moduleurl = options.moduleUrl; + + return currentScorm; + } + + /** + * Get a SCORM by module ID. + * + * @param courseId Course ID. + * @param cmId Course module ID. + * @param options Other options. + * @return Promise resolved when the SCORM is retrieved. + */ + getScorm(courseId: number, cmId: number, options: AddonModScormGetScormOptions = {}): Promise { + return this.getScormByField(courseId, 'coursemodule', cmId, options); + } + + /** + * Get a SCORM by SCORM ID. + * + * @param courseId Course ID. + * @param id SCORM ID. + * @param options Other options. + * @return Promise resolved when the SCORM is retrieved. + */ + getScormById(courseId: number, id: number, options: AddonModScormGetScormOptions = {}): Promise { + return this.getScormByField(courseId, 'id', id, options); + } + + /** + * Get a readable SCORM grade method. + * + * @param scorm SCORM. + * @return Grading method. + */ + getScormGradeMethod(scorm: AddonModScormScorm): string { + if (scorm.maxattempt == 1) { + switch (scorm.grademethod) { + case AddonModScormProvider.GRADEHIGHEST: + return Translate.instant('addon.mod_scorm.gradehighest'); + + case AddonModScormProvider.GRADEAVERAGE: + return Translate.instant('addon.mod_scorm.gradeaverage'); + + case AddonModScormProvider.GRADESUM: + return Translate.instant('addon.mod_scorm.gradesum'); + + case AddonModScormProvider.GRADESCOES: + return Translate.instant('addon.mod_scorm.gradescoes'); + default: + return ''; + } + } + + switch (scorm.whatgrade) { + case AddonModScormProvider.HIGHESTATTEMPT: + return Translate.instant('addon.mod_scorm.highestattempt'); + + case AddonModScormProvider.AVERAGEATTEMPT: + return Translate.instant('addon.mod_scorm.averageattempt'); + + case AddonModScormProvider.FIRSTATTEMPT: + return Translate.instant('addon.mod_scorm.firstattempt'); + + case AddonModScormProvider.LASTATTEMPT: + return Translate.instant('addon.mod_scorm.lastattempt'); + default: + return ''; + } + } + + /** + * Invalidates access information. + * + * @param scormId SCORM ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAccessInformation(scormId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getAccessInformationCacheKey(scormId)); + } + + /** + * Invalidates all the data related to a certain SCORM. + * + * @param scormId SCORM ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAllScormData(scormId: number, siteId?: string, userId?: number): Promise { + await Promise.all([ + this.invalidateAttemptCount(scormId, siteId, userId), + this.invalidateScos(scormId, siteId), + this.invalidateScormUserData(scormId, siteId), + this.invalidateAccessInformation(scormId, siteId), + ]); + } + + /** + * Invalidates attempt count. + * + * @param scormId SCORM ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateAttemptCount(scormId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.getSite(siteId); + userId = userId || site.getUserId(); + + await site.invalidateWsCacheForKey(this.getAttemptCountCacheKey(scormId, userId)); + } + + /** + * Invalidate the prefetched content. + * + * @param moduleId The module ID. + * @param courseId Course ID of the module. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined use site's current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateContent(moduleId: number, courseId: number, siteId?: string, userId?: number): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const scorm = await this.getScorm(courseId, moduleId, { siteId }); + + await Promise.all([ + this.invalidateAllScormData(scorm.id, siteId, userId), + CoreFilepool.invalidateFilesByComponent(siteId, AddonModScormProvider.COMPONENT, moduleId, true), + ]); + } + + /** + * Invalidates SCORM data. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateScormData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getScormDataCacheKey(courseId)); + } + + /** + * Invalidates SCORM user data for all attempts. + * + * @param scormId SCORM ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateScormUserData(scormId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getScormUserDataCommonCacheKey(scormId)); + } + + /** + * Invalidates SCORM scos for all organizations. + * + * @param scormId SCORM ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateScos(scormId: number, siteId?: string): Promise { + const site = await CoreSites.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getScosCacheKey(scormId)); + } + + /** + * Check if a SCORM's attempt is incomplete. + * + * @param scormId SCORM ID. + * @param attempt Attempt. + * @param options Other options. + * @return Promise resolved with a boolean: true if incomplete, false otherwise. + */ + async isAttemptIncomplete(scormId: number, attempt: number, options: AddonModScormOfflineOptions = {}): Promise { + const scos = await this.getScosWithData(scormId, attempt, options); + + return scos.some(sco => sco.isvisible && sco.launch && this.isStatusIncomplete(sco.status)); + } + + /** + * Given a launch URL, check if it's a external link. + * Based on Moodle's scorm_external_link. + * + * @param link Link to check. + * @return Whether it's an external link. + */ + protected isExternalLink(link: string): boolean { + link = link.toLowerCase(); + + if (link.match(/^https?:\/\//i) && !CoreUrlUtils.isLocalFileUrl(link)) { + return true; + } else if (link.substr(0, 4) == 'www.') { + return true; + } + + return false; + } + + /** + * Check if the given SCORM is closed. + * + * @param scorm SCORM to check. + * @return Whether the SCORM is closed. + */ + isScormClosed(scorm: AddonModScormScorm): boolean { + return !!(scorm.timeclose && CoreTimeUtils.timestamp() > scorm.timeclose); + } + + /** + * Check if the given SCORM is downloadable. + * + * @param scorm SCORM to check. + * @return Whether the SCORM is downloadable. + */ + isScormDownloadable(scorm: AddonModScormScorm): boolean { + return typeof scorm.protectpackagedownloads != 'undefined' && scorm.protectpackagedownloads === false; + } + + /** + * Check if the given SCORM is open. + * + * @param scorm SCORM to check. + * @return Whether the SCORM is open. + */ + isScormOpen(scorm: AddonModScormScorm): boolean { + return !!(scorm.timeopen && scorm.timeopen > CoreTimeUtils.timestamp()); + } + + /** + * Check if a SCORM is unsupported in the app. If it's not, returns the error code to show. + * + * @param scorm SCORM to check. + * @return String with error code if unsupported, undefined if supported. + */ + isScormUnsupported(scorm: AddonModScormScorm): string | undefined { + if (!this.isScormValidVersion(scorm)) { + return 'addon.mod_scorm.errorinvalidversion'; + } else if (!this.isScormDownloadable(scorm)) { + return 'addon.mod_scorm.errornotdownloadable'; + } else if (!this.isValidPackageUrl(this.getPackageUrl(scorm))) { + return 'addon.mod_scorm.errorpackagefile'; + } + } + + /** + * Check if it's a valid SCORM 1.2. + * + * @param scorm SCORM to check. + * @return Whether the SCORM is valid. + */ + isScormValidVersion(scorm: AddonModScormScorm): boolean { + return scorm.version == 'SCORM_1.2'; + } + + /** + * Check if a SCO status is incomplete. + * + * @param status SCO status. + * @return Whether it's incomplete. + */ + isStatusIncomplete(status?: string): boolean { + return !status || status == 'notattempted' || status == 'incomplete' || status == 'browsed'; + } + + /** + * Check if a package URL is valid. + * + * @param packageUrl Package URL. + * @return Whether it's valid. + */ + isValidPackageUrl(packageUrl: string): boolean { + if (!packageUrl) { + return false; + } + if (packageUrl.indexOf('imsmanifest.xml') > -1) { + return false; + } + + return true; + } + + /** + * Report a SCO as being launched. + * + * @param scormId SCORM ID. + * @param scoId SCO ID. + * @param name Name of the SCORM. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the WS call is successful. + */ + logLaunchSco(scormId: number, scoId: number, name?: string, siteId?: string): Promise { + const params: AddonModScormLaunchScoWSParams = { + scormid: scormId, + scoid: scoId, + }; + + return CoreCourseLogHelper.logSingle( + 'mod_scorm_launch_sco', + params, + AddonModScormProvider.COMPONENT, + scormId, + name, + 'scorm', + { scoid: scoId }, + siteId, + ); + } + + /** + * Report a SCORM as being viewed. + * + * @param id Module ID. + * @param name Name of the SCORM. + * @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: AddonModScormViewScormWSParams = { + scormid: id, + }; + + return CoreCourseLogHelper.logSingle( + 'mod_scorm_view_scorm', + params, + AddonModScormProvider.COMPONENT, + id, + name, + 'scorm', + {}, + siteId, + ); + } + + /** + * Saves a SCORM tracking record. + * + * @param scoId Sco ID. + * @param attempt Attempt number. + * @param tracks Tracking data to store. + * @param scorm SCORM. + * @param offline Whether the attempt is offline. + * @param userData User data for this attempt and SCO. If not defined, it will be retrieved from DB. Recommended. + * @return Promise resolved when data is saved. + */ + async saveTracks( + scoId: number, + attempt: number, + tracks: AddonModScormDataEntry[], + scorm: AddonModScormScorm, + offline?: boolean, + userData?: AddonModScormUserDataMap, + siteId?: string, + ): Promise { + + siteId = siteId || CoreSites.getCurrentSiteId(); + + if (offline) { + if (!userData) { + userData = await this.getScormUserData(scorm.id, attempt, { offline, cmId: scorm.coursemodule, siteId }); + } + + return AddonModScormOffline.saveTracks(scorm, scoId, attempt, tracks, userData, siteId); + } + + await this.saveTracksOnline(scorm.id, scoId, attempt, tracks, siteId); + + // Tracks have been saved, update cached user data. + this.updateUserDataAfterSave(scorm.id, attempt, tracks, { cmId: scorm.coursemodule, siteId }); + + CoreEvents.trigger(AddonModScormProvider.DATA_SENT_EVENT, { + scormId: scorm.id, + scoId: scoId, + attempt: attempt, + }, CoreSites.getCurrentSiteId()); + } + + /** + * Saves a SCORM tracking record. + * + * @param scormId SCORM ID. + * @param scoId Sco ID. + * @param attempt Attempt number. + * @param tracks Tracking data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when data is saved. + */ + async saveTracksOnline( + scormId: number, + scoId: number, + attempt: number, + tracks: AddonModScormDataEntry[], + siteId?: string, + ): Promise { + if (!tracks || !tracks.length) { + return []; // Nothing to save. + } + + const site = await CoreSites.getSite(siteId); + + const params: AddonModScormInsertScormTracksWSParams = { + scoid: scoId, + attempt: attempt, + tracks: tracks, + }; + + CoreSync.blockOperation(AddonModScormProvider.COMPONENT, scormId, 'saveTracksOnline', site.id); + + try { + const response = await site.write('mod_scorm_insert_scorm_tracks', params); + + return response.trackids; + } finally { + CoreSync.unblockOperation(AddonModScormProvider.COMPONENT, scormId, 'saveTracksOnline', site.id); + } + } + + /** + * Saves a SCORM tracking record using a synchronous call. + * Please use this function only if synchronous is a must. It's recommended to use saveTracks. + * + * @param scoId Sco ID. + * @param attempt Attempt number. + * @param tracks Tracking data to store. + * @param scorm SCORM. + * @param offline Whether the attempt is offline. + * @param userData User data for this attempt and SCO. Required if offline=true. + * @return In online returns true if data is inserted, false otherwise. + * In offline returns true if data to insert is valid, false otherwise. True doesn't mean that the + * data has been stored, this function can return true but the insertion can still fail somehow. + */ + saveTracksSync( + scoId: number, + attempt: number, + tracks: AddonModScormDataEntry[], + scorm: AddonModScormScorm, + offline?: boolean, + userData?: AddonModScormUserDataMap, + ): boolean { + if (offline) { + return AddonModScormOffline.saveTracksSync(scorm, scoId, attempt, tracks, userData!); + } else { + const success = this.saveTracksSyncOnline(scoId, attempt, tracks); + + if (success) { + // Tracks have been saved, update cached user data. + this.updateUserDataAfterSave(scorm.id, attempt, tracks, { cmId: scorm.coursemodule }); + + CoreEvents.trigger(AddonModScormProvider.DATA_SENT_EVENT, { + scormId: scorm.id, + scoId: scoId, + attempt: attempt, + }, CoreSites.getCurrentSiteId()); + } + + return success; + } + } + + /** + * Saves a SCORM tracking record using a synchronous call. + * Please use this function only if synchronous is a must. It's recommended to use saveTracksOnline. + * + * @param scoId Sco ID. + * @param attempt Attempt number. + * @param tracks Tracking data. + * @return True if success, false otherwise. + */ + saveTracksSyncOnline(scoId: number, attempt: number, tracks: AddonModScormDataEntry[]): boolean { + if (!tracks || !tracks.length) { + return true; // Nothing to save. + } + + const params: AddonModScormInsertScormTracksWSParams = { + scoid: scoId, + attempt: attempt, + tracks: tracks, + }; + const currentSite = CoreSites.getCurrentSite(); + if (!currentSite) { + return false; + } + + const preSets: CoreWSPreSets = { + siteUrl: currentSite.getURL(), + wsToken: currentSite.getToken(), + }; + const wsFunction = 'mod_scorm_insert_scorm_tracks'; + + // Check if the method is available, use a prefixed version if possible. + if (!currentSite.wsAvailable(wsFunction, false)) { + return false; + } + + try { + const response = CoreWS.syncCall(wsFunction, params, preSets); + + return !!(response && response.trackids); + } catch { + return false; + } + } + + /** + * Check if the SCORM main file should be downloaded. + * This function should only be called if the SCORM can be downloaded (not downloaded or outdated). + * + * @param scorm SCORM to check. + * @param isOutdated True if package outdated, false if not downloaded, undefined to calculate it. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if it should be downloaded, false otherwise. + */ + async shouldDownloadMainFile(scorm: AddonModScormScorm, isOutdated?: boolean, siteId?: string): Promise { + siteId = siteId || CoreSites.getCurrentSiteId(); + + const component = AddonModScormProvider.COMPONENT; + + if (typeof isOutdated == 'undefined') { + // Calculate if it's outdated. + const data = await CoreUtils.ignoreErrors(CoreFilepool.getPackageData(siteId, component, scorm.coursemodule)); + + if (!data) { + // Package not found, not downloaded. + return false; + } + + const isOutdated = data.status == CoreConstants.OUTDATED || + (data.status == CoreConstants.DOWNLOADING && data.previous == CoreConstants.OUTDATED); + + // Package needs to be downloaded if it's not outdated (not downloaded) or if the hash has changed. + return !isOutdated || data.extra != scorm.sha1hash; + + } else if (isOutdated) { + // The package is outdated, but maybe the file hasn't changed. + const extra = await CoreUtils.ignoreErrors(CoreFilepool.getPackageExtra(siteId, component, scorm.coursemodule)); + + if (!extra) { + // Package not found, not downloaded. + return true; + } + + return scorm.sha1hash != extra; + } else { + // Package is not outdated and not downloaded, download the main file. + return true; + } + } + + /** + * If needed, updates cached user data after saving tracks in online. + * + * @param scormId SCORM ID. + * @param attempt Attempt number. + * @param tracks Tracking data saved. + * @param options Other options. + * @return Promise resolved when updated. + */ + protected async updateUserDataAfterSave( + scormId: number, + attempt: number, + tracks: AddonModScormDataEntry[], + options: {cmId?: number; siteId?: string}, + ): Promise { + if (!tracks || !tracks.length) { + return; + } + + // Check if we need to update. We only update if we sent some track with a dot notation. + const needsUpdate = tracks.some(track => track.element && track.element.indexOf('.') > -1); + + if (!needsUpdate) { + return; + } + + await this.getScormUserDataOnline(scormId, attempt, { + cmId: options.cmId, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId: options.siteId, + }); + } + +} + +export const AddonModScorm = makeSingleton(AddonModScormProvider); + +/** + * Params of mod_scorm_get_scorm_access_information WS. + */ +export type AddonModScormGetScormAccessInformationWSParams = { + scormid: number; // Scorm instance id. +}; + +/** + * Data returned by mod_scorm_get_scorm_access_information WS. + */ +export type AddonModScormGetScormAccessInformationWSResponse = { + warnings?: CoreWSExternalWarning[]; + canaddinstance?: boolean; // Whether the user has the capability mod/scorm:addinstance allowed. + canviewreport?: boolean; // Whether the user has the capability mod/scorm:viewreport allowed. + canskipview?: boolean; // Whether the user has the capability mod/scorm:skipview allowed. + cansavetrack?: boolean; // Whether the user has the capability mod/scorm:savetrack allowed. + canviewscores?: boolean; // Whether the user has the capability mod/scorm:viewscores allowed. + candeleteresponses?: boolean; // Whether the user has the capability mod/scorm:deleteresponses allowed. + candeleteownresponses?: boolean; // Whether the user has the capability mod/scorm:deleteownresponses allowed. +}; + +/** + * Params of mod_scorm_get_scorm_attempt_count WS. + */ +export type AddonModScormGetScormAttemptCountWSParams = { + scormid: number; // SCORM instance id. + userid: number; // User id. + ignoremissingcompletion?: boolean; // Ignores attempts that haven't reported a grade/completion. +}; + +/** + * Data returned by mod_scorm_get_scorm_attempt_count WS. + */ +export type AddonModScormGetScormAttemptCountWSResponse = { + attemptscount: number; // Attempts count. + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_scorm_get_scorm_user_data WS. + */ +export type AddonModScormGetScormUserDataWSParams = { + scormid: number; // Scorm instance id. + attempt: number; // Attempt number. +}; + +/** + * Data returned by mod_scorm_get_scorm_user_data WS. + */ +export type AddonModScormGetScormUserDataWSResponse = { + data: AddonModScormWSScoUserData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Each entry returned by mod_scorm_get_scorm_user_data WS. + */ +export type AddonModScormWSScoUserData = { + scoid: number; // Sco id. + userdata: AddonModScormDataEntry[]; + defaultdata: AddonModScormDataEntry[]; +}; + +/** + * Data for each data entry returned by mod_scorm_get_scorm_user_data WS. + */ +export type AddonModScormDataEntry = { + element: string; // Element name. + value: AddonModScormDataValue; // Element value. +}; + +/** + * Possible values for a data value. + */ +export type AddonModScormDataValue = string | number; + +/** + * Map of formatted user data, indexed by SCO id. + */ +export type AddonModScormUserDataMap = Record; + +/** + * User data returned mod_scorm_get_scorm_user_data, but formatted. + */ +export type AddonModScormScoUserData = Omit & { + defaultdata: Record; + userdata: Record; +}; + +/** + * Params of mod_scorm_get_scorm_scoes WS. + */ +export type AddonModScormGetScormScoesWSParams = { + scormid: number; // Scorm instance id. + organization?: string; // Organization id. +}; + +/** + * Data returned by mod_scorm_get_scorm_scoes WS. + */ +export type AddonModScormGetScormScoesWSResponse = { + scoes: AddonModScormWSSco[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * SCO data returned by mod_scorm_get_scorm_scoes WS. + */ +export type AddonModScormWSSco = { + id: number; // Sco id. + scorm: number; // Scorm id. + manifest: string; // Manifest id. + organization: string; // Organization id. + parent: string; // Parent. + identifier: string; // Identifier. + launch: string; // Launch file. + scormtype: string; // Scorm type (asset, sco). + title: string; // Sco title. + sortorder: number; // Sort order. + extradata?: AddonModScormDataEntry[]; // Additional SCO data. +}; + +/** + * SCO data with some calculated data. + */ +export type AddonModScormScoWithData = AddonModScormWSSco & { + isvisible?: boolean; + prereq?: boolean; + status?: string; + exitvar?: string; + exitvalue?: string; + scoreraw?: string | number; + incomplete?: boolean; +}; + +/** + * SCO data, including children to build the TOC. + */ +export type AddonModScormTOCTreeSco = AddonModScormScoWithData & { + children: AddonModScormTOCTreeSco[]; +}; + +/** + * SCO data, including children to build the TOC. + */ +export type AddonModScormTOCListSco = AddonModScormTOCTreeSco & { + level: number; +}; + +/** + * Params of mod_scorm_get_scorms_by_courses WS. + */ +export type AddonModScormGetScormsByCoursesWSParams = { + courseids?: number[]; // Array of course ids. +}; + +/** + * Data returned by mod_scorm_get_scorms_by_courses WS. + */ +export type AddonModScormGetScormsByCoursesWSResponse = { + scorms: AddonModScormScormWSData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Scorm data returned by mod_scorm_get_scorms_by_courses WS. + */ +export type AddonModScormScormWSData = { + id: number; // SCORM id. + coursemodule: number; // Course module id. + course: number; // Course id. + name: string; // SCORM name. + intro: string; // The SCORM intro. + introformat: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + introfiles?: CoreWSExternalFile[]; + packagesize?: number; // SCORM zip package size. + packageurl?: string; // SCORM zip package URL. + version?: string; // SCORM version (SCORM_12, SCORM_13, SCORM_AICC). + maxgrade?: number; // Max grade. + grademethod?: number; // Grade method. + whatgrade?: number; // What grade. + maxattempt?: number; // Maximum number of attemtps. + forcecompleted?: boolean; // Status current attempt is forced to "completed". + forcenewattempt?: number; // Controls re-entry behaviour. + lastattemptlock?: boolean; // Prevents to launch new attempts once finished. + displayattemptstatus?: number; // How to display attempt status. + displaycoursestructure?: boolean; // Display contents structure. + sha1hash?: string; // Package content or ext path hash. + md5hash?: string; // MD5 Hash of package file. + revision?: number; // Revison number. + launch?: number; // First content to launch. + skipview?: number; // How to skip the content structure page. + hidebrowse?: boolean; // Disable preview mode?. + hidetoc?: number; // How to display the SCORM structure in player. + nav?: number; // Show navigation buttons. + navpositionleft?: number; // Navigation position left. + navpositiontop?: number; // Navigation position top. + auto?: boolean; // Auto continue?. + popup?: number; // Display in current or new window. + width?: number; // Frame width. + height?: number; // Frame height. + timeopen?: number; // Available from. + timeclose?: number; // Available to. + displayactivityname?: boolean; // Display the activity name above the player?. + scormtype?: string; // SCORM type. + reference?: string; // Reference to the package. + protectpackagedownloads?: boolean; // Protect package downloads?. + updatefreq?: number; // Auto-update frequency for remote packages. + options?: string; // Additional options. + completionstatusrequired?: number; // Status passed/completed required?. + completionscorerequired?: number; // Minimum score required. + completionstatusallscos?: number; // Require all scos to return completion status. + autocommit?: boolean; // Save track data automatically?. + timemodified?: number; // Time of last modification. + section?: number; // Course section id. + visible?: boolean; // Visible. + groupmode?: number; // Group mode. + groupingid?: number; // Group id. +}; + +/** + * Scorm data with some calculated data + */ +export type AddonModScormScorm = AddonModScormScormWSData & { + warningMessage?: string; + moduleurl?: string; +}; + +/** + * Params of mod_scorm_insert_scorm_tracks WS. + */ +export type AddonModScormInsertScormTracksWSParams = { + scoid: number; // SCO id. + attempt: number; // Attempt number. + tracks: AddonModScormDataEntry[]; +}; + +/** + * Data returned by mod_scorm_insert_scorm_tracks WS. + */ +export type AddonModScormInsertScormTracksWSResponse = { + trackids: number[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of mod_scorm_launch_sco WS. + */ +export type AddonModScormLaunchScoWSParams = { + scormid: number; // SCORM instance id. + scoid?: number; // SCO id (empty for launching the first SCO). +}; + +/** + * Params of mod_scorm_view_scorm WS. + */ +export type AddonModScormViewScormWSParams = { + scormid: number; // Scorm instance id. +}; + +/** + * Options to pass to get SCORM. + */ +export type AddonModScormGetScormOptions = CoreSitesCommonWSOptions & { + moduleUrl?: string; // Module URL. +}; + +/** + * Common options with an organization ID. + */ +export type AddonModScormOrganizationOptions = CoreCourseCommonModWSOptions & { + organization?: string; // Organization ID. +}; + +/** + * Common options with offline boolean. + */ +export type AddonModScormOfflineOptions = CoreCourseCommonModWSOptions & { + offline?: boolean; // Whether the attempt is offline. +}; + +/** + * Options to pass to getAttemptCount. + */ +export type AddonModScormGetAttemptCountOptions = CoreCourseCommonModWSOptions & { + ignoreMissing?: boolean; // Whether it should ignore attempts that haven't reported a grade/completion. + userId?: number; // User ID. If not defined use site's current user. +}; + +/** + * Options to pass to getScormUserData. + */ +export type AddonModScormGetUserDataOptions = AddonModScormOfflineOptions & { + scos?: AddonModScormWSSco[]; // SCOs returned by getScos. Recommended if offline=true. +}; + +/** + * Options to pass to getScosWithData. + */ +export type AddonModScormGetScosWithDataOptions = AddonModScormOfflineOptions & AddonModScormOrganizationOptions; + +/** + * Result of getAttemptCount. + */ +export type AddonModScormAttemptCountResult = { + online: number[]; // List of online attempts numbers. + offline: number[]; // List of offline attempts numbers. + total: number; // Total of unique attempts. + lastAttempt: AddonModScormAttempt; // Last attempt in the SCORM: the number and whether it's offline. +}; + +/** + * Data for an attempt: number and whether it's offline. + */ +export type AddonModScormAttempt = { + num: number; + offline: boolean; +}; + +/** + * SCORM organization. + */ +export type AddonModScormOrganization = { + identifier: string; + title: string; + sortorder: number; +}; + +/** + * Grade for an attempt. + */ +export type AddonModScormAttemptGrade = { + num: number; + grade: number; +}; + +/** + * Grade for an online attempt. + */ +export type AddonModScormCommonEventData = { + scormId: number; + scoId: number; + attempt: number; +}; + +/** + * SCO icon data. + */ +export type AddonModScormScoIcon = { + icon: string; + description: string; +}; + +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 { + [AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT]: AddonModScormCommonEventData; + [AddonModScormProvider.LAUNCH_PREV_SCO_EVENT]: AddonModScormCommonEventData; + [AddonModScormProvider.UPDATE_TOC_EVENT]: AddonModScormCommonEventData; + [AddonModScormProvider.GO_OFFLINE_EVENT]: AddonModScormCommonEventData; + [AddonModScormProvider.DATA_SENT_EVENT]: AddonModScormCommonEventData; + [AddonModScormSyncProvider.AUTO_SYNCED]: AddonModScormAutoSyncEventData; + } + +} diff --git a/src/core/features/compile/services/compile.ts b/src/core/features/compile/services/compile.ts index 871c943c0..173c46c4f 100644 --- a/src/core/features/compile/services/compile.ts +++ b/src/core/features/compile/services/compile.ts @@ -136,7 +136,7 @@ import { ADDON_MOD_LTI_SERVICES } from '@addons/mod/lti/lti.module'; import { ADDON_MOD_PAGE_SERVICES } from '@addons/mod/page/page.module'; import { ADDON_MOD_QUIZ_SERVICES } from '@addons/mod/quiz/quiz.module'; import { ADDON_MOD_RESOURCE_SERVICES } from '@addons/mod/resource/resource.module'; -// @todo import { ADDON_MOD_SCORM_SERVICES } from '@addons/mod/scorm/scorm.module'; +import { ADDON_MOD_SCORM_SERVICES } from '@addons/mod/scorm/scorm.module'; import { ADDON_MOD_SURVEY_SERVICES } from '@addons/mod/survey/survey.module'; import { ADDON_MOD_URL_SERVICES } from '@addons/mod/url/url.module'; // @todo import { ADDON_MOD_WIKI_SERVICES } from '@addons/mod/wiki/wiki.module'; @@ -301,7 +301,7 @@ export class CoreCompileProvider { ...ADDON_MOD_PAGE_SERVICES, ...ADDON_MOD_QUIZ_SERVICES, ...ADDON_MOD_RESOURCE_SERVICES, - // @todo ...ADDON_MOD_SCORM_SERVICES, + ...ADDON_MOD_SCORM_SERVICES, ...ADDON_MOD_SURVEY_SERVICES, ...ADDON_MOD_URL_SERVICES, // @todo ...ADDON_MOD_WIKI_SERVICES,