fix(services): refactor GBOService instantiation and update template listing logic
This commit is contained in:
parent
61d8cfe93c
commit
0e74502cc1
6 changed files with 91 additions and 60 deletions
|
@ -49,7 +49,7 @@ import { GBServer } from '../../../src/app.js';
|
||||||
import { GBVMService } from '../../basic.gblib/services/GBVMService.js';
|
import { GBVMService } from '../../basic.gblib/services/GBVMService.js';
|
||||||
import Excel from 'exceljs';
|
import Excel from 'exceljs';
|
||||||
import asyncPromise from 'async-promises';
|
import asyncPromise from 'async-promises';
|
||||||
import { GuaribasPackage } from '../models/GBModel.js';
|
import { GuaribasInstance, GuaribasPackage } from '../models/GBModel.js';
|
||||||
import { GBAdminService } from './../../admin.gbapp/services/GBAdminService.js';
|
import { GBAdminService } from './../../admin.gbapp/services/GBAdminService.js';
|
||||||
import { AzureDeployerService } from './../../azuredeployer.gbapp/services/AzureDeployerService.js';
|
import { AzureDeployerService } from './../../azuredeployer.gbapp/services/AzureDeployerService.js';
|
||||||
import { KBService } from './../../kb.gbapp/services/KBService.js';
|
import { KBService } from './../../kb.gbapp/services/KBService.js';
|
||||||
|
@ -234,7 +234,7 @@ export class GBDeployer implements IGBDeployer {
|
||||||
|
|
||||||
const service = await AzureDeployerService.createInstance(this);
|
const service = await AzureDeployerService.createInstance(this);
|
||||||
const application = await service.createApplication(accessToken, botId);
|
const application = await service.createApplication(accessToken, botId);
|
||||||
|
|
||||||
// Fills new instance base information and get App secret.
|
// Fills new instance base information and get App secret.
|
||||||
|
|
||||||
instance.marketplaceId = (application as any).appId;
|
instance.marketplaceId = (application as any).appId;
|
||||||
|
@ -269,11 +269,24 @@ export class GBDeployer implements IGBDeployer {
|
||||||
* Verifies if bot exists on bot catalog.
|
* Verifies if bot exists on bot catalog.
|
||||||
*/
|
*/
|
||||||
public async botExists(botId: string): Promise<boolean> {
|
public async botExists(botId: string): Promise<boolean> {
|
||||||
const service = await AzureDeployerService.createInstance(this);
|
|
||||||
|
|
||||||
return await service.botExists(botId);
|
if (GBConfigService.get('GB_MODE') !== 'legacy') {
|
||||||
|
const where = { botId: botId };
|
||||||
|
|
||||||
|
return await GuaribasInstance.findOne({
|
||||||
|
where: where
|
||||||
|
}) !== null;
|
||||||
|
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
|
||||||
|
const service = await AzureDeployerService.createInstance(this);
|
||||||
|
|
||||||
|
return await service.botExists(botId);
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Performs all tasks of deploying a new bot on the cloud.
|
* Performs all tasks of deploying a new bot on the cloud.
|
||||||
*/
|
*/
|
||||||
|
@ -469,13 +482,13 @@ export class GBDeployer implements IGBDeployer {
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
await asyncPromise.eachSeries(rows, async (line: any) => {
|
await asyncPromise.eachSeries(rows, async (line: any) => {
|
||||||
if (line && line.length > 0) {
|
if (line && line.length > 0) {
|
||||||
const key = line[1];
|
const key = line[1];
|
||||||
let value = line[2];
|
let value = line[2];
|
||||||
|
|
||||||
|
|
||||||
if (key && value) {
|
if (key && value) {
|
||||||
if (value.text) { value = value.text };
|
if (value.text) { value = value.text };
|
||||||
obj[key] = value;
|
obj[key] = value;
|
||||||
|
@ -490,7 +503,7 @@ export class GBDeployer implements IGBDeployer {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*/
|
*/
|
||||||
|
|
||||||
public async downloadFolder(
|
public async downloadFolder(
|
||||||
min: GBMinInstance,
|
min: GBMinInstance,
|
||||||
localPath: string,
|
localPath: string,
|
||||||
|
@ -499,7 +512,7 @@ export class GBDeployer implements IGBDeployer {
|
||||||
client = null
|
client = null
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const storageMode = process.env.GB_MODE;
|
const storageMode = process.env.GB_MODE;
|
||||||
|
|
||||||
if (storageMode === 'gbcluster') {
|
if (storageMode === 'gbcluster') {
|
||||||
const minioClient = new Client({
|
const minioClient = new Client({
|
||||||
endPoint: process.env.DRIVE_SERVER || 'localhost',
|
endPoint: process.env.DRIVE_SERVER || 'localhost',
|
||||||
|
@ -508,31 +521,31 @@ export class GBDeployer implements IGBDeployer {
|
||||||
accessKey: process.env.DRIVE_ACCESSKEY,
|
accessKey: process.env.DRIVE_ACCESSKEY,
|
||||||
secretKey: process.env.DRIVE_SECRET,
|
secretKey: process.env.DRIVE_SECRET,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bucketName = process.env.DRIVE_BUCKETPREFIX + min.botId + '.gbai';
|
const bucketName = process.env.DRIVE_BUCKETPREFIX + min.botId + '.gbai';
|
||||||
|
|
||||||
if (!(await GBUtil.exists(localPath))) {
|
if (!(await GBUtil.exists(localPath))) {
|
||||||
await fs.mkdir(localPath, { recursive: true });
|
await fs.mkdir(localPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectsStream = minioClient.listObjects(bucketName, remotePath, true);
|
const objectsStream = minioClient.listObjects(bucketName, remotePath, true);
|
||||||
for await (const obj of objectsStream) {
|
for await (const obj of objectsStream) {
|
||||||
const itemPath = path.join(localPath, obj.name);
|
const itemPath = path.join(localPath, obj.name);
|
||||||
|
|
||||||
if (obj.name.endsWith('/')) {
|
if (obj.name.endsWith('/')) {
|
||||||
if (!(await GBUtil.exists(itemPath))) {
|
if (!(await GBUtil.exists(itemPath))) {
|
||||||
await fs.mkdir(itemPath, { recursive: true });
|
await fs.mkdir(itemPath, { recursive: true });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let download = true;
|
let download = true;
|
||||||
|
|
||||||
if (await GBUtil.exists(itemPath)) {
|
if (await GBUtil.exists(itemPath)) {
|
||||||
const stats = await fs.stat(itemPath);
|
const stats = await fs.stat(itemPath);
|
||||||
if (stats.mtime >= new Date(obj.lastModified)) {
|
if (stats.mtime >= new Date(obj.lastModified)) {
|
||||||
download = false;
|
download = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (download) {
|
if (download) {
|
||||||
await minioClient.fGetObject(bucketName, obj.name, itemPath);
|
await minioClient.fGetObject(bucketName, obj.name, itemPath);
|
||||||
await fs.utimes(itemPath, new Date(), new Date(obj.lastModified));
|
await fs.utimes(itemPath, new Date(), new Date(obj.lastModified));
|
||||||
|
@ -542,42 +555,42 @@ export class GBDeployer implements IGBDeployer {
|
||||||
} else {
|
} else {
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
const { baseUrl, client } = await GBDeployer.internalGetDriveClient(min);
|
const { baseUrl, client } = await GBDeployer.internalGetDriveClient(min);
|
||||||
|
|
||||||
remotePath = remotePath.replace(/\\/gi, '/');
|
remotePath = remotePath.replace(/\\/gi, '/');
|
||||||
const parts = remotePath.split('/');
|
const parts = remotePath.split('/');
|
||||||
|
|
||||||
let pathBase = localPath;
|
let pathBase = localPath;
|
||||||
if (!(await GBUtil.exists(pathBase))) {
|
if (!(await GBUtil.exists(pathBase))) {
|
||||||
await fs.mkdir(pathBase, { recursive: true });
|
await fs.mkdir(pathBase, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
await CollectionUtil.asyncForEach(parts, async (item) => {
|
await CollectionUtil.asyncForEach(parts, async (item) => {
|
||||||
pathBase = path.join(pathBase, item);
|
pathBase = path.join(pathBase, item);
|
||||||
if (!(await GBUtil.exists(pathBase))) {
|
if (!(await GBUtil.exists(pathBase))) {
|
||||||
await fs.mkdir(pathBase, { recursive: true });
|
await fs.mkdir(pathBase, { recursive: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let packagePath = GBUtil.getGBAIPath(min.botId);
|
let packagePath = GBUtil.getGBAIPath(min.botId);
|
||||||
packagePath = urlJoin(packagePath, remotePath);
|
packagePath = urlJoin(packagePath, remotePath);
|
||||||
let url = `${baseUrl}/drive/root:/${packagePath}:/children`;
|
let url = `${baseUrl}/drive/root:/${packagePath}:/children`;
|
||||||
|
|
||||||
let documents;
|
let documents;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await client.api(url).get();
|
const res = await client.api(url).get();
|
||||||
documents = res.value;
|
documents = res.value;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
GBLogEx.info(min, `Error downloading: ${error.toString()}`);
|
GBLogEx.info(min, `Error downloading: ${error.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (documents === undefined || documents.length === 0) {
|
if (documents === undefined || documents.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await CollectionUtil.asyncForEach(documents, async (item) => {
|
await CollectionUtil.asyncForEach(documents, async (item) => {
|
||||||
const itemPath = path.join(localPath, remotePath, item.name);
|
const itemPath = path.join(localPath, remotePath, item.name);
|
||||||
|
|
||||||
if (item.folder) {
|
if (item.folder) {
|
||||||
if (!(await GBUtil.exists(itemPath))) {
|
if (!(await GBUtil.exists(itemPath))) {
|
||||||
await fs.mkdir(itemPath, { recursive: true });
|
await fs.mkdir(itemPath, { recursive: true });
|
||||||
|
@ -586,17 +599,17 @@ export class GBDeployer implements IGBDeployer {
|
||||||
await this.downloadFolder(min, localPath, nextFolder);
|
await this.downloadFolder(min, localPath, nextFolder);
|
||||||
} else {
|
} else {
|
||||||
let download = true;
|
let download = true;
|
||||||
|
|
||||||
if (await GBUtil.exists(itemPath)) {
|
if (await GBUtil.exists(itemPath)) {
|
||||||
const stats = await fs.stat(itemPath);
|
const stats = await fs.stat(itemPath);
|
||||||
if (new Date(stats.mtime) >= new Date(item.lastModifiedDateTime)) {
|
if (new Date(stats.mtime) >= new Date(item.lastModifiedDateTime)) {
|
||||||
download = false;
|
download = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (download) {
|
if (download) {
|
||||||
const url = item['@microsoft.graph.downloadUrl'];
|
const url = item['@microsoft.graph.downloadUrl'];
|
||||||
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
await fs.writeFile(itemPath, new Uint8Array(await response.arrayBuffer()), { encoding: null });
|
await fs.writeFile(itemPath, new Uint8Array(await response.arrayBuffer()), { encoding: null });
|
||||||
await fs.utimes(itemPath, new Date(), new Date(item.lastModifiedDateTime));
|
await fs.utimes(itemPath, new Date(), new Date(item.lastModifiedDateTime));
|
||||||
|
@ -605,7 +618,7 @@ export class GBDeployer implements IGBDeployer {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -714,7 +727,7 @@ export class GBDeployer implements IGBDeployer {
|
||||||
con['storageDriver'] = min.core.getParam<string>(min.instance, `${connectionName} Driver`, null);
|
con['storageDriver'] = min.core.getParam<string>(min.instance, `${connectionName} Driver`, null);
|
||||||
con['storageTables'] = min.core.getParam<string>(min.instance, `${connectionName} Tables`, null);
|
con['storageTables'] = min.core.getParam<string>(min.instance, `${connectionName} Tables`, null);
|
||||||
const storageName = min.core.getParam<string>(min.instance, `${connectionName} Name`, null);
|
const storageName = min.core.getParam<string>(min.instance, `${connectionName} Name`, null);
|
||||||
|
|
||||||
let file = min.core.getParam<string>(min.instance, `${connectionName} File`, null);
|
let file = min.core.getParam<string>(min.instance, `${connectionName} File`, null);
|
||||||
|
|
||||||
if (storageName) {
|
if (storageName) {
|
||||||
|
|
|
@ -1269,7 +1269,7 @@ export class GBMinService {
|
||||||
if (GBConfigService.get('GB_MODE') !== 'legacy') {
|
if (GBConfigService.get('GB_MODE') !== 'legacy') {
|
||||||
const context = adapter['createContext'](req);
|
const context = adapter['createContext'](req);
|
||||||
context['_activity'] = context.activity.body;
|
context['_activity'] = context.activity.body;
|
||||||
await handler(context);
|
await adapter['processActivity'](req, res, handler);
|
||||||
|
|
||||||
// Return status
|
// Return status
|
||||||
res.status(200);
|
res.status(200);
|
||||||
|
|
|
@ -35,6 +35,7 @@ import { Messages } from '../strings.js';
|
||||||
import { MainService } from '../service/MainService.js';
|
import { MainService } from '../service/MainService.js';
|
||||||
import { SaaSPackage } from '../index.js';
|
import { SaaSPackage } from '../index.js';
|
||||||
import { CollectionUtil } from 'pragmatismo-io-framework';
|
import { CollectionUtil } from 'pragmatismo-io-framework';
|
||||||
|
import { GBOService } from '../service/GBOService.js';
|
||||||
|
|
||||||
export class NewUserDialog extends IGBDialog {
|
export class NewUserDialog extends IGBDialog {
|
||||||
static getBotNameDialog(min: GBMinInstance) {
|
static getBotNameDialog(min: GBMinInstance) {
|
||||||
|
@ -83,7 +84,7 @@ export class NewUserDialog extends IGBDialog {
|
||||||
async step => {
|
async step => {
|
||||||
const locale = 'en-US';
|
const locale = 'en-US';
|
||||||
await step.context.sendActivity('Aqui estão alguns modelos para você escolher:');
|
await step.context.sendActivity('Aqui estão alguns modelos para você escolher:');
|
||||||
let gboService = min.gbappServices['gboService'];
|
let gboService = new GBOService();
|
||||||
const list = await gboService.listTemplates(min);
|
const list = await gboService.listTemplates(min);
|
||||||
|
|
||||||
let templateMessage = undefined;
|
let templateMessage = undefined;
|
||||||
|
@ -101,8 +102,9 @@ export class NewUserDialog extends IGBDialog {
|
||||||
async step => {
|
async step => {
|
||||||
const list = step.activeDialog.state.options.templateList;
|
const list = step.activeDialog.state.options.templateList;
|
||||||
let template = null;
|
let template = null;
|
||||||
|
let gboService = new GBOService();
|
||||||
await CollectionUtil.asyncForEach(list, async item => {
|
await CollectionUtil.asyncForEach(list, async item => {
|
||||||
let gboService = min.gbappServices['gboService'];
|
|
||||||
if (gboService.kmpSearch(step.context.activity.originalText, item.name) != -1) {
|
if (gboService.kmpSearch(step.context.activity.originalText, item.name) != -1) {
|
||||||
template = item.name;
|
template = item.name;
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,6 +37,7 @@ import { GBOnlineSubscription } from './model/MainModel.js'
|
||||||
import { MSSubscriptionService } from './service/MSSubscription.js'
|
import { MSSubscriptionService } from './service/MSSubscription.js'
|
||||||
import { CollectionUtil } from 'pragmatismo-io-framework';
|
import { CollectionUtil } from 'pragmatismo-io-framework';
|
||||||
import { NewUserDialog } from './dialog/NewUserDialog.js'
|
import { NewUserDialog } from './dialog/NewUserDialog.js'
|
||||||
|
import { GBOService } from './service/GBOService.js'
|
||||||
|
|
||||||
export class SaaSPackage implements IGBPackage {
|
export class SaaSPackage implements IGBPackage {
|
||||||
sysPackages: IGBPackage[]
|
sysPackages: IGBPackage[]
|
||||||
|
@ -109,7 +110,7 @@ export class SaaSPackage implements IGBPackage {
|
||||||
|
|
||||||
async loadBot(min: GBMinInstance): Promise<void> {
|
async loadBot(min: GBMinInstance): Promise<void> {
|
||||||
|
|
||||||
let gboService = min.gbappServices['gboService'];
|
let gboService = new GBOService();
|
||||||
|
|
||||||
// Gets the sendToDevice method of whatsapp.gblib and setups scheduler.
|
// Gets the sendToDevice method of whatsapp.gblib and setups scheduler.
|
||||||
|
|
||||||
|
|
|
@ -32,26 +32,24 @@
|
||||||
|
|
||||||
import { GBMinInstance, GBLog } from "botlib";
|
import { GBMinInstance, GBLog } from "botlib";
|
||||||
import { CollectionUtil } from 'pragmatismo-io-framework';
|
import { CollectionUtil } from 'pragmatismo-io-framework';
|
||||||
const MicrosoftGraph = require("@microsoft/microsoft-graph-client");
|
import MicrosoftGraph from "@microsoft/microsoft-graph-client";
|
||||||
const Juno = require('juno-payment-node');
|
|
||||||
const sgMail = require('@sendgrid/mail');
|
import sgMail from '@sendgrid/mail';
|
||||||
const PasswordGenerator = require('strict-password-generator').default;
|
import { default as PasswordGenerator } from 'strict-password-generator';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import { GBConfigService } from "../../core.gbapp/services/GBConfigService.js";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
|
||||||
export class GBOService {
|
export class GBOService {
|
||||||
|
|
||||||
public isValidCardNumber(ccNumber) {
|
public isValidCardNumber(ccNumber) {
|
||||||
let card = new Juno.Card();
|
|
||||||
return card.validateNumber(ccNumber);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public isValidSecurityCode(ccNumber, cvcNumber) {
|
public isValidSecurityCode(ccNumber, cvcNumber) {
|
||||||
let card = new Juno.Card();
|
|
||||||
return card.validateCvc(ccNumber, cvcNumber);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public isValidExpireDate(month, year) {
|
public isValidExpireDate(month, year) {
|
||||||
let card = new Juno.Card();
|
|
||||||
return card.validateExpireDate(month, year);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async sendEmail(token: string, to: string, from: string,
|
public async sendEmail(token: string, to: string, from: string,
|
||||||
|
@ -127,25 +125,41 @@ export class GBOService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async listTemplates(min: GBMinInstance) {
|
public async listTemplates(min: GBMinInstance) {
|
||||||
|
if (GBConfigService.get('GB_MODE') === 'legacy') {
|
||||||
|
let templateLibraryId = process.env.SAAS_TEMPLATE_LIBRARY;
|
||||||
|
let siteId = process.env.STORAGE_SITE_ID;
|
||||||
|
|
||||||
let templateLibraryId = process.env.SAAS_TEMPLATE_LIBRARY;
|
let token =
|
||||||
let siteId = process.env.STORAGE_SITE_ID;
|
await (min.adminService as any).acquireElevatedToken(min.instance.instanceId, true);
|
||||||
|
|
||||||
let token =
|
let client = MicrosoftGraph.Client.init({
|
||||||
await (min.adminService as any).acquireElevatedToken(min.instance.instanceId, true);
|
authProvider: done => {
|
||||||
|
done(null, token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const packagePath = `/`;
|
||||||
|
let res = await client.api(
|
||||||
|
`https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${templateLibraryId}/drive/root/children`)
|
||||||
|
.get();
|
||||||
|
|
||||||
let client = MicrosoftGraph.Client.init({
|
return res.value;
|
||||||
authProvider: done => {
|
}
|
||||||
done(null, token);
|
else {
|
||||||
|
|
||||||
|
const templatesDir = path.join(process.env.PWD,'templates');
|
||||||
|
const gbaiDirectories = [];
|
||||||
|
|
||||||
|
// Read all entries in the templates directory
|
||||||
|
const entries = await fs.readdir(templatesDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
// Check if it's a directory and ends with .gbai
|
||||||
|
if (entry.isDirectory() && entry.name.endsWith('.gbai')) {
|
||||||
|
gbaiDirectories.push({ name: entry.name });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
return gbaiDirectories;
|
||||||
const packagePath = `/`;
|
}
|
||||||
let res = await client.api(
|
|
||||||
`https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${templateLibraryId}/drive/root/children`)
|
|
||||||
.get();
|
|
||||||
|
|
||||||
return res.value;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async copyTemplates(min: GBMinInstance, gbaiDest, templateName: string, kind: string, botName: string) {
|
public async copyTemplates(min: GBMinInstance, gbaiDest, templateName: string, kind: string, botName: string) {
|
||||||
|
|
|
@ -34,6 +34,7 @@ import { GBOnlineSubscription } from '../model/MainModel.js';
|
||||||
import { GBMinInstance, GBLog } from 'botlib';
|
import { GBMinInstance, GBLog } from 'botlib';
|
||||||
import { CollectionUtil } from 'pragmatismo-io-framework';
|
import { CollectionUtil } from 'pragmatismo-io-framework';
|
||||||
import urlJoin from 'url-join';
|
import urlJoin from 'url-join';
|
||||||
|
import { GBOService } from './GBOService.js';
|
||||||
|
|
||||||
export class MainService {
|
export class MainService {
|
||||||
async createSubscriptionMSFT(email: string, plan: string, offer: string, quantity: number, additionalData: any) { }
|
async createSubscriptionMSFT(email: string, plan: string, offer: string, quantity: number, additionalData: any) { }
|
||||||
|
@ -108,8 +109,8 @@ export class MainService {
|
||||||
|
|
||||||
let siteId = process.env.STORAGE_SITE_ID;
|
let siteId = process.env.STORAGE_SITE_ID;
|
||||||
let libraryId = process.env.STORAGE_LIBRARY;
|
let libraryId = process.env.STORAGE_LIBRARY;
|
||||||
let gboService = min.gbappServices['gboService'];
|
let gboService = new GBOService();
|
||||||
|
|
||||||
let sleep = ms => {
|
let sleep = ms => {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
setTimeout(resolve, ms);
|
setTimeout(resolve, ms);
|
||||||
|
|
Loading…
Add table
Reference in a new issue