mirror of
https://github.com/plankanban/planka.git
synced 2025-07-19 13:19:44 +02:00
parent
f20a3d50f5
commit
97f4c0ab0d
27 changed files with 2180 additions and 702 deletions
|
@ -1,6 +1,3 @@
|
|||
const path = require('path');
|
||||
const rimraf = require('rimraf');
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
record: {
|
||||
|
@ -50,26 +47,12 @@ module.exports = {
|
|||
const attachment = await Attachment.archiveOne(inputs.record.id);
|
||||
|
||||
if (attachment) {
|
||||
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||
|
||||
try {
|
||||
const type = attachment.type || 'local';
|
||||
if (type === 's3') {
|
||||
const client = await sails.helpers.utils.getSimpleStorageServiceClient();
|
||||
if (client) {
|
||||
if (attachment.url) {
|
||||
const parsedUrl = new URL(attachment.url);
|
||||
await client.delete({ Key: parsedUrl.pathname.replace(/^\/+/, '') });
|
||||
}
|
||||
if (attachment.thumb) {
|
||||
const parsedUrl = new URL(attachment.thumb);
|
||||
await client.delete({ Key: parsedUrl.pathname.replace(/^\/+/, '') });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
try {
|
||||
rimraf.sync(path.join(sails.config.custom.attachmentsPath, attachment.dirname));
|
||||
await fileManager.deleteFolder(
|
||||
`${sails.config.custom.attachmentsPathSegment}/${attachment.dirname}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const rimraf = require('rimraf');
|
||||
const moveFile = require('move-file');
|
||||
const { rimraf } = require('rimraf');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const sharp = require('sharp');
|
||||
|
||||
|
@ -16,86 +13,19 @@ module.exports = {
|
|||
},
|
||||
|
||||
async fn(inputs) {
|
||||
const dirname = uuid();
|
||||
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||
|
||||
const dirname = uuid();
|
||||
const folderPathSegment = `${sails.config.custom.attachmentsPathSegment}/${dirname}`;
|
||||
const filename = filenamify(inputs.file.filename);
|
||||
|
||||
const rootPath = path.join(sails.config.custom.attachmentsPath, dirname);
|
||||
const filePath = path.join(rootPath, filename);
|
||||
const filePath = await fileManager.move(
|
||||
inputs.file.fd,
|
||||
`${folderPathSegment}/${filename}`,
|
||||
inputs.file.type,
|
||||
);
|
||||
|
||||
if (sails.config.custom.s3Config) {
|
||||
const client = await sails.helpers.utils.getSimpleStorageServiceClient();
|
||||
const s3Image = await client.upload({
|
||||
Body: fs.createReadStream(inputs.file.fd),
|
||||
Key: `attachments/${dirname}/${filename}`,
|
||||
ContentType: inputs.file.type,
|
||||
});
|
||||
|
||||
let image = sharp(inputs.file.fd, {
|
||||
animated: true,
|
||||
});
|
||||
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await image.metadata();
|
||||
} catch (error) {} // eslint-disable-line no-empty
|
||||
|
||||
const fileData = {
|
||||
type: 's3',
|
||||
dirname,
|
||||
filename,
|
||||
thumb: null,
|
||||
image: null,
|
||||
url: s3Image.Location,
|
||||
name: inputs.file.filename,
|
||||
};
|
||||
|
||||
if (metadata && !['svg', 'pdf'].includes(metadata.format)) {
|
||||
let { width, pageHeight: height = metadata.height } = metadata;
|
||||
if (metadata.orientation && metadata.orientation > 4) {
|
||||
[image, width, height] = [image.rotate(), height, width];
|
||||
}
|
||||
|
||||
const isPortrait = height > width;
|
||||
const thumbnailsExtension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
||||
|
||||
try {
|
||||
const resizeBuffer = await image
|
||||
.resize(
|
||||
256,
|
||||
isPortrait ? 320 : undefined,
|
||||
width < 256 || (isPortrait && height < 320)
|
||||
? {
|
||||
kernel: sharp.kernel.nearest,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
.toBuffer();
|
||||
const s3Thumb = await client.upload({
|
||||
Key: `attachments/${dirname}/thumbnails/cover-256.${thumbnailsExtension}`,
|
||||
Body: resizeBuffer,
|
||||
ContentType: inputs.file.type,
|
||||
});
|
||||
fileData.thumb = s3Thumb.Location;
|
||||
fileData.image = { width, height };
|
||||
} catch (error1) {
|
||||
console.warn(error2.stack); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
rimraf.sync(inputs.file.fd);
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
return fileData;
|
||||
}
|
||||
|
||||
fs.mkdirSync(rootPath);
|
||||
await moveFile(inputs.file.fd, filePath);
|
||||
|
||||
let image = sharp(filePath, {
|
||||
let image = sharp(filePath || inputs.file.fd, {
|
||||
animated: true,
|
||||
});
|
||||
|
||||
|
@ -105,7 +35,6 @@ module.exports = {
|
|||
} catch (error) {} // eslint-disable-line no-empty
|
||||
|
||||
const fileData = {
|
||||
type: 'local',
|
||||
dirname,
|
||||
filename,
|
||||
image: null,
|
||||
|
@ -113,9 +42,6 @@ module.exports = {
|
|||
};
|
||||
|
||||
if (metadata && !['svg', 'pdf'].includes(metadata.format)) {
|
||||
const thumbnailsPath = path.join(rootPath, 'thumbnails');
|
||||
fs.mkdirSync(thumbnailsPath);
|
||||
|
||||
let { width, pageHeight: height = metadata.height } = metadata;
|
||||
if (metadata.orientation && metadata.orientation > 4) {
|
||||
[image, width, height] = [image.rotate(), height, width];
|
||||
|
@ -125,7 +51,7 @@ module.exports = {
|
|||
const thumbnailsExtension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
||||
|
||||
try {
|
||||
await image
|
||||
const resizeBuffer = await image
|
||||
.resize(
|
||||
256,
|
||||
isPortrait ? 320 : undefined,
|
||||
|
@ -135,19 +61,29 @@ module.exports = {
|
|||
}
|
||||
: undefined,
|
||||
)
|
||||
.toFile(path.join(thumbnailsPath, `cover-256.${thumbnailsExtension}`));
|
||||
.toBuffer();
|
||||
|
||||
await fileManager.save(
|
||||
`${folderPathSegment}/thumbnails/cover-256.${thumbnailsExtension}`,
|
||||
resizeBuffer,
|
||||
inputs.file.type,
|
||||
);
|
||||
|
||||
fileData.image = {
|
||||
width,
|
||||
height,
|
||||
thumbnailsExtension,
|
||||
};
|
||||
} catch (error1) {
|
||||
try {
|
||||
rimraf.sync(thumbnailsPath);
|
||||
} catch (error2) {
|
||||
console.warn(error2.stack); // eslint-disable-line no-console
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
try {
|
||||
await rimraf(inputs.file.fd);
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const fs = require('fs').promises;
|
||||
const rimraf = require('rimraf');
|
||||
const fs = require('fs');
|
||||
const { rimraf } = require('rimraf');
|
||||
|
||||
module.exports = {
|
||||
inputs: {
|
||||
|
@ -14,7 +14,7 @@ module.exports = {
|
|||
},
|
||||
|
||||
async fn(inputs) {
|
||||
const content = await fs.readFile(inputs.file.fd);
|
||||
const content = await fs.promises.readFile(inputs.file.fd);
|
||||
const trelloBoard = JSON.parse(content);
|
||||
|
||||
if (
|
||||
|
@ -28,7 +28,7 @@ module.exports = {
|
|||
}
|
||||
|
||||
try {
|
||||
rimraf.sync(inputs.file.fd);
|
||||
await rimraf(inputs.file.fd);
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const rimraf = require('rimraf');
|
||||
const { rimraf } = require('rimraf');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const sharp = require('sharp');
|
||||
|
||||
|
@ -32,7 +30,10 @@ module.exports = {
|
|||
throw 'fileIsNotImage';
|
||||
}
|
||||
|
||||
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||
|
||||
const dirname = uuid();
|
||||
const folderPathSegment = `${sails.config.custom.projectBackgroundImagesPathSegment}/${dirname}`;
|
||||
|
||||
let { width, pageHeight: height = metadata.height } = metadata;
|
||||
if (metadata.orientation && metadata.orientation > 4) {
|
||||
|
@ -41,68 +42,16 @@ module.exports = {
|
|||
|
||||
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
||||
|
||||
if (sails.config.custom.s3Config) {
|
||||
const client = await sails.helpers.utils.getSimpleStorageServiceClient();
|
||||
let originalUrl = '';
|
||||
let thumbUrl = '';
|
||||
|
||||
try {
|
||||
const s3Original = await client.upload({
|
||||
Body: await image.toBuffer(),
|
||||
Key: `project-background-images/${dirname}/original.${extension}`,
|
||||
ContentType: inputs.file.type,
|
||||
});
|
||||
originalUrl = s3Original.Location;
|
||||
|
||||
const resizeBuffer = await image
|
||||
.resize(
|
||||
336,
|
||||
200,
|
||||
width < 336 || height < 200
|
||||
? {
|
||||
kernel: sharp.kernel.nearest,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
.toBuffer();
|
||||
const s3Thumb = await client.upload({
|
||||
Body: resizeBuffer,
|
||||
Key: `project-background-images/${dirname}/cover-336.${extension}`,
|
||||
ContentType: inputs.file.type,
|
||||
});
|
||||
thumbUrl = s3Thumb.Location;
|
||||
} catch (error1) {
|
||||
try {
|
||||
client.delete({ Key: `project-background-images/${dirname}/original.${extension}` });
|
||||
} catch (error2) {
|
||||
console.warn(error2.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
throw 'fileIsNotImage';
|
||||
}
|
||||
|
||||
try {
|
||||
rimraf.sync(inputs.file.fd);
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
return {
|
||||
dirname,
|
||||
extension,
|
||||
original: originalUrl,
|
||||
thumb: thumbUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const rootPath = path.join(sails.config.custom.projectBackgroundImagesPath, dirname);
|
||||
|
||||
fs.mkdirSync(rootPath);
|
||||
|
||||
try {
|
||||
await image.toFile(path.join(rootPath, `original.${extension}`));
|
||||
const originalBuffer = await image.toBuffer();
|
||||
|
||||
await image
|
||||
await fileManager.save(
|
||||
`${folderPathSegment}/original.${extension}`,
|
||||
originalBuffer,
|
||||
inputs.file.type,
|
||||
);
|
||||
|
||||
const cover336Buffer = await image
|
||||
.resize(
|
||||
336,
|
||||
200,
|
||||
|
@ -112,10 +61,18 @@ module.exports = {
|
|||
}
|
||||
: undefined,
|
||||
)
|
||||
.toFile(path.join(rootPath, `cover-336.${extension}`));
|
||||
.toBuffer();
|
||||
|
||||
await fileManager.save(
|
||||
`${folderPathSegment}/cover-336.${extension}`,
|
||||
cover336Buffer,
|
||||
inputs.file.type,
|
||||
);
|
||||
} catch (error1) {
|
||||
console.warn(error1.stack); // eslint-disable-line no-console
|
||||
|
||||
try {
|
||||
rimraf.sync(rootPath);
|
||||
fileManager.deleteFolder(folderPathSegment);
|
||||
} catch (error2) {
|
||||
console.warn(error2.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
@ -124,7 +81,7 @@ module.exports = {
|
|||
}
|
||||
|
||||
try {
|
||||
rimraf.sync(inputs.file.fd);
|
||||
await rimraf(inputs.file.fd);
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
const path = require('path');
|
||||
const rimraf = require('rimraf');
|
||||
|
||||
const valuesValidator = (value) => {
|
||||
if (!_.isPlainObject(value)) {
|
||||
return false;
|
||||
|
@ -86,27 +83,11 @@ module.exports = {
|
|||
(!project.backgroundImage ||
|
||||
project.backgroundImage.dirname !== inputs.record.backgroundImage.dirname)
|
||||
) {
|
||||
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||
|
||||
try {
|
||||
if (sails.config.custom.s3Config) {
|
||||
const client = await sails.helpers.utils.getSimpleStorageServiceClient();
|
||||
if (client && inputs.record.backgroundImage && inputs.record.backgroundImage.original) {
|
||||
const parsedUrl = new URL(inputs.record.backgroundImage.original);
|
||||
await client.delete({ Key: parsedUrl.pathname.replace(/^\/+/, '') });
|
||||
}
|
||||
if (client && inputs.record.backgroundImage && inputs.record.backgroundImage.thumb) {
|
||||
const parsedUrl = new URL(inputs.record.backgroundImage.thumb);
|
||||
await client.delete({ Key: parsedUrl.pathname.replace(/^\/+/, '') });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
try {
|
||||
rimraf.sync(
|
||||
path.join(
|
||||
sails.config.custom.projectBackgroundImagesPath,
|
||||
inputs.record.backgroundImage.dirname,
|
||||
),
|
||||
await fileManager.deleteFolder(
|
||||
`${sails.config.custom.projectBackgroundImagesPathSegment}/${inputs.record.backgroundImage.dirname}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const rimraf = require('rimraf');
|
||||
const { rimraf } = require('rimraf');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const sharp = require('sharp');
|
||||
|
||||
|
@ -32,7 +30,10 @@ module.exports = {
|
|||
throw 'fileIsNotImage';
|
||||
}
|
||||
|
||||
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||
|
||||
const dirname = uuid();
|
||||
const folderPathSegment = `${sails.config.custom.userAvatarsPathSegment}/${dirname}`;
|
||||
|
||||
let { width, pageHeight: height = metadata.height } = metadata;
|
||||
if (metadata.orientation && metadata.orientation > 4) {
|
||||
|
@ -41,68 +42,16 @@ module.exports = {
|
|||
|
||||
const extension = metadata.format === 'jpeg' ? 'jpg' : metadata.format;
|
||||
|
||||
if (sails.config.custom.s3Config) {
|
||||
const client = await sails.helpers.utils.getSimpleStorageServiceClient();
|
||||
let originalUrl = '';
|
||||
let squareUrl = '';
|
||||
|
||||
try {
|
||||
const s3Original = await client.upload({
|
||||
Body: await image.toBuffer(),
|
||||
Key: `user-avatars/${dirname}/original.${extension}`,
|
||||
ContentType: inputs.file.type,
|
||||
});
|
||||
originalUrl = s3Original.Location;
|
||||
|
||||
const resizeBuffer = await image
|
||||
.resize(
|
||||
100,
|
||||
100,
|
||||
width < 100 || height < 100
|
||||
? {
|
||||
kernel: sharp.kernel.nearest,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
.toBuffer();
|
||||
const s3Square = await client.upload({
|
||||
Body: resizeBuffer,
|
||||
Key: `user-avatars/${dirname}/square-100.${extension}`,
|
||||
ContentType: inputs.file.type,
|
||||
});
|
||||
squareUrl = s3Square.Location;
|
||||
} catch (error1) {
|
||||
try {
|
||||
client.delete({ Key: `user-avatars/${dirname}/original.${extension}` });
|
||||
} catch (error2) {
|
||||
console.warn(error2.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
throw 'fileIsNotImage';
|
||||
}
|
||||
|
||||
try {
|
||||
rimraf.sync(inputs.file.fd);
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
return {
|
||||
dirname,
|
||||
extension,
|
||||
original: originalUrl,
|
||||
square: squareUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const rootPath = path.join(sails.config.custom.userAvatarsPath, dirname);
|
||||
|
||||
fs.mkdirSync(rootPath);
|
||||
|
||||
try {
|
||||
await image.toFile(path.join(rootPath, `original.${extension}`));
|
||||
const originalBuffer = await image.toBuffer();
|
||||
|
||||
await image
|
||||
await fileManager.save(
|
||||
`${folderPathSegment}/original.${extension}`,
|
||||
originalBuffer,
|
||||
inputs.file.type,
|
||||
);
|
||||
|
||||
const square100Buffer = await image
|
||||
.resize(
|
||||
100,
|
||||
100,
|
||||
|
@ -112,10 +61,18 @@ module.exports = {
|
|||
}
|
||||
: undefined,
|
||||
)
|
||||
.toFile(path.join(rootPath, `square-100.${extension}`));
|
||||
.toBuffer();
|
||||
|
||||
await fileManager.save(
|
||||
`${folderPathSegment}/square-100.${extension}`,
|
||||
square100Buffer,
|
||||
inputs.file.type,
|
||||
);
|
||||
} catch (error1) {
|
||||
console.warn(error1.stack); // eslint-disable-line no-console
|
||||
|
||||
try {
|
||||
rimraf.sync(rootPath);
|
||||
fileManager.deleteFolder(folderPathSegment);
|
||||
} catch (error2) {
|
||||
console.warn(error2.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
@ -124,7 +81,7 @@ module.exports = {
|
|||
}
|
||||
|
||||
try {
|
||||
rimraf.sync(inputs.file.fd);
|
||||
await rimraf(inputs.file.fd);
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
const path = require('path');
|
||||
const bcrypt = require('bcrypt');
|
||||
const rimraf = require('rimraf');
|
||||
const { v4: uuid } = require('uuid');
|
||||
|
||||
const valuesValidator = (value) => {
|
||||
|
@ -101,23 +99,12 @@ module.exports = {
|
|||
inputs.record.avatar &&
|
||||
(!user.avatar || user.avatar.dirname !== inputs.record.avatar.dirname)
|
||||
) {
|
||||
const fileManager = sails.hooks['file-manager'].getInstance();
|
||||
|
||||
try {
|
||||
if (sails.config.custom.s3Config) {
|
||||
const client = await sails.helpers.utils.getSimpleStorageServiceClient();
|
||||
if (client && inputs.record.avatar && inputs.record.avatar.original) {
|
||||
const parsedUrl = new URL(inputs.record.avatar.original);
|
||||
await client.delete({ Key: parsedUrl.pathname.replace(/^\/+/, '') });
|
||||
}
|
||||
if (client && inputs.record.avatar && inputs.record.avatar.square) {
|
||||
const parsedUrl = new URL(inputs.record.avatar.square);
|
||||
await client.delete({ Key: parsedUrl.pathname.replace(/^\/+/, '') });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
try {
|
||||
rimraf.sync(path.join(sails.config.custom.userAvatarsPath, inputs.record.avatar.dirname));
|
||||
await fileManager.deleteFolder(
|
||||
`${sails.config.custom.userAvatarsPathSegment}/${inputs.record.avatar.dirname}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(error.stack); // eslint-disable-line no-console
|
||||
}
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
const AWS = require('aws-sdk');
|
||||
|
||||
class S3Client {
|
||||
constructor(options) {
|
||||
AWS.config.update({
|
||||
accessKeyId: options.accessKeyId,
|
||||
secretAccessKey: options.secretAccessKey,
|
||||
region: options.region,
|
||||
});
|
||||
this.bucket = options.bucket;
|
||||
this.client = new AWS.S3({
|
||||
endpoint: options.endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
upload({ Key, Body, ContentType }) {
|
||||
return this.client
|
||||
.upload({
|
||||
Bucket: this.bucket,
|
||||
Key,
|
||||
Body,
|
||||
ContentType,
|
||||
ACL: 'public-read',
|
||||
})
|
||||
.promise();
|
||||
}
|
||||
|
||||
delete({ Key }) {
|
||||
return this.client
|
||||
.deleteObject({
|
||||
Bucket: this.bucket,
|
||||
Key,
|
||||
})
|
||||
.promise();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fn() {
|
||||
if (sails.config.custom.s3Config) {
|
||||
return new S3Client(sails.config.custom.s3Config);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
|
@ -4,7 +4,7 @@ const { v4: uuid } = require('uuid');
|
|||
async function doUpload(paramName, req, options) {
|
||||
const uploadOptions = {
|
||||
...options,
|
||||
dirname: options.dirname || sails.config.custom.fileUploadTmpDir,
|
||||
dirname: options.dirname || sails.config.custom.uploadsTempPath,
|
||||
};
|
||||
const upload = util.promisify((opts, callback) => {
|
||||
return req.file(paramName).upload(opts, (error, files) => callback(error, files));
|
||||
|
@ -33,7 +33,7 @@ module.exports = {
|
|||
exits.success(
|
||||
await doUpload(inputs.paramName, inputs.req, {
|
||||
saveAs: uuid(),
|
||||
dirname: sails.config.custom.fileUploadTmpDir,
|
||||
dirname: sails.config.custom.uploadsTempPath,
|
||||
maxBytes: null,
|
||||
}),
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue