Welcome to mirror list, hosted at ThFree Co, Russian Federation.

git.blender.org/blender.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSybren A. Stüvel <sybren>2021-09-23 15:56:45 +0300
committerSybren A. Stüvel <sybren@blender.org>2021-09-23 16:00:45 +0300
commit9b12b23d0bace4056ed14ff3e3e8415eb4ff75af (patch)
treeca6568dc8a705ed12776217d61b94616df712cf3 /source/blender/blenkernel/intern
parent222fd1abf09ae65b7082f58bf2ac43422c77162c (diff)
Assets: add Asset Catalog system
Catalogs work like directories on disk (without hard-/symlinks), in that an asset is only contained in one catalog. See T90066 for design considerations. #### Known Limitations Only a single catalog definition file (CDF), is supported, at `${ASSET_LIBRARY_ROOT}/blender_assets.cats.txt`. In the future this is to be expanded to support arbitrary CDFs (like one per blend file, one per subdirectory, etc.). The current implementation is based on the asset browser, which in practice means that the asset browser owns the `AssetCatalogService` instance for the selected asset library. In the future these instances will be accessible via a less UI-bound asset system. The UI is still very rudimentary, only showing the catalog ID for the currently selected asset. Most notably, the loaded catalogs are not shown yet. The UI is being implemented and will be merged soon. #### Catalog Identifiers Catalogs are internally identified by UUID. In older designs this was a human-readable name, which has the problem that it has to be kept in sync with its semantics (so when renaming a catalog from X to Y, the UUID can be kept the same). Since UUIDs don't communicate any human-readable information, the mapping from catalog UUID to its path (stored in the Catalog Definition File, CDF) is critical for understanding which asset is stored in which human-readable catalog. To make this less critical, and to allow manual data reconstruction after a CDF is lost/corrupted, each catalog also has a "simple name" that's stored along with the UUID. This is also stored on each asset, next to the catalog UUID. #### Writing to Disk Before saving asset catalogs to disk, the to-be-overwritten file gets inspected. Any new catalogs that are found thre are loaded to memory before writing the catalogs back to disk: - Changed catalog path: in-memory data wins - Catalogs deleted on disk: they are recreated based on in-memory data - Catalogs deleted in memory: deleted on disk as well - New catalogs on disk: are loaded and thus survive the overwriting #### Tree Design This implements the initial tree structure to load catalogs into. See T90608, and the basic design in T90066. Reviewed By: Severin Maniphest Tasks: T91552 Differential Revision: https://developer.blender.org/D12589
Diffstat (limited to 'source/blender/blenkernel/intern')
-rw-r--r--source/blender/blenkernel/intern/asset.cc25
-rw-r--r--source/blender/blenkernel/intern/asset_catalog.cc573
-rw-r--r--source/blender/blenkernel/intern/asset_catalog_test.cc443
-rw-r--r--source/blender/blenkernel/intern/asset_library.cc53
-rw-r--r--source/blender/blenkernel/intern/asset_library_test.cc82
-rw-r--r--source/blender/blenkernel/intern/asset_test.cc70
6 files changed, 1246 insertions, 0 deletions
diff --git a/source/blender/blenkernel/intern/asset.cc b/source/blender/blenkernel/intern/asset.cc
index f74018b20c5..ac9bcccc8bd 100644
--- a/source/blender/blenkernel/intern/asset.cc
+++ b/source/blender/blenkernel/intern/asset.cc
@@ -26,8 +26,10 @@
#include "BLI_listbase.h"
#include "BLI_string.h"
+#include "BLI_string_ref.hh"
#include "BLI_string_utils.h"
#include "BLI_utildefines.h"
+#include "BLI_uuid.h"
#include "BKE_asset.h"
#include "BKE_icons.h"
@@ -37,6 +39,8 @@
#include "MEM_guardedalloc.h"
+using namespace blender;
+
AssetMetaData *BKE_asset_metadata_create(void)
{
AssetMetaData *asset_data = (AssetMetaData *)MEM_callocN(sizeof(*asset_data), __func__);
@@ -115,6 +119,27 @@ void BKE_asset_library_reference_init_default(AssetLibraryReference *library_ref
memcpy(library_ref, DNA_struct_default_get(AssetLibraryReference), sizeof(*library_ref));
}
+void BKE_asset_metadata_catalog_id_clear(struct AssetMetaData *asset_data)
+{
+ asset_data->catalog_id = BLI_uuid_nil();
+ asset_data->catalog_simple_name[0] = '\0';
+}
+
+void BKE_asset_metadata_catalog_id_set(struct AssetMetaData *asset_data,
+ const bUUID catalog_id,
+ const char *catalog_simple_name)
+{
+ asset_data->catalog_id = catalog_id;
+
+ constexpr size_t max_simple_name_length = sizeof(asset_data->catalog_simple_name);
+
+ /* The substr() call is necessary to make copy() copy the first N characters (instead of refusing
+ * to copy and producing an empty string). */
+ StringRef trimmed_id =
+ StringRef(catalog_simple_name).trim().substr(0, max_simple_name_length - 1);
+ trimmed_id.copy(asset_data->catalog_simple_name, max_simple_name_length);
+}
+
/* Queries -------------------------------------------- */
PreviewImage *BKE_asset_metadata_preview_get_from_id(const AssetMetaData *UNUSED(asset_data),
diff --git a/source/blender/blenkernel/intern/asset_catalog.cc b/source/blender/blenkernel/intern/asset_catalog.cc
new file mode 100644
index 00000000000..0c64a0b085c
--- /dev/null
+++ b/source/blender/blenkernel/intern/asset_catalog.cc
@@ -0,0 +1,573 @@
+/*
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+/** \file
+ * \ingroup bke
+ */
+
+#include "BKE_asset_catalog.hh"
+
+#include "BLI_fileops.h"
+#include "BLI_path_util.h"
+#include "BLI_string_ref.hh"
+
+/* For S_ISREG() and S_ISDIR() on Windows. */
+#ifdef WIN32
+# include "BLI_winstuff.h"
+#endif
+
+#include <fstream>
+
+namespace blender::bke {
+
+const char AssetCatalogService::PATH_SEPARATOR = '/';
+const CatalogFilePath AssetCatalogService::DEFAULT_CATALOG_FILENAME = "blender_assets.cats.txt";
+
+AssetCatalogService::AssetCatalogService(const CatalogFilePath &asset_library_root)
+ : asset_library_root_(asset_library_root)
+{
+}
+
+bool AssetCatalogService::is_empty() const
+{
+ return catalogs_.is_empty();
+}
+
+AssetCatalog *AssetCatalogService::find_catalog(CatalogID catalog_id)
+{
+ std::unique_ptr<AssetCatalog> *catalog_uptr_ptr = this->catalogs_.lookup_ptr(catalog_id);
+ if (catalog_uptr_ptr == nullptr) {
+ return nullptr;
+ }
+ return catalog_uptr_ptr->get();
+}
+
+void AssetCatalogService::delete_catalog(CatalogID catalog_id)
+{
+ std::unique_ptr<AssetCatalog> *catalog_uptr_ptr = this->catalogs_.lookup_ptr(catalog_id);
+ if (catalog_uptr_ptr == nullptr) {
+ /* Catalog cannot be found, which is fine. */
+ return;
+ }
+
+ /* Mark the catalog as deleted. */
+ AssetCatalog *catalog = catalog_uptr_ptr->get();
+ catalog->flags.is_deleted = true;
+
+ /* Move ownership from this->catalogs_ to this->deleted_catalogs_. */
+ this->deleted_catalogs_.add(catalog_id, std::move(*catalog_uptr_ptr));
+
+ /* The catalog can now be removed from the map without freeing the actual AssetCatalog. */
+ this->catalogs_.remove(catalog_id);
+
+ this->rebuild_tree();
+}
+
+AssetCatalog *AssetCatalogService::create_catalog(const CatalogPath &catalog_path)
+{
+ std::unique_ptr<AssetCatalog> catalog = AssetCatalog::from_path(catalog_path);
+
+ /* So we can std::move(catalog) and still use the non-owning pointer: */
+ AssetCatalog *const catalog_ptr = catalog.get();
+
+ /* TODO(@sybren): move the `AssetCatalog::from_path()` function to another place, that can reuse
+ * catalogs when a catalog with the given path is already known, and avoid duplicate catalog IDs.
+ */
+ BLI_assert_msg(!catalogs_.contains(catalog->catalog_id), "duplicate catalog ID not supported");
+ catalogs_.add_new(catalog->catalog_id, std::move(catalog));
+
+ if (catalog_definition_file_) {
+ /* Ensure the new catalog gets written to disk at some point. If there is no CDF in memory yet,
+ * it's enough to have the catalog known to the service as it'll be saved to a new file. */
+ catalog_definition_file_->add_new(catalog_ptr);
+ }
+
+ return catalog_ptr;
+}
+
+static std::string asset_definition_default_file_path_from_dir(StringRef asset_library_root)
+{
+ char file_path[PATH_MAX];
+ BLI_join_dirfile(file_path,
+ sizeof(file_path),
+ asset_library_root.data(),
+ AssetCatalogService::DEFAULT_CATALOG_FILENAME.data());
+ return file_path;
+}
+
+void AssetCatalogService::load_from_disk()
+{
+ load_from_disk(asset_library_root_);
+}
+
+void AssetCatalogService::load_from_disk(const CatalogFilePath &file_or_directory_path)
+{
+ BLI_stat_t status;
+ if (BLI_stat(file_or_directory_path.data(), &status) == -1) {
+ // TODO(@sybren): throw an appropriate exception.
+ return;
+ }
+
+ if (S_ISREG(status.st_mode)) {
+ load_single_file(file_or_directory_path);
+ }
+ else if (S_ISDIR(status.st_mode)) {
+ load_directory_recursive(file_or_directory_path);
+ }
+ else {
+ // TODO(@sybren): throw an appropriate exception.
+ }
+
+ /* TODO: Should there be a sanitize step? E.g. to remove catalogs with identical paths? */
+
+ catalog_tree_ = read_into_tree();
+}
+
+void AssetCatalogService::load_directory_recursive(const CatalogFilePath &directory_path)
+{
+ // TODO(@sybren): implement proper multi-file support. For now, just load
+ // the default file if it is there.
+ CatalogFilePath file_path = asset_definition_default_file_path_from_dir(directory_path);
+
+ if (!BLI_exists(file_path.data())) {
+ /* No file to be loaded is perfectly fine. */
+ return;
+ }
+
+ this->load_single_file(file_path);
+}
+
+void AssetCatalogService::load_single_file(const CatalogFilePath &catalog_definition_file_path)
+{
+ /* TODO(@sybren): check that #catalog_definition_file_path is contained in #asset_library_root_,
+ * otherwise some assumptions may fail. */
+ std::unique_ptr<AssetCatalogDefinitionFile> cdf = parse_catalog_file(
+ catalog_definition_file_path);
+
+ BLI_assert_msg(!this->catalog_definition_file_,
+ "Only loading of a single catalog definition file is supported.");
+ this->catalog_definition_file_ = std::move(cdf);
+}
+
+std::unique_ptr<AssetCatalogDefinitionFile> AssetCatalogService::parse_catalog_file(
+ const CatalogFilePath &catalog_definition_file_path)
+{
+ auto cdf = std::make_unique<AssetCatalogDefinitionFile>();
+ cdf->file_path = catalog_definition_file_path;
+
+ auto catalog_parsed_callback = [this, catalog_definition_file_path](
+ std::unique_ptr<AssetCatalog> catalog) {
+ if (this->catalogs_.contains(catalog->catalog_id)) {
+ // TODO(@sybren): apparently another CDF was already loaded. This is not supported yet.
+ std::cerr << catalog_definition_file_path << ": multiple definitions of catalog "
+ << catalog->catalog_id << " in multiple files, ignoring this one." << std::endl;
+ /* Don't store 'catalog'; unique_ptr will free its memory. */
+ return false;
+ }
+
+ /* The AssetCatalog pointer is now owned by the AssetCatalogService. */
+ this->catalogs_.add_new(catalog->catalog_id, std::move(catalog));
+ return true;
+ };
+
+ cdf->parse_catalog_file(cdf->file_path, catalog_parsed_callback);
+
+ return cdf;
+}
+
+void AssetCatalogService::merge_from_disk_before_writing()
+{
+ /* TODO(Sybren): expand to support multiple CDFs. */
+
+ if (!catalog_definition_file_ || catalog_definition_file_->file_path.empty() ||
+ !BLI_is_file(catalog_definition_file_->file_path.c_str())) {
+ return;
+ }
+
+ auto catalog_parsed_callback = [this](std::unique_ptr<AssetCatalog> catalog) {
+ const bUUID catalog_id = catalog->catalog_id;
+
+ /* The following two conditions could be or'ed together. Keeping them separated helps when
+ * adding debug prints, breakpoints, etc. */
+ if (this->catalogs_.contains(catalog_id)) {
+ /* This catalog was already seen, so just ignore it. */
+ return false;
+ }
+ if (this->deleted_catalogs_.contains(catalog_id)) {
+ /* This catalog was already seen and subsequently deleted, so just ignore it. */
+ return false;
+ }
+
+ /* This is a new catalog, so let's keep it around. */
+ this->catalogs_.add_new(catalog_id, std::move(catalog));
+ return true;
+ };
+
+ catalog_definition_file_->parse_catalog_file(catalog_definition_file_->file_path,
+ catalog_parsed_callback);
+}
+
+bool AssetCatalogService::write_to_disk(const CatalogFilePath &directory_for_new_files)
+{
+ /* TODO(Sybren): expand to support multiple CDFs. */
+
+ if (!catalog_definition_file_) {
+ if (catalogs_.is_empty() && deleted_catalogs_.is_empty()) {
+ /* Avoid saving anything, when there is nothing to save. */
+ return true; /* Writing nothing when there is nothing to write is still a success. */
+ }
+
+ /* A CDF has to be created to contain all current in-memory catalogs. */
+ const CatalogFilePath cdf_path = asset_definition_default_file_path_from_dir(
+ directory_for_new_files);
+ catalog_definition_file_ = construct_cdf_in_memory(cdf_path);
+ }
+
+ merge_from_disk_before_writing();
+ return catalog_definition_file_->write_to_disk();
+}
+
+std::unique_ptr<AssetCatalogDefinitionFile> AssetCatalogService::construct_cdf_in_memory(
+ const CatalogFilePath &file_path)
+{
+ auto cdf = std::make_unique<AssetCatalogDefinitionFile>();
+ cdf->file_path = file_path;
+
+ for (auto &catalog : catalogs_.values()) {
+ cdf->add_new(catalog.get());
+ }
+
+ return cdf;
+}
+
+std::unique_ptr<AssetCatalogTree> AssetCatalogService::read_into_tree()
+{
+ auto tree = std::make_unique<AssetCatalogTree>();
+
+ /* Go through the catalogs, insert each path component into the tree where needed. */
+ for (auto &catalog : catalogs_.values()) {
+ const AssetCatalogTreeItem *parent = nullptr;
+ AssetCatalogTreeItem::ChildMap *insert_to_map = &tree->children_;
+
+ BLI_assert_msg(!ELEM(catalog->path[0], '/', '\\'),
+ "Malformed catalog path: Path should be formatted like a relative path");
+
+ const char *next_slash_ptr;
+ /* Looks more complicated than it is, this just iterates over path components. E.g.
+ * "just/some/path" iterates over "just", then "some" then "path". */
+ for (const char *name_begin = catalog->path.data(); name_begin && name_begin[0];
+ /* Jump to one after the next slash if there is any. */
+ name_begin = next_slash_ptr ? next_slash_ptr + 1 : nullptr) {
+ next_slash_ptr = BLI_path_slash_find(name_begin);
+
+ /* Note that this won't be null terminated. */
+ StringRef component_name = next_slash_ptr ?
+ StringRef(name_begin, next_slash_ptr - name_begin) :
+ /* Last component in the path. */
+ name_begin;
+
+ /* Insert new tree element - if no matching one is there yet! */
+ auto [item, was_inserted] = insert_to_map->emplace(
+ component_name, AssetCatalogTreeItem(component_name, parent));
+
+ /* Walk further into the path (no matter if a new item was created or not). */
+ parent = &item->second;
+ insert_to_map = &item->second.children_;
+ }
+ }
+
+ return tree;
+}
+
+void AssetCatalogService::rebuild_tree()
+{
+ this->catalog_tree_ = read_into_tree();
+}
+
+AssetCatalogTreeItem::AssetCatalogTreeItem(StringRef name, const AssetCatalogTreeItem *parent)
+ : name_(name), parent_(parent)
+{
+}
+
+StringRef AssetCatalogTreeItem::get_name() const
+{
+ return name_;
+}
+
+CatalogPath AssetCatalogTreeItem::catalog_path() const
+{
+ std::string current_path = name_;
+ for (const AssetCatalogTreeItem *parent = parent_; parent; parent = parent->parent_) {
+ current_path = parent->name_ + AssetCatalogService::PATH_SEPARATOR + current_path;
+ }
+ return current_path;
+}
+
+int AssetCatalogTreeItem::count_parents() const
+{
+ int i = 0;
+ for (const AssetCatalogTreeItem *parent = parent_; parent; parent = parent->parent_) {
+ i++;
+ }
+ return i;
+}
+
+void AssetCatalogTree::foreach_item(const AssetCatalogTreeItem::ItemIterFn callback) const
+{
+ AssetCatalogTreeItem::foreach_item_recursive(children_, callback);
+}
+
+void AssetCatalogTreeItem::foreach_item_recursive(const AssetCatalogTreeItem::ChildMap &children,
+ const ItemIterFn callback)
+{
+ for (const auto &[key, item] : children) {
+ callback(item);
+ foreach_item_recursive(item.children_, callback);
+ }
+}
+
+AssetCatalogTree *AssetCatalogService::get_catalog_tree()
+{
+ return catalog_tree_.get();
+}
+
+bool AssetCatalogDefinitionFile::contains(const CatalogID catalog_id) const
+{
+ return catalogs_.contains(catalog_id);
+}
+
+void AssetCatalogDefinitionFile::add_new(AssetCatalog *catalog)
+{
+ catalogs_.add_new(catalog->catalog_id, catalog);
+}
+
+void AssetCatalogDefinitionFile::parse_catalog_file(
+ const CatalogFilePath &catalog_definition_file_path,
+ AssetCatalogParsedFn catalog_loaded_callback)
+{
+ std::fstream infile(catalog_definition_file_path);
+ std::string line;
+ while (std::getline(infile, line)) {
+ const StringRef trimmed_line = StringRef(line).trim();
+ if (trimmed_line.is_empty() || trimmed_line[0] == '#') {
+ continue;
+ }
+
+ std::unique_ptr<AssetCatalog> catalog = this->parse_catalog_line(trimmed_line);
+ if (!catalog) {
+ continue;
+ }
+
+ AssetCatalog *non_owning_ptr = catalog.get();
+ const bool keep_catalog = catalog_loaded_callback(std::move(catalog));
+ if (!keep_catalog) {
+ continue;
+ }
+
+ if (this->contains(non_owning_ptr->catalog_id)) {
+ std::cerr << catalog_definition_file_path << ": multiple definitions of catalog "
+ << non_owning_ptr->catalog_id << " in the same file, using first occurrence."
+ << std::endl;
+ /* Don't store 'catalog'; unique_ptr will free its memory. */
+ continue;
+ }
+
+ /* The AssetDefinitionFile should include this catalog when writing it back to disk. */
+ this->add_new(non_owning_ptr);
+ }
+}
+
+std::unique_ptr<AssetCatalog> AssetCatalogDefinitionFile::parse_catalog_line(const StringRef line)
+{
+ const char delim = ':';
+ const int64_t first_delim = line.find_first_of(delim);
+ if (first_delim == StringRef::not_found) {
+ std::cerr << "Invalid line in " << this->file_path << ": " << line << std::endl;
+ return std::unique_ptr<AssetCatalog>(nullptr);
+ }
+
+ /* Parse the catalog ID. */
+ const std::string id_as_string = line.substr(0, first_delim).trim();
+ bUUID catalog_id;
+ const bool uuid_parsed_ok = BLI_uuid_parse_string(&catalog_id, id_as_string.c_str());
+ if (!uuid_parsed_ok) {
+ std::cerr << "Invalid UUID in " << this->file_path << ": " << line << std::endl;
+ return std::unique_ptr<AssetCatalog>(nullptr);
+ }
+
+ /* Parse the path and simple name. */
+ const StringRef path_and_simple_name = line.substr(first_delim + 1);
+ const int64_t second_delim = path_and_simple_name.find_first_of(delim);
+
+ CatalogPath catalog_path;
+ std::string simple_name;
+ if (second_delim == 0) {
+ /* Delimiter as first character means there is no path. These lines are to be ignored. */
+ return std::unique_ptr<AssetCatalog>(nullptr);
+ }
+
+ if (second_delim == StringRef::not_found) {
+ /* No delimiter means no simple name, just treat it as all "path". */
+ catalog_path = path_and_simple_name;
+ simple_name = "";
+ }
+ else {
+ catalog_path = path_and_simple_name.substr(0, second_delim);
+ simple_name = path_and_simple_name.substr(second_delim + 1).trim();
+ }
+
+ catalog_path = AssetCatalog::cleanup_path(catalog_path);
+ return std::make_unique<AssetCatalog>(catalog_id, catalog_path, simple_name);
+}
+
+bool AssetCatalogDefinitionFile::write_to_disk() const
+{
+ BLI_assert_msg(!this->file_path.empty(), "Writing to CDF requires its file path to be known");
+ return this->write_to_disk(this->file_path);
+}
+
+bool AssetCatalogDefinitionFile::write_to_disk(const CatalogFilePath &dest_file_path) const
+{
+ const CatalogFilePath writable_path = dest_file_path + ".writing";
+ const CatalogFilePath backup_path = dest_file_path + "~";
+
+ if (!this->write_to_disk_unsafe(writable_path)) {
+ /* TODO: communicate what went wrong. */
+ return false;
+ }
+ if (BLI_exists(dest_file_path.c_str())) {
+ if (BLI_rename(dest_file_path.c_str(), backup_path.c_str())) {
+ /* TODO: communicate what went wrong. */
+ return false;
+ }
+ }
+ if (BLI_rename(writable_path.c_str(), dest_file_path.c_str())) {
+ /* TODO: communicate what went wrong. */
+ return false;
+ }
+
+ return true;
+}
+
+bool AssetCatalogDefinitionFile::write_to_disk_unsafe(const CatalogFilePath &dest_file_path) const
+{
+ char directory[PATH_MAX];
+ BLI_split_dir_part(dest_file_path.c_str(), directory, sizeof(directory));
+ if (!ensure_directory_exists(directory)) {
+ /* TODO(Sybren): pass errors to the UI somehow. */
+ return false;
+ }
+
+ std::ofstream output(dest_file_path);
+
+ // TODO(@sybren): remember the line ending style that was originally read, then use that to write
+ // the file again.
+
+ // Write the header.
+ // TODO(@sybren): move the header definition to some other place.
+ output << "# This is an Asset Catalog Definition file for Blender." << std::endl;
+ output << "#" << std::endl;
+ output << "# Empty lines and lines starting with `#` will be ignored." << std::endl;
+ output << "# Other lines are of the format \"UUID:catalog/path/for/assets:simple catalog name\""
+ << std::endl;
+ output << "" << std::endl;
+
+ // Write the catalogs.
+ // TODO(@sybren): order them by Catalog ID or Catalog Path.
+ for (const auto &catalog : catalogs_.values()) {
+ if (catalog->flags.is_deleted) {
+ continue;
+ }
+ output << catalog->catalog_id << ":" << catalog->path << ":" << catalog->simple_name
+ << std::endl;
+ }
+ output.close();
+ return !output.bad();
+}
+
+bool AssetCatalogDefinitionFile::ensure_directory_exists(
+ const CatalogFilePath directory_path) const
+{
+ /* TODO(@sybren): design a way to get such errors presented to users (or ensure that they never
+ * occur). */
+ if (directory_path.empty()) {
+ std::cerr
+ << "AssetCatalogService: no asset library root configured, unable to ensure it exists."
+ << std::endl;
+ return false;
+ }
+
+ if (BLI_exists(directory_path.data())) {
+ if (!BLI_is_dir(directory_path.data())) {
+ std::cerr << "AssetCatalogService: " << directory_path
+ << " exists but is not a directory, this is not a supported situation."
+ << std::endl;
+ return false;
+ }
+
+ /* Root directory exists, work is done. */
+ return true;
+ }
+
+ /* Ensure the root directory exists. */
+ std::error_code err_code;
+ if (!BLI_dir_create_recursive(directory_path.data())) {
+ std::cerr << "AssetCatalogService: error creating directory " << directory_path << ": "
+ << err_code << std::endl;
+ return false;
+ }
+
+ /* Root directory has been created, work is done. */
+ return true;
+}
+
+AssetCatalog::AssetCatalog(const CatalogID catalog_id,
+ const CatalogPath &path,
+ const std::string &simple_name)
+ : catalog_id(catalog_id), path(path), simple_name(simple_name)
+{
+}
+
+std::unique_ptr<AssetCatalog> AssetCatalog::from_path(const CatalogPath &path)
+{
+ const CatalogPath clean_path = cleanup_path(path);
+ const CatalogID cat_id = BLI_uuid_generate_random();
+ const std::string simple_name = sensible_simple_name_for_path(clean_path);
+ auto catalog = std::make_unique<AssetCatalog>(cat_id, clean_path, simple_name);
+ return catalog;
+}
+
+std::string AssetCatalog::sensible_simple_name_for_path(const CatalogPath &path)
+{
+ std::string name = path;
+ std::replace(name.begin(), name.end(), AssetCatalogService::PATH_SEPARATOR, '-');
+ if (name.length() < MAX_NAME - 1) {
+ return name;
+ }
+
+ /* Trim off the start of the path, as that's the most generic part and thus contains the least
+ * information. */
+ return "..." + name.substr(name.length() - 60);
+}
+
+CatalogPath AssetCatalog::cleanup_path(const CatalogPath &path)
+{
+ /* TODO(@sybren): maybe go over each element of the path, and trim those? */
+ CatalogPath clean_path = StringRef(path).trim().trim(AssetCatalogService::PATH_SEPARATOR).trim();
+ return clean_path;
+}
+
+} // namespace blender::bke
diff --git a/source/blender/blenkernel/intern/asset_catalog_test.cc b/source/blender/blenkernel/intern/asset_catalog_test.cc
new file mode 100644
index 00000000000..0f389999d6d
--- /dev/null
+++ b/source/blender/blenkernel/intern/asset_catalog_test.cc
@@ -0,0 +1,443 @@
+/*
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * The Original Code is Copyright (C) 2020 Blender Foundation
+ * All rights reserved.
+ */
+
+#include "BKE_appdir.h"
+#include "BKE_asset_catalog.hh"
+
+#include "BLI_fileops.h"
+#include "BLI_path_util.h"
+
+#include "testing/testing.h"
+
+namespace blender::bke::tests {
+
+/* UUIDs from lib/tests/asset_library/blender_assets.cats.txt */
+const bUUID UUID_ID_WITHOUT_PATH("e34dd2c5-5d2e-4668-9794-1db5de2a4f71");
+const bUUID UUID_POSES_ELLIE("df60e1f6-2259-475b-93d9-69a1b4a8db78");
+const bUUID UUID_POSES_ELLIE_WHITESPACE("b06132f6-5687-4751-a6dd-392740eb3c46");
+const bUUID UUID_POSES_ELLIE_TRAILING_SLASH("3376b94b-a28d-4d05-86c1-bf30b937130d");
+const bUUID UUID_POSES_RUZENA("79a4f887-ab60-4bd4-94da-d572e27d6aed");
+const bUUID UUID_POSES_RUZENA_HAND("81811c31-1a88-4bd7-bb34-c6fc2607a12e");
+const bUUID UUID_POSES_RUZENA_FACE("82162c1f-06cc-4d91-a9bf-4f72c104e348");
+const bUUID UUID_WITHOUT_SIMPLENAME("d7916a31-6ca9-4909-955f-182ca2b81fa3");
+
+/* UUIDs from lib/tests/asset_library/modified_assets.cats.txt */
+const bUUID UUID_AGENT_47("c5744ba5-43f5-4f73-8e52-010ad4a61b34");
+
+/* Subclass that adds accessors such that protected fields can be used in tests. */
+class TestableAssetCatalogService : public AssetCatalogService {
+ public:
+ explicit TestableAssetCatalogService(const CatalogFilePath &asset_library_root)
+ : AssetCatalogService(asset_library_root)
+ {
+ }
+
+ AssetCatalogDefinitionFile *get_catalog_definition_file()
+ {
+ return catalog_definition_file_.get();
+ }
+};
+
+class AssetCatalogTest : public testing::Test {
+ protected:
+ CatalogFilePath asset_library_root_;
+ CatalogFilePath temp_library_path_;
+
+ void SetUp() override
+ {
+ const std::string test_files_dir = blender::tests::flags_test_asset_dir();
+ if (test_files_dir.empty()) {
+ FAIL();
+ }
+
+ asset_library_root_ = test_files_dir + "/" + "asset_library";
+ temp_library_path_ = "";
+ }
+
+ /* Register a temporary path, which will be removed at the end of the test.
+ * The returned path ends in a slash. */
+ CatalogFilePath use_temp_path()
+ {
+ BKE_tempdir_init("");
+ const CatalogFilePath tempdir = BKE_tempdir_session();
+ temp_library_path_ = tempdir + "test-temporary-path/";
+ return temp_library_path_;
+ }
+
+ CatalogFilePath create_temp_path()
+ {
+ CatalogFilePath path = use_temp_path();
+ BLI_dir_create_recursive(path.c_str());
+ return path;
+ }
+
+ struct CatalogPathInfo {
+ StringRef name;
+ int parent_count;
+ };
+
+ void assert_expected_tree_items(AssetCatalogTree *tree,
+ const std::vector<CatalogPathInfo> &expected_paths)
+ {
+ int i = 0;
+ tree->foreach_item([&](const AssetCatalogTreeItem &actual_item) {
+ ASSERT_LT(i, expected_paths.size())
+ << "More catalogs in tree than expected; did not expect " << actual_item.catalog_path();
+
+ char expected_filename[FILE_MAXFILE];
+ /* Is the catalog name as expected? "character", "Ellie", ... */
+ BLI_split_file_part(
+ expected_paths[i].name.data(), expected_filename, sizeof(expected_filename));
+ EXPECT_EQ(expected_filename, actual_item.get_name());
+ /* Does the computed number of parents match? */
+ EXPECT_EQ(expected_paths[i].parent_count, actual_item.count_parents());
+ EXPECT_EQ(expected_paths[i].name, actual_item.catalog_path());
+
+ i++;
+ });
+ }
+
+ void TearDown() override
+ {
+ if (!temp_library_path_.empty()) {
+ BLI_delete(temp_library_path_.c_str(), true, true);
+ temp_library_path_ = "";
+ }
+ }
+};
+
+TEST_F(AssetCatalogTest, load_single_file)
+{
+ AssetCatalogService service(asset_library_root_);
+ service.load_from_disk(asset_library_root_ + "/" + "blender_assets.cats.txt");
+
+ // Test getting a non-existant catalog ID.
+ EXPECT_EQ(nullptr, service.find_catalog(BLI_uuid_generate_random()));
+
+ // Test getting an invalid catalog (without path definition).
+ AssetCatalog *cat_without_path = service.find_catalog(UUID_ID_WITHOUT_PATH);
+ ASSERT_EQ(nullptr, cat_without_path);
+
+ // Test getting a regular catalog.
+ AssetCatalog *poses_ellie = service.find_catalog(UUID_POSES_ELLIE);
+ ASSERT_NE(nullptr, poses_ellie);
+ EXPECT_EQ(UUID_POSES_ELLIE, poses_ellie->catalog_id);
+ EXPECT_EQ("character/Ellie/poselib", poses_ellie->path);
+ EXPECT_EQ("POSES_ELLIE", poses_ellie->simple_name);
+
+ // Test whitespace stripping and support in the path.
+ AssetCatalog *poses_whitespace = service.find_catalog(UUID_POSES_ELLIE_WHITESPACE);
+ ASSERT_NE(nullptr, poses_whitespace);
+ EXPECT_EQ(UUID_POSES_ELLIE_WHITESPACE, poses_whitespace->catalog_id);
+ EXPECT_EQ("character/Ellie/poselib/white space", poses_whitespace->path);
+ EXPECT_EQ("POSES_ELLIE WHITESPACE", poses_whitespace->simple_name);
+
+ // Test getting a UTF-8 catalog ID.
+ AssetCatalog *poses_ruzena = service.find_catalog(UUID_POSES_RUZENA);
+ ASSERT_NE(nullptr, poses_ruzena);
+ EXPECT_EQ(UUID_POSES_RUZENA, poses_ruzena->catalog_id);
+ EXPECT_EQ("character/Ružena/poselib", poses_ruzena->path);
+ EXPECT_EQ("POSES_RUŽENA", poses_ruzena->simple_name);
+}
+
+TEST_F(AssetCatalogTest, load_single_file_into_tree)
+{
+ AssetCatalogService service(asset_library_root_);
+ service.load_from_disk(asset_library_root_ + "/" + "blender_assets.cats.txt");
+
+ /* Contains not only paths from the CDF but also the missing parents (implicitly defined
+ * catalogs). */
+ std::vector<CatalogPathInfo> expected_paths{
+ {"character", 0},
+ {"character/Ellie", 1},
+ {"character/Ellie/poselib", 2},
+ {"character/Ellie/poselib/white space", 3},
+ {"character/Ružena", 1},
+ {"character/Ružena/poselib", 2},
+ {"character/Ružena/poselib/face", 3},
+ {"character/Ružena/poselib/hand", 3},
+ {"path", 0}, // Implicit.
+ {"path/without", 1}, // Implicit.
+ {"path/without/simplename", 2}, // From CDF.
+ };
+
+ AssetCatalogTree *tree = service.get_catalog_tree();
+ assert_expected_tree_items(tree, expected_paths);
+}
+
+TEST_F(AssetCatalogTest, write_single_file)
+{
+ TestableAssetCatalogService service(asset_library_root_);
+ service.load_from_disk(asset_library_root_ + "/" +
+ AssetCatalogService::DEFAULT_CATALOG_FILENAME);
+
+ const CatalogFilePath save_to_path = use_temp_path() +
+ AssetCatalogService::DEFAULT_CATALOG_FILENAME;
+ AssetCatalogDefinitionFile *cdf = service.get_catalog_definition_file();
+ cdf->write_to_disk(save_to_path);
+
+ AssetCatalogService loaded_service(save_to_path);
+ loaded_service.load_from_disk();
+
+ // Test that the expected catalogs are there.
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE_WHITESPACE));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE_TRAILING_SLASH));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_RUZENA));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_RUZENA_HAND));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_RUZENA_FACE));
+
+ // Test that the invalid catalog definition wasn't copied.
+ EXPECT_EQ(nullptr, loaded_service.find_catalog(UUID_ID_WITHOUT_PATH));
+
+ // TODO(@sybren): test ordering of catalogs in the file.
+}
+
+TEST_F(AssetCatalogTest, no_writing_empty_files)
+{
+ const CatalogFilePath temp_lib_root = create_temp_path();
+ AssetCatalogService service(temp_lib_root);
+ service.write_to_disk(temp_lib_root);
+
+ const CatalogFilePath default_cdf_path = temp_lib_root +
+ AssetCatalogService::DEFAULT_CATALOG_FILENAME;
+ EXPECT_FALSE(BLI_exists(default_cdf_path.c_str()));
+}
+
+TEST_F(AssetCatalogTest, create_first_catalog_from_scratch)
+{
+ /* Even from scratch a root directory should be known. */
+ const CatalogFilePath temp_lib_root = use_temp_path();
+ AssetCatalogService service;
+
+ /* Just creating the service should NOT create the path. */
+ EXPECT_FALSE(BLI_exists(temp_lib_root.c_str()));
+
+ AssetCatalog *cat = service.create_catalog("some/catalog/path");
+ ASSERT_NE(nullptr, cat);
+ EXPECT_EQ(cat->path, "some/catalog/path");
+ EXPECT_EQ(cat->simple_name, "some-catalog-path");
+
+ /* Creating a new catalog should not save anything to disk yet. */
+ EXPECT_FALSE(BLI_exists(temp_lib_root.c_str()));
+
+ /* Writing to disk should create the directory + the default file. */
+ service.write_to_disk(temp_lib_root);
+ EXPECT_TRUE(BLI_is_dir(temp_lib_root.c_str()));
+
+ const CatalogFilePath definition_file_path = temp_lib_root + "/" +
+ AssetCatalogService::DEFAULT_CATALOG_FILENAME;
+ EXPECT_TRUE(BLI_is_file(definition_file_path.c_str()));
+
+ AssetCatalogService loaded_service(temp_lib_root);
+ loaded_service.load_from_disk();
+
+ // Test that the expected catalog is there.
+ AssetCatalog *written_cat = loaded_service.find_catalog(cat->catalog_id);
+ ASSERT_NE(nullptr, written_cat);
+ EXPECT_EQ(written_cat->catalog_id, cat->catalog_id);
+ EXPECT_EQ(written_cat->path, cat->path);
+}
+
+TEST_F(AssetCatalogTest, create_catalog_after_loading_file)
+{
+ const CatalogFilePath temp_lib_root = create_temp_path();
+
+ /* Copy the asset catalog definition files to a separate location, so that we can test without
+ * overwriting the test file in SVN. */
+ const CatalogFilePath default_catalog_path = asset_library_root_ + "/" +
+ AssetCatalogService::DEFAULT_CATALOG_FILENAME;
+ const CatalogFilePath writable_catalog_path = temp_lib_root +
+ AssetCatalogService::DEFAULT_CATALOG_FILENAME;
+ BLI_copy(default_catalog_path.c_str(), writable_catalog_path.c_str());
+ EXPECT_TRUE(BLI_is_dir(temp_lib_root.c_str()));
+ EXPECT_TRUE(BLI_is_file(writable_catalog_path.c_str()));
+
+ TestableAssetCatalogService service(temp_lib_root);
+ service.load_from_disk();
+ EXPECT_EQ(writable_catalog_path, service.get_catalog_definition_file()->file_path);
+ EXPECT_NE(nullptr, service.find_catalog(UUID_POSES_ELLIE)) << "expected catalogs to be loaded";
+
+ /* This should create a new catalog but not write to disk. */
+ const AssetCatalog *new_catalog = service.create_catalog("new/catalog");
+ const bUUID new_catalog_id = new_catalog->catalog_id;
+
+ /* Reload the on-disk catalog file. */
+ TestableAssetCatalogService loaded_service(temp_lib_root);
+ loaded_service.load_from_disk();
+ EXPECT_EQ(writable_catalog_path, loaded_service.get_catalog_definition_file()->file_path);
+
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE))
+ << "expected pre-existing catalogs to be kept in the file";
+ EXPECT_EQ(nullptr, loaded_service.find_catalog(new_catalog_id))
+ << "expecting newly added catalog to not yet be saved to " << temp_lib_root;
+
+ /* Write and reload the catalog file. */
+ service.write_to_disk(temp_lib_root.c_str());
+ AssetCatalogService reloaded_service(temp_lib_root);
+ reloaded_service.load_from_disk();
+ EXPECT_NE(nullptr, reloaded_service.find_catalog(UUID_POSES_ELLIE))
+ << "expected pre-existing catalogs to be kept in the file";
+ EXPECT_NE(nullptr, reloaded_service.find_catalog(new_catalog_id))
+ << "expecting newly added catalog to exist in the file";
+}
+
+TEST_F(AssetCatalogTest, create_catalog_path_cleanup)
+{
+ const CatalogFilePath temp_lib_root = use_temp_path();
+ AssetCatalogService service(temp_lib_root);
+ AssetCatalog *cat = service.create_catalog(" /some/path / ");
+
+ EXPECT_FALSE(BLI_uuid_is_nil(cat->catalog_id));
+ EXPECT_EQ("some/path", cat->path);
+ EXPECT_EQ("some-path", cat->simple_name);
+}
+
+TEST_F(AssetCatalogTest, create_catalog_simple_name)
+{
+ const CatalogFilePath temp_lib_root = use_temp_path();
+ AssetCatalogService service(temp_lib_root);
+ AssetCatalog *cat = service.create_catalog(
+ "production/Spite Fright/Characters/Victora/Pose Library/Approved/Body Parts/Hands");
+
+ EXPECT_FALSE(BLI_uuid_is_nil(cat->catalog_id));
+ EXPECT_EQ("production/Spite Fright/Characters/Victora/Pose Library/Approved/Body Parts/Hands",
+ cat->path);
+ EXPECT_EQ("...ht-Characters-Victora-Pose Library-Approved-Body Parts-Hands", cat->simple_name);
+}
+
+TEST_F(AssetCatalogTest, delete_catalog_leaf)
+{
+ AssetCatalogService service(asset_library_root_);
+ service.load_from_disk(asset_library_root_ + "/" + "blender_assets.cats.txt");
+
+ /* Delete a leaf catalog, i.e. one that is not a parent of another catalog.
+ * This keeps this particular test easy. */
+ service.delete_catalog(UUID_POSES_RUZENA_HAND);
+ EXPECT_EQ(nullptr, service.find_catalog(UUID_POSES_RUZENA_HAND));
+
+ /* Contains not only paths from the CDF but also the missing parents (implicitly defined
+ * catalogs). This is why a leaf catalog was deleted. */
+ std::vector<CatalogPathInfo> expected_paths{
+ {"character", 0},
+ {"character/Ellie", 1},
+ {"character/Ellie/poselib", 2},
+ {"character/Ellie/poselib/white space", 3},
+ {"character/Ružena", 1},
+ {"character/Ružena/poselib", 2},
+ {"character/Ružena/poselib/face", 3},
+ // {"character/Ružena/poselib/hand", 3}, // this is the deleted one
+ {"path", 0},
+ {"path/without", 1},
+ {"path/without/simplename", 2},
+ };
+
+ AssetCatalogTree *tree = service.get_catalog_tree();
+ assert_expected_tree_items(tree, expected_paths);
+}
+
+TEST_F(AssetCatalogTest, delete_catalog_write_to_disk)
+{
+ TestableAssetCatalogService service(asset_library_root_);
+ service.load_from_disk(asset_library_root_ + "/" +
+ AssetCatalogService::DEFAULT_CATALOG_FILENAME);
+
+ service.delete_catalog(UUID_POSES_ELLIE);
+
+ const CatalogFilePath save_to_path = use_temp_path();
+ AssetCatalogDefinitionFile *cdf = service.get_catalog_definition_file();
+ cdf->write_to_disk(save_to_path + "/" + AssetCatalogService::DEFAULT_CATALOG_FILENAME);
+
+ AssetCatalogService loaded_service(save_to_path);
+ loaded_service.load_from_disk();
+
+ // Test that the expected catalogs are there, except the deleted one.
+ EXPECT_EQ(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE_WHITESPACE));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE_TRAILING_SLASH));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_RUZENA));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_RUZENA_HAND));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_RUZENA_FACE));
+}
+
+TEST_F(AssetCatalogTest, merge_catalog_files)
+{
+ const CatalogFilePath cdf_dir = create_temp_path();
+ const CatalogFilePath original_cdf_file = asset_library_root_ + "/blender_assets.cats.txt";
+ const CatalogFilePath modified_cdf_file = asset_library_root_ + "/modified_assets.cats.txt";
+ const CatalogFilePath temp_cdf_file = cdf_dir + "blender_assets.cats.txt";
+ BLI_copy(original_cdf_file.c_str(), temp_cdf_file.c_str());
+
+ // Load the unmodified, original CDF.
+ TestableAssetCatalogService service(asset_library_root_);
+ service.load_from_disk(cdf_dir);
+
+ // Copy a modified file, to mimick a situation where someone changed the CDF after we loaded it.
+ BLI_copy(modified_cdf_file.c_str(), temp_cdf_file.c_str());
+
+ // Overwrite the modified file. This should merge the on-disk file with our catalogs.
+ service.write_to_disk(cdf_dir);
+
+ AssetCatalogService loaded_service(cdf_dir);
+ loaded_service.load_from_disk();
+
+ // Test that the expected catalogs are there.
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE_WHITESPACE));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE_TRAILING_SLASH));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_RUZENA));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_RUZENA_HAND));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_RUZENA_FACE));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_AGENT_47)); // New in the modified file.
+
+ // When there are overlaps, the in-memory (i.e. last-saved) paths should win.
+ const AssetCatalog *ruzena_face = loaded_service.find_catalog(UUID_POSES_RUZENA_FACE);
+ EXPECT_EQ("character/Ružena/poselib/face", ruzena_face->path);
+}
+
+TEST_F(AssetCatalogTest, backups)
+{
+ const CatalogFilePath cdf_dir = create_temp_path();
+ const CatalogFilePath original_cdf_file = asset_library_root_ + "/blender_assets.cats.txt";
+ const CatalogFilePath writable_cdf_file = cdf_dir + "/blender_assets.cats.txt";
+ BLI_copy(original_cdf_file.c_str(), writable_cdf_file.c_str());
+
+ /* Read a CDF, modify, and write it. */
+ AssetCatalogService service(cdf_dir);
+ service.load_from_disk();
+ service.delete_catalog(UUID_POSES_ELLIE);
+ service.write_to_disk(cdf_dir);
+
+ const CatalogFilePath backup_path = writable_cdf_file + "~";
+ ASSERT_TRUE(BLI_is_file(backup_path.c_str()));
+
+ AssetCatalogService loaded_service;
+ loaded_service.load_from_disk(backup_path);
+
+ // Test that the expected catalogs are there, including the deleted one.
+ // This is the backup, after all.
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE_WHITESPACE));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_ELLIE_TRAILING_SLASH));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_RUZENA));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_RUZENA_HAND));
+ EXPECT_NE(nullptr, loaded_service.find_catalog(UUID_POSES_RUZENA_FACE));
+}
+
+} // namespace blender::bke::tests
diff --git a/source/blender/blenkernel/intern/asset_library.cc b/source/blender/blenkernel/intern/asset_library.cc
new file mode 100644
index 00000000000..1153e7b29f5
--- /dev/null
+++ b/source/blender/blenkernel/intern/asset_library.cc
@@ -0,0 +1,53 @@
+/*
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+/** \file
+ * \ingroup bke
+ */
+
+#include "BKE_asset_library.hh"
+
+#include "MEM_guardedalloc.h"
+
+#include <memory>
+
+/**
+ * Loading an asset library at this point only means loading the catalogs. Later on this should
+ * invoke reading of asset representations too.
+ */
+struct AssetLibrary *BKE_asset_library_load(const char *library_path)
+{
+ blender::bke::AssetLibrary *lib = new blender::bke::AssetLibrary();
+ lib->load(library_path);
+ return reinterpret_cast<struct AssetLibrary *>(lib);
+}
+
+void BKE_asset_library_free(struct AssetLibrary *asset_library)
+{
+ blender::bke::AssetLibrary *lib = reinterpret_cast<blender::bke::AssetLibrary *>(asset_library);
+ delete lib;
+}
+
+namespace blender::bke {
+
+void AssetLibrary::load(StringRefNull library_root_directory)
+{
+ auto catalog_service = std::make_unique<AssetCatalogService>(library_root_directory);
+ catalog_service->load_from_disk();
+ this->catalog_service = std::move(catalog_service);
+}
+
+} // namespace blender::bke
diff --git a/source/blender/blenkernel/intern/asset_library_test.cc b/source/blender/blenkernel/intern/asset_library_test.cc
new file mode 100644
index 00000000000..37686175aed
--- /dev/null
+++ b/source/blender/blenkernel/intern/asset_library_test.cc
@@ -0,0 +1,82 @@
+/*
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * The Original Code is Copyright (C) 2020 Blender Foundation
+ * All rights reserved.
+ */
+
+#include "BKE_appdir.h"
+#include "BKE_asset_catalog.hh"
+#include "BKE_asset_library.hh"
+
+#include "testing/testing.h"
+
+namespace blender::bke::tests {
+
+TEST(AssetLibraryTest, load_and_free_c_functions)
+{
+ const std::string test_files_dir = blender::tests::flags_test_asset_dir();
+ if (test_files_dir.empty()) {
+ FAIL();
+ }
+
+ /* Load the asset library. */
+ const std::string library_path = test_files_dir + "/" + "asset_library";
+ ::AssetLibrary *library_c_ptr = BKE_asset_library_load(library_path.data());
+ ASSERT_NE(nullptr, library_c_ptr);
+
+ /* Check that it can be cast to the C++ type and has a Catalog Service. */
+ blender::bke::AssetLibrary *library_cpp_ptr = reinterpret_cast<blender::bke::AssetLibrary *>(
+ library_c_ptr);
+ AssetCatalogService *service = library_cpp_ptr->catalog_service.get();
+ ASSERT_NE(nullptr, service);
+
+ /* Check that the catalogs defined in the library are actually loaded. This just tests one single
+ * catalog, as that indicates the file has been loaded. Testing that that loading went OK is for
+ * the asset catalog service tests. */
+ const bUUID uuid_poses_ellie("df60e1f6-2259-475b-93d9-69a1b4a8db78");
+ AssetCatalog *poses_ellie = service->find_catalog(uuid_poses_ellie);
+ ASSERT_NE(nullptr, poses_ellie) << "unable to find POSES_ELLIE catalog";
+ EXPECT_EQ("character/Ellie/poselib", poses_ellie->path);
+
+ BKE_asset_library_free(library_c_ptr);
+}
+
+TEST(AssetLibraryTest, load_nonexistent_directory)
+{
+ const std::string test_files_dir = blender::tests::flags_test_asset_dir();
+ if (test_files_dir.empty()) {
+ FAIL();
+ }
+
+ /* Load the asset library. */
+ const std::string library_path = test_files_dir + "/" +
+ "asset_library/this/subdir/does/not/exist";
+ ::AssetLibrary *library_c_ptr = BKE_asset_library_load(library_path.data());
+ ASSERT_NE(nullptr, library_c_ptr);
+
+ /* Check that it can be cast to the C++ type and has a Catalog Service. */
+ blender::bke::AssetLibrary *library_cpp_ptr = reinterpret_cast<blender::bke::AssetLibrary *>(
+ library_c_ptr);
+ AssetCatalogService *service = library_cpp_ptr->catalog_service.get();
+ ASSERT_NE(nullptr, service);
+
+ /* Check that the catalog service doesn't have any catalogs. */
+ EXPECT_TRUE(service->is_empty());
+
+ BKE_asset_library_free(library_c_ptr);
+}
+
+} // namespace blender::bke::tests
diff --git a/source/blender/blenkernel/intern/asset_test.cc b/source/blender/blenkernel/intern/asset_test.cc
new file mode 100644
index 00000000000..f1bf21a4f83
--- /dev/null
+++ b/source/blender/blenkernel/intern/asset_test.cc
@@ -0,0 +1,70 @@
+/*
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program 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 General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * The Original Code is Copyright (C) 2020 Blender Foundation
+ * All rights reserved.
+ */
+
+#include "BKE_asset.h"
+
+#include "BLI_uuid.h"
+
+#include "DNA_asset_types.h"
+
+#include "testing/testing.h"
+
+namespace blender::bke::tests {
+
+TEST(AssetMetadataTest, set_catalog_id)
+{
+ AssetMetaData meta;
+ const bUUID uuid = BLI_uuid_generate_random();
+
+ /* Test trivial values. */
+ BKE_asset_metadata_catalog_id_clear(&meta);
+ EXPECT_TRUE(BLI_uuid_is_nil(meta.catalog_id));
+ EXPECT_STREQ("", meta.catalog_simple_name);
+
+ /* Test simple situation where the given short name is used as-is. */
+ BKE_asset_metadata_catalog_id_set(&meta, uuid, "simple");
+ EXPECT_TRUE(BLI_uuid_equal(uuid, meta.catalog_id));
+ EXPECT_STREQ("simple", meta.catalog_simple_name);
+
+ /* Test whitespace trimming. */
+ BKE_asset_metadata_catalog_id_set(&meta, uuid, " Govoriš angleško? ");
+ EXPECT_STREQ("Govoriš angleško?", meta.catalog_simple_name);
+
+ /* Test length trimming to 63 chars + terminating zero. */
+ constexpr char len66[] = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
+ constexpr char len63[] = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1";
+ BKE_asset_metadata_catalog_id_set(&meta, uuid, len66);
+ EXPECT_STREQ(len63, meta.catalog_simple_name);
+
+ /* Test length trimming happens after whitespace trimming. */
+ constexpr char len68[] =
+ " \
+ 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20 ";
+ BKE_asset_metadata_catalog_id_set(&meta, uuid, len68);
+ EXPECT_STREQ(len63, meta.catalog_simple_name);
+
+ /* Test length trimming to 63 bytes, and not 63 characters. ✓ in UTF-8 is three bytes long. */
+ constexpr char with_utf8[] =
+ "00010203040506✓0708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
+ BKE_asset_metadata_catalog_id_set(&meta, uuid, with_utf8);
+ EXPECT_STREQ("00010203040506✓0708090a0b0c0d0e0f101112131415161718191a1b1c1d",
+ meta.catalog_simple_name);
+}
+
+} // namespace blender::bke::tests