Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ ScanEngine.cpp
PlatformProfile.cpp
QtMeshCloudClient.cpp
CloudCredentialStore.cpp
CloudUploadPlanner.cpp
AssetBrowserController.cpp
MaterialPreviewRenderer.cpp
ModelTurntableRenderer.cpp
Expand Down Expand Up @@ -223,6 +224,7 @@ ScanEngine.h
PlatformProfile.h
QtMeshCloudClient.h
CloudCredentialStore.h
CloudUploadPlanner.h
AssetBrowserController.h
MaterialPreviewRenderer.h
ModelTurntableRenderer.h
Expand Down
80 changes: 80 additions & 0 deletions src/CloudUploadPlanner.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#include "CloudUploadPlanner.h"

#include <QFileInfo>
#include <QMimeDatabase>
#include <QRegularExpression>
#include <QSet>

namespace {

bool hasExtension(const QString& ext, const QSet<QString>& extensions)
{
return extensions.contains(ext.toLower());
}

} // namespace

QString CloudUploadPlanner::makeProjectSlug(const QString& name, const QString& fallback)
{
auto normalize = [](const QString& value) {
QString slug = value.trimmed().toLower();
slug.replace(QRegularExpression(QStringLiteral("[^a-z0-9]+")), QStringLiteral("-"));
slug.replace(QRegularExpression(QStringLiteral("^-+|-+$")), QString());
slug.replace(QRegularExpression(QStringLiteral("-{2,}")), QStringLiteral("-"));
return slug;
};

QString slug = normalize(name);
if (slug.isEmpty())
slug = normalize(fallback);
if (slug.isEmpty())
slug = QStringLiteral("qtmesh-project");
if (slug.size() > 64) {
slug.truncate(64);
slug.replace(QRegularExpression(QStringLiteral("-+$")), QString());
}
return slug;
}

QString CloudUploadPlanner::inferAssetRole(const QString& fileName)
{
const QString ext = QFileInfo(fileName).suffix().toLower();
if (hasExtension(ext, {QStringLiteral("mesh"), QStringLiteral("obj"), QStringLiteral("fbx"),
QStringLiteral("gltf"), QStringLiteral("glb"), QStringLiteral("dae"),
QStringLiteral("collada"), QStringLiteral("stl"), QStringLiteral("tmd")}))
return QStringLiteral("model");
if (hasExtension(ext, {QStringLiteral("skeleton"), QStringLiteral("skel")}))
return QStringLiteral("skeleton");
if (hasExtension(ext, {QStringLiteral("anim"), QStringLiteral("animation")}))
return QStringLiteral("animation");
if (hasExtension(ext, {QStringLiteral("material"), QStringLiteral("mat"), QStringLiteral("mtl")}))
return QStringLiteral("material");
if (hasExtension(ext, {QStringLiteral("png"), QStringLiteral("jpg"), QStringLiteral("jpeg"),
QStringLiteral("tga"), QStringLiteral("bmp"), QStringLiteral("dds"),
QStringLiteral("tif"), QStringLiteral("tiff"), QStringLiteral("tim")}))
return QStringLiteral("texture");
if (hasExtension(ext, {QStringLiteral("json"), QStringLiteral("yml"), QStringLiteral("yaml"),
QStringLiteral("xml")}))
return QStringLiteral("metadata");
if (hasExtension(ext, {QStringLiteral("rsd"), QStringLiteral("ply")}))
return QStringLiteral("sidecar");
return QStringLiteral("file");
}

QList<QtMeshCloudClient::AssetFileDescriptor>
CloudUploadPlanner::buildAssetFileDescriptors(const QStringList& paths)
{
QList<QtMeshCloudClient::AssetFileDescriptor> descriptors;
QMimeDatabase mimeDatabase;
for (const QString& path : paths) {
const QFileInfo info(path);
QtMeshCloudClient::AssetFileDescriptor descriptor;
descriptor.path = path;
descriptor.uploadName = info.fileName();
descriptor.role = inferAssetRole(info.fileName());
descriptor.mimeType = mimeDatabase.mimeTypeForFile(info, QMimeDatabase::MatchExtension).name();
descriptor.sizeBytes = info.size();
descriptors.append(descriptor);
}
return descriptors;
}
17 changes: 17 additions & 0 deletions src/CloudUploadPlanner.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#ifndef CLOUD_UPLOAD_PLANNER_H
#define CLOUD_UPLOAD_PLANNER_H

#include "QtMeshCloudClient.h"

#include <QString>
#include <QStringList>

namespace CloudUploadPlanner {

QString makeProjectSlug(const QString& name, const QString& fallback = QStringLiteral("qtmesh-project"));
QString inferAssetRole(const QString& fileName);
QList<QtMeshCloudClient::AssetFileDescriptor> buildAssetFileDescriptors(const QStringList& paths);

} // namespace CloudUploadPlanner

#endif // CLOUD_UPLOAD_PLANNER_H
62 changes: 62 additions & 0 deletions src/CloudUploadPlanner_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#include "CloudUploadPlanner.h"

#include <gtest/gtest.h>

#include <QTemporaryDir>
#include <QFile>

TEST(CloudUploadPlanner, MakesStableSlug)
{
EXPECT_EQ(CloudUploadPlanner::makeProjectSlug(QStringLiteral(" My PS1 Asset Pack!! ")),
QStringLiteral("my-ps1-asset-pack"));
EXPECT_EQ(CloudUploadPlanner::makeProjectSlug(QStringLiteral("###"), QStringLiteral("Fallback Name")),
QStringLiteral("fallback-name"));
}

TEST(CloudUploadPlanner, TruncatesSlugWithoutTrailingDash)
{
const QString slug = CloudUploadPlanner::makeProjectSlug(
QStringLiteral("abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz"));
EXPECT_LE(slug.size(), 64);
EXPECT_FALSE(slug.endsWith(QLatin1Char('-')));
}

TEST(CloudUploadPlanner, CollisionSuffixStaysWithinLimit)
{
const QString longName = QStringLiteral(
"abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz abcdefghijklmnopqrstuvwxyz");
const QString retrySlug = CloudUploadPlanner::makeProjectSlug(
QStringLiteral("%1-%2").arg(longName, QStringLiteral("20260529140530")));
EXPECT_LE(retrySlug.size(), 64);
EXPECT_FALSE(retrySlug.endsWith(QLatin1Char('-')));
}

TEST(CloudUploadPlanner, InfersKnownAssetRoles)
{
EXPECT_EQ(CloudUploadPlanner::inferAssetRole(QStringLiteral("model.FBX")), QStringLiteral("model"));
EXPECT_EQ(CloudUploadPlanner::inferAssetRole(QStringLiteral("rig.skeleton")), QStringLiteral("skeleton"));
EXPECT_EQ(CloudUploadPlanner::inferAssetRole(QStringLiteral("walk.anim")), QStringLiteral("animation"));
EXPECT_EQ(CloudUploadPlanner::inferAssetRole(QStringLiteral("wall.TIM")), QStringLiteral("texture"));
EXPECT_EQ(CloudUploadPlanner::inferAssetRole(QStringLiteral("stage.RSD")), QStringLiteral("sidecar"));
EXPECT_EQ(CloudUploadPlanner::inferAssetRole(QStringLiteral("metadata.json")), QStringLiteral("metadata"));
EXPECT_EQ(CloudUploadPlanner::inferAssetRole(QStringLiteral("notes.txt")), QStringLiteral("file"));
}

TEST(CloudUploadPlanner, BuildsDescriptorsFromPaths)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("cube.obj"));
QFile file(path);
ASSERT_TRUE(file.open(QIODevice::WriteOnly));
ASSERT_EQ(file.write("data"), 4);
file.close();

const auto descriptors = CloudUploadPlanner::buildAssetFileDescriptors({path});
ASSERT_EQ(descriptors.size(), 1);
EXPECT_EQ(descriptors.first().path, path);
EXPECT_EQ(descriptors.first().uploadName, QStringLiteral("cube.obj"));
EXPECT_EQ(descriptors.first().role, QStringLiteral("model"));
EXPECT_EQ(descriptors.first().sizeBytes, 4);
EXPECT_FALSE(descriptors.first().mimeType.isEmpty());
}
106 changes: 106 additions & 0 deletions src/QtMeshCloudClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QThread>
#include <QTimer>
#include <QUrl>

namespace {
Expand Down Expand Up @@ -544,6 +545,88 @@
return out;
}

QtMeshCloudClient::ProjectsListResult QtMeshCloudClient::fetchProjects(const QString& bearerToken,
int timeoutMs)
{
ProjectsListResult out;
if (bearerToken.isEmpty()) {
out.errorString = QStringLiteral("missing bearer token");
return out;
}

const QUrl url(apiBaseUrl() + QStringLiteral("/v1/projects"));
if (!url.isValid()) {
out.errorString = QStringLiteral("invalid API base URL");
return out;
}

QNetworkAccessManager nam;
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("qtmesheditor"));
req.setRawHeader("Authorization", QByteArrayLiteral("Bearer ") + bearerToken.toUtf8());
req.setTransferTimeout(timeoutMs);

SentryReporter::addBreadcrumb(QStringLiteral("cloud.project"),
QStringLiteral("QtMesh Cloud fetchProjects: start"));

QNetworkReply* reply = nam.get(req);
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();

out.httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray responseBody = reply->readAll();
const auto nerr = reply->error();
const QString transportErr = reply->errorString();
reply->deleteLater();

if (nerr != QNetworkReply::NoError || out.httpStatus < 200 || out.httpStatus >= 300) {
out.responseBodySnippet = trimSnippet(responseBody);
out.errorString = nerr != QNetworkReply::NoError ? transportErr : QStringLiteral("HTTP %1").arg(out.httpStatus);
if (!out.responseBodySnippet.isEmpty())
out.errorString += QStringLiteral(" — ") + out.responseBodySnippet;
SentryReporter::addBreadcrumb(QStringLiteral("cloud.project"),
QStringLiteral("QtMesh Cloud fetchProjects: failure HTTP %1").arg(out.httpStatus),
QStringLiteral("warning"));
return out;
}

QJsonObject root;
if (!parseJsonObjectBody(responseBody, root, out.errorString))
return out;

const QJsonValue projectsValue = root.value(QStringLiteral("projects"));
if (!projectsValue.isArray()) {
out.errorString = QStringLiteral("response missing \"projects\" array");
SentryReporter::addBreadcrumb(QStringLiteral("cloud.project"),
QStringLiteral("QtMesh Cloud fetchProjects: malformed response"),
QStringLiteral("warning"));
return out;
}

const QJsonArray projects = projectsValue.toArray();
for (const QJsonValue& value : projects) {
const QJsonObject project = value.toObject();
ProjectSummary summary;
summary.id = project.value(QStringLiteral("id")).toString();
summary.ownerSlug = project.value(QStringLiteral("ownerSlug")).toString();
summary.projectSlug = project.value(QStringLiteral("slug")).toString();
summary.name = project.value(QStringLiteral("name")).toString();
if (!summary.ownerSlug.isEmpty() && !summary.projectSlug.isEmpty()) {
summary.projectUrl = QStringLiteral("https://qtmesh.dev/%1/%2")
.arg(QString::fromUtf8(QUrl::toPercentEncoding(summary.ownerSlug)),
QString::fromUtf8(QUrl::toPercentEncoding(summary.projectSlug)));
}
if (!summary.id.isEmpty() && !summary.ownerSlug.isEmpty() && !summary.projectSlug.isEmpty())
out.projects.append(summary);
}

out.ok = true;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
SentryReporter::addBreadcrumb(QStringLiteral("cloud.project"),
QStringLiteral("QtMesh Cloud fetchProjects: ok count=%1").arg(out.projects.size()));
return out;
}

QtMeshCloudClient::ProjectResult QtMeshCloudClient::createProject(const QString& bearerToken,
const QString& name,
const QString& slug,
Expand Down Expand Up @@ -731,10 +814,11 @@
return out;
}

QtMeshCloudClient::FileUploadResult QtMeshCloudClient::uploadFileContent(

Check failure on line 817 in src/QtMeshCloudClient.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 27 to the 25 allowed.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ52X0ezIk2cqW8BISHs&open=AZ52X0ezIk2cqW8BISHs&pullRequest=698
const QString& bearerToken,
const UploadTarget& target,
const QString& localPath,
const std::atomic_bool* canceled,
int timeoutMs)
{
FileUploadResult out;
Expand All @@ -752,6 +836,11 @@
out.errorString = QStringLiteral("uploadFileContent must run on a worker thread");
return out;
}
if (canceled && canceled->load()) {
out.canceled = true;
out.errorString = QStringLiteral("upload canceled");
return out;
}

QFile file(localPath);
if (!file.open(QIODevice::ReadOnly)) {
Expand Down Expand Up @@ -785,15 +874,32 @@

QNetworkReply* reply = nam.put(req, &file);
QEventLoop loop;
QTimer cancelPoll;
if (canceled) {
cancelPoll.setInterval(100);
QObject::connect(&cancelPoll, &QTimer::timeout, &loop, [&]() {

Check failure on line 880 in src/QtMeshCloudClient.cpp

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Explicitly capture the required scope variables.

See more on https://sonarcloud.io/project/issues?id=fernandotonon_QtMeshEditor&issues=AZ52X0ezIk2cqW8BISHt&open=AZ52X0ezIk2cqW8BISHt&pullRequest=698
if (canceled->load())
reply->abort();
});
cancelPoll.start();
}
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
if (canceled)
cancelPoll.stop();

out.httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray responseBody = reply->readAll();
const auto nerr = reply->error();
const QString transportErr = reply->errorString();
reply->deleteLater();

if (canceled && canceled->load()) {
out.canceled = true;
out.errorString = QStringLiteral("upload canceled");
return out;
}

if (nerr != QNetworkReply::NoError || out.httpStatus < 200 || out.httpStatus >= 300) {
out.responseBodySnippet = trimSnippet(responseBody);
out.errorString = nerr != QNetworkReply::NoError ? transportErr : QStringLiteral("HTTP %1").arg(out.httpStatus);
Expand Down
23 changes: 23 additions & 0 deletions src/QtMeshCloudClient.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
#include <QStringList>
#include <QJsonObject>

#include <atomic>

/// HTTP client for QtMesh Cloud ingest API (remote rules + scan upload).
class QtMeshCloudClient {
public:
Expand Down Expand Up @@ -94,6 +96,25 @@ class QtMeshCloudClient {
QString projectUrl;
};

struct ProjectSummary {
QString id;
QString ownerSlug;
QString projectSlug;
QString name;
QString projectUrl;
};

struct ProjectsListResult {
bool ok = false;
int httpStatus = 0;
QString errorString;
QString responseBodySnippet;
QList<ProjectSummary> projects;
};

/// GET /v1/projects — lists cloud projects the authenticated user can access.
static ProjectsListResult fetchProjects(const QString& bearerToken, int timeoutMs = 30000);

/// POST /v1/projects — creates a private cloud project owned by the authenticated user.
static ProjectResult createProject(const QString& bearerToken,
const QString& name,
Expand Down Expand Up @@ -139,6 +160,7 @@ class QtMeshCloudClient {

struct FileUploadResult {
bool ok = false;
bool canceled = false;
int httpStatus = 0;
QString errorString;
QString responseBodySnippet;
Expand All @@ -150,6 +172,7 @@ class QtMeshCloudClient {
static FileUploadResult uploadFileContent(const QString& bearerToken,
const UploadTarget& target,
const QString& localPath,
const std::atomic_bool* canceled = nullptr,
int timeoutMs = 120000);

struct CompleteUploadResult {
Expand Down
Loading
Loading