From f7355b0721eb548f62a401b9f4d2299a325013ad Mon Sep 17 00:00:00 2001 From: goldbocman Date: Sat, 1 Aug 2026 18:08:44 +0200 Subject: [PATCH] feature/groups-commands: group new, group (list) and group delete for easier groups manipulations --- .../modpack/GroupManager.java | 109 ++++++++++ .../modpack/GroupManagerTest.java | 143 +++++++++++++ docs/commands/commands.mdx | 3 + .../skidam/automodpack/modpack/Commands.java | 189 ++++++++++++++++++ 4 files changed, 444 insertions(+) create mode 100644 core/src/main/java/pl/skidam/automodpack_core/modpack/GroupManager.java create mode 100644 core/src/test/java/pl/skidam/automodpack_core/modpack/GroupManagerTest.java diff --git a/core/src/main/java/pl/skidam/automodpack_core/modpack/GroupManager.java b/core/src/main/java/pl/skidam/automodpack_core/modpack/GroupManager.java new file mode 100644 index 000000000..8fb3565b7 --- /dev/null +++ b/core/src/main/java/pl/skidam/automodpack_core/modpack/GroupManager.java @@ -0,0 +1,109 @@ +package pl.skidam.automodpack_core.modpack; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import pl.skidam.automodpack_core.Constants; +import pl.skidam.automodpack_core.config.Jsons; +import pl.skidam.automodpack_core.utils.ModpackContentTools; + +public class GroupManager { + + private static final Pattern INVALID_CHARS = Pattern.compile("[<>:\"/\\\\|?*\\x00-\\x1F]"); + private static final Set RESERVED_NAMES = Set.of( + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"); + private static final int MAX_NAME_LENGTH = 100; + + private GroupManager() {} + + // Group ids are used directly as directory names (see groupDirectory), so they're restricted to + // what both Windows and Unix accept as a valid, unambiguous filename. + public static void validateName(String name) { + if (name == null || name.isBlank()) throw new IllegalArgumentException("Group name cannot be empty"); + if (name.length() > MAX_NAME_LENGTH) throw new IllegalArgumentException("Group name cannot be longer than " + MAX_NAME_LENGTH + " characters"); + if (!name.equals(name.strip())) throw new IllegalArgumentException("Group name cannot have leading or trailing whitespace"); + if (name.equals(".") || name.equals("..")) throw new IllegalArgumentException("Group name cannot be \".\" or \"..\""); + if (name.endsWith(".")) throw new IllegalArgumentException("Group name cannot end with a dot"); + if (INVALID_CHARS.matcher(name).find()) throw new IllegalArgumentException("Group name cannot contain any of: < > : \" / \\ | ? * or control characters"); + + String baseName = name.contains(".") ? name.substring(0, name.indexOf('.')) : name; + if (RESERVED_NAMES.contains(baseName.toUpperCase(Locale.ROOT))) { + throw new IllegalArgumentException("\"" + name + "\" is a reserved name and cannot be used as a group name"); + } + } + + public static Path groupDirectory(String groupId) { + return Constants.hostModpackDir.resolve(groupId); + } + + public static void createGroupFolders(Path groupDirectory) throws IOException { + Files.createDirectories(groupDirectory.resolve("config")); + Files.createDirectories(groupDirectory.resolve("mods")); + Files.createDirectories(groupDirectory.resolve("resourcepacks")); + Files.createDirectories(groupDirectory.resolve("shaderpacks")); + } + + public static void deleteGroupFolders(Path groupDirectory) throws IOException { + if (!Files.exists(groupDirectory)) return; + try (Stream paths = Files.walk(groupDirectory)) { + for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + } + + public record GroupCounts(int mods, int resourcepacks, int shaders) { + public static final GroupCounts EMPTY = new GroupCounts(0, 0, 0); + } + + // Per-type counts only exist in the generated manifest (Jsons.GroupDeclaration itself has no file + // lists), so this returns EMPTY until the modpack has been generated at least once. + public static GroupCounts countGroup(String groupId) { + Jsons.ModpackContentFields manifest = ModpackContentTools.read(Constants.hostModpackContentFile); + Jsons.ModpackContentFields.ModpackGroupFields group = manifest == null ? null : manifest.groups.get(groupId); + if (group == null) return GroupCounts.EMPTY; + + int mods = 0, resourcepacks = 0, shaders = 0; + for (Jsons.ModpackContentFields.ModpackContentItem item : manifest.list) { + if (!group.files.contains(item.file)) continue; + switch (item.type) { + case "mod" -> mods++; + case "resourcepack" -> resourcepacks++; + case "shader" -> shaders++; + default -> { + } + } + } + return new GroupCounts(mods, resourcepacks, shaders); + } + + public static List listGroupFiles(String groupId, String type) { + Jsons.ModpackContentFields manifest = ModpackContentTools.read(Constants.hostModpackContentFile); + Jsons.ModpackContentFields.ModpackGroupFields group = manifest == null ? null : manifest.groups.get(groupId); + if (group == null) return List.of(); + + List files = new ArrayList<>(); + for (Jsons.ModpackContentFields.ModpackContentItem item : manifest.list) { + if (group.files.contains(item.file) && item.type.equals(type)) { + files.add(item.file); + } + } + files.sort(String.CASE_INSENSITIVE_ORDER); + return files; + } + + public static boolean isRequired(String groupId) { + Jsons.GroupDeclaration declaration = Constants.serverConfig.groups.get(groupId); + return declaration != null && declaration.required; + } +} diff --git a/core/src/test/java/pl/skidam/automodpack_core/modpack/GroupManagerTest.java b/core/src/test/java/pl/skidam/automodpack_core/modpack/GroupManagerTest.java new file mode 100644 index 000000000..2689d57b4 --- /dev/null +++ b/core/src/test/java/pl/skidam/automodpack_core/modpack/GroupManagerTest.java @@ -0,0 +1,143 @@ +package pl.skidam.automodpack_core.modpack; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import pl.skidam.automodpack_core.Constants; +import pl.skidam.automodpack_core.config.Jsons; +import pl.skidam.automodpack_core.utils.ModpackContentTools; + +class GroupManagerTest { + + @TempDir + Path testFilesDir; + Path originalContentFile; + + @BeforeEach + void setUp() { + originalContentFile = Constants.hostModpackContentFile; + Constants.hostModpackContentFile = testFilesDir.resolve("automodpack-content.json"); + } + + @AfterEach + void tearDown() { + Constants.hostModpackContentFile = originalContentFile; + } + + @Test + void acceptsOrdinaryNames() { + assertDoesNotThrow(() -> GroupManager.validateName("extras")); + assertDoesNotThrow(() -> GroupManager.validateName("Animated Entities")); + assertDoesNotThrow(() -> GroupManager.validateName("shader-pack_2")); + } + + @Test + void rejectsBlankNames() { + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("")); + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName(" ")); + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName(null)); + } + + @Test + void rejectsSurroundingWhitespaceAndDots() { + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName(" extras")); + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("extras ")); + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("extras.")); + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName(".")); + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("..")); + } + + @Test + void rejectsInvalidWindowsFilenameCharacters() { + for (String invalid : List.of("ab", "a:b", "a\"b", "a/b", "a\\b", "a|b", "a?b", "a*b")) { + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName(invalid), invalid); + } + } + + @Test + void rejectsReservedDeviceNamesCaseInsensitively() { + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("CON")); + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("con")); + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("Com3")); + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("lpt9")); + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("NUL.txt")); + } + + @Test + void rejectsNamesLongerThanLimit() { + assertThrows(IllegalArgumentException.class, () -> GroupManager.validateName("a".repeat(101))); + assertDoesNotThrow(() -> GroupManager.validateName("a".repeat(100))); + } + + @Test + void createAndDeleteGroupFolders() throws IOException { + Path groupDir = testFilesDir.resolve("extras"); + GroupManager.createGroupFolders(groupDir); + + assertTrue(Files.isDirectory(groupDir.resolve("mods"))); + assertTrue(Files.isDirectory(groupDir.resolve("resourcepacks"))); + assertTrue(Files.isDirectory(groupDir.resolve("shaderpacks"))); + + Files.writeString(groupDir.resolve("mods/some-mod.jar"), "a"); + + GroupManager.deleteGroupFolders(groupDir); + assertFalse(Files.exists(groupDir)); + } + + @Test + void deleteGroupFoldersIsNoOpWhenMissing() { + assertDoesNotThrow(() -> GroupManager.deleteGroupFolders(testFilesDir.resolve("does-not-exist"))); + } + + @Test + void countGroupIsEmptyWithoutGeneratedManifest() { + assertEquals(GroupManager.GroupCounts.EMPTY, GroupManager.countGroup("extras")); + } + + @Test + void countGroupTalliesByType() throws IOException { + writeManifest("extras", Set.of( + item("/mods/a.jar", "mod"), + item("/mods/b.jar", "mod"), + item("/resourcepacks/pack.zip", "resourcepack"), + item("/shaderpacks/shader.zip", "shader"), + item("/config/other.json", "config")), + Set.of("/mods/a.jar", "/mods/b.jar", "/resourcepacks/pack.zip", "/shaderpacks/shader.zip")); + + assertEquals(new GroupManager.GroupCounts(2, 1, 1), GroupManager.countGroup("extras")); + } + + @Test + void listGroupFilesFiltersByTypeAndSorts() throws IOException { + writeManifest("extras", Set.of( + item("/mods/zebra.jar", "mod"), + item("/mods/alpha.jar", "mod"), + item("/resourcepacks/pack.zip", "resourcepack")), + Set.of("/mods/zebra.jar", "/mods/alpha.jar", "/resourcepacks/pack.zip")); + + assertEquals(List.of("/mods/alpha.jar", "/mods/zebra.jar"), GroupManager.listGroupFiles("extras", "mod")); + } + + private static Jsons.ModpackContentFields.ModpackContentItem item(String file, String type) { + return new Jsons.ModpackContentFields.ModpackContentItem(file, "1", type, false, false, false, "sha1", null); + } + + private void writeManifest(String groupId, Set items, Set groupFiles) throws IOException { + Jsons.ModpackContentFields manifest = new Jsons.ModpackContentFields(items); + Jsons.ModpackContentFields.ModpackGroupFields group = new Jsons.ModpackContentFields.ModpackGroupFields(); + group.files = groupFiles; + manifest.groups = Map.of(groupId, group); + ModpackContentTools.write(Constants.hostModpackContentFile, manifest); + } +} diff --git a/docs/commands/commands.mdx b/docs/commands/commands.mdx index d8885548a..e7459e176 100644 --- a/docs/commands/commands.mdx +++ b/docs/commands/commands.mdx @@ -10,4 +10,7 @@ - `/automodpack host fingerprint` - Get the [certificate fingerprint](../technicals/certificate) of the modpack host. - `/automodpack host fingerprint dns ` - Generate a DNSSEC record that lets clients authenticate the host's self-signed certificate automatically. - `/automodpack host fingerprint share ` - Generate plain and pinned copyable addresses without storing the fingerprint in Minecraft's server list. +- `/automodpack group` - List of modpack groups. `--full` for more details . +- `/automodpack group new "groupName"` - Create new group. +- `/automodpack group remove "groupName"` - Remove already existing group. `--yes` to delete immediately, without confirming. - `/automodpack config reload` - Reload config files. diff --git a/src/main/java/pl/skidam/automodpack/modpack/Commands.java b/src/main/java/pl/skidam/automodpack/modpack/Commands.java index 29b40410d..5a3eddc3d 100644 --- a/src/main/java/pl/skidam/automodpack/modpack/Commands.java +++ b/src/main/java/pl/skidam/automodpack/modpack/Commands.java @@ -16,6 +16,7 @@ import pl.skidam.automodpack_core.config.BootstrapConfig; import pl.skidam.automodpack_core.config.ConfigTools; import pl.skidam.automodpack_core.config.Jsons; +import pl.skidam.automodpack_core.modpack.GroupManager; import pl.skidam.automodpack_core.modpack.ModpackId; import pl.skidam.automodpack_core.protocol.ModpackConnectionMode; import pl.skidam.automodpack_core.utils.AddressHelpers; @@ -23,9 +24,14 @@ import java.io.IOException; import java.net.InetSocketAddress; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.TreeMap; import net.minecraft.ChatFormatting; import net.minecraft.util.Util; import net.minecraft.commands.CommandSourceStack; @@ -108,6 +114,30 @@ public static void register(CommandDispatcher dispatcher) { .executes(Commands::reload) ) ) + .then(literal("group") + .requires((source) -> source.permissions().hasPermission(new Permission.HasCommandLevel(PermissionLevel.byId(3)))) + .executes(Commands::groupList) + .then(literal("--full") + .executes(Commands::groupListFull) + ) + .then(literal("new") + .then(argument("name", StringArgumentType.string()) + .executes(Commands::groupNew) + ) + ) + .then(literal("remove") + .then(argument("name", StringArgumentType.string()) + .executes(Commands::groupRemovePrompt) + .then(literal("--yes") + .executes(Commands::groupRemoveConfirmed) + ) + ) + ) + ) + .then(literal("confirm") + .requires((source) -> source.permissions().hasPermission(new Permission.HasCommandLevel(PermissionLevel.byId(3)))) + .executes(Commands::confirmPending) + ) ); dispatcher.register( @@ -117,6 +147,164 @@ public static void register(CommandDispatcher dispatcher) { ); } + // A dedicated-server console only ever prints a chat component's flat text, ignoring click/hover + // events, so a destructive action can't be confirmed with a clickable button there. Instead + // "group remove" (without --yes) stashes the actual deletion here and prints instructions to run + // "amp confirm" next; expiresAtMillis guards against a stale prompt firing long after the fact. + private record PendingConfirmation(Runnable action, long expiresAtMillis) {} + + private static volatile PendingConfirmation pendingConfirmation; + + private static int confirmPending(CommandContext context) { + PendingConfirmation pending = pendingConfirmation; + pendingConfirmation = null; + if (pending == null || System.currentTimeMillis() > pending.expiresAtMillis()) { + send(context, "Nothing to confirm.", ChatFormatting.RED, false); + return Command.SINGLE_SUCCESS; + } + + pending.action().run(); + return Command.SINGLE_SUCCESS; + } + + private static int groupList(CommandContext context) { + Util.backgroundExecutor().execute(() -> { + for (String groupId : new TreeMap<>(serverConfig.groups).keySet()) { + GroupManager.GroupCounts counts = GroupManager.countGroup(groupId); + String summary = String.format("%d mods, %d resourcepacks, %d shaders", counts.mods(), counts.resourcepacks(), counts.shaders()); + send(context, groupId, ChatFormatting.YELLOW, summary, ChatFormatting.WHITE, false); + } + send(context, "To display full list, type amp group --full", ChatFormatting.GRAY, false); + }); + + return Command.SINGLE_SUCCESS; + } + + private static int groupListFull(CommandContext context) { + Util.backgroundExecutor().execute(() -> { + for (String groupId : new TreeMap<>(serverConfig.groups).keySet()) { + send(context, groupId + ":", ChatFormatting.YELLOW, false); + sendGroupFileList(context, groupId, "mod", "Mods"); + sendGroupFileList(context, groupId, "resourcepack", "Resourcepacks"); + sendGroupFileList(context, groupId, "shader", "Shaders"); + } + }); + + return Command.SINGLE_SUCCESS; + } + + private static void sendGroupFileList(CommandContext context, String groupId, String type, String label) { + List files = GroupManager.listGroupFiles(groupId, type); + send(context, " " + label + " (" + files.size() + "):", ChatFormatting.GREEN, false); + for (String file : files) { + send(context, " - " + stripTypeFolder(file), ChatFormatting.GRAY, false); + } + } + + // Files come back as "/mods/foo.jar", "/resourcepacks/foo.zip", "/shaderpacks/foo.zip" - the leading + // folder is already implied by the "Mods"/"Resourcepacks"/"Shaders" sub-header, so drop it here. + private static String stripTypeFolder(String file) { + int secondSlash = file.indexOf('/', 1); + return secondSlash == -1 ? file : file.substring(secondSlash + 1); + } + + private static int groupNew(CommandContext context) { + String name = StringArgumentType.getString(context, "name"); + try { + GroupManager.validateName(name); + } catch (IllegalArgumentException e) { + send(context, e.getMessage(), ChatFormatting.RED, false); + return 0; + } + + if (serverConfig.groups.keySet().stream().anyMatch(name::equalsIgnoreCase)) { + send(context, "A group named \"" + name + "\" already exists!", ChatFormatting.RED, false); + return 0; + } + + Util.backgroundExecutor().execute(() -> { + Path groupDirectory = GroupManager.groupDirectory(name); + try { + GroupManager.createGroupFolders(groupDirectory); + } catch (IOException e) { + LOGGER.error("Failed to create group folders", e); + send(context, "Failed to create group folders: " + e.getMessage(), ChatFormatting.RED, false); + return; + } + + Jsons.GroupDeclaration declaration = new Jsons.GroupDeclaration(); + declaration.displayName = name; + Map groups = new LinkedHashMap<>(serverConfig.groups); + groups.put(name, declaration); + serverConfig.groups = groups; + + if (!saveServerConfig(context)) return; + + send(context, "Group created", ChatFormatting.GREEN, copyable(groupDirectory.toAbsolutePath().normalize().toString()), ChatFormatting.YELLOW, + true); + }); + + return Command.SINGLE_SUCCESS; + } + + private static int groupRemovePrompt(CommandContext context) { + String name = StringArgumentType.getString(context, "name"); + if (!checkGroupRemovable(context, name)) return 0; + + pendingConfirmation = new PendingConfirmation(() -> performGroupRemoval(context, name), System.currentTimeMillis() + 30_000); + send(context, "Are you sure you want to delete group \"" + name + "\"? Type \"amp confirm\" to proceed.", ChatFormatting.YELLOW, false); + return Command.SINGLE_SUCCESS; + } + + private static int groupRemoveConfirmed(CommandContext context) { + String name = StringArgumentType.getString(context, "name"); + if (!checkGroupRemovable(context, name)) return 0; + + Util.backgroundExecutor().execute(() -> performGroupRemoval(context, name)); + return Command.SINGLE_SUCCESS; + } + + private static boolean checkGroupRemovable(CommandContext context, String name) { + if (!serverConfig.groups.containsKey(name)) { + send(context, "No group named \"" + name + "\" exists!", ChatFormatting.RED, false); + return false; + } + if (GroupManager.isRequired(name)) { + send(context, "Group \"" + name + "\" is required and cannot be removed.", ChatFormatting.RED, false); + return false; + } + return true; + } + + private static void performGroupRemoval(CommandContext context, String name) { + try { + GroupManager.deleteGroupFolders(GroupManager.groupDirectory(name)); + } catch (IOException e) { + LOGGER.error("Failed to delete group folders", e); + send(context, "Failed to delete group folders: " + e.getMessage(), ChatFormatting.RED, false); + return; + } + + Map groups = new LinkedHashMap<>(serverConfig.groups); + groups.remove(name); + serverConfig.groups = groups; + + if (!saveServerConfig(context)) return; + + send(context, "Group \"" + name + "\" removed!", ChatFormatting.GREEN, true); + } + + private static boolean saveServerConfig(CommandContext context) { + try { + ConfigTools.writeAtomic(serverConfigFile, serverConfig); + return true; + } catch (IOException e) { + LOGGER.error("Failed to save server config", e); + send(context, "Failed to save server config: " + e.getMessage(), ChatFormatting.RED, false); + return false; + } + } + private static int fingerprint(CommandContext context) { String fingerprint = hostServer.getCertificateFingerprint(); if (fingerprint != null) { @@ -382,6 +570,7 @@ private static int about(CommandContext context) { send(context, "/automodpack generate", ChatFormatting.YELLOW, false); send(context, "/automodpack host start/stop/restart/connections/fingerprint/bootstrap", ChatFormatting.YELLOW, false); send(context, "/automodpack config reload", ChatFormatting.YELLOW, false); + send(context, "/automodpack group [--full]/new/remove", ChatFormatting.YELLOW, false); return Command.SINGLE_SUCCESS; }