diff --git a/include/uibase/exceptions.h b/include/uibase/exceptions.h index c89a4833..b6425675 100644 --- a/include/uibase/exceptions.h +++ b/include/uibase/exceptions.h @@ -20,6 +20,8 @@ namespace MOBase class QDLLEXPORT Exception : public std::exception { public: + Exception(const char* text) : m_Message(text) {} + Exception(const std::string& text) : m_Message(QByteArray::fromStdString(text)) {} Exception(const QString& text) : m_Message(text.toUtf8()) {} virtual const char* what() const noexcept override { return m_Message.constData(); } diff --git a/include/uibase/extensions/extension.h b/include/uibase/extensions/extension.h new file mode 100644 index 00000000..5bcc072c --- /dev/null +++ b/include/uibase/extensions/extension.h @@ -0,0 +1,291 @@ +#ifndef UIBASE_EXTENSION_H +#define UIBASE_EXTENSION_H + +#include +#include +#include + +#include +#include + +#include "../dllimport.h" +#include "../iplugingame.h" +#include "../versioning.h" +#include "requirements.h" +#include "theme.h" +#include "translation.h" + +namespace MOBase +{ +class IExtension; + +class InvalidExtensionMetaDataException : public Exception +{ +public: + using Exception::Exception; +}; + +enum class ExtensionType +{ + THEME, + TRANSLATION, + PLUGIN, + GAME +}; + +class QDLLEXPORT ExtensionContributor +{ +public: + ExtensionContributor(QString name, QString homepage); + + // retrieve the name of the contributor + // + const auto& name() const { return m_Name; } + + // retrieve the homagepage of the contributor + // + const auto& homepage() const { return m_Homepage; } + +private: + ExtensionContributor() = default; + + friend class ExtensionMetaData; + + QString m_Name, m_Homepage; +}; + +class QDLLEXPORT ExtensionMetaData +{ +public: + // retrieve the identifier of the extension + // + const auto& identifier() const { return m_Identifier; } + + // retrieve the name of the extension + // + auto name() const { return localized(m_Name); } + + // retrieve the author of the extension if set + // + const auto& author() const { return m_Author; } + + // retrieve the list of contributors of the extension + // + const auto& contributors() const { return m_Contributors; } + + // retrieve the type of the extension + // + auto type() const { return m_Type; } + + // retrieve the description of the extension + // + auto description() const { return localized(m_Description); } + + // retrieve the icon for the extension (might be an empty icon) + // + const auto& icon() const { return m_Icon; } + + // retrieve the version of the extension + // + const auto& version() const { return m_Version; } + + // retrieve the requirements of the extension + // + const auto& requirements() const { return m_Requirements; } + + // retrieve the raw JSON metadata, this is mostly useful for specific extension type + // to extract custom parts + // + const auto& json() const { return m_JsonData; } + + // retrieve the content objects of the extension + QJsonObject content() const; + +protected: + ExtensionMetaData(std::filesystem::path const& path, const QJsonObject& jsonData); + +private: + friend class ExtensionFactory; + + constexpr static const char* DEFAULT_TRANSLATIONS_FOLDER = "translations"; + constexpr static const char* DEFAULT_STYLESHEET_PATH = "stylesheets"; + + std::optional parseType(QString const& value) const; + +private: + QJsonObject m_JsonData; + QString m_TranslationContext; + + QString m_Identifier; + QString m_Name; + ExtensionContributor m_Author; + std::vector m_Contributors; + ExtensionType m_Type; + QString m_Description; + QIcon m_Icon; + Version m_Version; + std::vector m_Requirements; + + std::filesystem::path m_TranslationFilesPrefix; + std::filesystem::path m_StyleSheetFilePath; + + QString localized(QString const& value) const; +}; + +class QDLLEXPORT IExtension +{ +public: + // retrieve the folder containing the extension + // + std::filesystem::path directory() const { return m_Path; } + + // retrieve the metadata of this extension + // + const auto& metadata() const { return m_MetaData; } + +public: + virtual ~IExtension() {} + IExtension& operator=(const IExtension&) = delete; + +protected: + IExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata); + +public: + IExtension(const IExtension&) = default; + +private: + std::filesystem::path m_Path; + ExtensionMetaData m_MetaData; +}; + +// factory for extensions +// +class QDLLEXPORT ExtensionFactory +{ +public: + // load metadata from the given file, throws InvalidExtensionMetaDataException if the + // file does not exist or is invalid + // + static ExtensionMetaData loadMetaData(std::filesystem::path const& path); + + // load an extension from the given directory, return a null-pointer if the extension + // could not be load + // + static std::unique_ptr + loadExtension(std::filesystem::path const& directory); + +private: + // load an extension from the given directory + // + static std::unique_ptr + loadExtension(std::filesystem::path const& directory, ExtensionMetaData&& metadata); +}; + +// theme extension that provides one or more base themes for MO2 +// +class QDLLEXPORT ThemeExtension : public IExtension +{ +public: + // retrieve the list of themes provided by this extension + // + const auto& themes() const { return m_Themes; } + +private: + ThemeExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata, + std::vector> themes); + + friend class ExtensionFactory; + static std::unique_ptr + loadExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata); + + static std::shared_ptr + parseTheme(std::filesystem::path const& extensionFolder, const QString& identifier, + const QJsonObject& jsonTheme); + +private: + std::vector> m_Themes; +}; + +// translation extension that provides one or more base translations for mo@ +// +class QDLLEXPORT TranslationExtension : public IExtension +{ +public: + // retrieve the list of translations provided by this extension + // + const auto& translations() const { return m_Translations; } + +private: + TranslationExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata, + std::vector> translations); + + friend class ExtensionFactory; + static std::unique_ptr + loadExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata); + + static std::shared_ptr + parseTranslation(std::filesystem::path const& extensionFolder, + const QString& identifier, const QJsonObject& jsonTranslation); + +private: + std::vector> m_Translations; +}; + +// plugin extension that provides one or more plugins for MO2, alongside theme or +// translation additions +// +class QDLLEXPORT PluginExtension : public IExtension +{ +public: + using IExtension::IExtension; + + // auto-detect plugins + // + bool autodetect() const { return m_AutoDetect; } + + // list of specified plugins + // + const auto& plugins() const { return m_Plugins; } + + const auto& themeAdditions() const { return m_ThemeAdditions; } + const auto& translationAdditions() const { return m_TranslationAdditions; } + +protected: + PluginExtension( + std::filesystem::path const& path, ExtensionMetaData&& metadata, bool autodetect, + std::map plugins, + std::vector> themeAdditions, + std::vector> translationAdditions); + + friend class ExtensionFactory; + static std::unique_ptr + loadExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata); + +private: + // auto-detect plugins + bool m_AutoDetect; + + // forced plugins + std::map m_Plugins; + + // theme and translations additions + std::vector> m_ThemeAdditions; + std::vector> m_TranslationAdditions; +}; + +// game extension that provides a game plugin, alongside other plugins, translation or +// theme (additions) +// +class QDLLEXPORT GameExtension : public PluginExtension +{ +private: + GameExtension(PluginExtension&& pluginExtension); + + friend class ExtensionFactory; + static std::unique_ptr loadExtension(std::filesystem::path const& path, + ExtensionMetaData&& metadata); +}; + +} // namespace MOBase + +#endif diff --git a/include/uibase/extensions/extensionsetting.h b/include/uibase/extensions/extensionsetting.h new file mode 100644 index 00000000..cd1ee7f6 --- /dev/null +++ b/include/uibase/extensions/extensionsetting.h @@ -0,0 +1,89 @@ +#pragma once + +#include +#include + +namespace MOBase +{ + +// class representing a group of settings +// +class SettingGroup +{ +public: + SettingGroup(QString const& name, QString const& title, QString const& description) + : m_Name{name}, m_Title{title}, m_Description{description} + {} + + // return the (internal) name of this group, localization independent + // + const auto& name() const { return m_Name; } + + // retrieve the title of this group, can be localized + // + const auto& title() const { return m_Title; } + + // retrieve the description of this group, can be localized + // + const auto& description() const { return m_Description; } + +private: + QString m_Name, m_Title, m_Description; +}; + +// class that represents an extension or a plugin setting +// +class Setting +{ +public: + // deprecated constructor that was previously available as PluginSettin + // + [[deprecated]] Setting(const QString& name, const QString& description, + const QVariant& defaultValue) + : m_Name{name}, m_Title{name}, m_Description{description}, m_Group{}, + m_DefaultValue{defaultValue} + {} + + Setting(const QString& name, const QString& title, const QString& description, + const QVariant& defaultValue) + : m_Name{name}, m_Title{title}, m_Description{description}, m_Group{}, + m_DefaultValue{defaultValue} + {} + + Setting(const QString& name, const QString& title, const QString& description, + const QString& group, const QVariant& defaultValue) + : m_Name{name}, m_Title{title}, m_Description{description}, m_Group{group}, + m_DefaultValue{defaultValue} + {} + +public: + // return the (internal) name of this setting, localization independent + // + const auto& name() const { return m_Name; } + + // retrieve the title of this setting, can be localized + // + const auto& title() const { return m_Title; } + + // retrieve the description of this setting, can be localized + // + const auto& description() const { return m_Description; } + + // retrieve the name of the group this settings belongs to or an empty string if there + // is none + // + const auto& group() const { return m_Group; } + + // retrieve the default value of this setting + // + const auto& defaultValue() const { return m_DefaultValue; } + +private: + QString m_Name; + QString m_Title; + QString m_Description; + QString m_Group; + QVariant m_DefaultValue; +}; + +} // namespace MOBase diff --git a/include/uibase/extensions/iextensionlist.h b/include/uibase/extensions/iextensionlist.h new file mode 100644 index 00000000..952ba16a --- /dev/null +++ b/include/uibase/extensions/iextensionlist.h @@ -0,0 +1,51 @@ +#ifndef UIBASE_IEXTENSIONLIST_H +#define UIBASE_IEXTENSIONLIST_H + +#include + +#include + +namespace MOBase +{ + +class IExtension; + +// interface to the list of extensions in MO2 +// +class IExtensionList +{ +public: + // check if the extension with the given identifier is installed or not + // + virtual bool installed(const QString& identifier) const = 0; + + // check if the extension with the given identifier is installed and enabled + // + virtual bool enabled(const QString& extension) const = 0; + + // check if the extension is enabled or not + // + virtual bool enabled(const IExtension& extension) const = 0; + + // retrieve the extension with the given identifier, throw std::out_of_range if no + // such extension is installed + // + virtual const IExtension& get(QString const& identifier) const = 0; + + // retrieve the installed extension at the given index, throw std::out_of_range if the + // index is out of range + // + virtual const IExtension& at(std::size_t const& index) const = 0; + virtual const IExtension& operator[](std::size_t const& index) const = 0; + + // retrieve the number of installed extensions + // + virtual std::size_t size() const = 0; + +public: + virtual ~IExtensionList() {} +}; + +} // namespace MOBase + +#endif diff --git a/include/uibase/extensions/ipluginloader.h b/include/uibase/extensions/ipluginloader.h new file mode 100644 index 00000000..f18f886a --- /dev/null +++ b/include/uibase/extensions/ipluginloader.h @@ -0,0 +1,65 @@ +/* +Mod Organizer shared UI functionality + +Copyright (C) 2012 Sebastian Herbord. All rights reserved. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 3 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef IPLUGINPROXY_H +#define IPLUGINPROXY_H + +#include +#include +#include + +#include "extension.h" + +namespace MOBase +{ + +class IPluginLoader : public QObject +{ +public: + // initialize the loader, set the error message on failure + // + virtual bool initialize(QString& errorMessage) = 0; + + // extract and load plugins from the given extension + // + // if multiple QObject* corresponds to the same Plugin, they should be returned + // together + // + virtual QList> load(const PluginExtension& extension) = 0; + + // unload plugins from the given extension + // + virtual void unload(const PluginExtension& identifier) = 0; + + // unload all plugins from this loader + // + virtual void unloadAll() = 0; + + virtual ~IPluginLoader() {} + +protected: + IPluginLoader() {} +}; + +} // namespace MOBase + +Q_DECLARE_INTERFACE(MOBase::IPluginLoader, "com.mo2.PluginLoader") + +#endif // IPLUGINPROXY_H diff --git a/include/uibase/extensions/requirements.h b/include/uibase/extensions/requirements.h new file mode 100644 index 00000000..03e333bf --- /dev/null +++ b/include/uibase/extensions/requirements.h @@ -0,0 +1,94 @@ +#ifndef UIBASE_REQUIREMENTS_H +#define UIBASE_REQUIREMENTS_H + +#include + +#include +#include + +#include "../dllimport.h" +#include "../exceptions.h" + +namespace MOBase +{ +class IOrganizer; +class ExtensionMetaData; + +class InvalidRequirementException : public Exception +{ +public: + using Exception::Exception; +}; + +class InvalidRequirementsException : public Exception +{ +public: + using Exception::Exception; +}; + +class ExtensionRequirementImpl; + +// extension requirements +// +class QDLLEXPORT ExtensionRequirement +{ +public: + // type of requirement + // + enum class Type + { + // requirement on the version of MO2, might be ignored by user + // + VERSION, + + // require a specific game, cannot be ignore + // + GAME, + + // require another extension, cannot be ignore + // + DEPENDENCY + }; + + using enum Type; + +public: + // check if the requirement is met + // + bool check(IOrganizer* organizer) const; + + // retrieve the type of this extension + // + Type type() const; + + // retrieve a textual representation of this requirement, e.g. "ModOrganizer 2.5.4" + // for a requirement that requires MO2 2.5.4 + // + QString string() const; + +public: + ~ExtensionRequirement(); + +private: + friend class ExtensionRequirementFactory; + + ExtensionRequirement(std::shared_ptr impl); + std::shared_ptr m_Impl; +}; + +// factory for requirements +// +class QDLLEXPORT ExtensionRequirementFactory +{ +public: + // extract requirements from the given metadata + // + static std::vector + parseRequirements(const QJsonValue& json_requirements); + +private: +}; + +} // namespace MOBase + +#endif diff --git a/include/uibase/extensions/theme.h b/include/uibase/extensions/theme.h new file mode 100644 index 00000000..1802ca14 --- /dev/null +++ b/include/uibase/extensions/theme.h @@ -0,0 +1,65 @@ +#ifndef UIBASE_THEME_H +#define UIBASE_THEME_H + +#include +#include + +#include + +#include "../dllimport.h" + +namespace MOBase +{ + +// class representing a base theme for MO2, e.g., VS Dark or Skyrim +// +class QDLLEXPORT Theme +{ + std::string identifier_, name_; + std::filesystem::path stylesheet_; + +public: + Theme(std::string_view identifier, std::string_view name, + std::filesystem::path stylesheet) + : identifier_{identifier}, name_{name}, stylesheet_{std::move(stylesheet)} + {} + + // retrieve the identifier of the theme + // + const auto& identifier() const { return identifier_; } + + // retrieve the name of the theme + // + const auto& name() const { return name_; } + + // retrieve the path to the stylesheet of the theme + // + const auto& stylesheet() const { return stylesheet_; } +}; + +// class representing additions for a base theme +// +class QDLLEXPORT ThemeAddition +{ + QRegularExpression baseThemeExpr_; + std::filesystem::path stylesheet_; + +public: + ThemeAddition(std::filesystem::path stylesheet) + : ThemeAddition{"*", std::move(stylesheet)} + {} + + ThemeAddition(std::string_view baseIdentifier, std::filesystem::path stylesheet); + + // retrieve the identifier of the base theme, if there is one + // + bool isAdditionFor(Theme const& theme) const; + + // retrieve the path to the stylesheet for this extension + // + const auto& stylesheet() const { return stylesheet_; } +}; + +} // namespace MOBase + +#endif diff --git a/include/uibase/extensions/translation.h b/include/uibase/extensions/translation.h new file mode 100644 index 00000000..dafb3371 --- /dev/null +++ b/include/uibase/extensions/translation.h @@ -0,0 +1,62 @@ +#ifndef UIBASE_TRANSLATION_H +#define UIBASE_TRANSLATION_H + +#include +#include + +#include "../dllimport.h" + +namespace MOBase +{ + +// class representing a base translation for MO2 +// +class QDLLEXPORT Translation +{ + std::string identifier_, language_; + std::vector qm_files_; + +public: + Translation(std::string_view identifier, std::string_view language, + std::vector qm_files) + : identifier_{identifier}, language_{language}, qm_files_{std::move(qm_files)} + {} + + // retrieve the identifier of the translation, e.g., en or fr_FR + // + const auto& identifier() const { return identifier_; } + + // retrieve the language of this translation + // + const auto& language() const { return language_; } + + // retrieve the path to the QM files including with this translation + // + const auto& files() const { return qm_files_; } +}; + +// class representing the extension of a base translation +// +class QDLLEXPORT TranslationAddition +{ + std::string baseIdentifier_; + std::vector qm_files_; + +public: + TranslationAddition(std::string_view baseIdentifier, + std::vector qm_files) + : baseIdentifier_{baseIdentifier}, qm_files_{std::move(qm_files)} + {} + + // retrieve the identifier of the base translation, if there is one + // + const auto& baseIdentifier() const { return baseIdentifier_; } + + // retrieve the path to the stylesheet for this extension + // + const auto& files() const { return qm_files_; } +}; + +} // namespace MOBase + +#endif diff --git a/include/uibase/extensions/versionconstraints.h b/include/uibase/extensions/versionconstraints.h new file mode 100644 index 00000000..61791fcb --- /dev/null +++ b/include/uibase/extensions/versionconstraints.h @@ -0,0 +1,75 @@ +#pragma once + +#include + +#include + +#include "../versioning.h" + +namespace MOBase +{ +class InvalidConstraintException : public Exception +{ +public: + using Exception::Exception; +}; + +class VersionConstraintImpl; + +// class representing a version constraint, e.g. "2.3.*" or ">=2.4" +// +class QDLLEXPORT VersionConstraint +{ +public: + // wildcard placeholder for major/minor/patch/subpatch when constructing wildcard + // + static constexpr int WILDCARD = -1; + +public: + // parse a constraint from the given string + // + static VersionConstraint parse(QString const& value, Version::ParseMode mode); + +public: + // check if the given version matches this constraint + // + bool matches(Version const& version) const; + +public: + ~VersionConstraint(); + +private: + VersionConstraint(std::shared_ptr impl); + + std::shared_ptr m_Impl; +}; + +// class representing a set of version constraints, usually from dependency +// requirements e.g. "2.3.*", or ">= 2.4, <2.5" +// +class QDLLEXPORT VersionConstraints +{ +public: + // parse a set of constraints from the given string + // + static VersionConstraints parse(QString const& value, Version::ParseMode mode); + +public: + // construct a set of constraints + // + VersionConstraints(QString const& repr, std::vector constraints); + + // check if the given version matches the set of constraints + // + bool matches(Version const& version) const; + + // retrieve a string representation of this set of constraints + // + auto string() const { return m_Repr; } + +private: + QString m_Repr; + std::vector m_Constraints; +}; + +} // namespace MOBase diff --git a/include/uibase/formatters.h b/include/uibase/formatters.h index 870e01d4..23e89905 100644 --- a/include/uibase/formatters.h +++ b/include/uibase/formatters.h @@ -1,6 +1,7 @@ #pragma once #include "./formatters/enums.h" +#include "./formatters/path.h" #include "./formatters/qt.h" #include "./formatters/random_access_containers.h" #include "./formatters/strings.h" diff --git a/include/uibase/formatters/path.h b/include/uibase/formatters/path.h new file mode 100644 index 00000000..d59050b6 --- /dev/null +++ b/include/uibase/formatters/path.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include + +template +struct std::formatter + : std::formatter +{ + template + FmtContext::iterator format(const std::filesystem::path& v, FmtContext& ctx) const + { + return std::formatter::format(v.native(), + ctx); + } +}; diff --git a/include/uibase/imodinterface.h b/include/uibase/imodinterface.h index ca8c6ff6..b093aea5 100644 --- a/include/uibase/imodinterface.h +++ b/include/uibase/imodinterface.h @@ -269,17 +269,17 @@ class IModInterface virtual void setUrl(const QString& url) = 0; public: // Plugin operations: - /** - * @brief Retrieve the specified setting in this mod for a plugin. - * - * @param pluginName Name of the plugin for which to retrieve a setting. This should - * always be IPlugin::name() unless you have a really good reason to access - * settings of another plugin. - * @param key Identifier of the setting. - * @param defaultValue The default value to return if the setting does not exist. - * - * @return the setting, if found, or the default value. - */ + /** + * @brief Retrieve the specified setting in this mod for a plugin. + * + * @param pluginName Name of the plugin for which to retrieve a setting. This should + * always be IPlugin::name() unless you have a really good reason to access + * settings of another plugin. + * @param key Identifier of the setting. + * @param defaultValue The default value to return if the setting does not exist. + * + * @return the setting, if found, or the default value. + */ virtual QVariant pluginSetting(const QString& pluginName, const QString& key, const QVariant& defaultValue = QVariant()) const = 0; diff --git a/include/uibase/imoinfo.h b/include/uibase/imoinfo.h index bf6588d5..df3b3383 100644 --- a/include/uibase/imoinfo.h +++ b/include/uibase/imoinfo.h @@ -41,6 +41,7 @@ namespace MOBase { class IFileTree; +class IExtensionList; class IModInterface; class IModRepositoryBridge; class IDownloadManager; @@ -313,6 +314,11 @@ class QDLLEXPORT IOrganizer : public QObject */ virtual IDownloadManager* downloadManager() const = 0; + /** + * @return the interface to the extension list. + */ + virtual IExtensionList& extensionList() const = 0; + /** * @return interface to the list of plugins (esps, esms, and esls) */ diff --git a/include/uibase/iplugin.h b/include/uibase/iplugin.h index aae89dcc..0e5a3802 100644 --- a/include/uibase/iplugin.h +++ b/include/uibase/iplugin.h @@ -21,14 +21,18 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #ifndef IPLUGIN_H #define IPLUGIN_H -#include "imoinfo.h" -#include "pluginrequirements.h" -#include "pluginsetting.h" -#include "versioninfo.h" +#include + #include #include #include -#include + +#include "extensions/extensionsetting.h" +#include "imoinfo.h" +#include "pluginrequirements.h" + +// deprecated header +#include "pluginsetting.h" namespace MOBase { @@ -81,21 +85,6 @@ class IPlugin */ virtual QString localizedName() const { return name(); } - /** - * @brief Retrieve the name of the master plugin of this plugin. - * - * It is often easier to implement a functionality as multiple plugins in MO2, but - * ship the plugins together, e.g. as a Python module or using `createFunctions()`. In - * this case, having a master plugin (one of the plugin, or a separate one) tells MO2 - * that these plugins are linked and should also be displayed together in the UI. If - * MO2 ever implements automatic updates for plugins, the `master()` plugin will also - * be used for this purpose. - * - * @return the name of the master plugin of this plugin, or an empty string if this - * plugin does not have a master. - */ - virtual QString master() const { return ""; } - /** * @brief Retrieve the requirements for the plugins. * @@ -108,22 +97,6 @@ class IPlugin return {}; } - /** - * @return the author of this plugin. - */ - virtual QString author() const = 0; - - /** - * @return a short description of the plugin to be displayed to the user. - */ - virtual QString description() const = 0; - - /** - * @return the version of the plugin. This can be used to detect outdated versions of - * plugins. - */ - virtual VersionInfo version() const = 0; - /** * @return the list of configurable settings for this plugin (in the user interface). * The list may be empty. @@ -131,7 +104,12 @@ class IPlugin * @note Plugin can store "hidden" (from the user) settings using * IOrganizer::persistent / IOrganizer::setPersistent. */ - virtual QList settings() const = 0; + virtual QList settings() const = 0; + + /** + * @return the list of groups for settings. + */ + virtual QList settingGroups() const { return {}; } /** * @return whether the plugin should be enabled by default diff --git a/include/uibase/iplugingame.h b/include/uibase/iplugingame.h index 0fa2508c..61d79eb4 100644 --- a/include/uibase/iplugingame.h +++ b/include/uibase/iplugingame.h @@ -64,13 +64,6 @@ class IPluginGame : public QObject, public IPlugin Q_DECLARE_FLAGS(ProfileSettings, ProfileSetting) public: - // Game plugin should not have requirements: - std::vector> - requirements() const final override - { - return {}; - } - // Game plugin can not be disabled bool enabledByDefault() const final override { return true; } diff --git a/include/uibase/ipluginproxy.h b/include/uibase/ipluginproxy.h deleted file mode 100644 index b0b8d843..00000000 --- a/include/uibase/ipluginproxy.h +++ /dev/null @@ -1,83 +0,0 @@ -/* -Mod Organizer shared UI functionality - -Copyright (C) 2012 Sebastian Herbord. All rights reserved. - -This library is free software; you can redistribute it and/or -modify it under the terms of the GNU Lesser General Public -License as published by the Free Software Foundation; either -version 3 of the License, or (at your option) any later version. - -This library is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public -License along with this library; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -*/ - -#ifndef IPLUGINPROXY_H -#define IPLUGINPROXY_H - -#include -#include -#include - -#include "iplugin.h" - -namespace MOBase -{ - -class IPluginProxy : public IPlugin -{ -public: - IPluginProxy() : m_ParentWidget(nullptr) {} - - /** - * @brief List the plugins managed by this proxy in the given - * folder. - * - * @param pluginPath Path containing the plugins. - * - * @return list of plugin identifiers that supported by this proxy. - */ - virtual QStringList pluginList(const QDir& pluginPath) const = 0; - - /** - * @brief Load the plugins corresponding to the given identifier. - * - * @param identifier Identifier of the proxied plugin to load. - * - * @return a list of QObject, one for each plugins in the given identifier. - */ - virtual QList load(const QString& identifier) = 0; - - /** - * @brief Unload the plugins corresponding to the given identifier. - * - * @param identifier Identifier of the proxied plugin to unload. - */ - virtual void unload(const QString& identifier) = 0; - - /** - * @brief Sets the widget that the tool should use as the parent whenever - * it creates a new modal dialog. - * - * @param widget The new parent widget. - */ - void setParentWidget(QWidget* widget) { m_ParentWidget = widget; } - -protected: - QWidget* parentWidget() const { return m_ParentWidget; } - -private: - QWidget* m_ParentWidget; -}; - -} // namespace MOBase - -Q_DECLARE_INTERFACE(MOBase::IPluginProxy, "com.tannin.ModOrganizer.PluginProxy/1.0") - -#endif // IPLUGINPROXY_H diff --git a/include/uibase/pluginsetting.h b/include/uibase/pluginsetting.h index 6844f4d0..0b760d55 100644 --- a/include/uibase/pluginsetting.h +++ b/include/uibase/pluginsetting.h @@ -21,29 +21,13 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA #ifndef PLUGINSETTING_H #define PLUGINSETTING_H -#include -#include -#include +#include "extensions/extensionsetting.h" namespace MOBase { -/** - * @brief struct to hold the user-configurable parameters a plugin accepts. The purpose - * of this struct is only to inform the application what settings to offer to the user, - * it does not hold the actual value - */ -struct PluginSetting -{ - PluginSetting(const QString& key, const QString& description, - const QVariant& defaultValue) - : key(key), description(description), defaultValue(defaultValue) - {} - - QString key; - QString description; - QVariant defaultValue; -}; +// deprecated alias +using PluginSetting [[deprecated]] = Setting; } // namespace MOBase diff --git a/include/uibase/utility.h b/include/uibase/utility.h index f16d6399..d8866ba0 100644 --- a/include/uibase/utility.h +++ b/include/uibase/utility.h @@ -383,11 +383,13 @@ QDLLEXPORT std::string ToString(const QString& source, bool utf8 = true); * @brief convert std::string to QString (assuming the string to be utf-8 encoded) **/ QDLLEXPORT QString ToQString(const std::string& source); +QDLLEXPORT QString ToQString(std::string_view source); /** * @brief convert std::wstring to QString (assuming the wstring to be utf-16 encoded) **/ QDLLEXPORT QString ToQString(const std::wstring& source); +QDLLEXPORT QString ToQString(std::wstring_view source); /** * @brief convert a systemtime object to a string containing date and time in local diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fa1d0a66..73d4b318 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -33,6 +33,16 @@ set(root_headers ../include/uibase/versioning.h ../include/uibase/versioninfo.h ) +set(extension_headers + ../include/uibase/extensions/extension.h + ../include/uibase/extensions/extensionsetting.h + ../include/uibase/extensions/iextensionlist.h + ../include/uibase/extensions/ipluginloader.h + ../include/uibase/extensions/requirements.h + ../include/uibase/extensions/theme.h + ../include/uibase/extensions/translation.h + ../include/uibase/extensions/versionconstraints.h +) set(interface_headers ../include/uibase/ifiletree.h ../include/uibase/iinstallationmanager.h @@ -51,7 +61,6 @@ set(interface_headers ../include/uibase/ipluginlist.h ../include/uibase/ipluginmodpage.h ../include/uibase/ipluginpreview.h - ../include/uibase/ipluginproxy.h ../include/uibase/iplugintool.h ../include/uibase/iprofile.h ../include/uibase/isavegame.h @@ -90,6 +99,7 @@ set(game_features_header set(formatters_header ../include/uibase/formatters/enums.h ../include/uibase/formatters/qt.h + ../include/uibase/formatters/path.h ../include/uibase/formatters/random_access_containers.h ../include/uibase/formatters/strings.h ../include/uibase/formatters.h @@ -127,6 +137,16 @@ mo2_target_sources(uibase versioninfo.cpp ) +mo2_target_sources(uibase + FOLDER src/extensions + PRIVATE + ${extension_headers} + extension.cpp + theme.cpp + requirements.cpp + versionconstraints.cpp +) + mo2_target_sources(uibase FOLDER src/interfaces PRIVATE @@ -181,6 +201,7 @@ target_sources(uibase BASE_DIRS ${CMAKE_CURRENT_LIST_DIR}/../include FILES ${root_headers} + ${extension_headers} ${interface_headers} ${tutorial_headers} ${widget_headers} diff --git a/src/extension.cpp b/src/extension.cpp new file mode 100644 index 00000000..651cf13e --- /dev/null +++ b/src/extension.cpp @@ -0,0 +1,479 @@ +#include "extensions/extension.h" + +#include +#include +#include +#include +#include + +#include "log.h" + +// name of the metadata file +// +static constexpr const char* METADATA_FILENAME = "metadata.json"; + +namespace MOBase +{ + +namespace +{ + // retrieve all files matching one of the glob pattern in globPatterns, assuming paths + // (in patterns) are relative to basePath + // + auto globExtensionFiles(std::filesystem::path basePath, + QStringList const& globPatterns) + { + std::vector files; + + for (const auto& globFile : globPatterns) { + // use a QFileInfo to extract the name (glob) and the directory - we currently do + // not handle recursive glob (**) + QFileInfo globFileInfo(basePath, globFile); + + QDirIterator dirIterator(globFileInfo.absolutePath(), {globFileInfo.fileName()}, + QDir::Files); + while (dirIterator.hasNext()) { + dirIterator.next(); + files.push_back(dirIterator.fileInfo().filesystemAbsoluteFilePath()); + } + } + + return files; + } + + // parse an author from a JSON value + // + ExtensionContributor parseContributor(QJsonValue const& value) + { + if (value.isNull()) { + return ExtensionContributor("", ""); + } + + // TODO: handle more fields in the future, handle string authors similar to NPM + + if (value.isObject()) { + const auto contrib = value.toObject(); + return ExtensionContributor(contrib["name"].toString(), + contrib["homepage"].toString()); + } + + return ExtensionContributor(value.toString(), {}); + } + +} // namespace + +ExtensionContributor::ExtensionContributor(QString name, QString homepage) + : m_Name{std::move(name)}, m_Homepage{std::move(homepage)} +{} + +ExtensionMetaData::ExtensionMetaData(std::filesystem::path const& path, + QJsonObject const& jsonData) + : m_JsonData{jsonData}, m_Version{0, 0, 0}, m_Requirements{} +{ + // read basic fields + m_Identifier = jsonData["id"].toString(); + if (m_Identifier.isEmpty()) { + throw InvalidExtensionMetaDataException("missing identifier"); + } + + { + const auto maybeType = parseType(jsonData["type"].toString()); + if (!maybeType.has_value()) { + throw InvalidExtensionMetaDataException( + std::format("invalid or missing type '{}'", jsonData["type"].toString())); + } + + m_Type = *maybeType; + } + + m_Name = jsonData["name"].toString(); + if (m_Name.isEmpty()) { + throw InvalidExtensionMetaDataException("missing name"); + } + + m_Author = parseContributor(jsonData["author"]); + m_Description = jsonData["description"].toString(); + + try { + m_Version = Version::parse(jsonData["version"].toString("0.0.0"), + Version::ParseMode::SemVer); + } catch (InvalidVersionException const& ex) { + throw InvalidExtensionMetaDataException( + std::format("invalid or missing version '{}': {}", + jsonData["version"].toString(), ex.what())); + } + + // translation context + m_TranslationContext = jsonData["translation-context"].toString(""); + + if (jsonData.contains("icon")) { + const QFileInfo icon{QDir(path), jsonData["icon"].toString()}; + if (icon.exists()) { + m_Icon = QIcon(icon.absoluteFilePath()); + } + } + + if (jsonData.contains("contributors")) { + for (const auto& jsonContributor : jsonData["contributors"].toArray()) { + m_Contributors.push_back(parseContributor(jsonContributor)); + } + } + + if (jsonData.contains("requirements")) { + try { + m_Requirements = + ExtensionRequirementFactory::parseRequirements(jsonData["requirements"]); + } catch (InvalidRequirementException const& ex) { + throw InvalidExtensionMetaDataException(ex.what()); + } catch (InvalidRequirementsException const& ex) { + throw InvalidExtensionMetaDataException(ex.what()); + } + } +} + +std::optional ExtensionMetaData::parseType(QString const& value) const +{ + std::map stringToTypes{ + {"theme", ExtensionType::THEME}, + {"translation", ExtensionType::TRANSLATION}, + {"plugin", ExtensionType::PLUGIN}, + {"game", ExtensionType::GAME}}; + + std::optional type; + for (auto& [k, v] : stringToTypes) { + if (k.compare(value, Qt::CaseInsensitive) == 0) { + type = v; + break; + } + } + + return type; +} + +QString ExtensionMetaData::localized(QString const& value) const +{ + // no translation context + if (m_TranslationContext.isEmpty()) { + return value; + } + + const auto result = QCoreApplication::translate(m_TranslationContext.toUtf8().data(), + value.toUtf8().data()); + return result.isEmpty() ? value : result; +} + +QJsonObject ExtensionMetaData::content() const +{ + if (!m_JsonData.contains("content")) { + return {}; + } + + const auto value = m_JsonData["content"]; + if (!value.isObject()) { + log::error("invalid metadata for {}, 'content' should be an object", m_Identifier); + return {}; + } + + return value.toObject(); +} + +IExtension::IExtension(std::filesystem::path const& path, ExtensionMetaData&& metadata) + : m_Path{path}, m_MetaData{std::move(metadata)} +{} + +ExtensionMetaData ExtensionFactory::loadMetaData(std::filesystem::path const& path) +{ + if (!exists(path)) { + throw InvalidExtensionMetaDataException( + std::format("metadata file '{}' not found", path)); + } + + // load the meta data + QJsonParseError jsonError; + QJsonDocument jsonMetaData; + { + QFile file(path); + if (!file.open(QFile::ReadOnly)) { + throw InvalidExtensionMetaDataException( + std::format("failed to open metadata file '{}'", path)); + } + + const auto jsonContent = file.readAll(); + jsonMetaData = QJsonDocument::fromJson(jsonContent, &jsonError); + } + + if (jsonMetaData.isNull()) { + throw InvalidExtensionMetaDataException( + std::format("invalid metadata file '{}': {}", path, jsonError.errorString())); + } + + return ExtensionMetaData(path.parent_path(), jsonMetaData.object()); +} + +std::unique_ptr +ExtensionFactory::loadExtension(std::filesystem::path const& directory) +{ + try { + return loadExtension(directory, loadMetaData(directory / METADATA_FILENAME)); + } catch (InvalidExtensionMetaDataException const& ex) { + log::warn("failed to load extension from '{}': invalid metadata ({})", + directory.native(), ex.what()); + return nullptr; + } +} + +std::unique_ptr +ExtensionFactory::loadExtension(std::filesystem::path const& directory, + ExtensionMetaData&& metadata) +{ + switch (metadata.type()) { + case ExtensionType::THEME: + return ThemeExtension::loadExtension(directory, std::move(metadata)); + case ExtensionType::TRANSLATION: + return TranslationExtension::loadExtension(directory, std::move(metadata)); + case ExtensionType::PLUGIN: + return PluginExtension::loadExtension(directory, std::move(metadata)); + case ExtensionType::GAME: + return GameExtension::loadExtension(directory, std::move(metadata)); + default: + log::warn("failed to load extension from '{}': invalid type", directory.native()); + return nullptr; + } +} + +ThemeExtension::ThemeExtension(std::filesystem::path const& path, + ExtensionMetaData&& metadata, + std::vector> themes) + : IExtension{path, std::move(metadata)}, m_Themes{std::move(themes)} +{} + +std::unique_ptr +ThemeExtension::loadExtension(std::filesystem::path const& path, + ExtensionMetaData&& metadata) +{ + std::vector> themes; + const auto& jsonThemes = metadata.content()["themes"].toObject(); + for (auto it = jsonThemes.begin(); it != jsonThemes.end(); ++it) { + const auto theme = parseTheme(path, it.key(), it.value().toObject()); + if (theme) { + themes.push_back(theme); + } else { + log::warn("failed to parse theme '{}' from '{}'", it.key(), path.native()); + } + } + + if (themes.empty()) { + log::error("failed to parse themes from '{}'", path.native()); + return nullptr; + } + + return std::unique_ptr{ + new ThemeExtension(path, std::move(metadata), std::move(themes))}; +} + +std::shared_ptr +ThemeExtension::parseTheme(std::filesystem::path const& extensionFolder, + const QString& identifier, const QJsonObject& jsonTheme) +{ + const auto name = jsonTheme["name"].toString(); + const auto filepath = + extensionFolder / jsonTheme["path"].toString().toUtf8().toStdString(); + + if (name.isEmpty() || !is_regular_file(filepath)) { + return nullptr; + } + + return std::make_shared(identifier.toStdString(), name.toStdString(), + filepath); +} + +TranslationExtension::TranslationExtension( + std::filesystem::path const& path, ExtensionMetaData&& metadata, + std::vector> translations) + : IExtension{std::move(path), std::move(metadata)}, + m_Translations(std::move(translations)) +{} + +std::unique_ptr +TranslationExtension::loadExtension(std::filesystem::path const& path, + ExtensionMetaData&& metadata) +{ + std::vector> translations; + const auto& jsonTranslations = metadata.content()["translations"].toObject(); + for (auto it = jsonTranslations.begin(); it != jsonTranslations.end(); ++it) { + const auto translation = parseTranslation(path, it.key(), it.value().toObject()); + if (translation) { + translations.push_back(translation); + } else { + log::warn("failed to parse translation '{}' from '{}'", it.key(), path.native()); + } + } + + if (translations.empty()) { + log::error("failed to parse translations from '{}'", path.native()); + return nullptr; + } + + return std::unique_ptr{ + new TranslationExtension(path, std::move(metadata), std::move(translations))}; +} + +std::shared_ptr +TranslationExtension::parseTranslation(std::filesystem::path const& extensionFolder, + const QString& identifier, + const QJsonObject& jsonTranslation) +{ + const auto jsonGlobFiles = jsonTranslation["files"].toVariant().toStringList(); + + std::vector qm_files = + globExtensionFiles(extensionFolder, jsonGlobFiles); + + if (qm_files.empty()) { + return nullptr; + } + + const auto jsonName = jsonTranslation["name"]; + QString name; + if (jsonName.isString()) { + name = jsonName.toString(); + } else { + QLocale locale(identifier); + name = QString("%1 (%2)") + .arg(locale.nativeLanguageName()) + .arg(locale.nativeTerritoryName()); + } + + return std::make_shared(identifier.toStdString(), name.toStdString(), + std::move(qm_files)); +} + +PluginExtension::PluginExtension( + std::filesystem::path const& path, ExtensionMetaData&& metadata, bool autodetect, + std::map plugins, + std::vector> themeAdditions, + std::vector> translationAdditions) + : IExtension(path, std::move(metadata)), m_AutoDetect{autodetect}, + m_Plugins{std::move(plugins)}, m_ThemeAdditions{std::move(themeAdditions)}, + m_TranslationAdditions{std::move(translationAdditions)} +{} + +std::unique_ptr +PluginExtension::loadExtension(std::filesystem::path const& path, + ExtensionMetaData&& metadata) +{ + namespace fs = std::filesystem; + + // load plugins + std::optional autodetect; + std::map plugins; + { + auto jsonPlugins = metadata.content()["plugins"].toObject(); + if (jsonPlugins.contains("autodetect")) { + autodetect = jsonPlugins["autodetect"].toBool(); + } + jsonPlugins.remove("autodetect"); + + for (auto it = jsonPlugins.begin(); it != jsonPlugins.end(); ++it) { + plugins[it.key().toStdString()] = + QFileInfo(path, it.value().toString()).filesystemAbsoluteFilePath(); + } + + if (!autodetect.has_value()) { + autodetect = plugins.empty(); + } + } + + // load themes + std::vector> themes; + { + auto jsonThemes = metadata.json()["themes"].toObject(); + for (auto it = jsonThemes.begin(); it != jsonThemes.end(); ++it) { + for (auto& file : globExtensionFiles(path, it.value().toVariant().toStringList())) + themes.push_back(std::make_shared(it.key().toStdString(), file)); + } + } + + // load translations + std::vector> translations; + { + auto jsonTranslations = metadata.json()["translations"].toObject(); + + if (jsonTranslations.contains("*")) { + // * is a custom entry - * should point to a list of file prefix, e.g., + // ["translations/foo_", "translations/bar_"] meaning that the translations + // files are prefixed by foo_ and bar_ inside the translations folder, language + // is extracted by removing the prefix + // + // TODO: remove this option + // + std::map> filesPerLanguage; + const auto prefixes = jsonTranslations["*"].toVariant().toStringList(); + + for (auto& prefix : prefixes) { + const auto filePrefix = QFileInfo(prefix).fileName(); + const auto files = globExtensionFiles(path, {prefix + "*.qm"}); + for (auto& file : files) { + // extract the identifier from the match, e.g., if the prefix is + // translations/installer_manual_, the filePrefix is installer_manual_, + // and glob will be like translations/installer_manual_fr.qm + const auto identifier = + QFileInfo(file).baseName().replace(filePrefix, "", Qt::CaseInsensitive); + filesPerLanguage[identifier].push_back(file); + } + } + + for (auto& [language, files] : filesPerLanguage) { + translations.push_back(std::make_shared( + language.toStdString(), std::move(files))); + } + } else if (jsonTranslations.contains("autodetect")) { + + // autodetect is a custom entry - "autodetect": "xxx" means that the extension + // contains a translations folder named "xxx" where each subfolder is a language + // (identifier) containing translation files for the language + + std::map> filesPerLanguage; + const auto folder = jsonTranslations["autodetect"].toString(); + for (const auto& lang : fs::directory_iterator(path / folder.toStdString())) { + if (!fs::is_directory(lang)) { + continue; + } + + filesPerLanguage[QString::fromStdWString(lang.path().filename().wstring())] = + globExtensionFiles(lang, {"*.qm"}); + } + + for (auto& [language, files] : filesPerLanguage) { + translations.push_back(std::make_shared( + language.toStdString(), std::move(files))); + } + + } else { + for (auto it = jsonTranslations.begin(); it != jsonTranslations.end(); ++it) { + translations.push_back(std::make_shared( + it.key().toStdString(), + globExtensionFiles(path, it.value().toVariant().toStringList()))); + } + } + } + + return std::unique_ptr(new PluginExtension( + std::move(path), std::move(metadata), *autodetect, std::move(plugins), + std::move(themes), std::move(translations))); +} + +GameExtension::GameExtension(PluginExtension&& pluginExtension) + : PluginExtension(std::move(pluginExtension)) +{} + +std::unique_ptr +GameExtension::loadExtension(std::filesystem::path const& path, + ExtensionMetaData&& metadata) +{ + auto extension = PluginExtension::loadExtension(std::move(path), std::move(metadata)); + return extension + ? std::unique_ptr(new GameExtension(std::move(*extension))) + : nullptr; +} + +} // namespace MOBase diff --git a/src/requirements.cpp b/src/requirements.cpp new file mode 100644 index 00000000..11bd81c5 --- /dev/null +++ b/src/requirements.cpp @@ -0,0 +1,198 @@ +#include "extensions/requirements.h" + +#include + +#include "extensions/extension.h" +#include "extensions/iextensionlist.h" +#include "extensions/versionconstraints.h" +#include "imoinfo.h" +#include "log.h" + +namespace MOBase +{ + +class ExtensionRequirementImpl +{ +public: + using Type = ExtensionRequirement::Type; + +public: + virtual bool check(IOrganizer* organizer) const = 0; + virtual Type type() const = 0; + virtual QString string() const = 0; + virtual ~ExtensionRequirementImpl() = default; +}; + +// requirement for the version of MO2 itself +// +class CoreVersionExtensionRequirement : public ExtensionRequirementImpl +{ +public: + CoreVersionExtensionRequirement(VersionConstraints const& constraints) + : m_Constraints{constraints} + {} + + bool check(IOrganizer* organizer) const override + { + return m_Constraints.matches(organizer->version()); + } + + Type type() const override { return Type::VERSION; } + + QString string() const override + { + return QString("ModOrganizer2 %1").arg(m_Constraints.string()); + } + +private: + VersionConstraints m_Constraints; +}; + +// requirement for another extension +// +class DependencyExtensionRequirement : public ExtensionRequirementImpl +{ +public: + DependencyExtensionRequirement(QString const& extension, + VersionConstraints const& constraints) + : m_Extension{extension}, m_Constraints{constraints} + {} + + bool check(IOrganizer* organizer) const override + { + return organizer->extensionList().enabled(m_Extension) && + m_Constraints.matches( + organizer->extensionList().get(m_Extension).metadata().version()); + } + + Type type() const override { return Type::DEPENDENCY; } + + QString string() const override + { + return QString("%1 %2").arg(m_Extension, m_Constraints.string()); + } + +private: + QString m_Extension; + VersionConstraints m_Constraints; +}; + +// requirement for games +// +class GameExtensionRequirement : public ExtensionRequirementImpl +{ +public: + GameExtensionRequirement(QStringList const& games) : m_Games{games} {} + + bool check(IOrganizer* organizer) const override + { + return organizer->managedGame() && + m_Games.contains(organizer->managedGame()->gameName()); + } + + Type type() const override { return Type::GAME; } + + QString string() const override { return m_Games.join(", "); } + +private: + QStringList m_Games; +}; + +} // namespace MOBase + +using namespace MOBase; + +ExtensionRequirement::ExtensionRequirement( + std::shared_ptr impl) + : m_Impl{std::move(impl)} +{} + +ExtensionRequirement::~ExtensionRequirement() = default; + +bool ExtensionRequirement::check(IOrganizer* organizer) const +{ + return m_Impl->check(organizer); +} + +ExtensionRequirement::Type ExtensionRequirement::type() const +{ + return m_Impl->type(); +} + +QString ExtensionRequirement::string() const +{ + return m_Impl->string(); +} + +namespace +{ +std::optional parseType(QString const& value) +{ + std::map stringToTypes{ + {"game", ExtensionRequirement::Type::GAME}, + {"extension", ExtensionRequirement::Type::DEPENDENCY}, + {"version", ExtensionRequirement::Type::VERSION}}; + + std::optional type; + for (auto& [k, v] : stringToTypes) { + if (k.compare(value, Qt::CaseInsensitive) == 0) { + type = v; + break; + } + } + + return type; +} +} // namespace + +std::vector +ExtensionRequirementFactory::parseRequirements(const QJsonValue& json_requirements) +{ + if (!json_requirements.isArray()) { + throw InvalidRequirementsException("expected an array of requirements"); + } + + std::vector requirements; + for (const auto& json_requirement : json_requirements.toArray()) { + if (!json_requirement.isObject()) { + throw InvalidRequirementException("invalid requirement"); + } + + auto json_object = json_requirement.toObject(); + + const auto type = parseType(json_object["type"].toString()); + if (!type.has_value()) { + throw InvalidRequirementException("missing requirement type"); + } + + try { + switch (*type) { + case ExtensionRequirement::Type::GAME: + if (!json_object.contains("games") || !json_object["games"].isArray()) { + throw InvalidRequirementException("invalid requirement"); + } + requirements.push_back( + ExtensionRequirement(std::make_shared( + json_object["games"].toVariant().toStringList()))); + break; + case ExtensionRequirement::Type::DEPENDENCY: + requirements.push_back( + ExtensionRequirement(std::make_shared( + json_object["extension"].toString(), + VersionConstraints::parse(json_object["version"].toString(), + Version::ParseMode::SemVer)))); + break; + case ExtensionRequirement::Type::VERSION: + requirements.push_back(ExtensionRequirement( + std::make_shared(VersionConstraints::parse( + json_object["version"].toString(), Version::ParseMode::MO2)))); + break; + } + } catch (InvalidConstraintException const& ex) { + throw InvalidRequirementException( + std::format("invalid requirement constraints: {}", ex.what())); + } + } + + return requirements; +} diff --git a/src/theme.cpp b/src/theme.cpp new file mode 100644 index 00000000..4d099863 --- /dev/null +++ b/src/theme.cpp @@ -0,0 +1,21 @@ +#include "extensions/theme.h" + +#include "utility.h" + +namespace MOBase +{ + +ThemeAddition::ThemeAddition(std::string_view baseIdentifier, + std::filesystem::path stylesheet) + : baseThemeExpr_{QRegularExpression::fromWildcard( + ToQString(baseIdentifier), Qt::CaseInsensitive, + QRegularExpression::DefaultWildcardConversion)}, + stylesheet_{std::move(stylesheet)} +{} + +bool ThemeAddition::isAdditionFor(Theme const& theme) const +{ + return baseThemeExpr_.match(ToQString(theme.identifier())).hasMatch(); +} + +} // namespace MOBase diff --git a/src/tutorialcontrol.cpp b/src/tutorialcontrol.cpp index d3b73025..1e1f7952 100644 --- a/src/tutorialcontrol.cpp +++ b/src/tutorialcontrol.cpp @@ -153,13 +153,14 @@ void TutorialControl::simulateClick(int x, int y) if (!wasTransparent) { m_TutorialView->setAttribute(Qt::WA_TransparentForMouseEvents, true); } - QWidget* hitControl = m_TargetControl->childAt(x, y); - QPoint globalPos = m_TargetControl->mapToGlobal(QPoint(x, y)); - QPoint hitPos = hitControl->mapFromGlobal(globalPos); - QMouseEvent* downEvent = new QMouseEvent( - QEvent::MouseButtonPress, hitPos, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QWidget* hitControl = m_TargetControl->childAt(x, y); + QPoint globalPos = m_TargetControl->mapToGlobal(QPoint(x, y)); + QPoint hitPos = hitControl->mapFromGlobal(globalPos); + QMouseEvent* downEvent = + new QMouseEvent(QEvent::MouseButtonPress, hitPos, hitPos, Qt::LeftButton, + Qt::LeftButton, Qt::NoModifier); QMouseEvent* upEvent = - new QMouseEvent(QEvent::MouseButtonRelease, hitPos, Qt::LeftButton, + new QMouseEvent(QEvent::MouseButtonRelease, hitPos, hitPos, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); qApp->postEvent(hitControl, (QEvent*)downEvent); diff --git a/src/utility.cpp b/src/utility.cpp index e3b7ddfb..700e152b 100644 --- a/src/utility.cpp +++ b/src/utility.cpp @@ -736,12 +736,22 @@ QString ToQString(const std::string& source) return QString::fromStdString(source); } +QString ToQString(std::string_view source) +{ + return QString::fromUtf8(source.data(), static_cast(source.size())); +} + QString ToQString(const std::wstring& source) { // return QString::fromWCharArray(source.c_str()); return QString::fromStdWString(source); } +QString ToQString(std::wstring_view source) +{ + return QString::fromWCharArray(source.data(), static_cast(source.size())); +} + QString ToString(const SYSTEMTIME& time) { char dateBuffer[100]; diff --git a/src/version.rc b/src/version.rc index ddbcfcb9..31c7de7b 100644 --- a/src/version.rc +++ b/src/version.rc @@ -10,7 +10,7 @@ VS_VERSION_INFO VERSIONINFO FILEVERSION VER_FILEVERSION PRODUCTVERSION VER_FILEVERSION FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -FILEFLAGS (0) +FILEFLAGS VS_FF_PRERELEASE FILEOS VOS__WINDOWS32 FILETYPE VFT_APP FILESUBTYPE (0) diff --git a/src/versionconstraints.cpp b/src/versionconstraints.cpp new file mode 100644 index 00000000..7a537bb2 --- /dev/null +++ b/src/versionconstraints.cpp @@ -0,0 +1,307 @@ +#include "extensions/versionconstraints.h" + +#include "formatters.h" + +using VersionCompareFunction = bool (*)(MOBase::Version const& lhs, + MOBase::Version const& rhs); + +// official semver regex +static const QRegularExpression s_ConstraintStrictRegEx{ + R"(^(?P>=|<=|<|>|!=|==|\^|~)?\s*(?P0|[1-9*]\d*)(?:\.(?P0|[1-9*]\d*)(?:\.(?P0|[1-9*]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?)?)?$)"}; + +// for MO2, to match stuff like 1.2.3rc1 or v1.2.3a1+XXX +static const QRegularExpression s_ConstraintMO2RegEx{ + R"(^(?P>=|<=|<|>|!=|\^|~)?\s*(?P0|[1-9*]\d*)(?:\.(?P0|[1-9*]\d*)(?:\.(?P0|[1-9*]\d*)(?:\.(?P0|[1-9*]\d*))?(?:(?Pdev|a|alpha|b|beta|rc)(?P0|[1-9](?:[.0-9])*))?)?)?$)"}; + +// match from value to release type +static const std::unordered_map + s_StringToRelease{{"dev", MOBase::Version::Development}, + {"alpha", MOBase::Version::Alpha}, + {"a", MOBase::Version::Alpha}, + {"beta", MOBase::Version::Beta}, + {"b", MOBase::Version::Beta}, + {"rc", MOBase::Version::ReleaseCandidate}}; + +#define _COMPARE_PAIR(OP) \ + {#OP, +[](MOBase::Version const& lhs, MOBase::Version const& rhs) { \ + return lhs OP rhs; \ + }} + +static const std::unordered_map s_CompareToFunction{ + _COMPARE_PAIR(>), _COMPARE_PAIR(>=), _COMPARE_PAIR(<), + _COMPARE_PAIR(<=), _COMPARE_PAIR(!=), _COMPARE_PAIR(==)}; + +#undef _COMPARE_PAIR + +namespace MOBase +{ + +class VersionConstraintImpl +{ +public: + virtual bool matches(Version const& version) const = 0; + virtual ~VersionConstraintImpl() = default; +}; + +// version constraint for a range with lower bound included and upper bound excluded, +// typically used for tilde, caret and wilcard constraints +// +class RangeVersionConstraint : public VersionConstraintImpl +{ +public: + RangeVersionConstraint(Version const& min, Version const& max) + : m_Min{min}, m_Max{max} + {} + + bool matches(Version const& version) const override + { + return m_Min <= version && version < m_Max; + } + +private: + Version m_Min, m_Max; +}; + +// version constraint for inequality and equality constraint +// +class InequalityVersionConstraint : public VersionConstraintImpl +{ + +public: + InequalityVersionConstraint(Version const& target, VersionCompareFunction compare) + : m_Target{target}, m_Compare{compare} + {} + + bool matches(Version const& version) const override + { + return m_Compare(version, m_Target); + } + +private: + Version m_Target; + VersionCompareFunction m_Compare; +}; + +VersionConstraint VersionConstraint::parse(QString const& value, + Version::ParseMode mode) +{ + const auto& regex = mode == Version::ParseMode::SemVer ? s_ConstraintStrictRegEx + : s_ConstraintMO2RegEx; + + const auto match = regex.match(value); + if (!match.hasMatch()) { + throw InvalidConstraintException( + QString::fromStdString(std::format("invalid constraint string: '{}'", value))); + } + + const auto constraint = match.captured("constraint"); + + const auto major_s = match.captured("major"); + const auto minor_s = match.captured("minor"); + const auto patch_s = match.captured("patch"); + const auto subpatch_s = match.captured("subpatch"); + + const auto wildcard = + major_s == "*" || minor_s == "*" || patch_s == "*" || subpatch_s == "*"; + const auto tilde = match.captured("constraint") == "~"; + const auto caret = match.captured("constraint") == "^"; + + // cannot use wildcard with a constraint + if (wildcard && !constraint.isEmpty()) { + throw InvalidConstraintException( + QString::fromStdString(std::format("invalid constraint string: '{}'", value))); + } + + // cannot use pre-release with wilcard, tilde or caret constraint + if ((wildcard || tilde || caret) && match.hasCaptured("prerelease")) { + throw InvalidConstraintException( + QString::fromStdString(std::format("invalid constraint string: '{}'", value))); + } + + // if a part has a wildcard, lower part should be missing or wildcard (e.g., 2.*.3 + // is invalid) + if (major_s == "*" && !minor_s.isEmpty() && minor_s != "*") { + throw InvalidConstraintException( + QString::fromStdString(std::format("invalid constraint string: '{}'", value))); + } + if (minor_s == "*" && !patch_s.isEmpty() && patch_s != "*") { + throw InvalidConstraintException( + QString::fromStdString(std::format("invalid constraint string: '{}'", value))); + } + if (patch_s == "*" && !subpatch_s.isEmpty() && subpatch_s != "*") { + throw InvalidConstraintException( + QString::fromStdString(std::format("invalid constraint string: '{}'", value))); + } + + std::vector> prereleases; + if (mode == Version::ParseMode::SemVer) { + for (auto& part : match.captured("prerelease") + .split(".", Qt::SplitBehaviorFlags::SkipEmptyParts)) { + // try to extract an int + bool ok = true; + const auto intValue = part.toInt(&ok); + if (ok) { + prereleases.push_back(intValue); + continue; + } + + // check if we have a valid prerelease type + const auto it = s_StringToRelease.find(part.toLower()); + if (it == s_StringToRelease.end()) { + throw InvalidVersionException( + QString::fromStdString(std::format("invalid prerelease type: '{}'", part))); + } + + prereleases.push_back(it->second); + } + } else { + prereleases.push_back(s_StringToRelease.at(match.captured("type"))); + + // for version with decimal point, e.g., 2.4.1rc1.1, we split the components into + // pre-release components to get {rc, 1, 1} - this works fine since {rc, 1} < {rc, + // 1, 1} + // + for (const auto& preVersion : + match.captured("prerelease").split(".", Qt::SkipEmptyParts)) { + prereleases.push_back(preVersion.toInt()); + } + } + + constexpr auto max_int = std::numeric_limits::max(); + + std::shared_ptr impl; + + if (wildcard || caret || tilde) { + + // you can get more information at + // https://python-poetry.org/docs/dependency-specification/ + + // note that the only case where all 4 xxxOk is false is for '*' + // + bool majorOk, minorOk, patchOk, subpatchOk; + auto major = major_s.toInt(&majorOk), minor = minor_s.toInt(&minorOk), + patch = patch_s.toInt(&patchOk), subpatch = subpatch_s.toInt(&subpatchOk); + + // the lower bound is always the actual version with missing or wildcard components + // set to 0, e.g. + // - 2.3.* -> >= 2.3.0 + // - ^1 -> >= 1.0.0 + // - ^0.3 -> >= 0.3.0 + // - ~1.2 -> >= 1.2.0 + const Version min = Version(major, minor, patch, subpatch); + + // the upper bound is a bit more complicated to compute + Version max = Version(max_int, max_int, max_int, max_int); + + if (wildcard) { + // for wildcard, we increment the last non-wildcard character by one + // + if (majorOk && minorOk && patchOk) { + max = Version(major, minor, patch + 1); + } else if (majorOk && minorOk) { + max = Version(major, minor + 1, 0); + } else if (majorOk) { + max = Version(major + 1, 0, 0); + } else { + max = Version(max_int, max_int, max_int, max_int); + } + } else if (caret) { + // TODO: clean this... + + if (!minorOk && !patchOk && !subpatchOk) { + max = Version(major + 1, 0, 0); + } else if (!patchOk && !subpatchOk) { + if (major == 0) { + max = Version(major, minor + 1, 0); + } else { + max = Version(major + 1, 0, 0); + } + } else if (!subpatchOk) { + if (major == 0 && minor == 0) { + max = Version(major, minor, patch + 1); + } else if (major == 0) { + max = Version(major, minor + 1, 0); + } else { + max = Version(major + 1, 0, 0); + } + } else { + if (major == 0 && minor == 0 && patch == 0 && subpatch == 0) { + max = min; // this creates an impossible range (>= 0, < 0), but is expected + } else if (major == 0 && minor == 0 && patch == 0) { + max = Version(major, minor, patch, subpatch + 1); + } else if (major == 0 && minor == 0) { + max = Version(major, minor, patch + 1, 0); + } else if (major == 0) { + max = Version(major, minor + 1, 0); + } else { + max = Version(major + 1, 0, 0); + } + } + + } else if (tilde) { + if (minorOk && patchOk && subpatchOk) { + max = Version(major, minor, patch, subpatch + 1); + } else if (minorOk && patchOk) { + max = Version(major, minor, patch + 1); + } else if (minorOk) { + max = Version(major, minor + 1, 0); + } else { + max = Version(major + 1, 0, 0); + } + } + + impl = std::make_shared(min, max); + + } else { + auto op = match.captured("constraint"); + if (op.isEmpty()) { + op = "=="; + } + impl = std::make_shared( + Version(major_s.toInt(), minor_s.toInt(), patch_s.toInt(), subpatch_s.toInt(), + std::move(prereleases)), + s_CompareToFunction.at(op)); + } + + return VersionConstraint(std::move(impl)); +} + +VersionConstraint::VersionConstraint(std::shared_ptr impl) + : m_Impl{std::move(impl)} +{} + +VersionConstraint::~VersionConstraint() = default; + +bool VersionConstraint::matches(Version const& version) const +{ + return m_Impl->matches(version); +} + +VersionConstraints VersionConstraints::parse(QString const& value, + Version::ParseMode mode) +{ + std::vector constraints; + auto parts = value.split(","); + for (auto& part : parts) { + // replace the part in-place to create a proper representation + part = part.simplified().replace(" ", ""); + + constraints.push_back(VersionConstraint::parse(part, mode)); + } + return VersionConstraints(parts.join(", "), std::move(constraints)); +} + +bool VersionConstraints::matches(Version const& version) const +{ + return std::all_of(m_Constraints.begin(), m_Constraints.end(), + [version](const auto& constraint) { + return constraint.matches(version); + }); +} + +VersionConstraints::VersionConstraints(QString const& repr, + std::vector checkers) + : m_Repr{repr}, m_Constraints{std::move(checkers)} +{} + +} // namespace MOBase diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7ff9e192..08390a87 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -3,11 +3,14 @@ cmake_minimum_required(VERSION 3.16) add_executable(uibase-tests EXCLUDE_FROM_ALL) target_sources(uibase-tests PRIVATE + test_utils.h + test_utils.cpp test_main.cpp test_formatters.cpp test_ifiletree.cpp test_strings.cpp test_versioning.cpp + test_extensions.cpp ) mo2_configure_tests(uibase-tests NO_SOURCES NO_MAIN NO_MOCK WARNINGS 4) target_link_libraries(uibase-tests PRIVATE uibase) diff --git a/tests/data/extensions/mo2-example-extension/icon.png b/tests/data/extensions/mo2-example-extension/icon.png new file mode 100644 index 00000000..2b18c97b Binary files /dev/null and b/tests/data/extensions/mo2-example-extension/icon.png differ diff --git a/tests/data/extensions/mo2-example-extension/metadata.json b/tests/data/extensions/mo2-example-extension/metadata.json new file mode 100644 index 00000000..cccb7948 --- /dev/null +++ b/tests/data/extensions/mo2-example-extension/metadata.json @@ -0,0 +1,22 @@ +{ + "id": "mo2-example-extension", + "name": "Example Extension for UI Base Tests", + "version": "1.0.0", + "description": "ModOrganizer2 example extension for UI Base Tests.", + "author": { + "name": "Mod Organizer 2", + "homepage": "https://www.modorganizer.org/" + }, + "icon": "icon.png", + "contributors": ["AL", "AnyOldName3", "Holt59", "Silarn"], + "type": "plugin", + "translation-context": "mo2-example-extension", + "content": { + "plugins": { + "autodetect": true + }, + "translations": { + "autodetect": "translations" + } + } +} diff --git a/tests/test_extensions.cpp b/tests/test_extensions.cpp new file mode 100644 index 00000000..322fbf34 --- /dev/null +++ b/tests/test_extensions.cpp @@ -0,0 +1,32 @@ +#pragma warning(push) +#pragma warning(disable : 4668) +#include +#pragma warning(pop) + +#include + +#include + +#include + +#include "test_utils.h" + +using namespace MOBase; + +TEST(ExtensionsTest, MetaData) +{ + mo2::tests::TranslationHelper tr; + + const auto metadata = ExtensionFactory::loadMetaData( + "./tests/data/extensions/mo2-example-extension/metadata.json"); + + tr.switchLanguage("en"); + EXPECT_EQ("mo2-example-extension", metadata.identifier()); + EXPECT_EQ("Example Extension for UI Base Tests", metadata.name()); + + tr.switchLanguage("fr"); + EXPECT_EQ("mo2-example-extension", metadata.identifier()); + EXPECT_EQ("Extension Démo pour les tests UI Base", metadata.name()); + + EXPECT_FALSE(metadata.icon().isNull()); +} diff --git a/tests/test_main.cpp b/tests/test_main.cpp index 0399b433..ff015903 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -3,13 +3,16 @@ #include #include +#include + int main(int argc, char** argv) { QCoreApplication app(argc, argv); - QTranslator translator; - if (translator.load("tests_fr", "tests/translations")) { - app.installTranslator(&translator); - } + + MOBase::log::createDefault({.name = "./mo2-tests.logs", + .maxLevel = MOBase::log::Levels::Info, + .pattern = ""}); + testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); } diff --git a/tests/test_strings.cpp b/tests/test_strings.cpp index 0185f83a..9fc51201 100644 --- a/tests/test_strings.cpp +++ b/tests/test_strings.cpp @@ -9,6 +9,8 @@ #include +#include "test_utils.h" + using namespace MOBase; TEST(StringsTest, IEquals) @@ -42,6 +44,11 @@ TEST(StringsTest, IReplaceAll) // this is more a tests of the tests TEST(StringsTest, Translation) { + mo2::tests::TranslationHelper tr; + ASSERT_EQ("Translate to French", + QCoreApplication::translate("uibase-tests", "Translate to French")); + + tr.switchLanguage("fr"); ASSERT_EQ("Traduction en Français", QCoreApplication::translate("uibase-tests", "Translate to French")); } diff --git a/tests/test_utils.cpp b/tests/test_utils.cpp new file mode 100644 index 00000000..19a897ae --- /dev/null +++ b/tests/test_utils.cpp @@ -0,0 +1,31 @@ +#include "test_utils.h" + +#include + +namespace mo2::tests +{ +TranslationHelper::TranslationHelper() {} + +TranslationHelper::~TranslationHelper() +{ + release(); +} + +void TranslationHelper::release() +{ + if (m_Translator) { + QCoreApplication::removeTranslator(m_Translator.get()); + m_Translator.reset(); + } +} + +void TranslationHelper::switchLanguage(const QString& lang) +{ + m_Translator = std::make_unique(); + if (m_Translator->load("tests_" + lang, "tests/translations")) { + QCoreApplication::installTranslator(m_Translator.get()); + } else { + m_Translator.reset(); + } +} +} // namespace mo2::tests diff --git a/tests/test_utils.h b/tests/test_utils.h new file mode 100644 index 00000000..9362be50 --- /dev/null +++ b/tests/test_utils.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include +#include + +namespace mo2::tests +{ + +class TranslationHelper +{ +public: + // create a new translation helper that can be used to switch language during tests + TranslationHelper(); + ~TranslationHelper(); + + // switch to the given language (should be available) + void switchLanguage(const QString& lang); + +private: + std::unique_ptr m_Translator; + + void release(); +}; + +} // namespace mo2::tests diff --git a/tests/test_versioning.cpp b/tests/test_versioning.cpp index 08a346fd..3738ebb7 100644 --- a/tests/test_versioning.cpp +++ b/tests/test_versioning.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -88,3 +89,117 @@ TEST(VersioningTest, VersionCompare) v(2, 4, 1, 0, {ReleaseCandidate, 1, 1})); ASSERT_TRUE(v(1, 0, 0) < v(2, 0, 0, Alpha)); } + +TEST(VersioningTest, VersionConstraintTest) +{ + // shortcut + using v = Version; + + constexpr auto MAX = std::numeric_limits::max(); + + auto check = [](const QString& constraint, Version const& v, + Version::ParseMode mode = Version::ParseMode::SemVer) { + return VersionConstraint::parse(constraint, mode).matches(v); + }; + + // inequality + + ASSERT_TRUE(check("2.5.2", v(2, 5, 2))); + ASSERT_FALSE(check("2.5.3", v(2, 5, 2))); + + ASSERT_TRUE(check(">2.5", v(2, 5, 1))); + ASSERT_TRUE(check(">2.5.2", v(2, 5, 3))); + ASSERT_FALSE(check(">2.5.2", v(2, 5, 2))); + ASSERT_FALSE(check(">2.5.3", v(2, 5, 2))); + + ASSERT_TRUE(check("<2.5", v(2, 4, MAX))); + + // wilcard + + ASSERT_TRUE(check("*", v(2, 4, MAX))); + + ASSERT_TRUE(check("2.4.*", v(2, 4, 0))); + ASSERT_TRUE(check("2.4.*", v(2, 4, MAX))); + ASSERT_FALSE(check("2.4.*", v(2, 3, MAX))); + ASSERT_FALSE(check("2.4.*", v(2, 5, 0))); + + // caret + + ASSERT_TRUE(check("^1.2.3", v(1, 2, 3))); + ASSERT_TRUE(check("^1.2.3", v(1, 2, 4))); + ASSERT_TRUE(check("^1.2.3", v(1, 3, 1))); + ASSERT_TRUE(check("^1.2.3", v(1, MAX, 5))); + ASSERT_FALSE(check("^1.2.3", v(1, 2, 2, MAX))); + ASSERT_FALSE(check("^1.2.3", v(1, 1, 0))); + ASSERT_FALSE(check("^1.2.3", v(2, 0, 0))); + + ASSERT_TRUE(check("^1.2", v(1, 2, 0))); + ASSERT_TRUE(check("^1.2", v(1, 2, 4))); + ASSERT_TRUE(check("^1.2", v(1, 3, 1))); + ASSERT_TRUE(check("^1.2", v(1, 9, 5))); + ASSERT_FALSE(check("^1.2", v(1, 1, MAX))); + ASSERT_FALSE(check("^1.2", v(1, 1, 0))); + ASSERT_FALSE(check("^1.2", v(2, 0, 0))); + + ASSERT_TRUE(check("^1", v(1, 0, 0))); + ASSERT_TRUE(check("^1", v(1, 2, 4))); + ASSERT_TRUE(check("^1", v(1, 3, 1))); + ASSERT_TRUE(check("^1", v(1, 9, 5))); + ASSERT_FALSE(check("^1", v(0, MAX, MAX))); + ASSERT_FALSE(check("^1", v(0, MAX, 0))); + ASSERT_FALSE(check("^1", v(2, 0, 0))); + + ASSERT_TRUE(check("^0.2.3", v(0, 2, 3))); + ASSERT_TRUE(check("^0.2.3", v(0, 2, MAX))); + ASSERT_FALSE(check("^0.2.3", v(0, 1, MAX))); + ASSERT_FALSE(check("^0.2.3", v(0, 3, 0))); + + ASSERT_TRUE(check("^0.0.3", v(0, 0, 3))); + ASSERT_TRUE(check("^0.0.3", v(0, 0, 3, MAX))); + ASSERT_FALSE(check("^0.0.3", v(0, 0, 2, MAX))); + ASSERT_FALSE(check("^0.0.3", v(0, 0, 4))); + + ASSERT_TRUE(check("^0.0", v(0, 0, 0))); + ASSERT_TRUE(check("^0.0", v(0, 0, MAX))); + ASSERT_FALSE(check("^0.0", v(0, 1, 0))); + + ASSERT_TRUE(check("^0", v(0, 0, 0))); + ASSERT_TRUE(check("^0", v(0, MAX, MAX, MAX))); + ASSERT_FALSE(check("^0", v(1, 0, 0))); + + // tilde + + ASSERT_TRUE(check("~1.2.3", v(1, 2, 3))); + ASSERT_TRUE(check("~1.2.3", v(1, 2, 3, MAX))); + ASSERT_FALSE(check("~1.2.3", v(1, 2, 2, MAX))); + ASSERT_FALSE(check("~1.2.3", v(1, 3, 0))); + + ASSERT_TRUE(check("~1.2", v(1, 2, 0))); + ASSERT_TRUE(check("~1.2", v(1, 2, MAX, MAX))); + ASSERT_FALSE(check("~1.2", v(1, 1, MAX, MAX))); + ASSERT_FALSE(check("~1.2", v(1, 3, 0))); + + ASSERT_TRUE(check("~1", v(1, 0, 0))); + ASSERT_TRUE(check("~1", v(1, MAX, MAX, MAX))); + ASSERT_FALSE(check("~1", v(0, MAX, MAX, MAX))); + ASSERT_FALSE(check("~1", v(2, 0, 0))); +} + +TEST(VersioningTest, VersionConstraintsTest) +{ + // shortcut + using v = Version; + + auto check = [](const QString& constraints, Version const& v, + Version::ParseMode mode = Version::ParseMode::SemVer) { + return VersionConstraints::parse(constraints, mode).matches(v); + }; + + ASSERT_TRUE(check("2.5.2", v(2, 5, 2))); + ASSERT_FALSE(check("2.5.3", v(2, 5, 2))); + + ASSERT_TRUE(check(">=2.5.0, <2.6.0", v(2, 5, 2))); + ASSERT_FALSE(check(">=2.5.0, <2.6.0", v(2, 6, 0))); + ASSERT_FALSE(check(">=2.5.0, <2.6.0", v(2, 5, 0, Development))); + ASSERT_FALSE(check(">=2.5.0, <2.6.0", v(2, 4, 4))); +} diff --git a/tests/translations/extract_translations.py b/tests/translations/extract_translations.py new file mode 100644 index 00000000..016f6b42 --- /dev/null +++ b/tests/translations/extract_translations.py @@ -0,0 +1,69 @@ +import json +from pathlib import Path + +from PyQt6.lupdate.source_file import SourceFile +from PyQt6.lupdate.translation_file import TranslationFile +from PyQt6.lupdate.translations import Context, Message + +folder = Path(__file__).parent + +tr_files = [ + TranslationFile(path, no_obsolete=False, no_summary=False, verbose=True) + for path in folder.glob("*.ts") +] + +sources: list[SourceFile] = [] + +# add custom source used in test +source = SourceFile(filename="uibase-tests") +context = Context(name="uibase-tests") +context.messages = [ + Message( + filename=__file__, + line_nr=-1, + source="Translate to French", + comment=None, + numerus=None, + ) +] +source.contexts.append(context) +sources.append(source) + + +# add metadata for tests extension +for metadata_path in folder.parent.joinpath("data", "extensions").glob("**/*.json"): + with open(metadata_path, "rb") as fp: + metadata = json.load(fp) + + if "translation-context" not in metadata: + continue + + source = SourceFile(filename=metadata_path) + + context = Context(name=metadata["translation-context"]) + + for key in ("name", "description"): + if key not in metadata: + continue + + context.messages.append( + Message( + filename=metadata_path, + line_nr=-1, + source=metadata[key], + comment=None, + numerus=None, + ) + ) + + source.contexts.append(context) + + sources.append(source) + +for tr_file in tr_files: + for source in sources: + tr_file.update(source) + + +for tr_file in tr_files: + tr_file.write() diff --git a/tests/translations/tests_en.qm b/tests/translations/tests_en.qm index d5ca5019..472bdf10 100644 Binary files a/tests/translations/tests_en.qm and b/tests/translations/tests_en.qm differ diff --git a/tests/translations/tests_en.ts b/tests/translations/tests_en.ts index 1f54d866..607b0b3c 100644 --- a/tests/translations/tests_en.ts +++ b/tests/translations/tests_en.ts @@ -1,9 +1,23 @@ + + mo2-example-extension + + + Example Extension for UI Base Tests + + + + + ModOrganizer2 example extension for UI Base Tests. + + + uibase-tests + Translate to French Translate to French diff --git a/tests/translations/tests_fr.qm b/tests/translations/tests_fr.qm index 80958d6e..101f64b4 100644 Binary files a/tests/translations/tests_fr.qm and b/tests/translations/tests_fr.qm differ diff --git a/tests/translations/tests_fr.ts b/tests/translations/tests_fr.ts index eed02a06..9dae8ee2 100644 --- a/tests/translations/tests_fr.ts +++ b/tests/translations/tests_fr.ts @@ -1,9 +1,23 @@ + + mo2-example-extension + + + Example Extension for UI Base Tests + Extension Démo pour les tests UI Base + + + + ModOrganizer2 example extension for UI Base Tests. + Exemple d'extension ModOrganizer2 pour les tests UI Base. + + uibase-tests + Translate to French Traduction en Français