From 7fe47a9d5b1727a61dc724523b530762f6d6ba19 Mon Sep 17 00:00:00 2001 From: rtk0c Date: Thu, 30 Jun 2022 21:38:53 -0700 Subject: Restructure project --- app/source/Cplt/Model/Assets.cpp | 306 ++++++++ app/source/Cplt/Model/Assets.hpp | 130 ++++ app/source/Cplt/Model/Database.cpp | 163 ++++ app/source/Cplt/Model/Database.hpp | 79 ++ app/source/Cplt/Model/Filter.cpp | 1 + app/source/Cplt/Model/Filter.hpp | 6 + app/source/Cplt/Model/GlobalStates.cpp | 163 ++++ app/source/Cplt/Model/GlobalStates.hpp | 55 ++ app/source/Cplt/Model/Items.cpp | 114 +++ app/source/Cplt/Model/Items.hpp | 253 ++++++ app/source/Cplt/Model/Project.cpp | 168 ++++ app/source/Cplt/Model/Project.hpp | 57 ++ app/source/Cplt/Model/Template/TableTemplate.cpp | 591 ++++++++++++++ app/source/Cplt/Model/Template/TableTemplate.hpp | 223 ++++++ .../Cplt/Model/Template/TableTemplateIterator.cpp | 52 ++ .../Cplt/Model/Template/TableTemplateIterator.hpp | 35 + app/source/Cplt/Model/Template/Template.hpp | 68 ++ app/source/Cplt/Model/Template/Template_Main.cpp | 214 ++++++ app/source/Cplt/Model/Template/Template_RTTI.cpp | 29 + app/source/Cplt/Model/Template/fwd.hpp | 11 + app/source/Cplt/Model/Workflow/Evaluation.cpp | 174 +++++ app/source/Cplt/Model/Workflow/Evaluation.hpp | 67 ++ .../Cplt/Model/Workflow/Nodes/DocumentNodes.cpp | 18 + .../Cplt/Model/Workflow/Nodes/DocumentNodes.hpp | 13 + .../Cplt/Model/Workflow/Nodes/NumericNodes.cpp | 94 +++ .../Cplt/Model/Workflow/Nodes/NumericNodes.hpp | 44 ++ app/source/Cplt/Model/Workflow/Nodes/TextNodes.cpp | 231 ++++++ app/source/Cplt/Model/Workflow/Nodes/TextNodes.hpp | 53 ++ .../Cplt/Model/Workflow/Nodes/UserInputNodes.cpp | 32 + .../Cplt/Model/Workflow/Nodes/UserInputNodes.hpp | 23 + app/source/Cplt/Model/Workflow/Nodes/fwd.hpp | 15 + app/source/Cplt/Model/Workflow/Value.hpp | 94 +++ app/source/Cplt/Model/Workflow/ValueInternals.hpp | 21 + app/source/Cplt/Model/Workflow/Value_Main.cpp | 35 + app/source/Cplt/Model/Workflow/Value_RTTI.cpp | 174 +++++ app/source/Cplt/Model/Workflow/Values/Basic.cpp | 111 +++ app/source/Cplt/Model/Workflow/Values/Basic.hpp | 67 ++ app/source/Cplt/Model/Workflow/Values/Database.cpp | 88 +++ app/source/Cplt/Model/Workflow/Values/Database.hpp | 51 ++ .../Cplt/Model/Workflow/Values/Dictionary.cpp | 49 ++ .../Cplt/Model/Workflow/Values/Dictionary.hpp | 25 + app/source/Cplt/Model/Workflow/Values/List.cpp | 100 +++ app/source/Cplt/Model/Workflow/Values/List.hpp | 50 ++ app/source/Cplt/Model/Workflow/Values/fwd.hpp | 17 + app/source/Cplt/Model/Workflow/Workflow.hpp | 316 ++++++++ app/source/Cplt/Model/Workflow/Workflow_Main.cpp | 846 +++++++++++++++++++++ app/source/Cplt/Model/Workflow/Workflow_RTTI.cpp | 143 ++++ app/source/Cplt/Model/Workflow/fwd.hpp | 22 + app/source/Cplt/Model/fwd.hpp | 35 + 49 files changed, 5726 insertions(+) create mode 100644 app/source/Cplt/Model/Assets.cpp create mode 100644 app/source/Cplt/Model/Assets.hpp create mode 100644 app/source/Cplt/Model/Database.cpp create mode 100644 app/source/Cplt/Model/Database.hpp create mode 100644 app/source/Cplt/Model/Filter.cpp create mode 100644 app/source/Cplt/Model/Filter.hpp create mode 100644 app/source/Cplt/Model/GlobalStates.cpp create mode 100644 app/source/Cplt/Model/GlobalStates.hpp create mode 100644 app/source/Cplt/Model/Items.cpp create mode 100644 app/source/Cplt/Model/Items.hpp create mode 100644 app/source/Cplt/Model/Project.cpp create mode 100644 app/source/Cplt/Model/Project.hpp create mode 100644 app/source/Cplt/Model/Template/TableTemplate.cpp create mode 100644 app/source/Cplt/Model/Template/TableTemplate.hpp create mode 100644 app/source/Cplt/Model/Template/TableTemplateIterator.cpp create mode 100644 app/source/Cplt/Model/Template/TableTemplateIterator.hpp create mode 100644 app/source/Cplt/Model/Template/Template.hpp create mode 100644 app/source/Cplt/Model/Template/Template_Main.cpp create mode 100644 app/source/Cplt/Model/Template/Template_RTTI.cpp create mode 100644 app/source/Cplt/Model/Template/fwd.hpp create mode 100644 app/source/Cplt/Model/Workflow/Evaluation.cpp create mode 100644 app/source/Cplt/Model/Workflow/Evaluation.hpp create mode 100644 app/source/Cplt/Model/Workflow/Nodes/DocumentNodes.cpp create mode 100644 app/source/Cplt/Model/Workflow/Nodes/DocumentNodes.hpp create mode 100644 app/source/Cplt/Model/Workflow/Nodes/NumericNodes.cpp create mode 100644 app/source/Cplt/Model/Workflow/Nodes/NumericNodes.hpp create mode 100644 app/source/Cplt/Model/Workflow/Nodes/TextNodes.cpp create mode 100644 app/source/Cplt/Model/Workflow/Nodes/TextNodes.hpp create mode 100644 app/source/Cplt/Model/Workflow/Nodes/UserInputNodes.cpp create mode 100644 app/source/Cplt/Model/Workflow/Nodes/UserInputNodes.hpp create mode 100644 app/source/Cplt/Model/Workflow/Nodes/fwd.hpp create mode 100644 app/source/Cplt/Model/Workflow/Value.hpp create mode 100644 app/source/Cplt/Model/Workflow/ValueInternals.hpp create mode 100644 app/source/Cplt/Model/Workflow/Value_Main.cpp create mode 100644 app/source/Cplt/Model/Workflow/Value_RTTI.cpp create mode 100644 app/source/Cplt/Model/Workflow/Values/Basic.cpp create mode 100644 app/source/Cplt/Model/Workflow/Values/Basic.hpp create mode 100644 app/source/Cplt/Model/Workflow/Values/Database.cpp create mode 100644 app/source/Cplt/Model/Workflow/Values/Database.hpp create mode 100644 app/source/Cplt/Model/Workflow/Values/Dictionary.cpp create mode 100644 app/source/Cplt/Model/Workflow/Values/Dictionary.hpp create mode 100644 app/source/Cplt/Model/Workflow/Values/List.cpp create mode 100644 app/source/Cplt/Model/Workflow/Values/List.hpp create mode 100644 app/source/Cplt/Model/Workflow/Values/fwd.hpp create mode 100644 app/source/Cplt/Model/Workflow/Workflow.hpp create mode 100644 app/source/Cplt/Model/Workflow/Workflow_Main.cpp create mode 100644 app/source/Cplt/Model/Workflow/Workflow_RTTI.cpp create mode 100644 app/source/Cplt/Model/Workflow/fwd.hpp create mode 100644 app/source/Cplt/Model/fwd.hpp (limited to 'app/source/Cplt/Model') diff --git a/app/source/Cplt/Model/Assets.cpp b/app/source/Cplt/Model/Assets.cpp new file mode 100644 index 0000000..0dfe847 --- /dev/null +++ b/app/source/Cplt/Model/Assets.cpp @@ -0,0 +1,306 @@ +#include "Assets.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace std::literals::string_view_literals; +namespace fs = std::filesystem; + +template +void OperateStreamForSavedAsset(TSavedAsset& cell, TStream& proxy) +{ + proxy.template ObjectAdapted(cell.Name); + proxy.template ObjectAdapted(cell.Uuid); + proxy.Value(cell.Payload); +} + +void SavedAsset::ReadFromDataStream(InputDataStream& stream) +{ + ::OperateStreamForSavedAsset(*this, stream); +} + +void SavedAsset::WriteToDataStream(OutputDataStream& stream) const +{ + ::OperateStreamForSavedAsset(*this, stream); +} + +Asset::Asset() = default; + +class AssetList::Private +{ +public: + Project* ConnectedProject; + tsl::array_map Assets; + tsl::array_map> Cache; + int CacheSizeLimit = 0; + + struct + { + std::string NewName; + NameSelectionError NewNameError = NameSelectionError::Empty; + + void ShowErrors() const + { + switch (NewNameError) { + case NameSelectionError::None: break; + case NameSelectionError::Duplicated: + ImGui::ErrorMessage(I18N_TEXT("Duplicate name", L10N_DUPLICATE_NAME_ERROR)); + break; + case NameSelectionError::Empty: + ImGui::ErrorMessage(I18N_TEXT("Name cannot be empty", L10N_EMPTY_NAME_ERROR)); + break; + } + } + + bool HasErrors() const + { + return NewNameError != NameSelectionError::None; + } + + void Validate(const AssetList& self) + { + if (NewName.empty()) { + NewNameError = NameSelectionError::Empty; + return; + } + + if (self.FindByName(NewName)) { + NewNameError = NameSelectionError::Duplicated; + return; + } + + NewNameError = NameSelectionError::None; + } + } PopupPrivateState; +}; + +AssetList::AssetList(Project& project) + : mPrivate{ std::make_unique() } +{ + mPrivate->ConnectedProject = &project; +} + +// Write an empty destructor here so std::unique_ptr's destructor can see AssetList::Private's implementation +AssetList::~AssetList() +{ +} + +Project& AssetList::GetConnectedProject() const +{ + return *mPrivate->ConnectedProject; +} + +void AssetList::Reload() +{ + // TODO fix asset dicovery loading + mPrivate->Assets.clear(); + mPrivate->Cache.clear(); + DiscoverFiles([this](SavedAsset asset) -> void { + mPrivate->Assets.insert(asset.Name, std::move(asset)); + }); +} + +int AssetList::GetCount() const +{ + return mPrivate->Assets.size(); +} + +const tsl::array_map& AssetList::GetAssets() const +{ + return mPrivate->Assets; +} + +const SavedAsset* AssetList::FindByName(std::string_view name) const +{ + auto iter = mPrivate->Assets.find(name); + if (iter != mPrivate->Assets.end()) { + return &iter.value(); + } else { + return nullptr; + } +} + +const SavedAsset& AssetList::Create(SavedAsset asset) +{ + auto [iter, DISCARD] = mPrivate->Assets.insert(asset.Name, SavedAsset{}); + auto& savedAsset = iter.value(); + + savedAsset = std::move(asset); + if (savedAsset.Uuid.is_nil()) { + savedAsset.Uuid = uuids::uuid_random_generator{}(); + } + + SaveInstance(savedAsset, nullptr); + + return savedAsset; +} + +std::unique_ptr AssetList::CreateAndLoad(SavedAsset assetIn) +{ + auto& savedAsset = Create(std::move(assetIn)); + auto asset = std::unique_ptr(CreateInstance(savedAsset)); + return asset; +} + +std::unique_ptr AssetList::Load(std::string_view name) const +{ + if (auto savedAsset = FindByName(name)) { + auto asset = Load(*savedAsset); + return asset; + } else { + return nullptr; + } +} + +std::unique_ptr AssetList::Load(const SavedAsset& asset) const +{ + return std::unique_ptr(LoadInstance(asset)); +} + +const SavedAsset* AssetList::Rename(std::string_view oldName, std::string_view newName) +{ + auto iter = mPrivate->Assets.find(oldName); + if (iter == mPrivate->Assets.end()) return nullptr; + + auto info = std::move(iter.value()); + info.Name = newName; + + RenameInstanceOnDisk(info, oldName); + + mPrivate->Assets.erase(iter); + auto [newIter, DISCARD] = mPrivate->Assets.insert(newName, std::move(info)); + + return &newIter.value(); +} + +bool AssetList::Remove(std::string_view name) +{ + auto iter = mPrivate->Assets.find(name); + if (iter == mPrivate->Assets.end()) { + return false; + } + auto& asset = iter.value(); + + fs::remove(RetrievePathFromAsset(asset)); + mPrivate->Assets.erase(iter); + + return true; +} + +int AssetList::GetCacheSizeLimit() const +{ + return mPrivate->CacheSizeLimit; +} + +void AssetList::SetCacheSizeLimit(int limit) +{ + mPrivate->CacheSizeLimit = limit; +} + +void AssetList::DisplayIconsList(ListState& state) +{ + // TODO +} + +void AssetList::DisplayDetailsList(ListState& state) +{ + // Note: stub function remained here in case any state processing needs to be done before issuing to implementers + DisplayDetailsTable(state); +} + +void AssetList::DisplayControls(ListState& state) +{ + auto& ps = mPrivate->PopupPrivateState; + bool openedDummy = true; + + if (ImGui::Button(ICON_FA_PLUS " " I18N_TEXT("Add", L10N_ADD))) { + ImGui::OpenPopup(I18N_TEXT("Add asset wizard", L10N_ADD_ASSET_DIALOG_TITLE)); + } + if (ImGui::BeginPopupModal(I18N_TEXT("Add asset wizard", L10N_ADD_ASSET_DIALOG_TITLE), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) { + DisplayAssetCreator(state); + ImGui::EndPopup(); + } + + ImGui::SameLine(); + if (ImGui::Button(ICON_FA_I_CURSOR " " I18N_TEXT("Rename", L10N_RENAME), state.SelectedAsset == nullptr)) { + ImGui::OpenPopup(I18N_TEXT("Rename asset wizard", L10N_RENAME_ASSET_DIALOG_TITLE)); + } + if (ImGui::BeginPopupModal(I18N_TEXT("Rename asset wizard", L10N_RENAME_ASSET_DIALOG_TITLE), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) { + if (ImGui::InputText(I18N_TEXT("Name", L10N_NAME), &ps.NewName)) { + ps.Validate(*this); + } + + if (ImGui::Button(I18N_TEXT("Confirm", L10N_CONFIRM), ps.HasErrors())) { + ImGui::CloseCurrentPopup(); + + auto movedAsset = Rename(state.SelectedAsset->Name, ps.NewName); + // Update the selected pointer to the new location (we mutated the map, the pointer may be invalid now) + state.SelectedAsset = movedAsset; + + ps = {}; + } + ImGui::SameLine(); + if (ImGui::Button(I18N_TEXT("Cancel", L10N_CANCEL))) { + ImGui::CloseCurrentPopup(); + } + + ps.ShowErrors(); + + ImGui::EndPopup(); + } + + ImGui::SameLine(); + if (ImGui::Button(ICON_FA_TRASH " " I18N_TEXT("Delete", L10N_DELETE), state.SelectedAsset == nullptr)) { + ImGui::OpenPopup(I18N_TEXT("Delete asset", L10N_DELETE_ASSET_DIALOG_TITLE)); + } + if (ImGui::BeginPopupModal(I18N_TEXT("Delete asset", L10N_DELETE_ASSET_DIALOG_TITLE), &openedDummy, ImGuiWindowFlags_AlwaysAutoResize)) { + if (ImGui::Button(I18N_TEXT("Confirm", L10N_CONFIRM))) { + ImGui::CloseCurrentPopup(); + + auto& assetName = state.SelectedAsset->Name; + Remove(assetName); + + state.SelectedAsset = nullptr; + } + ImGui::SameLine(); + if (ImGui::Button(I18N_TEXT("Cancel", L10N_CANCEL))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } +} + +void AssetList::DiscoverFilesByExtension(const std::function& callback, const fs::path& containerDir, std::string_view extension) const +{ + for (auto entry : fs::directory_iterator(containerDir)) { + if (!entry.is_regular_file()) continue; + + // If the caller provided an extension to match against, and it doesn't equal to current file extension, skip + if (!extension.empty() && + entry.path().extension() != extension) + { + continue; + } + + callback(SavedAsset{ + .Name = RetrieveNameFromFile(entry.path()), + .Uuid = RetrieveUuidFromFile(entry.path()), + // TODO load payload + }); + } +} + +void AssetList::DiscoverFilesByHeader(const std::function& callback, const fs::path& containerDir, const std::function& validater) const +{ + // TODO +} diff --git a/app/source/Cplt/Model/Assets.hpp b/app/source/Cplt/Model/Assets.hpp new file mode 100644 index 0000000..d2f8570 --- /dev/null +++ b/app/source/Cplt/Model/Assets.hpp @@ -0,0 +1,130 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include + +/// A structure representing a ready-to-be-loaded asset, locating on the disk. +/// Each asset should be identified by a unique uuid within the asset category (i.e. a workflow and a template can share the same uuid), +/// generated on insertion to an asset list if not given by the caller. +struct SavedAsset +{ + std::string Name; + /// UUID of this asset. This field is generated as a random UUID v4 upon insertion into an AssetList, if not already provided by the caller (indicated by !is_nil()). + uuids::uuid Uuid; + /// Extra data to be used by the AssetList/Asset implementation. + uint64_t Payload; + + void ReadFromDataStream(InputDataStream& stream); + void WriteToDataStream(OutputDataStream& stream) const; +}; + +class Asset +{ +public: + Asset(); + virtual ~Asset() = default; +}; + +enum class NameSelectionError +{ + None, + Duplicated, + Empty, +}; + +class AssetList +{ +private: + class Private; + std::unique_ptr mPrivate; + +public: + AssetList(Project& project); + virtual ~AssetList(); + + Project& GetConnectedProject() const; + + // TODO support file watches + void Reload(); + + int GetCount() const; + // TODO convert to custom iterable + const tsl::array_map& GetAssets() const; + + const SavedAsset* FindByName(std::string_view name) const; + const SavedAsset& Create(SavedAsset asset); + std::unique_ptr CreateAndLoad(SavedAsset asset); + /// Load the asset on disk by its name. + std::unique_ptr Load(std::string_view name) const; + /// Load the asset on disk by a reference to its SavedAsset instance. This function assumes that the given SavedAsset + /// is stored in AssetList, otherwise the behavior is undefined. + std::unique_ptr Load(const SavedAsset& asset) const; + const SavedAsset* Rename(std::string_view oldName, std::string_view newName); + bool Remove(std::string_view name); + + int GetCacheSizeLimit() const; + void SetCacheSizeLimit(int limit); + + struct ListState + { + const SavedAsset* SelectedAsset = nullptr; + }; + void DisplayIconsList(ListState& state); + void DisplayDetailsList(ListState& state); + void DisplayControls(ListState& state); + +protected: + virtual void DiscoverFiles(const std::function& callback) const = 0; + + // Helper + void DiscoverFilesByExtension(const std::function& callback, const std::filesystem::path& containerDir, std::string_view extension) const; + void DiscoverFilesByHeader(const std::function& callback, const std::filesystem::path& containerDir, const std::function& validater) const; + + /// Create an empty/default instance of this asset type on disk, potentially qualified by SavedAsset::Payload. + /// Return `true` on success and `false` on failure. + virtual bool SaveInstance(const SavedAsset& assetInfo, const Asset* asset) const = 0; + /// The returned pointer indicate ownership to the object. + virtual Asset* LoadInstance(const SavedAsset& assetInfo) const = 0; + /// Create an empty/default instance of this asset type, potentially qualified by SavedAsset::Payload. + /// The returned pointer indicate ownership to the object. + virtual Asset* CreateInstance(const SavedAsset& assetInfo) const = 0; + virtual bool RenameInstanceOnDisk(const SavedAsset& assetInfo, std::string_view oldName) const = 0; + + virtual std::string RetrieveNameFromFile(const std::filesystem::path& file) const = 0; + virtual uuids::uuid RetrieveUuidFromFile(const std::filesystem::path& file) const = 0; + virtual std::filesystem::path RetrievePathFromAsset(const SavedAsset& asset) const = 0; + + virtual void DisplayAssetCreator(ListState& state) = 0; + virtual void DisplayDetailsTable(ListState& state) const = 0; +}; + +template +class AssetListTyped : public AssetList +{ +public: + using AssetList::AssetList; + +#pragma clang diagnostic push +#pragma ide diagnostic ignored "HidingNonVirtualFunction" + std::unique_ptr CreateAndLoad(SavedAsset asset) + { + return std::unique_ptr(static_cast(AssetList::CreateAndLoad(asset).release())); + } + + std::unique_ptr Load(std::string_view name) const + { + return std::unique_ptr(static_cast(AssetList::Load(name).release())); + } + + std::unique_ptr Load(const SavedAsset& asset) const + { + return std::unique_ptr(static_cast(AssetList::Load(asset).release())); + } +#pragma clang diagnostic pop +}; diff --git a/app/source/Cplt/Model/Database.cpp b/app/source/Cplt/Model/Database.cpp new file mode 100644 index 0000000..07c6e36 --- /dev/null +++ b/app/source/Cplt/Model/Database.cpp @@ -0,0 +1,163 @@ +#include "Database.hpp" + +#include + +#include +#include + +namespace fs = std::filesystem; + +SalesTable::SalesTable(MainDatabase& db) + // language=SQLite + : GetRowCount(db.GetSQLite(), "SELECT Count(*) FROM Sales") + // language=SQLite + , GetRows(db.GetSQLite(), "SELECT * FROM Sales LIMIT ? OFFSET ?") + // language=SQLite + , GetItems(db.GetSQLite(), "SELECT * FROM SalesItems WHERE SaleId == ?") +{ +} + +PurchasesTable::PurchasesTable(MainDatabase& db) + // language=SQLite + : GetRowCount(db.GetSQLite(), "SELECT Count(*) FROM Purchases") + // language=SQLite + , GetRows(db.GetSQLite(), "SELECT * FROM Purchases LIMIT ? OFFSET ?") + // language=SQLite + , GetItems(db.GetSQLite(), "SELECT * FROM PurchasesItems WHERE PurchaseId == ?") +{ +} + +DeliveryTable::DeliveryTable(MainDatabase& db) + // language=SQLite + : FilterByTypeAndId(db.GetSQLite(), "SELECT * FROM Deliveries WHERE AssociatedOrder == ? AND Outgoing = ?") + // language=SQLite + , GetItems(db.GetSQLite(), "SELECT * FROM DeliveriesItems WHERE DeliveryId == ?") +{ +} + +static std::string GetDatabaseFilePath(const Project& project) +{ + auto dbsDir = project.GetPath() / "databases"; + fs::create_directories(dbsDir); + + auto dbFile = dbsDir / "transactions.sqlite3"; + return dbFile.string(); +} + +/// Wrapper for SQLite::Database that creates the default tables +MainDatabase::DatabaseWrapper::DatabaseWrapper(MainDatabase& self) + : mSqlite(GetDatabaseFilePath(*self.mProject), SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE) +{ + // If this table doesn't exist, the database probably just got initialized + if (mSqlite.tableExists("Sales")) { + return; + } + + // 'Sales' schema + // - Customer: the customer item ID + // - Deadline: unix epoch time of order deadline + // - DeliveryTime: the time this order was completed (through a set of deliveries) + + // 'Purchases' schema + // - Factory: the factory id, + // - OrderTime: the time this order was made + // - DeliveryTime: the time this order was completed (through a set of deliveries) + + // 'Deliveries' schema + // - ShipmentTime: unix epoch time stamp of sending to delivery + // - ArrivalTime: unix epoch time stamp of delivery arrived at warehouse; 0 if not arrived yet + // - AssociatedOrder: Id of the order that this delivery is completing (which table: Outgoing=true -> Sales, Outgoing=false -> Purchases) + // - Outgoing: true if the delivery is from warehouse to customer; false if the delivery is from factory to warehouse + + // Note: the 'Id' key would be unique (not recycled after row deletion) because it's explicit + // https://www.sqlite.org/rowidtable.html + + // language=SQLite + mSqlite.exec(R"""( +CREATE TABLE IF NOT EXISTS Sales( + Id INT PRIMARY KEY, + Customer INT, + Deadline DATETIME, + DeliveryTime DATETIME +); +CREATE TABLE IF NOT EXISTS SalesItems( + SaleId INT, + ItemId INT, + Count INT +); + +CREATE TABLE IF NOT EXISTS Purchases( + Id INT PRIMARY KEY, + Factory INT, + OrderTime DATETIME, + DeliveryTime DATETIME +); +CREATE TABLE IF NOT EXISTS PurchasesItems( + PurchaseId INT, + ItemId INT, + Count INT +); + +CREATE TABLE IF NOT EXISTS Deliveries( + Id INT PRIMARY KEY, + ShipmentTime DATETIME, + ArrivalTime DATETIME, + AssociatedOrder INT, + Outgoing BOOLEAN +); +CREATE TABLE IF NOT EXISTS DeliveriesItems( + DeliveryId INT, + ItemId INT, + Count INT +); +)"""); +} + +MainDatabase::MainDatabase(Project& project) + : mProject{ &project } + , mDbWrapper(*this) + , mSales(*this) + , mPurchases(*this) + , mDeliveries(*this) +{ +} + +const SQLite::Database& MainDatabase::GetSQLite() const +{ + return mDbWrapper.mSqlite; +} + +SQLite::Database& MainDatabase::GetSQLite() +{ + return mDbWrapper.mSqlite; +} + +const SalesTable& MainDatabase::GetSales() const +{ + return mSales; +} + +SalesTable& MainDatabase::GetSales() +{ + return mSales; +} + +const PurchasesTable& MainDatabase::GetPurchases() const +{ + return mPurchases; +} + +PurchasesTable& MainDatabase::GetPurchases() +{ + return mPurchases; +} + +const DeliveryTable& MainDatabase::GetDeliveries() const +{ + return mDeliveries; +} + +DeliveryTable& MainDatabase::GetDeliveries() +{ + return mDeliveries; +} diff --git a/app/source/Cplt/Model/Database.hpp b/app/source/Cplt/Model/Database.hpp new file mode 100644 index 0000000..222e43d --- /dev/null +++ b/app/source/Cplt/Model/Database.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include + +#include +#include +#include + +enum class TableKind +{ + Sales, + SalesItems, + Purchases, + PurchasesItems, + Deliveries, + DeliveriesItems, +}; + +class SalesTable +{ +public: + SQLite::Statement GetRowCount; + SQLite::Statement GetRows; + SQLite::Statement GetItems; + +public: + SalesTable(MainDatabase& db); +}; + +class PurchasesTable +{ +public: + SQLite::Statement GetRowCount; + SQLite::Statement GetRows; + SQLite::Statement GetItems; + +public: + PurchasesTable(MainDatabase& db); +}; + +class DeliveryTable +{ +public: + SQLite::Statement FilterByTypeAndId; + SQLite::Statement GetItems; + +public: + DeliveryTable(MainDatabase& db); +}; + +class MainDatabase +{ +private: + class DatabaseWrapper + { + public: + SQLite::Database mSqlite; + DatabaseWrapper(MainDatabase& self); + }; + + Project* mProject; + DatabaseWrapper mDbWrapper; + SalesTable mSales; + PurchasesTable mPurchases; + DeliveryTable mDeliveries; + +public: + MainDatabase(Project& project); + + const SQLite::Database& GetSQLite() const; + SQLite::Database& GetSQLite(); + + const SalesTable& GetSales() const; + SalesTable& GetSales(); + const PurchasesTable& GetPurchases() const; + PurchasesTable& GetPurchases(); + const DeliveryTable& GetDeliveries() const; + DeliveryTable& GetDeliveries(); +}; diff --git a/app/source/Cplt/Model/Filter.cpp b/app/source/Cplt/Model/Filter.cpp new file mode 100644 index 0000000..1e4b31b --- /dev/null +++ b/app/source/Cplt/Model/Filter.cpp @@ -0,0 +1 @@ +#include "Filter.hpp" diff --git a/app/source/Cplt/Model/Filter.hpp b/app/source/Cplt/Model/Filter.hpp new file mode 100644 index 0000000..1b923e1 --- /dev/null +++ b/app/source/Cplt/Model/Filter.hpp @@ -0,0 +1,6 @@ +#pragma once + +class TableRowsFilter +{ + // TODO +}; diff --git a/app/source/Cplt/Model/GlobalStates.cpp b/app/source/Cplt/Model/GlobalStates.cpp new file mode 100644 index 0000000..417514f --- /dev/null +++ b/app/source/Cplt/Model/GlobalStates.cpp @@ -0,0 +1,163 @@ +#include "GlobalStates.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static std::unique_ptr globalStateInstance; +static fs::path globalDataPath; + +void GlobalStates::Init() +{ + Init(StandardDirectories::UserData() / "cplt"); +} + +void GlobalStates::Init(std::filesystem::path userDataDir) +{ + globalStateInstance = std::make_unique(); + globalDataPath = userDataDir; + fs::create_directories(globalDataPath); + + // Reading recent projects + { + std::ifstream ifs(globalDataPath / "recents.json"); + if (!ifs) return; + + Json::Value root; + ifs >> root; + + if (!root.isObject()) return; + if (auto& recents = root["RecentProjects"]; recents.isArray()) { + for (auto& elm : recents) { + if (!elm.isString()) continue; + + fs::path path(elm.asCString()); + if (!fs::exists(path)) continue; + + auto utf8String = path.string(); + globalStateInstance->mRecentProjects.push_back(RecentProject{ + .Path = std::move(path), + .CachedUtf8String = std::move(utf8String), + }); + } + } + } +} + +void GlobalStates::Shutdown() +{ + if (!globalStateInstance) return; + + globalStateInstance->SetCurrentProject(nullptr); + + if (globalStateInstance->mDirty) { + globalStateInstance->WriteToDisk(); + } +} + +GlobalStates& GlobalStates::GetInstance() +{ + return *globalStateInstance; +} + +const std::filesystem::path& GlobalStates::GetUserDataPath() +{ + return globalDataPath; +} + +const std::vector& GlobalStates::GetRecentProjects() const +{ + return mRecentProjects; +} + +void GlobalStates::ClearRecentProjects() +{ + mRecentProjects.clear(); + MarkDirty(); +} + +void GlobalStates::AddRecentProject(const Project& project) +{ + mRecentProjects.push_back(RecentProject{ + .Path = project.GetPath(), + .CachedUtf8String = project.GetPath().string(), + }); + MarkDirty(); +} + +void GlobalStates::MoveProjectToTop(const Project& project) +{ + for (auto it = mRecentProjects.begin(); it != mRecentProjects.end(); ++it) { + if (it->Path == project.GetPath()) { + std::rotate(it, it + 1, mRecentProjects.end()); + MarkDirty(); + return; + } + } + AddRecentProject(project); +} + +void GlobalStates::RemoveRecentProject(int idx) +{ + assert(idx >= 0 && idx < mRecentProjects.size()); + + mRecentProjects.erase(mRecentProjects.begin() + idx); + MarkDirty(); +} + +bool GlobalStates::HasCurrentProject() const +{ + return mCurrentProject != nullptr; +} + +Project* GlobalStates::GetCurrentProject() const +{ + return mCurrentProject.get(); +} + +void GlobalStates::SetCurrentProject(std::unique_ptr project) +{ + if (mCurrentProject) { + mCurrentProject->WriteToDisk(); + mCurrentProject = nullptr; + } + if (project) { + MoveProjectToTop(*project); + } + mCurrentProject = std::move(project); +} + +void GlobalStates::WriteToDisk() const +{ + Json::Value root; + + auto& recentProjects = root["RecentProjects"] = Json::Value(Json::arrayValue); + for (auto& [path, _] : mRecentProjects) { + recentProjects.append(Json::Value(path.string())); + } + + std::ofstream ofs(globalDataPath / "recents.json"); + ofs << root; + + mDirty = false; +} + +bool GlobalStates::IsDirty() const +{ + return mDirty; +} + +void GlobalStates::MarkDirty() +{ + mDirty = true; + OnModified(); +} diff --git a/app/source/Cplt/Model/GlobalStates.hpp b/app/source/Cplt/Model/GlobalStates.hpp new file mode 100644 index 0000000..1eb47fb --- /dev/null +++ b/app/source/Cplt/Model/GlobalStates.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include +#include +#include + +class GlobalStates +{ +public: + static void Init(); + static void Init(std::filesystem::path userDataDir); + static void Shutdown(); + + static GlobalStates& GetInstance(); + static const std::filesystem::path& GetUserDataPath(); + + struct RecentProject + { + std::filesystem::path Path; + std::string CachedUtf8String; + }; + +public: + Signal<> OnModified; + +private: + std::vector mRecentProjects; + std::unique_ptr mCurrentProject; + mutable bool mDirty = false; + +public: + const std::vector& GetRecentProjects() const; + void ClearRecentProjects(); + void AddRecentProject(const Project& project); + /// Move or add the project to end of the recent projects list. + /// If the project is not in the list of recently used projects, it will be appended, otherwise + /// it will be moved to the end. + void MoveProjectToTop(const Project& project); + void RemoveRecentProject(int idx); + + bool HasCurrentProject() const; + Project* GetCurrentProject() const; + void SetCurrentProject(std::unique_ptr project); + + // TODO async autosaving to prevent data loss on crash + void WriteToDisk() const; + + bool IsDirty() const; + +private: + void MarkDirty(); +}; diff --git a/app/source/Cplt/Model/Items.cpp b/app/source/Cplt/Model/Items.cpp new file mode 100644 index 0000000..9d2abc6 --- /dev/null +++ b/app/source/Cplt/Model/Items.cpp @@ -0,0 +1,114 @@ +#include "Items.hpp" + +const std::string& ProductItem::GetDescription() const +{ + return mDescription; +} + +void ProductItem::SetDescription(std::string description) +{ + mDescription = std::move(description); +} + +int ProductItem::GetPrice() const +{ + return mPrice; +} +void ProductItem::SetPrice(int price) +{ + mPrice = price; +} + +int ProductItem::GetStock() const +{ + return mStock; +} + +void ProductItem::SetStock(int stock) +{ + mStock = stock; +} + +Json::Value ProductItem::Serialize() const +{ + Json::Value elm; + elm["Description"] = mDescription; + elm["Price"] = mPrice; + elm["Stock"] = mStock; + return elm; +} + +void ProductItem::Deserialize(const Json::Value& elm) +{ + mDescription = elm["Description"].asString(); + mPrice = elm["Price"].asInt(); + mStock = elm["Stock"].asInt(); +} + +const std::string& FactoryItem::GetDescription() const +{ + return mDescription; +} + +void FactoryItem::SetDescription(std::string description) +{ + mDescription = std::move(description); +} + +const std::string& FactoryItem::GetEmail() const +{ + return mEmail; +} + +void FactoryItem::SetEmail(std::string email) +{ + mEmail = std::move(email); +} + +Json::Value FactoryItem::Serialize() const +{ + Json::Value elm; + elm["Description"] = mDescription; + elm["Email"] = mEmail; + return elm; +} + +void FactoryItem::Deserialize(const Json::Value& elm) +{ + mDescription = elm["Description"].asString(); + mEmail = elm["Email"].asString(); +} + +const std::string& CustomerItem::GetDescription() const +{ + return mDescription; +} + +void CustomerItem::SetDescription(std::string description) +{ + mDescription = std::move(description); +} + +const std::string& CustomerItem::GetEmail() const +{ + return mEmail; +} + +void CustomerItem::SetEmail(std::string email) +{ + mEmail = std::move(email); +} + +Json::Value CustomerItem::Serialize() const +{ + Json::Value elm; + elm["Description"] = mDescription; + elm["Email"] = mEmail; + return elm; +} + +void CustomerItem::Deserialize(const Json::Value& elm) +{ + mDescription = elm["Description"].asString(); + mEmail = elm["Email"].asString(); +} diff --git a/app/source/Cplt/Model/Items.hpp b/app/source/Cplt/Model/Items.hpp new file mode 100644 index 0000000..c00ee59 --- /dev/null +++ b/app/source/Cplt/Model/Items.hpp @@ -0,0 +1,253 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +template +class ItemList +{ +private: + std::vector mStorage; + tsl::array_map mNameLookup; + +public: + template + T& Insert(std::string name, Args... args) + { + auto iter = mNameLookup.find(name); + if (iter != mNameLookup.end()) { + throw std::runtime_error("Duplicate key."); + } + + for (size_t i = 0; i < mStorage.size(); ++i) { + if (mStorage[i].IsInvalid()) { + mStorage[i] = T(*this, i, std::move(name), std::forward(args)...); + mNameLookup.insert(name, i); + return mStorage[i]; + } + } + + size_t id = mStorage.size(); + mNameLookup.insert(name, id); + mStorage.emplace_back(*this, id, std::move(name), std::forward(args)...); + return mStorage[id]; + } + + void Remove(size_t index) + { + auto& item = mStorage[index]; + mNameLookup.erase(item.GetName()); + mStorage[index] = T(*this); + } + + T* Find(size_t id) + { + return &mStorage[id]; + } + + const T* Find(size_t id) const + { + return &mStorage[id]; + } + + const T* Find(std::string_view name) const + { + auto iter = mNameLookup.find(name); + if (iter != mNameLookup.end()) { + return &mStorage[iter.value()]; + } else { + return nullptr; + } + } + + Json::Value Serialize() const + { + Json::Value items(Json::arrayValue); + for (auto& item : mStorage) { + if (!item.IsInvalid()) { + auto elm = item.Serialize(); + elm["Id"] = item.GetId(); + elm["Name"] = item.GetName(); + items.append(elm); + } + } + + Json::Value root; + root["MaxItemId"] = mStorage.size(); + root["Items"] = std::move(items); + + return root; + } + + ItemList() = default; + + ItemList(const Json::Value& root) + { + constexpr const char* kMessage = "Failed to load item list from JSON."; + + auto& itemCount = root["MaxItemId"]; + if (!itemCount.isIntegral()) throw std::runtime_error(kMessage); + + mStorage.resize(itemCount.asInt64(), T(*this)); + + auto& items = root["Items"]; + if (!items.isArray()) throw std::runtime_error(kMessage); + + for (auto& elm : items) { + if (!elm.isObject()) throw std::runtime_error(kMessage); + + auto& id = elm["Id"]; + if (!id.isIntegral()) throw std::runtime_error(kMessage); + auto& name = elm["Name"]; + if (!name.isString()) throw std::runtime_error(kMessage); + + size_t iid = id.asInt64(); + mStorage[iid] = T(*this, iid, name.asString()); + mStorage[iid].Deserialize(elm); + } + } + + typename decltype(mStorage)::iterator begin() + { + return mStorage.begin(); + } + + typename decltype(mStorage)::iterator end() + { + return mStorage.end(); + } + + typename decltype(mStorage)::const_iterator begin() const + { + return mStorage.begin(); + } + + typename decltype(mStorage)::const_iterator end() const + { + return mStorage.end(); + } + +private: + template + friend class ItemBase; + + void UpdateItemName(const T& item, const std::string& newName) + { + mNameLookup.erase(item.GetName()); + mNameLookup.insert(newName, item.GetId()); + } +}; + +template +class ItemBase +{ +private: + ItemList* mList; + size_t mId; + std::string mName; + +public: + ItemBase(ItemList& list, size_t id = std::numeric_limits::max(), std::string name = "") + : mList{ &list } + , mId{ id } + , mName{ std::move(name) } + { + } + + bool IsInvalid() const + { + return mId == std::numeric_limits::max(); + } + + ItemList& GetList() const + { + return *mList; + } + + size_t GetId() const + { + return mId; + } + + const std::string& GetName() const + { + return mName; + } + + void SetName(std::string name) + { + mList->UpdateItemName(static_cast(*this), name); + mName = std::move(name); + } +}; + +class ProductItem : public ItemBase +{ +private: + std::string mDescription; + int mPrice = 0; + int mStock = 0; + +public: + using ItemBase::ItemBase; + + const std::string& GetDescription() const; + void SetDescription(std::string description); + /// Get the price of this item in US cents. + int GetPrice() const; + void SetPrice(int price); + /// Get the current number of this product in warehouse. + /// This is a housekeeping field and shouldn't be editable by the user from the UI. + int GetStock() const; + void SetStock(int stock); + + Json::Value Serialize() const; + void Deserialize(const Json::Value& elm); +}; + +class FactoryItem : public ItemBase +{ +private: + std::string mDescription; + std::string mEmail; + +public: + using ItemBase::ItemBase; + + const std::string& GetDescription() const; + void SetDescription(std::string description); + const std::string& GetEmail() const; + void SetEmail(std::string email); + + Json::Value Serialize() const; + void Deserialize(const Json::Value& elm); +}; + +class CustomerItem : public ItemBase +{ +private: + std::string mDescription; + std::string mEmail; + +public: + using ItemBase::ItemBase; + + const std::string& GetDescription() const; + void SetDescription(std::string description); + const std::string& GetEmail() const; + void SetEmail(std::string email); + + Json::Value Serialize() const; + void Deserialize(const Json::Value& elm); +}; diff --git a/app/source/Cplt/Model/Project.cpp b/app/source/Cplt/Model/Project.cpp new file mode 100644 index 0000000..a1e9bab --- /dev/null +++ b/app/source/Cplt/Model/Project.cpp @@ -0,0 +1,168 @@ +#include "Project.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +template +static void ReadItemList(ItemList& list, const fs::path& filePath) +{ + std::ifstream ifs(filePath); + if (ifs) { + Json::Value root; + ifs >> root; + + list = ItemList(root); + } +} + +static void CreateProjectSubfolders(const Project& project) +{ + fs::create_directory(project.GetDatabasesDirectory()); + fs::create_directory(project.GetItemsDirectory()); + fs::create_directory(project.GetWorkflowsDirectory()); + fs::create_directory(project.GetTemplatesDirectory()); +} + +Project::Project(fs::path rootPath) + : mRootPath{ std::move(rootPath) } + , mRootPathString{ mRootPath.string() } + , Workflows(*this) + , Templates(*this) + , Database(*this) +{ + // TODO better diagnostic + const char* kInvalidFormatErr = "Failed to load project: invalid format."; + + std::ifstream ifs(mRootPath / "cplt_project.json"); + if (!ifs) { + std::string message; + message += "Failed to load project file at '"; + message += mRootPath.string(); + message += "'."; + throw std::runtime_error(message); + } + + { + Json::Value root; + ifs >> root; + + const auto& croot = root; // Use const reference so that accessors default to returning a null if not found, instead of silently creating new elements + if (!croot.isObject()) { + throw std::runtime_error(kInvalidFormatErr); + } + + if (auto& name = croot["Name"]; name.isString()) { + mName = name.asString(); + } else { + throw std::runtime_error(kInvalidFormatErr); + } + } + + CreateProjectSubfolders(*this); + + auto itemsDir = mRootPath / "items"; + ReadItemList(Products, itemsDir / "products.json"); + ReadItemList(Factories, itemsDir / "factories.json"); + ReadItemList(Customers, itemsDir / "customers.json"); + + Workflows.Reload(); + Templates.Reload(); +} + +Project::Project(fs::path rootPath, std::string name) + : mRootPath{ std::move(rootPath) } + , mRootPathString{ mRootPath.string() } + , mName{ std::move(name) } + , Workflows(*this) + , Templates(*this) + , Database(*this) +{ + CreateProjectSubfolders(*this); +} + +const fs::path& Project::GetPath() const +{ + return mRootPath; +} + +const std::string& Project::GetPathString() const +{ + return mRootPathString; +} + +fs::path Project::GetDatabasesDirectory() const +{ + return mRootPath / "databases"; +} + +fs::path Project::GetItemsDirectory() const +{ + return mRootPath / "items"; +} + +fs::path Project::GetWorkflowsDirectory() const +{ + return mRootPath / "workflows"; +} + +fs::path Project::GetWorkflowPath(std::string_view name) const +{ + return (mRootPath / "workflows" / name).concat(".cplt-workflow"); +} + +fs::path Project::GetTemplatesDirectory() const +{ + return mRootPath / "templates"; +} + +fs::path Project::GetTemplatePath(std::string_view name) const +{ + return (mRootPath / "templates" / name).concat(".cplt-template"); +} + +const std::string& Project::GetName() const +{ + return mName; +} + +void Project::SetName(std::string name) +{ + mName = std::move(name); +} + +Json::Value Project::Serialize() +{ + Json::Value root(Json::objectValue); + + root["Name"] = mName; + + return root; +} + +template +static void WriteItemList(ItemList& list, const fs::path& filePath) +{ + std::ofstream ofs(filePath); + ofs << list.Serialize(); +} + +void Project::WriteToDisk() +{ + std::ofstream ofs(mRootPath / "cplt_project.json"); + ofs << this->Serialize(); + + auto itemsDir = GetItemsDirectory(); + WriteItemList(Products, itemsDir / "products.json"); + WriteItemList(Factories, itemsDir / "factories.json"); + WriteItemList(Customers, itemsDir / "customers.json"); +} diff --git a/app/source/Cplt/Model/Project.hpp b/app/source/Cplt/Model/Project.hpp new file mode 100644 index 0000000..8119a97 --- /dev/null +++ b/app/source/Cplt/Model/Project.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +class Project +{ +private: + std::filesystem::path mRootPath; + std::string mRootPathString; + std::string mName; + + // (Exception to style guidelines) + // This is put after the private fields, so that when XxxDatabase's constructor runs, all of them will be initialized +public: + WorkflowAssetList Workflows; + TemplateAssetList Templates; + ItemList Products; + ItemList Factories; + ItemList Customers; + MainDatabase Database; + +public: + /// Load the project from a directory containing the cplt_project.json file. + /// This only loads the main project file, the caller needs to + Project(std::filesystem::path rootPath); + + /// Create a project with the given name in the given path. Note that the path should be a directory that will contain the project files once created. + /// This function assumes the given directory will exist and is empty. + Project(std::filesystem::path rootPath, std::string name); + + /// Path to a *directory* that contains the project file. + const std::filesystem::path& GetPath() const; + const std::string& GetPathString() const; + + std::filesystem::path GetDatabasesDirectory() const; + std::filesystem::path GetItemsDirectory() const; + std::filesystem::path GetWorkflowsDirectory() const; + std::filesystem::path GetWorkflowPath(std::string_view name) const; + std::filesystem::path GetTemplatesDirectory() const; + std::filesystem::path GetTemplatePath(std::string_view name) const; + + const std::string& GetName() const; + void SetName(std::string name); + + Json::Value Serialize(); + void WriteToDisk(); +}; diff --git a/app/source/Cplt/Model/Template/TableTemplate.cpp b/app/source/Cplt/Model/Template/TableTemplate.cpp new file mode 100644 index 0000000..5cd9ed8 --- /dev/null +++ b/app/source/Cplt/Model/Template/TableTemplate.cpp @@ -0,0 +1,591 @@ +#include "TableTemplate.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +bool TableCell::IsDataHoldingCell() const +{ + return IsPrimaryCell() || !IsMergedCell(); +} + +bool TableCell::IsPrimaryCell() const +{ + return PrimaryCellLocation == Location; +} + +bool TableCell::IsMergedCell() const +{ + return PrimaryCellLocation.x == -1 || PrimaryCellLocation.y == -1; +} + +template +void OperateStreamForTableCell(TTableCell& cell, TStream& proxy) +{ + proxy.template ObjectAdapted(cell.Content); + proxy.Object(cell.Location); + proxy.Object(cell.PrimaryCellLocation); + proxy.Value(cell.SpanX); + proxy.Value(cell.SpanY); + proxy.Enum(cell.HorizontalAlignment); + proxy.Enum(cell.VerticalAlignment); + proxy.Enum(cell.Type); + proxy.Value(cell.DataId); +} + +void TableCell::ReadFromDataStream(InputDataStream& stream) +{ + ::OperateStreamForTableCell(*this, stream); +} + +void TableCell::WriteToDataStream(OutputDataStream& stream) const +{ + ::OperateStreamForTableCell(*this, stream); +} + +Vec2i TableArrayGroup::GetLeftCell() const +{ + return { Row, LeftCell }; +} + +Vec2i TableArrayGroup::GetRightCell() const +{ + return { Row, RightCell }; +} + +int TableArrayGroup::GetCount() const +{ + return RightCell - LeftCell + 1; +} + +Vec2i TableArrayGroup::FindCell(std::string_view name) +{ + // TODO + return Vec2i{}; +} + +template +static bool UpdateElementName(TMap& map, std::string_view oldName, std::string_view newName) +{ + auto iter = map.find(oldName); + if (iter == map.end()) { + return false; + } + + auto elm = iter.value(); + auto [DISCARD, inserted] = map.insert(newName, elm); + if (!inserted) { + return false; + } + + map.erase(iter); + return true; +} + +bool TableArrayGroup::UpdateCellName(std::string_view oldName, std::string_view newName) +{ + return ::UpdateElementName(mName2Cell, oldName, newName); +} + +template +void OperateStreamForTableArrayGroup(TTableArrayGroup& group, TStream& stream) +{ + stream.Value(group.Row); + stream.Value(group.LeftCell); + stream.Value(group.RightCell); +} + +void TableArrayGroup::ReadFromDataStream(InputDataStream& stream) +{ + ::OperateStreamForTableArrayGroup(*this, stream); +} + +void TableArrayGroup::WriteToDataStream(OutputDataStream& stream) const +{ + ::OperateStreamForTableArrayGroup(*this, stream); +} + +TableInstantiationParameters::TableInstantiationParameters(const TableTemplate& table) + : mTable{ &table } +{ +} + +TableInstantiationParameters& TableInstantiationParameters::ResetTable(const TableTemplate& newTable) +{ + mTable = &newTable; + return *this; +} + +TableInstantiationParameters TableInstantiationParameters::RebindTable(const TableTemplate& newTable) const +{ + TableInstantiationParameters result(newTable); + result.SingularCells = this->SingularCells; + result.ArrayGroups = this->ArrayGroups; + return result; +} + +const TableTemplate& TableInstantiationParameters::GetTable() const +{ + return *mTable; +} + +bool TableTemplate::IsInstance(const Template* tmpl) +{ + return tmpl->GetKind() == KD_Table; +} + +TableTemplate::TableTemplate() + : Template(KD_Table) +{ +} + +int TableTemplate::GetTableWidth() const +{ + return mColumnWidths.size(); +} + +int TableTemplate::GetTableHeight() const +{ + return mRowHeights.size(); +} + +void TableTemplate::Resize(int newWidth, int newHeight) +{ + // TODO this doesn't gracefully handle resizing to a smaller size which trims some merged cells + + std::vector cells; + cells.reserve(newWidth * newHeight); + + int tableWidth = GetTableWidth(); + int tableHeight = GetTableHeight(); + + for (int y = 0; y < newHeight; ++y) { + if (y >= tableHeight) { + for (int x = 0; x < newWidth; ++x) { + cells.push_back(TableCell{}); + } + continue; + } + + for (int x = 0; x < newWidth; ++x) { + if (x >= tableWidth) { + cells.push_back(TableCell{}); + } else { + auto& cell = GetCell({ x, y }); + cells.push_back(std::move(cell)); + } + } + } + + mCells = std::move(cells); + mColumnWidths.resize(newWidth, 80); + mRowHeights.resize(newHeight, 20); +} + +int TableTemplate::GetRowHeight(int row) const +{ + return mRowHeights[row]; +} + +void TableTemplate::SetRowHeight(int row, int height) +{ + mRowHeights[row] = height; +} + +int TableTemplate::GetColumnWidth(int column) const +{ + return mColumnWidths[column]; +} + +void TableTemplate::SetColumnWidth(int column, int width) +{ + mColumnWidths[column] = width; +} + +const TableCell& TableTemplate::GetCell(Vec2i pos) const +{ + int tableWidth = GetTableWidth(); + return mCells[pos.y * tableWidth + pos.x]; +} + +TableCell& TableTemplate::GetCell(Vec2i pos) +{ + return const_cast(const_cast(this)->GetCell(pos)); +} + +void TableTemplate::SetCellType(Vec2i pos, TableCell::CellType type) +{ + auto& cell = GetCell(pos); + if (cell.Type == type) { + return; + } + + switch (cell.Type) { + // Nothing to change + case TableCell::ConstantCell: break; + + case TableCell::SingularParametricCell: + mName2Parameters.erase(cell.Content); + break; + + case TableCell::ArrayParametricCell: { + auto& ag = mArrayGroups[cell.DataId]; + if (pos.x == ag.LeftCell) { + ag.LeftCell++; + } else if (pos.x == ag.RightCell) { + ag.RightCell--; + } else { + } + } break; + } + + switch (type) { + // Nothing to do + case TableCell::ConstantCell: break; + + case TableCell::SingularParametricCell: { + int idx = pos.y * GetTableWidth() + pos.x; + auto [DISCARD, inserted] = mName2Parameters.insert(cell.Content, idx); + + // Duplicate name + if (!inserted) { + return; + } + } break; + + case TableCell::ArrayParametricCell: { + auto ptr = AddArrayGroup(pos.y, pos.x, pos.x); + + // Duplicate name + if (ptr == nullptr) { + return; + } + } break; + } + + cell.Type = type; +} + +bool TableTemplate::UpdateParameterName(std::string_view oldName, std::string_view newName) +{ + return ::UpdateElementName(mName2Parameters, oldName, newName); +} + +int TableTemplate::GetArrayGroupCount() const +{ + return mArrayGroups.size(); +} + +const TableArrayGroup& TableTemplate::GetArrayGroup(int id) const +{ + return mArrayGroups[id]; +} + +TableArrayGroup& TableTemplate::GetArrayGroup(int id) +{ + return mArrayGroups[id]; +} + +TableArrayGroup* TableTemplate::AddArrayGroup(int row, int left, int right) +{ + // size_t max value: 18446744073709551615 + // ^~~~~~~~~~~~~~~~~~~~ 20 chars + char name[20]; + auto res = std::to_chars(std::begin(name), std::end(name), mArrayGroups.size()); + std::string_view nameStr(name, res.ptr - name); + + return AddArrayGroup(nameStr, row, left, right); +} + +TableArrayGroup* TableTemplate::AddArrayGroup(std::string_view name, int row, int left, int right) +{ + assert(row >= 0 && row < GetTableHeight()); + assert(left >= 0 && left < GetTableWidth()); + assert(right >= 0 && right < GetTableWidth()); + + // TODO check for overlap + + if (left > right) { + std::swap(left, right); + } + + auto [DISCARD, inserted] = mName2ArrayGroups.insert(name, (int)mArrayGroups.size()); + if (!inserted) { + return nullptr; + } + + mArrayGroups.push_back(TableArrayGroup{ + .Row = row, + .LeftCell = left, + .RightCell = right, + }); + auto& ag = mArrayGroups.back(); + + for (int x = left; x <= right; x++) { + auto& cell = GetCell({ x, row }); + + // Update type + cell.Type = TableCell::ArrayParametricCell; + + // Insert parameter name lookup + while (true) { + auto [DISCARD, inserted] = ag.mName2Cell.insert(cell.Content, x); + if (inserted) { + break; + } + + cell.Content += "-"; + } + } + + return &ag; +} + +bool TableTemplate::UpdateArrayGroupName(std::string_view oldName, std::string_view newName) +{ + return ::UpdateElementName(mName2ArrayGroups, oldName, newName); +} + +bool TableTemplate::ExtendArrayGroupLeft(int id, int n) +{ + assert(n > 0); + + auto& ag = mArrayGroups[id]; + ag.LeftCell -= n; + + return false; +} + +bool TableTemplate::ExtendArrayGroupRight(int id, int n) +{ + assert(n > 0); + + auto& ag = mArrayGroups[id]; + ag.RightCell += n; + + return false; +} + +TableCell* TableTemplate::FindCell(std::string_view name) +{ + auto iter = mName2Parameters.find(name); + if (iter != mName2Parameters.end()) { + return &mCells[iter.value()]; + } else { + return nullptr; + } +} + +TableArrayGroup* TableTemplate::FindArrayGroup(std::string_view name) +{ + auto iter = mName2ArrayGroups.find(name); + if (iter != mName2ArrayGroups.end()) { + return &mArrayGroups[iter.value()]; + } else { + return nullptr; + } +} + +TableTemplate::MergeCellsResult TableTemplate::MergeCells(Vec2i topLeft, Vec2i bottomRight) +{ + auto SortTwo = [](int& a, int& b) { + if (a > b) { + std::swap(a, b); + } + }; + SortTwo(topLeft.x, bottomRight.x); + SortTwo(topLeft.y, bottomRight.y); + + auto ResetProgress = [&]() { + for (int y = topLeft.y; y < bottomRight.y; ++y) { + for (int x = topLeft.x; x < bottomRight.x; ++x) { + auto& cell = GetCell({ x, y }); + cell.PrimaryCellLocation = { -1, -1 }; + } + } + }; + + for (int y = topLeft.y; y < bottomRight.y; ++y) { + for (int x = topLeft.x; x < bottomRight.x; ++x) { + auto& cell = GetCell({ x, y }); + if (cell.IsMergedCell()) { + ResetProgress(); + return MCR_CellAlreadyMerged; + } + + cell.PrimaryCellLocation = topLeft; + } + } + + auto& primaryCell = GetCell(topLeft); + primaryCell.SpanX = bottomRight.x - topLeft.x; + primaryCell.SpanY = bottomRight.y - topLeft.y; + + return MCR_Success; +} + +TableTemplate::BreakCellsResult TableTemplate::BreakCells(Vec2i topLeft) +{ + auto& primaryCell = GetCell(topLeft); + if (!primaryCell.IsMergedCell()) { + return BCR_CellNotMerged; + } + + for (int dy = 0; dy < primaryCell.SpanY; ++dy) { + for (int dx = 0; dx < primaryCell.SpanX; ++dx) { + auto& cell = GetCell({ topLeft.x + dx, topLeft.y + dy }); + cell.PrimaryCellLocation = { -1, -1 }; + } + } + + primaryCell.SpanX = 1; + primaryCell.SpanY = 1; + + return BCR_Success; +} + +lxw_workbook* TableTemplate::InstantiateToExcelWorkbook(const TableInstantiationParameters& params) const +{ + auto workbook = workbook_new("Table.xlsx"); + InstantiateToExcelWorksheet(workbook, params); + return workbook; +} + +lxw_worksheet* TableTemplate::InstantiateToExcelWorksheet(lxw_workbook* workbook, const TableInstantiationParameters& params) const +{ + auto worksheet = workbook_add_worksheet(workbook, "CpltExport.xlsx"); + + // Map: row number -> length of generated ranges + std::map generatedRanges; + + for (size_t i = 0; i < mArrayGroups.size(); ++i) { + auto& info = mArrayGroups[i]; + auto& param = params.ArrayGroups[i]; + + auto iter = generatedRanges.find(i); + if (iter != generatedRanges.end()) { + int available = iter->second; + if (available >= param.size()) { + // Current space is enough to fit in this array group, skip + continue; + } + } + + // Not enough space to fit in this array group, update (or insert) the appropriate amount of generated rows + int row = i; + int count = param.size(); + generatedRanges.try_emplace(row, count); + } + + auto GetOffset = [&](int y) -> int { + // std::find_if + int verticalOffset = 0; + for (auto it = generatedRanges.begin(); it != generatedRanges.end() && it->first < y; ++it) { + verticalOffset += it->second; + } + return verticalOffset; + }; + + auto WriteCell = [&](int row, int col, const TableCell& cell, const char* text) -> void { + if (cell.IsPrimaryCell()) { + int lastRow = row + cell.SpanY - 1; + int lastCol = col + cell.SpanX - 1; + // When both `string` and `format` are null, the top-left cell contents are untouched (what we just wrote in the above switch) + worksheet_merge_range(worksheet, row, col, lastRow, lastCol, text, nullptr); + } else { + worksheet_write_string(worksheet, row, col, text, nullptr); + } + }; + + // Write/instantiate all array groups + for (size_t i = 0; i < mArrayGroups.size(); ++i) { + auto& groupInfo = mArrayGroups[i]; + auto& groupParams = params.ArrayGroups[i]; + + int rowCellCount = groupInfo.GetCount(); + int rowCount = groupParams.size(); + int baseRowIdx = groupInfo.Row + GetOffset(groupInfo.Row); + + // For each row that would be generated + for (int rowIdx = 0; rowIdx < rowCount; ++rowIdx) { + auto& row = groupParams[rowIdx]; + + // For each cell in the row + for (int rowCellIdx = 0; rowCellIdx < rowCellCount; ++rowCellIdx) { + // TODO support merged cells in array groups + worksheet_write_string(worksheet, baseRowIdx + rowIdx, rowCellIdx, row[rowCellIdx].c_str(), nullptr); + } + } + } + + int tableWidth = GetTableWidth(); + int tableHeight = GetTableHeight(); + + // Write all regular and singular parameter cells + for (int y = 0; y < tableHeight; ++y) { + for (int x = 0; x < tableWidth; ++x) { + auto& cell = GetCell({ x, y }); + + if (!cell.IsDataHoldingCell()) { + continue; + } + + switch (cell.Type) { + case TableCell::ConstantCell: { + int row = y + GetOffset(y); + int col = x; + + WriteCell(row, col, cell, cell.Content.c_str()); + } break; + + case TableCell::SingularParametricCell: { + int row = y + GetOffset(y); + int col = x; + + auto iter = params.SingularCells.find({ x, y }); + if (iter != params.SingularCells.end()) { + WriteCell(row, col, cell, iter.value().c_str()); + } + } break; + + // See loop above that processes whole array groups at the same time + case TableCell::ArrayParametricCell: break; + } + } + } + + return worksheet; +} + +class TableTemplate::Private +{ +public: + template + static void OperateStream(TTableTemplate& table, TProxy& proxy) + { + proxy.template ObjectAdapted>(table.mColumnWidths); + proxy.template ObjectAdapted>(table.mRowHeights); + proxy.template ObjectAdapted>(table.mCells); + proxy.template ObjectAdapted>(table.mArrayGroups); + proxy.template ObjectAdapted>(table.mName2Parameters); + proxy.template ObjectAdapted>(table.mName2ArrayGroups); + } +}; + +void TableTemplate::ReadFromDataStream(InputDataStream& stream) +{ + Private::OperateStream(*this, stream); +} + +void TableTemplate::WriteToDataStream(OutputDataStream& stream) const +{ + Private::OperateStream(*this, stream); +} diff --git a/app/source/Cplt/Model/Template/TableTemplate.hpp b/app/source/Cplt/Model/Template/TableTemplate.hpp new file mode 100644 index 0000000..3e931d4 --- /dev/null +++ b/app/source/Cplt/Model/Template/TableTemplate.hpp @@ -0,0 +1,223 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +class TableCell +{ +public: + enum TextAlignment + { + /// For horizontal alignment, this means align left. For vertical alignment, this means align top. + AlignAxisMin, + /// Align middle of the text to the middle of the axis. + AlignCenter, + /// For horizontal alignment, this means align right. For vertical alignment, this means align bottom. + AlignAxisMax, + }; + + enum CellType + { + ConstantCell, + SingularParametricCell, + ArrayParametricCell, + }; + +public: + /// Display content of this cell. This doesn't necessarily have to line up with the parameter name (if this cell is one). + std::string Content; + Vec2i Location; + /// Location of the primary (top left) cell, if this cell is a part of a merged group. + /// Otherwise, either component of this field shall be -1. + Vec2i PrimaryCellLocation{ -1, -1 }; + int SpanX = 0; + int SpanY = 0; + TextAlignment HorizontalAlignment = AlignCenter; + TextAlignment VerticalAlignment = AlignCenter; + CellType Type = ConstantCell; + /// The id of the group description object, if this cell isn't a constant or singular parameter cell. Otherwise, this value is -1. + int DataId = -1; + +public: + /// Return whether this cell holds meaningful data, i.e. true when this cell is either unmerged or the primary cell of a merged range. + bool IsDataHoldingCell() const; + /// Return whether this cell is the primary (i.e. top left) cell of a merged range or not. + bool IsPrimaryCell() const; + /// Return whether this cell is a part of a merged range or not. Includes the primary cell. + bool IsMergedCell() const; + + void ReadFromDataStream(InputDataStream& stream); + void WriteToDataStream(OutputDataStream& stream) const; +}; + +// TODO support reverse (bottom to top) filling order +// TODO support horizontal filling order + +/// Parameter group information for a grouped array of cells. When instantiated, an array of 0 or more +/// elements shall be provided by the user, which will replace the group of templated cells with a list +/// of rows, each instantiated with the n-th element in the provided array. +/// \code +/// [["foo", "bar", "foobar"], +/// ["a", "b", c"], +/// ["1", "2", "3"], +/// ["x", "y", "z"]] +/// // ... may be more +/// \endcode +/// This would create 4 rows of data in the place of the original parameter group. +/// +/// If more than one array parameter groups are on the same row, they would share space between each other: +/// \code +/// | 2 elements was fed to it +/// | | 1 element was fed to it +/// V V +/// {~~~~~~~~~~~~~~~~}{~~~~~~~~~~~~~~} +/// +------+---------+---------------+ +/// | Foo | Example | Another group | +/// +------+---------+---------------+ +/// | Cool | Example | | +/// +------+---------+---------------+ +/// \endcode +/// +/// \see TableCell +/// \see TableInstantiationParameters +/// \see TableTemplate +class TableArrayGroup +{ +public: + /// Parameter name mapped to cell location (index from LeftCell). + tsl::array_map mName2Cell; + int Row; + /// Leftmost cell in this group + int LeftCell; + /// Rightmost cell in this group + int RightCell; + +public: + Vec2i GetLeftCell() const; + Vec2i GetRightCell() const; + int GetCount() const; + + /// Find the location of the cell within this array group that has the given name. + Vec2i FindCell(std::string_view name); + bool UpdateCellName(std::string_view oldName, std::string_view newName); + + void ReadFromDataStream(InputDataStream& stream); + void WriteToDataStream(OutputDataStream& stream) const; +}; + +// Forward declaration of libxlsxwriter structs +struct lxw_workbook; +struct lxw_worksheet; + +/// An object containing the necessary information to instantiate a table template. +/// \see TableTemplate +class TableInstantiationParameters +{ +private: + const TableTemplate* mTable; + +public: + tsl::robin_map SingularCells; + + using ArrayGroupRow = std::vector; + using ArrayGroupData = std::vector; + std::vector ArrayGroups; + +public: + TableInstantiationParameters(const TableTemplate& table); + + TableInstantiationParameters& ResetTable(const TableTemplate& newTable); + TableInstantiationParameters RebindTable(const TableTemplate& newTable) const; + + const TableTemplate& GetTable() const; +}; + +/// A table template, where individual cells can be filled by workflows instantiating this template. Merged cells, +/// parametric rows/columns, and grids are also supported. +/// +/// This current supports exporting to xlsx files. +class TableTemplate : public Template +{ + friend class TableSingleParamsIter; + friend class TableArrayGroupsIter; + class Private; + +private: + /// Map from parameter name to index of the parameter cell (stored in mCells). + tsl::array_map mName2Parameters; + /// Map from array group name to the index of the array group (stored in mArrayGroups). + tsl::array_map mName2ArrayGroups; + std::vector mCells; + std::vector mArrayGroups; + std::vector mRowHeights; + std::vector mColumnWidths; + +public: + static bool IsInstance(const Template* tmpl); + TableTemplate(); + + int GetTableWidth() const; + int GetTableHeight() const; + void Resize(int newWidth, int newHeight); + + int GetRowHeight(int row) const; + void SetRowHeight(int row, int height); + int GetColumnWidth(int column) const; + void SetColumnWidth(int column, int width); + + const TableCell& GetCell(Vec2i pos) const; + TableCell& GetCell(Vec2i pos); + ///
    + ///
  • In case of becoming a SingularParametricCell: the parameter name is filled with TableCell::Content. + ///
  • In case of becoming a ArrayGroupParametricCell: the array group name is automatically generated as the nth group it would be come. + /// i.e., if there aRe currently 3 groups, the newly created group would be named "4". + /// If this name collides with an existing group, hyphens \c - will be append to the name until no collision happens. + ///
+ void SetCellType(Vec2i pos, TableCell::CellType type); + + /// Updates the parameter cell to a new name. Returns true on success and false on failure (param not found or name duplicates). + bool UpdateParameterName(std::string_view oldName, std::string_view newName); + + int GetArrayGroupCount() const; + const TableArrayGroup& GetArrayGroup(int id) const; + TableArrayGroup& GetArrayGroup(int id); + TableArrayGroup* AddArrayGroup(int row, int left, int right); + TableArrayGroup* AddArrayGroup(std::string_view name, int row, int left, int right); + bool UpdateArrayGroupName(std::string_view oldName, std::string_view newName); + bool ExtendArrayGroupLeft(int id, int n); + bool ExtendArrayGroupRight(int id, int n); + + /// Find a singular parameter cell by its name. This does not include cells within an array group. + TableCell* FindCell(std::string_view name); + + /// Find an array group by its name. + TableArrayGroup* FindArrayGroup(std::string_view name); + + enum MergeCellsResult + { + MCR_CellAlreadyMerged, + MCR_Success, + }; + MergeCellsResult MergeCells(Vec2i topLeft, Vec2i bottomRight); + + enum BreakCellsResult + { + BCR_CellNotMerged, + BCR_Success, + }; + BreakCellsResult BreakCells(Vec2i topLeft); + + lxw_workbook* InstantiateToExcelWorkbook(const TableInstantiationParameters& params) const; + lxw_worksheet* InstantiateToExcelWorksheet(lxw_workbook* workbook, const TableInstantiationParameters& params) const; + + void ReadFromDataStream(InputDataStream& stream) override; + void WriteToDataStream(OutputDataStream& stream) const override; +}; diff --git a/app/source/Cplt/Model/Template/TableTemplateIterator.cpp b/app/source/Cplt/Model/Template/TableTemplateIterator.cpp new file mode 100644 index 0000000..19e30b9 --- /dev/null +++ b/app/source/Cplt/Model/Template/TableTemplateIterator.cpp @@ -0,0 +1,52 @@ +#include "TableTemplateIterator.hpp" + +TableSingleParamsIter::TableSingleParamsIter(TableTemplate& tmpl) + : mTemplate{ &tmpl } + , mIter{ tmpl.mName2Parameters.begin() } +{ +} + +bool TableSingleParamsIter::HasNext() const +{ + return mIter != mTemplate->mName2Parameters.end(); +} + +TableCell& TableSingleParamsIter::Next() +{ + int id = mIter.value(); + ++mIter; + + return mTemplate->mCells[id]; +} + +TableArrayGroupsIter::TableArrayGroupsIter(TableTemplate& tmpl) + : mTemplate{ &tmpl } + , mIter{ tmpl.mName2ArrayGroups.begin() } +{ +} + +bool TableArrayGroupsIter::HasNext() const +{ + return mIter != mTemplate->mName2ArrayGroups.end(); +} + +TableArrayGroup& TableArrayGroupsIter::Peek() const +{ + int id = mIter.value(); + return mTemplate->mArrayGroups[id]; +} + +std::string_view TableArrayGroupsIter::PeekName() const +{ + return mIter.key_sv(); +} + +const char* TableArrayGroupsIter::PeekNameCStr() const +{ + return mIter.key(); +} + +void TableArrayGroupsIter::Next() +{ + ++mIter; +} diff --git a/app/source/Cplt/Model/Template/TableTemplateIterator.hpp b/app/source/Cplt/Model/Template/TableTemplateIterator.hpp new file mode 100644 index 0000000..c4b5bf9 --- /dev/null +++ b/app/source/Cplt/Model/Template/TableTemplateIterator.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +#include + +class TableSingleParamsIter +{ +private: + TableTemplate* mTemplate; + tsl::array_map::iterator mIter; + +public: + TableSingleParamsIter(TableTemplate& tmpl); + + bool HasNext() const; + TableCell& Next(); +}; + +class TableArrayGroupsIter +{ +private: + TableTemplate* mTemplate; + tsl::array_map::iterator mIter; + +public: + TableArrayGroupsIter(TableTemplate& tmpl); + + bool HasNext() const; + TableArrayGroup& Peek() const; + std::string_view PeekName() const; + const char* PeekNameCStr() const; + void Next(); +}; diff --git a/app/source/Cplt/Model/Template/Template.hpp b/app/source/Cplt/Model/Template/Template.hpp new file mode 100644 index 0000000..cf926d0 --- /dev/null +++ b/app/source/Cplt/Model/Template/Template.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +class Template : public Asset +{ +public: + enum Kind + { + KD_Table, + + InvalidKind, + KindCount = InvalidKind, + }; + + using CategoryType = TemplateAssetList; + +private: + Kind mKind; + +public: + static const char* FormatKind(Kind kind); + static std::unique_ptr