diff --git a/package.json b/package.json
index 22526c9..4a73f7c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "psychojs",
- "version": "2023.2.3",
+ "version": "2024.1.0",
"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..e9a4ea0 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:
@@ -183,13 +187,26 @@ export class GUI
{
atLeastOneIncludedKey = true;
- markup += ``;
-
- // if the field is required:
+ // deal with field options:
+ // - if the field is required:
+ if (key.slice(-4) === "|req")
+ {
+ key = `${key.slice(0, -4)}*`;
+ }
if (key.slice(-1) === "*")
{
self._requiredKeys.push(keyId);
}
+ // - all other new options are currently discarded
+ // TODO
+
+ // remove the new option extensions:
+ if (key.slice(-4) === "|req" || key.slice(-4) === "|cfg" || key.slice(-4) === "|fix" || key.slice(-4) === "|opt")
+ {
+ key = key.slice(0, -4);
+ }
+
+ markup += ``;
// if value is an array, we create a select drop-down menu:
if (Array.isArray(value))
@@ -236,7 +253,6 @@ export class GUI
markup += "
";
// buttons:
- markup += "";
markup += "
";
markup += "";
if (self._requireParticipantClick)
@@ -276,7 +292,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)
@@ -413,13 +429,13 @@ export class GUI
markup += "
";
if (showCancel)
{
markup += "";
@@ -489,11 +505,15 @@ export class GUI
markup += "";
markup += "
";
markup += `
Warning
`;
+
+ markup += "
";
markup += `
${text}
`;
+ markup += "
";
// progress bar:
markup += `
`;
markup += "
";
+ markup += "";
markup += "
";
@@ -590,6 +610,12 @@ export class GUI
const input = document.getElementById("form-input-" + keyIdx);
if (input)
{
+ // deal with field options:
+ if (key.slice(-4) === "|req" || key.slice(-4) === "|cfg" || key.slice(-4) === "|fix" || key.slice(-4) === "|opt")
+ {
+ delete this._dictionary[key];
+ key = key.slice(0, -4);
+ }
this._dictionary[key] = input.value;
}
});
@@ -604,8 +630,11 @@ export class GUI
// clear all events (and keypresses) accumulated until now:
this._psychoJS.eventManager.clearEvents();
- this._dialog.hide();
- this._dialog = null;
+ if (this._dialog)
+ {
+ this._dialog.hide();
+ this._dialog = null;
+ }
this._dialogComponent.status = PsychoJS.Status.FINISHED;
}
@@ -685,7 +714,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");
@@ -703,7 +735,6 @@ export class GUI
return;
}
-
// if all requirements are fulfilled and the participant is not required to click on the OK button,
// then we close the dialog box and move on with the experiment:
if (allRequirementsFulfilled)
diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js
index c8ca9d1..ba4eca2 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,
@@ -144,8 +144,8 @@ export class PsychoJS
});
// add the pavlovia server to the list of hosts:
- const hostsWithPavlovia = new Set([...hosts, "https://pavlovia.org/run/", "https://run.pavlovia.org/"]);
- this._hosts = Array.from(hostsWithPavlovia);
+ const pavloviaHosts = new Set([...hosts, "https://pavlovia.org/run/", "https://run.pavlovia.org/", "https://devlovia.org/run/", "https://run.devlovia.org/"]);
+ this._hosts = Array.from(pavloviaHosts);
// GUI:
this._gui = new GUI(this);
@@ -166,6 +166,9 @@ export class PsychoJS
this._cancellationUrl = undefined;
this._completionUrl = undefined;
+ // survey id, if applicable:
+ this._surveyId = undefined;
+
// status:
this.status = PsychoJS.Status.NOT_CONFIGURED;
@@ -186,7 +189,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");
@@ -377,6 +380,7 @@ export class PsychoJS
if (typeof surveyId !== "undefined")
{
params.surveyId = surveyId;
+ this._surveyId = surveyId;
}
await this._serverManager.openSession(params);
@@ -399,10 +403,21 @@ 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)
{
- self._experiment.save({ sync: true });
+ // note: we set lastUploadTimestamp to undefined to prevent uploadData from throttling this call
+ delete self._config.experiment.resultsUpload.lastUploadTimestamp;
+ self._experiment.save({
+ sync: true
+ });
}
// close the session:
@@ -414,6 +429,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: false
+ });
+ },
+ self._config.experiment.resultsUpload.period * 60 * 1000
+ );
+ }
}
// start the asynchronous download of resources:
@@ -423,7 +452,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 +548,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 +575,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...",
@@ -602,7 +640,7 @@ export class PsychoJS
if (showOK)
{
- let text = "Thank you for your patience.";
+ let text = "Thank you for your patience. ";
text += (typeof message !== "undefined") ? message : "Goodbye!";
this._gui.dialog({
message: text,
@@ -629,6 +667,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 +740,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 +781,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..a08e365 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);
}
@@ -127,7 +134,7 @@ export class ServerManager extends PsychObject
* @property {Object.} [error] an error message if we could not open the session
*/
/**
- * Open a session for this experiment on the remote PsychoJS manager.
+ * Open a session for this experiment on the pavlovia server.
*
* @param {Object} params - the open session parameters
*
@@ -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 });
}
@@ -214,10 +236,10 @@ export class ServerManager extends PsychObject
* previously been opened)
*/
/**
- * Close the session for this experiment on the remote PsychoJS manager.
+ * Close the session for this experiment on the pavlovia server.
*
- * @param {boolean} [isCompleted= false] - whether or not the experiment was completed
- * @param {boolean} [sync= false] - whether or not to communicate with the server in a synchronous manner
+ * @param {boolean} [isCompleted= false] - whether the experiment was completed
+ * @param {boolean} [sync= false] - whether to communicate with the server in a synchronous manner
* @returns {Promise | void} the response
*/
async closeSession(isCompleted = false, sync = false)
@@ -238,6 +260,10 @@ export class ServerManager extends PsychObject
+ "/sessions/" + this._psychoJS.config.session.token + "/delete";
const formData = new FormData();
formData.append("isCompleted", isCompleted);
+ if (typeof this._psychoJS._surveyId !== "undefined")
+ {
+ formData.append("surveyId", this._psychoJS._surveyId);
+ }
navigator.sendBeacon(url, formData);
this._psychoJS.config.session.status = "CLOSED";
@@ -250,10 +276,18 @@ export class ServerManager extends PsychObject
{
try
{
+ const data = {
+ isCompleted
+ };
+ if (typeof this._psychoJS._surveyId !== "undefined")
+ {
+ data["surveyId"] = this._psychoJS._surveyId;
+ }
+
const deleteResponse = await this._queryServerAPI(
"DELETE",
`experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}`,
- { isCompleted },
+ data,
"FORM"
);
@@ -569,18 +603,24 @@ export class ServerManager extends PsychObject
{
// add the SurveyJS and PsychoJS Survey .js and .css resources:
resources[r] = {
- name: "jquery-3.6.0.min.js",
- path: "./lib/vendors/jquery-3.6.0.min.js",
+ name: "jquery-3.5.1.min.js",
+ path: "./lib/vendors/jquery-3.5.1.min.js",
+ // name: "jquery-3.6.0.min.js",
+ // path: "./lib/vendors/jquery-3.6.0.min.js",
download: true
};
resources.push({
- name: "survey.jquery-1.9.50.min.js",
- path: "./lib/vendors/survey.jquery-1.9.50.min.js",
+ name: "surveyjs.jquery-1.9.126.min.js",
+ path: "./lib/vendors/surveyjs.jquery-1.9.126.min.js",
+ // name: "survey.jquery-1.9.50.min.js",
+ // path: "./lib/vendors/survey.jquery-1.9.50.min.js",
download: true
});
resources.push({
- name: "survey.defaultV2-1.9.50.min.css",
- path: "./lib/vendors/survey.defaultV2-1.9.50.min.css",
+ name: "surveyjs.defaultV2-1.9.126-OST.min.css",
+ path: "./lib/vendors/surveyjs.defaultV2-1.9.126-OST.min.css",
+ // name: "survey.defaultV2-1.9.50.min.css",
+ // path: "./lib/vendors/survey.defaultV2-1.9.50.min.css",
download: true
});
resources.push({
@@ -783,6 +823,15 @@ 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();
+ const checkThrottling = (typeof this._psychoJS.config.experiment.resultsUpload.lastUploadTimestamp !== "undefined");
+ if (checkThrottling && (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..3695f6f 100644
--- a/src/data/ExperimentHandler.js
+++ b/src/data/ExperimentHandler.js
@@ -97,6 +97,9 @@ export class ExperimentHandler extends PsychObject
this._trialsData = [];
this._currentTrialData = {};
+ // whether a header for the .csv result file is necessary:
+ this._isCsvHeaderNeeded = true;
+
this._experimentEnded = false;
}
@@ -236,9 +239,9 @@ export class ExperimentHandler extends PsychObject
*
* @param {Object} options
* @param {Array.