Merge pull request #1299 from dpalou/MOBILE-2350

Mobile 2350
main
Juan Leyva 2018-04-30 15:51:08 +02:00 committed by GitHub
commit bec35e3c98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 6984 additions and 33 deletions

View File

@ -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();

View 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);
}
}

View 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 {}

View 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>

View File

@ -0,0 +1,9 @@
addon-mod-scorm-index {
.addon-mod_scorm-toc {
img {
width: auto;
display: inline;
}
}
}

View 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);
}
}

View 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>

View File

@ -0,0 +1,3 @@
addon-mod-scorm-toc-popover {
}

View 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);
}
}

View 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."
}

View 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>

View 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 {}

View 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();
}
}

View 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>

View 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 {}

View 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');
}
}

View 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');
}
}

View 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);
}
});
}
}

View 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');
}
}

View 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;
}
}

View 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/';
}
}

View 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);
});
}
}

View 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 });
});
}
}

View 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;
});
});
});
});
}
}

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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;
}
}

View File

@ -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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

View File

@ -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);
}
});
}
}

View File

@ -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);
});

View File

@ -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);
}
},
]

View File

@ -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;

View File

@ -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">

View File

@ -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);
}
});
}

View File

@ -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;