mirror of
https://github.com/brmlab/wekan-mailer.git
synced 2025-06-07 17:34:12 +02:00
Merge branch 'feature/mailConfig' into develop
This commit is contained in:
commit
6e8f27df40
12 changed files with 284 additions and 66 deletions
|
@ -6,8 +6,14 @@ services:
|
|||
container_name: wekan-mailer
|
||||
restart: always
|
||||
environment:
|
||||
- WEKAN_URL=http://localhost:3000
|
||||
- WEKAN_USER=someuser
|
||||
- WEKAN_PASSWORD=somepass
|
||||
- WEKAN_TARGET_BOARD=someId
|
||||
- WEKAN_TARGET_LIST=someListId
|
||||
- WEKAN_URL=http://localhost:3000
|
||||
- WEKAN_USER=someuser
|
||||
- WEKAN_PASS=somepass
|
||||
- WEKAN_TARGET_BOARD=someId
|
||||
- WEKAN_TARGET_LIST=someListId
|
||||
- MAIL_PROTOCOL=imap
|
||||
- MAIL_URL=imap.server.somewhere
|
||||
- MAIL_PORT=993
|
||||
- MAIL_USER=mailuser
|
||||
- MAIL_PASS=mailpass
|
||||
- MAIL_FOLDER=inbox
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
package cz.brmlab.wm;
|
||||
|
||||
import cz.brmlab.wm.utils.Exceptions.BrmException;
|
||||
import cz.brmlab.wm.wekan.WekanConfiguration;
|
||||
import cz.brmlab.wm.config.MailConfiguration;
|
||||
import cz.brmlab.wm.config.WekanConfiguration;
|
||||
import cz.brmlab.wm.utils.exceptions.BrmException;
|
||||
import cz.brmlab.wm.wekan.pojo.card.PostCardResponse;
|
||||
import cz.brmlab.wm.wekan.rest.CardPost;
|
||||
import cz.brmlab.wm.wekan.rest.LoginPost;
|
||||
|
@ -28,6 +29,7 @@ public class Application implements CommandLineRunner {
|
|||
public void run(String... args) {
|
||||
try {
|
||||
WekanConfiguration wekanConfiguration = new WekanConfiguration();
|
||||
MailConfiguration mailConfiguration = new MailConfiguration();
|
||||
|
||||
LoginPost loginPost = new LoginPost(wekanConfiguration);
|
||||
loginPost.login();
|
||||
|
@ -37,6 +39,7 @@ public class Application implements CommandLineRunner {
|
|||
|
||||
} catch (BrmException ex) {
|
||||
log.error("Error {} encountered, shutting down!", ex.getExitCode());
|
||||
log.error("Error message is: {}", ex.getMessage());
|
||||
System.exit(ex.getExitCode().getCode());
|
||||
}
|
||||
}
|
||||
|
|
25
src/main/java/cz/brmlab/wm/config/EnvConfig.java
Normal file
25
src/main/java/cz/brmlab/wm/config/EnvConfig.java
Normal file
|
@ -0,0 +1,25 @@
|
|||
package cz.brmlab.wm.config;
|
||||
|
||||
import cz.brmlab.wm.utils.exceptions.BrmException;
|
||||
import cz.brmlab.wm.utils.exceptions.ExitCode;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
interface EnvConfig {
|
||||
|
||||
/**
|
||||
* Checks if environment variables does contains keys from provided list of keys.
|
||||
*
|
||||
* @param props List of required environment properties.
|
||||
* @throws BrmException if required property key is missing in environment variables.
|
||||
*/
|
||||
default void checkProps(List<String> props) throws BrmException {
|
||||
for (String prop : props) {
|
||||
if (System.getenv(prop) == null) {
|
||||
String message = ExitCode.CONFIGURATION_MISSING.getReason() + prop;
|
||||
throw new BrmException(message, ExitCode.CONFIGURATION_MISSING);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
72
src/main/java/cz/brmlab/wm/config/MailConfiguration.java
Normal file
72
src/main/java/cz/brmlab/wm/config/MailConfiguration.java
Normal file
|
@ -0,0 +1,72 @@
|
|||
package cz.brmlab.wm.config;
|
||||
|
||||
import cz.brmlab.wm.utils.exceptions.BrmException;
|
||||
import cz.brmlab.wm.utils.exceptions.ExitCode;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class MailConfiguration implements EnvConfig {
|
||||
|
||||
//ENV variables for mail
|
||||
private static final String MAIL_PROTOCOL = "MAIL_PROTOCOL";
|
||||
private static final String MAIL_URL = "MAIL_URL";
|
||||
private static final String MAIL_PORT = "MAIL_PORT";
|
||||
private static final String MAIL_USER = "MAIL_USER";
|
||||
private static final String MAIL_PASS = "MAIL_PASS";
|
||||
private static final String MAIL_FOLDER = "MAIL_FOLDER";
|
||||
|
||||
//List of mail ENV vars
|
||||
private static final List<String> properties = new ArrayList<>(Arrays.asList(MAIL_PROTOCOL, MAIL_URL, MAIL_PORT, MAIL_USER, MAIL_PASS, MAIL_FOLDER));
|
||||
|
||||
@Getter
|
||||
private String mailProtocol;
|
||||
|
||||
@Getter
|
||||
private String mailUrl;
|
||||
|
||||
@Getter
|
||||
private String mailUser;
|
||||
|
||||
@Getter
|
||||
private String mailPassword;
|
||||
|
||||
@Getter
|
||||
private String mailFolder;
|
||||
|
||||
@Getter
|
||||
private String mailPort;
|
||||
|
||||
/**
|
||||
* Configuration for mail. Taken from the container ENV variables.
|
||||
*
|
||||
* @throws BrmException if some of the properties is missing in ENV variables.
|
||||
*/
|
||||
public MailConfiguration() throws BrmException {
|
||||
log.trace("{}() - start.", this.getClass().getSimpleName());
|
||||
|
||||
checkProps(properties);
|
||||
|
||||
this.mailProtocol = System.getenv(MAIL_PROTOCOL);
|
||||
this.mailUrl = System.getenv(MAIL_URL);
|
||||
this.mailPort = System.getenv(MAIL_PORT);
|
||||
this.mailUser = System.getenv(MAIL_USER);
|
||||
this.mailPassword = System.getenv(MAIL_PASS);
|
||||
this.mailFolder = System.getenv(MAIL_FOLDER);
|
||||
|
||||
checkProtocol();
|
||||
|
||||
log.info("Mail config loaded successfully.");
|
||||
}
|
||||
|
||||
private void checkProtocol() throws BrmException {
|
||||
if (!this.mailProtocol.equalsIgnoreCase("imap")){
|
||||
throw new BrmException(ExitCode.UNSUPPORTED_PROTOCOL.getReason(), ExitCode.UNSUPPORTED_PROTOCOL);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,7 +1,6 @@
|
|||
package cz.brmlab.wm.wekan;
|
||||
package cz.brmlab.wm.config;
|
||||
|
||||
import cz.brmlab.wm.utils.Exceptions.BrmException;
|
||||
import cz.brmlab.wm.utils.Exceptions.ExitCode;
|
||||
import cz.brmlab.wm.utils.exceptions.BrmException;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
|
@ -10,37 +9,17 @@ import java.util.Arrays;
|
|||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
public class WekanConfiguration {
|
||||
public class WekanConfiguration implements EnvConfig {
|
||||
|
||||
//ENV variables for wekan
|
||||
private static final String WEKAN_URL = "WEKAN_URL";
|
||||
private static final String WEKAN_USER = "WEKAN_USER";
|
||||
private static final String WEKAN_PASSWORD = "WEKAN_PASSWORD";
|
||||
private static final String WEKAN_PASS = "WEKAN_PASS";
|
||||
private static final String WEKAN_TARGET_BOARD = "WEKAN_TARGET_BOARD";
|
||||
private static final String WEKAN_TARGET_LIST = "WEKAN_TARGET_LIST";
|
||||
|
||||
//List of wekan ENV vars
|
||||
private static final List<String> properties = new ArrayList<>(Arrays.asList(WEKAN_URL, WEKAN_USER, WEKAN_PASSWORD, WEKAN_TARGET_BOARD, WEKAN_TARGET_LIST));
|
||||
|
||||
/**
|
||||
* Configuration for wekan. Taken from the container ENV variables.
|
||||
*
|
||||
* @throws BrmException if some of the properties is missing in ENV variables.
|
||||
*/
|
||||
public WekanConfiguration() throws BrmException {
|
||||
log.trace("{}() - start.", this.getClass().getSimpleName());
|
||||
|
||||
for (String prop : properties) {
|
||||
checkProp(prop);
|
||||
}
|
||||
this.wekanUrl = System.getenv(WEKAN_URL);
|
||||
this.wekanUser = System.getenv(WEKAN_USER);
|
||||
this.wekanPassword = System.getenv(WEKAN_PASSWORD);
|
||||
this.wekanBoard = System.getenv(WEKAN_TARGET_BOARD);
|
||||
this.wekanList = System.getenv(WEKAN_TARGET_LIST);
|
||||
|
||||
log.info("Wekan config loaded successfully.");
|
||||
}
|
||||
private static final List<String> properties = new ArrayList<>(Arrays.asList(WEKAN_URL, WEKAN_USER, WEKAN_PASS, WEKAN_TARGET_BOARD, WEKAN_TARGET_LIST));
|
||||
|
||||
@Getter
|
||||
private String wekanUrl;
|
||||
|
@ -57,13 +36,23 @@ public class WekanConfiguration {
|
|||
@Getter
|
||||
private String wekanList;
|
||||
|
||||
private void checkProp(String prop) throws BrmException {
|
||||
log.trace("checkProp({}) - start.", prop);
|
||||
/**
|
||||
* Configuration for wekan. Taken from the container ENV variables.
|
||||
*
|
||||
* @throws BrmException if some of the properties is missing in ENV variables.
|
||||
*/
|
||||
public WekanConfiguration() throws BrmException {
|
||||
log.trace("{}() - start.", this.getClass().getSimpleName());
|
||||
|
||||
if (System.getenv(prop) == null) {
|
||||
String message = ExitCode.CONFIGURATION_MISSING.getReason() + prop;
|
||||
log.error(message, ExitCode.CONFIGURATION_MISSING);
|
||||
throw new BrmException(message, ExitCode.CONFIGURATION_MISSING);
|
||||
}
|
||||
checkProps(properties);
|
||||
|
||||
this.wekanUrl = System.getenv(WEKAN_URL);
|
||||
this.wekanUser = System.getenv(WEKAN_USER);
|
||||
this.wekanPassword = System.getenv(WEKAN_PASS);
|
||||
this.wekanBoard = System.getenv(WEKAN_TARGET_BOARD);
|
||||
this.wekanList = System.getenv(WEKAN_TARGET_LIST);
|
||||
|
||||
log.info("Wekan config loaded successfully.");
|
||||
}
|
||||
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package cz.brmlab.wm.utils.Exceptions;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class BrmException extends Exception {
|
||||
private final String message;
|
||||
private final ExitCode exitCode;
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package cz.brmlab.wm.utils.exceptions;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NonNull;
|
||||
|
||||
@AllArgsConstructor
|
||||
public class BrmException extends Exception {
|
||||
@Getter
|
||||
@NonNull
|
||||
private final String message;
|
||||
@Getter
|
||||
@NonNull
|
||||
private final ExitCode exitCode;
|
||||
}
|
|
@ -1,10 +1,11 @@
|
|||
package cz.brmlab.wm.utils.Exceptions;
|
||||
package cz.brmlab.wm.utils.exceptions;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
public enum ExitCode {
|
||||
|
||||
CONFIGURATION_MISSING(10, "Missing configuration property: "),
|
||||
UNSUPPORTED_PROTOCOL(15, "Unsupported email protocol specified! IMAP only is currently supported."),
|
||||
POST_ERROR(20, "Failed POST request, RC: ");
|
||||
|
||||
@Getter
|
|
@ -1,8 +1,8 @@
|
|||
package cz.brmlab.wm.wekan.rest;
|
||||
|
||||
import cz.brmlab.wm.utils.Exceptions.BrmException;
|
||||
import cz.brmlab.wm.utils.Exceptions.ExitCode;
|
||||
import cz.brmlab.wm.wekan.WekanConfiguration;
|
||||
import cz.brmlab.wm.utils.exceptions.BrmException;
|
||||
import cz.brmlab.wm.utils.exceptions.ExitCode;
|
||||
import cz.brmlab.wm.config.WekanConfiguration;
|
||||
import cz.brmlab.wm.wekan.pojo.card.PostCardRequest;
|
||||
import cz.brmlab.wm.wekan.pojo.card.PostCardResponse;
|
||||
import cz.brmlab.wm.wekan.pojo.login.LoginToken;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package cz.brmlab.wm.wekan.rest;
|
||||
|
||||
import cz.brmlab.wm.utils.LogMarker.LogMarker;
|
||||
import cz.brmlab.wm.wekan.WekanConfiguration;
|
||||
import cz.brmlab.wm.config.WekanConfiguration;
|
||||
import cz.brmlab.wm.wekan.pojo.login.LoginRequest;
|
||||
import cz.brmlab.wm.wekan.pojo.login.LoginToken;
|
||||
import lombok.Getter;
|
||||
|
|
116
src/test/java/cz/brmlab/wm/config/MailConfigurationTest.java
Normal file
116
src/test/java/cz/brmlab/wm/config/MailConfigurationTest.java
Normal file
|
@ -0,0 +1,116 @@
|
|||
package cz.brmlab.wm.config;
|
||||
|
||||
import cz.brmlab.wm.utils.exceptions.BrmException;
|
||||
import cz.brmlab.wm.utils.exceptions.ExitCode;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.contrib.java.lang.system.EnvironmentVariables;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class MailConfigurationTest {
|
||||
|
||||
@Rule
|
||||
public final EnvironmentVariables environmentVariables
|
||||
= new EnvironmentVariables();
|
||||
|
||||
private static final String MAIL_PROTOCOL = "MAIL_PROTOCOL";
|
||||
private static final String MAIL_PROTOCOL_VALUE = "imap";
|
||||
|
||||
private static final String MAIL_URL = "MAIL_URL";
|
||||
private static final String MAIL_URL_VALUE = "mail.test.url";
|
||||
|
||||
private static final String MAIL_PORT = "MAIL_PORT";
|
||||
private static final String MAIL_PORT_VALUE = "993";
|
||||
|
||||
private static final String MAIL_USER = "MAIL_USER";
|
||||
private static final String MAIL_USER_VALUE = "someuser";
|
||||
|
||||
private static final String MAIL_PASS = "MAIL_PASS";
|
||||
private static final String MAIL_PASS_VALUE = "somepass";
|
||||
|
||||
private static final String MAIL_FOLDER = "MAIL_FOLDER";
|
||||
private static final String MAIL_FOLDER_VALUE = "inbox";
|
||||
|
||||
|
||||
@Before
|
||||
public void setEnvironmentVariables() {
|
||||
environmentVariables.set(MAIL_PROTOCOL, MAIL_PROTOCOL_VALUE);
|
||||
environmentVariables.set(MAIL_URL, MAIL_URL_VALUE);
|
||||
environmentVariables.set(MAIL_PORT, MAIL_PORT_VALUE);
|
||||
environmentVariables.set(MAIL_USER, MAIL_USER_VALUE);
|
||||
environmentVariables.set(MAIL_PASS, MAIL_PASS_VALUE);
|
||||
environmentVariables.set(MAIL_FOLDER, MAIL_FOLDER_VALUE);
|
||||
}
|
||||
|
||||
@After
|
||||
public void cleanEnvVars() {
|
||||
environmentVariables.clear(MAIL_PROTOCOL, MAIL_URL, MAIL_USER, MAIL_PASS, MAIL_FOLDER);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configurationOk() {
|
||||
|
||||
MailConfiguration configuration = null;
|
||||
try {
|
||||
configuration = new MailConfiguration();
|
||||
} catch (BrmException e) {
|
||||
fail("OK configuration should not throw an error!");
|
||||
}
|
||||
assertEquals(MAIL_PROTOCOL_VALUE, configuration.getMailProtocol());
|
||||
assertEquals(MAIL_URL_VALUE, configuration.getMailUrl());
|
||||
assertEquals(MAIL_USER_VALUE, configuration.getMailUser());
|
||||
assertEquals(MAIL_PASS_VALUE, configuration.getMailPassword());
|
||||
assertEquals(MAIL_FOLDER_VALUE, configuration.getMailFolder());
|
||||
assertEquals(MAIL_PORT_VALUE, configuration.getMailPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allConfigurationMissing() {
|
||||
cleanEnvVars();
|
||||
MailConfiguration configuration = null;
|
||||
try {
|
||||
configuration = new MailConfiguration();
|
||||
fail("Missing whole configuration should throw an error!");
|
||||
} catch (BrmException ignored) {
|
||||
|
||||
}
|
||||
|
||||
assertNull(null, configuration);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingOneProp() {
|
||||
|
||||
environmentVariables.clear(MAIL_PASS);
|
||||
|
||||
MailConfiguration configuration = null;
|
||||
try {
|
||||
configuration = new MailConfiguration();
|
||||
fail("Missing one property in configuration should throw an error!");
|
||||
} catch (BrmException ex) {
|
||||
assertEquals(ExitCode.CONFIGURATION_MISSING.getReason() + MAIL_PASS, ex.getMessage());
|
||||
}
|
||||
|
||||
assertNull(null, configuration);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unsupportedProtocol() {
|
||||
environmentVariables.set(MAIL_PROTOCOL, "POP3");
|
||||
|
||||
MailConfiguration configuration = null;
|
||||
try {
|
||||
configuration = new MailConfiguration();
|
||||
fail("Unsupported protocol should throw an error!");
|
||||
} catch (BrmException ex) {
|
||||
assertEquals(ExitCode.UNSUPPORTED_PROTOCOL.getReason(), ex.getMessage());
|
||||
}
|
||||
|
||||
assertNull(null, configuration);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package cz.brmlab.wm.wekan;
|
||||
package cz.brmlab.wm.config;
|
||||
|
||||
import cz.brmlab.wm.utils.Exceptions.BrmException;
|
||||
import cz.brmlab.wm.utils.Exceptions.ExitCode;
|
||||
import cz.brmlab.wm.utils.exceptions.BrmException;
|
||||
import cz.brmlab.wm.utils.exceptions.ExitCode;
|
||||
import org.junit.After;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
|
@ -21,8 +21,8 @@ public class WekanConfigurationTest {
|
|||
private static final String WEKAN_USER = "WEKAN_USER";
|
||||
private static final String WEKAN_USER_VALUE = "someuser";
|
||||
|
||||
private static final String WEKAN_PASSWORD = "WEKAN_PASSWORD";
|
||||
private static final String WEKAN_PASSWORD_VALUE = "somepass";
|
||||
private static final String WEKAN_PASS = "WEKAN_PASS";
|
||||
private static final String WEKAN_PASS_VALUE = "somepass";
|
||||
|
||||
private static final String WEKAN_TARGET_BOARD = "WEKAN_TARGET_BOARD";
|
||||
private static final String WEKAN_TARGET_BOARD_VALUE = "someboardif";
|
||||
|
@ -33,14 +33,14 @@ public class WekanConfigurationTest {
|
|||
|
||||
@After
|
||||
public void cleanEnvVars() {
|
||||
environmentVariables.clear(WEKAN_URL, WEKAN_USER, WEKAN_PASSWORD, WEKAN_TARGET_BOARD, WEKAN_TARGET_LIST);
|
||||
environmentVariables.clear(WEKAN_URL, WEKAN_USER, WEKAN_PASS, WEKAN_TARGET_BOARD, WEKAN_TARGET_LIST);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configurationOk() {
|
||||
environmentVariables.set(WEKAN_URL, WEKAN_URL_VALUE);
|
||||
environmentVariables.set(WEKAN_USER, WEKAN_USER_VALUE);
|
||||
environmentVariables.set(WEKAN_PASSWORD, WEKAN_PASSWORD_VALUE);
|
||||
environmentVariables.set(WEKAN_PASS, WEKAN_PASS_VALUE);
|
||||
environmentVariables.set(WEKAN_TARGET_BOARD, WEKAN_TARGET_BOARD_VALUE);
|
||||
environmentVariables.set(WEKAN_TARGET_LIST, WEKAN_TARGET_LIST_VALUE);
|
||||
|
||||
|
@ -53,7 +53,7 @@ public class WekanConfigurationTest {
|
|||
}
|
||||
assertEquals(WEKAN_URL_VALUE, configuration.getWekanUrl());
|
||||
assertEquals(WEKAN_USER_VALUE, configuration.getWekanUser());
|
||||
assertEquals(WEKAN_PASSWORD_VALUE, configuration.getWekanPassword());
|
||||
assertEquals(WEKAN_PASS_VALUE, configuration.getWekanPassword());
|
||||
assertEquals(WEKAN_TARGET_BOARD_VALUE, configuration.getWekanBoard());
|
||||
assertEquals(WEKAN_TARGET_LIST_VALUE, configuration.getWekanList());
|
||||
}
|
||||
|
@ -63,7 +63,7 @@ public class WekanConfigurationTest {
|
|||
WekanConfiguration configuration = null;
|
||||
try {
|
||||
configuration = new WekanConfiguration();
|
||||
fail("Missing whole configuration should not throw an error!");
|
||||
fail("Missing whole configuration should throw an error!");
|
||||
} catch (BrmException ignored) {
|
||||
|
||||
}
|
||||
|
@ -75,16 +75,16 @@ public class WekanConfigurationTest {
|
|||
|
||||
environmentVariables.set(WEKAN_URL, WEKAN_URL_VALUE);
|
||||
environmentVariables.set(WEKAN_USER, WEKAN_USER_VALUE);
|
||||
//environmentVariables.set(WEKAN_PASSWORD, WEKAN_PASSWORD_VALUE);
|
||||
//environmentVariables.set(WEKAN_PASS, WEKAN_PASS_VALUE);
|
||||
environmentVariables.set(WEKAN_TARGET_BOARD, WEKAN_TARGET_BOARD_VALUE);
|
||||
environmentVariables.set(WEKAN_TARGET_LIST, WEKAN_TARGET_LIST_VALUE);
|
||||
|
||||
WekanConfiguration configuration = null;
|
||||
try {
|
||||
configuration = new WekanConfiguration();
|
||||
fail("Missing one property in configuration should not throw an error!");
|
||||
fail("Missing one property in configuration should throw an error!");
|
||||
} catch (BrmException ex) {
|
||||
assertEquals(ExitCode.CONFIGURATION_MISSING.getReason() + WEKAN_PASSWORD, ex.getMessage());
|
||||
assertEquals(ExitCode.CONFIGURATION_MISSING.getReason() + WEKAN_PASS, ex.getMessage());
|
||||
}
|
||||
assertNull(null, configuration);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue