diff --git a/package-lock.json b/package-lock.json index c8224530d..018ea41b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2371,11 +2371,11 @@ } }, "@ionic/angular": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.5.2.tgz", - "integrity": "sha512-yXIydPTIMAX4RobidAByaQ/y+yMS6FYgwEs08GTN/GyvQ4XeWVbojwTm62ILLN2qYS/80ok2uupFwlcyKSMztw==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@ionic/angular/-/angular-5.6.3.tgz", + "integrity": "sha512-6MUQV+K0xrrdTHZle+HXIOEk5TIAsFt5r6hbhfzknfZT1IMNtoEgh1xpvoEjOpjvPa84mQo7oe6Hy4kM7TQmIQ==", "requires": { - "@ionic/core": "5.5.2", + "@ionic/core": "5.6.3", "tslib": "^1.9.3" }, "dependencies": { @@ -2737,18 +2737,19 @@ } }, "@ionic/core": { - "version": "5.5.2", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.5.2.tgz", - "integrity": "sha512-rOfPj8D5NRWdOYYulNTdKtMAMURfmutDQ3ciA3L7daCooG3MWt2/0siiL6rcZFMxfG7KDxHctuwVwYoC1mPuhg==", + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-5.6.3.tgz", + "integrity": "sha512-RPugxDcCwB5rgEh6yR2QDTzblT8BRBktsW6y+VBt62yHRzgEAENEfVyvkADz+CkGAsmZuPmC8OQC2jJrx/fJFA==", "requires": { - "ionicons": "^5.1.2", - "tslib": "^1.10.0" + "@stencil/core": "^2.4.0", + "ionicons": "^5.5.1", + "tslib": "^2.1.0" }, "dependencies": { "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" } } }, @@ -3688,6 +3689,11 @@ "@sinonjs/commons": "^1.7.0" } }, + "@stencil/core": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.5.1.tgz", + "integrity": "sha512-SHVX/XaMYEzZJr7ttFSU9a1GmZRMUS9l7f/hbWnKYRn4S9zl1CqGf2iR/ofJ7B+vKaHLjapCAVrzCrkciVIpXA==" + }, "@szmarczak/http-timer": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", @@ -12493,9 +12499,12 @@ "dev": true }, "ionicons": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-5.2.3.tgz", - "integrity": "sha512-87qtgBkieKVFagwYA9Cf91B3PCahQbEOMwMt8bSvlQSgflZ4eE5qI4MGj2ZlIyadeX0dgo+0CzZsy3ow0CsBAg==" + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/ionicons/-/ionicons-5.5.1.tgz", + "integrity": "sha512-1auVisfaXmkmxINer8Q3kJGHP1vSxk86hf7By95eJ+Av9+oBcNuAEBfSe3jaMaGRVxVw8U/2j23MFq7R3c0HPg==", + "requires": { + "@stencil/core": "^2.5.0" + } }, "ios-sim": { "version": "8.0.2", diff --git a/package.json b/package.json index e58e0e2e3..c978d7314 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "@ionic-native/status-bar": "^5.0.0", "@ionic-native/web-intent": "^5.28.0", "@ionic-native/zip": "^5.28.0", - "@ionic/angular": "^5.5.2", + "@ionic/angular": "^5.6.3", "@ngx-translate/core": "^13.0.0", "@ngx-translate/http-loader": "^6.0.0", "@types/cordova": "0.0.34", 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/classes/data-model-12.ts b/src/addons/mod/scorm/classes/data-model-12.ts new file mode 100644 index 000000000..06d29525a --- /dev/null +++ b/src/addons/mod/scorm/classes/data-model-12.ts @@ -0,0 +1,1075 @@ +// (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 { CoreUtils } from '@services/utils/utils'; +import { CoreEvents } from '@singletons/events'; +import { + AddonModScorm, + AddonModScormCommonEventData, + AddonModScormDataEntry, + AddonModScormDataValue, + AddonModScormProvider, + AddonModScormScorm, + AddonModScormUserDataMap, +} from '../services/scorm'; + +// Standard Data Type Definition. +const CMI_STRING_256 = '^[\\u0000-\\uFFFF]{0,255}$'; +const CMI_STRING_4096 = '^[\\u0000-\\uFFFF]{0,4096}$'; +const CMI_TIME = '^([0-2]{1}[0-9]{1}):([0-5]{1}[0-9]{1}):([0-5]{1}[0-9]{1})(.[0-9]{1,2})?$'; +const CMI_TIMESPAN = '^([0-9]{2,4}):([0-9]{2}):([0-9]{2})(.[0-9]{1,2})?$'; +const CMI_INTEGER = '^\\d+$'; // eslint-disable-line @typescript-eslint/no-unused-vars +const CMI_SINTEGER = '^-?([0-9]+)$'; +const CMI_DECIMAL = '^-?([0-9]{0,3})(.[0-9]*)?$'; +const CMI_IDENTIFIER = '^[\\u0021-\\u007E]{0,255}$'; +const CMI_FEEDBACK = CMI_STRING_256; // This must be redefined. +const CMI_INDEX = '[._](\\d+).'; + +// Vocabulary Data Type Definition. +const CMI_STATUS = '^passed$|^completed$|^failed$|^incomplete$|^browsed$'; +const CMI_STATUS_2 = '^passed$|^completed$|^failed$|^incomplete$|^browsed$|^not attempted$'; +const CMI_EXIT = '^time-out$|^suspend$|^logout$|^$'; +const CMI_TYPE = '^true-false$|^choice$|^fill-in$|^matching$|^performance$|^sequencing$|^likert$|^numeric$'; +const CMI_RESULT = '^correct$|^wrong$|^unanticipated$|^neutral$|^([0-9]{0,3})?(.[0-9]*)?$'; +const NAV_EVENT = '^previous$|^continue$'; + +// Children lists. +const CMI_CHILDREN = 'core,suspend_data,launch_data,comments,objectives,student_data,student_preference,interactions'; +const CORE_CHILDREN = 'student_id,student_name,lesson_location,credit,lesson_status,entry,score,total_time,lesson_mode,' + + 'exit,session_time'; +const SCORE_CHILDREN = 'raw,min,max'; +const COMMENTS_CHILDREN = 'content,location,time'; +const OBJECTIVES_CHILDREN = 'id,score,status'; +const CORRECT_RESPONSES_CHILDREN = 'pattern'; // eslint-disable-line @typescript-eslint/no-unused-vars +const STUDENT_DATA_CHILDREN = 'mastery_score,max_time_allowed,time_limit_action'; +const STUDENT_PREFERENCE_CHILDREN = 'audio,language,speed,text'; +const INTERACTIONS_CHILDREN = 'id,objectives,time,type,correct_responses,weighting,student_response,result,latency'; + +// Data ranges. +const SCORE_RANGE = '0#100'; +const AUDIO_RANGE = '-1#100'; +const SPEED_RANGE = '-100#100'; +const WEIGHTING_RANGE = '-100#100'; +const TEXT_RANGE = '-1#1'; + +// Error messages. +const ERROR_STRINGS = { + 0: 'No error', + 101: 'General exception', + 201: 'Invalid argument error', + 202: 'Element cannot have children', + 203: 'Element not an array - cannot have count', + 301: 'Not initialized', + 401: 'Not implemented error', + 402: 'Invalid set value, element is a keyword', + 403: 'Element is read only', + 404: 'Element is write only', + 405: 'Incorrect data type', +}; + +/** + * SCORM data model implementation for version 1.2. + */ +/* eslint-disable @typescript-eslint/naming-convention */ +export class AddonModScormDataModel12 { + + protected currentUserData: AddonModScormUserDataMap = {}; // Current user data. + protected def: Record> = {}; // Object containing the default values. + protected defExtra: Record> = {}; // Objectives and interactions (all .n. elems). + protected dataModel: Record> = {}; // The SCORM 1.2 data model. + + protected initialized = false; // Whether LMSInitialize has been called. + protected errorCode = '0'; // Last error. + protected timeout?: number; // Timeout to commit changes. + + protected siteId: string; + protected scorm: AddonModScormScorm; + protected scoId: number; + protected attempt: number; + protected mode: string; + protected offline: boolean; + + /** + * Constructor. + * + * @param siteId Site ID. + * @param scorm SCORM. + * @param scoId Current SCO ID. + * @param attempt Attempt number. + * @param userData The user default data. + * @param mode Mode being played. By default, MODENORMAL. + * @param offline Whether the attempt is offline. + */ + constructor( + siteId: string, + scorm: AddonModScormScorm, + scoId: number, + attempt: number, + userData: AddonModScormUserDataMap, + mode?: string, + offline?: boolean, + ) { + this.siteId = siteId; + this.scorm = scorm; + this.scoId = scoId; + this.attempt = attempt; + this.mode = mode || AddonModScormProvider.MODENORMAL; + this.offline = !!offline; + + this.init(userData); + } + + /** + * Utility function for adding two times in format hh:mm:ss. + * + * @param first First time. + * @param second Second time. + * @return Total time. + */ + protected addTime(first: string, second: string): string { + const sFirst = first.split(':'); + const sSecond = second.split(':'); + const cFirst = sFirst[2].split('.'); + const cSecond = sSecond[2].split('.'); + let change = 0; + + let firstCents = 0; // Cents. + if (cFirst.length > 1) { + firstCents = parseInt(cFirst[1], 10); + } + + let secondCents = 0; + if (cSecond.length > 1) { + secondCents = parseInt(cSecond[1], 10); + } + + let cents: string | number = firstCents + secondCents; + change = Math.floor(cents / 100); + cents = cents - (change * 100); + if (Math.floor(cents) < 10) { + cents = '0' + cents.toString(); + } + + let secs: string | number = parseInt(cFirst[0], 10) + parseInt(cSecond[0], 10) + change; // Seconds. + change = Math.floor(secs / 60); + secs = secs - (change * 60); + if (Math.floor(secs) < 10) { + secs = '0' + secs.toString(); + } + + let mins: string | number = parseInt(sFirst[1], 10) + parseInt(sSecond[1], 10) + change; // Minutes. + change = Math.floor(mins / 60); + mins = mins - (change * 60); + if (mins < 10) { + mins = '0' + mins.toString(); + } + + let hours: string | number = parseInt(sFirst[0], 10) + parseInt(sSecond[0], 10) + change; // Hours. + if (hours < 10) { + hours = '0' + hours.toString(); + } + + if (cents != '0') { + return hours + ':' + mins + ':' + secs + '.' + cents; + } else { + return hours + ':' + mins + ':' + secs; + } + } + + /** + * Collect all the user tracking data that must be persisted in the system, this is usually called by LMSCommit(). + * + * @return Collected data. + */ + protected collectData(): AddonModScormDataEntry[] { + if (!this.currentUserData[this.scoId]) { + return []; + } + + const data: AddonModScormDataEntry[] = []; + + for (const element in this.currentUserData[this.scoId].userdata) { + // Ommit for example the nav. elements and the session time element. + if (element.substr(0, 3) != 'cmi' || element == 'cmi.core.session_time') { + continue; + } + + // Get the generic name for this element (e.g. convert 'cmi.interactions.1.id' to 'cmi.interactions.n.id') + const expression = new RegExp(CMI_INDEX, 'g'); + const elementModel = element.replace(expression, '.n.'); + + // Check if this specific element is not defined in the datamodel, but the generic element name is. + if (this.dataModel[this.scoId][element] === undefined && this.dataModel[this.scoId][elementModel] !== undefined) { + // Add this element to the data model (by cloning the generic element) so we can track changes to it. + this.dataModel[this.scoId][element] = CoreUtils.clone(this.dataModel[this.scoId][elementModel]); + } + + // Check if the current element exists in the datamodel and it's not a read only element. + if (this.dataModel[this.scoId][element] === undefined || this.dataModel[this.scoId][element].mod == 'r') { + continue; + } + + const el: AddonModScormDataEntry = { + // Moodle stores the organizations and interactions using _n. instead .n. + element: element.replace(expression, '_$1.'), + value: this.getEl(element), + }; + + // Check if the element has a default value. + if (this.dataModel[this.scoId][element].defaultvalue !== undefined) { + + // Check if the default value is different from the current value. + if (this.dataModel[this.scoId][element].defaultvalue !== el.value) { + data.push(el); + + // Update the element default to reflect the current committed value. + this.dataModel[this.scoId][element].defaultvalue = el.value; + } + } else { + data.push(el); + + // No default value for the element, so set it now. + this.dataModel[this.scoId][element].defaultvalue = el.value; + } + } + + return data; + } + + /** + * Get the value of the given element from the non-persistent (current) user data. + * + * @param el The element + * @return The element value + */ + protected getEl(el: string): string | number { + if (this.currentUserData[this.scoId] && this.currentUserData[this.scoId].userdata[el] !== undefined) { + return this.currentUserData[this.scoId].userdata[el]; + } + + return ''; + } + + /** + * Initialize the model. + * + * @param userData The user default data. + */ + protected init(userData: AddonModScormUserDataMap): void { + // Prepare the definition array containing the default values. + for (const scoId in userData) { + const sco = userData[scoId]; + this.def[scoId] = sco.defaultdata; + this.defExtra[scoId] = sco.userdata; + } + + // Set up data model for each SCO. + for (const scoId in this.def) { + this.dataModel[scoId] = { + 'cmi._children': { defaultvalue: CMI_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi._version': { defaultvalue: '3.4', mod: 'r', writeerror: '402' }, + 'cmi.core._children': { defaultvalue: CORE_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi.core.student_id': { defaultvalue: this.def[scoId]['cmi.core.student_id'], mod: 'r', writeerror: '403' }, + 'cmi.core.student_name': { defaultvalue: this.def[scoId]['cmi.core.student_name'], mod: 'r', writeerror: '403' }, + 'cmi.core.lesson_location': { + defaultvalue: this.def[scoId]['cmi.core.lesson_location'], + format: CMI_STRING_256, + mod: 'rw', + writeerror: '405', + }, + 'cmi.core.credit': { defaultvalue: this.def[scoId]['cmi.core.credit'], mod: 'r', writeerror: '403' }, + 'cmi.core.lesson_status': { + defaultvalue: this.def[scoId]['cmi.core.lesson_status'], + format: CMI_STATUS, + mod: 'rw', + writeerror: '405', + }, + 'cmi.core.entry': { defaultvalue: this.def[scoId]['cmi.core.entry'], mod: 'r', writeerror: '403' }, + 'cmi.core.score._children': { defaultvalue: SCORE_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi.core.score.raw': { + defaultvalue: this.def[scoId]['cmi.core.score.raw'], + format: CMI_DECIMAL, + range: SCORE_RANGE, + mod: 'rw', + writeerror: '405', + }, + 'cmi.core.score.max': { + defaultvalue: this.def[scoId]['cmi.core.score.max'], + format: CMI_DECIMAL, + range: SCORE_RANGE, + mod: 'rw', + writeerror: '405', + }, + 'cmi.core.score.min': { + defaultvalue: this.def[scoId]['cmi.core.score.min'], + format: CMI_DECIMAL, + range: SCORE_RANGE, + mod: 'rw', + writeerror: '405', + }, + 'cmi.core.total_time': { defaultvalue: this.def[scoId]['cmi.core.total_time'], mod: 'r', writeerror: '403' }, + 'cmi.core.lesson_mode': { defaultvalue: this.def[scoId]['cmi.core.lesson_mode'], mod: 'r', writeerror: '403' }, + 'cmi.core.exit': { + defaultvalue: this.def[scoId]['cmi.core.exit'], + format: CMI_EXIT, + mod: 'w', + readerror: '404', + writeerror: '405', + }, + 'cmi.core.session_time': { + format: CMI_TIMESPAN, + mod: 'w', + defaultvalue: '00:00:00', + readerror: '404', + writeerror: '405', + }, + 'cmi.suspend_data': { + defaultvalue: this.def[scoId]['cmi.suspend_data'], + format: CMI_STRING_4096, + mod: 'rw', + writeerror: '405', + }, + 'cmi.launch_data': { defaultvalue: this.def[scoId]['cmi.launch_data'], mod: 'r', writeerror: '403' }, + 'cmi.comments': { + defaultvalue: this.def[scoId]['cmi.comments'], + format: CMI_STRING_4096, + mod: 'rw', + writeerror: '405', + }, + // Deprecated evaluation attributes. + 'cmi.evaluation.comments._count': { defaultvalue: '0', mod: 'r', writeerror: '402' }, + 'cmi.evaluation.comments._children': { defaultvalue: COMMENTS_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi.evaluation.comments.n.content': { + defaultvalue: '', + pattern: CMI_INDEX, + format: CMI_STRING_256, + mod: 'rw', + writeerror: '405', + }, + 'cmi.evaluation.comments.n.location': { + defaultvalue: '', + pattern: CMI_INDEX, + format: CMI_STRING_256, + mod: 'rw', + writeerror: '405', + }, + 'cmi.evaluation.comments.n.time': { + defaultvalue: '', + pattern: CMI_INDEX, + format: CMI_TIME, + mod: 'rw', + writeerror: '405', + }, + 'cmi.comments_from_lms': { mod: 'r', writeerror: '403' }, + 'cmi.objectives._children': { defaultvalue: OBJECTIVES_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi.objectives._count': { mod: 'r', defaultvalue: '0', writeerror: '402' }, + 'cmi.objectives.n.id': { pattern: CMI_INDEX, format: CMI_IDENTIFIER, mod: 'rw', writeerror: '405' }, + 'cmi.objectives.n.score._children': { pattern: CMI_INDEX, mod: 'r', writeerror: '402' }, + 'cmi.objectives.n.score.raw': { + defaultvalue: '', + pattern: CMI_INDEX, + format: CMI_DECIMAL, + range: SCORE_RANGE, + mod: 'rw', + writeerror: '405', + }, + 'cmi.objectives.n.score.min': { + defaultvalue: '', + pattern: CMI_INDEX, + format: CMI_DECIMAL, + range: SCORE_RANGE, + mod: 'rw', + writeerror: '405', + }, + 'cmi.objectives.n.score.max': { + defaultvalue: '', + pattern: CMI_INDEX, + format: CMI_DECIMAL, + range: SCORE_RANGE, + mod: 'rw', + writeerror: '405', + }, + 'cmi.objectives.n.status': { pattern: CMI_INDEX, format: CMI_STATUS_2, mod: 'rw', writeerror: '405' }, + 'cmi.student_data._children': { defaultvalue: STUDENT_DATA_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi.student_data.mastery_score': { + defaultvalue: this.def[scoId]['cmi.student_data.mastery_score'], + mod: 'r', + writeerror: '403', + }, + 'cmi.student_data.max_time_allowed': { + defaultvalue: this.def[scoId]['cmi.student_data.max_time_allowed'], + mod: 'r', + writeerror: '403', + }, + 'cmi.student_data.time_limit_action': { + defaultvalue: this.def[scoId]['cmi.student_data.time_limit_action'], + mod: 'r', + writeerror: '403', + }, + 'cmi.student_preference._children': { + defaultvalue: STUDENT_PREFERENCE_CHILDREN, + mod: 'r', + writeerror: '402', + }, + 'cmi.student_preference.audio': { + defaultvalue: this.def[scoId]['cmi.student_preference.audio'], + format: CMI_SINTEGER, + range: AUDIO_RANGE, + mod: 'rw', + writeerror: '405', + }, + 'cmi.student_preference.language': { + defaultvalue: this.def[scoId]['cmi.student_preference.language'], + format: CMI_STRING_256, + mod: 'rw', + writeerror: '405', + }, + 'cmi.student_preference.speed': { + defaultvalue: this.def[scoId]['cmi.student_preference.speed'], + format: CMI_SINTEGER, + range: SPEED_RANGE, + mod: 'rw', + writeerror: '405', + }, + 'cmi.student_preference.text': { + defaultvalue: this.def[scoId]['cmi.student_preference.text'], + format: CMI_SINTEGER, + range: TEXT_RANGE, + mod: 'rw', + writeerror: '405', + }, + 'cmi.interactions._children': { defaultvalue: INTERACTIONS_CHILDREN, mod: 'r', writeerror: '402' }, + 'cmi.interactions._count': { mod: 'r', defaultvalue: '0', writeerror: '402' }, + 'cmi.interactions.n.id': { + pattern: CMI_INDEX, + format: CMI_IDENTIFIER, + mod: 'w', + readerror: '404', + writeerror: '405', + }, + 'cmi.interactions.n.objectives._count': { pattern: CMI_INDEX, mod: 'r', defaultvalue: '0', writeerror: '402' }, + 'cmi.interactions.n.objectives.n.id': { + pattern: CMI_INDEX, + format: CMI_IDENTIFIER, + mod: 'w', + readerror: '404', + writeerror: '405', + }, + 'cmi.interactions.n.time': { pattern: CMI_INDEX, format: CMI_TIME, mod: 'w', readerror: '404', writeerror: '405' }, + 'cmi.interactions.n.type': { pattern: CMI_INDEX, format: CMI_TYPE, mod: 'w', readerror: '404', writeerror: '405' }, + 'cmi.interactions.n.correct_responses._count': { + pattern: CMI_INDEX, + mod: 'r', + defaultvalue: '0', + writeerror: '402', + }, + 'cmi.interactions.n.correct_responses.n.pattern': { + pattern: CMI_INDEX, + format: CMI_FEEDBACK, + mod: 'w', + readerror: '404', + writeerror: '405', + }, + 'cmi.interactions.n.weighting': { + pattern: CMI_INDEX, + format: CMI_DECIMAL, + range: WEIGHTING_RANGE, + mod: 'w', + readerror: '404', + writeerror: '405', + }, + 'cmi.interactions.n.student_response': { + pattern: CMI_INDEX, + format: CMI_FEEDBACK, + mod: 'w', + readerror: '404', + writeerror: '405', + }, + 'cmi.interactions.n.result': { + pattern: CMI_INDEX, + format: CMI_RESULT, + mod: 'w', + readerror: '404', + writeerror: '405', + }, + 'cmi.interactions.n.latency': { + pattern: CMI_INDEX, + format: CMI_TIMESPAN, + mod: 'w', + readerror: '404', + writeerror: '405', + }, + 'nav.event': { defaultvalue: '', format: NAV_EVENT, mod: 'w', readerror: '404', writeerror: '405' }, + }; + + this.currentUserData[scoId] = { + scoid: Number(scoId), + userdata: {}, + defaultdata: {}, + }; + + // Load default values. + for (const element in this.dataModel[scoId]) { + if (element.match(/\.n\./) === null) { + if (this.dataModel[scoId][element].defaultvalue !== undefined) { + this.currentUserData[scoId].userdata[element] = this.dataModel[scoId][element].defaultvalue!; + } + } + } + + // Load initial user data for current SCO. + for (const element in this.def[scoId]) { + if (element.match(/\.n\./) === null) { + if (this.dataModel[scoId][element].defaultvalue !== undefined) { + this.currentUserData[scoId].userdata[element] = this.dataModel[scoId][element].defaultvalue!; + } else if (this.defExtra[scoId][element] !== undefined) { + // Check in user data values. + this.currentUserData[scoId].userdata[element] = this.defExtra[scoId][element]; + } else { + this.currentUserData[scoId].userdata[element] = ''; + } + } + } + + // Load interactions and objectives, and init the counters. + const expression = new RegExp(CMI_INDEX, 'g'); + + for (const element in this.defExtra[scoId]) { + let counterElement = ''; + let currentCounterIndex = '0'; + + // This check for an indexed element. cmi.objectives.1.id or cmi.objectives_1.id. + if (element.match(expression)) { + // Normalize to the expected value according the standard. + // Moodle stores this values using _n. instead .n. + const elementDotFormat = element.replace(expression, '.$1.'); + this.currentUserData[scoId].userdata[elementDotFormat] = this.defExtra[scoId][element]; + + // Get the correct counter and current index. + if (elementDotFormat.indexOf('cmi.evaluation.comments') === 0) { + counterElement = 'cmi.evaluation.comments._count'; + currentCounterIndex = elementDotFormat.match(/.(\d+)./)?.[1] || '0'; + } else if (elementDotFormat.indexOf('cmi.objectives') === 0) { + counterElement = 'cmi.objectives._count'; + currentCounterIndex = elementDotFormat.match(/.(\d+)./)?.[1] || '0'; + } else if (elementDotFormat.indexOf('cmi.interactions') === 0) { + if (elementDotFormat.indexOf('.objectives.') > 0) { + const currentN = elementDotFormat.match(/cmi.interactions.(\d+)./)?.[1]; + currentCounterIndex = elementDotFormat.match(/objectives.(\d+)./)?.[1] || '0'; + counterElement = 'cmi.interactions.' + currentN + '.objectives._count'; + } else if (elementDotFormat.indexOf('.correct_responses.') > 0) { + const currentN = elementDotFormat.match(/cmi.interactions.(\d+)./)?.[1]; + currentCounterIndex = elementDotFormat.match(/correct_responses.(\d+)./)?.[1] || '0'; + counterElement = 'cmi.interactions.' + currentN + '.correct_responses._count'; + } else { + counterElement = 'cmi.interactions._count'; + currentCounterIndex = elementDotFormat.match(/.(\d+)./)?.[1] || '0'; + } + } + + if (counterElement) { + const counterData = this.currentUserData[scoId].userdata[counterElement]; + if (counterData === undefined) { + this.currentUserData[scoId].userdata[counterElement] = 0; + } + // Check if we need to sum. + if (Number(currentCounterIndex) == Number(counterData)) { + this.currentUserData[scoId].userdata[counterElement] = Number(counterData) + 1; + } + if (Number(currentCounterIndex) > Number(counterData)) { + this.currentUserData[scoId].userdata[counterElement] = Number(currentCounterIndex) - 1; + } + } + + } + } + + // Set default status. + if (this.currentUserData[scoId].userdata['cmi.core.lesson_status'] === '') { + this.currentUserData[scoId].userdata['cmi.core.lesson_status'] = 'not attempted'; + } + + // Define mode and credit. + this.currentUserData[scoId].userdata['cmi.core.credit'] = this.mode == AddonModScormProvider.MODENORMAL ? + 'credit' : + 'no-credit'; + this.currentUserData[scoId].userdata['cmi.core.lesson_mode'] = this.mode; + } + } + + /** + * Commit the changes. + * + * @param param Param. + * @return "true" if success, "false" otherwise. + */ + LMSCommit(param: string): string { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = undefined; + } + + this.errorCode = '0'; + if (param == '') { + if (this.initialized) { + const result = this.storeData(false); + + // Trigger TOC update. + this.triggerEvent(AddonModScormProvider.UPDATE_TOC_EVENT); + + this.errorCode = result ? '0' : '101'; + + // Conver to string representing a boolean. + return result ? 'true' : 'false'; + } else { + this.errorCode = '301'; + } + } else { + this.errorCode = '201'; + } + + return 'false'; + } + + /** + * Finish the data model. + * + * @param param Param. + * @return "true" if success, "false" otherwise. + */ + LMSFinish(param: string): string { + this.errorCode = '0'; + + if (param == '') { + if (this.initialized) { + this.initialized = false; + + const result = this.storeData(true); + if (this.getEl('nav.event') != '') { + if (this.getEl('nav.event') == 'continue') { + this.triggerEvent(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT); + } else { + this.triggerEvent(AddonModScormProvider.LAUNCH_PREV_SCO_EVENT); + } + } else { + if (this.scorm.auto) { + this.triggerEvent(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT); + } + } + + this.errorCode = result ? '0' : '101'; + + // Trigger TOC update. + this.triggerEvent(AddonModScormProvider.UPDATE_TOC_EVENT); + + // Conver to string representing a boolean. + return result ? 'true' : 'false'; + } else { + this.errorCode = '301'; + } + } else { + this.errorCode = '201'; + } + + return 'false'; + } + + /** + * Get diagnostic. + * + * @param param Param. + * @return Result. + */ + LMSGetDiagnostic(param: string): string { + if (param == '') { + param = this.errorCode; + } + + return param; + } + + /** + * Get the error message for a certain code. + * + * @param param Error code. + * @return Error message. + */ + LMSGetErrorString(param: string): string { + if (param != '') { + return ERROR_STRINGS[param]; + } else { + return ''; + } + } + + /** + * Get the last error code. + * + * @return Last error code. + */ + LMSGetLastError(): string { + return this.errorCode; + } + + /** + * Get the value of a certain element. + * + * @param element Name of the element to get. + * @return Value. + */ + LMSGetValue(element: string): AddonModScormDataValue { + this.errorCode = '0'; + + if (this.initialized) { + if (element != '') { + const expression = new RegExp(CMI_INDEX, 'g'); + const elementModel = String(element).replace(expression, '.n.'); + + if (this.dataModel[this.scoId][elementModel] !== undefined) { + if (this.dataModel[this.scoId][elementModel].mod != 'w') { + this.errorCode = '0'; + + return this.getEl(element); + } else { + this.errorCode = this.dataModel[this.scoId][elementModel].readerror || '0'; + } + } else { + const childrenStr = '._children'; + const countStr = '._count'; + + if (elementModel.substr(elementModel.length - childrenStr.length, elementModel.length) == childrenStr) { + const parentModel = elementModel.substr(0, elementModel.length - childrenStr.length); + + if (this.dataModel[this.scoId][parentModel] !== undefined) { + this.errorCode = '202'; + } else { + this.errorCode = '201'; + } + } else if (elementModel.substr(elementModel.length - countStr.length, elementModel.length) == countStr) { + const parentModel = elementModel.substr(0, elementModel.length - countStr.length); + + if (this.dataModel[this.scoId][parentModel] !== undefined) { + this.errorCode = '203'; + } else { + this.errorCode = '201'; + } + } else { + this.errorCode = '201'; + } + } + } else { + this.errorCode = '201'; + } + } else { + this.errorCode = '301'; + } + + return ''; + } + + /** + * Initialize the data model. + * + * @param param Param. + * @return "true" if initialized, "false" otherwise. + */ + LMSInitialize(param: string): string { + this.errorCode = '0'; + + if (param == '') { + if (!this.initialized) { + this.initialized = true; + this.errorCode = '0'; + + return 'true'; + } else { + this.errorCode = '101'; + } + } else { + this.errorCode = '201'; + } + + return 'false'; + } + + /** + * Set the value of a certain element. + * + * @param element Name of the element to set. + * @param value Value to set. + * @return "true" if success, "false" otherwise. + */ + LMSSetValue(element: string, value: AddonModScormDataValue): string { + this.errorCode = '0'; + + if (this.initialized) { + if (element != '') { + let expression = new RegExp(CMI_INDEX, 'g'); + const elementModel = String(element).replace(expression, '.n.'); + + if (this.dataModel[this.scoId][elementModel] !== undefined) { + if (this.dataModel[this.scoId][elementModel].mod != 'r') { + expression = new RegExp(this.dataModel[this.scoId][elementModel].format!); + value = value + ''; + + const matches = value.match(expression); + + if (matches != null) { + // Create dynamic data model element. + if (element != elementModel) { + + // Init default counters and values. + if (element.indexOf('cmi.objectives') === 0) { + const currentN = element.match(/cmi.objectives.(\d+)./)?.[1]; + const counterElement = 'cmi.objectives.' + currentN + '.score'; + + if (this.currentUserData[this.scoId].userdata[counterElement + '._children'] === undefined) { + this.setEl( + this.currentUserData[this.scoId].userdata[counterElement + '._children'], + SCORE_CHILDREN, + ); + this.setEl( this.currentUserData[this.scoId].userdata[counterElement + '.raw'], ''); + this.setEl( this.currentUserData[this.scoId].userdata[counterElement + '.min'], ''); + this.setEl( this.currentUserData[this.scoId].userdata[counterElement + '.max'], ''); + } + + } else if (element.indexOf('cmi.interactions') === 0) { + const currentN = element.match(/cmi.interactions.(\d+)./)?.[1]; + let counterElement = 'cmi.interactions.' + currentN + '.objectives._count'; + + if (this.currentUserData[this.scoId].userdata[counterElement] === undefined) { + this.setEl(counterElement, 0); + } + + counterElement = 'cmi.interactions.' + currentN + '.correct_responses._count'; + if (this.currentUserData[this.scoId].userdata[counterElement] === undefined) { + this.setEl(counterElement, 0); + } + } + + const elementIndexes = element.split('.'); + let subElement = 'cmi'; + + for (let i = 1; i < elementIndexes.length - 1; i++) { + const elementIndex = elementIndexes[i]; + + if (elementIndexes[i + 1].match(/^\d+$/)) { + const counterElement = subElement + '.' + elementIndex + '._count'; + + if (this.currentUserData[this.scoId].userdata[counterElement] === undefined) { + this.setEl(counterElement, 0); + } + + if (elementIndexes[i + 1] == this.getEl(counterElement)) { + const count = this.getEl(counterElement); + this.setEl(counterElement, Number(count) + 1); + } + + if (elementIndexes[i + 1] > this.getEl(counterElement)) { + this.errorCode = '201'; + } + + subElement = subElement.concat('.' + elementIndex + '.' + elementIndexes[i + 1]); + i++; + } else { + subElement = subElement.concat('.' + elementIndex); + } + } + + element = subElement.concat('.' + elementIndexes[elementIndexes.length - 1]); + } + + // Store data. + if (this.errorCode == '0') { + if (this.scorm.autocommit && !this.timeout) { + this.timeout = window.setTimeout(this.LMSCommit.bind(this), 60000, ['']); + } + + const range = this.dataModel[this.scoId][elementModel].range; + if (range !== undefined) { + const ranges = range.split('#'); + value = Number(value); + + if (value >= Number(ranges[0]) && value <= Number(ranges[1])) { + this.setEl(element, Number(value)); + this.errorCode = '0'; + + return 'true'; + } else { + this.errorCode = this.dataModel[this.scoId][elementModel].writeerror; + } + } else { + if (element == 'cmi.comments') { + this.setEl('cmi.comments', this.getEl('cmi.comments') + value); + } else { + this.setEl(element, value); + } + this.errorCode = '0'; + + return 'true'; + } + } + } else { + this.errorCode = this.dataModel[this.scoId][elementModel].writeerror; + } + } else { + this.errorCode = this.dataModel[this.scoId][elementModel].writeerror; + } + } else { + this.errorCode = '201'; + } + } else { + this.errorCode = '201'; + } + } else { + this.errorCode = '301'; + } + + return 'false'; + } + + /** + * Set a SCO ID. + * The scoId is like a pointer to be able to retrieve the SCO default values and set the new ones in the overall SCORM + * data structure. + * + * @param scoId The new SCO id. + */ + loadSco(scoId: number): void { + this.scoId = scoId; + } + + /** + * Set the value of the given element in the non-persistent (current) user data. + * + * @param el The element. + * @param value The value. + */ + protected setEl(el: string, value: AddonModScormDataValue): void { + this.currentUserData[this.scoId] = this.currentUserData[this.scoId] || { + scoid: this.scoId, + userdata: {}, + defaultdata: {}, + }; + this.currentUserData[this.scoId].userdata[el] = value; + } + + /** + * Set offline mode to true or false. + * + * @param offline True if offline, false otherwise. + */ + setOffline(offline: boolean): void { + this.offline = offline; + } + + /** + * Persist the current user data (this is usually called by LMSCommit). + * + * @param storeTotalTime If true, we need to calculate the total time too. + * @return True if success, false otherwise. + */ + protected storeData(storeTotalTime?: boolean): boolean { + let tracks: AddonModScormDataEntry[]; + + if (storeTotalTime) { + if (this.getEl('cmi.core.lesson_status') == 'not attempted') { + this.setEl('cmi.core.lesson_status', 'completed'); + } + + if (this.getEl('cmi.core.lesson_mode') == AddonModScormProvider.MODENORMAL) { + if (this.getEl('cmi.core.credit') == 'credit') { + if (this.getEl('cmi.student_data.mastery_score') !== '' && this.getEl('cmi.core.score.raw') !== '') { + if (parseFloat( this.getEl('cmi.core.score.raw')) >= + parseFloat( this.getEl('cmi.student_data.mastery_score'))) { + this.setEl('cmi.core.lesson_status', 'passed'); + } else { + this.setEl('cmi.core.lesson_status', 'failed'); + } + } + } + } + + if (this.getEl('cmi.core.lesson_mode') == AddonModScormProvider.MODEBROWSE) { + if (this.dataModel[this.scoId]['cmi.core.lesson_status'].defaultvalue == '' && + this.getEl('cmi.core.lesson_status') == 'not attempted') { + this.setEl('cmi.core.lesson_status', 'browsed'); + } + } + + tracks = this.collectData(); + tracks.push(this.totalTime()); + } else { + tracks = this.collectData(); + } + + const ok = AddonModScorm.saveTracksSync(this.scoId, this.attempt, tracks, this.scorm, this.offline, this.currentUserData); + + if (this.offline || ok) { + return ok; + } + + // Failure storing data in online. Go offline. + this.offline = true; + this.triggerEvent(AddonModScormProvider.GO_OFFLINE_EVENT); + + return AddonModScorm.saveTracksSync(this.scoId, this.attempt, tracks, this.scorm, this.offline, this.currentUserData); + + } + + /** + * Utility function for calculating the total time spent in the SCO. + * + * @return Total time element. + */ + protected totalTime(): AddonModScormDataEntry { + const totalTime = this.addTime( this.getEl('cmi.core.total_time'), this.getEl('cmi.core.session_time')); + + return { element: 'cmi.core.total_time', value: totalTime }; + } + + /** + * Convenience function to trigger events. + * + * @param name Name of the event to trigger. + */ + protected triggerEvent(name: string): void { + CoreEvents.trigger(name, { + scormId: this.scorm.id, + scoId: this.scoId, + attempt: this.attempt, + }, this.siteId); + } + +} + +type DataModelEntry = ReadOnlyDataModelEntry | WritableDataModelEntry; + +type ReadOnlyDataModelEntry = DataModelCommonProperties & { + mod: 'r'; + format?: string; +}; + +type WritableDataModelEntry = DataModelCommonProperties & { + mod: 'w' | 'rw'; + format: string; +}; + +type DataModelCommonProperties = { + writeerror: string; + readerror?: string; + defaultvalue?: string | number; + range?: string; + pattern?: string; +}; diff --git a/src/addons/mod/scorm/components/components.module.ts b/src/addons/mod/scorm/components/components.module.ts new file mode 100644 index 000000000..407c08e0b --- /dev/null +++ b/src/addons/mod/scorm/components/components.module.ts @@ -0,0 +1,37 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { AddonModScormIndexComponent } from './index/index'; +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; +import { AddonModScormTocComponent } from './toc/toc'; + +@NgModule({ + declarations: [ + AddonModScormIndexComponent, + AddonModScormTocComponent, + ], + imports: [ + CoreSharedModule, + CoreCourseComponentsModule, + ], + providers: [ + ], + exports: [ + AddonModScormIndexComponent, + AddonModScormTocComponent, + ], +}) +export class AddonModScormComponentsModule {} diff --git a/src/addons/mod/scorm/components/index/addon-mod-scorm-index.html b/src/addons/mod/scorm/components/index/addon-mod-scorm-index.html new file mode 100644 index 000000000..61d17c466 --- /dev/null +++ b/src/addons/mod/scorm/components/index/addon-mod-scorm-index.html @@ -0,0 +1,246 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ scorm.warningMessage }} + + + + + + + + {{ 'addon.mod_scorm.attempts' | translate }} + + + + + +

{{ 'addon.mod_scorm.noattemptsallowed' | translate }}

+
+

+ {{ 'core.unlimited' | translate }} + {{ scorm.maxattempt }} +

+
+ + +

{{ 'addon.mod_scorm.noattemptsmade' | translate }}

+
+

{{ numAttempts }}

+
+ + +

{{ 'addon.mod_scorm.gradeforattempt' | translate }} {{attempt.num}}

+
+

+ {{ attempt.gradeFormatted }} + {{ 'addon.mod_scorm.cannotcalculategrade' | translate }} +

+
+
+ + +

{{ 'addon.mod_scorm.gradeforattempt' | translate }} {{attempt.num}}

+

+ {{ 'addon.mod_scorm.offlineattemptnote' | translate }} +

+

+ {{ 'addon.mod_scorm.offlineattemptovermax' | translate }} +

+
+

+ {{ attempt.gradeFormatted }} + {{ 'addon.mod_scorm.cannotcalculategrade' | translate }} +

+
+ + +

{{ 'addon.mod_scorm.grademethod' | translate }}

+
+

{{ gradeMethodReadable }}

+
+ + +

{{ 'addon.mod_scorm.gradereported' | translate }}

+
+

+ {{ gradeFormatted }} + {{ 'addon.mod_scorm.cannotcalculategrade' | translate }} +

+
+ + +

{{ 'core.lastsync' | translate }}

+

{{ syncTime }}

+
+
+
+
+ + + + + + {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} + + + + + + + {{ 'addon.mod_scorm.contents' | translate }} + + + + {{ 'addon.mod_scorm.organizations' | translate }} + + + {{ org.title }} + + + + + + + + + + + +

+ {{ 'addon.mod_scorm.dataattemptshown' | translate:{number: attemptToContinue} }} +

+

{{ currentOrganization.title }}

+
+

+ + + + + + + + + + + + ({{ 'addon.mod_scorm.score' | translate }}: {{sco.scoreraw}}) + +

+
+
+
+
+
+ + + + + +

{{ errorMessage | translate }}

+
+
+ + {{ 'core.openinbrowser' | translate }} + + +
+ + + + + +

{{ 'addon.mod_scorm.exceededmaxattempts' | translate }}

+
+
+
+ + + + + + + + {{ 'addon.mod_scorm.newattempt' | translate }} + + + + + + +

{{ statusMessage | translate }}

+
+
+ + + + + + + {{ 'addon.mod_scorm.browse' | translate }} + + + + + + {{ 'addon.mod_scorm.enter' | translate }} + + + + + +
+ + + + + +

{{ progressMessage | translate }}

+ +
+
+
+
+
+
diff --git a/src/addons/mod/scorm/components/index/index.scss b/src/addons/mod/scorm/components/index/index.scss new file mode 100644 index 000000000..636b83653 --- /dev/null +++ b/src/addons/mod/scorm/components/index/index.scss @@ -0,0 +1,19 @@ +@import "~theme/globals"; + +:host { + .addon-mod_scorm-attempt-summary ion-item > p { + font-size: 14px; + } + + .addon-mod_scorm-toc { + // Hide all non sco icons using css to maintain padding. + ion-icon { + opacity: 0; + @include margin(5px, 8px, null, null); + } + + .addon-mod_scorm-type-sco ion-icon { + opacity: 1 + } + } +} diff --git a/src/addons/mod/scorm/components/index/index.ts b/src/addons/mod/scorm/components/index/index.ts new file mode 100644 index 000000000..efaf7b3bd --- /dev/null +++ b/src/addons/mod/scorm/components/index/index.ts @@ -0,0 +1,605 @@ +// (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 { Component, OnInit, Optional } from '@angular/core'; +import { CoreCourseModuleMainActivityComponent } from '@features/course/classes/main-activity-component'; +import { CoreCourseContentsPage } from '@features/course/pages/contents/contents'; +import { CoreCourse } from '@features/course/services/course'; +import { IonContent } from '@ionic/angular'; +import { CoreNavigator } from '@services/navigator'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { AddonModScormPrefetchHandler } from '../../services/handlers/prefetch'; +import { + AddonModScorm, + AddonModScormAttemptCountResult, + AddonModScormGetScormAccessInformationWSResponse, + AddonModScormAttemptGrade, + AddonModScormOrganization, + AddonModScormProvider, + AddonModScormScorm, +} from '../../services/scorm'; +import { AddonModScormHelper, AddonModScormTOCScoWithIcon } from '../../services/scorm-helper'; +import { + AddonModScormAutoSyncEventData, + AddonModScormSync, + AddonModScormSyncProvider, + AddonModScormSyncResult, +} from '../../services/scorm-sync'; + +/** + * Component that displays a SCORM entry page. + */ +@Component({ + selector: 'addon-mod-scorm-index', + templateUrl: 'addon-mod-scorm-index.html', + styleUrls: ['index.scss'], +}) +export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityComponent implements OnInit { + + component = AddonModScormProvider.COMPONENT; + moduleName = 'scorm'; + + scorm?: AddonModScormScorm; // The SCORM object. + currentOrganization: Partial = {}; // Selected organization. + startNewAttempt = false; + errorMessage?: string; // Error message. + syncTime?: string; // Last sync time. + hasOffline = false; // Whether the SCORM has offline data. + attemptToContinue?: number; // The attempt to continue or review. + statusMessage?: string; // Message about the status. + downloading = false; // Whether the SCORM is being downloaded. + percentage?: string; // Download/unzip percentage. + showPercentage = false; // Whether to show the percentage. + progressMessage?: string; // Message about download/unzip. + organizations?: AddonModScormOrganization[]; // List of organizations. + loadingToc = false; // Whether the TOC is being loaded. + toc?: AddonModScormTOCScoWithIcon[]; // Table of contents (structure). + accessInfo?: AddonModScormGetScormAccessInformationWSResponse; // Access information. + skip?: boolean; // Launch immediately. + incomplete = false; // Whether last attempt is incomplete. + numAttempts = -1; // Number of attempts. + grade?: number; // Grade. + gradeFormatted?: string; // Grade formatted. + gradeMethodReadable?: string; // Grade method in a readable format. + attemptsLeft = -1; // Number of attempts left. + onlineAttempts: AttemptGrade[] = []; // Grades for online attempts. + offlineAttempts: AttemptGrade[] = []; // Grades for offline attempts. + + protected fetchContentDefaultError = 'addon.mod_scorm.errorgetscorm'; // Default error to show when loading contents. + protected syncEventName = AddonModScormSyncProvider.AUTO_SYNCED; + protected attempts?: AddonModScormAttemptCountResult; // Data about online and offline attempts. + protected lastAttempt?: number; // Last attempt. + protected lastIsOffline = false; // Whether the last attempt is offline. + protected hasPlayed = false; // Whether the user has opened the player page. + protected dataSentObserver?: CoreEventObserver; // To detect data sent to server. + protected dataSent = false; // Whether some data was sent to server while playing the SCORM. + + constructor( + protected content?: IonContent, + @Optional() courseContentsPage?: CoreCourseContentsPage, + ) { + super('AddonModScormIndexComponent', content, courseContentsPage); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + await this.loadContent(false, true); + + if (!this.scorm) { + return; + } + + if (this.skip) { + this.open(); + } + + try { + await AddonModScorm.logView(this.scorm.id, this.scorm.name); + + this.checkCompletion(); + } catch { + // Ignore errors. + } + } + + /** + * Check the completion. + */ + protected checkCompletion(): void { + CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); + } + + /** + * Download a SCORM package or restores an ongoing download. + * + * @return Promise resolved when done. + */ + protected async downloadScormPackage(): Promise { + this.downloading = true; + + try { + await AddonModScormPrefetchHandler.download(this.module, this.courseId, undefined, (data) => { + if (!data) { + return; + } + + this.percentage = undefined; + this.showPercentage = false; + + if (data.downloading) { + // Downloading package. + if (this.scorm!.packagesize && data.progress) { + const percentageNumber = Number(data.progress.loaded / this.scorm!.packagesize) * 100; + this.percentage = percentageNumber.toFixed(1); + this.showPercentage = percentageNumber >= 0 && percentageNumber <= 100; + } + } else if (data.message) { + // Show a message. + this.progressMessage = data.message; + } else if (data.progress && data.progress.loaded && data.progress.total) { + // Unzipping package. + const percentageNumber = Number(data.progress.loaded / data.progress.total) * 100; + this.percentage = percentageNumber.toFixed(1); + this.showPercentage = percentageNumber >= 0 && percentageNumber <= 100; + } + }); + + } finally { + this.progressMessage = undefined; + this.percentage = undefined; + this.downloading = false; + } + } + + /** + * @inheritdoc + */ + protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + try { + // Get the SCORM instance. + this.scorm = await AddonModScorm.getScorm(this.courseId, this.module.id, { moduleUrl: this.module.url }); + + this.dataRetrieved.emit(this.scorm); + this.description = this.scorm.intro || this.description; + this.errorMessage = AddonModScorm.isScormUnsupported(this.scorm); + + if (this.scorm.warningMessage) { + return; // SCORM is closed or not open yet, we can't get more data. + } + + if (sync) { + // Try to synchronize the SCORM. + await CoreUtils.ignoreErrors(this.syncActivity(showErrors)); + } + + const [syncTime, accessInfo] = await Promise.all([ + AddonModScormSync.getReadableSyncTime(this.scorm.id), + AddonModScorm.getAccessInformation(this.scorm.id, { cmId: this.module.id }), + this.fetchAttemptData(this.scorm), + ]); + + this.syncTime = syncTime; + this.accessInfo = accessInfo; + + // Check whether to launch the SCORM immediately. + if (typeof this.skip == 'undefined') { + this.skip = !this.hasOffline && !this.errorMessage && + (!this.scorm.lastattemptlock || this.attemptsLeft > 0) && + this.accessInfo.canskipview && !this.accessInfo.canviewreport && + this.scorm.skipview! >= AddonModScormProvider.SKIPVIEW_FIRST && + (this.scorm.skipview == AddonModScormProvider.SKIPVIEW_ALWAYS || this.lastAttempt == 0); + } + } finally { + this.fillContextMenu(refresh); + } + } + + /** + * Fetch attempt data. + * + * @param scorm Scorm. + * @return Promise resolved when done. + */ + protected async fetchAttemptData(scorm: AddonModScormScorm): Promise { + // Get the number of attempts. + this.attempts = await AddonModScorm.getAttemptCount(scorm.id, { cmId: this.module.id }); + this.hasOffline = !!this.attempts.offline.length; + + // Determine the attempt that will be continued or reviewed. + const attempt = await AddonModScormHelper.determineAttemptToContinue(scorm, this.attempts); + + this.lastAttempt = attempt.num; + this.lastIsOffline = attempt.offline; + + if (this.lastAttempt != this.attempts.lastAttempt.num) { + this.attemptToContinue = this.lastAttempt; + } else { + this.attemptToContinue = undefined; + } + + // Check if the last attempt is incomplete. + this.incomplete = await AddonModScorm.isAttemptIncomplete(scorm.id, this.lastAttempt, { + offline: this.lastIsOffline, + cmId: this.module.id, + }); + + this.numAttempts = this.attempts.total; + this.gradeMethodReadable = AddonModScorm.getScormGradeMethod(scorm); + this.attemptsLeft = AddonModScorm.countAttemptsLeft(scorm, this.attempts.lastAttempt.num); + + if (scorm.forcenewattempt == AddonModScormProvider.SCORM_FORCEATTEMPT_ALWAYS || + (scorm.forcenewattempt && !this.incomplete)) { + this.startNewAttempt = true; + } + + await Promise.all([ + this.getReportedGrades(scorm, this.attempts), + this.fetchStructure(scorm), + this.loadPackageSize(scorm), + this.setStatusListener(), + ]); + } + + /** + * Load SCORM package size if needed. + * + * @return Promise resolved when done. + */ + protected async loadPackageSize(scorm: AddonModScormScorm): Promise { + if (scorm.packagesize || this.errorMessage) { + return; + } + + // SCORM is supported but we don't have package size. Try to calculate it. + scorm.packagesize = await AddonModScorm.calculateScormSize(scorm); + } + + /** + * Fetch the structure of the SCORM (TOC). + * + * @return Promise resolved when done. + */ + protected async fetchStructure(scorm: AddonModScormScorm): Promise { + this.organizations = await AddonModScorm.getOrganizations(scorm.id, { cmId: this.module.id }); + + if (!this.currentOrganization.identifier) { + // Load first organization (if any). + if (this.organizations.length) { + this.currentOrganization.identifier = this.organizations[0].identifier; + } else { + this.currentOrganization.identifier = ''; + } + } + + return this.loadOrganizationToc(scorm, this.currentOrganization.identifier); + } + + /** + * Get the grade of an attempt and add it to the scorm attempts list. + * + * @param attempt The attempt number. + * @param offline Whether it's an offline attempt. + * @param attempts Object where to add the attempt. + * @return Promise resolved when done. + */ + protected async getAttemptGrade( + attempt: number, + offline: boolean, + attempts: Record, + ): Promise { + const grade = await AddonModScorm.getAttemptGrade(this.scorm!, attempt, offline); + + attempts[attempt] = { + num: attempt, + grade: grade, + }; + } + + /** + * Get the grades of each attempt and the grade of the SCORM. + * + * @return Promise resolved when done. + */ + protected async getReportedGrades(scorm: AddonModScormScorm, attempts: AddonModScormAttemptCountResult): Promise { + const promises: Promise[] = []; + const onlineAttempts: Record = {}; + const offlineAttempts: Record = {}; + + // Calculate the grade for each attempt. + attempts.online.forEach((attempt) => { + // Check that attempt isn't in offline to prevent showing the same attempt twice. Offline should be more recent. + if (attempts.offline.indexOf(attempt) == -1) { + promises.push(this.getAttemptGrade(attempt, false, onlineAttempts)); + } + }); + + attempts.offline.forEach((attempt) => { + promises.push(this.getAttemptGrade(attempt, true, offlineAttempts)); + }); + + await Promise.all(promises); + + // Calculate the grade of the whole SCORM. We only use online attempts to calculate this data. + this.grade = AddonModScorm.calculateScormGrade(scorm, onlineAttempts); + + // Add the attempts to the SCORM in array format in ASC order, and format the grades. + this.onlineAttempts = CoreUtils.objectToArray(onlineAttempts); + this.offlineAttempts = CoreUtils.objectToArray(offlineAttempts); + this.onlineAttempts.sort((a, b) => a.num - b.num); + this.offlineAttempts.sort((a, b) => a.num - b.num); + + // Now format the grades. + this.onlineAttempts.forEach((attempt) => { + attempt.gradeFormatted = AddonModScorm.formatGrade(scorm, attempt.grade); + }); + this.offlineAttempts.forEach((attempt) => { + attempt.gradeFormatted = AddonModScorm.formatGrade(scorm, attempt.grade); + }); + + this.gradeFormatted = AddonModScorm.formatGrade(scorm, this.grade); + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned on the sync function. + * @return If suceed or not. + */ + protected hasSyncSucceed(result: AddonModScormSyncResult): boolean { + if (result.updated || this.dataSent) { + // Check completion status if something was sent. + this.checkCompletion(); + } + + this.dataSent = false; + + return true; + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + super.ionViewDidEnter(); + + if (!this.hasPlayed) { + return; + } + + this.hasPlayed = false; + this.startNewAttempt = false; // Uncheck new attempt. + + // Add a delay to make sure the player has started the last writing calls so we can detect conflicts. + setTimeout(() => { + this.dataSentObserver?.off(); // Stop listening for changes. + this.dataSentObserver = undefined; + + // Refresh data. + this.showLoadingAndRefresh(true, false); + }, 500); + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + protected async invalidateContent(): Promise { + const promises: Promise[] = []; + + promises.push(AddonModScorm.invalidateScormData(this.courseId)); + + if (this.scorm) { + promises.push(AddonModScorm.invalidateAllScormData(this.scorm.id)); + } + + await Promise.all(promises); + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param syncEventData Data receiven on sync observer. + * @return True if refresh is needed, false otherwise. + */ + protected isRefreshSyncNeeded(syncEventData: AddonModScormAutoSyncEventData): boolean { + if (syncEventData.updated && this.scorm && syncEventData.scormId == this.scorm.id) { + // Check completion status. + this.checkCompletion(); + + return true; + } + + return false; + } + + /** + * Load a organization's TOC. + */ + async loadOrganization(): Promise { + try { + await this.loadOrganizationToc(this.scorm!, this.currentOrganization.identifier!); + } catch (error) { + CoreDomUtils.showErrorModalDefault(error, this.fetchContentDefaultError, true); + } + } + + /** + * Load the TOC of a certain organization. + * + * @param organizationId The organization id. + * @return Promise resolved when done. + */ + protected async loadOrganizationToc(scorm: AddonModScormScorm, organizationId: string): Promise { + if (!scorm.displaycoursestructure) { + // TOC is not displayed, no need to load it. + return; + } + + this.loadingToc = true; + + try { + this.toc = await AddonModScormHelper.getToc(scorm.id, this.lastAttempt!, this.incomplete, { + organization: organizationId, + offline: this.lastIsOffline, + cmId: this.module.id, + }); + + // Search organization title. + this.organizations!.forEach((org) => { + if (org.identifier == organizationId) { + this.currentOrganization.title = org.title; + } + }); + } finally { + this.loadingToc = false; + } + } + + /** + * Open a SCORM. It will download the SCORM package if it's not downloaded or it has changed. + * + * @param event Event. + * @param scoId SCO that needs to be loaded when the SCORM is opened. If not defined, load first SCO. + */ + async open(event?: Event, preview: boolean = false, scoId?: number): Promise { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + if (this.downloading) { + // Scope is being downloaded, abort. + return; + } + + const isOutdated = this.currentStatus == CoreConstants.OUTDATED; + const scorm = this.scorm!; + + if (!isOutdated && this.currentStatus != CoreConstants.NOT_DOWNLOADED) { + // Already downloaded, open it. + this.openScorm(scoId, preview); + + return; + } + + // SCORM needs to be downloaded. + await AddonModScormHelper.confirmDownload(scorm, isOutdated); + // Invalidate WS data if SCORM is outdated. + if (isOutdated) { + await CoreUtils.ignoreErrors(AddonModScorm.invalidateAllScormData(scorm.id)); + } + + try { + await this.downloadScormPackage(); + // Success downloading, open SCORM if user hasn't left the view. + if (!this.isDestroyed) { + this.openScorm(scoId, preview); + } + } catch (error) { + if (!this.isDestroyed) { + CoreDomUtils.showErrorModalDefault( + error, + Translate.instant('addon.mod_scorm.errordownloadscorm', { name: scorm.name }), + ); + } + } + } + + /** + * Open a SCORM package. + * + * @param scoId SCO ID. + */ + protected openScorm(scoId?: number, preview: boolean = false): void { + // Display the full page when returning to the page. + this.skip = false; + this.hasPlayed = true; + + // Detect if anything was sent to server. + this.dataSentObserver?.off(); + + this.dataSentObserver = CoreEvents.on(AddonModScormProvider.DATA_SENT_EVENT, (data) => { + if (data.scormId === this.scorm!.id) { + this.dataSent = true; + } + }, this.siteId); + + CoreNavigator.navigate('player', { + params: { + mode: preview ? AddonModScormProvider.MODEBROWSE : AddonModScormProvider.MODENORMAL, + moduleUrl: this.module.url, + newAttempt: !!this.startNewAttempt, + organizationId: this.currentOrganization.identifier, + scoId: scoId, + }, + }); + } + + /** + * @inheritdoc + */ + protected async showStatus(status: string): Promise { + + if (status == CoreConstants.OUTDATED && this.scorm) { + // Only show the outdated message if the file should be downloaded. + const download = await AddonModScorm.shouldDownloadMainFile(this.scorm, true); + + this.statusMessage = download ? 'addon.mod_scorm.scormstatusoutdated' : ''; + } else if (status == CoreConstants.NOT_DOWNLOADED) { + this.statusMessage = 'addon.mod_scorm.scormstatusnotdownloaded'; + } else if (status == CoreConstants.DOWNLOADING) { + if (!this.downloading) { + // It's being downloaded right now but the view isn't tracking it. "Restore" the download. + this.downloadScormPackage(); + } + } else { + this.statusMessage = ''; + } + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected async sync(): Promise { + const result = await AddonModScormSync.syncScorm(this.scorm!); + + if (!result.updated && this.dataSent) { + // The user sent data to server, but not in the sync process. Check if we need to fetch data. + await CoreUtils.ignoreErrors( + AddonModScormSync.prefetchAfterUpdate(AddonModScormPrefetchHandler.instance, this.module, this.courseId), + ); + } + + return result; + } + +} + +/** + * Grade for an online attempt. + */ +export type AttemptGrade = AddonModScormAttemptGrade & { + gradeFormatted?: string; +}; diff --git a/src/addons/mod/scorm/components/toc/toc.html b/src/addons/mod/scorm/components/toc/toc.html new file mode 100644 index 000000000..ee5b3015e --- /dev/null +++ b/src/addons/mod/scorm/components/toc/toc.html @@ -0,0 +1,50 @@ + + + {{ 'addon.mod_scorm.toc' | translate }} + + + + + + + + + + diff --git a/src/addons/mod/scorm/components/toc/toc.ts b/src/addons/mod/scorm/components/toc/toc.ts new file mode 100644 index 000000000..1d1ca3b47 --- /dev/null +++ b/src/addons/mod/scorm/components/toc/toc.ts @@ -0,0 +1,68 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, Input, OnInit } from '@angular/core'; +import { ModalController } from '@singletons'; +import { AddonModScormGetScormAccessInformationWSResponse, AddonModScormProvider } from '../../services/scorm'; +import { AddonModScormTOCScoWithIcon } from '../../services/scorm-helper'; + +/** + * Modal to display the TOC of a SCORM. + */ +@Component({ + selector: 'addon-mod-scorm-toc', + templateUrl: 'toc.html', +}) +export class AddonModScormTocComponent implements OnInit { + + @Input() toc: AddonModScormTOCScoWithIcon[] = []; + @Input() attemptToContinue?: number; + @Input() selected?: number; + @Input() moduleId!: number; + @Input() courseId!: number; + @Input() accessInfo!: AddonModScormGetScormAccessInformationWSResponse; + @Input() mode = ''; + + isBrowse = false; + isReview = false; + + /** + * @inheritdoc + */ + ngOnInit(): void { + this.isBrowse = this.mode === AddonModScormProvider.MODEBROWSE; + this.isReview = this.mode === AddonModScormProvider.MODEREVIEW; + } + + /** + * Function called when a SCO is clicked. + * + * @param sco Clicked SCO. + */ + loadSco(sco: AddonModScormTOCScoWithIcon): void { + if (!sco.prereq || !sco.isvisible || !sco.launch) { + return; + } + + ModalController.dismiss(sco); + } + + /** + * Close modal. + */ + closeModal(): void { + ModalController.dismiss(); + } + +} 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/pages/index/index.html b/src/addons/mod/scorm/pages/index/index.html new file mode 100644 index 000000000..b8f8eb0e3 --- /dev/null +++ b/src/addons/mod/scorm/pages/index/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/addons/mod/scorm/pages/index/index.ts b/src/addons/mod/scorm/pages/index/index.ts new file mode 100644 index 000000000..dfaa663ba --- /dev/null +++ b/src/addons/mod/scorm/pages/index/index.ts @@ -0,0 +1,30 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, ViewChild } from '@angular/core'; +import { CoreCourseModuleMainActivityPage } from '@features/course/classes/main-activity-page'; +import { AddonModScormIndexComponent } from '../../components/index/index'; + +/** + * Page that displays the scorm entry page. + */ +@Component({ + selector: 'page-addon-mod-scorm-index', + templateUrl: 'index.html', +}) +export class AddonModScormIndexPage extends CoreCourseModuleMainActivityPage implements OnInit { + + @ViewChild(AddonModScormIndexComponent) activityComponent?: AddonModScormIndexComponent; + +} diff --git a/src/addons/mod/scorm/pages/player/player.html b/src/addons/mod/scorm/pages/player/player.html new file mode 100644 index 000000000..617eb0974 --- /dev/null +++ b/src/addons/mod/scorm/pages/player/player.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + +

{{ errorMessage | translate }}

+
+
diff --git a/src/addons/mod/scorm/pages/player/player.ts b/src/addons/mod/scorm/pages/player/player.ts new file mode 100644 index 000000000..6a351d21f --- /dev/null +++ b/src/addons/mod/scorm/pages/player/player.ts @@ -0,0 +1,574 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CoreMainMenuPage } from '@features/mainmenu/pages/menu/menu'; +import { CoreNavigator } from '@services/navigator'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController } from '@singletons'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { AddonModScormDataModel12 } from '../../classes/data-model-12'; +import { AddonModScormTocComponent } from '../../components/toc/toc'; +import { + AddonModScorm, + AddonModScormAttemptCountResult, + AddonModScormGetScormAccessInformationWSResponse, + AddonModScormProvider, + AddonModScormScorm, + AddonModScormScoWithData, + AddonModScormUserDataMap, +} from '../../services/scorm'; +import { AddonModScormHelper, AddonModScormTOCScoWithIcon } from '../../services/scorm-helper'; +import { AddonModScormSync } from '../../services/scorm-sync'; + +/** + * Page that allows playing a SCORM. + */ +@Component({ + selector: 'page-addon-mod-scorm-player', + templateUrl: 'player.html', +}) +export class AddonModScormPlayerPage implements OnInit, OnDestroy { + + title?: string; // Title. + scorm!: AddonModScormScorm; // The SCORM object. + showToc = false; // Whether to show the table of contents (TOC). + loadingToc = true; // Whether the TOC is being loaded. + toc: AddonModScormTOCScoWithIcon[] = []; // List of SCOs. + loaded = false; // Whether the data has been loaded. + previousSco?: AddonModScormScoWithData; // Previous SCO. + nextSco?: AddonModScormScoWithData; // Next SCO. + src?: string; // Iframe src. + errorMessage?: string; // Error message. + accessInfo?: AddonModScormGetScormAccessInformationWSResponse; // Access information. + scormWidth?: number; // Width applied to scorm iframe. + scormHeight?: number; // Height applied to scorm iframe. + incomplete = false; // Whether last attempt is incomplete. + cmId!: number; // Course module ID. + courseId!: number; // Course ID. + + protected siteId!: string; + protected mode!: string; // Mode to play the SCORM. + protected moduleUrl!: string; // Module URL. + protected newAttempt = false; // Whether to start a new attempt. + protected organizationId?: string; // Organization ID to load. + protected attempt?: number; // The attempt number. + protected offline = false; // Whether it's offline mode. + protected userData?: AddonModScormUserDataMap; // User data. + protected initialScoId?: number; // Initial SCO ID to load. + protected currentSco?: AddonModScormScoWithData; // Current SCO. + protected dataModel?: AddonModScormDataModel12; // Data Model. + protected attemptToContinue?: number; // Attempt to continue (for the popover). + + // Observers. + protected tocObserver?: CoreEventObserver; + protected launchNextObserver?: CoreEventObserver; + protected launchPrevObserver?: CoreEventObserver; + protected goOfflineObserver?: CoreEventObserver; + + constructor( + protected mainMenuPage: CoreMainMenuPage, + ) {} + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + this.cmId = CoreNavigator.getRouteNumberParam('cmId')!; + this.courseId = CoreNavigator.getRouteNumberParam('courseId')!; + this.mode = CoreNavigator.getRouteParam('mode') || AddonModScormProvider.MODENORMAL; + this.moduleUrl = CoreNavigator.getRouteParam('moduleUrl') || ''; + this.newAttempt = !!CoreNavigator.getRouteBooleanParam('newAttempt'); + this.organizationId = CoreNavigator.getRouteParam('organizationId'); + this.initialScoId = CoreNavigator.getRouteNumberParam('scoId'); + this.siteId = CoreSites.getCurrentSiteId(); + + try { + // Fetch the SCORM data. + await this.fetchData(); + + if (!this.currentSco) { + return; + } + + // Set start time if it's a new attempt. + if (this.newAttempt) { + try { + await this.setStartTime(this.currentSco.id); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true); + } + } + + // Load SCO. + this.loadSco(this.currentSco); + } finally { + this.loaded = true; + } + + } + + /** + * Initialize. + * + * @return Promise resolved when done. + */ + protected async initialize(): Promise { + // Get the SCORM instance. + this.scorm = await AddonModScorm.getScorm(this.courseId, this.cmId, { + moduleUrl: this.moduleUrl, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + }); + + // Block the SCORM so it cannot be synchronized. + CoreSync.blockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player'); + + // We use SCORM name at start, later we'll use the SCO title. + this.title = this.scorm.name; + this.showToc = AddonModScorm.displayTocInPlayer(this.scorm); + + if (this.scorm.popup) { + this.mainMenuPage.changeVisibility(false); + + // If we receive a value > 100 we assume it's a fixed pixel size. + if (this.scorm.width! > 100) { + this.scormWidth = this.scorm.width; + + // Only get fixed size on height if width is also fixed. + if (this.scorm.height! > 100) { + this.scormHeight = this.scorm.height; + } + } + } + + // Listen for events to update the TOC, navigate through SCOs and go offline. + this.tocObserver = CoreEvents.on(AddonModScormProvider.UPDATE_TOC_EVENT, (data) => { + if (data.scormId !== this.scorm.id) { + return; + } + + if (this.offline) { + // Wait a bit to make sure data is stored. + setTimeout(this.refreshToc.bind(this), 100); + } else { + this.refreshToc(); + } + }, this.siteId); + + this.launchNextObserver = CoreEvents.on(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT, (data) => { + if (data.scormId === this.scorm.id && this.nextSco) { + this.loadSco(this.nextSco); + } + }, this.siteId); + + this.launchPrevObserver = CoreEvents.on(AddonModScormProvider.LAUNCH_PREV_SCO_EVENT, (data) => { + if (data.scormId === this.scorm.id && this.previousSco) { + this.loadSco(this.previousSco); + } + }, this.siteId); + + this.goOfflineObserver = CoreEvents.on(AddonModScormProvider.GO_OFFLINE_EVENT, (data) => { + if (data.scormId !== this.scorm.id || this.offline) { + return; + } + this.offline = true; + + // Wait a bit to prevent collisions between this store and SCORM API's store. + setTimeout(async () => { + try { + AddonModScormHelper.convertAttemptToOffline(this.scorm, this.attempt!); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } + + this.refreshToc(); + }, 200); + }, this.siteId); + } + + /** + * Calculate the next and previous SCO. + * + * @param scoId Current SCO ID. + */ + protected calculateNextAndPreviousSco(scoId: number): void { + this.previousSco = AddonModScormHelper.getPreviousScoFromToc(this.toc, scoId); + this.nextSco = AddonModScormHelper.getNextScoFromToc(this.toc, scoId); + } + + /** + * Determine the attempt to use, the mode (normal/preview) and if it's offline or online. + * + * @param attemptsData Attempts count. + * @return Promise resolved when done. + */ + protected async determineAttemptAndMode(attemptsData: AddonModScormAttemptCountResult): Promise { + const data = await AddonModScormHelper.determineAttemptToContinue(this.scorm, attemptsData); + + let incomplete = false; + this.attempt = data.num; + this.offline = data.offline; + + if (this.attempt != attemptsData.lastAttempt.num) { + this.attemptToContinue = this.attempt; + } + + // Check if current attempt is incomplete. + if (this.attempt > 0) { + incomplete = await AddonModScorm.isAttemptIncomplete(this.scorm.id, this.attempt, { + offline: this.offline, + cmId: this.cmId, + }); + } + + // Determine mode and attempt to use. + const result = AddonModScorm.determineAttemptAndMode(this.scorm, this.mode, this.attempt, this.newAttempt, incomplete); + + if (result.attempt > this.attempt) { + // We're creating a new attempt. + if (this.offline) { + // Last attempt was offline, so we'll create a new offline attempt. + await AddonModScormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length); + } else { + try { + // Last attempt was online, verify that we can create a new online attempt. We ignore cache. + await AddonModScorm.getScormUserData(this.scorm.id, result.attempt, { + cmId: this.cmId, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + }); + } catch { + // Cannot communicate with the server, create an offline attempt. + this.offline = true; + + await AddonModScormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length); + } + } + } + + this.mode = result.mode; + this.newAttempt = result.newAttempt; + this.attempt = result.attempt; + } + + /** + * Fetch data needed to play the SCORM. + * + * @return Promise resolved when done. + */ + protected async fetchData(): Promise { + if (!this.scorm) { + await this.initialize(); + } + + // Wait for any ongoing sync to finish. We won't sync a SCORM while it's being played. + await AddonModScormSync.waitForSync(this.scorm.id); + + try { + // Get attempts data. + const attemptsData = await AddonModScorm.getAttemptCount(this.scorm.id, { cmId: this.cmId }); + + await this.determineAttemptAndMode(attemptsData); + + const [data, accessInfo] = await Promise.all([ + AddonModScorm.getScormUserData(this.scorm.id, this.attempt!, { + cmId: this.cmId, + offline: this.offline, + }), + AddonModScorm.getAccessInformation(this.scorm.id, { + cmId: this.cmId, + }), + this.fetchToc(), + ]); + + this.userData = data; + this.accessInfo = accessInfo; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true); + } + } + + /** + * Fetch the TOC. + * + * @return Promise resolved when done. + */ + protected async fetchToc(): Promise { + this.loadingToc = true; + + try { + // We need to check incomplete again: attempt number or status might have changed. + this.incomplete = await AddonModScorm.isAttemptIncomplete(this.scorm.id, this.attempt!, { + offline: this.offline, + cmId: this.cmId, + }); + + // Get TOC. + this.toc = await AddonModScormHelper.getToc(this.scorm.id, this.attempt!, this.incomplete, { + organization: this.organizationId, + offline: this.offline, + cmId: this.cmId, + }); + + if (this.currentSco) { + return; + } + + if (this.newAttempt) { + // Creating a new attempt, use the first SCO defined by the SCORM. + this.initialScoId = this.scorm.launch; + } + + // Determine current SCO if we received an ID. + if (this.initialScoId && this.initialScoId > 0) { + // SCO set by parameter, get it from TOC. + this.currentSco = AddonModScormHelper.getScoFromToc(this.toc, this.initialScoId); + } + + if (this.currentSco) { + return; + } + + // No SCO defined. Get the first valid one. + const sco = await AddonModScormHelper.getFirstSco(this.scorm.id, this.attempt!, { + toc: this.toc, + organization: this.organizationId, + mode: this.mode, + offline: this.offline, + cmId: this.cmId, + }); + + if (sco) { + this.currentSco = sco; + } else { + // We couldn't find a SCO to load: they're all inactive or without launch URL. + this.errorMessage = 'addon.mod_scorm.errornovalidsco'; + } + } finally { + this.loadingToc = false; + } + } + + /** + * Page will leave. + */ + ionViewWillLeave(): void { + CoreEvents.trigger(CoreEvents.ACTIVITY_DATA_SENT, { module: 'scorm' }); + + // Empty src when leaving the state so unload event is triggered in the iframe. + this.src = ''; + } + + /** + * Load a SCO. + * + * @param sco The SCO to load. + * @return Promise resolved when done. + */ + async loadSco(sco: AddonModScormScoWithData): Promise { + if (!this.dataModel) { + // Create the model. + this.dataModel = new AddonModScormDataModel12( + this.siteId, + this.scorm, + sco.id, + this.attempt!, + this.userData!, + this.mode, + this.offline, + ); + + // Add the model to the window so the SCORM can access it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ( window).API = this.dataModel; + } else { + // Load the SCO in the existing model. + this.dataModel.loadSco(sco.id); + } + + this.currentSco = sco; + this.title = sco.title || this.scorm.name; // Try to use SCO title. + + this.calculateNextAndPreviousSco(sco.id); + + // Load the SCO source. + this.loadScoSrc(sco); + + if (sco.scormtype == 'asset') { + // Mark the asset as completed. + this.markCompleted(sco); + } + + // Trigger SCO launch event. + CoreUtils.ignoreErrors(AddonModScorm.logLaunchSco(this.scorm.id, sco.id, this.scorm.name)); + } + + /** + * Load SCO src. + * + * @param sco SCO to load. + * @return Promise resolved when done. + */ + protected async loadScoSrc(sco: AddonModScormScoWithData): Promise { + const src = await AddonModScorm.getScoSrc(this.scorm, sco); + + if (src == this.src) { + // Re-loading same page. Set it to empty and then re-set the src in the next digest so it detects it has changed. + this.src = ''; + + await CoreUtils.nextTick(); + } + + this.src = src; + } + + /** + * Given an SCO, mark it as completed. + * + * @param sco SCO to mark. + * @return Promise resolved when done. + */ + protected async markCompleted(sco: AddonModScormScoWithData): Promise { + const tracks = [{ + element: 'cmi.core.lesson_status', + value: 'completed', + }]; + + try { + AddonModScorm.saveTracks(sco.id, this.attempt!, tracks, this.scorm, this.offline); + } catch { + // Error saving data. Go offline if needed. + if (this.offline) { + return; + } + + const data = await AddonModScorm.getScormUserData(this.scorm.id, this.attempt!, { + cmId: this.cmId, + }); + + if (data[sco.id] && data[sco.id].userdata['cmi.core.lesson_status'] == 'completed') { + // Already marked as completed. + return; + } + + try { + // Go offline. + await AddonModScormHelper.convertAttemptToOffline(this.scorm, this.attempt!); + + this.offline = true; + this.dataModel?.setOffline(true); + + await AddonModScorm.saveTracks(sco.id, this.attempt!, tracks, this.scorm, true); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.error', true); + } + } finally { + // Refresh TOC, some prerequisites might have changed. + this.refreshToc(); + } + } + + /** + * Show the TOC. + */ + async openToc(): Promise { + const modal = await ModalController.create({ + component: AddonModScormTocComponent, + componentProps: { + toc: this.toc, + attemptToContinue: this.attemptToContinue, + selected: this.currentSco && this.currentSco.id, + moduleId: this.cmId, + courseId: this.courseId, + accessInfo: this.accessInfo, + mode: this.mode, + }, + cssClass: 'core-modal-lateral', + showBackdrop: true, + backdropDismiss: true, + // @todo enterAnimation: 'core-modal-lateral-transition', + // leaveAnimation: 'core-modal-lateral-transition' + }); + + await modal.present(); + + const result = await modal.onDidDismiss(); + + if (result.data) { + this.loadSco(result.data); + } + } + + /** + * Refresh the TOC. + * + * @return Promise resolved when done. + */ + protected async refreshToc(): Promise { + try { + await CoreUtils.ignoreErrors(AddonModScorm.invalidateAllScormData(this.scorm.id)); + + await this.fetchToc(); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true); + } + } + + /** + * Set SCORM start time. + * + * @param scoId SCO ID. + * @return Promise resolved when done. + */ + protected async setStartTime(scoId: number): Promise { + const tracks = [{ + element: 'x.start.time', + value: String(CoreTimeUtils.timestamp()), + }]; + + await AddonModScorm.saveTracks(scoId, this.attempt!, tracks, this.scorm, this.offline); + + if (this.offline) { + return; + } + + // New online attempt created, update cached data about online attempts. + await CoreUtils.ignoreErrors(AddonModScorm.getAttemptCount(this.scorm.id, { + cmId: this.cmId, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + })); + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + // Stop listening for events. + this.tocObserver?.off(); + this.launchNextObserver?.off(); + this.launchPrevObserver?.off(); + setTimeout(() => { + this.goOfflineObserver?.off(); + }, 500); + + this.mainMenuPage.changeVisibility(true); + + // Unblock the SCORM so it can be synced. + CoreSync.unblockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player'); + } + +} diff --git a/src/addons/mod/scorm/scorm-lazy.module.ts b/src/addons/mod/scorm/scorm-lazy.module.ts new file mode 100644 index 000000000..a0e05b611 --- /dev/null +++ b/src/addons/mod/scorm/scorm-lazy.module.ts @@ -0,0 +1,45 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModScormComponentsModule } from './components/components.module'; +import { AddonModScormIndexPage } from './pages/index/index'; +import { AddonModScormPlayerPage } from './pages/player/player'; + +const routes: Routes = [ + { + path: ':courseId/:cmId', + component: AddonModScormIndexPage, + }, + { + path: ':courseId/:cmId/player', + component: AddonModScormPlayerPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CoreSharedModule, + AddonModScormComponentsModule, + ], + declarations: [ + AddonModScormIndexPage, + AddonModScormPlayerPage, + ], +}) +export class AddonModScormLazyModule {} diff --git a/src/addons/mod/scorm/scorm.module.ts b/src/addons/mod/scorm/scorm.module.ts new file mode 100644 index 000000000..45d28649e --- /dev/null +++ b/src/addons/mod/scorm/scorm.module.ts @@ -0,0 +1,76 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { APP_INITIALIZER, NgModule, Type } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; +import { CoreCronDelegate } from '@services/cron'; +import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModScormComponentsModule } from './components/components.module'; +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, AddonModScormModuleHandlerService } from './services/handlers/module'; +import { AddonModScormPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModScormSyncCronHandler } from './services/handlers/sync-cron'; +import { AddonModScormProvider } from './services/scorm'; +import { AddonModScormHelperProvider } from './services/scorm-helper'; +import { AddonModScormOfflineProvider } from './services/scorm-offline'; +import { AddonModScormSyncProvider } from './services/scorm-sync'; + +export const ADDON_MOD_SCORM_SERVICES: Type[] = [ + AddonModScormProvider, + AddonModScormOfflineProvider, + AddonModScormHelperProvider, + AddonModScormSyncProvider, +]; + +const routes: Routes = [ + { + path: AddonModScormModuleHandlerService.PAGE_NAME, + loadChildren: () => import('./scorm-lazy.module').then(m => m.AddonModScormLazyModule), + }, +]; + +@NgModule({ + imports: [ + CoreMainMenuTabRoutingModule.forChild(routes), + AddonModScormComponentsModule, + ], + 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, diff --git a/src/core/features/course/services/handlers/log-cron.ts b/src/core/features/course/services/handlers/log-cron.ts index 1922f4e04..cb447acd8 100644 --- a/src/core/features/course/services/handlers/log-cron.ts +++ b/src/core/features/course/services/handlers/log-cron.ts @@ -37,6 +37,11 @@ export class CoreCourseLogCronHandlerService implements CoreCronHandler { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars async execute(siteId?: string, force?: boolean): Promise { + if (!siteId && !CoreSites.isLoggedIn()) { + // No current site, stop. + return; + } + const site = await CoreSites.getSite(siteId); return CoreCourse.logView(site.getSiteHomeId(), undefined, site.getId(), site.getInfo()?.sitename); diff --git a/src/theme/theme.base.scss b/src/theme/theme.base.scss index 641e22700..af5fa751f 100644 --- a/src/theme/theme.base.scss +++ b/src/theme/theme.base.scss @@ -480,3 +480,10 @@ ion-button.core-button-select { .core-browser-copy-area { display: none; } + +// Different levels of padding. +@for $i from 0 through 15 { + .core-padding-#{$i} { + @include padding(null, null, null, 15px * $i + 16px); + } +}