package envoy.client.data.commands; import java.util.*; import java.util.function.Consumer; import java.util.logging.*; import java.util.regex.Pattern; import java.util.stream.Collectors; import envoy.util.EnvoyLog; /** * This class stores all {@link SystemCommand}s used. *

* Project: envoy-client
* File: SystemCommandsMap.java
* Created: 17.07.2020
* * @author Leon Hofmeister * @since Envoy Client v0.2-beta */ public final class SystemCommandsMap { private final Map systemCommands = new HashMap<>(); private final Pattern commandPattern = Pattern.compile("^[a-zA-Z0-9_:!\\(\\)\\?\\.\\,\\;\\-]+$"); private static final Logger logger = EnvoyLog.getLogger(SystemCommandsMap.class); /** * Adds a new command to the map if the command name is valid. * * @param command the input string to execute the * given action * @param systemCommand the command to add - can be built using * {@link SystemCommandBuilder} * @see SystemCommandsMap#isValidKey(String) * @since Envoy Client v0.2-beta */ public void add(String command, SystemCommand systemCommand) { if (isValidKey(command)) systemCommands.put(command.toLowerCase(), systemCommand); } /** * This method checks if the input String is a key in the map and returns the * wrapped System command if present. * It will return an empty optional if the value after the slash is not a key in * the map, which is a valid case (i.e. input="3/4" and "4" is not a key in the * map). *

* Usage example:
* {@code SystemCommandsMap systemCommands = new SystemCommandsMap();}
* {@code Button button = new Button();} * {@code systemCommands.add("example", text -> button.setText(text.get(0), 1);}
* {@code ....}
* user input: {@code "/example xyz ..."}
* {@code systemCommands.get("example xyz ...")} or * {@code systemCommands.get("/example xyz ...")} * result: {@code Optional} * * @param input the input string given by the user * @return the wrapped system command, if present * @since Envoy Client v0.2-beta */ public Optional get(String input) { return Optional.ofNullable(systemCommands.get(getCommand(input.toLowerCase()))); } /** * This method ensures that the "/" of a {@link SystemCommand} is stripped.
* It returns the command as (most likely) entered as key in the map for the * first word of the text.
* It should only be called on strings that contain a "/" at position 0/-1. * * @param raw the input * @return the command as entered in the map * @since Envoy Client v0.2-beta * @apiNote this method will (most likely) not return anything useful if * whatever is entered after the slash is not a system command. Only * exception: for recommendation purposes. */ public String getCommand(String raw) { final var trimmed = raw.stripLeading(); final var index = trimmed.indexOf(' '); return trimmed.substring(trimmed.charAt(0) == '/' ? 1 : 0, index < 1 ? trimmed.length() : index); } /** * Examines whether a key can be put in the map and logs it with * {@code Level.WARNING} if that key violates API constrictions.
* (allowed chars are a-zA-Z0-9_:!()?.,;-) *

* The approach to not throw an exception was taken so that an ugly try-catch * block for every addition to the system commands map could be avoided, an * error that should only occur during implementation and not in production. * * @param command the key to examine * @return whether this key can be used in the map * @since Envoy Client v0.2-beta */ public boolean isValidKey(String command) { final boolean valid = commandPattern.matcher(command).matches(); if (!valid) logger.log(Level.WARNING, "The command \"" + command + "\" is not valid. As it will cause problems in execution, it will not be entered into the map. Only the characters " + commandPattern + "are allowed"); return valid; } /** * Takes a 'raw' string (the whole input) and checks if "/" is the first visible * character and then checks if a command is present after that "/". If that is * the case, it will be executed. *

* * @param raw the raw input string * @return whether a command could be found * @since Envoy Client v0.2-beta */ public boolean executeIfAnyPresent(String raw) { // possibly a command was detected and could be executed final var raw2 = raw.stripLeading(); final var commandFound = raw2.startsWith("/") ? executeIfPresent(raw2) : false; // the command was executed successfully - no further checking needed if (commandFound) logger.log(Level.FINE, "executed system command " + getCommand(raw2)); return commandFound; } /** * This method checks if the input String is a key in the map and executes the * wrapped System command if present. * Its intended usage is after a "/" has been detected in the input String. * It will do nothing if the value after the slash is not a key in * the map, which is a valid case (i.e. input="3/4" and "4" is not a key in the * map). *

* Usage example:
* {@code SystemCommandsMap systemCommands = new SystemCommandsMap();}
* {@code Button button = new Button();}
* {@code systemCommands.add("example", (words)-> button.setText(words.get(0), 1);}
* {@code ....}
* user input: {@code "/example xyz ..."}
* {@code systemCommands.executeIfPresent("example xyz ...")} * result: {@code button.getText()=="xyz"} * * @param input the input string given by the user * @return whether a command could be found * @since Envoy Client v0.2-beta */ public boolean executeIfPresent(String input) { final var command = getCommand(input); final var value = get(command); value.ifPresent(systemCommand -> { // Splitting the String so that the leading command including the first " " is // removed and only as many following words as allowed by the system command // persist final var arguments = extractArguments(input, systemCommand); // Executing the function try { systemCommand.getAction().accept(arguments); systemCommand.onCall(); } catch (final Exception e) { logger.log(Level.WARNING, "The system command " + command + " threw an exception: ", e); } }); return value.isPresent(); } /** * Supplies missing values with default values. * * @param input the input String * @param systemCommand the command that is expected * @return the list of arguments that can be used to parse the systemCommand * @since Envoy Client v0.2-beta */ private List extractArguments(String input, SystemCommand systemCommand) { // no more arguments follow after the command (e.g. text = "/DABR") final var indexOfSpace = input.indexOf(" "); if (indexOfSpace < 0) return supplementDefaults(new String[] {}, systemCommand); // the arguments behind a system command final var remainingString = input.substring(indexOfSpace + 1); final var numberOfArguments = systemCommand.getNumberOfArguments(); // splitting those arguments and supplying default values final var textArguments = remainingString.split(" ", -1); final var originalArguments = numberOfArguments >= 0 ? Arrays.copyOfRange(textArguments, 0, numberOfArguments) : textArguments; final var arguments = supplementDefaults(originalArguments, systemCommand); return arguments; } /** * Retrieves the recommendations based on the current input entered.
* The first word is used for the recommendations and * it does not matter if the "/" is at its beginning or not.
* If none are present, nothing will be done.
* Otherwise the given function will be executed on the recommendations.
* * @param input the input string * @param action the action that should be taken for the recommendations, if any * are present * @since Envoy Client v0.2-beta */ public void requestRecommendations(String input, Consumer> action) { final var partialCommand = getCommand(input); // Get the expected commands final var recommendations = recommendCommands(partialCommand); if (recommendations.isEmpty()) return; // Execute the given action else action.accept(recommendations); } /** * Recommends commands based upon the currently entered input.
* In the current implementation, all we check is whether a key contains this * input. This might be updated later on. * * @param partialCommand the partially entered command * @return a set of all commands that match this input * @since Envoy Client v0.2-beta */ private Set recommendCommands(String partialCommand) { // current implementation only looks if input is contained within a command, // might be updated return systemCommands.keySet() .stream() .filter(command -> command.contains(partialCommand)) .sorted((command1, command2) -> Integer.compare(systemCommands.get(command1).getRelevance(), systemCommands.get(command2).getRelevance())) .collect(Collectors.toSet()); } /** * * Supplies the default values for arguments if none are present in the text for * any argument.
* Will only work for {@code SystemCommand}s whose argument counter is bigger * than 1. * * @param textArguments the arguments that were parsed from the text * @param toEvaluate the system command whose default values should be used * @return the final argument list * @since Envoy Client v0.2-beta * @apiNote this method will insert an empty String if the size of the list * given to the {@code SystemCommand} is smaller than its argument * counter and no more text arguments could be found. */ private List supplementDefaults(String[] textArguments, SystemCommand toEvaluate) { final var defaults = toEvaluate.getDefaults(); final var numberOfArguments = toEvaluate.getNumberOfArguments(); final List result = new ArrayList<>(); if (toEvaluate.getNumberOfArguments() > 0) for (int index = 0; index < numberOfArguments; index++) { String textArg = null; if (index < textArguments.length) textArg = textArguments[index]; // Set the argument at position index to the current argument of the text, if it // is present. Otherwise the default for that argument will be taken if present. // In the worst case, an empty String will be used. result.add(!(textArg == null) && !textArg.isBlank() ? textArg : index < defaults.size() ? defaults.get(index) : ""); } return result; } /** * @return all {@link SystemCommand}s used with the underlying command as key * @since Envoy Client v0.2-beta */ public Map getSystemCommands() { return systemCommands; } }