const fs = require('fs-extra');
const path = require('path');
const Utils = require('../Utils.js');
/**
* Messages that the server listens for from clients.
* Includes try-catch and notification (via console) of receipt.
* Corresponds to client/internal/clients/admin/shared/msgsToServer.js
*/
class Msgs {
/*
* Returns a new instance of this class.
*
* @param {type} jt description
* @return {type} description
*/
constructor(jt) {
this.jt = jt;
}
/*
* addParticipants - Adds the specified number of participants to the session.
*
* @param {type} d an object with the following fields,
* @param {string} d.sId the id of the sessions
* @param {number} d.number the number of participants to add.
* @param {Socket} socket the client socket who sent the message.
*/
addParticipants(d, socket) {
var session = this.jt.data.getSession(d.sId);
var num = parseInt(d.number);
session.addParticipants(num);
}
appSaveFileContents(d, socket) {
var app = this.jt.data.app(d.aId, d.options);
app.setFileContents(d.content);
app = app.reload();
this.jt.data.appsMetaData[d.aId] = app.metaData();
}
getClients(data, socket) {
let clients = this.jt.data.getClients(data.sessionId);
var message = {cb: data.cb, clients: clients};
socket.emit('dataMessage', message);
}
/**
* getAppMetadatas - Get an array of the app metadatas found on the server.
*
* @param {String} cb The callback to execute when returning the metadatas to the client.
* @param {Socket} socket The socket which asked for the data.
* @return {Message} A message object, which includes callback (cb) and data fields.
*/
getAppMetadatas(cb, socket) {
var apps = this.jt.data.getApps();
var metadatas = [];
for (var i in apps) {
metadatas.push(apps[i].metaData());
}
var message = {cb: cb, metadatas: metadatas};
this.jt.io.to('socket_' + socket.id).emit('dataMessage', message);
}
/**
* getApp - Return the app with identifier data.appId.
*
* @param {String} data.appId Identifier of the app.
* @param {Socket} socket The socket which asked for the app.
* @return {Message} A message object, which includes callback (cb) and data fields.
*/
getApp(data, socket) {
data.app = this.jt.data.getApp(data.appPath).shellWithChildren();
this.jt.io.to('socket_' + socket.id).emit('dataMessage', data);
}
setAppContents(data, socket) {
fs.writeFileSync(path.join(this.jt.path, data.appPath), data.content);
var outMessage = {};
outMessage.appPath = data.appPath;
outMessage.cb = data.cb;
outMessage.app = this.jt.data.getApp(data.appPath).shellWithChildren();
this.jt.io.to('socket_' + socket.id).emit('dataMessage', outMessage);
}
setSessionId(data, socket) {
var session = this.jt.data.getSession(data.oldId);
session.setId(data.newId);
this.jt.io.to('socket_' + socket.id).emit('setSessionId', data);
}
renameApp(data, socket) {
fs.renameSync(data.originalId, data.newId);
this.jt.data.appsMetaData[data.newId] = this.jt.data.appsMetaData[data.originalId];
delete this.jt.data.appsMetaData[data.originalId];
this.jt.data.appsMetaData[data.newId].appPath = data.newId;
this.jt.data.appsMetaData[data.newId].id = data.newId;
let sep = '\\';
if (data.newId.includes('/')) {
sep = '/';
}
let lastFolderChar = data.newId.lastIndexOf(sep);
this.jt.data.appsMetaData[data.newId].shortId = data.newId.substring(lastFolderChar+1);
}
/*
* setNumParticipants - Sets the specified number of participants to the session.
*
* @param {type} d an object with the following fields,
* @param {string} d.sId the id of the sessions
* @param {number} d.number the number of participants for the session.
* @param {Socket} socket the client socket who sent the message.
*/
setNumParticipants(d, socket) {
var session = this.jt.data.getSession(d.sId);
var num = parseInt(d.number);
session.setNumParticipants(num);
}
// setSessionOption(d, socket) {
// let session = this.jt.data.getSession(d.sessionId);
// }
resetSession(d, socket) {
let session = this.jt.data.getSession(d.sId);
session.reset();
this.jt.io.to('socket_' + socket.id).emit('openSession', session.shellWithChildren());
this.jt.data.lastOpenedSession = session;
}
appAddStage(d, socket) {
var session = this.jt.data.getApp(d.aId);
}
deleteParticipant(d, socket) {
var session = this.jt.data.getSession(d.sId);
session.deleteParticipant(d.pId);
}
saveOutput(sId, socket) {
var session = this.jt.data.getSession(sId);
session.saveOutput();
}
setDefaultAdminPwd(d, sock) {
this.jt.settings.setDefaultAdminPwd(d.curPwd, d.newPwd);
}
setUsersMode(d, sock) {
this.jt.settings.setMultipleUsers(d);
}
createSessionAndAddApp(msgData, sock) {
var appPath = msgData.appId;
var options = msgData.options;
var session = this.jt.data.createSession(msgData.userId);
session.resume();
this.jt.data.sessions.push(session);
var d = {sId: session.id, appPath: appPath, options: options};
this.sessionAddApp(d);
this.openSession(session.id, sock);
}
/**
* Creates and starts a session from an oTree project's session config (the oTree admin's
* "Create session"): {configId (a queue id, settings.py#name), numParticipants, config
* (values to change in session.config), userId}. Opens it for the admin who asked.
*/
otreeCreateSession(d, sock) {
var session = this.jt.data.createSession(d.userId);
session.resume();
this.jt.data.sessions.push(session);
session.addApp(d.configId);
if (session.otreeConfig != null && d.config != null) {
Object.assign(session.otreeConfig, d.config);
if (d.config.participation_fee != null) session.showUpFee = Number(d.config.participation_fee);
if (d.config.real_world_currency_per_point != null) session.exchangeRate = Number(d.config.real_world_currency_per_point);
}
var n = parseInt(d.numParticipants);
session.setNumParticipants(n > 0 ? n : (session.suggestedNumParticipants || 1));
session.save();
session.start();
this.jt.socketServer.emitToAdmins('addSession', session.shell());
if (sock != null) {
this.openSession(session.id, sock);
}
return session;
}
/**
* Sends the admin who asked where each participant of a session is (the oTree admin's
* monitor): {sessionId, rows: [{code, label, app, round, page, status, secondsOnPage, payoff, payment}]}.
*/
otreeMonitor(sessionId, sock) {
var session = this.jt.data.session(sessionId);
if (session == null || sock == null) {
return;
}
var rows = Object.values(session.participants).map((p) => {
var player = p.player;
var row = { code: p.id, label: p.label || '', app: '', round: null, page: '', status: 'not started',
secondsOnPage: null, payoff: p.points(), payment: p.payment() };
if (p.isFinishedSession() && p.appIndex > 0) {
row.status = 'finished';
} else if (player != null) {
row.app = player.app().shortId;
row.round = player.group.period.id;
row.page = player.stage != null ? player.stage.id : '';
row.status = player.status;
var start = player.stage != null ? player['timeStart_' + player.stage.id] : null;
if (start != null) {
row.secondsOnPage = Math.round((Date.now() - Utils.dateFromStr(start).getTime()) / 1000);
}
}
return row;
});
this.jt.io.to('socket_' + sock.id).emit('otreeMonitor', { sessionId: sessionId, rows: rows });
}
/**
* Sends the admin who asked the admin reports of a session's oTree apps (their
* admin_report.html): {sessionId, reports: [{app, round, html}]}, for the rounds started.
*/
otreeReports(sessionId, sock) {
var session = this.jt.data.session(sessionId);
if (session == null || sock == null) {
return;
}
var reports = [];
var bridge = require('../dialects/otree/runtime.js');
var otreeStatic = require('../dialects/otree/static.js');
for (var app of session.apps) {
if (app.otree == null || !bridge.getBridge().has_admin_report(app.otree.pkg)) {
continue;
}
app.periods.forEach((period) => {
if (period == null) return;
var html;
try {
html = bridge.getBridge().admin_report(app.otree.pkg, period, otreeStatic.urlFor(this.jt.basePath, app.otree.pkg));
} catch (err) {
html = '<div class="alert alert-danger">The report could not be shown: ' + Utils.escapeHTML(String(err.message).trim().split('\n').pop()) + '</div>';
}
reports.push({ app: app.shortId, round: period.id, html: html });
});
}
this.jt.io.to('socket_' + sock.id).emit('otreeReports', { sessionId: sessionId, reports: reports });
}
/**
* Converts an oTree app of the catalogue to a jtree app (dialects/otree/convert.js), in a
* folder beside it (<app>-jtree, or -jtree-2, ...), which joins the catalogue; tells the
* admin who asked: otreeConverted {appPath, outPath, level, report} or {appPath, error}.
* Returns a promise of that.
*/
otreeConvertApp(d, sock) {
var appPath = d != null ? d.appPath : null;
var app = appPath != null ? this.jt.data.apps[appPath] : null;
var reply = (result) => {
if (sock != null) this.jt.io.to('socket_' + sock.id).emit('otreeConverted', result);
return result;
};
if (app == null || app.otree == null) {
return Promise.resolve(reply({ appPath: appPath, error: 'not an oTree app of the catalogue' }));
}
var dir = path.dirname(appPath);
var outPath = dir + '-jtree';
for (var n = 2; fs.existsSync(outPath); n++) {
outPath = dir + '-jtree-' + n;
}
var { convertApp } = require('../dialects/otree/convert.js');
return convertApp(dir, outPath).then((result) => {
this.jt.data.reloadApps();
this.jt.socketServer.refreshAdmins();
return reply({ appPath: appPath, outPath: outPath, level: result.level, report: result.report });
}, (err) => {
fs.removeSync(outPath);
return reply({ appPath: appPath, error: String(err.message || err) });
});
}
/**
* Converts a z-Tree treatment of the catalogue to a jtree app (dialects/ztree/convert.js), in a
* folder beside it (<name>-jtree, or -jtree-2, ...), which joins the catalogue; tells the admin
* who asked: ztreeConverted {appPath, outPath, level, report} or {appPath, error}.
*/
ztreeConvertApp(d, sock) {
var appPath = d != null ? d.appPath : null;
var app = appPath != null ? this.jt.data.apps[appPath] : null;
var reply = (result) => {
if (sock != null) this.jt.io.to('socket_' + sock.id).emit('ztreeConverted', result);
return result;
};
if (app == null || app.ztree == null || !/\.ztt$/i.test(appPath)) {
return Promise.resolve(reply({ appPath: appPath, error: 'not a z-Tree treatment (.ztt) of the catalogue' }));
}
var base = appPath.replace(/\.ztt$/i, '');
var outPath = base + '-jtree';
for (var n = 2; fs.existsSync(outPath); n++) {
outPath = base + '-jtree-' + n;
}
var { convertTreatmentFile } = require('../dialects/ztree/convert.js');
return convertTreatmentFile(appPath, outPath).then((result) => {
this.jt.data.loadAppDir(outPath);
this.jt.socketServer.refreshAdmins();
return reply({ appPath: appPath, outPath: outPath, level: result.level, report: result.report });
}, (err) => {
fs.removeSync(outPath);
return reply({ appPath: appPath, error: String(err.message || err) });
});
}
/**
* Sends the admin who asked the z-Tree tables of a session's z-Tree treatments:
* ztreeTables {sessionId, apps: {<index in the session, from 1>: {periods: [{globals,
* subjects, summary, contracts}], session, current (period index)}}}.
*/
ztreeTables(sessionId, sock) {
var session = this.jt.data.session(sessionId);
if (session == null || sock == null) {
return;
}
var apps = {};
session.apps.forEach((app, i) => {
var run = app.ztreeRun;
if (run == null) return;
var plain = (records) => records.map((r) => {
var out = {};
for (var k in r) {
if (k === '_id') continue;
var v = r[k];
out[k] = v != null && typeof v === 'object' ? (v.zArray ? v.values.join(' ') : null) : v;
}
return out;
});
apps[i + 1] = {
periods: run.periods.map((p) => p == null ? null : {
globals: plain([p.globals]), subjects: plain(p.subjects), summary: plain([p.summary]), contracts: plain(p.contracts),
}),
session: plain(run.sessionRecords),
current: run.periods.length - 1,
};
});
this.jt.io.to('socket_' + sock.id).emit('ztreeTables', { sessionId: sessionId, apps: apps });
}
createApp(appId, sock) {
var app = this.jt.data.createApp(appId);
if (app !== null) {
this.jt.socketServer.emitToAdmins('createApp', app);
}
return app;
}
createAppFromFile(data, sock) {
var app = this.jt.data.createApp(data.fn);
app.setContents(data.contents);
if (app !== null) {
this.jt.socketServer.emitToAdmins('createApp', app);
}
return app;
}
updateAppPreview(d, socket) {
var app = this.jt.data.app(d.appId, d.options);
this.jt.io.to('socket_' + socket.id).emit('updateAppPreview', app.shellWithChildren());
}
/** Older name for {@link Msgs#createSessionAndAddApp}, from when queues were not apps. */
startSessionFromQueue(data, sock) {
this.createSessionAndAddApp({appId: data.qId, options: data.options, userId: data.userId}, sock);
}
createRoom(id, sock) {
var room = this.jt.data.createRoom(id);
if (room !== null) {
this.jt.socketServer.emitToAdmins('createRoom', room.shell());
}
return room;
}
createUser(data, sock) {
var user = this.jt.data.createUser(data.id, data.type);
if (user !== null) {
this.jt.socketServer.emitToAdmins('createUser', user.shell());
}
return user;
}
createQueue(id, sock) {
var queue = this.jt.data.createQueue(id);
if (queue !== null) {
this.jt.socketServer.emitToAdmins('createQueue', queue.shell());
}
return queue;
}
saveRoom(room, sock) {
this.jt.data.saveRoom(room);
// this.jt.socketServer.emitToAdmins('saveRoom', room);a
}
deleteQueue(id, sock) {
this.jt.data.deleteApp(id);
this.jt.socketServer.emitToAdmins('deleteQueue', id);
this.jt.socketServer.emitToAdmins('deleteApp', id);
}
deleteApp(id, sock) {
this.jt.data.deleteApp(id);
this.jt.socketServer.emitToAdmins('deleteApp', id);
}
saveAppHTML(app, sock) {
this.jt.data.saveApp(app);
var appInfo = this.jt.data.apps[app.id];
appInfo.origId = app.origId;
this.jt.socketServer.emitToAdmins('appSaved', appInfo);
}
deleteSession(d) {
var mypath = path.join(this.jt.path, this.jt.settings.sessionsFolder, d);
for (var i in this.jt.data.sessions) {
var session = this.jt.data.sessions[i];
if (d === session.id) {
if (session.autoSaveTimer !== null) {
clearInterval(session.autoSaveTimer);
}
if (session.fileStream !== null) {
session.fileStream.end();
}
this.jt.data.sessions.splice(i, 1);
break;
}
}
// TODO: Does not delete on Windows.
// Workaround: delete empty folders on Data.loadSessions.
try {
if (fs.existsSync(mypath)) {
fs.removeSync(mypath);
}
} catch (err) {}
this.jt.socketServer.emitToAdmins('deleteSession', d);
}
messages(data, sock) {
for (var i=0; i<data.length; i++) {
var msgName = data[i].msgName;
var msgData = data[i].msgData;
this.jt.log('received message ' + msgName + ': ' + JSON.stringify(msgData));
if (!Msgs.names().includes(msgName)) {
this.jt.log('ignoring unknown message ' + msgName);
continue;
}
this[msgName](msgData, sock);
}
}
openSession(sId, socket) {
var session = Utils.findByIdWOJQ(this.jt.data.sessions, sId);
if (session !== null && session !== undefined) {
socket.join(session.roomId());
this.jt.io.to('socket_' + socket.id).emit('openSession', session.shellWithChildren());
this.jt.data.lastOpenedSession = session;
// this.reloadClients();
// Called from client-side.
}
}
reloadApps(msg, socket) {
this.jt.data.reloadApps();
if (msg == null) {
msg = {
userId: ''
}
}
this.jt.socketServer.refreshAdmin(null, 'socket_' + socket.id, msg.userId);
}
reloadClients() {
const clients = this.jt.data.participantClients;
for (let i=0; i<clients.length; i++) {
try {
clients[i].reload();
} catch (err) {}
}
}
/**
* sessionAddApp - Add an app to the given session.
*
* @param {Object} d An object containing the session ID (sId), the app id (appPath), and options for the app (options).
* @return {type}
*/
sessionAddApp(data, socket) {
if (data.appPath == null) {
data.appPath = data.appId;
}
this.jt.data.getSession(data.sId).addApp(data.appPath, data.options);
}
sessionAddUser(d) {
this.jt.data.getSession(d.sId).addUser(d.uId);
}
/** Older name for {@link Msgs#sessionAddApp}, from when queues were not apps. */
sessionAddQueue(d) {
this.jt.data.getSession(d.sId).addApp(d.qId);
}
/** Opens a session in a room: {roomId, sessionId} (see Room#openSession). */
roomOpenSession(d) {
var room = this.jt.data.room(d.roomId);
var session = this.jt.data.session(d.sessionId);
if (room != null && session != null) {
room.openSession(session);
}
}
roomAddApp(d) {
this.jt.data.room(d.roomId).addApp(d.appId);
}
queueAddApp(d) {
this.jt.data.queue(d.queueId).addApp(d.appId, d.options);
var queue = this.jt.data.reloadQueue(d.queueId);
this.jt.socketServer.emitToAdmins('queueAddApp', {queueId: queue.id, app: queue.apps[queue.apps.length - 1]});
}
/*
* Advance all participants who have not started the session yet. Calls {@link Session#advanceSlowest}.
*
* @param {string} id The id of the session.
*/
sessionStart(id) {
var session = Utils.findByIdWOJQ(this.jt.data.sessions, id);
if (session !== null) {
session.start();
}
}
/*
* Advances the slowest participants in the given session. Calls {@link Session#advanceSlowest}.
*
* @param {string} id The id of the session.
*/
sessionAdvanceSlowest(id) {
var session = Utils.findByIdWOJQ(this.jt.data.sessions, id);
if (session !== null) {
session.advanceSlowest();
}
session.emitParticipantUpdates();
}
sessionCreate(userId, sock) {
var session = this.jt.data.createSession(userId);
session.resume();
this.jt.data.sessions.push(session);
this.openSession(session.id, sock);
}
sessionDeleteApp(d) {
var session = Utils.findByIdWOJQ(this.jt.data.sessions, d.sId);
if (session !== null) {
session.deleteApp(d);
}
}
setAllowAdminPlay(d, socket) {
let session = Utils.findByIdWOJQ(this.jt.data.sessions, d.sessionId);
if (session != null) {
session.setAllowAdminPlay(d.val);
}
}
setSessionAppOption(d, socket) {
var session = Utils.findByIdWOJQ(this.jt.data.sessions, d.sId);
if (session !== null) {
session.setOption(d.name, d.value);
}
}
setSessionAppOptions(d, socket) {
var session = Utils.findByIdWOJQ(this.jt.data.sessions, d.sId);
if (session !== null) {
for (var i in d.options) {
session.setAppOption(d.appId, d.index, i, d.options[i]);
}
}
}
sessionPause(id, sock) {
var session = Utils.findByIdWOJQ(this.jt.data.sessions, id);
if (session !== null) {
session.pause();
}
}
sessionResume(id, sock) {
var session = Utils.findByIdWOJQ(this.jt.data.sessions, id);
if (session !== null) {
session.resume();
}
}
setAllowNewParts(d, socket) {
var session = this.jt.data.getSession(d.sId);
session.setAllowNewParts(d.value);
}
/** {sId}: plays the session to its end with bots (see Session#playWithBots). */
sessionPlayWithBots(d, socket) {
var session = this.jt.data.getSession(d.sId);
if (session != null) session.playWithBots().catch((err) => console.log(err.stack));
}
/** {sId, mode}: the session's mode ('jtree', 'ztree', 'otree'), or null for the server's. */
setSessionMode(d, socket) {
var session = this.jt.data.getSession(d.sId);
if (session != null) session.setMode(d.mode);
}
setCaseSensitiveLabels(d, socket) {
var session = this.jt.data.getSession(d.sId);
session.setCaseSensitiveLabels(d.value);
}
setAutoplay(data) {
let session = this.jt.data.session(data.sId);
if (session !== null) {
let part = session.participants[data.pId];
if (part !== undefined) {
part.emit('setAutoplay', {val: data.val});
} else {
// console.log('Msgs.setAutoplay: undefined participant ' + data.pId);
}
}
}
setAutoplayForAll(data) {
let session = this.jt.data.session(data.sId);
if (session !== null) {
for (let i in session.participants) {
session.participants[i].emit('setAutoplay', {val: data.val});
}
}
}
setAutoplayDelay(data) {
let session = this.jt.data.session(data.sId);
if (session !== null) {
for (let i in session.participants) {
session.participants[i].emit('setAutoplayDelay', {val: data.val});
}
}
}
/*
* startTreatment - Appends an app to the session and starts it at once for
* the given participants (all of them when none are given), the way z-Tree
* starts a treatment for the selected clients. Participants still playing
* an earlier app are left where they are.
*
* @param {string} d.sId the id of the session.
* @param {string} d.appId the app to start.
* @param {Object} [d.options] options for the app.
* @param {string[]} [d.pIds] the participants to start it for.
*/
startTreatment(d) {
let session = this.jt.data.getSession(d.sId);
let app = session.addApp(d.appId, d.options || {});
if (app == null) {
return;
}
let appIndex = session.apps.length;
let pIds = d.pIds != null && d.pIds.length > 0 ? d.pIds : Object.keys(session.participants);
if (!session.started) {
session.started = true;
session.emit('dataUpdate', [{roomId: session.roomId(), field: 'started', value: true}]);
}
for (let i in pIds) {
let participant = session.participants[pIds[i]];
if (participant == null || participant.player != null) {
continue;
}
participant.appIndex = appIndex;
participant.save();
session.participantBeginApp(participant);
}
session.emitParticipantUpdates();
}
/*
* leaveStage - Moves the given participants out of the stage they are
* playing, as if they had submitted it. See {@link Player#endStage}.
*
* @param {string} d.sId the id of the session.
* @param {string[]} d.pIds the participants.
*/
leaveStage(d) {
let session = this.jt.data.getSession(d.sId);
for (let i in d.pIds) {
let participant = session.participants[d.pIds[i]];
if (participant != null && participant.player != null) {
participant.player.endStage(true);
}
}
session.emitParticipantUpdates();
}
/*
* setStopAfterPeriod - Ends an app of the session once its current period
* is over, instead of going on to the next period.
*
* @param {string} d.sId the id of the session.
* @param {number} d.appIndex the app's (1-based) position in the session.
* @param {boolean} d.value whether to stop.
*/
setStopAfterPeriod(d) {
let session = this.jt.data.getSession(d.sId);
let app = session.apps[d.appIndex - 1];
if (app != null) {
app.stopAfterPeriod = d.value === true;
}
}
}
/** The messages admins may send: the methods above. */
Msgs.names = function() {
return Object.getOwnPropertyNames(Msgs.prototype).filter((name) => {
return name !== 'constructor' && typeof Msgs.prototype[name] === 'function';
});
};
var exports = module.exports = {};
exports.new = Msgs;
exports.names = Msgs.names;