mirror of
https://github.com/copyrighttxt/watrbx-game-engine.git
synced 2026-09-04 20:57:49 +00:00
3399 lines
103 KiB
C++
3399 lines
103 KiB
C++
#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 <QFileDialog>
|
|
#include <QFileInfo>
|
|
#include <QLabel>
|
|
#include <QLineEdit>
|
|
#include <QMenu>
|
|
#include <QPoint>
|
|
#include <QPushButton>
|
|
#include <QStandardItem>
|
|
#include <QStandardItemModel>
|
|
#include <QTextstream>
|
|
#include <QTreeView>
|
|
#include <QWidget>
|
|
#include <QClipboard>
|
|
|
|
#include <boost/algorithm/string.hpp>
|
|
#include <boost/thread.hpp>
|
|
#include <boost/thread/future.hpp>
|
|
|
|
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<void()>);
|
|
Q_DECLARE_METATYPE(boost::function<void(const QPoint&)>);
|
|
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<class IdType>
|
|
IdType extractPropertyHelper(boost::shared_future<std::string> future, const std::string fieldName)
|
|
{
|
|
Variant v;
|
|
WebParser::parseJSONObject(future.get(), v);
|
|
return v.cast<shared_ptr<const ValueTable> >()->at(fieldName).cast<IdType>();
|
|
}
|
|
|
|
template<class IdType>
|
|
static boost::function<IdType()> extractProperty(boost::shared_future<std::string> future, const std::string& fieldName)
|
|
{
|
|
return boost::bind(&extractPropertyHelper<IdType>, future, fieldName);
|
|
}
|
|
|
|
QVariant getEntityId(EntityProperties* settings, EntityCategory category) {
|
|
QVariant id;
|
|
if (settings)
|
|
{
|
|
const std::string& fieldName = kWebSettings[category].perItemIdFieldName;
|
|
|
|
boost::optional<int> intId = settings->get<int>(fieldName);
|
|
boost::optional<std::string> stringId = settings->get<std::string>(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<EntityProperties*>(), category);
|
|
}
|
|
|
|
QVariant qvar(boost::function<void()> val)
|
|
{
|
|
QVariant result;
|
|
result.setValue<boost::function<void()> >(val);
|
|
return result;
|
|
}
|
|
|
|
QVariant qvar(EntityProperties* val)
|
|
{
|
|
QVariant result;
|
|
result.setValue<EntityProperties*>(val);
|
|
return result;
|
|
}
|
|
|
|
QVariant qvar2(boost::function<void(const QPoint&)> val)
|
|
{
|
|
QVariant result;
|
|
result.setValue<boost::function<void(const QPoint&)> >(val);
|
|
return result;
|
|
}
|
|
|
|
template<class T>
|
|
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<double>());
|
|
}
|
|
else if (v.isType<int>())
|
|
{
|
|
return format("%d", v.cast<int>());
|
|
}
|
|
else if (v.isType<shared_ptr<const ValueTable> >())
|
|
{
|
|
shared_ptr<ValueTable> newTable(new ValueTable);
|
|
shared_ptr<const ValueTable> ctbl = v.cast<shared_ptr<const ValueTable> >();
|
|
for (ValueTable::const_iterator itr = ctbl->begin(); itr != ctbl->end(); ++itr)
|
|
{
|
|
(*newTable)[itr->first] = fixNumbersForWeb(itr->second);
|
|
}
|
|
return shared_ptr<const ValueTable>(newTable);
|
|
}
|
|
else if (v.isType<shared_ptr<const ValueArray> >())
|
|
{
|
|
shared_ptr<const ValueArray> carr = v.cast<shared_ptr<const ValueArray> >();
|
|
shared_ptr<ValueArray> newArray(new ValueArray(carr->size()));
|
|
for (size_t i = 0; i < carr->size(); ++i)
|
|
{
|
|
(*newArray)[i] = fixNumbersForWeb((*carr)[i]);
|
|
}
|
|
return shared_ptr<const ValueArray>(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<int>(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<shared_ptr<const ValueTable>()> f = extractProperty<shared_ptr<const ValueTable> >(HttpAsync::get(
|
|
formatUrl("%WEB_BASE%/places/%ITEM_ID%/settings", universeId, id), options), "Creator");
|
|
|
|
shared_ptr<const ValueTable> 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<int>(kWebSettings[ENTITY_CATEGORY_Badges].jsonItemIdFieldName).get();
|
|
|
|
// load badges information for the place
|
|
HttpOptions options;
|
|
options.setDoNotUseCachedResponse();
|
|
boost::function<shared_ptr<const ValueArray>()> f = extractProperty<shared_ptr<const ValueArray> >(HttpAsync::get(
|
|
formatUrl(badgesWebSettings.fetchPageUrlFormatString, universeId, id), options), badgesWebSettings.jsonItemGroupFieldName);
|
|
|
|
// set badges list
|
|
shared_ptr<const ValueArray> jsonList = f();
|
|
for (size_t i = 0; i < jsonList->size(); ++i)
|
|
{
|
|
EntityProperties newProp;
|
|
newProp.setFromValueTable(jsonList->at(i).cast<shared_ptr<const ValueTable> >());
|
|
properties->addChild(newProp);
|
|
}
|
|
}
|
|
|
|
void namedAssetsPropertiesFixer(int universeId, EntityProperties* properties)
|
|
{
|
|
if (boost::optional<shared_ptr<const ValueTable> > optAssetInfo =
|
|
properties->get<shared_ptr<const ValueTable> >("Asset"))
|
|
{
|
|
shared_ptr<const ValueTable> 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<std::string>("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<int> nameType = properties->get<int>("Type"))
|
|
{
|
|
return nameType == (int)ALIAS_TYPE_Asset || nameType == (int)ALIAS_TYPE_AssetVersion;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool validateImageName(const QString& name, const std::vector<QString>* 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<QString>::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<QString>* 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<int> currentGameGroupId, bool* created, QString* errorMessage)
|
|
{
|
|
EntityProperties createImageAssetResponse;
|
|
HttpPostData postData(
|
|
shared_ptr<std::istream>(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<bool>("Success").get_value_or(false);
|
|
boost::optional<int> optionalAssetId = createImageAssetResponse.get<int>("BackingAssetId");
|
|
if (!succeededInCreatingAsset || !optionalAssetId.is_initialized())
|
|
{
|
|
*created = false;
|
|
std::string returnedMessage = createImageAssetResponse.get<std::string>("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<std::pair<QString, QString> >* fileAndImageNames, int currentGameId,
|
|
boost::optional<int> currentGameGroupId)
|
|
{
|
|
std::vector<char> results(fileAndImageNames->size(), 0);
|
|
std::vector<QString> 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<int> assetId = settings->get<int>("AssetTypeId"))
|
|
{
|
|
if (assetId.get() == assetType)
|
|
{
|
|
return generalDisplayNameGetter(settings);
|
|
}
|
|
}
|
|
return QString();
|
|
}
|
|
|
|
std::string getPropertiesAsJason(int assetType, EntityProperties* settings)
|
|
{
|
|
if (boost::optional<int> assetId = settings->get<int>("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<std::string>("Name").get() &&
|
|
assetType == properties->get<int>("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<int>* groupId, EntityProperties* placeProperties)
|
|
{
|
|
typedef Reflection::ValueTable JsonTable;
|
|
typedef shared_ptr<const JsonTable> JsonTableRef;
|
|
try
|
|
{
|
|
JsonTableRef r = placeProperties->get<JsonTableRef>("Creator").get();
|
|
// CreatorType 0 = User 1 = Group
|
|
JsonTable::const_iterator iter = r->find("CreatorType");
|
|
if (iter != r->end() && iter->second.isNumber() && iter->second.cast<int>() == 1)
|
|
{
|
|
*groupId = r->find("CreatorTargetId")->second.cast<int>();
|
|
}
|
|
}
|
|
catch (const RBX::base_exception&)
|
|
{
|
|
}
|
|
return placeProperties;
|
|
}
|
|
|
|
boost::optional<int> getGroupIdFromPlaceEntityProperties(EntityPropertiesForCategory::ref categoryProperties)
|
|
{
|
|
std::vector<EntityProperties*> unused;
|
|
boost::optional<int> groupId;
|
|
categoryProperties->transform<EntityProperties*>(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<std::string>& jsonFuture)
|
|
{
|
|
hasPendingFuture = true;
|
|
this->jsonFuture = jsonFuture;
|
|
}
|
|
|
|
void EntityProperties::setFromValueTable(const boost::shared_ptr<const ValueTable>& 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<ValueTable> val(new ValueTable);
|
|
for (ValueTable::iterator itr = data.begin(); itr != data.end(); ++itr)
|
|
{
|
|
(*val)[itr->first] = fixNumbersForWeb(itr->second);
|
|
}
|
|
WebParser::writeJSON(shared_ptr<const ValueTable>(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<const ValueTable> 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<Variant> EntityProperties::getVariant(const std::string& fieldName)
|
|
{
|
|
waitUntilLoaded();
|
|
boost::optional<Variant> 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<std::string>& jsonFuture)
|
|
{
|
|
properties.setFromJsonFuture(jsonFuture);
|
|
}
|
|
|
|
void UniverseSettings::waitUntilLoaded()
|
|
{
|
|
properties.waitUntilLoaded();
|
|
}
|
|
|
|
bool UniverseSettings::isLoadedAndParsed() const
|
|
{
|
|
return properties.isLoadedAndParsed();
|
|
}
|
|
|
|
bool UniverseSettings::hasRootPlace()
|
|
{
|
|
return properties.get<int>("RootPlace");
|
|
}
|
|
|
|
int UniverseSettings::rootPlaceId()
|
|
{
|
|
boost::optional<int> rootPlaceId = properties.get<int>("RootPlace");
|
|
RBXASSERT(rootPlaceId);
|
|
if (rootPlaceId)
|
|
{
|
|
return rootPlaceId.get();
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
std::string UniverseSettings::getName()
|
|
{
|
|
return properties.get<std::string>("Name").get_value_or("");
|
|
}
|
|
|
|
void UniverseSettings::setName(const std::string& newName)
|
|
{
|
|
properties.set("Name", newName);
|
|
}
|
|
|
|
bool UniverseSettings::currentUserHasAccess()
|
|
{
|
|
return properties.get<bool>("CurrentUserHasEditPermissions").get_value_or(false);
|
|
}
|
|
|
|
std::string UniverseSettings::asJson()
|
|
{
|
|
return properties.asJson();
|
|
}
|
|
|
|
EntityPropertiesForCategory::ref EntityPropertiesForCategory::loadFromWeb(
|
|
const CategoryWebSettings& webSettings, int universeId, boost::function<void()> 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<class IdType>
|
|
void EntityPropertiesForCategory::publishTo(ref target,
|
|
const std::vector<std::string>& ignoreFieldsWhenCopying,
|
|
boost::unordered_map<IdType, boost::function<IdType()> >* outputRemaps)
|
|
{
|
|
waitUntilLoaded();
|
|
target->waitUntilLoaded();
|
|
RBXASSERT(&webSettings == &target->webSettings);
|
|
|
|
boost::unordered_map<IdType, boost::function<IdType()> > idRemaps;
|
|
|
|
std::vector<HttpFuture> danglingFutures;
|
|
|
|
// create & update slots, marking used ids
|
|
boost::unordered_set<IdType> usedTargetIds;
|
|
for (EntityCollection::iterator itr = data.begin(); itr != data.end(); ++itr)
|
|
{
|
|
const boost::optional<std::string> name = itr->get<std::string>("Name");
|
|
const boost::optional<IdType> id = itr->get<IdType>(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<IdType> targetId = targetItr->get<IdType>(webSettings.perItemIdFieldName);
|
|
RBXASSERT(targetId);
|
|
if (usedTargetIds.count(targetId.get()) == 0 &&
|
|
targetItr->get<std::string>("Name").get() == name.get())
|
|
{
|
|
found = true;
|
|
usedTargetIds.insert(targetId.get());
|
|
idRemaps[id.get()] = boost::bind(&identity<IdType>, 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<Variant> 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<IdType>(
|
|
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<IdType> targetId = itr->get<IdType>(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<const ValueTable> 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<const ValueArray> jsonList = topLevel->at(webSettings.jsonItemGroupFieldName)
|
|
.cast<shared_ptr<const ValueArray> >();
|
|
|
|
finalPage = topLevel->at("FinalPage").cast<bool>();
|
|
exceededLimit = FInt::GameViewItemCap < (int)(data.size() + jsonList->size());
|
|
needMoreData = !(finalPage || exceededLimit);
|
|
|
|
for (size_t i = 0; i < jsonList->size(); ++i)
|
|
{
|
|
shared_ptr<const ValueTable> itemInfo = jsonList->at(i).cast<shared_ptr<const ValueTable> >();
|
|
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<RobloxGameExplorer>(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<RobloxGameExplorer>(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<CategoryWebSettings*>(&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<std::string> RobloxGameExplorer::findCachedAssetOrEmpty(const RBX::ContentId& contentId, int universeId)
|
|
{
|
|
boost::optional<std::string> 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<std::string>();
|
|
}
|
|
|
|
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<boost::shared_future<void> > publishFutures;
|
|
entitySettings[ENTITY_CATEGORY_NamedAssets]->transform<boost::shared_future<void> >(
|
|
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<int> optionalAssetId, boost::optional<int> 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<QString>* out)
|
|
{
|
|
if (currentGameId == kNoGameLoaded)
|
|
{
|
|
return;
|
|
}
|
|
|
|
entitySettings[ENTITY_CATEGORY_NamedAssets]->transform<QString>(
|
|
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<QString>* out)
|
|
{
|
|
if (currentGameId == kNoGameLoaded)
|
|
{
|
|
return;
|
|
}
|
|
entitySettings[ENTITY_CATEGORY_NamedAssets]->transform<QString>(
|
|
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<QString>* out)
|
|
{
|
|
if (currentGameId == kNoGameLoaded)
|
|
return;
|
|
|
|
entitySettings[ENTITY_CATEGORY_NamedAssets]->transform<QString>(
|
|
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<std::string> animationsData;
|
|
entitySettings[ENTITY_CATEGORY_NamedAssets]->transform<std::string>(
|
|
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<RobloxGameExplorer>(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<int, boost::function<std::string()> > RobloxGameExplorer::buildPlaceContentPair(EntityProperties* properties)
|
|
{
|
|
std::pair<int, boost::function<std::string()> > result;
|
|
const CategoryWebSettings& placeWebSettings = kWebSettings[ENTITY_CATEGORY_Places];
|
|
result.first = properties->get<int>(placeWebSettings.perItemIdFieldName).get();
|
|
|
|
bool useLocalContent = false;
|
|
if (result.first == currentPlaceId)
|
|
{
|
|
if (RobloxIDEDoc* ideDoc = RobloxDocManager::Instance().getPlayDoc())
|
|
{
|
|
if (shared_ptr<DataModel> 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<std::stringstream> 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<int> targetUniverseGroupId, boost::unordered_map<int, boost::function<int()> >* devProductIdRemap,
|
|
EntityProperties* properties)
|
|
{
|
|
NamedAssetCopyInfo result;
|
|
result.assetName = properties->get<std::string>("Name").get();
|
|
|
|
if (FFlag::GameExplorerUseV2AliasEndpoint)
|
|
{
|
|
result.aliasType = properties->get<int>("Type").get();
|
|
|
|
if ((AliasType)result.aliasType == ALIAS_TYPE_DeveloperProduct)
|
|
{
|
|
result.needsTargetIdUpdate = true;
|
|
result.newTargetIdGetter = (*devProductIdRemap)[properties->get<int>("TargetId").get()];
|
|
return result;
|
|
}
|
|
}
|
|
|
|
const int sourceAssetTypeId = properties->get<int>("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<EntityProperties*> foundProperty;
|
|
|
|
targetProperties->transform<EntityProperties*>(
|
|
boost::bind(&getPropertiesIfNameAndAssetTypeMatch, boost::ref(result.assetName), sourceAssetTypeId, _1),
|
|
&foundProperty);
|
|
|
|
boost::optional<int> foundAssetId;
|
|
for (size_t i = 0; i < foundProperty.size(); ++i)
|
|
{
|
|
if (foundProperty[i])
|
|
{
|
|
foundAssetId = foundProperty[i]->get<int>("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<int>(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<int()> 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<std::pair<int, boost::function<std::string()> > > placeContentFutures;
|
|
// start place content requests
|
|
entitySettings[ENTITY_CATEGORY_Places]->transform<std::pair<int, boost::function<std::string()> > >(
|
|
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<int, boost::function<int()> > devProductIdRemap;
|
|
std::vector<std::string> ignoreFields;
|
|
ignoreFields.push_back("PriceInRobux");
|
|
ignoreFields.push_back("PriceInTickets");
|
|
entitySettings[ENTITY_CATEGORY_DeveloperProducts]->publishTo<int>(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<std::string>(
|
|
targetNamedAssets, std::vector<std::string>(), NULL);
|
|
|
|
targetPlacesLoadedEvent.Wait();
|
|
boost::unordered_map<int, boost::function<int()> > placeIdRemap;
|
|
entitySettings[ENTITY_CATEGORY_Places]->publishTo<int>(targetPlaces, std::vector<std::string>(), &placeIdRemap);
|
|
|
|
std::vector<HttpFuture> 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<NamedAssetCopyInfo> namedAssetCopyInfos;
|
|
boost::optional<int> targetGroupId = getGroupIdFromPlaceEntityProperties(targetPlaces);
|
|
|
|
entitySettings[ENTITY_CATEGORY_NamedAssets]->transform<NamedAssetCopyInfo>(
|
|
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<int>("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<int>(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<int>(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<int>, gameId));
|
|
}
|
|
|
|
void RobloxGameExplorer::handleUniverseForPlaceResponse(QPointer<RobloxGameExplorer> 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<const ValueTable> >())
|
|
{
|
|
shared_ptr<const ValueTable> table = var.cast<shared_ptr<const ValueTable> >();
|
|
ValueTable::const_iterator itr = table->find("UniverseId");
|
|
if (itr != table->end() && !itr->second.isVoid() && itr->second.isNumber())
|
|
{
|
|
int gameId = itr->second.cast<int>();
|
|
QMetaObject::invokeMethod(explorer, "doOpenGameFromGameId", Qt::QueuedConnection,
|
|
Q_ARG(int, gameId));
|
|
}
|
|
}
|
|
}
|
|
|
|
void RobloxGameExplorer::doneLoadingDataForCategory(QPointer<RobloxGameExplorer> 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<QStandardItemModel*>(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<int> assetTypeId = properties->get<int>("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<std::string>("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<QStandardItem*> 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<EntityProperties*>())
|
|
{
|
|
if (boost::optional<int> assetTypeId = properties->get<int>("AssetTypeId"))
|
|
{
|
|
if (assetTypeId == (int)ASSET_TYPE_ID_Script)
|
|
{
|
|
item->setIcon(scriptIcon);
|
|
if (namedAssetHasUnpublishedChanges(properties->get<std::string>("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<int()> 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<QStandardItem*> items;
|
|
entitySettings[category]->transform(boost::function<QStandardItem*(EntityProperties*)>(
|
|
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<HttpFuture>(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<EntityProperties*>())
|
|
{
|
|
// 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<shared_ptr<const Reflection::ValueTable> > thumbnailDetails = childProp.get<shared_ptr<const Reflection::ValueTable> >("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<std::string>();
|
|
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<EntityProperties*>())
|
|
{
|
|
boost::optional<int> assetTypeId = properties->get<int>("AssetTypeId");
|
|
if (!assetTypeId.is_initialized() || assetTypeId.get() != (int)ASSET_TYPE_ID_Image)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
boost::optional<int> assetId = properties->get<int>("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<std::string>("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<HttpFuture>().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<std::string>("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<int> var = properties->get<int>("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<int> var = properties->get<int>("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<int>("AssetTypeId") == (int)ASSET_TYPE_ID_Image;
|
|
isScript = properties->get<int>("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<std::string>("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<std::string>("Name").get();
|
|
UpdateUIManager::Instance().waitForLongProcess(QString("Removing name"),
|
|
boost::bind(&blockingRemoveNamedAsset, currentGameId, name, &needReload));
|
|
|
|
if (properties->get<int>("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<Instance>::create<Script>());
|
|
}
|
|
else if (insertAsLocalScript && result == insertAsLocalScript)
|
|
{
|
|
insertNamedScript(properties, Creatable<Instance>::create<LocalScript>());
|
|
}
|
|
else if (insertAsModuleScript && result == insertAsModuleScript)
|
|
{
|
|
insertNamedScript(properties, Creatable<Instance>::create<ModuleScript>());
|
|
}
|
|
else if (revertToLastPublished && result == revertToLastPublished)
|
|
{
|
|
QMessageBox mb;
|
|
mb.setText("This will destroy all local changes you have made. Are you sure?");
|
|
mb.setStandardButtons(QMessageBox::Yes | QMessageBox::No);
|
|
if (mb.exec() == QMessageBox::Yes)
|
|
{
|
|
std::string name = properties->get<std::string>("Name").get();
|
|
QFile(getUnpublishedAssetFilePath(name, currentGameId)).remove();
|
|
refreshNamedScriptIcons(currentSessionId);
|
|
if (RobloxScriptDoc* scriptDoc = RobloxDocManager::Instance().findOpenScriptDoc(
|
|
LuaSourceBuffer::fromContentId(ContentId::fromGameAssetName(name))))
|
|
{
|
|
scriptDoc->refreshEditorFromSourceBuffer();
|
|
}
|
|
|
|
Q_EMIT namedAssetModified(currentGameId, QString::fromStdString(name));
|
|
}
|
|
}
|
|
else if (publishScript && result == publishScript)
|
|
{
|
|
QMessageBox mb;
|
|
mb.setText("Are you sure you want to publish your changes?");
|
|
mb.setStandardButtons(QMessageBox::Yes | QMessageBox::No);
|
|
if (mb.exec() == QMessageBox::Yes)
|
|
{
|
|
boost::shared_future<void> publishFuture = publishIfThereAreLocalModifications(properties);
|
|
UpdateUIManager::Instance().waitForLongProcess("Publishing LinkedSource...",
|
|
boost::bind(&boost::shared_future<void>::get, &publishFuture));
|
|
refreshNamedScriptIcons(currentSessionId);
|
|
}
|
|
}
|
|
else if (FFlag::GameExplorerCopyPath && copyPath && result == copyPath)
|
|
{
|
|
std::string path = ContentId::fromGameAssetName(properties->get<std::string>("Name").get()).toString();
|
|
|
|
QClipboard *clipboard = QApplication::clipboard();
|
|
clipboard->setText(QString(path.c_str()));
|
|
}
|
|
|
|
if (needReload)
|
|
{
|
|
reloadDataFromWeb();
|
|
}
|
|
}
|
|
|
|
void RobloxGameExplorer::placeGroupContextMenuHandler(const QPoint& point)
|
|
{
|
|
QMenu menu(this);
|
|
|
|
QAction* add = menu.addAction("Add Empty Place");
|
|
|
|
QAction* configure = menu.addAction("Change Place List");
|
|
|
|
QAction* result = menu.exec(point);
|
|
if (result == add)
|
|
{
|
|
addNewPlace();
|
|
}
|
|
else if (result == configure)
|
|
{
|
|
openAndFocusConfigureDoc(
|
|
formatUrl("%WEB_BASE%/universes/configure?id=%UNIVERSE_ID%#/#places", currentGameId));
|
|
}
|
|
}
|
|
|
|
void RobloxGameExplorer::developerProductGroupContextMenuHandler(const QPoint& point)
|
|
{
|
|
QMenu menu(this);
|
|
|
|
QAction* configure = menu.addAction("Configure Products");
|
|
|
|
QAction* result = menu.exec(point);
|
|
if (result == configure)
|
|
{
|
|
openAndFocusConfigureDoc(
|
|
formatUrl("%WEB_BASE%/universes/configure?id=%UNIVERSE_ID%#/#developerProducts", currentGameId));
|
|
}
|
|
}
|
|
|
|
void RobloxGameExplorer::namedAssetsGroupContextMenuHandler(const QPoint& point)
|
|
{
|
|
QMenu menu(this);
|
|
|
|
QAction* add = menu.addAction("Add Image");
|
|
|
|
QAction* bulkAdd = menu.addAction("Add Multiple Images");
|
|
|
|
QAction* result = menu.exec(point);
|
|
if (result == add)
|
|
{
|
|
bool created = false;
|
|
QString newName;
|
|
AddImageDialog dialog;
|
|
dialog.runModal(this, &created, &newName);
|
|
}
|
|
else if (result == bulkAdd)
|
|
{
|
|
bulkAddNewImageNames();
|
|
}
|
|
}
|
|
|
|
void RobloxGameExplorer::handleRename(const QPoint& globalPoint)
|
|
{
|
|
static boost::once_flag flag = BOOST_ONCE_INIT;
|
|
boost::call_once(boost::bind(&RobloxGoogleAnalytics::trackEvent,
|
|
GA_CATEGORY_ACTION, "Game Explorer", "Context menu rename", 0, false), flag);
|
|
|
|
QModelIndex index = indexAt(viewport()->mapFromGlobal(globalPoint));
|
|
RBXASSERT(index.isValid());
|
|
if (index.isValid())
|
|
{
|
|
edit(index);
|
|
}
|
|
}
|
|
|
|
void RobloxGameExplorer::bulkAddNewImageNames()
|
|
{
|
|
RobloxSettings settings;
|
|
QString imageLastDir = settings.value(kLastImageDirectoryKey).toString();
|
|
if (imageLastDir.isEmpty())
|
|
imageLastDir = RobloxMainWindow::getDefaultSavePath();
|
|
|
|
QStringList files =
|
|
QFileDialog::getOpenFileNames(this, "Choose images to upload", imageLastDir,
|
|
"Images (*.jpg *.jpeg *.png *.gif *.bmp);;All Files (*.*)");
|
|
|
|
if (!files.empty())
|
|
{
|
|
std::vector<QString> usedNames;
|
|
getListOfImages(&usedNames);
|
|
|
|
// documentation suggests copying file list before processing
|
|
QStringList copy = files;
|
|
std::vector<std::pair<QString, QString> > fileAndImageNames(copy.size());
|
|
size_t index = 0;
|
|
for (QStringList::iterator itr = copy.begin(); itr != copy.end(); ++itr)
|
|
{
|
|
std::pair<QString, QString>& fileAndImageName = fileAndImageNames[index++];
|
|
fileAndImageName.first = QDir::toNativeSeparators(*itr);
|
|
findAvailableImageNameFromFilename(*itr, &usedNames, &fileAndImageName.second);
|
|
usedNames.push_back(fileAndImageName.second);
|
|
}
|
|
RBXASSERT(((int)index) == copy.size());
|
|
|
|
UpdateUIManager::Instance().waitForLongProcess("Uploading Images",
|
|
boost::bind(&bulkAddNewImageNamesThread, &fileAndImageNames, currentGameId, currentGameGroupId));
|
|
reloadDataFromWeb();
|
|
}
|
|
}
|
|
|
|
static void doInsert(shared_ptr<Instance> instance)
|
|
{
|
|
if (RobloxIDEDoc* ideDoc = RobloxDocManager::Instance().getPlayDoc())
|
|
{
|
|
if (DataModel* dm = ideDoc->getDataModel())
|
|
{
|
|
RBX::DataModel::LegacyLock lock(dm, RBX::DataModelJob::Write);
|
|
|
|
Selection* selection = dm->create<Selection>();
|
|
Instance* parent;
|
|
if (selection->size() == 1)
|
|
{
|
|
parent = selection->front().get();
|
|
}
|
|
else
|
|
{
|
|
parent = dm->getWorkspace();
|
|
}
|
|
|
|
instance->setParent(parent);
|
|
}
|
|
}
|
|
}
|
|
|
|
void RobloxGameExplorer::insertNamedImage(EntityProperties* imageInfo)
|
|
{
|
|
shared_ptr<Decal> decal = Creatable<Instance>::create<Decal>();
|
|
decal->setTexture(ContentId::fromGameAssetName(imageInfo->get<std::string>("Name").get()));
|
|
doInsert(decal);
|
|
}
|
|
|
|
void RobloxGameExplorer::insertNamedScript(EntityProperties* scriptInfo, shared_ptr<LuaSourceContainer> container)
|
|
{
|
|
const std::string assetName = scriptInfo->get<std::string>("Name").get();
|
|
container->setScriptId(ContentId::fromGameAssetName(assetName));
|
|
std::string scriptName = assetName;
|
|
if (boost::starts_with(scriptName, "Scripts/"))
|
|
{
|
|
scriptName = scriptName.substr(8);
|
|
}
|
|
container->setName(scriptName);
|
|
doInsert(container);
|
|
}
|
|
|
|
void RobloxGameExplorer::doneEditingUniverseName()
|
|
{
|
|
std::string oldName = gameSettings.getName();
|
|
std::string newName = gameNameEdit->text().toStdString();
|
|
if (oldName == newName) return;
|
|
|
|
gameSettings.setName(newName);
|
|
|
|
bool success;
|
|
std::string newSettings;
|
|
UpdateUIManager::Instance().waitForLongProcess("Updating Game Name...",
|
|
boost::bind(&updateNameThread, gameSettings.asJson(),
|
|
formatUrl("%API_PROXY%/universes/update-universe?universeid=%UNIVERSE_ID%", currentGameId),
|
|
&success, &newSettings));
|
|
|
|
if (!success)
|
|
{
|
|
gameSettings.setName(oldName);
|
|
}
|
|
else
|
|
{
|
|
boost::promise<std::string> jsonPromise;
|
|
jsonPromise.set_value(newSettings);
|
|
gameSettings.setFromJsonFuture(HttpFuture(jsonPromise.get_future()));
|
|
}
|
|
gameNameEdit->setText(QString::fromStdString(gameSettings.getName()));
|
|
}
|
|
|
|
void RobloxGameExplorer::reloadDataFromWeb()
|
|
{
|
|
currentSessionId++;
|
|
|
|
// kick off request to load settings
|
|
// todo: use HttpRbxApiService
|
|
HttpOptions getOptions;
|
|
getOptions.setDoNotUseCachedResponse();
|
|
gameSettings.setFromJsonFuture(HttpAsync::get(
|
|
formatUrl("%API_PROXY%/universes/get-info?universeId=%UNIVERSE_ID%", currentGameId),
|
|
getOptions));
|
|
|
|
if (!gameSettings.currentUserHasAccess() && !RobloxIDEDoc::getIsCloudEditSession())
|
|
{
|
|
nonGameLoaded();
|
|
return;
|
|
}
|
|
|
|
for (unsigned int i = 0; i < ENTITY_CATEGORY_MAX; ++i)
|
|
{
|
|
setGroupLoadingStatus((EntityCategory)i);
|
|
entitySettings[(EntityCategory)i] = EntityPropertiesForCategory::loadFromWeb(
|
|
kWebSettings[i], currentGameId,
|
|
boost::bind(&doneLoadingDataForCategory, QPointer<RobloxGameExplorer>(this),
|
|
currentSessionId, (EntityCategory)i));
|
|
}
|
|
}
|
|
|
|
void RobloxGameExplorer::doubleClickHandler(const QModelIndex& modelIndex)
|
|
{
|
|
QStandardItem* item = getModel()->itemFromIndex(modelIndex);
|
|
if (!item) return;
|
|
|
|
QVariant callback = item->data(ROLE_DoubleClickCallback);
|
|
if (!callback.isNull())
|
|
{
|
|
callback.value<boost::function<void()> >()();
|
|
}
|
|
}
|
|
|
|
void RobloxGameExplorer::contextMenuHandler(const QPoint& point)
|
|
{
|
|
QModelIndex index = indexAt(point);
|
|
if (index.isValid())
|
|
{
|
|
if (QStandardItem* item = getModel()->itemFromIndex(index))
|
|
{
|
|
QVariant callback = item->data(ROLE_ContextMenuCallback);
|
|
if (!callback.isNull())
|
|
{
|
|
callback.value<boost::function<void(const QPoint&)> >()(viewport()->mapToGlobal(point));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void RobloxGameExplorer::itemChangedHandler(QStandardItem* updatedItem)
|
|
{
|
|
QVariant callback = updatedItem->data(ROLE_NameChangedCallback);
|
|
if (!callback.isNull())
|
|
{
|
|
callback.value<boost::function<void()> >()();
|
|
}
|
|
}
|
|
|
|
void RobloxGameExplorer::handleNameUpdates()
|
|
{
|
|
// find incorrect names
|
|
for (int i = 0; i < ENTITY_CATEGORY_MAX; ++i)
|
|
{
|
|
EntityCategory category = (EntityCategory)i;
|
|
QStandardItem* group = findGroup(category);
|
|
EntityPropertiesForCategory::ref categoryProperties = entitySettings[category];
|
|
RBXASSERT(group);
|
|
if (!group) continue;
|
|
|
|
for (int row = 0; row < group->rowCount(); ++row)
|
|
{
|
|
checkRowForNameUpdate(category, group->child(row));
|
|
}
|
|
}
|
|
}
|
|
|
|
void RobloxGameExplorer::checkRowForNameUpdate(EntityCategory category, QStandardItem* entityRow)
|
|
{
|
|
QVariant rowId = getEntityId(entityRow, category);
|
|
if (!rowId.isValid()) return;
|
|
|
|
EntityProperties* props = entityRow->data(ROLE_EntityProperties).value<EntityProperties*>();
|
|
RBXASSERT(props);
|
|
if (!props) return;
|
|
|
|
boost::optional<std::string> oldName = props->get<std::string>("Name");
|
|
QString newName = entityRow->text();
|
|
bool validated = true;
|
|
|
|
if (category == ENTITY_CATEGORY_NamedAssets)
|
|
{
|
|
QString errorMessage;
|
|
validated = validateImageName(newName, NULL, &errorMessage);
|
|
if (!validated)
|
|
{
|
|
std::string stdErrorMessage = errorMessage.toStdString();
|
|
StandardOut::singleton()->printf(MESSAGE_ERROR, "%s", stdErrorMessage.c_str());
|
|
}
|
|
|
|
for (QStandardItem* stepParent = entityRow->parent();
|
|
stepParent != NULL && stepParent != findGroup(category);
|
|
stepParent = stepParent->parent())
|
|
{
|
|
newName = stepParent->text() + "/" + newName;
|
|
}
|
|
}
|
|
|
|
if (oldName.is_initialized() && oldName.get() != newName.toStdString())
|
|
{
|
|
bool success = false;
|
|
|
|
if (validated)
|
|
{
|
|
if (category == ENTITY_CATEGORY_NamedAssets)
|
|
{
|
|
QMessageBox mb(this);
|
|
mb.setText("Assets are referenced by name, so any references to this name will fail to "
|
|
"load. Are you sure you want to rename this asset?");
|
|
mb.setStandardButtons(QMessageBox::Yes | QMessageBox::No);
|
|
if (mb.exec() != QMessageBox::Yes)
|
|
{
|
|
if (uiSettings[category].nameMode == CategoryUiSettings::NameAsFolderPath)
|
|
{
|
|
entityRow->setText((uiSettings[category].displayNameGetter(props)).split('/').back());
|
|
}
|
|
else
|
|
{
|
|
entityRow->setText(uiSettings[category].displayNameGetter(props));
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
props->set("Name", newName.toStdString());
|
|
UpdateUIManager::Instance().waitForLongProcess(
|
|
QString("Updating name of %1 to %2").arg(QString::fromStdString(oldName.get())).arg(newName),
|
|
boost::bind(&updateNameThread, props->asJson(),
|
|
formatUrl(kWebSettings[category].updateFormatString, currentGameId, rowId),
|
|
&success, (std::string*)NULL));
|
|
}
|
|
|
|
if (!success)
|
|
{
|
|
props->set("Name", oldName.get());
|
|
if (uiSettings[category].nameMode == CategoryUiSettings::NameAsFolderPath)
|
|
{
|
|
entityRow->setText((uiSettings[category].displayNameGetter(props)).split('/').back());
|
|
}
|
|
else
|
|
{
|
|
entityRow->setText(uiSettings[category].displayNameGetter(props));
|
|
}
|
|
QMessageBox messageBox(this);
|
|
messageBox.setText("Error while updating name. See Output window for details.");
|
|
messageBox.setStandardButtons(QMessageBox::Ok);
|
|
messageBox.exec();
|
|
}
|
|
else
|
|
{
|
|
// special name update logic for places
|
|
if (category == ENTITY_CATEGORY_Places)
|
|
{
|
|
// if it is the currently opened place update tab name
|
|
if (rowId.toInt() == currentPlaceId)
|
|
{
|
|
if (RobloxIDEDoc* ideDoc = RobloxDocManager::Instance().getPlayDoc())
|
|
{
|
|
ideDoc->updateDisplayName(newName);
|
|
}
|
|
}
|
|
}
|
|
if (category == ENTITY_CATEGORY_NamedAssets)
|
|
{
|
|
ContentId newContentId = ContentId::fromGameAssetName(newName.toStdString());
|
|
ContentId oldContentId = ContentId::fromGameAssetName(oldName.get());
|
|
|
|
// update temp files
|
|
if (boost::optional<std::string> oldContentOverride =
|
|
findCachedAssetOrEmpty(oldContentId, currentGameId))
|
|
{
|
|
updateAsset(newContentId, oldContentOverride.get());
|
|
}
|
|
else
|
|
{
|
|
// if there is a lingering unpublished change for the target name, remove it
|
|
QFile(getUnpublishedAssetFilePath(newName.toStdString(), currentGameId)).remove();
|
|
}
|
|
|
|
// remove unpublished changes for old name, that name does not exist anymore
|
|
QFile(getUnpublishedAssetFilePath(oldName.get(), currentGameId)).remove();
|
|
|
|
int assetTypeId = props->get<int>("AssetTypeId").get();
|
|
if (assetTypeId == (int)ASSET_TYPE_ID_Script)
|
|
{
|
|
if (RobloxIDEDoc* ideDoc = RobloxDocManager::Instance().getPlayDoc())
|
|
{
|
|
if (DataModel* dm = ideDoc->getDataModel())
|
|
{
|
|
DataModel::LegacyLock l(dm, DataModelJob::Write);
|
|
ContentProvider* cp = dm->create<ContentProvider>();
|
|
|
|
cp->invalidateCache(oldContentId);
|
|
cp->invalidateCache(newContentId);
|
|
}
|
|
}
|
|
|
|
LuaSourceBuffer oldLsb = LuaSourceBuffer::fromContentId(oldContentId);
|
|
LuaSourceBuffer newLsb = LuaSourceBuffer::fromContentId(newContentId);
|
|
if (RobloxScriptDoc* scriptDoc = RobloxDocManager::Instance().findOpenScriptDoc(oldLsb))
|
|
{
|
|
scriptDoc->setScript(scriptDoc->getDataModel(), newLsb);
|
|
}
|
|
}
|
|
else if (assetTypeId == (int)ASSET_TYPE_ID_Image)
|
|
{
|
|
if (RobloxIDEDoc* ideDoc = RobloxDocManager::Instance().getPlayDoc())
|
|
{
|
|
QStringList list;
|
|
list.push_back(QString("rbxgameasset://%1").arg(QString::fromStdString(oldName.get())));
|
|
list.push_back(QString("rbxgameasset://%1").arg(newName));
|
|
ideDoc->forceReloadImages(list);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
bool RobloxGameExplorer::namedAssetHasUnpublishedChanges(const std::string& name)
|
|
{
|
|
return QFile(getUnpublishedAssetFilePath(name, currentGameId)).exists();
|
|
}
|
|
|
|
boost::shared_future<void> RobloxGameExplorer::publishIfThereAreLocalModifications(EntityProperties* settings)
|
|
{
|
|
std::string name = settings->get<std::string>("Name").get();
|
|
ContentId contentId = ContentId::fromGameAssetName(name);
|
|
boost::optional<std::string> localVersion = findCachedAssetOrEmpty(contentId, currentGameId);
|
|
|
|
if (localVersion && settings->get<int>("AssetTypeId") == (int)ASSET_TYPE_ID_Script)
|
|
{
|
|
return boost::shared_future<void>(
|
|
publishScriptAsset(localVersion.get(), settings->get<int>("AssetId").get(),
|
|
currentGameGroupId)
|
|
.then(boost::bind(&removeFile, getUnpublishedAssetFilePath(name, currentGameId), _1)));
|
|
}
|
|
else
|
|
{
|
|
boost::promise<void> prom;
|
|
prom.set_value();
|
|
return boost::shared_future<void>(prom.get_future());
|
|
}
|
|
}
|