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