diff --git a/package.json b/package.json index 22526c9..7763518 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psychojs", - "version": "2023.2.3", + "version": "2024.4.1", "private": true, "description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments", "license": "MIT", diff --git a/src/core/GUI.js b/src/core/GUI.js index e3cc571..8a0241f 100644 --- a/src/core/GUI.js +++ b/src/core/GUI.js @@ -80,14 +80,17 @@ export class GUI * @param {Object} options.dictionary - associative array of values for the participant to set * @param {String} options.title - name of the project * @param {boolean} [options.requireParticipantClick=true] - whether the participant must click on the OK - * button, when it becomes enabled, to move on with the experiment + * button, when it becomes enabled, to move on with the experiment + * @param {boolean} [options.OKAlwaysEnabledForLocal=false] - whether the OK button is always enabled + * when the experiment runs locally */ DlgFromDict({ logoUrl, text, dictionary, title, - requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick + requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick, + OKAlwaysEnabledForLocal = true }) { this._progressBarMax = 0; @@ -96,6 +99,7 @@ export class GUI this._setRequiredKeys = new Map(); this._progressMessage = " "; this._requireParticipantClick = requireParticipantClick; + this._OKAlwaysEnabledForLocal = OKAlwaysEnabledForLocal; this._dictionary = dictionary; // prepare a PsychoJS component: @@ -276,7 +280,7 @@ export class GUI self._updateProgressBar(); // setup change event handlers for all required keys: - this._requiredKeys.forEach((keyId) => + self._requiredKeys.forEach((keyId) => { const input = document.getElementById(keyId); if (input) @@ -685,7 +689,10 @@ export class GUI if (typeof this._okButton !== "undefined") { // locally the OK button is always enabled, otherwise only if all requirements have been fulfilled: - if (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL || allRequirementsFulfilled) + if ( + (this._OKAlwaysEnabledForLocal && this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL) + || allRequirementsFulfilled + ) { this._okButton.classList.add("dialog-button"); this._okButton.classList.remove("disabled"); diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js index c8ca9d1..e14b715 100644 --- a/src/core/PsychoJS.js +++ b/src/core/PsychoJS.js @@ -108,9 +108,9 @@ export class PsychoJS } /** - * @param {Object} options - * @param {boolean} [options.debug= true] whether to log debug information in the browser console - * @param {boolean} [options.collectIP= false] whether to collect the IP information of the participant + * @param {Object} options - options + * @param {boolean} [options.debug= true] - whether to log debug information in the browser console + * @param {boolean} [options.collectIP= false] - whether to collect the IP information of the participant */ constructor({ debug = true, @@ -186,7 +186,7 @@ export class PsychoJS this._saveResults = saveResults; this.logger.info("[PsychoJS] Initialised."); - this.logger.info("[PsychoJS] @version 2022.3.0"); + this.logger.info("[PsychoJS] @version 2024.1.0"); // hide the initialisation message: const root = document.getElementById("root"); @@ -399,9 +399,18 @@ export class PsychoJS { if (self._config.session.status === "OPEN") { + // stop the regular uploading of results, if need be: + if (self._config.experiment.resultsUpload.intervalId > 0) + { + clearInterval(self._config.experiment.resultsUpload.intervalId); + self._config.experiment.resultsUpload.intervalId = -1; + } + // save the incomplete results if need be: if (self._config.experiment.saveIncompleteResults && self._saveResults) { + // note: we set lastUploadTimestamp to undefined to prevent uploadData from throttling this call + delete self._config.experiment.resultsUpload.lastUploadTimestamp; self._experiment.save({ sync: true }); } @@ -414,6 +423,20 @@ export class PsychoJS self._window.close(); } }); + + // upload the data at regular interval, if need be: + if (self._saveResults && self._config.experiment.resultsUpload.period > 0) + { + self._config.experiment.resultsUpload.intervalId = setInterval(() => + { + self._experiment.save({ + tag: "", + clear: true + }); + }, + self._config.experiment.resultsUpload.period * 60 * 1000 + ); + } } // start the asynchronous download of resources: @@ -423,7 +446,7 @@ export class PsychoJS if (this._checkWebGLSupport && !Window.checkWebGLSupport()) { // add an entry to experiment results to warn the designer about a potential WebGL issue: - this._experiment.addData('hardware_acceleration', 'NOT SUPPORTED'); + this._experiment.addData("hardware_acceleration", "NOT SUPPORTED"); this._experiment.nextEntry(); this._gui.dialog({ @@ -519,9 +542,10 @@ export class PsychoJS *
Note: if the resource manager is busy, we inform the participant * that he or she needs to wait for a bit.
* - * @param {Object} options + * @param {Object} options - options * @param {string} [options.message] - optional message to be displayed in a dialog box before quitting * @param {boolean} [options.isCompleted = false] - whether the participant has completed the experiment + * @return {void} */ async quit({ message, isCompleted = false, closeWindow = true, showOK = true } = {}) { @@ -545,6 +569,14 @@ export class PsychoJS window.removeEventListener("beforeunload", this.beforeunloadCallback); } + // stop the regular uploading of results, if need be: + if (this._config.experiment.resultsUpload.intervalId > 0) + { + clearInterval(this._config.experiment.resultsUpload.intervalId); + this._config.experiment.resultsUpload.intervalId = -1; + } + delete this._config.experiment.resultsUpload.lastUploadTimestamp; + // save the results and the logs of the experiment: this.gui.finishDialog({ text: "Terminating the experiment. Please wait a few moments...", @@ -629,6 +661,7 @@ export class PsychoJS * @protected * @param {string} configURL - the URL of the configuration file * @param {string} name - the name of the experiment + * @return {void} */ async _configure(configURL, name) { @@ -701,10 +734,15 @@ export class PsychoJS name, saveFormat: ExperimentHandler.SaveFormat.CSV, saveIncompleteResults: true, - keys: [], + keys: [] }, }; } + // init the partial results upload options + this._config.experiment.resultsUpload = { + period: -1, + intervalId: -1 + }; // get the server parameters (those starting with a double underscore): this._serverMsg = new Map(); @@ -737,6 +775,7 @@ export class PsychoJS * *Note: we use [http://www.geoplugin.net/json.gp]{@link http://www.geoplugin.net/json.gp}.
* @protected + * @return {void} */ async _getParticipantIPInfo() { diff --git a/src/core/ServerManager.js b/src/core/ServerManager.js index d522f6f..d01830e 100644 --- a/src/core/ServerManager.js +++ b/src/core/ServerManager.js @@ -58,6 +58,13 @@ export class ServerManager extends PsychObject this._nbLoadedResources = 0; this._setupPreloadQueue(); + // throttling period for calls to uploadData and uploadLog (in mn): + // note: (a) the period is potentially updated when a session is opened to reflect that associated with + // the experiment on the back-end database + // (b) throttling is also enforced on the back-end: artificially altering the period + // on the participant's browser will result in server errors + this._uploadThrottlePeriod = 5; + this._addAttribute("autoLog", autoLog); this._addAttribute("status", ServerManager.Status.READY); } @@ -194,6 +201,21 @@ export class ServerManager extends PsychObject self._psychoJS.config.experiment.keys = []; } + // partial results upload options: + if ("partialResultsUploadPeriod" in experiment) + { + // note: resultsUpload is initialised in PsychoJS._configure but we reinitialise it here + // all the same (belt and braces approach) + self._psychoJS.config.experiment.resultsUpload = { + period: experiment.partialResultsUploadPeriod, + intervalId: -1 + }; + } + if ("uploadThrottlePeriod" in experiment) + { + this._uploadThrottlePeriod = experiment.uploadThrottlePeriod; + } + self.setStatus(ServerManager.Status.READY); resolve({...response, token: openSessionResponse.token, status: openSessionResponse.status }); } @@ -783,6 +805,16 @@ export class ServerManager extends PsychObject }; this._psychoJS.logger.debug("uploading data for experiment: " + this._psychoJS.config.experiment.fullpath); + // data upload throttling: + const now = MonotonicClock.getReferenceTime(); + if ( (typeof this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp !== "undefined") && + (now - this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp < this._uploadThrottlePeriod * 60) + ) + { + return Promise.reject({ ...response, error: "upload canceled by throttling"}); + } + this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp = now; + this.setStatus(ServerManager.Status.BUSY); const path = `experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}/results`; diff --git a/src/data/ExperimentHandler.js b/src/data/ExperimentHandler.js index 97692b5..16f8ac1 100644 --- a/src/data/ExperimentHandler.js +++ b/src/data/ExperimentHandler.js @@ -266,7 +266,7 @@ export class ExperimentHandler extends PsychObject } } } - for (let a in this.extraInfo) + for (const a in this.extraInfo) { if (this.extraInfo.hasOwnProperty(a)) { @@ -303,7 +303,7 @@ export class ExperimentHandler extends PsychObject && !this._psychoJS._serverMsg.has("__pilotToken") ) { - return /*await*/ this._psychoJS.serverManager.uploadData(key, csv, sync); + return this._psychoJS.serverManager.uploadData(key, csv, sync); } else { @@ -320,7 +320,7 @@ export class ExperimentHandler extends PsychObject for (let r = 0; r < data.length; r++) { - let doc = { + const doc = { __projectId, __experimentName: this._experimentName, __participant: this._participant, diff --git a/src/util/PsychObject.js b/src/util/PsychObject.js index 9a29274..b33bba4 100644 --- a/src/util/PsychObject.js +++ b/src/util/PsychObject.js @@ -21,7 +21,7 @@ export class PsychObject extends EventEmitter { /** * @param {module:core.PsychoJS} psychoJS - the PsychoJS instance - * @param {string} name - the name of the object (mostly useful for debugging) + * @param {string} [name] - the name of the object (mostly useful for debugging) */ constructor(psychoJS, name) {