PEBL 2.2
Psychology Experiment Building Language - Cross-platform psychological experiment development system
ScaleManager.cpp
Go to the documentation of this file.
1// ScaleManager.cpp - PEBL Scale file management implementation
2// Copyright (c) 2026 Shane T. Mueller
3// Licensed under GPL
4
5#include "ScaleManager.h"
6#include "Study.h"
7#include "Chain.h"
8#include <json.hpp>
9#include <filesystem>
10#include <fstream>
11#include <algorithm>
12#include <set>
13#include <cstdlib>
14
15#ifdef _WIN32
16#include <windows.h>
17#else
18#include <unistd.h>
19#endif
20
21namespace fs = std::filesystem;
22using json = nlohmann::json;
23
24ScaleManager::ScaleManager(const std::string& batteryPath, const std::string& workspacePath)
25 : mBatteryPath(batteryPath)
26 , mWorkspacePath(workspacePath)
27{
28 // Library paths: media/apps/scales/ (relative to PEBL root, which is parent of battery/)
29 mBatteryScalesPath = batteryPath + "/../media/apps/scales";
30 mBatteryDefinitionsPath = mBatteryScalesPath + "/definitions";
31
32 // Workspace paths (writable, user scales)
33 if (!workspacePath.empty()) {
34 mWorkspaceScalesPath = workspacePath + "/scales";
35 mWorkspaceDefinitionsPath = mWorkspaceScalesPath + "/definitions";
36 }
37
38 EnsureDirectoriesExist();
39}
40
44
45void ScaleManager::EnsureDirectoriesExist()
46{
47 // Library directories are read-only, don't try to create them
48
49 // Workspace directories (should be writable)
50 if (!mWorkspacePath.empty()) {
51 try {
52 fs::create_directories(mWorkspaceScalesPath);
53 } catch (const std::exception& e) {
54 // Continue even if directory creation fails
55 }
56 }
57}
58
59std::vector<std::string> ScaleManager::GetAvailableScales()
60{
61 std::set<std::string> scalesSet; // Use set to avoid duplicates
62
63 // Scan a directory for per-scale subdirectories containing <code>/<code>.json or <code>.osd
64 auto scanForScales = [&](const std::string& basePath) {
65 try {
66 if (!fs::exists(basePath)) return;
67 for (const auto& entry : fs::directory_iterator(basePath)) {
68 if (entry.is_directory()) {
69 std::string dirName = entry.path().filename().string();
70 std::string defFile = entry.path().string() + "/" + dirName + ".json";
71 std::string osdFile = entry.path().string() + "/" + dirName + ".osd";
72 if (fs::exists(defFile) || fs::exists(osdFile)) {
73 scalesSet.insert(dirName);
74 }
75 }
76 }
77 } catch (const std::exception& e) {
78 // Continue on error
79 }
80 };
81
82 // Scan library: media/apps/scales/definitions/<code>/<code>.json
83 scanForScales(mBatteryDefinitionsPath);
84
85 // Scan workspace: workspace/scales/<code>/<code>.json
86 if (!mWorkspacePath.empty()) {
87 scanForScales(mWorkspaceScalesPath);
88 }
89
90 // Convert set to sorted vector
91 std::vector<std::string> scales(scalesSet.begin(), scalesSet.end());
92 std::sort(scales.begin(), scales.end());
93
94 return scales;
95}
96
97std::shared_ptr<ScaleDefinition> ScaleManager::CreateScale(const std::string& code)
98{
99 return ScaleDefinition::CreateNew(code);
100}
101
102std::shared_ptr<ScaleDefinition> ScaleManager::LoadScale(const std::string& code)
103{
104 auto scale = std::make_shared<ScaleDefinition>();
105
106 // Try workspace first: workspace/scales/<code>/<code>.json
107 if (!mWorkspaceScalesPath.empty()) {
108 if (scale->LoadFromScalesDir(mWorkspaceScalesPath, code)) {
109 return scale;
110 }
111 }
112
113 // Fall back to library: media/apps/scales/definitions/<code>/<code>.json
114 if (scale->LoadFromScalesDir(mBatteryDefinitionsPath, code)) {
115 return scale;
116 }
117
118 return nullptr;
119}
120
121bool ScaleManager::SaveScale(std::shared_ptr<ScaleDefinition> scale)
122{
123 if (!scale) {
124 return false;
125 }
126
127 // Save to per-scale directory in workspace: workspace/scales/<code>/
128 // Primary format is .osd (single-file bundle); no separate .json or translation files needed.
129 std::string scaleDir = GetScalesPath() + "/" + scale->GetScaleInfo().code;
130 try {
131 fs::create_directories(scaleDir);
132 } catch (const std::exception& e) {
133 printf("Error creating scale directory: %s\n", e.what());
134 return false;
135 }
136
137 return scale->ExportToOSD(scaleDir);
138}
139
140bool ScaleManager::DeleteScale(const std::string& code)
141{
142 try {
143 // Delete definition file
144 std::string defPath = GetDefinitionPath(code);
145 if (fs::exists(defPath)) {
146 fs::remove(defPath);
147 }
148
149 // Delete all translation files
150 auto transFiles = GetTranslationFiles(code);
151 for (const auto& transFile : transFiles) {
152 if (fs::exists(transFile)) {
153 fs::remove(transFile);
154 }
155 }
156
157 return true;
158 } catch (const std::exception& e) {
159 return false;
160 }
161}
162
163bool ScaleManager::ExportToBattery(std::shared_ptr<ScaleDefinition> scale)
164{
165 return SaveScale(scale);
166}
167
168std::shared_ptr<ScaleDefinition> ScaleManager::ImportFromFile(const std::string& filePath)
169{
170 return ScaleDefinition::LoadFromFile(filePath);
171}
172
173std::vector<ScaleManager::LooseOSDEntry> ScaleManager::GetLooseOSDEntries() const
174{
175 std::vector<LooseOSDEntry> entries;
176 if (mWorkspaceScalesPath.empty()) return entries;
177
178 try {
179 if (!fs::exists(mWorkspaceScalesPath)) return entries;
180 for (const auto& entry : fs::directory_iterator(mWorkspaceScalesPath)) {
181 if (!entry.is_regular_file()) continue;
182 std::string filename = entry.path().filename().string();
183 if (filename.size() < 5 || filename.substr(filename.size() - 4) != ".osd") continue;
184
185 // Only treat as loose if there is no matching <code>/ subdirectory already
186 std::string stemCode = filename.substr(0, filename.size() - 4);
187 std::string subdirPath = mWorkspaceScalesPath + "/" + stemCode;
188 if (fs::exists(subdirPath) && fs::is_directory(subdirPath)) continue;
189
190 // Read code/name from the .osd bundle
191 try {
192 std::ifstream f(entry.path().string());
193 if (!f.is_open()) continue;
194 json bundle = json::parse(f);
195 if (!bundle.contains("definition")) continue;
196
198 e.path = entry.path().string();
199 if (bundle["definition"].contains("scale_info")) {
200 auto& si = bundle["definition"]["scale_info"];
201 if (si.contains("code")) e.code = si["code"].get<std::string>();
202 if (si.contains("name")) e.name = si["name"].get<std::string>();
203 }
204 if (e.code.empty()) e.code = stemCode;
205 if (e.name.empty()) e.name = e.code;
206 entries.push_back(e);
207 } catch (...) {}
208 }
209 } catch (...) {}
210
211 return entries;
212}
213
214std::shared_ptr<ScaleDefinition> ScaleManager::InstallLooseOSD(const std::string& osdPath)
215{
216 // Load the .osd to determine the scale code
217 auto scale = ScaleDefinition::LoadFromOSDFile(osdPath);
218 if (!scale) {
219 printf("InstallLooseOSD: failed to load OSD from: %s\n", osdPath.c_str());
220 return nullptr;
221 }
222 std::string code = scale->GetScaleInfo().code;
223 if (code.empty()) {
224 printf("InstallLooseOSD: scale has no code in: %s\n", osdPath.c_str());
225 return nullptr;
226 }
227
228 // Create workspace/scales/<code>/ directory
229 std::string targetDir = mWorkspaceScalesPath + "/" + code;
230 std::string targetFile = targetDir + "/" + code + ".osd";
231 try {
232 fs::create_directories(targetDir);
233 fs::copy_file(osdPath, targetFile, fs::copy_options::overwrite_existing);
234 fs::remove(osdPath); // Remove the loose file now that it is installed
235 printf("Installed OSD '%s' to: %s\n", code.c_str(), targetFile.c_str());
236 } catch (const std::exception& e) {
237 printf("InstallLooseOSD error: %s\n", e.what());
238 return nullptr;
239 }
240
241 return scale;
242}
243
244std::string ScaleManager::GetDefinitionPath(const std::string& code) const
245{
246 // Check workspace: workspace/scales/<code>/<code>.json
247 if (!mWorkspaceScalesPath.empty()) {
248 std::string path = mWorkspaceScalesPath + "/" + code + "/" + code + ".json";
249 if (fs::exists(path)) return path;
250 }
251 // Check library: media/apps/scales/definitions/<code>/<code>.json
252 {
253 std::string path = mBatteryDefinitionsPath + "/" + code + "/" + code + ".json";
254 if (fs::exists(path)) return path;
255 }
256 // Return expected library path even if not found
257 return mBatteryDefinitionsPath + "/" + code + "/" + code + ".json";
258}
259
260std::string ScaleManager::GetOSDPath(const std::string& code) const
261{
262 // Check workspace: workspace/scales/<code>/<code>.osd
263 if (!mWorkspaceScalesPath.empty()) {
264 std::string path = mWorkspaceScalesPath + "/" + code + "/" + code + ".osd";
265 if (fs::exists(path)) return path;
266 }
267 // Check library definitions: media/apps/scales/definitions/<code>/<code>.osd
268 {
269 std::string path = mBatteryDefinitionsPath + "/" + code + "/" + code + ".osd";
270 if (fs::exists(path)) return path;
271 }
272 return "";
273}
274
275std::string ScaleManager::GetTranslationPath(const std::string& code, const std::string& lang) const
276{
277 // Check workspace: new format first, then old
278 if (!mWorkspaceScalesPath.empty()) {
279 std::string newPath = mWorkspaceScalesPath + "/" + code + "/" + code + "." + lang + ".json";
280 if (fs::exists(newPath)) return newPath;
281 std::string oldPath = mWorkspaceScalesPath + "/" + code + "/" + code + ".pbl-" + lang + ".json";
282 if (fs::exists(oldPath)) return oldPath;
283 }
284 // Check library: new format first, then old
285 {
286 std::string newPath = mBatteryDefinitionsPath + "/" + code + "/" + code + "." + lang + ".json";
287 if (fs::exists(newPath)) return newPath;
288 std::string oldPath = mBatteryDefinitionsPath + "/" + code + "/" + code + ".pbl-" + lang + ".json";
289 if (fs::exists(oldPath)) return oldPath;
290 }
291 // Return expected path in new format
292 return mBatteryDefinitionsPath + "/" + code + "/" + code + "." + lang + ".json";
293}
294
295bool ScaleManager::ScaleExists(const std::string& code) const
296{
297 return fs::exists(GetDefinitionPath(code));
298}
299
300std::vector<std::string> ScaleManager::GetTranslationFiles(const std::string& code) const
301{
302 std::set<std::string> langsSeen; // Track languages to avoid duplicates
303 std::vector<std::string> files;
304
305 std::string newPrefix = code + ".";
306 std::string oldPrefix = code + ".pbl-";
307
308 auto scanDir = [&](const std::string& dirPath) {
309 try {
310 if (!fs::exists(dirPath)) return;
311 for (const auto& entry : fs::directory_iterator(dirPath)) {
312 if (!entry.is_regular_file()) continue;
313 std::string filename = entry.path().filename().string();
314 if (filename.size() < 5 || filename.substr(filename.size() - 5) != ".json") continue;
315
316 std::string lang;
317
318 // Try new format: CODE.lang.json
319 if (filename.find(newPrefix) == 0) {
320 lang = filename.substr(newPrefix.length(), filename.size() - newPrefix.length() - 5);
321 // Guard: lang must be non-empty and must not contain "pbl-" (that's old format)
322 if (lang.empty() || lang.find("pbl-") == 0) {
323 // Try old format instead
324 if (filename.find(oldPrefix) == 0) {
325 lang = filename.substr(oldPrefix.length(), filename.size() - oldPrefix.length() - 5);
326 } else {
327 continue;
328 }
329 }
330 }
331 // Try old format: CODE.pbl-lang.json
332 else if (filename.find(oldPrefix) == 0) {
333 lang = filename.substr(oldPrefix.length(), filename.size() - oldPrefix.length() - 5);
334 } else {
335 continue;
336 }
337
338 if (!lang.empty() && langsSeen.find(lang) == langsSeen.end()) {
339 langsSeen.insert(lang);
340 files.push_back(entry.path().string());
341 }
342 }
343 } catch (const std::exception& e) {
344 // Continue on error
345 }
346 };
347
348 // Scan workspace first (higher priority), then library
349 if (!mWorkspaceScalesPath.empty()) {
350 scanDir(mWorkspaceScalesPath + "/" + code);
351 }
352 scanDir(mBatteryDefinitionsPath + "/" + code);
353
354 return files;
355}
356
358{
359 ScaleMetadata meta;
360 meta.code = code;
361 meta.questionCount = 0;
362
363 try {
364 std::string defPath = GetDefinitionPath(code);
365 if (fs::exists(defPath)) {
366 // Load from .json definition file
367 std::ifstream file(defPath);
368 if (!file.is_open()) return meta;
369
370 json j;
371 file >> j;
372
373 if (j.contains("scale_info")) {
374 auto& info = j["scale_info"];
375 if (info.contains("name")) meta.name = info["name"];
376 if (info.contains("description")) meta.description = info["description"];
377 if (info.contains("author")) meta.author = info["author"];
378 }
379 if (j.contains("questions") && j["questions"].is_array()) {
380 meta.questionCount = j["questions"].size();
381 } else if (j.contains("items") && j["items"].is_array()) {
382 meta.questionCount = j["items"].size();
383 }
384
385 // Get available languages from separate files
386 auto transFiles = GetTranslationFiles(code);
387 for (const auto& transFile : transFiles) {
388 fs::path p(transFile);
389 std::string filename = p.filename().string();
390 std::string prefix = code + ".pbl-";
391 if (filename.find(prefix) == 0 && filename.size() > prefix.length() + 5) {
392 std::string lang = filename.substr(prefix.length());
393 lang = lang.substr(0, lang.length() - 5);
394 meta.availableLanguages.push_back(lang);
395 }
396 }
397 } else {
398 // Fall back to .osd bundle
399 std::string osdPath = GetOSDPath(code);
400 if (osdPath.empty()) return meta;
401
402 std::ifstream file(osdPath);
403 if (!file.is_open()) return meta;
404
405 json bundle;
406 file >> bundle;
407
408 if (!bundle.contains("definition")) return meta;
409 auto& j = bundle["definition"];
410
411 if (j.contains("scale_info")) {
412 auto& info = j["scale_info"];
413 if (info.contains("name")) meta.name = info["name"];
414 if (info.contains("description")) meta.description = info["description"];
415 if (info.contains("author")) meta.author = info["author"];
416 }
417 if (j.contains("items") && j["items"].is_array()) {
418 meta.questionCount = j["items"].size();
419 } else if (j.contains("questions") && j["questions"].is_array()) {
420 meta.questionCount = j["questions"].size();
421 }
422
423 // Languages come from the translations block inside the bundle
424 if (bundle.contains("translations") && bundle["translations"].is_object()) {
425 for (auto& [lang, _] : bundle["translations"].items()) {
426 meta.availableLanguages.push_back(lang);
427 }
428 }
429 }
430
431 std::sort(meta.availableLanguages.begin(), meta.availableLanguages.end());
432
433 } catch (const std::exception& e) {
434 // Return partial metadata on error
435 }
436
437 return meta;
438}
439
440bool ScaleManager::CreateStudyFromScale(std::shared_ptr<ScaleDefinition> scale,
441 const std::string& workspaceStudiesPath,
442 const std::string& studyName)
443{
444 if (!scale) {
445 printf("Error: Scale is null\n");
446 return false;
447 }
448
449 std::string scaleCode = scale->GetScaleInfo().code;
450
451 // Use provided study name, or default to scale code
452 std::string actualStudyName = studyName.empty() ? scaleCode : studyName;
453 std::string studyPath = workspaceStudiesPath + "/" + actualStudyName;
454
455 printf("Creating scale study at: %s\n", studyPath.c_str());
456
457 try {
458 // Check if study already exists - if so, remove it (overwrite mode)
459 if (fs::exists(studyPath)) {
460 printf("Removing existing study directory: %s\n", studyPath.c_str());
461 fs::remove_all(studyPath);
462 }
463
464 // Create study using Study::CreateNew (use study name, not scale code)
465 auto study = Study::CreateNew(studyPath, actualStudyName);
466 if (!study) {
467 printf("Error: Failed to create study structure\n");
468 return false;
469 }
470
471 // Set study metadata from scale
472 study->SetDescription("Scale: " + scale->GetScaleInfo().name);
473 if (!scale->GetScaleInfo().citation.empty()) {
474 study->SetAuthor(scale->GetScaleInfo().citation);
475 }
476
477 // Create test directory with standard PEBL structure matching ScaleRunner's expected layout:
478 // tests/{code}/
479 // {code}.pbl <- ScaleRunner.pbl (renamed)
480 // definitions/ <- {code}.json
481 // translations/ <- {code}.{lang}.json
482 // params/ <- schema + par files
483 std::string testPath = studyPath + "/tests/" + scaleCode;
484 std::string paramsPath = testPath + "/params";
485 fs::create_directories(testPath + "/definitions");
486 fs::create_directories(testPath + "/translations");
487 fs::create_directories(paramsPath);
488
489 // Copy ScaleRunner.pbl and rename to match scale code
490 std::string scaleRunnerSource = mBatteryScalesPath + "/ScaleRunner.pbl";
491 std::string scaleRunnerDest = testPath + "/" + scaleCode + ".pbl";
492
493 if (!fs::exists(scaleRunnerSource)) {
494 printf("Error: ScaleRunner.pbl not found at: %s\n", scaleRunnerSource.c_str());
495 return false;
496 }
497
498 fs::copy_file(scaleRunnerSource, scaleRunnerDest);
499 printf("Copied ScaleRunner.pbl to %s\n", scaleRunnerDest.c_str());
500
501 // Export definition to definitions/ and translations to translations/
502 if (!scale->ExportToJSON(testPath + "/definitions", testPath + "/translations")) {
503 printf("Error: Failed to export scale files\n");
504 return false;
505 }
506
507 printf("Exported scale definition and translations\n");
508
509 // Create .pbl.about.txt from README.md if available, else from metadata
510 {
511 std::string aboutPath = testPath + "/" + scaleCode + ".pbl.about.txt";
512 std::string readmePath = mBatteryDefinitionsPath + "/" + scaleCode + "/README.md";
513 bool copied = false;
514
515 if (fs::exists(readmePath)) {
516 try {
517 fs::copy_file(readmePath, aboutPath, fs::copy_options::overwrite_existing);
518 printf("Created about file from README.md: %s\n", aboutPath.c_str());
519 copied = true;
520 } catch (const std::exception& e) {
521 printf("Warning: Failed to copy README.md: %s\n", e.what());
522 }
523 }
524
525 // Also check workspace for README.md
526 if (!copied && !mWorkspaceScalesPath.empty()) {
527 std::string wsReadme = mWorkspaceScalesPath + "/" + scaleCode + "/README.md";
528 if (fs::exists(wsReadme)) {
529 try {
530 fs::copy_file(wsReadme, aboutPath, fs::copy_options::overwrite_existing);
531 printf("Created about file from workspace README.md: %s\n", aboutPath.c_str());
532 copied = true;
533 } catch (const std::exception& e) {
534 printf("Warning: Failed to copy workspace README.md: %s\n", e.what());
535 }
536 }
537 }
538
539 // Fallback: generate from scale metadata
540 if (!copied) {
541 std::ofstream aboutFile(aboutPath);
542 if (aboutFile.is_open()) {
543 const auto& info = scale->GetScaleInfo();
544 aboutFile << info.name << std::endl;
545 aboutFile << std::endl;
546 if (!info.description.empty()) {
547 aboutFile << info.description << std::endl;
548 aboutFile << std::endl;
549 }
550 if (!info.citation.empty()) {
551 aboutFile << "Citation:" << std::endl;
552 aboutFile << info.citation << std::endl;
553 aboutFile << std::endl;
554 }
555 if (!info.license.empty()) {
556 aboutFile << "License: " << info.license << std::endl;
557 }
558 aboutFile.close();
559 printf("Created about file from metadata: %s\n", aboutPath.c_str());
560 }
561 }
562 }
563
564 // Generate schema and parameter files from the scale's OSD parameters block
565 GenerateSchemaFiles(*scale, testPath);
566
567 // Generate screenshot for the deployed test
568 GenerateScreenshot(scaleCode, testPath);
569
570 // Add test to study
571 Test test;
572 test.testName = scaleCode; // The actual .pbl filename (e.g., "MOCI")
573 test.displayName = scale->GetScaleInfo().name; // Human-readable name
574 test.testPath = scaleCode; // Directory name (e.g., "MOCI")
575 test.included = true;
576
577 study->AddTest(test);
578
579 // Save study
580 if (!study->Save()) {
581 printf("Error: Failed to save study-info.json\n");
582 return false;
583 }
584
585 // Create default chain with the scale test
586 std::string chainPath = studyPath + "/chains/Main.json";
587 auto defaultChain = Chain::CreateNew(chainPath, "Main",
588 "Default chain for " + scale->GetScaleInfo().name);
589 if (defaultChain) {
590 // Add the scale test to the chain
591 ChainItem testItem(ItemType::Test);
592 testItem.testName = scaleCode;
593 testItem.paramVariant = "default";
594 testItem.language = "en";
595 testItem.randomGroup = 0;
596
597 defaultChain->AddItem(testItem);
598
599 if (defaultChain->Save()) {
600 printf("Created default chain with test: Main.json\n");
601 } else {
602 printf("Warning: Failed to save default chain\n");
603 }
604 } else {
605 printf("Warning: Failed to create default chain\n");
606 }
607
608 printf("Successfully created scale study: %s\n", scaleCode.c_str());
609 return true;
610
611 } catch (const std::exception& e) {
612 printf("Exception while creating scale study: %s\n", e.what());
613 return false;
614 }
615}
616
617bool ScaleManager::AddScaleToStudy(std::shared_ptr<ScaleDefinition> scale,
618 const std::string& studyPath)
619{
620 if (!scale) {
621 printf("Error: Scale is null\n");
622 return false;
623 }
624
625 std::string scaleCode = scale->GetScaleInfo().code;
626 printf("Adding scale '%s' to study at: %s\n", scaleCode.c_str(), studyPath.c_str());
627
628 try {
629 // Load the existing study
630 auto study = Study::LoadFromDirectory(studyPath);
631 if (!study) {
632 printf("Error: Failed to load study from: %s\n", studyPath.c_str());
633 return false;
634 }
635
636 // Check if this scale/test already exists in the study
637 std::string testPath = studyPath + "/tests/" + scaleCode;
638 if (fs::exists(testPath)) {
639 printf("Removing existing test directory: %s\n", testPath.c_str());
640 fs::remove_all(testPath);
641 }
642
643 // Create test directory with standard PEBL structure matching ScaleRunner's expected layout:
644 // tests/{code}/
645 // {code}.pbl <- ScaleRunner.pbl (renamed)
646 // definitions/ <- {code}.json
647 // translations/ <- {code}.{lang}.json
648 // params/ <- schema + par files
649 std::string paramsPath = testPath + "/params";
650 fs::create_directories(testPath + "/definitions");
651 fs::create_directories(testPath + "/translations");
652 fs::create_directories(paramsPath);
653
654 // Copy ScaleRunner.pbl
655 std::string scaleRunnerSource = mBatteryScalesPath + "/ScaleRunner.pbl";
656 std::string scaleRunnerDest = testPath + "/" + scaleCode + ".pbl";
657
658 if (!fs::exists(scaleRunnerSource)) {
659 printf("Error: ScaleRunner.pbl not found at: %s\n", scaleRunnerSource.c_str());
660 return false;
661 }
662
663 fs::copy_file(scaleRunnerSource, scaleRunnerDest);
664
665 // Export definition to definitions/ and translations to translations/
666 if (!scale->ExportToJSON(testPath + "/definitions", testPath + "/translations")) {
667 printf("Error: Failed to export scale files\n");
668 return false;
669 }
670
671 // Create .pbl.about.txt from README.md if available, else from metadata
672 {
673 std::string aboutPath = testPath + "/" + scaleCode + ".pbl.about.txt";
674 std::string readmePath = mBatteryDefinitionsPath + "/" + scaleCode + "/README.md";
675 bool copied = false;
676
677 if (fs::exists(readmePath)) {
678 try {
679 fs::copy_file(readmePath, aboutPath, fs::copy_options::overwrite_existing);
680 copied = true;
681 } catch (const std::exception&) {}
682 }
683
684 if (!copied && !mWorkspaceScalesPath.empty()) {
685 std::string wsReadme = mWorkspaceScalesPath + "/" + scaleCode + "/README.md";
686 if (fs::exists(wsReadme)) {
687 try {
688 fs::copy_file(wsReadme, aboutPath, fs::copy_options::overwrite_existing);
689 copied = true;
690 } catch (const std::exception&) {}
691 }
692 }
693
694 if (!copied) {
695 std::ofstream aboutFile(aboutPath);
696 if (aboutFile.is_open()) {
697 const auto& info = scale->GetScaleInfo();
698 aboutFile << info.name << std::endl;
699 aboutFile << std::endl;
700 if (!info.description.empty()) {
701 aboutFile << info.description << std::endl;
702 aboutFile << std::endl;
703 }
704 if (!info.citation.empty()) {
705 aboutFile << "Citation:" << std::endl;
706 aboutFile << info.citation << std::endl;
707 }
708 aboutFile.close();
709 }
710 }
711 }
712
713 // Generate schema and parameter files from the scale's OSD parameters block
714 GenerateSchemaFiles(*scale, testPath);
715
716 // Generate screenshot for the deployed test
717 GenerateScreenshot(scaleCode, testPath);
718
719 // Add test to study (if not already present)
720 if (!study->GetTest(scaleCode)) {
721 Test test;
722 test.testName = scaleCode;
723 test.displayName = scale->GetScaleInfo().name;
724 test.testPath = scaleCode;
725 test.included = true;
726 study->AddTest(test);
727 }
728
729 // Save study
730 if (!study->Save()) {
731 printf("Error: Failed to save study-info.json\n");
732 return false;
733 }
734
735 printf("Successfully added scale '%s' to study\n", scaleCode.c_str());
736 return true;
737
738 } catch (const std::exception& e) {
739 printf("Exception while adding scale to study: %s\n", e.what());
740 return false;
741 }
742}
743
744bool ScaleManager::GenerateScreenshot(const std::string& scaleCode, const std::string& testPath)
745{
746 std::string screenshotDest = testPath + "/" + scaleCode + ".pbl.png";
747
748 // If screenshot already exists at destination, skip generation
749 if (fs::exists(screenshotDest)) {
750 printf("Screenshot already exists: %s\n", screenshotDest.c_str());
751 return true;
752 }
753
754 // First check for pre-existing screenshot in library definitions
755 std::string libraryScreenshot = mBatteryDefinitionsPath + "/" + scaleCode + "/" + scaleCode + ".pbl.png";
756 if (fs::exists(libraryScreenshot)) {
757 try {
758 fs::copy_file(libraryScreenshot, screenshotDest, fs::copy_options::overwrite_existing);
759 printf("Copied existing screenshot from library: %s\n", screenshotDest.c_str());
760 return true;
761 } catch (const std::exception& e) {
762 printf("Warning: Failed to copy library screenshot: %s\n", e.what());
763 }
764 }
765
766 // Check workspace definitions
767 if (!mWorkspaceDefinitionsPath.empty()) {
768 std::string wsScreenshot = mWorkspaceDefinitionsPath + "/" + scaleCode + "/" + scaleCode + ".pbl.png";
769 if (fs::exists(wsScreenshot)) {
770 try {
771 fs::copy_file(wsScreenshot, screenshotDest, fs::copy_options::overwrite_existing);
772 printf("Copied existing screenshot from workspace: %s\n", screenshotDest.c_str());
773 return true;
774 } catch (const std::exception& e) {
775 printf("Warning: Failed to copy workspace screenshot: %s\n", e.what());
776 }
777 }
778 }
779
780 // No pre-existing screenshot - generate one using scale-screenshot.pbl
781 // Find the PEBL binary (same directory as the launcher executable)
782 std::string peblPath;
783#ifdef _WIN32
784 char exeBuf[MAX_PATH];
785 GetModuleFileNameA(NULL, exeBuf, MAX_PATH);
786 std::string exeStr(exeBuf);
787 size_t lastSlash = exeStr.find_last_of("\\/");
788 if (lastSlash != std::string::npos) {
789 peblPath = exeStr.substr(0, lastSlash + 1) + "pebl2.exe";
790 } else {
791 peblPath = "pebl2.exe";
792 }
793#else
794 char exeBuf[1024];
795 ssize_t len = readlink("/proc/self/exe", exeBuf, sizeof(exeBuf) - 1);
796 if (len != -1) {
797 exeBuf[len] = '\0';
798 std::string exeStr(exeBuf);
799 size_t lastSlash = exeStr.find_last_of('/');
800 if (lastSlash != std::string::npos) {
801 peblPath = exeStr.substr(0, lastSlash + 1) + "pebl2";
802 } else {
803 peblPath = "pebl2";
804 }
805 } else {
806 peblPath = "pebl2";
807 }
808#endif
809
810 if (!fs::exists(peblPath)) {
811 printf("Warning: PEBL executable not found at: %s\n", peblPath.c_str());
812 return false;
813 }
814
815 // Find scale-screenshot.pbl (one directory up from scales/)
816 std::string screenshotScript = mBatteryScalesPath + "/../scale-screenshot.pbl";
817 if (!fs::exists(screenshotScript)) {
818 printf("Warning: scale-screenshot.pbl not found at: %s\n", screenshotScript.c_str());
819 return false;
820 }
821
822 // Resolve to absolute paths for the command
823 std::string absScript = fs::canonical(screenshotScript).string();
824 std::string absTestPath = fs::canonical(testPath).string();
825
826 // Determine working directory based on directory layout:
827 // - Study deployment: testPath has definitions/<code>.json → run from testPath
828 // - Workspace save: testPath has <code>.json directly → run from parent so script finds <code>/<code>.json
829 std::string cwd = absTestPath;
830 bool workspaceLayout = false;
831
832 std::string directJson = absTestPath + "/" + scaleCode + ".json";
833 std::string defsJson = absTestPath + "/definitions/" + scaleCode + ".json";
834
835 if (fs::exists(directJson) && !fs::exists(defsJson)) {
836 // Workspace layout: <code>/<code>.json — run from parent directory
837 cwd = fs::path(absTestPath).parent_path().string();
838 workspaceLayout = true;
839 }
840
841 // Run: cd <cwd> && pebl2 scale-screenshot.pbl -v <scaleCode>
842#ifdef _WIN32
843 std::string cmd = "cd /d \"" + cwd + "\" && \"" + peblPath + "\" \"" + absScript + "\" -v " + scaleCode + " >nul 2>&1";
844#else
845 std::string cmd = "cd \"" + cwd + "\" && \"" + peblPath + "\" \"" + absScript + "\" -v " + scaleCode + " >/dev/null 2>&1";
846#endif
847
848 printf("Generating screenshot: %s\n", cmd.c_str());
849 int ret = system(cmd.c_str());
850
851 // If workspace layout, output lands in cwd/<code>.pbl.png — move to testPath
852 if (workspaceLayout) {
853 std::string outputFile = cwd + "/" + scaleCode + ".pbl.png";
854 if (fs::exists(outputFile)) {
855 try {
856 fs::rename(outputFile, screenshotDest);
857 } catch (const std::exception&) {
858 // rename may fail across filesystems, fall back to copy+remove
859 try {
860 fs::copy_file(outputFile, screenshotDest, fs::copy_options::overwrite_existing);
861 fs::remove(outputFile);
862 } catch (const std::exception& e) {
863 printf("Warning: Failed to move screenshot: %s\n", e.what());
864 }
865 }
866 }
867 }
868
869 if (fs::exists(screenshotDest)) {
870 printf("Screenshot generated: %s\n", screenshotDest.c_str());
871 return true;
872 } else {
873 printf("Warning: Screenshot generation failed (exit code %d)\n", ret);
874 return false;
875 }
876}
877
878// Generate .pbl.schema.json and .pbl.par.json from the scale's OSD parameters block.
879// Called at scale deployment time (CreateStudyFromScale / AddScaleToStudy) so all
880// OSD-defined parameters are immediately visible in the parameter editor.
881// The par.json is created fresh; the schema is always regenerated from the definition.
882bool ScaleManager::GenerateSchemaFiles(const ScaleDefinition& scale, const std::string& testPath)
883{
884 const std::string& code = scale.GetScaleInfo().code;
885 const std::string& name = scale.GetScaleInfo().name;
886 const auto& osdParams = scale.GetParameters();
887 std::string paramsPath = testPath + "/params";
888
889 fs::create_directories(paramsPath);
890
891 // Base parameter names handled specially — not iterated from OSD map
892 static const std::set<std::string> baseNames = {"scale", "shuffle_questions", "show_header"};
893
894 // Helper: convert a string default value to the right JSON type
895 auto jsonDefault = [](const std::string& type, const std::string& value) -> nlohmann::json {
896 if (type == "boolean" || type == "integer") {
897 try { return std::stoi(value); } catch (...) {}
898 } else if (type == "float") {
899 try { return std::stod(value); } catch (...) {}
900 }
901 return value;
902 };
903
904 // Helper: look up a base param's default from OSD, falling back to hardcoded default
905 auto osdDefault = [&](const std::string& pname, nlohmann::json fallback) -> nlohmann::json {
906 auto it = osdParams.find(pname);
907 if (it != osdParams.end() && !it->second.defaultValue.empty()) {
908 return jsonDefault(it->second.type, it->second.defaultValue);
909 }
910 return fallback;
911 };
912
913 nlohmann::json schemaParams = nlohmann::json::array();
914 nlohmann::json parDefaults = nlohmann::json::object();
915
916 // 1. scale — hidden, always locked to this scale's code
917 schemaParams.push_back({
918 {"name", "scale"},
919 {"type", "string"},
920 {"default", code},
921 {"description", "OSD scale code (reads definitions/{code}.json)"},
922 {"hidden", true}
923 });
924 parDefaults["scale"] = code;
925
926 // 2. shuffle_questions — default from OSD if declared, else 0
927 {
928 auto def = osdDefault("shuffle_questions", 0);
929 schemaParams.push_back({
930 {"name", "shuffle_questions"},
931 {"type", "boolean"},
932 {"default", def},
933 {"description", "Randomize item order within randomization groups"},
934 {"options", nlohmann::json::array({0, 1})}
935 });
936 parDefaults["shuffle_questions"] = def;
937 }
938
939 // 3. show_header — default from OSD if declared, else 1
940 {
941 auto def = osdDefault("show_header", 1);
942 schemaParams.push_back({
943 {"name", "show_header"},
944 {"type", "boolean"},
945 {"default", def},
946 {"description", "Display the scale title header above the questionnaire"},
947 {"options", nlohmann::json::array({0, 1})}
948 });
949 parDefaults["show_header"] = def;
950 }
951
952 // 4. OSD-defined extra parameters (text substitution vars, feature flags, etc.)
953 for (const auto& [pname, pdef] : osdParams) {
954 if (baseNames.count(pname)) continue; // already handled above
955
956 nlohmann::json sp;
957 sp["name"] = pname;
958 sp["type"] = pdef.type.empty() ? "string" : pdef.type;
959
960 if (!pdef.defaultValue.empty()) {
961 auto def = jsonDefault(pdef.type, pdef.defaultValue);
962 sp["default"] = def;
963 parDefaults[pname] = def;
964 }
965 if (!pdef.description.empty()) {
966 sp["description"] = pdef.description;
967 }
968 if (!pdef.options.empty()) {
969 nlohmann::json opts = nlohmann::json::array();
970 for (const auto& o : pdef.options) opts.push_back(o);
971 sp["options"] = opts;
972 } else if (pdef.type == "boolean") {
973 sp["options"] = nlohmann::json::array({0, 1});
974 }
975
976 schemaParams.push_back(sp);
977 }
978
979 // 5. Selectable dimension enable/disable checkboxes
980 // Build set of param names already covered (base + OSD explicit) to avoid duplicates
981 std::set<std::string> coveredParams = baseNames;
982 for (const auto& [pname, _] : osdParams) coveredParams.insert(pname);
983
984 for (const auto& dim : scale.GetDimensions()) {
985 if (!dim.selectable) continue;
986
987 // Determine param name: use enabled_param if set, else auto-name "do_{id}"
988 std::string pname = dim.enabled_param.empty() ? ("do_" + dim.id) : dim.enabled_param;
989 if (coveredParams.count(pname)) continue; // already defined explicitly
990 coveredParams.insert(pname);
991
992 int defVal = dim.default_enabled ? 1 : 0;
993 schemaParams.push_back({
994 {"name", pname},
995 {"type", "boolean"},
996 {"default", defVal},
997 {"description", "Include " + dim.name + " dimension"},
998 {"options", nlohmann::json::array({0, 1})}
999 });
1000 parDefaults[pname] = defVal;
1001 }
1002
1003 // Write schema
1004 nlohmann::json schema = {
1005 {"test", code},
1006 {"version", "1.0"},
1007 {"description", name + " — auto-generated from scale definition"},
1008 {"parameters", schemaParams}
1009 };
1010 std::string schemaPath = paramsPath + "/" + code + ".pbl.schema.json";
1011 std::ofstream sf(schemaPath);
1012 if (!sf.is_open()) {
1013 printf("Warning: Failed to write schema file: %s\n", schemaPath.c_str());
1014 return false;
1015 }
1016 sf << schema.dump(2);
1017 sf.close();
1018 printf("Generated schema: %s\n", schemaPath.c_str());
1019
1020 // Write par.json with all defaults (only if not already present)
1021 std::string parPath = paramsPath + "/" + code + ".pbl.par.json";
1022 if (!fs::exists(parPath)) {
1023 std::ofstream pf(parPath);
1024 if (pf.is_open()) {
1025 pf << parDefaults.dump(2);
1026 pf.close();
1027 printf("Generated params: %s\n", parPath.c_str());
1028 }
1029 }
1030
1031 return true;
1032}
#define NULL
Definition BinReloc.cpp:317
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
std::map< std::string, ScaleParameter > & GetParameters()
static std::shared_ptr< ScaleDefinition > LoadFromFile(const std::string &jsonPath)
ScaleInfo & GetScaleInfo()
static std::shared_ptr< ScaleDefinition > CreateNew(const std::string &code)
static std::shared_ptr< ScaleDefinition > LoadFromOSDFile(const std::string &osdPath)
bool ExportToBattery(std::shared_ptr< ScaleDefinition > scale)
bool ScaleExists(const std::string &code) const
std::vector< std::string > GetAvailableScales()
bool AddScaleToStudy(std::shared_ptr< ScaleDefinition > scale, const std::string &studyPath)
bool CreateStudyFromScale(std::shared_ptr< ScaleDefinition > scale, const std::string &workspaceStudiesPath, const std::string &studyName="")
std::string GetDefinitionPath(const std::string &code) const
bool SaveScale(std::shared_ptr< ScaleDefinition > scale)
std::string GetScalesPath() const
std::vector< LooseOSDEntry > GetLooseOSDEntries() const
ScaleManager(const std::string &batteryPath, const std::string &workspacePath="")
bool DeleteScale(const std::string &code)
std::shared_ptr< ScaleDefinition > ImportFromFile(const std::string &filePath)
std::string GetOSDPath(const std::string &code) const
ScaleMetadata GetScaleMetadata(const std::string &code) const
std::string GetTranslationPath(const std::string &code, const std::string &lang) const
std::shared_ptr< ScaleDefinition > CreateScale(const std::string &code)
std::shared_ptr< ScaleDefinition > InstallLooseOSD(const std::string &osdPath)
std::shared_ptr< ScaleDefinition > LoadScale(const std::string &code)
static std::shared_ptr< Study > CreateNew(const std::string &path, const std::string &name, const std::string &author="")
Definition Study.cpp:129
static std::shared_ptr< Study > LoadFromDirectory(const std::string &path)
Definition Study.cpp:100
std::string testName
Definition Chain.h:33
std::string paramVariant
Definition Chain.h:34
std::string language
Definition Chain.h:35
int randomGroup
Definition Chain.h:36
std::string name
std::string code
std::vector< std::string > availableLanguages
Definition Study.h:24
std::string displayName
Definition Study.h:26
std::string testPath
Definition Study.h:27
std::string testName
Definition Study.h:25
bool included
Definition Study.h:28