|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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>
|
|
@ -0,0 +1,9 @@
|
|||
addon-mod-scorm-index {
|
||||
|
||||
.addon-mod_scorm-toc {
|
||||
img {
|
||||
width: auto;
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
<ion-list>
|
||||
<ion-item text-wrap *ngIf="attemptToContinue">
|
||||
<p>{{ 'addon.mod_scorm.dataattemptshown' | translate:{number: attemptToContinue} }}</p>
|
||||
</ion-item>
|
||||
<ion-item text-center *ngIf="isBrowse">
|
||||
<p>{{ 'addon.mod_scorm.mod_scorm.browsemode' }}</p>
|
||||
</ion-item>
|
||||
<ion-item text-center *ngIf="isReview">
|
||||
<p>{{ 'addon.mod_scorm.mod_scorm.reviewmode' }}</p>
|
||||
</ion-item>
|
||||
|
||||
<!-- List of SCOs. -->
|
||||
<ng-container *ngFor="let sco of toc">
|
||||
<a *ngIf="sco.isvisible" ion-item text-wrap [ngClass]="['core-padding-' + sco.level]" (click)="loadSco(sco)" [attr.disabled]="!sco.prereq || !sco.launch ? true : null" detail-none>
|
||||
<img [src]="sco.image.url" [alt]="sco.image.description" />
|
||||
<span>{{ sco.title }}</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</ion-list>
|
|
@ -0,0 +1,3 @@
|
|||
addon-mod-scorm-toc-popover {
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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."
|
||||
}
|
|
@ -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>
|
|
@ -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 {}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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 {}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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/';
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
// (C) Copyright 2015 Martin Dougiamas
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CoreCronDelegate } from '@providers/cron';
|
||||
import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate';
|
||||
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
|
||||
import { CoreContentLinksDelegate } from '@core/contentlinks/providers/delegate';
|
||||
import { AddonModScormProvider } from './providers/scorm';
|
||||
import { AddonModScormHelperProvider } from './providers/helper';
|
||||
import { AddonModScormOfflineProvider } from './providers/scorm-offline';
|
||||
import { AddonModScormModuleHandler } from './providers/module-handler';
|
||||
import { AddonModScormPrefetchHandler } from './providers/prefetch-handler';
|
||||
import { AddonModScormSyncCronHandler } from './providers/sync-cron-handler';
|
||||
import { AddonModScormIndexLinkHandler } from './providers/index-link-handler';
|
||||
import { AddonModScormGradeLinkHandler } from './providers/grade-link-handler';
|
||||
import { AddonModScormSyncProvider } from './providers/scorm-sync';
|
||||
import { AddonModScormComponentsModule } from './components/components.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
],
|
||||
imports: [
|
||||
AddonModScormComponentsModule
|
||||
],
|
||||
providers: [
|
||||
AddonModScormProvider,
|
||||
AddonModScormOfflineProvider,
|
||||
AddonModScormHelperProvider,
|
||||
AddonModScormSyncProvider,
|
||||
AddonModScormModuleHandler,
|
||||
AddonModScormPrefetchHandler,
|
||||
AddonModScormSyncCronHandler,
|
||||
AddonModScormIndexLinkHandler,
|
||||
AddonModScormGradeLinkHandler
|
||||
]
|
||||
})
|
||||
export class AddonModScormModule {
|
||||
constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModScormModuleHandler,
|
||||
prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModScormPrefetchHandler,
|
||||
cronDelegate: CoreCronDelegate, syncHandler: AddonModScormSyncCronHandler, linksDelegate: CoreContentLinksDelegate,
|
||||
indexHandler: AddonModScormIndexLinkHandler, gradeHandler: AddonModScormGradeLinkHandler) {
|
||||
|
||||
moduleDelegate.registerHandler(moduleHandler);
|
||||
prefetchDelegate.registerHandler(prefetchHandler);
|
||||
cronDelegate.register(syncHandler);
|
||||
linksDelegate.registerHandler(indexHandler);
|
||||
linksDelegate.registerHandler(gradeHandler);
|
||||
}
|
||||
}
|
|
@ -104,3 +104,10 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Different levels of padding.
|
||||
@for $i from 0 through 15 {
|
||||
.ios .core-padding-#{$i} {
|
||||
padding-left: 15px * $i + $item-ios-padding-start;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -105,3 +105,10 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Different levels of padding.
|
||||
@for $i from 0 through 15 {
|
||||
.md .core-padding-#{$i} {
|
||||
padding-left: 15px * $i + $item-md-padding-start;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -86,6 +86,7 @@ import { AddonModFeedbackModule } from '@addon/mod/feedback/feedback.module';
|
|||
import { AddonModFolderModule } from '@addon/mod/folder/folder.module';
|
||||
import { AddonModPageModule } from '@addon/mod/page/page.module';
|
||||
import { AddonModQuizModule } from '@addon/mod/quiz/quiz.module';
|
||||
import { AddonModScormModule } from '@addon/mod/scorm/scorm.module';
|
||||
import { AddonModUrlModule } from '@addon/mod/url/url.module';
|
||||
import { AddonModSurveyModule } from '@addon/mod/survey/survey.module';
|
||||
import { AddonModImscpModule } from '@addon/mod/imscp/imscp.module';
|
||||
|
@ -185,6 +186,7 @@ export const CORE_PROVIDERS: any[] = [
|
|||
AddonModFolderModule,
|
||||
AddonModPageModule,
|
||||
AddonModQuizModule,
|
||||
AddonModScormModule,
|
||||
AddonModUrlModule,
|
||||
AddonModSurveyModule,
|
||||
AddonModImscpModule,
|
||||
|
|
|
@ -621,7 +621,7 @@ canvas[core-chart] {
|
|||
color: $color-base;
|
||||
}
|
||||
|
||||
.text-#{$color-name}, p.#{$color-name}, .item p.text-#{$color-name} {
|
||||
.text-#{$color-name}, p.text-#{$color-name}, .item p.text-#{$color-name}, .card p.text-#{$color-name} {
|
||||
color: $color-base;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,3 +40,10 @@
|
|||
top: $navbar-wp-height;
|
||||
height: calc(100% - #{($navbar-wp-height)});
|
||||
}
|
||||
|
||||
// Different levels of padding.
|
||||
@for $i from 0 through 15 {
|
||||
.wp .core-padding-#{$i} {
|
||||
padding-left: 15px * $i + $item-wp-padding-start;
|
||||
}
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 178 B |
After Width: | Height: | Size: 105 B |
After Width: | Height: | Size: 190 B |
After Width: | Height: | Size: 190 B |
After Width: | Height: | Size: 597 B |
After Width: | Height: | Size: 79 B |
After Width: | Height: | Size: 190 B |
After Width: | Height: | Size: 362 B |
|
@ -139,6 +139,13 @@ export class CoreIframeComponent implements OnInit, OnChanges {
|
|||
winAndDoc = this.getContentWindowAndDocument(element);
|
||||
this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document);
|
||||
this.treatLinks(element, winAndDoc.document);
|
||||
|
||||
if (winAndDoc.window) {
|
||||
// Send a resize events to the iframe so it calculates the right size if needed.
|
||||
setTimeout(() => {
|
||||
winAndDoc.window.dispatchEvent(new Event('resize'));
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,7 +46,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
|
|||
protected courseProvider: CoreCourseProvider;
|
||||
protected appProvider: CoreAppProvider;
|
||||
protected eventsProvider: CoreEventsProvider;
|
||||
protected modulePrefetchProvider: CoreCourseModulePrefetchDelegate;
|
||||
protected modulePrefetchDelegate: CoreCourseModulePrefetchDelegate;
|
||||
|
||||
constructor(injector: Injector, protected content?: Content) {
|
||||
super(injector);
|
||||
|
@ -55,6 +55,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
|
|||
this.courseProvider = injector.get(CoreCourseProvider);
|
||||
this.appProvider = injector.get(CoreAppProvider);
|
||||
this.eventsProvider = injector.get(CoreEventsProvider);
|
||||
this.modulePrefetchDelegate = injector.get(CoreCourseModulePrefetchDelegate);
|
||||
|
||||
const network = injector.get(Network);
|
||||
|
||||
|
@ -158,7 +159,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
|
|||
this.loaded = false;
|
||||
this.content && this.content.scrollToTop();
|
||||
|
||||
return this.refreshContent(true, showErrors);
|
||||
return this.refreshContent(sync, showErrors);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -226,7 +227,7 @@ export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainR
|
|||
}, this.siteId);
|
||||
|
||||
// Also, get the current status.
|
||||
this.modulePrefetchProvider.getModuleStatus(this.module, this.courseId).then((status) => {
|
||||
this.modulePrefetchDelegate.getModuleStatus(this.module, this.courseId).then((status) => {
|
||||
this.currentStatus = status;
|
||||
this.showStatus(status);
|
||||
});
|
||||
|
|
|
@ -53,7 +53,6 @@ import { CoreEmulatorCaptureHelperProvider } from './providers/capture-helper';
|
|||
import { CoreAppProvider } from '@providers/app';
|
||||
import { CoreFileProvider } from '@providers/file';
|
||||
import { CoreTextUtilsProvider } from '@providers/utils/text';
|
||||
import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype';
|
||||
import { CoreUrlUtilsProvider } from '@providers/utils/url';
|
||||
import { CoreUtilsProvider } from '@providers/utils/utils';
|
||||
import { CoreInitDelegate } from '@providers/init';
|
||||
|
@ -184,10 +183,10 @@ export const IONIC_NATIVE_PROVIDERS = [
|
|||
SQLite,
|
||||
{
|
||||
provide: Zip,
|
||||
deps: [CoreAppProvider, File, CoreMimetypeUtilsProvider, CoreTextUtilsProvider],
|
||||
useFactory: (appProvider: CoreAppProvider, file: File, mimeUtils: CoreMimetypeUtilsProvider): Zip => {
|
||||
deps: [CoreAppProvider, File, CoreTextUtilsProvider],
|
||||
useFactory: (appProvider: CoreAppProvider, file: File, textUtils: CoreTextUtilsProvider): Zip => {
|
||||
// Use platform instead of CoreAppProvider to prevent circular dependencies.
|
||||
return appProvider.isMobile() ? new Zip() : new ZipMock(file, mimeUtils);
|
||||
return appProvider.isMobile() ? new Zip() : new ZipMock(file, textUtils);
|
||||
}
|
||||
},
|
||||
]
|
||||
|
|
|
@ -14,9 +14,9 @@
|
|||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Zip } from '@ionic-native/zip';
|
||||
import { JSZip } from 'jszip';
|
||||
import * as JSZip from 'jszip';
|
||||
import { File } from '@ionic-native/file';
|
||||
import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype';
|
||||
import { CoreTextUtilsProvider } from '@providers/utils/text';
|
||||
|
||||
/**
|
||||
* Emulates the Cordova Zip plugin in desktop apps and in browser.
|
||||
|
@ -24,10 +24,36 @@ import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype';
|
|||
@Injectable()
|
||||
export class ZipMock extends Zip {
|
||||
|
||||
constructor(private file: File, private mimeUtils: CoreMimetypeUtilsProvider) {
|
||||
constructor(private file: File, private textUtils: CoreTextUtilsProvider) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory. It creates all the foldes in dirPath 1 by 1 to prevent errors.
|
||||
*
|
||||
* @param {string} destination Destination parent folder.
|
||||
* @param {string} dirPath Relative path to the folder.
|
||||
* @return {Promise<void>} Promise resolved when done.
|
||||
*/
|
||||
protected createDir(destination: string, dirPath: string): Promise<void> {
|
||||
// Create all the folders 1 by 1 in order, otherwise it fails.
|
||||
const folders = dirPath.split('/');
|
||||
let promise = Promise.resolve();
|
||||
|
||||
for (let i = 0; i < folders.length; i++) {
|
||||
const folder = folders[i];
|
||||
|
||||
promise = promise.then(() => {
|
||||
return this.file.createDir(destination, folder, true).then(() => {
|
||||
// Folder created, add it to the destination path.
|
||||
destination = this.textUtils.concatenatePaths(destination, folder);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts files from a ZIP archive.
|
||||
*
|
||||
|
@ -37,35 +63,68 @@ export class ZipMock extends Zip {
|
|||
* @return {Promise<number>} Promise that resolves with a number. 0 is success, -1 is error.
|
||||
*/
|
||||
unzip(source: string, destination: string, onProgress?: Function): Promise<number> {
|
||||
|
||||
// Replace all %20 with spaces.
|
||||
source = source.replace(/%20/g, ' ');
|
||||
destination = destination.replace(/%20/g, ' ');
|
||||
|
||||
const sourceDir = source.substring(0, source.lastIndexOf('/')),
|
||||
sourceName = source.substr(source.lastIndexOf('/') + 1);
|
||||
sourceName = source.substr(source.lastIndexOf('/') + 1),
|
||||
zip = new JSZip();
|
||||
|
||||
// Read the file first.
|
||||
return this.file.readAsArrayBuffer(sourceDir, sourceName).then((data) => {
|
||||
const zip = new JSZip(data),
|
||||
promises = [],
|
||||
total = Object.keys(zip.files).length;
|
||||
let loaded = 0;
|
||||
|
||||
if (!zip.files || !zip.files.length) {
|
||||
// Now load the file using the JSZip library.
|
||||
return zip.loadAsync(data);
|
||||
}).then((): any => {
|
||||
|
||||
if (!zip.files || !Object.keys(zip.files).length) {
|
||||
// Nothing to extract.
|
||||
return 0;
|
||||
}
|
||||
|
||||
zip.files.forEach((file, name) => {
|
||||
let type,
|
||||
promise;
|
||||
// First of all, create the directory where the files will be unzipped.
|
||||
const destParent = destination.substring(0, source.lastIndexOf('/')),
|
||||
destFolderName = destination.substr(source.lastIndexOf('/') + 1);
|
||||
|
||||
return this.file.createDir(destParent, destFolderName, false);
|
||||
}).then(() => {
|
||||
|
||||
const promises = [],
|
||||
total = Object.keys(zip.files).length;
|
||||
let loaded = 0;
|
||||
|
||||
for (const name in zip.files) {
|
||||
const file = zip.files[name];
|
||||
let promise;
|
||||
|
||||
if (!file.dir) {
|
||||
// It's a file. Get the mimetype and write the file.
|
||||
type = this.mimeUtils.getMimeType(this.mimeUtils.getFileExtension(name));
|
||||
promise = this.file.writeFile(destination, name, new Blob([file.asArrayBuffer()], { type: type }));
|
||||
// It's a file.
|
||||
const fileDir = name.substring(0, name.lastIndexOf('/')),
|
||||
fileName = name.substr(name.lastIndexOf('/') + 1),
|
||||
filePromises = [];
|
||||
let fileData;
|
||||
|
||||
if (fileDir) {
|
||||
// The file is in a subfolder, create it first.
|
||||
filePromises.push(this.createDir(destination, fileDir));
|
||||
}
|
||||
|
||||
// Read the file contents as a Blob.
|
||||
filePromises.push(file.async('blob').then((data) => {
|
||||
fileData = data;
|
||||
}));
|
||||
|
||||
promise = Promise.all(filePromises).then(() => {
|
||||
// File read and parent folder created, now write the file.
|
||||
const parentFolder = this.textUtils.concatenatePaths(destination, fileDir);
|
||||
|
||||
return this.file.writeFile(parentFolder, fileName, fileData, {replace: true});
|
||||
});
|
||||
} else {
|
||||
// It's a folder, create it if it doesn't exist.
|
||||
promise = this.file.createDir(destination, name, false);
|
||||
promise = this.createDir(destination, name);
|
||||
}
|
||||
|
||||
promises.push(promise.then(() => {
|
||||
|
@ -73,7 +132,7 @@ export class ZipMock extends Zip {
|
|||
loaded++;
|
||||
onProgress && onProgress({ loaded: loaded, total: total });
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.all(promises).then(() => {
|
||||
return 0;
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
</ion-item>
|
||||
<ion-item text-wrap *ngIf="fileSystemRoot">
|
||||
<h2>{{ 'core.settings.filesystemroot' | translate}}</h2>
|
||||
<p><a *ngIf="fsClickable" [href]="fileSystemRoot" core-link auto-login="no">{{ filesystemroot }}</a></p>
|
||||
<p><a *ngIf="fsClickable" [href]="fileSystemRoot" core-link auto-login="no">{{ fileSystemRoot }}</a></p>
|
||||
<p *ngIf="!fsClickable">{{ fileSystemRoot }}</p>
|
||||
</ion-item>
|
||||
<ion-item text-wrap *ngIf="navigator && navigator.userAgent">
|
||||
|
|
|
@ -731,6 +731,10 @@ export class CoreFileProvider {
|
|||
destFolder = this.addBasePathIfNeeded(destFolder || this.mimeUtils.removeExtension(path));
|
||||
|
||||
return this.zip.unzip(fileEntry.toURL(), destFolder, onProgress);
|
||||
}).then((result) => {
|
||||
if (result == -1) {
|
||||
return Promise.reject(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -706,8 +706,8 @@ export class CoreWSProvider {
|
|||
data = ('response' in xhr) ? xhr.response : xhr.responseText;
|
||||
|
||||
// Check status.
|
||||
xhr.status = Math.max(xhr.status === 1223 ? 204 : xhr.status, 0);
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
const status = Math.max(xhr.status === 1223 ? 204 : xhr.status, 0);
|
||||
if (status < 200 || status >= 300) {
|
||||
// Request failed.
|
||||
errorResponse.message = data;
|
||||
|
||||
|
|