#include "stdafx.h" #include "RobloxGameExplorer.h" #include "QtUtilities.h" #include "RobloxDocManager.h" #include "RobloxIDEDoc.h" #include "RobloxMainWindow.h" #include "RobloxScriptDoc.h" #include "RobloxSettings.h" #include "RobloxStudioVerbs.h" #include "RobloxWebDoc.h" #include "UpdateUIManager.h" #include "RobloxServicesTools.h" #include "Util/ContentId.h" #include "Util/LuaWebService.h" #include "Util/RobloxGoogleAnalytics.h" #include "V8DataModel/ContentProvider.h" #include "V8DataModel/DataModel.h" #include "V8DataModel/Decal.h" #include "V8DataModel/PartOperationAsset.h" #include "V8Xml/WebParser.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include FASTINTVARIABLE(GameViewItemCap, 200) FASTINTVARIABLE(NewGameTemplateId, 95206881) FASTFLAG(StudioCSGAssets) FASTFLAGVARIABLE(GameExplorerCopyPath, false) FASTFLAGVARIABLE(GameExplorerUseV2AliasEndpoint, false) FASTFLAGVARIABLE(GameExplorerCopyId, false) FASTFLAGVARIABLE(UseNewBadgesCreatePage, false) Q_DECLARE_METATYPE(boost::function); Q_DECLARE_METATYPE(boost::function); Q_DECLARE_METATYPE(EntityProperties*); Q_DECLARE_METATYPE(RBX::HttpFuture); using namespace RBX; using namespace Reflection; namespace { const int kNoGameLoaded = -1; const QString kImageAssetNamePrefix = "Images/"; const QString kErrorTextStyleSheet = "color: #d86868"; const QString kErrorTextInputStyleSheet = "border: 2px solid #d86868"; const char* kLastImageDirectoryKey = "image_last_directory"; enum GameExplorerDataRoles { ROLE_CategoryIfGroupRoot = Qt::UserRole + 1, ROLE_EntityProperties, ROLE_DoubleClickCallback, ROLE_ContextMenuCallback, ROLE_NameChangedCallback, }; void placesPropertiesFixer(int universeId, EntityProperties* properties); void badgesPropertiesFixer(int universeId, EntityProperties* properties); void namedAssetsPropertiesFixer(int universeId, EntityProperties* properties); const CategoryWebSettings kWebSettings[ENTITY_CATEGORY_MAX] = { // Places { "Places", "PlaceId", "ID", "%API_PROXY%/universes/get-universe-places?universeId=%UNIVERSE_ID%&page=%ITEM_ID%", "%WEB_BASE%/ide/places/create?universeId=%UNIVERSE_ID%", "%WEB_BASE%/ide/places/%ITEM_ID%/updatesettings", "%WEB_BASE%/universes/removeplace?universeId=%UNIVERSE_ID%&placeId=%ITEM_ID%", "%WEB_BASE%/asset/?id=%ITEM_ID%", "%DATA_BASE%/Data/Upload.ashx?assetid=%ITEM_ID%", &placesPropertiesFixer }, // Developer products { "DeveloperProducts", "ProductId", "ProductId", "%API_PROXY%/developerproducts/list?universeId=%UNIVERSE_ID%&page=%ITEM_ID%", "%API_PROXY%/developerproducts/add?universeId=%UNIVERSE_ID%", "%API_PROXY%/developerproducts/update?universeId=%UNIVERSE_ID%", "", // remove not supported "", // dev products don't have content to get/set "", // no content, so no content write url NULL // no properties fixer }, { "Places", "PlaceId", "BadgeId", "%API_PROXY%/universes/get-universe-places?universeId=%UNIVERSE_ID%&page=%ITEM_ID%", "", "", "", "", "", &badgesPropertiesFixer }, // Named assets { "Aliases", "Name", "Name", "%API_PROXY%/universes/get-aliases?universeId=%UNIVERSE_ID%&page=%ITEM_ID%", "%API_PROXY%/universes/create-alias-v2?universeId=%UNIVERSE_ID%", "%API_PROXY%/universes/update-alias-v2?universeId=%UNIVERSE_ID%&oldName=%ITEM_ID%", "%API_PROXY%/universes/delete-alias?universeId=%UNIVERSE_ID%&name=%ITEM_ID%", "", // Images do not need to have content get/set on publish "", // See above &namedAssetsPropertiesFixer }, }; const CategoryWebSettings badgesWebSettings = {"GameBadges", "PlaceId", "BadgeId", "%WEB_BASE%/badges/list-badges-for-place/json?placeId=%ITEM_ID%", "", "", "", "", "", NULL}; template IdType extractPropertyHelper(boost::shared_future future, const std::string fieldName) { Variant v; WebParser::parseJSONObject(future.get(), v); return v.cast >()->at(fieldName).cast(); } template static boost::function extractProperty(boost::shared_future future, const std::string& fieldName) { return boost::bind(&extractPropertyHelper, future, fieldName); } QVariant getEntityId(EntityProperties* settings, EntityCategory category) { QVariant id; if (settings) { const std::string& fieldName = kWebSettings[category].perItemIdFieldName; boost::optional intId = settings->get(fieldName); boost::optional stringId = settings->get(fieldName); RBXASSERT(intId.is_initialized() != stringId.is_initialized()); if (intId) { id = intId.get(); } else if (stringId) { id = QString::fromStdString(stringId.get()); } } return id; } QVariant getEntityId(QStandardItem* item, EntityCategory category) { return getEntityId(item->data(ROLE_EntityProperties).value(), category); } QVariant qvar(boost::function val) { QVariant result; result.setValue >(val); return result; } QVariant qvar(EntityProperties* val) { QVariant result; result.setValue(val); return result; } QVariant qvar2(boost::function val) { QVariant result; result.setValue >(val); return result; } template T identity(T x) { return x; } std::string formatUrl(const std::string& formatString, int universeId, QVariant itemId = -1) { const QString baseUrl = RobloxSettings::getBaseURL(); const std::string stdBaseUrl = baseUrl.toStdString(); const QString apiBaseUrl = QString::fromStdString(ContentProvider::getUnsecureApiBaseUrl(stdBaseUrl)); const QString dataBaseUrl = QString::fromStdString(ReplaceTopSubdomain(stdBaseUrl, "data")); return QString::fromStdString(formatString) .replace("%WEB_BASE%", baseUrl) .replace("%DATA_BASE%", dataBaseUrl) .replace("%API_PROXY%", apiBaseUrl) .replace("%UNIVERSE_ID%", QString("%1").arg(universeId)) .replace("%ITEM_ID%", QString::fromStdString(Http::urlEncode(itemId.toString().toStdString()))) .toStdString(); } std::string formatUrl(const std::string& formatString, int universeId, std::string itemId) { return formatUrl(formatString, universeId, QString::fromStdString(itemId)); } Variant fixNumbersForWeb(const Variant& v) { // MVC framework does not parse numbers for optional arguments well. It // can succeed if the optional numbers are sent in strings; to be careful, wrap // all numbers as strings. // http://stackoverflow.com/questions/8964646/javascriptserializer-and-asp-net-mvc-model-binding-yield-different-results/8968207#8968207 if (v.isFloat()) { return format("%.30g", v.cast()); } else if (v.isType()) { return format("%d", v.cast()); } else if (v.isType >()) { shared_ptr newTable(new ValueTable); shared_ptr ctbl = v.cast >(); for (ValueTable::const_iterator itr = ctbl->begin(); itr != ctbl->end(); ++itr) { (*newTable)[itr->first] = fixNumbersForWeb(itr->second); } return shared_ptr(newTable); } else if (v.isType >()) { shared_ptr carr = v.cast >(); shared_ptr newArray(new ValueArray(carr->size())); for (size_t i = 0; i < carr->size(); ++i) { (*newArray)[i] = fixNumbersForWeb((*carr)[i]); } return shared_ptr(newArray); } else { return v; } } void blockingMakeRoot(int gameId, int placeId, bool* success) { try { HttpPostData d("", Http::kContentTypeDefaultUnspecified, false); HttpAsync::post(formatUrl("%WEB_BASE%/universes/setrootplace?universeid=%UNIVERSE_ID%&placeid=%ITEM_ID%", gameId, placeId), d).get(); } catch (const RBX::base_exception& e) { StandardOut::singleton()->printf(MESSAGE_ERROR, "Error while setting start place: %s", e.what()); *success = false; return; } *success = true; } void blockingUnMakeRoot(int gameId, int placeId, bool* success) { try { HttpPostData d("", Http::kContentTypeDefaultUnspecified, false); HttpAsync::post(formatUrl("%WEB_BASE%/universes/unrootplace?universeid=%UNIVERSE_ID%&placeid=%ITEM_ID%", gameId, placeId), d).get(); } catch (const RBX::base_exception& e) { StandardOut::singleton()->printf(MESSAGE_ERROR, "Error while un-setting start place: %s", e.what()); *success = false; return; } *success = true; } void placesPropertiesFixer(int universeId, EntityProperties* properties) { const QVariant id = properties->get(kWebSettings[ENTITY_CATEGORY_Places].jsonItemIdFieldName).get(); HttpOptions options; options.setDoNotUseCachedResponse(); properties->setFromJsonFuture(HttpAsync::get( formatUrl("%API_PROXY%/Marketplace/ProductInfo?assetId=%ITEM_ID%", universeId, id), options)); try { // TODO: On FFlag removal, replace ID with AssetId in CategoryWebSettings for place properties->set("ID", properties->getVariant("AssetId").get()); // replace creator information for the place (available only if place owner is logged in) boost::function()> f = extractProperty >(HttpAsync::get( formatUrl("%WEB_BASE%/places/%ITEM_ID%/settings", universeId, id), options), "Creator"); shared_ptr jsonList = f(); if (jsonList && jsonList->size() > 0) properties->set("Creator", jsonList); } catch (...) { } } void badgesPropertiesFixer(int universeId, EntityProperties* properties) { // get place id const QVariant id = properties->get(kWebSettings[ENTITY_CATEGORY_Badges].jsonItemIdFieldName).get(); // load badges information for the place HttpOptions options; options.setDoNotUseCachedResponse(); boost::function()> f = extractProperty >(HttpAsync::get( formatUrl(badgesWebSettings.fetchPageUrlFormatString, universeId, id), options), badgesWebSettings.jsonItemGroupFieldName); // set badges list shared_ptr jsonList = f(); for (size_t i = 0; i < jsonList->size(); ++i) { EntityProperties newProp; newProp.setFromValueTable(jsonList->at(i).cast >()); properties->addChild(newProp); } } void namedAssetsPropertiesFixer(int universeId, EntityProperties* properties) { if (boost::optional > optAssetInfo = properties->get >("Asset")) { shared_ptr assetInfo = optAssetInfo.get(); properties->set("AssetId", assetInfo->find("Id")->second); properties->set("AssetTypeId", assetInfo->find("TypeId")->second); } } QString generalDisplayNameGetter(EntityProperties* settings) { return QString::fromStdString(settings->get("Name").get_value_or("")); } QString namedImagesDisplayNameGetter(EntityProperties* settings) { QString name = generalDisplayNameGetter(settings); RBXASSERT(name.startsWith(kImageAssetNamePrefix)); if (name.startsWith(kImageAssetNamePrefix)) { name = name.right(name.size() - kImageAssetNamePrefix.size()); } return name; } bool namedAssetsShouldBuildExplorerItemCallback(EntityProperties* properties) { if (boost::optional nameType = properties->get("Type")) { return nameType == (int)ALIAS_TYPE_Asset || nameType == (int)ALIAS_TYPE_AssetVersion; } return true; } bool validateImageName(const QString& name, const std::vector* usedNames, QString* errorMessage) { bool valid = true; if (name.trimmed().isEmpty()) { valid = false; *errorMessage = "Name cannot be empty"; } if (usedNames) { QString compareName = QString(kImageAssetNamePrefix + name); for (std::vector::const_iterator itr = usedNames->begin(); itr != usedNames->end(); ++itr) { if (compareName.compare(*itr, Qt::CaseInsensitive) == 0) { valid = false; *errorMessage = "Name already taken (not case sensitive)"; break; } } } if (name.contains(QChar('\\')) || name.contains(QChar('/'))) { valid = false; *errorMessage = "Name cannot include '\\' or '/'"; } return valid; } void findAvailableImageNameFromFilename(const QString& fileName, const std::vector* usedNames, QString* availableImageName) { QString completedBase = QFileInfo(fileName).completeBaseName(); *availableImageName = completedBase; QString unusedErrorMessage; // Make up to #usedNames+1 attempts to find a free name: worst case, all used names // conflict, so need to try one more than that to get a free name. for (size_t attempt = 0; attempt <= usedNames->size() && !validateImageName(*availableImageName, usedNames, &unusedErrorMessage); ++attempt) { *availableImageName = QString("%1 (%2)").arg(completedBase).arg(attempt + 1); } } void createImageAssetAndNameThread(const QString& name, const QString& imageFileName, int currentGameId, boost::optional currentGameGroupId, bool* created, QString* errorMessage) { EntityProperties createImageAssetResponse; HttpPostData postData( shared_ptr(new std::ifstream(imageFileName.toStdString().c_str(), std::ios::binary)), Http::kContentTypeDefaultUnspecified, false); std::string createImageUrl = formatUrl("%DATA_BASE%/data/upload/json?assetTypeId=13&name=%ITEM_ID%&description=madeinstudio", -1, name); if (currentGameGroupId) { createImageUrl += format("&groupId=%d", currentGameGroupId.get()); } createImageAssetResponse.setFromJsonFuture(HttpAsync::post(createImageUrl, postData)); bool succeededInCreatingAsset = createImageAssetResponse.get("Success").get_value_or(false); boost::optional optionalAssetId = createImageAssetResponse.get("BackingAssetId"); if (!succeededInCreatingAsset || !optionalAssetId.is_initialized()) { *created = false; std::string returnedMessage = createImageAssetResponse.get("Message").get_value_or("Try again later."); *errorMessage =QString("Failed to create image: %1").arg(QString::fromStdString(returnedMessage)); static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", "Failed to create image asset", 0, false), flag); return; } int assetId = optionalAssetId.get(); EntityProperties createNameRequest; createNameRequest.set("Name", name.toStdString()); if (FFlag::GameExplorerUseV2AliasEndpoint) { createNameRequest.set("Type", (int)ALIAS_TYPE_Asset); createNameRequest.set("TargetId", assetId); } else { createNameRequest.set("AssetId", assetId); } Http http(formatUrl(kWebSettings[ENTITY_CATEGORY_NamedAssets].createFormatString, currentGameId, name)); std::string propertiesJson = createNameRequest.asJson(); std::istringstream propertiesStream(propertiesJson); std::string postResponse; try { http.post(propertiesStream, Http::kContentTypeApplicationJson, false, postResponse); } catch (const RBX::base_exception& e) { *created = false; *errorMessage = "An error occurred. See Output Window for details."; std::string gaMessage = format("Error while creating named asset: %s", e.what()); StandardOut::singleton()->print(MESSAGE_ERROR, gaMessage.c_str()); static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", gaMessage.c_str(), 0, false), flag); return; } RobloxGoogleAnalytics::trackEvent(GA_CATEGORY_ACTION, "Game Explorer", "Created named image", 0); *created = true; } void bulkAddNewImageNamesThread(std::vector >* fileAndImageNames, int currentGameId, boost::optional currentGameGroupId) { std::vector results(fileAndImageNames->size(), 0); std::vector errorMessages(fileAndImageNames->size()); boost::thread_group threadGroup; for (size_t i = 0; i < fileAndImageNames->size(); ++i) { threadGroup.create_thread(boost::bind(&createImageAssetAndNameThread, kImageAssetNamePrefix + (*fileAndImageNames)[i].second, (*fileAndImageNames)[i].first, currentGameId, currentGameGroupId, (bool*)(&results[i]), &errorMessages[i])); } threadGroup.join_all(); // TODO: show modal with failed file names int failed = 0; for (size_t i = 0; i < fileAndImageNames->size(); ++i) { if (!results[i]) { failed++; } } if (failed > 0) { StandardOut::singleton()->printf(MESSAGE_ERROR, "Failed to upload %d images", failed); } } void updateNameThread(const std::string& properties, const std::string& updateUrl, bool* success, std::string* responseValue) { Http updater(updateUrl); std::istringstream serializedSettings(properties); std::string postResponse; try { updater.post(serializedSettings, Http::kContentTypeApplicationJson, false, postResponse); } catch (const RBX::base_exception& e) { StandardOut::singleton()->printf(MESSAGE_ERROR, "Error while updating name: %s", e.what()); *success = false; return; } *success = true; if (responseValue) { *responseValue = postResponse; } } QString getNameOrEmptyIfNotAssetType(int assetType, EntityProperties* settings) { if (boost::optional assetId = settings->get("AssetTypeId")) { if (assetId.get() == assetType) { return generalDisplayNameGetter(settings); } } return QString(); } std::string getPropertiesAsJason(int assetType, EntityProperties* settings) { if (boost::optional assetId = settings->get("AssetTypeId")) { if (assetId.get() == assetType) { return settings->asJson(); } } return std::string(); } static EntityProperties* getPropertiesIfNameAndAssetTypeMatch(const std::string& name, int assetType, EntityProperties* properties) { if (name == properties->get("Name").get() && assetType == properties->get("AssetTypeId").get()) { return properties; } else { return NULL; } } QString getUnpublishedChangesDirectory(int universeId) { return QDir(AppSettings::instance().tempLocation() + QString("/UnpublishedChanges/%1/").arg(universeId)) .absolutePath() + "/"; } QString getUnpublishedAssetFilePath(const std::string& assetName, int universeId) { return getUnpublishedChangesDirectory(universeId) + QString::fromStdString(assetName); } bool anyFilesRecursive(QDir root) { QStringList files = root.entryList(QDir::Files); if (!files.empty()) { return true; } QStringList dirs = root.entryList(QDir::NoDotAndDotDot | QDir::Dirs); for (QStringList::iterator itr = dirs.begin(); itr != dirs.end(); ++itr) { if (anyFilesRecursive(QDir(root.filePath(*itr)))) { return true; } } return false; } void removeFile(QString path, HttpFuture future) { try { future.get(); QFile(path).remove(); } catch (const RBX::base_exception& e) { StandardOut::singleton()->printf(MESSAGE_ERROR, "Error while publishing script: %s", e.what()); } } void deleteFilesAndFoldersRecursive(QDir root) { if (!root.exists()) { return; } QFileInfoList list = root.entryInfoList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); for (QFileInfoList::iterator itr = list.begin(); itr != list.end(); ++itr) { if (itr->isFile()) { QFile(itr->absoluteFilePath()).remove(); } else if (itr->isDir()) { deleteFilesAndFoldersRecursive(QDir(itr->absoluteFilePath())); } } QDir().rmdir(root.absolutePath()); } EntityProperties* getGroupIdFromPlaceEntityPropertyHelper(boost::optional* groupId, EntityProperties* placeProperties) { typedef Reflection::ValueTable JsonTable; typedef shared_ptr JsonTableRef; try { JsonTableRef r = placeProperties->get("Creator").get(); // CreatorType 0 = User 1 = Group JsonTable::const_iterator iter = r->find("CreatorType"); if (iter != r->end() && iter->second.isNumber() && iter->second.cast() == 1) { *groupId = r->find("CreatorTargetId")->second.cast(); } } catch (const RBX::base_exception&) { } return placeProperties; } boost::optional getGroupIdFromPlaceEntityProperties(EntityPropertiesForCategory::ref categoryProperties) { std::vector unused; boost::optional groupId; categoryProperties->transform(boost::bind(&getGroupIdFromPlaceEntityPropertyHelper, &groupId, _1), &unused); return groupId; } } // end anonymous namespace EntityProperties::EntityProperties() : hasPendingFuture(false) , ableToGetFuture(true) , ableToParseJson(true) { } void EntityProperties::setFromJsonFuture(const boost::shared_future& jsonFuture) { hasPendingFuture = true; this->jsonFuture = jsonFuture; } void EntityProperties::setFromValueTable(const boost::shared_ptr& valueTable) { hasPendingFuture = false; ableToGetFuture = true; ableToParseJson = true; data.clear(); data.insert(valueTable->begin(), valueTable->end()); } void EntityProperties::setFromEntitySettings(EntityProperties& other) { waitUntilLoaded(); other.waitUntilLoaded(); RBXASSERT(!other.hasPendingFuture); RBXASSERT(other.ableToGetFuture); RBXASSERT(other.ableToParseJson); hasPendingFuture = false; ableToGetFuture = true; ableToParseJson = true; data.clear(); data.insert(other.data.begin(), other.data.end()); } std::string EntityProperties::asJson() { waitUntilLoaded(); std::string result; shared_ptr val(new ValueTable); for (ValueTable::iterator itr = data.begin(); itr != data.end(); ++itr) { (*val)[itr->first] = fixNumbersForWeb(itr->second); } WebParser::writeJSON(shared_ptr(val), result); return result; } void EntityProperties::waitUntilLoaded() { if (!hasPendingFuture) { return; } hasPendingFuture = false; std::string json; try { json = jsonFuture.get(); } catch (const RBX::base_exception&) { static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", "load entity settings http failure", 0, false), flag); ableToGetFuture = false; ableToParseJson = false; return; } ableToGetFuture = true; shared_ptr table; ableToParseJson = WebParser::parseJSONTable(json, table); if (!ableToParseJson) { static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", "load entity settings json parse failure", 0, false), flag); return; } data.clear(); data.insert(table->begin(), table->end()); } bool EntityProperties::isLoadedAndParsed() const { if (hasPendingFuture) return false; for (EntityCollection::const_iterator itr = children.begin(); itr != children.end(); ++itr) { if (!itr->isLoadedAndParsed()) { return false; } } return ableToGetFuture && ableToParseJson; } boost::optional EntityProperties::getVariant(const std::string& fieldName) { waitUntilLoaded(); boost::optional result; RBX::Reflection::ValueTable::iterator itr = data.find(fieldName); if (itr != data.end()) { result = itr->second; } return result; } void UniverseSettings::setFromJsonFuture(const boost::shared_future& jsonFuture) { properties.setFromJsonFuture(jsonFuture); } void UniverseSettings::waitUntilLoaded() { properties.waitUntilLoaded(); } bool UniverseSettings::isLoadedAndParsed() const { return properties.isLoadedAndParsed(); } bool UniverseSettings::hasRootPlace() { return properties.get("RootPlace"); } int UniverseSettings::rootPlaceId() { boost::optional rootPlaceId = properties.get("RootPlace"); RBXASSERT(rootPlaceId); if (rootPlaceId) { return rootPlaceId.get(); } return -1; } std::string UniverseSettings::getName() { return properties.get("Name").get_value_or(""); } void UniverseSettings::setName(const std::string& newName) { properties.set("Name", newName); } bool UniverseSettings::currentUserHasAccess() { return properties.get("CurrentUserHasEditPermissions").get_value_or(false); } std::string UniverseSettings::asJson() { return properties.asJson(); } EntityPropertiesForCategory::ref EntityPropertiesForCategory::loadFromWeb( const CategoryWebSettings& webSettings, int universeId, boost::function doneLoadingCallback) { ref newSettings(new EntityPropertiesForCategory(webSettings, universeId)); newSettings->doneLoadingCallback = doneLoadingCallback; newSettings->requestPage(1); return newSettings; } bool EntityPropertiesForCategory::isLoadedAndParsed() { waitUntilLoaded(); for (EntityCollection::const_iterator itr = data.begin(); itr != data.end(); ++itr) { if (!itr->isLoadedAndParsed()) { return false; } } return true; } template void EntityPropertiesForCategory::publishTo(ref target, const std::vector& ignoreFieldsWhenCopying, boost::unordered_map >* outputRemaps) { waitUntilLoaded(); target->waitUntilLoaded(); RBXASSERT(&webSettings == &target->webSettings); boost::unordered_map > idRemaps; std::vector danglingFutures; // create & update slots, marking used ids boost::unordered_set usedTargetIds; for (EntityCollection::iterator itr = data.begin(); itr != data.end(); ++itr) { const boost::optional name = itr->get("Name"); const boost::optional id = itr->get(webSettings.perItemIdFieldName); RBXASSERT(name); RBXASSERT(id); EntityProperties copyOfSettings; copyOfSettings.setFromEntitySettings(*itr); bool found = false; for (EntityCollection::iterator targetItr = target->data.begin(); !found && targetItr != target->data.end(); ++targetItr) { const boost::optional targetId = targetItr->get(webSettings.perItemIdFieldName); RBXASSERT(targetId); if (usedTargetIds.count(targetId.get()) == 0 && targetItr->get("Name").get() == name.get()) { found = true; usedTargetIds.insert(targetId.get()); idRemaps[id.get()] = boost::bind(&identity, targetId.get()); copyOfSettings.set(webSettings.perItemIdFieldName, targetId.get()); for (size_t i = 0; i < ignoreFieldsWhenCopying.size(); ++i) { const std::string& fieldName = ignoreFieldsWhenCopying[i]; boost::optional valueToKeep = targetItr->getVariant(fieldName); RBXASSERT(valueToKeep.is_initialized()); if (valueToKeep.is_initialized()) { copyOfSettings.set(fieldName, valueToKeep.get()); } } HttpPostData postData(copyOfSettings.asJson(), Http::kContentTypeApplicationJson, false); danglingFutures.push_back(HttpAsync::post( formatUrl(webSettings.updateFormatString, target->universeId, targetId.get()), postData)); } } if (!found) { HttpPostData postData(copyOfSettings.asJson(), Http::kContentTypeApplicationJson, false); idRemaps[id.get()] = extractProperty( HttpAsync::post(formatUrl(webSettings.createFormatString, target->universeId), postData), webSettings.jsonItemIdFieldName); } } // clean up unused ids if (!webSettings.removeFormatString.empty()) { for (EntityCollection::iterator itr = target->data.begin(); itr != target->data.end(); ++itr) { boost::optional targetId = itr->get(webSettings.perItemIdFieldName); RBXASSERT(targetId); if (usedTargetIds.count(targetId.get()) == 0) { HttpPostData postData("", Http::kContentTypeDefaultUnspecified, false); danglingFutures.push_back(HttpAsync::post( formatUrl(webSettings.removeFormatString, target->universeId, targetId.get()), postData)); } } } // complete pending requests for (size_t i = 0; i < danglingFutures.size(); ++i) { danglingFutures[i].get(); } if (outputRemaps) { outputRemaps->clear(); outputRemaps->insert(idRemaps.begin(), idRemaps.end()); } } EntityPropertiesForCategory::EntityPropertiesForCategory(const CategoryWebSettings& webSettings, int universeId) : webSettings(webSettings) , universeId(universeId) , doneEvent(true /*manual reset*/) { } void EntityPropertiesForCategory::requestPage(int page) { Http fetcher(formatUrl(webSettings.fetchPageUrlFormatString, universeId, page)); fetcher.doNotUseCachedResponse = true; fetcher.get(boost::bind(&EntityPropertiesForCategory::handlePage, shared_from_this(), page, _1, _2)); } void EntityPropertiesForCategory::handlePage(int loadedPage, std::string* json, std::exception* error) { std::string errorStatus; shared_ptr topLevel; if (!LuaWebService::parseWebJSONResponseHelper(json, error, topLevel, errorStatus)) { StandardOut::singleton()->printf(MESSAGE_ERROR, "Error while loading entities for universe %d: %s", universeId, errorStatus.c_str()); static boost::once_flag flag = BOOST_ONCE_INIT; std::string label = format("load entity page failed: %s", errorStatus.c_str()); boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", label.c_str(), 0, false), flag); return; } bool needMoreData = true; bool finalPage, exceededLimit; try { shared_ptr jsonList = topLevel->at(webSettings.jsonItemGroupFieldName) .cast >(); finalPage = topLevel->at("FinalPage").cast(); exceededLimit = FInt::GameViewItemCap < (int)(data.size() + jsonList->size()); needMoreData = !(finalPage || exceededLimit); for (size_t i = 0; i < jsonList->size(); ++i) { shared_ptr itemInfo = jsonList->at(i).cast >(); data.push_back(EntityProperties()); data.back().setFromValueTable(itemInfo); if (webSettings.propertiesFixer) { webSettings.propertiesFixer(universeId, &data.back()); } } // request more after adding if (needMoreData) { // kick off next page load requestPage(loadedPage + 1); } } catch (const std::exception& e) { std::string label = format("load entity page exception: %s", e.what()); static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", label.c_str(), 0, false), flag); StandardOut::singleton()->print(MESSAGE_WARNING, "Error while loading data for game. Try again later."); doneEvent.Set(); } if (!needMoreData) { if (exceededLimit && !finalPage) { StandardOut::singleton()->printf(MESSAGE_WARNING, "Warning: studio can currently only load %d items per category at a time. " "Some of your items may not have been loaded.", FInt::GameViewItemCap); static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", "over item limit", 0, false), flag); } // wait for actual data for (EntityCollection::iterator itr = data.begin(); itr != data.end(); ++itr) { itr->waitUntilLoaded(); } doneEvent.Set(); if (doneLoadingCallback) { doneLoadingCallback(); doneLoadingCallback = NULL; } } } void EntityPropertiesForCategory::waitUntilLoaded() { doneEvent.Wait(); } void AddImageDialog::runModal(QWidget* parent, bool* created, QString* newName) { this->created = created; this->newName = newName; // initialize usedNames usedNames.clear(); RobloxGameExplorer& rge = UpdateUIManager::Instance().getViewWidget(eDW_GAME_EXPLORER); rge.getListOfImages(&usedNames); this->currentGameId = rge.getCurrentGameId(); this->currentGameGroupId = rge.getCurrentGameGroupId(); *created = false; dialog = new QDialog(parent); QGridLayout* layout = new QGridLayout(); QGridLayout* inputLayout = new QGridLayout(); // Image file QLabel* fileLabel = new QLabel("Image:", dialog); inputLayout->addWidget(fileLabel, 0, 0, Qt::AlignRight); fileNameLabel = new QLabel(" ", dialog); inputLayout->addWidget(fileNameLabel, 0, 1, Qt::AlignLeft); fileNameEdit = new QPushButton("Choose File", dialog); connect(fileNameEdit, SIGNAL(clicked()), this, SLOT(openFileSelector())); inputLayout->addWidget(fileNameEdit, 1, 1, Qt::AlignLeft); fileNameErrorMessage = new QLabel(" ", dialog); fileNameErrorMessage->setStyleSheet(kErrorTextStyleSheet); inputLayout->addWidget(fileNameErrorMessage, 2, 1, Qt::AlignLeft); // Name QLabel* nameLabel = new QLabel("Name:", dialog); inputLayout->addWidget(nameLabel, 3, 0, Qt::AlignRight); nameEdit = new QLineEdit(dialog); nameEdit->setFixedWidth(250); connect(nameEdit, SIGNAL(textEdited(const QString&)), this, SLOT(validateName())); inputLayout->addWidget(nameEdit, 3, 1, Qt::AlignLeft); nameErrorMessage = new QLabel(" ", dialog); nameErrorMessage->setStyleSheet(kErrorTextStyleSheet); inputLayout->addWidget(nameErrorMessage, 4, 1, Qt::AlignLeft); layout->addLayout(inputLayout, 0, 0, 1, 2); // General error message generalErrorMessage = new QLabel("", dialog); generalErrorMessage->setStyleSheet(kErrorTextStyleSheet); layout->addWidget(generalErrorMessage, 1, 0, 1, 2, Qt::AlignCenter); QPushButton* createButton = new QPushButton("Create", dialog); connect(createButton, SIGNAL(clicked()), this, SLOT(createButtonClicked())); layout->addWidget(createButton, 2, 0); QPushButton* cancelButton = new QPushButton("Cancel", dialog); connect(cancelButton, SIGNAL(clicked()), dialog, SLOT(reject())); layout->addWidget(cancelButton, 2, 1); dialog->setLayout(layout); dialog->exec(); } void AddImageDialog::createImageAndNameThread() { QString name = kImageAssetNamePrefix + nameEdit->text(); QString errorMessage; createImageAssetAndNameThread(name, fileNameLabel->text(), currentGameId, currentGameGroupId, created, &errorMessage); if (*created) { *newName = name; } else { generalErrorMessage->setText(errorMessage); } } bool AddImageDialog::validateName() { QString errorMessage; bool valid = validateImageName(nameEdit->text(), &usedNames, &errorMessage); if (valid) { nameEdit->setStyleSheet(""); nameErrorMessage->setText(" "); } else { nameEdit->setStyleSheet(kErrorTextInputStyleSheet); nameErrorMessage->setText(errorMessage); } return valid; } bool AddImageDialog::validateImageFile() { bool valid = true; QString errorMessage; QString fileName = fileNameLabel->text(); if (fileName.trimmed().isEmpty()) { valid = false; fileNameErrorMessage->setText("Image file is required"); } else if (!QFile(fileName).exists()) { valid = false; fileNameErrorMessage->setText("File does not exist"); } if (valid) { fileNameErrorMessage->setText(" "); } return valid; } void AddImageDialog::openFileSelector() { RobloxSettings settings; QString imageLastDir = settings.value(kLastImageDirectoryKey).toString(); if (imageLastDir.isEmpty()) imageLastDir = RobloxMainWindow::getDefaultSavePath(); QString fileName = QDir::toNativeSeparators( QFileDialog::getOpenFileName(dialog, "Choose image to upload", imageLastDir, "Images (*.jpg *.jpeg *.png *.gif *.bmp);;All Files (*.*)")); if (!fileName.isEmpty()) { fileNameLabel->setText(fileName); settings.setValue(kLastImageDirectoryKey, QFileInfo(fileName).absolutePath()); QString availableName; findAvailableImageNameFromFilename(fileName, &usedNames, &availableName); nameEdit->setText(availableName); } validateImageFile(); } void AddImageDialog::createButtonClicked() { bool nameValid = validateName(); bool fileValid = validateImageFile(); if (nameValid && fileValid) { UpdateUIManager::Instance().waitForLongProcess("Creating asset name...", boost::bind(&AddImageDialog::createImageAndNameThread, this)); if (*created) { UpdateUIManager::Instance().getViewWidget(eDW_GAME_EXPLORER) .reloadDataFromWeb(); dialog->accept(); } } else { // refocus the first field that is not valid if (!nameValid) { nameEdit->setFocus(); } else { fileNameEdit->setFocus(); } } } RobloxGameExplorer::RobloxGameExplorer(QWidget* parent) : QTreeView(parent) , currentGameId(kNoGameLoaded) , currentPlaceId(kNoGameLoaded) , currentSessionId(0) , enoughDataLoadedToBeAbleToOpenStartPlace(false) , startPlaceOpenRequested(false) { if (!FFlag::GameExplorerUseV2AliasEndpoint) { CategoryWebSettings* namedAssetSettings = const_cast(&kWebSettings[ENTITY_CATEGORY_NamedAssets]); namedAssetSettings->createFormatString = "%API_PROXY%/universes/create-alias?universeId=%UNIVERSE_ID%"; namedAssetSettings->updateFormatString = "%API_PROXY%/universes/update-alias?universeId=%UNIVERSE_ID%&oldName=%ITEM_ID%"; } CategoryUiSettings placeSettings = { "Places", QtUtilities::getPixmap(":/images/ClassImages.PNG", 19), &generalDisplayNameGetter, NULL, // shouldBuildExplorerItemCallback boost::bind(&RobloxGameExplorer::placeDoubleClickCallback, this, _1), boost::bind(&RobloxGameExplorer::placeContextMenuHandler, this, _1, _2), boost::bind(&RobloxGameExplorer::placeGroupContextMenuHandler, this, _1), boost::bind(&RobloxGameExplorer::checkRowForNameUpdate, this, ENTITY_CATEGORY_Places, _1), CategoryUiSettings::FlatNames }; uiSettings[ENTITY_CATEGORY_Places] = placeSettings; CategoryUiSettings devProductSettings = { "Developer Products", QPixmap(":/images/DeveloperProduct.png"), &generalDisplayNameGetter, NULL, // shouldBuildExplorerItemCallback NULL, // double click callback boost::bind(&RobloxGameExplorer::developerProductContextMenuHandler, this, _1, _2), boost::bind(&RobloxGameExplorer::developerProductGroupContextMenuHandler, this, _1), boost::bind(&RobloxGameExplorer::checkRowForNameUpdate, this, ENTITY_CATEGORY_DeveloperProducts, _1), CategoryUiSettings::FlatNames }; uiSettings[ENTITY_CATEGORY_DeveloperProducts] = devProductSettings; CategoryUiSettings badgeSettings = { "Badges", QPixmap(":/16x16/images/Studio 2.0 icons/16x16/badges.png"), &generalDisplayNameGetter, NULL, // shouldBuildExplorerItemCallback NULL, // double click callback boost::bind(&RobloxGameExplorer::badgesContextMenuHandler, this, _1, _2), NULL, NULL, CategoryUiSettings::FlatNames }; uiSettings[ENTITY_CATEGORY_Badges] = badgeSettings; CategoryUiSettings namedAssetSettings = { "Assets", QtUtilities::getPixmap(":/images/ClassImages.PNG", 52), &generalDisplayNameGetter, &namedAssetsShouldBuildExplorerItemCallback, boost::bind(&RobloxGameExplorer::namedAssetsDoubleClickCallback, this, _1), boost::bind(&RobloxGameExplorer::namedAssetsContextMenuHandler, this, _1, _2), boost::bind(&RobloxGameExplorer::namedAssetsGroupContextMenuHandler, this, _1), boost::bind(&RobloxGameExplorer::checkRowForNameUpdate, this, ENTITY_CATEGORY_NamedAssets, _1), CategoryUiSettings::NameAsFolderPath }; uiSettings[ENTITY_CATEGORY_NamedAssets] = namedAssetSettings; setModel(new QStandardItemModel); setExpandsOnDoubleClick(false); setEditTriggers(QAbstractItemView::SelectedClicked); setContextMenuPolicy(Qt::CustomContextMenu); QStringList lst; lst.push_back(""); getModel()->setHorizontalHeaderLabels(lst); gameNameEdit = new AbortableLineEdit(boost::bind(&UniverseSettings::getName, &gameSettings)); gameNameEdit->setSizePolicy(QSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Maximum)); gameNameEdit->setEnabled(false); idLabel = new QLabel(""); idLabel->setSizePolicy(QSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum)); idLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); reloadButton = new QPushButton(""); reloadButton->setIcon(QPixmap(":/images/advT_rotateGrid_old.png")); reloadButton->setEnabled(false); QGridLayout* layout = new QGridLayout(header()); int left, unused; layout->getContentsMargins(&left, &unused, &unused, &unused); layout->setContentsMargins(left, 0, 0, 0); layout->addWidget(gameNameEdit, 0, 0); layout->addWidget(idLabel, 0, 1); layout->addWidget(reloadButton, 0, 2, Qt::AlignRight); layout->setColumnStretch(0, 1); header()->hide(); connect(gameNameEdit, SIGNAL(editingFinished ()), this, SLOT( doneEditingUniverseName())); connect(reloadButton, SIGNAL(clicked()), this, SLOT(reloadDataFromWeb())); connect(this, SIGNAL(doubleClicked (QModelIndex)), this, SLOT( doubleClickHandler (QModelIndex))); connect(this, SIGNAL(customContextMenuRequested (const QPoint &)), this, SLOT( contextMenuHandler (const QPoint &))); connect(getModel(), SIGNAL(itemChanged (QStandardItem*)), this, SLOT(itemChangedHandler(QStandardItem*))); } boost::optional RobloxGameExplorer::findCachedAssetOrEmpty(const RBX::ContentId& contentId, int universeId) { boost::optional assetName; if (contentId.isNamedAsset()) { assetName = contentId.getAssetName(); } if (contentId.isConvertedNamedAsset()) { assetName = contentId.getUnConvertedAssetName(); } if (assetName) { QFile file(getUnpublishedAssetFilePath(assetName.get(), universeId)); if (file.exists()) { file.open(QIODevice::ReadOnly); QString content(file.readAll()); return content.toStdString(); } } return boost::optional(); } void RobloxGameExplorer::updateAsset(const RBX::ContentId& contentId, const std::string& newContents) { RBXASSERT(contentId.isNamedAsset()); RBXASSERT(currentGameId != kNoGameLoaded); if (contentId.isNamedAsset() && currentGameId != kNoGameLoaded) { QString filePath = getUnpublishedAssetFilePath(contentId.getAssetName(), currentGameId); QDir dir(filePath + "/.."); QDir().mkpath(dir.absolutePath()); QFile file(filePath); file.open(QIODevice::WriteOnly | QIODevice::Truncate); QTextStream outStream(&file); outStream << QString::fromStdString(newContents); outStream.flush(); refreshNamedScriptIcons(currentSessionId); } } void RobloxGameExplorer::notifyIfUnpublishedChanges(int universeId) { QDir unpublishedDir(getUnpublishedChangesDirectory(universeId)); if (!unpublishedDir.exists()) { return; } if (anyFilesRecursive(unpublishedDir)) { QMessageBox mb; mb.setText("RobloxStudio is using unpublished changes for this universe. You can revert " "these changes to the last published version using the Game Explorer."); mb.setStandardButtons(QMessageBox::Ok); mb.exec(); } } void RobloxGameExplorer::publishNamedAssetsToCurrentSlot() { RBXASSERT(currentGameId != kNoGameLoaded); if (currentGameId == kNoGameLoaded) { return; } std::vector > publishFutures; entitySettings[ENTITY_CATEGORY_NamedAssets]->transform >( boost::bind(&RobloxGameExplorer::publishIfThereAreLocalModifications, this, _1), &publishFutures); for (auto& f : publishFutures) { try { f.get(); } catch (const RBX::base_exception&) { } } QMetaObject::invokeMethod(this, "refreshNamedScriptIcons", Qt::QueuedConnection, Q_ARG(int, currentSessionId)); } HttpFuture RobloxGameExplorer::publishScriptAsset(const std::string& scriptText, boost::optional optionalAssetId, boost::optional optionalGroupId) { int assetId = 0; if (optionalAssetId) { assetId = optionalAssetId.get(); } // if assetId url parameter is 0 it will create a new asset QString uploadUrl; uploadUrl = QString::fromStdString(formatUrl( "%DATA_BASE%/Data/Upload.ashx?type=Lua&name=Script&description=Script&ispublic=false&json=1&assetId=%ITEM_ID%", -1, assetId)); // Group id url parameter is ignored when updating an existing asset, so only append url parameter // when creating a new asset. if (optionalGroupId && !optionalAssetId) { uploadUrl += QString("&groupId=%1").arg(optionalGroupId.get()); } HttpPostData postData(scriptText, Http::kContentTypeTextPlain, false); return HttpAsync::post(uploadUrl.toStdString(), postData); } void RobloxGameExplorer::getListOfImages(std::vector* out) { if (currentGameId == kNoGameLoaded) { return; } entitySettings[ENTITY_CATEGORY_NamedAssets]->transform( boost::bind(&getNameOrEmptyIfNotAssetType, ASSET_TYPE_ID_Image, _1), out); out->erase(std::remove_if(out->begin(), out->end(), boost::bind(&QString::isEmpty, _1)), out->end()); } void RobloxGameExplorer::getListOfScripts(std::vector* out) { if (currentGameId == kNoGameLoaded) { return; } entitySettings[ENTITY_CATEGORY_NamedAssets]->transform( boost::bind(&getNameOrEmptyIfNotAssetType, ASSET_TYPE_ID_Script, _1), out); out->erase(std::remove_if(out->begin(), out->end(), boost::bind(&QString::isEmpty, _1)), out->end()); } void RobloxGameExplorer::getListOfAnimations(std::vector* out) { if (currentGameId == kNoGameLoaded) return; entitySettings[ENTITY_CATEGORY_NamedAssets]->transform( boost::bind(&getNameOrEmptyIfNotAssetType, ASSET_TYPE_ID_Animation, _1), out); out->erase(std::remove_if(out->begin(), out->end(), boost::bind(&QString::isEmpty, _1)), out->end()); } QString RobloxGameExplorer::getAnimationsDataJson() { if (currentGameId == kNoGameLoaded) return QString(); std::vector animationsData; entitySettings[ENTITY_CATEGORY_NamedAssets]->transform( boost::bind(&getPropertiesAsJason, ASSET_TYPE_ID_Animation, _1), &animationsData); animationsData.erase(std::remove_if(animationsData.begin(), animationsData.end(), boost::bind(&std::string::empty, _1)), animationsData.end()); if (animationsData.size() < 1) return QString(); std::stringstream outputBuffer; outputBuffer << "["; outputBuffer << boost::algorithm::join(animationsData, ","); outputBuffer << "]"; return QString(outputBuffer.str().c_str()); } void RobloxGameExplorer::openGameFromGameId(int gameId) { // close the current play doc, if there is one RobloxIDEDoc* ideDoc = RobloxDocManager::Instance().getPlayDoc(); if (gameId != currentGameId && ideDoc) { // If we have an ide doc either it is 1) a place file / template where // _neither_ game id nor place id are set, or 2) an open game where both // game id and place id are _both_ set. RBXASSERT((currentPlaceId == kNoGameLoaded) == (currentGameId == kNoGameLoaded)); QMessageBox dialog(this); dialog.setText("Opening a new game will close the currently opened Place. Continue?"); dialog.setStandardButtons(QMessageBox::Yes | QMessageBox::No); dialog.setDefaultButton(QMessageBox::No); int state = dialog.exec(); if (state == QMessageBox::No) { return; } RBXASSERT(state == QMessageBox::Yes); // close the current play doc if (!UpdateUIManager::Instance().getMainWindow().requestDocClose(ideDoc)) { return; } } startPlaceOpenRequested = true; if (currentGameId == gameId) { openStartPlaceIfRequestedAndNoPlaceOpenedAndDataReady(); } doOpenGameFromGameId(gameId); } void RobloxGameExplorer::doOpenGameFromGameId(int gameId) { // Check if we are requesting to open the current game if (gameId == currentGameId) { Q_EMIT namedAssetsLoaded(gameId); return; } notifyIfUnpublishedChanges(gameId); // If we are opening a different game: enoughDataLoadedToBeAbleToOpenStartPlace = false; UpdateUIManager::Instance().getMainWindow().closeConfigureWebDoc(); if (currentGameId == kNoGameLoaded) { // clear "this place not associated with game" message getModel()->setRowCount(0); } // initialize view for a new universe currentGameId = gameId; UpdateUIManager::Instance().getMainWindow().publishGameAction->setEnabled(!RobloxIDEDoc::getIsCloudEditSession()); // initialize groups if they're missing for (unsigned int i = 0; i < ENTITY_CATEGORY_MAX; ++i) { EntityCategory category = (EntityCategory)i; if (!findGroup(category)) { QStandardItem* group = makeGroup(category); if (uiSettings[category].groupContextMenuCallback) { group->setData(qvar2(uiSettings[category].groupContextMenuCallback), ROLE_ContextMenuCallback); } if (i == ENTITY_CATEGORY_Places) { setExpanded(group->index(), true); } } } reloadDataFromWeb(); header()->show(); gameNameEdit->setText("Loading..."); gameNameEdit->setEnabled(false); idLabel->setText(QString("Id: %1").arg(currentGameId)); reloadButton->setEnabled(true); } void RobloxGameExplorer::openGameFromPlaceId(int placeId) { if (placeId == currentPlaceId) return; if (placeId == 0) { nonGameLoaded(); return; } currentPlaceId = placeId; refreshOpenedPlaceIndicator(); Http getUniverse(formatUrl("%API_PROXY%/universes/get-universe-containing-place?placeid=%ITEM_ID%", -1, placeId)); getUniverse.doNotUseCachedResponse = true; getUniverse.get(boost::bind(&RobloxGameExplorer::handleUniverseForPlaceResponse, QPointer(this), currentSessionId, placeId, _1, _2)); } void RobloxGameExplorer::onCloseIdeDoc() { currentPlaceId = kNoGameLoaded; if (currentGameId == kNoGameLoaded) { getModel()->setRowCount(0); } else { refreshOpenedPlaceIndicator(); } } static void kickOffPublish() { RobloxIDEDoc* ideDoc = RobloxDocManager::Instance().getPlayDoc(); RBXASSERT(ideDoc); if (!ideDoc) return; Verb* v = ideDoc->getVerb("PublishToRobloxAsVerb"); RBXASSERT(v); if (!v) return; v->doIt(NULL); } void RobloxGameExplorer::nonGameLoaded() { currentGameId = kNoGameLoaded; currentPlaceId = kNoGameLoaded; // change session to invalidate any pending http requests currentSessionId++; getModel()->setRowCount(0); gameNameEdit->setText(""); idLabel->setText(""); header()->hide(); reloadButton->setEnabled(false); UpdateUIManager::Instance().getMainWindow().publishGameAction->setEnabled(false); QStandardItem* noGameItem = new QStandardItem("Publish this place to load game data (double click here)"); noGameItem->setData(qvar(boost::bind(&kickOffPublish)), ROLE_DoubleClickCallback); noGameItem->setEditable(false); QFont f = noGameItem->font(); f.setItalic(true); noGameItem->setFont(f); getModel()->appendRow(noGameItem); Q_EMIT namedAssetsLoaded(currentGameId); } std::pair > RobloxGameExplorer::buildPlaceContentPair(EntityProperties* properties) { std::pair > result; const CategoryWebSettings& placeWebSettings = kWebSettings[ENTITY_CATEGORY_Places]; result.first = properties->get(placeWebSettings.perItemIdFieldName).get(); bool useLocalContent = false; if (result.first == currentPlaceId) { if (RobloxIDEDoc* ideDoc = RobloxDocManager::Instance().getPlayDoc()) { if (shared_ptr dm = ideDoc->getEditDataModel()) { // This step is best-effort. The asset publishes help, but are // not strictly required for publish try { if (FFlag::StudioCSGAssets) PartOperationAsset::publishAll(dm.get()); } catch (const std::exception&) { } try { shared_ptr serialized = dm->serializeDataModel(); result.second = boost::bind(&std::stringstream::str, serialized); useLocalContent = true; } catch(const std::exception& e) { StandardOut::singleton()->printf(MESSAGE_ERROR, "Error when saving current place: %s. Continuing to publish with last known copy of this place.", e.what()); } } } } if (!useLocalContent) { HttpOptions options; options.setDoNotUseCachedResponse(); result.second = boost::bind(&HttpFuture::get, HttpAsync::get(formatUrl(placeWebSettings.getContentFormatString, currentGameId, result.first))); } return result; } RobloxGameExplorer::NamedAssetCopyInfo RobloxGameExplorer::buildNamedContentInfo( EntityPropertiesForCategory::ref targetProperties, int sourceUniverseId, boost::optional targetUniverseGroupId, boost::unordered_map >* devProductIdRemap, EntityProperties* properties) { NamedAssetCopyInfo result; result.assetName = properties->get("Name").get(); if (FFlag::GameExplorerUseV2AliasEndpoint) { result.aliasType = properties->get("Type").get(); if ((AliasType)result.aliasType == ALIAS_TYPE_DeveloperProduct) { result.needsTargetIdUpdate = true; result.newTargetIdGetter = (*devProductIdRemap)[properties->get("TargetId").get()]; return result; } } const int sourceAssetTypeId = properties->get("AssetTypeId").get(); // for immutable asset types just keep the source asset id if (sourceAssetTypeId == (int)ASSET_TYPE_ID_Image) { result.needsTargetIdUpdate = false; return result; } RBXASSERT(sourceAssetTypeId == (int)ASSET_TYPE_ID_Script); // If the asset type is mutable we always want to update asset id because // when the asset name properties were copied over the asset id binding gets clobbered. // (so either we need to restore the asset id after creating a new version, or we // will need to create a completely new asset for the asset name). result.needsTargetIdUpdate = true; // for mutable asset types, figure out if there is an existing asset name pointing to a // mutable type std::vector foundProperty; targetProperties->transform( boost::bind(&getPropertiesIfNameAndAssetTypeMatch, boost::ref(result.assetName), sourceAssetTypeId, _1), &foundProperty); boost::optional foundAssetId; for (size_t i = 0; i < foundProperty.size(); ++i) { if (foundProperty[i]) { foundAssetId = foundProperty[i]->get("AssetId"); break; } } std::string scriptText; const ContentId contentId = ContentId::fromGameAssetName(result.assetName); try { scriptText = LuaSourceBuffer::getScriptAssetSource(contentId, sourceUniverseId).get(); HttpFuture scriptPublishFuture = publishScriptAsset(scriptText, foundAssetId, targetUniverseGroupId); if (FFlag::GameExplorerUseV2AliasEndpoint) { result.newTargetIdGetter = extractProperty(scriptPublishFuture, "AssetId"); } else { result.newAssetIdProperties.setFromJsonFuture(scriptPublishFuture); } } catch (const RBX::base_exception&) { StandardOut::singleton()->printf(MESSAGE_ERROR, "Failed to update asset %s", result.assetName.c_str()); // mark the result as not-update result.needsTargetIdUpdate = false; } return result; } void RobloxGameExplorer::publishGameThread(boost::function newUniverseFuture, bool* succeeded, int* targetUniverseId) { *targetUniverseId = -1; *succeeded = false; try { *targetUniverseId = newUniverseFuture(); UniverseSettings targetUniverseSettings; { // todo: use HttpRbxApiService HttpOptions getOptions; getOptions.setDoNotUseCachedResponse(); targetUniverseSettings.setFromJsonFuture(HttpAsync::get( formatUrl("%API_PROXY%/universes/get-info?universeId=%UNIVERSE_ID%", *targetUniverseId), getOptions)); } std::vector > > placeContentFutures; // start place content requests entitySettings[ENTITY_CATEGORY_Places]->transform > >( boost::bind(&RobloxGameExplorer::buildPlaceContentPair, this, _1), &placeContentFutures); CEvent targetPlacesLoadedEvent(true); EntityPropertiesForCategory::ref targetPlaces = EntityPropertiesForCategory::loadFromWeb( kWebSettings[ENTITY_CATEGORY_Places], *targetUniverseId, boost::bind(&CEvent::Set, &targetPlacesLoadedEvent)); CEvent devProductsLoadedEvent(true); EntityPropertiesForCategory::ref targetDevProducts = EntityPropertiesForCategory::loadFromWeb( kWebSettings[ENTITY_CATEGORY_DeveloperProducts], *targetUniverseId, boost::bind(&CEvent::Set, &devProductsLoadedEvent)); CEvent namedAssetsLoadedEvent(true); EntityPropertiesForCategory::ref targetNamedAssets; if (!FFlag::GameExplorerUseV2AliasEndpoint) { targetNamedAssets = EntityPropertiesForCategory::loadFromWeb( kWebSettings[ENTITY_CATEGORY_NamedAssets], *targetUniverseId, boost::bind(&CEvent::Set, &namedAssetsLoadedEvent)); } devProductsLoadedEvent.Wait(); boost::unordered_map > devProductIdRemap; std::vector ignoreFields; ignoreFields.push_back("PriceInRobux"); ignoreFields.push_back("PriceInTickets"); entitySettings[ENTITY_CATEGORY_DeveloperProducts]->publishTo(targetDevProducts, ignoreFields, &devProductIdRemap); if (FFlag::GameExplorerUseV2AliasEndpoint) { // load aliases for the target universe after publishing dev products because // dev products modifications can change aliases targetNamedAssets = EntityPropertiesForCategory::loadFromWeb( kWebSettings[ENTITY_CATEGORY_NamedAssets], *targetUniverseId, boost::bind(&CEvent::Set, &namedAssetsLoadedEvent)); } namedAssetsLoadedEvent.Wait(); entitySettings[ENTITY_CATEGORY_NamedAssets]->publishTo( targetNamedAssets, std::vector(), NULL); targetPlacesLoadedEvent.Wait(); boost::unordered_map > placeIdRemap; entitySettings[ENTITY_CATEGORY_Places]->publishTo(targetPlaces, std::vector(), &placeIdRemap); std::vector danglingFutures; // publish place contents for (size_t i = 0; i < placeContentFutures.size(); ++i) { HttpPostData postData(placeContentFutures[i].second(), Http::kContentTypeDefaultUnspecified, true); danglingFutures.push_back(HttpAsync::post( formatUrl(kWebSettings[ENTITY_CATEGORY_Places].writeContentFormatString, *targetUniverseId, placeIdRemap[placeContentFutures[i].first]()), postData)); } std::vector namedAssetCopyInfos; boost::optional targetGroupId = getGroupIdFromPlaceEntityProperties(targetPlaces); entitySettings[ENTITY_CATEGORY_NamedAssets]->transform( boost::bind(&RobloxGameExplorer::buildNamedContentInfo, this, targetNamedAssets, currentGameId, targetGroupId, &devProductIdRemap, _1), &namedAssetCopyInfos); for (size_t i = 0; i < namedAssetCopyInfos.size(); ++i) { NamedAssetCopyInfo& info = namedAssetCopyInfos[i]; if (info.needsTargetIdUpdate) { if (!FFlag::GameExplorerUseV2AliasEndpoint) { info.newAssetIdProperties.waitUntilLoaded(); if (!info.newAssetIdProperties.isLoadedAndParsed()) { throw std::runtime_error(format("Unable to update asset \"%s\", aborting publish", info.assetName.c_str())); } } EntityProperties propertiesForUpdate; propertiesForUpdate.set("Name", info.assetName); if (FFlag::GameExplorerUseV2AliasEndpoint) { propertiesForUpdate.set("Type", info.aliasType); propertiesForUpdate.set("TargetId", info.newTargetIdGetter()); } else { propertiesForUpdate.set("AssetId", info.newAssetIdProperties.get("AssetId").get()); } HttpPostData postData(propertiesForUpdate.asJson(), Http::kContentTypeApplicationJson, false); danglingFutures.push_back(HttpAsync::post( formatUrl(kWebSettings[ENTITY_CATEGORY_NamedAssets].updateFormatString, *targetUniverseId, info.assetName), postData)); } } // Apply game level setting changes, (e.g. root place) if (targetUniverseSettings.hasRootPlace()) { bool rootSuccess; blockingUnMakeRoot(*targetUniverseId, targetUniverseSettings.rootPlaceId(), &rootSuccess); } if (gameSettings.hasRootPlace()) { bool rootSuccess; blockingMakeRoot(*targetUniverseId, placeIdRemap[gameSettings.rootPlaceId()](), &rootSuccess); } for (size_t i = 0; i < danglingFutures.size(); ++i) { danglingFutures[i].get(); } // cleanup assets in target folder QString unpublishedChangesDir = getUnpublishedChangesDirectory(*targetUniverseId); deleteFilesAndFoldersRecursive(QDir(unpublishedChangesDir)); *succeeded = true; } catch (const RBX::base_exception& e) { static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", format("PublishTo exception: %s", e.what()).c_str(), 0, false), flag); StandardOut::singleton()->printf(MESSAGE_ERROR, "Error while publishing game: %s", e.what()); *succeeded = false; } } void RobloxGameExplorer::publishGameToNewSlot() { RobloxGoogleAnalytics::trackEvent(GA_CATEGORY_ACTION, "Game Explorer", "Publish Game To New Slot"); EntityProperties newUniverseProperties; newUniverseProperties.set("Name", gameSettings.getName() + " (Copy)"); HttpFuture future = HttpAsync::post(formatUrl("%API_PROXY%/universes/create-universe", -1), HttpPostData(newUniverseProperties.asJson(), Http::kContentTypeApplicationJson, false)); publishInternal(extractProperty(future, "UniverseId")); } void RobloxGameExplorer::publishGameToNewGroupSlot(int groupId) { RobloxGoogleAnalytics::trackEvent(GA_CATEGORY_ACTION, "Game Explorer", "Publish Game To New Group Slot"); EntityProperties newUniverseProperties; newUniverseProperties.set("Name", gameSettings.getName() + " (Copy)"); HttpFuture future = HttpAsync::post(formatUrl("%API_PROXY%/universes/create-universe?groupId=%ITEM_ID%", -1, groupId), HttpPostData(newUniverseProperties.asJson(), Http::kContentTypeApplicationJson, false)); publishInternal(extractProperty(future, "UniverseId")); } void RobloxGameExplorer::publishGameToExistingSlot(int gameId) { RobloxGoogleAnalytics::trackEvent(GA_CATEGORY_ACTION, "Game Explorer", gameId == currentGameId ? "Publish Game To Current Slot" : "Publish Game To Other Existing Slot"); publishInternal(boost::bind(&identity, gameId)); } void RobloxGameExplorer::handleUniverseForPlaceResponse(QPointer explorer, int originatingSessionId, int placeId, std::string* result, std::exception* exception) { if (explorer.isNull()) return; if (explorer->currentSessionId != originatingSessionId) return; Variant var; if (result && WebParser::parseJSONObject(*result, var) && var.isType >()) { shared_ptr table = var.cast >(); ValueTable::const_iterator itr = table->find("UniverseId"); if (itr != table->end() && !itr->second.isVoid() && itr->second.isNumber()) { int gameId = itr->second.cast(); QMetaObject::invokeMethod(explorer, "doOpenGameFromGameId", Qt::QueuedConnection, Q_ARG(int, gameId)); } } } void RobloxGameExplorer::doneLoadingDataForCategory(QPointer explorer, int originatingSessionId, EntityCategory category) { if (explorer.isNull()) return; if (explorer->currentSessionId != originatingSessionId) return; // wait for settings to load in this thread (the http thread), not the main thread! explorer->gameSettings.waitUntilLoaded(); if (!explorer->entitySettings[category]->isLoadedAndParsed() || !explorer->gameSettings.isLoadedAndParsed()) { QMetaObject::invokeMethod(explorer, "failedToLoadSettings", Qt::QueuedConnection, Q_ARG(int, originatingSessionId)); return; } QMetaObject::invokeMethod(explorer, "populateWithLoadedData", Qt::QueuedConnection, Q_ARG(int, originatingSessionId), Q_ARG(int, category)); if (category == ENTITY_CATEGORY_Places) { QMetaObject::invokeMethod(explorer, "afterPlacesLoadedFinished", Qt::QueuedConnection, Q_ARG(int, originatingSessionId)); } if (category == ENTITY_CATEGORY_NamedAssets) { QMetaObject::invokeMethod(explorer, "afterNamedAssetsFinished", Qt::QueuedConnection, Q_ARG(int, originatingSessionId)); } if (category == ENTITY_CATEGORY_Badges) { QMetaObject::invokeMethod(explorer, "afterBadgesFinished", Qt::QueuedConnection, Q_ARG(int, originatingSessionId)); } } QStandardItemModel* RobloxGameExplorer::getModel() { return dynamic_cast(model()); } QStandardItem* RobloxGameExplorer::findGroup(EntityCategory category) { QModelIndexList resultList = getModel()->match(getModel()->index(0,0), ROLE_CategoryIfGroupRoot, category, 1, Qt::MatchExactly); RBXASSERT(resultList.size() <= 1); return resultList.empty() ? NULL : getModel()->itemFromIndex(*resultList.begin()); } QStandardItem* RobloxGameExplorer::makeGroup(EntityCategory category) { CategoryUiSettings& settings = uiSettings[category]; QStandardItem* group = new QStandardItem(settings.groupDisplayName); group->setEditable(false); group->setData(settings.icon, Qt::DecorationRole); group->setData(category, ROLE_CategoryIfGroupRoot); getModel()->appendRow(group); return group; } void RobloxGameExplorer::openAndFocusConfigureDoc(const std::string& url) { RobloxWebDoc* webDoc = UpdateUIManager::Instance().getMainWindow().getConfigureWebDoc(); webDoc->open(&UpdateUIManager::Instance().getMainWindow(), QString::fromStdString(url)); } void RobloxGameExplorer::setGroupLoadingStatus(EntityCategory category) { QStandardItem* group = findGroup(category); group->setRowCount(0); group->setText(uiSettings[category].groupDisplayName + " (Loading...)"); } void RobloxGameExplorer::placeDoubleClickCallback(EntityProperties* placeInfo) { static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", "Open Place", 0, false), flag); openPlace(getEntityId(placeInfo, ENTITY_CATEGORY_Places).toInt()); } void RobloxGameExplorer::namedAssetsDoubleClickCallback(EntityProperties* properties) { boost::optional assetTypeId = properties->get("AssetTypeId"); if (!assetTypeId) { return; } if (assetTypeId == (int)ASSET_TYPE_ID_Image) { insertNamedImage(properties); } else if (assetTypeId == (int)ASSET_TYPE_ID_Script) { RobloxDocManager::Instance().openDoc(LuaSourceBuffer::fromContentId( ContentId::fromGameAssetName(properties->get("Name").get()))); } } void RobloxGameExplorer::openPlace(int placeId) { if (placeId != currentPlaceId) { UpdateUIManager::Instance().getMainWindow().handleFileOpen("", IRobloxDoc::IDE, QString::fromStdString( formatUrl( "%WEB_BASE%/Game/edit.ashx?PlaceID=%ITEM_ID%&upload=%ITEM_ID%&testmode=false&universeId=%UNIVERSE_ID%", currentGameId, placeId))); } else { // double-clicking currently opened place; bring ide doc to focus if (RobloxIDEDoc* ideDoc = RobloxDocManager::Instance().getPlayDoc()) { RobloxDocManager::Instance().setCurrentDoc(ideDoc); } } } void RobloxGameExplorer::refreshOpenedPlaceIndicator() { if (QStandardItem* placeGroup = findGroup(ENTITY_CATEGORY_Places)) { for (int i = 0; i < placeGroup->rowCount(); ++i) { updateItemForOpenedPlaceState(placeGroup->child(i)); } } } void RobloxGameExplorer::refreshNamedScriptIcons(int originatingSessionId) { if (originatingSessionId != currentSessionId) { return; } QPixmap scriptPixmap(":/RibbonBar/images/RibbonIcons/Insert/Script.png"); QIcon scriptIcon(scriptPixmap.scaled(QSize(16, 16))); QIcon modifiedScriptIcon(":/images/script_source_setting.png"); std::vector itemsToExplore; itemsToExplore.push_back(findGroup(ENTITY_CATEGORY_NamedAssets)); while (!itemsToExplore.empty()) { QStandardItem* item = itemsToExplore.back(); itemsToExplore.pop_back(); if (!item) continue; if (EntityProperties* properties = item->data(ROLE_EntityProperties).value()) { if (boost::optional assetTypeId = properties->get("AssetTypeId")) { if (assetTypeId == (int)ASSET_TYPE_ID_Script) { item->setIcon(scriptIcon); if (namedAssetHasUnpublishedChanges(properties->get("Name").get())) { item->setIcon(modifiedScriptIcon); } } } } for (int i = 0; i < item->rowCount(); ++i) { itemsToExplore.push_back(item->child(i)); } } } void RobloxGameExplorer::openStartPlaceIfRequestedAndNoPlaceOpenedAndDataReady() { if (startPlaceOpenRequested && enoughDataLoadedToBeAbleToOpenStartPlace) { // mark request fulfilled if we met the criteria startPlaceOpenRequested = false; // but only open an ide doc with start place if no ide doc currently open if (currentPlaceId == kNoGameLoaded && gameSettings.hasRootPlace()) { openPlace(gameSettings.rootPlaceId()); } } } void RobloxGameExplorer::updateItemForOpenedPlaceState(QStandardItem* placeItem) { if (getEntityId(placeItem, ENTITY_CATEGORY_Places) == currentPlaceId) { QFont f = placeItem->font(); f.setFamily("Helvetica"); f.setBold(true); placeItem->setFont(f); } else { QFont f = placeItem->font(); f.setFamily("Source Sans Pro"); f.setBold(false); placeItem->setFont(f); } } QStandardItem* RobloxGameExplorer::buildItem(const CategoryUiSettings& uiSettings, const CategoryWebSettings& webSettings, QStandardItem* root, EntityProperties* settings) { if (uiSettings.shouldBuildExplorerItemCallback && !uiSettings.shouldBuildExplorerItemCallback(settings)) { return NULL; } QString name = uiSettings.displayNameGetter(settings); QStandardItem* itemParent = root; if (uiSettings.nameMode == CategoryUiSettings::NameAsFolderPath) { // break on path separators QStringList path = name.split(QChar('/')); for (int pathPieceIndex = 0; pathPieceIndex < path.size() - 1; ++pathPieceIndex) { QString& pathPiece = path[pathPieceIndex]; // try to find path item as an item under current parent bool found = false; for (int parentRow = 0; parentRow < itemParent->rowCount() && !found; ++parentRow) { QStandardItem* potentialParent = itemParent->child(parentRow); if (potentialParent->text() == pathPiece) { itemParent = potentialParent; found = true; } } if (!found) { QStandardItem* newParent = new QStandardItem(pathPiece); newParent->setEditable(false); newParent->setData(QtUtilities::getPixmap(":/images/ClassImages.PNG", 77), Qt::DecorationRole); itemParent->appendRow(newParent); itemParent = newParent; } } name = path.back(); } QStandardItem* item = new QStandardItem(name); if (uiSettings.doubleClickCallback) { item->setData(qvar(boost::bind(uiSettings.doubleClickCallback, settings)), ROLE_DoubleClickCallback); } item->setData(uiSettings.icon, Qt::DecorationRole); item->setData(qvar2(boost::bind(uiSettings.contextMenuCallback, _1, settings)), ROLE_ContextMenuCallback); item->setData(qvar(settings), ROLE_EntityProperties); if (uiSettings.nameChangedCallback) { item->setData(qvar(boost::bind(uiSettings.nameChangedCallback, item)), ROLE_NameChangedCallback); } item->setEditable(true); itemParent->appendRow(item); return item; } void RobloxGameExplorer::publishInternal(boost::function newUniverseFuture) { UpdateUIManager::Instance().getMainWindow().closePublishGameWindow(); RBXASSERT(currentGameId != kNoGameLoaded); if (currentGameId == kNoGameLoaded) return; reloadDataFromWeb(); bool publishSucceeded; int targetGameId; UpdateUIManager::Instance().waitForLongProcess("Publishing Game...", boost::bind(&RobloxGameExplorer::publishGameThread, this, newUniverseFuture, &publishSucceeded, &targetGameId)); if (publishSucceeded) { refreshNamedScriptIcons(currentSessionId); if (targetGameId != currentGameId) { QMessageBox dialog(this); dialog.setText("Successfully published game. Do you want to open the published game now?"); dialog.setStandardButtons(QMessageBox::Yes | QMessageBox::No); dialog.setDefaultButton(QMessageBox::No); int state = dialog.exec(); if (state == QMessageBox::Yes) { openGameFromGameId(targetGameId); } } else { QMessageBox messageBox(this); messageBox.setText("Successfully published game."); messageBox.setStandardButtons(QMessageBox::Ok); messageBox.exec(); } } else { QMessageBox messageBox(this); messageBox.setText("Error while publishing. See Output window for details."); messageBox.setStandardButtons(QMessageBox::Ok); messageBox.exec(); } } void RobloxGameExplorer::failedToLoadSettings(int originatingSessionId) { if (originatingSessionId != currentSessionId) return; nonGameLoaded(); QMessageBox warning(this); warning.setText("Failed to load game settings, some studio features have been disabled."); warning.setStandardButtons(QMessageBox::Ok); warning.exec(); } void RobloxGameExplorer::populateWithLoadedData(int originatingSessionId, int cat) { EntityCategory category = (EntityCategory)cat; QStandardItem* group = findGroup(category); if (currentSessionId != originatingSessionId || group == NULL) return; const CategoryUiSettings& settings = uiSettings[category]; std::vector items; entitySettings[category]->transform(boost::function( boost::bind(&RobloxGameExplorer::buildItem, boost::ref(settings), boost::ref(kWebSettings[cat]), group, _1)), &items); // get rid of "Loading..." group->setText(settings.groupDisplayName); } void RobloxGameExplorer::afterPlacesLoadedFinished(int originatingSessionId) { if (originatingSessionId != currentSessionId) return; refreshOpenedPlaceIndicator(); enoughDataLoadedToBeAbleToOpenStartPlace = true; if (gameSettings.hasRootPlace()) { if (QStandardItem* placeGroup = findGroup(ENTITY_CATEGORY_Places)) { for (int i = 0; i < placeGroup->rowCount(); ++i) { QStandardItem* placeItem = placeGroup->child(i); if (getEntityId(placeItem, ENTITY_CATEGORY_Places) == gameSettings.rootPlaceId()) { placeItem->setData(QtUtilities::getPixmap(":/images/ClassImages.PNG", 25), Qt::DecorationRole); } } } } QString baseName = QString::fromStdString(gameSettings.getName()); gameNameEdit->setText(baseName); gameNameEdit->setEnabled(true); currentGameGroupId = getGroupIdFromPlaceEntityProperties(entitySettings[ENTITY_CATEGORY_Places]); openStartPlaceIfRequestedAndNoPlaceOpenedAndDataReady(); } static void invokeThumbnailLoadedForImage(RobloxGameExplorer* explorer, int originatingSessionId, QModelIndex item, HttpFuture future) { QVariant var; var.setValue(future); QMetaObject::invokeMethod(explorer, "thumbnailLoadedForImage", Qt::QueuedConnection, Q_ARG(int, originatingSessionId), Q_ARG(QModelIndex, item), Q_ARG(QVariant, var)); } void RobloxGameExplorer::afterBadgesFinished(int originatingSessionId) { if (originatingSessionId != currentSessionId) return; if (QStandardItem* badgesGroup = findGroup(ENTITY_CATEGORY_Badges)) { for (int i = 0; i < badgesGroup->rowCount(); ++i) { const CategoryUiSettings& badgesUiSettings = uiSettings[ENTITY_CATEGORY_Badges]; QStandardItem* placesItem = badgesGroup->child(i); placesItem->setEditable(false); if (EntityProperties* properties = placesItem->data(ROLE_EntityProperties).value()) { // update icon and context menu handler for placeItem under Badge placesItem->setData(uiSettings[ENTITY_CATEGORY_Places].icon, Qt::DecorationRole); placesItem->setData(qvar2(boost::bind(&RobloxGameExplorer::badgesPlaceContextMenuHandler, this, _1, properties)), ROLE_ContextMenuCallback); // create badge items for the place EntityProperties::EntityCollection& children = properties->getChildren(); for (size_t i = 0; i < children.size(); ++i) { EntityProperties& childProp = children[i]; QStandardItem* newItem = buildItem(badgesUiSettings, badgesWebSettings, placesItem, &childProp); if (!newItem) continue; newItem->setEditable(false); // update badge icon with the image url for the badge boost::optional > thumbnailDetails = childProp.get >("Thumbnail"); if (thumbnailDetails.is_initialized() && thumbnailDetails.get()) { Reflection::ValueTable::const_iterator iter = thumbnailDetails.get()->find("Url"); if (iter != thumbnailDetails.get()->end()) { std::string thumbnailUrl = iter->second.get(); if (!thumbnailUrl.empty()) { HttpOptions options; options.setExternal(true); HttpAsync::get(thumbnailUrl, options).then(boost::bind(&invokeThumbnailLoadedForImage, this, currentSessionId, newItem->index(), _1)); } } } } } } } } void RobloxGameExplorer::afterNamedAssetsFinished(int originatingSessionId) { if (originatingSessionId != currentSessionId) return; if (QStandardItem* namedAssetsGroup = findGroup(ENTITY_CATEGORY_NamedAssets)) { QStringList imagesToReload; afterNamedAssetsFinishedRecursive(namedAssetsGroup, imagesToReload); if (RobloxIDEDoc* ideDoc = RobloxDocManager::Instance().getPlayDoc()) { ideDoc->forceReloadImages(imagesToReload); } refreshNamedScriptIcons(currentSessionId); Q_EMIT namedAssetsLoaded(currentGameId); } } void RobloxGameExplorer::afterNamedAssetsFinishedRecursive(QStandardItem* root, QStringList& imagesToReload) { for (int i = 0; i < root->rowCount(); ++i) { QStandardItem* namedImageItem = root->child(i); if (EntityProperties* properties = namedImageItem->data(ROLE_EntityProperties).value()) { boost::optional assetTypeId = properties->get("AssetTypeId"); if (!assetTypeId.is_initialized() || assetTypeId.get() != (int)ASSET_TYPE_ID_Image) { continue; } boost::optional assetId = properties->get("AssetId"); if (assetId.is_initialized()) { HttpAsync::get(formatUrl("%WEB_BASE%/asset/?id=%ITEM_ID%", -1, assetId.get())) .then(boost::bind(&invokeThumbnailLoadedForImage, this, currentSessionId, namedImageItem->index(), _1)); } std::string name = properties->get("Name").get(); imagesToReload.push_back(QString::fromStdString(ContentId::fromGameAssetName(name).toString())); } afterNamedAssetsFinishedRecursive(namedImageItem, imagesToReload); } } void RobloxGameExplorer::thumbnailLoadedForImage(int originatingSessionId, QModelIndex index, QVariant future) { if (originatingSessionId != currentSessionId) return; QStandardItem* item = getModel()->itemFromIndex(index); RBXASSERT(item); if (!item) return; std::string imageContent; try { imageContent = future.value().get(); } catch (const RBX::base_exception&) { // This should indicate to the user that there was an issue with loading the // thumbnail instead of silently returning. return; } QImage image; if (image.loadFromData((uchar*)imageContent.c_str(), imageContent.size())) { image = image.scaled(QSize(16,16)); item->setData(image, Qt::DecorationRole); } } static void addNewPlaceHelperThread(int universeId, bool* succeeded) { // get default settings std::string defaultSettingsUrl = formatUrl( "%WEB_BASE%/ide/places/defaultsettings?universeId=%UNIVERSE_ID%&templateId=%ITEM_ID%", universeId, FInt::NewGameTemplateId); Http getDefaults(defaultSettingsUrl); getDefaults.doNotUseCachedResponse = true; std::string defaultSettings; try { getDefaults.get(defaultSettings); } catch (const RBX::base_exception& e) { *succeeded = false; StandardOut::singleton()->printf(MESSAGE_ERROR, "Error while adding: %s", e.what()); std::string gaMessage = format("Error while getting default place value for add: %s", e.what()); static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", gaMessage.c_str(), 0, false), flag); return; } // create from default settings std::string createUrl = formatUrl(kWebSettings[ENTITY_CATEGORY_Places].createFormatString, universeId); Http create(createUrl); std::istringstream defaultSettingsStream(defaultSettings); std::string postResponse; try { create.post(defaultSettingsStream, Http::kContentTypeApplicationJson, false, postResponse); } catch (const RBX::base_exception& e) { *succeeded = false; StandardOut::singleton()->printf(MESSAGE_ERROR, "Error while adding: %s", e.what()); std::string gaMessage = format("Error while creating place from getting default value: %s", e.what()); static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", gaMessage.c_str(), 0, false), flag); return; } *succeeded = true; } void RobloxGameExplorer::addNewPlace() { bool succeeded = false; UpdateUIManager::Instance().waitForLongProcess(QString("Adding Place..."), boost::bind(&addNewPlaceHelperThread, currentGameId, &succeeded)); reloadDataFromWeb(); if (!succeeded) { QMessageBox messageBox(this); messageBox.setText("Error while adding place. See Output window for details."); messageBox.setStandardButtons(QMessageBox::Ok); messageBox.exec(); } } static void blockingRemovePlace(int gameId, int placeId, bool isRoot, bool* reload) { if (isRoot) { blockingUnMakeRoot(gameId, placeId, reload); if (!*reload) { return; } } try { HttpPostData d("", Http::kContentTypeDefaultUnspecified, false); HttpAsync::post(formatUrl(kWebSettings[ENTITY_CATEGORY_Places].removeFormatString, gameId, placeId), d).get(); } catch (const RBX::base_exception& e) { StandardOut::singleton()->printf(MESSAGE_ERROR, "Error while removing place: %s", e.what()); } *reload = true; } void RobloxGameExplorer::placeContextMenuHandler(const QPoint& point, EntityProperties* properties) { QMenu menu(this); int placeId = getEntityId(properties, ENTITY_CATEGORY_Places).toInt(); QAction* remove = menu.addAction("Remove From Game"); remove->setIcon(QIcon(":/16x16/images/Studio 2.0 icons/16x16/delete_x_16_h.png")); QAction* rename = menu.addAction("Rename"); bool isCurrentRoot = gameSettings.hasRootPlace() && gameSettings.rootPlaceId() == placeId; QAction* makeRoot = menu.addAction("Mark as Start Place"); makeRoot->setIcon(QtUtilities::getPixmap(":/images/ClassImages.PNG", 25)); makeRoot->setEnabled(!isCurrentRoot); QAction* copyId = NULL; if (FFlag::GameExplorerCopyId) { copyId = menu.addAction("Copy ID to Clipboard"); } QAction* result = menu.exec(point); bool reload = false; if (result == remove) { static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", "Removing place from universe", 0, false), flag); bool placeNotCurrentlyOpen = true; if (currentPlaceId == placeId) { // try to close the current play doc if (RobloxIDEDoc* ideDoc = RobloxDocManager::Instance().getPlayDoc()) { placeNotCurrentlyOpen = UpdateUIManager::Instance().getMainWindow().requestDocClose(ideDoc); } } if (placeNotCurrentlyOpen) { UpdateUIManager::Instance().waitForLongProcess( QString("Removing place \"%1\" from game").arg(QString::fromStdString(properties->get("Name").get())), boost::bind(&blockingRemovePlace, currentGameId, placeId, isCurrentRoot, &reload)); } } else if (result == rename) { reload = false; handleRename(point); } else if (result == makeRoot) { static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", "Setting root place", 0, false), flag); UpdateUIManager::Instance().waitForLongProcess( QString("Changing start place for game"), boost::bind(&blockingMakeRoot, currentGameId, placeId, &reload)); } else if (FFlag::GameExplorerCopyId && copyId && result == copyId) { QClipboard *clipboard = QApplication::clipboard(); clipboard->setText(QString::number(placeId)); } if (reload) { reloadDataFromWeb(); } } void RobloxGameExplorer::developerProductContextMenuHandler(const QPoint& point, EntityProperties* properties) { QMenu menu(this); QAction* rename = menu.addAction("Rename"); QAction* copyId = NULL; if (FFlag::GameExplorerCopyId) { copyId = menu.addAction("Copy ID to Clipboard"); } QAction* result = menu.exec(point); if (result == rename) { handleRename(point); } else if (FFlag::GameExplorerCopyId && copyId && result == copyId) { QString productId = getEntityId(properties, ENTITY_CATEGORY_DeveloperProducts).toString(); QClipboard *clipboard = QApplication::clipboard(); clipboard->setText(productId); } } void RobloxGameExplorer::badgesContextMenuHandler(const QPoint& point, EntityProperties* properties) { boost::optional var = properties->get("BadgeAssetId").get(); if (!var.is_initialized()) return; int badgeId = var.get(); RBXASSERT(badgeId > 0); QMenu menu(this); QAction* configureAction = menu.addAction(tr("Configure")); QAction* copyIdAction = NULL; if (FFlag::GameExplorerCopyId) copyIdAction = menu.addAction("Copy ID to Clipboard"); QAction* result = menu.exec(point); if (result == configureAction) { openAndFocusConfigureDoc( formatUrl("%WEB_BASE%/My/Item.aspx?ID=%ITEM_ID%", currentGameId, badgeId)); } else if (FFlag::GameExplorerCopyId && copyIdAction && result == copyIdAction) { QClipboard *clipboard = QApplication::clipboard(); clipboard->setText(QString::number(badgeId)); } } void RobloxGameExplorer::badgesPlaceContextMenuHandler(const QPoint& point, EntityProperties* properties) { boost::optional var = properties->get("PlaceId"); if (!var.is_initialized()) return; int placeId = var.get(); RBXASSERT(placeId > 0); QMenu menu(this); QAction* createBadgeAction = menu.addAction(tr("Create Badge")); QAction* result = menu.exec(point); if (result == createBadgeAction) { if (FFlag::UseNewBadgesCreatePage) { std::string url = formatUrl("%WEB_BASE%/build/upload?AssetTypeId=21&TargetPlaceId=%ITEM_ID%", currentGameId, placeId); if (currentGameGroupId) { url += format("&groupId=%d", currentGameGroupId.get()); } openAndFocusConfigureDoc(url); } else { openAndFocusConfigureDoc( formatUrl("%WEB_BASE%/My/NewBadge.aspx?targetID=%ITEM_ID%", currentGameId, placeId)); } } } static void blockingRemoveNamedAsset(int universeId, const std::string& name, bool* success) { try { HttpPostData d("", Http::kContentTypeDefaultUnspecified, false); HttpAsync::post(formatUrl(kWebSettings[ENTITY_CATEGORY_NamedAssets].removeFormatString, universeId, name), d).get(); } catch (const RBX::base_exception& e) { StandardOut::singleton()->printf(MESSAGE_ERROR, "Error while removing name: %s", e.what()); *success = false; return; } QFile(getUnpublishedAssetFilePath(name, universeId)).remove(); *success = true; } void RobloxGameExplorer::namedAssetsContextMenuHandler(const QPoint& point, EntityProperties* properties) { QMenu menu(this); QAction* remove = menu.addAction("Remove From Game"); remove->setIcon(QIcon(":/16x16/images/Studio 2.0 icons/16x16/delete_x_16_h.png")); QAction* rename = menu.addAction("Rename"); QAction* copyPath = NULL; QAction* insert = NULL; QAction* insertAsScript = NULL; QAction* insertAsLocalScript = NULL; QAction* insertAsModuleScript = NULL; QAction* publishScript = NULL; QAction* revertToLastPublished = NULL; bool isImage = false; bool isScript = false; bool hasOpenDataModel = RobloxDocManager::Instance().getPlayDoc() != NULL; isImage = properties->get("AssetTypeId") == (int)ASSET_TYPE_ID_Image; isScript = properties->get("AssetTypeId") == (int)ASSET_TYPE_ID_Script; if (isImage) { insert = menu.addAction("Insert"); insert->setEnabled(hasOpenDataModel); if (FFlag::GameExplorerCopyPath) { copyPath = menu.addAction("Copy Path to Clipboard"); } } else if (isScript) { insertAsScript = menu.addAction("Insert as Script"); insertAsScript->setEnabled(hasOpenDataModel); insertAsLocalScript = menu.addAction("Insert as LocalScript"); insertAsLocalScript->setEnabled(hasOpenDataModel); insertAsModuleScript = menu.addAction("Insert as ModuleScript"); insertAsModuleScript->setEnabled(hasOpenDataModel); menu.addSeparator(); std::string name = properties->get("Name").get(); bool hasUnpublishedChange = namedAssetHasUnpublishedChanges(name); revertToLastPublished = menu.addAction("Revert to Last Published Version"); revertToLastPublished->setEnabled(hasUnpublishedChange); publishScript = menu.addAction("Publish This LinkedSource"); publishScript->setEnabled(hasUnpublishedChange); } bool needReload = false; QAction* result = menu.exec(point); if (result == remove) { QMessageBox mb; mb.setText("Assets are referenced by name, so any references to this name will fail to " "load. Are you sure you want to remove this asset?"); mb.setStandardButtons(QMessageBox::Yes | QMessageBox::No); if (mb.exec() != QMessageBox::Yes) { return; } static boost::once_flag flag = BOOST_ONCE_INIT; boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent, GA_CATEGORY_ACTION, "Game Explorer", "Removing name from universe", 0, false), flag); std::string name = properties->get("Name").get(); UpdateUIManager::Instance().waitForLongProcess(QString("Removing name"), boost::bind(&blockingRemoveNamedAsset, currentGameId, name, &needReload)); if (properties->get("AssetTypeId") == (int)ASSET_TYPE_ID_Script) { RobloxDocManager::Instance().closeDoc( LuaSourceBuffer::fromContentId(ContentId::fromGameAssetName(name))); } } else if (result == rename) { handleRename(point); } else if (insert && result == insert) { insertNamedImage(properties); } else if (insertAsScript && result == insertAsScript) { insertNamedScript(properties, Creatable::create