diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98592f84..23580c4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,19 +15,20 @@ jobs: strategy: matrix: - java: [8, 11, 16, 17] + java: [25] steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Set up JDK 1.8 - uses: actions/setup-java@v1 + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v4 with: + distribution: temurin java-version: ${{ matrix.java }} - name: Cache Maven packages - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.m2 key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30038286..f94f7bab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,22 +18,23 @@ on: - 'v*' env: - JAVA_VERSION: 8 + JAVA_VERSION: 25 jobs: publish: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Java ${{ env.JAVA_VERSION }} - uses: actions/setup-java@v1 + uses: actions/setup-java@v4 with: + distribution: temurin java-version: ${{ env.JAVA_VERSION }} - name: Cache all the things - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.m2 key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }} diff --git a/pom.xml b/pom.xml index a9f47966..62c62470 100644 --- a/pom.xml +++ b/pom.xml @@ -44,32 +44,38 @@ UTF-8 - 1.8 - 1.8 + 25 + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + ${maven.compiler.release} + + -Xlint:deprecation + -Xlint:removal + + + org.apache.maven.plugins maven-shade-plugin - 3.1.0 + 3.6.2 - false + false - fr.zcraft:quartzlib - org.bstats:bstats-bukkit + org.bstats:* - - fr.zcraft.quartzlib - fr.zcraft.imageonmap.quartzlib - org.bstats - fr.moribus.imageonmap + fr.moribus.imageonmap.libs.bstats true @@ -110,36 +116,22 @@ - spigot-repo - https://hub.spigotmc.org/nexus/content/repositories/snapshots/ - - - zdevelopers-quartzlib - https://maven.zcraft.fr/QuartzLib - - - - - CodeMC - https://repo.codemc.org/repository/maven-public + papermc + https://repo.papermc.io/repository/maven-public/ - org.bukkit - bukkit - 1.13.2-R0.1-SNAPSHOT - - - fr.zcraft - quartzlib - 0.0.6 + io.papermc.paper + paper-api + 26.3.build.175-stable + provided org.bstats bstats-bukkit - 1.8 + 3.1.0 compile diff --git a/src/main/java/fr/moribus/imageonmap/ImageOnMap.java b/src/main/java/fr/moribus/imageonmap/ImageOnMap.java index 321c3de4..82420021 100644 --- a/src/main/java/fr/moribus/imageonmap/ImageOnMap.java +++ b/src/main/java/fr/moribus/imageonmap/ImageOnMap.java @@ -37,6 +37,7 @@ package fr.moribus.imageonmap; +import fr.moribus.imageonmap.commands.Commands; import fr.moribus.imageonmap.commands.maptool.DeleteCommand; import fr.moribus.imageonmap.commands.maptool.ExploreCommand; import fr.moribus.imageonmap.commands.maptool.GetCommand; @@ -46,29 +47,36 @@ import fr.moribus.imageonmap.commands.maptool.NewCommand; import fr.moribus.imageonmap.commands.maptool.RenameCommand; import fr.moribus.imageonmap.commands.maptool.UpdateCommand; +import fr.moribus.imageonmap.gui.framework.Gui; +import fr.moribus.imageonmap.i18n.I18n; import fr.moribus.imageonmap.image.ImageIOExecutor; import fr.moribus.imageonmap.image.ImageRendererExecutor; import fr.moribus.imageonmap.image.MapInitEvent; import fr.moribus.imageonmap.map.MapManager; +import fr.moribus.imageonmap.tools.ActionBar; +import fr.moribus.imageonmap.tools.PluginLogger; +import fr.moribus.imageonmap.tools.UpdateChecker; import fr.moribus.imageonmap.ui.MapItemManager; -import fr.zcraft.quartzlib.components.commands.CommandWorkers; -import fr.zcraft.quartzlib.components.commands.Commands; -import fr.zcraft.quartzlib.components.gui.Gui; -import fr.zcraft.quartzlib.components.i18n.I18n; -import fr.zcraft.quartzlib.core.QuartzPlugin; -import fr.zcraft.quartzlib.tools.PluginLogger; -import fr.zcraft.quartzlib.tools.UpdateChecker; +import fr.moribus.imageonmap.ui.SplatterMapManager; +import fr.moribus.imageonmap.worker.Worker; import java.io.File; import java.io.IOException; import org.bstats.bukkit.Metrics; +import org.bstats.charts.SingleLineChart; +import org.bukkit.plugin.java.JavaPlugin; -public final class ImageOnMap extends QuartzPlugin { +public final class ImageOnMap extends JavaPlugin { private static final String IMAGES_DIRECTORY_NAME = "images"; private static final String MAPS_DIRECTORY_NAME = "maps"; + + /** + * How long to wait, when the plugin is disabled, for the queued images to be written to disk. + */ + private static final long IMAGE_IO_SHUTDOWN_TIMEOUT_MS = 10_000; + private static ImageOnMap plugin; private final File mapsDirectory; private File imagesDirectory; - private CommandWorkers commandWorker; public ImageOnMap() { imagesDirectory = new File(this.getDataFolder(), IMAGES_DIRECTORY_NAME); @@ -92,11 +100,6 @@ public File getImageFile(int mapID) { return new File(imagesDirectory, "map" + mapID + ".png"); } - public CommandWorkers getCommandWorker() { - return commandWorker; - } - - @SuppressWarnings("unchecked") @Override public void onEnable() { // Creating the images and maps directories if necessary @@ -112,9 +115,10 @@ public void onEnable() { saveDefaultConfig(); - commandWorker = loadComponent(CommandWorkers.class); - loadComponents(I18n.class, Gui.class, Commands.class, PluginConfiguration.class, ImageIOExecutor.class, - ImageRendererExecutor.class); + I18n.init(this, getFile()); + Gui.init(this); + Worker.start(ImageIOExecutor.class); + Worker.start(ImageRendererExecutor.class); //Init all the things ! I18n.setPrimaryLocale(PluginConfiguration.LANG.get()); @@ -122,6 +126,7 @@ public void onEnable() { MapManager.init(); MapInitEvent.init(); MapItemManager.init(); + SplatterMapManager.init(this); Commands.register( @@ -142,14 +147,16 @@ public void onEnable() { Commands.registerShortcut("maptool", ExploreCommand.class, "maps"); Commands.registerShortcut("maptool", GiveCommand.class, "givemap"); + Commands.registerToServer(this); + if (PluginConfiguration.CHECK_FOR_UPDATES.get()) { UpdateChecker.boot("imageonmap.26585"); } if (PluginConfiguration.COLLECT_DATA.get()) { - final Metrics metrics = new Metrics(this,5920); - metrics.addCustomChart(new Metrics.SingleLineChart("rendered-images", MapManager::getImagesCount)); - metrics.addCustomChart(new Metrics.SingleLineChart("used-minecraft-maps", MapManager::getMapCount)); + final Metrics metrics = new Metrics(this, 5920); + metrics.addCustomChart(new SingleLineChart("rendered-images", MapManager::getImagesCount)); + metrics.addCustomChart(new SingleLineChart("used-minecraft-maps", MapManager::getMapCount)); } else { PluginLogger.warning("Collect data disabled"); } @@ -161,7 +168,12 @@ public void onDisable() { MapItemManager.exit(); //MigratorExecutor.waitForMigration();//Removed for now doesn't work nor is useful, maybe useful later on - super.onDisable(); + Gui.exit(); + ActionBar.clear(); + Worker.stop(ImageRendererExecutor.class, 0); + Worker.stop(ImageIOExecutor.class, IMAGE_IO_SHUTDOWN_TIMEOUT_MS); + Commands.clear(); + I18n.exit(); } private File checkPluginDirectory(File primaryFile, File... alternateFiles) throws IOException { diff --git a/src/main/java/fr/moribus/imageonmap/Permissions.java b/src/main/java/fr/moribus/imageonmap/Permissions.java index cf079c2d..2b4214dc 100644 --- a/src/main/java/fr/moribus/imageonmap/Permissions.java +++ b/src/main/java/fr/moribus/imageonmap/Permissions.java @@ -37,8 +37,8 @@ package fr.moribus.imageonmap; -import fr.zcraft.quartzlib.components.i18n.I; -import fr.zcraft.quartzlib.tools.PluginLogger; +import fr.moribus.imageonmap.i18n.I; +import fr.moribus.imageonmap.tools.PluginLogger; import java.util.Set; import org.bukkit.permissions.Permissible; import org.bukkit.permissions.PermissionAttachmentInfo; diff --git a/src/main/java/fr/moribus/imageonmap/PluginConfiguration.java b/src/main/java/fr/moribus/imageonmap/PluginConfiguration.java index c5f0bae5..d9927885 100644 --- a/src/main/java/fr/moribus/imageonmap/PluginConfiguration.java +++ b/src/main/java/fr/moribus/imageonmap/PluginConfiguration.java @@ -36,32 +36,144 @@ package fr.moribus.imageonmap; -import static fr.zcraft.quartzlib.components.configuration.ConfigurationItem.item; -import static fr.zcraft.quartzlib.components.configuration.ConfigurationItem.list; - -import fr.zcraft.quartzlib.components.configuration.Configuration; -import fr.zcraft.quartzlib.components.configuration.ConfigurationItem; -import fr.zcraft.quartzlib.components.configuration.ConfigurationList; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IllformedLocaleException; +import java.util.List; import java.util.Locale; +import java.util.function.Function; +import org.bukkit.configuration.file.FileConfiguration; + + +public final class PluginConfiguration { + public static final ConfigurationItem LANG = item("lang", null, PluginConfiguration::toLocale); + + public static final ConfigurationItem COLLECT_DATA = item("collect-data", true); + + public static final ConfigurationItem CHECK_FOR_UPDATES = item("check-for-updates", true); + + public static final ConfigurationItem MAP_GLOBAL_LIMIT = + item("map-global-limit", 0, "Limit-map-by-server"); + public static final ConfigurationItem MAP_PLAYER_LIMIT = + item("map-player-limit", 0, "Limit-map-by-player"); + + public static final ConfigurationItem SAVE_FULL_IMAGE = item("save-full-image", true); + + + public static final ConfigurationItem LIMIT_SIZE_X = item("limit-map-size-x", 0); + public static final ConfigurationItem LIMIT_SIZE_Y = item("limit-map-size-y", 0); + + public static final ConfigurationItem> IMAGES_HOSTNAMES_WHITELIST = + item("images-hostnames-whitelist", Collections.emptyList(), PluginConfiguration::toStringList); + + private PluginConfiguration() { + } + + private static ConfigurationItem item(String path, boolean defaultValue) { + return item(path, defaultValue, PluginConfiguration::toBoolean); + } + + private static ConfigurationItem item(String path, int defaultValue, String... deprecatedPaths) { + return item(path, defaultValue, PluginConfiguration::toInteger, deprecatedPaths); + } + + private static ConfigurationItem item(String path, T defaultValue, Function parser, + String... deprecatedPaths) { + return new ConfigurationItem<>(path, defaultValue, parser, deprecatedPaths); + } + private static Locale toLocale(Object value) { + if (value.toString().isEmpty()) { + return null; + } + try { + return new Locale.Builder().setLanguageTag(value.toString()).build(); + } catch (IllformedLocaleException ex) { + return null; + } + } -public final class PluginConfiguration extends Configuration { - public static ConfigurationItem LANG = item("lang", Locale.class); + private static Boolean toBoolean(Object value) { + if (value instanceof Boolean) { + return (Boolean) value; + } + switch (value.toString().trim().toLowerCase()) { + case "true": + case "yes": + case "on": + case "1": + return true; + case "false": + case "no": + case "off": + case "0": + return false; + default: + return null; + } + } - public static ConfigurationItem COLLECT_DATA = item("collect-data", true); + private static Integer toInteger(Object value) { + if (value instanceof Number) { + return ((Number) value).intValue(); + } + try { + return Integer.parseInt(value.toString().trim()); + } catch (NumberFormatException ex) { + return null; + } + } - public static ConfigurationItem CHECK_FOR_UPDATES = item("check-for-updates", true); + private static List toStringList(Object value) { + if (!(value instanceof List)) { + return null; + } + final List list = new ArrayList<>(); + for (Object element : (List) value) { + if (element != null) { + list.add(element.toString()); + } + } + return list; + } - public static ConfigurationItem MAP_GLOBAL_LIMIT = item("map-global-limit", 0, "Limit-map-by-server"); - public static ConfigurationItem MAP_PLAYER_LIMIT = item("map-player-limit", 0, "Limit-map-by-player"); + /** + * A value of the configuration file. The value is read from the configuration every time it is requested. + * + * @param The type of the value. + */ + public static final class ConfigurationItem { + private final String path; + private final T defaultValue; + private final Function parser; + private final String[] deprecatedPaths; - public static ConfigurationItem SAVE_FULL_IMAGE = item("save-full-image", true); + private ConfigurationItem(String path, T defaultValue, Function parser, String[] deprecatedPaths) { + this.path = path; + this.defaultValue = defaultValue; + this.parser = parser; + this.deprecatedPaths = deprecatedPaths; + } + /** + * Returns the value of this item. + * + * @return The configured value, or the default value if the configured one is missing or invalid. + */ + public T get() { + final FileConfiguration config = ImageOnMap.getPlugin().getConfig(); - public static ConfigurationItem LIMIT_SIZE_X = item("limit-map-size-x", 0); - public static ConfigurationItem LIMIT_SIZE_Y = item("limit-map-size-y", 0); + Object rawValue = config.get(path); + for (int i = 0; rawValue == null && i < deprecatedPaths.length; i++) { + rawValue = config.get(deprecatedPaths[i]); + } - public static ConfigurationList IMAGES_HOSTNAMES_WHITELIST = - list("images-hostnames-whitelist", String.class); + if (rawValue == null) { + return defaultValue; + } + final T value = parser.apply(rawValue); + return value != null ? value : defaultValue; + } + } } diff --git a/src/main/java/fr/moribus/imageonmap/commands/Command.java b/src/main/java/fr/moribus/imageonmap/commands/Command.java new file mode 100644 index 00000000..862e7f05 --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/commands/Command.java @@ -0,0 +1,417 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.commands; + +import fr.moribus.imageonmap.ImageOnMap; +import fr.moribus.imageonmap.commands.CommandException.Reason; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +/** + * A sub-command of a {@link CommandGroup}. Sub-commands are annotated with {@link CommandInfo}. + */ +public abstract class Command { + private static final Pattern FLAG_PATTERN = Pattern.compile("(--?)[a-zA-Z0-9-]+"); + + protected CommandGroup commandGroup; + protected String commandName; + protected String usageParameters; + protected String commandDescription; + protected String[] aliases; + + protected boolean flagsEnabled; + protected Set acceptedFlags; + + protected CommandSender sender; + protected String[] args; + protected Set flags; + + /** + * Parses arguments to extract flags. + * + * @param args The raw arguments. + * @param acceptedFlags A set with lowercase accepted flags. + * @param realArgs An initially empty list filled with the real arguments, ordered. + * @param flags An initially empty set filled with flags found in the raw arguments. + */ + private static void parseArgs(final String[] args, final Set acceptedFlags, List realArgs, + Set flags) { + for (final String arg : args) { + if (!FLAG_PATTERN.matcher(arg).matches()) { + realArgs.add(arg); + continue; + } + + final Set flagsInArg; + if (arg.startsWith("--")) { + final String flatFlag = arg.replace("--", "").trim().toLowerCase(); + if (isValidFlag(acceptedFlags, flatFlag)) { + flagsInArg = Collections.singleton(flatFlag); + } else { + realArgs.add(arg); + continue; + } + } else { + final String flatFlags = arg.replace("-", "").trim().toLowerCase(); + flagsInArg = new HashSet<>(flatFlags.length()); + + for (char c : flatFlags.toCharArray()) { + final String flag = String.valueOf(c); + if (isValidFlag(acceptedFlags, flag)) { + flagsInArg.add(flag); + } + } + + // If there is no valid flag at all in the argument, we ignore it and add it back to args + if (flagsInArg.isEmpty()) { + realArgs.add(arg); + continue; + } + } + + flags.addAll(flagsInArg); + } + } + + private void parseArgs(String[] args) { + if (!flagsEnabled) { + this.args = args; + this.flags = null; + return; + } + + final List argsList = new ArrayList<>(args.length); + flags = new HashSet<>(); + parseArgs(args, acceptedFlags, argsList, flags); + this.args = argsList.toArray(new String[0]); + } + + private static boolean isValidFlag(Set acceptedFlags, String flag) { + return acceptedFlags != null && (acceptedFlags.isEmpty() || acceptedFlags.contains(flag.toLowerCase())); + } + + /** + * Displays a gray informational message. + * + * @param sender The receiver of the message. + * @param message The message to display. + */ + protected static void info(CommandSender sender, String message) { + sender.sendMessage("§7" + message); + } + + protected void info(String message) { + info(sender, message); + } + + /** + * Displays a green success message. + * + * @param sender The receiver of the message. + * @param message The message to display. + */ + protected static void success(CommandSender sender, String message) { + sender.sendMessage("§a" + message); + } + + protected void success(String message) { + success(sender, message); + } + + /** + * Displays a red warning message. + * + * @param sender The receiver of the message. + * @param message The message to display. + */ + protected static void warning(CommandSender sender, String message) { + sender.sendMessage("§c" + message); + } + + protected void warning(String message) { + warning(sender, message); + } + + /** + * Runs the command. + * + *

Use protected fields to access data (like {@link #args}).

+ * + * @throws CommandException If something bad happens. + */ + protected abstract void run() throws CommandException; + + /** + * Initializes the command. Internal use. + * + * @param commandGroup The group this command instance belongs to. + */ + void init(CommandGroup commandGroup) { + this.commandGroup = commandGroup; + + final CommandInfo commandInfo = this.getClass().getAnnotation(CommandInfo.class); + if (commandInfo == null) { + throw new IllegalArgumentException("Command has no CommandInfo annotation"); + } + + commandName = commandInfo.name().toLowerCase(); + usageParameters = commandInfo.usageParameters(); + commandDescription = commandGroup.getDescription(commandName); + aliases = commandInfo.aliases(); + + final WithFlags withFlags = this.getClass().getAnnotation(WithFlags.class); + flagsEnabled = withFlags != null; + if (flagsEnabled) { + acceptedFlags = new HashSet<>(); + for (final String flag : withFlags.value()) { + acceptedFlags.add(flag.toLowerCase()); + } + } else { + acceptedFlags = Collections.emptySet(); + } + } + + /** + * Checks if a given sender is allowed to execute this command. + * + * @param sender The sender. + * @return {@code true} if the sender can execute the command. + */ + public boolean canExecute(CommandSender sender) { + final String permissionPrefix = ImageOnMap.getPlugin().getName().toLowerCase() + "."; + return sender.hasPermission(permissionPrefix + commandGroup.getUsualName()); + } + + /** + * Checks if a given sender is allowed to execute this command. + * + * @param sender The sender. + * @param args The arguments passed to the command. + * @return {@code true} if the sender can execute the command. + */ + public boolean canExecute(CommandSender sender, String[] args) { + return canExecute(sender); + } + + /** + * Tab-completes the command. + * + *

Use protected fields to access data (like {@link #args}).

+ * + * @return A list with suggestions, or {@code null} without suggestions. + * @throws CommandException If something bad happens. + */ + protected List complete() throws CommandException { + return null; + } + + /** + * Executes this command. + * + * @param sender The sender. + * @param args The raw arguments passed to the command. + */ + public void execute(CommandSender sender, String[] args) { + this.sender = sender; + parseArgs(args); + + try { + if (!canExecute(sender, args)) { + throw new CommandException(this, Reason.SENDER_NOT_AUTHORIZED); + } + run(); + } catch (CommandException ex) { + warning(ex.getReasonString()); + } + + this.sender = null; + this.args = null; + this.flags = null; + } + + /** + * Tab-completes this command. + * + * @param sender The sender. + * @param args The raw arguments passed to the command. + * @return The suggestions. + */ + public List tabComplete(CommandSender sender, String[] args) { + List result = null; + + this.sender = sender; + parseArgs(args); + + try { + if (canExecute(sender, args)) { + result = complete(); + } + } catch (CommandException ignored) { + // No suggestions. + } + + this.sender = null; + this.args = null; + this.flags = null; + + if (result == null) { + result = new ArrayList<>(); + } + return result; + } + + public String getUsageParameters() { + return usageParameters; + } + + /** + * Returns this command's usage string. + * + * @return This command's usage string, formatted like this: {@code /{command} {sub-command} {usage parameters}}. + */ + public String getUsageString() { + return "/" + commandGroup.getUsualName() + " " + commandName + " " + usageParameters; + } + + public String getName() { + return commandName; + } + + CommandGroup getCommandGroup() { + return commandGroup; + } + + public String[] getAliases() { + return aliases; + } + + /** + * Checks if the given name matches this command's, or any of its aliases. + * + * @param name A command name. + * @return {@code true} if this command can be called like that. + */ + public boolean matches(String name) { + if (commandName.equals(name.toLowerCase())) { + return true; + } + + for (String alias : aliases) { + if (alias.equals(name)) { + return true; + } + } + + return false; + } + + /** + * Builds a command string. + * + * @param args Some arguments. + * @return A ready-to-be-executed command string with the passed arguments. + */ + public String build(String... args) { + final StringBuilder command = new StringBuilder("/" + commandGroup.getUsualName() + " " + commandName); + for (String arg : args) { + command.append(" ").append(arg); + } + return command.toString(); + } + + /** + * Stops the command execution because an argument is invalid, and displays an error message. + * + * @param reason The error. + * @throws CommandException the thrown exception. + */ + protected void throwInvalidArgument(String reason) throws CommandException { + throw new CommandException(this, Reason.INVALID_PARAMETERS, reason); + } + + /** + * Stops the command execution because the command usage is disallowed, and displays an error message. + * + * @throws CommandException the thrown exception. + */ + protected void throwNotAuthorized() throws CommandException { + throw new CommandException(this, Reason.SENDER_NOT_AUTHORIZED); + } + + /** + * Retrieves the {@link Player} who executed this command. If the command is not executed by a player, aborts the + * execution and displays an error message. + * + * @return The player executing this command. + * @throws CommandException If the sender is not a player. + */ + protected Player playerSender() throws CommandException { + if (!(sender instanceof Player)) { + throw new CommandException(this, Reason.COMMANDSENDER_EXPECTED_PLAYER); + } + return (Player) sender; + } + + /** + * Aborts the execution and displays an error message. + * + * @param message The message. + * @throws CommandException the thrown exception. + */ + protected void error(String message) throws CommandException { + throw new CommandException(this, Reason.COMMAND_ERROR, message); + } + + /** + * Checks if a flag is set. To use flags, the command class must be annotated by {@link WithFlags}. + * + * @param flag The flag. + * @return {@code true} if the flag was passed by the sender. + */ + protected boolean hasFlag(String flag) { + return flags != null && flags.contains(flag.toLowerCase()); + } +} diff --git a/src/main/java/fr/moribus/imageonmap/commands/CommandException.java b/src/main/java/fr/moribus/imageonmap/commands/CommandException.java new file mode 100644 index 00000000..e3a22d21 --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/commands/CommandException.java @@ -0,0 +1,111 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.commands; + +import fr.moribus.imageonmap.gui.framework.GuiUtils; +import fr.moribus.imageonmap.tools.ChatColor; +import fr.moribus.imageonmap.tools.PluginLogger; + +/** + * Thrown to abort the execution of a command and display an error message. + */ +public class CommandException extends Exception { + private final Reason reason; + private final Command command; + private final String extra; + + /** + * A new CommandException. + * + * @param command The command that raised the exception. + * @param reason The reason code. + * @param extra Any extra message. + */ + public CommandException(Command command, Reason reason, String extra) { + this.command = command; + this.reason = reason; + this.extra = extra; + } + + public CommandException(Command command, Reason reason) { + this(command, reason, ""); + } + + public Reason getReason() { + return reason; + } + + /** + * Builds the "reason" string for this command exception. + * + * @return The reason string. + */ + public String getReasonString() { + switch (reason) { + case COMMANDSENDER_EXPECTED_PLAYER: + return "You must be a player to use this command."; + case INVALID_PARAMETERS: + final String prefix = ChatColor.GOLD + Commands.CHAT_PREFIX + " " + ChatColor.RESET; + return "\n" + + ChatColor.RED + Commands.CHAT_PREFIX + ' ' + ChatColor.BOLD + "Invalid argument" + '\n' + + GuiUtils.generatePrefixedFixedLengthString(ChatColor.RED + Commands.CHAT_PREFIX + " ", extra) + + '\n' + + GuiUtils.generatePrefixedFixedLengthString(prefix, "Usage: " + command.getUsageString()) + + '\n' + + GuiUtils.generatePrefixedFixedLengthString(prefix, + "For more information, use /" + command.getCommandGroup().getUsualName() + + " help " + command.getName()); + case COMMAND_ERROR: + return extra.isEmpty() ? "An unknown error suddenly happened." : extra; + case SENDER_NOT_AUTHORIZED: + return "You do not have the permission to use this command."; + default: + PluginLogger.warning("Unknown CommandException caught", this); + return "An unknown error suddenly happened."; + } + } + + public enum Reason { + COMMANDSENDER_EXPECTED_PLAYER, + INVALID_PARAMETERS, + COMMAND_ERROR, + SENDER_NOT_AUTHORIZED + } +} diff --git a/src/main/java/fr/moribus/imageonmap/commands/CommandGroup.java b/src/main/java/fr/moribus/imageonmap/commands/CommandGroup.java new file mode 100644 index 00000000..d1c20d27 --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/commands/CommandGroup.java @@ -0,0 +1,331 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.commands; + +import fr.moribus.imageonmap.tools.PluginLogger; +import io.papermc.paper.command.brigadier.BasicCommand; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Scanner; +import org.bukkit.command.CommandSender; + +/** + * A root command (like {@code /maptool}) dispatching to sub-commands, or a shortcut command (like {@code /tomap}) + * executing a single sub-command directly. + */ +public class CommandGroup implements BasicCommand { + private final CommandGroup shortcutCommandGroup; + private final String[] names; + private final Class[] commandsClasses; + private final ArrayList commands = new ArrayList<>(); + private final HashMap commandsDescriptions = new HashMap<>(); + private String description = ""; + + @SuppressWarnings("unchecked") + CommandGroup(CommandGroup shortcutCommandGroup, Class commandClass, String... names) { + this.names = names; + this.commandsClasses = new Class[] {commandClass}; + this.shortcutCommandGroup = shortcutCommandGroup; + initCommands(); + } + + @SafeVarargs + CommandGroup(String[] names, Class... commandsClasses) { + this.names = names; + this.commandsClasses = commandsClasses; + this.shortcutCommandGroup = null; + initDescriptions(); + initCommands(); + } + + /** + * Gets the command args from the given group args. + * + * @param args The group args. + * @return The command args. + */ + public static String[] getCommandArgsFromGroupArgs(String[] args) { + return Arrays.copyOfRange(args, 1, args.length); + } + + private void initDescriptions() { + final String fileName = "help/" + getUsualName() + ".txt"; + final InputStream stream = getClass().getClassLoader().getResourceAsStream(fileName); + + if (stream == null) { + PluginLogger.warning("Could not load description file for the " + getUsualName() + " command"); + return; + } + + final StringBuilder builder = new StringBuilder(); + boolean isGroupDescription = true; + + try (Scanner scanner = new Scanner(stream, StandardCharsets.UTF_8)) { + // Getting the group's description, and then each command's description + while (scanner.hasNextLine()) { + final String line = scanner.nextLine(); + final int colonIndex = line.indexOf(':'); + + if (isGroupDescription) { + final int firstSpaceIndex = line.indexOf(' '); + if (colonIndex > 0 && firstSpaceIndex > colonIndex) { + isGroupDescription = false; + } + } + + if (isGroupDescription) { + builder.append(line).append('\n'); + } else if (colonIndex >= 0) { + commandsDescriptions.put(line.substring(0, colonIndex).trim(), + line.substring(colonIndex + 1).trim()); + } + } + } + + description = builder.toString().trim(); + } + + private void initCommands() { + for (Class commandClass : commandsClasses) { + addCommand(commandClass); + } + + if (!isShortcutCommand()) { + addCommand(HelpCommand.class); + } + } + + private void addCommand(Class commandClass) { + try { + final Command newCommand = commandClass.getConstructor().newInstance(); + newCommand.init(isShortcutCommand() ? shortcutCommandGroup : this); + commands.add(newCommand); + } catch (Exception ex) { + PluginLogger.warning("Exception while initializing command", ex); + } + } + + @Override + public void execute(CommandSourceStack commandSourceStack, String[] args) { + executeMatchingCommand(commandSourceStack.getSender(), args); + } + + @Override + public Collection suggest(CommandSourceStack commandSourceStack, String[] args) { + return tabComplete(commandSourceStack.getSender(), args); + } + + /** + * Executes the command matching the args. + * + * @param sender The command's sender. + * @param args The command args. + * @return true if a matching command was found. + */ + public boolean executeMatchingCommand(CommandSender sender, String[] args) { + if (isShortcutCommand()) { + commands.get(0).execute(sender, args); + return true; + } + + if (args.length <= 0) { + sender.sendMessage(getUsage()); + return false; + } + + final Command command = getMatchingCommand(args[0]); + if (command != null) { + command.execute(sender, getCommandArgsFromGroupArgs(args)); + } else { + sender.sendMessage(getUsage()); + } + return command != null; + } + + /** + * Computes a list of possible autocomplete suggestions for the given partial arguments. + * + * @param sender The sender of the command. + * @param args The partial arguments. + * @return A list of suggestions. + */ + public List tabComplete(CommandSender sender, String[] args) { + if (isShortcutCommand()) { + return commands.get(0).tabComplete(sender, args); + } + + if (args.length <= 1) { + return tabComplete(sender, args.length == 1 ? args[0] : null); + } + + final Command command = getMatchingCommand(args[0]); + if (command != null) { + return command.tabComplete(sender, getCommandArgsFromGroupArgs(args)); + } + return new ArrayList<>(); + } + + /** + * Computes a list of possible autocomplete suggestions for the given command. + * + * @param sender The sender of the command. + * @param commandName The beginning of the name of the command. + * @return A list of suggestions. + */ + public List tabComplete(CommandSender sender, String commandName) { + final ArrayList matchingCommands = new ArrayList<>(); + + for (Command command : commands) { + if (!command.canExecute(sender)) { + continue; + } + if (commandName == null || command.getName().startsWith(commandName.toLowerCase())) { + matchingCommands.add(command.getName()); + } + } + + return matchingCommands; + } + + /** + * Gets the command matching the given name. + * + * @param commandName The command name. + * @return The matching command, or null if none were found. + */ + public Command getMatchingCommand(String commandName) { + for (Command command : commands) { + if (command.matches(commandName)) { + return command; + } + } + return null; + } + + /** + * Gets the command matching the given class. + * + * @param commandClass The command class. + * @return The matching command, or null if none were found. + */ + public Command getCommandInfo(Class commandClass) { + for (Command command : commands) { + if (command.getClass().equals(commandClass)) { + return command; + } + } + return null; + } + + /** + * Checks if this command matches the given name. + * + * @param name The name of the command to test for. + * @return if this command matches the given name. + */ + public boolean matches(String name) { + final String lowerName = name.toLowerCase(); + for (String commandName : names) { + if (commandName.equals(lowerName)) { + return true; + } + } + return false; + } + + /** + * Returns the names of all sub-commands. + * + * @return the names of all sub-commands. + */ + public String[] getCommandsNames() { + final String[] commandsNames = new String[commands.size()]; + for (int i = 0; i < commands.size(); i++) { + commandsNames[i] = commands.get(i).getName(); + } + return commandsNames; + } + + protected String getUsage() { + if (isShortcutCommand()) { + return "§cUsage: " + commands.get(0).getUsageString(); + } + return "§cUsage: /" + getUsualName() + " <" + String.join("|", getCommandsNames()) + ">"; + } + + public String getUsualName() { + return names[0]; + } + + public List getAliases() { + return Arrays.asList(names).subList(1, names.length); + } + + public Command[] getCommands() { + return commands.toArray(new Command[0]); + } + + /** + * Returns the description of this command, as displayed in the help. + * + * @return The description. + */ + public String getDescription() { + if (isShortcutCommand()) { + final String commandDescription = shortcutCommandGroup.getDescription(commands.get(0).getName()); + return commandDescription != null ? commandDescription : ""; + } + return description; + } + + public String getDescription(String commandName) { + return commandsDescriptions.get(commandName); + } + + public boolean isShortcutCommand() { + return shortcutCommandGroup != null; + } +} diff --git a/src/main/java/fr/moribus/imageonmap/commands/CommandInfo.java b/src/main/java/fr/moribus/imageonmap/commands/CommandInfo.java new file mode 100644 index 00000000..1d91b96a --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/commands/CommandInfo.java @@ -0,0 +1,67 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.commands; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Describes a sub-command. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface CommandInfo { + /** + * The name of the command. + */ + String name(); + + /** + * The "usage" description. + */ + String usageParameters() default ""; + + /** + * Aliases for this command. + */ + String[] aliases() default {}; +} diff --git a/src/main/java/fr/moribus/imageonmap/commands/Commands.java b/src/main/java/fr/moribus/imageonmap/commands/Commands.java new file mode 100644 index 00000000..d9c6c015 --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/commands/Commands.java @@ -0,0 +1,128 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.commands; + +import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; +import java.util.ArrayList; +import java.util.List; +import org.bukkit.plugin.java.JavaPlugin; + +/** + * Registry of the plugin commands. + */ +public final class Commands { + public static final String CHAT_PREFIX = "┃"; + private static final List commandGroups = new ArrayList<>(); + + private Commands() { + } + + /** + * Registers a root command with its sub-commands. + * + * @param name The name of the command. + * @param commandsClasses The sub-commands. + */ + @SafeVarargs + public static void register(String name, Class... commandsClasses) { + commandGroups.add(new CommandGroup(new String[] {name}, commandsClasses)); + } + + /** + * Registers a shortcut command, executing directly a sub-command of a registered command. + * + * @param commandGroupName The name of the registered root command. + * @param commandClass The sub-command to execute. + * @param shortcutNames The name of the shortcut, then its aliases. + */ + public static void registerShortcut(String commandGroupName, Class commandClass, + String... shortcutNames) { + final CommandGroup group = getMatchingCommandGroup(commandGroupName); + if (group == null) { + throw new IllegalArgumentException("Invalid command group name: " + commandGroupName); + } + commandGroups.add(new CommandGroup(group, commandClass, shortcutNames)); + } + + /** + * Registers the commands in the server once it is ready to accept them. + * + * @param plugin The plugin. + */ + public static void registerToServer(JavaPlugin plugin) { + plugin.getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, event -> { + for (CommandGroup group : commandGroups) { + event.registrar().register(group.getUsualName(), group.getDescription(), group.getAliases(), group); + } + }); + } + + /** + * Unregisters all commands from this registry. + */ + public static void clear() { + commandGroups.clear(); + } + + /** + * Gets the command matching the given class. + * + * @param commandClass The command class. + * @return The matching command, or null if none were found. + */ + public static Command getCommandInfo(Class commandClass) { + for (CommandGroup commandGroup : commandGroups) { + final Command command = commandGroup.getCommandInfo(commandClass); + if (command != null) { + return command; + } + } + return null; + } + + private static CommandGroup getMatchingCommandGroup(String commandName) { + for (CommandGroup commandGroup : commandGroups) { + if (commandGroup.matches(commandName)) { + return commandGroup; + } + } + return null; + } +} diff --git a/src/main/java/fr/moribus/imageonmap/commands/HelpCommand.java b/src/main/java/fr/moribus/imageonmap/commands/HelpCommand.java new file mode 100644 index 00000000..fea5ba3e --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/commands/HelpCommand.java @@ -0,0 +1,232 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.commands; + +import fr.moribus.imageonmap.ImageOnMap; +import fr.moribus.imageonmap.gui.framework.GuiUtils; +import fr.moribus.imageonmap.tools.ChatColor; +import fr.moribus.imageonmap.tools.PluginLogger; +import fr.moribus.imageonmap.tools.Text; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Scanner; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.entity.Player; + +/** + * The {@code help} sub-command, automatically added to every root command. + */ +@CommandInfo(name = "help", usageParameters = "") +public class HelpCommand extends Command { + private static final int ITEMS_PER_PAGE = 8; + + @Override + protected void run() throws CommandException { + if (args.length < 1) { + groupHelp(1); + } else { + if (args.length == 1 && args[0].startsWith("--page=")) { + try { + groupHelp(Integer.parseInt(args[0].split("=")[1])); + return; + } catch (NumberFormatException | ArrayIndexOutOfBoundsException ignored) { + // Not a page: displays the help of a command named like this. + } + } + commandHelp(); + } + } + + private void groupHelp(int page) { + final List displayedCommands = new ArrayList<>(); + + for (Command subCommand : commandGroup.getCommands()) { + if (subCommand.canExecute(sender, args)) { + displayedCommands.add(subCommand); + } + } + + if (sender instanceof Player) { + info(""); + } + + final boolean paginate = sender instanceof Player; + final int pagesCount = (int) Math.ceil((double) displayedCommands.size() / ITEMS_PER_PAGE); + final int currentPage = page < 1 ? 1 : Math.min(page, pagesCount); + final int from = paginate ? Math.max(0, (currentPage - 1) * ITEMS_PER_PAGE) : 0; + final int to = paginate ? Math.min(from + ITEMS_PER_PAGE, displayedCommands.size()) : displayedCommands.size(); + + displayHeader(); + for (int i = from; i < to; i++) { + displayItem(displayedCommands.get(i)); + } + if (paginate && pagesCount > 1) { + displayFooter(currentPage, pagesCount); + } + } + + private void displayHeader() { + final String header = ChatColor.BOLD + (commandGroup.getDescription().isEmpty() + ? ImageOnMap.getPlugin().getName() + " help for /" + commandGroup.getUsualName() + : commandGroup.getDescription()); + + sender.sendMessage(sender instanceof Player + ? GuiUtils.generatePrefixedFixedLengthString( + ChatColor.BLUE + Commands.CHAT_PREFIX + " " + ChatColor.RESET, header) + : header + ); + } + + private void displayItem(Command command) { + final String commandName = "/" + commandGroup.getUsualName() + " " + command.getName(); + final String description = commandGroup.getDescription(command.getName()); + + String helpMessage = ChatColor.GOLD + commandName; + if (description != null) { + helpMessage += ChatColor.GOLD + ": " + ChatColor.WHITE + description; + } + + final String formattedHelpMessage = sender instanceof Player + ? GuiUtils.generatePrefixedFixedLengthString(ChatColor.GOLD + Commands.CHAT_PREFIX + " ", helpMessage) + : helpMessage; + + sender.sendMessage(Text.legacy(formattedHelpMessage) + .clickEvent(ClickEvent.suggestCommand(commandName + " ")) + .hoverEvent(HoverEvent.showText(Component.text(command.getUsageString())))); + } + + private void displayFooter(int currentPage, int pagesCount) { + final TextComponent.Builder footer = Component.text(); + + if (currentPage > 1) { + footer.append(Component.text("« Previous", NamedTextColor.GRAY) + .clickEvent(ClickEvent.runCommand(build("--page=" + (currentPage - 1)))) + .hoverEvent(HoverEvent.showText(Component.text("Go to page " + (currentPage - 1))))) + .append(Component.text(" ⋅ ", NamedTextColor.GRAY)); + } + + footer.append(Component.text("Page " + currentPage + " of " + pagesCount, NamedTextColor.GRAY, + TextDecoration.BOLD)); + + if (currentPage < pagesCount) { + footer.append(Component.text(" ⋅ ", NamedTextColor.GRAY)) + .append(Component.text("Next »", NamedTextColor.GRAY) + .clickEvent(ClickEvent.runCommand(build("--page=" + (currentPage + 1)))) + .hoverEvent(HoverEvent.showText(Component.text("Go to page " + (currentPage + 1))))); + } + + sender.sendMessage(footer.build()); + } + + private void commandHelp() throws CommandException { + final Command command = commandGroup.getMatchingCommand(args[0]); + if (command == null) { + error("The specified command does not exist."); + return; + } + + if (!command.canExecute(sender, args)) { + warning("You do not have the permission to use this command."); + } + + String message = "\n"; + message += GuiUtils.generatePrefixedFixedLengthString("§6" + Commands.CHAT_PREFIX + "§l ", + ImageOnMap.getPlugin().getName() + " help for /" + command.getCommandGroup().getUsualName() + " " + + command.getName()) + "\n"; + message += GuiUtils.generatePrefixedFixedLengthString("§6" + Commands.CHAT_PREFIX + " ", + "Usage: §r" + command.getUsageString()) + "\n"; + + try { + final String help = getHelpText(command); + if (help.isEmpty()) { + message += "§c" + Commands.CHAT_PREFIX + " There is no help message for this command."; + } else { + message += help; + } + } catch (IOException ex) { + message += "§c" + Commands.CHAT_PREFIX + " Could not read help for this command."; + PluginLogger.warning("Could not read help for the command: " + command.getName(), ex); + } + + sender.sendMessage(message); + } + + private String getHelpText(Command command) throws IOException { + final String fileName = "help/" + commandGroup.getUsualName() + "/" + command.getName() + ".txt"; + final StringBuilder result = new StringBuilder(); + + try (InputStream stream = getClass().getClassLoader().getResourceAsStream(fileName)) { + if (stream == null) { + return ""; + } + + try (Scanner scanner = new Scanner(stream, StandardCharsets.UTF_8)) { + while (scanner.hasNextLine()) { + result.append("§l§9" + Commands.CHAT_PREFIX + " §r").append(scanner.nextLine()).append("\n"); + } + } + } + + return result.toString().trim(); + } + + @Override + protected List complete() throws CommandException { + if (args.length != 1) { + return null; + } + + final ArrayList matches = new ArrayList<>(); + for (Command command : commandGroup.getCommands()) { + if (command.getName().startsWith(args[0])) { + matches.add(command.getName()); + } + } + return matches; + } +} diff --git a/src/main/java/fr/moribus/imageonmap/commands/IoMCommand.java b/src/main/java/fr/moribus/imageonmap/commands/IoMCommand.java index cc06be23..417c1856 100644 --- a/src/main/java/fr/moribus/imageonmap/commands/IoMCommand.java +++ b/src/main/java/fr/moribus/imageonmap/commands/IoMCommand.java @@ -37,11 +37,9 @@ package fr.moribus.imageonmap.commands; import fr.moribus.imageonmap.PluginConfiguration; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.map.ImageMap; import fr.moribus.imageonmap.map.MapManager; -import fr.zcraft.quartzlib.components.commands.Command; -import fr.zcraft.quartzlib.components.commands.CommandException; -import fr.zcraft.quartzlib.components.i18n.I; import java.net.URL; import java.util.ArrayList; import java.util.List; @@ -72,6 +70,7 @@ protected boolean checkHostnameWhitelist(final URL url) { .anyMatch(h -> h.equalsIgnoreCase(url.getHost())); } + @SuppressWarnings("deprecation") protected void retrieveUUID(final String arg, final Consumer consumer) { // If it is being removed we may have to use Mojang services consumer.accept(Bukkit.getOfflinePlayer(arg).getUniqueId()); diff --git a/src/main/java/fr/moribus/imageonmap/commands/WithFlags.java b/src/main/java/fr/moribus/imageonmap/commands/WithFlags.java new file mode 100644 index 00000000..3746c69d --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/commands/WithFlags.java @@ -0,0 +1,58 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.commands; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Makes a command accept flags, i.e. parameters prefixed with - or -- extracted from the args + * array and made available through {@link Command#hasFlag(String)}. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE}) +public @interface WithFlags { + /** + * The accepted flags. If empty, all flags are accepted. + */ + String[] value() default {}; +} diff --git a/src/main/java/fr/moribus/imageonmap/commands/maptool/DeleteCommand.java b/src/main/java/fr/moribus/imageonmap/commands/maptool/DeleteCommand.java index 1ea4e597..34ec03e9 100644 --- a/src/main/java/fr/moribus/imageonmap/commands/maptool/DeleteCommand.java +++ b/src/main/java/fr/moribus/imageonmap/commands/maptool/DeleteCommand.java @@ -37,20 +37,24 @@ package fr.moribus.imageonmap.commands.maptool; import fr.moribus.imageonmap.Permissions; +import fr.moribus.imageonmap.commands.Command; +import fr.moribus.imageonmap.commands.CommandException; +import fr.moribus.imageonmap.commands.CommandInfo; +import fr.moribus.imageonmap.commands.Commands; import fr.moribus.imageonmap.commands.IoMCommand; +import fr.moribus.imageonmap.commands.WithFlags; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.map.ImageMap; import fr.moribus.imageonmap.map.MapManager; import fr.moribus.imageonmap.map.MapManagerException; -import fr.zcraft.quartzlib.components.commands.CommandException; -import fr.zcraft.quartzlib.components.commands.CommandInfo; -import fr.zcraft.quartzlib.components.commands.WithFlags; -import fr.zcraft.quartzlib.components.i18n.I; -import fr.zcraft.quartzlib.components.rawtext.RawText; -import fr.zcraft.quartzlib.tools.PluginLogger; -import fr.zcraft.quartzlib.tools.text.RawMessage; +import fr.moribus.imageonmap.tools.PluginLogger; +import fr.moribus.imageonmap.tools.Text; import java.util.ArrayList; import java.util.List; -import org.bukkit.ChatColor; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -58,16 +62,18 @@ @WithFlags({"confirm"}) public class DeleteCommand extends IoMCommand { - private static RawText deleteMsg(Class klass, String playerName, ImageMap map) { - return new RawText(I.t("You are going to delete") + " ") - .then(map.getId()) - .color(ChatColor.GOLD) - .then(". " + I.t("Are you sure ? ")) - .color(ChatColor.WHITE) - .then(I.t("[Confirm]")) - .color(ChatColor.GREEN) - .hover(new RawText(I.t("{red}This map will be deleted {bold}forever{red}!"))) - .command(klass, playerName + ":" + "\"" + map.getId() + "\"", "--confirm") + private static Component deleteMsg(Class klass, String playerName, ImageMap map) { + final String confirmCommand = Commands.getCommandInfo(klass) + .build(playerName + ":" + "\"" + map.getId() + "\"", "--confirm"); + + return Component.text() + .append(Text.legacy(I.t("You are going to delete") + " ")) + .append(Component.text(map.getId(), NamedTextColor.GOLD)) + .append(Text.legacy(". " + I.t("Are you sure ? ")).color(NamedTextColor.WHITE)) + .append(Text.legacy(I.t("[Confirm]")).color(NamedTextColor.GREEN) + .hoverEvent(HoverEvent.showText(Text.legacy( + I.t("{red}This map will be deleted {bold}forever{red}!")))) + .clickEvent(ClickEvent.runCommand(confirmCommand))) .build(); } @@ -110,8 +116,7 @@ protected void run() throws CommandException { } if (!confirm) { - RawText msg = deleteMsg(getClass(), playerName, map); - RawMessage.send(sender, msg); + sender.sendMessage(deleteMsg(getClass(), playerName, map)); } else { if (sender != null && sender.isOnline() && sender.getInventory() != null) { MapManager.clear(sender.getInventory(), map); diff --git a/src/main/java/fr/moribus/imageonmap/commands/maptool/ExploreCommand.java b/src/main/java/fr/moribus/imageonmap/commands/maptool/ExploreCommand.java index ae05a3d6..bb7943c7 100644 --- a/src/main/java/fr/moribus/imageonmap/commands/maptool/ExploreCommand.java +++ b/src/main/java/fr/moribus/imageonmap/commands/maptool/ExploreCommand.java @@ -38,13 +38,13 @@ import fr.moribus.imageonmap.Permissions; +import fr.moribus.imageonmap.commands.CommandException; +import fr.moribus.imageonmap.commands.CommandInfo; import fr.moribus.imageonmap.commands.IoMCommand; import fr.moribus.imageonmap.gui.MapListGui; -import fr.zcraft.quartzlib.components.commands.CommandException; -import fr.zcraft.quartzlib.components.commands.CommandInfo; -import fr.zcraft.quartzlib.components.gui.Gui; -import fr.zcraft.quartzlib.components.i18n.I; -import fr.zcraft.quartzlib.tools.PluginLogger; +import fr.moribus.imageonmap.gui.framework.Gui; +import fr.moribus.imageonmap.i18n.I; +import fr.moribus.imageonmap.tools.PluginLogger; import java.util.ArrayList; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; diff --git a/src/main/java/fr/moribus/imageonmap/commands/maptool/GetCommand.java b/src/main/java/fr/moribus/imageonmap/commands/maptool/GetCommand.java index e527a31d..00b3e4e9 100644 --- a/src/main/java/fr/moribus/imageonmap/commands/maptool/GetCommand.java +++ b/src/main/java/fr/moribus/imageonmap/commands/maptool/GetCommand.java @@ -38,12 +38,12 @@ import fr.moribus.imageonmap.ImageOnMap; import fr.moribus.imageonmap.Permissions; +import fr.moribus.imageonmap.commands.CommandException; +import fr.moribus.imageonmap.commands.CommandInfo; import fr.moribus.imageonmap.commands.IoMCommand; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.map.ImageMap; import fr.moribus.imageonmap.map.MapManager; -import fr.zcraft.quartzlib.components.commands.CommandException; -import fr.zcraft.quartzlib.components.commands.CommandInfo; -import fr.zcraft.quartzlib.components.i18n.I; import java.util.ArrayList; import java.util.List; import org.bukkit.command.CommandSender; diff --git a/src/main/java/fr/moribus/imageonmap/commands/maptool/GetRemainingCommand.java b/src/main/java/fr/moribus/imageonmap/commands/maptool/GetRemainingCommand.java index 7a8c29f5..cf05f8c7 100644 --- a/src/main/java/fr/moribus/imageonmap/commands/maptool/GetRemainingCommand.java +++ b/src/main/java/fr/moribus/imageonmap/commands/maptool/GetRemainingCommand.java @@ -37,11 +37,11 @@ package fr.moribus.imageonmap.commands.maptool; import fr.moribus.imageonmap.Permissions; +import fr.moribus.imageonmap.commands.CommandException; +import fr.moribus.imageonmap.commands.CommandInfo; import fr.moribus.imageonmap.commands.IoMCommand; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.ui.MapItemManager; -import fr.zcraft.quartzlib.components.commands.CommandException; -import fr.zcraft.quartzlib.components.commands.CommandInfo; -import fr.zcraft.quartzlib.components.i18n.I; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; diff --git a/src/main/java/fr/moribus/imageonmap/commands/maptool/GiveCommand.java b/src/main/java/fr/moribus/imageonmap/commands/maptool/GiveCommand.java index 2751ab23..713fbc2a 100644 --- a/src/main/java/fr/moribus/imageonmap/commands/maptool/GiveCommand.java +++ b/src/main/java/fr/moribus/imageonmap/commands/maptool/GiveCommand.java @@ -37,12 +37,12 @@ package fr.moribus.imageonmap.commands.maptool; import fr.moribus.imageonmap.Permissions; +import fr.moribus.imageonmap.commands.CommandException; +import fr.moribus.imageonmap.commands.CommandInfo; import fr.moribus.imageonmap.commands.IoMCommand; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.map.ImageMap; import fr.moribus.imageonmap.map.MapManager; -import fr.zcraft.quartzlib.components.commands.CommandException; -import fr.zcraft.quartzlib.components.commands.CommandInfo; -import fr.zcraft.quartzlib.components.i18n.I; import java.util.ArrayList; import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; diff --git a/src/main/java/fr/moribus/imageonmap/commands/maptool/ListCommand.java b/src/main/java/fr/moribus/imageonmap/commands/maptool/ListCommand.java index b1fdc333..52ffd21e 100644 --- a/src/main/java/fr/moribus/imageonmap/commands/maptool/ListCommand.java +++ b/src/main/java/fr/moribus/imageonmap/commands/maptool/ListCommand.java @@ -37,19 +37,23 @@ package fr.moribus.imageonmap.commands.maptool; import fr.moribus.imageonmap.Permissions; +import fr.moribus.imageonmap.commands.CommandException; +import fr.moribus.imageonmap.commands.CommandInfo; +import fr.moribus.imageonmap.commands.Commands; import fr.moribus.imageonmap.commands.IoMCommand; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.map.ImageMap; import fr.moribus.imageonmap.map.MapManager; import fr.moribus.imageonmap.map.PosterMap; -import fr.zcraft.quartzlib.components.commands.CommandException; -import fr.zcraft.quartzlib.components.commands.CommandInfo; -import fr.zcraft.quartzlib.components.i18n.I; -import fr.zcraft.quartzlib.components.rawtext.RawText; -import fr.zcraft.quartzlib.components.rawtext.RawTextPart; -import fr.zcraft.quartzlib.tools.text.RawMessage; +import fr.moribus.imageonmap.tools.Text; import java.util.ArrayList; import java.util.List; -import org.bukkit.ChatColor; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -90,32 +94,32 @@ protected void run() throws CommandException { info(sender, I.tn("{white}{bold}{0} map found.", "{white}{bold}{0} maps found.", mapList.size())); - RawTextPart rawText = new RawText(""); - rawText = addMap(rawText, mapList.get(0)); + final TextComponent.Builder rawText = Component.text(); + addMap(rawText, mapList.get(0)); //TODO pagination chat for (int i = 1, c = mapList.size(); i < c; i++) { - rawText = rawText.then(", ").color(ChatColor.GRAY); - rawText = addMap(rawText, mapList.get(i)); + rawText.append(Component.text(", ", NamedTextColor.GRAY)); + addMap(rawText, mapList.get(i)); } - RawMessage.send(sender, rawText.build()); + sender.sendMessage(rawText.build()); }); } - private RawTextPart addMap(RawTextPart rawText, ImageMap map) { + private void addMap(TextComponent.Builder rawText, ImageMap map) { final String size = map.getType() == ImageMap.Type.SINGLE ? "1 × 1" : ((PosterMap) map).getColumnCount() + " × " + ((PosterMap) map).getRowCount(); - return rawText - .then(map.getId()) - .color(ChatColor.WHITE) - .command(GetCommand.class, map.getId()) - .hover(new RawText() - .then(map.getName()).style(ChatColor.BOLD, ChatColor.GREEN).then("\n") - .then(map.getId() + ", " + size).color(ChatColor.GRAY).then("\n\n") - .then(I.t("{white}Click{gray} to get this map")) - ); + rawText.append(Component.text(map.getId(), NamedTextColor.WHITE) + .clickEvent(ClickEvent.runCommand(Commands.getCommandInfo(GetCommand.class).build(map.getId()))) + .hoverEvent(HoverEvent.showText(Component.text() + .append(Component.text(map.getName(), NamedTextColor.GREEN, TextDecoration.BOLD)) + .append(Component.text("\n")) + .append(Component.text(map.getId() + ", " + size, NamedTextColor.GRAY)) + .append(Component.text("\n\n")) + .append(Text.legacy(I.t("{white}Click{gray} to get this map"))) + .build()))); } @Override diff --git a/src/main/java/fr/moribus/imageonmap/commands/maptool/MigrateCommand.java b/src/main/java/fr/moribus/imageonmap/commands/maptool/MigrateCommand.java index 993f3372..baed57a9 100644 --- a/src/main/java/fr/moribus/imageonmap/commands/maptool/MigrateCommand.java +++ b/src/main/java/fr/moribus/imageonmap/commands/maptool/MigrateCommand.java @@ -37,11 +37,11 @@ package fr.moribus.imageonmap.commands.maptool; import fr.moribus.imageonmap.Permissions; +import fr.moribus.imageonmap.commands.CommandException; +import fr.moribus.imageonmap.commands.CommandInfo; import fr.moribus.imageonmap.commands.IoMCommand; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.migration.MigratorExecutor; -import fr.zcraft.quartzlib.components.commands.CommandException; -import fr.zcraft.quartzlib.components.commands.CommandInfo; -import fr.zcraft.quartzlib.components.i18n.I; import org.bukkit.command.CommandSender; @CommandInfo(name = "migrate") diff --git a/src/main/java/fr/moribus/imageonmap/commands/maptool/NewCommand.java b/src/main/java/fr/moribus/imageonmap/commands/maptool/NewCommand.java index bef3a490..f984e036 100644 --- a/src/main/java/fr/moribus/imageonmap/commands/maptool/NewCommand.java +++ b/src/main/java/fr/moribus/imageonmap/commands/maptool/NewCommand.java @@ -37,22 +37,21 @@ package fr.moribus.imageonmap.commands.maptool; import fr.moribus.imageonmap.Permissions; +import fr.moribus.imageonmap.commands.CommandException; +import fr.moribus.imageonmap.commands.CommandInfo; import fr.moribus.imageonmap.commands.IoMCommand; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.image.ImageRendererExecutor; import fr.moribus.imageonmap.image.ImageUtils; import fr.moribus.imageonmap.map.ImageMap; import fr.moribus.imageonmap.map.MapManager; import fr.moribus.imageonmap.map.PosterMap; -import fr.zcraft.quartzlib.components.commands.CommandException; -import fr.zcraft.quartzlib.components.commands.CommandInfo; -import fr.zcraft.quartzlib.components.i18n.I; -import fr.zcraft.quartzlib.components.worker.WorkerCallback; -import fr.zcraft.quartzlib.tools.PluginLogger; -import fr.zcraft.quartzlib.tools.text.ActionBar; -import fr.zcraft.quartzlib.tools.text.MessageSender; +import fr.moribus.imageonmap.tools.ActionBar; +import fr.moribus.imageonmap.tools.ChatColor; +import fr.moribus.imageonmap.tools.PluginLogger; +import fr.moribus.imageonmap.worker.WorkerCallback; import java.net.MalformedURLException; import java.net.URL; -import org.bukkit.ChatColor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -77,6 +76,8 @@ private ImageUtils.ScalingType resizeMode() throws CommandException { } } + // URLs are parsed with the lenient java.net.URL parser on purpose, so that any link that worked before still works. + @SuppressWarnings("deprecation") @Override protected void run() throws CommandException { final Player player = playerSender(); @@ -159,8 +160,7 @@ protected void run() throws CommandException { @Override public void finished(ImageMap result) { ActionBar.removeMessage(player); - MessageSender - .sendActionBarMessage(player, ChatColor.DARK_GREEN + I.t("Rendering finished!")); + ActionBar.sendMessage(player, ChatColor.DARK_GREEN + I.t("Rendering finished!")); if (result.give(player) && (result instanceof PosterMap && !((PosterMap) result).hasColumnData())) { diff --git a/src/main/java/fr/moribus/imageonmap/commands/maptool/RenameCommand.java b/src/main/java/fr/moribus/imageonmap/commands/maptool/RenameCommand.java index 1059eb77..d05673e5 100644 --- a/src/main/java/fr/moribus/imageonmap/commands/maptool/RenameCommand.java +++ b/src/main/java/fr/moribus/imageonmap/commands/maptool/RenameCommand.java @@ -55,12 +55,12 @@ package fr.moribus.imageonmap.commands.maptool; import fr.moribus.imageonmap.Permissions; +import fr.moribus.imageonmap.commands.CommandException; +import fr.moribus.imageonmap.commands.CommandInfo; import fr.moribus.imageonmap.commands.IoMCommand; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.map.ImageMap; import fr.moribus.imageonmap.map.MapManager; -import fr.zcraft.quartzlib.components.commands.CommandException; -import fr.zcraft.quartzlib.components.commands.CommandInfo; -import fr.zcraft.quartzlib.components.i18n.I; import java.util.ArrayList; import java.util.List; import org.bukkit.command.CommandSender; diff --git a/src/main/java/fr/moribus/imageonmap/commands/maptool/UpdateCommand.java b/src/main/java/fr/moribus/imageonmap/commands/maptool/UpdateCommand.java index 3e843b5d..eabdb057 100644 --- a/src/main/java/fr/moribus/imageonmap/commands/maptool/UpdateCommand.java +++ b/src/main/java/fr/moribus/imageonmap/commands/maptool/UpdateCommand.java @@ -37,27 +37,28 @@ package fr.moribus.imageonmap.commands.maptool; import fr.moribus.imageonmap.Permissions; +import fr.moribus.imageonmap.commands.CommandException; +import fr.moribus.imageonmap.commands.CommandInfo; import fr.moribus.imageonmap.commands.IoMCommand; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.image.ImageRendererExecutor; import fr.moribus.imageonmap.image.ImageUtils; import fr.moribus.imageonmap.map.ImageMap; import fr.moribus.imageonmap.map.MapManager; -import fr.zcraft.quartzlib.components.commands.CommandException; -import fr.zcraft.quartzlib.components.commands.CommandInfo; -import fr.zcraft.quartzlib.components.i18n.I; -import fr.zcraft.quartzlib.components.worker.WorkerCallback; -import fr.zcraft.quartzlib.tools.PluginLogger; -import fr.zcraft.quartzlib.tools.text.ActionBar; -import fr.zcraft.quartzlib.tools.text.MessageSender; +import fr.moribus.imageonmap.tools.ActionBar; +import fr.moribus.imageonmap.tools.ChatColor; +import fr.moribus.imageonmap.tools.PluginLogger; +import fr.moribus.imageonmap.worker.WorkerCallback; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; -import org.bukkit.ChatColor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @CommandInfo(name = "update", usageParameters = "[player name]: [stretched|covered] ") public class UpdateCommand extends IoMCommand { + // URLs are parsed with the lenient java.net.URL parser on purpose, so that any link that worked before still works. + @SuppressWarnings("deprecation") @Override protected void run() throws CommandException { //TODO fix the issue where to many quick usage of offlineNameFetch will return null @@ -193,7 +194,7 @@ protected void run() throws CommandException { public void finished(ImageMap result) { if (playerSender != null) { ActionBar.removeMessage(playerSender); - MessageSender.sendActionBarMessage(playerSender, + ActionBar.sendMessage(playerSender, ChatColor.DARK_GREEN + I.t("The map was updated using the new image!")); } } diff --git a/src/main/java/fr/moribus/imageonmap/gui/ConfirmDeleteMapGui.java b/src/main/java/fr/moribus/imageonmap/gui/ConfirmDeleteMapGui.java index 5695a33f..8f72d431 100644 --- a/src/main/java/fr/moribus/imageonmap/gui/ConfirmDeleteMapGui.java +++ b/src/main/java/fr/moribus/imageonmap/gui/ConfirmDeleteMapGui.java @@ -37,17 +37,17 @@ package fr.moribus.imageonmap.gui; import fr.moribus.imageonmap.Permissions; +import fr.moribus.imageonmap.gui.framework.ActionGui; +import fr.moribus.imageonmap.gui.framework.Gui; +import fr.moribus.imageonmap.gui.framework.GuiAction; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.map.ImageMap; import fr.moribus.imageonmap.map.MapManager; import fr.moribus.imageonmap.map.MapManagerException; -import fr.zcraft.quartzlib.components.gui.ActionGui; -import fr.zcraft.quartzlib.components.gui.Gui; -import fr.zcraft.quartzlib.components.gui.GuiAction; -import fr.zcraft.quartzlib.components.i18n.I; -import fr.zcraft.quartzlib.tools.PluginLogger; -import fr.zcraft.quartzlib.tools.items.ItemStackBuilder; +import fr.moribus.imageonmap.tools.ChatColor; +import fr.moribus.imageonmap.tools.ItemStackBuilder; +import fr.moribus.imageonmap.tools.PluginLogger; import java.util.Random; -import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.inventory.ItemStack; diff --git a/src/main/java/fr/moribus/imageonmap/gui/MapDetailGui.java b/src/main/java/fr/moribus/imageonmap/gui/MapDetailGui.java index f224b192..39ef7d53 100644 --- a/src/main/java/fr/moribus/imageonmap/gui/MapDetailGui.java +++ b/src/main/java/fr/moribus/imageonmap/gui/MapDetailGui.java @@ -37,19 +37,19 @@ package fr.moribus.imageonmap.gui; import fr.moribus.imageonmap.Permissions; +import fr.moribus.imageonmap.gui.framework.ExplorerGui; +import fr.moribus.imageonmap.gui.framework.Gui; +import fr.moribus.imageonmap.gui.framework.GuiAction; +import fr.moribus.imageonmap.gui.framework.PromptGui; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.map.ImageMap; import fr.moribus.imageonmap.map.PosterMap; import fr.moribus.imageonmap.map.SingleMap; +import fr.moribus.imageonmap.tools.ItemStackBuilder; +import fr.moribus.imageonmap.tools.PluginLogger; +import fr.moribus.imageonmap.tools.RunTask; import fr.moribus.imageonmap.ui.MapItemManager; -import fr.zcraft.quartzlib.components.gui.ExplorerGui; -import fr.zcraft.quartzlib.components.gui.Gui; -import fr.zcraft.quartzlib.components.gui.GuiAction; -import fr.zcraft.quartzlib.components.gui.PromptGui; -import fr.zcraft.quartzlib.components.i18n.I; -import fr.zcraft.quartzlib.tools.PluginLogger; -import fr.zcraft.quartzlib.tools.items.ItemStackBuilder; -import fr.zcraft.quartzlib.tools.runners.RunTask; -import org.apache.commons.lang.ArrayUtils; +import java.util.Arrays; import org.bukkit.Material; import org.bukkit.OfflinePlayer; import org.bukkit.inventory.ItemStack; @@ -148,7 +148,7 @@ protected void onUpdate() { if (poster.hasColumnData()) { setDataShape(poster.getColumnCount(), poster.getRowCount()); } else { - setData(ArrayUtils.toObject(poster.getMapsIDs())); + setData(Arrays.stream(poster.getMapsIDs()).boxed().toArray(Integer[]::new)); } } else { setDataShape(1, 1); diff --git a/src/main/java/fr/moribus/imageonmap/gui/MapListGui.java b/src/main/java/fr/moribus/imageonmap/gui/MapListGui.java index 476ccd7a..db7384f0 100644 --- a/src/main/java/fr/moribus/imageonmap/gui/MapListGui.java +++ b/src/main/java/fr/moribus/imageonmap/gui/MapListGui.java @@ -38,21 +38,19 @@ import fr.moribus.imageonmap.Permissions; import fr.moribus.imageonmap.PluginConfiguration; +import fr.moribus.imageonmap.gui.framework.ExplorerGui; +import fr.moribus.imageonmap.gui.framework.Gui; +import fr.moribus.imageonmap.i18n.I; import fr.moribus.imageonmap.map.ImageMap; import fr.moribus.imageonmap.map.MapManager; import fr.moribus.imageonmap.map.PosterMap; import fr.moribus.imageonmap.map.SingleMap; +import fr.moribus.imageonmap.tools.ItemStackBuilder; import fr.moribus.imageonmap.ui.MapItemManager; import fr.moribus.imageonmap.ui.SplatterMapManager; -import fr.zcraft.quartzlib.components.gui.ExplorerGui; -import fr.zcraft.quartzlib.components.gui.Gui; -import fr.zcraft.quartzlib.components.i18n.I; -import fr.zcraft.quartzlib.tools.items.ItemStackBuilder; -import org.bukkit.Color; import org.bukkit.Material; import org.bukkit.OfflinePlayer; import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.MapMeta; public class MapListGui extends ExplorerGui { @@ -103,13 +101,7 @@ protected ItemStack getViewItem(ImageMap map) { builder.lore(I.tl(getPlayerLocale(), "{gray}» {white}Right-click{gray} for details and options")); - final ItemStack mapItem = builder.item(); - - final MapMeta meta = (MapMeta) mapItem.getItemMeta(); - meta.setColor(Color.GREEN); - mapItem.setItemMeta(meta); - - return mapItem; + return builder.item(); } @Override diff --git a/src/main/java/fr/moribus/imageonmap/gui/framework/ActionGui.java b/src/main/java/fr/moribus/imageonmap/gui/framework/ActionGui.java new file mode 100644 index 00000000..5d50e7ce --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/gui/framework/ActionGui.java @@ -0,0 +1,216 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.gui.framework; + +import fr.moribus.imageonmap.tools.ItemStackBuilder; +import fr.moribus.imageonmap.tools.PluginLogger; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.HashMap; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +/** + * An inventory GUI where items are buttons triggering actions, handled by methods annotated with {@link GuiAction}. + */ +public abstract class ActionGui extends InventoryGui { + private static final String ACTION_HANDLER_NAME = "action_"; + + private final Class guiClass = this.getClass(); + private final HashMap actions = new HashMap<>(); + + protected void action(String name, int slot, ItemStackBuilder item) { + action(name, slot, item.item()); + } + + protected void action(String name, int slot) { + action(name, slot, (ItemStack) null); + } + + /** + * Creates a new action. + * + * @param name The name of the action. + * @param slot The slot of the action button. + * @param item The item of the action button. + */ + protected void action(String name, int slot, ItemStack item) { + if (slot > getSize() || slot < 0) { + throw new IllegalArgumentException("Illegal slot ID"); + } + actions.put(slot, new Action(name, slot, item, getActionHandler(guiClass, name))); + } + + /** + * Changes the item of an action. + * + * @param name The name of the action. + * @param item The new item of the action button. + */ + protected void updateAction(String name, ItemStack item) { + for (Action action : actions.values()) { + if (action.name.equals(name)) { + action.item = item; + return; + } + } + throw new IllegalArgumentException("Unknown action name : " + name); + } + + @Override + protected abstract void onUpdate(); + + @Override + public void update() { + actions.clear(); + super.update(); + } + + @Override + protected void populate(Inventory inventory) { + for (Action action : actions.values()) { + inventory.setItem(action.slot, action.item); + } + } + + @Override + protected void onClick(InventoryClickEvent event) { + // The user clicked in their own inventory + if (event.getRawSlot() >= event.getInventory().getSize() + && !event.getAction().equals(InventoryAction.MOVE_TO_OTHER_INVENTORY)) { + return; + } + + event.setCancelled(true); + callAction(actions.get(event.getRawSlot()), event); + } + + private void callAction(Action action, InventoryClickEvent event) { + if (action == null || action.callback == null) { + return; + } + + try { + if (action.callback.getParameterTypes().length == 1) { + action.callback.invoke(this, event); + } else { + action.callback.invoke(this); + } + } catch (IllegalAccessException | IllegalArgumentException ex) { + PluginLogger.error("Could not invoke GUI action handler", ex); + } catch (InvocationTargetException ex) { + PluginLogger.error("Error while invoking action handler {0} of GUI {1}", + ex.getCause(), action.name, guiClass.getName()); + } + } + + private boolean isActionHandlerValid(Method method) { + final int parameterCount = method.getParameterTypes().length; + if (parameterCount >= 2) { + return false; + } + if (parameterCount == 1 && method.getParameterTypes()[0] != InventoryClickEvent.class) { + return false; + } + + try { + method.setAccessible(true); + } catch (SecurityException ex) { + return false; + } + + return true; + } + + private Method getActionHandler(Class klass, String name) { + if (name == null || name.isEmpty()) { + return null; + } + + do { + for (Method method : klass.getDeclaredMethods()) { + final GuiAction actionAnnotation = method.getAnnotation(GuiAction.class); + if (actionAnnotation == null) { + continue; + } + + if (!actionAnnotation.value().isEmpty()) { + if (actionAnnotation.value().equals(name) && isActionHandlerValid(method)) { + return method; + } + } else { + final String methodName = method.getName(); + if (methodName.equals(name) && isActionHandlerValid(method)) { + return method; + } + if (methodName.startsWith(ACTION_HANDLER_NAME) + && methodName.substring(ACTION_HANDLER_NAME.length()).equals(name) + && isActionHandlerValid(method)) { + return method; + } + } + } + + klass = klass.getSuperclass(); + } while (klass != null); + + return null; + } + + protected boolean hasActions() { + return !actions.isEmpty(); + } + + private static final class Action { + private final String name; + private final int slot; + private final Method callback; + private ItemStack item; + + private Action(String name, int slot, ItemStack item, Method callback) { + this.name = name; + this.slot = slot; + this.item = item; + this.callback = callback; + } + } +} diff --git a/src/main/java/fr/moribus/imageonmap/gui/framework/ExplorerGui.java b/src/main/java/fr/moribus/imageonmap/gui/framework/ExplorerGui.java new file mode 100644 index 00000000..4332b79f --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/gui/framework/ExplorerGui.java @@ -0,0 +1,578 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.gui.framework; + +import fr.moribus.imageonmap.tools.ChatColor; +import org.bukkit.Material; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +/** + * A GUI to explore a list (1D) or a grid (2D) of data, with pagination. + * + * @param The type of the explored data. + */ +public abstract class ExplorerGui extends ActionGui { + private T[] data; + private boolean isData2D = false; + + private int viewSize; + private int viewHeight; + private int viewWidth; + + private boolean keepHorizontalScrollingSpace = false; + private boolean keepVerticalScrollingSpace = false; + + private int currentPageX = 0; + private int currentPageY = 0; + + private int dataHeight = 0; + private int dataWidth = 0; + + private int pageCountX; + private int pageCountY; + + protected ExplorerGui() { + // Defined early to be able to use getSize() in the onUpdate method. + setSize(MAX_INVENTORY_SIZE); + } + + /** + * Sets the explored data, as a list. + * + * @param data The data. + */ + protected void setData(T[] data) { + this.data = data; + final int dataLength = data == null ? 0 : data.length; + setDataShape(0, dataLength); + } + + /** + * Sets the shape of the explored data. If the width is positive, the data is a grid. + * + * @param dataWidth The width of the grid. + * @param dataHeight The height of the grid. + */ + protected void setDataShape(int dataWidth, int dataHeight) { + this.dataWidth = dataWidth; + this.dataHeight = dataHeight; + this.isData2D = dataWidth > 0; + } + + protected boolean hasData() { + if (isData2D) { + return dataWidth > 0 && dataHeight > 0; + } else { + return data != null && data.length > 0; + } + } + + @Override + protected void populate(Inventory inventory) { + if (pageCountX > 1) { + updateAction("next", getPageItem("next", canGoNext())); + updateAction("previous", getPageItem("previous", canGoPrevious())); + } + + if (pageCountY > 1) { + updateAction("up", getPageItem("up", canGoUp())); + updateAction("down", getPageItem("down", canGoDown())); + } + + if (hasData()) { + if (!isData2D) { + final int start = currentPageX * viewSize; + final int max = Math.min(viewSize, data.length - start); + + for (int i = 0; i < max; i++) { + inventory.setItem(i, getViewItem(getData(i + start))); + } + } else { + final int startX = currentPageX * viewWidth; + final int startY = currentPageY * viewHeight; + + final int maxX = Math.min(viewWidth, dataWidth - startX); + final int maxY = Math.min(viewHeight, dataHeight - startY); + + for (int i = maxY; i-- > 0; ) { + for (int j = maxX; j-- > 0; ) { + inventory.setItem(i * INVENTORY_ROW_SIZE + j, getViewItem(j + startX, i + startY)); + } + } + } + } else { + updateAction("__empty__", getEmptyViewItem()); + } + + if (hasActions()) { + super.populate(inventory); + } + } + + @Override + protected void onClick(InventoryClickEvent event) { + final int slot = event.getRawSlot(); + + // Clicked in the action bar + if (hasActions() && slot >= MAX_INVENTORY_SIZE - INVENTORY_ROW_SIZE && slot < MAX_INVENTORY_SIZE) { + super.onClick(event); + return; + } + + if (isData2D && pageCountY > 1 && slot % INVENTORY_ROW_SIZE == INVENTORY_ROW_SIZE - 1) { + super.onClick(event); + return; + } + + // The user clicked in the GUI + if (affectsGui(event)) { + if (hasData()) { + switch (event.getAction()) { + case PICKUP_ALL: + case PICKUP_HALF: + case PICKUP_ONE: + case PICKUP_SOME: + case HOTBAR_SWAP: + case MOVE_TO_OTHER_INVENTORY: + onActionPickup(event); + break; + + case PLACE_ALL: + case PLACE_ONE: + case PLACE_SOME: + case SWAP_WITH_CURSOR: + onActionPut(event); + break; + + case DROP_ALL_CURSOR: + case DROP_ONE_CURSOR: + break; + + default: + event.setCancelled(true); + } + } else { + event.setCancelled(true); + } + } else { + if (event.getAction().equals(InventoryAction.MOVE_TO_OTHER_INVENTORY)) { + onActionMove(event); + } + } + } + + @Override + protected void onDrag(InventoryDragEvent event) { + if (!affectsGui(event)) { + return; + } + + for (int slot : event.getRawSlots()) { + // Clicked in the action bar + if (hasActions() && slot >= MAX_INVENTORY_SIZE - INVENTORY_ROW_SIZE && slot < MAX_INVENTORY_SIZE) { + super.onDrag(event); + return; + } + + if (isData2D && pageCountY > 1 && slot % INVENTORY_ROW_SIZE == INVENTORY_ROW_SIZE - 1) { + super.onDrag(event); + return; + } + } + + event.setCancelled(true); + + if (!onPutItem(event.getOldCursor())) { + return; + } + event.setCursor(new ItemStack(Material.AIR)); + } + + private void onActionPickup(InventoryClickEvent event) { + final ExplorerGuiEvent eventSlot = new ExplorerGuiEvent(this, event); + + if (event.getClick().equals(ClickType.RIGHT)) { + onRightClick(eventSlot); + event.setCancelled(true); + return; + } + + final ItemStack pickedUpItem = getPickedUpItem(eventSlot); + if (pickedUpItem == null) { + event.setCancelled(true); + return; + } + + event.setCurrentItem(pickedUpItem); + GuiUtils.setItemLater(this, event.getSlot(), getViewItem(eventSlot)); + } + + private void onActionPut(InventoryClickEvent event) { + event.setCancelled(true); + + if (!onPutItem(event.getCursor())) { + return; + } + event.getView().setCursor(new ItemStack(Material.AIR)); + } + + private void onActionMove(InventoryClickEvent event) { + event.setCancelled(true); + + if (!onPutItem(event.getCurrentItem())) { + return; + } + event.setCurrentItem(new ItemStack(Material.AIR)); + } + + @Override + protected void onAfterUpdate() { + // Calculating page count + if (data != null && data.length <= 0) { + viewWidth = INVENTORY_ROW_SIZE; + viewHeight = 1; + viewSize = viewWidth; + pageCountX = 1; + pageCountY = 1; + } else if (!isData2D) { + final int dataLength = (data == null) ? 0 : data.length; + viewWidth = INVENTORY_ROW_SIZE; + viewHeight = Math.min((int) Math.ceil((double) dataLength / (double) viewWidth), + MAX_INVENTORY_COLUMN_SIZE); + + if (viewHeight >= MAX_INVENTORY_COLUMN_SIZE + && (hasActions() || dataLength > MAX_INVENTORY_SIZE || keepHorizontalScrollingSpace)) { + viewHeight--; + } + + viewSize = viewWidth * viewHeight; + pageCountX = (int) Math.ceil((double) dataLength / (double) viewSize); + pageCountY = 1; + } else { + viewWidth = Math.min(dataWidth, INVENTORY_ROW_SIZE); + viewHeight = Math.min(dataHeight, MAX_INVENTORY_COLUMN_SIZE); + + pageCountX = (int) Math.ceil((double) dataWidth / (double) viewWidth); + pageCountY = (int) Math.ceil((double) dataHeight / (double) viewHeight); + + if (viewWidth >= INVENTORY_ROW_SIZE + && ((pageCountY > 1 && viewWidth == INVENTORY_ROW_SIZE) || keepVerticalScrollingSpace)) { + viewWidth--; + } + + if (viewHeight >= MAX_INVENTORY_COLUMN_SIZE + && ((pageCountX > 1 && viewHeight == MAX_INVENTORY_COLUMN_SIZE) || keepHorizontalScrollingSpace)) { + viewHeight--; + } + + pageCountX = (int) Math.ceil((double) dataWidth / (double) viewWidth); + pageCountY = (int) Math.ceil((double) dataHeight / (double) viewHeight); + } + + if (pageCountX > 1) { + action("previous", MAX_INVENTORY_SIZE - INVENTORY_ROW_SIZE); + action("next", MAX_INVENTORY_SIZE - 1 - (pageCountY > 1 ? 1 : 0)); + } + + if (pageCountY > 1) { + action("up", INVENTORY_ROW_SIZE - 1); + action("down", MAX_INVENTORY_SIZE - 1); + } + + if (!hasData()) { + action("__empty__", 22); + } + } + + private T getData(int i) { + if (i < 0 || i >= data.length) { + return null; + } + return data[i]; + } + + private ItemStack getViewItem(ExplorerGuiEvent event) { + if (!event.isValid) { + return null; + } + + if (isData2D) { + return getViewItem(event.dataX, event.dataY); + } else { + return getViewItem(getData(event.posData)); + } + } + + /** + * Returns the item displayed for the given grid position (2D data). + * + * @param x The column. + * @param y The row. + * @return The item. + */ + protected ItemStack getViewItem(int x, int y) { + return getViewItem(getData(y * dataWidth + x)); + } + + /** + * Returns the item displayed for the given data (1D data). + * + * @param data The data. + * @return The item. + */ + protected ItemStack getViewItem(T data) { + return null; + } + + protected ItemStack getEmptyViewItem() { + return GuiUtils.makeItem(Material.BARRIER, ChatColor.RED + "Empty", + ChatColor.GRAY + "There's nothing to see here"); + } + + private ItemStack getPickedUpItem(ExplorerGuiEvent event) { + if (!event.isValid) { + return null; + } + + if (isData2D) { + return getPickedUpItem(event.dataX, event.dataY); + } else { + return getPickedUpItemAt(event.posData); + } + } + + /** + * Returns the item given to the player when they pick up the given grid position (2D data). + * + * @param x The column. + * @param y The row. + * @return The item, or {@code null} to cancel the pickup. + */ + protected ItemStack getPickedUpItem(int x, int y) { + return getViewItem(x, y); + } + + /** + * Returns the item given to the player when they pick up the given data (1D data). + * + * @param data The data. + * @return The item, or {@code null} to cancel the pickup. + */ + protected ItemStack getPickedUpItem(T data) { + return getViewItem(data); + } + + private ItemStack getPickedUpItemAt(int dataIndex) { + if (dataIndex < 0 || dataIndex >= data.length) { + return null; + } + return getPickedUpItem(getData(dataIndex)); + } + + protected ItemStack getPageItem(String paginationButtonType, boolean canUse) { + final String title; + final int newPage; + final int lastPage; + + switch (paginationButtonType) { + case "next": + title = canUse ? "Next page" : "No next page"; + newPage = currentPageX + 1; + lastPage = getPageCount(); + break; + case "previous": + title = canUse ? "Previous page" : "No previous page"; + newPage = currentPageX - 1; + lastPage = getPageCount(); + break; + case "up": + title = canUse ? "Go up" : "Top page"; + newPage = currentPageY - 1; + lastPage = getVerticalPageCount(); + break; + case "down": + title = canUse ? "Go down" : "Bottom page"; + newPage = currentPageY + 1; + lastPage = getVerticalPageCount(); + break; + default: + return null; // invalid page type + } + + final Material material = canUse ? Material.ARROW : Material.STICK; + final String displayName = (canUse ? ChatColor.WHITE : ChatColor.GRAY) + title; + + if (canUse) { + return GuiUtils.makeItem(material, displayName, ChatColor.GRAY + "Go to page " + ChatColor.WHITE + + (newPage + 1) + ChatColor.GRAY + " of " + ChatColor.WHITE + lastPage); + } + return GuiUtils.makeItem(material, displayName); + } + + private void onRightClick(ExplorerGuiEvent event) { + if (!event.isValid) { + return; + } + + if (isData2D) { + onRightClick(event.dataX, event.dataY); + } else { + onRightClick(getData(event.posData)); + } + } + + protected void onRightClick(int x, int y) { + } + + protected void onRightClick(T data) { + } + + protected boolean onPutItem(ItemStack item) { + return true; + } + + @GuiAction + public void next() { + setCurrentPageX(currentPageX + 1); + } + + @GuiAction + public void previous() { + setCurrentPageX(currentPageX - 1); + } + + @GuiAction + public void up() { + setCurrentPageY(currentPageY - 1); + } + + @GuiAction + public void down() { + setCurrentPageY(currentPageY + 1); + } + + public boolean canGoNext() { + return currentPageX < pageCountX - 1; + } + + public boolean canGoPrevious() { + return currentPageX > 0; + } + + public boolean canGoUp() { + return currentPageY > 0; + } + + public boolean canGoDown() { + return currentPageY < pageCountY - 1; + } + + public void setCurrentPageX(int currentPageX) { + setCurrentPage(currentPageX, currentPageY); + } + + public void setCurrentPageY(int currentPageY) { + setCurrentPage(currentPageX, currentPageY); + } + + /** + * Changes the displayed page. + * + * @param currentPageX The horizontal page. + * @param currentPageY The vertical page. + */ + public void setCurrentPage(int currentPageX, int currentPageY) { + if (currentPageX < 0 || currentPageX > pageCountX - 1) { + return; + } + + if (isData2D) { + if (currentPageY < 0 || currentPageY > pageCountY - 1) { + return; + } + this.currentPageY = currentPageY; + } + + this.currentPageX = currentPageX; + refresh(); + } + + public int getPageCount() { + return pageCountX; + } + + public int getVerticalPageCount() { + return pageCountY; + } + + public void setKeepHorizontalScrollingSpace(boolean keepHorizontalScrollingSpace) { + this.keepHorizontalScrollingSpace = keepHorizontalScrollingSpace; + } + + public void setKeepVerticalScrollingSpace(boolean keepVerticalScrollingSpace) { + this.keepVerticalScrollingSpace = keepVerticalScrollingSpace; + } + + private static final class ExplorerGuiEvent { + private final int dataX; + private final int dataY; + private final int posData; + private final boolean isValid; + + private ExplorerGuiEvent(ExplorerGui gui, InventoryClickEvent event) { + if (gui.isData2D) { + dataX = gui.currentPageX * gui.viewWidth + event.getSlot() % INVENTORY_ROW_SIZE; + dataY = gui.currentPageY * gui.viewHeight + event.getSlot() / INVENTORY_ROW_SIZE; + posData = -1; + isValid = dataX < gui.dataWidth && dataX >= 0 && dataY < gui.dataHeight && dataY >= 0; + } else { + dataX = -1; + dataY = -1; + posData = gui.currentPageX * gui.viewSize + event.getSlot(); + isValid = posData >= 0 && posData < gui.dataHeight; + } + } + } +} diff --git a/src/main/java/fr/moribus/imageonmap/gui/framework/Gui.java b/src/main/java/fr/moribus/imageonmap/gui/framework/Gui.java new file mode 100644 index 00000000..51f28eaf --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/gui/framework/Gui.java @@ -0,0 +1,137 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.gui.framework; + +import fr.moribus.imageonmap.ImageOnMap; +import fr.moribus.imageonmap.tools.RunTask; +import java.util.HashMap; +import java.util.Map; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; + +/** + * Manages the GUIs opened by the players. + */ +public final class Gui { + private static final Map openGuis = new HashMap<>(); + private static Listener quitListener = null; + + private Gui() { + } + + /** + * Initializes the GUI system. + * + * @param plugin The plugin. + */ + public static void init(ImageOnMap plugin) { + openGuis.clear(); + quitListener = new Listener() { + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + final GuiBase openGui = openGuis.get(event.getPlayer()); + if (openGui != null) { + openGui.registerClose(); + } + } + }; + Bukkit.getPluginManager().registerEvents(quitListener, plugin); + } + + /** + * Closes the GUI system. + */ + public static void exit() { + for (GuiBase openGui : openGuis.values().toArray(new GuiBase[0])) { + openGui.registerClose(); + } + openGuis.clear(); + if (quitListener != null) { + HandlerList.unregisterAll(quitListener); + quitListener = null; + } + } + + /** + * Opens a GUI for a player. + * + * @param owner The player the GUI will be shown to. + * @param gui The GUI. + * @param parent The parent of the newly created GUI. Can be null. + * @param A GUI type. + * @return The opened GUI. + */ + public static T open(final Player owner, final T gui, final GuiBase parent) { + final GuiBase openGui = openGuis.get(owner); + if (openGui != null) { + openGui.registerClose(); + } + if (parent != null) { + gui.setParent(parent); + } + + RunTask.later(() -> gui.open(owner), 0); + return gui; + } + + public static T open(Player owner, T gui) { + return open(owner, gui, null); + } + + static void registerEvents(Listener listener) { + Bukkit.getPluginManager().registerEvents(listener, ImageOnMap.getPlugin()); + } + + static void unregisterEvents(Listener listener) { + HandlerList.unregisterAll(listener); + } + + static void registerGuiOpen(Player player, GuiBase gui) { + openGuis.put(player, gui); + } + + static void registerGuiClose(GuiBase gui) { + openGuis.remove(gui.getPlayer(), gui); + } +} diff --git a/src/main/java/fr/moribus/imageonmap/gui/framework/GuiAction.java b/src/main/java/fr/moribus/imageonmap/gui/framework/GuiAction.java new file mode 100644 index 00000000..54e4ea2d --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/gui/framework/GuiAction.java @@ -0,0 +1,58 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.gui.framework; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a method of an {@link ActionGui} as the handler of an action. If no name is given, the name of the method is + * used as the action name. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD}) +public @interface GuiAction { + /** + * The name of the action. + */ + String value() default ""; +} diff --git a/src/main/java/fr/moribus/imageonmap/gui/framework/GuiBase.java b/src/main/java/fr/moribus/imageonmap/gui/framework/GuiBase.java new file mode 100644 index 00000000..3d9e7060 --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/gui/framework/GuiBase.java @@ -0,0 +1,163 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.gui.framework; + +import fr.moribus.imageonmap.i18n.I18n; +import java.util.Locale; +import org.bukkit.entity.Player; +import org.bukkit.event.Listener; + +/** + * Base class of all GUIs. + */ +public abstract class GuiBase { + private Player player; + private Locale playerLocale; + private GuiBase parent; + private Listener listener; + private boolean open = false; + private boolean immune = false; + + /** + * Asks the GUI to update its data, and display it to the player. + */ + public void update() { + onUpdate(); + onAfterUpdate(); + } + + /** + * Closes this GUI. If it has a parent GUI, the parent is opened again. + */ + public void close() { + close(false); + } + + protected void close(boolean immune) { + registerClose(); + if (parent != null) { + Gui.open(player, parent).setImmune(immune); + } + } + + void registerClose() { + if (!open) { + return; + } + + open = false; + Gui.registerGuiClose(this); + if (listener != null) { + Gui.unregisterEvents(listener); + } + + onClose(); + } + + protected void onUpdate() { + } + + protected void onAfterUpdate() { + } + + protected Listener getEventListener() { + return null; + } + + protected void onClose() { + } + + protected void open(Player player) { + this.player = player; + this.playerLocale = I18n.getPlayerLocale(player); + + Gui.registerGuiOpen(player, this); + update(); + + if (listener == null) { + listener = getEventListener(); + } + if (listener != null) { + Gui.registerEvents(listener); + } + + open = true; + } + + public final boolean isOpen() { + return open; + } + + public final GuiBase getParent() { + return parent; + } + + void setParent(GuiBase parent) { + if (parent == this) { + throw new IllegalArgumentException("A GUI cannot be its own parent."); + } + this.parent = parent; + } + + protected final Player getPlayer() { + return player; + } + + protected final Locale getPlayerLocale() { + return playerLocale; + } + + /** + * Checks if the GUI is immune to the next close event, and consumes this immunity. + * + * @return {@code true} if the GUI was immune. + */ + protected boolean checkImmune() { + if (!immune) { + return false; + } + immune = false; + return true; + } + + private void setImmune(boolean immune) { + this.immune = immune; + } +} diff --git a/src/main/java/fr/moribus/imageonmap/gui/framework/GuiUtils.java b/src/main/java/fr/moribus/imageonmap/gui/framework/GuiUtils.java new file mode 100644 index 00000000..5399fdd0 --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/gui/framework/GuiUtils.java @@ -0,0 +1,180 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.gui.framework; + +import fr.moribus.imageonmap.tools.ChatColor; +import fr.moribus.imageonmap.tools.RunTask; +import fr.moribus.imageonmap.tools.Text; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import org.bukkit.Material; +import org.bukkit.entity.HumanEntity; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +/** + * Various utilities for GUIs and texts. + */ +public final class GuiUtils { + private GuiUtils() { + } + + /** + * Sets an item in the inventory of a GUI on the next tick. + * + * @param gui The GUI. + * @param slot The slot. + * @param item The item. + */ + public static void setItemLater(InventoryGui gui, int slot, ItemStack item) { + RunTask.nextTick(() -> { + final Inventory inventory = gui.getInventory(); + inventory.setItem(slot, item); + for (HumanEntity player : inventory.getViewers()) { + if (player instanceof Player) { + ((Player) player).updateInventory(); + } + } + }); + } + + public static ItemStack makeItem(Material material, String title, String... loreLines) { + return makeItem(new ItemStack(material), title, Arrays.asList(loreLines)); + } + + /** + * Sets the title and lore of an item. + * + * @param itemStack The item. + * @param title The title (legacy formatting codes allowed). If {@code null}, the title is not changed. + * @param loreLines The lore (legacy formatting codes allowed). If {@code null} or empty, the lore is not changed. + * @return The item. + */ + public static ItemStack makeItem(ItemStack itemStack, String title, List loreLines) { + final ItemMeta meta = Objects.requireNonNull(itemStack.getItemMeta()); + + if (title != null) { + meta.displayName(Text.legacy(title)); + } + if (loreLines != null && !loreLines.isEmpty()) { + meta.lore(Text.legacy(loreLines)); + } + + itemStack.setItemMeta(meta); + return itemStack; + } + + public static List generateLore(String text) { + return generateLore(text, 28); + } + + /** + * Splits a text into lines of the given length, keeping the formatting. + * + * @param text The text. + * @param lineLength The maximal length of a line (formatting codes excluded). + * @return The lines. + */ + public static List generateLore(String text, int lineLength) { + final List lore = new ArrayList<>(); + final String[] segments = text.split("\n"); + String previousSegmentFormatting = ""; + + // We divide by segments of blocks without line break to keep these breaks in the final result + for (String segment : segments) { + segment = previousSegmentFormatting + segment; + + if (ChatColor.stripColor(segment).length() <= lineLength) { + lore.add(segment); + continue; + } + + final String[] words = segment.split(" "); + String currentLine = ""; + + for (final String word : words) { + if (ChatColor.stripColor(currentLine + word).length() > lineLength + && ChatColor.stripColor(word).length() <= lineLength) { + lore.add(currentLine.trim()); + // It's important to preserve the formatting + currentLine = ChatColor.getLastColors(currentLine) + word + " "; + } else { + currentLine += word + " "; + } + } + + // Don't forget the last line... + if (!ChatColor.stripColor(currentLine.trim()).isEmpty()) { + lore.add(currentLine.trim()); + } + + previousSegmentFormatting = ChatColor.getLastColors(lore.get(lore.size() - 1)); + } + + return lore; + } + + public static String generatePrefixedFixedLengthString(String prefix, String text) { + return generatePrefixedFixedLengthString(prefix, text, 55); + } + + /** + * Splits a text into lines of the given length, and prefixes each of them. + * + * @param prefix The prefix. + * @param text The text. + * @param lineLength The maximal length of a line (prefix and formatting codes excluded). + * @return The prefixed lines, joined with line breaks. + */ + public static String generatePrefixedFixedLengthString(String prefix, String text, int lineLength) { + final List lines = generateLore(text, lineLength); + final StringBuilder result = new StringBuilder(); + + for (final String line : lines) { + result.append(prefix).append(line).append('\n'); + } + + return result.toString().trim(); + } +} diff --git a/src/main/java/fr/moribus/imageonmap/gui/framework/InventoryGui.java b/src/main/java/fr/moribus/imageonmap/gui/framework/InventoryGui.java new file mode 100644 index 00000000..0d9e1074 --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/gui/framework/InventoryGui.java @@ -0,0 +1,218 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.gui.framework; + +import fr.moribus.imageonmap.tools.RunTask; +import fr.moribus.imageonmap.tools.Text; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; + +/** + * Base class of the inventory-based GUIs. + */ +public abstract class InventoryGui extends GuiBase { + protected static final int INVENTORY_ROW_SIZE = 9; + protected static final int MAX_INVENTORY_COLUMN_SIZE = 6; + protected static final int MAX_INVENTORY_SIZE = INVENTORY_ROW_SIZE * MAX_INVENTORY_COLUMN_SIZE; + protected static final int MAX_TITLE_LENGTH = 32; + + private int size = 0; + private String title = "Inventory"; + private String inventoryTitle = null; + private Inventory inventory; + + protected static boolean affectsGui(final InventoryDragEvent event) { + for (int slot : event.getRawSlots()) { + if (slot < event.getInventory().getSize()) { + return true; + } + } + return false; + } + + protected static boolean affectsGui(final InventoryClickEvent event) { + return event.getRawSlot() < event.getInventory().getSize(); + } + + /** + * Refreshes the content of the inventory, without updating the GUI data. + */ + public void refresh() { + inventory.clear(); + populate(inventory); + } + + @Override + protected void open(final Player player) { + super.open(player); + player.openInventory(inventory); + } + + @Override + public void close() { + if (!isOpen()) { + return; + } + + // If close() is called manually, not from InventoryCloseEvent. + // Ran on the next tick because it's unsafe to call Player.closeInventory() from an InventoryEvent. + RunTask.nextTick(() -> { + if (isDisplayedToPlayer()) { + getPlayer().closeInventory(); + } + }); + + super.close(); + } + + protected abstract void populate(final Inventory inventory); + + protected abstract void onClick(final InventoryClickEvent event); + + protected void onDrag(final InventoryDragEvent event) { + if (affectsGui(event)) { + event.setCancelled(true); + } + } + + @Override + public void update() { + super.update(); + + final Player player = getPlayer(); + + // The inventory is updated in place if the player is currently looking at it and if neither its title nor its + // size changed; else, it is re-created. + if (inventory != null && isDisplayedToPlayer() && title.equals(inventoryTitle) + && inventory.getSize() == size) { + refresh(); + } else { + inventory = Bukkit.createInventory(player, size, Text.legacy(title)); + inventoryTitle = title; + populate(inventory); + + if (isOpen()) { + RunTask.nextTick(() -> { + player.closeInventory(); + RunTask.nextTick(() -> player.openInventory(inventory)); + }); + } + } + } + + private boolean isDisplayedToPlayer() { + if (inventory == null || getPlayer() == null) { + return false; + } + final InventoryView openInventoryView = getPlayer().getOpenInventory(); + return inventory.equals(openInventoryView.getTopInventory()); + } + + protected final int getSize() { + return size; + } + + protected final void setSize(final int size) { + this.size = Math.min(((int) (Math.ceil((double) size / INVENTORY_ROW_SIZE))) * INVENTORY_ROW_SIZE, + MAX_INVENTORY_SIZE); + } + + protected String getTitle() { + return title; + } + + protected void setTitle(String title) { + if (title != null && title.length() > MAX_TITLE_LENGTH) { + title = title.substring(0, MAX_TITLE_LENGTH - 4) + "..."; + } + this.title = title != null ? title : ""; + } + + public Inventory getInventory() { + return inventory; + } + + @Override + protected Listener getEventListener() { + return new InventoryGuiListener(); + } + + protected class InventoryGuiListener implements Listener { + @EventHandler + public void onInventoryDrag(final InventoryDragEvent event) { + if (event.getWhoClicked() != getPlayer()) { + return; + } + onDrag(event); + } + + @EventHandler + public void onInventoryClick(final InventoryClickEvent event) { + if (event.getWhoClicked() != getPlayer()) { + return; + } + onClick(event); + } + + @EventHandler + public void onInventoryClose(final InventoryCloseEvent event) { + if (event.getPlayer() != getPlayer()) { + return; + } + if (!event.getInventory().equals(inventory)) { + return; + } + + if (isOpen()) { + if (checkImmune()) { + return; + } + close(); + } + } + } +} diff --git a/src/main/java/fr/moribus/imageonmap/gui/framework/PromptGui.java b/src/main/java/fr/moribus/imageonmap/gui/framework/PromptGui.java new file mode 100644 index 00000000..7ffa0081 --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/gui/framework/PromptGui.java @@ -0,0 +1,224 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.gui.framework; + +import fr.moribus.imageonmap.tools.RunTask; +import io.papermc.paper.event.packet.UncheckedSignChangeEvent; +import io.papermc.paper.math.BlockPosition; +import io.papermc.paper.math.Position; +import java.util.List; +import java.util.function.Consumer; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.Sign; +import org.bukkit.block.TileState; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.sign.Side; +import org.bukkit.block.sign.SignSide; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; + +/** + * A GUI asking the player for a text, using a sign editor. + * + *

The sign only exists client-side (it is never placed in the world): it is displayed a few blocks below the + * player while they edit it, and removed afterwards.

+ */ +public class PromptGui extends GuiBase { + private static final int SIGN_LINES_COUNT = 4; + private static final int SIGN_COLUMNS_COUNT = 15; + + /** + * How many blocks below the player the virtual sign is displayed. The client only allows editing signs close to + * the player. + */ + private static final int SIGN_DEPTH = 3; + + private final Consumer callback; + private final String contents; + private Location signLocation; + + public PromptGui(Consumer callback, String contents) { + this.callback = callback; + this.contents = contents; + } + + /** + * Prompts a player for a text. + * + * @param owner The player. + * @param callback Called with the text entered by the player. + * @param contents The text initially displayed. + * @param parent The parent GUI, re-opened after the prompt (can be {@code null}). + */ + public static void prompt(Player owner, Consumer callback, String contents, GuiBase parent) { + Gui.open(owner, new PromptGui(callback, contents), parent); + } + + private static String getSignContents(List lines) { + final StringBuilder content = new StringBuilder(); + + for (int i = 0; i < lines.size(); i++) { + final String line = PlainTextComponentSerializer.plainText().serialize(lines.get(i)); + if (i == 0) { + content.append(line.trim()); + } else if (!line.isEmpty()) { + content.append(" ").append(line.trim()); + } + } + + return content.toString().trim(); + } + + private static String[] splitSignContents(String content) { + final String[] lines = new String[SIGN_LINES_COUNT + 1]; + int curLineIndex = 0; + + if (content != null) { + lines[0] = content; + + while (curLineIndex < SIGN_LINES_COUNT) { + final String curLine = lines[curLineIndex]; + if (curLine.length() <= SIGN_COLUMNS_COUNT) { + break; + } + + final int spacePos = curLine.lastIndexOf(' ', SIGN_COLUMNS_COUNT); + if (spacePos < 0) { + break; + } + + lines[curLineIndex + 1] = curLine.substring(spacePos + 1); + lines[curLineIndex] = curLine.substring(0, spacePos); + curLineIndex++; + } + } + + return lines; + } + + private static Location findAvailableLocation(Player player) { + final Location location = player.getLocation().getBlock().getLocation(); + location.setY(Math.max(location.getBlockY() - SIGN_DEPTH, player.getWorld().getMinHeight())); + return location; + } + + @Override + protected void open(final Player player) { + super.open(player); + + signLocation = findAvailableLocation(player); + + final BlockData signData = Material.OAK_SIGN.createBlockData(); + final Sign sign = (Sign) signData.createBlockState(); + final SignSide side = sign.getSide(Side.FRONT); + final String[] lines = splitSignContents(contents); + + for (int i = 0; i < SIGN_LINES_COUNT; i++) { + side.line(i, Component.text(lines[i] != null ? lines[i] : "")); + } + + player.sendBlockChange(signLocation, signData); + player.sendBlockUpdate(signLocation, sign); + + RunTask.later(() -> { + if (isOpen() && player.isOnline()) { + player.openVirtualSign(Position.block(signLocation), Side.FRONT); + } + }, 3); + } + + @Override + protected void onClose() { + final Player player = getPlayer(); + + if (signLocation != null && player != null && player.isOnline() + && player.getWorld().equals(signLocation.getWorld())) { + final Block block = signLocation.getBlock(); + player.sendBlockChange(signLocation, block.getBlockData()); + + final BlockState state = block.getState(); + if (state instanceof TileState) { + player.sendBlockUpdate(signLocation, (TileState) state); + } + } + + super.onClose(); + } + + private void validate(List lines) { + callback.accept(getSignContents(lines)); + this.close(true); + } + + @Override + protected Listener getEventListener() { + return new PromptGuiListener(); + } + + private final class PromptGuiListener implements Listener { + @EventHandler + public void onSignChange(UncheckedSignChangeEvent event) { + if (event.getPlayer() != getPlayer() || signLocation == null) { + return; + } + + final BlockPosition position = event.getEditedBlockPosition(); + if (position.blockX() != signLocation.getBlockX() || position.blockY() != signLocation.getBlockY() + || position.blockZ() != signLocation.getBlockZ()) { + return; + } + + event.setCancelled(true); + + final List lines = event.lines(); + RunTask.nextTick(() -> { + if (isOpen()) { + validate(lines); + } + }); + } + } +} diff --git a/src/main/java/fr/moribus/imageonmap/i18n/I.java b/src/main/java/fr/moribus/imageonmap/i18n/I.java new file mode 100644 index 00000000..add38c63 --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/i18n/I.java @@ -0,0 +1,127 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.i18n; + +import java.util.Locale; +import org.bukkit.entity.Player; + +/** + * Short translation methods. + * + *

Messages are gettext {@code msgid}s, formatted with {@link java.text.MessageFormat} parameters and + * user-friendly formatting codes like {@code {red}} or {@code {ce}}.

+ */ +public final class I { + private I() { + } + + /** + * Translates a message into the primary locale. + * + * @param text The message to translate. + * @param parameters The parameters of the message. + * @return The translated message. + */ + public static String t(String text, Object... parameters) { + return I18n.translate(null, null, text, null, null, parameters); + } + + /** + * Translates a message into the given locale. + * + * @param locale The locale (if {@code null}, the primary locale is used). + * @param text The message to translate. + * @param parameters The parameters of the message. + * @return The translated message. + */ + public static String t(Locale locale, String text, Object... parameters) { + return I18n.translate(locale, null, text, null, null, parameters); + } + + /** + * Translates a message with plurals into the primary locale. + * + * @param singular The singular form of the message. + * @param plural The plural form of the message. + * @param count The count used to select the plural form. + * @param parameters The parameters of the message. If none, the count is used as {@code {0}}. + * @return The translated message. + */ + public static String tn(String singular, String plural, Integer count, Object... parameters) { + return I18n.translate(null, null, singular, plural, count, parameters); + } + + /** + * Translates a message with plurals into the given locale. + * + * @param locale The locale (if {@code null}, the primary locale is used). + * @param singular The singular form of the message. + * @param plural The plural form of the message. + * @param count The count used to select the plural form. + * @param parameters The parameters of the message. If none, the count is used as {@code {0}}. + * @return The translated message. + */ + public static String tn(Locale locale, String singular, String plural, Integer count, Object... parameters) { + return I18n.translate(locale, null, singular, plural, count, parameters); + } + + /** + * Translates a message into the given locale. + * + * @param locale The locale (if {@code null}, the primary locale is used). + * @param text The message to translate. + * @param parameters The parameters of the message. + * @return The translated message. + */ + public static String tl(Locale locale, String text, Object... parameters) { + return I18n.translate(locale, null, text, null, null, parameters); + } + + /** + * Translates a message into the locale of the given player, and sends it to them. + * + * @param player The receiver. + * @param text The message to translate. + * @param parameters The parameters of the message. + */ + public static void sendT(Player player, String text, Object... parameters) { + player.sendMessage(I18n.translate(I18n.getPlayerLocale(player), null, text, null, null, parameters)); + } +} diff --git a/src/main/java/fr/moribus/imageonmap/i18n/I18n.java b/src/main/java/fr/moribus/imageonmap/i18n/I18n.java new file mode 100644 index 00000000..a6d343c7 --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/i18n/I18n.java @@ -0,0 +1,421 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.i18n; + +import fr.moribus.imageonmap.tools.ChatColor; +import fr.moribus.imageonmap.tools.PluginLogger; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.text.MessageFormat; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +/** + * Internationalization: loads gettext {@code .po} translation files and translates messages. + * + *

Translations are loaded from the {@code i18n} directory of the plugin JAR, and then from the {@code i18n} + * directory of the plugin data folder, whose translations take precedence.

+ */ +public final class I18n { + private static final String I18N_DIRECTORY = "i18n"; + private static final int JAR_PRIORITY = 0; + private static final int DATA_FOLDER_PRIORITY = 100; + + private static final Map> translators = new ConcurrentHashMap<>(); + + private static final String ERROR_COLOR = ChatColor.RED.toString(); + private static final String NOTICE_COLOR = ChatColor.WHITE.toString(); + private static final String SUCCESS_COLOR = ChatColor.GREEN.toString(); + private static final String STATUS_COLOR = ChatColor.GRAY.toString(); + private static final String COMMAND_COLOR = ChatColor.GOLD.toString(); + + private static Locale primaryLocale = null; + private static Locale fallbackLocale = null; + + private I18n() { + } + + /** + * Loads the translations of the given plugin. + * + * @param plugin The plugin. + * @param jarFile The plugin JAR file. + */ + public static void init(JavaPlugin plugin, File jarFile) { + translators.clear(); + setPrimaryLocale(Locale.getDefault()); + fallbackLocale = Locale.US; + + if (jarFile != null) { + loadFromJar(plugin, jarFile); + } + load(new File(plugin.getDataFolder(), I18N_DIRECTORY)); + } + + /** + * Unloads all translations. + */ + public static void exit() { + translators.clear(); + } + + private static void loadFromJar(JavaPlugin plugin, File file) { + final String directory = I18N_DIRECTORY + "/"; + + try (JarFile jarFile = new JarFile(file)) { + final Enumeration entries = jarFile.entries(); + while (entries.hasMoreElements()) { + final String name = entries.nextElement().getName(); + if (!name.startsWith(directory) || name.endsWith("/") || !name.endsWith(".po")) { + continue; + } + + final String[] nameParts = name.split("/"); + final String basename = nameParts[nameParts.length - 1]; + final ReaderSupplier reader = () -> { + final InputStream stream = plugin.getResource(name); + return stream != null ? new InputStreamReader(stream, StandardCharsets.UTF_8) : null; + }; + registerTranslator(new Translator(localeFromString(basename.split("\\.")[0]), "jar:" + name, + JAR_PRIORITY, reader)); + } + } catch (IOException e) { + PluginLogger.error("Unable to load JAR file {0}", e, file.getAbsolutePath()); + } + } + + private static void load(final File file) { + if (!file.exists()) { + return; + } + + if (file.isDirectory()) { + final File[] children = file.listFiles(); + if (children == null) { + return; + } + for (final File child : children) { + load(child); + } + } else if (file.isFile() && file.getName().toLowerCase().endsWith(".po")) { + registerTranslator(new Translator(localeFromString(file.getName().split("\\.")[0]), + file.getAbsolutePath(), DATA_FOLDER_PRIORITY, + () -> new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))); + } + } + + private static void registerTranslator(final Translator translator) { + final List chain = translators.computeIfAbsent(translator.locale, + locale -> new CopyOnWriteArrayList<>()); + chain.add(translator); + chain.sort((t1, t2) -> t1.priority == t2.priority + ? t1.path.compareTo(t2.path) + : Integer.compare(t2.priority, t1.priority)); + } + + private static String translateFromChain(Locale locale, String context, String messageId, + String messageIdPlural, Integer count) { + final List chain = translators.get(locale); + if (chain == null) { + return null; + } + + for (Translator translator : chain) { + final String translated = translator.translate(context, messageId, messageIdPlural, count); + if (translated != null) { + return translated; + } + } + + return null; + } + + /** + * Translates a message. + * + * @param locale The locale to translate into. If {@code null} or not available, the primary locale + * is used, then the fallback locale (English). + * @param context The context of the message (can be {@code null}). + * @param messageId The message. + * @param messageIdPlural The plural form of the message (can be {@code null}). + * @param count The count used to select a plural form (can be {@code null}). + * @param parameters The parameters of the message. + * @return The translated and formatted message. + */ + public static String translate(Locale locale, String context, String messageId, String messageIdPlural, + Integer count, Object... parameters) { + String translated = null; + Locale usedLocale = Locale.getDefault(); + + // The count is likely to be used in the string, so if only a count is given, it is also used as the first + // and only parameter ({0}). + if (count != null && (parameters == null || parameters.length == 0)) { + parameters = new Object[] {count}; + } + + if (locale != null) { + translated = translateFromChain(locale, context, messageId, messageIdPlural, count); + if (translated != null) { + usedLocale = locale; + } else { + // Then, we look up for a close locale (same language and country, then same language) + Locale perfect = null; + final Set close = new HashSet<>(); + + for (Locale curLocale : translators.keySet()) { + if (curLocale.getLanguage().equals(locale.getLanguage())) { + if (curLocale.getCountry().equals(locale.getCountry())) { + perfect = curLocale; + } else { + close.add(curLocale); + } + } + } + + if (perfect != null) { + translated = translateFromChain(perfect, context, messageId, messageIdPlural, count); + } + + if (translated != null) { + usedLocale = perfect; + } else { + for (Locale closeLocale : close) { + translated = translateFromChain(closeLocale, context, messageId, messageIdPlural, count); + if (translated != null) { + usedLocale = closeLocale; + break; + } + } + } + } + } + + if (translated == null && primaryLocale != null) { + translated = translateFromChain(primaryLocale, context, messageId, messageIdPlural, count); + if (translated != null) { + usedLocale = primaryLocale; + } + } + + if (translated == null && fallbackLocale != null) { + translated = translateFromChain(fallbackLocale, context, messageId, messageIdPlural, count); + if (translated != null) { + usedLocale = fallbackLocale; + } + } + + // If it's still null, we use the original message, using English plural rules. + if (translated == null) { + if (count != null && count != 1 && messageIdPlural != null) { + translated = messageIdPlural; + } else { + translated = messageId; + } + usedLocale = primaryLocale != null ? primaryLocale + : (fallbackLocale != null ? fallbackLocale : Locale.getDefault()); + } + + translated = replaceFormattingCodes(translated); + + // Single quotes are escaped so the formatter keeps them. + final MessageFormat formatter = new MessageFormat(translated.replace("'", "''"), usedLocale); + + // Non-breaking spaces are removed, as Minecraft does not handle them well. + return formatter.format(parameters) + .replace(" ", " ").replace(" ", " ").replace(" ", " ") + .replace(" ", " ") + .replace("⁠", ""); + } + + private static String replaceFormattingCodes(String text) { + return text.replace("{black}", ChatColor.BLACK.toString()) + .replace("{darkblue}", ChatColor.DARK_BLUE.toString()) + .replace("{darkgreen}", ChatColor.DARK_GREEN.toString()) + .replace("{darkaqua}", ChatColor.DARK_AQUA.toString()) + .replace("{darkred}", ChatColor.DARK_RED.toString()) + .replace("{darkpurple}", ChatColor.DARK_PURPLE.toString()) + .replace("{gold}", ChatColor.GOLD.toString()) + .replace("{gray}", ChatColor.GRAY.toString()) + .replace("{darkgray}", ChatColor.DARK_GRAY.toString()) + .replace("{blue}", ChatColor.BLUE.toString()) + .replace("{green}", ChatColor.GREEN.toString()) + .replace("{aqua}", ChatColor.AQUA.toString()) + .replace("{red}", ChatColor.RED.toString()) + .replace("{lightpurple}", ChatColor.LIGHT_PURPLE.toString()) + .replace("{yellow}", ChatColor.YELLOW.toString()) + .replace("{white}", ChatColor.WHITE.toString()) + .replace("{bold}", ChatColor.BOLD.toString()) + .replace("{strikethrough}", ChatColor.STRIKETHROUGH.toString()) + .replace("{underline}", ChatColor.UNDERLINE.toString()) + .replace("{italic}", ChatColor.ITALIC.toString()) + .replace("{obfuscated}", ChatColor.MAGIC.toString()) + .replace("{reset}", ChatColor.RESET.toString()) + .replace("{ce}", ERROR_COLOR) + .replace("{cc}", COMMAND_COLOR) + .replace("{ci}", NOTICE_COLOR) + .replace("{cs}", SUCCESS_COLOR) + .replace("{cst}", STATUS_COLOR); + } + + /** + * Returns the locale used by the client of the given player. + * + * @param player The player. + * @return The player's locale, or {@code null} if the player is {@code null}. + */ + public static Locale getPlayerLocale(Player player) { + if (player == null) { + return null; + } + return player.locale(); + } + + public static Locale getPrimaryLocale() { + return primaryLocale; + } + + /** + * Sets the primary locale, used when no locale is specified or when a translation is missing from the requested + * locale. + * + * @param locale The locale. If {@code null}, the system's default locale is used. + */ + public static void setPrimaryLocale(final Locale locale) { + primaryLocale = locale != null ? locale : Locale.getDefault(); + } + + /** + * Converts a locale name like {@code fr_FR} or {@code fr-FR} into a locale. + * + * @param localeName The locale name. + * @return The locale. + */ + public static Locale localeFromString(final String localeName) { + final String[] splitLocale = localeName.split("[_\\-]", 2); + if (splitLocale.length >= 2) { + return Locale.of(splitLocale[0], splitLocale[1]); + } else { + return Locale.of(localeName); + } + } + + @FunctionalInterface + private interface ReaderSupplier { + Reader open() throws IOException; + } + + /** + * A translation file, loaded lazily on first use. + */ + private static final class Translator { + private final Locale locale; + private final String path; + private final int priority; + private final ReaderSupplier readerSupplier; + private volatile POFile source = null; + private volatile boolean loaded = false; + + private Translator(Locale locale, String path, int priority, ReaderSupplier readerSupplier) { + this.locale = locale; + this.path = path; + this.priority = priority; + this.readerSupplier = readerSupplier; + } + + private synchronized void load() { + if (loaded) { + return; + } + loaded = true; + + try (Reader reader = readerSupplier.open()) { + if (reader == null) { + PluginLogger.error("Unable to load file {0}", path); + return; + } + final POFile file = new POFile(reader); + file.parse(); + source = file; + } catch (IOException | POFile.CannotParsePOException e) { + PluginLogger.error("Cannot parse the {0} translations file.", e, path); + source = null; + } + } + + private String translate(String context, String messageId, String messageIdPlural, Integer count) { + if (!loaded) { + load(); + } + if (source == null) { + return null; + } + + final POFile.Translation translation = source.getTranslation(context, messageId); + if (translation == null || translation.getTranslations().isEmpty()) { + return null; + } + + if (count != null && translation.getTranslations().size() != 1) { + final int pluralIndex = source.computePluralForm(count); + if (translation.getTranslations().size() <= pluralIndex) { + return null; + } + return translation.getTranslations().get(pluralIndex); + } else { + return translation.getTranslations().get(0); + } + } + } +} diff --git a/src/main/java/fr/moribus/imageonmap/i18n/POFile.java b/src/main/java/fr/moribus/imageonmap/i18n/POFile.java new file mode 100644 index 00000000..ed7176ac --- /dev/null +++ b/src/main/java/fr/moribus/imageonmap/i18n/POFile.java @@ -0,0 +1,277 @@ +/* + * Copyright or © or Copr. Moribus (2013) + * Copyright or © or Copr. ProkopyL (2015) + * Copyright or © or Copr. Amaury Carrade (2016 – 2021) + * Copyright or © or Copr. Vlammar (2019 – 2021) + * + * Parts of this file are adapted from QuartzLib (https://github.com/zDevelopers/QuartzLib), + * Copyright or © or Copr. ZDevelopers (2015 – 2021), governed by the CeCILL-B license. + * + * This software is a computer program whose purpose is to allow insertion of + * custom images in a Minecraft world. + * + * This software is governed by the CeCILL license under French law and + * abiding by the rules of distribution of free software. You can use, + * modify and/ or redistribute the software under the terms of the CeCILL + * license as circulated by CEA, CNRS and INRIA at the following URL + * "http://www.cecill.info". + * + * As a counterpart to the access to the source code and rights to copy, + * modify and redistribute granted by the license, users are provided only + * with a limited warranty and the software's author, the holder of the + * economic rights, and the successive licensors have only limited + * liability. + * + * In this respect, the user's attention is drawn to the risks associated + * with loading, using, modifying and/or developing or reproducing the + * software by the user in light of its specific status of free software, + * that may mean that it is complicated to manipulate, and that also + * therefore means that it is reserved for developers and experienced + * professionals having in-depth computer knowledge. Users are therefore + * encouraged to load and test the software's suitability as regards their + * requirements in conditions enabling the security of their systems and/or + * data to be ensured and, more generally, to use and operate it in the + * same conditions as regards security. + * + * The fact that you are presently reading this means that you have had + * knowledge of the CeCILL license and that you accept its terms. + */ + +package fr.moribus.imageonmap.i18n; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * A gettext {@code .po} translations file. + */ +public class POFile { + private static final String NO_CONTEXT_KEY = UUID.randomUUID().toString(); + + private final Map> translations = new HashMap<>(); + private BufferedReader rawReader; + private int pluralCount = 2; + private String pluralFormScript = ""; + private PluralForms pluralForms = null; + + public POFile(Reader reader) { + this.rawReader = reader instanceof BufferedReader ? (BufferedReader) reader : new BufferedReader(reader); + } + + /** + * Parses the file. + * + * @throws CannotParsePOException if the file is malformed. + */ + public void parse() throws CannotParsePOException { + if (rawReader == null) { + return; + } + + try (BufferedReader reader = rawReader) { + String line; + int lineNumber = 0; + + // For each section, we first collect the keys defined (they may expand on several lines); + // then we extract and save them when we hit a blank line, acting as a separator between + // translations. + final Map tokens = new HashMap<>(); + String lastToken = null; + + while ((line = reader.readLine()) != null) { + lineNumber++; + line = line.trim(); + + if (!line.isEmpty()) { + if (line.startsWith("#")) { + continue; + } else if (line.startsWith("\"")) { + // Continued values of tokens on another line + if (lastToken == null) { + throw new CannotParsePOException("Unnamed token value", lineNumber); + } + tokens.put(lastToken, tokens.get(lastToken) + extractTokenValue(line)); + } else { + // Beginning of a new token + final String[] lineParts = line.split(" ", 2); + if (lineParts.length < 2) { + throw new CannotParsePOException("Malformed token line", lineNumber); + } + tokens.put(lineParts[0], extractTokenValue(lineParts[1])); + lastToken = lineParts[0]; + } + } else { + if (!tokens.isEmpty()) { + analyseEntry(tokens); + } + tokens.clear(); + lastToken = null; + } + } + + if (!tokens.isEmpty()) { + analyseEntry(tokens); + } + + pluralForms = new PluralForms(pluralCount, pluralFormScript); + } catch (IOException e) { + throw new CannotParsePOException("An IO exception occurred while parsing the file", e); + } + + rawReader = null; + } + + private String extractTokenValue(String raw) { + final StringBuilder extracted = new StringBuilder(); + boolean inString = false; + + for (int i = 0; i < raw.length(); i++) { + final char character = raw.charAt(i); + + if (character == '"') { + if (i == 0 || raw.charAt(i - 1) != '\\') { + inString = !inString; + if (!inString) { + break; + } + } else { + extracted.append('"'); + } + } else if (inString && !(character == '\\' && i != raw.length() - 1 && raw.charAt(i + 1) == '"')) { + extracted.append(character); + } + } + + return extracted.toString(); + } + + private void analyseEntry(Map tokens) { + if (!tokens.containsKey("msgid")) { + return; + } + + final String msgid = tokens.get("msgid"); + + if (!msgid.isEmpty()) { + final String msgctxt = tokens.get("msgctxt"); + + // msgstr can be in two different formats: + // - msgstr: then there is only one translation; + // - msgstr[i]: then multiple translations are available (for plurals). + final List msgstr; + if (tokens.containsKey("msgstr")) { + msgstr = Collections.singletonList(tokens.get("msgstr")); + } else { + msgstr = new ArrayList<>(); + for (int i = 0; ; i++) { + final String tokenValue = tokens.get("msgstr[" + i + "]"); + if (tokenValue == null) { + break; + } + msgstr.add(tokenValue); + } + } + + // No translation available, skipped. + if (msgstr.isEmpty() || msgstr.get(0).trim().isEmpty()) { + return; + } + + translations.computeIfAbsent(msgctxt != null ? msgctxt : NO_CONTEXT_KEY, key -> new HashMap<>()) + .put(msgid, new Translation(msgstr)); + } else { + // Metadata + final String rawMetadata = tokens.get("msgstr"); + if (rawMetadata == null) { + return; + } + + // Ensures both interpreted and written line breaks are interpreted + for (String meta : rawMetadata.split("(\\\\n)|\n")) { + final String[] metaParts = meta.split(":"); + if (metaParts.length < 2 || !metaParts[0].trim().equalsIgnoreCase("plural-forms")) { + continue; + } + + final String[] parts = metaParts[1].trim().split(";", 2); + if (parts.length < 2) { + continue; + } + + try { + pluralCount = Integer.parseInt(parts[0].split("=")[1].trim()); + pluralFormScript = parts[1].trim(); + if (pluralFormScript.endsWith(";")) { + pluralFormScript = pluralFormScript.substring(0, pluralFormScript.length() - 1); + } + // Converts “plural=