17namespace fs = std::filesystem;
18using json = nlohmann::json;
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/" },
26const int OpenScalesBrowser::NUM_MANIFESTS =
sizeof(MANIFESTS) /
sizeof(MANIFESTS[0]);
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);
44 std::memset(mFilterText, 0,
sizeof(mFilterText));
50 mScalesDir = scalesDir;
56 mCachePath = scalesDir +
"/../openscales_manifest.json";
59 if (mCatalog.empty()) {
67std::string OpenScalesBrowser::FetchURL(
const std::string& url) {
69 CURL* curl = curl_easy_init();
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");
80 curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 0L);
81 curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 0L);
84 CURLcode res = curl_easy_perform(curl);
86 curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
88 if (res != CURLE_OK) {
89 fprintf(stderr,
"OpenScalesBrowser FetchURL failed: %s\n", curl_easy_strerror(res));
91 curl_easy_cleanup(curl);
93 if (res != CURLE_OK || httpCode != 200) {
99bool OpenScalesBrowser::FetchManifest() {
100 mStatusMessage =
"Fetching catalogs from OpenScales...";
104 int totalFetched = 0;
108 json allEntries = json::array();
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);
116 printf(
" Failed to fetch %s\n", url.c_str());
121 json arr = json::parse(body);
122 if (arr.is_array()) {
124 for (
auto& entry : arr) {
125 entry[
"_repo"] = MANIFESTS[m].repo;
126 entry[
"_scale_dir"] = MANIFESTS[m].scaleDir;
127 allEntries.push_back(entry);
129 totalFetched += (int)arr.size();
133 printf(
" Failed to parse %s\n", url.c_str());
139 if (allEntries.empty()) {
140 mStatusMessage =
"Failed to fetch any catalogs. Check your internet connection.";
145 std::string merged = allEntries.dump(2);
147 std::ofstream out(mCachePath);
152 ParseManifest(merged);
154 mStatusMessage =
"Catalog updated: " + std::to_string(mCatalog.size()) +
" scales"
155 + (totalFailed > 0 ?
" (" + std::to_string(totalFailed) +
" source(s) unavailable)" :
"")
160bool OpenScalesBrowser::DownloadScale(
const std::string& code) {
162 std::string scaleDir =
"/scales/openscales/";
163 for (
const auto& e : mCatalog) {
164 if (e.code == code && !e.repo.empty()) {
166 for (
int m = 0; m < NUM_MANIFESTS; m++) {
167 if (e.repo == MANIFESTS[m].repo) {
168 scaleDir = MANIFESTS[m].scaleDir;
175 std::string url = std::string(BASE_URL) + scaleDir + code +
"/" + code +
".osd";
176 mStatusMessage =
"Downloading " + code +
"...";
178 std::string body = FetchURL(url);
180 mStatusMessage =
"Failed to download " + code +
".";
188 mStatusMessage =
"Downloaded file is not valid JSON.";
193 std::string dir = mScalesDir +
"/" + code;
194 std::string path = dir +
"/" + code +
".osd";
197 fs::create_directories(dir);
198 std::ofstream out(path);
201 }
catch (
const std::exception& e) {
202 mStatusMessage = std::string(
"Failed to save: ") + e.what();
206 mStatusMessage =
"Downloaded " + code +
" successfully.";
209 for (
auto& entry : mCatalog) {
210 if (entry.code == code) {
211 entry.isLocal =
true;
226void OpenScalesBrowser::LoadCachedManifest() {
227 if (!fs::exists(mCachePath)) {
228 mStatusMessage =
"No cached catalog. Click 'Refresh' to download.";
233 std::ifstream in(mCachePath);
234 std::stringstream buf;
236 ParseManifest(buf.str());
237 mStatusMessage =
"Loaded cached catalog: " + std::to_string(mCatalog.size()) +
" scales.";
239 mStatusMessage =
"Failed to read cached catalog.";
243void OpenScalesBrowser::ParseManifest(
const std::string& jsonStr) {
247 json arr = json::parse(jsonStr);
248 if (!arr.is_array())
return;
250 for (
const auto& entry : arr) {
252 e.
code = entry.value(
"code",
"");
253 e.
name = entry.value(
"name",
"");
254 e.
domain = entry.value(
"domain",
"");
256 e.
license = entry.value(
"license",
"");
257 e.
url = entry.value(
"url",
"");
258 e.
n_items = entry.value(
"n_items", 0);
260 e.
n_items = entry.value(
"items_count", 0);
262 e.
repo = entry.value(
"_repo", entry.value(
"repo",
"openscales"));
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>());
271 if (!e.
code.empty()) {
272 mCatalog.push_back(e);
276 mStatusMessage =
"Failed to parse catalog JSON.";
282void OpenScalesBrowser::UpdateDomainList() {
283 std::set<std::string> domSet;
284 for (
const auto& e : mCatalog) {
287 mDomains.assign(domSet.begin(), domSet.end());
288 std::sort(mDomains.begin(), mDomains.end());
291void OpenScalesBrowser::UpdateLocalStatus() {
292 for (
auto& e : mCatalog) {
293 std::string path = mScalesDir +
"/" + e.
code +
"/" + e.
code +
".osd";
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);
303 for (
int i = 0; i < (int)mCatalog.size(); i++) {
304 const auto& e = mCatalog[i];
307 if (mSelectedDomain >= 0 && mSelectedDomain < (
int)mDomains.size()) {
308 if (e.
domain != mDomains[mSelectedDomain])
continue;
312 if (!filterLower.empty()) {
314 std::transform(haystack.begin(), haystack.end(), haystack.begin(), ::tolower);
315 if (haystack.find(filterLower) == std::string::npos)
continue;
326 if (!mVisible)
return;
328 ImGui::SetNextWindowSize(ImVec2(900, 600), ImGuiCond_FirstUseEver);
329 if (!ImGui::Begin(
"Browse OpenScales", &mVisible, ImGuiWindowFlags_NoCollapse)) {
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;
341 for (
int i = 0; i < (int)mDomains.size(); i++) {
342 if (ImGui::Selectable(mDomains[i].c_str(), mSelectedDomain == i)) {
349 ImGui::PopItemWidth();
352 ImGui::PushItemWidth(ImGui::GetContentRegionAvail().x - 100);
353 ImGui::InputTextWithHint(
"##Filter",
"Search scales...", mFilterText,
sizeof(mFilterText));
354 ImGui::PopItemWidth();
357 if (ImGui::Button(
"Refresh")) {
360 if (ImGui::IsItemHovered()) {
361 ImGui::SetTooltip(
"Download latest scale catalog from OpenScales");
367 auto filtered = GetFilteredIndices();
370 float listWidth = 350;
371 ImGui::BeginChild(
"ScaleListPanel", ImVec2(listWidth, -ImGui::GetFrameHeightWithSpacing() - 4),
true);
373 ImGui::Text(
"%d scales", (
int)filtered.size());
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();
383 for (
int fi = 0; fi < (int)filtered.size(); fi++) {
384 int idx = filtered[fi];
385 const auto& e = mCatalog[idx];
387 ImGui::TableNextRow();
392 ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg0,
393 ImGui::GetColorU32(ImVec4(0.15f, 0.35f, 0.15f, 0.3f)));
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;
403 ImGui::TableSetColumnIndex(1);
404 ImGui::TextUnformatted(e.
name.c_str());
406 ImGui::TableSetColumnIndex(2);
419 ImGui::BeginChild(
"ScaleDetailPanel", ImVec2(0, -ImGui::GetFrameHeightWithSpacing() - 4),
true);
421 if (mSelectedIndex >= 0 && mSelectedIndex < (
int)mCatalog.size()) {
422 const auto& e = mCatalog[mSelectedIndex];
424 ImGui::TextWrapped(
"%s", e.
name.c_str());
425 ImGui::TextDisabled(
"%s", e.
code.c_str());
429 ImGui::Text(
"Domain: %s", e.
domain.c_str());
431 if (!e.
repo.empty() && e.
repo !=
"openscales") {
433 ImGui::TextDisabled(
" [%s]", e.
repo.c_str());
435 ImGui::Text(
"Items: %d", e.
n_items);
439 for (
size_t i = 0; i < e.
languages.size(); i++) {
440 if (i) langs +=
", ";
443 ImGui::Text(
"Languages: %s", langs.c_str());
447 ImGui::Text(
"License: %s", e.
license.c_str());
459 if (!e.
url.empty()) {
460 ImGui::TextDisabled(
"URL: %s", e.
url.c_str());
468 ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f),
"Already downloaded");
469 if (ImGui::Button(
"Re-download")) {
470 DownloadScale(e.
code);
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);
479 ImGui::PopStyleColor(3);
482 ImGui::TextDisabled(
"Select a scale to see details.");
488 if (!mStatusMessage.empty()) {
489 ImGui::TextDisabled(
"%s", mStatusMessage.c_str());
void Show(const std::string &scalesDir)
std::vector< std::string > languages