PEBL 2.2
Psychology Experiment Building Language - Cross-platform psychological experiment development system
ScaleDefinition.cpp
Go to the documentation of this file.
1// ScaleDefinition.cpp - PEBL Scale data model implementation (ScaleRunner format)
2// Copyright (c) 2026 Shane T. Mueller
3// Licensed under GPL
4
5#include "ScaleDefinition.h"
6#include <json.hpp>
7#include <fstream>
8#include <iostream>
9#include <sstream>
10#include <algorithm>
11#include <filesystem>
12
13namespace fs = std::filesystem;
14using json = nlohmann::json;
15
16// Parse a visible_when JSON value into flat conditions
17// Returns true if parsed successfully (even if complex)
18static void ParseVisibleWhen(const json& vw,
19 bool& has_visible_when,
20 std::string& logic,
21 std::vector<VisibleWhenCondition>& conditions,
22 bool& is_complex)
23{
24 has_visible_when = true;
25 logic = "all";
26 conditions.clear();
27 is_complex = false;
28
29 if (vw.is_null()) {
30 has_visible_when = false;
31 return;
32 }
33
34 // Helper lambda to parse a single simple condition object
35 auto parseSimple = [](const json& obj, VisibleWhenCondition& c) -> bool {
36 if (obj.contains("parameter") && obj["parameter"].is_string()) {
37 c.source_type = "parameter";
38 c.source_name = obj["parameter"].get<std::string>();
39 } else if ((obj.contains("item") && obj["item"].is_string()) ||
40 (obj.contains("question") && obj["question"].is_string())) {
41 c.source_type = "item";
42 c.source_name = obj.contains("item") ? obj["item"].get<std::string>() : obj["question"].get<std::string>();
43 } else {
44 return false;
45 }
46 if (obj.contains("operator") && obj["operator"].is_string()) {
47 c.op = obj["operator"].get<std::string>();
48 }
49 if (obj.contains("value")) {
50 if (obj["value"].is_string()) {
51 c.value = obj["value"].get<std::string>();
52 c.is_list = false;
53 } else if (obj["value"].is_array()) {
54 c.is_list = true;
55 for (const auto& v : obj["value"]) {
56 if (v.is_string()) {
57 c.values.push_back(v.get<std::string>());
58 } else {
59 c.values.push_back(v.dump());
60 }
61 }
62 } else {
63 // Scalar number/bool — store as string
64 c.value = obj["value"].dump();
65 c.is_list = false;
66 }
67 }
68 return true;
69 };
70
71 // Case 1: Simple single condition {parameter/item, operator, value}
72 if (vw.contains("parameter") || vw.contains("item") || vw.contains("question")) {
74 if (parseSimple(vw, c)) {
75 conditions.push_back(c);
76 }
77 return;
78 }
79
80 // Case 2: Compound all/any
81 std::string key;
82 if (vw.contains("all")) {
83 key = "all";
84 logic = "all";
85 } else if (vw.contains("any")) {
86 key = "any";
87 logic = "any";
88 } else {
89 // Unknown structure
90 is_complex = true;
91 return;
92 }
93
94 if (!vw[key].is_array()) {
95 is_complex = true;
96 return;
97 }
98
99 // Check each child — if any child is itself an all/any group, mark complex
100 for (const auto& child : vw[key]) {
101 if (child.contains("all") || child.contains("any")) {
102 is_complex = true;
103 conditions.clear();
104 return;
105 }
107 if (parseSimple(child, c)) {
108 conditions.push_back(c);
109 } else {
110 is_complex = true;
111 conditions.clear();
112 return;
113 }
114 }
115}
116
117// Serialize conditions back to JSON
118static json SerializeVisibleWhen(const std::string& logic,
119 const std::vector<VisibleWhenCondition>& conditions)
120{
121 auto condToJson = [](const VisibleWhenCondition& c) -> json {
122 json obj;
123 if (c.source_type == "item") {
124 obj["item"] = c.source_name;
125 } else {
126 obj["parameter"] = c.source_name;
127 }
128 obj["operator"] = c.op;
129 if (c.is_list) {
130 json arr = json::array();
131 for (const auto& v : c.values) {
132 arr.push_back(v);
133 }
134 obj["value"] = arr;
135 } else {
136 obj["value"] = c.value;
137 }
138 return obj;
139 };
140
141 if (conditions.size() == 1) {
142 return condToJson(conditions[0]);
143 }
144
145 json arr = json::array();
146 for (const auto& c : conditions) {
147 arr.push_back(condToJson(c));
148 }
149 return json({{logic, arr}});
150}
151
153 : mDefaultRequired(-1)
154 , mDirty(false)
155 , mSourceIsOSD(false)
156{
157 mDataOutput.individual_file = "{code}-{subnum}.csv";
158 mDataOutput.pooled_file = "{code}-pooled.csv";
159 mDataOutput.report_file = "{code}-report-{subnum}.html";
160}
161
165
166std::shared_ptr<ScaleDefinition> ScaleDefinition::CreateNew(const std::string& code)
167{
168 auto scale = std::make_shared<ScaleDefinition>();
169 scale->mScaleInfo.code = code;
170 scale->mScaleInfo.name = code; // Default to code, user will change
171
172 // Add default English translations for required keys
173 scale->mTranslations["en"]["question_head"] = "Please answer the following questions:";
174 scale->mTranslations["en"]["debrief"] = "Thank you for completing this questionnaire.";
175
176 scale->mDirty = true;
177 return scale;
178}
179
180std::shared_ptr<ScaleDefinition> ScaleDefinition::LoadFromFile(const std::string& jsonPath)
181{
182 auto scale = std::make_shared<ScaleDefinition>();
183 if (!scale->LoadDefinitionJSON(jsonPath)) {
184 return nullptr;
185 }
186 scale->mDirty = false;
187 return scale;
188}
189
190std::shared_ptr<ScaleDefinition> ScaleDefinition::LoadFromOSDFile(const std::string& osdPath)
191{
192 auto scale = std::make_shared<ScaleDefinition>();
193 if (!scale->LoadFromOSDBundlePath(osdPath)) {
194 return nullptr;
195 }
196 scale->mDirty = false;
197 return scale;
198}
199
200bool ScaleDefinition::LoadFromScalesDir(const std::string& basePath, const std::string& scaleCode)
201{
202 // Load from <basePath>/<code>/<code>.json (fall back to <code>.osd bundle)
203 std::string scaleDir = basePath + "/" + scaleCode;
204 std::string defPath = scaleDir + "/" + scaleCode + ".json";
205 std::string osdPath = scaleDir + "/" + scaleCode + ".osd";
206
207 if (fs::exists(defPath)) {
208 if (!LoadDefinitionJSON(defPath)) {
209 return false;
210 }
211 // If a .osd bundle also exists, supplement mParameters from it.
212 // This handles the case where the user edited the .osd directly to add
213 // parameters but the .json was saved before those parameters were added.
214 // The .osd parameters block is the authoritative source for parameter metadata.
215 if (fs::exists(osdPath)) {
216 try {
217 std::ifstream osdFile(osdPath);
218 if (osdFile.is_open()) {
219 json bundle;
220 osdFile >> bundle;
221 if (bundle.contains("definition") && bundle["definition"].contains("parameters")) {
222 for (auto& [key, value] : bundle["definition"]["parameters"].items()) {
223 ScaleParameter param;
224 if (value.contains("type")) param.type = value["type"];
225 if (value.contains("default")) param.defaultValue = value["default"].is_string()
226 ? value["default"].get<std::string>()
227 : value["default"].dump();
228 if (value.contains("description")) param.description = value["description"];
229 if (value.contains("options")) {
230 for (const auto& opt : value["options"]) {
231 param.options.push_back(opt.is_string() ? opt.get<std::string>() : opt.dump());
232 }
233 }
234 // Only add — do not overwrite params already set from .json
235 if (mParameters.find(key) == mParameters.end()) {
236 mParameters[key] = param;
237 }
238 }
239 }
240 }
241 } catch (...) {
242 // OSD supplement is best-effort; don't fail the load
243 }
244 }
245 } else if (fs::exists(osdPath)) {
246 // .osd bundle includes both definition and translations — load all at once
247 if (!LoadFromOSDBundlePath(osdPath)) {
248 return false;
249 }
250 mDirty = false;
251 return true;
252 } else {
253 return false;
254 }
255
256 // Load translations from the same directory
257 // Support both PEBL naming (.pbl-<lang>.json) and OSD format (.<lang>.json)
258 std::string pblPrefix = scaleCode + ".pbl-";
259 std::string defFilename = scaleCode + ".json"; // Skip the definition file itself
260
261 try {
262 if (fs::exists(scaleDir)) {
263 for (const auto& entry : fs::directory_iterator(scaleDir)) {
264 if (entry.is_regular_file()) {
265 std::string filename = entry.path().filename().string();
266
267 // Skip the definition file itself
268 if (filename == defFilename) continue;
269
270 // Try PEBL format: {code}.pbl-{lang}.json
271 if (filename.find(pblPrefix) == 0 &&
272 filename.size() >= 5 &&
273 filename.substr(filename.size() - 5) == ".json") {
274 std::string lang = filename.substr(pblPrefix.length());
275 lang = lang.substr(0, lang.length() - 5);
276 LoadTranslationJSON(entry.path().string(), lang);
277 continue;
278 }
279
280 // Try OSD format: {code}.{lang}.json
281 std::string openPrefix = scaleCode + ".";
282 if (filename.find(openPrefix) == 0 &&
283 filename.size() >= 5 &&
284 filename.substr(filename.size() - 5) == ".json") {
285 std::string lang = filename.substr(openPrefix.length());
286 lang = lang.substr(0, lang.length() - 5);
287 if (!lang.empty() && lang.find('.') == std::string::npos) {
288 LoadTranslationJSON(entry.path().string(), lang);
289 }
290 }
291 }
292 }
293 }
294 } catch (const std::exception& e) {
295 // Continue even if translation loading fails
296 }
297
298 mDirty = false;
299 return true;
300}
301
302bool ScaleDefinition::SaveToFile(const std::string& jsonPath)
303{
304 if (SaveDefinitionJSON(jsonPath)) {
305 mDirty = false;
306 return true;
307 }
308 return false;
309}
310
311bool ScaleDefinition::ExportToJSON(const std::string& definitionsPath, const std::string& translationsPath)
312{
313 // Save definition
314 std::string defFile = definitionsPath + "/" + mScaleInfo.code + ".json";
315 if (!SaveDefinitionJSON(defFile)) {
316 return false;
317 }
318
319 // Save translations in new format only (.{lang}.json)
320 for (const auto& [lang, translations] : mTranslations) {
321 std::string transFile = translationsPath + "/" + mScaleInfo.code + "." + lang + ".json";
322 if (!SaveTranslationJSON(transFile, lang)) {
323 return false;
324 }
325 }
326
327 mDirty = false;
328 return true;
329}
330
331bool ScaleDefinition::ExportToOSD(const std::string& outputDir)
332{
333 // Build an OSD bundle: {"osd_version":"1.0","definition":{...},"translations":{...}}
334 // Builds definition JSON from in-memory model (no dependency on .json file on disk).
335 try {
336 json definition;
337 if (!BuildDefinitionJSONObject(definition)) {
338 printf("ExportToOSD: failed to build definition JSON\n");
339 return false;
340 }
341
342 json translations;
343 for (const auto& [lang, transMap] : mTranslations) {
344 json transJson;
345 for (const auto& [key, value] : transMap) {
346 transJson[key] = value;
347 }
348 translations[lang] = transJson;
349 }
350
351 json bundle;
352 bundle["osd_version"] = "1.0";
353 bundle["definition"] = definition;
354 bundle["translations"] = translations;
355
356 std::string osdFile = outputDir + "/" + mScaleInfo.code + ".osd";
357 std::ofstream f(osdFile);
358 if (!f.is_open()) return false;
359 f << bundle.dump(2);
360 return true;
361 } catch (const std::exception& e) {
362 printf("ExportToOSD error: %s\n", e.what());
363 return false;
364 }
365}
366
367bool ScaleDefinition::LoadDefinitionJSON(const std::string& jsonPath)
368{
369 try {
370 std::ifstream file(jsonPath);
371 if (!file.is_open()) {
372 return false;
373 }
374 json j;
375 file >> j;
376 return ParseDefinitionFromJSON(j);
377 } catch (const std::exception& e) {
378 std::cerr << "Error loading scale definition: " << e.what() << std::endl;
379 return false;
380 }
381}
382
383bool ScaleDefinition::ParseDefinitionFromJSON(const json& j)
384{
385 try {
386 // Preserve raw JSON so unknown fields (pages, response_footer, etc.)
387 // survive round-trip through load/save
388 mRawDefinition = j;
389
390 // Load scale_info
391 if (j.contains("scale_info")) {
392 auto& info = j["scale_info"];
393 if (info.contains("name")) mScaleInfo.name = info["name"];
394 if (info.contains("code")) mScaleInfo.code = info["code"];
395 if (info.contains("abbreviation")) mScaleInfo.abbreviation = info["abbreviation"];
396 if (info.contains("description")) mScaleInfo.description = info["description"];
397 if (info.contains("citation")) mScaleInfo.citation = info["citation"];
398 if (info.contains("license")) mScaleInfo.license = info["license"];
399 if (info.contains("license_explanation")) mScaleInfo.license_explanation = info["license_explanation"];
400 if (info.contains("license_url")) mScaleInfo.license_url = info["license_url"];
401 if (info.contains("version")) mScaleInfo.version = info["version"];
402 if (info.contains("url")) mScaleInfo.url = info["url"];
403 if (info.contains("domain")) mScaleInfo.domain = info["domain"];
404 }
405
406 // Load parameters
407 if (j.contains("parameters")) {
408 mParameters.clear();
409 for (auto& [key, value] : j["parameters"].items()) {
410 ScaleParameter param;
411 if (value.contains("type")) param.type = value["type"];
412 if (value.contains("default")) {
413 if (value["default"].is_string()) {
414 param.defaultValue = value["default"];
415 } else {
416 param.defaultValue = value["default"].dump();
417 }
418 }
419 if (value.contains("description")) param.description = value["description"];
420 if (value.contains("options") && value["options"].is_array()) {
421 for (const auto& opt : value["options"]) {
422 if (opt.is_string()) {
423 param.options.push_back(opt.get<std::string>());
424 } else {
425 param.options.push_back(opt.dump());
426 }
427 }
428 }
429 mParameters[key] = param;
430 }
431 }
432
433 // Load likert_options
434 if (j.contains("likert_options")) {
435 auto& lo = j["likert_options"];
436 if (lo.contains("points")) mLikertOptions.points = lo["points"];
437 if (lo.contains("min")) mLikertOptions.min = lo["min"];
438 if (lo.contains("max")) mLikertOptions.max = lo["max"];
439 if (lo.contains("question_head")) mLikertOptions.question_head = lo["question_head"];
440 if (lo.contains("labels")) {
441 mLikertOptions.labels = lo["labels"].get<std::vector<std::string>>();
442 }
443 }
444
445 // Load default_required
446 if (j.contains("default_required")) {
447 if (j["default_required"].is_boolean()) {
448 mDefaultRequired = j["default_required"].get<bool>() ? 1 : 0;
449 }
450 }
451
452 // Load dimensions
453 if (j.contains("dimensions")) {
454 mDimensions.clear();
455 for (const auto& dj : j["dimensions"]) {
457 if (dj.contains("id")) d.id = dj["id"];
458 if (dj.contains("name")) d.name = dj["name"];
459 if (dj.contains("abbreviation")) d.abbreviation = dj["abbreviation"];
460 if (dj.contains("description")) d.description = dj["description"];
461 if (dj.contains("enabled_param") && !dj["enabled_param"].is_null()) {
462 d.enabled_param = dj["enabled_param"];
463 }
464 if (dj.contains("selectable") && dj["selectable"].is_boolean()) {
465 d.selectable = dj["selectable"].get<bool>();
466 }
467 if (dj.contains("default_enabled") && dj["default_enabled"].is_boolean()) {
468 d.default_enabled = dj["default_enabled"].get<bool>();
469 }
470 if (dj.contains("visible_when") && !dj["visible_when"].is_null()) {
471 bool is_complex = false;
472 ParseVisibleWhen(dj["visible_when"],
475 d.visible_when,
476 is_complex);
477 // Dimensions don't support complex mode — just keep conditions
478 }
479 mDimensions.push_back(d);
480 }
481 }
482
483 // Load questions — "items" is the OSD/OpenScales key; "questions" is the legacy battery key
484 const std::string itemsKey = j.contains("items") ? "items" : (j.contains("questions") ? "questions" : "");
485 if (!itemsKey.empty()) {
486 mQuestions.clear();
487 for (const auto& qj : j[itemsKey]) {
489 if (qj.contains("id")) q.id = qj["id"];
490 if (qj.contains("text_key")) q.text_key = qj["text_key"];
491 if (qj.contains("type")) q.type = qj["type"];
492 if (qj.contains("dimension") && !qj["dimension"].is_null()) {
493 q.dimension = qj["dimension"];
494 }
495 if (qj.contains("coding")) q.coding = qj["coding"];
496 if (qj.contains("random_group")) {
497 q.random_group = qj["random_group"];
498 } else {
499 // Auto-default: inst and items with visible_when get group 0 (fixed)
500 if (q.type == "inst" || qj.contains("visible_when")) {
501 q.random_group = 0;
502 }
503 }
504
505 // Required state
506 if (qj.contains("required")) {
507 if (qj["required"].is_boolean()) {
508 q.required_state = qj["required"].get<bool>() ? 1 : 0;
509 }
510 }
511
512 // Input validation (C9) — new multi-constraint flat format
513 if (qj.contains("validation") && qj["validation"].is_object()) {
514 auto& vj = qj["validation"];
515 auto& val = q.validation;
516 // New format fields
517 if (vj.contains("min_length")) val.min_length = vj["min_length"].get<int>();
518 if (vj.contains("max_length")) val.max_length = vj["max_length"].get<int>();
519 if (vj.contains("min_length_error")) val.min_length_error = vj["min_length_error"].get<std::string>();
520 if (vj.contains("max_length_error")) val.max_length_error = vj["max_length_error"].get<std::string>();
521 if (vj.contains("min_words")) val.min_words = vj["min_words"].get<int>();
522 if (vj.contains("max_words")) val.max_words = vj["max_words"].get<int>();
523 if (vj.contains("min_words_error")) val.min_words_error = vj["min_words_error"].get<std::string>();
524 if (vj.contains("max_words_error")) val.max_words_error = vj["max_words_error"].get<std::string>();
525 if (vj.contains("number_min")) { val.number_min_set = true; val.number_min = vj["number_min"].get<double>(); }
526 if (vj.contains("number_max")) { val.number_max_set = true; val.number_max = vj["number_max"].get<double>(); }
527 if (vj.contains("number_min_error")) val.number_min_error = vj["number_min_error"].get<std::string>();
528 if (vj.contains("number_max_error")) val.number_max_error = vj["number_max_error"].get<std::string>();
529 if (vj.contains("pattern")) val.pattern = vj["pattern"].get<std::string>();
530 if (vj.contains("pattern_error")) val.pattern_error = vj["pattern_error"].get<std::string>();
531 if (vj.contains("min_selected")) val.min_selected = vj["min_selected"].get<int>();
532 if (vj.contains("max_selected")) val.max_selected = vj["max_selected"].get<int>();
533 if (vj.contains("min_selected_error")) val.min_selected_error = vj["min_selected_error"].get<std::string>();
534 if (vj.contains("max_selected_error")) val.max_selected_error = vj["max_selected_error"].get<std::string>();
535 // Legacy single-type format — convert on load
536 if (vj.contains("type")) {
537 std::string legacyType = vj["type"].get<std::string>();
538 double legacyMin = vj.value("min", 0.0);
539 double legacyMax = vj.value("max", 0.0);
540 int legacyValue = vj.value("value", 0);
541 std::string legacyErrKey = vj.value("error_key", std::string(""));
542 if (legacyType == "selection") {
543 if (legacyMin > 0) { val.min_selected = (int)legacyMin; val.min_selected_error = legacyErrKey; }
544 if (legacyMax > 0) { val.max_selected = (int)legacyMax; val.max_selected_error = legacyErrKey; }
545 } else if (legacyType == "number") {
546 if (legacyMin != 0 || legacyMax != 0) {
547 val.number_min_set = true; val.number_min = legacyMin; val.number_min_error = legacyErrKey;
548 val.number_max_set = true; val.number_max = legacyMax; val.number_max_error = legacyErrKey;
549 }
550 } else if (legacyType == "min_length") {
551 val.min_length = legacyValue; val.min_length_error = legacyErrKey;
552 } else if (legacyType == "max_length") {
553 val.max_length = legacyValue; val.max_length_error = legacyErrKey;
554 } else if (legacyType == "pattern") {
555 val.pattern = vj.value("regex", std::string("")); val.pattern_error = legacyErrKey;
556 }
557 }
558 }
559
560 // Conditional display
561 if (qj.contains("visible_when") && !qj["visible_when"].is_null()) {
562 ParseVisibleWhen(qj["visible_when"],
567 }
568
569 // Type-specific fields
570 if (qj.contains("likert_points")) q.likert_points = qj["likert_points"];
571 if (qj.contains("likert_min")) q.likert_min = qj["likert_min"];
572 if (qj.contains("likert_max")) q.likert_max = qj["likert_max"];
573 if (qj.contains("likert_reverse")) q.likert_reverse = qj["likert_reverse"].get<bool>();
574 if (qj.contains("randomize_options")) q.randomize_options = qj["randomize_options"].get<bool>();
575 if (qj.contains("likert_labels") && qj["likert_labels"].is_array()) {
576 q.likert_labels = qj["likert_labels"].get<std::vector<std::string>>();
577 }
578 if (qj.contains("min")) q.min_value = qj["min"];
579 if (qj.contains("max")) q.max_value = qj["max"];
580 if (qj.contains("left")) q.left_label = qj["left"];
581 else if (qj.contains("min_label")) q.left_label = qj["min_label"];
582 if (qj.contains("right")) q.right_label = qj["right"];
583 else if (qj.contains("max_label")) q.right_label = qj["max_label"];
584 if (qj.contains("orientation")) q.vas_orientation = qj["orientation"];
585 if (qj.contains("anchors") && qj["anchors"].is_array()) {
586 q.vas_anchors.clear();
587 for (const auto& a : qj["anchors"]) {
589 if (a.contains("value")) va.value = a["value"].get<double>();
590 if (a.contains("label")) va.label = a["label"];
591 q.vas_anchors.push_back(va);
592 }
593 }
594 if (qj.contains("options")) {
595 // Options can be plain strings OR objects {text_key, value, ...}
596 q.options.clear();
597 for (const auto& opt : qj["options"]) {
598 if (opt.is_string()) {
599 q.options.push_back(opt.get<std::string>());
600 } else if (opt.is_object() && opt.contains("text_key")) {
601 q.options.push_back(opt["text_key"].get<std::string>());
602 }
603 }
604 }
605 if (qj.contains("correct")) {
606 q.correct = qj["correct"].get<std::vector<std::string>>();
607 }
608 if (qj.contains("image")) q.image = qj["image"];
609 if (qj.contains("columns")) {
610 q.columns = qj["columns"].get<std::vector<std::string>>();
611 }
612 if (qj.contains("rows")) {
613 q.rows = qj["rows"].get<std::vector<std::string>>();
614 }
615
616 // Answer alias (S3 answer piping)
617 if (qj.contains("answer_alias") && qj["answer_alias"].is_string())
618 q.answer_alias = qj["answer_alias"];
619 if (qj.contains("question_head") && qj["question_head"].is_string())
620 q.question_head = qj["question_head"];
621
622 // Gate (blocking item)
623 if (qj.contains("gate") && qj["gate"].is_object()) {
624 const auto& gj = qj["gate"];
625 q.has_gate = true;
626 if (gj.contains("required_value")) q.gate_required_value = gj["required_value"];
627 if (gj.contains("operator")) q.gate_operator = gj["operator"];
628 if (gj.contains("value") && gj["value"].is_number()) q.gate_value = gj["value"].get<double>();
629 if (gj.contains("terminate_message_key")) q.gate_terminate_message_key = gj["terminate_message_key"];
630 }
631
632 // Section revisable (section-type only, default true)
633 if (q.type == "section" && qj.contains("revisable") && qj["revisable"].is_boolean())
634 q.revisable = qj["revisable"].get<bool>();
635
636 // Section randomize (section-type only, default false)
637 if (q.type == "section" && qj.contains("randomize") && qj["randomize"].is_object()) {
638 auto& r = qj["randomize"];
639 if (r.contains("method") && r["method"].is_string() && r["method"] == "shuffle")
640 q.section_randomize = true;
641 if (r.contains("fixed") && r["fixed"].is_array())
642 for (auto& f : r["fixed"]) if (f.is_string()) q.section_randomize_fixed.push_back(f);
643 }
644
645 mQuestions.push_back(q);
646 }
647 }
648
649 // Load scoring
650 if (j.contains("scoring")) {
651 mScoring.clear();
652 for (auto& [key, value] : j["scoring"].items()) {
654 if (value.contains("method")) ds.method = value["method"];
655 if (value.contains("items")) {
656 if (value["items"].is_array()) {
657 // Array format: all forward-coded
658 ds.items = value["items"].get<std::vector<std::string>>();
659 for (const auto& id : ds.items) {
660 ds.item_coding[id] = 1;
661 }
662 } else if (value["items"].is_object()) {
663 // Object format: explicit coding per item
664 for (auto& [ikey, ival] : value["items"].items()) {
665 int coding = ival.get<int>();
666 ds.item_coding[ikey] = coding;
667 if (coding != 0) {
668 ds.items.push_back(ikey);
669 }
670 }
671 }
672 }
673 if (value.contains("description")) ds.description = value["description"];
674 if (value.contains("weights")) {
675 for (auto& [wkey, wval] : value["weights"].items()) {
676 ds.weights[wkey] = wval.get<double>();
677 }
678 }
679 // Legacy item_coding field (deprecated, but still supported)
680 if (value.contains("item_coding")) {
681 for (auto& [ikey, ival] : value["item_coding"].items()) {
682 ds.item_coding[ikey] = ival.get<int>();
683 // Also add to items if not already there and non-zero
684 if (ival.get<int>() != 0) {
685 if (std::find(ds.items.begin(), ds.items.end(), ikey) == ds.items.end()) {
686 ds.items.push_back(ikey);
687 }
688 }
689 }
690 }
691 if (value.contains("correct_answers")) {
692 for (auto& [cakey, caval] : value["correct_answers"].items()) {
693 ds.correct_answers[cakey] = caval.get<std::vector<std::string>>();
694 }
695 }
696 if (value.contains("norms") && value["norms"].contains("thresholds")) {
697 for (const auto& t : value["norms"]["thresholds"]) {
698 NormThreshold nt;
699 if (t.contains("min")) nt.min = t["min"].get<double>();
700 if (t.contains("max")) nt.max = t["max"].get<double>();
701 if (t.contains("label_key")) nt.label = t["label_key"].get<std::string>();
702 else if (t.contains("label")) nt.label = t["label"].get<std::string>();
703 ds.norms.push_back(nt);
704 }
705 }
706 if (value.contains("transform") && value["transform"].is_array()) {
707 for (const auto& step : value["transform"]) {
708 TransformStep ts;
709 if (step.contains("op")) ts.op = step["op"].get<std::string>();
710 if (step.contains("value") && step["value"].is_number()) {
711 ts.value = step["value"].get<double>();
712 }
713 ds.transform.push_back(ts);
714 }
715 }
716 if (value.contains("scores")) {
717 ds.scores = value["scores"].get<std::vector<std::string>>();
718 }
719 if (value.contains("value_map")) {
720 for (auto& [vmkey, vmval] : value["value_map"].items()) {
721 if (vmval.is_array()) {
722 ds.value_map[vmkey] = vmval.get<std::vector<double>>();
723 }
724 }
725 }
726 mScoring[key] = ds;
727 }
728 }
729
730 // Load computed variables
731 if (j.contains("computed")) {
732 mComputed.clear();
733 for (auto& [key, value] : j["computed"].items()) {
735 if (value.contains("expression")) cv.expression = value["expression"];
736 if (value.contains("type")) cv.type = value["type"];
737 if (value.contains("norms") && value["norms"].contains("thresholds")) {
738 for (const auto& t : value["norms"]["thresholds"]) {
739 NormThreshold nt;
740 if (t.contains("min")) nt.min = t["min"].get<double>();
741 if (t.contains("max")) nt.max = t["max"].get<double>();
742 if (t.contains("label_key")) nt.label = t["label_key"].get<std::string>();
743 else if (t.contains("label")) nt.label = t["label"].get<std::string>();
744 cv.norms.push_back(nt);
745 }
746 }
747 mComputed[key] = cv;
748 }
749 }
750
751 // Load report
752 if (j.contains("report")) {
753 auto& rj = j["report"];
754 if (rj.contains("template")) mReportConfig.template_type = rj["template"];
755 if (rj.contains("include")) {
756 mReportConfig.include = rj["include"].get<std::vector<std::string>>();
757 }
758 if (rj.contains("header")) mReportConfig.header = rj["header"];
759 if (rj.contains("footer_refs")) {
760 mReportConfig.footer_refs = rj["footer_refs"].get<std::vector<std::string>>();
761 }
762 }
763
764 // Load data_output
765 if (j.contains("data_output")) {
766 auto& dj = j["data_output"];
767 if (dj.contains("individual_file")) mDataOutput.individual_file = dj["individual_file"];
768 if (dj.contains("pooled_file")) mDataOutput.pooled_file = dj["pooled_file"];
769 if (dj.contains("report_file")) mDataOutput.report_file = dj["report_file"];
770 if (dj.contains("columns")) mDataOutput.columns = dj["columns"];
771 if (dj.contains("pooled_columns")) mDataOutput.pooled_columns = dj["pooled_columns"];
772 }
773
774 return true;
775 } catch (const std::exception& e) {
776 std::cerr << "Error parsing scale definition JSON: " << e.what() << std::endl;
777 return false;
778 }
779}
780
781bool ScaleDefinition::LoadFromOSDBundlePath(const std::string& osdPath)
782{
783 try {
784 std::ifstream file(osdPath);
785 if (!file.is_open()) {
786 return false;
787 }
788 json bundle;
789 file >> bundle;
790
791 if (!bundle.contains("definition")) {
792 return false;
793 }
794 if (!ParseDefinitionFromJSON(bundle["definition"])) {
795 return false;
796 }
797
798 // Load translations from the bundle
799 if (bundle.contains("translations") && bundle["translations"].is_object()) {
800 for (auto& [lang, trans] : bundle["translations"].items()) {
801 if (trans.is_object()) {
802 for (auto& [key, value] : trans.items()) {
803 if (value.is_string()) {
804 mTranslations[lang][key] = value.get<std::string>();
805 }
806 }
807 }
808 }
809 }
810
811 mSourceIsOSD = true;
812 return true;
813 } catch (const std::exception& e) {
814 std::cerr << "Error loading OSD bundle: " << e.what() << std::endl;
815 return false;
816 }
817}
818
819bool ScaleDefinition::LoadTranslationJSON(const std::string& jsonPath, const std::string& language)
820{
821 try {
822 std::ifstream file(jsonPath);
823 if (!file.is_open()) {
824 return false;
825 }
826
827 json j;
828 file >> j;
829
830 // Load all key-value pairs for this language
831 for (auto& [key, value] : j.items()) {
832 if (value.is_string()) {
833 mTranslations[language][key] = value.get<std::string>();
834 }
835 }
836
837 return true;
838 } catch (const std::exception& e) {
839 return false;
840 }
841}
842
843bool ScaleDefinition::BuildDefinitionJSONObject(json& outJSON) const
844{
845 try {
846 // Start with raw JSON from original file to preserve unknown fields
847 // (pages, response_footer, min_label/max_label, coding, etc.)
848 json j = mRawDefinition;
849
850 // Overlay known structured fields from C++ model
851
852 // Save scale_info
853 j["scale_info"] = {
854 {"name", mScaleInfo.name},
855 {"code", mScaleInfo.code},
856 {"abbreviation", mScaleInfo.abbreviation},
857 {"description", mScaleInfo.description},
858 {"citation", mScaleInfo.citation},
859 {"license", mScaleInfo.license},
860 {"license_explanation", mScaleInfo.license_explanation},
861 {"license_url", mScaleInfo.license_url},
862 {"version", mScaleInfo.version},
863 {"url", mScaleInfo.url}
864 };
865 if (!mScaleInfo.domain.empty()) {
866 j["scale_info"]["domain"] = mScaleInfo.domain;
867 }
868
869 // Save parameters
870 if (!mParameters.empty()) {
871 j["parameters"] = json::object();
872 for (const auto& [key, param] : mParameters) {
873 j["parameters"][key] = {
874 {"type", param.type},
875 {"default", param.defaultValue},
876 {"description", param.description}
877 };
878 if (!param.options.empty()) {
879 j["parameters"][key]["options"] = param.options;
880 }
881 }
882 }
883
884 // Save likert_options - merge with existing to preserve unknown keys
885 // like response_footer
886 if (!j.contains("likert_options")) {
887 j["likert_options"] = json::object();
888 }
889 j["likert_options"]["points"] = mLikertOptions.points;
890 // Write question_head unless the source explicitly had a likert_options block
891 // without a question_head key (e.g. KDQOL-36 which has "likert_options": {}).
892 // New scales (mRawDefinition is empty) always get question_head written.
893 bool rawHadLikertOptions = mRawDefinition.contains("likert_options");
894 if (!rawHadLikertOptions || mRawDefinition["likert_options"].contains("question_head")) {
895 j["likert_options"]["question_head"] = mLikertOptions.question_head;
896 }
897 j["likert_options"]["labels"] = mLikertOptions.labels;
898 if (mLikertOptions.min != -1) {
899 j["likert_options"]["min"] = mLikertOptions.min;
900 }
901 if (mLikertOptions.max != -1) {
902 j["likert_options"]["max"] = mLikertOptions.max;
903 }
904
905 // Save dimensions
906 if (!mDimensions.empty()) {
907 j["dimensions"] = json::array();
908 for (const auto& d : mDimensions) {
909 json dj = {
910 {"id", d.id},
911 {"name", d.name},
912 {"abbreviation", d.abbreviation},
913 {"description", d.description},
914 {"enabled_param", d.enabled_param.empty() ? nullptr : json(d.enabled_param)}
915 };
916 if (d.selectable) {
917 dj["selectable"] = true;
918 dj["default_enabled"] = d.default_enabled;
919 }
920 if (d.has_visible_when && !d.visible_when.empty()) {
921 dj["visible_when"] = SerializeVisibleWhen(d.visible_when_logic, d.visible_when);
922 }
923 j["dimensions"].push_back(dj);
924 }
925 }
926
927 // Save questions - merge per-question to preserve unknown fields
928 // (min_label, max_label, coding, dimension, etc.)
929 {
930 // Build a map of raw questions by id for merging
931 std::map<std::string, json> rawQuestionMap;
932 const std::string rawKey = mRawDefinition.contains("items") ? "items" :
933 (mRawDefinition.contains("questions") ? "questions" : "");
934 if (!rawKey.empty()) {
935 for (const auto& rq : mRawDefinition[rawKey]) {
936 if (rq.contains("id")) {
937 rawQuestionMap[rq["id"].get<std::string>()] = rq;
938 }
939 }
940 }
941
942 j["items"] = json::array();
943 for (const auto& q : mQuestions) {
944 // Start with raw question data if it exists (preserves unknown fields)
945 json qj;
946 auto it = rawQuestionMap.find(q.id);
947 if (it != rawQuestionMap.end()) {
948 qj = it->second;
949 }
950
951 // Overlay known fields
952 qj["id"] = q.id;
953 qj["text_key"] = q.text_key;
954 qj["type"] = q.type;
955 qj["random_group"] = q.random_group;
956
957 // Required state
958 if (q.required_state >= 0) {
959 qj["required"] = (q.required_state == 1);
960 } else {
961 qj.erase("required"); // Don't write if using default
962 }
963
964 // Input validation (C9) — new multi-constraint flat format
966 nlohmann::json vj;
967 const auto& val = q.validation;
968 if (val.min_length >= 0) { vj["min_length"] = val.min_length; if (!val.min_length_error.empty()) vj["min_length_error"] = val.min_length_error; }
969 if (val.max_length >= 0) { vj["max_length"] = val.max_length; if (!val.max_length_error.empty()) vj["max_length_error"] = val.max_length_error; }
970 if (val.min_words >= 0) { vj["min_words"] = val.min_words; if (!val.min_words_error.empty()) vj["min_words_error"] = val.min_words_error; }
971 if (val.max_words >= 0) { vj["max_words"] = val.max_words; if (!val.max_words_error.empty()) vj["max_words_error"] = val.max_words_error; }
972 if (val.number_min_set) { vj["number_min"] = val.number_min; if (!val.number_min_error.empty()) vj["number_min_error"] = val.number_min_error; }
973 if (val.number_max_set) { vj["number_max"] = val.number_max; if (!val.number_max_error.empty()) vj["number_max_error"] = val.number_max_error; }
974 if (!val.pattern.empty()) { vj["pattern"] = val.pattern; if (!val.pattern_error.empty()) vj["pattern_error"] = val.pattern_error; }
975 if (val.min_selected >= 0) { vj["min_selected"] = val.min_selected; if (!val.min_selected_error.empty()) vj["min_selected_error"] = val.min_selected_error; }
976 if (val.max_selected >= 0) { vj["max_selected"] = val.max_selected; if (!val.max_selected_error.empty()) vj["max_selected_error"] = val.max_selected_error; }
977 qj["validation"] = vj;
978 } else {
979 qj.erase("validation");
980 }
981
982 // Type-specific fields
983 if (q.type == "likert") {
984 if (q.likert_points != -1) {
985 qj["likert_points"] = q.likert_points;
986 }
987 if (q.likert_min != -1) {
988 qj["likert_min"] = q.likert_min;
989 }
990 if (q.likert_max != -1) {
991 qj["likert_max"] = q.likert_max;
992 }
993 if (q.likert_reverse) {
994 qj["likert_reverse"] = true;
995 }
996 if (!q.likert_labels.empty()) {
997 qj["likert_labels"] = q.likert_labels;
998 }
999 }
1000 if (q.type == "vas") {
1001 qj["min"] = q.min_value;
1002 qj["max"] = q.max_value;
1003 if (!q.left_label.empty()) {
1004 qj["min_label"] = q.left_label;
1005 }
1006 if (!q.right_label.empty()) {
1007 qj["max_label"] = q.right_label;
1008 }
1009 if (!q.vas_orientation.empty() && q.vas_orientation != "horizontal") {
1010 qj["orientation"] = q.vas_orientation;
1011 }
1012 if (!q.vas_anchors.empty()) {
1013 nlohmann::json anchorsArr = nlohmann::json::array();
1014 for (const auto& a : q.vas_anchors) {
1015 anchorsArr.push_back({{"value", a.value}, {"label", a.label}});
1016 }
1017 qj["anchors"] = anchorsArr;
1018 }
1019 }
1020 if (q.type == "multi" || q.type == "multicheck") {
1021 if (!q.options.empty()) {
1022 // If the original options were objects {text_key, value, ...}, preserve
1023 // them from mRawDefinition so scoring values are not lost.
1024 bool originalHasObjectOptions = false;
1025 auto rawIt = rawQuestionMap.find(q.id);
1026 if (rawIt != rawQuestionMap.end()
1027 && rawIt->second.contains("options")
1028 && rawIt->second["options"].is_array()
1029 && !rawIt->second["options"].empty()
1030 && rawIt->second["options"][0].is_object()) {
1031 originalHasObjectOptions = true;
1032 }
1033 if (!originalHasObjectOptions) {
1034 qj["options"] = q.options;
1035 }
1036 // If object-format, qj already has the original from rawQuestionMap
1037 }
1038 if (q.randomize_options) {
1039 qj["randomize_options"] = true;
1040 }
1041 if (!q.correct.empty()) {
1042 qj["correct"] = q.correct;
1043 }
1044 }
1045 if (q.type == "image" || q.type == "imageresponse") {
1046 if (!q.image.empty()) {
1047 qj["image"] = q.image;
1048 }
1049 }
1050 if (q.type == "grid") {
1051 if (!q.columns.empty()) {
1052 qj["columns"] = q.columns;
1053 }
1054 if (!q.rows.empty()) {
1055 qj["rows"] = q.rows;
1056 }
1057 }
1058
1059 // Conditional display (visible_when)
1060 if (!q.has_visible_when) {
1061 qj.erase("visible_when");
1062 } else if (q.visible_when_is_complex) {
1063 // Leave qj["visible_when"] untouched (from raw JSON)
1064 } else if (!q.visible_when_simple.empty()) {
1065 qj["visible_when"] = SerializeVisibleWhen(q.visible_when_logic, q.visible_when_simple);
1066 }
1067
1068 // Answer alias (S3 answer piping)
1069 if (!q.answer_alias.empty())
1070 qj["answer_alias"] = q.answer_alias;
1071 if (!q.question_head.empty())
1072 qj["question_head"] = q.question_head;
1073 else
1074 qj.erase("answer_alias");
1075
1076 // Gate (blocking item)
1077 if (q.has_gate && (!q.gate_required_value.empty() || !q.gate_operator.empty())) {
1078 json gateObj;
1079 if (!q.gate_operator.empty()) {
1080 gateObj["operator"] = q.gate_operator;
1081 gateObj["value"] = q.gate_value;
1082 } else {
1083 gateObj["required_value"] = q.gate_required_value;
1084 }
1085 gateObj["terminate_message_key"] = q.gate_terminate_message_key;
1086 qj["gate"] = gateObj;
1087 } else {
1088 qj.erase("gate");
1089 }
1090
1091 // Section revisable — only write when false (true is default, omit it)
1092 if (q.type == "section" && !q.revisable)
1093 qj["revisable"] = false;
1094 else
1095 qj.erase("revisable");
1096
1097 // Section randomize — only write when true (false is default, omit it)
1098 if (q.type == "section" && q.section_randomize) {
1099 nlohmann::json rdm;
1100 rdm["method"] = "shuffle";
1101 if (!q.section_randomize_fixed.empty())
1102 rdm["fixed"] = q.section_randomize_fixed;
1103 qj["randomize"] = rdm;
1104 } else {
1105 qj.erase("randomize");
1106 }
1107
1108 j["items"].push_back(qj);
1109 }
1110 }
1111
1112 // Save default_required
1113 if (mDefaultRequired >= 0) {
1114 j["default_required"] = (mDefaultRequired == 1);
1115 } else {
1116 j.erase("default_required");
1117 }
1118
1119 // Save scoring
1120 if (!mScoring.empty()) {
1121 j["scoring"] = json::object();
1122 for (const auto& [key, ds] : mScoring) {
1123 json sj;
1124 // Output in preferred order: description, method, items
1125 sj["description"] = ds.description;
1126 sj["method"] = ds.method;
1127
1128 // Dual-format items: array if all forward-coded, object if mixed
1129 bool allForward = true;
1130 for (const auto& [ikey, ival] : ds.item_coding) {
1131 if (ival != 1) { allForward = false; break; }
1132 }
1133
1134 if (ds.item_coding.empty() || allForward) {
1135 // Array format: all forward-coded
1136 sj["items"] = ds.items;
1137 } else {
1138 // Object format: explicit coding
1139 sj["items"] = json::object();
1140 // Preserve item order from ds.items
1141 for (const auto& id : ds.items) {
1142 auto it = ds.item_coding.find(id);
1143 sj["items"][id] = (it != ds.item_coding.end()) ? it->second : 1;
1144 }
1145 // Include any in item_coding but not in items (coding 0)
1146 for (const auto& [ikey, ival] : ds.item_coding) {
1147 if (std::find(ds.items.begin(), ds.items.end(), ikey) == ds.items.end()) {
1148 sj["items"][ikey] = ival;
1149 }
1150 }
1151 }
1152
1153 if (!ds.weights.empty()) {
1154 sj["weights"] = ds.weights;
1155 }
1156 if (!ds.correct_answers.empty()) {
1157 sj["correct_answers"] = json::object();
1158 for (const auto& [cakey, caval] : ds.correct_answers) {
1159 sj["correct_answers"][cakey] = caval;
1160 }
1161 }
1162 if (!ds.norms.empty()) {
1163 nlohmann::json thresholdsArr = nlohmann::json::array();
1164 for (const auto& t : ds.norms) {
1165 thresholdsArr.push_back({{"min", t.min}, {"max", t.max}, {"label_key", t.label}});
1166 }
1167 sj["norms"] = {{"thresholds", thresholdsArr}};
1168 }
1169 if (!ds.transform.empty()) {
1170 nlohmann::json tArr = nlohmann::json::array();
1171 for (const auto& ts : ds.transform) {
1172 tArr.push_back({{"op", ts.op}, {"value", ts.value}});
1173 }
1174 sj["transform"] = tArr;
1175 }
1176 if (!ds.scores.empty()) {
1177 sj["scores"] = ds.scores;
1178 }
1179 if (!ds.value_map.empty()) {
1180 sj["value_map"] = json::object();
1181 for (const auto& [vmkey, vmval] : ds.value_map) {
1182 sj["value_map"][vmkey] = vmval;
1183 }
1184 }
1185 j["scoring"][key] = sj;
1186 }
1187 }
1188
1189 // Save computed variables
1190 if (!mComputed.empty()) {
1191 j["computed"] = nlohmann::json::object();
1192 for (const auto& [key, cv] : mComputed) {
1193 nlohmann::json cvj = {
1194 {"expression", cv.expression},
1195 {"type", cv.type}
1196 };
1197 if (!cv.norms.empty()) {
1198 nlohmann::json thresholdsArr = nlohmann::json::array();
1199 for (const auto& t : cv.norms) {
1200 thresholdsArr.push_back({{"min", t.min}, {"max", t.max}, {"label_key", t.label}});
1201 }
1202 cvj["norms"] = {{"thresholds", thresholdsArr}};
1203 }
1204 j["computed"][key] = cvj;
1205 }
1206 }
1207
1208 // Save report (only if we have meaningful data or it was in original)
1209 if (!mReportConfig.template_type.empty() || mRawDefinition.contains("report")) {
1210 j["report"] = {
1211 {"template", mReportConfig.template_type},
1212 {"include", mReportConfig.include},
1213 {"header", mReportConfig.header},
1214 {"footer_refs", mReportConfig.footer_refs}
1215 };
1216 }
1217
1218 // Save data_output (only if we have meaningful data or it was in original)
1219 if (!mDataOutput.individual_file.empty() || mRawDefinition.contains("data_output")) {
1220 j["data_output"] = {
1221 {"individual_file", mDataOutput.individual_file},
1222 {"pooled_file", mDataOutput.pooled_file},
1223 {"report_file", mDataOutput.report_file},
1224 {"columns", mDataOutput.columns},
1225 {"pooled_columns", mDataOutput.pooled_columns}
1226 };
1227 }
1228
1229 // Note: "pages", "response_footer" (in likert_options), "min_label",
1230 // "max_label", "coding", "dimension" and other OSD format fields
1231 // are preserved automatically from mRawDefinition
1232
1233 outJSON = std::move(j);
1234 return true;
1235 } catch (const std::exception& e) {
1236 std::cerr << "Error building scale definition JSON: " << e.what() << std::endl;
1237 return false;
1238 }
1239}
1240
1241bool ScaleDefinition::SaveDefinitionJSON(const std::string& jsonPath)
1242{
1243 try {
1244 json j;
1245 if (!BuildDefinitionJSONObject(j)) {
1246 return false;
1247 }
1248 std::ofstream file(jsonPath);
1249 if (!file.is_open()) {
1250 return false;
1251 }
1252 file << j.dump(2); // 2-space indent
1253 file.close();
1254 return true;
1255 } catch (const std::exception& e) {
1256 std::cerr << "Error saving scale definition: " << e.what() << std::endl;
1257 return false;
1258 }
1259}
1260
1261bool ScaleDefinition::SaveTranslationJSON(const std::string& jsonPath, const std::string& language)
1262{
1263 try {
1264 if (mTranslations.find(language) == mTranslations.end()) {
1265 return false; // No translations for this language
1266 }
1267
1268 json j;
1269 for (const auto& [key, value] : mTranslations[language]) {
1270 j[key] = value;
1271 }
1272
1273 std::ofstream file(jsonPath);
1274 if (!file.is_open()) {
1275 return false;
1276 }
1277 file << j.dump(2);
1278 file.close();
1279
1280 return true;
1281 } catch (const std::exception& e) {
1282 return false;
1283 }
1284}
1285
1287{
1288 mQuestions.push_back(question);
1289 mDirty = true;
1290}
1291
1292void ScaleDefinition::InsertQuestion(int index, const ScaleQuestion& question)
1293{
1294 if (index < 0) index = 0;
1295 if (index > (int)mQuestions.size()) index = (int)mQuestions.size();
1296 mQuestions.insert(mQuestions.begin() + index, question);
1297 mDirty = true;
1298}
1299
1300void ScaleDefinition::RemoveQuestion(const std::string& questionID)
1301{
1302 auto it = std::remove_if(mQuestions.begin(), mQuestions.end(),
1303 [&questionID](const ScaleQuestion& q) { return q.id == questionID; });
1304
1305 if (it != mQuestions.end()) {
1306 mQuestions.erase(it, mQuestions.end());
1307 mDirty = true;
1308 }
1309}
1310
1311void ScaleDefinition::MoveQuestion(int fromIndex, int toIndex)
1312{
1313 if (fromIndex < 0 || fromIndex >= (int)mQuestions.size() ||
1314 toIndex < 0 || toIndex >= (int)mQuestions.size()) {
1315 return;
1316 }
1317
1318 if (fromIndex == toIndex) {
1319 return;
1320 }
1321
1322 ScaleQuestion q = mQuestions[fromIndex];
1323 mQuestions.erase(mQuestions.begin() + fromIndex);
1324 mQuestions.insert(mQuestions.begin() + toIndex, q);
1325 mDirty = true;
1326}
1327
1328ScaleQuestion* ScaleDefinition::GetQuestion(const std::string& questionID)
1329{
1330 for (auto& q : mQuestions) {
1331 if (q.id == questionID) {
1332 return &q;
1333 }
1334 }
1335 return nullptr;
1336}
1337
1339{
1340 mDimensions.push_back(dimension);
1341 mDirty = true;
1342}
1343
1344void ScaleDefinition::RemoveDimension(const std::string& dimensionID)
1345{
1346 auto it = std::remove_if(mDimensions.begin(), mDimensions.end(),
1347 [&dimensionID](const ScaleDimension& d) { return d.id == dimensionID; });
1348
1349 if (it != mDimensions.end()) {
1350 mDimensions.erase(it, mDimensions.end());
1351 mDirty = true;
1352 }
1353}
1354
1355ScaleDimension* ScaleDefinition::GetDimension(const std::string& dimensionID)
1356{
1357 for (auto& d : mDimensions) {
1358 if (d.id == dimensionID) {
1359 return &d;
1360 }
1361 }
1362 return nullptr;
1363}
1364
1365void ScaleDefinition::AddTranslation(const std::string& language, const std::string& key, const std::string& value)
1366{
1367 mTranslations[language][key] = value;
1368 mDirty = true;
1369}
1370
1371void ScaleDefinition::RemoveTranslation(const std::string& language)
1372{
1373 mTranslations.erase(language);
1374 mDirty = true;
1375}
1376
1377std::vector<std::string> ScaleDefinition::GetAvailableLanguages() const
1378{
1379 std::vector<std::string> languages;
1380 for (const auto& [lang, _] : mTranslations) {
1381 languages.push_back(lang);
1382 }
1383 return languages;
1384}
1385
1386std::string ScaleDefinition::GetTranslation(const std::string& language, const std::string& key) const
1387{
1388 auto langIt = mTranslations.find(language);
1389 if (langIt != mTranslations.end()) {
1390 auto keyIt = langIt->second.find(key);
1391 if (keyIt != langIt->second.end()) {
1392 return keyIt->second;
1393 }
1394 }
1395 return "";
1396}
1397
1399{
1400 ValidationResult result;
1401
1402 // Check scale info
1403 if (mScaleInfo.code.empty()) {
1404 result.errors.push_back("Scale code is required");
1405 }
1406 if (mScaleInfo.name.empty()) {
1407 result.errors.push_back("Scale name is required");
1408 }
1409
1410 // Check questions
1411 if (mQuestions.empty()) {
1412 result.errors.push_back("Scale must have at least one question");
1413 }
1414
1415 // Check for duplicate question IDs
1416 std::map<std::string, int> idCounts;
1417 for (const auto& q : mQuestions) {
1418 if (q.id.empty()) {
1419 result.errors.push_back("Question missing ID");
1420 } else {
1421 idCounts[q.id]++;
1422 }
1423 if (q.text_key.empty()) {
1424 result.warnings.push_back("Question " + q.id + " missing text_key");
1425 }
1426 if (q.type.empty()) {
1427 result.errors.push_back("Question " + q.id + " missing type");
1428 }
1429 }
1430
1431 for (const auto& [id, count] : idCounts) {
1432 if (count > 1) {
1433 result.errors.push_back("Duplicate question ID: " + id);
1434 }
1435 }
1436
1437 // Check dimensions reference valid questions
1438 for (const auto& dim : mDimensions) {
1439 if (dim.id.empty()) {
1440 result.errors.push_back("Dimension missing ID");
1441 }
1442 }
1443
1444 // Check scoring references valid questions
1445 for (const auto& [dimId, scoring] : mScoring) {
1446 for (const auto& qid : scoring.items) {
1447 bool found = false;
1448 for (const auto& q : mQuestions) {
1449 if (q.id == qid) {
1450 found = true;
1451 break;
1452 }
1453 }
1454 if (!found) {
1455 result.warnings.push_back("Scoring " + dimId + " references non-existent question: " + qid);
1456 }
1457 }
1458 }
1459
1460 return result;
1461}
1462
1463bool ScaleDefinition::Validate(std::string& errorOutput)
1464{
1465 // Use internal validation
1467
1468 std::stringstream ss;
1469 if (!result.errors.empty()) {
1470 ss << "Errors:\n";
1471 for (const auto& err : result.errors) {
1472 ss << " - " << err << "\n";
1473 }
1474 }
1475 if (!result.warnings.empty()) {
1476 ss << "Warnings:\n";
1477 for (const auto& warn : result.warnings) {
1478 ss << " - " << warn << "\n";
1479 }
1480 }
1481
1482 errorOutput = ss.str();
1483 return result.IsValid();
1484}
nlohmann::json json
Definition Chain.cpp:14
nlohmann::json json
bool Validate(std::string &errorOutput)
ScaleDimension * GetDimension(const std::string &dimensionID)
void InsertQuestion(int index, const ScaleQuestion &question)
std::string GetTranslation(const std::string &language, const std::string &key) const
void RemoveTranslation(const std::string &language)
bool LoadFromScalesDir(const std::string &basePath, const std::string &scaleCode)
void RemoveDimension(const std::string &dimensionID)
void AddDimension(const ScaleDimension &dimension)
std::vector< std::string > GetAvailableLanguages() const
static std::shared_ptr< ScaleDefinition > LoadFromFile(const std::string &jsonPath)
bool SaveToFile(const std::string &jsonPath)
ScaleQuestion * GetQuestion(const std::string &questionID)
static std::shared_ptr< ScaleDefinition > CreateNew(const std::string &code)
ValidationResult ValidateInternal() const
void AddTranslation(const std::string &language, const std::string &key, const std::string &value)
bool ExportToJSON(const std::string &definitionsPath, const std::string &translationsPath)
void AddQuestion(const ScaleQuestion &question)
static std::shared_ptr< ScaleDefinition > LoadFromOSDFile(const std::string &osdPath)
void MoveQuestion(int fromIndex, int toIndex)
bool ExportToOSD(const std::string &outputDir)
void RemoveQuestion(const std::string &questionID)
std::string expression
std::vector< NormThreshold > norms
std::string report_file
std::string pooled_file
std::string columns
std::string pooled_columns
std::string individual_file
std::vector< std::string > scores
std::map< std::string, std::vector< std::string > > correct_answers
std::vector< TransformStep > transform
std::vector< std::string > items
std::map< std::string, int > item_coding
std::map< std::string, double > weights
std::vector< NormThreshold > norms
std::map< std::string, std::vector< double > > value_map
std::string description
std::vector< std::string > labels
std::string question_head
std::string label
bool HasAnyValidation() const
std::vector< std::string > include
std::vector< std::string > footer_refs
std::string template_type
std::string header
std::vector< std::string > warnings
std::vector< std::string > errors
std::string name
std::vector< VisibleWhenCondition > visible_when
std::string abbreviation
std::string enabled_param
std::string description
std::string visible_when_logic
std::string name
std::string license
std::string abbreviation
std::string code
std::string license_url
std::string description
std::string citation
std::string url
std::string license_explanation
std::string version
std::string domain
std::string description
std::string defaultValue
std::string type
std::vector< std::string > options
std::string vas_orientation
std::string gate_terminate_message_key
std::string right_label
std::string image
std::vector< VisibleWhenCondition > visible_when_simple
std::vector< std::string > rows
std::vector< VasAnchor > vas_anchors
QuestionValidation validation
std::vector< std::string > options
std::string type
std::string text_key
std::string answer_alias
std::string gate_required_value
std::string left_label
std::vector< std::string > correct
std::string dimension
std::vector< std::string > section_randomize_fixed
std::vector< std::string > columns
std::vector< std::string > likert_labels
std::string visible_when_logic
std::string question_head
std::string gate_operator
int count
Definition test.cpp:12