diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml
index 9092587..18f7b3b 100644
--- a/dependency-reduced-pom.xml
+++ b/dependency-reduced-pom.xml
@@ -155,7 +155,7 @@
io.papermc.paper
paper-api
- 1.21.8-R0.1-SNAPSHOT
+ 1.21.4-R0.1-SNAPSHOT
provided
diff --git a/src/main/java/net/greenfieldmc/core/GreenfieldCore.java b/src/main/java/net/greenfieldmc/core/GreenfieldCore.java
index 64ec303..dbb17a9 100644
--- a/src/main/java/net/greenfieldmc/core/GreenfieldCore.java
+++ b/src/main/java/net/greenfieldmc/core/GreenfieldCore.java
@@ -9,6 +9,7 @@
import net.greenfieldmc.core.paintingswitch.PaintingSwitchModule;
import net.greenfieldmc.core.powershovel.PowerShovelModule;
import net.greenfieldmc.core.redblock.RedblockModule;
+import net.greenfieldmc.core.signmanager.SignManagerModule;
import net.greenfieldmc.core.templates.TemplatesModule;
import net.greenfieldmc.core.testresult.TestResultModule;
import net.greenfieldmc.core.utilities.UtilitiesModule;
@@ -40,7 +41,8 @@ public void onEnable() {
new AdvancedBuildModule(this, ModuleConfig::isAdvancedBuildModeEnabled),
new RedblockModule(this, ModuleConfig::isRedblockEnabled),
new ChatFormatModule(this, ModuleConfig::isChatFormatEnabled),
- new TemplatesModule(this, ModuleConfig::isTemplatesEnabled)
+ new TemplatesModule(this, ModuleConfig::isTemplatesEnabled),
+ new SignManagerModule(this, ModuleConfig::isSignManagerEnabled)
));
MODULES.forEach(Module::enable);
diff --git a/src/main/java/net/greenfieldmc/core/ModuleConfig.java b/src/main/java/net/greenfieldmc/core/ModuleConfig.java
index 9e95f9c..e95cf50 100644
--- a/src/main/java/net/greenfieldmc/core/ModuleConfig.java
+++ b/src/main/java/net/greenfieldmc/core/ModuleConfig.java
@@ -19,6 +19,7 @@ public class ModuleConfig extends Configuration {
private final boolean utilities;
private final boolean authHub;
private final boolean templates;
+ private final boolean signManager;
public ModuleConfig(Plugin plugin) {
super(plugin, ConfigType.YML, "moduleConfig");
@@ -37,6 +38,7 @@ public ModuleConfig(Plugin plugin) {
addEntry("modules.utilities", true);
addEntry("modules.authHub", true);
addEntry("modules.templates", true);
+ addEntry("modules.signManager", true);
save();
@@ -53,6 +55,7 @@ public ModuleConfig(Plugin plugin) {
utilities = getBoolean("modules.utilities");
authHub = getBoolean("modules.authHub");
templates = getBoolean("modules.templates");
+ signManager = getBoolean("modules.signManager");
}
@@ -108,4 +111,8 @@ public boolean isTemplatesEnabled() {
return templates;
}
+ public boolean isSignManagerEnabled() {
+ return signManager;
+ }
+
}
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java b/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java
new file mode 100644
index 0000000..51cd45d
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/MinecraftFontWidths.java
@@ -0,0 +1,107 @@
+package net.greenfieldmc.core.signmanager;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Utility for calculating Minecraft text pixel widths and centering text.
+ * Minecraft uses a variable-width bitmap font where each character has a
+ * specific pixel width (plus 1px gap between characters).
+ * Signs center-align text within a 90-pixel-wide area.
+ */
+public class MinecraftFontWidths {
+
+ // Sign text rendering width in pixels
+ private static final int SIGN_WIDTH_PIXELS = 90;
+
+ // Space character effective width (glyph 4px + 1px gap)
+ private static final int SPACE_EFFECTIVE_WIDTH = 5;
+
+ // Default character width if not in the map (6px is the most common glyph width)
+ private static final int DEFAULT_CHAR_WIDTH = 6;
+
+ // Map of character -> glyph pixel width (not including the 1px inter-char gap)
+ private static final Map CHAR_WIDTHS = new HashMap<>();
+
+ static {
+ // 2px glyphs
+ for (char c : "!,.:;|i".toCharArray()) CHAR_WIDTHS.put(c, 2);
+ CHAR_WIDTHS.put('\'', 2);
+ CHAR_WIDTHS.put('`', 2);
+
+ // 3px glyphs
+ CHAR_WIDTHS.put('l', 3);
+
+ // 4px glyphs
+ for (char c : "\"()*I[]{}t".toCharArray()) CHAR_WIDTHS.put(c, 4);
+
+ // 5px glyphs
+ for (char c : "<>fk".toCharArray()) CHAR_WIDTHS.put(c, 5);
+ CHAR_WIDTHS.put(' ', 4); // space glyph is 4px
+
+ // 7px glyphs
+ for (char c : "@~".toCharArray()) CHAR_WIDTHS.put(c, 7);
+ }
+
+ /**
+ * Gets the pixel width of a single character glyph in Minecraft's default font.
+ */
+ public static int getCharWidth(char c) {
+ return CHAR_WIDTHS.getOrDefault(c, DEFAULT_CHAR_WIDTH);
+ }
+
+ /**
+ * Calculates the total pixel width of a string in Minecraft's default font.
+ * Each character contributes its glyph width + 1px gap (except the last character).
+ */
+ public static int getTextWidth(String text) {
+ if (text == null || text.isEmpty()) return 0;
+ int width = 0;
+ for (int i = 0; i < text.length(); i++) {
+ width += getCharWidth(text.charAt(i));
+ if (i < text.length() - 1) width += 1; // 1px gap between characters
+ }
+ return width;
+ }
+
+ /**
+ * Calculates the pixel width of a Component's plain text content.
+ */
+ public static int getComponentWidth(Component component) {
+ var plainText = PlainTextComponentSerializer.plainText().serialize(component);
+ return getTextWidth(plainText);
+ }
+
+ /**
+ * Creates a center-padded version of the given component,
+ * mimicking how signs render center-aligned text.
+ *
+ * @param component The component to center.
+ * @param targetWidth The target pixel width to center within (typically SIGN_WIDTH_PIXELS).
+ * @return A new component with leading spaces to approximate center alignment.
+ */
+ public static Component centerText(Component component, int targetWidth) {
+ int textWidth = getComponentWidth(component);
+ if (textWidth >= targetWidth) return component;
+
+ int totalPadding = targetWidth - textWidth;
+ int leftPadding = totalPadding / 2;
+
+ // Each space is SPACE_EFFECTIVE_WIDTH pixels wide (4px glyph + 1px gap)
+ int spaceCount = leftPadding / SPACE_EFFECTIVE_WIDTH;
+ if (spaceCount <= 0) return component;
+
+ return Component.text(" ".repeat(spaceCount)).append(component);
+ }
+
+ /**
+ * Centers a component using the standard sign width.
+ */
+ public static Component centerText(Component component) {
+ return centerText(component, SIGN_WIDTH_PIXELS);
+ }
+}
+
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java b/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java
new file mode 100644
index 0000000..1b07055
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/SavedSign.java
@@ -0,0 +1,197 @@
+package net.greenfieldmc.core.signmanager;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextDecoration;
+import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
+import org.bukkit.Material;
+import org.bukkit.inventory.ItemStack;
+import org.bukkit.inventory.meta.BlockStateMeta;
+import org.bukkit.block.Sign;
+import org.bukkit.block.sign.Side;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class SavedSign {
+
+ private String name;
+ private final Material signMaterial;
+ private final List frontLines; // GsonComponentSerializer JSON strings
+ private final List backLines; // GsonComponentSerializer JSON strings
+
+ public SavedSign(String name, Material signMaterial, List frontLines, List backLines) {
+ this.name = name;
+ this.signMaterial = signMaterial;
+ this.frontLines = frontLines;
+ this.backLines = backLines;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public Material getSignMaterial() {
+ return signMaterial;
+ }
+
+ public List getFrontLines() {
+ return frontLines;
+ }
+
+ public List getBackLines() {
+ return backLines;
+ }
+
+ /**
+ * Builds hover text showing the sign's front and back text content.
+ */
+ public List buildHoverLines() {
+ var lines = new ArrayList();
+ lines.add(Component.text("── Front ──", NamedTextColor.GRAY));
+ for (var line : frontLines) {
+ var component = deserializeLine(line);
+ var plain = PlainTextComponentSerializer.plainText().serialize(component);
+ if (!plain.isBlank()) {
+ lines.add(MinecraftFontWidths.centerText(component));
+ }
+ }
+ lines.add(Component.text("── Back ──", NamedTextColor.GRAY));
+ for (var line : backLines) {
+ var component = deserializeLine(line);
+ var plain = PlainTextComponentSerializer.plainText().serialize(component);
+ if (!plain.isBlank()) {
+ lines.add(MinecraftFontWidths.centerText(component));
+ }
+ }
+ return lines;
+ }
+
+ /**
+ * Converts the saved sign data back into a placeable sign ItemStack with
+ * the original front/back text restored in the block entity NBT.
+ */
+ public ItemStack toItemStack() {
+ var item = new ItemStack(signMaterial, 1);
+ var meta = item.getItemMeta();
+
+ if (meta instanceof BlockStateMeta blockStateMeta) {
+ var state = blockStateMeta.getBlockState();
+ if (state instanceof Sign sign) {
+ var frontSide = sign.getSide(Side.FRONT);
+ for (int i = 0; i < frontLines.size() && i < 4; i++) {
+ frontSide.line(i, deserializeLine(frontLines.get(i)));
+ }
+ var backSide = sign.getSide(Side.BACK);
+ for (int i = 0; i < backLines.size() && i < 4; i++) {
+ backSide.line(i, deserializeLine(backLines.get(i)));
+ }
+ blockStateMeta.setBlockState(state);
+ }
+ }
+
+ item.setItemMeta(meta);
+ return item;
+ }
+
+ /**
+ * Creates a SavedSign from a sign ItemStack held by a player.
+ */
+ public static @Nullable SavedSign fromItemStack(ItemStack item, String name) {
+ if (item == null || !isSignMaterial(item.getType())) return null;
+
+ var meta = item.getItemMeta();
+ var frontLines = new ArrayList();
+ var backLines = new ArrayList();
+
+ if (meta instanceof BlockStateMeta blockStateMeta) {
+ var state = blockStateMeta.getBlockState();
+ if (state instanceof Sign sign) {
+ var frontSide = sign.getSide(Side.FRONT);
+ for (int i = 0; i < 4; i++) {
+ frontLines.add(serializeLine(frontSide.line(i)));
+ }
+ var backSide = sign.getSide(Side.BACK);
+ for (int i = 0; i < 4; i++) {
+ backLines.add(serializeLine(backSide.line(i)));
+ }
+ }
+ }
+
+ // If no block state meta, just use empty lines
+ while (frontLines.size() < 4) frontLines.add(serializeLine(Component.empty()));
+ while (backLines.size() < 4) backLines.add(serializeLine(Component.empty()));
+
+ return new SavedSign(name, item.getType(), frontLines, backLines);
+ }
+
+ /**
+ * Returns the plain text content of all sign lines for search matching.
+ */
+ public String getPlainText() {
+ var sb = new StringBuilder();
+ sb.append(name).append(" ");
+ for (var line : frontLines) {
+ sb.append(PlainTextComponentSerializer.plainText().serialize(deserializeLine(line))).append(" ");
+ }
+ for (var line : backLines) {
+ sb.append(PlainTextComponentSerializer.plainText().serialize(deserializeLine(line))).append(" ");
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Returns a human-readable sign type description based on the material name.
+ * e.g. "Oak Sign", "Birch Hanging Sign", "Dark Oak Wall Sign"
+ */
+ public String getSignTypeDescription() {
+ var name = signMaterial.name();
+ // Remove _SIGN suffix and convert underscores to spaces, title case
+ name = name.replace("_SIGN", "").replace("_", " ");
+ var words = name.toLowerCase().split(" ");
+ var sb = new StringBuilder();
+ for (var word : words) {
+ if (!word.isEmpty()) {
+ sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)).append(" ");
+ }
+ }
+ sb.append("Sign");
+ return sb.toString().trim();
+ }
+
+ /**
+ * Returns true if the sign material is a hanging sign type.
+ */
+ public boolean isHangingSign() {
+ return signMaterial.name().contains("HANGING");
+ }
+
+ /**
+ * Returns true if the sign material is a wall sign type.
+ */
+ public boolean isWallSign() {
+ return signMaterial.name().contains("WALL");
+ }
+
+ public static boolean isSignMaterial(Material material) {
+ return material != null && material.name().endsWith("_SIGN");
+ }
+
+ private static String serializeLine(Component component) {
+ return GsonComponentSerializer.gson().serialize(component);
+ }
+
+ private static Component deserializeLine(String json) {
+ try {
+ return GsonComponentSerializer.gson().deserialize(json);
+ } catch (Exception e) {
+ return Component.text(json);
+ }
+ }
+}
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java b/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java
new file mode 100644
index 0000000..a1c8a82
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/SavedSignGroup.java
@@ -0,0 +1,60 @@
+package net.greenfieldmc.core.signmanager;
+
+import org.bukkit.inventory.ItemStack;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Represents a saved group of signs. A group appears as a single entry in the chat paginator
+ * using the display sign (from the main hand at save time). Clicking the group entry
+ * gives the player all member signs.
+ */
+public class SavedSignGroup {
+
+ private final String name;
+ private final SavedSign displaySign;
+ private final List memberSigns;
+
+ public SavedSignGroup(String name, SavedSign displaySign, List memberSigns) {
+ this.name = name;
+ this.displaySign = displaySign;
+ this.memberSigns = new ArrayList<>(memberSigns);
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public SavedSign getDisplaySign() {
+ return displaySign;
+ }
+
+ public List getMemberSigns() {
+ return memberSigns;
+ }
+
+ /**
+ * Returns all member signs as clean ItemStacks ready to be given to a player.
+ */
+ public List toMemberItemStacks() {
+ var items = new ArrayList();
+ for (var sign : memberSigns) {
+ items.add(sign.toItemStack());
+ }
+ return items;
+ }
+
+ /**
+ * Returns the combined plain text of the group for search matching.
+ */
+ public String getPlainText() {
+ var sb = new StringBuilder();
+ sb.append(name).append(" ");
+ sb.append(displaySign.getPlainText()).append(" ");
+ for (var sign : memberSigns) {
+ sb.append(sign.getPlainText()).append(" ");
+ }
+ return sb.toString();
+ }
+}
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java
new file mode 100644
index 0000000..22324d8
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerEntry.java
@@ -0,0 +1,165 @@
+package net.greenfieldmc.core.signmanager;
+
+import com.njdaeger.pdk.command.brigadier.ICommandContext;
+import com.njdaeger.pdk.utils.text.pager.ChatPaginator;
+import com.njdaeger.pdk.utils.text.pager.PageItem;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.TextComponent;
+import net.kyori.adventure.text.event.ClickEvent;
+import net.kyori.adventure.text.event.HoverEvent;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextColor;
+import org.bukkit.Material;
+import org.bukkit.inventory.ItemStack;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+/**
+ * Represents a single entry in the Sign Manager paginator.
+ * Can be either an individual SavedSign or a SavedSignGroup.
+ * Groups appear as a single line using the display sign info, but give all member signs when clicked.
+ */
+@SuppressWarnings("DataFlowIssue")
+public class SignManagerEntry implements PageItem {
+
+ // Unicode block characters for sign type indicators
+ // Full block for normal signs, lower half block for hanging signs
+ private static final String BLOCK_FULL = "█"; // U+2588 Full Block
+ private static final String BLOCK_LOWER_HALF = "▄"; // U+2584 Lower Half Block
+ private static final String BLOCK_THREE_QUARTER = "▆"; // U+2586 Lower Three Quarters Block
+
+ private final @Nullable SavedSign sign;
+ private final @Nullable SavedSignGroup group;
+
+ private SignManagerEntry(@Nullable SavedSign sign, @Nullable SavedSignGroup group) {
+ this.sign = sign;
+ this.group = group;
+ }
+
+ public static SignManagerEntry ofSign(SavedSign sign) {
+ return new SignManagerEntry(sign, null);
+ }
+
+ public static SignManagerEntry ofGroup(SavedSignGroup group) {
+ return new SignManagerEntry(null, group);
+ }
+
+ public boolean isGroup() {
+ return group != null;
+ }
+
+ public String getName() {
+ return isGroup() ? group.getName() : sign.getName();
+ }
+
+ /**
+ * Returns the display sign — either the individual sign itself or the group's display sign.
+ */
+ public SavedSign getDisplaySign() {
+ return isGroup() ? group.getDisplaySign() : sign;
+ }
+
+ /**
+ * Returns all sign items to give the player when this entry is clicked.
+ */
+ public List toGiveItemStacks() {
+ if (isGroup()) {
+ return group.toMemberItemStacks();
+ } else {
+ return List.of(sign.toItemStack());
+ }
+ }
+
+ /**
+ * Returns the number of signs in this entry.
+ */
+ public int getSignCount() {
+ return isGroup() ? group.getMemberSigns().size() : 1;
+ }
+
+ /**
+ * Returns the plain text content for search matching.
+ */
+ public String getPlainText() {
+ return isGroup() ? group.getPlainText() : sign.getPlainText();
+ }
+
+ @Override
+ public TextComponent getItemText(ChatPaginator, ICommandContext> paginator, ICommandContext generatorInfo) {
+ var displaySign = getDisplaySign();
+ var material = displaySign.getSignMaterial();
+
+ // 1. Sign type indicator: colored unicode block with hover showing material
+ var blockChar = getBlockCharForSign(displaySign);
+ var blockColor = getColorForMaterial(material);
+ var typeIndicator = Component.text(blockChar, blockColor)
+ .hoverEvent(HoverEvent.showText(Component.text(displaySign.getSignTypeDescription(), NamedTextColor.GRAY)));
+
+ // 2. Count bracket: [N] for groups, [1] for singles
+ var countText = Component.text(" [" + getSignCount() + "] ", NamedTextColor.GRAY);
+
+ // 3. Name with hover (sign content) and click action (give signs)
+ var hoverContent = Component.text();
+ var hoverLines = displaySign.buildHoverLines();
+ for (int i = 0; i < hoverLines.size(); i++) {
+ if (i > 0) hoverContent.appendNewline();
+ hoverContent.append(hoverLines.get(i));
+ }
+ if (isGroup()) {
+ hoverContent.appendNewline();
+ hoverContent.append(Component.text("Group: " + group.getName() + " (" + getSignCount() + " signs)", NamedTextColor.YELLOW));
+ }
+ hoverContent.appendNewline();
+ hoverContent.append(Component.text("Click to receive", NamedTextColor.GREEN));
+
+ var nameComponent = Component.text(getName(), paginator.getHighlightColor())
+ .hoverEvent(HoverEvent.showText(hoverContent.build()))
+ .clickEvent(ClickEvent.runCommand("/sm give " + getName()));
+
+ var line = Component.text();
+ line.append(typeIndicator);
+ line.append(countText);
+ line.append(nameComponent);
+
+ return line.build();
+ }
+
+ @Override
+ public String getPlainItemText(ChatPaginator, ICommandContext> paginator, ICommandContext generatorInfo) {
+ return getName();
+ }
+
+ /**
+ * Returns the appropriate unicode block character for the sign type.
+ * Full block for regular signs, lower half for hanging signs.
+ */
+ private static String getBlockCharForSign(SavedSign sign) {
+ if (sign.isHangingSign()) {
+ return BLOCK_THREE_QUARTER;
+ }
+ return BLOCK_FULL;
+ }
+
+ /**
+ * Returns a hex color for the sign material type.
+ * Colors approximate the wood type's appearance.
+ * These are placeholder values — the user will set exact colors manually.
+ */
+ private static TextColor getColorForMaterial(Material material) {
+ var name = material.name();
+ if (name.startsWith("OAK")) return TextColor.color(0xaa8a61);
+ if (name.startsWith("SPRUCE")) return TextColor.color(0x705538);
+ if (name.startsWith("BIRCH")) return TextColor.color(0xc4c4c4);
+ if (name.startsWith("JUNGLE")) return TextColor.color(0x494949);
+ if (name.startsWith("ACACIA")) return TextColor.color(0x4b4a4a);
+ if (name.startsWith("DARK_OAK")) return TextColor.color(0x374d2c);
+ if (name.startsWith("MANGROVE")) return TextColor.color(0xaa4334);
+ if (name.startsWith("CHERRY")) return TextColor.color(0x909090);
+ if (name.startsWith("BAMBOO")) return TextColor.color(0x2c2c2c);
+ if (name.startsWith("CRIMSON")) return TextColor.color(0xce7533);
+ if (name.startsWith("WARPED")) return TextColor.color(0x5787a2);
+ if (name.startsWith("PALE_OAK")) return TextColor.color(0xc7a840);
+ return TextColor.color(0xC4A054); // default to oak-ish
+ }
+}
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java
new file mode 100644
index 0000000..de87166
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerMessages.java
@@ -0,0 +1,29 @@
+package net.greenfieldmc.core.signmanager;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.TextComponent;
+import net.kyori.adventure.text.format.NamedTextColor;
+
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+import static net.greenfieldmc.core.ComponentUtils.moduleMessage;
+
+public class SignManagerMessages {
+
+ public static final TextComponent MODULE = moduleMessage("SignManager");
+
+ public static final String ERROR_NOT_HOLDING_SIGN = "You must be holding a sign item in your main hand.";
+ public static final String ERROR_SIGN_NOT_FOUND = "No saved sign found with that name.";
+ public static final String ERROR_GROUP_NOT_FOUND = "No saved sign group found with that name.";
+ public static final String ERROR_SIGN_ALREADY_EXISTS = "A sign or group with that name already exists.";
+ public static final String ERROR_NO_SIGNS_IN_HOTBAR = "No sign items found in your hotbar.";
+ public static final String ERROR_ENTRY_NOT_FOUND = "No saved sign or group found with that name.";
+
+ public static final Function SIGN_SAVED = (name) -> MODULE.append(Component.text("Successfully saved sign \"" + name + "\".", NamedTextColor.GRAY));
+ public static final Function SIGN_DELETED = (name) -> MODULE.append(Component.text("Successfully deleted sign \"" + name + "\".", NamedTextColor.GRAY));
+ public static final BiFunction SIGN_RENAMED = (oldName, newName) -> MODULE.append(Component.text("Renamed sign \"" + oldName + "\" to \"" + newName + "\".", NamedTextColor.GRAY));
+ public static final Function GROUP_SAVED = (name) -> MODULE.append(Component.text("Successfully saved sign group \"" + name + "\".", NamedTextColor.GRAY));
+ public static final Function GROUP_DELETED = (name) -> MODULE.append(Component.text("Successfully deleted sign group \"" + name + "\".", NamedTextColor.GRAY));
+ public static final Function GROUP_SAVED_COUNT = (count) -> MODULE.append(Component.text("Saved " + count + " sign(s) from your hotbar.", NamedTextColor.GRAY));
+}
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java
new file mode 100644
index 0000000..c6f45fe
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/SignManagerModule.java
@@ -0,0 +1,40 @@
+package net.greenfieldmc.core.signmanager;
+
+import net.greenfieldmc.core.GreenfieldCore;
+import net.greenfieldmc.core.Module;
+import net.greenfieldmc.core.ModuleConfig;
+import net.greenfieldmc.core.shared.services.IVaultService;
+import net.greenfieldmc.core.shared.services.VaultServiceImpl;
+import net.greenfieldmc.core.signmanager.services.ISignManagerService;
+import net.greenfieldmc.core.signmanager.services.ISignManagerStorageService;
+import net.greenfieldmc.core.signmanager.services.SignManagerCommandService;
+import net.greenfieldmc.core.signmanager.services.SignManagerServiceImpl;
+import net.greenfieldmc.core.signmanager.services.SignManagerStorageServiceImpl;
+
+import java.util.function.Predicate;
+
+public class SignManagerModule extends Module {
+
+ private IVaultService vaultService;
+ private ISignManagerStorageService storageService;
+ private ISignManagerService signManagerService;
+
+ public SignManagerModule(GreenfieldCore plugin, Predicate canEnable) {
+ super(plugin, canEnable);
+ }
+
+ @Override
+ protected void tryEnable() throws Exception {
+ vaultService = enableIntegration(new VaultServiceImpl(plugin, this), true);
+ storageService = enableIntegration(new SignManagerStorageServiceImpl(plugin, this), true);
+ signManagerService = enableIntegration(new SignManagerServiceImpl(plugin, this, storageService), true);
+ enableIntegration(new SignManagerCommandService(plugin, this, signManagerService), true);
+ }
+
+ @Override
+ protected void tryDisable() throws Exception {
+ disableIntegration(signManagerService);
+ disableIntegration(storageService);
+ disableIntegration(vaultService);
+ }
+}
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignEntryNameArgument.java b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignEntryNameArgument.java
new file mode 100644
index 0000000..511bbfc
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignEntryNameArgument.java
@@ -0,0 +1,44 @@
+package net.greenfieldmc.core.signmanager.arguments;
+
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.exceptions.DynamicCommandExceptionType;
+import com.njdaeger.pdk.command.brigadier.ICommandContext;
+import com.njdaeger.pdk.command.brigadier.arguments.AbstractStringTypedArgument;
+import net.greenfieldmc.core.signmanager.services.ISignManagerService;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class SignEntryNameArgument extends AbstractStringTypedArgument {
+
+ private static final DynamicCommandExceptionType ENTRY_NOT_FOUND = new DynamicCommandExceptionType(o -> () -> "Sign or group '" + o.toString() + "' not found");
+
+ private final ISignManagerService signManagerService;
+
+ public SignEntryNameArgument(ISignManagerService signManagerService) {
+ this.signManagerService = signManagerService;
+ }
+
+ @Override
+ public List listBasicSuggestions(ICommandContext commandContext) {
+ return signManagerService.getAllEntryNames();
+ }
+
+ @Override
+ public String convertToNative(String entryName) {
+ return entryName.toLowerCase();
+ }
+
+ @Override
+ public String convertToCustom(@Nullable CommandSender source, String nativeType, StringReader reader) throws CommandSyntaxException {
+ var names = signManagerService.getAllEntryNames();
+ var match = names.stream().filter(n -> n.equalsIgnoreCase(nativeType)).findFirst().orElse(null);
+ if (match == null) {
+ reader.setCursor(reader.getCursor() - nativeType.length());
+ throw ENTRY_NOT_FOUND.createWithContext(reader, nativeType);
+ }
+ return match;
+ }
+}
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignGroupNameArgument.java b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignGroupNameArgument.java
new file mode 100644
index 0000000..c494413
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignGroupNameArgument.java
@@ -0,0 +1,45 @@
+package net.greenfieldmc.core.signmanager.arguments;
+
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.exceptions.DynamicCommandExceptionType;
+import com.njdaeger.pdk.command.brigadier.ICommandContext;
+import com.njdaeger.pdk.command.brigadier.arguments.AbstractStringTypedArgument;
+import net.greenfieldmc.core.signmanager.services.ISignManagerService;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class SignGroupNameArgument extends AbstractStringTypedArgument {
+
+ private static final DynamicCommandExceptionType GROUP_NOT_FOUND = new DynamicCommandExceptionType(o -> () -> "Sign group '" + o.toString() + "' not found");
+
+ private final ISignManagerService signManagerService;
+
+ public SignGroupNameArgument(ISignManagerService signManagerService) {
+ this.signManagerService = signManagerService;
+ }
+
+ @Override
+ public List listBasicSuggestions(ICommandContext commandContext) {
+ return signManagerService.getGroupNames();
+ }
+
+ @Override
+ public String convertToNative(String groupName) {
+ return groupName.toLowerCase();
+ }
+
+ @Override
+ public String convertToCustom(@Nullable CommandSender source, String nativeType, StringReader reader) throws CommandSyntaxException {
+ var groups = signManagerService.getGroupNames();
+ var match = groups.stream().filter(g -> g.equalsIgnoreCase(nativeType)).findFirst().orElse(null);
+ if (match == null) {
+ reader.setCursor(reader.getCursor() - nativeType.length());
+ throw GROUP_NOT_FOUND.createWithContext(reader, nativeType);
+ }
+ return match;
+ }
+}
+
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignNameArgument.java b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignNameArgument.java
new file mode 100644
index 0000000..b74bca7
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/arguments/SignNameArgument.java
@@ -0,0 +1,45 @@
+package net.greenfieldmc.core.signmanager.arguments;
+
+import com.mojang.brigadier.StringReader;
+import com.mojang.brigadier.exceptions.CommandSyntaxException;
+import com.mojang.brigadier.exceptions.DynamicCommandExceptionType;
+import com.njdaeger.pdk.command.brigadier.ICommandContext;
+import com.njdaeger.pdk.command.brigadier.arguments.AbstractStringTypedArgument;
+import net.greenfieldmc.core.signmanager.SavedSign;
+import net.greenfieldmc.core.signmanager.services.ISignManagerService;
+import org.bukkit.command.CommandSender;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public class SignNameArgument extends AbstractStringTypedArgument {
+
+ private static final DynamicCommandExceptionType SIGN_NOT_FOUND = new DynamicCommandExceptionType(o -> () -> "Sign '" + o.toString() + "' not found");
+
+ private final ISignManagerService signManagerService;
+
+ public SignNameArgument(ISignManagerService signManagerService) {
+ this.signManagerService = signManagerService;
+ }
+
+ @Override
+ public List listBasicSuggestions(ICommandContext commandContext) {
+ return signManagerService.getAllSigns();
+ }
+
+ @Override
+ public String convertToNative(SavedSign sign) {
+ return sign.getName().toLowerCase();
+ }
+
+ @Override
+ public SavedSign convertToCustom(@Nullable CommandSender source, String nativeType, StringReader reader) throws CommandSyntaxException {
+ var sign = signManagerService.getSign(nativeType);
+ if (sign == null) {
+ reader.setCursor(reader.getCursor() - nativeType.length());
+ throw SIGN_NOT_FOUND.createWithContext(reader, nativeType);
+ }
+ return sign;
+ }
+}
+
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/paginators/SignManagerPaginator.java b/src/main/java/net/greenfieldmc/core/signmanager/paginators/SignManagerPaginator.java
new file mode 100644
index 0000000..4629d2b
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/paginators/SignManagerPaginator.java
@@ -0,0 +1,62 @@
+package net.greenfieldmc.core.signmanager.paginators;
+
+import com.njdaeger.pdk.command.brigadier.ICommandContext;
+import com.njdaeger.pdk.utils.text.pager.ChatPaginatorBuilder;
+import com.njdaeger.pdk.utils.text.pager.ComponentPosition;
+import com.njdaeger.pdk.utils.text.pager.LineWrappingMode;
+import com.njdaeger.pdk.utils.text.pager.components.PageNavigationComponent;
+import com.njdaeger.pdk.utils.text.pager.components.ResultCountComponent;
+import net.greenfieldmc.core.signmanager.SignManagerEntry;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.event.ClickEvent;
+import net.kyori.adventure.text.event.HoverEvent;
+import net.kyori.adventure.text.format.NamedTextColor;
+
+public class SignManagerPaginator extends ChatPaginatorBuilder {
+
+ public SignManagerPaginator() {
+ super();
+
+ setLineWrappingMode(LineWrappingMode.ELLIPSIS);
+ setResultsPerPage(8);
+
+ // Title
+ addComponent(Component.text("Sign Manager", NamedTextColor.LIGHT_PURPLE), ComponentPosition.TOP_CENTER);
+
+ // Result count
+ addComponent(new ResultCountComponent<>(true), ComponentPosition.TOP_LEFT);
+
+ // Search query display in top right
+ addComponent((ctx, paginator, results, page) -> {
+ var query = ctx.getTyped("query", String.class, "");
+ if (query == null || query.isBlank()) {
+ return Component.text("No filter", NamedTextColor.GRAY);
+ }
+ return Component.text("Search: ", NamedTextColor.GRAY)
+ .append(Component.text(query, NamedTextColor.GOLD));
+ }, ComponentPosition.TOP_RIGHT);
+
+ // Page navigation
+ addComponent(new PageNavigationComponent<>(
+ (ctx, res, pg) -> buildPageCommand(ctx, 1),
+ (ctx, res, pg) -> buildPageCommand(ctx, pg - 1),
+ (ctx, res, pg) -> buildPageCommand(ctx, pg + 1),
+ (ctx, res, pg) -> buildPageCommand(ctx, (int) Math.ceil(res.size() / 8.0))
+ ), ComponentPosition.BOTTOM_CENTER);
+
+ // Search button at bottom right
+ addComponent((ctx, paginator, results, pg) -> Component.text("[☀]", paginator.getHighlightColor())
+ .hoverEvent(HoverEvent.showText(Component.text("Search for a sign", NamedTextColor.GRAY)))
+ .clickEvent(ClickEvent.suggestCommand("/sm search "))
+ , ComponentPosition.BOTTOM_RIGHT);
+ }
+
+ private static String buildPageCommand(ICommandContext ctx, int page) {
+ var query = ctx.getTyped("query", String.class, "");
+ if (query == null || query.isBlank()) {
+ return "/sm page " + page;
+ }
+ return "/sm search " + query + " -page " + page;
+ }
+}
+
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java
new file mode 100644
index 0000000..50119b5
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerService.java
@@ -0,0 +1,124 @@
+package net.greenfieldmc.core.signmanager.services;
+
+import net.greenfieldmc.core.IModuleService;
+import net.greenfieldmc.core.signmanager.SavedSign;
+import net.greenfieldmc.core.signmanager.SavedSignGroup;
+import net.greenfieldmc.core.signmanager.SignManagerEntry;
+import org.bukkit.entity.Player;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+
+public interface ISignManagerService extends IModuleService {
+
+ /**
+ * Save a sign from the player's main hand.
+ * @param player The player holding the sign.
+ * @param name The name to save the sign under.
+ * @return The saved sign, or null if the player is not holding a sign.
+ */
+ @Nullable SavedSign saveSignFromHand(Player player, String name);
+
+ /**
+ * Save all sign items from the player's hotbar as a group.
+ * The main hand item is used as the display sign.
+ * @param player The player whose hotbar to scan.
+ * @param groupName The group name.
+ * @return The saved group, or null if no signs found in hotbar or main hand is not a sign.
+ */
+ @Nullable SavedSignGroup saveGroupFromHotbar(Player player, String groupName);
+
+ /**
+ * Save a sign to the database.
+ * @param sign The sign to save.
+ */
+ void saveSign(SavedSign sign);
+
+ /**
+ * Rename a saved sign.
+ * @param oldName The current name of the sign.
+ * @param newName The new name for the sign.
+ * @return true if the sign was found and renamed.
+ */
+ boolean renameSign(String oldName, String newName);
+
+ /**
+ * Delete a saved sign or group by name. Tries sign first, then group.
+ * @param name The name of the entry.
+ * @return true if the entry was found and deleted.
+ */
+ boolean deleteEntry(String name);
+
+ /**
+ * Delete a saved sign by name.
+ * @param name The name of the sign.
+ * @return true if the sign was found and deleted.
+ */
+ boolean deleteSign(String name);
+
+ /**
+ * Delete a saved sign group by name.
+ * @param groupName The group name.
+ * @return true if the group was found and deleted.
+ */
+ boolean deleteGroup(String groupName);
+
+ /**
+ * Get a saved sign by name.
+ * @param name The name of the sign.
+ * @return The saved sign, or null if not found.
+ */
+ @Nullable SavedSign getSign(String name);
+
+ /**
+ * Check if a name is already taken by a sign or group.
+ * @param name The name to check.
+ * @return true if the name is in use.
+ */
+ boolean nameExists(String name);
+
+ /**
+ * Get all GUI entries (individual signs + groups as single entries), sorted alphabetically.
+ * @return A list of all entries.
+ */
+ List getAllEntries();
+
+ /**
+ * Fuzzy search entries by name and content.
+ * @param query The search query.
+ * @return A list of matching entries.
+ */
+ List searchEntries(String query);
+
+ /**
+ * Give a player all sign items for an entry by name.
+ * @param player The player to give signs to.
+ * @param entryName The name of the sign or group entry.
+ * @return The entry that was given, or null if not found.
+ */
+ @Nullable SignManagerEntry giveEntry(Player player, String entryName);
+
+ /**
+ * Get all distinct group names.
+ * @return A list of group names.
+ */
+ List getGroupNames();
+
+ /**
+ * Get all individual saved signs (not groups).
+ * @return A list of all individual saved signs.
+ */
+ List getAllSigns();
+
+ /**
+ * Get all sign names (individual signs only, not groups).
+ * @return A list of sign names.
+ */
+ List getSignNames();
+
+ /**
+ * Get all entry names (signs + groups).
+ * @return A list of all entry names.
+ */
+ List getAllEntryNames();
+}
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java
new file mode 100644
index 0000000..57478fa
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/services/ISignManagerStorageService.java
@@ -0,0 +1,92 @@
+package net.greenfieldmc.core.signmanager.services;
+
+import net.greenfieldmc.core.IModuleService;
+import net.greenfieldmc.core.signmanager.SavedSign;
+import net.greenfieldmc.core.signmanager.SavedSignGroup;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.function.Predicate;
+
+public interface ISignManagerStorageService extends IModuleService {
+
+ /**
+ * Get a saved sign by name.
+ * @param name The name of the sign.
+ * @return The saved sign, or null if not found.
+ */
+ @Nullable SavedSign getSign(String name);
+
+ /**
+ * Get all saved signs matching a filter.
+ * @param filter The filter predicate.
+ * @return A list of matching saved signs.
+ */
+ List getSigns(Predicate filter);
+
+ /**
+ * Get all saved signs.
+ * @return A list of all saved signs.
+ */
+ default List getSigns() {
+ return getSigns(sign -> true);
+ }
+
+ /**
+ * Save a sign to the database.
+ * @param sign The sign to save.
+ */
+ void saveSign(SavedSign sign);
+
+
+ /**
+ * Delete a sign by name.
+ * @param name The name of the sign to delete.
+ */
+ void deleteSign(String name);
+
+ /**
+ * Get a saved sign group by name.
+ * @param name The name of the group.
+ * @return The saved sign group, or null if not found.
+ */
+ @Nullable SavedSignGroup getGroup(String name);
+
+ /**
+ * Get all saved sign groups matching a filter.
+ * @param filter The filter predicate.
+ * @return A list of matching saved sign groups.
+ */
+ List getGroups(Predicate filter);
+
+ /**
+ * Get all saved sign groups.
+ * @return A list of all saved sign groups.
+ */
+ default List getGroups() {
+ return getGroups(group -> true);
+ }
+
+ /**
+ * Save a sign group to the database.
+ * @param group The sign group to save.
+ */
+ void saveGroup(SavedSignGroup group);
+
+ /**
+ * Delete a sign group by name.
+ * @param name The name of the group to delete.
+ */
+ void deleteGroup(String name);
+
+ /**
+ * Get all distinct group names.
+ * @return A list of unique group names.
+ */
+ List getGroupNames();
+
+ /**
+ * Persist all changes to disk.
+ */
+ void saveDatabase();
+}
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java
new file mode 100644
index 0000000..a308e17
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerCommandService.java
@@ -0,0 +1,168 @@
+package net.greenfieldmc.core.signmanager.services;
+
+import com.njdaeger.pdk.command.brigadier.ICommandContext;
+import com.njdaeger.pdk.command.brigadier.builder.CommandBuilder;
+import com.njdaeger.pdk.command.brigadier.builder.PdkArgumentTypes;
+import com.njdaeger.pdk.command.exception.PDKCommandException;
+import com.njdaeger.pdk.utils.text.pager.ChatPaginator;
+import net.greenfieldmc.core.IModuleService;
+import net.greenfieldmc.core.Module;
+import net.greenfieldmc.core.ModuleService;
+import net.greenfieldmc.core.signmanager.SavedSign;
+import net.greenfieldmc.core.signmanager.SignManagerEntry;
+import net.greenfieldmc.core.signmanager.SignManagerMessages;
+import net.greenfieldmc.core.signmanager.arguments.SignEntryNameArgument;
+import net.greenfieldmc.core.signmanager.arguments.SignNameArgument;
+import net.greenfieldmc.core.signmanager.paginators.SignManagerPaginator;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import org.bukkit.plugin.Plugin;
+
+import java.util.stream.IntStream;
+
+public class SignManagerCommandService extends ModuleService implements IModuleService {
+
+ private final ISignManagerService signManagerService;
+ private final ChatPaginator paginator = new SignManagerPaginator().build();
+
+ public SignManagerCommandService(Plugin plugin, Module module, ISignManagerService signManagerService) {
+ super(plugin, module);
+ this.signManagerService = signManagerService;
+ }
+
+ // === User commands ===
+
+ private void listSigns(ICommandContext ctx) throws PDKCommandException {
+ var entries = signManagerService.getAllEntries();
+ if (entries.isEmpty()) ctx.error("There are no saved signs to display.");
+ int page = ctx.getTyped("pageNumber", 1);
+ paginator.generatePage(ctx, entries, page).sendTo(ctx.getSender());
+ }
+
+ private void searchSigns(ICommandContext ctx) throws PDKCommandException {
+ var query = ctx.getTyped("query", String.class);
+ var entries = signManagerService.searchEntries(query);
+ if (entries.isEmpty()) ctx.error("No signs found matching \"" + query + "\".");
+ int page = ctx.getFlag("page", 1);
+ paginator.generatePage(ctx, entries, page).sendTo(ctx.getSender());
+ }
+
+ private void giveSigns(ICommandContext ctx) throws PDKCommandException {
+ var player = ctx.asPlayer();
+ var entryName = ctx.getTyped("entryName", String.class);
+ var entry = signManagerService.giveEntry(player, entryName);
+ if (entry == null) {
+ ctx.error(SignManagerMessages.ERROR_ENTRY_NOT_FOUND);
+ return;
+ }
+ if (entry.isGroup()) {
+ ctx.send(SignManagerMessages.MODULE.append(
+ Component.text("Gave you " + entry.getSignCount() + " sign(s) from group \"" + entry.getName() + "\".", NamedTextColor.GRAY)));
+ } else {
+ ctx.send(SignManagerMessages.MODULE.append(
+ Component.text("Gave you sign \"" + entry.getName() + "\".", NamedTextColor.GRAY)));
+ }
+ }
+
+ // === Staff commands ===
+ private void saveEntry(ICommandContext ctx) throws PDKCommandException {
+ var player = ctx.asPlayer();
+ var signName = ctx.getTyped("name", String.class);
+
+ if (signManagerService.nameExists(signName)) {
+ ctx.error(SignManagerMessages.ERROR_SIGN_ALREADY_EXISTS);
+ return;
+ }
+
+ var sign = signManagerService.saveSignFromHand(player, signName);
+ if (sign == null) {
+ ctx.error(SignManagerMessages.ERROR_NOT_HOLDING_SIGN);
+ return;
+ }
+ ctx.send(SignManagerMessages.SIGN_SAVED.apply(signName));
+ }
+
+ private void saveGroupEntry(ICommandContext ctx) throws PDKCommandException {
+ var player = ctx.asPlayer();
+ var groupName = ctx.getTyped("name", String.class);
+
+ if (signManagerService.nameExists(groupName)) {
+ ctx.error(SignManagerMessages.ERROR_SIGN_ALREADY_EXISTS);
+ return;
+ }
+
+ var group = signManagerService.saveGroupFromHotbar(player, groupName);
+ if (group == null) {
+ ctx.error(SignManagerMessages.ERROR_NO_SIGNS_IN_HOTBAR);
+ return;
+ }
+ ctx.send(SignManagerMessages.GROUP_SAVED.apply(groupName));
+ ctx.send(SignManagerMessages.GROUP_SAVED_COUNT.apply(group.getMemberSigns().size()));
+ }
+
+ private void renameSign(ICommandContext ctx) throws PDKCommandException {
+ var savedSign = ctx.getTyped("signName", SavedSign.class);
+ var newName = ctx.getTyped("newName", String.class);
+
+ if (signManagerService.nameExists(newName)) {
+ ctx.error(SignManagerMessages.ERROR_SIGN_ALREADY_EXISTS);
+ return;
+ }
+
+ var oldName = savedSign.getName();
+ signManagerService.renameSign(oldName, newName);
+ ctx.send(SignManagerMessages.SIGN_RENAMED.apply(oldName, newName));
+ }
+
+ /**
+ * Unified delete command. Deletes a sign or group by name.
+ */
+ private void deleteEntry(ICommandContext ctx) throws PDKCommandException {
+ var entryName = ctx.getTyped("entryName", String.class);
+ boolean deleted = signManagerService.deleteEntry(entryName);
+ if (!deleted) {
+ ctx.error(SignManagerMessages.ERROR_ENTRY_NOT_FOUND);
+ return;
+ }
+ ctx.send(SignManagerMessages.SIGN_DELETED.apply(entryName));
+ }
+
+ @Override
+ public void tryEnable(Plugin plugin, Module module) throws Exception {
+ CommandBuilder.of("sm", "signmanager")
+ .description("Sign Manager - Save, search, and browse signs")
+ .permission("greenfieldcore.signmanager.use")
+ .defaultExecutor(this::listSigns)
+ .canExecute()
+ // User commands
+ .then("page")
+ .then("pageNumber", PdkArgumentTypes.integer(ctx -> IntStream.rangeClosed(1, (int) Math.ceil(signManagerService.getAllEntries().size() / 8.0)).boxed().toList(), () -> "Page number")).executes(this::listSigns)
+ .end()
+ .then("search")
+ .then("query", PdkArgumentTypes.greedyString()).executes(this::searchSigns)
+ .end()
+ .then("give")
+ .then("entryName", new SignEntryNameArgument(signManagerService)).executes(this::giveSigns)
+ .end()
+ // Staff commands
+ .then("save").permission("greenfieldcore.signmanager.manage")
+ .then("-group") // Group save flag
+ .then("name", PdkArgumentTypes.string()).executes(this::saveGroupEntry)
+ .end()
+ .then("name", PdkArgumentTypes.string()).executes(this::saveEntry)
+ .end()
+ .then("rename").permission("greenfieldcore.signmanager.manage")
+ .then("signName", new SignNameArgument(signManagerService))
+ .then("newName", PdkArgumentTypes.string()).executes(this::renameSign)
+ .end()
+ .end()
+ .then("delete").permission("greenfieldcore.signmanager.manage")
+ .then("entryName", new SignEntryNameArgument(signManagerService)).executes(this::deleteEntry)
+ .end()
+ .register(plugin);
+ }
+
+ @Override
+ public void tryDisable(Plugin plugin, Module module) throws Exception {
+ }
+}
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java
new file mode 100644
index 0000000..b6aeb86
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerServiceImpl.java
@@ -0,0 +1,190 @@
+package net.greenfieldmc.core.signmanager.services;
+
+import net.greenfieldmc.core.Module;
+import net.greenfieldmc.core.ModuleService;
+import net.greenfieldmc.core.signmanager.SavedSign;
+import net.greenfieldmc.core.signmanager.SavedSignGroup;
+import net.greenfieldmc.core.signmanager.SignManagerEntry;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.Plugin;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+public class SignManagerServiceImpl extends ModuleService implements ISignManagerService {
+
+ private final ISignManagerStorageService storageService;
+
+ public SignManagerServiceImpl(Plugin plugin, Module module, ISignManagerStorageService storageService) {
+ super(plugin, module);
+ this.storageService = storageService;
+ }
+
+ @Override
+ public void tryEnable(Plugin plugin, Module module) throws Exception {
+ }
+
+ @Override
+ public void tryDisable(Plugin plugin, Module module) throws Exception {
+ }
+
+ @Override
+ public @Nullable SavedSign saveSignFromHand(Player player, String name) {
+ var item = player.getInventory().getItemInMainHand();
+ if (!SavedSign.isSignMaterial(item.getType())) return null;
+
+ var sign = SavedSign.fromItemStack(item, name);
+ if (sign == null) return null;
+
+ storageService.saveSign(sign);
+ storageService.saveDatabase();
+ return sign;
+ }
+
+ @Override
+ public @Nullable SavedSignGroup saveGroupFromHotbar(Player player, String groupName) {
+ var mainHandItem = player.getInventory().getItemInMainHand();
+ if (!SavedSign.isSignMaterial(mainHandItem.getType())) return null;
+
+ var displaySign = SavedSign.fromItemStack(mainHandItem, groupName + "_display");
+ if (displaySign == null) return null;
+
+ var memberSigns = new ArrayList();
+ int signCount = 0;
+ for (int slot = 0; slot < 9; slot++) {
+ var item = player.getInventory().getItem(slot);
+ if (item == null || !SavedSign.isSignMaterial(item.getType())) continue;
+
+ signCount++;
+ var memberSign = SavedSign.fromItemStack(item, groupName + "_" + signCount);
+ if (memberSign != null) memberSigns.add(memberSign);
+ }
+
+ if (memberSigns.isEmpty()) return null;
+
+ var group = new SavedSignGroup(groupName, displaySign, memberSigns);
+ storageService.saveGroup(group);
+ storageService.saveDatabase();
+ return group;
+ }
+
+ @Override
+ public void saveSign(SavedSign sign) {
+ storageService.saveSign(sign);
+ storageService.saveDatabase();
+ }
+
+ @Override
+ public boolean renameSign(String oldName, String newName) {
+ var sign = storageService.getSign(oldName);
+ if (sign == null) return false;
+ storageService.deleteSign(oldName);
+ sign.setName(newName);
+ storageService.saveSign(sign);
+ storageService.saveDatabase();
+ return true;
+ }
+
+ @Override
+ public boolean deleteEntry(String name) {
+ if (deleteSign(name)) return true;
+ return deleteGroup(name);
+ }
+
+ @Override
+ public boolean deleteSign(String name) {
+ var sign = storageService.getSign(name);
+ if (sign == null) return false;
+ storageService.deleteSign(name);
+ storageService.saveDatabase();
+ return true;
+ }
+
+ @Override
+ public boolean deleteGroup(String groupName) {
+ var group = storageService.getGroup(groupName);
+ if (group == null) return false;
+ storageService.deleteGroup(groupName);
+ storageService.saveDatabase();
+ return true;
+ }
+
+ @Override
+ public @Nullable SavedSign getSign(String name) {
+ return storageService.getSign(name);
+ }
+
+ @Override
+ public boolean nameExists(String name) {
+ return storageService.getSign(name) != null || storageService.getGroup(name) != null;
+ }
+
+ @Override
+ public List getAllEntries() {
+ var entries = new ArrayList();
+ storageService.getSigns().forEach(sign -> entries.add(SignManagerEntry.ofSign(sign)));
+ storageService.getGroups().forEach(group -> entries.add(SignManagerEntry.ofGroup(group)));
+ entries.sort(Comparator.comparing(entry -> entry.getName().toLowerCase()));
+ return entries;
+ }
+
+ @Override
+ public List searchEntries(String query) {
+ if (query == null || query.isBlank()) return getAllEntries();
+ var lowerQuery = query.toLowerCase();
+ return getAllEntries().stream()
+ .filter(entry -> entry.getPlainText().toLowerCase().contains(lowerQuery))
+ .toList();
+ }
+
+ @Override
+ public @Nullable SignManagerEntry giveEntry(Player player, String entryName) {
+ // Check individual signs first
+ var sign = storageService.getSign(entryName);
+ if (sign != null) {
+ var entry = SignManagerEntry.ofSign(sign);
+ for (var item : entry.toGiveItemStacks()) {
+ player.getInventory().addItem(item);
+ }
+ return entry;
+ }
+
+ // Check groups
+ var group = storageService.getGroup(entryName);
+ if (group != null) {
+ var entry = SignManagerEntry.ofGroup(group);
+ for (var item : entry.toGiveItemStacks()) {
+ player.getInventory().addItem(item);
+ }
+ return entry;
+ }
+
+ return null;
+ }
+
+ @Override
+ public List getGroupNames() {
+ return storageService.getGroupNames();
+ }
+
+ @Override
+ public List getAllSigns() {
+ return storageService.getSigns();
+ }
+
+ @Override
+ public List getSignNames() {
+ return storageService.getSigns().stream().map(SavedSign::getName).toList();
+ }
+
+ @Override
+ public List getAllEntryNames() {
+ var names = new ArrayList();
+ storageService.getSigns().forEach(sign -> names.add(sign.getName()));
+ storageService.getGroups().forEach(group -> names.add(group.getName()));
+ names.sort(String.CASE_INSENSITIVE_ORDER);
+ return names;
+ }
+}
diff --git a/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java
new file mode 100644
index 0000000..545082d
--- /dev/null
+++ b/src/main/java/net/greenfieldmc/core/signmanager/services/SignManagerStorageServiceImpl.java
@@ -0,0 +1,162 @@
+package net.greenfieldmc.core.signmanager.services;
+
+import com.njdaeger.pdk.config.ConfigType;
+import com.njdaeger.pdk.config.IConfig;
+import net.greenfieldmc.core.Module;
+import net.greenfieldmc.core.ModuleService;
+import net.greenfieldmc.core.signmanager.SavedSign;
+import net.greenfieldmc.core.signmanager.SavedSignGroup;
+import org.bukkit.Material;
+import org.bukkit.plugin.Plugin;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+
+public class SignManagerStorageServiceImpl extends ModuleService implements ISignManagerStorageService {
+
+ private final Map signs = new HashMap<>();
+ private final Map groups = new HashMap<>();
+ private IConfig config;
+
+ public SignManagerStorageServiceImpl(Plugin plugin, Module module) {
+ super(plugin, module);
+ }
+
+ @Override
+ public void tryEnable(Plugin plugin, Module module) throws Exception {
+ try {
+ this.config = ConfigType.YML.createNew(plugin, "signmanager");
+
+ // Load individual signs
+ if (config.hasSection("signs")) {
+ for (var signName : config.getSection("signs").getKeys(false)) {
+ var sign = loadSign("signs." + signName, signName);
+ if (sign != null) signs.put(signName.toLowerCase(), sign);
+ }
+ }
+
+ // Load groups
+ if (config.hasSection("groups")) {
+ for (var groupName : config.getSection("groups").getKeys(false)) {
+ // Load display sign
+ var displaySign = loadSign("groups." + groupName + ".displaySign", groupName + "_display");
+ if (displaySign == null) {
+ plugin.getLogger().warning("SignManager: Group '" + groupName + "' has invalid display sign. Skipping.");
+ continue;
+ }
+
+ // Load member signs
+ var memberSigns = new ArrayList();
+ var section = config.getSection("groups." + groupName);
+ if (section.contains("members")) {
+ for (var memberKey : section.getSection("members").getKeys(false)) {
+ var memberSign = loadSign("groups." + groupName + ".members." + memberKey, memberKey);
+ if (memberSign != null) memberSigns.add(memberSign);
+ }
+ }
+
+ groups.put(groupName.toLowerCase(), new SavedSignGroup(groupName, displaySign, memberSigns));
+ }
+ }
+ } catch (Exception e) {
+ throw new Exception("Failed to enable SignManagerStorageService", e);
+ }
+ }
+
+ private @Nullable SavedSign loadSign(String path, String name) {
+ var section = config.getSection(path);
+ if (section == null) return null;
+ var material = Material.matchMaterial(section.getString("material"));
+ if (material == null) return null;
+ var frontLines = section.getStringList("frontLines");
+ var backLines = section.getStringList("backLines");
+
+ while (frontLines.size() < 4) frontLines.add("\"\"");
+ while (backLines.size() < 4) backLines.add("\"\"");
+
+ return new SavedSign(name, material, frontLines, backLines);
+ }
+
+ private void persistSign(String path, SavedSign sign) {
+ config.setEntry(path + ".material", sign.getSignMaterial().name());
+ config.setEntry(path + ".frontLines", sign.getFrontLines());
+ config.setEntry(path + ".backLines", sign.getBackLines());
+ }
+
+ @Override
+ public void tryDisable(Plugin plugin, Module module) throws Exception {
+ saveDatabase();
+ }
+
+ @Override
+ public @Nullable SavedSign getSign(String name) {
+ return signs.get(name.toLowerCase());
+ }
+
+ @Override
+ public List getSigns(Predicate filter) {
+ return new ArrayList<>(signs.values()).stream().filter(filter).toList();
+ }
+
+ @Override
+ public void saveSign(SavedSign sign) {
+ signs.put(sign.getName().toLowerCase(), sign);
+ persistSign("signs." + sign.getName().toLowerCase(), sign);
+ }
+
+
+ @Override
+ public void deleteSign(String name) {
+ var removed = signs.remove(name.toLowerCase());
+ if (removed != null) {
+ config.setEntry("signs." + name.toLowerCase(), null);
+ }
+ }
+
+ @Override
+ public @Nullable SavedSignGroup getGroup(String name) {
+ return groups.get(name.toLowerCase());
+ }
+
+ @Override
+ public List getGroups(Predicate filter) {
+ return new ArrayList<>(groups.values()).stream().filter(filter).toList();
+ }
+
+ @Override
+ public void saveGroup(SavedSignGroup group) {
+ groups.put(group.getName().toLowerCase(), group);
+ var basePath = "groups." + group.getName().toLowerCase();
+ persistSign(basePath + ".displaySign", group.getDisplaySign());
+ // Clear old members and rewrite
+ config.setEntry(basePath + ".members", null);
+ var memberSigns = group.getMemberSigns();
+ for (int i = 0; i < memberSigns.size(); i++) {
+ persistSign(basePath + ".members.sign_" + i, memberSigns.get(i));
+ }
+ }
+
+ @Override
+ public void deleteGroup(String name) {
+ var removed = groups.remove(name.toLowerCase());
+ if (removed != null) {
+ config.setEntry("groups." + name.toLowerCase(), null);
+ }
+ }
+
+ @Override
+ public List getGroupNames() {
+ return new ArrayList<>(groups.keySet());
+ }
+
+ @Override
+ public void saveDatabase() {
+ signs.values().forEach(this::saveSign);
+ groups.values().forEach(this::saveGroup);
+ config.save();
+ }
+}
diff --git a/src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java b/src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java
index 2c9756c..cde09a9 100644
--- a/src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java
+++ b/src/main/java/net/greenfieldmc/core/testresult/TestAttempt.java
@@ -96,7 +96,7 @@ public void setHasChanged(boolean hasChanged) {
@Override
public String getPlainItemText(ChatPaginator, ICommandContext> paginator, ICommandContext generatorInfo) {
- return "Attempt #" + attemptNumber + " - Successful: " + successful;
+ return attemptNotes;
}
@Override
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
index c34f869..5b2dfb2 100644
--- a/src/main/resources/plugin.yml
+++ b/src/main/resources/plugin.yml
@@ -10,4 +10,6 @@ softdepend:
- Vault
- dynmap
- Essentials
+ - EssentialsChat
+ - LuckPerms
- WorldEdit
\ No newline at end of file