PEBL 2.2
Psychology Experiment Building Language - Cross-platform psychological experiment development system
SnapshotManager.cpp
Go to the documentation of this file.
1// SnapshotManager.cpp - PEBL snapshot creation and management
2// Copyright (c) 2026 Shane T. Mueller
3// Licensed under GPL
4
5#include "SnapshotManager.h"
6#include "Study.h"
7#include "Chain.h"
8#include "../../libs/json.hpp"
9#include <sys/stat.h>
10#include <cstring>
11#include <fstream>
12#include <sstream>
13#include <iomanip>
14#include <ctime>
15#include <filesystem>
16
17namespace fs = std::filesystem;
18
19#ifdef _WIN32
20#include <windows.h>
21#include <direct.h>
22// Undef Windows CopyFile macro to avoid conflict with our function
23#ifdef CopyFile
24#undef CopyFile
25#endif
26#ifndef stat
27#define stat _stat
28#endif
29#define mkdir(path, mode) _mkdir(path)
30#ifndef S_ISDIR
31#define S_ISDIR(mode) (((mode) & _S_IFMT) == _S_IFDIR)
32#endif
33#ifndef S_ISREG
34#define S_ISREG(mode) (((mode) & _S_IFMT) == _S_IFREG)
35#endif
36#else
37#include <dirent.h>
38#include <sys/types.h>
39#endif
40
41using json = nlohmann::json;
42
45
48
49std::string SnapshotManager::CreateSnapshot(const std::string& studyPath,
50 const std::string& snapshotsDir) {
51 // Load study to get metadata
52 auto study = Study::LoadFromDirectory(studyPath);
53 if (!study) {
54 return "";
55 }
56
57 // Generate snapshot name: studyname_vN_YYYY-MM-DD
58 std::string snapshotName = GenerateSnapshotName(study->GetName(), study->GetVersion());
59 std::string snapshotPath = snapshotsDir + "/" + snapshotName;
60
61 // Check if snapshot already exists
62 if (DirectoryExists(snapshotPath)) {
63 // Append timestamp to make unique
64 auto now = std::time(nullptr);
65 std::ostringstream oss;
66 oss << snapshotPath << "_" << now;
67 snapshotPath = oss.str();
68 }
69
70 // Copy study directory, excluding data/ directories
71 if (!CopyDirectory(studyPath, snapshotPath, true)) {
72 return "";
73 }
74
75 return snapshotName;
76}
77
79 ValidationResult result;
80 result.isValid = true;
81
82 // Check if directory exists
83 if (!DirectoryExists(snapshotPath)) {
84 result.isValid = false;
85 result.errors.push_back("Snapshot directory does not exist");
86 return result;
87 }
88
89 // Check for study-info.json
90 std::string studyInfoPath = snapshotPath + "/study-info.json";
91 if (!FileExists(studyInfoPath)) {
92 result.isValid = false;
93 result.errors.push_back("study-info.json not found");
94 return result;
95 }
96
97 // Try to load study
98 auto study = Study::LoadFromDirectory(snapshotPath);
99 if (!study) {
100 result.isValid = false;
101 result.errors.push_back("Failed to parse study-info.json");
102 return result;
103 }
104
105 // Validate study structure
106 auto studyValidation = study->Validate();
107 result.errors.insert(result.errors.end(), studyValidation.errors.begin(), studyValidation.errors.end());
108 result.warnings.insert(result.warnings.end(), studyValidation.warnings.begin(), studyValidation.warnings.end());
109
110 // Check for chains/ directory
111 std::string chainsDir = snapshotPath + "/chains";
112 if (!DirectoryExists(chainsDir)) {
113 result.warnings.push_back("chains/ directory not found");
114 }
115
116 // Check for tests/ directory
117 std::string testsDir = snapshotPath + "/tests";
118 if (!DirectoryExists(testsDir)) {
119 result.warnings.push_back("tests/ directory not found");
120 }
121
122 // Verify no data/ directories (snapshots shouldn't have data)
123 std::string dataDir = snapshotPath + "/data";
124 if (DirectoryExists(dataDir)) {
125 result.warnings.push_back("Snapshot contains data/ directory (should be excluded)");
126 }
127
128 // Check each test for data/ subdirectories
129 for (const auto& test : study->GetTests()) {
130 std::string testDataDir = snapshotPath + "/tests/" + test.testPath + "/data";
131 if (DirectoryExists(testDataDir)) {
132 result.warnings.push_back("Test " + test.testName + " contains data/ directory");
133 }
134 }
135
136 result.isValid = result.errors.empty();
137 return result;
138}
139
140bool SnapshotManager::ImportSnapshot(const std::string& snapshotPath,
141 const std::string& studiesDir,
142 const std::string& newStudyName) {
143 std::string studyPath = studiesDir + "/" + newStudyName;
144
145 // Check if study already exists
146 if (DirectoryExists(studyPath)) {
147 return false;
148 }
149
150 // Copy snapshot to studies directory
151 // Note: For ZIP imports, format conversion happens before this is called
152 // For directory imports, we need to convert after copying
153 if (!CopyDirectory(snapshotPath, studyPath, false)) {
154 return false;
155 }
156
157 // Create data/ directory (snapshots don't have it)
158 std::string dataDir = studyPath + "/data";
159 if (!DirectoryExists(dataDir)) {
160 mkdir(dataDir.c_str(), 0755);
161 }
162
163 return true;
164}
165
166std::string SnapshotManager::GenerateSnapshotName(const std::string& studyName, int version) {
167 // Replace spaces with hyphens, convert to lowercase
168 std::string safeName = studyName;
169 for (char& c : safeName) {
170 if (c == ' ') c = '-';
171 else c = std::tolower(c);
172 }
173
174 // Get current date
175 auto now = std::time(nullptr);
176 auto tm = *std::localtime(&now);
177
178 std::ostringstream oss;
179 oss << safeName << "_v" << version << "_"
180 << std::put_time(&tm, "%Y-%m-%d");
181
182 return oss.str();
183}
184
186 SnapshotInfo info;
187 info.version = 0;
188 info.testCount = 0;
189 info.chainCount = 0;
190
191 auto study = Study::LoadFromDirectory(snapshotPath);
192 if (!study) {
193 return info;
194 }
195
196 info.studyName = study->GetName();
197 info.version = study->GetVersion();
198 info.description = study->GetDescription();
199 info.author = study->GetAuthor();
200 info.createdDate = study->GetCreatedDate();
201 info.testCount = static_cast<int>(study->GetTests().size());
202 info.chainCount = study->GetChainCount();
203
204 return info;
205}
206
207bool SnapshotManager::CopyDirectory(const std::string& source, const std::string& dest, bool excludeData) {
208 // Create destination directory
209 struct stat info;
210 if (stat(dest.c_str(), &info) != 0) {
211 if (mkdir(dest.c_str(), 0755) != 0) {
212 return false;
213 }
214 }
215
216 bool success = true;
217
218 try {
219 for (const auto& entry : fs::directory_iterator(source)) {
220 std::string name = entry.path().filename().string();
221 std::string sourcePath = entry.path().string();
222 std::string destPath = dest + "/" + name;
223
224 bool isDirectory = entry.is_directory();
225
226 // Check if should exclude
227 if (ShouldExcludeFromSnapshot(name, isDirectory) && excludeData) {
228 continue;
229 }
230
231 if (isDirectory) {
232 // Recursively copy directory
233 if (!CopyDirectory(sourcePath, destPath, excludeData)) {
234 success = false;
235 break;
236 }
237 } else {
238 // Copy file
239 if (!CopyFileContents(sourcePath, destPath)) {
240 success = false;
241 break;
242 }
243 }
244 }
245 } catch (const fs::filesystem_error&) {
246 return false;
247 }
248
249 return success;
250}
251
252bool SnapshotManager::CopyFileContents(const std::string& source, const std::string& dest) {
253 std::ifstream src(source, std::ios::binary);
254 if (!src.is_open()) {
255 return false;
256 }
257
258 std::ofstream dst(dest, std::ios::binary);
259 if (!dst.is_open()) {
260 return false;
261 }
262
263 dst << src.rdbuf();
264
265 src.close();
266 dst.close();
267
268#ifndef _WIN32
269 // Copy file permissions (POSIX only)
270 struct stat fileInfo;
271 if (stat(source.c_str(), &fileInfo) == 0) {
272 chmod(dest.c_str(), fileInfo.st_mode);
273 }
274#endif
275
276 return true;
277}
278
279bool SnapshotManager::DirectoryExists(const std::string& path) const {
280 struct stat info;
281 return (stat(path.c_str(), &info) == 0 && (info.st_mode & S_IFDIR));
282}
283
284bool SnapshotManager::FileExists(const std::string& path) const {
285 struct stat info;
286 return (stat(path.c_str(), &info) == 0 && !(info.st_mode & S_IFDIR));
287}
288
289std::string SnapshotManager::GetCurrentDateString() const {
290 auto now = std::time(nullptr);
291 auto tm = *std::localtime(&now);
292
293 std::ostringstream oss;
294 oss << std::put_time(&tm, "%Y-%m-%d");
295 return oss.str();
296}
297
298bool SnapshotManager::ShouldExcludeFromSnapshot(const std::string& name, bool isDirectory) const {
299 // Exclude data/ directories
300 if (isDirectory && name == "data") {
301 return true;
302 }
303
304 // Exclude hidden files/directories
305 if (!name.empty() && name[0] == '.') {
306 return true;
307 }
308
309 // Exclude temporary files
310 if (name.find("~") != std::string::npos) {
311 return true;
312 }
313
314 if (name.find(".tmp") != std::string::npos) {
315 return true;
316 }
317
318 return false;
319}
320
321bool SnapshotManager::ConvertSnapshotFormat(const std::string& studyPath) {
333 try {
334 std::string jsonPath = studyPath + "/study-info.json";
335
336 // Read existing study-info.json
337 std::ifstream file(jsonPath);
338 if (!file.is_open()) {
339 return false;
340 }
341
342 json platformJson;
343 file >> platformJson;
344 file.close();
345
346 // Create launcher-compatible JSON
347 json launcherJson;
348
349 // Convert metadata
350 launcherJson["study_name"] = platformJson.value("study_name", "Imported Study");
351 launcherJson["description"] = platformJson.value("description", "");
352 launcherJson["author"] = platformJson.value("created_by", "");
353
354 // Convert version from string to integer
355 std::string versionStr = platformJson.value("version", "1");
356 int versionInt = 1;
357 // Try to extract number from strings like "Version 3"
358 size_t lastSpace = versionStr.rfind(' ');
359 if (lastSpace != std::string::npos) {
360 std::string numPart = versionStr.substr(lastSpace + 1);
361 versionInt = std::atoi(numPart.c_str());
362 if (versionInt == 0) versionInt = 1;
363 }
364 launcherJson["version"] = versionInt;
365
366 // Convert tests array
367 json testsArray = json::array();
368 if (platformJson.contains("tests") && platformJson["tests"].is_array()) {
369 for (const auto& platformTest : platformJson["tests"]) {
370 json launcherTest;
371
372 // In platform format: test_id is the identifier, test_name is display name
373 std::string testId = platformTest.value("test_id", "");
374 std::string testDisplayName = platformTest.value("test_name", testId);
375
376 launcherTest["test_name"] = testId; // Launcher uses test_name as identifier
377 launcherTest["display_name"] = testDisplayName;
378 launcherTest["test_path"] = testId; // Path is same as identifier
379 launcherTest["included"] = true;
380
381 // Build parameter_variants by scanning params/ directory for .par.json files
382 json paramVariants = json::object();
383 std::string paramsDir = studyPath + "/tests/" + testId + "/params";
384 if (DirectoryExists(paramsDir)) {
385 // Always add a "default" variant
386 json defaultVariant;
387 defaultVariant["description"] = "Default parameters";
388 defaultVariant["file"] = nullptr;
389 paramVariants["default"] = defaultVariant;
390
391 // Scan for .par.json files
392 for (const auto& entry : fs::directory_iterator(paramsDir)) {
393 if (entry.is_regular_file()) {
394 std::string filename = entry.path().filename().string();
395 // Look for .par.json files
396 if (filename.size() > 9 && filename.substr(filename.size() - 9) == ".par.json") {
397 // Extract variant name (remove .par.json extension)
398 std::string variantName = filename.substr(0, filename.size() - 9);
399 json variant;
400 variant["description"] = variantName;
401 variant["file"] = filename;
402 paramVariants[variantName] = variant;
403 printf("Found parameter variant: %s -> %s\n", variantName.c_str(), filename.c_str());
404 }
405 }
406 }
407 }
408 launcherTest["parameter_variants"] = paramVariants;
409
410 testsArray.push_back(launcherTest);
411 }
412 }
413 launcherJson["tests"] = testsArray;
414
415 // Upload config must be set intentionally via Study Settings;
416 // do not auto-populate from upload.json files in test directories
417 launcherJson["study_token"] = "";
418 launcherJson["upload_server_url"] = "";
419 launcherJson["created_date"] = platformJson.value("created_at", "");
420 launcherJson["modified_date"] = platformJson.value("created_at", "");
421
422 // Write converted JSON back to file
423 std::ofstream outFile(jsonPath);
424 if (!outFile.is_open()) {
425 return false;
426 }
427
428 outFile << launcherJson.dump(2); // 2-space indentation
429 outFile.close();
430
431 printf("Converted snapshot format to launcher format\n");
432 return true;
433
434 } catch (const std::exception& e) {
435 printf("Error converting snapshot format: %s\n", e.what());
436 return false;
437 }
438}
nlohmann::json json
Definition Chain.cpp:14
SnapshotInfo GetSnapshotInfo(const std::string &snapshotPath)
std::string CreateSnapshot(const std::string &studyPath, const std::string &snapshotsDir)
static std::string GenerateSnapshotName(const std::string &studyName, int version)
bool ConvertSnapshotFormat(const std::string &studyPath)
bool ImportSnapshot(const std::string &snapshotPath, const std::string &studiesDir, const std::string &newStudyName)
ValidationResult ValidateSnapshot(const std::string &snapshotPath)
static std::shared_ptr< Study > LoadFromDirectory(const std::string &path)
Definition Study.cpp:100
std::vector< std::string > errors
std::vector< std::string > warnings