PEBL 2.2
Psychology Experiment Building Language - Cross-platform psychological experiment development system
Chain.cpp
Go to the documentation of this file.
1// Chain.cpp - PEBL Chain data model implementation
2// Copyright (c) 2026 Shane T. Mueller
3// Licensed under GPL
4
5#include "Chain.h"
6#include "Study.h"
7#include "../../libs/json.hpp"
8#include <fstream>
9#include <sstream>
10#include <cstdlib>
11#include <ctime>
12#include <sys/stat.h>
13
14using json = nlohmann::json;
15
16// ============================================================================
17// ItemType Conversion
18// ============================================================================
19
20std::string ItemTypeToString(ItemType type) {
21 switch (type) {
23 return "instruction";
25 return "consent";
27 return "completion";
28 case ItemType::Test:
29 return "test";
30 default:
31 return "unknown";
32 }
33}
34
35ItemType StringToItemType(const std::string& str) {
36 if (str == "instruction") return ItemType::Instruction;
37 if (str == "consent") return ItemType::Consent;
38 if (str == "completion") return ItemType::Completion;
39 if (str == "test") return ItemType::Test;
40 return ItemType::Instruction; // Default fallback
41}
42
43// ============================================================================
44// ChainItem Implementation
45// ============================================================================
46
47std::string ChainItem::CreateChainPageConfig(const std::string& tempDir) const {
48 // Generate unique filename using timestamp + random
49 std::srand(std::time(nullptr));
50 int random = std::rand() % 10000;
51 std::string uuid = std::to_string(std::time(nullptr)) + "-" + std::to_string(random);
52#ifdef _WIN32
53 std::string configFile = tempDir + "\\chainpage-" + uuid + ".json";
54#else
55 std::string configFile = tempDir + "/chainpage-" + uuid + ".json";
56#endif
57
58 try {
59 json j;
60 j["title"] = title;
61 j["content"] = content;
62 j["page_type"] = ItemTypeToString(type);
63
64 std::ofstream file(configFile);
65 if (!file.is_open()) {
66 return "";
67 }
68
69 file << j.dump(2); // Pretty print with 2-space indent
70 file.close();
71
72 return configFile;
73
74 } catch (const std::exception& e) {
75 return "";
76 }
77}
78
79std::string ChainItem::BuildTestCommand(const std::string& studyPath,
80 const std::string& subjectID) const {
81 std::ostringstream cmd;
82
83 // Base command: pebl2 testPath/testName.pbl
84 cmd << "pebl2 " << studyPath << "/tests/" << testName << "/" << testName << ".pbl";
85
86 // Add subject ID
87 cmd << " -s " << subjectID;
88
89 // Add language if specified
90 if (!language.empty()) {
91 cmd << " --language " << language;
92 }
93
94 // Note: Parameter variant handling requires Study context to look up the actual filename
95 // from the ParameterVariant.file field. This method doesn't have that access.
96 // Use LauncherUI::ExecuteChainItem() for proper parameter file handling.
97 if (!paramVariant.empty() && paramVariant != "default") {
98 // This is incomplete - would need: --pfile params/filename.par.json
99 // where filename comes from ParameterVariant.file, not the variant name
100 cmd << " --pfile params/" << paramVariant; // Caller should pass actual filename
101 }
102
103 return cmd.str();
104}
105
106std::string ChainItem::GetDisplayName() const {
107 if (IsPageItem()) {
108 return title.empty() ? ItemTypeToString(type) : title;
109 } else {
110 return testName.empty() ? "Test" : testName;
111 }
112}
113
114// ============================================================================
115// Chain Implementation
116// ============================================================================
117
119 : mParticipantCounter(1001), mUploadEnabled(false),
120 mLSLEnabled(false), mLSLStreamName("PEBL_{test}")
121{
122}
123
126
127std::shared_ptr<Chain> Chain::LoadFromFile(const std::string& path) {
128 auto chain = std::make_shared<Chain>();
129 chain->mFilePath = path;
130
131 if (!chain->LoadFromJSON(path)) {
132 return nullptr;
133 }
134
135 return chain;
136}
137
139 return SaveToJSON(mFilePath);
140}
141
142std::shared_ptr<Chain> Chain::CreateNew(const std::string& path,
143 const std::string& name,
144 const std::string& description) {
145 auto chain = std::make_shared<Chain>();
146 chain->mFilePath = path;
147 chain->mName = name;
148 chain->mDescription = description;
149
150 return chain;
151}
152
153void Chain::AddItem(const ChainItem& item) {
154 mItems.push_back(item);
155}
156
157void Chain::InsertItem(int index, const ChainItem& item) {
158 if (index < 0 || index > static_cast<int>(mItems.size())) {
159 return; // Invalid index
160 }
161
162 mItems.insert(mItems.begin() + index, item);
163}
164
165bool Chain::RemoveItem(int index) {
166 if (index < 0 || index >= static_cast<int>(mItems.size())) {
167 return false; // Invalid index
168 }
169
170 mItems.erase(mItems.begin() + index);
171 return true;
172}
173
174bool Chain::MoveItem(int fromIndex, int toIndex) {
175 if (fromIndex < 0 || fromIndex >= static_cast<int>(mItems.size())) {
176 return false; // Invalid source index
177 }
178
179 if (toIndex < 0 || toIndex >= static_cast<int>(mItems.size())) {
180 return false; // Invalid destination index
181 }
182
183 if (fromIndex == toIndex) {
184 return true; // No-op
185 }
186
187 // Extract item
188 ChainItem item = mItems[fromIndex];
189
190 // Remove from old position
191 mItems.erase(mItems.begin() + fromIndex);
192
193 // Adjust toIndex if needed (removing item may shift indices)
194 if (toIndex > fromIndex) {
195 toIndex--;
196 }
197
198 // Insert at new position
199 mItems.insert(mItems.begin() + toIndex, item);
200
201 return true;
202}
203
205 if (index < 0 || index >= static_cast<int>(mItems.size())) {
206 return nullptr;
207 }
208
209 return &mItems[index];
210}
211
212const ChainItem* Chain::GetItem(int index) const {
213 if (index < 0 || index >= static_cast<int>(mItems.size())) {
214 return nullptr;
215 }
216
217 return &mItems[index];
218}
219
221 ValidationResult result;
222
223 // Check required fields
224 if (mName.empty()) {
225 result.errors.push_back("Chain name is required");
226 }
227
228 if (mFilePath.empty()) {
229 result.warnings.push_back("Chain file path not set");
230 }
231
232 // Check items
233 if (mItems.empty()) {
234 result.warnings.push_back("Chain has no items");
235 }
236
237 for (size_t i = 0; i < mItems.size(); i++) {
238 const ChainItem& item = mItems[i];
239 std::string prefix = "Item " + std::to_string(i + 1) + ": ";
240
241 if (item.IsPageItem()) {
242 // Validate page items
243 if (item.title.empty()) {
244 result.warnings.push_back(prefix + "Page has no title");
245 }
246
247 if (item.content.empty()) {
248 result.warnings.push_back(prefix + "Page has no content");
249 }
250
251 } else {
252 // Validate test items
253 if (item.testName.empty()) {
254 result.errors.push_back(prefix + "Test name is required");
255 }
256
257 // If study is provided, check if test exists in study
258 if (study != nullptr) {
259 const Test* test = study->GetTest(item.testName);
260 if (test == nullptr) {
261 result.errors.push_back(prefix + "Test not found in study: " + item.testName);
262 } else {
263 // Check if test is included
264 if (!test->included) {
265 result.warnings.push_back(prefix + "Test is marked as not included: " + item.testName);
266 }
267
268 // Check parameter variant
269 if (!item.paramVariant.empty() && item.paramVariant != "default") {
270 const ParameterVariant* variant = test->GetVariant(item.paramVariant);
271 if (variant == nullptr) {
272 result.errors.push_back(prefix + "Parameter variant not found: " + item.paramVariant);
273 }
274 }
275
276 // Check language
277 if (!item.language.empty()) {
278 std::vector<std::string> availableLangs = test->GetAvailableLanguages(study->GetPath());
279 bool langFound = false;
280 for (const auto& lang : availableLangs) {
281 if (lang == item.language) {
282 langFound = true;
283 break;
284 }
285 }
286 if (!langFound && !availableLangs.empty()) {
287 result.warnings.push_back(prefix + "Language not found in translations: " + item.language);
288 }
289 }
290 }
291 }
292 }
293 }
294
295 return result;
296}
297
298bool Chain::LoadFromJSON(const std::string& jsonPath) {
299 try {
300 std::ifstream file(jsonPath);
301 if (!file.is_open()) {
302 return false;
303 }
304
305 json j;
306 file >> j;
307
308 // Load metadata
309 mName = j.value("chain_name", "");
310 mDescription = j.value("description", "");
311 mParticipantCounter = j.value("participant_counter", 1001);
312 mUploadEnabled = j.value("upload_enabled", false);
313 mLSLEnabled = j.value("lsl_enabled", false);
314 mLSLStreamName = j.value("lsl_stream_name", "PEBL_{test}");
315
316 // Load items
317 mItems.clear();
318 if (j.contains("items") && j["items"].is_array()) {
319 for (const auto& itemJson : j["items"]) {
320 std::string itemTypeStr = itemJson.value("item_type", "instruction");
321 ItemType itemType = StringToItemType(itemTypeStr);
322
323 ChainItem item(itemType);
324
325 if (item.IsPageItem()) {
326 // Load page item fields (handle null values)
327 item.title = (itemJson.contains("title") && !itemJson["title"].is_null())
328 ? itemJson["title"].get<std::string>() : "";
329 item.content = (itemJson.contains("content") && !itemJson["content"].is_null())
330 ? itemJson["content"].get<std::string>() : "";
331
332 } else {
333 // Load test item fields (handle null values)
334 item.testName = (itemJson.contains("test_name") && !itemJson["test_name"].is_null())
335 ? itemJson["test_name"].get<std::string>() : "";
336 item.paramVariant = (itemJson.contains("param_variant") && !itemJson["param_variant"].is_null())
337 ? itemJson["param_variant"].get<std::string>() : "default";
338 item.language = (itemJson.contains("language") && !itemJson["language"].is_null())
339 ? itemJson["language"].get<std::string>() : "";
340 // Support both "random_group" (launcher) and "randomization_group" (platform)
341 if (itemJson.contains("random_group") && !itemJson["random_group"].is_null()) {
342 item.randomGroup = itemJson["random_group"].get<int>();
343 } else if (itemJson.contains("randomization_group") && !itemJson["randomization_group"].is_null()) {
344 item.randomGroup = itemJson["randomization_group"].get<int>();
345 } else {
346 item.randomGroup = 0;
347 }
348 }
349
350 mItems.push_back(item);
351 }
352 }
353
354 return true;
355
356 } catch (const std::exception& e) {
357 return false;
358 }
359}
360
361bool Chain::SaveToJSON(const std::string& jsonPath) {
362 try {
363 json j;
364
365 // Save metadata
366 j["chain_name"] = mName;
367 j["description"] = mDescription;
368 j["participant_counter"] = mParticipantCounter;
369 j["upload_enabled"] = mUploadEnabled;
370 j["lsl_enabled"] = mLSLEnabled;
371 j["lsl_stream_name"] = mLSLStreamName;
372
373 // Save items
374 json itemsArray = json::array();
375 for (const auto& item : mItems) {
376 json itemJson;
377 itemJson["item_type"] = ItemTypeToString(item.type);
378
379 if (item.IsPageItem()) {
380 // Save page item fields
381 itemJson["title"] = item.title;
382 itemJson["content"] = item.content;
383
384 } else {
385 // Save test item fields
386 itemJson["test_name"] = item.testName;
387 itemJson["param_variant"] = item.paramVariant;
388 itemJson["language"] = item.language;
389 itemJson["random_group"] = item.randomGroup;
390 }
391
392 itemsArray.push_back(itemJson);
393 }
394 j["items"] = itemsArray;
395
396 // Write to file with pretty printing
397 std::ofstream file(jsonPath);
398 if (!file.is_open()) {
399 return false;
400 }
401
402 file << j.dump(2); // 2-space indentation
403 file.close();
404
405 return true;
406
407 } catch (const std::exception& e) {
408 return false;
409 }
410}
411
413 mParticipantCounter++;
414 Save();
415}
nlohmann::json json
Definition Chain.cpp:14
std::string ItemTypeToString(ItemType type)
Definition Chain.cpp:20
ItemType StringToItemType(const std::string &str)
Definition Chain.cpp:35
ItemType
Definition Chain.h:13
std::string ItemTypeToString(ItemType type)
Definition Chain.cpp:20
ItemType StringToItemType(const std::string &str)
Definition Chain.cpp:35
~Chain()
Definition Chain.cpp:124
void AddItem(const ChainItem &item)
Definition Chain.cpp:153
Chain()
Definition Chain.cpp:118
ValidationResult Validate(const class Study *study=nullptr) const
Definition Chain.cpp:220
bool Save()
Definition Chain.cpp:138
void IncrementParticipantCounter()
Definition Chain.cpp:412
static std::shared_ptr< Chain > LoadFromFile(const std::string &path)
Definition Chain.cpp:127
ChainItem * GetItem(int index)
Definition Chain.cpp:204
static std::shared_ptr< Chain > CreateNew(const std::string &path, const std::string &name, const std::string &description="")
Definition Chain.cpp:142
bool RemoveItem(int index)
Definition Chain.cpp:165
bool MoveItem(int fromIndex, int toIndex)
Definition Chain.cpp:174
void InsertItem(int index, const ChainItem &item)
Definition Chain.cpp:157
Definition Study.h:44
Test * GetTest(const std::string &testName)
Definition Study.cpp:187
const std::string & GetPath() const
Definition Study.h:70
std::string testName
Definition Chain.h:33
bool IsPageItem() const
Definition Chain.h:52
std::string CreateChainPageConfig(const std::string &tempDir) const
Definition Chain.cpp:47
std::string title
Definition Chain.h:29
std::string GetDisplayName() const
Definition Chain.cpp:106
std::string content
Definition Chain.h:30
std::string BuildTestCommand(const std::string &studyPath, const std::string &subjectID) const
Definition Chain.cpp:79
std::string paramVariant
Definition Chain.h:34
ItemType type
Definition Chain.h:26
std::string language
Definition Chain.h:35
std::vector< std::string > warnings
Definition Chain.h:109
std::vector< std::string > errors
Definition Chain.h:108
Definition Study.h:24
std::vector< std::string > GetAvailableLanguages(const std::string &studyPath) const
Definition Study.cpp:48
const ParameterVariant * GetVariant(const std::string &variantName) const
Definition Study.cpp:80
bool included
Definition Study.h:28