PEBL 2.2
Psychology Experiment Building Language - Cross-platform psychological experiment development system
Study.cpp
Go to the documentation of this file.
1// Study.cpp - PEBL Study data model implementation
2// Copyright (c) 2026 Shane T. Mueller
3// Licensed under GPL
4
5#include "Study.h"
6#include "Chain.h"
7#include "../../libs/json.hpp"
8#include <fstream>
9#include <sstream>
10#include <ctime>
11#include <iomanip>
12#include <algorithm>
13#include <sys/stat.h>
14#include <filesystem>
15
16#ifdef _WIN32
17#include <windows.h>
18#include <direct.h>
19#ifndef stat
20#define stat _stat
21#endif
22#ifndef S_ISDIR
23#define S_ISDIR(mode) (((mode) & _S_IFMT) == _S_IFDIR)
24#endif
25#ifndef S_ISREG
26#define S_ISREG(mode) (((mode) & _S_IFMT) == _S_IFREG)
27#endif
28#ifndef S_IFDIR
29#define S_IFDIR _S_IFDIR
30#endif
31#else
32#include <dirent.h>
33#endif
34
35using json = nlohmann::json;
36namespace fs = std::filesystem;
37
38// ============================================================================
39// Test Implementation
40// ============================================================================
41
42bool Test::Exists(const std::string& studyPath) const {
43 std::string fullPath = studyPath + "/tests/" + testPath;
44 struct stat info;
45 return (stat(fullPath.c_str(), &info) == 0 && (info.st_mode & S_IFDIR));
46}
47
48std::vector<std::string> Test::GetAvailableLanguages(const std::string& studyPath) const {
49 std::vector<std::string> languages;
50 std::string translationsDir = studyPath + "/tests/" + testPath + "/translations";
51
52 try {
53 if (!fs::exists(translationsDir) || !fs::is_directory(translationsDir)) {
54 return languages;
55 }
56
57 for (const auto& entry : fs::directory_iterator(translationsDir)) {
58 if (!entry.is_regular_file()) continue;
59
60 std::string filename = entry.path().filename().string();
61
62 // Look for pattern: testname.pbl-LANG.json
63 size_t dashPos = filename.rfind('-');
64 size_t jsonPos = filename.rfind(".json");
65
66 if (dashPos != std::string::npos && jsonPos != std::string::npos) {
67 std::string lang = filename.substr(dashPos + 1, jsonPos - dashPos - 1);
68 if (!lang.empty()) {
69 languages.push_back(lang);
70 }
71 }
72 }
73 } catch (const fs::filesystem_error&) {
74 // Directory doesn't exist or can't be read
75 }
76
77 return languages;
78}
79
80const ParameterVariant* Test::GetVariant(const std::string& variantName) const {
81 auto it = parameterVariants.find(variantName);
82 if (it != parameterVariants.end()) {
83 return &(it->second);
84 }
85 return nullptr;
86}
87
88// ============================================================================
89// Study Implementation
90// ============================================================================
91
93 : mVersion(1), mUploadEnabled(false)
94{
95}
96
99
100std::shared_ptr<Study> Study::LoadFromDirectory(const std::string& path) {
101 auto study = std::make_shared<Study>();
102 study->mPath = path;
103
104 std::string jsonPath = path + "/study-info.json";
105 printf(" Looking for: %s\n", jsonPath.c_str());
106
107 // Check if file exists
108 std::ifstream testFile(jsonPath);
109 if (!testFile.good()) {
110 printf(" File does not exist or cannot be opened\n");
111 return nullptr;
112 }
113 testFile.close();
114
115 if (!study->LoadFromJSON(jsonPath)) {
116 printf(" Failed to parse JSON from file\n");
117 return nullptr;
118 }
119
120 printf(" Successfully loaded study: %s\n", study->GetName().c_str());
121 return study;
122}
123
125 std::string jsonPath = mPath + "/study-info.json";
126 return SaveToJSON(jsonPath);
127}
128
129std::shared_ptr<Study> Study::CreateNew(const std::string& path,
130 const std::string& name,
131 const std::string& author) {
132 auto study = std::make_shared<Study>();
133 study->mPath = path;
134 study->mName = name;
135 study->mAuthor = author;
136 study->mVersion = 1;
137 study->mCreatedDate = study->GetCurrentISO8601Time();
138 study->mModifiedDate = study->mCreatedDate;
139
140 // Create directory structure using filesystem API (handles paths with spaces)
141 try {
142 fs::create_directories(path);
143 fs::create_directories(fs::path(path) / "chains");
144 fs::create_directories(fs::path(path) / "tests");
145 // Note: data/ directory removed - not needed in study structure
146 } catch (const fs::filesystem_error& e) {
147 printf("Error creating study directories: %s\n", e.what());
148 }
149
150 // Create default chain
151 std::string chainPath = path + "/chains/Main.json";
152 auto defaultChain = Chain::CreateNew(chainPath, "Main", "Default chain for this study");
153 if (defaultChain) {
154 defaultChain->Save();
155 printf("Created default chain: Main.json\n");
156 }
157
158 return study;
159}
160
161bool Study::AddTest(const Test& test) {
162 // Check if test already exists
163 for (const auto& t : mTests) {
164 if (t.testName == test.testName) {
165 return false; // Already exists
166 }
167 }
168
169 mTests.push_back(test);
170 UpdateModifiedDate();
171 return true;
172}
173
174bool Study::RemoveTest(const std::string& testName) {
175 auto it = std::find_if(mTests.begin(), mTests.end(),
176 [&testName](const Test& t) { return t.testName == testName; });
177
178 if (it != mTests.end()) {
179 mTests.erase(it);
180 UpdateModifiedDate();
181 return true;
182 }
183
184 return false;
185}
186
187Test* Study::GetTest(const std::string& testName) {
188 auto it = std::find_if(mTests.begin(), mTests.end(),
189 [&testName](const Test& t) { return t.testName == testName; });
190
191 if (it != mTests.end()) {
192 return &(*it);
193 }
194
195 return nullptr;
196}
197
198const Test* Study::GetTest(const std::string& testName) const {
199 auto it = std::find_if(mTests.begin(), mTests.end(),
200 [&testName](const Test& t) { return t.testName == testName; });
201
202 if (it != mTests.end()) {
203 return &(*it);
204 }
205
206 return nullptr;
207}
208
209std::vector<std::string> Study::GetChainFiles() const {
210 std::vector<std::string> chains;
211 std::string chainsDir = mPath + "/chains";
212
213 try {
214 if (!fs::exists(chainsDir) || !fs::is_directory(chainsDir)) {
215 return chains;
216 }
217
218 for (const auto& entry : fs::directory_iterator(chainsDir)) {
219 if (!entry.is_regular_file()) continue;
220
221 std::string filename = entry.path().filename().string();
222
223 // Look for .json files
224 if (filename.size() > 5 && filename.substr(filename.size() - 5) == ".json") {
225 chains.push_back(filename);
226 }
227 }
228 } catch (const fs::filesystem_error&) {
229 // Directory doesn't exist or can't be read
230 }
231
232 return chains;
233}
234
236 return static_cast<int>(GetChainFiles().size());
237}
238
240 ValidationResult result;
241
242 // Check required fields
243 if (mName.empty()) {
244 result.errors.push_back("Study name is required");
245 }
246
247 if (mPath.empty()) {
248 result.errors.push_back("Study path is required");
249 }
250
251 if (mVersion < 1) {
252 result.errors.push_back("Study version must be >= 1");
253 }
254
255 // Check directory structure
256 struct stat info;
257 if (stat(mPath.c_str(), &info) != 0 || !(info.st_mode & S_IFDIR)) {
258 result.errors.push_back("Study directory does not exist: " + mPath);
259 } else {
260 // Check subdirectories
261 std::string chainsDir = mPath + "/chains";
262 if (stat(chainsDir.c_str(), &info) != 0 || !(info.st_mode & S_IFDIR)) {
263 result.warnings.push_back("chains/ directory missing");
264 }
265
266 std::string testsDir = mPath + "/tests";
267 if (stat(testsDir.c_str(), &info) != 0 || !(info.st_mode & S_IFDIR)) {
268 result.warnings.push_back("tests/ directory missing");
269 }
270 }
271
272 // Validate tests
273 for (const auto& test : mTests) {
274 if (test.testName.empty()) {
275 result.errors.push_back("Test name cannot be empty");
276 }
277
278 if (test.testPath.empty()) {
279 result.errors.push_back("Test path cannot be empty for test: " + test.testName);
280 }
281
282 // Check if test directory exists
283 if (!test.Exists(mPath)) {
284 result.warnings.push_back("Test directory not found: " + test.testPath);
285 }
286 }
287
288 return result;
289}
290
291bool Study::LoadFromJSON(const std::string& jsonPath) {
292 try {
293 std::ifstream file(jsonPath);
294 if (!file.is_open()) {
295 return false;
296 }
297
298 json j;
299 file >> j;
300
301 // Load required fields
302 mName = j.value("study_name", "");
303 mVersion = j.value("version", 1);
304
305 // Load optional fields
306 mDescription = j.value("description", "");
307 mAuthor = j.value("author", "");
308 mStudyToken = j.value("study_token", "");
309 mUploadServerURL = j.value("upload_server_url", "");
310 mCreatedDate = j.value("created_date", "");
311 mModifiedDate = j.value("modified_date", "");
312
313 // Load tests
314 mTests.clear();
315 if (j.contains("tests") && j["tests"].is_array()) {
316 for (const auto& testJson : j["tests"]) {
317 Test test;
318 test.testName = testJson.value("test_name", "");
319 test.displayName = testJson.value("display_name", testJson.value("test_name", ""));
320 test.testPath = testJson.value("test_path", "");
321 test.included = testJson.value("included", true);
322
323 // Load parameter variants
324 if (testJson.contains("parameter_variants") && testJson["parameter_variants"].is_object()) {
325 for (auto& [key, value] : testJson["parameter_variants"].items()) {
326 ParameterVariant variant;
327 variant.description = value.value("description", "");
328 // Handle null file values (default variant has null file)
329 if (value.contains("file") && !value["file"].is_null()) {
330 variant.file = value["file"].get<std::string>();
331 } else {
332 variant.file = "";
333 }
334 test.parameterVariants[key] = variant;
335 }
336 }
337
338 mTests.push_back(test);
339 }
340 }
341
342 return true;
343
344 } catch (const std::exception& e) {
345 // JSON parsing error
346 return false;
347 }
348}
349
350bool Study::SaveToJSON(const std::string& jsonPath) {
351 try {
352 json j;
353
354 // Save metadata
355 j["study_name"] = mName;
356 j["description"] = mDescription;
357 j["version"] = mVersion;
358 j["author"] = mAuthor;
359 j["study_token"] = mStudyToken;
360 j["upload_server_url"] = mUploadServerURL;
361 j["created_date"] = mCreatedDate;
362 j["modified_date"] = mModifiedDate;
363
364 // Save tests
365 json testsArray = json::array();
366 for (const auto& test : mTests) {
367 json testJson;
368 testJson["test_name"] = test.testName;
369 testJson["display_name"] = test.displayName.empty() ? test.testName : test.displayName;
370 testJson["test_path"] = test.testPath;
371 testJson["included"] = test.included;
372
373 // Save parameter variants
374 if (!test.parameterVariants.empty()) {
375 json variantsJson;
376 for (const auto& [key, variant] : test.parameterVariants) {
377 json variantJson;
378 variantJson["description"] = variant.description;
379 if (!variant.file.empty()) {
380 variantJson["file"] = variant.file;
381 } else {
382 variantJson["file"] = nullptr;
383 }
384 variantsJson[key] = variantJson;
385 }
386 testJson["parameter_variants"] = variantsJson;
387 }
388
389 testsArray.push_back(testJson);
390 }
391 j["tests"] = testsArray;
392
393 // Write to file with pretty printing
394 std::ofstream file(jsonPath);
395 if (!file.is_open()) {
396 return false;
397 }
398
399 file << j.dump(2); // 2-space indentation
400 file.close();
401
402 return true;
403
404 } catch (const std::exception& e) {
405 return false;
406 }
407}
408
409void Study::UpdateModifiedDate() {
410 mModifiedDate = GetCurrentISO8601Time();
411}
412
413std::string Study::GetCurrentISO8601Time() const {
414 auto now = std::time(nullptr);
415 auto tm = *std::gmtime(&now);
416
417 std::ostringstream oss;
418 oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ");
419 return oss.str();
420}
421
422std::string Study::GetStudyCode() const {
423 std::string code;
424 code.reserve(4);
425
426 // Extract first 4 alphanumeric characters from study name
427 for (char c : mName) {
428 if (std::isalnum(static_cast<unsigned char>(c))) {
429 code += std::toupper(static_cast<unsigned char>(c));
430 if (code.length() >= 4) {
431 break;
432 }
433 }
434 }
435
436 // Pad with 'X' if less than 4 characters
437 while (code.length() < 4) {
438 code += 'X';
439 }
440
441 return code;
442}
443
444// ============================================================================
445// Upload Configuration Management
446// ============================================================================
447
448std::string Study::GetUploadConfigPath(const std::string& testName) const {
449 return mPath + "/tests/" + testName + "/upload.json";
450}
451
452bool Study::TestHasUploadConfig(const std::string& testName) const {
453 std::string uploadPath = GetUploadConfigPath(testName);
454 struct stat info;
455 return (stat(uploadPath.c_str(), &info) == 0);
456}
457
458bool Study::CreateUploadConfigForTest(const std::string& testName) {
459 // Validate inputs
460 if (mStudyToken.empty()) {
461 printf("Cannot create upload config: study token not set\n");
462 return false;
463 }
464
465 if (mUploadServerURL.empty()) {
466 printf("Cannot create upload config: upload server URL not set\n");
467 return false;
468 }
469
470 // Get test
471 const Test* test = GetTest(testName);
472 if (!test) {
473 printf("Cannot create upload config: test not found: %s\n", testName.c_str());
474 return false;
475 }
476
477 // Parse server URL to extract host, port, path
478 std::string host, page;
479 int port = 443; // Default HTTPS port
480
481 std::string url = mUploadServerURL;
482
483 // Remove protocol
484 if (url.find("https://") == 0) {
485 url = url.substr(8);
486 port = 443;
487 } else if (url.find("http://") == 0) {
488 url = url.substr(7);
489 port = 80;
490 }
491
492 // Find first slash to separate host from path
493 size_t slashPos = url.find('/');
494 if (slashPos != std::string::npos) {
495 host = url.substr(0, slashPos);
496 page = url.substr(slashPos);
497 } else {
498 host = url;
499 page = "/api/upload.php"; // Default page
500 }
501
502 // Check for port in host (host:port format)
503 size_t colonPos = host.find(':');
504 if (colonPos != std::string::npos) {
505 std::string portStr = host.substr(colonPos + 1);
506 port = std::atoi(portStr.c_str());
507 host = host.substr(0, colonPos);
508 }
509
510 // Create upload.json content
511 try {
512 json j;
513 j["host"] = host;
514 j["page"] = page;
515 j["subnumpage"] = "/api/getNewParticipantId.php"; // For server-assigned participant IDs
516 j["port"] = port;
517 j["token"] = mStudyToken;
518 j["taskname"] = testName;
519 j["username"] = mStudyToken;
520 j["uploadpassword"] = mStudyToken;
521
522 // Write to file
523 std::string uploadPath = GetUploadConfigPath(testName);
524
525 // Ensure test directory exists
526 std::string testDir = mPath + "/tests/" + testName;
527 try {
528 fs::create_directories(testDir);
529 } catch (const fs::filesystem_error& e) {
530 printf("Error creating test directory: %s\n", e.what());
531 return false;
532 }
533
534 std::ofstream file(uploadPath);
535 if (!file.is_open()) {
536 printf("Failed to create upload config file: %s\n", uploadPath.c_str());
537 return false;
538 }
539
540 file << j.dump(2); // Pretty print with 2-space indent
541 file.close();
542
543 printf("Created upload config: %s\n", uploadPath.c_str());
544 return true;
545
546 } catch (const std::exception& e) {
547 printf("Error creating upload config: %s\n", e.what());
548 return false;
549 }
550}
nlohmann::json json
Definition Chain.cpp:14
static std::shared_ptr< Chain > CreateNew(const std::string &path, const std::string &name, const std::string &description="")
Definition Chain.cpp:142
Study()
Definition Study.cpp:92
bool TestHasUploadConfig(const std::string &testName) const
Definition Study.cpp:452
Test * GetTest(const std::string &testName)
Definition Study.cpp:187
bool Save()
Definition Study.cpp:124
bool CreateUploadConfigForTest(const std::string &testName)
Definition Study.cpp:458
std::string GetStudyCode() const
Definition Study.cpp:422
static std::shared_ptr< Study > CreateNew(const std::string &path, const std::string &name, const std::string &author="")
Definition Study.cpp:129
bool RemoveTest(const std::string &testName)
Definition Study.cpp:174
std::vector< std::string > GetChainFiles() const
Definition Study.cpp:209
ValidationResult Validate() const
Definition Study.cpp:239
bool AddTest(const Test &test)
Definition Study.cpp:161
int GetChainCount() const
Definition Study.cpp:235
static std::shared_ptr< Study > LoadFromDirectory(const std::string &path)
Definition Study.cpp:100
std::string GetUploadConfigPath(const std::string &testName) const
Definition Study.cpp:448
~Study()
Definition Study.cpp:97
std::string file
Definition Study.h:16
std::string description
Definition Study.h:15
std::vector< std::string > warnings
Definition Study.h:108
std::vector< std::string > errors
Definition Study.h:107
Definition Study.h:24
std::vector< std::string > GetAvailableLanguages(const std::string &studyPath) const
Definition Study.cpp:48
bool Exists(const std::string &studyPath) const
Definition Study.cpp:42
std::string displayName
Definition Study.h:26
std::string testPath
Definition Study.h:27
std::string testName
Definition Study.h:25
std::map< std::string, ParameterVariant > parameterVariants
Definition Study.h:29
const ParameterVariant * GetVariant(const std::string &variantName) const
Definition Study.cpp:80
bool included
Definition Study.h:28