| @ -16,7 +16,6 @@ import { Component, Optional, Injector } from '@angular/core'; | ||||
| import { Content, NavController } from 'ionic-angular'; | ||||
| import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; | ||||
| import { CoreQuestionBehaviourDelegate } from '@core/question/providers/behaviour-delegate'; | ||||
| import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; | ||||
| import { AddonModQuizProvider } from '../../providers/quiz'; | ||||
| import { AddonModQuizHelperProvider } from '../../providers/helper'; | ||||
| import { AddonModQuizOfflineProvider } from '../../providers/quiz-offline'; | ||||
| @ -71,8 +70,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp | ||||
|     constructor(injector: Injector, protected quizProvider: AddonModQuizProvider, @Optional() content: Content, | ||||
|             protected quizHelper: AddonModQuizHelperProvider, protected quizOffline: AddonModQuizOfflineProvider, | ||||
|             protected quizSync: AddonModQuizSyncProvider, protected behaviourDelegate: CoreQuestionBehaviourDelegate, | ||||
|             protected prefetchHandler: AddonModQuizPrefetchHandler, protected navCtrl: NavController, | ||||
|             protected prefetchDelegate: CoreCourseModulePrefetchDelegate) { | ||||
|             protected prefetchHandler: AddonModQuizPrefetchHandler, protected navCtrl: NavController) { | ||||
|         super(injector, content); | ||||
|     } | ||||
| 
 | ||||
| @ -117,7 +115,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp | ||||
|             // If the site doesn't support check updates, always prefetch it because we cannot tell if there's something new.
 | ||||
|             const isDownloaded = this.currentStatus == CoreConstants.DOWNLOADED; | ||||
| 
 | ||||
|             if (!isDownloaded || !this.prefetchDelegate.canCheckUpdates()) { | ||||
|             if (!isDownloaded || !this.modulePrefetchDelegate.canCheckUpdates()) { | ||||
|                 // Prefetch the quiz.
 | ||||
|                 this.showStatusSpinner = true; | ||||
| 
 | ||||
| @ -125,7 +123,7 @@ export class AddonModQuizIndexComponent extends CoreCourseModuleMainActivityComp | ||||
|                     // Success downloading, open quiz.
 | ||||
|                     this.openQuiz(); | ||||
|                 }).catch((error) => { | ||||
|                     if (this.hasOffline || (isDownloaded && !this.prefetchDelegate.canCheckUpdates())) { | ||||
|                     if (this.hasOffline || (isDownloaded && !this.modulePrefetchDelegate.canCheckUpdates())) { | ||||
|                         // Error downloading but there is something offline, allow continuing it.
 | ||||
|                         // If the site doesn't support check updates, continue too because we cannot tell if there's something new.
 | ||||
|                         this.openQuiz(); | ||||
|  | ||||
							
								
								
									
										905
									
								
								src/addon/mod/scorm/classes/data-model-12.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,905 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // 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 { CoreEventsProvider } from '@providers/events'; | ||||
| import { AddonModScormProvider } from '../providers/scorm'; | ||||
| 
 | ||||
| /** | ||||
|  * SCORM data model implementation for version 1.2. | ||||
|  */ | ||||
| export class AddonModScormDataModel12 { | ||||
| 
 | ||||
|     // Standard Data Type Definition.
 | ||||
|     protected CMI_STRING_256 = '^[\\u0000-\\uFFFF]{0,255}$'; | ||||
|     protected CMI_STRING_4096 = '^[\\u0000-\\uFFFF]{0,4096}$'; | ||||
|     protected CMI_TIME = '^([0-2]{1}[0-9]{1}):([0-5]{1}[0-9]{1}):([0-5]{1}[0-9]{1})(\.[0-9]{1,2})?$'; | ||||
|     protected CMI_TIMESPAN = '^([0-9]{2,4}):([0-9]{2}):([0-9]{2})(\.[0-9]{1,2})?$'; | ||||
|     protected CMI_INTEGER = '^\\d+$'; | ||||
|     protected CMI_SINTEGER = '^-?([0-9]+)$'; | ||||
|     protected CMI_DECIMAL = '^-?([0-9]{0,3})(\.[0-9]*)?$'; | ||||
|     protected CMI_IDENTIFIER = '^[\\u0021-\\u007E]{0,255}$'; | ||||
|     protected CMI_FEEDBACK = this.CMI_STRING_256; // This must be redefined.
 | ||||
|     protected CMI_INDEX = '[._](\\d+).'; | ||||
| 
 | ||||
|     // Vocabulary Data Type Definition.
 | ||||
|     protected CMI_STATUS = '^passed$|^completed$|^failed$|^incomplete$|^browsed$'; | ||||
|     protected CMI_STATUS_2 = '^passed$|^completed$|^failed$|^incomplete$|^browsed$|^not attempted$'; | ||||
|     protected CMI_EXIT = '^time-out$|^suspend$|^logout$|^$'; | ||||
|     protected CMI_TYPE = '^true-false$|^choice$|^fill-in$|^matching$|^performance$|^sequencing$|^likert$|^numeric$'; | ||||
|     protected CMI_RESULT = '^correct$|^wrong$|^unanticipated$|^neutral$|^([0-9]{0,3})?(\.[0-9]*)?$'; | ||||
|     protected NAV_EVENT = '^previous$|^continue$'; | ||||
| 
 | ||||
|     // Children lists.
 | ||||
|     protected CMI_CHILDREN = 'core,suspend_data,launch_data,comments,objectives,student_data,student_preference,interactions'; | ||||
|     protected CORE_CHILDREN = 'student_id,student_name,lesson_location,credit,lesson_status,entry,score,total_time,lesson_mode,' + | ||||
|             'exit,session_time'; | ||||
|     protected SCORE_CHILDREN = 'raw,min,max'; | ||||
|     protected COMMENTS_CHILDREN = 'content,location,time'; | ||||
|     protected OBJECTIVES_CHILDREN = 'id,score,status'; | ||||
|     protected CORRECT_RESPONSES_CHILDREN = 'pattern'; | ||||
|     protected STUDENT_DATA_CHILDREN = 'mastery_score,max_time_allowed,time_limit_action'; | ||||
|     protected STUDENT_PREFERENCE_CHILDREN = 'audio,language,speed,text'; | ||||
|     protected INTERACTIONS_CHILDREN = 'id,objectives,time,type,correct_responses,weighting,student_response,result,latency'; | ||||
| 
 | ||||
|     // Data ranges.
 | ||||
|     protected SCORE_RANGE = '0#100'; | ||||
|     protected AUDIO_RANGE = '-1#100'; | ||||
|     protected SPEED_RANGE = '-100#100'; | ||||
|     protected WEIGHTING_RANGE = '-100#100'; | ||||
|     protected TEXT_RANGE = '-1#1'; | ||||
| 
 | ||||
|     // Error messages.
 | ||||
|     protected 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' | ||||
|     }; | ||||
| 
 | ||||
|     protected currentUserData = {}; // Current user data.
 | ||||
|     protected def = {}; // Object containing the default values.
 | ||||
|     protected defExtra = {}; // Extra object that will contain the objectives and interactions data (all the .n. elements).
 | ||||
|     protected dataModel = {}; // The SCORM 1.2 data model.
 | ||||
| 
 | ||||
|     protected initialized = false; // Whether LMSInitialize has been called.
 | ||||
|     protected errorCode: string; // Last error.
 | ||||
|     protected timeout; // Timeout to commit changes.
 | ||||
| 
 | ||||
|     /** | ||||
|      * Constructor. | ||||
|      * | ||||
|      * @param {CoreEventsProvider} eventsProvider Events provider instance. | ||||
|      * @param {AddonModScormProvider} scormProvider SCORM provider instance. | ||||
|      * @param {any} scorm SCORM. | ||||
|      * @param {number} scoId Current SCO ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {any} userData The user default data. | ||||
|      * @param {string} [mode] Mode being played. By default, MODENORMAL. | ||||
|      * @param {boolean} offline Whether the attempt is offline. | ||||
|      */ | ||||
|     constructor(protected eventsProvider: CoreEventsProvider, protected scormProvider: AddonModScormProvider, | ||||
|             protected siteId: string, protected scorm: any, protected scoId: number, protected attempt: number, | ||||
|             userData: any, protected mode?: string, protected offline?: boolean) { | ||||
| 
 | ||||
|         this.mode = mode || AddonModScormProvider.MODENORMAL; | ||||
|         this.offline = !!offline; | ||||
| 
 | ||||
|         this.init(userData); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Utility function for adding two times in format hh:mm:ss. | ||||
|      * | ||||
|      * @param {string} first  First time. | ||||
|      * @param {string} second Second time. | ||||
|      * @return {string} Total time. | ||||
|      */ | ||||
|     protected addTime(first: string, second: string): string { | ||||
|         const sFirst = first.split(':'), | ||||
|             sSecond = second.split(':'), | ||||
|             cFirst = sFirst[2].split('.'), | ||||
|             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; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Utility function for cloning an object | ||||
|      * | ||||
|      * @param {any} obj The object to be cloned | ||||
|      * @return {any} The object cloned | ||||
|      */ | ||||
|     protected cloneObj(obj: any): any { | ||||
|         if (obj == null || typeof(obj) != 'object') { | ||||
|             return obj; | ||||
|         } | ||||
| 
 | ||||
|         const temp = new obj.constructor(); // Changed (twice).
 | ||||
|         for (const key in obj) { | ||||
|             temp[key] = this.cloneObj(obj[key]); | ||||
|         } | ||||
| 
 | ||||
|         return temp; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Collect all the user tracking data that must be persisted in the system, this is usually called by LMSCommit(). | ||||
|      * | ||||
|      * @return {any[]} Collected data. | ||||
|      */ | ||||
|     protected collectData(): any[] { | ||||
|         const data = []; | ||||
| 
 | ||||
|         for (const element in this.currentUserData[this.scoId]) { | ||||
|             // Ommit for example the nav. elements.
 | ||||
|             if (element.substr(0, 3) == 'cmi') { | ||||
|                 const expression = new RegExp(this.CMI_INDEX, 'g'); | ||||
| 
 | ||||
|                 // Get the generic name for this element (e.g. convert 'cmi.interactions.1.id' to 'cmi.interactions.n.id')
 | ||||
|                 const elementModel = String(element).replace(expression, '.n.'); | ||||
| 
 | ||||
|                 // Ignore the session time element.
 | ||||
|                 if (element != 'cmi.core.session_time') { | ||||
| 
 | ||||
|                     // Check if this specific element is not defined in the datamodel, but the generic element name is.
 | ||||
|                     if (typeof this.dataModel[this.scoId][element] == 'undefined' && | ||||
|                             typeof 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] = this.cloneObj(this.dataModel[this.scoId][elementModel]); | ||||
|                     } | ||||
| 
 | ||||
|                     // Check if the current element exists in the datamodel.
 | ||||
|                     if (typeof this.dataModel[this.scoId][element] != 'undefined') { | ||||
| 
 | ||||
|                         // Make sure this is not a read only element.
 | ||||
|                         if (this.dataModel[this.scoId][element].mod != 'r') { | ||||
| 
 | ||||
|                             const el = { | ||||
|                                 // 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 (typeof 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 || | ||||
|                                         typeof this.dataModel[this.scoId][element].defaultvalue != typeof(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 {string} el The element | ||||
|      * @return {any} The element value | ||||
|      */ | ||||
|     protected getEl(el: string): any { | ||||
|         if (typeof this.currentUserData[this.scoId] != 'undefined' && typeof this.currentUserData[this.scoId][el] != 'undefined') { | ||||
|             return this.currentUserData[this.scoId][el]; | ||||
|         } | ||||
| 
 | ||||
|         return ''; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize the model. | ||||
|      * | ||||
|      * @param {any} userData The user default data. | ||||
|      */ | ||||
|     protected init(userData: any): 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: this.CMI_CHILDREN, mod: 'r', writeerror: '402' }, | ||||
|                 'cmi._version': { defaultvalue: '3.4', mod: 'r', writeerror: '402' }, | ||||
|                 'cmi.core._children': { defaultvalue: this.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: this.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: this.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: this.SCORE_CHILDREN, mod: 'r', writeerror: '402' }, | ||||
|                 'cmi.core.score.raw': { defaultvalue: this.def[scoId]['cmi.core.score.raw'], format: this.CMI_DECIMAL, | ||||
|                         range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.core.score.max': { defaultvalue: this.def[scoId]['cmi.core.score.max'], format: this.CMI_DECIMAL, | ||||
|                         range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.core.score.min': { defaultvalue: this.def[scoId]['cmi.core.score.min'], format: this.CMI_DECIMAL, | ||||
|                         range: this.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: this.CMI_EXIT, mod: 'w', | ||||
|                         readerror: '404', writeerror: '405' }, | ||||
|                 'cmi.core.session_time': { format: this.CMI_TIMESPAN, mod: 'w', defaultvalue: '00:00:00', readerror: '404', | ||||
|                         writeerror: '405' }, | ||||
|                 'cmi.suspend_data': { defaultvalue: this.def[scoId]['cmi.suspend_data'], format: this.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: this.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: this.COMMENTS_CHILDREN, mod: 'r', writeerror: '402' }, | ||||
|                 'cmi.evaluation.comments.n.content': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_STRING_256, | ||||
|                         mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.evaluation.comments.n.location': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_STRING_256, | ||||
|                         mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.evaluation.comments.n.time': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_TIME, | ||||
|                         mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.comments_from_lms': { mod: 'r', writeerror: '403' }, | ||||
|                 'cmi.objectives._children': { defaultvalue: this.OBJECTIVES_CHILDREN, mod: 'r', writeerror: '402' }, | ||||
|                 'cmi.objectives._count': { mod: 'r', defaultvalue: '0', writeerror: '402' }, | ||||
|                 'cmi.objectives.n.id': { pattern: this.CMI_INDEX, format: this.CMI_IDENTIFIER, mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.objectives.n.score._children': { pattern: this.CMI_INDEX, mod: 'r', writeerror: '402' }, | ||||
|                 'cmi.objectives.n.score.raw': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_DECIMAL, | ||||
|                         range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.objectives.n.score.min': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_DECIMAL, | ||||
|                         range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.objectives.n.score.max': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_DECIMAL, | ||||
|                         range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.objectives.n.status': { pattern: this.CMI_INDEX, format: this.CMI_STATUS_2, mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.student_data._children': { defaultvalue: this.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: this.STUDENT_PREFERENCE_CHILDREN, mod: 'r', | ||||
|                         writeerror: '402' }, | ||||
|                 'cmi.student_preference.audio': { defaultvalue: this.def[scoId]['cmi.student_preference.audio'], | ||||
|                         format: this.CMI_SINTEGER, range: this.AUDIO_RANGE, mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.student_preference.language': { defaultvalue: this.def[scoId]['cmi.student_preference.language'], | ||||
|                         format: this.CMI_STRING_256, mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.student_preference.speed': { defaultvalue: this.def[scoId]['cmi.student_preference.speed'], | ||||
|                         format: this.CMI_SINTEGER, range: this.SPEED_RANGE, mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.student_preference.text': { defaultvalue: this.def[scoId]['cmi.student_preference.text'], | ||||
|                         format: this.CMI_SINTEGER, range: this.TEXT_RANGE, mod: 'rw', writeerror: '405' }, | ||||
|                 'cmi.interactions._children': { defaultvalue: this.INTERACTIONS_CHILDREN, mod: 'r', writeerror: '402' }, | ||||
|                 'cmi.interactions._count': { mod: 'r', defaultvalue: '0', writeerror: '402' }, | ||||
|                 'cmi.interactions.n.id': { pattern: this.CMI_INDEX, format: this.CMI_IDENTIFIER, mod: 'w', readerror: '404', | ||||
|                         writeerror: '405' }, | ||||
|                 'cmi.interactions.n.objectives._count': { pattern: this.CMI_INDEX, mod: 'r', defaultvalue: '0', writeerror: '402' }, | ||||
|                 'cmi.interactions.n.objectives.n.id': { pattern: this.CMI_INDEX, format: this.CMI_IDENTIFIER, mod: 'w', | ||||
|                         readerror: '404', writeerror: '405' }, | ||||
|                 'cmi.interactions.n.time': { pattern: this.CMI_INDEX, format: this.CMI_TIME, mod: 'w', readerror: '404', | ||||
|                         writeerror: '405' }, | ||||
|                 'cmi.interactions.n.type': { pattern: this.CMI_INDEX, format: this.CMI_TYPE, mod: 'w', readerror: '404', | ||||
|                         writeerror: '405' }, | ||||
|                 'cmi.interactions.n.correct_responses._count': { pattern: this.CMI_INDEX, mod: 'r', defaultvalue: '0', | ||||
|                         writeerror: '402' }, | ||||
|                 'cmi.interactions.n.correct_responses.n.pattern': { pattern: this.CMI_INDEX, format: this.CMI_FEEDBACK, | ||||
|                         mod: 'w', readerror: '404', writeerror: '405' }, | ||||
|                 'cmi.interactions.n.weighting': { pattern: this.CMI_INDEX, format: this.CMI_DECIMAL, | ||||
|                         range: this.WEIGHTING_RANGE, mod: 'w', readerror: '404', writeerror: '405' }, | ||||
|                 'cmi.interactions.n.student_response': { pattern: this.CMI_INDEX, format: this.CMI_FEEDBACK, mod: 'w', | ||||
|                         readerror: '404', writeerror: '405' }, | ||||
|                 'cmi.interactions.n.result': { pattern: this.CMI_INDEX, format: this.CMI_RESULT, mod: 'w', readerror: '404', | ||||
|                         writeerror: '405' }, | ||||
|                 'cmi.interactions.n.latency': { pattern: this.CMI_INDEX, format: this.CMI_TIMESPAN, mod: 'w', | ||||
|                         readerror: '404', writeerror: '405' }, | ||||
|                 'nav.event': { defaultvalue: '', format: this.NAV_EVENT, mod: 'w', readerror: '404', writeerror: '405' } | ||||
|             }; | ||||
| 
 | ||||
|             this.currentUserData[scoId] = {}; | ||||
| 
 | ||||
|             // Load default values.
 | ||||
|             for (const element in this.dataModel[scoId]) { | ||||
|                 if (element.match(/\.n\./) === null) { | ||||
|                     if (typeof this.dataModel[scoId][element].defaultvalue != 'undefined') { | ||||
|                         this.currentUserData[scoId][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 (typeof this.dataModel[scoId][element].defaultvalue != 'undefined') { | ||||
|                         this.currentUserData[scoId][element] = this.dataModel[scoId][element].defaultvalue; | ||||
|                     } else if (typeof this.defExtra[scoId][element] != 'undefined') { | ||||
|                         // Check in user data values.
 | ||||
|                         this.currentUserData[scoId][element] = this.defExtra[scoId][element]; | ||||
|                     } else { | ||||
|                         this.currentUserData[scoId][element] = ''; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             // Load interactions and objectives, and init the counters.
 | ||||
|             const expression = new RegExp(this.CMI_INDEX, 'g'); | ||||
| 
 | ||||
|             for (const element in this.defExtra[scoId]) { | ||||
|                 let counterElement = '', | ||||
|                     currentCounterIndex: any = 0, | ||||
|                     elementDotFormat, | ||||
|                     currentN; | ||||
| 
 | ||||
|                 // 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.
 | ||||
|                     elementDotFormat = element.replace(expression, '.$1.'); | ||||
|                     this.currentUserData[scoId][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]; | ||||
|                     } else if (elementDotFormat.indexOf('cmi.objectives') === 0) { | ||||
|                         counterElement = 'cmi.objectives._count'; | ||||
|                         currentCounterIndex = elementDotFormat.match(/.(\d+)./)[1]; | ||||
|                     } else if (elementDotFormat.indexOf('cmi.interactions') === 0) { | ||||
|                         if (elementDotFormat.indexOf('.objectives.') > 0) { | ||||
|                             currentN = elementDotFormat.match(/cmi.interactions.(\d+)./)[1]; | ||||
|                             currentCounterIndex = elementDotFormat.match(/objectives.(\d+)./)[1]; | ||||
|                             counterElement = 'cmi.interactions.' + currentN + '.objectives._count'; | ||||
|                         } else if (elementDotFormat.indexOf('.correct_responses.') > 0) { | ||||
|                             currentN = elementDotFormat.match(/cmi.interactions.(\d+)./)[1]; | ||||
|                             currentCounterIndex = elementDotFormat.match(/correct_responses.(\d+)./)[1]; | ||||
|                             counterElement = 'cmi.interactions.' + currentN + '.correct_responses._count'; | ||||
|                         } else { | ||||
|                             counterElement = 'cmi.interactions._count'; | ||||
|                             currentCounterIndex = elementDotFormat.match(/.(\d+)./)[1]; | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     if (counterElement) { | ||||
|                         if (typeof this.currentUserData[scoId][counterElement] == 'undefined') { | ||||
|                             this.currentUserData[scoId][counterElement] = 0; | ||||
|                         } | ||||
|                         // Check if we need to sum.
 | ||||
|                         if (parseInt(currentCounterIndex) == parseInt(this.currentUserData[scoId][counterElement])) { | ||||
|                             this.currentUserData[scoId][counterElement] = parseInt(this.currentUserData[scoId][counterElement]) + 1; | ||||
|                         } | ||||
|                         if (parseInt(currentCounterIndex) > parseInt(this.currentUserData[scoId][counterElement])) { | ||||
|                             this.currentUserData[scoId][counterElement] = parseInt(currentCounterIndex) - 1; | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             // Set default status.
 | ||||
|             if (this.currentUserData[scoId]['cmi.core.lesson_status'] === '') { | ||||
|                 this.currentUserData[scoId]['cmi.core.lesson_status'] = 'not attempted'; | ||||
|             } | ||||
| 
 | ||||
|             // Define mode and credit.
 | ||||
|             this.currentUserData[scoId]['cmi.core.credit'] = this.mode == AddonModScormProvider.MODENORMAL ? 'credit' : 'no-credit'; | ||||
|             this.currentUserData[scoId]['cmi.core.lesson_mode'] = this.mode; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Commit the changes. | ||||
|      * | ||||
|      * @param {string} param Param. | ||||
|      * @return {string} "true" if success, "false" otherwise. | ||||
|      */ | ||||
|     LMSCommit(param: string): string { | ||||
|         if (this.timeout) { | ||||
|             clearTimeout(this.timeout); | ||||
|             this.timeout = null; | ||||
|         } | ||||
| 
 | ||||
|         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 {string} param Param. | ||||
|      * @return {string} "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 == '1') { | ||||
|                         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  {string} param Param. | ||||
|      * @return {string} Result. | ||||
|      */ | ||||
|     LMSGetDiagnostic(param: string): string { | ||||
|         if (param == '') { | ||||
|             param = this.errorCode; | ||||
|         } | ||||
| 
 | ||||
|         return param; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the error message for a certain code. | ||||
|      * | ||||
|      * @param {string} param Error code. | ||||
|      * @return {string} Error message. | ||||
|      */ | ||||
|     LMSGetErrorString(param: string): string { | ||||
|         if (param != '') { | ||||
|             return this.ERROR_STRINGS[param]; | ||||
|         } else { | ||||
|            return ''; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the last error code. | ||||
|      * | ||||
|      * @return {string} Last error code. | ||||
|      */ | ||||
|     LMSGetLastError(): string { | ||||
|         return this.errorCode; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the value of a certain element. | ||||
|      * | ||||
|      * @param {string} element Name of the element to get. | ||||
|      * @return {string} Value. | ||||
|      */ | ||||
|     LMSGetValue(element: string): string { | ||||
|         this.errorCode = '0'; | ||||
| 
 | ||||
|         if (this.initialized) { | ||||
|             if (element != '') { | ||||
|                 const expression = new RegExp(this.CMI_INDEX, 'g'), | ||||
|                     elementModel = String(element).replace(expression, '.n.'); | ||||
| 
 | ||||
|                 if (typeof 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; | ||||
|                     } | ||||
|                 } else { | ||||
|                     const childrenStr = '._children', | ||||
|                         countStr = '._count'; | ||||
| 
 | ||||
|                     if (elementModel.substr(elementModel.length - childrenStr.length, elementModel.length) == childrenStr) { | ||||
|                         const parentModel = elementModel.substr(0, elementModel.length - childrenStr.length); | ||||
| 
 | ||||
|                         if (typeof 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 (typeof 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 {string} param Param. | ||||
|      * @return {string} "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 {string} element Name of the element to set. | ||||
|      * @param {any} value Value to set. | ||||
|      * @return {string} "true" if success, "false" otherwise. | ||||
|      */ | ||||
|     LMSSetValue(element: string, value: any): string { | ||||
|         this.errorCode = '0'; | ||||
| 
 | ||||
|         if (this.initialized) { | ||||
|             if (element != '') { | ||||
|                 let expression = new RegExp(this.CMI_INDEX, 'g'); | ||||
|                 const elementModel = String(element).replace(expression, '.n.'); | ||||
| 
 | ||||
|                 if (typeof 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], | ||||
|                                         counterElement = 'cmi.objectives.' + currentN + '.score'; | ||||
| 
 | ||||
|                                     if (typeof this.currentUserData[this.scoId][counterElement + '._children'] == 'undefined') { | ||||
|                                         this.setEl(this.currentUserData[this.scoId][counterElement + '._children'], | ||||
|                                                 this.SCORE_CHILDREN); | ||||
|                                         this.setEl(this.currentUserData[this.scoId][counterElement + '.raw'], ''); | ||||
|                                         this.setEl(this.currentUserData[this.scoId][counterElement + '.min'], ''); | ||||
|                                         this.setEl(this.currentUserData[this.scoId][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 (typeof this.currentUserData[this.scoId][counterElement] == 'undefined') { | ||||
|                                         this.setEl(counterElement, 0); | ||||
|                                     } | ||||
| 
 | ||||
|                                     counterElement = 'cmi.interactions.' + currentN + '.correct_responses._count'; | ||||
|                                     if (typeof this.currentUserData[this.scoId][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 (typeof this.currentUserData[this.scoId][counterElement] == 'undefined') { | ||||
|                                             this.setEl(counterElement, 0); | ||||
|                                         } | ||||
| 
 | ||||
|                                         if (elementIndexes[i + 1] == this.getEl(counterElement)) { | ||||
|                                             const count = this.getEl(counterElement); | ||||
|                                             this.setEl(counterElement, parseInt(count, 10) + 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 = setTimeout(this.LMSCommit.bind(this), 60000, ['']); | ||||
|                                 } | ||||
| 
 | ||||
|                                 if (typeof this.dataModel[this.scoId][elementModel].range != 'undefined') { | ||||
|                                     const range = this.dataModel[this.scoId][elementModel].range, | ||||
|                                         ranges = range.split('#'); | ||||
| 
 | ||||
|                                     value = value * 1.0; | ||||
|                                     if ((value >= ranges[0]) && (value <= ranges[1])) { | ||||
|                                         this.setEl(element, 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 {number} 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 {string} el The element. | ||||
|      * @param {any} value The value. | ||||
|      */ | ||||
|     protected setEl(el: string, value: any): void { | ||||
|         if (typeof this.currentUserData[this.scoId] == 'undefined') { | ||||
|             this.currentUserData[this.scoId] = {}; | ||||
|         } | ||||
| 
 | ||||
|         this.currentUserData[this.scoId][el] = value; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Set offline mode to true or false. | ||||
|      * | ||||
|      * @param {boolean} 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 {boolean} storeTotalTime If true, we need to calculate the total time too. | ||||
|      * @return {boolean} True if success, false otherwise. | ||||
|      */ | ||||
|     protected storeData(storeTotalTime?: boolean): boolean { | ||||
|         let tracks; | ||||
| 
 | ||||
|         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 success = this.scormProvider.saveTracksSync(this.scoId, this.attempt, tracks, this.scorm, this.offline, | ||||
|                 this.currentUserData); | ||||
| 
 | ||||
|         if (!this.offline && !success) { | ||||
|             // Failure storing data in online. Go offline.
 | ||||
|             this.offline = true; | ||||
| 
 | ||||
|             this.triggerEvent(AddonModScormProvider.GO_OFFLINE_EVENT); | ||||
| 
 | ||||
|             return this.scormProvider.saveTracksSync(this.scoId, this.attempt, tracks, this.scorm, this.offline, | ||||
|                     this.currentUserData); | ||||
|         } | ||||
| 
 | ||||
|         return success; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Utility function for calculating the total time spent in the SCO. | ||||
|      * | ||||
|      * @return {any} Total time element. | ||||
|      */ | ||||
|     protected totalTime(): any { | ||||
|         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 {string} name Name of the event to trigger. | ||||
|      */ | ||||
|     protected triggerEvent(name: string): void { | ||||
|         this.eventsProvider.trigger(name, { | ||||
|             scormId: this.scorm.id, | ||||
|             scoId: this.scoId, | ||||
|             attempt: this.attempt | ||||
|         }, this.siteId); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										49
									
								
								src/addon/mod/scorm/components/components.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,49 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // 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 { CommonModule } from '@angular/common'; | ||||
| import { IonicModule } from 'ionic-angular'; | ||||
| import { TranslateModule } from '@ngx-translate/core'; | ||||
| import { CoreComponentsModule } from '@components/components.module'; | ||||
| import { CoreDirectivesModule } from '@directives/directives.module'; | ||||
| import { CoreCourseComponentsModule } from '@core/course/components/components.module'; | ||||
| import { AddonModScormIndexComponent } from './index/index'; | ||||
| import { AddonModScormTocPopoverComponent } from './toc-popover/toc-popover'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     declarations: [ | ||||
|         AddonModScormIndexComponent, | ||||
|         AddonModScormTocPopoverComponent | ||||
|     ], | ||||
|     imports: [ | ||||
|         CommonModule, | ||||
|         IonicModule, | ||||
|         TranslateModule.forChild(), | ||||
|         CoreComponentsModule, | ||||
|         CoreDirectivesModule, | ||||
|         CoreCourseComponentsModule | ||||
|     ], | ||||
|     providers: [ | ||||
|     ], | ||||
|     exports: [ | ||||
|         AddonModScormIndexComponent, | ||||
|         AddonModScormTocPopoverComponent | ||||
|     ], | ||||
|     entryComponents: [ | ||||
|         AddonModScormIndexComponent, | ||||
|         AddonModScormTocPopoverComponent | ||||
|     ] | ||||
| }) | ||||
| export class AddonModScormComponentsModule {} | ||||
							
								
								
									
										170
									
								
								src/addon/mod/scorm/components/index/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,170 @@ | ||||
| <!-- Buttons to add to the header. --> | ||||
| <core-navbar-buttons end> | ||||
|     <core-context-menu> | ||||
|         <core-context-menu-item *ngIf="externalUrl" [priority]="900" [content]="'core.openinbrowser' | translate" [href]="externalUrl" [iconAction]="'open'"></core-context-menu-item> | ||||
|         <core-context-menu-item *ngIf="description" [priority]="800" [content]="'core.moduleintro' | translate" (action)="expandDescription()" [iconAction]="'arrow-forward'"></core-context-menu-item> | ||||
|         <core-context-menu-item *ngIf="loaded && !hasOffline && isOnline" [priority]="700" [content]="'core.refresh' | translate" (action)="doRefresh(null, $event)" [iconAction]="refreshIcon" [closeOnClick]="false"></core-context-menu-item> | ||||
|         <core-context-menu-item *ngIf="loaded && hasOffline && isOnline"  [priority]="600" [content]="'core.settings.synchronizenow' | translate" (action)="doRefresh(null, $event, true)" [iconAction]="syncIcon" [closeOnClick]="false"></core-context-menu-item> | ||||
|         <core-context-menu-item *ngIf="prefetchStatusIcon" [priority]="500" [content]="prefetchText" (action)="prefetch()" [iconAction]="prefetchStatusIcon" [closeOnClick]="false"></core-context-menu-item> | ||||
|         <core-context-menu-item *ngIf="size" [priority]="400" [content]="size" [iconDescription]="'cube'" (action)="removeFiles()" [iconAction]="'trash'"></core-context-menu-item> | ||||
|     </core-context-menu> | ||||
| </core-navbar-buttons> | ||||
| 
 | ||||
| <!-- Content. --> | ||||
| <core-loading [hideUntil]="loaded" class="core-loading-center"> | ||||
| 
 | ||||
|     <core-course-module-description [description]="description" [component]="component" [componentId]="componentId"></core-course-module-description> | ||||
| 
 | ||||
|     <!-- Warning message. --> | ||||
|     <div *ngIf="scorm && scorm.warningMessage" class="core-info-card" icon-start> | ||||
|         <ion-icon name="information"></ion-icon> | ||||
|         {{ scorm.warningMessage }} | ||||
|     </div> | ||||
| 
 | ||||
|     <div *ngIf="scorm && loaded && !scorm.warningMessage"> | ||||
|         <!-- Attempts status. --> | ||||
|         <ion-card *ngIf="scorm.displayattemptstatus || Object.keys(scorm.offlineAttempts).length"> | ||||
|             <ion-card-header text-wrap> | ||||
|                 <h2>{{ 'addon.mod_scorm.attempts' | translate }}</h2> | ||||
|             </ion-card-header> | ||||
|             <ion-list> | ||||
|                 <ng-container *ngIf="scorm.displayattemptstatus"> | ||||
|                     <ion-item text-wrap *ngIf="scorm.maxattempt >= 0"> | ||||
|                         <p class="item-heading">{{ 'addon.mod_scorm.noattemptsallowed' | translate }}</p> | ||||
|                         <p *ngIf="scorm.maxattempt == 0">{{ 'core.unlimited' | translate }}</p> | ||||
|                         <p *ngIf="scorm.maxattempt > 0">{{ scorm.maxattempt }}</p> | ||||
|                     </ion-item> | ||||
|                     <ion-item text-wrap *ngIf="scorm.numAttempts >= 0"> | ||||
|                         <p class="item-heading">{{ 'addon.mod_scorm.noattemptsmade' | translate }}</p> | ||||
|                         <p>{{ scorm.numAttempts }}</p> | ||||
|                     </ion-item> | ||||
|                     <ion-item text-wrap *ngFor="let attempt of scorm.onlineAttempts"> | ||||
|                         <p class="item-heading">{{ 'addon.mod_scorm.gradeforattempt' | translate }} {{attempt.number}}</p> | ||||
|                         <p *ngIf="attempt.grade != -1">{{ attempt.grade }}</p> | ||||
|                         <p *ngIf="attempt.grade == -1">{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}</p> | ||||
|                     </ion-item> | ||||
|                 </ng-container> | ||||
|                 <ion-item text-wrap *ngFor="let attempt of scorm.offlineAttempts"> | ||||
|                     <p class="item-heading">{{ 'addon.mod_scorm.gradeforattempt' | translate }} {{attempt.number}}</p> | ||||
|                     <p *ngIf="attempt.grade != -1">{{ attempt.grade }}</p> | ||||
|                     <p *ngIf="attempt.grade == -1">{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}</p> | ||||
|                     <p *ngIf="scorm.maxattempt == 0 || attempt.number <= scorm.maxattempt">{{ 'addon.mod_scorm.offlineattemptnote' | translate }}</p> | ||||
|                     <p *ngIf="scorm.maxattempt != 0 && attempt.number > scorm.maxattempt">{{ 'addon.mod_scorm.offlineattemptovermax' | translate }}</p> | ||||
|                 </ion-item> | ||||
|                 <ion-item text-wrap *ngIf="scorm.displayattemptstatus && scorm.gradeMethodReadable"> | ||||
|                     <p class="item-heading">{{ 'addon.mod_scorm.grademethod' | translate }}</p> | ||||
|                     <p>{{ scorm.gradeMethodReadable }}</p> | ||||
|                 </ion-item> | ||||
|                 <ion-item text-wrap *ngIf="scorm.displayattemptstatus && scorm.grade"> | ||||
|                     <p class="item-heading">{{ 'addon.mod_scorm.gradereported' | translate }}</p> | ||||
|                     <p *ngIf="scorm.grade != -1">{{ scorm.grade }}</p> | ||||
|                     <p *ngIf="scorm.grade == -1">{{ 'addon.mod_scorm.cannotcalculategrade' | translate }}</p> | ||||
|                 </ion-item> | ||||
|                 <ion-item text-wrap *ngIf="syncTime"> | ||||
|                     <p class="item-heading">{{ 'core.lastsync' | translate }}</p> | ||||
|                     <p>{{ syncTime }}</p> | ||||
|                 </ion-item> | ||||
|             </ion-list> | ||||
|         </ion-card> | ||||
| 
 | ||||
|         <!-- Synchronization warning. --> | ||||
|         <div class="core-warning-card" icon-start *ngIf="!errorMessage && hasOffline"> | ||||
|             <ion-icon name="warning"></ion-icon> | ||||
|             {{ 'core.hasdatatosync' | translate: {$a: moduleName} }} | ||||
|         </div> | ||||
| 
 | ||||
|         <!-- TOC. --> | ||||
|         <ion-card *ngIf="scorm && organizations && ((scorm.displaycoursestructure && organizations.length) || organizations.length > 1)" class="addon-mod_scorm-toc"> | ||||
|             <ion-card-header text-wrap> | ||||
|                 <h2>{{ 'addon.mod_scorm.contents' | translate }}</h2> | ||||
|             </ion-card-header> | ||||
|             <ion-list> | ||||
|                 <ion-item text-wrap *ngIf="organizations.length > 1"> | ||||
|                     <ion-label>{{ 'addon.mod_scorm.organizations' | translate }}</ion-label> | ||||
|                     <ion-select [(ngModel)]="currentOrganization.identifier" (ionChange)="loadOrganization()" interface="popover"> | ||||
|                         <ion-option *ngFor="let org of organizations" [value]="org.identifier">{{ org.title }}</ion-option> | ||||
|                     </ion-select> | ||||
|                 </ion-item> | ||||
|                 <ion-item text-center *ngIf="scorm.displaycoursestructure && loadingToc"> | ||||
|                     <ion-spinner></ion-spinner> | ||||
|                 </ion-item> | ||||
|                 <ion-item text-wrap *ngIf="scorm.displaycoursestructure && !loadingToc"> | ||||
|                     <!-- If data shown doesn't belong to last attempt, show a warning. --> | ||||
|                     <p *ngIf="attemptToContinue">{{ 'addon.mod_scorm.dataattemptshown' | translate:{number: attemptToContinue} }}</p> | ||||
|                     <p>{{ currentOrganization.title }}</p> | ||||
|                     <div *ngFor="let sco of toc" class="core-padding-{{sco.level}}"> | ||||
|                         <p *ngIf="sco.isvisible"> | ||||
|                             <img [src]="sco.image.url" [alt]="sco.image.description" /> | ||||
|                             <a *ngIf="sco.prereq && sco.launch" (click)="open($event, sco.id)">{{ sco.title }}</a> | ||||
|                             <span *ngIf="!sco.prereq || !sco.launch">{{ sco.title }}</span> | ||||
|                         </p> | ||||
|                     </div> | ||||
|                 </ion-item> | ||||
|             </ion-list> | ||||
|         </ion-card> | ||||
| 
 | ||||
|         <!-- Open in browser button. --> | ||||
|         <ion-card *ngIf="errorMessage"> | ||||
|             <ion-item text-wrap> | ||||
|                 <p class="text-danger">{{ errorMessage | translate }}</p> | ||||
|             </ion-item> | ||||
|             <ion-item text-wrap> | ||||
|                 <a ion-button block icon-end [href]="externalUrl" core-link> | ||||
|                     {{ 'core.openinbrowser' | translate }} | ||||
|                     <ion-icon name="open"></ion-icon> | ||||
|                 </a> | ||||
|             </ion-item> | ||||
|         </ion-card> | ||||
| 
 | ||||
|         <!-- Warning that user doesn't have any more attempts. --> | ||||
|         <ion-card *ngIf="!errorMessage && scorm && scorm.attemptsLeft <= 0"> | ||||
|             <ion-item text-wrap> | ||||
|                 <p class="text-danger">{{ 'addon.mod_scorm.exceededmaxattempts' | translate }}</p> | ||||
|             </ion-item> | ||||
|         </ion-card> | ||||
| 
 | ||||
|         <!-- Open SCORM in app form --> | ||||
|         <ion-card *ngIf="!errorMessage && scorm && (!scorm.lastattemptlock || scorm.attemptsLeft > 0)"> | ||||
|             <ion-list> | ||||
|                 <!-- Open mode (Preview or Normal) --> | ||||
|                 <div *ngIf="!scorm.hidebrowse" radio-group [(ngModel)]="scormOptions.mode" name="mode"> | ||||
|                     <ion-item> | ||||
|                         <p class="item-heading">{{ 'addon.mod_scorm.mode' | translate }}</p> | ||||
|                     </ion-item> | ||||
|                     <ion-item> | ||||
|                         <ion-label>{{ 'addon.mod_scorm.browse' | translate }}</ion-label> | ||||
|                         <ion-radio [value]="modeBrowser"></ion-radio> | ||||
|                     </ion-item> | ||||
|                     <ion-item> | ||||
|                         <ion-label>{{ 'addon.mod_scorm.normal' | translate }}</ion-label> | ||||
|                         <ion-radio [value]="modeNormal"></ion-radio> | ||||
|                     </ion-item> | ||||
|                 </div> | ||||
| 
 | ||||
|                 <!-- Create new attempt --> | ||||
|                 <ion-item text-wrap *ngIf="!scorm.forcenewattempt && scorm.numAttempts > 0 && !scorm.incomplete && scorm.attemptsLeft > 0"> | ||||
|                     <ion-label>{{ 'addon.mod_scorm.newattempt' | translate }}</ion-label> | ||||
|                     <ion-checkbox item-end name="newAttempt" [(ngModel)]="scormOptions.newAttempt"> | ||||
|                     </ion-checkbox> | ||||
|                 </ion-item> | ||||
| 
 | ||||
|                 <!-- Button to open the SCORM. --> | ||||
|                 <ng-container *ngIf="!downloading"> | ||||
|                     <ion-item text-wrap *ngIf="statusMessage"> | ||||
|                         <p >{{ statusMessage | translate }}</p> | ||||
|                     </ion-item> | ||||
|                     <ion-item text-wrap> | ||||
|                         <a ion-button block (click)="open($event)">{{ 'addon.mod_scorm.enter' | translate }}</a> | ||||
|                     </ion-item> | ||||
|                 </ng-container> | ||||
| 
 | ||||
|                 <!-- Download progress. --> | ||||
|                 <ion-item text-center *ngIf="downloading"> | ||||
|                     <ion-spinner></ion-spinner> | ||||
|                     <p *ngIf="progressMessage">{{ progressMessage | translate }}</p> | ||||
|                     <p *ngIf="percentage <= 100">{{ 'core.percentagenumber' | translate:{$a: percentage} }}</p> | ||||
|                 </ion-item> | ||||
|             </ion-list> | ||||
|         </ion-card> | ||||
|     </div> | ||||
| </core-loading> | ||||
							
								
								
									
										9
									
								
								src/addon/mod/scorm/components/index/index.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,9 @@ | ||||
| addon-mod-scorm-index { | ||||
| 
 | ||||
|     .addon-mod_scorm-toc { | ||||
|         img { | ||||
|             width: auto; | ||||
|             display: inline; | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										531
									
								
								src/addon/mod/scorm/components/index/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,531 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, Optional, Injector } from '@angular/core'; | ||||
| import { Content, NavController } from 'ionic-angular'; | ||||
| import { CoreUtilsProvider } from '@providers/utils/utils'; | ||||
| import { CoreCourseModuleMainActivityComponent } from '@core/course/classes/main-activity-component'; | ||||
| import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; | ||||
| import { AddonModScormProvider, AddonModScormAttemptCountResult } from '../../providers/scorm'; | ||||
| import { AddonModScormHelperProvider } from '../../providers/helper'; | ||||
| import { AddonModScormOfflineProvider } from '../../providers/scorm-offline'; | ||||
| import { AddonModScormSyncProvider } from '../../providers/scorm-sync'; | ||||
| import { AddonModScormPrefetchHandler } from '../../providers/prefetch-handler'; | ||||
| import { CoreConstants } from '@core/constants'; | ||||
| 
 | ||||
| /** | ||||
|  * Component that displays a SCORM entry page. | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'addon-mod-scorm-index', | ||||
|     templateUrl: 'index.html', | ||||
| }) | ||||
| export class AddonModScormIndexComponent extends CoreCourseModuleMainActivityComponent { | ||||
|     component = AddonModScormProvider.COMPONENT; | ||||
|     moduleName = 'scorm'; | ||||
| 
 | ||||
|     scorm: any; // The SCORM object.
 | ||||
|     currentOrganization: any = {}; // Selected organization.
 | ||||
|     scormOptions: any = { // Options to open the SCORM.
 | ||||
|         mode: AddonModScormProvider.MODENORMAL, | ||||
|         newAttempt: false | ||||
|     }; | ||||
|     modeNormal = AddonModScormProvider.MODENORMAL; // Normal open mode.
 | ||||
|     modeBrowser = AddonModScormProvider.MODEBROWSE; // Browser open mode.
 | ||||
|     errorMessage: string; // Error message.
 | ||||
|     syncTime: string; // Last sync time.
 | ||||
|     hasOffline: boolean; // Whether the SCORM has offline data.
 | ||||
|     attemptToContinue: number; // The attempt to continue or review.
 | ||||
|     statusMessage: string; // Message about the status.
 | ||||
|     downloading: boolean; // Whether the SCORM is being downloaded.
 | ||||
|     percentage: string; // Download/unzip percentage.
 | ||||
|     progressMessage: string; // Message about download/unzip.
 | ||||
|     organizations: any[]; // List of organizations.
 | ||||
|     loadingToc: boolean; // Whether the TOC is being loaded.
 | ||||
|     toc: any[]; // Table of contents (structure).
 | ||||
| 
 | ||||
|     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: boolean; // Whether the last attempt is offline.
 | ||||
|     protected hasPlayed = false; // Whether the user has opened the player page.
 | ||||
| 
 | ||||
|     constructor(injector: Injector, protected scormProvider: AddonModScormProvider, @Optional() protected content: Content, | ||||
|             protected scormHelper: AddonModScormHelperProvider, protected scormOffline: AddonModScormOfflineProvider, | ||||
|             protected scormSync: AddonModScormSyncProvider, protected prefetchHandler: AddonModScormPrefetchHandler, | ||||
|             protected navCtrl: NavController, protected prefetchDelegate: CoreCourseModulePrefetchDelegate, | ||||
|             protected utils: CoreUtilsProvider) { | ||||
|         super(injector, content); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         super.ngOnInit(); | ||||
| 
 | ||||
|         this.loadContent(false, true).then(() => { | ||||
|             if (!this.scorm) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             this.scormProvider.logView(this.scorm.id).then(() => { | ||||
|                 this.checkCompletion(); | ||||
|             }).catch((error) => { | ||||
|                 // Ignore errors.
 | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check the completion. | ||||
|      */ | ||||
|     protected checkCompletion(): void { | ||||
|         this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Download a SCORM package or restores an ongoing download. | ||||
|      * | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected downloadScormPackage(): Promise<any> { | ||||
|         this.downloading = true; | ||||
| 
 | ||||
|         return this.prefetchHandler.download(this.module, this.courseId, undefined, (data) => { | ||||
|             if (!data) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             if (data.downloading) { | ||||
|                 // Downloading package.
 | ||||
|                 if (this.scorm.packagesize && data.progress) { | ||||
|                     this.percentage = (Number(data.progress.loaded / this.scorm.packagesize) * 100).toFixed(1); | ||||
|                 } | ||||
|             } else if (data.message) { | ||||
|                 // Show a message.
 | ||||
|                 this.progressMessage = data.message; | ||||
|                 this.percentage = undefined; | ||||
|             } else if (data.progress && data.progress.loaded && data.progress.total) { | ||||
|                 // Unzipping package.
 | ||||
|                 this.percentage = (Number(data.progress.loaded / data.progress.total) * 100).toFixed(1); | ||||
|             } else { | ||||
|                 this.percentage = undefined; | ||||
|             } | ||||
| 
 | ||||
|         }).finally(() => { | ||||
|             this.progressMessage = undefined; | ||||
|             this.percentage = undefined; | ||||
|             this.downloading = false; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the SCORM data. | ||||
|      * | ||||
|      * @param {boolean} [refresh=false] If it's refreshing content. | ||||
|      * @param {boolean} [sync=false] If the refresh is needs syncing. | ||||
|      * @param {boolean} [showErrors=false] If show errors to the user of hide them. | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise<any> { | ||||
| 
 | ||||
|         // Get the SCORM instance.
 | ||||
|         return this.scormProvider.getScorm(this.courseId, this.module.id, this.module.url).then((scormData) => { | ||||
|             this.scorm = scormData; | ||||
| 
 | ||||
|             this.dataRetrieved.emit(this.scorm); | ||||
|             this.description = this.scorm.intro || this.description; | ||||
| 
 | ||||
|             const result = this.scormProvider.isScormUnsupported(this.scorm); | ||||
|             if (result) { | ||||
|                 this.errorMessage = result; | ||||
|             } else { | ||||
|                 this.errorMessage = ''; | ||||
|             } | ||||
| 
 | ||||
|             if (this.scorm.warningMessage) { | ||||
|                 return; // SCORM is closed or not open yet, we can't get more data.
 | ||||
|             } | ||||
| 
 | ||||
|             let promise; | ||||
|             if (sync) { | ||||
|                 // Try to synchronize the assign.
 | ||||
|                 promise = this.syncActivity(showErrors).catch(() => { | ||||
|                     // Ignore errors.
 | ||||
|                 }); | ||||
|             } else { | ||||
|                 promise = Promise.resolve(); | ||||
|             } | ||||
| 
 | ||||
|             return promise.catch(() => { | ||||
|                 // Ignore errors, keep getting data even if sync fails.
 | ||||
|             }).then(() => { | ||||
| 
 | ||||
|                 // No need to return this promise, it should be faster than the rest.
 | ||||
|                 this.scormSync.getReadableSyncTime(this.scorm.id).then((syncTime) => { | ||||
|                     this.syncTime = syncTime; | ||||
|                 }); | ||||
| 
 | ||||
|                 // Get the number of attempts.
 | ||||
|                 return this.scormProvider.getAttemptCount(this.scorm.id); | ||||
|             }).then((attemptsData) => { | ||||
|                 this.attempts = attemptsData; | ||||
|                 this.hasOffline = !!this.attempts.offline.length; | ||||
| 
 | ||||
|                 // Determine the attempt that will be continued or reviewed.
 | ||||
|                 return this.scormHelper.determineAttemptToContinue(this.scorm, this.attempts); | ||||
|             }).then((attempt) => { | ||||
|                 this.lastAttempt = attempt.number; | ||||
|                 this.lastIsOffline = attempt.offline; | ||||
| 
 | ||||
|                 if (this.lastAttempt != this.attempts.lastAttempt.number) { | ||||
|                     this.attemptToContinue = this.lastAttempt; | ||||
|                 } else { | ||||
|                     this.attemptToContinue = undefined; | ||||
|                 } | ||||
| 
 | ||||
|                 // Check if the last attempt is incomplete.
 | ||||
|                 return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.lastAttempt, this.lastIsOffline); | ||||
|             }).then((incomplete) => { | ||||
|                 const promises = []; | ||||
| 
 | ||||
|                 this.scorm.incomplete = incomplete; | ||||
|                 this.scorm.numAttempts = this.attempts.total; | ||||
|                 this.scorm.gradeMethodReadable = this.scormProvider.getScormGradeMethod(this.scorm); | ||||
|                 this.scorm.attemptsLeft = this.scormProvider.countAttemptsLeft(this.scorm, this.attempts.lastAttempt.number); | ||||
|                 if (this.scorm.forceattempt && this.scorm.incomplete) { | ||||
|                     this.scormOptions.newAttempt = true; | ||||
|                 } | ||||
| 
 | ||||
|                 promises.push(this.getReportedGrades()); | ||||
| 
 | ||||
|                 promises.push(this.fetchStructure()); | ||||
| 
 | ||||
|                 if (!this.scorm.packagesize && this.errorMessage === '') { | ||||
|                     // SCORM is supported but we don't have package size. Try to calculate it.
 | ||||
|                     promises.push(this.scormProvider.calculateScormSize(this.scorm).then((size) => { | ||||
|                         this.scorm.packagesize = size; | ||||
|                     })); | ||||
|                 } | ||||
| 
 | ||||
|                 // Handle status.
 | ||||
|                 this.setStatusListener(); | ||||
| 
 | ||||
|                 return Promise.all(promises); | ||||
|             }); | ||||
|         }).then(() => { | ||||
|             // All data obtained, now fill the context menu.
 | ||||
|             this.fillContextMenu(refresh); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch the structure of the SCORM (TOC). | ||||
|      * | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected fetchStructure(): Promise<any> { | ||||
|         return this.scormProvider.getOrganizations(this.scorm.id).then((organizations) => { | ||||
|             this.organizations = organizations; | ||||
| 
 | ||||
|             if (!this.currentOrganization.identifier) { | ||||
|                 // Load first organization (if any).
 | ||||
|                 if (organizations.length) { | ||||
|                     this.currentOrganization.identifier = organizations[0].identifier; | ||||
|                 } else { | ||||
|                     this.currentOrganization.identifier = ''; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             return this.loadOrganizationToc(this.currentOrganization.identifier); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the grade of an attempt and add it to the scorm attempts list. | ||||
|      * | ||||
|      * @param {number} attempt The attempt number. | ||||
|      * @param {boolean} offline Whether it's an offline attempt. | ||||
|      * @param {any} attempts Object where to add the attempt. | ||||
|      * @return {Promise<void>} Promise resolved when done. | ||||
|      */ | ||||
|     protected getAttemptGrade(attempt: number, offline: boolean, attempts: any): Promise<void> { | ||||
|         return this.scormProvider.getAttemptGrade(this.scorm, attempt, offline).then((grade) => { | ||||
|             attempts[attempt] = { | ||||
|                 number: attempt, | ||||
|                 grade: grade | ||||
|             }; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the grades of each attempt and the grade of the SCORM. | ||||
|      * | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected getReportedGrades(): Promise<any> { | ||||
|         const promises = [], | ||||
|             onlineAttempts = {}, | ||||
|             offlineAttempts = {}; | ||||
| 
 | ||||
|         // Calculate the grade for each attempt.
 | ||||
|         this.attempts.online.forEach((attempt) => { | ||||
|             // Check that attempt isn't in offline to prevent showing the same attempt twice. Offline should be more recent.
 | ||||
|             if (this.attempts.offline.indexOf(attempt) == -1) { | ||||
|                 promises.push(this.getAttemptGrade(attempt, false, onlineAttempts)); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         this.attempts.offline.forEach((attempt) => { | ||||
|             promises.push(this.getAttemptGrade(attempt, true, offlineAttempts)); | ||||
|         }); | ||||
| 
 | ||||
|         return Promise.all(promises).then(() => { | ||||
| 
 | ||||
|             // Calculate the grade of the whole SCORM. We only use online attempts to calculate this data.
 | ||||
|             this.scorm.grade = this.scormProvider.calculateScormGrade(this.scorm, onlineAttempts); | ||||
| 
 | ||||
|             // Add the attempts to the SCORM in array format in ASC order, and format the grades.
 | ||||
|             this.scorm.onlineAttempts = this.utils.objectToArray(onlineAttempts); | ||||
|             this.scorm.offlineAttempts = this.utils.objectToArray(offlineAttempts); | ||||
|             this.scorm.onlineAttempts.sort((a, b) => { | ||||
|                 return a.number - b.number; | ||||
|             }); | ||||
|             this.scorm.offlineAttempts.sort((a, b) => { | ||||
|                 return a.number - b.number; | ||||
|             }); | ||||
| 
 | ||||
|             // Now format the grades.
 | ||||
|             this.scorm.onlineAttempts.forEach((attempt) => { | ||||
|                 attempt.grade = this.scormProvider.formatGrade(this.scorm, attempt.grade); | ||||
|             }); | ||||
|             this.scorm.offlineAttempts.forEach((attempt) => { | ||||
|                 attempt.grade = this.scormProvider.formatGrade(this.scorm, attempt.grade); | ||||
|             }); | ||||
| 
 | ||||
|             this.scorm.grade = this.scormProvider.formatGrade(this.scorm, this.scorm.grade); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Checks if sync has succeed from result sync data. | ||||
|      * | ||||
|      * @param  {any}     result Data returned on the sync function. | ||||
|      * @return {boolean}        If suceed or not. | ||||
|      */ | ||||
|     protected hasSyncSucceed(result: any): boolean { | ||||
|         if (result.updated) { | ||||
|             // Check completion status.
 | ||||
|             this.checkCompletion(); | ||||
|         } | ||||
| 
 | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * User entered the page that contains the component. | ||||
|      */ | ||||
|     ionViewDidEnter(): void { | ||||
|         super.ionViewDidEnter(); | ||||
| 
 | ||||
|         if (this.hasPlayed) { | ||||
|             this.hasPlayed = false; | ||||
|             this.scormOptions.newAttempt = false; // Uncheck new attempt.
 | ||||
| 
 | ||||
|             // Add a delay to make sure the player has started the last writing calls so we can detect conflicts.
 | ||||
|             setTimeout(() => { | ||||
|                 // Refresh data.
 | ||||
|                 this.showLoadingAndRefresh(true, false); | ||||
|             }, 500); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * User left the page that contains the component. | ||||
|      */ | ||||
|     ionViewDidLeave(): void { | ||||
|         super.ionViewDidLeave(); | ||||
| 
 | ||||
|         if (this.navCtrl.getActive().component.name == 'AddonModScormPlayerPage') { | ||||
|             this.hasPlayed = true; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Perform the invalidate content function. | ||||
|      * | ||||
|      * @return {Promise<any>} Resolved when done. | ||||
|      */ | ||||
|     protected invalidateContent(): Promise<any> { | ||||
|         const promises = []; | ||||
| 
 | ||||
|         promises.push(this.scormProvider.invalidateScormData(this.courseId)); | ||||
| 
 | ||||
|         if (this.scorm) { | ||||
|             promises.push(this.scormProvider.invalidateAllScormData(this.scorm.id)); | ||||
|         } | ||||
| 
 | ||||
|         return Promise.all(promises); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Compares sync event data with current data to check if refresh content is needed. | ||||
|      * | ||||
|      * @param {any} syncEventData Data receiven on sync observer. | ||||
|      * @return {boolean} True if refresh is needed, false otherwise. | ||||
|      */ | ||||
|     protected isRefreshSyncNeeded(syncEventData: any): 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. | ||||
|      */ | ||||
|     loadOrganization(): void { | ||||
|         this.loadOrganizationToc(this.currentOrganization.identifier).catch((error) => { | ||||
|             this.domUtils.showErrorModalDefault(error, this.fetchContentDefaultError, true); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load the TOC of a certain organization. | ||||
|      * | ||||
|      * @param {string} organizationId The organization id. | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected loadOrganizationToc(organizationId: string): Promise<any> { | ||||
|         if (!this.scorm.displaycoursestructure) { | ||||
|             // TOC is not displayed, no need to load it.
 | ||||
|             return Promise.resolve(); | ||||
|         } | ||||
| 
 | ||||
|         this.loadingToc = true; | ||||
| 
 | ||||
|         return this.scormProvider.getOrganizationToc(this.scorm.id, this.lastAttempt, organizationId, this.lastIsOffline) | ||||
|                 .then((toc) => { | ||||
| 
 | ||||
|             this.toc = this.scormProvider.formatTocToArray(toc); | ||||
| 
 | ||||
|             // Get images for each SCO.
 | ||||
|             this.toc.forEach((sco) => { | ||||
|                 sco.image = this.scormProvider.getScoStatusIcon(sco, this.scorm.incomplete); | ||||
|             }); | ||||
| 
 | ||||
|             // 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.
 | ||||
|     // The scoId param indicates the SCO that needs to be loaded when the SCORM is opened. If not defined, load first SCO.
 | ||||
|     open(e: Event, scoId: number): void { | ||||
|         e.preventDefault(); | ||||
|         e.stopPropagation(); | ||||
| 
 | ||||
|         if (this.downloading) { | ||||
|             // Scope is being downloaded, abort.
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const isOutdated = this.currentStatus == CoreConstants.OUTDATED; | ||||
| 
 | ||||
|         if (isOutdated || this.currentStatus == CoreConstants.NOT_DOWNLOADED) { | ||||
|             // SCORM needs to be downloaded.
 | ||||
|             this.scormHelper.confirmDownload(this.scorm, isOutdated).then(() => { | ||||
|                 // Invalidate WS data if SCORM is outdated.
 | ||||
|                 const promise = isOutdated ? this.scormProvider.invalidateAllScormData(this.scorm.id) : Promise.resolve(); | ||||
| 
 | ||||
|                 promise.finally(() => { | ||||
|                     this.downloadScormPackage().then(() => { | ||||
|                         // Success downloading, open SCORM if user hasn't left the view.
 | ||||
|                         if (!this.isDestroyed) { | ||||
|                             this.openScorm(scoId); | ||||
|                         } | ||||
|                     }).catch((error) => { | ||||
|                         if (!this.isDestroyed) { | ||||
|                             this.domUtils.showErrorModalDefault(error, this.translate.instant( | ||||
|                                     'addon.mod_scorm.errordownloadscorm', {name: this.scorm.name})); | ||||
|                         } | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
|         } else { | ||||
|             this.openScorm(scoId); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Open a SCORM package. | ||||
|      * | ||||
|      * @param {number} scoId SCO ID. | ||||
|      */ | ||||
|     protected openScorm(scoId: number): void { | ||||
|         this.navCtrl.push('AddonModScormPlayerPage', { | ||||
|             scorm: this.scorm, | ||||
|             mode: this.scormOptions.mode, | ||||
|             newAttempt: !!this.scormOptions.newAttempt, | ||||
|             organizationId: this.currentOrganization.identifier, | ||||
|             scoId: scoId | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Displays some data based on the current status. | ||||
|      * | ||||
|      * @param {string} status The current status. | ||||
|      * @param {string} [previousStatus] The previous status. If not defined, there is no previous status. | ||||
|      */ | ||||
|     protected showStatus(status: string, previousStatus?: string): void { | ||||
| 
 | ||||
|         if (status == CoreConstants.OUTDATED && this.scorm) { | ||||
|             // Only show the outdated message if the file should be downloaded.
 | ||||
|             this.scormProvider.shouldDownloadMainFile(this.scorm, true).then((download) => { | ||||
|                 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<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected sync(): Promise<any> { | ||||
|         return this.scormSync.syncScorm(this.scorm); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										19
									
								
								src/addon/mod/scorm/components/toc-popover/toc-popover.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,19 @@ | ||||
| <ion-list> | ||||
|     <ion-item text-wrap *ngIf="attemptToContinue"> | ||||
|         <p>{{ 'addon.mod_scorm.dataattemptshown' | translate:{number: attemptToContinue} }}</p> | ||||
|     </ion-item> | ||||
|     <ion-item text-center *ngIf="isBrowse"> | ||||
|         <p>{{ 'addon.mod_scorm.mod_scorm.browsemode' }}</p> | ||||
|     </ion-item> | ||||
|     <ion-item text-center *ngIf="isReview"> | ||||
|         <p>{{ 'addon.mod_scorm.mod_scorm.reviewmode' }}</p> | ||||
|     </ion-item> | ||||
| 
 | ||||
|     <!-- List of SCOs. --> | ||||
|     <ng-container *ngFor="let sco of toc"> | ||||
|         <a *ngIf="sco.isvisible" ion-item text-wrap [ngClass]="['core-padding-' + sco.level]" (click)="loadSco(sco)" [attr.disabled]="!sco.prereq || !sco.launch ? true : null" detail-none> | ||||
|             <img [src]="sco.image.url" [alt]="sco.image.description" /> | ||||
|             <span>{{ sco.title }}</span> | ||||
|         </a> | ||||
|     </ng-container> | ||||
| </ion-list> | ||||
| @ -0,0 +1,3 @@ | ||||
| addon-mod-scorm-toc-popover { | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										54
									
								
								src/addon/mod/scorm/components/toc-popover/toc-popover.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,54 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // 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 } from '@angular/core'; | ||||
| import { NavParams, ViewController } from 'ionic-angular'; | ||||
| import { AddonModScormProvider } from '../../providers/scorm'; | ||||
| 
 | ||||
| /** | ||||
|  * Component to display the TOC of a SCORM. | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'addon-mod-scorm-toc-popover', | ||||
|     templateUrl: 'toc-popover.html' | ||||
| }) | ||||
| export class AddonModScormTocPopoverComponent { | ||||
|     toc: any[]; | ||||
|     isBrowse: boolean; | ||||
|     isReview: boolean; | ||||
|     attemptToContinue: number; | ||||
| 
 | ||||
|     constructor(navParams: NavParams, private viewCtrl: ViewController) { | ||||
|         this.toc = navParams.get('toc') || []; | ||||
|         this.attemptToContinue = navParams.get('attemptToContinue'); | ||||
| 
 | ||||
|         const mode = navParams.get('mode'); | ||||
| 
 | ||||
|         this.isBrowse = mode === AddonModScormProvider.MODEBROWSE; | ||||
|         this.isReview = mode === AddonModScormProvider.MODEREVIEW; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Function called when a SCO is clicked. | ||||
|      * | ||||
|      * @param {any} sco Clicked SCO. | ||||
|      */ | ||||
|     loadSco(sco: any): void { | ||||
|         if (!sco.prereq || !sco.isvisible || !sco.launch) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.viewCtrl.dismiss(sco); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										51
									
								
								src/addon/mod/scorm/lang/en.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,51 @@ | ||||
| { | ||||
|     "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", | ||||
|     "mode": "Mode", | ||||
|     "newattempt": "Start a new attempt", | ||||
|     "noattemptsallowed": "Number of attempts allowed", | ||||
|     "noattemptsmade": "Number of attempts you have made", | ||||
|     "normal": "Normal", | ||||
|     "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", | ||||
|     "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", | ||||
|     "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." | ||||
| } | ||||
							
								
								
									
										16
									
								
								src/addon/mod/scorm/pages/index/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,16 @@ | ||||
| <ion-header> | ||||
|     <ion-navbar> | ||||
|         <ion-title><core-format-text [text]="title"></core-format-text></ion-title> | ||||
| 
 | ||||
|         <ion-buttons end> | ||||
|             <!-- The buttons defined by the component will be added in here. --> | ||||
|         </ion-buttons> | ||||
|     </ion-navbar> | ||||
| </ion-header> | ||||
| <ion-content> | ||||
|     <ion-refresher [enabled]="scormComponent.loaded" (ionRefresh)="scormComponent.doRefresh($event)"> | ||||
|         <ion-refresher-content pullingText="{{ 'core.pulltorefresh' | translate }}"></ion-refresher-content> | ||||
|     </ion-refresher> | ||||
| 
 | ||||
|     <addon-mod-scorm-index [module]="module" [courseId]="courseId" (dataRetrieved)="updateData($event)"></addon-mod-scorm-index> | ||||
| </ion-content> | ||||
							
								
								
									
										33
									
								
								src/addon/mod/scorm/pages/index/index.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,33 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // 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 { IonicPageModule } from 'ionic-angular'; | ||||
| import { TranslateModule } from '@ngx-translate/core'; | ||||
| import { CoreDirectivesModule } from '@directives/directives.module'; | ||||
| import { AddonModScormComponentsModule } from '../../components/components.module'; | ||||
| import { AddonModScormIndexPage } from './index'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     declarations: [ | ||||
|         AddonModScormIndexPage, | ||||
|     ], | ||||
|     imports: [ | ||||
|         CoreDirectivesModule, | ||||
|         AddonModScormComponentsModule, | ||||
|         IonicPageModule.forChild(AddonModScormIndexPage), | ||||
|         TranslateModule.forChild() | ||||
|     ], | ||||
| }) | ||||
| export class AddonModScormIndexPageModule {} | ||||
							
								
								
									
										62
									
								
								src/addon/mod/scorm/pages/index/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,62 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, ViewChild } from '@angular/core'; | ||||
| import { IonicPage, NavParams } from 'ionic-angular'; | ||||
| import { AddonModScormIndexComponent } from '../../components/index/index'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that displays the SCORM entry page. | ||||
|  */ | ||||
| @IonicPage({ segment: 'addon-mod-scorm-index' }) | ||||
| @Component({ | ||||
|     selector: 'page-addon-mod-scorm-index', | ||||
|     templateUrl: 'index.html', | ||||
| }) | ||||
| export class AddonModScormIndexPage { | ||||
|     @ViewChild(AddonModScormIndexComponent) scormComponent: AddonModScormIndexComponent; | ||||
| 
 | ||||
|     title: string; | ||||
|     module: any; | ||||
|     courseId: number; | ||||
| 
 | ||||
|     constructor(navParams: NavParams) { | ||||
|         this.module = navParams.get('module') || {}; | ||||
|         this.courseId = navParams.get('courseId'); | ||||
|         this.title = this.module.name; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Update some data based on the SCORM instance. | ||||
|      * | ||||
|      * @param {any} scorm SCORM instance. | ||||
|      */ | ||||
|     updateData(scorm: any): void { | ||||
|         this.title = scorm.name || this.title; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * User entered the page. | ||||
|      */ | ||||
|     ionViewDidEnter(): void { | ||||
|         this.scormComponent.ionViewDidEnter(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * User left the page. | ||||
|      */ | ||||
|     ionViewDidLeave(): void { | ||||
|         this.scormComponent.ionViewDidLeave(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										19
									
								
								src/addon/mod/scorm/pages/player/player.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,19 @@ | ||||
| <ion-header> | ||||
|     <ion-navbar> | ||||
|         <ion-title><core-format-text [text]="title"></core-format-text></ion-title> | ||||
| 
 | ||||
|         <ion-buttons end> | ||||
|             <button *ngIf="showToc && !loadingToc && toc && toc.length" ion-button icon-only (click)="openToc($event)"> | ||||
|                 <ion-icon name="bookmark"></ion-icon> | ||||
|             </button> | ||||
|             <ion-spinner *ngIf="showToc && loadingToc"></ion-spinner> | ||||
|         </ion-buttons> | ||||
|     </ion-navbar> | ||||
| </ion-header> | ||||
| <ion-content> | ||||
|     <core-loading [hideUntil]="loaded"> | ||||
|         <core-navigation-bar [previous]="previousSco" [next]="nextSco" (action)="loadSco($event)"></core-navigation-bar> | ||||
|         <core-iframe *ngIf="loaded && src" [src]="src" [iframeWidth]="scorm.popup ? scorm.width : undefined" [iframeHeight]="scorm.popup ? scorm.height : undefined"></core-iframe> | ||||
|         <p *ngIf="!src && errorMessage">{{ errorMessage | translate }}</p> | ||||
|     </core-loading> | ||||
| </ion-content> | ||||
							
								
								
									
										33
									
								
								src/addon/mod/scorm/pages/player/player.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,33 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // 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 { IonicPageModule } from 'ionic-angular'; | ||||
| import { TranslateModule } from '@ngx-translate/core'; | ||||
| import { CoreComponentsModule } from '@components/components.module'; | ||||
| import { CoreDirectivesModule } from '@directives/directives.module'; | ||||
| import { AddonModScormPlayerPage } from './player'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     declarations: [ | ||||
|         AddonModScormPlayerPage, | ||||
|     ], | ||||
|     imports: [ | ||||
|         CoreComponentsModule, | ||||
|         CoreDirectivesModule, | ||||
|         IonicPageModule.forChild(AddonModScormPlayerPage), | ||||
|         TranslateModule.forChild() | ||||
|     ], | ||||
| }) | ||||
| export class AddonModScormPlayerPageModule {} | ||||
							
								
								
									
										450
									
								
								src/addon/mod/scorm/pages/player/player.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,450 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // 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 { IonicPage, NavParams, PopoverController } from 'ionic-angular'; | ||||
| import { CoreEventsProvider } from '@providers/events'; | ||||
| import { CoreSitesProvider } from '@providers/sites'; | ||||
| import { CoreSyncProvider } from '@providers/sync'; | ||||
| import { CoreDomUtilsProvider } from '@providers/utils/dom'; | ||||
| import { CoreTimeUtilsProvider } from '@providers/utils/time'; | ||||
| import { AddonModScormProvider, AddonModScormAttemptCountResult } from '../../providers/scorm'; | ||||
| import { AddonModScormHelperProvider } from '../../providers/helper'; | ||||
| import { AddonModScormSyncProvider } from '../../providers/scorm-sync'; | ||||
| import { AddonModScormDataModel12 } from '../../classes/data-model-12'; | ||||
| import { AddonModScormTocPopoverComponent } from '../../components/toc-popover/toc-popover'; | ||||
| 
 | ||||
| /** | ||||
|  * Page that allows playing a SCORM. | ||||
|  */ | ||||
| @IonicPage({ segment: 'addon-mod-scorm-player' }) | ||||
| @Component({ | ||||
|     selector: 'page-addon-mod-scorm-player', | ||||
|     templateUrl: 'player.html', | ||||
| }) | ||||
| export class AddonModScormPlayerPage implements OnInit, OnDestroy { | ||||
| 
 | ||||
|     title: string; // Title.
 | ||||
|     scorm: any; // The SCORM object.
 | ||||
|     showToc: boolean; // Whether to show the table of contents (TOC).
 | ||||
|     loadingToc = true; // Whether the TOC is being loaded.
 | ||||
|     toc: any[]; // List of SCOs.
 | ||||
|     loaded: boolean; // Whether the data has been loaded.
 | ||||
|     previousSco: any; // Previous SCO.
 | ||||
|     nextSco: any; // Next SCO.
 | ||||
|     src: string; // Iframe src.
 | ||||
|     errorMessage: string; // Error message.
 | ||||
| 
 | ||||
|     protected siteId: string; | ||||
|     protected mode: string; // Mode to play the SCORM.
 | ||||
|     protected newAttempt: boolean; // 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: any; // User data.
 | ||||
|     protected initialScoId: number; // Initial SCO ID to load.
 | ||||
|     protected currentSco: any; // Current SCO.
 | ||||
|     protected dataModel: AddonModScormDataModel12; // Data Model.
 | ||||
|     protected attemptToContinue: number; // Attempt to continue (for the popover).
 | ||||
| 
 | ||||
|     // Observers.
 | ||||
|     protected tocObserver: any; | ||||
|     protected launchNextObserver: any; | ||||
|     protected launchPrevObserver: any; | ||||
|     protected goOfflineObserver: any; | ||||
| 
 | ||||
|     constructor(navParams: NavParams, protected popoverCtrl: PopoverController, protected eventsProvider: CoreEventsProvider, | ||||
|             protected sitesProvider: CoreSitesProvider, protected syncProvider: CoreSyncProvider, | ||||
|             protected domUtils: CoreDomUtilsProvider, protected timeUtils: CoreTimeUtilsProvider, | ||||
|             protected scormProvider: AddonModScormProvider, protected scormHelper: AddonModScormHelperProvider, | ||||
|             protected scormSyncProvider: AddonModScormSyncProvider) { | ||||
| 
 | ||||
|         this.scorm = navParams.get('scorm') || {}; | ||||
|         this.mode = navParams.get('mode') || AddonModScormProvider.MODENORMAL; | ||||
|         this.newAttempt = !!navParams.get('newAttempt'); | ||||
|         this.organizationId = navParams.get('organizationId'); | ||||
|         this.initialScoId = navParams.get('scoId'); | ||||
|         this.siteId = this.sitesProvider.getCurrentSiteId(); | ||||
| 
 | ||||
|         // We use SCORM name at start, later we'll use the SCO title.
 | ||||
|         this.title = this.scorm.name; | ||||
| 
 | ||||
|         // Block the SCORM so it cannot be synchronized.
 | ||||
|         this.syncProvider.blockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player'); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being initialized. | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
| 
 | ||||
|         this.showToc = this.scormProvider.displayTocInPlayer(this.scorm); | ||||
| 
 | ||||
|         if (this.scorm.popup) { | ||||
|             // If we receive a value <= 100 we need to assume it's a percentage.
 | ||||
|             if (this.scorm.width <= 100) { | ||||
|                 this.scorm.width = this.scorm.width + '%'; | ||||
|             } | ||||
|             if (this.scorm.height <= 100) { | ||||
|                 this.scorm.height = this.scorm.height + '%'; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Fetch the SCORM data.
 | ||||
|         this.fetchData().then(() => { | ||||
|             if (this.currentSco) { | ||||
|                 // Set start time if it's a new attempt.
 | ||||
|                 const promise = this.newAttempt ? this.setStartTime(this.currentSco.id) : Promise.resolve(); | ||||
| 
 | ||||
|                 return promise.catch((error) => { | ||||
|                     this.domUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true); | ||||
|                 }).finally(() => { | ||||
|                     // Load SCO.
 | ||||
|                     this.loadSco(this.currentSco); | ||||
|                 }); | ||||
|             } | ||||
|         }).finally(() => { | ||||
|             this.loaded = true; | ||||
|         }); | ||||
| 
 | ||||
|         // Listen for events to update the TOC, navigate through SCOs and go offline.
 | ||||
|         this.tocObserver = this.eventsProvider.on(AddonModScormProvider.UPDATE_TOC_EVENT, (data) => { | ||||
|             if (data.scormId === this.scorm.id) { | ||||
|                 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 = this.eventsProvider.on(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT, (data) => { | ||||
|             if (data.scormId === this.scorm.id && this.nextSco) { | ||||
|                 this.loadSco(this.nextSco); | ||||
|             } | ||||
|         }, this.siteId); | ||||
| 
 | ||||
|         this.launchPrevObserver = this.eventsProvider.on(AddonModScormProvider.LAUNCH_PREV_SCO_EVENT, (data) => { | ||||
|             if (data.scormId === this.scorm.id && this.previousSco) { | ||||
|                 this.loadSco(this.previousSco); | ||||
|             } | ||||
|         }, this.siteId); | ||||
| 
 | ||||
|         this.goOfflineObserver = this.eventsProvider.on(AddonModScormProvider.GO_OFFLINE_EVENT, (data) => { | ||||
|             if (data.scormId === this.scorm.id && !this.offline) { | ||||
|                 this.offline = true; | ||||
| 
 | ||||
|                 // Wait a bit to prevent collisions between this store and SCORM API's store.
 | ||||
|                 setTimeout(() => { | ||||
|                     this.scormHelper.convertAttemptToOffline(this.scorm, this.attempt).catch((error) => { | ||||
|                         this.domUtils.showErrorModalDefault(error, 'core.error', true); | ||||
|                     }).then(() => { | ||||
|                         this.refreshToc(); | ||||
|                     }); | ||||
|                 }, 200); | ||||
|             } | ||||
|         }, this.siteId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Calculate the next and previous SCO. | ||||
|      * | ||||
|      * @param {number} scoId Current SCO ID. | ||||
|      */ | ||||
|     protected calculateNextAndPreviousSco(scoId: number): void { | ||||
|         this.previousSco = this.scormHelper.getPreviousScoFromToc(this.toc, scoId); | ||||
|         this.nextSco = this.scormHelper.getNextScoFromToc(this.toc, scoId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Determine the attempt to use, the mode (normal/preview) and if it's offline or online. | ||||
|      * | ||||
|      * @param {AddonModScormAttemptCountResult} attemptsData Attempts count. | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected determineAttemptAndMode(attemptsData: AddonModScormAttemptCountResult): Promise<any> { | ||||
|         let result; | ||||
| 
 | ||||
|         return this.scormHelper.determineAttemptToContinue(this.scorm, attemptsData).then((data) => { | ||||
|             this.attempt = data.number; | ||||
|             this.offline = data.offline; | ||||
| 
 | ||||
|             if (this.attempt != attemptsData.lastAttempt.number) { | ||||
|                 this.attemptToContinue = this.attempt; | ||||
|             } | ||||
| 
 | ||||
|             // Check if current attempt is incomplete.
 | ||||
|             if (this.attempt > 0) { | ||||
|                 return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.attempt, this.offline); | ||||
|             } else { | ||||
|                 // User doesn't have attempts. Last attempt is not incomplete (since he doesn't have any).
 | ||||
|                 return false; | ||||
|             } | ||||
|         }).then((incomplete) => { | ||||
|             // Determine mode and attempt to use.
 | ||||
|             result = this.scormProvider.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.
 | ||||
|                     return this.scormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length); | ||||
|                 } else { | ||||
|                     // Last attempt was online, verify that we can create a new online attempt. We ignore cache.
 | ||||
|                     return this.scormProvider.getScormUserData(this.scorm.id, result.attempt, undefined, false, true).catch(() => { | ||||
|                         // Cannot communicate with the server, create an offline attempt.
 | ||||
|                         this.offline = true; | ||||
| 
 | ||||
|                         return this.scormHelper.createOfflineAttempt(this.scorm, result.attempt, attemptsData.online.length); | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
|         }).then(() => { | ||||
|             this.mode = result.mode; | ||||
|             this.newAttempt = result.newAttempt; | ||||
|             this.attempt = result.attempt; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch data needed to play the SCORM. | ||||
|      * | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected fetchData(): Promise<any> { | ||||
|         // Wait for any ongoing sync to finish. We won't sync a SCORM while it's being played.
 | ||||
|         return this.scormSyncProvider.waitForSync(this.scorm.id).then(() => { | ||||
|             // Get attempts data.
 | ||||
|             return this.scormProvider.getAttemptCount(this.scorm.id).then((attemptsData) => { | ||||
|                 return this.determineAttemptAndMode(attemptsData).then(() => { | ||||
|                     // Fetch TOC and get user data.
 | ||||
|                     const promises = []; | ||||
| 
 | ||||
|                     promises.push(this.fetchToc()); | ||||
|                     promises.push(this.scormProvider.getScormUserData(this.scorm.id, this.attempt, undefined, this.offline) | ||||
|                             .then((data) => { | ||||
|                         this.userData = data; | ||||
|                     })); | ||||
| 
 | ||||
|                     return Promise.all(promises); | ||||
|                 }); | ||||
|             }).catch((error) => { | ||||
|                 this.domUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Fetch the TOC. | ||||
|      * | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected fetchToc(): Promise<any> { | ||||
|         this.loadingToc = true; | ||||
| 
 | ||||
|         // We need to check incomplete again: attempt number or status might have changed.
 | ||||
|         return this.scormProvider.isAttemptIncomplete(this.scorm.id, this.attempt, this.offline).then((incomplete) => { | ||||
|             this.scorm.incomplete = incomplete; | ||||
| 
 | ||||
|             // Get TOC.
 | ||||
|             return this.scormProvider.getOrganizationToc(this.scorm.id, this.attempt, this.organizationId, this.offline); | ||||
|         }).then((toc) => { | ||||
|             this.toc = this.scormProvider.formatTocToArray(toc); | ||||
| 
 | ||||
|             // Get images for each SCO.
 | ||||
|             this.toc.forEach((sco) => { | ||||
|                 sco.image = this.scormProvider.getScoStatusIcon(sco, this.scorm.incomplete); | ||||
|             }); | ||||
| 
 | ||||
|             // Determine current SCO if we received an ID..
 | ||||
|             if (this.initialScoId > 0) { | ||||
|                 // SCO set by parameter, get it from TOC.
 | ||||
|                 this.currentSco = this.scormHelper.getScoFromToc(this.toc, this.initialScoId); | ||||
|             } | ||||
| 
 | ||||
|             if (!this.currentSco) { | ||||
|                 // No SCO defined. Get the first valid one.
 | ||||
|                 return this.scormHelper.getFirstSco(this.scorm.id, this.attempt, this.toc, this.organizationId, this.offline) | ||||
|                         .then((sco) => { | ||||
| 
 | ||||
|                     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 { | ||||
|         // Empty src when leaving the state so unload event is triggered in the iframe.
 | ||||
|         this.src = ''; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Load a SCO. | ||||
|      * | ||||
|      * @param {any} sco The SCO to load. | ||||
|      */ | ||||
|     protected loadSco(sco: any): void { | ||||
|         if (!this.dataModel) { | ||||
|             // Create the model.
 | ||||
|             this.dataModel = new AddonModScormDataModel12(this.eventsProvider, this.scormProvider, 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.
 | ||||
|             (<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.scormProvider.getScoSrc(this.scorm, sco).then((src) => { | ||||
|             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 = ''; | ||||
| 
 | ||||
|                 setTimeout(() => { | ||||
|                     this.src = src; | ||||
|                 }); | ||||
|             } else { | ||||
|                 this.src = src; | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         if (sco.scormtype == 'asset') { | ||||
|             // Mark the asset as completed.
 | ||||
|             const tracks = [{ | ||||
|                 element: 'cmi.core.lesson_status', | ||||
|                 value: 'completed' | ||||
|             }]; | ||||
| 
 | ||||
|             this.scormProvider.saveTracks(sco.id, this.attempt, tracks, this.scorm, this.offline).catch(() => { | ||||
|                 // Error saving data. We'll go offline if we're online and the asset is not marked as completed already.
 | ||||
|                 if (!this.offline) { | ||||
|                     return this.scormProvider.getScormUserData(this.scorm.id, this.attempt, undefined, false).then((data) => { | ||||
|                         if (!data[sco.id] || data[sco.id].userdata['cmi.core.lesson_status'] != 'completed') { | ||||
|                             // Go offline.
 | ||||
|                             return this.scormHelper.convertAttemptToOffline(this.scorm, this.attempt).then(() => { | ||||
|                                 this.offline = true; | ||||
|                                 this.dataModel.setOffline(true); | ||||
| 
 | ||||
|                                 return this.scormProvider.saveTracks(sco.id, this.attempt, tracks, this.scorm, true); | ||||
|                             }).catch((error) => { | ||||
|                                 this.domUtils.showErrorModalDefault(error, 'core.error', true); | ||||
|                             }); | ||||
|                         } | ||||
|                     }); | ||||
|                 } | ||||
|             }).then(() => { | ||||
|                 // Refresh TOC, some prerequisites might have changed.
 | ||||
|                 this.refreshToc(); | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         // Trigger SCO launch event.
 | ||||
|         this.scormProvider.logLaunchSco(this.scorm.id, sco.id).catch(() => { | ||||
|             // Ignore errors.
 | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show the TOC. | ||||
|      * | ||||
|      * @param {MouseEvent} event Event. | ||||
|      */ | ||||
|     openToc(event: MouseEvent): void { | ||||
|         const popover = this.popoverCtrl.create(AddonModScormTocPopoverComponent, { | ||||
|             toc: this.toc, | ||||
|             attemptToContinue: this.attemptToContinue, | ||||
|             mode: this.mode | ||||
|         }); | ||||
| 
 | ||||
|         // If the popover sends back a SCO, load it.
 | ||||
|         popover.onDidDismiss((sco) => { | ||||
|             if (sco) { | ||||
|                 this.loadSco(sco); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         popover.present({ | ||||
|             ev: event | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Refresh the TOC. | ||||
|      * | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected refreshToc(): Promise<any> { | ||||
|         return this.scormProvider.invalidateAllScormData(this.scorm.id).catch(() => { | ||||
|             // Ignore errors.
 | ||||
|         }).then(() => { | ||||
|             return this.fetchToc(); | ||||
|         }).catch((error) => { | ||||
|             this.domUtils.showErrorModalDefault(error, 'addon.mod_scorm.errorgetscorm', true); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Set SCORM start time. | ||||
|      * | ||||
|      * @param {number} scoId SCO ID. | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected setStartTime(scoId: number): Promise<any> { | ||||
|         const tracks = [{ | ||||
|             element: 'x.start.time', | ||||
|             value: this.timeUtils.timestamp() | ||||
|         }]; | ||||
| 
 | ||||
|         return this.scormProvider.saveTracks(scoId, this.attempt, tracks, this.scorm, this.offline).then(() => { | ||||
|             if (!this.offline) { | ||||
|                 // New online attempt created, update cached data about online attempts.
 | ||||
|                 this.scormProvider.getAttemptCount(this.scorm.id, false, true).catch(() => { | ||||
|                     // Ignore errors.
 | ||||
|                 }); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Component being destroyed. | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         // Stop listening for events.
 | ||||
|         this.tocObserver && this.tocObserver.off(); | ||||
|         this.launchNextObserver && this.launchNextObserver.off(); | ||||
|         this.launchPrevObserver && this.launchPrevObserver.off(); | ||||
|         this.goOfflineObserver && this.goOfflineObserver.off(); | ||||
| 
 | ||||
|         // Unblock the SCORM so it can be synced.
 | ||||
|         this.syncProvider.unblockOperation(AddonModScormProvider.COMPONENT, this.scorm.id, 'player'); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										32
									
								
								src/addon/mod/scorm/providers/grade-link-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,32 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // 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 { CoreSitesProvider } from '@providers/sites'; | ||||
| import { CoreDomUtilsProvider } from '@providers/utils/dom'; | ||||
| import { CoreContentLinksModuleGradeHandler } from '@core/contentlinks/classes/module-grade-handler'; | ||||
| import { CoreCourseHelperProvider } from '@core/course/providers/helper'; | ||||
| 
 | ||||
| /** | ||||
|  * Handler to treat links to SCORM grade. | ||||
|  */ | ||||
| @Injectable() | ||||
| export class AddonModScormGradeLinkHandler extends CoreContentLinksModuleGradeHandler { | ||||
|     name = 'AddonModScormGradeLinkHandler'; | ||||
|     canReview = false; | ||||
| 
 | ||||
|     constructor(courseHelper: CoreCourseHelperProvider, domUtils: CoreDomUtilsProvider, sitesProvider: CoreSitesProvider) { | ||||
|         super(courseHelper, domUtils, sitesProvider, 'AddonModScorm', 'scorm'); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										330
									
								
								src/addon/mod/scorm/providers/helper.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,330 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { TranslateService } from '@ngx-translate/core'; | ||||
| import { CoreSitesProvider } from '@providers/sites'; | ||||
| import { CoreDomUtilsProvider } from '@providers/utils/dom'; | ||||
| import { CoreUtilsProvider } from '@providers/utils/utils'; | ||||
| import { AddonModScormProvider, AddonModScormAttemptCountResult } from './scorm'; | ||||
| import { AddonModScormOfflineProvider } from './scorm-offline'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper service that provides some features for SCORM. | ||||
|  */ | ||||
| @Injectable() | ||||
| export class AddonModScormHelperProvider { | ||||
| 
 | ||||
|     // List of elements we want to ignore when copying attempts (they're calculated).
 | ||||
|     protected elementsToIgnore = ['status', 'score_raw', 'total_time', 'session_time', 'student_id', 'student_name', 'credit', | ||||
|                         'mode', 'entry']; | ||||
| 
 | ||||
|     constructor(private sitesProvider: CoreSitesProvider, private translate: TranslateService, | ||||
|             private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider, | ||||
|             private scormProvider: AddonModScormProvider, private scormOfflineProvider: AddonModScormOfflineProvider) { } | ||||
| 
 | ||||
|     /** | ||||
|      * Show a confirm dialog if needed. If SCORM doesn't have size, try to calculate it. | ||||
|      * | ||||
|      * @param {any} scorm SCORM to download. | ||||
|      * @param {boolean} [isOutdated] True if package outdated, false if not outdated, undefined to calculate it. | ||||
|      * @return {Promise<any>} Promise resolved if the user confirms or no confirmation needed. | ||||
|      */ | ||||
|     confirmDownload(scorm: any, isOutdated?: boolean): Promise<any> { | ||||
|         // Check if file should be downloaded.
 | ||||
|         return this.scormProvider.shouldDownloadMainFile(scorm, isOutdated).then((download) => { | ||||
|             if (download) { | ||||
|                 let subPromise; | ||||
| 
 | ||||
|                 if (!scorm.packagesize) { | ||||
|                     // We don't have package size, try to calculate it.
 | ||||
|                     subPromise = this.scormProvider.calculateScormSize(scorm).then((size) => { | ||||
|                         // Store it so we don't have to calculate it again when using the same object.
 | ||||
|                         scorm.packagesize = size; | ||||
| 
 | ||||
|                         return size; | ||||
|                     }); | ||||
|                 } else { | ||||
|                     subPromise = Promise.resolve(scorm.packagesize); | ||||
|                 } | ||||
| 
 | ||||
|                 return subPromise.then((size) => { | ||||
|                     return this.domUtils.confirmDownloadSize({size: size, total: true}); | ||||
|                 }); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Creates a new offline attempt based on an existing online attempt. | ||||
|      * | ||||
|      * @param {any} scorm SCORM. | ||||
|      * @param {number} attempt Number of the online attempt. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @return {Promise<any>} Promise resolved when the attempt is created. | ||||
|      */ | ||||
|     convertAttemptToOffline(scorm: any, attempt: number, siteId?: string): Promise<any> { | ||||
|         siteId = siteId || this.sitesProvider.getCurrentSiteId(); | ||||
| 
 | ||||
|         // Get data from the online attempt.
 | ||||
|         return this.scormProvider.getScormUserData(scorm.id, attempt, undefined, false, false, siteId).then((onlineData) => { | ||||
|             // The SCORM API might have written some data to the offline attempt already.
 | ||||
|             // We don't want to override it with cached online data.
 | ||||
|             return this.scormOfflineProvider.getScormUserData(scorm.id, attempt, undefined, siteId).catch(() => { | ||||
|                 // Ignore errors.
 | ||||
|             }).then((offlineData) => { | ||||
|                 const dataToStore = this.utils.clone(onlineData); | ||||
| 
 | ||||
|                 // Filter the data to copy.
 | ||||
|                 for (const scoId in dataToStore) { | ||||
|                     const sco = dataToStore[scoId]; | ||||
| 
 | ||||
|                     // Delete calculated data.
 | ||||
|                     this.elementsToIgnore.forEach((el) => { | ||||
|                         delete sco.userdata[el]; | ||||
|                     }); | ||||
| 
 | ||||
|                     // Don't override offline data.
 | ||||
|                     if (offlineData && offlineData[sco.scoid] && offlineData[sco.scoid].userdata) { | ||||
|                         const scoUserData = {}; | ||||
| 
 | ||||
|                         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; | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 return this.scormOfflineProvider.createNewAttempt(scorm, attempt, dataToStore, onlineData, siteId); | ||||
|             }); | ||||
|         }).catch(() => { | ||||
|             // Shouldn't happen.
 | ||||
|             return Promise.reject(this.translate.instant('addon.mod_scorm.errorcreateofflineattempt')); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Creates a new offline attempt. | ||||
|      * | ||||
|      * @param {any} scorm SCORM. | ||||
|      * @param {number} newAttempt Number of the new attempt. | ||||
|      * @param {number} lastOnline Number of the last online attempt. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @return {Promise<any>} Promise resolved when the attempt is created. | ||||
|      */ | ||||
|     createOfflineAttempt(scorm: any, newAttempt: number, lastOnline: number, siteId?: string): Promise<any> { | ||||
|         siteId = siteId || this.sitesProvider.getCurrentSiteId(); | ||||
| 
 | ||||
|         // Try to get data from online attempts.
 | ||||
|         return this.searchOnlineAttemptUserData(scorm.id, lastOnline, siteId).then((userData) => { | ||||
|             // 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], | ||||
|                     filtered = {}; | ||||
| 
 | ||||
|                 for (const element in sco.userdata) { | ||||
|                     if (element.indexOf('.') == -1 && this.elementsToIgnore.indexOf(element) == -1) { | ||||
|                         // The element doesn't use a dot notation, probably SCO data.
 | ||||
|                         filtered[element] = sco.userdata[element]; | ||||
|                     } | ||||
|                 } | ||||
| 
 | ||||
|                 sco.userdata = filtered; | ||||
|             } | ||||
| 
 | ||||
|             return this.scormOfflineProvider.createNewAttempt(scorm, newAttempt, userData, undefined, siteId); | ||||
|         }).catch(() => { | ||||
|             return Promise.reject(this.translate.instant('addon.mod_scorm.errorcreateofflineattempt')); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * 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 {any} scorm SCORM object. | ||||
|      * @param {AddonModScormAttemptCountResult} attempts Attempts count. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @return {Promise<{number: number, offline: boolean}>} Promise resolved with the attempt data. | ||||
|      */ | ||||
|     determineAttemptToContinue(scorm: any, attempts: AddonModScormAttemptCountResult, siteId?: string) | ||||
|             : Promise<{number: number, offline: boolean}> { | ||||
| 
 | ||||
|         let lastOnline; | ||||
| 
 | ||||
|         // Get last online attempt.
 | ||||
|         if (attempts.online.length) { | ||||
|             lastOnline = Math.max.apply(Math, attempts.online); | ||||
|         } | ||||
| 
 | ||||
|         if (lastOnline) { | ||||
|             // Check if last online incomplete.
 | ||||
|             const hasOffline = attempts.offline.indexOf(lastOnline) > -1; | ||||
| 
 | ||||
|             return this.scormProvider.isAttemptIncomplete(scorm.id, lastOnline, hasOffline, false, siteId).then((incomplete) => { | ||||
|                 if (incomplete) { | ||||
|                     return { | ||||
|                         number: lastOnline, | ||||
|                         offline: hasOffline | ||||
|                     }; | ||||
|                 } else { | ||||
|                     return this.getLastBeforeMax(scorm, attempts); | ||||
|                 } | ||||
|             }); | ||||
|         } else { | ||||
|             return Promise.resolve(this.getLastBeforeMax(scorm, attempts)); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the first SCO to load in a SCORM. If a non-empty TOC is provided, it will be the first valid SCO in the TOC. | ||||
|      * Otherwise, it will be the first valid SCO returned by $mmaModScorm#getScos. | ||||
|      * | ||||
|      * @param {number} scormId Scorm ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {any[]} [toc] SCORM's TOC. | ||||
|      * @param {string} [organization] Organization to use. | ||||
|      * @param {boolean} [offline] Whether the attempt is offline. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @return {Promise<any>} Promise resolved with the first SCO. | ||||
|      */ | ||||
|     getFirstSco(scormId: number, attempt: number, toc?: any[], organization?: string, offline?: boolean, siteId?: string) | ||||
|             : Promise<any> { | ||||
| 
 | ||||
|         let promise; | ||||
|         if (toc && toc.length) { | ||||
|             promise = Promise.resolve(toc); | ||||
|         } else { | ||||
|             // SCORM doesn't have a TOC. Get all the scos.
 | ||||
|             promise = this.scormProvider.getScosWithData(scormId, attempt, organization, offline, false, siteId); | ||||
|         } | ||||
| 
 | ||||
|         return promise.then((scos) => { | ||||
|             // Search the first valid SCO.
 | ||||
|             for (let i = 0; i < scos.length; i++) { | ||||
|                 const sco = scos[i]; | ||||
| 
 | ||||
|                 if (sco.isvisible && sco.prereq && sco.launch) { | ||||
|                     return sco; | ||||
|                 } | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * 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 {any} scorm SCORM object. | ||||
|      * @param {AddonModScormAttemptCountResult} attempts Attempts count. | ||||
|      * @return {{number: number, offline: boolean}} Last attempt data. | ||||
|      */ | ||||
|     protected getLastBeforeMax(scorm: any, attempts: AddonModScormAttemptCountResult): {number: number, offline: boolean} { | ||||
|         if (scorm.maxattempt != 0 && attempts.lastAttempt.number > scorm.maxattempt) { | ||||
|             return { | ||||
|                 number: scorm.maxattempt, | ||||
|                 offline: attempts.offline.indexOf(scorm.maxattempt) > -1 | ||||
|             }; | ||||
|         } else { | ||||
|             return { | ||||
|                 number: attempts.lastAttempt.number, | ||||
|                 offline: attempts.lastAttempt.offline | ||||
|             }; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Given a TOC in array format and a scoId, return the next available SCO. | ||||
|      * | ||||
|      * @param {any[]} toc SCORM's TOC. | ||||
|      * @param {number} scoId SCO ID. | ||||
|      * @return {any} Next SCO. | ||||
|      */ | ||||
|     getNextScoFromToc(toc: any, scoId: number): any { | ||||
|         for (let i = 0; i < toc.length; i++) { | ||||
|             if (toc[i].id == scoId) { | ||||
|                 // We found the current SCO. Now let's 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 {any[]} toc SCORM's TOC. | ||||
|      * @param {number} scoId SCO ID. | ||||
|      * @return {any} Previous SCO. | ||||
|      */ | ||||
|     getPreviousScoFromToc(toc: any, scoId: number): any { | ||||
|         for (let i = 0; i < toc.length; i++) { | ||||
|             if (toc[i].id == scoId) { | ||||
|                 // 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 {any[]} toc SCORM's TOC. | ||||
|      * @param {number} scoId SCO ID. | ||||
|      * @return {any} SCO. | ||||
|      */ | ||||
|     getScoFromToc(toc: any[], scoId: number): any { | ||||
|         for (let i = 0; i < toc.length; i++) { | ||||
|             if (toc[i].id == scoId) { | ||||
|                 return toc[i]; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Searches user data for an online attempt. If the data can't be retrieved, re-try with the previous online attempt. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Online attempt to get the data. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @return {Promise<any>} Promise resolved with user data. | ||||
|      */ | ||||
|     searchOnlineAttemptUserData(scormId: number, attempt: number, siteId?: string): Promise<any> { | ||||
|         siteId = siteId || this.sitesProvider.getCurrentSiteId(); | ||||
| 
 | ||||
|         return this.scormProvider.getScormUserData(scormId, attempt, undefined, false, false, siteId).catch(() => { | ||||
|             if (attempt > 0) { | ||||
|                 // We couldn't retrieve the data. Try again with the previous online attempt.
 | ||||
|                 return this.searchOnlineAttemptUserData(scormId, attempt - 1, siteId); | ||||
|             } else { | ||||
|                 // No more attempts to try. Reject
 | ||||
|                 return Promise.reject(null); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										29
									
								
								src/addon/mod/scorm/providers/index-link-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,29 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // 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 '@core/contentlinks/classes/module-index-handler'; | ||||
| import { CoreCourseHelperProvider } from '@core/course/providers/helper'; | ||||
| 
 | ||||
| /** | ||||
|  * Handler to treat links to SCORM index. | ||||
|  */ | ||||
| @Injectable() | ||||
| export class AddonModScormIndexLinkHandler extends CoreContentLinksModuleIndexHandler { | ||||
|     name = 'AddonModScormIndexLinkHandler'; | ||||
| 
 | ||||
|     constructor(courseHelper: CoreCourseHelperProvider) { | ||||
|         super(courseHelper, 'AddonModScorm', 'scorm'); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										71
									
								
								src/addon/mod/scorm/providers/module-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,71 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { NavController, NavOptions } from 'ionic-angular'; | ||||
| import { AddonModScormIndexComponent } from '../components/index/index'; | ||||
| import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate'; | ||||
| import { CoreCourseProvider } from '@core/course/providers/course'; | ||||
| 
 | ||||
| /** | ||||
|  * Handler to support SCORM modules. | ||||
|  */ | ||||
| @Injectable() | ||||
| export class AddonModScormModuleHandler implements CoreCourseModuleHandler { | ||||
|     name = 'AddonModScorm'; | ||||
|     modName = 'scorm'; | ||||
| 
 | ||||
|     constructor(private courseProvider: CoreCourseProvider) { } | ||||
| 
 | ||||
|     /** | ||||
|      * Check if the handler is enabled on a site level. | ||||
|      * | ||||
|      * @return {boolean} Whether or not the handler is enabled on a site level. | ||||
|      */ | ||||
|     isEnabled(): boolean { | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the data required to display the module in the course contents view. | ||||
|      * | ||||
|      * @param {any} module The module object. | ||||
|      * @param {number} courseId The course ID. | ||||
|      * @param {number} sectionId The section ID. | ||||
|      * @return {CoreCourseModuleHandlerData} Data to render the module. | ||||
|      */ | ||||
|     getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData { | ||||
|         return { | ||||
|             icon: this.courseProvider.getModuleIconSrc('scorm'), | ||||
|             title: module.name, | ||||
|             class: 'addon-mod_scorm-handler', | ||||
|             showDownloadButton: true, | ||||
|             action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { | ||||
|                 navCtrl.push('AddonModScormIndexPage', {module: module, courseId: courseId}, options); | ||||
|             } | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the component to render the module. This is needed to support singleactivity course format. | ||||
|      * The component returned must implement CoreCourseModuleMainComponent. | ||||
|      * | ||||
|      * @param {any} course The course object. | ||||
|      * @param {any} module The module object. | ||||
|      * @return {any} The component to use, undefined if not found. | ||||
|      */ | ||||
|     getMainComponent(course: any, module: any): any { | ||||
|         return AddonModScormIndexComponent; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										49
									
								
								src/addon/mod/scorm/providers/pluginfile-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,49 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // 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 '@providers/plugin-file-delegate'; | ||||
| 
 | ||||
| /** | ||||
|  * Handler to treat file URLs in SCORM. | ||||
|  */ | ||||
| @Injectable() | ||||
| export class AddonModScormPluginFileHandler implements CorePluginFileHandler { | ||||
|     name = 'AddonModScormPluginFileHandler'; | ||||
| 
 | ||||
|     /** | ||||
|      * Return the RegExp to match the revision on pluginfile URLs. | ||||
|      * | ||||
|      * @param {string[]} args Arguments of the pluginfile URL defining component and filearea at least. | ||||
|      * @return {RegExp} RegExp to match the revision on pluginfile URLs. | ||||
|      */ | ||||
|     getComponentRevisionRegExp(args: string[]): RegExp { | ||||
|         // Check filearea.
 | ||||
|         if (args[2] == 'content') { | ||||
|             // Component + Filearea + Revision
 | ||||
|             return new RegExp('/mod_resource/content/([0-9]+)/'); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Should return the string to remove the revision on pluginfile url. | ||||
|      * | ||||
|      * @param {string[]} args Arguments of the pluginfile URL defining component and filearea at least. | ||||
|      * @return {string} String to remove the revision on pluginfile url. | ||||
|      */ | ||||
|     getComponentRevisionReplace(args: string[]): string { | ||||
|         // Component + Filearea + Revision
 | ||||
|         return '/mod_scorm/content/0/'; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										425
									
								
								src/addon/mod/scorm/providers/prefetch-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,425 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // 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, Injector } from '@angular/core'; | ||||
| import { CoreFileProvider } from '@providers/file'; | ||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||
| import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler'; | ||||
| import { AddonModScormProvider } from './scorm'; | ||||
| 
 | ||||
| /** | ||||
|  * Progress event used when downloading a SCORM. | ||||
|  */ | ||||
| export interface AddonModScormProgressEvent { | ||||
|     /** | ||||
|      * Whether the event is due to the download of a chunk of data. | ||||
|      * @type {boolean} | ||||
|      */ | ||||
|     downloading?: boolean; | ||||
| 
 | ||||
|     /** | ||||
|      * Progress event sent by the download. | ||||
|      * @type {ProgressEvent} | ||||
|      */ | ||||
|     progress?: ProgressEvent; | ||||
| 
 | ||||
|     /** | ||||
|      * A message related to the progress. This is usually used to notify that a certain step of the download has started. | ||||
|      * @type {string} | ||||
|      */ | ||||
|     message?: string; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Handler to prefetch SCORMs. | ||||
|  */ | ||||
| @Injectable() | ||||
| export class AddonModScormPrefetchHandler extends CoreCourseModulePrefetchHandlerBase { | ||||
|     name = 'AddonModScorm'; | ||||
|     modName = 'scorm'; | ||||
|     component = AddonModScormProvider.COMPONENT; | ||||
|     updatesNames = /^configuration$|^.*files$|^tracks$/; | ||||
| 
 | ||||
|     constructor(injector: Injector, protected fileProvider: CoreFileProvider, protected textUtils: CoreTextUtilsProvider, | ||||
|              protected scormProvider: AddonModScormProvider) { | ||||
|         super(injector); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Download the module. | ||||
|      * | ||||
|      * @param {any} module The module object returned by WS. | ||||
|      * @param {number} courseId Course ID. | ||||
|      * @param {string} [dirPath] Path of the directory where to store all the content files. | ||||
|      * @param {Function} [onProgress] Function to call on progress. | ||||
|      * @return {Promise<any>} Promise resolved when all content is downloaded. | ||||
|      */ | ||||
|     download(module: any, courseId: number, dirPath?: string, onProgress?: (event: AddonModScormProgressEvent) => any) | ||||
|             : Promise<any> { | ||||
| 
 | ||||
|         const siteId = this.sitesProvider.getCurrentSiteId(); | ||||
| 
 | ||||
|         return this.prefetchPackage(module, courseId, true, this.downloadOrPrefetchScorm.bind(this), siteId, false, onProgress); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Download or prefetch a SCORM. | ||||
|      * | ||||
|      * @param {any} module Module. | ||||
|      * @param {number} courseId Course ID the module belongs to. | ||||
|      * @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section. | ||||
|      * @param {String} siteId Site ID. | ||||
|      * @param {boolean} prefetch True to prefetch, false to download right away. | ||||
|      * @param {Function} [onProgress] Function to call on progress. | ||||
|      * @return {Promise<any>} Promise resolved with the "extra" data to store: the hash of the file. | ||||
|      */ | ||||
|     protected downloadOrPrefetchScorm(module: any, courseId: number, single: boolean, siteId: string, prefetch: boolean, | ||||
|             onProgress?: (event: AddonModScormProgressEvent) => any): Promise<string> { | ||||
| 
 | ||||
|         let scorm; | ||||
| 
 | ||||
|         return this.scormProvider.getScorm(courseId, module.id, module.url, false, siteId).then((scormData) => { | ||||
|             scorm = scormData; | ||||
| 
 | ||||
|             const promises = [], | ||||
|                 introFiles = this.getIntroFilesFromInstance(module, scorm); | ||||
| 
 | ||||
|             // Download WS data.
 | ||||
|             promises.push(this.fetchWSData(scorm, siteId).catch(() => { | ||||
|                 // If prefetchData 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.
 | ||||
|             })); | ||||
| 
 | ||||
|             // Download the package.
 | ||||
|             promises.push(this.downloadOrPrefetchMainFileIfNeeded(scorm, prefetch, onProgress, siteId)); | ||||
| 
 | ||||
|             // Download intro files.
 | ||||
|             promises.push(this.filepoolProvider.downloadOrPrefetchFiles(siteId, introFiles, prefetch, false, this.component, | ||||
|                     module.id).catch(() => { | ||||
|                 // Ignore errors.
 | ||||
|             })); | ||||
| 
 | ||||
|             return Promise.all(promises); | ||||
|         }).then(() => { | ||||
|             // Success, return the hash.
 | ||||
|             return scorm.sha1hash; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Downloads/Prefetches and unzips the SCORM package. | ||||
|      * | ||||
|      * @param {any} scorm SCORM object. | ||||
|      * @param {boolean} [prefetch] True if prefetch, false otherwise. | ||||
|      * @param {Function} [onProgress] Function to call on progress. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @return {Promise<any>} Promise resolved when the file is downloaded and unzipped. | ||||
|      */ | ||||
|     protected downloadOrPrefetchMainFile(scorm: any, prefetch?: boolean, onProgress?: (event: AddonModScormProgressEvent) => any, | ||||
|             siteId?: string): Promise<any> { | ||||
| 
 | ||||
|         const packageUrl = this.scormProvider.getPackageUrl(scorm); | ||||
|         let dirPath; | ||||
| 
 | ||||
|         // Get the folder where the unzipped files will be.
 | ||||
|         return this.scormProvider.getScormFolder(scorm.moduleurl).then((path) => { | ||||
|             dirPath = path; | ||||
| 
 | ||||
|             // Notify that the download is starting.
 | ||||
|             onProgress && onProgress({message: 'core.downloading'}); | ||||
| 
 | ||||
|             // Download the ZIP file to the filepool.
 | ||||
|             if (prefetch) { | ||||
|                return this.filepoolProvider.addToQueueByUrl(siteId, packageUrl, this.component, scorm.coursemodule, undefined, | ||||
|                         undefined, this.downloadProgress.bind(this, true, onProgress)); | ||||
|             } else { | ||||
|                 return this.filepoolProvider.downloadUrl(siteId, packageUrl, true, this.component, scorm.coursemodule, | ||||
|                         undefined, this.downloadProgress.bind(this, true, onProgress)); | ||||
|             } | ||||
|         }).then(() => { | ||||
|             // Remove the destination folder to prevent having old unused files.
 | ||||
|             return this.fileProvider.removeDir(dirPath).catch(() => { | ||||
|                 // Ignore errors, it might have failed because the folder doesn't exist.
 | ||||
|             }); | ||||
|         }).then(() => { | ||||
|             // Get the ZIP file path.
 | ||||
|             return this.filepoolProvider.getFilePathByUrl(siteId, packageUrl); | ||||
|         }).then((zipPath) => { | ||||
|             // Notify that the unzip is starting.
 | ||||
|             onProgress && onProgress({message: 'core.unzipping'}); | ||||
| 
 | ||||
|             // Unzip and delete the zip when finished.
 | ||||
|             return this.fileProvider.unzipFile(zipPath, dirPath, this.downloadProgress.bind(this, false, onProgress)).then(() => { | ||||
|                 return this.filepoolProvider.removeFileByUrl(siteId, packageUrl).catch(() => { | ||||
|                     // Ignore errors.
 | ||||
|                 }); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Downloads/Prefetches and unzips the SCORM package if it should be downloaded. | ||||
|      * | ||||
|      * @param {any} scorm SCORM object. | ||||
|      * @param {boolean} [prefetch] True if prefetch, false otherwise. | ||||
|      * @param {Function} [onProgress] Function to call on progress. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @return {Promise<any>} Promise resolved when the file is downloaded and unzipped. | ||||
|      */ | ||||
|     protected downloadOrPrefetchMainFileIfNeeded(scorm: any, prefetch?: boolean, | ||||
|             onProgress?: (event: AddonModScormProgressEvent) => any, siteId?: string): Promise<any> { | ||||
| 
 | ||||
|         siteId = siteId || this.sitesProvider.getCurrentSiteId(); | ||||
| 
 | ||||
|         const result = this.scormProvider.isScormUnsupported(scorm); | ||||
| 
 | ||||
|         if (result) { | ||||
|             return Promise.reject(this.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.
 | ||||
|         return this.scormProvider.shouldDownloadMainFile(scorm, undefined, siteId).then((download) => { | ||||
|             if (download) { | ||||
|                 return this.downloadOrPrefetchMainFile(scorm, prefetch, onProgress, siteId); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Function that converts a regular ProgressEvent into a AddonModScormProgressEvent. | ||||
|      * | ||||
|      * @param {Function} [onProgress] Function to call on progress. | ||||
|      * @param {ProgressEvent} [progress] Event returned by the download function. | ||||
|      */ | ||||
|     protected downloadProgress(downloading: boolean, onProgress?: (event: AddonModScormProgressEvent) => any, | ||||
|             progress?: ProgressEvent): void { | ||||
| 
 | ||||
|         if (onProgress && progress && progress.loaded) { | ||||
|             onProgress({ | ||||
|                 downloading: downloading, | ||||
|                 progress: progress | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get WS data for SCORM. | ||||
|      * | ||||
|      * @param {any} scorm SCORM object. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @return {Promise<any>} Promise resolved when the data is prefetched. | ||||
|      */ | ||||
|     fetchWSData(scorm: any, siteId?: string): Promise<any> { | ||||
|         siteId = siteId || this.sitesProvider.getCurrentSiteId(); | ||||
| 
 | ||||
|         const promises = []; | ||||
| 
 | ||||
|         // Prefetch number of attempts (including not completed).
 | ||||
|         promises.push(this.scormProvider.getAttemptCountOnline(scorm.id, undefined, true, siteId).catch(() => { | ||||
|             // If it fails, assume we have no attempts.
 | ||||
|             return 0; | ||||
|         }).then((numAttempts) => { | ||||
|             if (numAttempts > 0) { | ||||
|                 // Get user data for each attempt.
 | ||||
|                 const dataPromises = []; | ||||
| 
 | ||||
|                 for (let i = 1; i <= numAttempts; i++) { | ||||
|                     dataPromises.push(this.scormProvider.getScormUserDataOnline(scorm.id, i, true, siteId).catch((err) => { | ||||
|                         // Ignore failures of all the attempts that aren't the last one.
 | ||||
|                         if (i == numAttempts) { | ||||
|                             return Promise.reject(err); | ||||
|                         } | ||||
|                     })); | ||||
|                 } | ||||
| 
 | ||||
|                 return Promise.all(dataPromises); | ||||
|             } else { | ||||
|                 // No attempts. We'll still try to get user data to be able to identify SCOs not visible and so.
 | ||||
|                 return this.scormProvider.getScormUserDataOnline(scorm.id, 0, true, siteId); | ||||
|             } | ||||
|         })); | ||||
| 
 | ||||
|         // Prefetch SCOs.
 | ||||
|         promises.push(this.scormProvider.getScos(scorm.id, undefined, true, siteId)); | ||||
| 
 | ||||
|         return Promise.all(promises); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the download size of a module. | ||||
|      * | ||||
|      * @param {any} module Module. | ||||
|      * @param {Number} courseId Course ID the module belongs to. | ||||
|      * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. | ||||
|      * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able | ||||
|      *                                                   to calculate the total size. | ||||
|      */ | ||||
|     getDownloadSize(module: any, courseId: any, single?: boolean): Promise<{ size: number, total: boolean }> { | ||||
|         return this.scormProvider.getScorm(courseId, module.id, module.url).then((scorm) => { | ||||
|             if (this.scormProvider.isScormUnsupported(scorm)) { | ||||
|                 return {size: -1, total: false}; | ||||
|             } else if (!scorm.packagesize) { | ||||
|                 // We don't have package size, try to calculate it.
 | ||||
|                 return this.scormProvider.calculateScormSize(scorm).then((size) => { | ||||
|                     return {size: size, total: true}; | ||||
|                 }); | ||||
|             } else { | ||||
|                 return {size: scorm.packagesize, total: true}; | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the downloaded size of a module. If not defined, we'll use getFiles to calculate it (it can be slow). | ||||
|      * | ||||
|      * @param {any} module Module. | ||||
|      * @param {number} courseId Course ID the module belongs to. | ||||
|      * @return {number|Promise<number>} Size, or promise resolved with the size. | ||||
|      */ | ||||
|     getDownloadedSize(module: any, courseId: number): number | Promise<number> { | ||||
|         return this.scormProvider.getScorm(courseId, module.id, module.url).then((scorm) => { | ||||
|             // Get the folder where SCORM should be unzipped.
 | ||||
|             return this.scormProvider.getScormFolder(scorm.moduleurl); | ||||
|         }).then((path) => { | ||||
|             return this.fileProvider.getDirectorySize(path); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get list of files. If not defined, we'll assume they're in module.contents. | ||||
|      * | ||||
|      * @param {any} module Module. | ||||
|      * @param {Number} courseId Course ID the module belongs to. | ||||
|      * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. | ||||
|      * @return {Promise<any[]>} Promise resolved with the list of files. | ||||
|      */ | ||||
|     getFiles(module: any, courseId: number, single?: boolean): Promise<any[]> { | ||||
|         return this.scormProvider.getScorm(courseId, module.id, module.url).then((scorm) => { | ||||
|             return this.scormProvider.getScormFileList(scorm); | ||||
|         }).catch(() => { | ||||
|             // SCORM not found, return empty list.
 | ||||
|             return []; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Invalidate the prefetched content. | ||||
|      * | ||||
|      * @param {number} moduleId The module ID. | ||||
|      * @param {number} courseId The course ID the module belongs to. | ||||
|      * @return {Promise<any>} Promise resolved when the data is invalidated. | ||||
|      */ | ||||
|     invalidateContent(moduleId: number, courseId: number): Promise<any> { | ||||
|         return this.scormProvider.invalidateContent(moduleId, courseId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Invalidate WS calls needed to determine module status. | ||||
|      * | ||||
|      * @param {any} module Module. | ||||
|      * @param {number} courseId Course ID the module belongs to. | ||||
|      * @return {Promise<any>} Promise resolved when invalidated. | ||||
|      */ | ||||
|     invalidateModule(module: any, courseId: number): Promise<any> { | ||||
|         // Invalidate the calls required to check if a SCORM is downloadable.
 | ||||
|         return this.scormProvider.invalidateScormData(courseId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. | ||||
|      * | ||||
|      * @param {any} module Module. | ||||
|      * @param {number} courseId Course ID the module belongs to. | ||||
|      * @return {boolean|Promise<boolean>} Whether the module can be downloaded. The promise should never be rejected. | ||||
|      */ | ||||
|     isDownloadable(module: any, courseId: number): boolean | Promise<boolean> { | ||||
|         return this.scormProvider.getScorm(courseId, module.id, module.url).then((scorm) => { | ||||
|             if (scorm.warningMessage) { | ||||
|                 // SCORM closed or not opened yet.
 | ||||
|                 return false; | ||||
|             } | ||||
| 
 | ||||
|             if (this.scormProvider.isScormUnsupported(scorm)) { | ||||
|                 return false; | ||||
|             } | ||||
| 
 | ||||
|             return true; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Whether or not the handler is enabled on a site level. | ||||
|      * | ||||
|      * @return {boolean|Promise<boolean>} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. | ||||
|      */ | ||||
|     isEnabled(): boolean | Promise<boolean> { | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Prefetch a module. | ||||
|      * | ||||
|      * @param {any} module Module. | ||||
|      * @param {number} courseId Course ID the module belongs to. | ||||
|      * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. | ||||
|      * @param {string} [dirPath] Path of the directory where to store all the content files. | ||||
|      * @param {Function} [onProgress] Function to call on progress. | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string, | ||||
|             onProgress?: (event: AddonModScormProgressEvent) => any): Promise<any> { | ||||
| 
 | ||||
|         const siteId = this.sitesProvider.getCurrentSiteId(); | ||||
| 
 | ||||
|         return this.prefetchPackage(module, courseId, single, this.downloadOrPrefetchScorm.bind(this), siteId, true, onProgress); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow). | ||||
|      * | ||||
|      * @param {any} module Module. | ||||
|      * @param {number} courseId Course ID the module belongs to. | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     removeFiles(module: any, courseId: number): Promise<any> { | ||||
|         const siteId = this.sitesProvider.getCurrentSiteId(); | ||||
|         let scorm; | ||||
| 
 | ||||
|         return this.scormProvider.getScorm(courseId, module.id, module.url, false, siteId).then((scormData) => { | ||||
|             scorm = scormData; | ||||
| 
 | ||||
|             // Get the folder where SCORM should be unzipped.
 | ||||
|             return this.scormProvider.getScormFolder(scorm.moduleurl); | ||||
|         }).then((path) => { | ||||
|             const promises = []; | ||||
| 
 | ||||
|             // Remove the unzipped folder.
 | ||||
|             promises.push(this.fileProvider.removeDir(path).catch((error) => { | ||||
|                 if (error && error.code == 1) { | ||||
|                     // Not found, ignore error.
 | ||||
|                 } else { | ||||
|                     return Promise.reject(error); | ||||
|                 } | ||||
|             })); | ||||
| 
 | ||||
|             // Maybe the ZIP wasn't deleted for some reason. Try to delete it too.
 | ||||
|             promises.push(this.filepoolProvider.removeFileByUrl(siteId, this.scormProvider.getPackageUrl(scorm)).catch(() => { | ||||
|                 // Ignore errors.
 | ||||
|             })); | ||||
| 
 | ||||
|             return Promise.all(promises); | ||||
|         }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										920
									
								
								src/addon/mod/scorm/providers/scorm-offline.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,920 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // 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 { CoreLoggerProvider } from '@providers/logger'; | ||||
| import { CoreSitesProvider } from '@providers/sites'; | ||||
| import { CoreSyncProvider } from '@providers/sync'; | ||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||
| import { CoreTimeUtilsProvider } from '@providers/utils/time'; | ||||
| import { CoreUtilsProvider } from '@providers/utils/utils'; | ||||
| import { CoreUserProvider } from '@core/user/providers/user'; | ||||
| import { AddonModScormProvider } from './scorm'; | ||||
| import { SQLiteDB } from '@classes/sqlitedb'; | ||||
| 
 | ||||
| /** | ||||
|  * Service to handle offline SCORM. | ||||
|  */ | ||||
| @Injectable() | ||||
| export class AddonModScormOfflineProvider { | ||||
| 
 | ||||
|     protected logger; | ||||
| 
 | ||||
|     // Variables for database.
 | ||||
|     protected ATTEMPTS_TABLE = 'addon_mod_scorm_offline_attempts'; | ||||
|     protected TRACKS_TABLE = 'addon_mod_scorm_offline_scos_tracks'; | ||||
|     protected tablesSchema = [ | ||||
|         { | ||||
|             name: this.ATTEMPTS_TABLE, | ||||
|             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: this.TRACKS_TABLE, | ||||
|             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'] | ||||
|         } | ||||
|     ]; | ||||
| 
 | ||||
|     constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private timeUtils: CoreTimeUtilsProvider, | ||||
|             private syncProvider: CoreSyncProvider, private utils: CoreUtilsProvider, private textUtils: CoreTextUtilsProvider, | ||||
|             private userProvider: CoreUserProvider) { | ||||
|         this.logger = logger.getInstance('AddonModScormOfflineProvider'); | ||||
| 
 | ||||
|         this.sitesProvider.createTablesFromSchema(this.tablesSchema); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * 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 {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Number of the attempt to change. | ||||
|      * @param {number} newAttempt New attempt number. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not defined use site's current user. | ||||
|      * @return {Promise<any>} Promise resolved when the attempt number changes. | ||||
|      */ | ||||
|     changeAttemptNumber(scormId: number, attempt: number, newAttempt: number, siteId?: string, userId?: number): Promise<any> { | ||||
| 
 | ||||
|         return this.sitesProvider.getSite(siteId).then((site) => { | ||||
|             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(); | ||||
|             let newData: any = { | ||||
|                     attempt: newAttempt, | ||||
|                     timemodified: this.timeUtils.timestamp() | ||||
|                 }; | ||||
| 
 | ||||
|             // Block the SCORM so it can't be synced.
 | ||||
|             this.syncProvider.blockOperation(AddonModScormProvider.COMPONENT, scormId, 'changeAttemptNumber', site.id); | ||||
| 
 | ||||
|             return db.updateRecords(this.ATTEMPTS_TABLE, newData, {scormId, userId, attempt}).then(() => { | ||||
| 
 | ||||
|                 // Now update the attempt number of all the tracks and mark them as not synced.
 | ||||
|                 newData = { | ||||
|                     attempt: newAttempt, | ||||
|                     synced: 0 | ||||
|                 }; | ||||
| 
 | ||||
|                 return db.updateRecords(this.TRACKS_TABLE, newData, {scormId, userId, attempt}).catch((error) => { | ||||
|                     // Failed to update the tracks, restore the old attempt number.
 | ||||
|                     return db.updateRecords(this.ATTEMPTS_TABLE, { attempt }, {scormId, userId, attempt: newAttempt}).then(() => { | ||||
|                         return Promise.reject(error); | ||||
|                     }); | ||||
|                 }); | ||||
|             }).finally(() => { | ||||
|                 // Unblock the SCORM.
 | ||||
|                 this.syncProvider.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 {any} scorm SCORM. | ||||
|      * @param {number} attempt Number of the new attempt. | ||||
|      * @param {any} userData User data to store in the attempt. | ||||
|      * @param {any} [snapshot] Optional. Snapshot to store in the attempt. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not defined use site's current user. | ||||
|      * @return {Promise<any>} Promise resolved when the new attempt is created. | ||||
|      */ | ||||
|     createNewAttempt(scorm: any, attempt: number, userData: any, snapshot?: any, siteId?: string, userId?: number): Promise<any> { | ||||
| 
 | ||||
|         return this.sitesProvider.getSite(siteId).then((site) => { | ||||
|             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.
 | ||||
|             this.syncProvider.blockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'createNewAttempt', site.id); | ||||
| 
 | ||||
|             // Create attempt in DB.
 | ||||
|             const db = site.getDb(), | ||||
|                 entry: any = { | ||||
|                     scormId: scorm.id, | ||||
|                     userId: userId, | ||||
|                     attempt: attempt, | ||||
|                     courseId: scorm.course, | ||||
|                     timecreated: this.timeUtils.timestamp(), | ||||
|                     timemodified: this.timeUtils.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)); | ||||
|             } | ||||
| 
 | ||||
|             return db.insertRecord(this.ATTEMPTS_TABLE, entry).then(() => { | ||||
|                 // Store all the data in userData.
 | ||||
|                 const promises = []; | ||||
| 
 | ||||
|                 for (const key in userData) { | ||||
|                     const sco = userData[key], | ||||
|                         tracks = []; | ||||
| 
 | ||||
|                     for (const element in sco.userdata) { | ||||
|                         tracks.push({element: element, value: sco.userdata[element]}); | ||||
|                     } | ||||
| 
 | ||||
|                     promises.push(this.saveTracks(scorm, sco.scoid, attempt, tracks, userData, site.id, userId)); | ||||
|                 } | ||||
| 
 | ||||
|                 return Promise.all(promises); | ||||
|             }).finally(() => { | ||||
|                 // Unblock the SCORM.
 | ||||
|                 this.syncProvider.unblockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'createNewAttempt', site.id); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Delete all the stored data from an attempt. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not defined use site's current user. | ||||
|      * @return {Promise<any>} Promise resolved when all the data has been deleted. | ||||
|      */ | ||||
|     deleteAttempt(scormId: number, attempt: number, siteId?: string, userId?: number): Promise<any> { | ||||
|         return this.sitesProvider.getSite(siteId).then((site) => { | ||||
|             userId = userId || site.getUserId(); | ||||
| 
 | ||||
|             this.logger.debug('Delete offline attempt ' + attempt + ' in SCORM ' + scormId); | ||||
| 
 | ||||
|             const promises = [], | ||||
|                 db = site.getDb(); | ||||
| 
 | ||||
|             // Delete the attempt.
 | ||||
|             promises.push(db.deleteRecords(this.ATTEMPTS_TABLE, {scormId, userId, attempt})); | ||||
| 
 | ||||
|             // Delete all the tracks.
 | ||||
|             promises.push(db.deleteRecords(this.TRACKS_TABLE, {scormId, userId, attempt})); | ||||
| 
 | ||||
|             return Promise.all(promises); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Helper function to return a formatted list of interactions for reports. | ||||
|      * This function is based in Moodle's scorm_format_interactions. | ||||
|      * | ||||
|      * @param {any} scoUserData Userdata from a certain SCO. | ||||
|      * @return {any} Formatted userdata. | ||||
|      */ | ||||
|     protected formatInteractions(scoUserData: any): any { | ||||
|         const formatted: any = {}; | ||||
| 
 | ||||
|         // 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') { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             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 = this.textUtils.roundToDecimals(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 {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @return {Promise<any[]>} Promise resolved when the offline attempts are retrieved. | ||||
|      */ | ||||
|     getAllAttempts(siteId?: string): Promise<any[]> { | ||||
|         return this.sitesProvider.getSiteDb(siteId).then((db) => { | ||||
|             return db.getAllRecords(this.ATTEMPTS_TABLE); | ||||
|         }).then((attempts) => { | ||||
|             attempts.forEach((attempt) => { | ||||
|                 attempt.snapshot = this.textUtils.parseJSON(attempt.snapshot); | ||||
|             }); | ||||
| 
 | ||||
|             return attempts; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get an offline attempt. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not defined use site's current user. | ||||
|      * @return {Promise<number>} Promise resolved with the attempt. | ||||
|      */ | ||||
|     getAttempt(scormId: number, attempt: number, siteId?: string, userId?: number): Promise<any> { | ||||
|         return this.sitesProvider.getSite(siteId).then((site) => { | ||||
|             userId = userId || site.getUserId(); | ||||
| 
 | ||||
|             return site.getDb().getRecord(this.ATTEMPTS_TABLE, {scormId, userId, attempt}).then((entry) => { | ||||
|                 entry.snapshot = this.textUtils.parseJSON(entry.snapshot); | ||||
| 
 | ||||
|                 return entry; | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the creation time of an attempt. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not defined use site's current user. | ||||
|      * @return {Promise<number>} Promise resolved with time the attempt was created. | ||||
|      */ | ||||
|     getAttemptCreationTime(scormId: number, attempt: number, siteId?: string, userId?: number): Promise<number> { | ||||
|         return this.getAttempt(scormId, attempt, siteId, userId).catch(() => { | ||||
|             return {}; // Attempt not found.
 | ||||
|         }).then((entry) => { | ||||
|             return entry.timecreated; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the offline attempts done by a user in the given SCORM. | ||||
|      * | ||||
|      * @param {number} scormId  SCORM ID. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not defined use site's current user. | ||||
|      * @return {Promise<any[]>} Promise resolved when the offline attempts are retrieved. | ||||
|      */ | ||||
|     getAttempts(scormId: number, siteId?: string, userId?: number): Promise<any[]> { | ||||
|         return this.sitesProvider.getSite(siteId).then((site) => { | ||||
|             userId = userId || site.getUserId(); | ||||
| 
 | ||||
|             return site.getDb().getRecords(this.ATTEMPTS_TABLE, {scormId, userId}); | ||||
|         }).then((attempts) => { | ||||
|             attempts.forEach((attempt) => { | ||||
|                 attempt.snapshot = this.textUtils.parseJSON(attempt.snapshot); | ||||
|             }); | ||||
| 
 | ||||
|             return attempts; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the snapshot of an attempt. | ||||
|      * | ||||
|      * @param {number} scormId  SCORM ID. | ||||
|      * @param {number} attempt  Attempt number. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not defined use site's current user. | ||||
|      * @return {Promise<any>} Promise resolved with the snapshot or undefined if no snapshot. | ||||
|      */ | ||||
|     getAttemptSnapshot(scormId: number, attempt: number, siteId?: string, userId?: number): Promise<any> { | ||||
|         return this.getAttempt(scormId, attempt, siteId, userId).catch(() => { | ||||
|             return {}; // Attempt not found.
 | ||||
|         }).then((entry) => { | ||||
|             return entry.snapshot; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get launch URLs from a list of SCOs, indexing them by SCO ID. | ||||
|      * | ||||
|      * @param {any[]} scos List of SCOs. Each SCO needs to have 'id' and 'launch' properties. | ||||
|      * @return {{[scoId: number]: string}} Launch URLs indexed by SCO ID. | ||||
|      */ | ||||
|     protected getLaunchUrlsFromScos(scos: any[]): {[scoId: number]: string} { | ||||
|         const response = {}; | ||||
| 
 | ||||
|         scos.forEach((sco) => { | ||||
|             response[sco.id] = sco.launch; | ||||
|         }); | ||||
| 
 | ||||
|         return response; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get data stored in local DB for a certain scorm and attempt. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {boolean} [excludeSynced] Whether it should only return not synced entries. | ||||
|      * @param {boolean} [excludeNotSynced] Whether it should only return synced entries. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not defined use site's current user. | ||||
|      * @return {Promise<any[]>} Promise resolved with the entries. | ||||
|      */ | ||||
|     getScormStoredData(scormId: number, attempt: number, excludeSynced?: boolean, excludeNotSynced?: boolean, siteId?: string, | ||||
|             userId?: number): Promise<any[]> { | ||||
| 
 | ||||
|         if (excludeSynced && excludeNotSynced) { | ||||
|             return Promise.resolve([]); | ||||
|         } | ||||
| 
 | ||||
|         return this.sitesProvider.getSite(siteId).then((site) => { | ||||
|             userId = userId || site.getUserId(); | ||||
| 
 | ||||
|             const conditions: any = { | ||||
|                 scormId: scormId, | ||||
|                 userId: userId, | ||||
|                 attempt: attempt | ||||
|             }; | ||||
| 
 | ||||
|             if (excludeSynced) { | ||||
|                 conditions.synced = 0; | ||||
|             } else if (excludeNotSynced) { | ||||
|                 conditions.synced = 1; | ||||
|             } | ||||
| 
 | ||||
|             return site.getDb().getRecords(this.TRACKS_TABLE, conditions); | ||||
|         }).then((tracks) => { | ||||
|             tracks.forEach((track) => { | ||||
|                 track.value = this.textUtils.parseJSON(track.value); | ||||
|             }); | ||||
| 
 | ||||
|             return tracks; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the user data for a certain SCORM and offline attempt. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {any[]} scos SCOs returned by AddonModScormProvider.getScos. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not defined use site's current user. | ||||
|      * @return {Promise<any>} Promise resolved when the user data is retrieved. | ||||
|      */ | ||||
|     getScormUserData(scormId: number, attempt: number, scos: any[], siteId?: string, userId?: number): Promise<any> { | ||||
|         let fullName = '', | ||||
|             userName = ''; | ||||
| 
 | ||||
|         return this.sitesProvider.getSite(siteId).then((site) => { | ||||
|             userId = userId || site.getUserId(); | ||||
| 
 | ||||
|             // Get username and fullname.
 | ||||
|             if (userId == site.getUserId()) { | ||||
|                 fullName = site.getInfo().fullname; | ||||
|                 userName = site.getInfo().username; | ||||
|             } else { | ||||
|                 return this.userProvider.getProfile(userId).then((profile) => { | ||||
|                     fullName = profile.fullname; | ||||
|                     userName = profile.username || ''; | ||||
|                 }).catch(() => { | ||||
|                     // Ignore errors.
 | ||||
|                 }); | ||||
|             } | ||||
|         }).then(() => { | ||||
| 
 | ||||
|             // Get user data. Ordering when using a compound index is complex, so we won't order by scoid.
 | ||||
|             return this.getScormStoredData(scormId, attempt, false, false, siteId, userId).then((entries) => { | ||||
|                 const response = {}, | ||||
|                     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 | ||||
|                             } | ||||
|                         }; | ||||
|                     } | ||||
| 
 | ||||
|                     response[scoId].userdata[entry.element] = entry.value; | ||||
|                     if (entry.timemodified > 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: '' | ||||
|                             } | ||||
|                         }; | ||||
|                     } | ||||
|                 }); | ||||
| 
 | ||||
|                 // 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 {number} scormId SCORM ID. | ||||
|      * @param {number} scoId SCO ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {string} element Name of the element to insert. | ||||
|      * @param {any} value Value to insert. | ||||
|      * @param {boolean} [forceCompleted] True if SCORM forces completed. | ||||
|      * @param {any} [scoData] User data for the given SCO. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not set use site's current user. | ||||
|      * @return {Promise<any>} Promise resolved when the insert is done. | ||||
|      */ | ||||
|     protected insertTrack(scormId: number, scoId: number, attempt: number, element: string, value: any, forceCompleted?: boolean, | ||||
|             scoData?: any, siteId?: string, userId?: number): Promise<any> { | ||||
| 
 | ||||
|         return this.sitesProvider.getSite(siteId).then((site) => { | ||||
|             userId = userId || site.getUserId(); | ||||
|             scoData = scoData || {}; | ||||
| 
 | ||||
|             const promises = [], // List of promises for actions previous to the real insert.
 | ||||
|                 scoUserData = scoData.userdata || {}, | ||||
|                 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; | ||||
| 
 | ||||
|                         promises.push(this.insertTrackToDB(db, userId, scormId, scoId, attempt, 'cmi.core.lesson_status', | ||||
|                                 'completed')); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             return Promise.all(promises).then(() => { | ||||
|                 // Don't update x.start.time, keep the original value.
 | ||||
|                 if (!scoUserData[element] || element != 'x.start.time') { | ||||
|                     let promise = <Promise<any>> this.insertTrackToDB(db, userId, scormId, scoId, attempt, element, value); | ||||
| 
 | ||||
|                     return promise.catch((error) => { | ||||
|                         if (lessonStatusInserted) { | ||||
|                             // Rollback previous insert.
 | ||||
|                             promise = <Promise<any>> this.insertTrackToDB(db, userId, scormId, scoId, attempt, | ||||
|                                     'cmi.core.lesson_status', 'incomplete'); | ||||
| 
 | ||||
|                             return promise.then(() => { | ||||
|                                 return Promise.reject(error); | ||||
|                             }); | ||||
|                         } | ||||
| 
 | ||||
|                         return Promise.reject(null); | ||||
|                     }); | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Insert a track in the DB. | ||||
|      * | ||||
|      * @param {SQLiteDB} db Site's DB. | ||||
|      * @param {number} userId User ID. | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number} scoId SCO ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {string} element Name of the element to insert. | ||||
|      * @param {any} value Value of the element to insert. | ||||
|      * @param {boolean} synchronous True if insert should NOT return a promise. Please use it only if synchronous is a must. | ||||
|      * @return {boolean|Promise<any>} 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: any, synchronous?: boolean): boolean | Promise<any> { | ||||
| 
 | ||||
|         const entry = { | ||||
|             userId: userId, | ||||
|             scormId: scormId, | ||||
|             scoId: scoId, | ||||
|             attempt: attempt, | ||||
|             element: element, | ||||
|             value: typeof value == 'undefined' ? null : JSON.stringify(value), | ||||
|             timemodified: this.timeUtils.timestamp(), | ||||
|             synced: 0 | ||||
|         }; | ||||
| 
 | ||||
|         if (synchronous) { | ||||
|             // The insert operation is always asynchronous, always return true.
 | ||||
|             db.insertRecord(this.TRACKS_TABLE, entry); | ||||
| 
 | ||||
|             return true; | ||||
|         } else { | ||||
|             return db.insertRecord(this.TRACKS_TABLE, 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 {number} scormId SCORM ID. | ||||
|      * @param {number} scoId SCO ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {string} element Name of the element to insert. | ||||
|      * @param {any} value Value of the element to insert. | ||||
|      * @param {boolean} [forceCompleted] True if SCORM forces completed. | ||||
|      * @param {any} [scoData] User data for the given SCO. | ||||
|      * @param {number} [userId] User ID. If not set use current user. | ||||
|      * @return {boolean} Promise resolved when the insert is done. | ||||
|      */ | ||||
|     protected insertTrackSync(scormId: number, scoId: number, attempt: number, element: string, value: any, | ||||
|             forceCompleted?: boolean, scoData?: any, userId?: number): boolean { | ||||
|         scoData = scoData || {}; | ||||
|         userId = userId || this.sitesProvider.getCurrentSiteUserId(); | ||||
| 
 | ||||
|         if (!this.sitesProvider.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 || {}, | ||||
|             db = this.sitesProvider.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; | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Don't update x.start.time, keep the original value.
 | ||||
|         if (!scoUserData[element] || element != 'x.start.time') { | ||||
|             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 {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {number} scoId SCO ID. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not defined use site's current user. | ||||
|      * @return {Promise<any>} Promise resolved when marked. | ||||
|      */ | ||||
|     markAsSynced(scormId: number, attempt: number, scoId: number, siteId?: string, userId?: number): Promise<any> { | ||||
|         return this.sitesProvider.getSite(siteId).then((site) => { | ||||
|             userId = userId || site.getUserId(); | ||||
| 
 | ||||
|             this.logger.debug('Mark SCO ' + scoId + ' as synced for attempt ' + attempt + ' in SCORM ' + scormId); | ||||
| 
 | ||||
|             return site.getDb().updateRecords(this.TRACKS_TABLE, {synced: 1}, { | ||||
|                 scormId: scormId, | ||||
|                 userId: userId, | ||||
|                 attempt: attempt, | ||||
|                 scoId: scoId, | ||||
|                 synced: 0 | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Removes the default data form user data. | ||||
|      * | ||||
|      * @param {any} userData User data returned by AddonModScormProvider.getScormUserData. | ||||
|      * @return {any} User data without default data. | ||||
|      */ | ||||
|     protected removeDefaultData(userData: any): any { | ||||
|         const result = this.utils.clone(userData); | ||||
| 
 | ||||
|         for (const key in result) { | ||||
|             delete result[key].defaultdata; | ||||
|         } | ||||
| 
 | ||||
|         return result; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Saves a SCORM tracking record in offline. | ||||
|      * | ||||
|      * @param {any} scorm SCORM. | ||||
|      * @param {number} scoId Sco ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {any[]} tracks Tracking data to store. | ||||
|      * @param {any} userData User data for this attempt and SCO. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not defined use site's current user. | ||||
|      * @return {Promise<any>} Promise resolved when data is saved. | ||||
|      */ | ||||
|     saveTracks(scorm: any, scoId: number, attempt: number, tracks: any[], userData: any, siteId?: string, userId?: number) | ||||
|             : Promise<any> { | ||||
| 
 | ||||
|         return this.sitesProvider.getSite(siteId).then((site) => { | ||||
|             userId = userId || site.getUserId(); | ||||
| 
 | ||||
|             // Block the SCORM so it can't be synced.
 | ||||
|             this.syncProvider.blockOperation(AddonModScormProvider.COMPONENT, scorm.id, 'saveTracksOffline', siteId); | ||||
| 
 | ||||
|             // Insert all the tracks.
 | ||||
|             const promises = []; | ||||
|             tracks.forEach((track) => { | ||||
|                 promises.push(this.insertTrack(scorm.id, scoId, attempt, track.element, track.value, scorm.forcecompleted, | ||||
|                         userData[scoId], siteId, userId)); | ||||
|             }); | ||||
| 
 | ||||
|             return Promise.all(promises).finally(() => { | ||||
|                 // Unblock the SCORM operation.
 | ||||
|                 this.syncProvider.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  {any} scorm    SCORM. | ||||
|      * @param  {number} scoId    Sco ID. | ||||
|      * @param  {number} attempt  Attempt number. | ||||
|      * @param  {Object[]} tracks Tracking data to store. | ||||
|      * @param  {any} userData User data for this attempt and SCO. | ||||
|      * @return {boolean}         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: any, scoId: number, attempt: number, tracks: any[], userData: any, userId?: number): boolean { | ||||
|         userId = userId || this.sitesProvider.getCurrentSiteUserId(); | ||||
|         let success = true; | ||||
| 
 | ||||
|         tracks.forEach((track) => { | ||||
|             if (!this.insertTrackSync(scorm.id, scoId, attempt, track.element, track.value, scorm.forcecompleted, userData[scoId], | ||||
|                     userId)) { | ||||
|                 success = false; | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         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 {any} userData Contains user's data. | ||||
|      * @param {string} param Name of parameter that should be checked. | ||||
|      * @param {any} [ifEmpty] Value to be replaced with if param is not set. | ||||
|      * @return {any} Value from userData[param] if set, ifEmpty otherwise. | ||||
|      */ | ||||
|     protected scormIsset(userData: any, param: string, ifEmpty: any = ''): any { | ||||
|         if (typeof userData[param] != 'undefined') { | ||||
|             return userData[param]; | ||||
|         } | ||||
| 
 | ||||
|         return ifEmpty; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Set an attempt's snapshot. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {any} userData User data to store as snapshot. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @param {number} [userId] User ID. If not defined use site's current user. | ||||
|      * @return {Promise<any>} Promise resolved when snapshot has been stored. | ||||
|      */ | ||||
|     setAttemptSnapshot(scormId: number, attempt: number, userData: any, siteId?: string, userId?: number): Promise<any> { | ||||
| 
 | ||||
|         return this.sitesProvider.getSite(siteId).then((site) => { | ||||
|             userId = userId || site.getUserId(); | ||||
| 
 | ||||
|             this.logger.debug('Set snapshot for attempt ' + attempt + ' in SCORM ' + scormId); | ||||
| 
 | ||||
|             const newData = { | ||||
|                 timemodified: this.timeUtils.timestamp(), | ||||
|                 snapshot: JSON.stringify(this.removeDefaultData(userData)) | ||||
|             }; | ||||
| 
 | ||||
|             return site.getDb().updateRecords(this.ATTEMPTS_TABLE, newData, { scormId, userId, attempt }); | ||||
|         }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										832
									
								
								src/addon/mod/scorm/providers/scorm-sync.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,832 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { TranslateService } from '@ngx-translate/core'; | ||||
| import { CoreAppProvider } from '@providers/app'; | ||||
| import { CoreEventsProvider } from '@providers/events'; | ||||
| import { CoreLoggerProvider } from '@providers/logger'; | ||||
| import { CoreSitesProvider } from '@providers/sites'; | ||||
| import { CoreSyncProvider } from '@providers/sync'; | ||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||
| import { CoreUtilsProvider } from '@providers/utils/utils'; | ||||
| import { CoreCourseProvider } from '@core/course/providers/course'; | ||||
| import { CoreSyncBaseProvider } from '@classes/base-sync'; | ||||
| import { AddonModScormProvider, AddonModScormAttemptCountResult } from './scorm'; | ||||
| import { AddonModScormOfflineProvider } from './scorm-offline'; | ||||
| import { AddonModScormPrefetchHandler } from './prefetch-handler'; | ||||
| 
 | ||||
| /** | ||||
|  * Data returned by a SCORM sync. | ||||
|  */ | ||||
| export interface AddonModScormSyncResult { | ||||
|     /** | ||||
|      * List of warnings. | ||||
|      * @type {string[]} | ||||
|      */ | ||||
|     warnings: string[]; | ||||
| 
 | ||||
|     /** | ||||
|      * Whether an attempt was finished in the site due to the sync, | ||||
|      * @type {boolean} | ||||
|      */ | ||||
|     attemptFinished: boolean; | ||||
| 
 | ||||
|     /** | ||||
|      * Whether some data was sent to the site. | ||||
|      * @type {boolean} | ||||
|      */ | ||||
|     updated: boolean; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Service to sync SCORMs. | ||||
|  */ | ||||
| @Injectable() | ||||
| export class AddonModScormSyncProvider extends CoreSyncBaseProvider { | ||||
| 
 | ||||
|     static AUTO_SYNCED = 'addon_mod_scorm_autom_synced'; | ||||
|     static SYNC_TIME = 600000; | ||||
| 
 | ||||
|     protected componentTranslate: string; | ||||
| 
 | ||||
|     constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, | ||||
|             syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, | ||||
|             courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider, | ||||
|             private scormProvider: AddonModScormProvider, private scormOfflineProvider: AddonModScormOfflineProvider, | ||||
|             private prefetchHandler: AddonModScormPrefetchHandler, private utils: CoreUtilsProvider) { | ||||
| 
 | ||||
|         super('AddonModScormSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); | ||||
| 
 | ||||
|         this.componentTranslate = courseProvider.translateModuleName('scorm'); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * 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 {number} scormId SCORM ID. | ||||
|      * @param {number} attempt The offline attempt to treat. | ||||
|      * @param {number} lastOffline Last offline attempt number. | ||||
|      * @param {number[]} newAttemptsSameOrder Attempts that'll be created as new attempts but keeping the current order. | ||||
|      * @param {any} newAttemptsAtEnd Object with attempts that'll be created at the end of the list of attempts (should be max 1). | ||||
|      * @param {number} lastOfflineCreated Time when the last offline attempt was created. | ||||
|      * @param {boolean} lastOfflineIncomplete Whether the last offline attempt is incomplete. | ||||
|      * @param {string[]} warnings Array where to add the warnings. | ||||
|      * @param {string} siteId Site ID. | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected addToNewOrDelete(scormId: number, attempt: number, lastOffline: number, newAttemptsSameOrder: number[], | ||||
|             newAttemptsAtEnd: any, lastOfflineCreated: number, lastOfflineIncomplete: boolean, warnings: string[], | ||||
|             siteId: string): Promise<any> { | ||||
| 
 | ||||
|         if (attempt == lastOffline) { | ||||
|             newAttemptsSameOrder.push(attempt); | ||||
| 
 | ||||
|             return Promise.resolve(); | ||||
|         } | ||||
| 
 | ||||
|         // Check if the attempt can be created.
 | ||||
|         return this.scormOfflineProvider.getAttemptCreationTime(scormId, attempt, siteId).then((time) => { | ||||
|             if (time > lastOfflineCreated) { | ||||
|                 // 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.'); | ||||
| 
 | ||||
|                     return this.scormOfflineProvider.deleteAttempt(scormId, attempt, siteId).then(() => { | ||||
|                         warnings.push(this.translate.instant('addon.mod_scorm.warningofflinedatadeleted', {number: attempt})); | ||||
|                     }).catch(() => { | ||||
|                         // Maybe there's something wrong with the data or the storage implementation.
 | ||||
|                     }); | ||||
|                 } else { | ||||
|                     // Add the attempt at the end.
 | ||||
|                     newAttemptsAtEnd[time] = attempt; | ||||
|                 } | ||||
| 
 | ||||
|             } else { | ||||
|                 newAttemptsSameOrder.push(attempt); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check if can retry an attempt synchronization. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {number} lastOnline Last online attempt number. | ||||
|      * @param {string} siteId Site ID. | ||||
|      * @return {Promise<any>} Promise resolved if can retry the synchronization, rejected otherwise. | ||||
|      */ | ||||
|     protected canRetrySync(scormId: number, attempt: number, lastOnline: number, siteId: string): Promise<any> { | ||||
|         // If it's the last attempt we don't need to ignore cache because we already did it.
 | ||||
|         const refresh = lastOnline != attempt; | ||||
| 
 | ||||
|         return this.scormProvider.getScormUserData(scormId, attempt, undefined, false, refresh, siteId).then((siteData) => { | ||||
|             // Get synchronization snapshot (if sync fails it should store a snapshot).
 | ||||
|             return this.scormOfflineProvider.getAttemptSnapshot(scormId, attempt, siteId).then((snapshot) => { | ||||
|                 if (!snapshot || !Object.keys(snapshot).length || !this.snapshotEquals(snapshot, siteData)) { | ||||
|                     // No snapshot or it doesn't match, we can't retry the synchronization.
 | ||||
|                     return Promise.reject(null); | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Create new attempts at the end of the offline attempts list. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {any} newAttempts Object with the attempts to create. The keys are the timecreated, the values are the attempt number. | ||||
|      * @param {number} lastOffline Number of last offline attempt. | ||||
|      * @param {string} siteId Site ID. | ||||
|      * @return {Promise<any>} Promise resolved when done. | ||||
|      */ | ||||
|     protected createNewAttemptsAtEnd(scormId: number, newAttempts: any, lastOffline: number, siteId: string): Promise<any> { | ||||
|         const times = Object.keys(newAttempts).sort(), // Sort in ASC order.
 | ||||
|             promises = []; | ||||
| 
 | ||||
|         if (!times.length) { | ||||
|             return Promise.resolve(); | ||||
|         } | ||||
| 
 | ||||
|         times.forEach((time, index) => { | ||||
|             const attempt = newAttempts[time]; | ||||
| 
 | ||||
|             promises.push(this.scormOfflineProvider.changeAttemptNumber(scormId, attempt, lastOffline + index + 1, siteId)); | ||||
|         }); | ||||
| 
 | ||||
|         return this.utils.allPromises(promises); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Finish a sync process: remove offline data if needed, prefetch SCORM data, set sync time and return the result. | ||||
|      * | ||||
|      * @param {string} siteId Site ID. | ||||
|      * @param {any} scorm SCORM. | ||||
|      * @param {string[]} warnings List of warnings generated by the sync. | ||||
|      * @param {number} [lastOnline] Last online attempt number before the sync. | ||||
|      * @param {boolean} [lastOnlineWasFinished] Whether the last online attempt was finished before the sync. | ||||
|      * @param {AddonModScormAttemptCountResult} [initialCount] Attempt count before the sync. | ||||
|      * @param {boolean} [updated] Whether some data was sent to the site. | ||||
|      * @return {Promise<AddonModScormSyncResult>} Promise resolved on success. | ||||
|      */ | ||||
|     protected finishSync(siteId: string, scorm: any, warnings: string[], lastOnline?: number, lastOnlineWasFinished?: boolean, | ||||
|             initialCount?: AddonModScormAttemptCountResult, updated?: boolean): Promise<AddonModScormSyncResult> { | ||||
| 
 | ||||
|         let promise; | ||||
| 
 | ||||
|         if (updated) { | ||||
|             // Update the WS data.
 | ||||
|             promise = this.scormProvider.invalidateAllScormData(scorm.id, siteId).catch(() => { | ||||
|                 // Ignore errors.
 | ||||
|             }).then(() => { | ||||
|                 return this.prefetchHandler.fetchWSData(scorm, siteId); | ||||
|             }); | ||||
|         } else { | ||||
|             promise = Promise.resolve(); | ||||
|         } | ||||
| 
 | ||||
|         return promise.then(() => { | ||||
|             return this.setSyncTime(scorm.id, siteId).catch(() => { | ||||
|                 // Ignore errors.
 | ||||
|             }); | ||||
|         }).then(() => { | ||||
|             // Check if an attempt was finished in Moodle.
 | ||||
|             if (initialCount) { | ||||
|                 // Get attempt count again to check if an attempt was finished.
 | ||||
|                 return this.scormProvider.getAttemptCount(scorm.id, undefined, false, siteId).then((attemptsData) => { | ||||
|                     if (attemptsData.online.length > initialCount.online.length) { | ||||
|                         return true; | ||||
|                     } else if (!lastOnlineWasFinished && lastOnline > 0) { | ||||
|                         // Last online attempt wasn't finished, let's check if it is now.
 | ||||
|                         return this.scormProvider.isAttemptIncomplete(scorm.id, lastOnline, false, true, siteId).then((inc) => { | ||||
|                             return !inc; | ||||
|                         }); | ||||
|                     } | ||||
| 
 | ||||
|                     return false; | ||||
|                 }); | ||||
|             } | ||||
| 
 | ||||
|             return false; | ||||
|         }).then((attemptFinished) => { | ||||
|             return { | ||||
|                 warnings: warnings, | ||||
|                 attemptFinished: attemptFinished, | ||||
|                 updated: updated | ||||
|             }; | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the creation time and the status (complete/incomplete) of an offline attempt. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {string} siteId Site ID. | ||||
|      * @return {Promise<{incomplete: boolean, timecreated: number}>} Promise resolved with the data. | ||||
|      */ | ||||
|     protected getOfflineAttemptData(scormId: number, attempt: number, siteId: string) | ||||
|             : Promise<{incomplete: boolean, timecreated: number}> { | ||||
| 
 | ||||
|         // Check if last offline attempt is incomplete.
 | ||||
|         return this.scormProvider.isAttemptIncomplete(scormId, attempt, true, false, siteId).then((incomplete) => { | ||||
|             return this.scormOfflineProvider.getAttemptCreationTime(scormId, attempt, siteId).then((timecreated) => { | ||||
|                 return { | ||||
|                     incomplete: incomplete, | ||||
|                     timecreated: 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 {number} scormId SCORM ID. | ||||
|      * @param {number[]} newAttempts Attempts that need to be converted into new attempts. | ||||
|      * @param {number} lastOnline Last online attempt. | ||||
|      * @param {number} lastCollision Last attempt with collision (exists in online and offline). | ||||
|      * @param {number[]} offlineAttempts Numbers of offline attempts. | ||||
|      * @param {string} siteId Site ID. | ||||
|      * @return {Promise<any>} Promise resolved when attempts have been moved. | ||||
|      */ | ||||
|     protected moveNewAttempts(scormId: any, newAttempts: number[], lastOnline: number, lastCollision: number, | ||||
|             offlineAttempts: number[], siteId: string): Promise<any> { | ||||
| 
 | ||||
|         if (!newAttempts.length) { | ||||
|             return Promise.resolve(); | ||||
|         } | ||||
| 
 | ||||
|         let promise = Promise.resolve(), | ||||
|             lastSuccessful; | ||||
| 
 | ||||
|         // Sort offline attempts in DESC order.
 | ||||
|         offlineAttempts = offlineAttempts.sort((a, b) => { | ||||
|             return Number(a) <= Number(b) ? 1 : -1; | ||||
|         }); | ||||
| 
 | ||||
|         // First move the offline attempts after the collisions.
 | ||||
|         offlineAttempts.forEach((attempt) => { | ||||
|             if (attempt > lastCollision) { | ||||
|                 // We use a chain of promises because we need to move them in order.
 | ||||
|                 promise = promise.then(() => { | ||||
|                     const newNumber = attempt + newAttempts.length; | ||||
| 
 | ||||
|                     return this.scormOfflineProvider.changeAttemptNumber(scormId, attempt, newNumber, siteId).then(() => { | ||||
|                         lastSuccessful = attempt; | ||||
|                     }); | ||||
|                 }); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         return promise.then(() => { | ||||
|             const successful = []; | ||||
|             let promises = []; | ||||
| 
 | ||||
|             // Sort newAttempts in ASC order.
 | ||||
|             newAttempts = newAttempts.sort((a, b) => { | ||||
|                 return Number(a) >= Number(b) ? 1 : -1; | ||||
|             }); | ||||
| 
 | ||||
|             // Now move the attempts in newAttempts.
 | ||||
|             newAttempts.forEach((attempt, index) => { | ||||
|                 // No need to use chain of promises.
 | ||||
|                 const newNumber = lastOnline + index + 1; | ||||
| 
 | ||||
|                 promises.push(this.scormOfflineProvider.changeAttemptNumber(scormId, attempt, newNumber, siteId).then(() => { | ||||
|                     successful.push(attempt); | ||||
|                 })); | ||||
|             }); | ||||
| 
 | ||||
|             return Promise.all(promises).catch((error) => { | ||||
|                 // Moving the new attempts failed (it shouldn't happen). Let's undo the new attempts move.
 | ||||
|                 promises = []; | ||||
| 
 | ||||
|                 successful.forEach((attempt) => { | ||||
|                     const newNumber = lastOnline + newAttempts.indexOf(attempt) + 1; | ||||
| 
 | ||||
|                     promises.push(this.scormOfflineProvider.changeAttemptNumber(scormId, newNumber, attempt, siteId)); | ||||
|                 }); | ||||
| 
 | ||||
|                 return this.utils.allPromises(promises).then(() => { | ||||
|                     return Promise.reject(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) { | ||||
|                 return Promise.reject(error); | ||||
|             } | ||||
| 
 | ||||
|             const attemptsToUndo = []; | ||||
|             let promise = Promise.resolve(); | ||||
| 
 | ||||
|             for (let i = lastSuccessful; offlineAttempts.indexOf(i) != -1; i++) { | ||||
|                 attemptsToUndo.push(i); | ||||
|             } | ||||
| 
 | ||||
|             attemptsToUndo.forEach((attempt) => { | ||||
|                 promise = promise.then(() => { | ||||
|                     // Move it back.
 | ||||
|                     return this.scormOfflineProvider.changeAttemptNumber(scormId, attempt + newAttempts.length, attempt, siteId); | ||||
|                 }); | ||||
|             }); | ||||
| 
 | ||||
|             return promise.then(() => { | ||||
|                 return Promise.reject(error); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Save a snapshot from a synchronization. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Attemot number. | ||||
|      * @param {string} siteId  Site ID. | ||||
|      * @return {Promise<any>} Promise resolved when the snapshot is stored. | ||||
|      */ | ||||
|     protected saveSyncSnapshot(scormId: number, attempt: number, siteId: string): Promise<any> { | ||||
|         // Try to get current state from the site.
 | ||||
|         return this.scormProvider.getScormUserData(scormId, attempt, undefined, false, true, siteId).then((data) => { | ||||
|             return this.scormOfflineProvider.setAttemptSnapshot(scormId, attempt, data, siteId); | ||||
|         }, () => { | ||||
|             // Error getting user data from the site. We'll have to build it ourselves.
 | ||||
|             // Let's try to get cached data about the attempt.
 | ||||
|             return this.scormProvider.getScormUserData(scormId, attempt, undefined, false, false, siteId).catch(() => { | ||||
|                 // No cached data.
 | ||||
|                 return {}; | ||||
|             }).then((data) => { | ||||
| 
 | ||||
|                 // We need to add the synced data to the snapshot.
 | ||||
|                 return this.scormOfflineProvider.getScormStoredData(scormId, attempt, false, true, siteId).then((synced) => { | ||||
|                     synced.forEach((entry) => { | ||||
|                         if (!data[entry.scoId]) { | ||||
|                             data[entry.scoId] = { | ||||
|                                 scoid: entry.scoId, | ||||
|                                 userdata: {} | ||||
|                             }; | ||||
|                         } | ||||
|                         data[entry.scoId].userdata[entry.element] = entry.value; | ||||
|                     }); | ||||
| 
 | ||||
|                     return this.scormOfflineProvider.setAttemptSnapshot(scormId, attempt, data, 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 {any} snapshot Attempt's snapshot. | ||||
|      * @param {any} userData Data retrieved from the site. | ||||
|      * @return {boolean} True if snapshot is equal to the user data, false otherwise. | ||||
|      */ | ||||
|     protected snapshotEquals(snapshot: any, userData: any): boolean { | ||||
|         // Check that snapshot contains the data from the site.
 | ||||
|         for (const scoId in userData) { | ||||
|             const siteSco = userData[scoId], | ||||
|                 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], | ||||
|                 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 {string} [siteId] Site ID to sync. If not defined, sync all sites. | ||||
|      * @return {Promise<any>} Promise resolved if sync is successful, rejected if sync fails. | ||||
|      */ | ||||
|     syncAllScorms(siteId?: string): Promise<any> { | ||||
|         return this.syncOnSites('all SCORMs', this.syncAllScormsFunc.bind(this), [], siteId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Sync all SCORMs on a site. | ||||
|      * | ||||
|      * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. | ||||
|      * @param {Promise<any>} Promise resolved if sync is successful, rejected if sync fails. | ||||
|      */ | ||||
|     protected syncAllScormsFunc(siteId?: string): Promise<any> { | ||||
| 
 | ||||
|         // Get all offline attempts.
 | ||||
|         return this.scormOfflineProvider.getAllAttempts(siteId).then((attempts) => { | ||||
|             const scorms = [], | ||||
|                 ids = [], // To prevent duplicates.
 | ||||
|                 promises = []; | ||||
| 
 | ||||
|             // Get the IDs of all the SCORMs that have something to be synced.
 | ||||
|             attempts.forEach((attempt) => { | ||||
|                 if (ids.indexOf(attempt.scormId) == -1) { | ||||
|                     ids.push(attempt.scormId); | ||||
| 
 | ||||
|                     scorms.push({ | ||||
|                         id: attempt.scormId, | ||||
|                         courseId: attempt.courseId | ||||
|                     }); | ||||
|                 } | ||||
|             }); | ||||
| 
 | ||||
|             // Sync all SCORMs that haven't been synced for a while and that aren't attempted right now.
 | ||||
|             scorms.forEach((scorm) => { | ||||
|                 if (!this.syncProvider.isBlocked(AddonModScormProvider.COMPONENT, scorm.id, siteId)) { | ||||
| 
 | ||||
|                     promises.push(this.scormProvider.getScormById(scorm.courseId, scorm.id, '', false, siteId).then((scorm) => { | ||||
|                         return this.syncScormIfNeeded(scorm, siteId).then((data) => { | ||||
|                             if (typeof data != 'undefined') { | ||||
|                                 // We tried to sync. Send event.
 | ||||
|                                 this.eventsProvider.trigger(AddonModScormSyncProvider.AUTO_SYNCED, { | ||||
|                                     scormId: scorm.id, | ||||
|                                     attemptFinished: data.attemptFinished, | ||||
|                                     warnings: data.warnings, | ||||
|                                     updated: data.updated | ||||
|                                 }, siteId); | ||||
|                             } | ||||
|                         }); | ||||
|                     })); | ||||
|                 } | ||||
|             }); | ||||
| 
 | ||||
|             return Promise.all(promises); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Send data from a SCORM offline attempt to the site. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number} attempt Attempt number. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @return {Promise<any>} Promise resolved when the attempt is successfully synced. | ||||
|      */ | ||||
|     protected syncAttempt(scormId: number, attempt: number, siteId?: string): Promise<any> { | ||||
|         siteId = siteId || this.sitesProvider.getCurrentSiteId(); | ||||
| 
 | ||||
|         this.logger.debug('Try to sync attempt ' + attempt + ' in SCORM ' + scormId + ' and site ' + siteId); | ||||
| 
 | ||||
|         // Get only not synced entries.
 | ||||
|         return this.scormOfflineProvider.getScormStoredData(scormId, attempt, true, false, siteId).then((entries) => { | ||||
|             const scos = {}, | ||||
|                 promises = []; | ||||
|             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).
 | ||||
|             entries.forEach((entry) => { | ||||
|                 if (entry.element.indexOf('.') > -1) { | ||||
|                     if (!scos[entry.scoId]) { | ||||
|                         scos[entry.scoId] = []; | ||||
|                     } | ||||
| 
 | ||||
|                     scos[entry.scoId].push({ | ||||
|                         element: entry.element, | ||||
|                         value: entry.value | ||||
|                     }); | ||||
|                 } | ||||
|             }); | ||||
| 
 | ||||
|             // Send the data in each SCO.
 | ||||
|             for (const id in scos) { | ||||
|                 const scoId = Number(id), | ||||
|                     tracks = scos[scoId]; | ||||
| 
 | ||||
|                 promises.push(this.scormProvider.saveTracksOnline(scormId, scoId, attempt, tracks, siteId).then(() => { | ||||
|                     // Sco data successfully sent. Mark them as synced. This is needed because some SCOs sync might fail.
 | ||||
|                     return this.scormOfflineProvider.markAsSynced(scormId, attempt, scoId, siteId).catch(() => { | ||||
|                         // Ignore errors.
 | ||||
|                     }).then(() => { | ||||
|                         somethingSynced = true; | ||||
|                     }); | ||||
|                 })); | ||||
|             } | ||||
| 
 | ||||
|             return this.utils.allPromises(promises).then(() => { | ||||
|                 // Attempt has been sent. Let's delete it from local.
 | ||||
|                 return this.scormOfflineProvider.deleteAttempt(scormId, attempt, siteId).catch(() => { | ||||
|                     // Failed to delete (shouldn't happen). Let's retry once.
 | ||||
|                     return this.scormOfflineProvider.deleteAttempt(scormId, attempt, siteId).catch(() => { | ||||
|                         // Maybe there's something wrong with the data or the storage implementation.
 | ||||
|                         this.logger.error('After sync: error deleting attempt ' + attempt + ' in SCORM ' + scormId); | ||||
|                     }); | ||||
|                 }); | ||||
|             }).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.'); | ||||
| 
 | ||||
|                     return this.saveSyncSnapshot(scormId, attempt, siteId).then(() => { | ||||
|                         return Promise.reject(error); | ||||
|                     }); | ||||
|                 } else { | ||||
|                     this.logger.error('Error synchronizing attempt ' + attempt + ' in SCORM ' + scormId); | ||||
|                 } | ||||
| 
 | ||||
|                 return Promise.reject(error); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Sync a SCORM only if a certain time has passed since the last time. | ||||
|      * | ||||
|      * @param {any} scorm SCORM. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @return {Promise<any>} Promise resolved when the SCORM is synced or if it doesn't need to be synced. | ||||
|      */ | ||||
|     syncScormIfNeeded(scorm: any, siteId?: string): Promise<any> { | ||||
|         return this.isSyncNeeded(scorm.id, siteId).then((needed) => { | ||||
|             if (needed) { | ||||
|                 return this.syncScorm(scorm, siteId); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Try to synchronize a SCORM. | ||||
|      * The promise returned will be resolved with an array with warnings if the synchronization is successful. A successful | ||||
|      * synchronization doesn't mean that all the data has been sent to the site, it's possible that some attempt can't be sent. | ||||
|      * | ||||
|      * @param {any} scorm SCORM. | ||||
|      * @param {string} [siteId] Site ID. If not defined, current site. | ||||
|      * @return {Promise<AddonModScormSyncResult>} Promise resolved in success. | ||||
|      */ | ||||
|     syncScorm(scorm: any, siteId?: string): Promise<AddonModScormSyncResult> { | ||||
|         siteId = siteId || this.sitesProvider.getCurrentSiteId(); | ||||
| 
 | ||||
|         let warnings = [], | ||||
|             syncPromise, | ||||
|             initialCount, | ||||
|             lastOnline = 0, | ||||
|             lastOnlineWasFinished = false; | ||||
| 
 | ||||
|         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 (this.syncProvider.isBlocked(AddonModScormProvider.COMPONENT, scorm.id, siteId)) { | ||||
|             this.logger.debug('Cannot sync SCORM ' + scorm.id + ' because it is blocked.'); | ||||
| 
 | ||||
|             return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); | ||||
|         } | ||||
| 
 | ||||
|         this.logger.debug('Try to sync SCORM ' + scorm.id + ' in site ' + siteId); | ||||
| 
 | ||||
|         // Get attempts data. We ignore cache for online attempts, so this call will fail if offline or server down.
 | ||||
|         syncPromise = this.scormProvider.getAttemptCount(scorm.id, false, true, siteId).then((attemptsData) => { | ||||
|             if (!attemptsData.offline || !attemptsData.offline.length) { | ||||
|                 // Nothing to sync.
 | ||||
|                 return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished); | ||||
|             } | ||||
| 
 | ||||
|             initialCount = attemptsData; | ||||
| 
 | ||||
|             const collisions = []; | ||||
| 
 | ||||
|             // 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.
 | ||||
|             const promise = lastOnline > 0 ? this.scormProvider.isAttemptIncomplete(scorm.id, lastOnline, false, true, siteId) : | ||||
|                     Promise.resolve(false); | ||||
| 
 | ||||
|             return promise.then((incomplete) => { | ||||
|                 lastOnlineWasFinished = !incomplete; | ||||
| 
 | ||||
|                 if (!collisions.length && !incomplete) { | ||||
|                     // No collisions and last attempt is complete. Send offline attempts to Moodle.
 | ||||
|                     const promises = []; | ||||
| 
 | ||||
|                     attemptsData.offline.forEach((attempt) => { | ||||
|                         if (scorm.maxattempt == 0 || attempt <= scorm.maxattempt) { | ||||
|                             promises.push(this.syncAttempt(scorm.id, attempt, siteId)); | ||||
|                         } | ||||
|                     }); | ||||
| 
 | ||||
|                     return Promise.all(promises).then(() => { | ||||
|                         // All data synced, finish.
 | ||||
|                         return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished, initialCount, true); | ||||
|                     }); | ||||
| 
 | ||||
|                 } else if (collisions.length) { | ||||
|                     // We have collisions, treat them.
 | ||||
|                     return this.treatCollisions(scorm.id, collisions, lastOnline, attemptsData.offline, siteId).then((warns) => { | ||||
|                         warnings = warnings.concat(warns); | ||||
| 
 | ||||
|                         // The offline attempts might have changed since some collisions can be converted to new attempts.
 | ||||
|                         return this.scormOfflineProvider.getAttempts(scorm.id, siteId).then((entries) => { | ||||
|                             const promises = []; | ||||
|                             let cannotSyncSome = false; | ||||
| 
 | ||||
|                             entries = entries.map((entry) => { | ||||
|                                 return entry.attempt; // Get only the attempt number.
 | ||||
|                             }); | ||||
| 
 | ||||
|                             if (incomplete && entries.indexOf(lastOnline) > -1) { | ||||
|                                 // Last online was incomplete, but it was continued in offline.
 | ||||
|                                 incomplete = false; | ||||
|                             } | ||||
| 
 | ||||
|                             entries.forEach((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 == 0 || attempt <= scorm.maxattempt) { | ||||
|                                         promises.push(this.syncAttempt(scorm.id, attempt, siteId)); | ||||
|                                     } | ||||
|                                 } else { | ||||
|                                     cannotSyncSome = true; | ||||
|                                 } | ||||
|                             }); | ||||
| 
 | ||||
|                             return Promise.all(promises).then(() => { | ||||
|                                 if (cannotSyncSome) { | ||||
|                                     warnings.push(this.translate.instant('addon.mod_scorm.warningsynconlineincomplete')); | ||||
|                                 } | ||||
| 
 | ||||
|                                 return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished, initialCount, | ||||
|                                         true); | ||||
|                             }); | ||||
|                         }); | ||||
|                     }); | ||||
|                 } else { | ||||
|                     // No collisions, but last online attempt is incomplete so we can't send offline attempts.
 | ||||
|                     warnings.push(this.translate.instant('addon.mod_scorm.warningsynconlineincomplete')); | ||||
| 
 | ||||
|                     return this.finishSync(siteId, scorm, warnings, lastOnline, lastOnlineWasFinished, initialCount, false); | ||||
|                 } | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         return this.addOngoingSync(scorm.id, syncPromise, siteId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Treat collisions found in a SCORM synchronization process. | ||||
|      * | ||||
|      * @param {number} scormId SCORM ID. | ||||
|      * @param {number[]} collisions Numbers of attempts that exist both in online and offline. | ||||
|      * @param {number} lastOnline Last online attempt. | ||||
|      * @param {number[]} offlineAttempts Numbers of offline attempts. | ||||
|      * @param {string} siteId Site ID. | ||||
|      * @return {Promise<string[]} 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 treatCollisions(scormId: number, collisions: number[], lastOnline: number, offlineAttempts: number[], siteId: string) | ||||
|             : Promise<string[]> { | ||||
| 
 | ||||
|         const warnings = [], | ||||
|             newAttemptsSameOrder = [], // Attempts that will be created as new attempts but keeping the current order.
 | ||||
|             newAttemptsAtEnd = {}, // Attempts that will be created at the end of the list of attempts (should be max 1 attempt).
 | ||||
|             lastCollision = Math.max.apply(Math, collisions); | ||||
|         let lastOffline = Math.max.apply(Math, offlineAttempts); | ||||
| 
 | ||||
|         // Get needed data from the last offline attempt.
 | ||||
|         return this.getOfflineAttemptData(scormId, lastOffline, siteId).then((lastOfflineData) => { | ||||
|             const promises = []; | ||||
| 
 | ||||
|             collisions.forEach((attempt) => { | ||||
|                 // First get synced entries to detect if it was a failed synchronization.
 | ||||
|                 promises.push(this.scormOfflineProvider.getScormStoredData(scormId, attempt, false, true, siteId).then((synced) => { | ||||
|                     if (synced && 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.
 | ||||
|                         return this.scormOfflineProvider.getScormStoredData(scormId, attempt, true, false, siteId) | ||||
|                                 .then((entries) => { | ||||
| 
 | ||||
|                             // Check if there are elements to sync.
 | ||||
|                             let hasDataToSend = false; | ||||
|                             for (const i in entries) { | ||||
|                                 const entry = entries[i]; | ||||
|                                 if (entry.element.indexOf('.') > -1) { | ||||
|                                     hasDataToSend = true; | ||||
|                                     break; | ||||
|                                 } | ||||
|                             } | ||||
| 
 | ||||
|                             if (hasDataToSend) { | ||||
|                                 // There are elements to sync. We need to check if it's possible to sync them or not.
 | ||||
|                                 return this.canRetrySync(scormId, attempt, lastOnline, siteId).catch(() => { | ||||
|                                     // 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 { | ||||
|                                 // Nothing to sync, delete the attempt.
 | ||||
|                                 return this.scormOfflineProvider.deleteAttempt(scormId, attempt, siteId).catch(() => { | ||||
|                                     // Maybe there's something wrong with the data or the storage implementation.
 | ||||
|                                 }); | ||||
|                             } | ||||
|                         }); | ||||
|                     } else { | ||||
|                         // It's not a failed synchronization. Check if it's an attempt continued in offline.
 | ||||
|                         return this.scormOfflineProvider.getAttemptSnapshot(scormId, attempt, siteId).then((snapshot) => { | ||||
|                             if (snapshot && Object.keys(snapshot).length) { | ||||
|                                 // 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; | ||||
| 
 | ||||
|                                 return this.scormProvider.getScormUserData(scormId, attempt, undefined, false, refresh, siteId) | ||||
|                                         .then((data) => { | ||||
| 
 | ||||
|                                     if (!this.snapshotEquals(snapshot, data)) { | ||||
|                                         // 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); | ||||
|                                     } | ||||
|                                 }); | ||||
|                             } else { | ||||
|                                 // No snapshot, it's a different attempt.
 | ||||
|                                 newAttemptsSameOrder.push(attempt); | ||||
|                             } | ||||
|                         }); | ||||
|                     } | ||||
|                 })); | ||||
|             }); | ||||
| 
 | ||||
|             return Promise.all(promises).then(() => { | ||||
|                 return this.moveNewAttempts(scormId, newAttemptsSameOrder, lastOnline, lastCollision, offlineAttempts, siteId) | ||||
|                         .then(() => { | ||||
| 
 | ||||
|                     // 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; | ||||
| 
 | ||||
|                     return this.createNewAttemptsAtEnd(scormId, newAttemptsAtEnd, lastOffline, siteId).then(() => { | ||||
|                         return warnings; | ||||
|                     }); | ||||
|                 }); | ||||
|             }); | ||||
|         }); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										1660
									
								
								src/addon/mod/scorm/providers/scorm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
							
								
								
									
										47
									
								
								src/addon/mod/scorm/providers/sync-cron-handler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,47 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { CoreCronHandler } from '@providers/cron'; | ||||
| import { AddonModScormSyncProvider } from './scorm-sync'; | ||||
| 
 | ||||
| /** | ||||
|  * Synchronization cron handler. | ||||
|  */ | ||||
| @Injectable() | ||||
| export class AddonModScormSyncCronHandler implements CoreCronHandler { | ||||
|     name = 'AddonModScormSyncCronHandler'; | ||||
| 
 | ||||
|     constructor(private scormSync: AddonModScormSyncProvider) {} | ||||
| 
 | ||||
|     /** | ||||
|      * Execute the process. | ||||
|      * Receives the ID of the site affected, undefined for all sites. | ||||
|      * | ||||
|      * @param  {string} [siteId] ID of the site affected, undefined for all sites. | ||||
|      * @return {Promise<any>}         Promise resolved when done, rejected if failure. | ||||
|      */ | ||||
|     execute(siteId?: string): Promise<any> { | ||||
|         return this.scormSync.syncAllScorms(siteId); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Get the time between consecutive executions. | ||||
|      * | ||||
|      * @return {number} Time between consecutive executions (in ms). | ||||
|      */ | ||||
|     getInterval(): number { | ||||
|         return AddonModScormSyncProvider.SYNC_TIME; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										61
									
								
								src/addon/mod/scorm/scorm.module.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @ -0,0 +1,61 @@ | ||||
| // (C) Copyright 2015 Martin Dougiamas
 | ||||
| //
 | ||||
| // 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 { CoreCronDelegate } from '@providers/cron'; | ||||
| import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; | ||||
| import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; | ||||
| import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate'; | ||||
| import { AddonModScormProvider } from './providers/scorm'; | ||||
| import { AddonModScormHelperProvider } from './providers/helper'; | ||||
| import { AddonModScormOfflineProvider } from './providers/scorm-offline'; | ||||
| import { AddonModScormModuleHandler } from './providers/module-handler'; | ||||
| import { AddonModScormPrefetchHandler } from './providers/prefetch-handler'; | ||||
| import { AddonModScormSyncCronHandler } from './providers/sync-cron-handler'; | ||||
| import { AddonModScormIndexLinkHandler } from './providers/index-link-handler'; | ||||
| import { AddonModScormGradeLinkHandler } from './providers/grade-link-handler'; | ||||
| import { AddonModScormSyncProvider } from './providers/scorm-sync'; | ||||
| import { AddonModScormComponentsModule } from './components/components.module'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     declarations: [ | ||||
|     ], | ||||
|     imports: [ | ||||
|         AddonModScormComponentsModule | ||||
|     ], | ||||
|     providers: [ | ||||
|         AddonModScormProvider, | ||||
|         AddonModScormOfflineProvider, | ||||
|         AddonModScormHelperProvider, | ||||
|         AddonModScormSyncProvider, | ||||
|         AddonModScormModuleHandler, | ||||
|         AddonModScormPrefetchHandler, | ||||
|         AddonModScormSyncCronHandler, | ||||
|         AddonModScormIndexLinkHandler, | ||||
|         AddonModScormGradeLinkHandler | ||||
|     ] | ||||
| }) | ||||
| export class AddonModScormModule { | ||||
|     constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModScormModuleHandler, | ||||
|             prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModScormPrefetchHandler, | ||||
|             cronDelegate: CoreCronDelegate, syncHandler: AddonModScormSyncCronHandler, linksDelegate: CoreContentLinksDelegate, | ||||
|             indexHandler: AddonModScormIndexLinkHandler, gradeHandler: AddonModScormGradeLinkHandler) { | ||||
| 
 | ||||
|         moduleDelegate.registerHandler(moduleHandler); | ||||
|         prefetchDelegate.registerHandler(prefetchHandler); | ||||
|         cronDelegate.register(syncHandler); | ||||
|         linksDelegate.registerHandler(indexHandler); | ||||
|         linksDelegate.registerHandler(gradeHandler); | ||||
|     } | ||||
| } | ||||
| @ -104,3 +104,10 @@ | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // Different levels of padding. | ||||
| @for $i from 0 through 15 { | ||||
|   .ios .core-padding-#{$i} { | ||||
|     padding-left: 15px * $i + $item-ios-padding-start; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -105,3 +105,10 @@ | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // Different levels of padding. | ||||
| @for $i from 0 through 15 { | ||||
|   .md .core-padding-#{$i} { | ||||
|     padding-left: 15px * $i + $item-md-padding-start; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -86,6 +86,7 @@ import { AddonModFeedbackModule } from '@addon/mod/feedback/feedback.module'; | ||||
| import { AddonModFolderModule } from '@addon/mod/folder/folder.module'; | ||||
| import { AddonModPageModule } from '@addon/mod/page/page.module'; | ||||
| import { AddonModQuizModule } from '@addon/mod/quiz/quiz.module'; | ||||
| import { AddonModScormModule } from '@addon/mod/scorm/scorm.module'; | ||||
| import { AddonModUrlModule } from '@addon/mod/url/url.module'; | ||||
| import { AddonModSurveyModule } from '@addon/mod/survey/survey.module'; | ||||
| import { AddonModImscpModule } from '@addon/mod/imscp/imscp.module'; | ||||
| @ -185,6 +186,7 @@ export const CORE_PROVIDERS: any[] = [ | ||||
|         AddonModFolderModule, | ||||
|         AddonModPageModule, | ||||
|         AddonModQuizModule, | ||||
|         AddonModScormModule, | ||||
|         AddonModUrlModule, | ||||
|         AddonModSurveyModule, | ||||
|         AddonModImscpModule, | ||||
|  | ||||
| @ -621,7 +621,7 @@ canvas[core-chart] { | ||||
|     color: $color-base; | ||||
|   } | ||||
| 
 | ||||
|   .text-#{$color-name}, p.#{$color-name}, .item p.text-#{$color-name} { | ||||
|   .text-#{$color-name}, p.text-#{$color-name}, .item p.text-#{$color-name}, .card p.text-#{$color-name} { | ||||
|     color: $color-base; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -40,3 +40,10 @@ | ||||
|     top: $navbar-wp-height; | ||||
|     height: calc(100% - #{($navbar-wp-height)}); | ||||
| } | ||||
| 
 | ||||
| // Different levels of padding. | ||||
| @for $i from 0 through 15 { | ||||
|   .wp .core-padding-#{$i} { | ||||
|     padding-left: 15px * $i + $item-wp-padding-start; | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										
											BIN
										
									
								
								src/assets/img/scorm/asset.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 178 B | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/img/scorm/browsed.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 105 B | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/img/scorm/completed.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 190 B | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/img/scorm/failed.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 190 B | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/img/scorm/incomplete.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 597 B | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/img/scorm/notattempted.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 79 B | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/img/scorm/passed.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 190 B | 
							
								
								
									
										
											BIN
										
									
								
								src/assets/img/scorm/suspend.gif
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 362 B | 
| @ -139,6 +139,13 @@ export class CoreIframeComponent implements OnInit, OnChanges { | ||||
|                 winAndDoc = this.getContentWindowAndDocument(element); | ||||
|                 this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document); | ||||
|                 this.treatLinks(element, winAndDoc.document); | ||||
| 
 | ||||
|                 if (winAndDoc.window) { | ||||
|                     // Send a resize events to the iframe so it calculates the right size if needed.
 | ||||
|                     setTimeout(() => { | ||||
|                         winAndDoc.window.dispatchEvent(new Event('resize')); | ||||
|                     }, 1000); | ||||
|                 } | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @ -46,7 +46,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR | ||||
|     protected courseProvider: CoreCourseProvider; | ||||
|     protected appProvider: CoreAppProvider; | ||||
|     protected eventsProvider: CoreEventsProvider; | ||||
|     protected modulePrefetchProvider: CoreCourseModulePrefetchDelegate; | ||||
|     protected modulePrefetchDelegate: CoreCourseModulePrefetchDelegate; | ||||
| 
 | ||||
|     constructor(injector: Injector, protected content?: Content) { | ||||
|         super(injector); | ||||
| @ -55,6 +55,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR | ||||
|         this.courseProvider = injector.get(CoreCourseProvider); | ||||
|         this.appProvider = injector.get(CoreAppProvider); | ||||
|         this.eventsProvider = injector.get(CoreEventsProvider); | ||||
|         this.modulePrefetchDelegate = injector.get(CoreCourseModulePrefetchDelegate); | ||||
| 
 | ||||
|         const network = injector.get(Network); | ||||
| 
 | ||||
| @ -158,7 +159,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR | ||||
|         this.loaded = false; | ||||
|         this.content && this.content.scrollToTop(); | ||||
| 
 | ||||
|         return this.refreshContent(true, showErrors); | ||||
|         return this.refreshContent(sync, showErrors); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -226,7 +227,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR | ||||
|             }, this.siteId); | ||||
| 
 | ||||
|             // Also, get the current status.
 | ||||
|             this.modulePrefetchProvider.getModuleStatus(this.module, this.courseId).then((status) => { | ||||
|             this.modulePrefetchDelegate.getModuleStatus(this.module, this.courseId).then((status) => { | ||||
|                 this.currentStatus = status; | ||||
|                 this.showStatus(status); | ||||
|             }); | ||||
|  | ||||
| @ -53,7 +53,6 @@ import { CoreEmulatorCaptureHelperProvider } from './providers/capture-helper'; | ||||
| import { CoreAppProvider } from '@providers/app'; | ||||
| import { CoreFileProvider } from '@providers/file'; | ||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||
| import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; | ||||
| import { CoreUrlUtilsProvider } from '@providers/utils/url'; | ||||
| import { CoreUtilsProvider } from '@providers/utils/utils'; | ||||
| import { CoreInitDelegate } from '@providers/init'; | ||||
| @ -184,10 +183,10 @@ export const IONIC_NATIVE_PROVIDERS = [ | ||||
|         SQLite, | ||||
|         { | ||||
|             provide: Zip, | ||||
|             deps: [CoreAppProvider, File, CoreMimetypeUtilsProvider, CoreTextUtilsProvider], | ||||
|             useFactory: (appProvider: CoreAppProvider, file: File, mimeUtils: CoreMimetypeUtilsProvider): Zip => { | ||||
|             deps: [CoreAppProvider, File, CoreTextUtilsProvider], | ||||
|             useFactory: (appProvider: CoreAppProvider, file: File, textUtils: CoreTextUtilsProvider): Zip => { | ||||
|                 // Use platform instead of CoreAppProvider to prevent circular dependencies.
 | ||||
|                 return appProvider.isMobile() ? new Zip() : new ZipMock(file, mimeUtils); | ||||
|                 return appProvider.isMobile() ? new Zip() : new ZipMock(file, textUtils); | ||||
|             } | ||||
|         }, | ||||
|     ] | ||||
|  | ||||
| @ -14,9 +14,9 @@ | ||||
| 
 | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { Zip } from '@ionic-native/zip'; | ||||
| import { JSZip } from 'jszip'; | ||||
| import * as JSZip from 'jszip'; | ||||
| import { File } from '@ionic-native/file'; | ||||
| import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; | ||||
| import { CoreTextUtilsProvider } from '@providers/utils/text'; | ||||
| 
 | ||||
| /** | ||||
|  * Emulates the Cordova Zip plugin in desktop apps and in browser. | ||||
| @ -24,10 +24,36 @@ import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; | ||||
| @Injectable() | ||||
| export class ZipMock extends Zip { | ||||
| 
 | ||||
|     constructor(private file: File, private mimeUtils: CoreMimetypeUtilsProvider) { | ||||
|     constructor(private file: File, private textUtils: CoreTextUtilsProvider) { | ||||
|         super(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Create a directory. It creates all the foldes in dirPath 1 by 1 to prevent errors. | ||||
|      * | ||||
|      * @param {string} destination Destination parent folder. | ||||
|      * @param {string} dirPath Relative path to the folder. | ||||
|      * @return {Promise<void>} Promise resolved when done. | ||||
|      */ | ||||
|     protected createDir(destination: string, dirPath: string): Promise<void> { | ||||
|         // Create all the folders 1 by 1 in order, otherwise it fails.
 | ||||
|         const folders = dirPath.split('/'); | ||||
|         let promise = Promise.resolve(); | ||||
| 
 | ||||
|         for (let i = 0; i < folders.length; i++) { | ||||
|             const folder = folders[i]; | ||||
| 
 | ||||
|             promise = promise.then(() => { | ||||
|                 return this.file.createDir(destination, folder, true).then(() => { | ||||
|                     // Folder created, add it to the destination path.
 | ||||
|                     destination = this.textUtils.concatenatePaths(destination, folder); | ||||
|                 }); | ||||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         return promise; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Extracts files from a ZIP archive. | ||||
|      * | ||||
| @ -37,35 +63,68 @@ export class ZipMock extends Zip { | ||||
|      * @return {Promise<number>} Promise that resolves with a number. 0 is success, -1 is error. | ||||
|      */ | ||||
|     unzip(source: string, destination: string, onProgress?: Function): Promise<number> { | ||||
| 
 | ||||
|         // Replace all %20 with spaces.
 | ||||
|         source = source.replace(/%20/g, ' '); | ||||
|         destination = destination.replace(/%20/g, ' '); | ||||
| 
 | ||||
|         const sourceDir = source.substring(0, source.lastIndexOf('/')), | ||||
|             sourceName = source.substr(source.lastIndexOf('/') + 1); | ||||
|             sourceName = source.substr(source.lastIndexOf('/') + 1), | ||||
|             zip = new JSZip(); | ||||
| 
 | ||||
|         // Read the file first.
 | ||||
|         return this.file.readAsArrayBuffer(sourceDir, sourceName).then((data) => { | ||||
|             const zip = new JSZip(data), | ||||
|                 promises = [], | ||||
|                 total = Object.keys(zip.files).length; | ||||
|             let loaded = 0; | ||||
| 
 | ||||
|             if (!zip.files || !zip.files.length) { | ||||
|             // Now load the file using the JSZip library.
 | ||||
|             return zip.loadAsync(data); | ||||
|         }).then((): any => { | ||||
| 
 | ||||
|             if (!zip.files || !Object.keys(zip.files).length) { | ||||
|                 // Nothing to extract.
 | ||||
|                 return 0; | ||||
|             } | ||||
| 
 | ||||
|             zip.files.forEach((file, name) => { | ||||
|                 let type, | ||||
|                     promise; | ||||
|             // First of all, create the directory where the files will be unzipped.
 | ||||
|             const destParent = destination.substring(0, source.lastIndexOf('/')), | ||||
|                 destFolderName = destination.substr(source.lastIndexOf('/') + 1); | ||||
| 
 | ||||
|             return this.file.createDir(destParent, destFolderName, false); | ||||
|         }).then(() => { | ||||
| 
 | ||||
|             const promises = [], | ||||
|                 total = Object.keys(zip.files).length; | ||||
|             let loaded = 0; | ||||
| 
 | ||||
|             for (const name in zip.files) { | ||||
|                 const file = zip.files[name]; | ||||
|                 let promise; | ||||
| 
 | ||||
|                 if (!file.dir) { | ||||
|                     // It's a file. Get the mimetype and write the file.
 | ||||
|                     type = this.mimeUtils.getMimeType(this.mimeUtils.getFileExtension(name)); | ||||
|                     promise = this.file.writeFile(destination, name, new Blob([file.asArrayBuffer()], { type: type })); | ||||
|                     // It's a file.
 | ||||
|                     const fileDir = name.substring(0, name.lastIndexOf('/')), | ||||
|                         fileName = name.substr(name.lastIndexOf('/') + 1), | ||||
|                         filePromises = []; | ||||
|                     let fileData; | ||||
| 
 | ||||
|                     if (fileDir) { | ||||
|                         // The file is in a subfolder, create it first.
 | ||||
|                         filePromises.push(this.createDir(destination, fileDir)); | ||||
|                     } | ||||
| 
 | ||||
|                     // Read the file contents as a Blob.
 | ||||
|                     filePromises.push(file.async('blob').then((data) => { | ||||
|                         fileData = data; | ||||
|                     })); | ||||
| 
 | ||||
|                     promise = Promise.all(filePromises).then(() => { | ||||
|                         // File read and parent folder created, now write the file.
 | ||||
|                         const parentFolder = this.textUtils.concatenatePaths(destination, fileDir); | ||||
| 
 | ||||
|                         return this.file.writeFile(parentFolder, fileName, fileData, {replace: true}); | ||||
|                     }); | ||||
|                 } else { | ||||
|                     // It's a folder, create it if it doesn't exist.
 | ||||
|                     promise = this.file.createDir(destination, name, false); | ||||
|                     promise = this.createDir(destination, name); | ||||
|                 } | ||||
| 
 | ||||
|                 promises.push(promise.then(() => { | ||||
| @ -73,7 +132,7 @@ export class ZipMock extends Zip { | ||||
|                     loaded++; | ||||
|                     onProgress && onProgress({ loaded: loaded, total: total }); | ||||
|                 })); | ||||
|             }); | ||||
|             } | ||||
| 
 | ||||
|             return Promise.all(promises).then(() => { | ||||
|                 return 0; | ||||
|  | ||||
| @ -38,7 +38,7 @@ | ||||
|         </ion-item> | ||||
|         <ion-item text-wrap *ngIf="fileSystemRoot"> | ||||
|             <h2>{{ 'core.settings.filesystemroot' | translate}}</h2> | ||||
|             <p><a *ngIf="fsClickable" [href]="fileSystemRoot" core-link auto-login="no">{{ filesystemroot }}</a></p> | ||||
|             <p><a *ngIf="fsClickable" [href]="fileSystemRoot" core-link auto-login="no">{{ fileSystemRoot }}</a></p> | ||||
|             <p *ngIf="!fsClickable">{{ fileSystemRoot }}</p> | ||||
|         </ion-item> | ||||
|         <ion-item text-wrap *ngIf="navigator && navigator.userAgent"> | ||||
|  | ||||
| @ -731,6 +731,10 @@ export class CoreFileProvider { | ||||
|             destFolder = this.addBasePathIfNeeded(destFolder || this.mimeUtils.removeExtension(path)); | ||||
| 
 | ||||
|             return this.zip.unzip(fileEntry.toURL(), destFolder, onProgress); | ||||
|         }).then((result) => { | ||||
|             if (result == -1) { | ||||
|                 return Promise.reject(null); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|  | ||||
| @ -706,8 +706,8 @@ export class CoreWSProvider { | ||||
|         data = ('response' in xhr) ? xhr.response : xhr.responseText; | ||||
| 
 | ||||
|         // Check status.
 | ||||
|         xhr.status = Math.max(xhr.status === 1223 ? 204 : xhr.status, 0); | ||||
|         if (xhr.status < 200 || xhr.status >= 300) { | ||||
|         const status = Math.max(xhr.status === 1223 ? 204 : xhr.status, 0); | ||||
|         if (status < 200 || status >= 300) { | ||||
|             // Request failed.
 | ||||
|             errorResponse.message = data; | ||||
| 
 | ||||
|  | ||||