mirror of
https://github.com/BenCodez/VotingPlugin.git
synced 2026-11-12 20:22:04 -05:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d845237f1f |
No files matched your search
@@ -2414,6 +2414,17 @@ public class VotingPluginMain extends AdvancedCorePlugin {
|
||||
}
|
||||
}
|
||||
|
||||
// Export documentation only after both handler lists and the player command label are ready.
|
||||
// A filesystem problem must never stop registration or normal voting.
|
||||
try {
|
||||
if (com.bencodez.votingplugin.commands.CommandDocumentationExporter.write(getDataFolder().toPath(),
|
||||
getVoteCommandPrefix(), getVoteCommand(), getAdminVoteCommand())) {
|
||||
getLogger().info("Updated generated command documentation in GeneratedDocs/");
|
||||
}
|
||||
} catch (IOException | RuntimeException error) {
|
||||
getLogger().warning("Unable to write generated command documentation: " + error.getMessage());
|
||||
}
|
||||
|
||||
plugin.debug("Loaded Commands");
|
||||
|
||||
}
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
package com.bencodez.votingplugin.commands;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.advancedcore.api.command.PlayerCommandHandler;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
/**
|
||||
* Exports the actual Bukkit command-handler registrations as a deterministic
|
||||
* machine-readable manifest and an administrator-friendly Markdown reference.
|
||||
* It never executes a command or accesses user data.
|
||||
*/
|
||||
public final class CommandDocumentationExporter {
|
||||
public static final String DIRECTORY = "GeneratedDocs";
|
||||
public static final String JSON_NAME = "commands.json";
|
||||
public static final String MARKDOWN_NAME = "commands.md";
|
||||
|
||||
private CommandDocumentationExporter() {
|
||||
}
|
||||
|
||||
public static final class CommandRow {
|
||||
private final String root;
|
||||
private final String syntax;
|
||||
private final String description;
|
||||
private final String permissionExpression;
|
||||
private final List<String> permissions;
|
||||
private final List<String> allTargetPermissions;
|
||||
private final boolean multiplePermissionChecks;
|
||||
private final String sender;
|
||||
|
||||
private CommandRow(String root, String syntax, String description, String permissionExpression,
|
||||
List<String> permissions, List<String> allTargetPermissions,
|
||||
boolean multiplePermissionChecks, String sender) {
|
||||
this.root = root;
|
||||
this.syntax = syntax;
|
||||
this.description = description;
|
||||
this.permissionExpression = permissionExpression;
|
||||
this.permissions = List.copyOf(permissions);
|
||||
this.allTargetPermissions = List.copyOf(allTargetPermissions);
|
||||
this.multiplePermissionChecks = multiplePermissionChecks;
|
||||
this.sender = sender;
|
||||
}
|
||||
}
|
||||
|
||||
/** Capture only loaded handlers, including conditionally registered handlers. */
|
||||
public static List<CommandRow> capture(String playerRoot, List<? extends CommandHandler> playerHandlers,
|
||||
List<? extends CommandHandler> adminHandlers) {
|
||||
List<CommandRow> result = new ArrayList<>();
|
||||
result.addAll(captureGroup(playerRoot, playerHandlers));
|
||||
result.addAll(captureGroup("/av", adminHandlers));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static List<CommandRow> captureGroup(String root, List<? extends CommandHandler> handlers) {
|
||||
List<CommandRow> result = new ArrayList<>();
|
||||
if (handlers == null) return result;
|
||||
for (CommandHandler handler : handlers) {
|
||||
if (handler == null) continue;
|
||||
String syntax = safe(handler.getHelpLineCommand(root));
|
||||
if (syntax.isEmpty()) continue;
|
||||
String permission = safe(handler.getPerm());
|
||||
List<String> allTarget = List.of();
|
||||
if (handler instanceof PlayerCommandHandler) {
|
||||
List<String> additional = ((PlayerCommandHandler) handler).getAdditionalPermissions();
|
||||
if (additional != null) allTarget = new ArrayList<>(new LinkedHashSet<>(additional));
|
||||
}
|
||||
String sender = handler.isForceConsole() ? "console" :
|
||||
handler.isAllowConsole() ? "player or console" : "player";
|
||||
result.add(new CommandRow(root, syntax, safe(handler.getHelpMessage()), permission,
|
||||
splitPermissions(permission), allTarget, handler.isAllowMultiplePermissions(), sender));
|
||||
}
|
||||
result.sort(Comparator.comparing((CommandRow row) -> row.syntax, String.CASE_INSENSITIVE_ORDER)
|
||||
.thenComparing(row -> row.permissionExpression, String.CASE_INSENSITIVE_ORDER));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String safe(String text) {
|
||||
return text == null ? "" : text.trim();
|
||||
}
|
||||
|
||||
private static List<String> splitPermissions(String expression) {
|
||||
Set<String> unique = new LinkedHashSet<>();
|
||||
for (String part : expression.split("\\|")) {
|
||||
String value = part.trim();
|
||||
if (!value.isEmpty()) unique.add(value);
|
||||
}
|
||||
return new ArrayList<>(unique);
|
||||
}
|
||||
|
||||
/** Stable JSON for future documentation bots; it contains no credentials or player data. */
|
||||
public static String toJson(List<CommandRow> rows) {
|
||||
JsonObject doc = new JsonObject();
|
||||
doc.addProperty("schemaVersion", 1);
|
||||
doc.addProperty("scope", "VotingPlugin Bukkit registered handlers");
|
||||
JsonArray commands = new JsonArray();
|
||||
for (CommandRow row : rows) {
|
||||
JsonObject object = new JsonObject();
|
||||
object.addProperty("root", row.root);
|
||||
object.addProperty("syntax", row.syntax);
|
||||
object.addProperty("description", row.description);
|
||||
object.addProperty("permissionExpression", row.permissionExpression);
|
||||
object.add("permissions", asArray(row.permissions));
|
||||
object.add("allTargetPermissions", asArray(row.allTargetPermissions));
|
||||
object.addProperty("multiplePermissionChecks", row.multiplePermissionChecks);
|
||||
object.addProperty("sender", row.sender);
|
||||
commands.add(object);
|
||||
}
|
||||
doc.add("commands", commands);
|
||||
return new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create().toJson(doc) + "\n";
|
||||
}
|
||||
|
||||
private static JsonArray asArray(List<String> values) {
|
||||
JsonArray array = new JsonArray();
|
||||
for (String value : values) array.add(value);
|
||||
return array;
|
||||
}
|
||||
|
||||
/** Reader-friendly generated snapshot for reviewing changes without running code. */
|
||||
public static String toMarkdown(List<CommandRow> rows) {
|
||||
StringBuilder markdown = new StringBuilder();
|
||||
markdown.append("# VotingPlugin Bukkit commands (generated)\n\n");
|
||||
markdown.append("This page is generated from the command handlers loaded by the running server. ");
|
||||
markdown.append("It is not a canonical release reference: configured command labels and ");
|
||||
markdown.append("conditionally loaded commands may differ between servers.\n\n");
|
||||
markdown.append("Permission expressions separated by | may provide alternatives when ");
|
||||
markdown.append("multiple-permission checks are enabled. Extra all-target permissions ");
|
||||
markdown.append("apply only to supported bulk player commands.\n\n");
|
||||
markdown.append("This export does not cover separate Velocity/BungeeCord proxy commands ");
|
||||
markdown.append("or standalone plugin.yml convenience aliases.\n\n");
|
||||
|
||||
Map<String, List<CommandRow>> groups = new LinkedHashMap<>();
|
||||
for (CommandRow row : rows) groups.computeIfAbsent(row.root, key -> new ArrayList<>()).add(row);
|
||||
for (Map.Entry<String, List<CommandRow>> group : groups.entrySet()) {
|
||||
markdown.append("## ").append(escapeCell(group.getKey())).append("\n\n");
|
||||
markdown.append("| Command | Description | Permission expression | All-target permissions | Sender |\n");
|
||||
markdown.append("|---|---|---|---|---|\n");
|
||||
for (CommandRow row : group.getValue()) {
|
||||
markdown.append("| ").append(code(row.syntax));
|
||||
markdown.append(" | ").append(escapeCell(row.description));
|
||||
markdown.append(" | ").append(code(row.permissionExpression.isEmpty() ? "(none)" : row.permissionExpression));
|
||||
markdown.append(" | ").append(row.allTargetPermissions.isEmpty() ? "—" :
|
||||
code(String.join(", ", row.allTargetPermissions)));
|
||||
markdown.append(" | ").append(escapeCell(row.sender)).append(" |\n");
|
||||
}
|
||||
markdown.append("\n");
|
||||
}
|
||||
return markdown.toString();
|
||||
}
|
||||
|
||||
private static String code(String value) {
|
||||
return String.valueOf((char) 96) + escapeCell(value) + (char) 96;
|
||||
}
|
||||
|
||||
private static String escapeCell(String value) {
|
||||
return safe(value).replace("\r", " ").replace("\n", " ").replace("|", "\\|")
|
||||
.replace(String.valueOf((char) 96), "`");
|
||||
}
|
||||
|
||||
/**
|
||||
* Write only when content changes, using temporary files and atomic moves
|
||||
* when supported. No writes occur outside the plugin data folder.
|
||||
*/
|
||||
public static boolean write(Path pluginDataFolder, String playerRoot,
|
||||
List<? extends CommandHandler> playerHandlers, List<? extends CommandHandler> adminHandlers)
|
||||
throws IOException {
|
||||
List<CommandRow> rows = capture(playerRoot, playerHandlers, adminHandlers);
|
||||
Path directory = pluginDataFolder.resolve(DIRECTORY);
|
||||
Files.createDirectories(directory);
|
||||
boolean jsonChanged = writeIfChanged(directory.resolve(JSON_NAME), toJson(rows));
|
||||
boolean markdownChanged = writeIfChanged(directory.resolve(MARKDOWN_NAME), toMarkdown(rows));
|
||||
return jsonChanged || markdownChanged;
|
||||
}
|
||||
|
||||
private static boolean writeIfChanged(Path target, String content) throws IOException {
|
||||
if (Files.isSymbolicLink(target)) throw new IOException("Refusing to overwrite symbolic link: " + target);
|
||||
if (Files.isRegularFile(target) && content.equals(Files.readString(target, StandardCharsets.UTF_8))) {
|
||||
return false;
|
||||
}
|
||||
Path temporary = Files.createTempFile(target.getParent(), target.getFileName().toString(), ".tmp");
|
||||
try {
|
||||
Files.writeString(temporary, content, StandardCharsets.UTF_8);
|
||||
try {
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException unsupported) {
|
||||
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.bencodez.votingplugin.tests.commands;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import com.bencodez.advancedcore.api.command.CommandHandler;
|
||||
import com.bencodez.advancedcore.api.command.PlayerCommandHandler;
|
||||
import com.bencodez.votingplugin.commands.CommandDocumentationExporter;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
class CommandDocumentationExporterTest {
|
||||
@TempDir
|
||||
Path temp;
|
||||
|
||||
@Test
|
||||
void capturesNewCommandsWithoutAHandMaintainedList() {
|
||||
CommandHandler shop = handler("/vote", "/vote Shop", "VotingPlugin.Commands.Vote.Shop|VotingPlugin.Player",
|
||||
"Open shop");
|
||||
CommandHandler points = handler("/vote", "/vote Points", "VotingPlugin.Commands.Vote.Points|VotingPlugin.Player",
|
||||
"View points");
|
||||
CommandHandler admin = handler("/av", "/av Reload", "VotingPlugin.Commands.AdminVote.Reload|VotingPlugin.Admin",
|
||||
"Reload plugin");
|
||||
var rows = CommandDocumentationExporter.capture("/vote", List.of(shop, points), List.of(admin));
|
||||
JsonObject json = JsonParser.parseString(CommandDocumentationExporter.toJson(rows)).getAsJsonObject();
|
||||
assertEquals(1, json.get("schemaVersion").getAsInt());
|
||||
JsonArray commands = json.getAsJsonArray("commands");
|
||||
assertEquals(3, commands.size());
|
||||
assertEquals("/vote Points", commands.get(0).getAsJsonObject().get("syntax").getAsString());
|
||||
assertEquals("/vote Shop", commands.get(1).getAsJsonObject().get("syntax").getAsString());
|
||||
assertEquals("/av Reload", commands.get(2).getAsJsonObject().get("syntax").getAsString());
|
||||
assertEquals(2, commands.get(0).getAsJsonObject().getAsJsonArray("permissions").size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void capturesBulkPermissionsAndSenderRestrictions() {
|
||||
PlayerCommandHandler bulk = mock(PlayerCommandHandler.class);
|
||||
when(bulk.getHelpLineCommand("/av")).thenReturn("/av User (player) AddPoints (number)");
|
||||
when(bulk.getPerm()).thenReturn("VotingPlugin.Commands.AdminVote.AddPoints|VotingPlugin.Admin");
|
||||
when(bulk.getHelpMessage()).thenReturn("Add points");
|
||||
when(bulk.getAdditionalPermissions()).thenReturn(List.of("VotingPlugin.Commands.AdminVote.AddPoints.All"));
|
||||
when(bulk.isAllowMultiplePermissions()).thenReturn(true);
|
||||
when(bulk.isForceConsole()).thenReturn(true);
|
||||
|
||||
var rows = CommandDocumentationExporter.capture("/vote", List.of(), List.of(bulk));
|
||||
JsonObject entry = JsonParser.parseString(CommandDocumentationExporter.toJson(rows))
|
||||
.getAsJsonObject().getAsJsonArray("commands").get(0).getAsJsonObject();
|
||||
assertEquals("console", entry.get("sender").getAsString());
|
||||
assertEquals("VotingPlugin.Commands.AdminVote.AddPoints.All",
|
||||
entry.getAsJsonArray("allTargetPermissions").get(0).getAsString());
|
||||
assertTrue(entry.get("multiplePermissionChecks").getAsBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersMarkdownSafelyAndRespectsConfiguredPlayerRoot() {
|
||||
CommandHandler handle = handler("/customvote", "/customvote Help/?",
|
||||
"VotingPlugin.Player|VotingPlugin.Commands.Vote.Help", "Help | usage\nwith newline");
|
||||
String markdown = CommandDocumentationExporter.toMarkdown(
|
||||
CommandDocumentationExporter.capture("/customvote", List.of(handle), List.of()));
|
||||
assertTrue(markdown.contains("## /customvote"));
|
||||
assertTrue(markdown.contains("Help \\| usage with newline"));
|
||||
assertTrue(markdown.contains("VotingPlugin.Player\\|VotingPlugin.Commands.Vote.Help"));
|
||||
assertFalse(markdown.contains("usage\nwith newline"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesOnlyWhenOutputChanges() throws Exception {
|
||||
CommandHandler original = handler("/vote", "/vote Shop", "VotingPlugin.Player", "Shop");
|
||||
assertTrue(CommandDocumentationExporter.write(temp, "/vote", List.of(original), List.of()));
|
||||
Path folder = temp.resolve(CommandDocumentationExporter.DIRECTORY);
|
||||
Path json = folder.resolve(CommandDocumentationExporter.JSON_NAME);
|
||||
Path markdown = folder.resolve(CommandDocumentationExporter.MARKDOWN_NAME);
|
||||
assertTrue(Files.readString(json, StandardCharsets.UTF_8).contains("/vote Shop"));
|
||||
assertTrue(Files.readString(markdown, StandardCharsets.UTF_8).contains("/vote Shop"));
|
||||
assertFalse(CommandDocumentationExporter.write(temp, "/vote", List.of(original), List.of()));
|
||||
CommandHandler added = handler("/vote", "/vote Next", "VotingPlugin.Player", "Next vote");
|
||||
assertTrue(CommandDocumentationExporter.write(temp, "/vote", List.of(original, added), List.of()));
|
||||
assertEquals(2, JsonParser.parseString(Files.readString(json))
|
||||
.getAsJsonObject().getAsJsonArray("commands").size());
|
||||
}
|
||||
|
||||
private static CommandHandler handler(String root, String syntax, String permission, String description) {
|
||||
CommandHandler handle = mock(CommandHandler.class);
|
||||
when(handle.getHelpLineCommand(root)).thenReturn(syntax);
|
||||
when(handle.getPerm()).thenReturn(permission);
|
||||
when(handle.getHelpMessage()).thenReturn(description);
|
||||
when(handle.isAllowConsole()).thenReturn(true);
|
||||
when(handle.isAllowMultiplePermissions()).thenReturn(true);
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Automatic command documentation export
|
||||
|
||||
VotingPlugin writes a fresh reference for its **registered Bukkit command handlers** whenever
|
||||
the plugin finishes registering commands on startup. There is no new runtime command or
|
||||
configuration switch. The export is diagnostic/documentation output only: failure to write
|
||||
it must not prevent voting.
|
||||
|
||||
Files written under the plugin data folder:
|
||||
|
||||
- `GeneratedDocs/commands.json`: schema version 1, a stable manifest with syntax, help text,
|
||||
permission expression, parsed permission names, bulk/all-target permission metadata,
|
||||
console/player eligibility, and the command root.
|
||||
- `GeneratedDocs/commands.md`: a human-readable table of the same information.
|
||||
|
||||
The exporter reads the registered `/vote` (including configured alternate name) and
|
||||
`/av` `CommandHandler` lists. Newly registered handlers automatically appear after the
|
||||
next successful startup. Output is stable and files are not rewritten when unchanged.
|
||||
It never runs the commands or reads player data, config secrets, tokens, or databases.
|
||||
|
||||
## Scope and cautions
|
||||
|
||||
- This is a **runtime snapshot**, not a canonical release manifest. Enabled/configured features
|
||||
and debug-only commands can differ between servers.
|
||||
- This initial implementation intentionally does **not** enumerate the separate
|
||||
BungeeCord/Velocity proxy dispatcher, plugin.yml convenience aliases, or commands from
|
||||
other plugins.
|
||||
- A registered command's `permissionExpression` may include multiple permission nodes
|
||||
separated by `|`. Its interpretation can depend on multiple-permission-check settings.
|
||||
- `allTargetPermissions` are extra permission nodes required for supported bulk
|
||||
player selectors; do not confuse them with ordinary command alternatives.
|
||||
- The generated folder is for read-only inspection or export. Do not edit its contents.
|
||||
|
||||
## Updating the published wiki
|
||||
|
||||
Copy the JSON output from a controlled **development/test server** using the intended
|
||||
configuration. A future documentation sync step can transform the JSON into the
|
||||
`BenCodez/Wiki` command-reference page, compare it with the last published snapshot,
|
||||
and open a focused PR when commands change.
|
||||
|
||||
**This PR does not yet connect a running server or CI job to the wiki.** The local export
|
||||
is automatic; publishing to GitHub still requires an artifact transfer/sync workflow.
|
||||
|
||||
For a release-quality update, review command additions or removals, test runtime
|
||||
availability, and confirm proxy commands separately before publishing.
|
||||
Reference in new issue
Block a user