PEBL 2.2
Psychology Experiment Building Language - Cross-platform psychological experiment development system
OpenScalesBrowser.cpp
Go to the documentation of this file.
1// OpenScalesBrowser.cpp - Browse and download scales from OpenScales repository
2// Copyright (c) 2026 Shane T. Mueller
3// Licensed under GPL
4
5#include "OpenScalesBrowser.h"
6#include <imgui.h>
7#include <json.hpp>
8#include <curl/curl.h>
9#include <fstream>
10#include <sstream>
11#include <algorithm>
12#include <set>
13#include <filesystem>
14#include <ctime>
15#include <cstring>
16
17namespace fs = std::filesystem;
18using json = nlohmann::json;
19
20// Manifest sources — fetched in order, merged into one catalog
21const OpenScalesBrowser::ManifestSource OpenScalesBrowser::MANIFESTS[] = {
22 { "/manifest.json", "openscales", "/scales/openscales/" },
23 { "/manifest_phenx.json", "phenx", "/scales/phenx/" },
24 { "/manifest_restricted.json", "restricted", "/scales/restricted/" },
25};
26const int OpenScalesBrowser::NUM_MANIFESTS = sizeof(MANIFESTS) / sizeof(MANIFESTS[0]);
27
28// ── curl callback ────────────────────────────────────────────────────────────
29
30static size_t CurlWriteCallback(void* contents, size_t size, size_t nmemb, void* userp) {
31 std::string* str = static_cast<std::string*>(userp);
32 str->append(static_cast<char*>(contents), size * nmemb);
33 return size * nmemb;
34}
35
36// ── Constructor ──────────────────────────────────────────────────────────────
37
39 : mVisible(false)
40 , mSelectedDomain(-1)
41 , mSelectedIndex(-1)
42 , mFetching(false)
43{
44 std::memset(mFilterText, 0, sizeof(mFilterText));
45}
46
47// ── Public interface ─────────────────────────────────────────────────────────
48
49void OpenScalesBrowser::Show(const std::string& scalesDir) {
50 mScalesDir = scalesDir;
51 mVisible = true;
52 mSelectedIndex = -1;
53 mStatusMessage = "";
54
55 // Determine cache path (next to scales dir)
56 mCachePath = scalesDir + "/../openscales_manifest.json";
57
58 // Load cached manifest if available
59 if (mCatalog.empty()) {
60 LoadCachedManifest();
61 }
62 UpdateLocalStatus();
63}
64
65// ── HTTP fetch ───────────────────────────────────────────────────────────────
66
67std::string OpenScalesBrowser::FetchURL(const std::string& url) {
68 std::string response;
69 CURL* curl = curl_easy_init();
70 if (!curl) return "";
71
72 curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
73 curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, CurlWriteCallback);
74 curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
75 curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
76 curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
77 curl_easy_setopt(curl, CURLOPT_USERAGENT, "PEBL-Launcher/2.4");
78#ifdef PEBL_WIN32
79 // Windows MinGW curl lacks system CA bundle — disable peer verification
80 curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
81 curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
82#endif
83
84 CURLcode res = curl_easy_perform(curl);
85 long httpCode = 0;
86 curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
87
88 if (res != CURLE_OK) {
89 fprintf(stderr, "OpenScalesBrowser FetchURL failed: %s\n", curl_easy_strerror(res));
90 }
91 curl_easy_cleanup(curl);
92
93 if (res != CURLE_OK || httpCode != 200) {
94 return "";
95 }
96 return response;
97}
98
99bool OpenScalesBrowser::FetchManifest() {
100 mStatusMessage = "Fetching catalogs from OpenScales...";
101 mFetching = true;
102 mCatalog.clear();
103
104 int totalFetched = 0;
105 int totalFailed = 0;
106
107 // Fetch each manifest and merge
108 json allEntries = json::array();
109
110 for (int m = 0; m < NUM_MANIFESTS; m++) {
111 std::string url = std::string(BASE_URL) + MANIFESTS[m].path;
112 std::string body = FetchURL(url);
113
114 if (body.empty()) {
115 totalFailed++;
116 printf(" Failed to fetch %s\n", url.c_str());
117 continue;
118 }
119
120 try {
121 json arr = json::parse(body);
122 if (arr.is_array()) {
123 // Tag each entry with its repo
124 for (auto& entry : arr) {
125 entry["_repo"] = MANIFESTS[m].repo;
126 entry["_scale_dir"] = MANIFESTS[m].scaleDir;
127 allEntries.push_back(entry);
128 }
129 totalFetched += (int)arr.size();
130 }
131 } catch (...) {
132 totalFailed++;
133 printf(" Failed to parse %s\n", url.c_str());
134 }
135 }
136
137 mFetching = false;
138
139 if (allEntries.empty()) {
140 mStatusMessage = "Failed to fetch any catalogs. Check your internet connection.";
141 return false;
142 }
143
144 // Cache merged result to disk
145 std::string merged = allEntries.dump(2);
146 try {
147 std::ofstream out(mCachePath);
148 out << merged;
149 out.close();
150 } catch (...) {}
151
152 ParseManifest(merged);
153 UpdateLocalStatus();
154 mStatusMessage = "Catalog updated: " + std::to_string(mCatalog.size()) + " scales"
155 + (totalFailed > 0 ? " (" + std::to_string(totalFailed) + " source(s) unavailable)" : "")
156 + ".";
157 return true;
158}
159
160bool OpenScalesBrowser::DownloadScale(const std::string& code) {
161 // Find the repo for this scale
162 std::string scaleDir = "/scales/openscales/"; // default
163 for (const auto& e : mCatalog) {
164 if (e.code == code && !e.repo.empty()) {
165 // Look up the scale directory for this repo
166 for (int m = 0; m < NUM_MANIFESTS; m++) {
167 if (e.repo == MANIFESTS[m].repo) {
168 scaleDir = MANIFESTS[m].scaleDir;
169 break;
170 }
171 }
172 break;
173 }
174 }
175 std::string url = std::string(BASE_URL) + scaleDir + code + "/" + code + ".osd";
176 mStatusMessage = "Downloading " + code + "...";
177
178 std::string body = FetchURL(url);
179 if (body.empty()) {
180 mStatusMessage = "Failed to download " + code + ".";
181 return false;
182 }
183
184 // Validate it's valid JSON
185 try {
186 json::parse(body);
187 } catch (...) {
188 mStatusMessage = "Downloaded file is not valid JSON.";
189 return false;
190 }
191
192 // Save to workspace/scales/{code}/{code}.osd
193 std::string dir = mScalesDir + "/" + code;
194 std::string path = dir + "/" + code + ".osd";
195
196 try {
197 fs::create_directories(dir);
198 std::ofstream out(path);
199 out << body;
200 out.close();
201 } catch (const std::exception& e) {
202 mStatusMessage = std::string("Failed to save: ") + e.what();
203 return false;
204 }
205
206 mStatusMessage = "Downloaded " + code + " successfully.";
207
208 // Update local status
209 for (auto& entry : mCatalog) {
210 if (entry.code == code) {
211 entry.isLocal = true;
212 break;
213 }
214 }
215
216 // Notify callback
217 if (mOnDownload) {
218 mOnDownload(code);
219 }
220
221 return true;
222}
223
224// ── Cache / parse ────────────────────────────────────────────────────────────
225
226void OpenScalesBrowser::LoadCachedManifest() {
227 if (!fs::exists(mCachePath)) {
228 mStatusMessage = "No cached catalog. Click 'Refresh' to download.";
229 return;
230 }
231
232 try {
233 std::ifstream in(mCachePath);
234 std::stringstream buf;
235 buf << in.rdbuf();
236 ParseManifest(buf.str());
237 mStatusMessage = "Loaded cached catalog: " + std::to_string(mCatalog.size()) + " scales.";
238 } catch (...) {
239 mStatusMessage = "Failed to read cached catalog.";
240 }
241}
242
243void OpenScalesBrowser::ParseManifest(const std::string& jsonStr) {
244 mCatalog.clear();
245
246 try {
247 json arr = json::parse(jsonStr);
248 if (!arr.is_array()) return;
249
250 for (const auto& entry : arr) {
252 e.code = entry.value("code", "");
253 e.name = entry.value("name", "");
254 e.domain = entry.value("domain", "");
255 e.description = entry.value("description", "");
256 e.license = entry.value("license", "");
257 e.url = entry.value("url", "");
258 e.n_items = entry.value("n_items", 0);
259 if (e.n_items == 0) {
260 e.n_items = entry.value("items_count", 0);
261 }
262 e.repo = entry.value("_repo", entry.value("repo", "openscales"));
263 e.isLocal = false;
264
265 if (entry.contains("languages") && entry["languages"].is_array()) {
266 for (const auto& lang : entry["languages"]) {
267 e.languages.push_back(lang.get<std::string>());
268 }
269 }
270
271 if (!e.code.empty()) {
272 mCatalog.push_back(e);
273 }
274 }
275 } catch (...) {
276 mStatusMessage = "Failed to parse catalog JSON.";
277 }
278
279 UpdateDomainList();
280}
281
282void OpenScalesBrowser::UpdateDomainList() {
283 std::set<std::string> domSet;
284 for (const auto& e : mCatalog) {
285 if (!e.domain.empty()) domSet.insert(e.domain);
286 }
287 mDomains.assign(domSet.begin(), domSet.end());
288 std::sort(mDomains.begin(), mDomains.end());
289}
290
291void OpenScalesBrowser::UpdateLocalStatus() {
292 for (auto& e : mCatalog) {
293 std::string path = mScalesDir + "/" + e.code + "/" + e.code + ".osd";
294 e.isLocal = fs::exists(path);
295 }
296}
297
298std::vector<int> OpenScalesBrowser::GetFilteredIndices() {
299 std::vector<int> result;
300 std::string filterLower(mFilterText);
301 std::transform(filterLower.begin(), filterLower.end(), filterLower.begin(), ::tolower);
302
303 for (int i = 0; i < (int)mCatalog.size(); i++) {
304 const auto& e = mCatalog[i];
305
306 // Domain filter
307 if (mSelectedDomain >= 0 && mSelectedDomain < (int)mDomains.size()) {
308 if (e.domain != mDomains[mSelectedDomain]) continue;
309 }
310
311 // Text filter
312 if (!filterLower.empty()) {
313 std::string haystack = e.code + " " + e.name + " " + e.description + " " + e.domain;
314 std::transform(haystack.begin(), haystack.end(), haystack.begin(), ::tolower);
315 if (haystack.find(filterLower) == std::string::npos) continue;
316 }
317
318 result.push_back(i);
319 }
320 return result;
321}
322
323// ── ImGui Render ─────────────────────────────────────────────────────────────
324
326 if (!mVisible) return;
327
328 ImGui::SetNextWindowSize(ImVec2(900, 600), ImGuiCond_FirstUseEver);
329 if (!ImGui::Begin("Browse OpenScales", &mVisible, ImGuiWindowFlags_NoCollapse)) {
330 ImGui::End();
331 return;
332 }
333
334 // ── Top bar: domain filter + text filter + refresh ───────────────────────
335 ImGui::PushItemWidth(150);
336 const char* domainPreview = (mSelectedDomain < 0) ? "All Domains" : mDomains[mSelectedDomain].c_str();
337 if (ImGui::BeginCombo("##Domain", domainPreview)) {
338 if (ImGui::Selectable("All Domains", mSelectedDomain < 0)) {
339 mSelectedDomain = -1;
340 }
341 for (int i = 0; i < (int)mDomains.size(); i++) {
342 if (ImGui::Selectable(mDomains[i].c_str(), mSelectedDomain == i)) {
343 mSelectedDomain = i;
344 mSelectedIndex = -1;
345 }
346 }
347 ImGui::EndCombo();
348 }
349 ImGui::PopItemWidth();
350
351 ImGui::SameLine();
352 ImGui::PushItemWidth(ImGui::GetContentRegionAvail().x - 100);
353 ImGui::InputTextWithHint("##Filter", "Search scales...", mFilterText, sizeof(mFilterText));
354 ImGui::PopItemWidth();
355
356 ImGui::SameLine();
357 if (ImGui::Button("Refresh")) {
358 FetchManifest();
359 }
360 if (ImGui::IsItemHovered()) {
361 ImGui::SetTooltip("Download latest scale catalog from OpenScales");
362 }
363
364 ImGui::Separator();
365
366 // ── Get filtered list ────────────────────────────────────────────────────
367 auto filtered = GetFilteredIndices();
368
369 // ── Left panel: scale list ───────────────────────────────────────────────
370 float listWidth = 350;
371 ImGui::BeginChild("ScaleListPanel", ImVec2(listWidth, -ImGui::GetFrameHeightWithSpacing() - 4), true);
372
373 ImGui::Text("%d scales", (int)filtered.size());
374 ImGui::Separator();
375
376 if (ImGui::BeginTable("ScaleTable", 3,
377 ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_Resizable)) {
378 ImGui::TableSetupColumn("Code", ImGuiTableColumnFlags_WidthFixed, 70);
379 ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch);
380 ImGui::TableSetupColumn("Items", ImGuiTableColumnFlags_WidthFixed, 40);
381 ImGui::TableHeadersRow();
382
383 for (int fi = 0; fi < (int)filtered.size(); fi++) {
384 int idx = filtered[fi];
385 const auto& e = mCatalog[idx];
386
387 ImGui::TableNextRow();
388 ImGui::PushID(idx);
389
390 // Highlight locally available scales
391 if (e.isLocal) {
392 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
393 ImGui::GetColorU32(ImVec4(0.15f, 0.35f, 0.15f, 0.3f)));
394 }
395
396 ImGui::TableSetColumnIndex(0);
397 bool selected = (mSelectedIndex == idx);
398 if (ImGui::Selectable(e.code.c_str(), selected,
399 ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) {
400 mSelectedIndex = idx;
401 }
402
403 ImGui::TableSetColumnIndex(1);
404 ImGui::TextUnformatted(e.name.c_str());
405
406 ImGui::TableSetColumnIndex(2);
407 ImGui::Text("%d", e.n_items);
408
409 ImGui::PopID();
410 }
411
412 ImGui::EndTable();
413 }
414
415 ImGui::EndChild();
416
417 // ── Right panel: details ─────────────────────────────────────────────────
418 ImGui::SameLine();
419 ImGui::BeginChild("ScaleDetailPanel", ImVec2(0, -ImGui::GetFrameHeightWithSpacing() - 4), true);
420
421 if (mSelectedIndex >= 0 && mSelectedIndex < (int)mCatalog.size()) {
422 const auto& e = mCatalog[mSelectedIndex];
423
424 ImGui::TextWrapped("%s", e.name.c_str());
425 ImGui::TextDisabled("%s", e.code.c_str());
426 ImGui::Spacing();
427
428 if (!e.domain.empty()) {
429 ImGui::Text("Domain: %s", e.domain.c_str());
430 }
431 if (!e.repo.empty() && e.repo != "openscales") {
432 ImGui::SameLine();
433 ImGui::TextDisabled(" [%s]", e.repo.c_str());
434 }
435 ImGui::Text("Items: %d", e.n_items);
436
437 if (!e.languages.empty()) {
438 std::string langs;
439 for (size_t i = 0; i < e.languages.size(); i++) {
440 if (i) langs += ", ";
441 langs += e.languages[i];
442 }
443 ImGui::Text("Languages: %s", langs.c_str());
444 }
445
446 if (!e.license.empty()) {
447 ImGui::Text("License: %s", e.license.c_str());
448 }
449
450 ImGui::Spacing();
451 ImGui::Separator();
452 ImGui::Spacing();
453
454 if (!e.description.empty()) {
455 ImGui::TextWrapped("%s", e.description.c_str());
456 ImGui::Spacing();
457 }
458
459 if (!e.url.empty()) {
460 ImGui::TextDisabled("URL: %s", e.url.c_str());
461 }
462
463 ImGui::Spacing();
464 ImGui::Separator();
465 ImGui::Spacing();
466
467 if (e.isLocal) {
468 ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "Already downloaded");
469 if (ImGui::Button("Re-download")) {
470 DownloadScale(e.code);
471 }
472 } else {
473 ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.5f, 0.8f, 1.0f));
474 ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.6f, 0.9f, 1.0f));
475 ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.15f, 0.4f, 0.7f, 1.0f));
476 if (ImGui::Button("Download Scale", ImVec2(-1, 30))) {
477 DownloadScale(e.code);
478 }
479 ImGui::PopStyleColor(3);
480 }
481 } else {
482 ImGui::TextDisabled("Select a scale to see details.");
483 }
484
485 ImGui::EndChild();
486
487 // ── Status bar ───────────────────────────────────────────────────────────
488 if (!mStatusMessage.empty()) {
489 ImGui::TextDisabled("%s", mStatusMessage.c_str());
490 }
491
492 ImGui::End();
493}
nlohmann::json json
Definition Chain.cpp:14
void Show(const std::string &scalesDir)
std::string description
std::vector< std::string > languages