mirror of
https://github.com/blender/blender
synced 2026-09-29 04:37:17 +03:00
Merge remote-tracking branch 'origin/blender-v3.0-release'
This commit is contained in:
commit
f392ce50c4
12 changed files with 353 additions and 17 deletions
|
|
@ -97,6 +97,15 @@ class AssetCatalogService {
|
|||
* or the save was successful. */
|
||||
bool write_to_disk(const CatalogFilePath &blend_file_path);
|
||||
|
||||
/**
|
||||
* Ensure that the next call to #on_blend_save_post() will choose a new location for the CDF
|
||||
* suitable for the location of the blend file (regardless of where the current catalogs come
|
||||
* from), and that catalogs will be merged with already-existing ones in that location.
|
||||
*
|
||||
* Use this for a "Save as..." that has to write the catalogs to the new blend file location,
|
||||
* instead of updating the previously read CDF. */
|
||||
void prepare_to_merge_on_write();
|
||||
|
||||
/**
|
||||
* Merge on-disk changes into the in-memory asset catalogs.
|
||||
* This should be called before writing the asset catalogs to disk.
|
||||
|
|
@ -238,6 +247,11 @@ class AssetCatalogService {
|
|||
*/
|
||||
void create_missing_catalogs();
|
||||
|
||||
/**
|
||||
* For every catalog, mark it as "dirty".
|
||||
*/
|
||||
void tag_all_catalogs_as_unsaved_changes();
|
||||
|
||||
/* For access by subclasses, as those will not be marked as friend by #AssetCatalogCollection. */
|
||||
AssetCatalogDefinitionFile *get_catalog_definition_file();
|
||||
OwningAssetCatalogMap &get_catalogs();
|
||||
|
|
|
|||
|
|
@ -98,6 +98,14 @@ bool AssetCatalogService::has_unsaved_changes() const
|
|||
return catalog_collection_->has_unsaved_changes_;
|
||||
}
|
||||
|
||||
void AssetCatalogService::tag_all_catalogs_as_unsaved_changes()
|
||||
{
|
||||
for (auto &catalog : catalog_collection_->catalogs_.values()) {
|
||||
catalog->flags.has_unsaved_changes = true;
|
||||
}
|
||||
catalog_collection_->has_unsaved_changes_ = true;
|
||||
}
|
||||
|
||||
bool AssetCatalogService::is_empty() const
|
||||
{
|
||||
BLI_assert(catalog_collection_);
|
||||
|
|
@ -486,6 +494,24 @@ bool AssetCatalogService::write_to_disk_ex(const CatalogFilePath &blend_file_pat
|
|||
return catalog_collection_->catalog_definition_file_->write_to_disk();
|
||||
}
|
||||
|
||||
void AssetCatalogService::prepare_to_merge_on_write()
|
||||
{
|
||||
/* TODO(Sybren): expand to support multiple CDFs. */
|
||||
|
||||
if (!catalog_collection_->catalog_definition_file_) {
|
||||
/* There is no CDF connected, so it's a no-op. */
|
||||
return;
|
||||
}
|
||||
|
||||
/* Remove any association with the CDF, so that a new location will be chosen
|
||||
* when the blend file is saved. */
|
||||
catalog_collection_->catalog_definition_file_.reset();
|
||||
|
||||
/* Mark all in-memory catalogs as "dirty", to force them to be kept around on
|
||||
* the next "load-merge-write" cycle. */
|
||||
tag_all_catalogs_as_unsaved_changes();
|
||||
}
|
||||
|
||||
CatalogFilePath AssetCatalogService::find_suitable_cdf_path_for_writing(
|
||||
const CatalogFilePath &blend_file_path)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@
|
|||
* \brief external writefile function prototypes.
|
||||
*/
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
struct BlendThumbnail;
|
||||
struct Main;
|
||||
struct MemFile;
|
||||
|
|
@ -72,3 +76,7 @@ extern bool BLO_write_file_mem(struct Main *mainvar,
|
|||
int write_flags);
|
||||
|
||||
/** \} */
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ extern "C" {
|
|||
|
||||
int ED_asset_library_reference_to_enum_value(const AssetLibraryReference *library);
|
||||
AssetLibraryReference ED_asset_library_reference_from_enum_value(int value);
|
||||
const struct EnumPropertyItem *ED_asset_library_reference_to_rna_enum_itemf(void);
|
||||
const struct EnumPropertyItem *ED_asset_library_reference_to_rna_enum_itemf(
|
||||
bool include_local_library);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,25 +98,30 @@ AssetLibraryReference ED_asset_library_reference_from_enum_value(int value)
|
|||
*
|
||||
* Since this is meant for UI display, skips non-displayable libraries, that is, libraries with an
|
||||
* empty name or path.
|
||||
*
|
||||
* \param include_local_library whether to include the "Current File" library or not.
|
||||
*/
|
||||
const EnumPropertyItem *ED_asset_library_reference_to_rna_enum_itemf()
|
||||
const EnumPropertyItem *ED_asset_library_reference_to_rna_enum_itemf(
|
||||
const bool include_local_library)
|
||||
{
|
||||
const EnumPropertyItem predefined_items[] = {
|
||||
/* For the future. */
|
||||
// {ASSET_REPO_BUNDLED, "BUNDLED", 0, "Bundled", "Show the default user assets"},
|
||||
{ASSET_LIBRARY_LOCAL,
|
||||
"LOCAL",
|
||||
ICON_BLENDER,
|
||||
"Current File",
|
||||
"Show the assets currently available in this Blender session"},
|
||||
{0, nullptr, 0, nullptr, nullptr},
|
||||
};
|
||||
|
||||
EnumPropertyItem *item = nullptr;
|
||||
int totitem = 0;
|
||||
|
||||
/* Add predefined items. */
|
||||
RNA_enum_items_add(&item, &totitem, predefined_items);
|
||||
if (include_local_library) {
|
||||
const EnumPropertyItem predefined_items[] = {
|
||||
/* For the future. */
|
||||
// {ASSET_REPO_BUNDLED, "BUNDLED", 0, "Bundled", "Show the default user assets"},
|
||||
{ASSET_LIBRARY_LOCAL,
|
||||
"LOCAL",
|
||||
ICON_BLENDER,
|
||||
"Current File",
|
||||
"Show the assets currently available in this Blender session"},
|
||||
{0, nullptr, 0, nullptr, nullptr},
|
||||
};
|
||||
|
||||
/* Add predefined items. */
|
||||
RNA_enum_items_add(&item, &totitem, predefined_items);
|
||||
}
|
||||
|
||||
/* Add separator if needed. */
|
||||
if (!BLI_listbase_is_empty(&U.asset_libraries)) {
|
||||
|
|
|
|||
|
|
@ -19,12 +19,19 @@
|
|||
*/
|
||||
|
||||
#include "BKE_asset_library.hh"
|
||||
#include "BKE_bpath.h"
|
||||
#include "BKE_context.h"
|
||||
#include "BKE_global.h"
|
||||
#include "BKE_lib_id.h"
|
||||
#include "BKE_main.h"
|
||||
#include "BKE_preferences.h"
|
||||
#include "BKE_report.h"
|
||||
|
||||
#include "BLI_fileops.h"
|
||||
#include "BLI_fnmatch.h"
|
||||
#include "BLI_path_util.h"
|
||||
#include "ED_asset.h"
|
||||
#include "ED_util.h"
|
||||
/* XXX needs access to the file list, should all be done via the asset system in future. */
|
||||
#include "ED_fileselect.h"
|
||||
|
||||
|
|
@ -33,6 +40,10 @@
|
|||
|
||||
#include "WM_api.h"
|
||||
|
||||
#include "DNA_space_types.h"
|
||||
|
||||
#include "BLO_writefile.h"
|
||||
|
||||
using namespace blender;
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
|
@ -643,6 +654,240 @@ static void ASSET_OT_catalogs_save(struct wmOperatorType *ot)
|
|||
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
||||
static bool could_be_asset_bundle(const Main *bmain);
|
||||
static const bUserAssetLibrary *selected_asset_library(struct wmOperator *op);
|
||||
static bool is_contained_in_selected_asset_library(struct wmOperator *op, const char *filepath);
|
||||
static bool set_filepath_for_asset_lib(const Main *bmain, struct wmOperator *op);
|
||||
static bool has_external_files(Main *bmain, struct ReportList *reports);
|
||||
|
||||
static bool asset_bundle_install_poll(bContext *C)
|
||||
{
|
||||
/* This operator only works when the asset browser is set to Current File. */
|
||||
const SpaceFile *sfile = CTX_wm_space_file(C);
|
||||
if (!ED_fileselect_is_local_asset_library(sfile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const Main *bmain = CTX_data_main(C);
|
||||
if (!could_be_asset_bundle(bmain)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Check whether this file is already located inside any asset library. */
|
||||
const struct bUserAssetLibrary *asset_lib = BKE_preferences_asset_library_containing_path(
|
||||
&U, bmain->name);
|
||||
if (asset_lib) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static int asset_bundle_install_invoke(struct bContext *C,
|
||||
struct wmOperator *op,
|
||||
const struct wmEvent * /*event*/)
|
||||
{
|
||||
Main *bmain = CTX_data_main(C);
|
||||
if (has_external_files(bmain, op->reports)) {
|
||||
return OPERATOR_CANCELLED;
|
||||
}
|
||||
|
||||
WM_event_add_fileselect(C, op);
|
||||
|
||||
/* Make the "Save As" dialog box default to "${ASSET_LIB_ROOT}/${CURRENT_FILE}.blend". */
|
||||
if (!set_filepath_for_asset_lib(bmain, op)) {
|
||||
return OPERATOR_CANCELLED;
|
||||
}
|
||||
|
||||
return OPERATOR_RUNNING_MODAL;
|
||||
}
|
||||
|
||||
static int asset_bundle_install_exec(bContext *C, wmOperator *op)
|
||||
{
|
||||
Main *bmain = CTX_data_main(C);
|
||||
if (has_external_files(bmain, op->reports)) {
|
||||
return OPERATOR_CANCELLED;
|
||||
}
|
||||
|
||||
/* Check file path, copied from #wm_file_write(). */
|
||||
char filepath[PATH_MAX];
|
||||
RNA_string_get(op->ptr, "filepath", filepath);
|
||||
const size_t len = strlen(filepath);
|
||||
|
||||
if (len == 0) {
|
||||
BKE_report(op->reports, RPT_ERROR, "Path is empty, cannot save");
|
||||
return OPERATOR_CANCELLED;
|
||||
}
|
||||
|
||||
if (len >= FILE_MAX) {
|
||||
BKE_report(op->reports, RPT_ERROR, "Path too long, cannot save");
|
||||
return OPERATOR_CANCELLED;
|
||||
}
|
||||
|
||||
/* Check that the destination is actually contained in the selected asset library. */
|
||||
if (!is_contained_in_selected_asset_library(op, filepath)) {
|
||||
BKE_reportf(op->reports, RPT_ERROR, "Selected path is outside of the selected asset library");
|
||||
return OPERATOR_CANCELLED;
|
||||
}
|
||||
|
||||
WM_cursor_wait(true);
|
||||
bke::AssetCatalogService *cat_service = get_catalog_service(C);
|
||||
/* Store undo step, such that on a failed save the 'prepare_to_merge_on_write' call can be
|
||||
* un-done. */
|
||||
cat_service->undo_push();
|
||||
cat_service->prepare_to_merge_on_write();
|
||||
|
||||
const int operator_result = WM_operator_name_call(
|
||||
C, "WM_OT_save_mainfile", WM_OP_EXEC_DEFAULT, op->ptr);
|
||||
WM_cursor_wait(false);
|
||||
|
||||
if (operator_result != OPERATOR_FINISHED) {
|
||||
cat_service->undo();
|
||||
return operator_result;
|
||||
}
|
||||
|
||||
const bUserAssetLibrary *lib = selected_asset_library(op);
|
||||
BLI_assert_msg(lib, "If the asset library is not known, how did we get here?");
|
||||
BKE_reportf(op->reports,
|
||||
RPT_INFO,
|
||||
"Saved \"%s\" to asset library \"%s\"",
|
||||
BLI_path_basename(bmain->name),
|
||||
lib->name);
|
||||
return OPERATOR_FINISHED;
|
||||
}
|
||||
|
||||
static const EnumPropertyItem *rna_asset_library_reference_itemf(bContext *UNUSED(C),
|
||||
PointerRNA *UNUSED(ptr),
|
||||
PropertyRNA *UNUSED(prop),
|
||||
bool *r_free)
|
||||
{
|
||||
const EnumPropertyItem *items = ED_asset_library_reference_to_rna_enum_itemf(false);
|
||||
if (!items) {
|
||||
*r_free = false;
|
||||
}
|
||||
|
||||
*r_free = true;
|
||||
return items;
|
||||
}
|
||||
|
||||
static void ASSET_OT_bundle_install(struct wmOperatorType *ot)
|
||||
{
|
||||
/* identifiers */
|
||||
ot->name = "Copy to Asset Library";
|
||||
ot->description =
|
||||
"Copy the current .blend file into an Asset Library. Only works on standalone .blend files "
|
||||
"(i.e. when no other files are referenced)";
|
||||
ot->idname = "ASSET_OT_bundle_install";
|
||||
|
||||
/* api callbacks */
|
||||
ot->exec = asset_bundle_install_exec;
|
||||
ot->invoke = asset_bundle_install_invoke;
|
||||
ot->poll = asset_bundle_install_poll;
|
||||
|
||||
ot->prop = RNA_def_property(ot->srna, "asset_library_ref", PROP_ENUM, PROP_NONE);
|
||||
RNA_def_property_flag(ot->prop, PROP_HIDDEN);
|
||||
RNA_def_enum_funcs(ot->prop, rna_asset_library_reference_itemf);
|
||||
|
||||
WM_operator_properties_filesel(ot,
|
||||
FILE_TYPE_FOLDER | FILE_TYPE_BLENDER,
|
||||
FILE_BLENDER,
|
||||
FILE_SAVE,
|
||||
WM_FILESEL_FILEPATH,
|
||||
FILE_DEFAULTDISPLAY,
|
||||
FILE_SORT_DEFAULT);
|
||||
}
|
||||
|
||||
/* Cheap check to see if this is an "asset bundle" just by checking main file name.
|
||||
* A proper check will be done in the exec function, to ensure that no external files will be
|
||||
* referenced. */
|
||||
static bool could_be_asset_bundle(const Main *bmain)
|
||||
{
|
||||
return fnmatch("*_bundle.blend", bmain->name, FNM_CASEFOLD) == 0;
|
||||
}
|
||||
|
||||
static const bUserAssetLibrary *selected_asset_library(struct wmOperator *op)
|
||||
{
|
||||
const int enum_value = RNA_enum_get(op->ptr, "asset_library_ref");
|
||||
const AssetLibraryReference lib_ref = ED_asset_library_reference_from_enum_value(enum_value);
|
||||
const bUserAssetLibrary *lib = BKE_preferences_asset_library_find_from_index(
|
||||
&U, lib_ref.custom_library_index);
|
||||
return lib;
|
||||
}
|
||||
|
||||
static bool is_contained_in_selected_asset_library(struct wmOperator *op, const char *filepath)
|
||||
{
|
||||
const bUserAssetLibrary *lib = selected_asset_library(op);
|
||||
if (!lib) {
|
||||
return false;
|
||||
}
|
||||
return BLI_path_contains(lib->path, filepath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the "filepath" RNA property based on selected "asset_library_ref".
|
||||
* \return true if ok, false if error.
|
||||
*/
|
||||
static bool set_filepath_for_asset_lib(const Main *bmain, struct wmOperator *op)
|
||||
{
|
||||
/* Find the directory path of the selected asset library. */
|
||||
const bUserAssetLibrary *lib = selected_asset_library(op);
|
||||
if (lib == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Concatenate the filename of the current blend file. */
|
||||
const char *blend_filename = BLI_path_basename(bmain->name);
|
||||
if (blend_filename == NULL || blend_filename[0] == '\0') {
|
||||
return false;
|
||||
}
|
||||
|
||||
char file_path[PATH_MAX];
|
||||
BLI_join_dirfile(file_path, sizeof(file_path), lib->path, blend_filename);
|
||||
RNA_string_set(op->ptr, "filepath", file_path);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
struct FileCheckCallbackInfo {
|
||||
struct ReportList *reports;
|
||||
bool external_file_found;
|
||||
};
|
||||
|
||||
static bool external_file_check_callback(void *callback_info_ptr,
|
||||
char * /*path_dst*/,
|
||||
const char *path_src)
|
||||
{
|
||||
FileCheckCallbackInfo *callback_info = static_cast<FileCheckCallbackInfo *>(callback_info_ptr);
|
||||
BKE_reportf(callback_info->reports,
|
||||
RPT_ERROR,
|
||||
"Unable to install asset bundle, has external dependency \"%s\"",
|
||||
path_src);
|
||||
callback_info->external_file_found = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do a check on any external files (.blend, textures, etc.) being used.
|
||||
* The "Install asset bundle" operator only works on standalone .blend files
|
||||
* (catalog definition files are fine, though).
|
||||
*
|
||||
* \return true when there are external files, false otherwise.
|
||||
*/
|
||||
static bool has_external_files(Main *bmain, struct ReportList *reports)
|
||||
{
|
||||
struct FileCheckCallbackInfo callback_info = {reports, false};
|
||||
|
||||
BKE_bpath_traverse_main(
|
||||
bmain,
|
||||
&external_file_check_callback,
|
||||
BKE_BPATH_TRAVERSE_SKIP_PACKED /* Packed files are fine. */
|
||||
| BKE_BPATH_TRAVERSE_SKIP_MULTIFILE /* Only report multifiles once, it's enough. */,
|
||||
&callback_info);
|
||||
return callback_info.external_file_found;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
||||
void ED_operatortypes_asset(void)
|
||||
{
|
||||
WM_operatortype_append(ASSET_OT_mark);
|
||||
|
|
@ -654,6 +899,7 @@ void ED_operatortypes_asset(void)
|
|||
WM_operatortype_append(ASSET_OT_catalog_undo);
|
||||
WM_operatortype_append(ASSET_OT_catalog_redo);
|
||||
WM_operatortype_append(ASSET_OT_catalog_undo_push);
|
||||
WM_operatortype_append(ASSET_OT_bundle_install);
|
||||
|
||||
WM_operatortype_append(ASSET_OT_list_refresh);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ struct FileSelectParams *ED_fileselect_ensure_active_params(struct SpaceFile *sf
|
|||
struct FileSelectParams *ED_fileselect_get_active_params(const struct SpaceFile *sfile);
|
||||
struct FileSelectParams *ED_fileselect_get_file_params(const struct SpaceFile *sfile);
|
||||
struct FileAssetSelectParams *ED_fileselect_get_asset_params(const struct SpaceFile *sfile);
|
||||
bool ED_fileselect_is_local_asset_library(const struct SpaceFile *sfile);
|
||||
|
||||
void ED_fileselect_set_params_from_userdef(struct SpaceFile *sfile);
|
||||
void ED_fileselect_params_to_userdef(struct SpaceFile *sfile,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
#include "ED_fileselect.h"
|
||||
|
||||
#include "UI_interface.h"
|
||||
#include "UI_interface_icons.h"
|
||||
#include "UI_resources.h"
|
||||
|
||||
#include "WM_api.h"
|
||||
|
|
@ -246,7 +247,20 @@ static void file_panel_asset_catalog_buttons_draw(const bContext *C, Panel *pane
|
|||
RNA_pointer_create(&screen->id, &RNA_FileAssetSelectParams, params, ¶ms_ptr);
|
||||
|
||||
uiItemR(row, ¶ms_ptr, "asset_library_ref", 0, "", ICON_NONE);
|
||||
if (params->asset_library_ref.type != ASSET_LIBRARY_LOCAL) {
|
||||
if (params->asset_library_ref.type == ASSET_LIBRARY_LOCAL) {
|
||||
bContext *mutable_ctx = CTX_copy(C);
|
||||
if (WM_operator_name_poll(mutable_ctx, "asset.bundle_install")) {
|
||||
uiItemS(col);
|
||||
uiItemMenuEnumO(col,
|
||||
mutable_ctx,
|
||||
"asset.bundle_install",
|
||||
"asset_library_ref",
|
||||
"Copy Bundle to Asset Library...",
|
||||
ICON_IMPORT);
|
||||
}
|
||||
CTX_free(mutable_ctx);
|
||||
}
|
||||
else {
|
||||
uiItemO(row, "", ICON_FILE_REFRESH, "FILE_OT_asset_library_refresh");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -411,6 +411,15 @@ FileAssetSelectParams *ED_fileselect_get_asset_params(const SpaceFile *sfile)
|
|||
return (sfile->browse_mode == FILE_BROWSE_MODE_ASSETS) ? sfile->asset_params : NULL;
|
||||
}
|
||||
|
||||
bool ED_fileselect_is_local_asset_library(const SpaceFile *sfile)
|
||||
{
|
||||
const FileAssetSelectParams *asset_params = ED_fileselect_get_asset_params(sfile);
|
||||
if (asset_params == NULL) {
|
||||
return false;
|
||||
}
|
||||
return asset_params->asset_library_ref.type == ASSET_LIBRARY_LOCAL;
|
||||
}
|
||||
|
||||
static void fileselect_refresh_asset_params(FileAssetSelectParams *asset_params)
|
||||
{
|
||||
AssetLibraryReference *library = &asset_params->asset_library_ref;
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ const EnumPropertyItem *rna_asset_library_reference_itemf(bContext *UNUSED(C),
|
|||
PropertyRNA *UNUSED(prop),
|
||||
bool *r_free)
|
||||
{
|
||||
const EnumPropertyItem *items = ED_asset_library_reference_to_rna_enum_itemf();
|
||||
const EnumPropertyItem *items = ED_asset_library_reference_to_rna_enum_itemf(true);
|
||||
if (!items) {
|
||||
*r_free = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -470,6 +470,8 @@ int WM_operator_repeat(struct bContext *C, struct wmOperator *op);
|
|||
int WM_operator_repeat_last(struct bContext *C, struct wmOperator *op);
|
||||
bool WM_operator_repeat_check(const struct bContext *C, struct wmOperator *op);
|
||||
bool WM_operator_is_repeat(const struct bContext *C, const struct wmOperator *op);
|
||||
|
||||
bool WM_operator_name_poll(struct bContext *C, const char *opstring);
|
||||
int WM_operator_name_call_ptr(struct bContext *C,
|
||||
struct wmOperatorType *ot,
|
||||
wmOperatorCallContext context,
|
||||
|
|
|
|||
|
|
@ -1614,6 +1614,16 @@ int WM_operator_name_call(bContext *C,
|
|||
return 0;
|
||||
}
|
||||
|
||||
bool WM_operator_name_poll(bContext *C, const char *opstring)
|
||||
{
|
||||
wmOperatorType *ot = WM_operatortype_find(opstring, 0);
|
||||
if (!ot) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return WM_operator_poll(C, ot);
|
||||
}
|
||||
|
||||
int WM_operator_name_call_with_properties(struct bContext *C,
|
||||
const char *opstring,
|
||||
wmOperatorCallContext context,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue