Adds ICS
This commit is contained in:
parent
441715675c
commit
a79898b2e9
48 changed files with 2062 additions and 1503 deletions
|
|
@ -1,65 +1,65 @@
|
|||
import {Repository} from "./Repository";
|
||||
import GroupConfiguration, {DBGroupConfiguration} from "../Database/tables/GroupConfiguration";
|
||||
import {GroupConfigurationModel} from "../Models/GroupConfigurationModel";
|
||||
import { GroupModel } from "../Models/GroupModel";
|
||||
import {GroupModel} from "../Models/GroupModel";
|
||||
import {Nullable} from "../types/Nullable";
|
||||
import {DatabaseConnection} from "../Database/DatabaseConnection";
|
||||
import {GroupRepository} from "./GroupRepository";
|
||||
|
||||
export class GroupConfigurationRepository extends Repository<GroupConfigurationModel, DBGroupConfiguration> {
|
||||
|
||||
constructor(
|
||||
protected readonly database: DatabaseConnection,
|
||||
private readonly groupRepository: GroupRepository,
|
||||
) {
|
||||
super(
|
||||
database,
|
||||
GroupConfiguration
|
||||
);
|
||||
}
|
||||
|
||||
public findGroupConfigurations(group: GroupModel): GroupConfigurationModel[] {
|
||||
return this.database.fetchAll<number, DBGroupConfiguration>(`
|
||||
constructor(
|
||||
protected readonly database: DatabaseConnection,
|
||||
private readonly groupRepository: GroupRepository,
|
||||
) {
|
||||
super(
|
||||
database,
|
||||
GroupConfiguration
|
||||
);
|
||||
}
|
||||
|
||||
public findGroupConfigurations(group: GroupModel): GroupConfigurationModel[] {
|
||||
return this.database.fetchAll<number, DBGroupConfiguration>(`
|
||||
SELECT * FROM groupConfiguration WHERE groupid = ?`,
|
||||
group.id
|
||||
).map((config) => {
|
||||
return this.convertToModelType(config, group);
|
||||
})
|
||||
}
|
||||
|
||||
public findConfigurationByPath(group: GroupModel, path: string): Nullable<GroupConfigurationModel> {
|
||||
const result = this.database.fetch<number, DBGroupConfiguration>(`
|
||||
group.id
|
||||
).map((config) => {
|
||||
return this.convertToModelType(config, group);
|
||||
})
|
||||
}
|
||||
|
||||
public findConfigurationByPath(group: GroupModel, path: string): Nullable<GroupConfigurationModel> {
|
||||
const result = this.database.fetch<number, DBGroupConfiguration>(`
|
||||
SELECT * FROM groupConfiguration WHERE groupid = ? AND key = ?`,
|
||||
group.id,
|
||||
path
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.convertToModelType(result, group);
|
||||
group.id,
|
||||
path
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
protected convertToModelType(intermediateModel: DBGroupConfiguration | undefined, group: Nullable<GroupModel> = null): GroupConfigurationModel {
|
||||
if (!intermediateModel) {
|
||||
throw new Error("No intermediate model provided");
|
||||
}
|
||||
|
||||
return {
|
||||
id: intermediateModel.id,
|
||||
group: group ?? this.groupRepository.getById(intermediateModel.id),
|
||||
key: intermediateModel.key,
|
||||
value: intermediateModel.value,
|
||||
}
|
||||
|
||||
return this.convertToModelType(result, group);
|
||||
}
|
||||
|
||||
|
||||
protected convertToModelType(intermediateModel: DBGroupConfiguration | undefined, group: Nullable<GroupModel> = null): GroupConfigurationModel {
|
||||
if (!intermediateModel) {
|
||||
throw new Error("No intermediate model provided");
|
||||
}
|
||||
|
||||
protected convertToCreateObject(instance: Partial<GroupConfigurationModel>): object {
|
||||
return {
|
||||
groupid: instance.group?.id ?? undefined,
|
||||
key: instance.key ?? undefined,
|
||||
value: instance.value ?? undefined,
|
||||
}
|
||||
|
||||
return {
|
||||
id: intermediateModel.id,
|
||||
group: group ?? this.groupRepository.getById(intermediateModel.id),
|
||||
key: intermediateModel.key,
|
||||
value: intermediateModel.value,
|
||||
}
|
||||
}
|
||||
|
||||
protected convertToCreateObject(instance: Partial<GroupConfigurationModel>): object {
|
||||
return {
|
||||
groupid: instance.group?.id ?? undefined,
|
||||
key: instance.key ?? undefined,
|
||||
value: instance.value ?? undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,94 +9,94 @@ import {Container} from "../Container/Container";
|
|||
|
||||
export class GroupRepository extends Repository<GroupModel, DBGroup> {
|
||||
|
||||
constructor(
|
||||
protected readonly database: DatabaseConnection,
|
||||
) {
|
||||
super(
|
||||
database,
|
||||
Groups
|
||||
);
|
||||
|
||||
constructor(
|
||||
protected readonly database: DatabaseConnection,
|
||||
) {
|
||||
super(
|
||||
database,
|
||||
Groups
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
public findGroupByName(name: string): Nullable<GroupModel> {
|
||||
const result = this.database.fetch<string, DBGroup>(
|
||||
`SELECT * FROM groups WHERE name = ? LIMIT 1`,
|
||||
name
|
||||
)
|
||||
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public findGroupByName(name: string): Nullable<GroupModel> {
|
||||
const result = this.database.fetch<string, DBGroup>(
|
||||
`SELECT * FROM groups WHERE name = ? LIMIT 1`,
|
||||
name
|
||||
)
|
||||
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.convertToModelType(result);
|
||||
}
|
||||
|
||||
public findGroupsByRoles(server: string, roleIds: string[]): GroupModel[] {
|
||||
const template = roleIds.map(_roleId => '?').join(',');
|
||||
|
||||
const dbResult = this.database.fetchAll<number[], DBGroup>(`
|
||||
|
||||
return this.convertToModelType(result);
|
||||
}
|
||||
|
||||
public findGroupsByRoles(server: string, roleIds: string[]): GroupModel[] {
|
||||
const template = roleIds.map(_roleId => '?').join(',');
|
||||
|
||||
const dbResult = this.database.fetchAll<number[], DBGroup>(`
|
||||
SELECT * FROM groups WHERE server = ? AND role IN (${template})
|
||||
`,
|
||||
server,
|
||||
...roleIds)
|
||||
|
||||
|
||||
return dbResult.map((result) => this.convertToModelType(result));
|
||||
server,
|
||||
...roleIds)
|
||||
|
||||
|
||||
return dbResult.map((result) => this.convertToModelType(result));
|
||||
}
|
||||
|
||||
public findGroupsByMember(member: GuildMember, onlyLeader: boolean = false) {
|
||||
if (!member) {
|
||||
throw new Error("Can't find member for guild: none given");
|
||||
}
|
||||
|
||||
public findGroupsByMember(member: GuildMember, onlyLeader: boolean = false) {
|
||||
if (!member) {
|
||||
throw new Error("Can't find member for guild: none given");
|
||||
}
|
||||
|
||||
const groups = this.findGroupsByRoles(member.guild.id, [...member.roles.cache.keys()])
|
||||
|
||||
if (!onlyLeader) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
return groups.filter((group: GroupModel) => {
|
||||
return group.leader.memberid === member.id;
|
||||
})
|
||||
}
|
||||
|
||||
public deleteGroup(group: GroupModel): void {
|
||||
this.delete(group.id);
|
||||
|
||||
const repo = Container.get<PlaydateRepository>(PlaydateRepository.name);
|
||||
const playdates = repo.findFromGroup(group, true)
|
||||
playdates.forEach((playdate) => {
|
||||
repo.delete(playdate.id);
|
||||
})
|
||||
}
|
||||
|
||||
protected convertToModelType(intermediateModel: DBGroup | undefined): GroupModel {
|
||||
if (!intermediateModel) {
|
||||
throw new Error("No intermediate model provided");
|
||||
}
|
||||
|
||||
return {
|
||||
id: intermediateModel.id,
|
||||
name: intermediateModel.name,
|
||||
leader: {
|
||||
server: intermediateModel.server,
|
||||
memberid: intermediateModel.leader
|
||||
},
|
||||
role: {
|
||||
server: intermediateModel.server,
|
||||
roleid: intermediateModel.role
|
||||
}
|
||||
}
|
||||
const groups = this.findGroupsByRoles(member.guild.id, [...member.roles.cache.keys()])
|
||||
|
||||
if (!onlyLeader) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
protected convertToCreateObject(instance: Partial<GroupModel>): object {
|
||||
return {
|
||||
name: instance.name ?? '',
|
||||
server: instance.role?.server ?? null,
|
||||
leader: instance.leader?.memberid ?? null,
|
||||
role: instance.role?.roleid ?? null,
|
||||
}
|
||||
return groups.filter((group: GroupModel) => {
|
||||
return group.leader.memberid === member.id;
|
||||
})
|
||||
}
|
||||
|
||||
public deleteGroup(group: GroupModel): void {
|
||||
this.delete(group.id);
|
||||
|
||||
const repo = Container.get<PlaydateRepository>(PlaydateRepository.name);
|
||||
const playdates = repo.findFromGroup(group, true)
|
||||
playdates.forEach((playdate) => {
|
||||
repo.delete(playdate.id);
|
||||
})
|
||||
}
|
||||
|
||||
protected convertToModelType(intermediateModel: DBGroup | undefined): GroupModel {
|
||||
if (!intermediateModel) {
|
||||
throw new Error("No intermediate model provided");
|
||||
}
|
||||
|
||||
return {
|
||||
id: intermediateModel.id,
|
||||
name: intermediateModel.name,
|
||||
leader: {
|
||||
server: intermediateModel.server,
|
||||
memberid: intermediateModel.leader
|
||||
},
|
||||
role: {
|
||||
server: intermediateModel.server,
|
||||
roleid: intermediateModel.role
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected convertToCreateObject(instance: Partial<GroupModel>): object {
|
||||
return {
|
||||
name: instance.name ?? '',
|
||||
server: instance.role?.server ?? null,
|
||||
leader: instance.leader?.memberid ?? null,
|
||||
role: instance.role?.roleid ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -7,90 +7,96 @@ import {GroupModel} from "../Models/GroupModel";
|
|||
import {Nullable} from "../types/Nullable";
|
||||
|
||||
export class PlaydateRepository extends Repository<PlaydateModel, DBPlaydate> {
|
||||
|
||||
constructor(
|
||||
protected readonly database: DatabaseConnection,
|
||||
private readonly groupRepository: GroupRepository,
|
||||
) {
|
||||
super(
|
||||
database,
|
||||
Playdate
|
||||
);
|
||||
}
|
||||
|
||||
findFromGroup(group: GroupModel, all = false) {
|
||||
let sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ?`;
|
||||
const params = [group.id];
|
||||
|
||||
if (!all) {
|
||||
sql += " AND time_from > ?"
|
||||
params.push(new Date().getTime())
|
||||
}
|
||||
|
||||
const finds = this.database.fetchAll<number, DBPlaydate>(
|
||||
sql,
|
||||
...params
|
||||
);
|
||||
|
||||
return finds.map((playdate) => this.convertToModelType(playdate, group));
|
||||
}
|
||||
findPlaydatesInRange(fromDate: Date|number, toDate: Date|number, group: GroupModel | undefined = undefined) {
|
||||
if (fromDate instanceof Date) {
|
||||
fromDate = fromDate.getTime();
|
||||
}
|
||||
if (toDate instanceof Date) {
|
||||
toDate = toDate.getTime();
|
||||
}
|
||||
|
||||
let sql = `SELECT * FROM ${this.schema.name} WHERE time_from > ? AND time_from < ?`;
|
||||
const params = [fromDate, toDate];
|
||||
|
||||
if (group) {
|
||||
sql = `${sql} AND groupid = ?`
|
||||
params.push(group.id)
|
||||
}
|
||||
|
||||
const finds = this.database.fetchAll<number, DBPlaydate>(
|
||||
sql,
|
||||
...params
|
||||
);
|
||||
constructor(
|
||||
protected readonly database: DatabaseConnection,
|
||||
private readonly groupRepository: GroupRepository,
|
||||
) {
|
||||
super(
|
||||
database,
|
||||
Playdate
|
||||
);
|
||||
}
|
||||
|
||||
return finds.map((playdate) => this.convertToModelType(playdate, group));
|
||||
findFromGroup(group: GroupModel, all = false) {
|
||||
let sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ?`;
|
||||
const params = [group.id];
|
||||
|
||||
if (!all) {
|
||||
sql += " AND time_from > ?"
|
||||
params.push(new Date().getTime())
|
||||
}
|
||||
|
||||
const finds = this.database.fetchAll<number, DBPlaydate>(
|
||||
sql,
|
||||
...params
|
||||
);
|
||||
|
||||
return finds.map((playdate) => this.convertToModelType(playdate, group));
|
||||
}
|
||||
|
||||
findPlaydatesInRange(fromDate: Date | number, toDate: Date | number | undefined = undefined, group: GroupModel | undefined = undefined) {
|
||||
if (fromDate instanceof Date) {
|
||||
fromDate = fromDate.getTime();
|
||||
}
|
||||
if (toDate instanceof Date) {
|
||||
toDate = toDate.getTime();
|
||||
}
|
||||
|
||||
let sql = `SELECT * FROM ${this.schema.name} WHERE time_from > ?`;
|
||||
const params = [fromDate];
|
||||
|
||||
if (toDate) {
|
||||
sql = `${sql} AND time_from < ?`
|
||||
params.push(toDate);
|
||||
}
|
||||
|
||||
getNextPlaydateForGroup(group: GroupModel): PlaydateModel | null {
|
||||
const sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ? AND time_from > ? ORDER BY time_from LIMIT 1`;
|
||||
|
||||
const find = this.database.fetch<number, DBPlaydate>(
|
||||
sql,
|
||||
group.id,
|
||||
Date.now()
|
||||
)
|
||||
|
||||
if (!find) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.convertToModelType(find, group)
|
||||
if (group) {
|
||||
sql = `${sql} AND groupid = ?`
|
||||
params.push(<number>group.id)
|
||||
}
|
||||
|
||||
protected convertToModelType(intermediateModel: DBPlaydate | undefined, fixedGroup: Nullable<GroupModel> = null): PlaydateModel {
|
||||
if (!intermediateModel) {
|
||||
throw new Error("Unable to convert the playdate model");
|
||||
}
|
||||
return {
|
||||
id: intermediateModel.id,
|
||||
group: fixedGroup ?? this.groupRepository.getById(intermediateModel.groupid),
|
||||
from_time: new Date(intermediateModel.time_from),
|
||||
to_time: new Date(intermediateModel.time_to),
|
||||
};
|
||||
|
||||
const finds = this.database.fetchAll<number, DBPlaydate>(
|
||||
sql,
|
||||
...params
|
||||
);
|
||||
|
||||
return finds.map((playdate) => this.convertToModelType(playdate, group));
|
||||
}
|
||||
|
||||
getNextPlaydateForGroup(group: GroupModel): PlaydateModel | null {
|
||||
const sql = `SELECT * FROM ${this.schema.name} WHERE groupid = ? AND time_from > ? ORDER BY time_from LIMIT 1`;
|
||||
|
||||
const find = this.database.fetch<number, DBPlaydate>(
|
||||
sql,
|
||||
group.id,
|
||||
Date.now()
|
||||
)
|
||||
|
||||
if (!find) {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected convertToCreateObject(instance: Partial<PlaydateModel>): object {
|
||||
return {
|
||||
groupid: instance.group?.id ?? null,
|
||||
time_from: instance.from_time?.getTime() ?? 0,
|
||||
time_to: instance.to_time?.getTime() ?? 0,
|
||||
}
|
||||
|
||||
return this.convertToModelType(find, group)
|
||||
}
|
||||
|
||||
protected convertToModelType(intermediateModel: DBPlaydate | undefined, fixedGroup: Nullable<GroupModel> = null): PlaydateModel {
|
||||
if (!intermediateModel) {
|
||||
throw new Error("Unable to convert the playdate model");
|
||||
}
|
||||
return {
|
||||
id: intermediateModel.id,
|
||||
group: fixedGroup ?? this.groupRepository.getById(intermediateModel.groupid),
|
||||
from_time: new Date(intermediateModel.time_from),
|
||||
to_time: new Date(intermediateModel.time_to),
|
||||
};
|
||||
}
|
||||
|
||||
protected convertToCreateObject(instance: Partial<PlaydateModel>): object {
|
||||
return {
|
||||
groupid: instance.group?.id ?? null,
|
||||
time_from: instance.from_time?.getTime() ?? 0,
|
||||
time_to: instance.to_time?.getTime() ?? 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,83 +1,85 @@
|
|||
import {DatabaseConnection} from "../Database/DatabaseConnection";
|
||||
import {Model} from "../Models/Model";
|
||||
import { Nullable } from "../types/Nullable";
|
||||
import {Nullable} from "../types/Nullable";
|
||||
import {DatabaseDefinition} from "../Database/DatabaseDefinition";
|
||||
import {Container} from "../Container/Container";
|
||||
import {EventHandler} from "../Events/EventHandler";
|
||||
import {ElementCreatedEvent} from "../Events/ElementCreatedEvent";
|
||||
|
||||
export class Repository<ModelType extends Model, IntermediateModelType = unknown> {
|
||||
|
||||
constructor(
|
||||
protected readonly database: DatabaseConnection,
|
||||
public readonly schema: DatabaseDefinition,
|
||||
) {}
|
||||
|
||||
public create(instance: Partial<ModelType>): number|bigint {
|
||||
const columnNames = this.schema.columns.filter((column) => {
|
||||
return !column.primaryKey
|
||||
}).map((column) => {
|
||||
return column.name;
|
||||
});
|
||||
|
||||
const createObject = this.convertToCreateObject(instance);
|
||||
const keys = Object.keys(createObject);
|
||||
const missingColumns = columnNames.filter((columnName) => {
|
||||
return !keys.includes(columnName);
|
||||
})
|
||||
|
||||
if (missingColumns.length > 0) {
|
||||
throw new Error("Can't create instance, due to missing column values: " + missingColumns);
|
||||
}
|
||||
|
||||
const sql = `INSERT INTO ${this.schema.name}(${Object.keys(createObject).join(',')})
|
||||
VALUES (${Object.keys(createObject).map(() => "?").join(',')})`;
|
||||
const result = this.database.execute(sql, ...Object.values(createObject));
|
||||
const id = result.lastInsertRowid;
|
||||
|
||||
Container.get<EventHandler>(EventHandler.name).dispatch(new ElementCreatedEvent<ModelType>(this.schema.name, instance, id));
|
||||
|
||||
return id;
|
||||
|
||||
constructor(
|
||||
protected readonly database: DatabaseConnection,
|
||||
public readonly schema: DatabaseDefinition,
|
||||
) {
|
||||
}
|
||||
|
||||
public create(instance: Partial<ModelType>): number | bigint {
|
||||
const columnNames = this.schema.columns.filter((column) => {
|
||||
return !column.primaryKey
|
||||
}).map((column) => {
|
||||
return column.name;
|
||||
});
|
||||
|
||||
const createObject = this.convertToCreateObject(instance);
|
||||
const keys = Object.keys(createObject);
|
||||
const missingColumns = columnNames.filter((columnName) => {
|
||||
return !keys.includes(columnName);
|
||||
})
|
||||
|
||||
if (missingColumns.length > 0) {
|
||||
throw new Error("Can't create instance, due to missing column values: " + missingColumns);
|
||||
}
|
||||
|
||||
public update(instance: Partial<ModelType>&{id: number}): boolean {
|
||||
const columnNames = this.schema.columns.filter((column) => {
|
||||
return !column.primaryKey
|
||||
}).map((column) => {
|
||||
return column.name;
|
||||
});
|
||||
|
||||
const createObject = this.convertToCreateObject(instance);
|
||||
const keys = Object.keys(createObject);
|
||||
const missingColumns = columnNames.filter((columnName) => {
|
||||
return !keys.includes(columnName);
|
||||
})
|
||||
|
||||
if (missingColumns.length > 0) {
|
||||
throw new Error("Can't create instance, due to missing column values: " + missingColumns);
|
||||
}
|
||||
const sql = `INSERT INTO ${this.schema.name}(${Object.keys(createObject).join(',')})
|
||||
VALUES (${Object.keys(createObject).map(() => "?").join(',')})`;
|
||||
const result = this.database.execute(sql, ...Object.values(createObject));
|
||||
const id = result.lastInsertRowid;
|
||||
|
||||
const sql = `UPDATE ${this.schema.name}
|
||||
Container.get<EventHandler>(EventHandler.name).dispatch(new ElementCreatedEvent<ModelType>(this.schema.name, instance, id));
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
public update(instance: Partial<ModelType> & { id: number }): boolean {
|
||||
const columnNames = this.schema.columns.filter((column) => {
|
||||
return !column.primaryKey
|
||||
}).map((column) => {
|
||||
return column.name;
|
||||
});
|
||||
|
||||
const createObject = this.convertToCreateObject(instance);
|
||||
const keys = Object.keys(createObject);
|
||||
const missingColumns = columnNames.filter((columnName) => {
|
||||
return !keys.includes(columnName);
|
||||
})
|
||||
|
||||
if (missingColumns.length > 0) {
|
||||
throw new Error("Can't create instance, due to missing column values: " + missingColumns);
|
||||
}
|
||||
|
||||
const sql = `UPDATE ${this.schema.name}
|
||||
SET ${Object.keys(createObject).map((key) => `${key} = ?`).join(',')}
|
||||
WHERE id = ?`;
|
||||
const result = this.database.execute(sql, ...Object.values(createObject), instance.id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
public getById(id: number): Nullable<ModelType> {
|
||||
const sql = `SELECT * FROM ${this.schema.name} WHERE id = ? LIMIT 1`;
|
||||
return this.convertToModelType(this.database.fetch<number, IntermediateModelType>(sql, id));
|
||||
}
|
||||
|
||||
public delete(id: number) {
|
||||
const sql = `DELETE FROM ${this.schema.name} WHERE id = ?`;
|
||||
return this.database.execute(sql, id);
|
||||
}
|
||||
|
||||
protected convertToModelType(intermediateModel: IntermediateModelType | undefined): ModelType {
|
||||
return intermediateModel as unknown as ModelType;
|
||||
}
|
||||
protected convertToCreateObject(instance: Partial<ModelType>): object {
|
||||
return instance;
|
||||
}
|
||||
const result = this.database.execute(sql, ...Object.values(createObject), instance.id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
public getById(id: number): Nullable<ModelType> {
|
||||
const sql = `SELECT * FROM ${this.schema.name} WHERE id = ? LIMIT 1`;
|
||||
return this.convertToModelType(this.database.fetch<number, IntermediateModelType>(sql, id));
|
||||
}
|
||||
|
||||
public delete(id: number) {
|
||||
const sql = `DELETE FROM ${this.schema.name} WHERE id = ?`;
|
||||
return this.database.execute(sql, id);
|
||||
}
|
||||
|
||||
protected convertToModelType(intermediateModel: IntermediateModelType | undefined): ModelType {
|
||||
return intermediateModel as unknown as ModelType;
|
||||
}
|
||||
|
||||
protected convertToCreateObject(instance: Partial<ModelType>): object {
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue