diff --git a/build.gradle.kts b/build.gradle.kts index fffd5a9a..3b218436 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,10 +44,11 @@ dependencies { compileOnly(libs.papi) - implementation(libs.nashorn) - implementation(libs.adventure.platform) - implementation(libs.adventure.minimessage) - implementation(libs.bstats) + implementation(libs.nashorn) + implementation(libs.adventure.platform) + implementation(libs.adventure.minimessage) + implementation(libs.adventure.legacy) + implementation(libs.bstats) compileOnly("org.jetbrains:annotations:23.0.0") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 06e791dd..fab80a35 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,8 +17,9 @@ bstats = "3.1.0" # Implementation nashorn = "15.6" -adventure-platform = "4.4.1" -adventure-minimessage = "4.24.0" +adventure-platform = "4.4.1" +adventure-minimessage = "4.24.0" +adventure-legacy = "4.24.0" [libraries] # Compile only @@ -39,6 +40,7 @@ sig = { module = "io.github.valerashimchuck:simpleitemgenerator-api", version.re # Implementation nashorn = { module = "org.openjdk.nashorn:nashorn-core", version.ref = "nashorn" } -adventure-platform = { module = "net.kyori:adventure-platform-bukkit", version.ref = "adventure-platform" } -adventure-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure-minimessage" } -bstats = { module = "org.bstats:bstats-bukkit", version.ref = "bstats" } +adventure-platform = { module = "net.kyori:adventure-platform-bukkit", version.ref = "adventure-platform" } +adventure-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure-minimessage" } +adventure-legacy = { module = "net.kyori:adventure-text-serializer-legacy", version.ref = "adventure-legacy" } +bstats = { module = "org.bstats:bstats-bukkit", version.ref = "bstats" } diff --git a/src/main/java/com/extendedclip/deluxemenus/DeluxeMenus.java b/src/main/java/com/extendedclip/deluxemenus/DeluxeMenus.java index 319272da..85902cd6 100644 --- a/src/main/java/com/extendedclip/deluxemenus/DeluxeMenus.java +++ b/src/main/java/com/extendedclip/deluxemenus/DeluxeMenus.java @@ -4,9 +4,11 @@ import com.extendedclip.deluxemenus.command.DeluxeMenusCommand; import com.extendedclip.deluxemenus.config.DeluxeMenusConfig; import com.extendedclip.deluxemenus.config.GeneralConfig; -import com.extendedclip.deluxemenus.dupe.DupeFixer; -import com.extendedclip.deluxemenus.dupe.MenuItemMarker; -import com.extendedclip.deluxemenus.hooks.*; +import com.extendedclip.deluxemenus.dupe.DupeFixer; +import com.extendedclip.deluxemenus.dupe.MenuItemMarker; +import com.extendedclip.deluxemenus.editor.MenuEditorListener; +import com.extendedclip.deluxemenus.editor.WebEditorServer; +import com.extendedclip.deluxemenus.hooks.*; import com.extendedclip.deluxemenus.listener.PlayerListener; import com.extendedclip.deluxemenus.menu.Menu; import com.extendedclip.deluxemenus.menu.MenuItem; @@ -65,7 +67,8 @@ public class DeluxeMenus extends JavaPlugin { private Map itemHooks; private final GeneralConfig generalConfig = new GeneralConfig(this); - private DeluxeMenusConfig menuConfig; + private DeluxeMenusConfig menuConfig; + private WebEditorServer webEditorServer; @NotNull private final TaskScheduler scheduler = UniversalScheduler.getScheduler(this); @@ -109,7 +112,9 @@ public void onEnable() { debug(DebugLevel.HIGHEST, Level.WARNING, "Failed to load from config.yml. Use /dm reload after fixing your errors."); } - new PlayerListener(this).register(); + this.webEditorServer = new WebEditorServer(this); + new PlayerListener(this).register(); + new MenuEditorListener(this).register(); if (!new DeluxeMenusCommand(this).register()) { debug(DebugLevel.HIGHEST, Level.SEVERE, "Could not register the DeluxeMenus command!"); } @@ -131,9 +136,13 @@ public void onDisable() { this.audiences = null; } - Menu.unloadForShutdown(this); - - itemHooks.clear(); + Menu.unloadForShutdown(this); + if (this.webEditorServer != null) { + this.webEditorServer.stop(); + this.webEditorServer = null; + } + + itemHooks.clear(); HandlerList.unregisterAll(this); } @@ -197,9 +206,13 @@ public MenuItemMarker getMenuItemMarker() { return menuItemMarker; } - public DeluxeMenusConfig getConfiguration() { - return menuConfig; - } + public DeluxeMenusConfig getConfiguration() { + return menuConfig; + } + + public WebEditorServer getWebEditorServer() { + return webEditorServer; + } public VaultHook getVault() { return vaultHook; diff --git a/src/main/java/com/extendedclip/deluxemenus/action/ActionType.java b/src/main/java/com/extendedclip/deluxemenus/action/ActionType.java index e970f76f..dc7ca50d 100644 --- a/src/main/java/com/extendedclip/deluxemenus/action/ActionType.java +++ b/src/main/java/com/extendedclip/deluxemenus/action/ActionType.java @@ -26,9 +26,11 @@ public enum ActionType { "- [message] "), LOG("[log]", "Log a message to the console", "- [log] "), BROADCAST("[broadcast]", "Broadcast a message to the server", "- '[broadcast] "), - CHAT("[chat]", "Send a chat message as the player performing the action", "- '[chat] "), - OPEN_GUI_MENU("[openguimenu]", "Open a GUI menu", "- '[openguimenu] '"), - OPEN_MENU("[openmenu]", "Open a GUI menu", "- '[openmenu] '"), + CHAT("[chat]", "Send a chat message as the player performing the action", "- '[chat] "), + OPEN_GUI_MENU("[openguimenu]", "Open a GUI menu", "- '[openguimenu] '"), + OPEN_GUI_INVENTORY("[open_gui_inventory]", "Open a GUI menu in the player inventory area", + "- '[open_gui_inventory] '"), + OPEN_MENU("[openmenu]", "Open a GUI menu", "- '[openmenu] '"), CONNECT("[connect]", "Connect to the specified bungee server", "- '[connect] '"), CLOSE("[close]", "Close the viewers open menu", "- '[close]"), REFRESH("[refresh]", "Refresh items in the current menu view", "- '[refresh]"), diff --git a/src/main/java/com/extendedclip/deluxemenus/action/ClickActionTask.java b/src/main/java/com/extendedclip/deluxemenus/action/ClickActionTask.java index dfb3592d..2ffdd29b 100644 --- a/src/main/java/com/extendedclip/deluxemenus/action/ClickActionTask.java +++ b/src/main/java/com/extendedclip/deluxemenus/action/ClickActionTask.java @@ -30,13 +30,12 @@ public class ClickActionTask extends UniversalRunnable { private final DeluxeMenus plugin; private final TaskScheduler scheduler; - private final UUID uuid; - private final ActionType actionType; - private final String exec; - // Ugly hack to get around the fact that arguments are not available at task execution time - private final Map arguments; - private final boolean parsePlaceholdersInArguments; - private final boolean parsePlaceholdersAfterArguments; + private final UUID uuid; + private final ActionType actionType; + private final String exec; + private final Map arguments; + private final boolean parsePlaceholdersInArguments; + private final boolean parsePlaceholdersAfterArguments; public ClickActionTask( @NotNull final DeluxeMenus plugin, @@ -168,10 +167,10 @@ public void run() { Menu.closeMenu(plugin, player, true, true); break; - case OPEN_GUI_MENU: - case OPEN_MENU: - final String temporaryExecutable = executable.replaceAll("\\s+", " ").replace(" ", " "); - final String[] executableParts = temporaryExecutable.split(" ", 2); + case OPEN_GUI_MENU: + case OPEN_MENU: + final String temporaryExecutable = executable.replaceAll("\\s+", " ").replace(" ", " "); + final String[] executableParts = temporaryExecutable.split(" ", 2); if (executableParts.length == 0) { plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Could not find and open menu " + executable); @@ -187,9 +186,10 @@ public void run() { break; } - final Menu menuToOpen = optionalMenuToOpen.get(); - - final List menuArgumentNames = menuToOpen.options().arguments(); + final Menu menuToOpen = optionalMenuToOpen.get(); + final Menu playerInventoryMenuToKeep = getPlayerInventoryMenuToKeep(holder, menuToOpen); + + final List menuArgumentNames = menuToOpen.options().arguments(); String[] passedArgumentValues = null; if (executableParts.length > 1) { @@ -204,26 +204,25 @@ public void run() { "Arguments were given for menu " + menuName + " in action [openguimenu] or [openmenu], but the menu does not support arguments!" ); } - - if (holder.isEmpty()) { - menuToOpen.openMenu(player); - break; - } - - menuToOpen.openMenu(player, holder.get().getTypedArgs(), holder.get().getPlaceholderPlayer()); - break; - } - - if (passedArgumentValues == null || passedArgumentValues.length == 0) { - // Replicate old behavior: If no arguments are given, open the menu with the arguments from the current menu - if (holder.isEmpty()) { - menuToOpen.openMenu(player); - break; - } - - menuToOpen.openMenu(player, holder.get().getTypedArgs(), holder.get().getPlaceholderPlayer()); - break; - } + + if (holder.isEmpty()) { + menuToOpen.openMenu(player, null, null, null); + break; + } + + menuToOpen.openMenu(player, holder.get().getTypedArgs(), holder.get().getPlaceholderPlayer(), playerInventoryMenuToKeep); + break; + } + + if (passedArgumentValues == null || passedArgumentValues.length == 0) { + if (holder.isEmpty()) { + menuToOpen.openMenu(player, null, null, null); + break; + } + + menuToOpen.openMenu(player, holder.get().getTypedArgs(), holder.get().getPlaceholderPlayer(), playerInventoryMenuToKeep); + break; + } if (passedArgumentValues.length < menuArgumentNames.size()) { plugin.debug( @@ -234,47 +233,69 @@ public void run() { break; } - final Map argumentsMap = new HashMap<>(); - if (holder.isPresent() && holder.get().getTypedArgs() != null) { - // Pass the arguments from the current menu to the new menu. If the new menu has arguments with the - // same name, they will be overwritten - argumentsMap.putAll(holder.get().getTypedArgs()); - } + final Map argumentsMap = new HashMap<>(); + if (holder.isPresent() && holder.get().getTypedArgs() != null) { + argumentsMap.putAll(holder.get().getTypedArgs()); + } for (int index = 0; index < menuArgumentNames.size(); index++) { - final String argumentName = menuArgumentNames.get(index); - - if (passedArgumentValues.length <= index) { - // This should never be the case! - plugin.debug( - DebugLevel.HIGHEST, - Level.WARNING, + final String argumentName = menuArgumentNames.get(index); + + if (passedArgumentValues.length <= index) { + plugin.debug( + DebugLevel.HIGHEST, + Level.WARNING, "Not enough arguments given for menu " + menuName + " when opening using the [openguimenu] or [openmenu] action!" ); break; - } - - if (menuArgumentNames.size() == index + 1) { - // If this is the last argument, get all remaining values and join them - final String lastArgumentValue = String.join(" ", Arrays.asList(passedArgumentValues).subList(index, passedArgumentValues.length)); - argumentsMap.put(argumentName, lastArgumentValue); - break; + } + + if (menuArgumentNames.size() == index + 1) { + final String lastArgumentValue = String.join(" ", Arrays.asList(passedArgumentValues).subList(index, passedArgumentValues.length)); + argumentsMap.put(argumentName, lastArgumentValue); + break; } argumentsMap.put(argumentName, passedArgumentValues[index]); } - - if (holder.isEmpty()) { - menuToOpen.openMenu(player, argumentsMap, null); - break; - } - - menuToOpen.openMenu(player, argumentsMap, holder.get().getPlaceholderPlayer()); - break; - - case CONNECT: - plugin.connect(player, executable); - break; + + if (holder.isEmpty()) { + menuToOpen.openMenu(player, argumentsMap, null, null); + break; + } + + menuToOpen.openMenu(player, argumentsMap, holder.get().getPlaceholderPlayer(), playerInventoryMenuToKeep); + break; + + case OPEN_GUI_INVENTORY: + if (holder.isEmpty()) { + plugin.debug( + DebugLevel.HIGHEST, + Level.WARNING, + "Cannot open player inventory menu " + executable + " because no DeluxeMenus menu is open." + ); + break; + } + + final String inventoryExecutable = executable.replaceAll("\\s+", " ").trim(); + if (inventoryExecutable.isBlank()) { + plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Could not find and open player inventory menu " + executable); + break; + } + + final String inventoryMenuName = inventoryExecutable.split(" ", 2)[0]; + final Optional optionalInventoryMenu = Menu.getSubMenuByName(inventoryMenuName); + if (optionalInventoryMenu.isEmpty()) { + plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Could not find and open player inventory menu " + executable); + break; + } + + holder.get().openPlayerInventoryMenu(optionalInventoryMenu.get()); + break; + + case CONNECT: + plugin.connect(player, executable); + break; case JSON_MESSAGE: AdventureUtils.sendJson(plugin, player, executable); @@ -508,26 +529,38 @@ public void run() { } break; - case PLAY_SOUND: - if (sound == null) { - plugin.debug( - DebugLevel.HIGHEST, - Level.WARNING, - "Sound name given for sound action: " + executable + ", is not a valid sound!" - ); - break; - } - player.playSound(player.getLocation(), sound, volume, pitch); - break; - } - break; + case PLAY_SOUND: + if (sound == null) { + plugin.debug( + DebugLevel.HIGHEST, + Level.WARNING, + "Sound name given for sound action: " + executable + ", is not a valid sound!" + ); + break; + } + player.playSound(player.getLocation(), sound, volume, pitch); + break; + } + break; default: break; } } - private boolean isRaw(ActionType actionType) { - return actionType == ActionType.PLAY_RAW_SOUND || actionType == ActionType.BROADCAST_RAW_SOUND || actionType == ActionType.BROADCAST_WORLD_RAW_SOUND; - } -} + private boolean isRaw(ActionType actionType) { + return actionType == ActionType.PLAY_RAW_SOUND || actionType == ActionType.BROADCAST_RAW_SOUND || actionType == ActionType.BROADCAST_WORLD_RAW_SOUND; + } + + private Menu getPlayerInventoryMenuToKeep(final @NotNull Optional holder, final @NotNull Menu menuToOpen) { + if (menuToOpen.options().playerInventoryMenu().isPresent()) { + return null; + } + + if (holder.isEmpty() || !holder.get().getMenuName().equalsIgnoreCase(menuToOpen.options().name())) { + return null; + } + + return holder.flatMap(MenuHolder::getRenderedPlayerInventoryMenu).orElse(null); + } +} diff --git a/src/main/java/com/extendedclip/deluxemenus/command/DeluxeMenusCommand.java b/src/main/java/com/extendedclip/deluxemenus/command/DeluxeMenusCommand.java index e360bde8..c6ee2db9 100644 --- a/src/main/java/com/extendedclip/deluxemenus/command/DeluxeMenusCommand.java +++ b/src/main/java/com/extendedclip/deluxemenus/command/DeluxeMenusCommand.java @@ -90,13 +90,16 @@ public List onTabComplete( private void registerSubCommands() { final List commands = List.of( new DumpCommand(plugin), + new DebugCommand(plugin), + new EditCommand(plugin), new ExecuteCommand(plugin), new HelpCommand(plugin), new ListCommand(plugin), new MetaCommand(plugin), new OpenCommand(plugin), new RefreshCommand(plugin), - new ReloadCommand(plugin) + new ReloadCommand(plugin), + new WebEditorCommand(plugin) ); for (final SubCommand subCommand : commands) { diff --git a/src/main/java/com/extendedclip/deluxemenus/command/subcommand/DebugCommand.java b/src/main/java/com/extendedclip/deluxemenus/command/subcommand/DebugCommand.java new file mode 100644 index 00000000..e1a68d67 --- /dev/null +++ b/src/main/java/com/extendedclip/deluxemenus/command/subcommand/DebugCommand.java @@ -0,0 +1,306 @@ +package com.extendedclip.deluxemenus.command.subcommand; + +import com.extendedclip.deluxemenus.DeluxeMenus; +import com.extendedclip.deluxemenus.hooks.ItemHook; +import com.extendedclip.deluxemenus.menu.Menu; +import com.extendedclip.deluxemenus.menu.MenuHolder; +import com.extendedclip.deluxemenus.menu.MenuItem; +import com.extendedclip.deluxemenus.requirement.RequirementList; +import com.extendedclip.deluxemenus.utils.Messages; +import com.extendedclip.deluxemenus.utils.StringUtils; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.TreeMap; +import java.util.stream.Collectors; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static net.kyori.adventure.text.Component.newline; +import static net.kyori.adventure.text.Component.text; + +public class DebugCommand extends SubCommand { + + private static final String DEBUG_COMMAND = "deluxemenus.debug"; + + public DebugCommand(final @NotNull DeluxeMenus plugin) { + super(plugin); + } + + @Override + public @NotNull String getName() { + return "debug"; + } + + @Override + public void execute(final @NotNull CommandSender sender, final @NotNull List arguments) { + if (!sender.hasPermission(DEBUG_COMMAND)) { + plugin.sms(sender, Messages.NO_PERMISSION); + return; + } + + if (arguments.size() < 2) { + sendUsage(sender); + return; + } + + switch (arguments.get(0).toLowerCase(Locale.ROOT)) { + case "placeholder": + case "placeholders": + debugPlaceholder(sender, arguments); + break; + case "hook": + case "itemhook": + debugHook(sender, arguments); + break; + case "item": + debugMenuItem(sender, arguments); + break; + default: + sendUsage(sender); + break; + } + } + + @Override + public @Nullable List onTabComplete(final @NotNull CommandSender sender, final @NotNull List arguments) { + if (!sender.hasPermission(DEBUG_COMMAND)) { + return null; + } + + if (arguments.isEmpty()) { + return List.of(getName()); + } + + if (arguments.size() == 1) { + return complete(List.of(getName()), arguments.get(0)); + } + + if (!getName().equalsIgnoreCase(arguments.get(0))) { + return null; + } + + if (arguments.size() == 2) { + return complete(List.of("placeholder", "hook", "item"), arguments.get(1)); + } + + final String mode = arguments.get(1).toLowerCase(Locale.ROOT); + if (arguments.size() == 3) { + if ("placeholder".equals(mode) || "placeholders".equals(mode)) { + return getPlayerNameCompletion(arguments.get(2)); + } + if ("hook".equals(mode) || "itemhook".equals(mode)) { + return completeHookPrefixes(arguments.get(2)); + } + if ("item".equals(mode)) { + return complete(Menu.getAllMenuNames(), arguments.get(2)); + } + } + + if (arguments.size() == 5 && "item".equals(mode)) { + return getPlayerNameCompletion(arguments.get(4)); + } + + if (arguments.size() == 4 && ("hook".equals(mode) || "itemhook".equals(mode))) { + return getPlayerNameCompletion(arguments.get(3)); + } + + return null; + } + + private void debugPlaceholder(final @NotNull CommandSender sender, final @NotNull List arguments) { + if (arguments.size() < 3) { + plugin.sms(sender, usage("/dm debug placeholder ")); + return; + } + + final Player player = Bukkit.getPlayerExact(arguments.get(1)); + if (player == null) { + plugin.sms(sender, Messages.PLAYER_IS_NOT_ONLINE.message().replaceText(PLAYER_REPLACER_BUILDER.replacement(arguments.get(1)).build())); + return; + } + + final String input = String.join(" ", arguments.subList(2, arguments.size())); + final String parsed = StringUtils.replacePlaceholders(input, player); + + plugin.sms(sender, header("Placeholder debug") + .append(row("Player", player.getName())) + .append(row("Input", input)) + .append(row("Parsed", parsed))); + } + + private void debugHook(final @NotNull CommandSender sender, final @NotNull List arguments) { + final String material = arguments.get(1); + final Optional optionalHook = findHook(material); + if (optionalHook.isEmpty()) { + plugin.sms(sender, header("Hook debug") + .append(row("Material", material)) + .append(row("Result", "No matching item hook prefix"))); + return; + } + + final Player player = arguments.size() >= 3 ? Bukkit.getPlayerExact(arguments.get(2)) : sender instanceof Player ? (Player) sender : null; + if (arguments.size() >= 3 && player == null) { + plugin.sms(sender, Messages.PLAYER_IS_NOT_ONLINE.message().replaceText(PLAYER_REPLACER_BUILDER.replacement(arguments.get(2)).build())); + return; + } + + final ItemHook hook = optionalHook.get(); + final String hookArgument = parseHookArgument(material, hook, player); + final ItemStack itemStack = player == null ? hook.getItem(hookArgument) : hook.getItem(player, hookArgument); + + plugin.sms(sender, header("Hook debug") + .append(row("Hook", hook.getClass().getSimpleName())) + .append(row("Prefix", hook.getPrefix())) + .append(row("Argument", hookArgument)) + .append(row("Item", describeItem(itemStack)))); + } + + private void debugMenuItem(final @NotNull CommandSender sender, final @NotNull List arguments) { + if (arguments.size() < 4) { + plugin.sms(sender, usage("/dm debug item ")); + return; + } + + final Optional optionalMenu = Menu.getMenuByName(arguments.get(1)); + if (optionalMenu.isEmpty()) { + plugin.sms(sender, Messages.INVALID_MENU.message().replaceText(MENU_REPLACER_BUILDER.replacement(arguments.get(1)).build())); + return; + } + + final int slot; + try { + slot = Integer.parseInt(arguments.get(2)); + } catch (final NumberFormatException exception) { + plugin.sms(sender, usage("/dm debug item ")); + return; + } + + final Player player = Bukkit.getPlayerExact(arguments.get(3)); + if (player == null) { + plugin.sms(sender, Messages.PLAYER_IS_NOT_ONLINE.message().replaceText(PLAYER_REPLACER_BUILDER.replacement(arguments.get(3)).build())); + return; + } + + final Menu menu = optionalMenu.get(); + final TreeMap slotItems = menu.getMenuItems().get(slot); + if (slotItems == null || slotItems.isEmpty()) { + plugin.sms(sender, header("Menu item debug") + .append(row("Menu", menu.options().name())) + .append(row("Slot", String.valueOf(slot))) + .append(row("Result", "No configured item in this slot"))); + return; + } + + final MenuItem item = slotItems.firstEntry().getValue(); + final MenuHolder holder = new MenuHolder(plugin, player); + holder.setMenuName(menu.options().name()); + holder.setPlaceholderPlayer(player); + holder.parsePlaceholdersInArguments(menu.options().parsePlaceholdersInArguments()); + holder.parsePlaceholdersAfterArguments(menu.options().parsePlaceholdersAfterArguments()); + + final ItemStack rendered = item.getItemStack(holder); + + plugin.sms(sender, header("Menu item debug") + .append(row("Menu", menu.options().name())) + .append(row("Player", player.getName())) + .append(row("Slot", String.valueOf(slot))) + .append(row("Priority", String.valueOf(item.options().priority()))) + .append(row("Material", item.options().material())) + .append(row("Rendered", describeItem(rendered))) + .append(row("View requirements", describeRequirementList(item.options().viewRequirements())))); + } + + private @NotNull Optional findHook(final @NotNull String material) { + final String lowerMaterial = material.toLowerCase(Locale.ROOT); + return plugin.getItemHooks().values() + .stream() + .filter(hook -> lowerMaterial.startsWith(hook.getPrefix())) + .findFirst(); + } + + private @NotNull String parseHookArgument(final @NotNull String material, final @NotNull ItemHook hook, final @Nullable Player player) { + final String argument = material.substring(hook.getPrefix().length()); + return player == null ? argument : StringUtils.replacePlaceholders(argument, player); + } + + private @NotNull String describeItem(final @Nullable ItemStack itemStack) { + if (itemStack == null) { + return "null"; + } + + final StringBuilder builder = new StringBuilder(itemStack.getType().name()) + .append(" x") + .append(itemStack.getAmount()); + + if (itemStack.getType() == Material.STONE) { + builder.append(" (fallback possible)"); + } + + final ItemMeta itemMeta = itemStack.getItemMeta(); + if (itemMeta != null && itemMeta.hasDisplayName()) { + builder.append(" | ").append(itemMeta.getDisplayName()); + } + + return builder.toString(); + } + + private @NotNull String describeRequirementList(final @NotNull Optional optionalRequirementList) { + if (optionalRequirementList.isEmpty()) { + return "none"; + } + + final RequirementList requirementList = optionalRequirementList.get(); + return requirementList.getRequirements().size() + + " configured, minimum " + + requirementList.getMinimumRequirements(); + } + + private void sendUsage(final @NotNull CommandSender sender) { + plugin.sms(sender, header("Debug usage") + .append(row("Placeholder", "/dm debug placeholder ")) + .append(row("Hook", "/dm debug hook [player]")) + .append(row("Item", "/dm debug item "))); + } + + private @NotNull Component usage(final @NotNull String usage) { + return text("Incorrect Usage! Use ", NamedTextColor.RED) + .append(text(usage, NamedTextColor.GRAY)); + } + + private @NotNull Component header(final @NotNull String title) { + return text(title, NamedTextColor.GOLD); + } + + private @NotNull Component row(final @NotNull String label, final @NotNull String value) { + return newline() + .append(text("> ", NamedTextColor.AQUA)) + .append(text(label, NamedTextColor.GRAY)) + .append(text(": ", NamedTextColor.DARK_GRAY)) + .append(text(value, NamedTextColor.WHITE)); + } + + private @NotNull List completeHookPrefixes(final @NotNull String argument) { + final List prefixes = plugin.getItemHooks().values() + .stream() + .map(ItemHook::getPrefix) + .collect(Collectors.toList()); + return complete(prefixes, argument); + } + + private @NotNull List complete(final @NotNull Collection values, final @NotNull String argument) { + final String lowerArgument = argument.toLowerCase(Locale.ROOT); + return values.stream() + .filter(value -> value.toLowerCase(Locale.ROOT).startsWith(lowerArgument)) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/extendedclip/deluxemenus/command/subcommand/EditCommand.java b/src/main/java/com/extendedclip/deluxemenus/command/subcommand/EditCommand.java new file mode 100644 index 00000000..c843b005 --- /dev/null +++ b/src/main/java/com/extendedclip/deluxemenus/command/subcommand/EditCommand.java @@ -0,0 +1,303 @@ +package com.extendedclip.deluxemenus.command.subcommand; + +import com.extendedclip.deluxemenus.DeluxeMenus; +import com.extendedclip.deluxemenus.editor.MenuConfigEditor; +import com.extendedclip.deluxemenus.editor.MenuEditPromptRegistry; +import com.extendedclip.deluxemenus.editor.MenuEditorManager; +import com.extendedclip.deluxemenus.menu.Menu; +import com.extendedclip.deluxemenus.utils.Messages; +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.stream.Collectors; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class EditCommand extends SubCommand { + + private static final String EDIT_PERMISSION = "deluxemenus.edit"; + private final MenuEditorManager editorManager; + private final MenuConfigEditor configEditor; + + public EditCommand(final @NotNull DeluxeMenus plugin) { + super(plugin); + this.editorManager = new MenuEditorManager(plugin); + this.configEditor = new MenuConfigEditor(plugin); + } + + @Override + public @NotNull String getName() { + return "edit"; + } + + @Override + public void execute(final @NotNull CommandSender sender, final @NotNull List arguments) { + if (!sender.hasPermission(EDIT_PERMISSION)) { + plugin.sms(sender, Messages.NO_PERMISSION); + return; + } + + if (arguments.isEmpty()) { + plugin.sms(sender, Messages.WRONG_USAGE); + return; + } + + final Optional optionalMenu = findMenu(arguments.get(0)); + if (optionalMenu.isEmpty()) { + plugin.sms(sender, Messages.INVALID_MENU.message().replaceText(MENU_REPLACER_BUILDER.replacement(arguments.get(0)).build())); + return; + } + + if (arguments.size() == 1) { + if (!(sender instanceof Player)) { + plugin.sms(sender, Messages.MUST_SPECIFY_PLAYER); + return; + } + + editorManager.open((Player) sender, optionalMenu.get()); + return; + } + + if (arguments.size() == 3 + && "slot".equalsIgnoreCase(arguments.get(1))) { + openSlot(sender, optionalMenu.get(), arguments.get(2)); + return; + } + + if (arguments.size() >= 5 + && "set".equalsIgnoreCase(arguments.get(1))) { + setValue(sender, optionalMenu.get(), arguments); + return; + } + + if (arguments.size() >= 4 + && "prompt".equalsIgnoreCase(arguments.get(1))) { + prompt(sender, optionalMenu.get(), arguments); + return; + } + + if (arguments.size() >= 4 + && "menu".equalsIgnoreCase(arguments.get(1))) { + setMenuValue(sender, optionalMenu.get(), arguments); + return; + } + + if (arguments.size() == 3 + && "delete".equalsIgnoreCase(arguments.get(1))) { + deleteItem(sender, optionalMenu.get(), arguments.get(2)); + return; + } + + plugin.sms(sender, Messages.WRONG_USAGE); + } + + @Override + public @Nullable List onTabComplete(final @NotNull CommandSender sender, final @NotNull List arguments) { + if (!sender.hasPermission(EDIT_PERMISSION)) { + return null; + } + + if (arguments.isEmpty()) { + return List.of(getName()); + } + + if (arguments.size() == 1) { + return complete(List.of(getName()), arguments.get(0)); + } + + if (!getName().equalsIgnoreCase(arguments.get(0))) { + return null; + } + + if (arguments.size() == 2) { + return complete(menuNames(), arguments.get(1)); + } + + if (arguments.size() == 3) { + return complete(List.of("set", "prompt", "slot", "menu", "delete"), arguments.get(2)); + } + + if (arguments.size() == 4 && "menu".equalsIgnoreCase(arguments.get(2))) { + return complete(menuOptions(), arguments.get(3)); + } + + if (arguments.size() == 5 && ("set".equalsIgnoreCase(arguments.get(2)) || "prompt".equalsIgnoreCase(arguments.get(2)))) { + return complete(editableOptions(), arguments.get(4)); + } + + return null; + } + + private void openSlot(final @NotNull CommandSender sender, final @NotNull Menu menu, final @NotNull String slotInput) { + if (!(sender instanceof Player)) { + plugin.sms(sender, Messages.MUST_SPECIFY_PLAYER); + return; + } + + final int slot = parseSlot(sender, slotInput); + if (slot < 0) { + return; + } + + editorManager.openSlot((Player) sender, menu, slot); + } + + private void setValue(final @NotNull CommandSender sender, final @NotNull Menu menu, final @NotNull List arguments) { + final int slot; + try { + slot = Integer.parseInt(arguments.get(2)); + } catch (final NumberFormatException exception) { + plugin.sms(sender, Messages.WRONG_USAGE); + return; + } + + final String option = arguments.get(3).toLowerCase(Locale.ROOT); + if (!editableOptions().contains(option)) { + plugin.sms(sender, Component.text("Unknown editable option: " + option, NamedTextColor.RED)); + return; + } + + final String value = String.join(" ", arguments.subList(4, arguments.size())); + try { + if (!configEditor.setItemValue(menu, slot, option, value)) { + plugin.sms(sender, Component.text("No editable item was found in slot " + slot + ".", NamedTextColor.RED)); + return; + } + } catch (final IOException exception) { + plugin.printStacktrace("Failed to save menu edit.", exception); + plugin.sms(sender, Component.text("Failed to save menu edit.", NamedTextColor.RED)); + return; + } + + configEditor.reload(menu); + plugin.sms(sender, Component.text("Updated " + option + " for slot " + slot + " in " + configEditor.describeTarget(menu) + ".", NamedTextColor.GREEN)); + } + + private void prompt(final @NotNull CommandSender sender, final @NotNull Menu menu, final @NotNull List arguments) { + if (!(sender instanceof Player)) { + plugin.sms(sender, Messages.MUST_SPECIFY_PLAYER); + return; + } + + final int slot; + try { + slot = Integer.parseInt(arguments.get(2)); + } catch (final NumberFormatException exception) { + plugin.sms(sender, Messages.WRONG_USAGE); + return; + } + + final String option = arguments.get(3).toLowerCase(Locale.ROOT); + if (!editableOptions().contains(option)) { + plugin.sms(sender, Component.text("Unknown editable option: " + option, NamedTextColor.RED)); + return; + } + + final Player player = (Player) sender; + MenuEditPromptRegistry.begin(player, menu.options().name(), slot, option); + plugin.sms(player, Component.text("Type the new " + option + " in chat, or type cancel.", NamedTextColor.YELLOW)); + } + + private void setMenuValue(final @NotNull CommandSender sender, final @NotNull Menu menu, final @NotNull List arguments) { + final String option = arguments.get(2).toLowerCase(Locale.ROOT); + if (!menuOptions().contains(option)) { + plugin.sms(sender, Component.text("Unknown editable menu option: " + option, NamedTextColor.RED)); + return; + } + + final String value = String.join(" ", arguments.subList(3, arguments.size())); + try { + configEditor.setMenuValue(menu, option, value); + } catch (final IOException exception) { + plugin.printStacktrace("Failed to save menu edit.", exception); + plugin.sms(sender, Component.text("Failed to save menu edit.", NamedTextColor.RED)); + return; + } + + configEditor.reload(menu); + plugin.sms(sender, Component.text("Updated " + option + " for " + menu.options().name() + " in " + configEditor.describeTarget(menu) + ".", NamedTextColor.GREEN)); + } + + private void deleteItem(final @NotNull CommandSender sender, final @NotNull Menu menu, final @NotNull String slotInput) { + final int slot = parseSlot(sender, slotInput); + if (slot < 0) { + return; + } + + try { + if (!configEditor.deleteItem(menu, slot)) { + plugin.sms(sender, Component.text("No item config was found in slot " + slot + ".", NamedTextColor.RED)); + return; + } + } catch (final IOException exception) { + plugin.printStacktrace("Failed to delete menu item.", exception); + plugin.sms(sender, Component.text("Failed to delete menu item.", NamedTextColor.RED)); + return; + } + + configEditor.reload(menu); + plugin.sms(sender, Component.text("Deleted item config for slot " + slot + " in " + configEditor.describeTarget(menu) + ".", NamedTextColor.GREEN)); + } + + private int parseSlot(final @NotNull CommandSender sender, final @NotNull String input) { + try { + return Integer.parseInt(input); + } catch (final NumberFormatException exception) { + plugin.sms(sender, Messages.WRONG_USAGE); + return -1; + } + } + + private @NotNull List editableOptions() { + return List.of( + "material", + "amount", + "priority", + "display_name", + "lore", + "model_data", + "item_flags", + "update", + "click_commands", + "left_click_commands", + "right_click_commands", + "shift_left_click_commands", + "shift_right_click_commands", + "middle_click_commands" + ); + } + + private @NotNull List menuOptions() { + return List.of("menu_title", "size"); + } + + private @NotNull Optional findMenu(final @NotNull String menuName) { + final Optional menu = Menu.getMenuByName(menuName); + if (menu.isPresent()) { + return menu; + } + + return Menu.getSubMenuByName(menuName); + } + + private @NotNull Collection menuNames() { + return java.util.stream.Stream.concat( + Menu.getAllMenuNames().stream(), + Menu.getAllSubMenus().stream().map(menu -> menu.options().name()) + ) + .collect(Collectors.toList()); + } + + private @NotNull List complete(final @NotNull Collection values, final @NotNull String argument) { + final String lowerArgument = argument.toLowerCase(Locale.ROOT); + return values.stream() + .filter(value -> value.toLowerCase(Locale.ROOT).startsWith(lowerArgument)) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/extendedclip/deluxemenus/command/subcommand/WebEditorCommand.java b/src/main/java/com/extendedclip/deluxemenus/command/subcommand/WebEditorCommand.java new file mode 100644 index 00000000..3e91eb58 --- /dev/null +++ b/src/main/java/com/extendedclip/deluxemenus/command/subcommand/WebEditorCommand.java @@ -0,0 +1,353 @@ +package com.extendedclip.deluxemenus.command.subcommand; + +import com.extendedclip.deluxemenus.DeluxeMenus; +import com.extendedclip.deluxemenus.editor.WebEditorServer; +import com.extendedclip.deluxemenus.menu.Menu; +import com.extendedclip.deluxemenus.utils.Messages; +import java.io.IOException; +import java.lang.reflect.Method; +import java.net.InetSocketAddress; +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.stream.Collectors; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static net.kyori.adventure.text.Component.text; + +public class WebEditorCommand extends SubCommand { + + private static final String WEB_EDITOR_PERMISSION = "deluxemenus.webeditor"; + private static final int DEFAULT_PORT = 8765; + private final String name; + + public WebEditorCommand(final @NotNull DeluxeMenus plugin) { + this(plugin, "webeditor"); + } + + public WebEditorCommand(final @NotNull DeluxeMenus plugin, final @NotNull String name) { + super(plugin); + this.name = name; + } + + @Override + public @NotNull String getName() { + return name; + } + + @Override + public void execute(final @NotNull CommandSender sender, final @NotNull List arguments) { + if (!sender.hasPermission(WEB_EDITOR_PERMISSION)) { + plugin.sms(sender, Messages.NO_PERMISSION); + return; + } + + if (arguments.isEmpty()) { + plugin.sms(sender, Messages.WRONG_USAGE); + return; + } + + final String action = arguments.get(0).toLowerCase(Locale.ROOT); + if ("list".equals(action)) { + listSessions(sender); + return; + } + + if ("resume".equals(action)) { + resumeSession(sender, arguments); + return; + } + + if ("cancel".equals(action)) { + cancelSession(sender, arguments); + return; + } + + final boolean localMode = "local".equals(action); + final int menuIndex = localMode ? 1 : 0; + if (arguments.size() <= menuIndex) { + plugin.sms(sender, Messages.WRONG_USAGE); + return; + } + + final Optional optionalMenu = findMenu(arguments.get(menuIndex)); + if (optionalMenu.isEmpty()) { + plugin.sms(sender, Messages.INVALID_MENU.message().replaceText(MENU_REPLACER_BUILDER.replacement(arguments.get(menuIndex)).build())); + return; + } + + final EditorEndpoint endpoint = parseEndpoint(sender, arguments, menuIndex + 1); + if (!localMode && !hasPublicHost(endpoint)) { + plugin.sms(sender, text("No public web editor host was detected.", NamedTextColor.RED)); + plugin.sms(sender, text("Set web_editor_public_url in config.yml, or use /dm webeditor local " + optionalMenu.get().options().name() + " .", NamedTextColor.GRAY)); + return; + } + + try { + final String url = plugin.getWebEditorServer().createSession(optionalMenu.get(), endpoint.port, endpoint.host.orElse(null)); + plugin.sms(sender, text("Web editor link: ", NamedTextColor.GREEN) + .append(text(url, NamedTextColor.YELLOW).clickEvent(ClickEvent.openUrl(url)))); + if (url.contains("://localhost:")) { + plugin.sms(sender, text("Remote hosting needs a public host and an open web editor port.", NamedTextColor.GRAY)); + } else { + plugin.sms(sender, text("The web editor port must be open on your hosting panel. The Minecraft port is separate.", NamedTextColor.GRAY)); + } + } catch (final WebEditorServer.ActiveSessionException exception) { + plugin.sms(sender, text("This menu already has an active web editor session: ", NamedTextColor.RED) + .append(text(exception.url(), NamedTextColor.YELLOW).clickEvent(ClickEvent.openUrl(exception.url())))); + plugin.sms(sender, text("Use /dm webeditor resume " + optionalMenu.get().options().name() + " or /dm webeditor cancel " + optionalMenu.get().options().name() + ".", NamedTextColor.GRAY)); + } catch (final IOException exception) { + plugin.printStacktrace("Failed to start web editor.", exception); + plugin.sms(sender, text("Failed to start web editor.", NamedTextColor.RED)); + } + } + + @Override + public @Nullable List onTabComplete(final @NotNull CommandSender sender, final @NotNull List arguments) { + if (!sender.hasPermission(WEB_EDITOR_PERMISSION)) { + return null; + } + + if (arguments.isEmpty()) { + return List.of(getName()); + } + + if (arguments.size() == 1) { + return complete(List.of(getName()), arguments.get(0)); + } + + if (!getName().equalsIgnoreCase(arguments.get(0))) { + return null; + } + + if (arguments.size() == 2) { + return complete(commandTargets(), arguments.get(1)); + } + + if (arguments.size() == 3 + && ("resume".equalsIgnoreCase(arguments.get(1)) || "cancel".equalsIgnoreCase(arguments.get(1)) || "local".equalsIgnoreCase(arguments.get(1)))) { + return complete(menuNames(), arguments.get(2)); + } + + return null; + } + + private void listSessions(final @NotNull CommandSender sender) { + final List sessions = plugin.getWebEditorServer().listSessions(); + if (sessions.isEmpty()) { + plugin.sms(sender, text("No active web editor sessions.", NamedTextColor.GRAY)); + return; + } + + plugin.sms(sender, text("Active web editor sessions:", NamedTextColor.GOLD)); + for (final WebEditorServer.SessionView session : sessions) { + final long minutes = Math.max(0, Duration.between(Instant.now(), session.expiresAt()).toMinutes()); + plugin.sms(sender, text("- " + session.menuName() + " (" + minutes + "m): ", NamedTextColor.GRAY) + .append(text(session.url(), NamedTextColor.YELLOW).clickEvent(ClickEvent.openUrl(session.url())))); + } + } + + private void resumeSession(final @NotNull CommandSender sender, final @NotNull List arguments) { + if (arguments.size() < 2) { + plugin.sms(sender, Messages.WRONG_USAGE); + return; + } + + final Optional url = plugin.getWebEditorServer().resumeSession(arguments.get(1)); + if (url.isEmpty()) { + plugin.sms(sender, text("No active web editor session for " + arguments.get(1) + ".", NamedTextColor.RED)); + return; + } + + plugin.sms(sender, text("Web editor link: ", NamedTextColor.GREEN) + .append(text(url.get(), NamedTextColor.YELLOW).clickEvent(ClickEvent.openUrl(url.get())))); + } + + private void cancelSession(final @NotNull CommandSender sender, final @NotNull List arguments) { + if (arguments.size() < 2) { + plugin.sms(sender, Messages.WRONG_USAGE); + return; + } + + if (!plugin.getWebEditorServer().cancelSession(arguments.get(1))) { + plugin.sms(sender, text("No active web editor session for " + arguments.get(1) + ".", NamedTextColor.RED)); + return; + } + + plugin.sms(sender, text("Cancelled web editor session for " + arguments.get(1) + ".", NamedTextColor.GREEN)); + } + + private @NotNull EditorEndpoint parseEndpoint( + final @NotNull CommandSender sender, + final @NotNull List arguments, + final int startIndex + ) { + int port = DEFAULT_PORT; + Optional host = virtualHost(sender); + final Optional configuredEndpoint = configuredEndpoint(); + if (configuredEndpoint.isPresent()) { + port = configuredEndpoint.get().port; + host = configuredEndpoint.get().host; + } + + for (int index = startIndex; index < arguments.size(); index++) { + final String argument = arguments.get(index); + final Optional optionalEndpoint = parseHostPort(argument); + if (optionalEndpoint.isPresent()) { + port = optionalEndpoint.get().port; + host = optionalEndpoint.get().host; + continue; + } + + final Optional optionalPort = parsePort(argument); + if (optionalPort.isPresent()) { + port = optionalPort.get(); + continue; + } + + host = Optional.of(argument); + } + + return new EditorEndpoint(port, host); + } + + private @NotNull Optional parsePort(final @NotNull String input) { + try { + final int port = Integer.parseInt(input); + if (port < 1 || port > 65535) { + return Optional.empty(); + } + + return Optional.of(port); + } catch (final NumberFormatException exception) { + return Optional.empty(); + } + } + + private @NotNull Optional parseHostPort(final @NotNull String input) { + String endpoint = input.trim(); + final int schemeIndex = endpoint.indexOf("://"); + if (schemeIndex >= 0) { + endpoint = endpoint.substring(schemeIndex + 3); + } + + final int pathIndex = endpoint.indexOf('/'); + if (pathIndex >= 0) { + endpoint = endpoint.substring(0, pathIndex); + } + + if (endpoint.startsWith("[")) { + final int endIndex = endpoint.indexOf(']'); + if (endIndex <= 0 || endIndex + 2 > endpoint.length() || endpoint.charAt(endIndex + 1) != ':') { + return Optional.empty(); + } + + final Optional port = parsePort(endpoint.substring(endIndex + 2)); + if (port.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(new EditorEndpoint(port.get(), Optional.of(endpoint.substring(0, endIndex + 1)))); + } + + final int colonIndex = endpoint.lastIndexOf(':'); + if (colonIndex <= 0 || colonIndex != endpoint.indexOf(':')) { + return Optional.empty(); + } + + final Optional port = parsePort(endpoint.substring(colonIndex + 1)); + if (port.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(new EditorEndpoint(port.get(), Optional.of(endpoint.substring(0, colonIndex)))); + } + + private @NotNull Optional configuredEndpoint() { + final String publicUrl = plugin.getGeneralConfig().webEditorPublicUrl(); + if (publicUrl == null || publicUrl.isBlank()) { + return Optional.empty(); + } + + final Optional endpoint = parseHostPort(publicUrl); + if (endpoint.isPresent()) { + return endpoint; + } + + return Optional.of(new EditorEndpoint(DEFAULT_PORT, Optional.of(publicUrl))); + } + + private boolean hasPublicHost(final @NotNull EditorEndpoint endpoint) { + if (endpoint.host.isPresent()) { + return true; + } + + final String configuredHost = plugin.getServer().getIp(); + return configuredHost != null && !configuredHost.isBlank() && !"0.0.0.0".equals(configuredHost); + } + + private @NotNull Optional virtualHost(final @NotNull CommandSender sender) { + if (!(sender instanceof Player)) { + return Optional.empty(); + } + + try { + final Method method = sender.getClass().getMethod("getVirtualHost"); + final Object virtualHost = method.invoke(sender); + if (virtualHost instanceof InetSocketAddress) { + return Optional.ofNullable(((InetSocketAddress) virtualHost).getHostString()); + } + + return Optional.ofNullable(virtualHost).map(String::valueOf); + } catch (final ReflectiveOperationException exception) { + return Optional.empty(); + } + } + + private @NotNull List complete(final @NotNull Collection values, final @NotNull String argument) { + final String lowerArgument = argument.toLowerCase(Locale.ROOT); + return values.stream() + .filter(value -> value.toLowerCase(Locale.ROOT).startsWith(lowerArgument)) + .collect(Collectors.toList()); + } + + private @NotNull Optional findMenu(final @NotNull String menuName) { + final Optional menu = Menu.getMenuByName(menuName); + if (menu.isPresent()) { + return menu; + } + + return Menu.getSubMenuByName(menuName); + } + + private @NotNull Collection menuNames() { + return java.util.stream.Stream.concat( + Menu.getAllMenuNames().stream(), + Menu.getAllSubMenus().stream().map(menu -> menu.options().name()) + ) + .collect(Collectors.toList()); + } + + private @NotNull Collection commandTargets() { + return java.util.stream.Stream.concat(menuNames().stream(), List.of("list", "resume", "cancel", "local").stream()) + .collect(Collectors.toList()); + } + + private static class EditorEndpoint { + private final int port; + private final Optional host; + + private EditorEndpoint(final int port, final @NotNull Optional host) { + this.port = port; + this.host = host; + } + } +} diff --git a/src/main/java/com/extendedclip/deluxemenus/config/DeluxeMenusConfig.java b/src/main/java/com/extendedclip/deluxemenus/config/DeluxeMenusConfig.java index 0baa68f5..77a4e78d 100644 --- a/src/main/java/com/extendedclip/deluxemenus/config/DeluxeMenusConfig.java +++ b/src/main/java/com/extendedclip/deluxemenus/config/DeluxeMenusConfig.java @@ -96,6 +96,8 @@ public class DeluxeMenusConfig { private final String separator = File.separator; private final File menuDirectory; + private final File subMenuDirectory; + private final File converterDirectory; private final DeluxeMenus plugin; private final List exampleMenus = Arrays.asList("basics_menu", "advanced_menu", "requirements_menu" // more example menus here @@ -106,12 +108,18 @@ public DeluxeMenusConfig(@NotNull final DeluxeMenus plugin) { this.plugin = plugin; menuDirectory = new File(this.plugin.getDataFolder() + separator + "gui_menus"); + subMenuDirectory = new File(this.plugin.getDataFolder() + separator + "sub_menu"); + converterDirectory = new File(this.plugin.getDataFolder() + separator + "converter"); try { if (menuDirectory.mkdirs()) { plugin.debug(DebugLevel.HIGH, Level.INFO, "Individual menus directory did not exist.", "Created directory: plugins" + separator + "DeluxeMenus" + separator + "gui_menus"); } + if (subMenuDirectory.mkdirs()) { + plugin.debug(DebugLevel.HIGH, Level.INFO, "Sub menu directory did not exist.", "Created directory: plugins" + separator + "DeluxeMenus" + separator + "sub_menu"); + } + converterDirectory.mkdirs(); } catch (SecurityException e) { - plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Something went wrong while creating directory: plugins" + separator + "DeluxeMenus" + separator + "gui_menus"); + plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Something went wrong while creating DeluxeMenus directories."); } } @@ -169,6 +177,8 @@ public boolean loadDefConfig() { c.addDefault("check_updates", true); c.addDefault("use_admin_commands_in_menus_list", false); c.addDefault("menus_list_page_size", 10); + c.addDefault("web_editor_public_url", ""); + c.addDefault("sub_menus", new HashMap<>()); c.options().copyDefaults(true); if (!c.contains("gui_menus")) { @@ -331,19 +341,64 @@ public int loadGUIMenus() { loadMenu(c, key, true, "config"); } } + loadSubMenus(); return Menu.getLoadedMenuSize(); } + public int loadSubMenus() { + + if (checkConfig(null, "config.yml", false) == null) { + return 0; + } + + FileConfiguration c = plugin.getConfig(); + + if (!c.contains("sub_menus")) { + return 0; + } + + if (!c.isConfigurationSection("sub_menus")) { + return 0; + } + + Set keys = c.getConfigurationSection("sub_menus").getKeys(false); + + if (keys.isEmpty()) { + return 0; + } + + for (String key : keys) { + + if (c.contains("sub_menus." + key + ".file")) { + loadSubMenuFromFile(key); + } else { + loadMenu(c, key, true, "config", true); + } + } + + return Menu.getLoadedSubMenuSize(); + } + public boolean loadMenuFromFile(String menuName) { + return loadMenuFromFile(menuName, false); + } - String fileName = plugin.getConfig().getString("gui_menus." + menuName + ".file"); + public boolean loadSubMenuFromFile(String menuName) { + return loadMenuFromFile(menuName, true); + } + + private boolean loadMenuFromFile(String menuName, final boolean subMenu) { + + final String configRoot = subMenu ? "sub_menus" : "gui_menus"; + final File directory = subMenu ? subMenuDirectory : menuDirectory; + String fileName = plugin.getConfig().getString(configRoot + "." + menuName + ".file"); if (!fileName.endsWith(".yml")) { plugin.debug(DebugLevel.HIGHEST, Level.SEVERE, "Filename specified for menu: " + menuName + " is not a .yml file!", "Make sure that the file name to load this menu from is specified as a .yml file!", "Skipping loading of menu: " + menuName); return false; } - File f = new File(menuDirectory.getPath(), fileName); + File f = new File(directory.getPath(), fileName); if (!f.exists()) { plugin.debug(DebugLevel.HIGHEST, Level.INFO, f.getName() + " does not exist!"); @@ -359,7 +414,7 @@ public boolean loadMenuFromFile(String menuName) { } plugin.debug(DebugLevel.HIGHEST, Level.INFO, f.getName() + " created! Add your menu options to this file and use /dm reload to load it!"); } catch (IOException e) { - plugin.debug(DebugLevel.HIGHEST, Level.SEVERE, "Could not create menu file: plugins" + separator + "DeluxeMenus" + separator + "gui_menus" + separator + fileName); + plugin.debug(DebugLevel.HIGHEST, Level.SEVERE, "Could not create menu file: plugins" + separator + "DeluxeMenus" + separator + directory.getName() + separator + fileName); return false; } } @@ -377,31 +432,35 @@ public boolean loadMenuFromFile(String menuName) { return false; } - final Path guiMenusPath = menuDirectory.toPath(); + final Path guiMenusPath = directory.toPath(); final Path menuPath = f.toPath(); final Path relativePath = guiMenusPath.relativize(menuPath); - loadMenu(cfg, menuName, false, relativePath.toString()); - return Menu.getMenuByName(menuName).isPresent(); + loadMenu(cfg, menuName, false, relativePath.toString(), subMenu); + return subMenu ? Menu.getSubMenuByName(menuName).isPresent() : Menu.getMenuByName(menuName).isPresent(); } public void loadMenu(FileConfiguration c, String key, boolean mainConfig, final @NotNull String path) { + loadMenu(c, key, mainConfig, path, false); + } + + private void loadMenu(FileConfiguration c, String key, boolean mainConfig, final @NotNull String path, final boolean subMenu) { if (mainConfig) { plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Menu: " + key + " does not have a file specified in config.yml! Creating menus in the " + "config.yml file is deprecated and will be removed in a future version! Please migrate your " + "menus to individual files in the gui_menus directory! For more information see: " + "https://wiki.helpch.at/clips-plugins/deluxemenus/external-menus"); } - String pre = "gui_menus." + key + "."; + String pre = (subMenu ? "sub_menus." : "gui_menus.") + key + "."; if (!mainConfig) { pre = ""; } - if (!c.contains(pre + "menu_title")) { + if (!subMenu && !c.contains(pre + "menu_title")) { plugin.debug(DebugLevel.HIGHEST, Level.SEVERE, "Menu title for menu: " + key + " is not present!", "Skipping menu: " + key); return; } - String title = null; + String title = subMenu ? key : null; if (c.isString(pre + "menu_title")) { title = c.getString(pre + "menu_title"); @@ -418,7 +477,7 @@ public void loadMenu(FileConfiguration c, String key, boolean mainConfig, final InventoryType type = InventoryType.CHEST; - if (c.contains(pre + "inventory_type")) { + if (!subMenu && c.contains(pre + "inventory_type")) { try { final InventoryType inventoryType = InventoryType.valueOf(c.getString(pre + "inventory_type").toUpperCase()); type = !VALID_INVENTORY_TYPES.contains(inventoryType) ? InventoryType.CHEST : inventoryType; @@ -428,10 +487,11 @@ public void loadMenu(FileConfiguration c, String key, boolean mainConfig, final } builder.type(type); + builder.subMenu(subMenu); final List openCommands = new ArrayList<>(); - if (c.contains(pre + "open_command")) { + if (!subMenu && c.contains(pre + "open_command")) { if (c.isString(pre + "open_command") && !c.getString(pre + "open_command").isEmpty()) { String cmd = c.getString(pre + "open_command"); @@ -498,10 +558,10 @@ public void loadMenu(FileConfiguration c, String key, boolean mainConfig, final builder.argumentRequirements(argumentRequirements); builder.argumentsUsageMessage(c.getString(pre + "args_usage_message", null)); - int size = 54; + int size = subMenu ? 36 : 54; if (type == InventoryType.CHEST) { if (!c.contains(pre + "size")) { - plugin.debug(DebugLevel.HIGHEST, Level.INFO, "Menu size for menu: " + key + " is not present!", "Using default size of 54"); + plugin.debug(DebugLevel.HIGHEST, Level.INFO, "Menu size for menu: " + key + " is not present!", "Using default size of " + size); } else { size = c.getInt(pre + "size"); @@ -514,14 +574,16 @@ public void loadMenu(FileConfiguration c, String key, boolean mainConfig, final size = 9; } - if (size > 54) { - plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Menu size for menu: " + key + " is higher than 54", "Defaulting to 54."); - size = 54; + final int maxSize = subMenu ? 36 : 54; + if (size > maxSize) { + plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Menu size for menu: " + key + " is higher than " + maxSize, "Defaulting to " + maxSize + "."); + size = maxSize; } if (size % 9 != 0) { - plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Menu size for menu: " + key + " is not a multiple of 9", "Defaulting to 54."); - size = 54; + final int defaultSize = subMenu ? 36 : 54; + plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Menu size for menu: " + key + " is not a multiple of 9", "Defaulting to " + defaultSize + "."); + size = defaultSize; } } } else { @@ -555,7 +617,12 @@ public void loadMenu(FileConfiguration c, String key, boolean mainConfig, final final boolean refresh = c.getBoolean(pre + "refresh", false); builder.refresh(refresh); - Map> items = loadMenuItems(c, key, mainConfig); + builder.hidePlayerInventory(!subMenu && c.getBoolean(pre + "hide_player_inventory", false)); + if (!subMenu) { + builder.playerInventoryMenu(c.getString(pre + "player_inventory_menu", c.getString(pre + "bottom_menu", null))); + } + + Map> items = loadMenuItems(c, key, mainConfig, subMenu); if (items == null || items.isEmpty()) { plugin.debug(DebugLevel.HIGHEST, Level.SEVERE, "Failed to load menu items for menu: " + key, "Skipping menu: " + key); @@ -571,7 +638,11 @@ public void loadMenu(FileConfiguration c, String key, boolean mainConfig, final } private Map> loadMenuItems(FileConfiguration c, String name, boolean mainConfig) { - String itemsPath = "gui_menus." + name + ".items"; + return loadMenuItems(c, name, mainConfig, false); + } + + private Map> loadMenuItems(FileConfiguration c, String name, boolean mainConfig, final boolean subMenu) { + String itemsPath = (subMenu ? "sub_menus." : "gui_menus.") + name + ".items"; if (!mainConfig) { itemsPath = "items"; @@ -829,6 +900,11 @@ private Map> loadMenuItems(FileConfiguration final MenuItem menuItem = new MenuItem(plugin, builder.build()); for (int slot : slots) { + if (subMenu && (slot < 0 || slot >= 36)) { + plugin.debug(DebugLevel.HIGHEST, Level.WARNING, "Slot " + slot + " for item: " + key + " in sub menu: " + name + " is outside 0-35.", "Skipping slot: " + slot); + continue; + } + TreeMap slotPriorityMap; if ((!menuItems.containsKey(slot)) || menuItems.get(slot) == null) { slotPriorityMap = new TreeMap<>(); @@ -1284,6 +1360,14 @@ public File getMenuDirector() { return menuDirectory; } + public File getSubMenuDirectory() { + return subMenuDirectory; + } + + public File getConverterDirectory() { + return converterDirectory; + } + public void addEnchantmentsOptionToBuilder( final FileConfiguration c, final String currentPath, diff --git a/src/main/java/com/extendedclip/deluxemenus/config/GeneralConfig.java b/src/main/java/com/extendedclip/deluxemenus/config/GeneralConfig.java index 0421b399..70e382cf 100644 --- a/src/main/java/com/extendedclip/deluxemenus/config/GeneralConfig.java +++ b/src/main/java/com/extendedclip/deluxemenus/config/GeneralConfig.java @@ -12,6 +12,7 @@ public class GeneralConfig { private boolean useAdminCommandsInMenusList = false; private int menusListPageSize = 10; private int metasListPageSize = 15; + private String webEditorPublicUrl = ""; public GeneralConfig(final @NotNull DeluxeMenus plugin) { this.plugin = plugin; @@ -23,12 +24,14 @@ public void load() { plugin.getConfig().addDefault("use_admin_commands_in_menus_list", false); plugin.getConfig().addDefault("menus_list_page_size", menusListPageSize); plugin.getConfig().addDefault("metas_list_page_size", metasListPageSize); + plugin.getConfig().addDefault("web_editor_public_url", webEditorPublicUrl); checkForUpdates = plugin.getConfig().getBoolean("check_updates", false); debugLevel = loadDebugLevel(); useAdminCommandsInMenusList = plugin.getConfig().getBoolean("use_admin_commands_in_menus_list", false); menusListPageSize = plugin.getConfig().getInt("menus_list_page_size", 10); metasListPageSize = plugin.getConfig().getInt("metas_list_page_size", 15); + webEditorPublicUrl = plugin.getConfig().getString("web_editor_public_url", ""); } public void reload() { @@ -56,6 +59,10 @@ public int metasListPageSize() { return metasListPageSize; } + public @NotNull String webEditorPublicUrl() { + return webEditorPublicUrl; + } + private @NotNull DebugLevel loadDebugLevel() { String configDebugLevel = plugin.getConfig().getString("debug", "HIGHEST"); diff --git a/src/main/java/com/extendedclip/deluxemenus/editor/MenuConfigEditor.java b/src/main/java/com/extendedclip/deluxemenus/editor/MenuConfigEditor.java new file mode 100644 index 00000000..eacd2ea3 --- /dev/null +++ b/src/main/java/com/extendedclip/deluxemenus/editor/MenuConfigEditor.java @@ -0,0 +1,301 @@ +package com.extendedclip.deluxemenus.editor; + +import com.extendedclip.deluxemenus.DeluxeMenus; +import com.extendedclip.deluxemenus.menu.Menu; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class MenuConfigEditor { + + private final DeluxeMenus plugin; + + public MenuConfigEditor(final @NotNull DeluxeMenus plugin) { + this.plugin = plugin; + } + + public @NotNull Optional resolveFile(final @NotNull Menu menu) { + if ("config".equalsIgnoreCase(menu.path())) { + return Optional.empty(); + } + + final File directory = menu.options().subMenu() + ? plugin.getConfiguration().getSubMenuDirectory() + : plugin.getConfiguration().getMenuDirector(); + return Optional.of(new File(directory, menu.path())); + } + + public @NotNull String readRaw(final @NotNull Menu menu) throws IOException { + final Optional optionalFile = resolveFile(menu); + if (optionalFile.isPresent()) { + return Files.readString(optionalFile.get().toPath(), StandardCharsets.UTF_8); + } + + return plugin.getConfig().saveToString(); + } + + public @NotNull String describeTarget(final @NotNull Menu menu) { + return resolveFile(menu).map(File::getPath).orElse("config.yml"); + } + + public void saveRaw(final @NotNull Menu menu, final @NotNull String raw) throws IOException { + final Optional optionalFile = resolveFile(menu); + if (optionalFile.isPresent()) { + Files.writeString(optionalFile.get().toPath(), raw, StandardCharsets.UTF_8); + return; + } + + try { + plugin.getConfig().loadFromString(raw); + plugin.saveConfig(); + } catch (final InvalidConfigurationException exception) { + throw new IOException("Invalid YAML", exception); + } + } + + public boolean setMaterial(final @NotNull Menu menu, final int slot, final @NotNull String material) throws IOException { + return setItemValue(menu, slot, "material", material); + } + + public boolean setItemValue(final @NotNull Menu menu, final int slot, final @NotNull String option, final @NotNull String value) throws IOException { + final YamlConfiguration config = load(menu); + final String itemPath = findOrCreateItemPath(config, menu, slot); + if (itemPath == null) { + return false; + } + + if (isListOption(option)) { + config.set(itemPath + "." + option, parseList(value)); + } else if (isIntegerOption(option)) { + config.set(itemPath + "." + option, parseInteger(value)); + } else if (isBooleanOption(option)) { + config.set(itemPath + "." + option, Boolean.parseBoolean(value)); + } else if (value.isBlank() && isOptionalOption(option)) { + config.set(itemPath + "." + option, null); + } else { + config.set(itemPath + "." + option, value); + } + + save(menu, config); + return true; + } + + public boolean deleteItem(final @NotNull Menu menu, final int slot) throws IOException { + final YamlConfiguration config = load(menu); + final String itemPath = findItemPath(config, menu, slot); + if (itemPath == null) { + return false; + } + + config.set(itemPath, null); + save(menu, config); + return true; + } + + public boolean setMenuValue(final @NotNull Menu menu, final @NotNull String option, final @NotNull String value) throws IOException { + final YamlConfiguration config = load(menu); + final String root = getRoot(menu); + + if ("size".equals(option)) { + config.set(root + option, parseInteger(value)); + } else if (value.isBlank()) { + config.set(root + option, null); + } else { + config.set(root + option, value); + } + + save(menu, config); + return true; + } + + public @NotNull Optional getMenuString(final @NotNull Menu menu, final @NotNull String option) { + final YamlConfiguration config = load(menu); + final String root = getRoot(menu); + final Object value = config.get(root + option); + return Optional.ofNullable(value).map(String::valueOf); + } + + public @NotNull Optional getItemString(final @NotNull Menu menu, final int slot, final @NotNull String option) { + final YamlConfiguration config = load(menu); + final String itemPath = findItemPath(config, menu, slot); + if (itemPath == null) { + return Optional.empty(); + } + + if (config.isList(itemPath + "." + option)) { + return Optional.of(String.join("\n", config.getStringList(itemPath + "." + option))); + } + + return Optional.ofNullable(config.get(itemPath + "." + option)).map(String::valueOf); + } + + public void reload(final @NotNull Menu menu) { + final String menuName = menu.options().name(); + final boolean subMenu = menu.options().subMenu(); + final boolean mainConfigMenu = "config".equalsIgnoreCase(menu.path()); + + Menu.unload(plugin, menuName); + plugin.reloadConfig(); + plugin.reload(); + if (subMenu) { + if (mainConfigMenu) { + plugin.getConfiguration().loadSubMenus(); + return; + } + + plugin.getConfiguration().loadSubMenuFromFile(menuName); + return; + } + + plugin.getConfiguration().loadGUIMenu(menuName); + } + + private @NotNull YamlConfiguration load(final @NotNull Menu menu) { + final Optional optionalFile = resolveFile(menu); + return optionalFile + .map(YamlConfiguration::loadConfiguration) + .orElseGet(() -> { + final YamlConfiguration yaml = new YamlConfiguration(); + for (final String key : plugin.getConfig().getKeys(false)) { + yaml.set(key, plugin.getConfig().get(key)); + } + return yaml; + }); + } + + private void save(final @NotNull Menu menu, final @NotNull YamlConfiguration config) throws IOException { + final Optional optionalFile = resolveFile(menu); + if (optionalFile.isPresent()) { + final File file = optionalFile.get(); + final File parent = file.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + config.save(file); + return; + } + + for (final String configKey : config.getKeys(false)) { + plugin.getConfig().set(configKey, config.get(configKey)); + } + plugin.saveConfig(); + } + + private @Nullable String findOrCreateItemPath(final @NotNull YamlConfiguration config, final @NotNull Menu menu, final int slot) { + final String existingPath = findItemPath(config, menu, slot); + if (existingPath != null) { + return existingPath; + } + + final String root = getRoot(menu); + final String itemKey = "slot_" + slot; + final String itemPath = root + "items." + itemKey; + config.set(itemPath + ".slot", slot); + config.set(itemPath + ".material", "STONE"); + return itemPath; + } + + private @Nullable String findItemPath(final @NotNull YamlConfiguration config, final @NotNull Menu menu, final int slot) { + final String root = getRoot(menu); + final ConfigurationSection items = config.getConfigurationSection(root + "items"); + if (items == null) { + return null; + } + + for (final String key : items.getKeys(false)) { + final String itemPath = root + "items." + key; + if (!matchesSlot(config, itemPath, slot)) { + continue; + } + + return itemPath; + } + + return null; + } + + private @NotNull String getRoot(final @NotNull Menu menu) { + if (resolveFile(menu).isPresent()) { + return ""; + } + + final String section = menu.options().subMenu() ? "sub_menus" : "gui_menus"; + return section + "." + menu.options().name() + "."; + } + + private boolean isListOption(final @NotNull String option) { + return option.endsWith("_commands") || "lore".equals(option) || "item_flags".equals(option); + } + + private boolean isIntegerOption(final @NotNull String option) { + return "amount".equals(option) || "priority".equals(option) || "slot".equals(option); + } + + private boolean isBooleanOption(final @NotNull String option) { + return "update".equals(option); + } + + private boolean isOptionalOption(final @NotNull String option) { + return !"material".equals(option); + } + + private int parseInteger(final @NotNull String value) { + try { + return Integer.parseInt(value.trim()); + } catch (final NumberFormatException exception) { + return 0; + } + } + + private @NotNull List parseList(final @NotNull String value) { + if (value.isBlank()) { + return List.of(); + } + return Arrays.stream(value.split("\\R|\\s\\|\\s|\\|")) + .map(String::trim) + .filter(line -> !line.isEmpty()) + .collect(Collectors.toList()); + } + + private boolean matchesSlot(final @NotNull YamlConfiguration config, final @NotNull String itemPath, final int slot) { + if (config.getInt(itemPath + ".slot", Integer.MIN_VALUE) == slot) { + return true; + } + + final List configuredSlots = config.getList(itemPath + ".slots"); + if (configuredSlots == null) { + return false; + } + + for (final Object configuredSlot : configuredSlots) { + if (configuredSlot != null && configuredSlotMatches(String.valueOf(configuredSlot), slot)) { + return true; + } + } + + return false; + } + + private boolean configuredSlotMatches(final @NotNull String configuredSlot, final int slot) { + final String[] range = configuredSlot.split("-", 2); + try { + if (range.length == 2) { + return slot >= Integer.parseInt(range[0].trim()) && slot <= Integer.parseInt(range[1].trim()); + } + + return slot == Integer.parseInt(configuredSlot.trim()); + } catch (final NumberFormatException exception) { + return false; + } + } +} diff --git a/src/main/java/com/extendedclip/deluxemenus/editor/MenuEditPromptRegistry.java b/src/main/java/com/extendedclip/deluxemenus/editor/MenuEditPromptRegistry.java new file mode 100644 index 00000000..52c5696e --- /dev/null +++ b/src/main/java/com/extendedclip/deluxemenus/editor/MenuEditPromptRegistry.java @@ -0,0 +1,87 @@ +package com.extendedclip.deluxemenus.editor; + +import com.extendedclip.deluxemenus.DeluxeMenus; +import com.extendedclip.deluxemenus.menu.Menu; +import java.io.IOException; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import static net.kyori.adventure.text.Component.text; + +public final class MenuEditPromptRegistry { + + private static final Map PROMPTS = new ConcurrentHashMap<>(); + + private MenuEditPromptRegistry() { + } + + public static void begin(final @NotNull Player player, final @NotNull String menuName, final int slot, final @NotNull String option) { + PROMPTS.put(player.getUniqueId(), new Prompt(menuName, slot, option)); + } + + public static boolean hasPrompt(final @NotNull Player player) { + return PROMPTS.containsKey(player.getUniqueId()); + } + + public static void cancel(final @NotNull Player player) { + PROMPTS.remove(player.getUniqueId()); + } + + public static void submit(final @NotNull DeluxeMenus plugin, final @NotNull Player player, final @NotNull String value) { + final Prompt prompt = PROMPTS.remove(player.getUniqueId()); + if (prompt == null) { + return; + } + + if ("cancel".equalsIgnoreCase(value)) { + plugin.sms(player, text("Edit cancelled.", NamedTextColor.GRAY)); + return; + } + + final Optional optionalMenu = findMenu(prompt.menuName); + if (optionalMenu.isEmpty()) { + plugin.sms(player, text("Menu is no longer loaded.", NamedTextColor.RED)); + return; + } + + final Menu menu = optionalMenu.get(); + final MenuConfigEditor configEditor = new MenuConfigEditor(plugin); + try { + configEditor.setItemValue(menu, prompt.slot, prompt.option, value); + } catch (final IOException exception) { + plugin.printStacktrace("Failed to save menu edit.", exception); + plugin.sms(player, text("Failed to save menu edit.", NamedTextColor.RED)); + return; + } + + configEditor.reload(menu); + plugin.sms(player, text("Updated " + prompt.option + " for slot " + prompt.slot + " in " + configEditor.describeTarget(menu) + ".", NamedTextColor.GREEN)); + findMenu(prompt.menuName).ifPresent(reloaded -> new MenuEditorManager(plugin).openSlot(player, reloaded, prompt.slot)); + } + + private static @NotNull Optional findMenu(final @NotNull String menuName) { + final Optional menu = Menu.getMenuByName(menuName); + if (menu.isPresent()) { + return menu; + } + + return Menu.getSubMenuByName(menuName); + } + + private static class Prompt { + private final String menuName; + private final int slot; + private final String option; + + private Prompt(final @NotNull String menuName, final int slot, final @NotNull String option) { + this.menuName = menuName; + this.slot = slot; + this.option = option; + } + } +} diff --git a/src/main/java/com/extendedclip/deluxemenus/editor/MenuEditorHolder.java b/src/main/java/com/extendedclip/deluxemenus/editor/MenuEditorHolder.java new file mode 100644 index 00000000..08ba887e --- /dev/null +++ b/src/main/java/com/extendedclip/deluxemenus/editor/MenuEditorHolder.java @@ -0,0 +1,49 @@ +package com.extendedclip.deluxemenus.editor; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.jetbrains.annotations.NotNull; + +public class MenuEditorHolder implements InventoryHolder { + + public enum View { + PREVIEW, + SLOT + } + + private final String menuName; + private final View view; + private final int slot; + private Inventory inventory; + + public MenuEditorHolder(final @NotNull String menuName) { + this(menuName, View.PREVIEW, -1); + } + + public MenuEditorHolder(final @NotNull String menuName, final @NotNull View view, final int slot) { + this.menuName = menuName; + this.view = view; + this.slot = slot; + } + + public @NotNull String menuName() { + return this.menuName; + } + + public @NotNull View view() { + return this.view; + } + + public int slot() { + return this.slot; + } + + @Override + public @NotNull Inventory getInventory() { + return this.inventory; + } + + public void setInventory(final @NotNull Inventory inventory) { + this.inventory = inventory; + } +} diff --git a/src/main/java/com/extendedclip/deluxemenus/editor/MenuEditorListener.java b/src/main/java/com/extendedclip/deluxemenus/editor/MenuEditorListener.java new file mode 100644 index 00000000..f495ed96 --- /dev/null +++ b/src/main/java/com/extendedclip/deluxemenus/editor/MenuEditorListener.java @@ -0,0 +1,210 @@ +package com.extendedclip.deluxemenus.editor; + +import com.extendedclip.deluxemenus.DeluxeMenus; +import com.extendedclip.deluxemenus.listener.Listener; +import com.extendedclip.deluxemenus.menu.Menu; +import java.io.IOException; +import java.util.Optional; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.InventoryHolder; +import org.jetbrains.annotations.NotNull; + +import static net.kyori.adventure.text.Component.newline; +import static net.kyori.adventure.text.Component.text; + +public class MenuEditorListener extends Listener { + + private final MenuEditorManager editorManager; + private final MenuConfigEditor configEditor; + + public MenuEditorListener(final @NotNull DeluxeMenus plugin) { + super(plugin); + this.editorManager = new MenuEditorManager(plugin); + this.configEditor = new MenuConfigEditor(plugin); + } + + @EventHandler + public void onChat(final @NotNull AsyncPlayerChatEvent event) { + if (!MenuEditPromptRegistry.hasPrompt(event.getPlayer())) { + return; + } + + event.setCancelled(true); + plugin.getScheduler().runTask(event.getPlayer(), () -> MenuEditPromptRegistry.submit(plugin, event.getPlayer(), event.getMessage())); + } + + @EventHandler + public void onQuit(final @NotNull PlayerQuitEvent event) { + MenuEditPromptRegistry.cancel(event.getPlayer()); + } + + @EventHandler + public void onDrag(final @NotNull InventoryDragEvent event) { + if (event.getInventory().getHolder() instanceof MenuEditorHolder) { + event.setCancelled(true); + } + } + + @EventHandler + public void onClick(final @NotNull InventoryClickEvent event) { + final InventoryHolder holder = event.getInventory().getHolder(); + if (!(holder instanceof MenuEditorHolder)) { + return; + } + + event.setCancelled(true); + if (!(event.getWhoClicked() instanceof Player)) { + return; + } + + final Player player = (Player) event.getWhoClicked(); + final MenuEditorHolder editorHolder = (MenuEditorHolder) holder; + final Optional optionalMenu = findMenu(editorHolder.menuName()); + if (optionalMenu.isEmpty()) { + player.closeInventory(); + plugin.sms(player, text("Menu is no longer loaded.", NamedTextColor.RED)); + return; + } + + final int slot = event.getRawSlot(); + if (slot < 0 || slot >= event.getInventory().getSize()) { + return; + } + + if (editorHolder.view() == MenuEditorHolder.View.PREVIEW) { + editorManager.openSlot(player, optionalMenu.get(), slot); + return; + } + + handleSlotEditor(player, optionalMenu.get(), editorHolder.slot(), slot); + } + + private void handleSlotEditor( + final @NotNull Player player, + final @NotNull Menu menu, + final int editedSlot, + final int buttonSlot + ) { + switch (buttonSlot) { + case MenuEditorManager.BUTTON_MATERIAL: + beginPrompt(player, menu, editedSlot, "material"); + break; + case MenuEditorManager.BUTTON_AMOUNT: + beginPrompt(player, menu, editedSlot, "amount"); + break; + case MenuEditorManager.BUTTON_DISPLAY_NAME: + beginPrompt(player, menu, editedSlot, "display_name"); + break; + case MenuEditorManager.BUTTON_LORE: + beginPrompt(player, menu, editedSlot, "lore"); + break; + case MenuEditorManager.BUTTON_MODEL_DATA: + beginPrompt(player, menu, editedSlot, "model_data"); + break; + case MenuEditorManager.BUTTON_ITEM_FLAGS: + beginPrompt(player, menu, editedSlot, "item_flags"); + break; + case MenuEditorManager.BUTTON_UPDATE: + toggleUpdate(player, menu, editedSlot); + break; + case MenuEditorManager.BUTTON_CLICK_COMMANDS: + beginPrompt(player, menu, editedSlot, "click_commands"); + break; + case MenuEditorManager.BUTTON_LEFT_CLICK_COMMANDS: + beginPrompt(player, menu, editedSlot, "left_click_commands"); + break; + case MenuEditorManager.BUTTON_RIGHT_CLICK_COMMANDS: + beginPrompt(player, menu, editedSlot, "right_click_commands"); + break; + case MenuEditorManager.BUTTON_SHIFT_LEFT_CLICK_COMMANDS: + beginPrompt(player, menu, editedSlot, "shift_left_click_commands"); + break; + case MenuEditorManager.BUTTON_SHIFT_RIGHT_CLICK_COMMANDS: + beginPrompt(player, menu, editedSlot, "shift_right_click_commands"); + break; + case MenuEditorManager.BUTTON_MIDDLE_CLICK_COMMANDS: + beginPrompt(player, menu, editedSlot, "middle_click_commands"); + break; + case MenuEditorManager.BUTTON_PRIORITY: + beginPrompt(player, menu, editedSlot, "priority"); + break; + case MenuEditorManager.BUTTON_DELETE: + deleteItem(player, menu, editedSlot); + break; + case MenuEditorManager.BUTTON_BACK: + editorManager.open(player, menu); + break; + case MenuEditorManager.BUTTON_REFRESH: + editorManager.openSlot(player, menu, editedSlot); + break; + default: + break; + } + } + + private void beginPrompt( + final @NotNull Player player, + final @NotNull Menu menu, + final int slot, + final @NotNull String option + ) { + MenuEditPromptRegistry.begin(player, menu.options().name(), slot, option); + player.closeInventory(); + + final String current = configEditor.getItemString(menu, slot, option).orElse("-"); + plugin.sms(player, text("Editing " + option + " for " + menu.options().name() + " slot " + slot, NamedTextColor.GOLD) + .append(newline()) + .append(text("Current: ", NamedTextColor.GRAY)) + .append(text(current, NamedTextColor.WHITE)) + .append(newline()) + .append(text("Send a value in chat. Use | between lore or command lines. ", NamedTextColor.YELLOW)) + .append(text("Cancel", NamedTextColor.RED).clickEvent(ClickEvent.suggestCommand("cancel")))); + } + + private void toggleUpdate(final @NotNull Player player, final @NotNull Menu menu, final int slot) { + final boolean current = Boolean.parseBoolean(configEditor.getItemString(menu, slot, "update").orElse("false")); + try { + configEditor.setItemValue(menu, slot, "update", String.valueOf(!current)); + configEditor.reload(menu); + } catch (final IOException exception) { + plugin.printStacktrace("Failed to save menu edit.", exception); + plugin.sms(player, text("Failed to save menu edit.", NamedTextColor.RED)); + return; + } + + findMenu(menu.options().name()).ifPresent(reloaded -> editorManager.openSlot(player, reloaded, slot)); + } + + private void deleteItem(final @NotNull Player player, final @NotNull Menu menu, final int slot) { + try { + if (!configEditor.deleteItem(menu, slot)) { + plugin.sms(player, text("No item config was found for slot " + slot + ".", NamedTextColor.RED)); + return; + } + configEditor.reload(menu); + } catch (final IOException exception) { + plugin.printStacktrace("Failed to delete menu item.", exception); + plugin.sms(player, text("Failed to delete menu item.", NamedTextColor.RED)); + return; + } + + findMenu(menu.options().name()).ifPresent(reloaded -> editorManager.open(player, reloaded)); + } + + private @NotNull Optional findMenu(final @NotNull String menuName) { + final Optional menu = Menu.getMenuByName(menuName); + if (menu.isPresent()) { + return menu; + } + + return Menu.getSubMenuByName(menuName); + } +} diff --git a/src/main/java/com/extendedclip/deluxemenus/editor/MenuEditorManager.java b/src/main/java/com/extendedclip/deluxemenus/editor/MenuEditorManager.java new file mode 100644 index 00000000..9ff5077c --- /dev/null +++ b/src/main/java/com/extendedclip/deluxemenus/editor/MenuEditorManager.java @@ -0,0 +1,188 @@ +package com.extendedclip.deluxemenus.editor; + +import com.extendedclip.deluxemenus.DeluxeMenus; +import com.extendedclip.deluxemenus.menu.Menu; +import com.extendedclip.deluxemenus.menu.MenuHolder; +import com.extendedclip.deluxemenus.menu.MenuItem; +import com.extendedclip.deluxemenus.utils.StringUtils; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class MenuEditorManager { + + public static final int BUTTON_MATERIAL = 19; + public static final int BUTTON_AMOUNT = 20; + public static final int BUTTON_DISPLAY_NAME = 21; + public static final int BUTTON_LORE = 22; + public static final int BUTTON_MODEL_DATA = 23; + public static final int BUTTON_ITEM_FLAGS = 24; + public static final int BUTTON_UPDATE = 25; + public static final int BUTTON_CLICK_COMMANDS = 28; + public static final int BUTTON_LEFT_CLICK_COMMANDS = 29; + public static final int BUTTON_RIGHT_CLICK_COMMANDS = 30; + public static final int BUTTON_SHIFT_LEFT_CLICK_COMMANDS = 31; + public static final int BUTTON_SHIFT_RIGHT_CLICK_COMMANDS = 32; + public static final int BUTTON_MIDDLE_CLICK_COMMANDS = 33; + public static final int BUTTON_PRIORITY = 37; + public static final int BUTTON_DELETE = 40; + public static final int BUTTON_BACK = 45; + public static final int BUTTON_REFRESH = 49; + + private final DeluxeMenus plugin; + private final MenuConfigEditor configEditor; + + public MenuEditorManager(final @NotNull DeluxeMenus plugin) { + this.plugin = plugin; + this.configEditor = new MenuConfigEditor(plugin); + } + + public void open(final @NotNull Player player, final @NotNull Menu menu) { + final int size = menu.options().size(); + final MenuEditorHolder editorHolder = new MenuEditorHolder(menu.options().name()); + final Inventory inventory = Bukkit.createInventory(editorHolder, size, title("Edit: " + menu.options().name())); + editorHolder.setInventory(inventory); + + final MenuHolder renderHolder = renderHolder(player, menu); + for (final Map.Entry> entry : menu.getMenuItems().entrySet()) { + final int slot = entry.getKey(); + if (slot < 0 || slot >= size || entry.getValue().isEmpty()) { + continue; + } + + final ItemStack itemStack = entry.getValue().firstEntry().getValue().getItemStack(renderHolder); + if (itemStack != null) { + inventory.setItem(slot, itemStack); + } + } + + player.openInventory(inventory); + } + + public void openSlot(final @NotNull Player player, final @NotNull Menu menu, final int slot) { + final MenuEditorHolder editorHolder = new MenuEditorHolder(menu.options().name(), MenuEditorHolder.View.SLOT, slot); + final Inventory inventory = Bukkit.createInventory(editorHolder, 54, title("Slot " + slot + ": " + menu.options().name())); + editorHolder.setInventory(inventory); + + inventory.setItem(4, previewItem(player, menu, slot)); + inventory.setItem(13, button(Material.PAPER, "&eSlot " + slot, + "&7Menu: &f" + menu.options().name(), + "&7File: &f" + menu.path(), + "&7Loaded item: &f" + (hasItem(menu, slot) ? "yes" : "no"))); + + inventory.setItem(BUTTON_MATERIAL, promptButton(Material.STONE, "Material", menu, slot, "material")); + inventory.setItem(BUTTON_AMOUNT, promptButton(Material.EMERALD, "Amount", menu, slot, "amount")); + inventory.setItem(BUTTON_DISPLAY_NAME, promptButton(Material.NAME_TAG, "Display Name", menu, slot, "display_name")); + inventory.setItem(BUTTON_LORE, promptButton(Material.BOOK, "Lore", menu, slot, "lore")); + inventory.setItem(BUTTON_MODEL_DATA, promptButton(Material.ITEM_FRAME, "Model Data", menu, slot, "model_data")); + inventory.setItem(BUTTON_ITEM_FLAGS, promptButton(Material.HOPPER, "Item Flags", menu, slot, "item_flags")); + inventory.setItem(BUTTON_UPDATE, button(Material.CLOCK, "&eToggle Update", + "&7Current: &f" + value(menu, slot, "update", "false"), + "&7Click to toggle placeholder refresh.")); + inventory.setItem(BUTTON_CLICK_COMMANDS, promptButton(Material.COMMAND_BLOCK, "Click Commands", menu, slot, "click_commands")); + inventory.setItem(BUTTON_LEFT_CLICK_COMMANDS, promptButton(Material.COMMAND_BLOCK, "Left Click Commands", menu, slot, "left_click_commands")); + inventory.setItem(BUTTON_RIGHT_CLICK_COMMANDS, promptButton(Material.COMMAND_BLOCK, "Right Click Commands", menu, slot, "right_click_commands")); + inventory.setItem(BUTTON_SHIFT_LEFT_CLICK_COMMANDS, promptButton(Material.COMMAND_BLOCK, "Shift Left Commands", menu, slot, "shift_left_click_commands")); + inventory.setItem(BUTTON_SHIFT_RIGHT_CLICK_COMMANDS, promptButton(Material.COMMAND_BLOCK, "Shift Right Commands", menu, slot, "shift_right_click_commands")); + inventory.setItem(BUTTON_MIDDLE_CLICK_COMMANDS, promptButton(Material.COMMAND_BLOCK, "Middle Click Commands", menu, slot, "middle_click_commands")); + inventory.setItem(BUTTON_PRIORITY, promptButton(Material.COMPARATOR, "Priority", menu, slot, "priority")); + inventory.setItem(BUTTON_DELETE, button(Material.BARRIER, "&cDelete Item", "&7Removes the item config for this slot.")); + inventory.setItem(BUTTON_BACK, button(Material.ARROW, "&aBack", "&7Return to the menu preview.")); + inventory.setItem(BUTTON_REFRESH, button(Material.CHEST, "&bReload Preview", "&7Reload the menu editor view.")); + + player.openInventory(inventory); + } + + private @NotNull MenuHolder renderHolder(final @NotNull Player player, final @NotNull Menu menu) { + final MenuHolder renderHolder = new MenuHolder(plugin, player); + renderHolder.setMenuName(menu.options().name()); + renderHolder.setPlaceholderPlayer(player); + renderHolder.parsePlaceholdersInArguments(menu.options().parsePlaceholdersInArguments()); + renderHolder.parsePlaceholdersAfterArguments(menu.options().parsePlaceholdersAfterArguments()); + return renderHolder; + } + + private @NotNull ItemStack previewItem(final @NotNull Player player, final @NotNull Menu menu, final int slot) { + final TreeMap items = menu.getMenuItems().get(slot); + if (items != null && !items.isEmpty()) { + final ItemStack itemStack = items.firstEntry().getValue().getItemStack(renderHolder(player, menu)); + if (itemStack != null) { + return itemStack; + } + } + + return button(Material.GRAY_STAINED_GLASS_PANE, "&7Empty Slot", "&7Set a material to create an item."); + } + + private @NotNull ItemStack promptButton( + final @NotNull Material material, + final @NotNull String label, + final @NotNull Menu menu, + final int slot, + final @NotNull String option + ) { + return button(material, "&e" + label, + "&7Current:", + "&f" + compact(value(menu, slot, option, "")), + "&7Click to edit."); + } + + private @NotNull ItemStack button(final @NotNull Material material, final @NotNull String name, final @NotNull String... lore) { + final ItemStack itemStack = new ItemStack(material); + final ItemMeta meta = itemStack.getItemMeta(); + if (meta == null) { + return itemStack; + } + + meta.setDisplayName(StringUtils.color(name)); + final List coloredLore = new ArrayList<>(); + for (final String line : lore) { + coloredLore.add(StringUtils.color(line)); + } + meta.setLore(coloredLore); + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES); + itemStack.setItemMeta(meta); + return itemStack; + } + + private @NotNull String value(final @NotNull Menu menu, final int slot, final @NotNull String option, final @NotNull String fallback) { + return configEditor.getItemString(menu, slot, option).orElse(fallback); + } + + private boolean hasItem(final @NotNull Menu menu, final int slot) { + final TreeMap items = menu.getMenuItems().get(slot); + return items != null && !items.isEmpty(); + } + + private @NotNull String compact(final @Nullable String value) { + if (value == null || value.isBlank()) { + return "-"; + } + + final String oneLine = value.replace("\r", "").replace("\n", " | "); + if (oneLine.length() <= 34) { + return oneLine; + } + + return oneLine.substring(0, 31) + "..."; + } + + private @NotNull String title(final @NotNull String title) { + final String colored = StringUtils.color("&8" + title); + if (colored.length() <= 32) { + return colored; + } + + return colored.substring(0, 32); + } +} diff --git a/src/main/java/com/extendedclip/deluxemenus/editor/WebEditorServer.java b/src/main/java/com/extendedclip/deluxemenus/editor/WebEditorServer.java new file mode 100644 index 00000000..ae1b33e4 --- /dev/null +++ b/src/main/java/com/extendedclip/deluxemenus/editor/WebEditorServer.java @@ -0,0 +1,604 @@ +package com.extendedclip.deluxemenus.editor; + +import com.extendedclip.deluxemenus.DeluxeMenus; +import com.extendedclip.deluxemenus.menu.Menu; +import com.extendedclip.deluxemenus.menu.MenuItem; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class WebEditorServer { + + private final DeluxeMenus plugin; + private final MenuConfigEditor configEditor; + private final Map sessions = new ConcurrentHashMap<>(); + private HttpServer server; + + public WebEditorServer(final @NotNull DeluxeMenus plugin) { + this.plugin = plugin; + this.configEditor = new MenuConfigEditor(plugin); + } + + public @NotNull String createSession( + final @NotNull Menu menu, + final int requestedPort, + final @Nullable String requestedHost + ) throws IOException { + cleanupSessions(); + final Optional activeSession = activeSession(menu.options().name()); + if (activeSession.isPresent()) { + throw new ActiveSessionException(activeSession.get().url); + } + + ensureStarted(requestedPort); + + final String token = UUID.randomUUID().toString().replace("-", ""); + final String url = "http://" + publicHost(requestedHost) + ":" + server.getAddress().getPort() + "/dm-web/" + token; + sessions.put(token, new Session(token, menu.options().name(), url, Instant.now().plus(Duration.ofMinutes(60)))); + + return url; + } + + public @NotNull Optional resumeSession(final @NotNull String menuName) { + cleanupSessions(); + return activeSession(menuName).map(session -> session.url); + } + + public boolean cancelSession(final @NotNull String menuName) { + cleanupSessions(); + final Optional activeSession = activeSession(menuName); + activeSession.ifPresent(session -> sessions.remove(session.token)); + return activeSession.isPresent(); + } + + public @NotNull List listSessions() { + cleanupSessions(); + return sessions.values().stream() + .map(session -> new SessionView(session.menuName, session.url, session.expiresAt)) + .collect(Collectors.toList()); + } + + public void stop() { + if (server != null) { + server.stop(0); + server = null; + } + sessions.clear(); + } + + private void ensureStarted(final int requestedPort) throws IOException { + if (server != null) { + if (server.getAddress().getPort() == requestedPort) { + return; + } + + stop(); + } + + server = HttpServer.create(new InetSocketAddress(requestedPort), 0); + server.createContext("/dm-web", this::handle); + server.setExecutor(null); + server.start(); + } + + private void handle(final @NotNull HttpExchange exchange) throws IOException { + final String path = exchange.getRequestURI().getPath(); + final String[] parts = path.split("/"); + if (parts.length < 3) { + send(exchange, 404, "Not found", "text/plain"); + return; + } + + final Optional optionalSession = getSession(parts[2]); + if (optionalSession.isEmpty()) { + send(exchange, 403, "Invalid or expired editor session.", "text/plain"); + return; + } + + final Session session = optionalSession.get(); + final String action = parts.length >= 4 ? parts[3] : ""; + final String method = exchange.getRequestMethod(); + + if ("save-menu".equals(action) && "POST".equalsIgnoreCase(method)) { + saveMenu(exchange, session); + return; + } + + if ("save-item".equals(action) && "POST".equalsIgnoreCase(method)) { + saveItem(exchange, session); + return; + } + + if ("delete-item".equals(action) && "POST".equalsIgnoreCase(method)) { + deleteItem(exchange, session); + return; + } + + if ("save-raw".equals(action) && "POST".equalsIgnoreCase(method)) { + saveRaw(exchange, session); + return; + } + + if (!"GET".equalsIgnoreCase(method)) { + send(exchange, 405, "Method not allowed", "text/plain"); + return; + } + + render(exchange, session); + } + + private void render(final @NotNull HttpExchange exchange, final @NotNull Session session) throws IOException { + final Optional optionalMenu = findMenu(session.menuName); + if (optionalMenu.isEmpty()) { + send(exchange, 404, "Menu is not loaded.", "text/plain"); + return; + } + + final Menu menu = optionalMenu.get(); + final Map query = parseForm(exchange.getRequestURI().getRawQuery()); + final int selectedSlot = clamp(parseInt(query.get("slot")), 0, Math.max(0, menu.options().size() - 1)); + final String saved = query.getOrDefault("saved", ""); + final String html = "" + + "" + + "DeluxeMenus Editor" + + style() + + "" + + "
DeluxeMenus Editor" + escape(menu.options().name()) + "
" + + "Refresh
" + + "
" + + "

" + escape(menu.options().name()) + "

" + + menu.options().size() + " slots
" + + "
" + renderGrid(menu, session, selectedSlot) + "
" + + status(saved) + + "
" + + "
" + + renderMenuForm(menu, session, selectedSlot) + + renderItemForm(menu, session, selectedSlot) + + renderRawForm(menu, session, selectedSlot) + + "
" + + "
" + + script() + + ""; + send(exchange, 200, html, "text/html; charset=utf-8"); + } + + private void saveMenu(final @NotNull HttpExchange exchange, final @NotNull Session session) throws IOException { + final Optional optionalMenu = findMenu(session.menuName); + if (optionalMenu.isEmpty()) { + send(exchange, 404, "Menu is not loaded.", "text/plain"); + return; + } + + final Map form = parseForm(readBody(exchange)); + final Menu menu = optionalMenu.get(); + configEditor.setMenuValue(menu, "menu_title", form.getOrDefault("menu_title", menu.options().title())); + configEditor.setMenuValue(menu, "size", form.getOrDefault("size", String.valueOf(menu.options().size()))); + reload(menu); + redirect(exchange, session, parseInt(form.get("slot")), "menu"); + } + + private void saveRaw(final @NotNull HttpExchange exchange, final @NotNull Session session) throws IOException { + final Optional optionalMenu = findMenu(session.menuName); + if (optionalMenu.isEmpty()) { + send(exchange, 404, "Menu is not loaded.", "text/plain"); + return; + } + + final Map form = parseForm(readBody(exchange)); + final Menu menu = optionalMenu.get(); + configEditor.saveRaw(menu, form.getOrDefault("content", "")); + reload(menu); + redirect(exchange, session, parseInt(form.get("slot")), "raw"); + } + + private void saveItem(final @NotNull HttpExchange exchange, final @NotNull Session session) throws IOException { + final Optional optionalMenu = findMenu(session.menuName); + if (optionalMenu.isEmpty()) { + send(exchange, 404, "Menu is not loaded.", "text/plain"); + return; + } + + final Map form = parseForm(readBody(exchange)); + final int slot = parseInt(form.get("slot")); + final Menu menu = optionalMenu.get(); + final String material = form.getOrDefault("material", "").isBlank() ? "STONE" : form.get("material"); + configEditor.setItemValue(menu, slot, "material", material); + configEditor.setItemValue(menu, slot, "amount", form.getOrDefault("amount", "-1")); + configEditor.setItemValue(menu, slot, "priority", form.getOrDefault("priority", "1")); + configEditor.setItemValue(menu, slot, "display_name", form.getOrDefault("display_name", "")); + configEditor.setItemValue(menu, slot, "lore", form.getOrDefault("lore", "")); + configEditor.setItemValue(menu, slot, "model_data", form.getOrDefault("model_data", "")); + configEditor.setItemValue(menu, slot, "item_flags", form.getOrDefault("item_flags", "")); + configEditor.setItemValue(menu, slot, "update", form.containsKey("update") ? "true" : "false"); + configEditor.setItemValue(menu, slot, "click_commands", form.getOrDefault("click_commands", "")); + configEditor.setItemValue(menu, slot, "left_click_commands", form.getOrDefault("left_click_commands", "")); + configEditor.setItemValue(menu, slot, "right_click_commands", form.getOrDefault("right_click_commands", "")); + configEditor.setItemValue(menu, slot, "shift_left_click_commands", form.getOrDefault("shift_left_click_commands", "")); + configEditor.setItemValue(menu, slot, "shift_right_click_commands", form.getOrDefault("shift_right_click_commands", "")); + configEditor.setItemValue(menu, slot, "middle_click_commands", form.getOrDefault("middle_click_commands", "")); + reload(menu); + redirect(exchange, session, slot, "slot"); + } + + private void deleteItem(final @NotNull HttpExchange exchange, final @NotNull Session session) throws IOException { + final Optional optionalMenu = findMenu(session.menuName); + if (optionalMenu.isEmpty()) { + send(exchange, 404, "Menu is not loaded.", "text/plain"); + return; + } + + final Map form = parseForm(readBody(exchange)); + final int slot = parseInt(form.get("slot")); + final Menu menu = optionalMenu.get(); + configEditor.deleteItem(menu, slot); + reload(menu); + redirect(exchange, session, slot, "delete"); + } + + private @NotNull Optional getSession(final @NotNull String token) { + final Session session = sessions.get(token); + if (session == null || session.expiresAt.isBefore(Instant.now())) { + sessions.remove(token); + return Optional.empty(); + } + + return Optional.of(session); + } + + private @NotNull Optional activeSession(final @NotNull String menuName) { + return sessions.values().stream() + .filter(session -> session.menuName.equalsIgnoreCase(menuName)) + .findFirst(); + } + + private void cleanupSessions() { + final Instant now = Instant.now(); + sessions.entrySet().removeIf(entry -> entry.getValue().expiresAt.isBefore(now)); + } + + private @NotNull Optional findMenu(final @NotNull String menuName) { + final Optional menu = Menu.getMenuByName(menuName); + if (menu.isPresent()) { + return menu; + } + + return Menu.getSubMenuByName(menuName); + } + + private @NotNull String renderGrid(final @NotNull Menu menu, final @NotNull Session session, final int selectedSlot) { + final StringBuilder builder = new StringBuilder(); + for (int slot = 0; slot < menu.options().size(); slot++) { + final boolean filled = isFilled(menu, slot); + builder.append("") + .append(slot) + .append("") + .append(escape(compact(slotLabel(menu, slot), 13))) + .append(""); + } + return builder.toString(); + } + + private @NotNull String renderMenuForm(final @NotNull Menu menu, final @NotNull Session session, final int selectedSlot) { + final String source = configEditor.resolveFile(menu).map(File::getPath).orElse("config.yml"); + return "
" + + "" + + "

Menu

" + escape(source) + "
" + + input("Title", "menu_title", configEditor.getMenuString(menu, "menu_title").orElse(menu.options().title())) + + "" + + "" + + "
"; + } + + private @NotNull String renderItemForm(final @NotNull Menu menu, final @NotNull Session session, final int selectedSlot) { + return "
" + + "
" + + "" + + "

Slot " + selectedSlot + "

" + escape(slotLabel(menu, selectedSlot)) + "
" + + "
" + + input("Material", "material", value(menu, selectedSlot, "material", "")) + + input("Amount", "amount", value(menu, selectedSlot, "amount", "-1")) + + input("Priority", "priority", value(menu, selectedSlot, "priority", "1")) + + input("Model Data", "model_data", value(menu, selectedSlot, "model_data", "")) + + "
" + + input("Display Name", "display_name", value(menu, selectedSlot, "display_name", "")) + + textarea("Lore", "lore", value(menu, selectedSlot, "lore", "")) + + textarea("Item Flags", "item_flags", value(menu, selectedSlot, "item_flags", "")) + + checkbox("Update Placeholders", "update", Boolean.parseBoolean(value(menu, selectedSlot, "update", "false"))) + + "
" + + textarea("Click Commands", "click_commands", value(menu, selectedSlot, "click_commands", "")) + + textarea("Left Click Commands", "left_click_commands", value(menu, selectedSlot, "left_click_commands", "")) + + textarea("Right Click Commands", "right_click_commands", value(menu, selectedSlot, "right_click_commands", "")) + + textarea("Shift Left Commands", "shift_left_click_commands", value(menu, selectedSlot, "shift_left_click_commands", "")) + + textarea("Shift Right Commands", "shift_right_click_commands", value(menu, selectedSlot, "shift_right_click_commands", "")) + + textarea("Middle Click Commands", "middle_click_commands", value(menu, selectedSlot, "middle_click_commands", "")) + + "
" + + "
" + + "
" + + "" + + "
"; + } + + private @NotNull String renderRawForm(final @NotNull Menu menu, final @NotNull Session session, final int selectedSlot) throws IOException { + return "
Raw YAML" + + "
" + + "" + + "" + + "
"; + } + + private @NotNull String input(final @NotNull String label, final @NotNull String name, final @NotNull String value) { + return ""; + } + + private @NotNull String textarea(final @NotNull String label, final @NotNull String name, final @NotNull String value) { + return ""; + } + + private @NotNull String checkbox(final @NotNull String label, final @NotNull String name, final boolean checked) { + return ""; + } + + private @NotNull String status(final @NotNull String saved) { + if (saved.isBlank()) { + return ""; + } + + return "
Saved " + escape(saved) + ".
"; + } + + private @NotNull String value(final @NotNull Menu menu, final int slot, final @NotNull String option, final @NotNull String fallback) { + return configEditor.getItemString(menu, slot, option).orElse(fallback); + } + + private boolean isFilled(final @NotNull Menu menu, final int slot) { + final Map items = menu.getMenuItems().get(slot); + return items != null && !items.isEmpty(); + } + + private @NotNull String slotLabel(final @NotNull Menu menu, final int slot) { + final Map items = menu.getMenuItems().get(slot); + if (items == null || items.isEmpty()) { + return "Empty"; + } + + return items.values().iterator().next().options().material(); + } + + private void reload(final @NotNull Menu menu) { + plugin.getScheduler().runTask(() -> configEditor.reload(menu)); + } + + private void redirect(final @NotNull HttpExchange exchange, final @NotNull Session session, final int slot, final @NotNull String saved) throws IOException { + send(exchange, 303, "", "text/plain", "/dm-web/" + session.token + "?slot=" + Math.max(0, slot) + "&saved=" + saved); + } + + private @NotNull String readBody(final @NotNull HttpExchange exchange) throws IOException { + return new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + } + + private @NotNull Map parseForm(final @Nullable String body) { + final Map form = new HashMap<>(); + if (body == null || body.isBlank()) { + return form; + } + + for (final String part : body.split("&")) { + final int index = part.indexOf('='); + if (index <= 0) { + continue; + } + final String formKey = URLDecoder.decode(part.substring(0, index), StandardCharsets.UTF_8); + form.put(formKey, URLDecoder.decode(part.substring(index + 1), StandardCharsets.UTF_8)); + } + return form; + } + + private int parseInt(final @Nullable String input) { + try { + return Integer.parseInt(input); + } catch (final Exception exception) { + return 0; + } + } + + private int clamp(final int value, final int min, final int max) { + return Math.max(min, Math.min(max, value)); + } + + private @NotNull String compact(final @NotNull String value, final int limit) { + if (value.length() <= limit) { + return value; + } + + return value.substring(0, Math.max(0, limit - 1)) + "..."; + } + + private @NotNull String publicHost(final @Nullable String requestedHost) { + final Optional normalizedRequestHost = normalizeHost(requestedHost); + if (normalizedRequestHost.isPresent()) { + return normalizedRequestHost.get(); + } + + final String configuredHost = plugin.getServer().getIp(); + if (configuredHost != null && !configuredHost.isBlank() && !"0.0.0.0".equals(configuredHost)) { + return normalizeHost(configuredHost).orElse(configuredHost); + } + + return "localhost"; + } + + private @NotNull Optional normalizeHost(final @Nullable String host) { + if (host == null || host.isBlank()) { + return Optional.empty(); + } + + String normalized = host.trim(); + final int schemeIndex = normalized.indexOf("://"); + if (schemeIndex >= 0) { + normalized = normalized.substring(schemeIndex + 3); + } + + final int pathIndex = normalized.indexOf('/'); + if (pathIndex >= 0) { + normalized = normalized.substring(0, pathIndex); + } + + if (normalized.startsWith("[")) { + final int endIndex = normalized.indexOf(']'); + if (endIndex > 0) { + return Optional.of(normalized.substring(0, endIndex + 1)); + } + } + + final int firstColon = normalized.indexOf(':'); + if (firstColon >= 0 && firstColon == normalized.lastIndexOf(':')) { + normalized = normalized.substring(0, firstColon); + } + + return normalized.isBlank() ? Optional.empty() : Optional.of(normalized); + } + + private @NotNull String escape(final @NotNull String input) { + return input.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + private void send(final @NotNull HttpExchange exchange, final int status, final @NotNull String body, final @NotNull String contentType) throws IOException { + send(exchange, status, body, contentType, null); + } + + private void send( + final @NotNull HttpExchange exchange, + final int status, + final @NotNull String body, + final @NotNull String contentType, + final @Nullable String location + ) throws IOException { + final byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + final Headers headers = exchange.getResponseHeaders(); + headers.set("Content-Type", contentType); + headers.set("Cache-Control", "no-store"); + headers.set("X-Content-Type-Options", "nosniff"); + if (location != null) { + headers.set("Location", location); + } + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + private @NotNull String style() { + return ""; + } + + private @NotNull String script() { + return ""; + } + + public static class ActiveSessionException extends IOException { + private final String url; + + private ActiveSessionException(final @NotNull String url) { + this.url = url; + } + + public @NotNull String url() { + return url; + } + } + + public static class SessionView { + private final String menuName; + private final String url; + private final Instant expiresAt; + + private SessionView(final @NotNull String menuName, final @NotNull String url, final @NotNull Instant expiresAt) { + this.menuName = menuName; + this.url = url; + this.expiresAt = expiresAt; + } + + public @NotNull String menuName() { + return menuName; + } + + public @NotNull String url() { + return url; + } + + public @NotNull Instant expiresAt() { + return expiresAt; + } + } + + private static class Session { + private final String token; + private final String menuName; + private final String url; + private final Instant expiresAt; + + private Session( + final @NotNull String token, + final @NotNull String menuName, + final @NotNull String url, + final @NotNull Instant expiresAt + ) { + this.token = token; + this.menuName = menuName; + this.url = url; + this.expiresAt = expiresAt; + } + } +} diff --git a/src/main/java/com/extendedclip/deluxemenus/listener/PlayerListener.java b/src/main/java/com/extendedclip/deluxemenus/listener/PlayerListener.java index f2d7d5f3..e596d9ec 100644 --- a/src/main/java/com/extendedclip/deluxemenus/listener/PlayerListener.java +++ b/src/main/java/com/extendedclip/deluxemenus/listener/PlayerListener.java @@ -15,22 +15,23 @@ import org.bukkit.event.inventory.ClickType; import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.event.inventory.InventoryCloseEvent; -import org.bukkit.event.inventory.InventoryOpenEvent; -import org.bukkit.event.player.PlayerCommandPreprocessEvent; -import org.bukkit.event.player.PlayerQuitEvent; -import org.jetbrains.annotations.NotNull; - -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.TimeUnit; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.jetbrains.annotations.NotNull; + +import org.bukkit.inventory.ItemStack; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; public class PlayerListener extends Listener { private final TaskScheduler scheduler; private final Cache cache = CacheBuilder.newBuilder().expireAfterWrite(75, TimeUnit.MILLISECONDS).build(); - // This is so dumb. Mojang fix your shit. - private final Cache shiftCache = CacheBuilder.newBuilder().expireAfterWrite(200, TimeUnit.MILLISECONDS).build(); + private final Cache shiftCache = CacheBuilder.newBuilder().expireAfterWrite(200, TimeUnit.MILLISECONDS).build(); public PlayerListener(@NotNull final DeluxeMenus plugin) { super(plugin); @@ -59,13 +60,36 @@ public void onCommandExecute(PlayerCommandPreprocessEvent event) { } @EventHandler - public void onLeave(PlayerQuitEvent event) { - Player player = event.getPlayer(); - - if (Menu.isInMenu(player)) { - Menu.closeMenu(plugin, player, false); - } - } + public void onLeave(PlayerQuitEvent event) { + Player player = event.getPlayer(); + + if (Menu.isInMenu(player)) { + Menu.closeMenu(plugin, player, false); + } + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onDeath(PlayerDeathEvent event) { + final Player player = event.getEntity(); + final Optional optionalHolder = Menu.getMenuHolder(player); + + if (optionalHolder.isEmpty()) { + return; + } + + final MenuHolder holder = optionalHolder.get(); + if (!holder.isPlayerInventoryHidden()) { + return; + } + + event.getDrops().removeIf(itemStack -> itemStack != null && plugin.getMenuItemMarker().isMarked(itemStack)); + if (!event.getKeepInventory()) { + for (final ItemStack itemStack : holder.getHiddenPlayerInventoryDrops()) { + event.getDrops().add(itemStack); + } + } + holder.restorePlayerInventory(); + } @EventHandler public void onOpen(InventoryOpenEvent event) { diff --git a/src/main/java/com/extendedclip/deluxemenus/menu/Menu.java b/src/main/java/com/extendedclip/deluxemenus/menu/Menu.java index 5056aecc..cc95c8b5 100644 --- a/src/main/java/com/extendedclip/deluxemenus/menu/Menu.java +++ b/src/main/java/com/extendedclip/deluxemenus/menu/Menu.java @@ -11,9 +11,10 @@ import com.extendedclip.deluxemenus.utils.StringUtils; import java.util.ArrayList; import java.util.Collection; -import java.util.HashSet; -import java.util.List; -import java.util.Map; +import java.util.HashSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Map.Entry; import java.util.Optional; import java.util.Set; @@ -23,17 +24,19 @@ import java.util.logging.Level; import org.bukkit.Bukkit; import org.bukkit.entity.Player; -import org.bukkit.event.inventory.InventoryType; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.ItemStack; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -public class Menu { - - private static final Map menus = new ConcurrentHashMap<>(); - private static final Set menuHolders = ConcurrentHashMap.newKeySet(); - private static final Map lastOpenedMenus = new ConcurrentHashMap<>(); +public class Menu { + + private static final Map menus = new ConcurrentHashMap<>(); + private static final Map subMenus = new ConcurrentHashMap<>(); + private static final Set menuHolders = ConcurrentHashMap.newKeySet(); + private static final Map lastOpenedMenus = new ConcurrentHashMap<>(); private final DeluxeMenus plugin; private final TaskScheduler scheduler; @@ -56,13 +59,17 @@ public Menu( this.items = items; this.path = path; - if (this.options.registerCommands()) { - this.command = new RegistrableMenuCommand(plugin, this); - this.command.register(); - } - - menus.put(this.options.name(), this); - } + if (!this.options.subMenu() && this.options.registerCommands()) { + this.command = new RegistrableMenuCommand(plugin, this); + this.command.register(); + } + + if (this.options.subMenu()) { + subMenus.put(this.options.name(), this); + } else { + menus.put(this.options.name(), this); + } + } public static void unload(final @NotNull DeluxeMenus plugin, final @NotNull String name) { for (Player p : Bukkit.getOnlinePlayers()) { @@ -71,10 +78,11 @@ public static void unload(final @NotNull DeluxeMenus plugin, final @NotNull Stri } } - Optional optionalMenu = Menu.getMenuByName(name); - if (optionalMenu.isEmpty()) { - return; - } + Optional optionalMenu = Menu.getMenuByName(name); + if (optionalMenu.isEmpty()) { + subMenus.remove(name); + return; + } optionalMenu.get().unregisterCommand(); menus.remove(name); @@ -86,12 +94,16 @@ public static void unload(final @NotNull DeluxeMenus plugin) { closeMenu(plugin, p, true); } } - for (Menu menu : Menu.getAllMenus()) { - menu.unregisterCommand(); - } - menus.clear(); - menuHolders.clear(); - lastOpenedMenus.clear(); + for (Menu menu : Menu.getAllMenus()) { + menu.unregisterCommand(); + } + for (Menu menu : Menu.getAllSubMenus()) { + menu.unregisterCommand(); + } + menus.clear(); + subMenus.clear(); + menuHolders.clear(); + lastOpenedMenus.clear(); } private void unregisterCommand() { @@ -109,21 +121,30 @@ public static void unloadForShutdown(final @NotNull DeluxeMenus plugin) { if (isInMenu(player)) { closeMenuForShutdown(plugin, player); } - } - menus.clear(); - } - - public static int getLoadedMenuSize() { - return menus.size(); - } + } + menus.clear(); + subMenus.clear(); + } + + public static int getLoadedMenuSize() { + return menus.size(); + } + + public static int getLoadedSubMenuSize() { + return subMenus.size(); + } public static @NotNull Set getAllMenuNames() { return menus.keySet(); } - public static @NotNull Collection getAllMenus() { - return menus.values(); - } + public static @NotNull Collection getAllMenus() { + return menus.values(); + } + + public static @NotNull Collection getAllSubMenus() { + return subMenus.values(); + } // Menus need to be stored in a list because config.yml can contain multiple menus. // This can be changed once we remove support for menus inside the config file. @@ -144,9 +165,13 @@ public static int getLoadedMenuSize() { ); } - public static @NotNull Optional getMenuByName(final @NotNull String name) { - return menus.entrySet().stream().filter(e -> e.getKey().equalsIgnoreCase(name)).findFirst().map(Entry::getValue); - } + public static @NotNull Optional getMenuByName(final @NotNull String name) { + return menus.entrySet().stream().filter(e -> e.getKey().equalsIgnoreCase(name)).findFirst().map(Entry::getValue); + } + + public static @NotNull Optional getSubMenuByName(final @NotNull String name) { + return subMenus.entrySet().stream().filter(e -> e.getKey().equalsIgnoreCase(name)).findFirst().map(Entry::getValue); + } public static @NotNull Optional getMenuByCommand(final @NotNull String command) { return menus.values().stream().filter(m -> m.getMenuCommandUsed(command).isPresent()).findFirst(); @@ -200,27 +225,33 @@ public static void closeMenu(final @NotNull DeluxeMenus plugin, final @NotNull P MenuHolder holder = optionalHolder.get(); holder.stopPlaceholderUpdate(); - holder.stopRefreshTask(); - - if (executeCloseActions) { - holder.getMenu().map(Menu::options).map(MenuOptions::closeHandler).flatMap(h -> h).ifPresent(h -> h.onClick(holder)); - } - - if (close) { - plugin.getScheduler().runTask(player, () -> { - player.closeInventory(); - cleanInventory(plugin, player); - }); - } - menuHolders.remove(holder); - lastOpenedMenus.put(player.getUniqueId(), holder.getMenu().orElse(null)); - } - - public static void closeMenuForShutdown(final @NotNull DeluxeMenus plugin, final @NotNull Player player) { - getMenuHolder(player).ifPresent(MenuHolder::stopPlaceholderUpdate); - - player.closeInventory(); - cleanInventory(plugin, player); + holder.stopRefreshTask(); + + if (executeCloseActions) { + holder.getMenu().map(Menu::options).map(MenuOptions::closeHandler).flatMap(h -> h).ifPresent(h -> h.onClick(holder)); + } + + if (close) { + plugin.getScheduler().runTask(player, () -> { + holder.restorePlayerInventory(); + player.closeInventory(); + cleanInventory(plugin, player); + }); + } else { + holder.restorePlayerInventory(); + } + menuHolders.remove(holder); + lastOpenedMenus.put(player.getUniqueId(), holder.getMenu().orElse(null)); + } + + public static void closeMenuForShutdown(final @NotNull DeluxeMenus plugin, final @NotNull Player player) { + getMenuHolder(player).ifPresent(holder -> { + holder.stopPlaceholderUpdate(); + holder.restorePlayerInventory(); + }); + + player.closeInventory(); + cleanInventory(plugin, player); } public static void closeMenu(final @NotNull DeluxeMenus plugin, final @NotNull Player player, final boolean close) { @@ -272,14 +303,27 @@ private boolean handleArgRequirements(final @NotNull MenuHolder holder) { return true; } - public void openMenu(final @NotNull Player viewer) { - openMenu(viewer, null, null); - } - - public void openMenu(final @NotNull Player viewer, final @Nullable Map args, final @Nullable Player placeholderPlayer) { - if (items == null || items.isEmpty()) { - return; - } + public void openMenu(final @NotNull Player viewer) { + openMenu(viewer, null, null); + } + + public void openMenu(final @NotNull Player viewer, final @Nullable Map args, final @Nullable Player placeholderPlayer) { + openMenu(viewer, args, placeholderPlayer, null); + } + + public void openMenu( + final @NotNull Player viewer, + final @Nullable Map args, + final @Nullable Player placeholderPlayer, + final @Nullable Menu playerInventoryMenu + ) { + if (this.options.subMenu()) { + return; + } + + if (items == null || items.isEmpty()) { + return; + } DeluxeMenusPreOpenMenuEvent preOpenEvent = new DeluxeMenusPreOpenMenuEvent(viewer); Bukkit.getPluginManager().callEvent(preOpenEvent); @@ -292,12 +336,15 @@ public void openMenu(final @NotNull Player viewer, final @Nullable Map { - Set activeItems = new HashSet<>(); - - for (Entry> entry : items.entrySet()) { - - for (MenuItem item : entry.getValue().values()) { - - int slot = item.options().slot(); - - if (slot >= this.options.size()) { - plugin.debug( - DebugLevel.HIGHEST, - Level.WARNING, - "Item set to slot " + slot + " for menu: " + this.options.name() + " exceeds the inventory size!", - "This item will not be added to the menu!" - ); - continue; - } - - if (item.options().viewRequirements().isPresent()) { - - if (item.options().viewRequirements().get().evaluate(holder)) { - - activeItems.add(item); - break; - } - } else { - - activeItems.add(item); - break; - } - } - } - - if (activeItems.isEmpty()) { - return; - } + Set activeItems = getActiveItems(holder); + + if (activeItems.isEmpty()) { + return; + } holder.setMenuName(this.options.name()); holder.setActiveItems(activeItems); this.options.openHandler().ifPresent(h -> h.onClick(holder)); - String title = StringUtils.color(holder.setPlaceholdersAndArguments(this.options.title())); - - Inventory inventory; + String title = StringUtils.colorMenuTitle(holder.setPlaceholdersAndArguments(this.options.title())); + + Inventory inventory; if (this.options.type() != InventoryType.CHEST) { inventory = Bukkit.createInventory(holder, this.options.type(), title); @@ -358,11 +374,13 @@ public void openMenu(final @NotNull Player viewer, final @Nullable Map playerInventoryItems = new HashMap<>(); + final boolean renderPlayerInventory = rendersPlayerInventory(holder); + + for (MenuItem item : activeItems) { ItemStack iStack = item.getItemStack(holder); @@ -372,24 +390,28 @@ public void openMenu(final @NotNull Player viewer, final @Nullable Map= this.options.size()) { - plugin.debug( - DebugLevel.HIGHEST, - Level.WARNING, + int slot = item.options().slot(); + + if (slot >= this.options.size() + (renderPlayerInventory ? 36 : 0)) { + plugin.debug( + DebugLevel.HIGHEST, + Level.WARNING, "Item set to slot " + slot + " for menu: " + this.options.name() + " exceeds the inventory size!", "This item will not be added to the menu!" ); continue; } - if (item.options().updatePlaceholders()) { - update = true; - } - - inventory.setItem(item.options().slot(), iStack); - } + if (item.options().updatePlaceholders()) { + update = true; + } + + if (slot < this.options.size()) { + inventory.setItem(item.options().slot(), iStack); + } else { + playerInventoryItems.put(slot, iStack); + } + } final boolean updatePlaceholders = update; @@ -398,12 +420,19 @@ public void openMenu(final @NotNull Player viewer, final @Nullable Map menuHolder.getMenuName().equalsIgnoreCase(options.name())).forEach(MenuHolder::refreshMenu); - } + public void refreshForAll() { + menuHolders.stream().filter(menuHolder -> menuHolder.getMenuName().equalsIgnoreCase(options.name())).forEach(MenuHolder::refreshMenu); + } + + public @NotNull Set getActiveItems(final @NotNull MenuHolder holder) { + final Set activeItems = new HashSet<>(getActiveItems(holder, 0, this.options.size(), 0)); + final Optional openPlayerInventoryMenu = holder.getPlayerInventoryMenu(); + + if (!this.options.hidePlayerInventory() + && openPlayerInventoryMenu.isEmpty() + && this.options.playerInventoryMenu().isEmpty()) { + return activeItems; + } + + if (openPlayerInventoryMenu.isPresent()) { + final Menu bottomMenu = openPlayerInventoryMenu.get(); + activeItems.addAll(bottomMenu.getActiveItems(holder, 0, Math.min(36, bottomMenu.options().size()), this.options.size())); + return activeItems; + } + + if (this.options.playerInventoryMenu().isPresent()) { + final String playerInventoryMenuName = this.options.playerInventoryMenu().get(); + final Optional bottomMenu = Menu.getSubMenuByName(playerInventoryMenuName); + if (bottomMenu.isPresent()) { + activeItems.addAll(bottomMenu.get().getActiveItems(holder, 0, Math.min(36, bottomMenu.get().options().size()), this.options.size())); + } else { + plugin.debug( + DebugLevel.HIGHEST, + Level.WARNING, + "Player inventory menu " + playerInventoryMenuName + " for menu " + this.options.name() + " was not found." + ); + } + return activeItems; + } + + return activeItems; + } + + private boolean rendersPlayerInventory(final @NotNull MenuHolder holder) { + return this.options.hidePlayerInventory() + || holder.getPlayerInventoryMenu().isPresent() + || this.options.playerInventoryMenu().flatMap(Menu::getSubMenuByName).isPresent(); + } + + private @NotNull Set getActiveItems(final @NotNull MenuHolder holder, final int minimumSlot, final int maximumSlot, final int slotOffset) { + final Set activeItems = new HashSet<>(); + + for (Entry> entry : items.entrySet()) { + final int configuredSlot = entry.getKey(); + + if (configuredSlot < minimumSlot || configuredSlot >= maximumSlot) { + continue; + } + + for (MenuItem item : entry.getValue().values()) { + if (item.options().viewRequirements().isPresent()) { + if (!item.options().viewRequirements().get().evaluate(holder)) { + continue; + } + } + + final int renderedSlot = configuredSlot + slotOffset; + activeItems.add(new MenuItem(plugin, item.options().asBuilder().slot(renderedSlot).build())); + break; + } + } + + return activeItems; + } public @NotNull Map> getMenuItems() { return this.items; diff --git a/src/main/java/com/extendedclip/deluxemenus/menu/MenuHolder.java b/src/main/java/com/extendedclip/deluxemenus/menu/MenuHolder.java index 82c9be24..76b3ca19 100644 --- a/src/main/java/com/extendedclip/deluxemenus/menu/MenuHolder.java +++ b/src/main/java/com/extendedclip/deluxemenus/menu/MenuHolder.java @@ -5,17 +5,25 @@ import com.extendedclip.deluxemenus.scheduler.scheduling.schedulers.TaskScheduler; import com.extendedclip.deluxemenus.scheduler.scheduling.tasks.MyScheduledTask; import com.extendedclip.deluxemenus.utils.StringUtils; -import org.bukkit.entity.Player; -import org.bukkit.inventory.Inventory; -import org.bukkit.inventory.InventoryHolder; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.meta.ItemMeta; -import org.jetbrains.annotations.NotNull; -import java.util.HashSet; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.TreeMap; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; public class MenuHolder implements InventoryHolder { @@ -30,9 +38,11 @@ public class MenuHolder implements InventoryHolder { private MyScheduledTask refreshTask = null; private Inventory inventory; private boolean updating; - private boolean parsePlaceholdersInArguments; - private boolean parsePlaceholdersAfterArguments; - private Map typedArgs; + private boolean parsePlaceholdersInArguments; + private boolean parsePlaceholdersAfterArguments; + private Map typedArgs; + private ItemStack[] hiddenPlayerStorageContents; + private Menu playerInventoryMenu; public MenuHolder(final @NotNull DeluxeMenus plugin, final @NotNull Player viewer) { this.plugin = plugin; @@ -122,7 +132,7 @@ public Optional getMenu() { ); } - public void refreshMenu() { + public void refreshMenu() { Optional optionalMenu = getMenu(); if (optionalMenu.isEmpty()) { @@ -139,38 +149,24 @@ public void refreshMenu() { scheduler.runTaskAsynchronously(() -> { - final Set active = new HashSet<>(); - final Set slotsToClear = new HashSet<>(); - - for (int i = 0; i < getInventory().getSize(); i++) { - TreeMap e = menu.getMenuItems().get(i); - - if (e == null) { - slotsToClear.add(i); - continue; - } - - boolean matched = false; - for (MenuItem item : e.values()) { - - if (item.options().viewRequirements().isPresent()) { - - if (item.options().viewRequirements().get().evaluate(this)) { - matched = true; - active.add(item); - break; - } - } else { - matched = true; - active.add(item); - break; - } - } - - if (!matched) { - slotsToClear.add(i); - } - } + final Set active = menu.getActiveItems(this); + final Set slotsToClear = new HashSet<>(); + + for (int i = 0; i < getInventory().getSize(); i++) { + final int slot = i; + if (active.stream().noneMatch(item -> item.options().slot() == slot)) { + slotsToClear.add(i); + } + } + + if (isPlayerInventoryHidden()) { + for (int i = getInventory().getSize(); i < getInventory().getSize() + 36; i++) { + final int slot = i; + if (active.stream().noneMatch(item -> item.options().slot() == slot)) { + slotsToClear.add(i); + } + } + } if (active.isEmpty()) { scheduler.runTask(viewer, () -> Menu.closeMenu(plugin, viewer, true)); @@ -179,9 +175,9 @@ public void refreshMenu() { scheduler.runTask(viewer, () -> { - for (int slot : slotsToClear) { - getInventory().setItem(slot, null); - } + for (int slot : slotsToClear) { + setRenderedItem(slot, null); + } boolean update = false; @@ -197,16 +193,16 @@ public void refreshMenu() { int slot = item.options().slot(); - if (slot >= menu.options().size()) { - continue; - } + if (!isRenderedSlot(slot)) { + continue; + } if (item.options().updatePlaceholders()) { update = true; } - getInventory().setItem(item.options().slot(), iStack); - } + setRenderedItem(item.options().slot(), iStack); + } setActiveItems(active); @@ -289,11 +285,11 @@ public void startUpdatePlaceholdersTask() { if (item.options().updatePlaceholders()) { - ItemStack i = inventory.getItem(item.options().slot()); - - if (i == null) { - continue; - } + ItemStack i = getRenderedItem(item.options().slot()); + + if (i == null) { + continue; + } int amt = i.getAmount(); @@ -312,20 +308,21 @@ public void startUpdatePlaceholdersTask() { } } - ItemMeta meta = i.getItemMeta(); - - if (item.options().displayNameHasPlaceholders() && item.options().displayName().isPresent()) { - meta.setDisplayName(StringUtils.color(setPlaceholdersAndArguments(item.options().displayName().get()))); - } - - if (item.options().loreHasPlaceholders()) { - meta.setLore(item.getMenuItemLore(getHolder(), item.options().lore())); - } - - i.setItemMeta(meta); - i.setAmount(amt); - } - } + ItemMeta meta = i.getItemMeta(); + + if (item.options().displayNameHasPlaceholders() && item.options().displayName().isPresent()) { + meta.setDisplayName(StringUtils.color(setPlaceholdersAndArguments(item.options().displayName().get()))); + } + + if (item.options().loreHasPlaceholders()) { + meta.setLore(item.getMenuItemLore(getHolder(), item.options().lore())); + } + + i.setItemMeta(meta); + i.setAmount(amt); + setRenderedItem(item.options().slot(), i); + } + } }, initialDelay, period ); @@ -344,9 +341,152 @@ public void setUpdating(boolean updating) { return this.inventory; } - public void setInventory(Inventory i) { - this.inventory = i; - } + public void setInventory(Inventory i) { + this.inventory = i; + } + + public @NotNull Optional getPlayerInventoryMenu() { + return Optional.ofNullable(this.playerInventoryMenu); + } + + public @NotNull Optional getRenderedPlayerInventoryMenu() { + if (this.playerInventoryMenu != null) { + return Optional.of(this.playerInventoryMenu); + } + + return getMenu() + .flatMap(menu -> menu.options().playerInventoryMenu()) + .flatMap(Menu::getSubMenuByName); + } + + public void setPlayerInventoryMenu(final @Nullable Menu menu) { + this.playerInventoryMenu = menu; + } + + public void openPlayerInventoryMenu(final @NotNull Menu menu) { + if (this.inventory == null) { + return; + } + + if (!menu.options().subMenu()) { + return; + } + + this.playerInventoryMenu = menu; + if (!isPlayerInventoryHidden()) { + hidePlayerInventory(); + } + refreshMenu(); + } + + public boolean isPlayerInventoryHidden() { + return this.hiddenPlayerStorageContents != null; + } + + public void hidePlayerInventory() { + if (isPlayerInventoryHidden()) { + return; + } + + final ItemStack[] storageContents = viewer.getInventory().getStorageContents(); + this.hiddenPlayerStorageContents = cloneContents(storageContents); + viewer.getInventory().setStorageContents(new ItemStack[storageContents.length]); + viewer.updateInventory(); + } + + public void restorePlayerInventory() { + if (!isPlayerInventoryHidden()) { + return; + } + + viewer.getInventory().setStorageContents(cloneContents(this.hiddenPlayerStorageContents)); + this.hiddenPlayerStorageContents = null; + viewer.updateInventory(); + } + + public @NotNull List getHiddenPlayerInventoryDrops() { + if (!isPlayerInventoryHidden()) { + return List.of(); + } + + return Arrays.stream(this.hiddenPlayerStorageContents) + .filter(Objects::nonNull) + .map(ItemStack::clone) + .collect(Collectors.toList()); + } + + public void applyPlayerInventoryItems(final @NotNull Map items) { + if (!isPlayerInventoryHidden()) { + return; + } + + for (Map.Entry entry : items.entrySet()) { + setRenderedItem(entry.getKey(), entry.getValue()); + } + viewer.updateInventory(); + } + + public @Nullable ItemStack getRenderedItem(final int rawSlot) { + if (rawSlot < 0) { + return null; + } + + if (rawSlot < inventory.getSize()) { + return inventory.getItem(rawSlot); + } + + if (!isPlayerInventoryHidden()) { + return null; + } + + return viewer.getOpenInventory().getItem(rawSlot); + } + + public void setRenderedItem(final int rawSlot, final @Nullable ItemStack itemStack) { + if (rawSlot < 0) { + return; + } + + if (rawSlot < inventory.getSize()) { + inventory.setItem(rawSlot, itemStack); + return; + } + + if (!isPlayerInventoryHidden()) { + return; + } + + final InventoryView view = viewer.getOpenInventory(); + if (view == null || view.getType() == InventoryType.CRAFTING) { + return; + } + + if (rawSlot >= inventory.getSize() + 36) { + return; + } + + view.setItem(rawSlot, itemStack); + } + + public boolean isRenderedSlot(final int rawSlot) { + if (rawSlot < 0) { + return false; + } + + if (rawSlot < inventory.getSize()) { + return true; + } + + return isPlayerInventoryHidden() && rawSlot < inventory.getSize() + 36; + } + + private @NotNull ItemStack[] cloneContents(final @NotNull ItemStack[] contents) { + final ItemStack[] clone = new ItemStack[contents.length]; + for (int i = 0; i < contents.length; i++) { + clone[i] = contents[i] == null ? null : contents[i].clone(); + } + return clone; + } public Map getTypedArgs() { return typedArgs; diff --git a/src/main/java/com/extendedclip/deluxemenus/menu/MenuItem.java b/src/main/java/com/extendedclip/deluxemenus/menu/MenuItem.java index f106161a..a132eeb5 100644 --- a/src/main/java/com/extendedclip/deluxemenus/menu/MenuItem.java +++ b/src/main/java/com/extendedclip/deluxemenus/menu/MenuItem.java @@ -269,10 +269,10 @@ public ItemStack getItemStack(@NotNull final MenuHolder holder) { itemMeta.setCustomModelDataComponent(parseCustomModelDataComponent(this.options.customModelDataComponent().get(), itemMeta.getCustomModelDataComponent(), holder)); } - if (this.options.displayName().isPresent()) { - final String displayName = holder.setPlaceholdersAndArguments(this.options.displayName().get()); - itemMeta.setDisplayName(StringUtils.color(displayName)); - } + if (this.options.displayName().isPresent()) { + final String displayName = holder.setPlaceholdersAndArguments(this.options.displayName().get()); + itemMeta.setDisplayName(StringUtils.color(displayName)); + } List lore = new ArrayList<>(); // This checks if a lore should be kept from the hooked item, and then if a lore exists on the item @@ -571,8 +571,8 @@ private boolean isHeadItem(@NotNull final String material) { protected List getMenuItemLore(@NotNull final MenuHolder holder, @NotNull final List lore) { return lore.stream() - .map(holder::setPlaceholdersAndArguments) - .map(StringUtils::color) + .map(holder::setPlaceholdersAndArguments) + .map(StringUtils::color) .map(line -> line.split("\n")) .flatMap(Arrays::stream) .map(line -> line.split("\\\\n")) diff --git a/src/main/java/com/extendedclip/deluxemenus/menu/options/MenuOptions.java b/src/main/java/com/extendedclip/deluxemenus/menu/options/MenuOptions.java index c5c4df0a..9dadf291 100644 --- a/src/main/java/com/extendedclip/deluxemenus/menu/options/MenuOptions.java +++ b/src/main/java/com/extendedclip/deluxemenus/menu/options/MenuOptions.java @@ -18,9 +18,12 @@ public class MenuOptions { private final int updateInterval; private final int refreshInterval; private final boolean refresh; + private final boolean hidePlayerInventory; + private final boolean subMenu; private final boolean parsePlaceholdersInArguments; private final boolean parsePlaceholdersAfterArguments; private final boolean enableBypassPerm; + private final String playerInventoryMenu; private final List commands; private final boolean registerCommands; @@ -40,9 +43,12 @@ private MenuOptions(final @NotNull MenuOptionsBuilder builder) { this.updateInterval = builder.updateInterval; this.refreshInterval = builder.refreshInterval; this.refresh = builder.refresh; + this.hidePlayerInventory = builder.hidePlayerInventory; + this.subMenu = builder.subMenu; this.parsePlaceholdersInArguments = builder.parsePlaceholdersInArguments; this.parsePlaceholdersAfterArguments = builder.parsePlaceholdersAfterArguments; this.enableBypassPerm = builder.enableBypassPerm; + this.playerInventoryMenu = builder.playerInventoryMenu; this.commands = builder.commands; this.registerCommands = builder.registerCommands; @@ -87,6 +93,14 @@ public boolean refresh() { return this.refresh; } + public boolean hidePlayerInventory() { + return this.hidePlayerInventory; + } + + public boolean subMenu() { + return this.subMenu; + } + public boolean parsePlaceholdersInArguments() { return this.parsePlaceholdersInArguments; } @@ -99,6 +113,10 @@ public boolean enableBypassPerm() { return this.enableBypassPerm; } + public @NotNull Optional playerInventoryMenu() { + return Optional.ofNullable(this.playerInventoryMenu); + } + public @NotNull List<@NotNull String> commands() { return this.commands; } @@ -138,9 +156,12 @@ public boolean registerCommands() { .updateInterval(this.updateInterval) .refreshInterval(this.refreshInterval) .refresh(this.refresh) + .hidePlayerInventory(this.hidePlayerInventory) + .subMenu(this.subMenu) .parsePlaceholdersInArguments(this.parsePlaceholdersInArguments) .parsePlaceholdersAfterArguments(this.parsePlaceholdersAfterArguments) .enableBypassPerm(this.enableBypassPerm) + .playerInventoryMenu(this.playerInventoryMenu) .commands(this.commands) .registerCommands(this.registerCommands) .arguments(this.arguments) @@ -160,9 +181,12 @@ public static class MenuOptionsBuilder { private int updateInterval = 10; private int refreshInterval = 10; private boolean refresh; + private boolean hidePlayerInventory; + private boolean subMenu; private boolean parsePlaceholdersInArguments = false; private boolean parsePlaceholdersAfterArguments = false; private boolean enableBypassPerm = false; + private String playerInventoryMenu; private List commands = List.of(); private boolean registerCommands = false; @@ -214,6 +238,16 @@ public MenuOptionsBuilder refresh(final boolean refresh) { return this; } + public MenuOptionsBuilder hidePlayerInventory(final boolean hidePlayerInventory) { + this.hidePlayerInventory = hidePlayerInventory; + return this; + } + + public MenuOptionsBuilder subMenu(final boolean subMenu) { + this.subMenu = subMenu; + return this; + } + public MenuOptionsBuilder parsePlaceholdersInArguments(final boolean parsePlaceholdersInArguments) { this.parsePlaceholdersInArguments = parsePlaceholdersInArguments; return this; @@ -229,6 +263,11 @@ public MenuOptionsBuilder enableBypassPerm(final boolean enableBypassPerm) { return this; } + public MenuOptionsBuilder playerInventoryMenu(final @Nullable String playerInventoryMenu) { + this.playerInventoryMenu = playerInventoryMenu; + return this; + } + public MenuOptionsBuilder commands(final @NotNull List<@NotNull String> commands) { this.commands = commands; return this; diff --git a/src/main/java/com/extendedclip/deluxemenus/utils/Messages.java b/src/main/java/com/extendedclip/deluxemenus/utils/Messages.java index 01919b25..e30c7446 100644 --- a/src/main/java/com/extendedclip/deluxemenus/utils/Messages.java +++ b/src/main/java/com/extendedclip/deluxemenus/utils/Messages.java @@ -70,11 +70,23 @@ public enum Messages { .append(newline()) .append(text(">", NamedTextColor.AQUA)) .append(space().append(space())) - .append(text("/dm dump ", NamedTextColor.WHITE)) - .append(newline()) - .append(text(">", NamedTextColor.AQUA)) - .append(space().append(space())) - .append(text("/dm meta ", NamedTextColor.WHITE)) + .append(text("/dm dump ", NamedTextColor.WHITE)) + .append(newline()) + .append(text(">", NamedTextColor.AQUA)) + .append(space().append(space())) + .append(text("/dm debug ", NamedTextColor.WHITE)) + .append(newline()) + .append(text(">", NamedTextColor.AQUA)) + .append(space().append(space())) + .append(text("/dm edit ", NamedTextColor.WHITE)) + .append(newline()) + .append(text(">", NamedTextColor.AQUA)) + .append(space().append(space())) + .append(text("/dm webeditor ", NamedTextColor.WHITE)) + .append(newline()) + .append(text(">", NamedTextColor.AQUA)) + .append(space().append(space())) + .append(text("/dm meta ", NamedTextColor.WHITE)) .append(newline()) .append(text(">", NamedTextColor.AQUA)) .append(space().append(space())) diff --git a/src/main/java/com/extendedclip/deluxemenus/utils/StringUtils.java b/src/main/java/com/extendedclip/deluxemenus/utils/StringUtils.java index 81fd30f8..2bf9d046 100644 --- a/src/main/java/com/extendedclip/deluxemenus/utils/StringUtils.java +++ b/src/main/java/com/extendedclip/deluxemenus/utils/StringUtils.java @@ -1,19 +1,56 @@ package com.extendedclip.deluxemenus.utils; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import me.clip.placeholderapi.PlaceholderAPI; -import net.md_5.bungee.api.ChatColor; -import org.bukkit.Color; -import org.bukkit.entity.Player; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import me.clip.placeholderapi.PlaceholderAPI; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.md_5.bungee.api.ChatColor; +import org.bukkit.Color; +import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public class StringUtils { - - private final static Pattern HEX_PATTERN = Pattern - .compile("&(#[a-f0-9]{6})", Pattern.CASE_INSENSITIVE); + + private final static Pattern HEX_PATTERN = Pattern + .compile("&(#[a-f0-9]{6})", Pattern.CASE_INSENSITIVE); + private final static Pattern MINI_HEX_PATTERN = Pattern + .compile("<(?:color:)?#([a-f0-9]{6})>", Pattern.CASE_INSENSITIVE); + private final static MiniMessage MINI_MESSAGE = MiniMessage.miniMessage(); + private final static LegacyComponentSerializer LEGACY_SECTION = LegacyComponentSerializer.legacySection(); + private final static Map MINI_TITLE_TAGS = Map.ofEntries( + Map.entry("black", ChatColor.BLACK.toString()), + Map.entry("dark_blue", ChatColor.DARK_BLUE.toString()), + Map.entry("dark_green", ChatColor.DARK_GREEN.toString()), + Map.entry("dark_aqua", ChatColor.DARK_AQUA.toString()), + Map.entry("dark_red", ChatColor.DARK_RED.toString()), + Map.entry("dark_purple", ChatColor.DARK_PURPLE.toString()), + Map.entry("gold", ChatColor.GOLD.toString()), + Map.entry("gray", ChatColor.GRAY.toString()), + Map.entry("grey", ChatColor.GRAY.toString()), + Map.entry("dark_gray", ChatColor.DARK_GRAY.toString()), + Map.entry("dark_grey", ChatColor.DARK_GRAY.toString()), + Map.entry("blue", ChatColor.BLUE.toString()), + Map.entry("green", ChatColor.GREEN.toString()), + Map.entry("aqua", ChatColor.AQUA.toString()), + Map.entry("red", ChatColor.RED.toString()), + Map.entry("light_purple", ChatColor.LIGHT_PURPLE.toString()), + Map.entry("yellow", ChatColor.YELLOW.toString()), + Map.entry("white", ChatColor.WHITE.toString()), + Map.entry("bold", ChatColor.BOLD.toString()), + Map.entry("b", ChatColor.BOLD.toString()), + Map.entry("italic", ChatColor.ITALIC.toString()), + Map.entry("i", ChatColor.ITALIC.toString()), + Map.entry("underlined", ChatColor.UNDERLINE.toString()), + Map.entry("u", ChatColor.UNDERLINE.toString()), + Map.entry("strikethrough", ChatColor.STRIKETHROUGH.toString()), + Map.entry("st", ChatColor.STRIKETHROUGH.toString()), + Map.entry("obfuscated", ChatColor.MAGIC.toString()), + Map.entry("obf", ChatColor.MAGIC.toString()), + Map.entry("reset", ChatColor.RESET.toString()) + ); /** * Translates the ampersand color codes like '&7' to their section symbol counterparts like 'ยง7'. @@ -33,11 +70,60 @@ public static String color(@NotNull String input) { } } - return ChatColor.translateAlternateColorCodes('&', input); - } - - @NotNull - public static String replacePlaceholdersAndArguments(@NotNull String input, final @Nullable Map arguments, + return ChatColor.translateAlternateColorCodes('&', input); + } + + @NotNull + public static String colorMenuTitle(@NotNull final String input) { + try { + final String parsed = color(LEGACY_SECTION.serialize(MINI_MESSAGE.deserialize(input))); + if (!hasMiniTitleTag(parsed)) { + return parsed; + } + } catch (final Exception ignored) { + } + return color(replaceMiniTitleTags(input)); + } + + private static boolean hasMiniTitleTag(@NotNull final String input) { + return input.indexOf('<') != -1 && input.indexOf('>') != -1; + } + + @NotNull + private static String replaceMiniTitleTags(@NotNull String input) { + if (VersionHelper.IS_HEX_VERSION) { + final Matcher matcher = MINI_HEX_PATTERN.matcher(input); + final StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + matcher.appendReplacement(buffer, Matcher.quoteReplacement(ChatColor.of("#" + matcher.group(1)).toString())); + } + matcher.appendTail(buffer); + input = buffer.toString(); + } + + for (final Map.Entry entry : MINI_TITLE_TAGS.entrySet()) { + input = replaceMiniTitleTag(input, entry.getKey(), entry.getValue()); + } + + return input; + } + + @NotNull + private static String replaceMiniTitleTag( + @NotNull String input, + @NotNull final String tag, + @NotNull final String replacement + ) { + input = Pattern.compile("<" + tag + ">", Pattern.CASE_INSENSITIVE) + .matcher(input) + .replaceAll(Matcher.quoteReplacement(replacement)); + return Pattern.compile("", Pattern.CASE_INSENSITIVE) + .matcher(input) + .replaceAll(Matcher.quoteReplacement(ChatColor.RESET.toString())); + } + + @NotNull + public static String replacePlaceholdersAndArguments(@NotNull String input, final @Nullable Map arguments, final @Nullable Player player, final boolean parsePlaceholdersInsideArguments, final boolean parsePlaceholdersAfterArguments) { diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index ec50f892..27036d61 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -11,10 +11,19 @@ commands: description: DeluxeMenus main commands aliases: [ dm, deluxemenu, dmenu ] permissions: - deluxemenus.admin: - description: admin commands - default: op - deluxemenus.open: + deluxemenus.admin: + description: admin commands + default: op + deluxemenus.debug: + description: debug placeholders, menu items, and hooked items + default: op + deluxemenus.edit: + description: edit menus in game + default: op + deluxemenus.webeditor: + description: create temporary web editor sessions + default: op + deluxemenus.open: description: open a menu with /dm open default: op deluxemenus.open.others: