MOBILE-2350 scorm: Implement data model for 1.2

main
Dani Palou 2018-04-24 11:06:18 +02:00
parent d4fe21c9a3
commit 1b9ec1ac94
2 changed files with 911 additions and 0 deletions

View File

@ -0,0 +1,905 @@
// (C) Copyright 2015 Martin Dougiamas
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { CoreEventsProvider } from '@providers/events';
import { AddonModScormProvider } from '../providers/scorm';
/**
* SCORM data model implementation for version 1.2.
*/
export class AddonModScormDataModel12 {
// Standard Data Type Definition.
protected CMI_STRING_256 = '^[\\u0000-\\uFFFF]{0,255}$';
protected CMI_STRING_4096 = '^[\\u0000-\\uFFFF]{0,4096}$';
protected CMI_TIME = '^([0-2]{1}[0-9]{1}):([0-5]{1}[0-9]{1}):([0-5]{1}[0-9]{1})(\.[0-9]{1,2})?$';
protected CMI_TIMESPAN = '^([0-9]{2,4}):([0-9]{2}):([0-9]{2})(\.[0-9]{1,2})?$';
protected CMI_INTEGER = '^\\d+$';
protected CMI_SINTEGER = '^-?([0-9]+)$';
protected CMI_DECIMAL = '^-?([0-9]{0,3})(\.[0-9]*)?$';
protected CMI_IDENTIFIER = '^[\\u0021-\\u007E]{0,255}$';
protected CMI_FEEDBACK = this.CMI_STRING_256; // This must be redefined.
protected CMI_INDEX = '[._](\\d+).';
// Vocabulary Data Type Definition.
protected CMI_STATUS = '^passed$|^completed$|^failed$|^incomplete$|^browsed$';
protected CMI_STATUS_2 = '^passed$|^completed$|^failed$|^incomplete$|^browsed$|^not attempted$';
protected CMI_EXIT = '^time-out$|^suspend$|^logout$|^$';
protected CMI_TYPE = '^true-false$|^choice$|^fill-in$|^matching$|^performance$|^sequencing$|^likert$|^numeric$';
protected CMI_RESULT = '^correct$|^wrong$|^unanticipated$|^neutral$|^([0-9]{0,3})?(\.[0-9]*)?$';
protected NAV_EVENT = '^previous$|^continue$';
// Children lists.
protected CMI_CHILDREN = 'core,suspend_data,launch_data,comments,objectives,student_data,student_preference,interactions';
protected CORE_CHILDREN = 'student_id,student_name,lesson_location,credit,lesson_status,entry,score,total_time,lesson_mode,' +
'exit,session_time';
protected SCORE_CHILDREN = 'raw,min,max';
protected COMMENTS_CHILDREN = 'content,location,time';
protected OBJECTIVES_CHILDREN = 'id,score,status';
protected CORRECT_RESPONSES_CHILDREN = 'pattern';
protected STUDENT_DATA_CHILDREN = 'mastery_score,max_time_allowed,time_limit_action';
protected STUDENT_PREFERENCE_CHILDREN = 'audio,language,speed,text';
protected INTERACTIONS_CHILDREN = 'id,objectives,time,type,correct_responses,weighting,student_response,result,latency';
// Data ranges.
protected SCORE_RANGE = '0#100';
protected AUDIO_RANGE = '-1#100';
protected SPEED_RANGE = '-100#100';
protected WEIGHTING_RANGE = '-100#100';
protected TEXT_RANGE = '-1#1';
// Error messages.
protected ERROR_STRINGS = {
0: 'No error',
101: 'General exception',
201: 'Invalid argument error',
202: 'Element cannot have children',
203: 'Element not an array - cannot have count',
301: 'Not initialized',
401: 'Not implemented error',
402: 'Invalid set value, element is a keyword',
403: 'Element is read only',
404: 'Element is write only',
405: 'Incorrect data type'
};
protected currentUserData = {}; // Current user data.
protected def = {}; // Object containing the default values.
protected defExtra = {}; // Extra object that will contain the objectives and interactions data (all the .n. elements).
protected dataModel = {}; // The SCORM 1.2 data model.
protected initialized = false; // Whether LMSInitialize has been called.
protected errorCode: string; // Last error.
protected timeout; // Timeout to commit changes.
/**
* Constructor.
*
* @param {CoreEventsProvider} eventsProvider Events provider instance.
* @param {AddonModScormProvider} scormProvider SCORM provider instance.
* @param {any} scorm SCORM.
* @param {number} scoId Current SCO ID.
* @param {number} attempt Attempt number.
* @param {any} userData The user default data.
* @param {string} [mode] Mode being played. By default, MODENORMAL.
* @param {boolean} offline Whether the attempt is offline.
*/
constructor(protected eventsProvider: CoreEventsProvider, protected scormProvider: AddonModScormProvider,
protected siteId: string, protected scorm: any, protected scoId: number, protected attempt: number,
userData: any, protected mode?: string, protected offline?: boolean) {
this.mode = mode || AddonModScormProvider.MODENORMAL;
this.offline = !!offline;
this.init(userData);
}
/**
* Utility function for adding two times in format hh:mm:ss.
*
* @param {string} first First time.
* @param {string} second Second time.
* @return {string} Total time.
*/
protected addTime(first: string, second: string): string {
const sFirst = first.split(':'),
sSecond = second.split(':'),
cFirst = sFirst[2].split('.'),
cSecond = sSecond[2].split('.');
let change = 0;
let firstCents = 0; // Cents.
if (cFirst.length > 1) {
firstCents = parseInt(cFirst[1], 10);
}
let secondCents = 0;
if (cSecond.length > 1) {
secondCents = parseInt(cSecond[1], 10);
}
let cents: string | number = firstCents + secondCents;
change = Math.floor(cents / 100);
cents = cents - (change * 100);
if (Math.floor(cents) < 10) {
cents = '0' + cents.toString();
}
let secs: string | number = parseInt(cFirst[0], 10) + parseInt(cSecond[0], 10) + change; // Seconds.
change = Math.floor(secs / 60);
secs = secs - (change * 60);
if (Math.floor(secs) < 10) {
secs = '0' + secs.toString();
}
let mins: string | number = parseInt(sFirst[1], 10) + parseInt(sSecond[1], 10) + change; // Minutes.
change = Math.floor(mins / 60);
mins = mins - (change * 60);
if (mins < 10) {
mins = '0' + mins.toString();
}
let hours: string | number = parseInt(sFirst[0], 10) + parseInt(sSecond[0], 10) + change; // Hours.
if (hours < 10) {
hours = '0' + hours.toString();
}
if (cents != '0') {
return hours + ':' + mins + ':' + secs + '.' + cents;
} else {
return hours + ':' + mins + ':' + secs;
}
}
/**
* Utility function for cloning an object
*
* @param {any} obj The object to be cloned
* @return {any} The object cloned
*/
protected cloneObj(obj: any): any {
if (obj == null || typeof(obj) != 'object') {
return obj;
}
const temp = new obj.constructor(); // Changed (twice).
for (const key in obj) {
temp[key] = this.cloneObj(obj[key]);
}
return temp;
}
/**
* Collect all the user tracking data that must be persisted in the system, this is usually called by LMSCommit().
*
* @return {any[]} Collected data.
*/
protected collectData(): any[] {
const data = [];
for (const element in this.currentUserData[this.scoId]) {
// Ommit for example the nav. elements.
if (element.substr(0, 3) == 'cmi') {
const expression = new RegExp(this.CMI_INDEX, 'g');
// Get the generic name for this element (e.g. convert 'cmi.interactions.1.id' to 'cmi.interactions.n.id')
const elementModel = String(element).replace(expression, '.n.');
// Ignore the session time element.
if (element != 'cmi.core.session_time') {
// Check if this specific element is not defined in the datamodel, but the generic element name is.
if (typeof this.dataModel[this.scoId][element] == 'undefined' &&
typeof this.dataModel[this.scoId][elementModel] != 'undefined') {
// Add this element to the data model (by cloning the generic element) so we can track changes to it.
this.dataModel[this.scoId][element] = this.cloneObj(this.dataModel[this.scoId][elementModel]);
}
// Check if the current element exists in the datamodel.
if (typeof this.dataModel[this.scoId][element] != 'undefined') {
// Make sure this is not a read only element.
if (this.dataModel[this.scoId][element].mod != 'r') {
const el = {
// Moodle stores the organizations and interactions using _n. instead .n.
element: element.replace(expression, '_$1.'),
value: this.getEl(element)
};
// Check if the element has a default value.
if (typeof this.dataModel[this.scoId][element].defaultvalue != 'undefined') {
// Check if the default value is different from the current value.
if (this.dataModel[this.scoId][element].defaultvalue != el.value ||
typeof this.dataModel[this.scoId][element].defaultvalue != typeof(el.value)) {
data.push(el);
// Update the element default to reflect the current committed value.
this.dataModel[this.scoId][element].defaultvalue = el.value;
}
} else {
data.push(el);
// No default value for the element, so set it now.
this.dataModel[this.scoId][element].defaultvalue = el.value;
}
}
}
}
}
}
return data;
}
/**
* Get the value of the given element from the non-persistent (current) user data.
*
* @param {string} el The element
* @return {any} The element value
*/
protected getEl(el: string): any {
if (typeof this.currentUserData[this.scoId] != 'undefined' && typeof this.currentUserData[this.scoId][el] != 'undefined') {
return this.currentUserData[this.scoId][el];
}
return '';
}
/**
* Initialize the model.
*
* @param {any} userData The user default data.
*/
protected init(userData: any): void {
// Prepare the definition array containing the default values.
for (const scoId in userData) {
const sco = userData[scoId];
this.def[scoId] = sco.defaultdata;
this.defExtra[scoId] = sco.userdata;
}
// Set up data model for each SCO.
for (const scoId in this.def) {
this.dataModel[scoId] = {
'cmi._children': { defaultvalue: this.CMI_CHILDREN, mod: 'r', writeerror: '402' },
'cmi._version': { defaultvalue: '3.4', mod: 'r', writeerror: '402' },
'cmi.core._children': { defaultvalue: this.CORE_CHILDREN, mod: 'r', writeerror: '402' },
'cmi.core.student_id': { defaultvalue: this.def[scoId]['cmi.core.student_id'], mod: 'r', writeerror: '403' },
'cmi.core.student_name': { defaultvalue: this.def[scoId]['cmi.core.student_name'], mod: 'r', writeerror: '403' },
'cmi.core.lesson_location': { defaultvalue: this.def[scoId]['cmi.core.lesson_location'],
format: this.CMI_STRING_256, mod: 'rw', writeerror: '405' },
'cmi.core.credit': { defaultvalue: this.def[scoId]['cmi.core.credit'], mod: 'r', writeerror: '403' },
'cmi.core.lesson_status': { defaultvalue: this.def[scoId]['cmi.core.lesson_status'], format: this.CMI_STATUS,
mod: 'rw', writeerror: '405' },
'cmi.core.entry': { defaultvalue: this.def[scoId]['cmi.core.entry'], mod: 'r', writeerror: '403' },
'cmi.core.score._children': { defaultvalue: this.SCORE_CHILDREN, mod: 'r', writeerror: '402' },
'cmi.core.score.raw': { defaultvalue: this.def[scoId]['cmi.core.score.raw'], format: this.CMI_DECIMAL,
range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' },
'cmi.core.score.max': { defaultvalue: this.def[scoId]['cmi.core.score.max'], format: this.CMI_DECIMAL,
range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' },
'cmi.core.score.min': { defaultvalue: this.def[scoId]['cmi.core.score.min'], format: this.CMI_DECIMAL,
range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' },
'cmi.core.total_time': { defaultvalue: this.def[scoId]['cmi.core.total_time'], mod: 'r', writeerror: '403' },
'cmi.core.lesson_mode': { defaultvalue: this.def[scoId]['cmi.core.lesson_mode'], mod: 'r', writeerror: '403' },
'cmi.core.exit': { defaultvalue: this.def[scoId]['cmi.core.exit'], format: this.CMI_EXIT, mod: 'w',
readerror: '404', writeerror: '405' },
'cmi.core.session_time': { format: this.CMI_TIMESPAN, mod: 'w', defaultvalue: '00:00:00', readerror: '404',
writeerror: '405' },
'cmi.suspend_data': { defaultvalue: this.def[scoId]['cmi.suspend_data'], format: this.CMI_STRING_4096,
mod: 'rw', writeerror: '405' },
'cmi.launch_data': { defaultvalue: this.def[scoId]['cmi.launch_data'], mod: 'r', writeerror: '403' },
'cmi.comments': { defaultvalue: this.def[scoId]['cmi.comments'], format: this.CMI_STRING_4096, mod: 'rw',
writeerror: '405' },
// Deprecated evaluation attributes.
'cmi.evaluation.comments._count': { defaultvalue: '0', mod: 'r', writeerror: '402' },
'cmi.evaluation.comments._children': { defaultvalue: this.COMMENTS_CHILDREN, mod: 'r', writeerror: '402' },
'cmi.evaluation.comments.n.content': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_STRING_256,
mod: 'rw', writeerror: '405' },
'cmi.evaluation.comments.n.location': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_STRING_256,
mod: 'rw', writeerror: '405' },
'cmi.evaluation.comments.n.time': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_TIME,
mod: 'rw', writeerror: '405' },
'cmi.comments_from_lms': { mod: 'r', writeerror: '403' },
'cmi.objectives._children': { defaultvalue: this.OBJECTIVES_CHILDREN, mod: 'r', writeerror: '402' },
'cmi.objectives._count': { mod: 'r', defaultvalue: '0', writeerror: '402' },
'cmi.objectives.n.id': { pattern: this.CMI_INDEX, format: this.CMI_IDENTIFIER, mod: 'rw', writeerror: '405' },
'cmi.objectives.n.score._children': { pattern: this.CMI_INDEX, mod: 'r', writeerror: '402' },
'cmi.objectives.n.score.raw': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_DECIMAL,
range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' },
'cmi.objectives.n.score.min': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_DECIMAL,
range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' },
'cmi.objectives.n.score.max': { defaultvalue: '', pattern: this.CMI_INDEX, format: this.CMI_DECIMAL,
range: this.SCORE_RANGE, mod: 'rw', writeerror: '405' },
'cmi.objectives.n.status': { pattern: this.CMI_INDEX, format: this.CMI_STATUS_2, mod: 'rw', writeerror: '405' },
'cmi.student_data._children': { defaultvalue: this.STUDENT_DATA_CHILDREN, mod: 'r', writeerror: '402' },
'cmi.student_data.mastery_score': { defaultvalue: this.def[scoId]['cmi.student_data.mastery_score'], mod: 'r',
writeerror: '403' },
'cmi.student_data.max_time_allowed': { defaultvalue: this.def[scoId]['cmi.student_data.max_time_allowed'],
mod: 'r', writeerror: '403' },
'cmi.student_data.time_limit_action': { defaultvalue: this.def[scoId]['cmi.student_data.time_limit_action'],
mod: 'r', writeerror: '403' },
'cmi.student_preference._children': { defaultvalue: this.STUDENT_PREFERENCE_CHILDREN, mod: 'r',
writeerror: '402' },
'cmi.student_preference.audio': { defaultvalue: this.def[scoId]['cmi.student_preference.audio'],
format: this.CMI_SINTEGER, range: this.AUDIO_RANGE, mod: 'rw', writeerror: '405' },
'cmi.student_preference.language': { defaultvalue: this.def[scoId]['cmi.student_preference.language'],
format: this.CMI_STRING_256, mod: 'rw', writeerror: '405' },
'cmi.student_preference.speed': { defaultvalue: this.def[scoId]['cmi.student_preference.speed'],
format: this.CMI_SINTEGER, range: this.SPEED_RANGE, mod: 'rw', writeerror: '405' },
'cmi.student_preference.text': { defaultvalue: this.def[scoId]['cmi.student_preference.text'],
format: this.CMI_SINTEGER, range: this.TEXT_RANGE, mod: 'rw', writeerror: '405' },
'cmi.interactions._children': { defaultvalue: this.INTERACTIONS_CHILDREN, mod: 'r', writeerror: '402' },
'cmi.interactions._count': { mod: 'r', defaultvalue: '0', writeerror: '402' },
'cmi.interactions.n.id': { pattern: this.CMI_INDEX, format: this.CMI_IDENTIFIER, mod: 'w', readerror: '404',
writeerror: '405' },
'cmi.interactions.n.objectives._count': { pattern: this.CMI_INDEX, mod: 'r', defaultvalue: '0', writeerror: '402' },
'cmi.interactions.n.objectives.n.id': { pattern: this.CMI_INDEX, format: this.CMI_IDENTIFIER, mod: 'w',
readerror: '404', writeerror: '405' },
'cmi.interactions.n.time': { pattern: this.CMI_INDEX, format: this.CMI_TIME, mod: 'w', readerror: '404',
writeerror: '405' },
'cmi.interactions.n.type': { pattern: this.CMI_INDEX, format: this.CMI_TYPE, mod: 'w', readerror: '404',
writeerror: '405' },
'cmi.interactions.n.correct_responses._count': { pattern: this.CMI_INDEX, mod: 'r', defaultvalue: '0',
writeerror: '402' },
'cmi.interactions.n.correct_responses.n.pattern': { pattern: this.CMI_INDEX, format: this.CMI_FEEDBACK,
mod: 'w', readerror: '404', writeerror: '405' },
'cmi.interactions.n.weighting': { pattern: this.CMI_INDEX, format: this.CMI_DECIMAL,
range: this.WEIGHTING_RANGE, mod: 'w', readerror: '404', writeerror: '405' },
'cmi.interactions.n.student_response': { pattern: this.CMI_INDEX, format: this.CMI_FEEDBACK, mod: 'w',
readerror: '404', writeerror: '405' },
'cmi.interactions.n.result': { pattern: this.CMI_INDEX, format: this.CMI_RESULT, mod: 'w', readerror: '404',
writeerror: '405' },
'cmi.interactions.n.latency': { pattern: this.CMI_INDEX, format: this.CMI_TIMESPAN, mod: 'w',
readerror: '404', writeerror: '405' },
'nav.event': { defaultvalue: '', format: this.NAV_EVENT, mod: 'w', readerror: '404', writeerror: '405' }
};
this.currentUserData[scoId] = {};
// Load default values.
for (const element in this.dataModel[scoId]) {
if (element.match(/\.n\./) === null) {
if (typeof this.dataModel[scoId][element].defaultvalue != 'undefined') {
this.currentUserData[scoId][element] = this.dataModel[scoId][element].defaultvalue;
}
}
}
// Load initial user data for current SCO.
for (const element in this.def[scoId]) {
if (element.match(/\.n\./) === null) {
if (typeof this.dataModel[scoId][element].defaultvalue != 'undefined') {
this.currentUserData[scoId][element] = this.dataModel[scoId][element].defaultvalue;
} else if (typeof this.defExtra[scoId][element] != 'undefined') {
// Check in user data values.
this.currentUserData[scoId][element] = this.defExtra[scoId][element];
} else {
this.currentUserData[scoId][element] = '';
}
}
}
// Load interactions and objectives, and init the counters.
const expression = new RegExp(this.CMI_INDEX, 'g');
for (const element in this.defExtra[scoId]) {
let counterElement = '',
currentCounterIndex: any = 0,
elementDotFormat,
currentN;
// This check for an indexed element. cmi.objectives.1.id or cmi.objectives_1.id.
if (element.match(expression)) {
// Normalize to the expected value according the standard.
// Moodle stores this values using _n. instead .n.
elementDotFormat = element.replace(expression, '.$1.');
this.currentUserData[scoId][elementDotFormat] = this.defExtra[scoId][element];
// Get the correct counter and current index.
if (elementDotFormat.indexOf('cmi.evaluation.comments') === 0) {
counterElement = 'cmi.evaluation.comments._count';
currentCounterIndex = elementDotFormat.match(/.(\d+)./)[1];
} else if (elementDotFormat.indexOf('cmi.objectives') === 0) {
counterElement = 'cmi.objectives._count';
currentCounterIndex = elementDotFormat.match(/.(\d+)./)[1];
} else if (elementDotFormat.indexOf('cmi.interactions') === 0) {
if (elementDotFormat.indexOf('.objectives.') > 0) {
currentN = elementDotFormat.match(/cmi.interactions.(\d+)./)[1];
currentCounterIndex = elementDotFormat.match(/objectives.(\d+)./)[1];
counterElement = 'cmi.interactions.' + currentN + '.objectives._count';
} else if (elementDotFormat.indexOf('.correct_responses.') > 0) {
currentN = elementDotFormat.match(/cmi.interactions.(\d+)./)[1];
currentCounterIndex = elementDotFormat.match(/correct_responses.(\d+)./)[1];
counterElement = 'cmi.interactions.' + currentN + '.correct_responses._count';
} else {
counterElement = 'cmi.interactions._count';
currentCounterIndex = elementDotFormat.match(/.(\d+)./)[1];
}
}
if (counterElement) {
if (typeof this.currentUserData[scoId][counterElement] == 'undefined') {
this.currentUserData[scoId][counterElement] = 0;
}
// Check if we need to sum.
if (parseInt(currentCounterIndex) == parseInt(this.currentUserData[scoId][counterElement])) {
this.currentUserData[scoId][counterElement] = parseInt(this.currentUserData[scoId][counterElement]) + 1;
}
if (parseInt(currentCounterIndex) > parseInt(this.currentUserData[scoId][counterElement])) {
this.currentUserData[scoId][counterElement] = parseInt(currentCounterIndex) - 1;
}
}
}
}
// Set default status.
if (this.currentUserData[scoId]['cmi.core.lesson_status'] === '') {
this.currentUserData[scoId]['cmi.core.lesson_status'] = 'not attempted';
}
// Define mode and credit.
this.currentUserData[scoId]['cmi.core.credit'] = this.mode == AddonModScormProvider.MODENORMAL ? 'credit' : 'no-credit';
this.currentUserData[scoId]['cmi.core.lesson_mode'] = this.mode;
}
}
/**
* Commit the changes.
*
* @param {string} param Param.
* @return {string} "true" if success, "false" otherwise.
*/
LMSCommit(param: string): string {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
this.errorCode = '0';
if (param == '') {
if (this.initialized) {
const result = this.storeData(false);
// Trigger TOC update.
this.triggerEvent(AddonModScormProvider.UPDATE_TOC_EVENT);
this.errorCode = result ? '0' : '101';
// Conver to string representing a boolean.
return result ? 'true' : 'false';
} else {
this.errorCode = '301';
}
} else {
this.errorCode = '201';
}
return 'false';
}
/**
* Finish the data model.
*
* @param {string} param Param.
* @return {string} "true" if success, "false" otherwise.
*/
LMSFinish(param: string): string {
this.errorCode = '0';
if (param == '') {
if (this.initialized) {
this.initialized = false;
const result = this.storeData(true);
if (this.getEl('nav.event') != '') {
if (this.getEl('nav.event') == 'continue') {
this.triggerEvent(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT);
} else {
this.triggerEvent(AddonModScormProvider.LAUNCH_PREV_SCO_EVENT);
}
} else {
if (this.scorm.auto == '1') {
this.triggerEvent(AddonModScormProvider.LAUNCH_NEXT_SCO_EVENT);
}
}
this.errorCode = result ? '0' : '101';
// Trigger TOC update.
this.triggerEvent(AddonModScormProvider.UPDATE_TOC_EVENT);
// Conver to string representing a boolean.
return result ? 'true' : 'false';
} else {
this.errorCode = '301';
}
} else {
this.errorCode = '201';
}
return 'false';
}
/**
* Get diagnostic.
*
* @param {string} param Param.
* @return {string} Result.
*/
LMSGetDiagnostic(param: string): string {
if (param == '') {
param = this.errorCode;
}
return param;
}
/**
* Get the error message for a certain code.
*
* @param {string} param Error code.
* @return {string} Error message.
*/
LMSGetErrorString(param: string): string {
if (param != '') {
return this.ERROR_STRINGS[param];
} else {
return '';
}
}
/**
* Get the last error code.
*
* @return {string} Last error code.
*/
LMSGetLastError(): string {
return this.errorCode;
}
/**
* Get the value of a certain element.
*
* @param {string} element Name of the element to get.
* @return {string} Value.
*/
LMSGetValue(element: string): string {
this.errorCode = '0';
if (this.initialized) {
if (element != '') {
const expression = new RegExp(this.CMI_INDEX, 'g'),
elementModel = String(element).replace(expression, '.n.');
if (typeof this.dataModel[this.scoId][elementModel] != 'undefined') {
if (this.dataModel[this.scoId][elementModel].mod != 'w') {
this.errorCode = '0';
return this.getEl(element);
} else {
this.errorCode = this.dataModel[this.scoId][elementModel].readerror;
}
} else {
const childrenStr = '._children',
countStr = '._count';
if (elementModel.substr(elementModel.length - childrenStr.length, elementModel.length) == childrenStr) {
const parentModel = elementModel.substr(0, elementModel.length - childrenStr.length);
if (typeof this.dataModel[this.scoId][parentModel] != 'undefined') {
this.errorCode = '202';
} else {
this.errorCode = '201';
}
} else if (elementModel.substr(elementModel.length - countStr.length, elementModel.length) == countStr) {
const parentModel = elementModel.substr(0, elementModel.length - countStr.length);
if (typeof this.dataModel[this.scoId][parentModel] != 'undefined') {
this.errorCode = '203';
} else {
this.errorCode = '201';
}
} else {
this.errorCode = '201';
}
}
} else {
this.errorCode = '201';
}
} else {
this.errorCode = '301';
}
return '';
}
/**
* Initialize the data model.
*
* @param {string} param Param.
* @return {string} "true" if initialized, "false" otherwise.
*/
LMSInitialize(param: string): string {
this.errorCode = '0';
if (param == '') {
if (!this.initialized) {
this.initialized = true;
this.errorCode = '0';
return 'true';
} else {
this.errorCode = '101';
}
} else {
this.errorCode = '201';
}
return 'false';
}
/**
* Set the value of a certain element.
*
* @param {string} element Name of the element to set.
* @param {any} value Value to set.
* @return {string} "true" if success, "false" otherwise.
*/
LMSSetValue(element: string, value: any): string {
this.errorCode = '0';
if (this.initialized) {
if (element != '') {
let expression = new RegExp(this.CMI_INDEX, 'g');
const elementModel = String(element).replace(expression, '.n.');
if (typeof this.dataModel[this.scoId][elementModel] != 'undefined') {
if (this.dataModel[this.scoId][elementModel].mod != 'r') {
expression = new RegExp(this.dataModel[this.scoId][elementModel].format);
value = value + '';
const matches = value.match(expression);
if (matches != null) {
// Create dynamic data model element.
if (element != elementModel) {
// Init default counters and values.
if (element.indexOf('cmi.objectives') === 0) {
const currentN = element.match(/cmi.objectives.(\d+)./)[1],
counterElement = 'cmi.objectives.' + currentN + '.score';
if (typeof this.currentUserData[this.scoId][counterElement + '._children'] == 'undefined') {
this.setEl(this.currentUserData[this.scoId][counterElement + '._children'],
this.SCORE_CHILDREN);
this.setEl(this.currentUserData[this.scoId][counterElement + '.raw'], '');
this.setEl(this.currentUserData[this.scoId][counterElement + '.min'], '');
this.setEl(this.currentUserData[this.scoId][counterElement + '.max'], '');
}
} else if (element.indexOf('cmi.interactions') === 0) {
const currentN = element.match(/cmi.interactions.(\d+)./)[1];
let counterElement = 'cmi.interactions.' + currentN + '.objectives._count';
if (typeof this.currentUserData[this.scoId][counterElement] == 'undefined') {
this.setEl(counterElement, 0);
}
counterElement = 'cmi.interactions.' + currentN + '.correct_responses._count';
if (typeof this.currentUserData[this.scoId][counterElement] == 'undefined') {
this.setEl(counterElement, 0);
}
}
const elementIndexes = element.split('.');
let subElement = 'cmi';
for (let i = 1; i < elementIndexes.length - 1; i++) {
const elementIndex = elementIndexes[i];
if (elementIndexes[i + 1].match(/^\d+$/)) {
const counterElement = subElement + '.' + elementIndex + '._count';
if (typeof this.currentUserData[this.scoId][counterElement] == 'undefined') {
this.setEl(counterElement, 0);
}
if (elementIndexes[i + 1] == this.getEl(counterElement)) {
const count = this.getEl(counterElement);
this.setEl(counterElement, parseInt(count, 10) + 1);
}
if (elementIndexes[i + 1] > this.getEl(counterElement)) {
this.errorCode = '201';
}
subElement = subElement.concat('.' + elementIndex + '.' + elementIndexes[i + 1]);
i++;
} else {
subElement = subElement.concat('.' + elementIndex);
}
}
element = subElement.concat('.' + elementIndexes[elementIndexes.length - 1]);
}
// Store data.
if (this.errorCode == '0') {
if (this.scorm.autocommit && !(this.timeout)) {
this.timeout = setTimeout(this.LMSCommit.bind(this), 60000, ['']);
}
if (typeof this.dataModel[this.scoId][elementModel].range != 'undefined') {
const range = this.dataModel[this.scoId][elementModel].range,
ranges = range.split('#');
value = value * 1.0;
if ((value >= ranges[0]) && (value <= ranges[1])) {
this.setEl(element, value);
this.errorCode = '0';
return 'true';
} else {
this.errorCode = this.dataModel[this.scoId][elementModel].writeerror;
}
} else {
if (element == 'cmi.comments') {
this.setEl('cmi.comments', this.getEl('cmi.comments') + value);
} else {
this.setEl(element, value);
}
this.errorCode = '0';
return 'true';
}
}
} else {
this.errorCode = this.dataModel[this.scoId][elementModel].writeerror;
}
} else {
this.errorCode = this.dataModel[this.scoId][elementModel].writeerror;
}
} else {
this.errorCode = '201';
}
} else {
this.errorCode = '201';
}
} else {
this.errorCode = '301';
}
return 'false';
}
/**
* Set a SCO ID.
* The scoId is like a pointer to be able to retrieve the SCO default values and set the new ones in the overall SCORM
* data structure.
*
* @param {number} scoId The new SCO id.
*/
loadSco(scoId: number): void {
this.scoId = scoId;
}
/**
* Set the value of the given element in the non-persistent (current) user data.
*
* @param {string} el The element.
* @param {any} value The value.
*/
protected setEl(el: string, value: any): void {
if (typeof this.currentUserData[this.scoId] == 'undefined') {
this.currentUserData[this.scoId] = {};
}
this.currentUserData[this.scoId][el] = value;
}
/**
* Set offline mode to true or false.
*
* @param {boolean} offline True if offline, false otherwise.
*/
setOffline(offline: boolean): void {
this.offline = offline;
}
/**
* Persist the current user data (this is usually called by LMSCommit).
*
* @param {boolean} storeTotalTime If true, we need to calculate the total time too.
* @return {boolean} True if success, false otherwise.
*/
protected storeData(storeTotalTime?: boolean): boolean {
let tracks;
if (storeTotalTime) {
if (this.getEl('cmi.core.lesson_status') == 'not attempted') {
this.setEl('cmi.core.lesson_status', 'completed');
}
if (this.getEl('cmi.core.lesson_mode') == AddonModScormProvider.MODENORMAL) {
if (this.getEl('cmi.core.credit') == 'credit') {
if (this.getEl('cmi.student_data.mastery_score') !== '' && this.getEl('cmi.core.score.raw') !== '') {
if (parseFloat(this.getEl('cmi.core.score.raw')) >=
parseFloat(this.getEl('cmi.student_data.mastery_score'))) {
this.setEl('cmi.core.lesson_status', 'passed');
} else {
this.setEl('cmi.core.lesson_status', 'failed');
}
}
}
}
if (this.getEl('cmi.core.lesson_mode') == AddonModScormProvider.MODEBROWSE) {
if (this.dataModel[this.scoId]['cmi.core.lesson_status'].defaultvalue == '' &&
this.getEl('cmi.core.lesson_status') == 'not attempted') {
this.setEl('cmi.core.lesson_status', 'browsed');
}
}
tracks = this.collectData();
tracks.push(this.totalTime());
} else {
tracks = this.collectData();
}
const success = this.scormProvider.saveTracksSync(this.scoId, this.attempt, tracks, this.scorm, this.offline,
this.currentUserData);
if (!this.offline && !success) {
// Failure storing data in online. Go offline.
this.offline = true;
this.triggerEvent(AddonModScormProvider.GO_OFFLINE_EVENT);
return this.scormProvider.saveTracksSync(this.scoId, this.attempt, tracks, this.scorm, this.offline,
this.currentUserData);
}
return success;
}
/**
* Utility function for calculating the total time spent in the SCO.
*
* @return {any} Total time element.
*/
protected totalTime(): any {
const totalTime = this.addTime(this.getEl('cmi.core.total_time'), this.getEl('cmi.core.session_time'));
return { element: 'cmi.core.total_time', value: totalTime };
}
/**
* Convenience function to trigger events.
*
* @param {string} name Name of the event to trigger.
*/
protected triggerEvent(name: string): void {
this.eventsProvider.trigger(name, {
scormId: this.scorm.id,
scoId: this.scoId,
attempt: this.attempt
}, this.siteId);
}
}

View File

@ -77,6 +77,12 @@ export class AddonModScormProvider {
static MODENORMAL = 'normal'; static MODENORMAL = 'normal';
static MODEREVIEW = 'review'; static MODEREVIEW = 'review';
// Events.
static LAUNCH_NEXT_SCO_EVENT = 'addon_mod_scorm_launch_next_sco';
static LAUNCH_PREV_SCO_EVENT = 'addon_mod_scorm_launch_prev_sco';
static UPDATE_TOC_EVENT = 'addon_mod_scorm_update_toc';
static GO_OFFLINE_EVENT = 'addon_mod_scorm_go_offline';
// Protected constants. // Protected constants.
protected VALID_STATUSES = ['notattempted', 'passed', 'completed', 'failed', 'incomplete', 'browsed', 'suspend']; protected VALID_STATUSES = ['notattempted', 'passed', 'completed', 'failed', 'incomplete', 'browsed', 'suspend'];
protected STATUSES = { protected STATUSES = {