ExtensionManager: Adapt service response parser to latest version

api/v1 on https://qc-extensions.qt.io/api-doc

Change-Id: Ic559029f61d238810e21e586ab7a64c5e847e38d
Reviewed-by: Marcus Tillmanns <marcus.tillmanns@qt.io>
This commit is contained in:
Alessandro Portale
2024-09-06 12:30:45 +02:00
parent af75109ba5
commit c83b4e64f7
10 changed files with 411 additions and 814 deletions

View File

@@ -1,8 +1,6 @@
<RCC>
<qresource prefix="/extensionmanager">
<file>testdata/augmentedplugindata.json</file>
<file>testdata/defaultpacks.json</file>
<file>testdata/thirdpartyplugins.json</file>
<file>testdata/varieddata.json</file>
</qresource>
</RCC>

View File

@@ -57,14 +57,14 @@ namespace ExtensionManager::Internal {
Q_LOGGING_CATEGORY(widgetLog, "qtc.extensionmanager.widget", QtWarningMsg)
constexpr TextFormat h5TF
{Theme::Token_Text_Default, UiElement::UiElementH5};
constexpr TextFormat h6TF
{h5TF.themeColor, UiElement::UiElementH6};
constexpr TextFormat h6CapitalTF
{Theme::Token_Text_Muted, UiElement::UiElementH6Capital};
constexpr TextFormat contentTF
{Theme::Token_Text_Default, UiElement::UiElementBody2};
constexpr TextFormat h5TF
{contentTF.themeColor, UiElement::UiElementH5};
constexpr TextFormat h6TF
{contentTF.themeColor, UiElement::UiElementH6};
constexpr TextFormat h6CapitalTF
{Theme::Token_Text_Muted, UiElement::UiElementH6Capital};
static QLabel *sectionTitle(const TextFormat &tf, const QString &title)
{
@@ -215,9 +215,9 @@ public:
m_dlCount->setText(QString::number(dlCount));
m_dlCountItems->setVisible(showDlCount);
const auto pluginData = current.data(RolePlugins).value<PluginsData>();
const QStringList plugins = current.data(RolePlugins).toStringList();
if (current.data(RoleItemType).toInt() == ItemTypePack) {
const int pluginsCount = pluginData.count();
const int pluginsCount = plugins.count();
const QString details = Tr::tr("Pack contains %n plugins.", nullptr, pluginsCount);
m_details->setText(details);
} else {
@@ -227,9 +227,10 @@ public:
const ItemType itemType = current.data(RoleItemType).value<ItemType>();
const bool isPack = itemType == ItemTypePack;
const bool isRemotePlugin = !(isPack || pluginSpecForId(current.data(RoleId).toString()));
installButton->setVisible(isRemotePlugin && !pluginData.empty());
const QString downloadUrl = current.data(RoleDownloadUrl).toString();
installButton->setVisible(isRemotePlugin && !downloadUrl.isEmpty());
if (installButton->isVisible())
installButton->setToolTip(pluginData.constFirst().second);
installButton->setToolTip(downloadUrl);
}
signals:
@@ -383,25 +384,17 @@ public:
private:
void updateView(const QModelIndex &current);
void fetchAndInstallPlugin(const QUrl &url);
void fetchAndDisplayImage(const QUrl &url);
QString m_currentItemName;
ExtensionsModel *m_extensionModel;
ExtensionsBrowser *m_extensionBrowser;
CollapsingWidget *m_secondaryDescriptionWidget;
HeadingWidget *m_headingWidget;
QWidget *m_primaryContent;
QWidget *m_secondaryContent;
QLabel *m_description;
QLabel *m_linksTitle;
QLabel *m_links;
QLabel *m_imageTitle;
QLabel *m_image;
QBuffer m_imageDataBuffer;
QMovie m_imageMovie;
QLabel *m_tagsTitle;
TagList *m_tags;
QLabel *m_compatVersionTitle;
QLabel *m_compatVersion;
QLabel *m_platformsTitle;
QLabel *m_platforms;
QLabel *m_dependenciesTitle;
@@ -409,14 +402,14 @@ private:
QLabel *m_packExtensionsTitle;
QLabel *m_packExtensions;
PluginStatusWidget *m_pluginStatus;
PluginsData m_currentItemPlugins;
QString m_currentDownloadUrl;
Tasking::TaskTreeRunner m_dlTaskTreeRunner;
Tasking::TaskTreeRunner m_imgTaskTreeRunner;
};
ExtensionManagerWidget::ExtensionManagerWidget()
{
m_extensionBrowser = new ExtensionsBrowser;
m_extensionModel = new ExtensionsModel(this);
m_extensionBrowser = new ExtensionsBrowser(m_extensionModel);
auto descriptionColumns = new QWidget;
m_secondaryDescriptionWidget = new CollapsingWidget;
@@ -425,21 +418,12 @@ ExtensionManagerWidget::ExtensionManagerWidget()
m_description->setWordWrap(true);
m_description->setTextInteractionFlags(Qt::TextBrowserInteraction);
m_description->setOpenExternalLinks(true);
m_linksTitle = sectionTitle(h6CapitalTF, Tr::tr("More information"));
m_links = tfLabel(contentTF, false);
m_links->setOpenExternalLinks(true);
m_links->setTextInteractionFlags(Qt::TextBrowserInteraction);
m_imageTitle = sectionTitle(h6CapitalTF, {});
m_image = new QLabel;
m_imageMovie.setDevice(&m_imageDataBuffer);
using namespace Layouting;
auto primary = new QWidget;
const auto spL = spacing(SpacingTokens::VPaddingL);
Column {
m_description,
Column { m_linksTitle, m_links, spL },
Column { m_imageTitle, m_image, spL },
st,
noMargin, spacing(SpacingTokens::ExVPaddingGapXl),
}.attachTo(primary);
@@ -447,8 +431,6 @@ ExtensionManagerWidget::ExtensionManagerWidget()
m_tagsTitle = sectionTitle(h6TF, Tr::tr("Tags"));
m_tags = new TagList;
m_compatVersionTitle = sectionTitle(h6TF, Tr::tr("Compatibility"));
m_compatVersion = tfLabel(contentTF, false);
m_platformsTitle = sectionTitle(h6TF, Tr::tr("Platforms"));
m_platforms = tfLabel(contentTF, false);
m_dependenciesTitle = sectionTitle(h6TF, Tr::tr("Dependencies"));
@@ -463,7 +445,6 @@ ExtensionManagerWidget::ExtensionManagerWidget()
sectionTitle(h6CapitalTF, Tr::tr("Extension details")),
Column {
Column { m_tagsTitle, m_tags, spXxs },
Column { m_compatVersionTitle, m_compatVersion, spXxs },
Column { m_platformsTitle, m_platforms, spXxs },
Column { m_dependenciesTitle, m_dependencies, spXxs },
Column { m_packExtensionsTitle, m_packExtensions, spXxs },
@@ -522,7 +503,7 @@ ExtensionManagerWidget::ExtensionManagerWidget()
m_secondaryDescriptionWidget->setWidth(secondaryDescriptionWidth);
});
connect(m_headingWidget, &HeadingWidget::pluginInstallationRequested, this, [this](){
fetchAndInstallPlugin(QUrl::fromUserInput(m_currentItemPlugins.constFirst().second));
fetchAndInstallPlugin(QUrl::fromUserInput(m_currentDownloadUrl));
});
connect(m_tags, &TagList::tagSelected, m_extensionBrowser, &ExtensionsBrowser::setFilter);
connect(m_headingWidget, &HeadingWidget::vendorClicked,
@@ -546,7 +527,10 @@ static QString markdownToHtml(const QString &markdown)
blockFormat.setBottomMargin(SpacingTokens::VGapL);
QTextCursor cursor(block);
cursor.mergeBlockFormat(blockFormat);
const TextFormat headingTf = blockFormat.headingLevel() == 1 ? h5TF : h6TF;
const TextFormat headingTf =
blockFormat.headingLevel() == 1 ? h5TF
: blockFormat.headingLevel() == 2 ? h6TF
: h6CapitalTF;
const QFont headingFont = headingTf.font();
for (auto it = block.begin(); !(it.atEnd()); ++it) {
QTextFragment fragment = it.fragment();
@@ -555,9 +539,10 @@ static QString markdownToHtml(const QString &markdown)
cursor.setPosition(fragment.position());
cursor.setPosition(fragment.position() + fragment.length(), QTextCursor::KeepAnchor);
if (blockFormat.hasProperty(QTextFormat::HeadingLevel)) {
charFormat.setFontCapitalization(headingFont.capitalization());
charFormat.setFontFamilies(headingFont.families());
charFormat.setFontWeight(headingFont.weight());
charFormat.setFontPointSize(headingFont.pointSizeF());
charFormat.setFontWeight(headingFont.weight());
charFormat.setForeground(headingTf.color());
} else if (charFormat.isAnchor()) {
charFormat.setForeground(creatorColor(Theme::Token_Text_Accent));
@@ -587,96 +572,60 @@ void ExtensionManagerWidget::updateView(const QModelIndex &current)
m_currentItemName = current.data(RoleName).toString();
const bool isPack = current.data(RoleItemType) == ItemTypePack;
m_pluginStatus->setPluginId(isPack ? QString() : current.data(RoleId).toString());
m_currentItemPlugins = current.data(RolePlugins).value<PluginsData>();
m_currentDownloadUrl = current.data(RoleDownloadUrl).toString();
auto toContentParagraph = [](const QString &text) {
{
const QStringList description = {
"# " + m_currentItemName,
current.data(RoleDescriptionShort).toString(),
"",
current.data(RoleDescriptionLong).toString()
};
const QString descriptionMarkdown = description.join("\n");
m_description->setText(markdownToHtml(descriptionMarkdown));
}
{
auto idToDisplayName = [this](const QString &id) {
const QModelIndex dependencyIndex = m_extensionModel->indexOfId(id);
return dependencyIndex.data(RoleName).toString();
};
auto toContentParagraph = [](const QStringList &text) {
const QString lines = text.join("<br/>");
const QString pHtml = QString::fromLatin1("<p style=\"margin-top:0;margin-bottom:0;"
"line-height:%1px\">%2</p>")
.arg(contentTF.lineHeight()).arg(text);
.arg(contentTF.lineHeight()).arg(lines);
return pHtml;
};
{
const TextData textData = current.data(RoleDescriptionText).value<TextData>();
const bool hasDescription = !textData.isEmpty();
if (hasDescription) {
QString descriptionMarkdown;
for (const TextData::Type &text : textData) {
if (!text.first.isEmpty()) {
const QLatin1String headingMark(descriptionMarkdown.isEmpty() ? "#" : "\n\n##");
descriptionMarkdown.append(headingMark + " " + text.first + "\n");
}
descriptionMarkdown.append(text.second.join("\n"));
}
m_description->setText(markdownToHtml(descriptionMarkdown));
}
m_description->setVisible(hasDescription);
const LinksData linksData = current.data(RoleDescriptionLinks).value<LinksData>();
const bool hasLinks = !linksData.isEmpty();
if (hasLinks) {
QString linksHtml;
const QStringList links = transform(linksData, [](const LinksData::Type &link) {
const QString anchor = link.first.isEmpty() ? link.second : link.first;
return QString::fromLatin1(R"(<a href="%1" style="color:%2">%3 &gt;</a>)")
.arg(link.second)
.arg(creatorColor(Theme::Token_Text_Accent).name())
.arg(anchor);
});
linksHtml = links.join("<br/>");
m_links->setText(toContentParagraph(linksHtml));
}
m_linksTitle->setVisible(hasLinks);
m_links->setVisible(hasLinks);
m_imgTaskTreeRunner.reset();
m_imageMovie.stop();
m_imageDataBuffer.close();
m_image->clear();
const ImagesData imagesData = current.data(RoleDescriptionImages).value<ImagesData>();
const bool hasImages = !imagesData.isEmpty();
if (hasImages) {
const ImagesData::Type &image = imagesData.constFirst(); // Only show one image
m_imageTitle->setText(image.first);
fetchAndDisplayImage(image.second);
}
m_imageTitle->setVisible(hasImages);
m_image->setVisible(hasImages);
}
{
const QStringList tags = current.data(RoleTags).toStringList();
m_tags->setTags(tags);
const bool hasTags = !tags.isEmpty();
m_tagsTitle->setVisible(hasTags);
m_tags->setVisible(hasTags);
const QString compatVersion = current.data(RoleCompatVersion).toString();
const bool hasCompatVersion = !compatVersion.isEmpty();
if (hasCompatVersion)
m_compatVersion->setText(compatVersion);
m_compatVersionTitle->setVisible(hasCompatVersion);
m_compatVersion->setVisible(hasCompatVersion);
const QStringList platforms = current.data(RolePlatforms).toStringList();
const bool hasPlatforms = !platforms.isEmpty();
if (hasPlatforms)
m_platforms->setText(toContentParagraph(platforms.join("<br/>")));
m_platforms->setText(toContentParagraph(platforms));
m_platformsTitle->setVisible(hasPlatforms);
m_platforms->setVisible(hasPlatforms);
const QStringList dependencies = current.data(RoleDependencies).toStringList();
const bool hasDependencies = !dependencies.isEmpty();
if (hasDependencies)
m_dependencies->setText(toContentParagraph(dependencies.join("<br/>")));
if (hasDependencies) {
const QStringList displayNames = transform(dependencies, idToDisplayName);
m_dependencies->setText(toContentParagraph(displayNames));
}
m_dependenciesTitle->setVisible(hasDependencies);
m_dependencies->setVisible(hasDependencies);
const PluginsData plugins = current.data(RolePlugins).value<PluginsData>();
const QStringList plugins = current.data(RolePlugins).toStringList();
const bool hasExtensions = isPack && !plugins.isEmpty();
if (hasExtensions) {
const QStringList extensions = transform(plugins, &QPair<QString, QString>::first);
m_packExtensions->setText(toContentParagraph(extensions.join("<br/>")));
const QStringList displayNames = transform(plugins, idToDisplayName);
m_packExtensions->setText(toContentParagraph(displayNames));
}
m_packExtensionsTitle->setVisible(hasExtensions);
m_packExtensions->setVisible(hasExtensions);
@@ -743,60 +692,6 @@ void ExtensionManagerWidget::fetchAndInstallPlugin(const QUrl &url)
m_dlTaskTreeRunner.start(group);
}
void ExtensionManagerWidget::fetchAndDisplayImage(const QUrl &url)
{
using namespace Tasking;
struct StorageStruct
{
QByteArray imageData;
QUrl url;
};
Storage<StorageStruct> storage;
const auto onFetchSetup = [url, storage](NetworkQuery &query) {
storage->url = url;
query.setRequest(QNetworkRequest(url));
query.setNetworkAccessManager(NetworkAccessManager::instance());
qCDebug(widgetLog).noquote() << "Sending image request:" << url.toDisplayString();
};
const auto onFetchDone = [storage](const NetworkQuery &query, DoneWith result) {
qCDebug(widgetLog) << "Got image QNetworkReply:" << query.reply()->error();
if (result == DoneWith::Success)
storage->imageData = query.reply()->readAll();
};
const auto onShowImage = [storage, this]() {
if (storage->imageData.isEmpty())
return;
m_imageDataBuffer.setData(storage->imageData);
qCDebug(widgetLog).noquote() << "Image reponse size:"
<< QLocale::system().formattedDataSize(
m_imageDataBuffer.size());
if (!m_imageDataBuffer.open(QIODevice::ReadOnly))
return;
QImageReader reader(&m_imageDataBuffer);
const bool animated = reader.supportsAnimation();
if (animated) {
m_image->setMovie(&m_imageMovie);
m_imageMovie.start();
} else {
const QPixmap pixmap = QPixmap::fromImage(reader.read());
m_image->setPixmap(pixmap);
}
qCDebug(widgetLog) << "Image dimensions:" << reader.size();
qCDebug(widgetLog) << "Image is animated:" << animated;
};
Group group{
storage,
NetworkQueryTask{onFetchSetup, onFetchDone},
onGroupDone(onShowImage),
};
m_imgTaskTreeRunner.start(group);
}
QWidget *createExtensionManagerWidget()
{
return new ExtensionManagerWidget;

View File

@@ -277,7 +277,7 @@ public:
painter->setFont(countTF.font());
painter->setPen(countTF.color());
const PluginsData plugins = index.data(RolePlugins).value<PluginsData>();
const QStringList plugins = index.data(RolePlugins).toStringList();
painter->drawText(smallCircle, countTF.drawTextFlags, QString::number(plugins.count()));
}
{
@@ -487,10 +487,12 @@ public:
SpinnerSolution::Spinner *m_spinner;
};
ExtensionsBrowser::ExtensionsBrowser(QWidget *parent)
ExtensionsBrowser::ExtensionsBrowser(ExtensionsModel *model, QWidget *parent)
: QWidget(parent)
, d(new ExtensionsBrowserPrivate)
{
d->model = model;
setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Preferred);
static const TextFormat titleTF
@@ -501,8 +503,6 @@ ExtensionsBrowser::ExtensionsBrowser(QWidget *parent)
d->searchBox = new SearchBox;
d->searchBox->setPlaceholderText(Tr::tr("Search"));
d->model = new ExtensionsModel(this);
d->searchProxyModel = new QSortFilterProxyModel(this);
d->searchProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
d->searchProxyModel->setFilterRole(RoleSearchText);
@@ -622,28 +622,11 @@ void ExtensionsBrowser::showEvent(QShowEvent *event)
QWidget::showEvent(event);
}
static QString customOsTypeToString(OsType osType)
{
switch (osType) {
case OsTypeWindows:
return "Windows";
case OsTypeLinux:
return "Linux";
case OsTypeMac:
return "macOS";
case OsTypeOtherUnix:
return "Other Unix";
case OsTypeOther:
default:
return "Other";
}
}
void ExtensionsBrowser::fetchExtensions()
{
#ifdef WITH_TESTS
// Uncomment for testing with local json data.
// Available: "augmentedplugindata", "defaultpacks", "varieddata", "thirdpartyplugins"
// Available: "defaultpacks", "thirdpartyplugins"
// d->model->setExtensionsJson(testData("defaultpacks")); return;
#endif // WITH_TESTS
@@ -655,14 +638,8 @@ void ExtensionsBrowser::fetchExtensions()
using namespace Tasking;
const auto onQuerySetup = [this](NetworkQuery &query) {
const QString url = "%1/api/v1/search?request=";
const QString requestTemplate
= R"({"qtc_version":"%1","host_os":"%2","host_os_version":"%3","host_architecture":"%4","page_size":200})";
const QString request = url.arg(settings().externalRepoUrl()) + requestTemplate
.arg(QCoreApplication::applicationVersion())
.arg(customOsTypeToString(HostOsInfo::hostOs()))
.arg(QSysInfo::productVersion())
.arg(QSysInfo::currentCpuArchitecture());
const QString url = "%1/api/v1/search";
const QString request = url.arg(settings().externalRepoUrl());
query.setRequest(QNetworkRequest(QUrl::fromUserInput(request)));
query.setNetworkAccessManager(NetworkAccessManager::instance());
qCDebug(browserLog).noquote() << "Sending JSON request:" << request;

View File

@@ -13,12 +13,14 @@ class TextFormat;
namespace ExtensionManager::Internal {
class ExtensionsModel;
class ExtensionsBrowser final : public QWidget
{
Q_OBJECT
public:
ExtensionsBrowser(QWidget *parent = nullptr);
ExtensionsBrowser(ExtensionsModel *model, QWidget *parent = nullptr);
~ExtensionsBrowser();
void setFilter(const QString &filter);

View File

@@ -5,7 +5,8 @@
#include "extensionmanagertr.h"
#include "utils/algorithm.h"
#include <utils/algorithm.h>
#include <utils/hostosinfo.h>
#include <coreplugin/coreconstants.h>
#include <coreplugin/icore.h>
@@ -21,272 +22,197 @@
#include <QStandardItemModel>
#include <QVersionNumber>
#include <functional>
using namespace ExtensionSystem;
using namespace Core;
using namespace Utils;
namespace ExtensionManager::Internal {
const char EXTENSION_KEY_ID[] = "id";
Q_LOGGING_CATEGORY(modelLog, "qtc.extensionmanager.model", QtWarningMsg)
struct Dependency
{
QString id;
QString version;
};
using Dependencies = QList<Dependency>;
struct Plugin
{
QString copyright;
Dependencies dependencies;
bool isInternal = false;
QString id;
QString name;
QString packageUrl;
QString vendor;
QString version;
};
using Plugins = QList<Plugin>;
struct Description {
ImagesData images;
LinksData links;
TextData text;
};
struct Extension {
QString compatVersion;
QString copyright;
Description description;
int downloadCount = -1;
QString id;
QString license;
QString name;
QStringList platforms;
Plugins plugins;
qint64 size = 0;
QStringList tags;
ItemType type = ItemTypePack;
QString vendor;
QString vendorId;
QString version;
};
using Extensions = QList<Extension>;
static const Dependencies dependenciesFromJson(const QJsonObject &obj)
{
const QJsonArray dependenciesArray = obj.value("Dependencies").toArray();
Dependencies dependencies;
for (const QJsonValueConstRef &dependencyVal : dependenciesArray) {
const QJsonObject dependencyObj = dependencyVal.toObject();
const QJsonObject metaDataObj = dependencyObj.value("meta_data").toObject();
dependencies.append({
.id = metaDataObj.value("Id").toString(),
.version = metaDataObj.value("Version").toString(),
});
}
return dependencies;
}
static Plugin pluginFromJson(const QJsonObject &obj)
{
const QJsonObject metaDataObj = obj.value("meta_data").toObject();
return {
.copyright = metaDataObj.value("Copyright").toString(),
.dependencies = dependenciesFromJson(metaDataObj),
.isInternal = obj.value("is_internal").toBool(false),
.id = metaDataObj.value("Id").toString(),
.name = metaDataObj.value("Name").toString(),
.packageUrl = obj.value("url").toString(),
.vendor = metaDataObj.value("Vendor").toString(),
.version = metaDataObj.value("Version").toString(),
};
}
static Description descriptionFromJson(const QJsonObject &obj)
{
TextData descriptionText;
const QJsonArray paragraphsArray = obj.value("paragraphs").toArray();
for (const QJsonValueConstRef &paragraphVal : paragraphsArray) {
const QJsonObject &paragraphObj = paragraphVal.toObject();
const QJsonArray &textArray = paragraphObj.value("text").toArray();
QStringList textLines;
for (const QJsonValueConstRef &textVal : textArray)
textLines.append(textVal.toString());
descriptionText.append({
paragraphObj.value("header").toString(),
textLines,
});
}
LinksData links;
const QJsonArray linksArray = obj.value("links").toArray();
for (const QJsonValueConstRef &linkVal : linksArray) {
const QJsonObject &linkObj = linkVal.toObject();
links.append({
linkObj.value("link_text").toString(),
linkObj.value("url").toString(),
});
}
ImagesData images;
const QJsonArray imagesArray = obj.value("images").toArray();
for (const QJsonValueConstRef &imageVal : imagesArray) {
const QJsonObject &imageObj = imageVal.toObject();
images.append({
imageObj.value("image_label").toString(),
imageObj.value("url").toString(),
});
}
const Description description = {
.images = images,
.links = links,
.text = descriptionText,
};
return description;
}
static Extension extensionFromJson(const QJsonObject &obj)
{
Plugins plugins;
const QJsonArray pluginsArray = obj.value("plugins").toArray();
for (const QJsonValueConstRef &pluginVal : pluginsArray)
plugins.append(pluginFromJson(pluginVal.toObject()));
QStringList tags;
const QJsonArray tagsArray = obj.value("tags").toArray();
for (const QJsonValueConstRef &tagVal : tagsArray)
tags.append(tagVal.toString());
QStringList platforms;
const QJsonArray platformsArray = obj.value("platforms").toArray();
for (const QJsonValueConstRef &platformsVal : platformsArray)
platforms.append(platformsVal.toString());
const QJsonObject descriptionObj = obj.value("description").toObject();
const Description description = descriptionFromJson(descriptionObj);
const Extension extension = {
.compatVersion = obj.value("compatibility").toString(),
.copyright = obj.value("copyright").toString(),
.description = description,
.downloadCount = obj.value("download_count").toInt(-1),
.id = obj.value("id").toString(),
.license = obj.value("license").toString(),
.name = obj.value("name").toString(),
.platforms = platforms,
.plugins = plugins,
.size = obj.value("total_size").toInteger(),
.tags = tags,
.type = obj.value("is_pack").toBool(true) ? ItemTypePack : ItemTypeExtension,
.vendor = obj.value("vendor").toString(),
.vendorId = obj.value("vendor_id").toString(),
.version = obj.value("version").toString(),
};
return extension;
}
static Extensions parseExtensionsRepoReply(const QByteArray &jsonData)
{
// https://qc-extensions.qt.io/api-docs
Extensions parsedExtensions;
const QJsonObject jsonObj = QJsonDocument::fromJson(jsonData).object();
const QJsonArray items = jsonObj.value("items").toArray();
for (const QJsonValueConstRef &itemVal : items) {
const QJsonObject itemObj = itemVal.toObject();
const Extension extension = extensionFromJson(itemObj);
parsedExtensions.append(extension);
}
return parsedExtensions;
}
static Extension extensionFromPluginSpec(const PluginSpec *pluginSpec)
{
const Dependencies dependencies
= transform(pluginSpec->dependencies(), [](const PluginDependency &pd) -> Dependency {
return {
.id = pd.id,
.version = pd.version,
};
});
const Plugin plugin = {
.copyright = pluginSpec->copyright(),
.dependencies = dependencies,
.id = pluginSpec->id(),
.name = pluginSpec->name(),
.packageUrl = {},
.vendor = pluginSpec->vendor(),
.version = pluginSpec->version(),
};
const QStringList lines = pluginSpec->description().split('\n')
+ pluginSpec->longDescription().split('\n');
const TextData text = {{pluginSpec->name(), lines}};
LinksData links;
if (const QString url = pluginSpec->url(); !url.isEmpty())
links.append({{}, url});
if (const QString docUrl = pluginSpec->documentationUrl(); !docUrl.isEmpty())
links.append({{Tr::tr("Documentation")}, docUrl});
const Description description = {
.images = {},
.links = links,
.text = text,
};
const QString platformsPattern = pluginSpec->platformSpecification().pattern();
const QStringList platforms = platformsPattern.isEmpty()
? QStringList({"macOS", "Windows", "Linux"})
: QStringList(platformsPattern);
const Extension extension = {
.compatVersion = pluginSpec->compatVersion(),
.copyright = pluginSpec->copyright(),
.description = description,
.id = pluginSpec->id(),
.license = pluginSpec->license(),
.name = pluginSpec->name(),
.platforms = platforms,
.plugins = {plugin},
.tags = {},
.type = ItemTypeExtension,
.vendor = pluginSpec->vendor(),
.vendorId = pluginSpec->vendorId(),
.version = pluginSpec->version(),
};
return extension;
}
class ExtensionsModelPrivate
{
public:
void setExtensions(const Extensions &extensions);
void addUnlistedLocalExtensions();
void addUnlistedLocalPlugins();
Extensions extensions;
static QVariant dataFromRemotePack(const QJsonObject &json, int role);
static QVariant dataFromRemotePlugin(const QJsonObject &json, int role);
QVariant dataFromRemoteExtension(int index, int role) const;
QVariant dataFromLocalPlugin(int index, int role) const;
QJsonArray responseItems;
PluginSpecs localPlugins;
};
void ExtensionsModelPrivate::setExtensions(const Extensions &extensions)
void ExtensionsModelPrivate::addUnlistedLocalPlugins()
{
this->extensions = extensions;
qCDebug(modelLog) << "Number of extensions from JSON:" << this->extensions.count();
addUnlistedLocalExtensions();
qCDebug(modelLog) << "Number of extensions with added local ones:" << this->extensions.count();
QStringList responseExtensions;
for (const QJsonValueConstRef &responseItem : responseItems)
responseExtensions << responseItem.toObject().value("id").toString();
localPlugins.clear();
for (PluginSpec *plugin : PluginManager::plugins())
if (!responseExtensions.contains(plugin->id()))
localPlugins.append(plugin);
qCDebug(modelLog) << "Number of extensions from JSON:" << responseExtensions.count();
qCDebug(modelLog) << "Number of added local plugins:" << localPlugins.count();
}
void ExtensionsModelPrivate::addUnlistedLocalExtensions()
QString joinedStringList(const QJsonValue &json)
{
const QStringList listedModelExtensions = transform(extensions, &Extension::name);
for (const PluginSpec *plugin : PluginManager::plugins())
if (!listedModelExtensions.contains(plugin->name()))
extensions.append(extensionFromPluginSpec(plugin));
if (json.isArray()) {
const QStringList lines = json.toVariant().toStringList();
return lines.join("\n");
}
return json.toString();
}
QString descriptionWithLinks(const QString &description, const QString &url,
const QString &documentationUrl)
{
QStringList fragments;
const QString mdLink("[%1](%2)");
if (!url.isEmpty())
fragments.append(mdLink.arg(url).arg(url));
if (!documentationUrl.isEmpty())
fragments.append(mdLink.arg(Tr::tr("Documentation")).arg(documentationUrl));
if (!fragments.isEmpty())
fragments.prepend("### " + Tr::tr("More Information"));
fragments.prepend(description);
return fragments.join("\n\n");
}
QVariant ExtensionsModelPrivate::dataFromRemotePack(const QJsonObject &json, int role)
{
switch (role) {
case RoleDescriptionLong:
return joinedStringList(json.value("long_description"));
case RoleDescriptionShort:
return joinedStringList(json.value("description"));
case RoleItemType:
return ItemTypePack;
case RolePlugins:
return json.value("plugins").toVariant().toStringList();
default:
break;
}
return {};
}
QVariant ExtensionsModelPrivate::dataFromRemotePlugin(const QJsonObject &json, int role)
{
const QJsonObject metaData = json.value("metadata").toObject();
switch (role) {
case RoleCopyright:
return metaData.value("Copyright");
case RoleDownloadUrl: {
const QJsonArray sources = json.value("sources").toArray();
const QString thisPlatform = customOsTypeToString(HostOsInfo::hostOs());
const QString thisArch = QSysInfo::currentCpuArchitecture();
for (const QJsonValue source : sources) {
const QJsonObject sourceObject = source.toObject();
const QJsonObject platform = sourceObject.value("platform").toObject();
if (platform.isEmpty() // Might be a Lua plugin
|| (platform.value("name").toString() == thisPlatform
&& platform.value("architecture") == thisArch))
return sourceObject.value("url").toString();
}
break;
}
case RoleItemType:
return ItemTypeExtension;
case RoleDescriptionLong: {
const QString description = joinedStringList(metaData.value("LongDescription"));
const QString url = metaData.value("Url").toString();
const QString documentationUrl = metaData.value("DocumentationUrl").toString();
return descriptionWithLinks(description, url, documentationUrl);
}
case RoleDescriptionShort:
return joinedStringList(metaData.value("Description"));
default:
break;
}
return {};
}
QVariant ExtensionsModelPrivate::dataFromRemoteExtension(int index, int role) const
{
const QJsonObject json = responseItems.at(index).toObject();
switch (role) {
case Qt::DisplayRole:
case RoleName:
return json.value("display_name");
case RoleDownloadCount:
return json.value("downloads");
case RoleId:
return json.value(EXTENSION_KEY_ID);
case RoleTags:
return json.value("tags").toVariant().toStringList();
case RoleVendor:
return json.value("display_vendor");
default:
break;
}
const QJsonObject pluginObject = json.value("plugin").toObject();
if (!pluginObject.isEmpty())
return dataFromRemotePlugin(pluginObject, role);
const QJsonObject packObject = json.value("pack").toObject();
if (!packObject.isEmpty())
return dataFromRemotePack(packObject, role);
return {};
}
QVariant ExtensionsModelPrivate::dataFromLocalPlugin(int index, int role) const
{
const PluginSpec *pluginSpec = localPlugins.at(index);
switch (role) {
case Qt::DisplayRole:
case RoleName:
return pluginSpec->displayName();
case RoleCopyright:
return pluginSpec->copyright();
case RoleDependencies: {
const QStringList dependencies
= transform(pluginSpec->dependencies(), &PluginDependency::id);
return dependencies;
}
case RoleDescriptionLong:
return descriptionWithLinks(pluginSpec->longDescription(), pluginSpec->url(),
pluginSpec->documentationUrl());
case RoleDescriptionShort:
return pluginSpec->description();
case RoleId:
return pluginSpec->id();
case RoleItemType:
return ItemTypeExtension;
case RolePlatforms: {
const QString platformsPattern = pluginSpec->platformSpecification().pattern();
const QStringList platforms = platformsPattern.isEmpty()
? QStringList({customOsTypeToString(OsTypeMac),
customOsTypeToString(OsTypeWindows),
customOsTypeToString(OsTypeLinux)})
: QStringList(platformsPattern);
return platforms;
}
case RoleVendor:
return pluginSpec->vendor();
case RoleVendorId:
return pluginSpec->vendorId();
default:
break;
}
return {};
}
ExtensionsModel::ExtensionsModel(QObject *parent)
@@ -302,77 +228,7 @@ ExtensionsModel::~ExtensionsModel()
int ExtensionsModel::rowCount([[maybe_unused]] const QModelIndex &parent) const
{
return d->extensions.count();
}
static QStringList dependenciesFromExtension(const Extension &extension)
{
QStringList dependencies;
for (const Plugin &plugin : extension.plugins) {
for (const Dependency &dependency : plugin.dependencies) {
const QString withVersion
= QString::fromLatin1("%1 (%2)").arg(dependency.id).arg(dependency.version);
dependencies.append(withVersion);
}
}
dependencies.sort();
dependencies.removeDuplicates();
return dependencies;
}
static QVariant dataFromExtension(const Extension &extension, int role)
{
switch (role) {
case Qt::DisplayRole:
case RoleName:
return Utils::findOr(
QStringList{extension.name, extension.id},
"No name found",
std::not_fn(&QString::isEmpty));
case RoleCompatVersion:
return extension.compatVersion;
case RoleCopyright:
return !extension.copyright.isEmpty() ? extension.copyright : QVariant();
case RoleDependencies:
return dependenciesFromExtension(extension);
case RoleDescriptionImages:
return QVariant::fromValue(extension.description.images);
case RoleDescriptionLinks:
return QVariant::fromValue(extension.description.links);
case RoleDescriptionText:
return QVariant::fromValue(extension.description.text);
case RoleDownloadCount:
return extension.downloadCount;
case RoleId:
return extension.id;
case RoleItemType:
return extension.type;
case RoleLicense:
return extension.license;
case RoleLocation:
break;
case RolePlatforms:
return extension.platforms;
case RolePlugins: {
PluginsData plugins;
for (const Plugin &plugin : extension.plugins)
plugins.append(qMakePair(plugin.id, plugin.packageUrl));
return QVariant::fromValue(plugins);
}
case RoleSize:
return extension.size;
case RoleTags:
return extension.tags;
case RoleVendor:
return !extension.vendor.isEmpty() ? extension.vendor : QVariant();
case RoleVersion:
return !extension.version.isEmpty() ? extension.version : QVariant();
case RoleVendorId:
return extension.vendorId;
default:
break;
}
return {};
return d->responseItems.count() + d->localPlugins.count();
}
ExtensionState extensionState(const QModelIndex &index)
@@ -392,10 +248,8 @@ static QString searchText(const QModelIndex &index)
QStringList searchTexts;
searchTexts.append(index.data(RoleName).toString());
searchTexts.append(index.data(RoleTags).toStringList());
for (const auto &data : index.data(RoleDescriptionText).value<TextData>()) {
searchTexts.append(data.first);
searchTexts.append(data.second);
}
searchTexts.append(index.data(RoleDescriptionShort).toString());
searchTexts.append(index.data(RoleDescriptionLong).toString());
searchTexts.append(index.data(RoleVendor).toString());
return searchTexts.join(" ");
}
@@ -407,28 +261,56 @@ QVariant ExtensionsModel::data(const QModelIndex &index, int role) const
if (role == RoleSearchText)
return searchText(index);
const Extension &extension = d->extensions.at(index.row());
const QVariant extensionData = dataFromExtension(extension, role);
// If data is unavailable, retrieve it from the first contained plugin
if (extensionData.isNull() && !extension.plugins.isEmpty()) {
const QString firstPluginId = extension.plugins.constFirst().id;
const Extension firstPluginExtension
= findOrDefault(d->extensions, Utils::equal(&Extension::id, firstPluginId));
if (firstPluginExtension.name.isEmpty())
return {};
return dataFromExtension(firstPluginExtension, role);
const bool isRemoteExtension = index.row() < d->responseItems.count();
const int itemIndex = index.row() - (isRemoteExtension ? 0 : d->responseItems.count());
return isRemoteExtension ? d->dataFromRemoteExtension(itemIndex, role)
: d->dataFromLocalPlugin(itemIndex, role);
}
return extensionData;
QModelIndex ExtensionsModel::indexOfId(const QString &extensionId) const
{
const int localIndex = indexOf(d->localPlugins, equal(&PluginSpec::id, extensionId));
if (localIndex >= 0)
return index(d->responseItems.count() + localIndex);
for (int remoteIndex = 0; const QJsonValueConstRef vlaue : d->responseItems) {
if (vlaue.toObject().value(EXTENSION_KEY_ID) == extensionId)
return index(remoteIndex);
++remoteIndex;
}
return {};
}
void ExtensionsModel::setExtensionsJson(const QByteArray &json)
{
const Extensions extensions = parseExtensionsRepoReply(json);
beginResetModel();
d->setExtensions(extensions);
QJsonParseError error;
const QJsonObject jsonObj = QJsonDocument::fromJson(json, &error).object();
qCDebug(modelLog) << "QJsonParseError:" << error.errorString();
d->responseItems = jsonObj.value("items").toArray();
d->addUnlistedLocalPlugins();
endResetModel();
}
QString customOsTypeToString(OsType osType)
{
switch (osType) {
case OsTypeWindows:
return "Windows";
case OsTypeLinux:
return "Linux";
case OsTypeMac:
return "macOS";
case OsTypeOtherUnix:
return "Other Unix";
case OsTypeOther:
default:
return "Other";
}
}
PluginSpec *pluginSpecForId(const QString &pluginId)
{
return findOrDefault(PluginManager::plugins(), equal(&PluginSpec::id, pluginId));

View File

@@ -3,6 +3,8 @@
#pragma once
#include <utils/osspecificaspects.h>
#include <QAbstractListModel>
namespace ExtensionSystem {
@@ -11,13 +13,6 @@ class PluginSpec;
namespace ExtensionManager::Internal {
using QPairList = QList<QPair<QString, QString> >;
using ImagesData = QPairList; // { <caption, url>, ... }
using LinksData = QPairList; // { <name, url>, ... }
using PluginsData = QPairList; // { <name, url>, ... }
using TextData = QList<QPair<QString, QStringList> >; // { <header, text>, ... }
enum ItemType {
ItemTypePack,
ItemTypeExtension,
@@ -32,22 +27,19 @@ enum ExtensionState {
enum Role {
RoleName = Qt::UserRole,
RoleCompatVersion,
RoleCopyright,
RoleDependencies,
RoleDescriptionImages,
RoleDescriptionLinks,
RoleDescriptionText,
RoleDescriptionLong,
RoleDescriptionShort,
RoleDownloadCount,
RoleDownloadUrl,
RoleExtensionState,
RoleId,
RoleItemType,
RoleLicense,
RoleLocation,
RolePlatforms,
RolePlugins,
RoleSearchText,
RoleSize,
RoleTags,
RoleVendor,
RoleVendorId,
@@ -63,12 +55,14 @@ public:
int rowCount(const QModelIndex &parent = {}) const;
QVariant data(const QModelIndex &index, int role) const;
QModelIndex indexOfId(const QString &extensionId) const;
void setExtensionsJson(const QByteArray &json);
private:
class ExtensionsModelPrivate *d = nullptr;
};
QString customOsTypeToString(Utils::OsType osType);
ExtensionSystem::PluginSpec *pluginSpecForId(const QString &pluginId);
#ifdef WITH_TESTS
@@ -76,6 +70,3 @@ QObject *createExtensionsModelTest();
#endif
} // ExtensionManager::Internal
Q_DECLARE_METATYPE(ExtensionManager::Internal::QPairList)
Q_DECLARE_METATYPE(ExtensionManager::Internal::TextData)

View File

@@ -1,71 +0,0 @@
{
"items": [
{
"name": "ScreenRecorder",
"description": {
"paragraphs": [
{
"header": "Screen Recorder plugin",
"text": [
"With FFmpeg, you can record your screens and save the recordings as animated images or videos.",
"To record screens:",
"",
"- Select Tools > Screen Recording.",
"- Select to select the screen to record from and to set the recorded screen area.",
"- Select to start recording.",
"- Select when you are done recording.",
"- Select Crop and Trim to edit the recording.",
"- Select Export to save the recording as an animated image or a video."
]
},
{
"header": "Set the screen and area to record",
"text": [
"Set the screen and the area to record in the Screen Recording Options dialog.",
"To select a screen and area:",
"",
"- In Display, select the display to record.",
"- In Recorded screen area, drag the guides to set the x and y coordinates of the starting point for the recording area, as well as the width and height of the area.",
"- Select OK to return to the Record Screen dialog."
]
}
],
"images": [
{
"image_label": "Create animated imges like this",
"url": "https://bugreports.qt.io/secure/attachment/156058/156058_DragAndCopyOnLinux.gif"
}
],
"links": [
{
"link_text": "Documentation",
"url": "https://doc.qt.io/qtcreator/creator-how-to-record-screens.html"
},
{
"link_text": "Homepage",
"url": "https://www.qt.io/"
}
]
},
"is_pack": false,
"plugins": [
{
"meta_data": {
"Name": "ScreenRecorder",
"Dependencies": [
{
"meta_data": {
"Name": "Core",
"Version": "13.0.2"
}
}
],
"Version": "13.0.2"
}
}
],
"tags": [ "Utility", "Docs" ],
"vendor": "The Qt Company Ltd"
}
]
}

View File

@@ -1,126 +1,114 @@
{
"items": [
{
"name": "Essentials",
"id": "essentials",
"display_name": "Essentials",
"tags": [ "Essentials" ],
"platforms": [ "macOS", "Windows", "Linux" ],
"license": "os",
"is_pack": true,
"description": {
"paragraphs": [
{
"text": [
"Basic services, such as editing and debugging code, viewing images, and adding resources to applications."
"license": "open-source",
"vendor_id": "theqtcompany",
"display_vendor": "The Qt Company",
"version": "14.0.2",
"pack": {
"description": "Get started",
"long_description": [
"Basic services, such as editing and debugging code, viewing images, and adding resources to applications.",
"",
"Online documentation: https://doc.qt.io/qtcreator/creator-coding-navigating.html"
],
"header": "Get started"
}
],
"links": [
{
"url": "https://doc.qt.io/qtcreator/creator-coding-navigating.html",
"link_text": "Online documentation"
}
]
},
"plugins": [
{ "meta_data": { "Name": "BinEditor" } },
{ "meta_data": { "Name": "Debugger" } },
{ "meta_data": { "Name": "DiffEditor" } },
{ "meta_data": { "Name": "ImageViewer" } },
{ "meta_data": { "Name": "Macros" } },
{ "meta_data": { "Name": "LanguageClient" } },
{ "meta_data": { "Name": "ResourceEditor" } }
"bineditor",
"debugger",
"diffeditor",
"imageviewer",
"macros",
"languageclient",
"resourceeditor"
]
}
},
{
"name": "C++ Support",
"id": "cppsupport",
"display_name": "C++ Support",
"tags": [ "Programming Language", "C++" ],
"platforms": [ "macOS", "Windows", "Linux" ],
"license": "os",
"is_pack": true,
"description": {
"paragraphs": [
{
"text": [
"license": "open-source",
"vendor_id": "theqtcompany",
"display_vendor": "The Qt Company",
"version": "14.0.2",
"pack": {
"description": "Get started",
"long_description": [
"Tools for developing Qt C++ applications."
],
"header": "Get started"
}
]
},
"plugins": [
{ "meta_data": { "Name": "ClangCodeModel" } },
{ "meta_data": { "Name": "ClangFormat" } },
{ "meta_data": { "Name": "ClassView" } },
{ "meta_data": { "Name": "CppEditor" } }
"clangcodemodel",
"clangformat",
"classview",
"cppeditor"
]
}
},
{
"name": "QML Support",
"id": "qmlsupport",
"display_name": "QML Support",
"tags": [ "Programming Language", "QML" ],
"platforms": [ "macOS", "Windows", "Linux" ],
"license": "os",
"is_pack": true,
"description": {
"paragraphs": [
{
"text": [
"license": "open-source",
"vendor_id": "theqtcompany",
"display_vendor": "The Qt Company",
"version": "14.0.2",
"pack": {
"description": "Get started",
"long_description": [
"Tools for developing Qt Quick applications."
],
"header": "Get started"
}
]
},
"plugins": [
{ "meta_data": { "Name": "QmlJSEditor" } },
{ "meta_data": { "Name": "QmlJSTools" } },
{ "meta_data": { "Name": "QmlPreview" } },
{ "meta_data": { "Name": "QmlProfiler" } }
"qmljseditor",
"qmljstools",
"qmlpreview",
"qmlprofiler"
]
}
},
{
"name": "Visual QML Editor",
"id": "visualqmleditor",
"display_name": "Visual QML Editor",
"tags": [ "Visual UI editor", "qml", "Quick" ],
"platforms": [ "macOS", "Windows", "Linux" ],
"license": "os",
"is_pack": true,
"description": {
"paragraphs": [
{
"text": [
"license": "open-source",
"vendor_id": "theqtcompany",
"display_vendor": "The Qt Company",
"version": "14.0.2",
"pack": {
"description": "Get started",
"long_description": [
"Tools for creating Qt Quick UIs."
],
"header": "Get started"
}
]
},
"plugins": [
{ "meta_data": { "Name": "QmlDesigner" } }
"qmldesigner"
]
}
},
{
"name": "Visual Widget Editor",
"id": "visualwidgeteditor",
"display_name": "Visual Widget Editor",
"tags": [ "Visual UI editor", "C++", "Widgets" ],
"platforms": [ "macOS", "Windows", "Linux" ],
"license": "os",
"is_pack": true,
"description": {
"paragraphs": [
{
"text": [
"license": "open-source",
"vendor_id": "theqtcompany",
"display_vendor": "The Qt Company",
"version": "14.0.2",
"pack": {
"description": "Get started",
"long_description": [
"Visual tool for creating Qt widget-based UIs."
],
"header": "Get started"
}
]
},
"plugins": [
{ "meta_data": { "Name": "Designer" } }
"designer"
]
}
}
]
}

View File

@@ -1,38 +1,49 @@
{
"items": [
{
"name": "SpellChecker",
"id": "spellchecker",
"display_name": "SpellChecker",
"tags": [ "Editor" ],
"platforms": [ "macOS", "Windows", "Linux" ],
"license": "os",
"is_pack": false,
"description": {
"paragraphs": [
{
"text": [
"license": "open-source",
"vendor_id": "carelcombrink",
"display_vendor": "Carel Combrink",
"downloads": 2333,
"plugin": {
"metadata": {
"Copyright": "(C) 2015 - 2024 Carel Combrink",
"Description": "Foo",
"LongDescription": [
"Spellcheck comments in source files."
],
"header": "Get started"
}
],
"links": [
{
"url": "https://github.com/CJCombrink/SpellChecker-Plugin",
"link_text": "GitHub page"
}
]
"Url" : "https://github.com/CJCombrink/SpellChecker-Plugin",
"DocumentationUrl" : "https://github.com/CJCombrink/SpellChecker-Plugin?tab=readme-ov-file#spellchecker-plugin"
},
"plugins": [
"sources": [
{
"meta_data": {
"Name": "SpellChecker",
"Copyright": "(C) 2015 - 2024 Carel Combrink"
},
"url": "https://github.com/CJCombrink/SpellChecker-Plugin/releases/download/v3.6.0/SpellChecker-Plugin_QtC13.0.0_macos_x64.tar.gz"
"url": "https://github.com/CJCombrink/SpellChecker-Plugin/releases/download/v3.7.0/SpellChecker-Plugin_QtC14.0.0_linux_x64.tar.gz",
"platform": {
"name": "Linux",
"architecture": "x86_64"
}
},
{
"url": "https://github.com/CJCombrink/SpellChecker-Plugin/releases/download/v3.7.0/SpellChecker-Plugin_QtC14.0.0_macos_x64.tar.gz",
"platform": {
"name": "macOS",
"architecture": "x86_64"
}
},
{
"url": "https://github.com/CJCombrink/SpellChecker-Plugin/releases/download/v3.7.0/SpellChecker-Plugin_QtC14.0.0_win64_x64.zip",
"platform": {
"name": "Windows",
"architecture": "x86_64"
}
],
"vendor": "Carel Combrink",
"copyright": "(C) 2015 - 2024 Carel Combrink"
}
]
}
}
]
}

View File

@@ -1,76 +0,0 @@
{
"items": [
{
"name": "Few tags",
"tags": [ "Tag one", "Tag two"]
},
{
"name": "Many tags",
"tags": [ "Tag_01", "Tag_02", "Tag_03", "Tag_04", "Tag_05", "Tag_06", "Tag_07", "Tag_08", "Tag_09", "Tag_10", "Tag_11", "Tag_12", "Tag_13", "Tag_14", "Tag_15", "Tag_16", "Tag_17", "Tag_18", "Tag_19", "Tag_20", "Tag_21", "Tag_22", "Tag_23", "Tag_24", "Tag_25", "Tag_26", "Tag_27", "Tag_28", "Tag_29", "Tag_30", "And_a_very_long_tag_without_spaces", "Ok, a last long tag without spaces, but that sgould be enough"],
"description": {
"paragraphs": [
{
"text": [
"... and a few long ones"
]
}
]
}
},
{
"name": "One static image",
"description": {
"paragraphs": [
{
"text": [
"png"
]
}
],
"images": [
{
"image_label": "Screenshot",
"url": "https://bugreports.qt.io/secure/attachment/147354/VirtualNodesShownAsNotExisting.png"
}
]
}
},
{
"name": "One animated image",
"description": {
"paragraphs": [
{
"text": [
"gif (animated)"
]
}
],
"images": [
{
"image_label": "Screencast",
"url": "https://bugreports.qt.io/secure/attachment/156058/156058_DragAndCopyOnLinux.gif"
}
]
}
},
{
"name": "Vendor, no download count",
"vendor": "Vendor name"
},
{
"name": "No vendor, but download count",
"download_count": 12345
},
{
"name": "Vendor and download count",
"vendor": "Vendor name",
"download_count": 12345
}
]
}