mirror of
https://gitlab.linphone.org/BC/public/linphone-desktop.git
synced 2026-01-27 00:48:08 +00:00
feat(app):
- rename `Database` class to `Paths` - use linphone logger - better log format when the app is compiled in debug mode - use the log collection of linphone
This commit is contained in:
parent
f0a4652c62
commit
41aa40e1e2
11 changed files with 235 additions and 165 deletions
|
|
@ -46,16 +46,18 @@ foreach (package ${QT5_PACKAGES})
|
|||
endif ()
|
||||
endforeach ()
|
||||
|
||||
list(APPEND LIBS "${CMAKE_SOURCE_DIR}/../OUTPUT/desktop/lib64/liblinphone.so")
|
||||
list(APPEND LIBS "${CMAKE_SOURCE_DIR}/../OUTPUT/desktop/lib64/liblinphone++.so")
|
||||
list(APPEND LIBS "${CMAKE_SOURCE_DIR}/../OUTPUT/desktop/lib64/libbelcard.so")
|
||||
list(APPEND LIBS "${CMAKE_SOURCE_DIR}/../OUTPUT/desktop/lib64/libbellesip.so")
|
||||
list(APPEND LIBS "${CMAKE_SOURCE_DIR}/../OUTPUT/desktop/lib64/libbctoolbox.so")
|
||||
|
||||
set(SOURCES
|
||||
src/app/App.cpp
|
||||
src/app/AvatarProvider.cpp
|
||||
src/app/Database.cpp
|
||||
src/app/DefaultTranslator.cpp
|
||||
src/app/Logger.cpp
|
||||
src/app/Paths.cpp
|
||||
src/components/camera/Camera.cpp
|
||||
src/components/chat/ChatModel.cpp
|
||||
src/components/chat/ChatProxyModel.cpp
|
||||
|
|
@ -78,9 +80,9 @@ set(SOURCES
|
|||
set(HEADERS
|
||||
src/app/App.hpp
|
||||
src/app/AvatarProvider.hpp
|
||||
src/app/Database.hpp
|
||||
src/app/DefaultTranslator.hpp
|
||||
src/app/Logger.hpp
|
||||
src/app/Paths.hpp
|
||||
src/components/camera/Camera.hpp
|
||||
src/components/chat/ChatModel.hpp
|
||||
src/components/chat/ChatProxyModel.hpp
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
#include "Database.hpp"
|
||||
#include "Paths.hpp"
|
||||
#include "../utils.hpp"
|
||||
|
||||
#include "AvatarProvider.hpp"
|
||||
|
|
@ -12,7 +12,7 @@ AvatarProvider::AvatarProvider () :
|
|||
QQmlImageProviderBase::Image,
|
||||
QQmlImageProviderBase::ForceAsynchronousImageLoading
|
||||
) {
|
||||
m_avatars_path = Utils::linphoneStringToQString(Database::getAvatarsPath());
|
||||
m_avatars_path = Utils::linphoneStringToQString(Paths::getAvatarsDirpath());
|
||||
}
|
||||
|
||||
QImage AvatarProvider::requestImage (
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QStandardPaths>
|
||||
|
||||
#include "../utils.hpp"
|
||||
|
||||
#include "Database.hpp"
|
||||
|
||||
// =============================================================================
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#define DATABASES_PATH \
|
||||
QStandardPaths::writableLocation(QStandardPaths::DataLocation)
|
||||
#define DATABASE_PATH_CONFIG "linphonerc"
|
||||
|
||||
#else
|
||||
|
||||
#define DATABASES_PATH \
|
||||
QStandardPaths::writableLocation(QStandardPaths::HomeLocation)
|
||||
#define DATABASE_PATH_CONFIG ".linphonerc"
|
||||
|
||||
#endif // ifdef _WIN32
|
||||
|
||||
#define DATABASE_PATH_AVATARS ".linphone/avatars/"
|
||||
#define DATABASE_PATH_CALL_HISTORY_LIST ".linphone-call-history.db"
|
||||
#define DATABASE_PATH_FRIENDS_LIST ".linphone-friends.db"
|
||||
#define DATABASE_PATH_MESSAGE_HISTORY_LIST ".linphone-history.db"
|
||||
|
||||
using namespace std;
|
||||
|
||||
// =============================================================================
|
||||
|
||||
inline bool ensureDatabaseFilePathExists (const QString &path) {
|
||||
QDir dir(DATABASES_PATH);
|
||||
|
||||
if (!dir.exists() && !dir.mkpath(DATABASES_PATH))
|
||||
return false;
|
||||
|
||||
QFile file(path);
|
||||
|
||||
return file.exists() || file.open(QIODevice::ReadWrite);
|
||||
}
|
||||
|
||||
string Database::getAvatarsPath () {
|
||||
QString path(DATABASES_PATH + "/" DATABASE_PATH_AVATARS);
|
||||
QDir dir(path);
|
||||
|
||||
if (!dir.exists() && !dir.mkpath(path))
|
||||
return "";
|
||||
|
||||
return Utils::qStringToLinphoneString(QDir::toNativeSeparators(path));
|
||||
}
|
||||
|
||||
inline string getDatabaseFilePath (const QString &filename) {
|
||||
QString path(DATABASES_PATH + "/");
|
||||
path += filename;
|
||||
return ensureDatabaseFilePathExists(path) ? Utils::qStringToLinphoneString(
|
||||
QDir::toNativeSeparators(path)
|
||||
) : "";
|
||||
}
|
||||
|
||||
string Database::getCallHistoryPath () {
|
||||
return getDatabaseFilePath(DATABASE_PATH_CALL_HISTORY_LIST);
|
||||
}
|
||||
|
||||
string Database::getConfigPath () {
|
||||
return getDatabaseFilePath(DATABASE_PATH_CONFIG);
|
||||
}
|
||||
|
||||
string Database::getFriendsListPath () {
|
||||
return getDatabaseFilePath(DATABASE_PATH_FRIENDS_LIST);
|
||||
}
|
||||
|
||||
string Database::getMessageHistoryPath () {
|
||||
return getDatabaseFilePath(DATABASE_PATH_MESSAGE_HISTORY_LIST);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
#ifndef DATABASE_H_
|
||||
#define DATABASE_H_
|
||||
|
||||
#include <string>
|
||||
|
||||
// =============================================================================
|
||||
|
||||
namespace Database {
|
||||
// Returns the databases paths.
|
||||
// If files cannot be created or are unavailable, a empty string is returned.
|
||||
// Use the directories separator of used OS.
|
||||
std::string getAvatarsPath ();
|
||||
|
||||
std::string getCallHistoryPath ();
|
||||
std::string getConfigPath ();
|
||||
std::string getFriendsListPath ();
|
||||
std::string getMessageHistoryPath ();
|
||||
}
|
||||
|
||||
#endif // DATABASE_H_
|
||||
|
|
@ -1,61 +1,126 @@
|
|||
#include <bctoolbox/logging.h>
|
||||
#include <bctoolbox/port.h>
|
||||
#include <linphone/linphonecore.h>
|
||||
#include <QDateTime>
|
||||
|
||||
#include "Paths.hpp"
|
||||
|
||||
#include "Logger.hpp"
|
||||
|
||||
#ifdef __linux__
|
||||
#define BLUE "\x1B[1;34m"
|
||||
#define YELLOW "\x1B[1;33m"
|
||||
#define GREEN "\x1B[1;32m"
|
||||
#define PURPLE "\x1B[1;35m"
|
||||
#define RED "\x1B[1;31m"
|
||||
#define RESET "\x1B[0m"
|
||||
#else
|
||||
#define BLUE ""
|
||||
#define YELLOW ""
|
||||
#define GREEN ""
|
||||
#define PURPLE ""
|
||||
#define RED ""
|
||||
#define RESET ""
|
||||
#endif // ifdef __linux__
|
||||
|
||||
#define QT_DOMAIN "qt"
|
||||
|
||||
#define MAX_LOGS_COLLECTION_SIZE 104857600 /* 100MB. */
|
||||
|
||||
// =============================================================================
|
||||
|
||||
void logger (QtMsgType type, const QMessageLogContext &context, const QString &msg) {
|
||||
Logger *Logger::m_instance = nullptr;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
static void qtLogger (QtMsgType type, const QMessageLogContext &context, const QString &msg) {
|
||||
const char *format;
|
||||
BctbxLogLevel level;
|
||||
|
||||
if (type == QtDebugMsg) {
|
||||
format = GREEN "[%s][Debug]" PURPLE "%s" RESET "%s\n";
|
||||
level = BCTBX_LOG_DEBUG;
|
||||
} else if (type == QtInfoMsg) {
|
||||
format = BLUE "[%s][Info]" PURPLE "%s" RESET "%s\n";
|
||||
level = BCTBX_LOG_MESSAGE;
|
||||
} else if (type == QtWarningMsg) {
|
||||
format = RED "[%s][Warning]" PURPLE "%s" RESET "%s\n";
|
||||
level = BCTBX_LOG_WARNING;
|
||||
} else if (type == QtCriticalMsg) {
|
||||
format = RED "[%s][Critical]" PURPLE "%s" RESET "%s\n";
|
||||
level = BCTBX_LOG_ERROR;
|
||||
} else if (type == QtFatalMsg) {
|
||||
format = RED "[%s][Fatal]" PURPLE "%s" RESET "%s\n";
|
||||
level = BCTBX_LOG_FATAL;
|
||||
} else
|
||||
return;
|
||||
|
||||
const char *context_str = "";
|
||||
|
||||
#ifdef QT_MESSAGELOGCONTEXT
|
||||
QByteArray context_arr = QStringLiteral("%1:%2: ").arg(context.file).arg(context.line).toLocal8Bit();
|
||||
context_str = context_arr.constData();
|
||||
#endif // ifdef QT_MESSAGELOGCONTEXT
|
||||
|
||||
QByteArray local_msg = msg.toLocal8Bit();
|
||||
QByteArray date_time = QDateTime::currentDateTime()
|
||||
.toString("HH:mm:ss").toLocal8Bit();
|
||||
QByteArray date_time = QDateTime::currentDateTime().toString("HH:mm:ss").toLocal8Bit();
|
||||
|
||||
const char *context_file = "cpp";
|
||||
int context_line = 0;
|
||||
fprintf(stderr, format, date_time.constData(), context_str, local_msg.constData());
|
||||
bctbx_log(QT_DOMAIN, level, "QT: %s%s", context_str, local_msg.constData());
|
||||
|
||||
if (context.file && !context.function) {
|
||||
context_file = context.file;
|
||||
context_line = context.line;
|
||||
}
|
||||
|
||||
if (type == QtDebugMsg)
|
||||
fprintf(
|
||||
stderr, GREEN "[%s][Debug]" PURPLE "%s:%u: " RESET "%s\n",
|
||||
date_time.constData(), context_file, context_line, local_msg.constData()
|
||||
);
|
||||
else if (type == QtInfoMsg)
|
||||
fprintf(
|
||||
stderr, BLUE "[%s][Info]" PURPLE "%s:%u: " RESET "%s\n",
|
||||
date_time.constData(), context_file, context_line, local_msg.constData()
|
||||
);
|
||||
else if (type == QtWarningMsg)
|
||||
fprintf(
|
||||
stderr, RED "[%s][Warning]" PURPLE "%s:%u: " RESET "%s\n",
|
||||
date_time.constData(), context_file, context_line, local_msg.constData()
|
||||
);
|
||||
else if (type == QtCriticalMsg)
|
||||
fprintf(
|
||||
stderr, RED "[%s][Critical]" PURPLE "%s:%u: " RESET "%s\n",
|
||||
date_time.constData(), context_file, context_line, local_msg.constData()
|
||||
);
|
||||
else if (type == QtFatalMsg) {
|
||||
fprintf(
|
||||
stderr, RED "[%s][Fatal]" PURPLE "%s:%u: " RESET "%s\n",
|
||||
date_time.constData(), context_file, context_line, local_msg.constData()
|
||||
);
|
||||
if (type == QtFatalMsg)
|
||||
abort();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
static void linphoneLogger (const char *domain, OrtpLogLevel type, const char *fmt, va_list args) {
|
||||
const char *format;
|
||||
|
||||
if (type == ORTP_DEBUG)
|
||||
format = GREEN "[%s][Debug]" YELLOW "Core:%s: " RESET "%s\n";
|
||||
else if (type == ORTP_TRACE)
|
||||
format = BLUE "[%s][Trace]" YELLOW "Core:%s: " RESET "%s\n";
|
||||
else if (type == ORTP_MESSAGE)
|
||||
format = BLUE "[%s][Info]" YELLOW "Core:%s: " RESET "%s\n";
|
||||
else if (type == ORTP_WARNING)
|
||||
format = RED "[%s][Warning]" YELLOW "Core:%s: " RESET "%s\n";
|
||||
else if (type == ORTP_ERROR)
|
||||
format = RED "[%s][Critical]" YELLOW "Core:%s: " RESET "%s\n";
|
||||
else if (type == ORTP_FATAL)
|
||||
format = RED "[%s][Fatal]" YELLOW "Core:%s: " RESET "%s\n";
|
||||
else
|
||||
return;
|
||||
|
||||
QByteArray date_time = QDateTime::currentDateTime().toString("HH:mm:ss").toLocal8Bit();
|
||||
char *msg = bctbx_strdup_vprintf(fmt, args);
|
||||
|
||||
fprintf(stderr, format, date_time.constData(), domain, msg);
|
||||
|
||||
bctbx_free(msg);
|
||||
|
||||
if (type == ORTP_FATAL)
|
||||
abort();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
void Logger::init () {
|
||||
if (m_instance)
|
||||
return;
|
||||
m_instance = new Logger();
|
||||
|
||||
qInstallMessageHandler(qtLogger);
|
||||
|
||||
linphone_core_set_log_level(ORTP_MESSAGE);
|
||||
linphone_core_set_log_handler(
|
||||
[](const char *domain, OrtpLogLevel type, const char *fmt, va_list args) {
|
||||
if (m_instance->m_display_core_logs)
|
||||
linphoneLogger(domain, type, fmt, args);
|
||||
}
|
||||
);
|
||||
|
||||
linphone_core_set_log_collection_path(Paths::getLogsDirpath().c_str());
|
||||
linphone_core_set_log_collection_max_file_size(MAX_LOGS_COLLECTION_SIZE);
|
||||
linphone_core_enable_log_collection(LinphoneLogCollectionEnabled);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,16 @@
|
|||
|
||||
// =============================================================================
|
||||
|
||||
void logger (QtMsgType type, const QMessageLogContext &context, const QString &msg);
|
||||
class Logger {
|
||||
public:
|
||||
static void init ();
|
||||
|
||||
private:
|
||||
Logger () = default;
|
||||
|
||||
bool m_display_core_logs = false;
|
||||
|
||||
static Logger *m_instance;
|
||||
};
|
||||
|
||||
#endif // LOGGER_H_
|
||||
|
|
|
|||
89
tests/src/app/Paths.cpp
Normal file
89
tests/src/app/Paths.cpp
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QStandardPaths>
|
||||
|
||||
#include "../utils.hpp"
|
||||
|
||||
#include "Paths.hpp"
|
||||
|
||||
// =============================================================================
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#define MAIN_PATH \
|
||||
QStandardPaths::writableLocation(QStandardPaths::DataLocation)
|
||||
#define PATH_CONFIG "linphonerc"
|
||||
|
||||
#define LINPHONE_FOLDER "linphone/"
|
||||
|
||||
#else
|
||||
|
||||
#define MAIN_PATH \
|
||||
QStandardPaths::writableLocation(QStandardPaths::HomeLocation)
|
||||
#define PATH_CONFIG ".linphonerc"
|
||||
|
||||
#define LINPHONE_FOLDER ".linphone/"
|
||||
|
||||
#endif // ifdef _WIN32
|
||||
|
||||
#define PATH_AVATARS LINPHONE_FOLDER "avatars/"
|
||||
#define PATH_LOGS LINPHONE_FOLDER "logs/"
|
||||
|
||||
#define PATH_CALL_HISTORY_LIST ".linphone-call-history.db"
|
||||
#define PATH_FRIENDS_LIST ".linphone-friends.db"
|
||||
#define PATH_MESSAGE_HISTORY_LIST ".linphone-history.db"
|
||||
|
||||
using namespace std;
|
||||
|
||||
// =============================================================================
|
||||
|
||||
inline void ensureDirectoryPathExists (const QString &path) {
|
||||
QDir dir(path);
|
||||
if (!dir.exists() && !dir.mkpath(path))
|
||||
qFatal("Unable to access at directory: `%s`", path.toStdString().c_str());
|
||||
}
|
||||
|
||||
inline void ensureFilePathExists (const QString &path) {
|
||||
QFileInfo info(path);
|
||||
ensureDirectoryPathExists(info.path());
|
||||
|
||||
QFile file(path);
|
||||
if (!file.exists() && !file.open(QIODevice::ReadWrite))
|
||||
qFatal("Unable to access at path: `%s`", path.toStdString().c_str());
|
||||
}
|
||||
|
||||
inline string getDirectoryPath (const QString &dirname) {
|
||||
ensureDirectoryPathExists(dirname);
|
||||
return Utils::qStringToLinphoneString(QDir::toNativeSeparators(dirname));
|
||||
}
|
||||
|
||||
inline string getFilePath (const QString &filename) {
|
||||
ensureFilePathExists(filename);
|
||||
return Utils::qStringToLinphoneString(QDir::toNativeSeparators(filename));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
string Paths::getAvatarsDirpath () {
|
||||
return getDirectoryPath(MAIN_PATH + "/" PATH_AVATARS);
|
||||
}
|
||||
|
||||
string Paths::getCallHistoryFilepath () {
|
||||
return getFilePath(MAIN_PATH + "/" + PATH_CALL_HISTORY_LIST);
|
||||
}
|
||||
|
||||
string Paths::getConfigFilepath () {
|
||||
return getFilePath(MAIN_PATH + "/" + PATH_CONFIG);
|
||||
}
|
||||
|
||||
string Paths::getFriendsListFilepath () {
|
||||
return getFilePath(MAIN_PATH + "/" + PATH_FRIENDS_LIST);
|
||||
}
|
||||
|
||||
string Paths::getLogsDirpath () {
|
||||
return getDirectoryPath(MAIN_PATH + "/" PATH_LOGS);
|
||||
}
|
||||
|
||||
string Paths::getMessageHistoryFilepath () {
|
||||
return getFilePath(MAIN_PATH + "/" + PATH_MESSAGE_HISTORY_LIST);
|
||||
}
|
||||
17
tests/src/app/Paths.hpp
Normal file
17
tests/src/app/Paths.hpp
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#ifndef PATHS_H_
|
||||
#define PATHS_H_
|
||||
|
||||
#include <string>
|
||||
|
||||
// =============================================================================
|
||||
|
||||
namespace Paths {
|
||||
std::string getAvatarsDirpath ();
|
||||
std::string getCallHistoryFilepath ();
|
||||
std::string getConfigFilepath ();
|
||||
std::string getFriendsListFilepath ();
|
||||
std::string getLogsDirpath ();
|
||||
std::string getMessageHistoryFilepath ();
|
||||
}
|
||||
|
||||
#endif // PATHS_H_
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
#include <QUuid>
|
||||
|
||||
#include "../../app/App.hpp"
|
||||
#include "../../app/Database.hpp"
|
||||
#include "../../app/Paths.hpp"
|
||||
#include "../../utils.hpp"
|
||||
#include "../core/CoreManager.hpp"
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ VcardModel::~VcardModel () {
|
|||
|
||||
QString image_path(
|
||||
::Utils::linphoneStringToQString(
|
||||
Database::getAvatarsPath() +
|
||||
Paths::getAvatarsDirpath() +
|
||||
photo->getValue().substr(sizeof(VCARD_SCHEME) - 1)
|
||||
)
|
||||
);
|
||||
|
|
@ -111,7 +111,7 @@ bool VcardModel::setAvatar (const QString &path) {
|
|||
.arg(uuid.mid(1, uuid.length() - 2)) // Remove `{}`.
|
||||
.arg(info.suffix());
|
||||
|
||||
QString dest = ::Utils::linphoneStringToQString(Database::getAvatarsPath()) + file_id;
|
||||
QString dest = ::Utils::linphoneStringToQString(Paths::getAvatarsDirpath()) + file_id;
|
||||
|
||||
if (!file.copy(dest))
|
||||
return false;
|
||||
|
|
@ -127,7 +127,7 @@ bool VcardModel::setAvatar (const QString &path) {
|
|||
if (old_photo) {
|
||||
QString image_path(
|
||||
::Utils::linphoneStringToQString(
|
||||
Database::getAvatarsPath() + old_photo->getValue().substr(sizeof(VCARD_SCHEME) - 1)
|
||||
Paths::getAvatarsDirpath() + old_photo->getValue().substr(sizeof(VCARD_SCHEME) - 1)
|
||||
)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#include <QTimer>
|
||||
|
||||
#include "../../app/Database.hpp"
|
||||
#include "../../app/Paths.hpp"
|
||||
|
||||
#include "CoreManager.hpp"
|
||||
|
||||
|
|
@ -11,10 +11,7 @@ using namespace std;
|
|||
CoreManager *CoreManager::m_instance = nullptr;
|
||||
|
||||
CoreManager::CoreManager (QObject *parent) : QObject(parent), m_handlers(make_shared<CoreHandlers>()) {
|
||||
string config_path = Database::getConfigPath();
|
||||
if (config_path.length() == 0)
|
||||
qFatal("Unable to get config path.");
|
||||
m_core = linphone::Factory::get()->createCore(m_handlers, config_path, "");
|
||||
m_core = linphone::Factory::get()->createCore(m_handlers, Paths::getConfigFilepath(), "");
|
||||
setDatabasesPaths();
|
||||
}
|
||||
|
||||
|
|
@ -45,20 +42,7 @@ VcardModel *CoreManager::createDetachedVcardModel () {
|
|||
}
|
||||
|
||||
void CoreManager::setDatabasesPaths () {
|
||||
string database_path;
|
||||
|
||||
database_path = Database::getFriendsListPath();
|
||||
if (database_path.length() == 0)
|
||||
qFatal("Unable to get friends list database path.");
|
||||
m_core->setFriendsDatabasePath(database_path);
|
||||
|
||||
database_path = Database::getCallHistoryPath();
|
||||
if (database_path.length() == 0)
|
||||
qFatal("Unable to get call history database path.");
|
||||
m_core->setCallLogsDatabasePath(database_path);
|
||||
|
||||
database_path = Database::getMessageHistoryPath();
|
||||
if (database_path.length() == 0)
|
||||
qFatal("Unable to get message history database path.");
|
||||
m_core->setChatDatabasePath(database_path);
|
||||
m_core->setFriendsDatabasePath(Paths::getFriendsListFilepath());
|
||||
m_core->setCallLogsDatabasePath(Paths::getCallHistoryFilepath());
|
||||
m_core->setChatDatabasePath(Paths::getMessageHistoryFilepath());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
#include "app/App.hpp"
|
||||
#include "app/Logger.hpp"
|
||||
|
||||
// ===================================================================
|
||||
// =============================================================================
|
||||
|
||||
int main (int argc, char *argv[]) {
|
||||
qInstallMessageHandler(logger);
|
||||
Logger::init();
|
||||
|
||||
QGuiApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
|
||||
App::init(argc, argv);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue