/**
* File : report/TCPReporter.js
* Created : 08/06/2016
* By : Francesc Busquets <francesc@gmail.com>
*
* JClic.js
* An HTML5 player of JClic activities
* https://projectestac.github.io/jclic.js
*
* @source https://github.com/projectestac/jclic.js
*
* @license EUPL-1.2
* @licstart
* (c) 2000-2020 Catalan Educational Telematic Network (XTEC)
*
* Licensed under the EUPL, Version 1.1 or -as soon they will be approved by
* the European Commission- subsequent versions of the EUPL (the "Licence");
* You may not use this work except in compliance with the Licence.
*
* You may obtain a copy of the Licence at:
* https://joinup.ec.europa.eu/software/page/eupl
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* Licence for the specific language governing permissions and limitations
* under the Licence.
* @licend
* @module
*/
/* global Promise, document, window, XMLSerializer */
import $ from 'jquery';
import Reporter from './Reporter.js';
import { log, startsWith, getMsg } from '../Utils.js';
/**
* This special case of {@link module:Reporter.Reporter Reporter} connects with an external service reporter providing
* the {@link https://github.com/projectestac/jclic/wiki/JClic-Reports-developers-guide JClic Reports API}.
* Connection parameters to the reports server (`path`, `service`, `userId`, `key`, `context`...)
* are passed through the `options` element of {@link module:JClicPlayer.JClicPlayer JClicPlayer} (acting as {@link module:JClicPlayer.JClicPlayer JClicPlayer}).
* @extends module:reports/Reporter.Reporter
*/
export class TCPReporter extends Reporter {
/**
* TCPReporter constructor
* @param {module:JClicPlayer.JClicPlayer} ps - The {@link module:JClicPlayer.JClicPlayer JClicPlayer} used to retrieve settings and localized messages
*/
constructor(ps) {
super(ps);
this.tasks = [];
}
/**
* Gets a specific property from this reporting system
* @override
* @param {string} key - Requested property
* @param {string}+ defaultValue - Default return value when requested property does not exist
* @returns {string}
*/
getProperty(key, defaultValue) {
return this.dbProperties !== null && this.dbProperties.hasOwnProperty(key) ?
this.dbProperties[key] :
defaultValue;
}
/**
* Adds a new element to the list of report beans pending to be transmitted.
* @param {module:report/TCPReporter.ReportBean} bean
*/
addTask(bean) {
if (this.processingTasks) {
if (this.waitingTasks === null)
this.waitingTasks = [bean];
else
this.waitingTasks.push(bean);
} else
this.tasks.push(bean);
}
/**
* Transmits all report beans currently stored in `tasks` to the reports server
* @returns {external:Promise}
*/
flushTasksPromise() {
if (this.processingTasks || this.currentSessionId === null ||
this.tasks.length === 0 || this.serviceUrl === null) {
// The task list cannot be processed now. Pass and wait until the next timer cycle:
if (this.processingTasks)
this.forceFlush = true;
return Promise.resolve(true);
}
else {
// Set up the `processingTasks` flag to avoid re-entrant processing
this.processingTasks = true;
const reportBean = new ReportBean('multiple');
for (let i = 0; i < this.tasks.length; i++)
reportBean.appendData(this.tasks[i].$bean);
log('debug', 'Reporting:', reportBean.$bean[0]);
return new Promise((resolve, reject) => {
this.transaction(reportBean.$bean)
.done((_data, _textStatus, _jqXHR) => {
// TODO: Check returned message for possible errors on the server side
this.failCount = 0;
// Clear waiting tasks
if (this.waitingTasks) {
this.tasks = this.waitingTasks;
this.waitingTasks = null;
}
else {
this.forceFlush = false;
this.tasks = [];
}
if (this.forceFlush && this.tasks.length > 0) {
this.forceFlush = false;
this.processingTasks = false;
this.flushTasksPromise().then(() => {
resolve(true);
});
}
else {
this.forceFlush = false;
resolve(true);
this.processingTasks = false;
}
})
.fail((jqXHR, textStatus, errorThrown) => {
if (++this.failCount > this.maxFails)
this.stopReporting().then();
reject(`Error reporting results to ${this.serviceUrl} [${textStatus} ${errorThrown}]`);
this.processingTasks = false;
});
});
}
}
/**
* Initializes this report system with an optional set of parameters.
* Returns a Promise, fulfilled when the reporter is fully initialized.
* @override
* @param {object} [options] - Initial settings passed to the reporting system
* @returns {external:Promise}
*/
init(options) {
if (typeof options === 'undefined' || options === null)
options = this.ps.options;
super.init(options);
this.initiated = false;
this.stopReporting();
this.serverPath = options.path || this.DEFAULT_SERVER_PATH;
this.descriptionDetail = this.serverPath;
let serverService = options.service || this.DEFAULT_SERVER_SERVICE;
if (!startsWith(serverService, '/'))
serverService = `/${serverService}`;
const serverProtocol = options.protocol || this.DEFAULT_SERVER_PROTOCOL;
this.serviceUrl = `${serverProtocol}://${this.serverPath}${serverService}`;
const bean = new ReportBean('get_properties');
return new Promise((resolve, reject) => {
this.transaction(bean.$bean)
.done((data, _textStatus, _jqXHR) => {
this.dbProperties = {};
$(data).find('param').each((_n, param) => {
const $param = $(param);
this.dbProperties[$param.attr('name')] = $param.attr('value');
});
this.promptUserId(false).then(userId => {
this.userId = userId;
const tl = options.lap || this.getProperty('TIME_LAP', this.DEFAULT_TIMER_LAP);
this.timerLap = Math.min(30, Math.max(1, parseInt(tl)));
this.timer = window.setInterval(() => this.flushTasksPromise().then(), this.timerLap * 1000);
// Warn before leaving the current page with unsaved data:
this.beforeUnloadFunction = event => {
if (this.serviceUrl !== null &&
(this.tasks.length > 0 || this.processingTasks)) {
this.flushTasksPromise().then();
const result = getMsg('Please wait until the results of your activities are sent to the reports system');
if (event)
event.returnValue = result;
return result;
}
};
window.addEventListener('beforeunload', this.beforeUnloadFunction);
this.initiated = true;
resolve(true);
}).catch(msg => {
this.stopReporting();
reject(`Error getting the user ID: ${msg}`);
});
})
.fail((jqXHR, textStatus, errorThrown) => {
this.stopReporting();
reject(`Error initializing reports service ${this.serviceUrl} [${textStatus} ${errorThrown}]`);
});
});
}
/**
* This method should be invoked when a new session starts.
* @override
* @param {module:project/JClicProject.JClicProject} jcp - The {@link module:project/JClicProject.JClicProject JClicProject} this session refers to.
*/
newSession(jcp) {
super.newSession(jcp);
if (this.serviceUrl && this.userId !== null) {
// Session ID will be obtained when reporting first activity
this.currentSessionId = null;
}
}
/**
* Creates a new session in the remote database and records its ID for future use
* @param {boolean} forceNewSession - When `true`, a new session will always be created.
* @returns {external:Promise} - A Promise reporter will be successfully resolved
* only when `currentSessionId` have a valid value.
*/
createDBSession(forceNewSession) {
if (this.currentSessionId !== null && !forceNewSession)
// A valid session is available, so just return it
return Promise.resolve(this.currentSessionId);
else
// A new session must be created:
return new Promise((resolve, reject) => {
if (this.initiated && this.userId !== null && this.currentSession !== null) {
this.flushTasksPromise().then(() => {
this.currentSessionId = null;
const bean = new ReportBean('add session');
bean.setParam('project', this.currentSession.projectName);
bean.setParam('activities', Number(this.currentSession.reportableActs));
bean.setParam('time', Number(this.currentSession.started));
bean.setParam('code', this.currentSession.code);
bean.setParam('user', this.userId);
bean.setParam('key', this.sessionKey);
bean.setParam('context', this.sessionContext);
this.transaction(bean.$bean)
.done((data, _textStatus, _jqXHR) => {
this.currentSessionId = $(data).find('param[name="session"]').attr('value');
resolve(this.currentSessionId);
})
.fail((jqXHR, textStatus, errorThrown) => {
this.stopReporting();
reject(`Error creating new reports session in ${this.serviceUrl} [${textStatus} ${errorThrown}]`);
});
});
} else
reject('Unable to start session in remote server!');
});
}
/**
* Closes this reporting system
* @override
* @returns {external:Promise} - A promise to be fullfilled when all pending tasks are finished, or _null_ if not active.
*/
end() {
this.reportActivity(true);
return this.stopReporting().then(super.end());
}
/**
* Performs a transaction on the remote server
* @param {external:jQuery} $xml - The XML element to be transmited, wrapped into a jQuery object
* @returns {external:jqXHR} - The {@link external:jqXHR} obtained as a result of a call to `$.ajax`.
* This object should be treated as a Promise or
* as a JQuery {@link https://api.jquery.com/category/deferred-object|Deferred} object.
*/
transaction($xml) {
return this.serviceUrl === null ?
null :
$.ajax({
method: 'POST',
url: this.serviceUrl,
data: '<?xml version="1.0" encoding="UTF-8"?>' +
(new XMLSerializer()).serializeToString($xml.get(0)).replace('minactions', 'minActions').replace('reportactions', 'reportActions'),
contentType: 'text/xml',
dataType: 'xml'
});
}
/**
* Gets the list of current groups or organizations registered on this reporting system.
* @override
* @returns {external:Promise} - When fulfilled, an array of group data is returned as a result
*/
getGroups() {
return new Promise((resolve, reject) => {
if (!this.userBased())
reject('This system does not manage users!');
else {
const bean = new ReportBean('get groups');
this.transaction(bean.$bean)
.done((data, _textStatus, _jqXHR) => {
const currentGroups = [];
$(data).find('group').each((_n, gr) => {
const $group = $(gr);
currentGroups.push({ id: $group.attr('id'), name: $group.attr('name') });
});
resolve(currentGroups);
})
.fail((jqXHR, textStatus, errorThrown) => {
reject(`Error retrieving groups list from ${this.serviceUrl} [${textStatus} ${errorThrown}]`);
});
}
});
}
/**
* Gets the list of users currently registered in the system, optionally filtered by
* a specific group ID.
* @override
* @param {string}+ groupId - Optional group ID to be used as a filter criteria
* @returns {external:Promise} - When fulfilled, an object with a collection of user data records
* is returned
*/
getUsers(groupId) {
return new Promise((resolve, reject) => {
if (!this.userBased())
reject('This system does not manage users!');
else {
const bean = new ReportBean('get users');
if (typeof groupId !== 'undefined' && groupId !== null)
bean.setParam('group', groupId);
this.transaction(bean.$bean)
.done((data, _textStatus, _jqXHR) => {
const currentUsers = [];
$(data).find('user').each((_n, usr) => {
const $user = $(usr);
const user = { id: $user.attr('id'), name: $user.attr('name') };
if ($user.attr('pwd'))
user.pwd = $user.attr('pwd');
currentUsers.push(user);
});
resolve(currentUsers);
})
.fail((jqXHR, textStatus, errorThrown) => {
reject(`Error retrieving users list from ${this.serviceUrl} [${textStatus} ${errorThrown}]`);
});
}
});
}
/**
* Gets extended data associated with a specific user.
* @param {string} userId - The requested user ID
* @returns {external:Promise} - When fulfilled, an object with user data is returned.
*/
getUserData(userId) {
return new Promise((resolve, reject) => {
if (!this.userBased())
reject('This system does not manage users!');
else {
const bean = new ReportBean('get user data');
if (typeof userId !== 'undefined' && userId !== null)
bean.setParam('user', userId);
else
reject('Invalid user ID');
this.transaction(bean.$bean)
.done((data, _textStatus, _jqXHR) => {
const $user = $(data).find('user');
if ($user.length !== 1) {
window.alert(getMsg('Invalid user'));
resolve('Invalid user ID');
} else {
const user = { id: $user.attr('id'), name: $user.attr('name') };
if ($user.attr('pwd'))
user.pwd = $user.attr('pwd');
resolve(user);
}
})
.fail((jqXHR, textStatus, errorThrown) => {
reject(`Error retrieving user data from ${this.serviceUrl} [${textStatus} ${errorThrown}]`);
});
}
});
}
/**
* Stops the reporting system, usually as a result of repeated errors or because the player
* shuts down.
* @returns {external:Promise} - A promise to be fullfilled when all pending tasks are finished.
*/
stopReporting() {
let result = null;
if (this.timer >= 0) {
window.clearInterval(this.timer);
this.timer = -1;
}
if (this.beforeUnloadFunction) {
window.removeEventListener('beforeunload', this.beforeUnloadFunction);
this.beforeUnloadFunction = null;
}
if (this.initiated) {
result = this.flushTasksPromise().then(() => {
this.serviceUrl = null;
this.descriptionDetail = `${this.serverPath} (${getMsg('not connected')})`;
this.initiated = false;
});
}
return result || Promise.resolve(true);
}
/**
* Prepares a {@link module:report/TCPReporter.ReportBean ReportBean} object with information related to the current
* activity, and pushes it into the list of pending `tasks`, to be processed by the main `timer`.
* @param {boolean} flushNow - When `true`, the activity data will be sent to server as soon as possible
*/
reportActivity(flushNow) {
if (this.lastActivity) {
if (!this.lastActivity.closed)
this.lastActivity.closeActivity();
const
actCount = this.actCount++,
act = this.lastActivity;
this.createDBSession(false).then(() => {
const bean = new ReportBean('add activity');
bean.setParam('session', this.currentSessionId);
bean.setParam('num', actCount);
bean.appendData(act.$getXML());
this.addTask(bean);
if (flushNow)
this.flushTasksPromise().then();
});
}
if (this.currentSession !== null &&
this.currentSession.currentSequence !== null &&
this.currentSession.currentSequence.currentActivity !== this.lastActivity) {
this.lastActivity = this.currentSession.currentSequence.currentActivity;
} else
this.lastActivity = null;
}
/**
* This method should be invoked when the user starts a new activity
* @override
* @param {module:Activity.Activity} act - The {@link module:Activity.Activity Activity} reporter has just started
*/
newActivity(act) {
super.newActivity(act);
this.reportActivity(false);
}
/**
* This method should be called when the current activity finishes. Data about user's final results
* on the activity will then be saved.
* @override
* @param {number} score - The final score, usually in a 0-100 scale.
* @param {number} numActions - The total number of actions done by the user to solve the activity
* @param {boolean} solved - `true` if the activity was finally solved, `false` otherwise.
*/
endActivity(score, numActions, solved) {
super.endActivity(score, numActions, solved);
this.reportActivity(true);
}
}
Object.assign(TCPReporter.prototype, {
/**
* Description of this reporting system
* @name module:report/TCPReporter.TCPReporter#descriptionKey
* @override
* @type {string} */
descriptionKey: 'Reporting to remote server',
/**
* Additional info to display after the reporter's `description`
* @name module:report/TCPReporter.TCPReporter#descriptionDetail
* @override
* @type {string} */
descriptionDetail: '(not connected)',
/**
* Main path of the reports server (without protocol nor service)
* @name module:report/TCPReporter.TCPReporter#serverPath
* @type {string} */
serverPath: '',
/**
* Function to be called by the browser before leaving the current page
* @name module:report/TCPReporter.TCPReporter#beforeUnloadFunction
* @type {function} */
beforeUnloadFunction: null,
/**
* Identifier of the current session, provided by the server
* @name module:report/TCPReporter.TCPReporter#currentSessionId
* @type {string} */
currentSessionId: '',
/**
* Last activity reported
* @name module:report/TCPReporter.TCPReporter#lastActivity
* @type {module:report/ActivityReg.ActivityReg} */
lastActivity: null,
/**
* Number of activities processed
* @name module:report/TCPReporter.TCPReporter#actCount
* @type {number} */
actCount: 0,
/**
* Service URL of the JClic Reports server
* @name module:report/TCPReporter.TCPReporter#serviceUrl
* @type {string} */
serviceUrl: null,
/**
* Object used to store specific properties of the connected reports system
* @name module:report/TCPReporter.TCPReporter#dbProperties
* @type {object} */
dbProperties: null,
/**
* List of {@link module:report/TCPReporter.ReportBean ReportBean} objects pending to be processed
* @name module:report/TCPReporter.TCPReporter#tasks
* @type {module:report/TCPReporter.ReportBean[]} */
tasks: null,
/**
* Waiting list of tasks, to be used while `tasks` is being processed
* @name module:report/TCPReporter.TCPReporter#waitingTasks
* @type {module:report/TCPReporter.ReportBean[]} */
waitingTasks: null,
/**
* Flag used to indicate if `transaction` is currently running
* @name module:report/TCPReporter.TCPReporter#processingTasks
* @type {boolean} */
processingTasks: false,
/**
* Force processing of pending tasks as soon as possible
* @name module:report/TCPReporter.TCPReporter#forceFlush
* @type {boolean} */
forceFlush: false,
/**
* Identifier of the background function obtained with a call to `window.setInterval`
* @name module:report/TCPReporter.TCPReporter#timer
* @type {number} */
timer: -1,
/**
* Time between calls to the background function, in seconds
* @name module:report/TCPReporter.TCPReporter#timerLap
* @type {number} */
timerLap: 5,
/**
* Counter of unsuccessful connection attempts with the report server
* @name module:report/TCPReporter.TCPReporter#failCount
* @type {number} */
failCount: 0,
/**
* Maximum number of failed attempts allowed before disconnecting
* @name module:report/TCPReporter.TCPReporter#maxFails
* @type {number} */
maxFails: 5,
/**
* Default path of JClic Reports Server
* @name module:report/TCPReporter.TCPReporter#DEFAULT_SERVER_PATH
* @type {string} */
DEFAULT_SERVER_PATH: 'localhost:9000',
/**
* Default name for the reports service
* @name module:report/TCPReporter.TCPReporter#DEFAULT_SERVER_SERVICE
* @type {string} */
DEFAULT_SERVER_SERVICE: '/JClicReportService',
/**
* Default server protocol
* Use always 'https' except when in 'http' and protocol not set in options
* @name module:report/TCPReporter.TCPReporter#DEFAULT_SERVER_PROTOCOL
* @type {string} */
DEFAULT_SERVER_PROTOCOL: (document && document.location && document.location.protocol === 'http:') ? 'http' : 'https',
/**
* Default lap between calls to `flushTasks`, in seconds
* @name module:report/TCPReporter.TCPReporter#DEFAULT_TIMER_LAP
* @type {number} */
DEFAULT_TIMER_LAP: 20,
});
/**
* This inner class encapsulates a chunk of information in XML format, ready to be
* transmitted to the remote reports server.
*/
export class ReportBean {
/**
* ReportBean constructor
* @param id {string} - The main identifier of this ReportBean. Current valid values are:
* `get property`, `get_properties`, `add session`, `add activity`, `get groups`, `get users`,
* `get user data`, `get group data`, `new group`, `new user` and `multiple`.
* @param $data {external:jQuery}+ - Optional XML data to be added to this bean
*/
constructor(id, $data) {
this.$bean = $('<bean/>').attr({ id: id });
if ($data)
this.appendData($data);
}
/**
* Adds an XML element to the bean
* @param {external:jQuery} $data - The XML element to be added to this bean
*/
appendData($data) {
if ($data) {
this.$bean.append($data);
}
}
/**
* Adds an XML element of type `param` to this ReportBean
* @param {string} name - The key name of the parameter
* @param {string|number|boolean} value - The value of the parameter
*/
setParam(name, value) {
if (typeof value !== 'undefined' && value !== null)
this.appendData($('<param/>').attr({ name: name, value: value }));
}
}
Object.assign(ReportBean.prototype, {
/**
* The main jQuery XML object managed by this ReportBean
* @name module:report/TCPReporter.ReportBean#$bean
* @type {external:jQuery} */
$bean: null,
});
TCPReporter.ReportBean = ReportBean;
// Register class in Reporter.CLASSES
export default Reporter.registerClass('TCPReporter', TCPReporter);