Construct more reasonable syntax messages
This commit is contained in:
parent
3f852d068e
commit
c208204fa3
6 changed files with 135 additions and 43 deletions
|
|
@ -39,6 +39,7 @@ import com.intellectualsites.commands.meta.CommandMeta;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import javax.annotation.Nullable;
|
import javax.annotation.Nullable;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
|
@ -101,7 +102,10 @@ public final class CommandTree<C, M extends CommandMeta> {
|
||||||
public Optional<Command<C, M>> parse(@Nonnull final CommandContext<C> commandContext,
|
public Optional<Command<C, M>> parse(@Nonnull final CommandContext<C> commandContext,
|
||||||
@Nonnull final Queue<String> args) throws
|
@Nonnull final Queue<String> args) throws
|
||||||
NoSuchCommandException, NoPermissionException, InvalidSyntaxException {
|
NoSuchCommandException, NoPermissionException, InvalidSyntaxException {
|
||||||
final Optional<Command<C, M>> commandOptional = parseCommand(commandContext, args, this.internalTree);
|
final Optional<Command<C, M>> commandOptional = parseCommand(new ArrayList<>(),
|
||||||
|
commandContext,
|
||||||
|
args,
|
||||||
|
this.internalTree);
|
||||||
commandOptional.flatMap(Command::getSenderType).ifPresent(requiredType -> {
|
commandOptional.flatMap(Command::getSenderType).ifPresent(requiredType -> {
|
||||||
if (!requiredType.isAssignableFrom(commandContext.getSender().getClass())) {
|
if (!requiredType.isAssignableFrom(commandContext.getSender().getClass())) {
|
||||||
throw new InvalidCommandSenderException(commandContext.getSender(), requiredType, Collections.emptyList());
|
throw new InvalidCommandSenderException(commandContext.getSender(), requiredType, Collections.emptyList());
|
||||||
|
|
@ -110,7 +114,8 @@ public final class CommandTree<C, M extends CommandMeta> {
|
||||||
return commandOptional;
|
return commandOptional;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional<Command<C, M>> parseCommand(@Nonnull final CommandContext<C> commandContext,
|
private Optional<Command<C, M>> parseCommand(@Nonnull final List<CommandArgument<C, ?>> parsedArguments,
|
||||||
|
@Nonnull final CommandContext<C> commandContext,
|
||||||
@Nonnull final Queue<String> commandQueue,
|
@Nonnull final Queue<String> commandQueue,
|
||||||
@Nonnull final Node<CommandArgument<C, ?>> root) {
|
@Nonnull final Node<CommandArgument<C, ?>> root) {
|
||||||
String permission = this.isPermitted(commandContext.getSender(), root);
|
String permission = this.isPermitted(commandContext.getSender(), root);
|
||||||
|
|
@ -121,7 +126,10 @@ public final class CommandTree<C, M extends CommandMeta> {
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
|
|
||||||
final Optional<Command<C, M>> parsedChild = this.attemptParseUnambiguousChild(commandContext, root, commandQueue);
|
final Optional<Command<C, M>> parsedChild = this.attemptParseUnambiguousChild(parsedArguments,
|
||||||
|
commandContext,
|
||||||
|
root,
|
||||||
|
commandQueue);
|
||||||
// noinspection all
|
// noinspection all
|
||||||
if (parsedChild != null) {
|
if (parsedChild != null) {
|
||||||
return parsedChild;
|
return parsedChild;
|
||||||
|
|
@ -136,9 +144,7 @@ public final class CommandTree<C, M extends CommandMeta> {
|
||||||
} else {
|
} else {
|
||||||
/* Too many arguments. We have a unique path, so we can send the entire context */
|
/* Too many arguments. We have a unique path, so we can send the entire context */
|
||||||
throw new InvalidSyntaxException(this.commandManager.getCommandSyntaxFormatter()
|
throw new InvalidSyntaxException(this.commandManager.getCommandSyntaxFormatter()
|
||||||
.apply(root.getValue()
|
.apply(parsedArguments, root),
|
||||||
.getOwningCommand()
|
|
||||||
.getArguments()),
|
|
||||||
commandContext.getSender(), this.getChain(root)
|
commandContext.getSender(), this.getChain(root)
|
||||||
.stream()
|
.stream()
|
||||||
.map(Node::getValue)
|
.map(Node::getValue)
|
||||||
|
|
@ -147,10 +153,7 @@ public final class CommandTree<C, M extends CommandMeta> {
|
||||||
} else {
|
} else {
|
||||||
/* Too many arguments. We have a unique path, so we can send the entire context */
|
/* Too many arguments. We have a unique path, so we can send the entire context */
|
||||||
throw new InvalidSyntaxException(this.commandManager.getCommandSyntaxFormatter()
|
throw new InvalidSyntaxException(this.commandManager.getCommandSyntaxFormatter()
|
||||||
.apply(Objects.requireNonNull(
|
.apply(parsedArguments, root),
|
||||||
Objects.requireNonNull(root.getValue())
|
|
||||||
.getOwningCommand())
|
|
||||||
.getArguments()),
|
|
||||||
commandContext.getSender(), this.getChain(root)
|
commandContext.getSender(), this.getChain(root)
|
||||||
.stream()
|
.stream()
|
||||||
.map(Node::getValue)
|
.map(Node::getValue)
|
||||||
|
|
@ -164,21 +167,32 @@ public final class CommandTree<C, M extends CommandMeta> {
|
||||||
if (child.getValue() != null) {
|
if (child.getValue() != null) {
|
||||||
final ArgumentParseResult<?> result = child.getValue().getParser().parse(commandContext, commandQueue);
|
final ArgumentParseResult<?> result = child.getValue().getParser().parse(commandContext, commandQueue);
|
||||||
if (result.getParsedValue().isPresent()) {
|
if (result.getParsedValue().isPresent()) {
|
||||||
return this.parseCommand(commandContext, commandQueue, child);
|
parsedArguments.add(child.getValue());
|
||||||
|
return this.parseCommand(parsedArguments, commandContext, commandQueue, child);
|
||||||
} /*else if (result.getFailure().isPresent() && root.children.size() == 1) {
|
} /*else if (result.getFailure().isPresent() && root.children.size() == 1) {
|
||||||
}*/
|
}*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* We could not find a match */
|
/* We could not find a match */
|
||||||
throw new NoSuchCommandException(commandContext.getSender(),
|
if (root.equals(this.internalTree)) {
|
||||||
getChain(root).stream().map(Node::getValue).collect(Collectors.toList()),
|
throw new NoSuchCommandException(commandContext.getSender(),
|
||||||
stringOrEmpty(commandQueue.peek()));
|
getChain(root).stream().map(Node::getValue).collect(Collectors.toList()),
|
||||||
|
stringOrEmpty(commandQueue.peek()));
|
||||||
|
}
|
||||||
|
/* We have already traversed the tree */
|
||||||
|
throw new InvalidSyntaxException(this.commandManager.getCommandSyntaxFormatter()
|
||||||
|
.apply(parsedArguments, root),
|
||||||
|
commandContext.getSender(), this.getChain(root)
|
||||||
|
.stream()
|
||||||
|
.map(Node::getValue)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
private Optional<Command<C, M>> attemptParseUnambiguousChild(@Nonnull final CommandContext<C> commandContext,
|
private Optional<Command<C, M>> attemptParseUnambiguousChild(@Nonnull final List<CommandArgument<C, ?>> parsedArguments,
|
||||||
|
@Nonnull final CommandContext<C> commandContext,
|
||||||
@Nonnull final Node<CommandArgument<C, ?>> root,
|
@Nonnull final Node<CommandArgument<C, ?>> root,
|
||||||
@Nonnull final Queue<String> commandQueue) {
|
@Nonnull final Queue<String> commandQueue) {
|
||||||
String permission;
|
String permission;
|
||||||
|
|
@ -204,18 +218,25 @@ public final class CommandTree<C, M extends CommandMeta> {
|
||||||
throw new InvalidSyntaxException(this.commandManager.getCommandSyntaxFormatter()
|
throw new InvalidSyntaxException(this.commandManager.getCommandSyntaxFormatter()
|
||||||
.apply(Objects.requireNonNull(
|
.apply(Objects.requireNonNull(
|
||||||
child.getValue().getOwningCommand())
|
child.getValue().getOwningCommand())
|
||||||
.getArguments()),
|
.getArguments(), child),
|
||||||
commandContext.getSender(), this.getChain(root)
|
commandContext.getSender(), this.getChain(root)
|
||||||
.stream()
|
.stream()
|
||||||
.map(Node::getValue)
|
.map(Node::getValue)
|
||||||
.collect(Collectors.toList()));
|
.collect(Collectors.toList()));
|
||||||
} else {
|
} else {
|
||||||
|
/*
|
||||||
throw new NoSuchCommandException(commandContext.getSender(),
|
throw new NoSuchCommandException(commandContext.getSender(),
|
||||||
this.getChain(root)
|
this.getChain(root)
|
||||||
.stream()
|
.stream()
|
||||||
.map(Node::getValue)
|
.map(Node::getValue)
|
||||||
.collect(Collectors.toList()),
|
.collect(Collectors.toList()),
|
||||||
"");
|
"");*/
|
||||||
|
throw new InvalidSyntaxException(this.commandManager.getCommandSyntaxFormatter()
|
||||||
|
.apply(parsedArguments, root),
|
||||||
|
commandContext.getSender(), this.getChain(root)
|
||||||
|
.stream()
|
||||||
|
.map(Node::getValue)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final ArgumentParseResult<?> result = child.getValue().getParser().parse(commandContext, commandQueue);
|
final ArgumentParseResult<?> result = child.getValue().getParser().parse(commandContext, commandQueue);
|
||||||
|
|
@ -227,9 +248,7 @@ public final class CommandTree<C, M extends CommandMeta> {
|
||||||
} else {
|
} else {
|
||||||
/* Too many arguments. We have a unique path, so we can send the entire context */
|
/* Too many arguments. We have a unique path, so we can send the entire context */
|
||||||
throw new InvalidSyntaxException(this.commandManager.getCommandSyntaxFormatter()
|
throw new InvalidSyntaxException(this.commandManager.getCommandSyntaxFormatter()
|
||||||
.apply(Objects.requireNonNull(child.getValue()
|
.apply(parsedArguments, child),
|
||||||
.getOwningCommand())
|
|
||||||
.getArguments()),
|
|
||||||
commandContext.getSender(), this.getChain(root)
|
commandContext.getSender(), this.getChain(root)
|
||||||
.stream()
|
.stream()
|
||||||
.map(Node::getValue)
|
.map(Node::getValue)
|
||||||
|
|
@ -237,7 +256,8 @@ public final class CommandTree<C, M extends CommandMeta> {
|
||||||
Collectors.toList()));
|
Collectors.toList()));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return this.parseCommand(commandContext, commandQueue, child);
|
parsedArguments.add(child.getValue());
|
||||||
|
return this.parseCommand(parsedArguments, commandContext, commandQueue, child);
|
||||||
}
|
}
|
||||||
} else if (result.getFailure().isPresent()) {
|
} else if (result.getFailure().isPresent()) {
|
||||||
throw new ArgumentParseException(result.getFailure().get(), commandContext.getSender(),
|
throw new ArgumentParseException(result.getFailure().get(), commandContext.getSender(),
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,10 @@
|
||||||
//
|
//
|
||||||
package com.intellectualsites.commands.arguments;
|
package com.intellectualsites.commands.arguments;
|
||||||
|
|
||||||
|
import com.intellectualsites.commands.CommandTree;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.function.Function;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utility that formats chains of {@link CommandArgument command arguments} into syntax strings
|
* Utility that formats chains of {@link CommandArgument command arguments} into syntax strings
|
||||||
|
|
@ -33,10 +34,17 @@ import java.util.function.Function;
|
||||||
* @param <C> Command sender type
|
* @param <C> Command sender type
|
||||||
*/
|
*/
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
public interface CommandSyntaxFormatter<C> extends Function<List<CommandArgument<C, ?>>, String> {
|
public interface CommandSyntaxFormatter<C> {
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
|
* Format the command arguments into a syntax string
|
||||||
|
*
|
||||||
|
* @param commandArguments Command arguments
|
||||||
|
* @param node Trailing node
|
||||||
|
* @return Syntax string
|
||||||
|
*/
|
||||||
@Nonnull
|
@Nonnull
|
||||||
String apply(@Nonnull List<CommandArgument<C, ?>> commandArguments);
|
String apply(@Nonnull List<CommandArgument<C, ?>> commandArguments,
|
||||||
|
@Nonnull CommandTree.Node<CommandArgument<C, ?>> node);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@
|
||||||
//
|
//
|
||||||
package com.intellectualsites.commands.arguments;
|
package com.intellectualsites.commands.arguments;
|
||||||
|
|
||||||
|
import com.intellectualsites.commands.CommandTree;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -41,7 +43,8 @@ public class StandardCommandSyntaxFormatter<C> implements CommandSyntaxFormatter
|
||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
@Override
|
@Override
|
||||||
public final String apply(@Nonnull final List<CommandArgument<C, ?>> commandArguments) {
|
public final String apply(@Nonnull final List<CommandArgument<C, ?>> commandArguments,
|
||||||
|
@Nonnull final CommandTree.Node<CommandArgument<C, ?>> node) {
|
||||||
final StringBuilder stringBuilder = new StringBuilder();
|
final StringBuilder stringBuilder = new StringBuilder();
|
||||||
final Iterator<CommandArgument<C, ?>> iterator = commandArguments.iterator();
|
final Iterator<CommandArgument<C, ?>> iterator = commandArguments.iterator();
|
||||||
while (iterator.hasNext()) {
|
while (iterator.hasNext()) {
|
||||||
|
|
@ -59,6 +62,27 @@ public class StandardCommandSyntaxFormatter<C> implements CommandSyntaxFormatter
|
||||||
stringBuilder.append(" ");
|
stringBuilder.append(" ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CommandTree.Node<CommandArgument<C, ?>> tail = node;
|
||||||
|
while (tail != null && !tail.isLeaf()) {
|
||||||
|
if (tail.getChildren().size() > 1) {
|
||||||
|
stringBuilder.append(" ");
|
||||||
|
final Iterator<CommandTree.Node<CommandArgument<C, ?>>> childIterator = tail.getChildren().iterator();
|
||||||
|
while (childIterator.hasNext()) {
|
||||||
|
final CommandTree.Node<CommandArgument<C, ?>> child = childIterator.next();
|
||||||
|
stringBuilder.append(child.getValue().getName());
|
||||||
|
if (childIterator.hasNext()) {
|
||||||
|
stringBuilder.append("|");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
final CommandArgument<C, ?> argument = tail.getChildren().get(0).getValue();
|
||||||
|
stringBuilder.append(" ")
|
||||||
|
.append(argument.isRequired() ? '<' : '[')
|
||||||
|
.append(argument.getName())
|
||||||
|
.append(argument.isRequired() ? '>' : ']');
|
||||||
|
tail = tail.getChildren().get(0);
|
||||||
|
}
|
||||||
return stringBuilder.toString();
|
return stringBuilder.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,14 +66,4 @@ public class InvalidSyntaxException extends CommandParseException {
|
||||||
return String.format("Invalid command syntax. Correct syntax is: %s", this.correctSyntax);
|
return String.format("Invalid command syntax. Correct syntax is: %s", this.correctSyntax);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public final synchronized Throwable fillInStackTrace() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public final synchronized Throwable initCause(final Throwable cause) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,12 @@ class CommandTreeTest {
|
||||||
manager.executeCommand(new TestCommandSender(), "default 5").join();
|
manager.executeCommand(new TestCommandSender(), "default 5").join();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidCommand() {
|
||||||
|
Assertions.assertThrows(CompletionException.class, () -> manager
|
||||||
|
.executeCommand(new TestCommandSender(), "invalid test").join());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public static final class SpecificCommandSender extends TestCommandSender {
|
public static final class SpecificCommandSender extends TestCommandSender {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,28 @@ package com.intellectualsites.commands;
|
||||||
|
|
||||||
import com.intellectualsites.commands.arguments.CommandArgument;
|
import com.intellectualsites.commands.arguments.CommandArgument;
|
||||||
import com.intellectualsites.commands.arguments.StaticArgument;
|
import com.intellectualsites.commands.arguments.StaticArgument;
|
||||||
|
import com.intellectualsites.commands.exceptions.ArgumentParseException;
|
||||||
|
import com.intellectualsites.commands.exceptions.InvalidCommandSenderException;
|
||||||
|
import com.intellectualsites.commands.exceptions.InvalidSyntaxException;
|
||||||
|
import com.intellectualsites.commands.exceptions.NoPermissionException;
|
||||||
|
import com.intellectualsites.commands.exceptions.NoSuchCommandException;
|
||||||
import org.bukkit.ChatColor;
|
import org.bukkit.ChatColor;
|
||||||
import org.bukkit.command.CommandSender;
|
import org.bukkit.command.CommandSender;
|
||||||
import org.bukkit.command.PluginIdentifiableCommand;
|
import org.bukkit.command.PluginIdentifiableCommand;
|
||||||
import org.bukkit.plugin.Plugin;
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
import javax.annotation.Nonnull;
|
import javax.annotation.Nonnull;
|
||||||
|
import javax.annotation.Nullable;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
final class BukkitCommand<C> extends org.bukkit.command.Command implements PluginIdentifiableCommand {
|
final class BukkitCommand<C> extends org.bukkit.command.Command implements PluginIdentifiableCommand {
|
||||||
|
|
||||||
|
private static final String MESSAGE_NO_PERMS = ChatColor.RED
|
||||||
|
+ "I'm sorry, but you do not have permission to perform this command. "
|
||||||
|
+ "Please contact the server administrators if you believe that this is in error.";
|
||||||
|
private static final String MESSAGE_UNKNOWN_COMMAND = "Unknown command. Type \"/help\" for help.";
|
||||||
|
|
||||||
private final CommandArgument<C, ?> command;
|
private final CommandArgument<C, ?> command;
|
||||||
private final BukkitCommandManager<C> bukkitCommandManager;
|
private final BukkitCommandManager<C> bukkitCommandManager;
|
||||||
private final com.intellectualsites.commands.Command<C, BukkitCommandMeta> cloudCommand;
|
private final com.intellectualsites.commands.Command<C, BukkitCommandMeta> cloudCommand;
|
||||||
|
|
@ -63,13 +75,24 @@ final class BukkitCommand<C> extends org.bukkit.command.Command implements Plugi
|
||||||
builder.toString())
|
builder.toString())
|
||||||
.whenComplete(((commandResult, throwable) -> {
|
.whenComplete(((commandResult, throwable) -> {
|
||||||
if (throwable != null) {
|
if (throwable != null) {
|
||||||
commandSender.sendMessage(ChatColor.RED + throwable.getMessage());
|
if (throwable instanceof InvalidSyntaxException) {
|
||||||
commandSender.sendMessage(ChatColor.RED + throwable.getCause().getMessage());
|
commandSender.sendMessage(ChatColor.RED + "Invalid Command Syntax. "
|
||||||
throwable.printStackTrace();
|
+ "Correct command syntax is: "
|
||||||
throwable.getCause().printStackTrace();
|
+ ChatColor.GRAY + "/"
|
||||||
} else {
|
+ ((InvalidSyntaxException) throwable).getCorrectSyntax());
|
||||||
// Do something...
|
} else if (throwable instanceof InvalidCommandSenderException) {
|
||||||
commandSender.sendMessage("All good!");
|
commandSender.sendMessage(ChatColor.RED + throwable.getMessage());
|
||||||
|
} else if (throwable instanceof NoPermissionException) {
|
||||||
|
commandSender.sendMessage(MESSAGE_NO_PERMS);
|
||||||
|
} else if (throwable instanceof NoSuchCommandException) {
|
||||||
|
commandSender.sendMessage(MESSAGE_UNKNOWN_COMMAND);
|
||||||
|
} else if (throwable instanceof ArgumentParseException) {
|
||||||
|
commandSender.sendMessage(ChatColor.RED + "Invalid Command Argument: "
|
||||||
|
+ ChatColor.GRAY + throwable.getCause().getMessage());
|
||||||
|
} else {
|
||||||
|
commandSender.sendMessage(throwable.getMessage());
|
||||||
|
throwable.printStackTrace();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -101,4 +124,25 @@ final class BukkitCommand<C> extends org.bukkit.command.Command implements Plugi
|
||||||
return this.cloudCommand.getCommandPermission();
|
return this.cloudCommand.getCommandPermission();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private <E extends Throwable> E captureException(@Nonnull final Class<E> clazz, @Nullable final Throwable throwable) {
|
||||||
|
if (throwable == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (clazz.equals(throwable.getClass())) {
|
||||||
|
//noinspection unchecked
|
||||||
|
return (E) throwable;
|
||||||
|
}
|
||||||
|
return captureException(clazz, throwable.getCause());
|
||||||
|
}
|
||||||
|
|
||||||
|
private <E extends Throwable> boolean handleException(@Nullable final E throwable,
|
||||||
|
@Nonnull final Consumer<E> consumer) {
|
||||||
|
if (throwable == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
consumer.accept(throwable);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue