CLOUD: Polish Callbacks
Cleaned up all example code and old callbacks. New Callback classes are introduced in "common/callback.h" and documented.
This commit is contained in:
parent
e1109c0c32
commit
9e531e3ce7
10 changed files with 153 additions and 156 deletions
|
@ -35,17 +35,6 @@ namespace Dropbox {
|
|||
Common::String DropboxStorage::KEY; //can't use ConfMan there yet, loading it on instance creation/auth
|
||||
Common::String DropboxStorage::SECRET; //TODO: hide these secrets somehow
|
||||
|
||||
static void printJsonCallback(Networking::Request* rq, void *ptr) {
|
||||
Common::JSONValue *json = (Common::JSONValue *)ptr;
|
||||
if (json) {
|
||||
debug("printJsonCallback:");
|
||||
debug("%s", json->stringify(true).c_str());
|
||||
delete json;
|
||||
} else {
|
||||
debug("printJsonCallback: got NULL instead of JSON!");
|
||||
}
|
||||
}
|
||||
|
||||
static void saveAccessTokenCallback(void *ptr) {
|
||||
Common::JSONValue *json = (Common::JSONValue *)ptr;
|
||||
if (json) {
|
||||
|
@ -69,53 +58,6 @@ static void saveAccessTokenCallback(void *ptr) {
|
|||
}
|
||||
}
|
||||
|
||||
void infoCallback(Networking::Request* request, void *jsonPointer) {
|
||||
if (!request) {
|
||||
warning("infoCallback: got NULL instead of Request");
|
||||
|
||||
Common::JSONValue *json = (Common::JSONValue *)jsonPointer;
|
||||
if (json) delete json; //yeah I know we can delete NULL safely
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::InfoCallback callback = (Storage::InfoCallback)request->pointer();
|
||||
|
||||
Common::JSONValue *json = (Common::JSONValue *)jsonPointer;
|
||||
if (json) {
|
||||
//Common::JSONObject result = json->asObject();
|
||||
if (callback) {
|
||||
callback(StorageInfo(json->stringify()));
|
||||
}
|
||||
delete json;
|
||||
} else {
|
||||
warning("infoCallback: got NULL instead of JSON!");
|
||||
}
|
||||
}
|
||||
|
||||
void info2Callback(Networking::Request* request, void *jsonPointer) {
|
||||
if (!request) {
|
||||
warning("infoCallback: got NULL instead of Request");
|
||||
|
||||
Common::JSONValue *json = (Common::JSONValue *)jsonPointer;
|
||||
if (json) delete json; //yeah I know we can delete NULL safely
|
||||
return;
|
||||
}
|
||||
|
||||
Common::BaseCallback *callback = (Common::BaseCallback *)request->pointer();
|
||||
|
||||
Common::JSONValue *json = (Common::JSONValue *)jsonPointer;
|
||||
if (json) {
|
||||
//Common::JSONObject result = json->asObject();
|
||||
if (callback) {
|
||||
(*callback)(new StorageInfo(json->stringify()));
|
||||
delete callback;
|
||||
}
|
||||
delete json;
|
||||
} else {
|
||||
warning("infoCallback: got NULL instead of JSON!");
|
||||
}
|
||||
}
|
||||
|
||||
DropboxStorage::DropboxStorage(Common::String accessToken, Common::String userId): _token(accessToken), _uid(userId) {
|
||||
curl_global_init(CURL_GLOBAL_ALL);
|
||||
}
|
||||
|
@ -124,41 +66,42 @@ DropboxStorage::~DropboxStorage() {
|
|||
curl_global_cleanup();
|
||||
}
|
||||
|
||||
void syncSavesInfoCallback(StorageInfo info) {
|
||||
debug("info: %s", info.info().c_str());
|
||||
}
|
||||
|
||||
void DropboxStorage::infoMethodCallback(void *storageInfo) {
|
||||
StorageInfo *info = (StorageInfo *)storageInfo;
|
||||
debug("info: %s", info->info().c_str());
|
||||
}
|
||||
|
||||
void DropboxStorage::syncSaves(OperationCallback callback) {
|
||||
void DropboxStorage::syncSaves(Common::BaseCallback<bool> *callback) {
|
||||
//this is not the real syncSaves() implementation
|
||||
info2(new Common::Callback<DropboxStorage>(this, &DropboxStorage::infoMethodCallback));
|
||||
info(new Common::Callback<DropboxStorage, StorageInfo>(this, &DropboxStorage::infoMethodCallback));
|
||||
//that line meant the following:
|
||||
//"please, do the info API request and, when it's finished, call the infoMethodCallback() of me"
|
||||
}
|
||||
|
||||
void DropboxStorage::info(InfoCallback callback) {
|
||||
/*
|
||||
Networking::CurlJsonRequest *request = new Networking::CurlJsonRequest(infoCallback, "https://api.dropboxapi.com/1/account/info");
|
||||
request->addHeader("Authorization: Bearer " + _token);
|
||||
ConnMan.addRequest(request);
|
||||
|
||||
request->setPointer(callback);
|
||||
*/
|
||||
}
|
||||
|
||||
void DropboxStorage::info2BridgeCallback(Common::BaseCallback *outerCallback, void *ptr) {
|
||||
//no NULL checks, delete and such yet
|
||||
Common::JSONValue *json = (Common::JSONValue *)ptr;
|
||||
(*outerCallback)(new StorageInfo(json->stringify()));
|
||||
}
|
||||
|
||||
void DropboxStorage::info2(Common::BaseCallback *outerCallback) {
|
||||
Common::BaseCallback *innerCallback = new Common::CallbackBridge<DropboxStorage>(this, &DropboxStorage::info2BridgeCallback, outerCallback);
|
||||
void DropboxStorage::info(Common::BaseCallback<StorageInfo> *outerCallback) {
|
||||
Common::BaseCallback<> *innerCallback = new Common::CallbackBridge<DropboxStorage, StorageInfo>(this, &DropboxStorage::infoInnerCallback, outerCallback);
|
||||
Networking::CurlJsonRequest *request = new Networking::CurlJsonRequest(innerCallback, "https://api.dropboxapi.com/1/account/info");
|
||||
request->addHeader("Authorization: Bearer " + _token);
|
||||
ConnMan.addRequest(request);
|
||||
//that callback bridge wraps the outerCallback (passed in arguments from user) into innerCallback
|
||||
//so, when CurlJsonRequest is finished, it calls the innerCallback
|
||||
//innerCallback (which is DropboxStorage::infoInnerCallback in this case) processes the void *ptr
|
||||
//and then calls the outerCallback (which wants to receive StorageInfo, not void *)
|
||||
}
|
||||
|
||||
void DropboxStorage::infoInnerCallback(Common::BaseCallback<StorageInfo> *outerCallback, void *ptr) {
|
||||
Common::JSONValue *json = (Common::JSONValue *)ptr;
|
||||
if (!json) {
|
||||
warning("NULL passed instead of JSON");
|
||||
delete outerCallback;
|
||||
return;
|
||||
}
|
||||
|
||||
if (outerCallback) {
|
||||
(*outerCallback)(StorageInfo(json->stringify()));
|
||||
delete outerCallback;
|
||||
}
|
||||
|
||||
delete json;
|
||||
}
|
||||
|
||||
void DropboxStorage::infoMethodCallback(StorageInfo storageInfo) {
|
||||
debug("info: %s", storageInfo.info().c_str());
|
||||
}
|
||||
|
||||
DropboxStorage *DropboxStorage::loadFromConfig() {
|
||||
|
@ -212,7 +155,7 @@ void DropboxStorage::authThroughConsole() {
|
|||
}
|
||||
|
||||
void DropboxStorage::getAccessToken(Common::String code) {
|
||||
Common::BaseCallback *callback = new Common::GlobalFunctionCallback(saveAccessTokenCallback);
|
||||
Common::BaseCallback<> *callback = new Common::GlobalFunctionCallback(saveAccessTokenCallback);
|
||||
Networking::CurlJsonRequest *request = new Networking::CurlJsonRequest(callback, "https://api.dropboxapi.com/1/oauth2/token");
|
||||
request->addPostField("code=" + code);
|
||||
request->addPostField("grant_type=authorization_code");
|
||||
|
|
|
@ -24,7 +24,6 @@
|
|||
#define BACKENDS_CLOUD_DROPBOX_STORAGE_H
|
||||
|
||||
#include "backends/cloud/storage.h"
|
||||
#include "backends/cloud/manager.h"
|
||||
#include "common/callback.h"
|
||||
|
||||
namespace Cloud {
|
||||
|
@ -40,36 +39,38 @@ class DropboxStorage: public Cloud::Storage {
|
|||
|
||||
static void getAccessToken(Common::String code);
|
||||
|
||||
void infoMethodCallback(void *serviceInfoPtr);
|
||||
|
||||
public:
|
||||
virtual ~DropboxStorage();
|
||||
|
||||
/** Returns pointer to Common::Array<CloudFile>. */
|
||||
virtual void listDirectory(Common::String path, ListDirectoryCallback callback) {} //TODO
|
||||
/** Returns pointer to Common::Array<StorageFile>. */
|
||||
virtual void listDirectory(Common::String path, Common::BaseCallback< Common::Array<StorageFile> > *callback) {} //TODO
|
||||
|
||||
/** Calls the callback when finished. */
|
||||
virtual void upload(Common::String path, Common::ReadStream* contents, OperationCallback callback) {} //TODO
|
||||
virtual void upload(Common::String path, Common::ReadStream* contents, Common::BaseCallback<bool> *callback) {} //TODO
|
||||
|
||||
/** Returns pointer to Common::ReadStream. */
|
||||
virtual void download(Common::String path, DownloadCallback callback) {} //TODO
|
||||
virtual void download(Common::String path, Common::BaseCallback<Common::ReadStream> *callback) {} //TODO
|
||||
|
||||
/** Calls the callback when finished. */
|
||||
virtual void remove(Common::String path, OperationCallback callback) {} //TODO
|
||||
virtual void remove(Common::String path, Common::BaseCallback<bool> *callback) {} //TODO
|
||||
|
||||
/** Calls the callback when finished. */
|
||||
virtual void syncSaves(OperationCallback callback);
|
||||
virtual void syncSaves(Common::BaseCallback<bool> *callback);
|
||||
|
||||
/** Calls the callback when finished. */
|
||||
virtual void createDirectory(Common::String path, OperationCallback callback) {} //TODO
|
||||
virtual void createDirectory(Common::String path, Common::BaseCallback<bool> *callback) {} //TODO
|
||||
|
||||
/** Calls the callback when finished. */
|
||||
virtual void touch(Common::String path, OperationCallback callback) {} //TODO
|
||||
virtual void touch(Common::String path, Common::BaseCallback<bool> *callback) {} //TODO
|
||||
|
||||
/** Returns pointer to the ServiceInfo struct. */
|
||||
virtual void info(InfoCallback callback);
|
||||
void info2(Common::BaseCallback *outerCallback);
|
||||
void info2BridgeCallback(Common::BaseCallback *outerCallback, void *ptr);
|
||||
/** Returns pointer to the StorageInfo struct. */
|
||||
virtual void info(Common::BaseCallback<StorageInfo> *callback);
|
||||
|
||||
/** This is what is called by CurlJsonRequest. */
|
||||
void infoInnerCallback(Common::BaseCallback<StorageInfo> *outerCallback, void *ptr);
|
||||
|
||||
/** This is what is called by infoInnerCallback() (it's its outer callback). */
|
||||
void infoMethodCallback(StorageInfo storageInfo);
|
||||
|
||||
/** Returns whether saves sync process is running. */
|
||||
virtual bool isSyncing() { return false; } //TODO
|
||||
|
|
|
@ -46,7 +46,7 @@ Storage* Manager::getCurrentStorage() {
|
|||
return _currentStorage;
|
||||
}
|
||||
|
||||
void Manager::syncSaves(Storage::OperationCallback callback) {
|
||||
void Manager::syncSaves(Common::BaseCallback<bool> *callback) {
|
||||
Storage* storage = getCurrentStorage();
|
||||
if (storage) storage->syncSaves(callback);
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ public:
|
|||
virtual void init();
|
||||
|
||||
virtual Storage* getCurrentStorage();
|
||||
virtual void syncSaves(Storage::OperationCallback callback);
|
||||
virtual void syncSaves(Common::BaseCallback<bool> *callback);
|
||||
};
|
||||
|
||||
} //end of namespace Cloud
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
#include "common/array.h"
|
||||
#include "common/stream.h"
|
||||
#include "common/str.h"
|
||||
#include "common/callback.h"
|
||||
|
||||
namespace Cloud {
|
||||
|
||||
|
@ -70,37 +71,32 @@ public:
|
|||
|
||||
class Storage {
|
||||
public:
|
||||
typedef void(*ListDirectoryCallback)(Common::Array<StorageFile>& result);
|
||||
typedef void(*DownloadCallback)(Common::ReadStream* result);
|
||||
typedef void(*InfoCallback)(StorageInfo result);
|
||||
typedef void(*OperationCallback)(bool successed);
|
||||
|
||||
Storage() {}
|
||||
virtual ~Storage() {}
|
||||
|
||||
/** Returns pointer to Common::Array<CloudFile>. */
|
||||
virtual void listDirectory(Common::String path, ListDirectoryCallback callback) = 0;
|
||||
/** Returns pointer to Common::Array<StorageFile>. */
|
||||
virtual void listDirectory(Common::String path, Common::BaseCallback< Common::Array<StorageFile> > *callback) = 0;
|
||||
|
||||
/** Calls the callback when finished. */
|
||||
virtual void upload(Common::String path, Common::ReadStream* contents, OperationCallback callback) = 0;
|
||||
virtual void upload(Common::String path, Common::ReadStream* contents, Common::BaseCallback<bool> *callback) = 0;
|
||||
|
||||
/** Returns pointer to Common::ReadStream. */
|
||||
virtual void download(Common::String path, DownloadCallback callback) = 0;
|
||||
virtual void download(Common::String path, Common::BaseCallback<Common::ReadStream> *callback) = 0;
|
||||
|
||||
/** Calls the callback when finished. */
|
||||
virtual void remove(Common::String path, OperationCallback callback) = 0;
|
||||
virtual void remove(Common::String path, Common::BaseCallback<bool> *callback) = 0;
|
||||
|
||||
/** Calls the callback when finished. */
|
||||
virtual void syncSaves(OperationCallback callback) = 0;
|
||||
virtual void syncSaves(Common::BaseCallback<bool> *callback) = 0;
|
||||
|
||||
/** Calls the callback when finished. */
|
||||
virtual void createDirectory(Common::String path, OperationCallback callback) = 0;
|
||||
virtual void createDirectory(Common::String path, Common::BaseCallback<bool> *callback) = 0;
|
||||
|
||||
/** Calls the callback when finished. */
|
||||
virtual void touch(Common::String path, OperationCallback callback) = 0;
|
||||
virtual void touch(Common::String path, Common::BaseCallback<bool> *callback) = 0;
|
||||
|
||||
/** Returns pointer to the ServiceInfo struct. */
|
||||
virtual void info(InfoCallback callback) = 0;
|
||||
/** Returns pointer to the StorageInfo struct. */
|
||||
virtual void info(Common::BaseCallback<StorageInfo> *callback) = 0;
|
||||
|
||||
/** Returns whether saves sync process is running. */
|
||||
virtual bool isSyncing() = 0;
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
|
||||
namespace Networking {
|
||||
|
||||
CurlJsonRequest::CurlJsonRequest(Common::BaseCallback* cb, const char *url) : Request(cb), _stream(0), _headersList(0), _contentsStream(DisposeAfterUse::YES) {
|
||||
CurlJsonRequest::CurlJsonRequest(Common::BaseCallback<> *cb, const char *url): Request(cb), _stream(0), _headersList(0), _contentsStream(DisposeAfterUse::YES) {
|
||||
_url = url;
|
||||
}
|
||||
|
||||
|
|
|
@ -25,10 +25,7 @@
|
|||
|
||||
#include "backends/networking/curl/request.h"
|
||||
#include "common/memstream.h"
|
||||
|
||||
namespace Common {
|
||||
class BaseCallback;
|
||||
}
|
||||
#include "common/json.h"
|
||||
|
||||
struct curl_slist;
|
||||
|
||||
|
@ -47,7 +44,7 @@ class CurlJsonRequest : public Request {
|
|||
char *getPreparedContents();
|
||||
|
||||
public:
|
||||
CurlJsonRequest(Common::BaseCallback *cb, const char *url);
|
||||
CurlJsonRequest(Common::BaseCallback<> *cb, const char *url);
|
||||
virtual ~CurlJsonRequest();
|
||||
|
||||
virtual bool handle();
|
||||
|
|
|
@ -22,30 +22,22 @@
|
|||
|
||||
#ifndef BACKENDS_NETWORKING_CURL_REQUEST_H
|
||||
#define BACKENDS_NETWORKING_CURL_REQUEST_H
|
||||
#include <common/callback.h>
|
||||
|
||||
#include "common/callback.h"
|
||||
|
||||
namespace Networking {
|
||||
|
||||
class Request {
|
||||
protected:
|
||||
typedef void(*SimpleCallback)(Request* request, void *result);
|
||||
|
||||
/**
|
||||
* Callback, which should be called before Request returns true in handle().
|
||||
* That's the way Requests pass the result to the code which asked to create this request.
|
||||
*/
|
||||
|
||||
Common::BaseCallback* _callback;
|
||||
|
||||
/**
|
||||
* Pointer, which could be set by Request creating code. It might be accessed
|
||||
* from this Request when callback is called, for example.
|
||||
*/
|
||||
|
||||
void *_pointer;
|
||||
Common::BaseCallback<> *_callback;
|
||||
|
||||
public:
|
||||
Request(Common::BaseCallback* cb): _callback(cb) {};
|
||||
Request(Common::BaseCallback<> *cb): _callback(cb) {};
|
||||
virtual ~Request() {};
|
||||
|
||||
/**
|
||||
|
@ -55,9 +47,6 @@ public:
|
|||
*/
|
||||
|
||||
virtual bool handle() = 0;
|
||||
|
||||
void setPointer(void *ptr) { _pointer = ptr; }
|
||||
void *pointer() const { return _pointer; }
|
||||
};
|
||||
|
||||
} //end of namespace Cloud
|
||||
|
|
|
@ -25,45 +25,116 @@
|
|||
|
||||
namespace Common {
|
||||
|
||||
class BaseCallback {
|
||||
/**
|
||||
* BaseCallback<S> is a simple base class for object-oriented callbacks.
|
||||
*
|
||||
* Object-oriented callbacks are such callbacks that know exact instance
|
||||
* which method must be called.
|
||||
*
|
||||
* For backwards compatibility purposes, there is a GlobalFunctionCallback,
|
||||
* which is BaseCallback<void *>, so it can be used with global C-like
|
||||
* functions too.
|
||||
*
|
||||
* <S> is the type, which is passed to operator() of this callback.
|
||||
* This allows you to specify that you accept a callback, which wants
|
||||
* to receive an <S> object.
|
||||
*/
|
||||
|
||||
template<typename S = void *> class BaseCallback {
|
||||
public:
|
||||
BaseCallback() {}
|
||||
virtual ~BaseCallback() {}
|
||||
virtual void operator()(void *ptr) = 0;
|
||||
virtual void operator()(S data) = 0;
|
||||
};
|
||||
|
||||
class GlobalFunctionCallback: public BaseCallback {
|
||||
/**
|
||||
* GlobalFunctionCallback is a simple wrapper for global C functions.
|
||||
*
|
||||
* If there is a method, which accepts BaseCallback<void *>, you can
|
||||
* easily pass your C function by passing
|
||||
* new GlobalFunctionCallback(yourFunction)
|
||||
*/
|
||||
|
||||
class GlobalFunctionCallback: public BaseCallback<void *> {
|
||||
typedef void(*GlobalFunction)(void *result);
|
||||
GlobalFunction _callback;
|
||||
|
||||
public:
|
||||
GlobalFunctionCallback(GlobalFunction cb): _callback(cb) {}
|
||||
virtual ~GlobalFunctionCallback() {}
|
||||
virtual void operator()(void *ptr) {
|
||||
if (_callback) _callback(ptr);
|
||||
virtual void operator()(void *data) {
|
||||
if (_callback) _callback(data);
|
||||
}
|
||||
};
|
||||
|
||||
template<class T> class Callback: public BaseCallback {
|
||||
typedef void(T::*TMethod)(void *);
|
||||
/**
|
||||
* Callback<T, S> implements an object-oriented callback.
|
||||
*
|
||||
* <T> stands for a class which method you want to call.
|
||||
* <S>, again, is the type of an object passed to operator().
|
||||
*
|
||||
* So, if you have void MyClass::myMethod(AnotherClass) method,
|
||||
* the corresponding callback is Callback<MyClass, AnotherClass>.
|
||||
* You create it similarly to this:
|
||||
* new Callback<MyClass, AnotherClass>(
|
||||
* pointerToMyClassObject,
|
||||
* &MyClass::myMethod
|
||||
* )
|
||||
*/
|
||||
|
||||
template<class T, typename S = void *> class Callback: public BaseCallback<S> {
|
||||
protected:
|
||||
typedef void(T::*TMethod)(S);
|
||||
T *_object;
|
||||
TMethod _method;
|
||||
public:
|
||||
Callback(T *object, TMethod method): _object(object), _method(method) {}
|
||||
virtual ~Callback() {}
|
||||
void operator()(void *ptr) { (_object->*_method)(ptr); }
|
||||
void operator()(S data) { (_object->*_method)(data); }
|
||||
};
|
||||
|
||||
template<class T> class CallbackBridge: public BaseCallback {
|
||||
typedef void(T::*TCallbackMethod)(BaseCallback *, void *);
|
||||
/**
|
||||
* CallbackBridge<T, OS, S> helps you to chain callbacks.
|
||||
*
|
||||
* CallbackBridge keeps a pointer to BaseCallback<OS>.
|
||||
* When its operator() is called, it passes this pointer
|
||||
* along with the actual data (of type <S>) to the method
|
||||
* of <T> class.
|
||||
*
|
||||
* This is needed when you have to call a callback only
|
||||
* when your own callback is called. So, your callback
|
||||
* is "inner" and the other one is "outer".
|
||||
*
|
||||
* CallbackBridge implements the "inner" one and calls
|
||||
* the method you wanted. It passes the "outer", so you
|
||||
* can call it from your method. You can ignore it, but
|
||||
* probably there is no point in using CallbackBridge then.
|
||||
*
|
||||
* So, if you receive a BaseCallback<SomeClass> callback
|
||||
* and you want to call it from your MyClass::myMethod method,
|
||||
* you should create CallbackBridge<MyClass, SomeClass, S>,
|
||||
* where <S> is data type you want to receive in MyClass::myMethod.
|
||||
*
|
||||
* You create it similarly to this:
|
||||
* new Callback<MyClass, SomeClass, AnotherClass>(
|
||||
* pointerToMyClassObject,
|
||||
* &MyClass::myMethod,
|
||||
* outerCallback
|
||||
* )
|
||||
* where `outerCallback` is BaseCallback<SomeClass> and `myMethod` is:
|
||||
* void MyClass::myMethod(BaseCallback<SomeClass> *, AnotherClass)
|
||||
*/
|
||||
|
||||
template<class T, typename OS, typename S = void *> class CallbackBridge: public BaseCallback<S> {
|
||||
typedef void(T::*TCallbackMethod)(BaseCallback<OS> *, S);
|
||||
T *_object;
|
||||
TCallbackMethod _method;
|
||||
BaseCallback *_outerCallback;
|
||||
BaseCallback<OS> *_outerCallback;
|
||||
public:
|
||||
CallbackBridge(T *object, TCallbackMethod method, BaseCallback *outerCallback):
|
||||
CallbackBridge(T *object, TCallbackMethod method, BaseCallback<OS> *outerCallback):
|
||||
_object(object), _method(method), _outerCallback(outerCallback) {}
|
||||
virtual ~CallbackBridge() {}
|
||||
void operator()(void *ptr) { (_object->*_method)(_outerCallback, ptr); }
|
||||
void operator()(S data) { (_object->*_method)(_outerCallback, data); }
|
||||
};
|
||||
|
||||
} // End of namespace Common
|
||||
|
|
|
@ -53,7 +53,7 @@ public:
|
|||
* Starts saves syncing process in currently active storage if there is any.
|
||||
*/
|
||||
|
||||
virtual void syncSaves(Cloud::Storage::OperationCallback callback = 0) = 0;
|
||||
virtual void syncSaves(BaseCallback<bool> *callback = 0) = 0;
|
||||
};
|
||||
|
||||
} //end of namespace Common
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue