diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java index 9861ade..0ab0624 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/Main.java @@ -24,6 +24,8 @@ import java.util.List; import java.util.Objects; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; +import org.bukkit.command.CommandSender; +import org.bukkit.command.PluginCommand; import org.bukkit.configuration.Configuration; import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; @@ -69,7 +71,10 @@ public final class Main extends JavaPlugin { this, settings.threads(), settings.maxConcurrent(), settings.queueSize(), settings.timeoutSeconds()); getLogger().info("Render pool: " + settings + "."); - Objects.requireNonNull(getCommand("pixelPic")).setExecutor(new PixelPicsCommand()); + PluginCommand command = Objects.requireNonNull(getCommand("pixelpic")); + PixelPicsCommand executor = new PixelPicsCommand(); + command.setExecutor(executor); + command.setTabCompleter(executor); for (Listener listener : List.of( new OnMapInitialize(), new CameraListener(), @@ -132,6 +137,23 @@ public final class Main extends JavaPlugin { return new RenderSettings(threads, maxConcurrent, queueSize, timeoutSeconds); } + /** + * {@code /pixelpic reload}: re-reads config.yml (debug logging; render limits applied to the running pool, + * queued photos are kept) and sets up the renderer again from the resource pack folder and the models, + * downloading or updating the pack as configured. False when a setup run is still going on. + */ + public boolean reload(CommandSender requester) { + if (setup.running()) return false; + reloadConfig(); + addNewConfigOptions(); + DebugLog.configure(getLogger(), getConfig().getBoolean("debug", false)); + RenderSettings settings = renderSettings(); + renderManager.configure( + settings.threads(), settings.maxConcurrent(), settings.queueSize(), settings.timeoutSeconds()); + getLogger().info("Reloaded config.yml: render pool " + settings + ", debug " + DebugLog.enabled() + "."); + return setup.start(requester); + } + @Override public void onDisable() { SurvivalRecipes.unregister(); diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java b/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java index e5c9af0..351d033 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/commands/PixelPicsCommand.java @@ -1,27 +1,47 @@ package eu.mhsl.minecraft.pixelpics.commands; import eu.mhsl.minecraft.pixelpics.Main; +import eu.mhsl.minecraft.pixelpics.render.RenderManager; +import eu.mhsl.minecraft.pixelpics.render.RenderStats; +import eu.mhsl.minecraft.pixelpics.setup.SetupManager; +import eu.mhsl.minecraft.pixelpics.survival.CameraItems; import eu.mhsl.minecraft.pixelpics.survival.PhotoService; +import eu.mhsl.minecraft.pixelpics.utils.DebugLog; import eu.mhsl.minecraft.pixelpics.utils.MapManager; import eu.mhsl.minecraft.pixelpics.utils.MapUsageScan; import java.io.IOException; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; +import java.util.stream.Stream; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.command.Command; -import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; +import org.bukkit.entity.Entity; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.IllegalPluginAccessException; import org.jetbrains.annotations.NotNull; -public class PixelPicsCommand implements CommandExecutor { +/** + * {@code /pixelpic}: {@code help} (everyone with {@code pixelpic.use}: how camera, film and photos work) and the admin + * subcommands ({@code pixelpic.admin}): {@code info}, {@code photo}, {@code give}, {@code reload}, {@code cleanup}. + * Messages are German like the rest of the player-facing texts. + */ +public class PixelPicsCommand implements TabExecutor { + private static final String ADMIN = "pixelpic.admin"; + private static final List ADMIN_SUBCOMMANDS = List.of("info", "photo", "give", "reload", "cleanup"); private static final int DEFAULT_CLEANUP_DAYS = 30; + private static final int MAX_GIVE = 64; private static boolean cleanupRunning = false; @@ -31,53 +51,374 @@ public class PixelPicsCommand implements CommandExecutor { @NotNull Command command, @NotNull String label, @NotNull String @NotNull [] args) { - if (args.length >= 1 && args[0].equalsIgnoreCase("cleanup")) { - return cleanup(sender, args); - } - - if (!(sender instanceof Player player)) { - sender.sendMessage( - Component.text("Dieser Command kann nur von einem Spieler ausgeführt werden!", NamedTextColor.RED)); + String sub = args.length == 0 ? "help" : args[0].toLowerCase(Locale.ROOT); + if (sub.equals("help") || sub.equals("?")) { + help(sender, label); return true; } - if (args.length > 0) return false; - - // Debug render without camera or film. - if (!player.hasPermission("pixelpic.admin")) { - player.sendActionBar( - Component.text("Dafür fehlt dir die Berechtigung — nutze eine Kamera.", NamedTextColor.RED)); + if (!ADMIN_SUBCOMMANDS.contains(sub)) { + error(sender, "Unbekannter Unterbefehl „" + args[0] + "“. Übersicht: /" + label + " help"); return true; } - - PhotoService.takePhoto(player); + if (!sender.hasPermission(ADMIN)) { + error(sender, "Dafür fehlt dir die Berechtigung (" + ADMIN + ")."); + return true; + } + switch (sub) { + case "info" -> info(sender); + case "photo" -> photo(sender, label, args); + case "give" -> give(sender, label, args); + case "reload" -> reload(sender); + default -> cleanup(sender, label, args); + } return true; } - /** - * {@code /pixelPic cleanup [days]} dry-run; {@code /pixelPic cleanup confirm [days]} deletes photos that no saved - * or loaded item references (see {@link MapUsageScan}) and that nobody has seen for {@code days} days. - */ - private boolean cleanup(CommandSender sender, String[] args) { - if (!sender.hasPermission("pixelpic.admin")) { - sender.sendMessage( - Component.text("Dafür fehlt dir die Berechtigung (pixelpic.admin).", NamedTextColor.RED)); - return true; + @Override + public List onTabComplete( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String label, + @NotNull String @NotNull [] args) { + boolean admin = sender.hasPermission(ADMIN); + List options = new ArrayList<>(); + if (args.length == 1) { + options.add("help"); + if (admin) options.addAll(ADMIN_SUBCOMMANDS); + } else if (admin) { + String sub = args[0].toLowerCase(Locale.ROOT); + int i = args.length - 1; + switch (sub) { + case "photo" -> { + if (i == 1) options.addAll(playerNames()); + } + case "give" -> { + if (i == 1) { + options.addAll(playerNames()); + options.add("@a"); + } else if (i == 2) { + options.addAll(List.of("camera", "film")); + } else if (i == 3) { + options.addAll(List.of("1", "8", "16", "64")); + } + } + case "cleanup" -> { + if (i <= 2) { + if (Stream.of(args).noneMatch("confirm"::equalsIgnoreCase)) options.add("confirm"); + options.add(String.valueOf(DEFAULT_CLEANUP_DAYS)); + } + } + default -> {} + } } - if (cleanupRunning) { - sender.sendMessage(Component.text("Es läuft bereits ein Aufräum-Scan.", NamedTextColor.YELLOW)); - return true; + String prefix = args[args.length - 1].toLowerCase(Locale.ROOT); + return options.stream() + .filter(o -> o.toLowerCase(Locale.ROOT).startsWith(prefix)) + .toList(); + } + + private static List playerNames() { + return Bukkit.getOnlinePlayers().stream().map(Player::getName).toList(); + } + + private static void help(CommandSender sender, String label) { + Main plugin = Main.getInstance(); + sender.sendMessage(Component.text( + "PixelPics " + plugin.getPluginMeta().getVersion() + " — Sofortbildkamera", NamedTextColor.GOLD)); + for (String line : List.of( + "Kamera craften: Eisen, Diamant, Glasscheiben, Enderauge und Redstone (Rezeptbuch: „Kamera“).", + "Filmrolle craften: 4 Papier und 1 Tintenbeutel (Rezeptbuch: „Filmrolle“).", + "Film laden: Kamera und Filmrolle zusammen ins Crafting-Feld (bis zu " + CameraItems.MAX_FILM + + " Filme).", + "Foto: Rechtsklick mit der Kamera (1 Film). Das Bild entwickelt sich nach ein paar Sekunden.", + "Kopie: Foto und Filmrolle zusammen ins Crafting-Feld.")) { + sender.sendMessage(Component.text("• " + line, NamedTextColor.GRAY)); + } + if (!sender.hasPermission(ADMIN)) return; + sender.sendMessage(Component.text("Admin-Befehle:", NamedTextColor.GOLD)); + adminLine(sender, label, "info", "", "Status, Versionen, Resource Pack, Warteschlange, Statistik"); + adminLine(sender, label, "photo", " [Spieler]", "Testfoto ohne Kamera und Film, mit Zeitmessung"); + adminLine(sender, label, "give", " [Anzahl]", "Kamera oder Filmrollen geben"); + adminLine(sender, label, "reload", "", "config.yml, Resource Pack und Modelle neu laden"); + adminLine( + sender, + label, + "cleanup", + " [confirm] [Tage]", + "ungenutzte Fotos löschen (ohne confirm nur Vorschau, Standard " + DEFAULT_CLEANUP_DAYS + " Tage)"); + } + + private static void adminLine(CommandSender sender, String label, String sub, String params, String text) { + String command = "/" + label + " " + sub; + sender.sendMessage(Component.text(command + params, NamedTextColor.YELLOW) + .clickEvent(ClickEvent.suggestCommand(command + " ")) + .hoverEvent(HoverEvent.showText(Component.text("Klicken zum Einfügen"))) + .append(Component.text(" – " + text, NamedTextColor.GRAY))); + } + + // ---- info ---- + + private static void info(CommandSender sender) { + Main plugin = Main.getInstance(); + SetupManager setup = plugin.getSetup(); + SetupManager.Report report = setup.report(); + String server = Bukkit.getMinecraftVersion(); + sender.sendMessage(Component.text( + "PixelPics " + plugin.getPluginMeta().getVersion() + " auf " + Bukkit.getName() + " " + + Bukkit.getVersion(), + NamedTextColor.GOLD)); + + String task = setup.task(); + if (plugin.getScreenRenderer() == null) { + String status = task != null + ? "wird eingerichtet: " + task + : setup.running() ? "wird eingerichtet" : "nicht bereit, Fotos sind deaktiviert"; + line(sender, "Status", status, NamedTextColor.RED); + } else { + line(sender, "Status", "bereit" + (task != null ? " (im Hintergrund: " + task + ")" : ""), null); + } + if (setup.problem() != null) line(sender, "Problem", setup.problem(), NamedTextColor.RED); + + if (report != null) { + List packs = new ArrayList<>(); + for (SetupManager.PackInfo p : report.packs()) { + String v = p.version() == null ? "Version unbekannt" : p.version(); + packs.add(p.name() + " (" + v + (p.loaded() ? "" : ", nicht geladen") + ")"); + } + line( + sender, + "Resource Pack", + packs.isEmpty() ? "Ordner-Pack" : String.join(", ", packs), + report.packs().stream() + .anyMatch(p -> p.loaded() + && p.version() != null + && !p.version().equals(server)) + ? NamedTextColor.RED + : null); + coverageLine(sender, "Blockzustände", report.blocks()); + coverageLine(sender, "Item-Modelle", report.items()); + if (!report.fontOk()) line(sender, "Schrift", "fehlt im Pack — Schilder bleiben leer", NamedTextColor.RED); + String dump = report.dumpVersion() == null + ? "kein Dump, nur CEM-Vorlagen" + : "Dump " + report.dumpVersion() + " (" + report.dumpSource() + "), " + report.entityModels() + + " Modelle" + + (report.datapackVariants() > 0 + ? ", " + report.datapackVariants() + " Datapack-Varianten" + : ""); + boolean dumpOk = server.equals(report.dumpVersion()); + line( + sender, + "Entity-Modelle", + dump + (dumpOk ? "" : " — passt nicht zu " + server + ", siehe Server-Log"), + dumpOk ? null : NamedTextColor.RED); } + RenderManager manager = plugin.getRenderManager(); + RenderManager.Load load = manager.load(); + line( + sender, + "Rendering", + load.running() + " laufen, " + load.queued() + " warten (max. " + load.maxConcurrent() + + " parallel + " + load.queueSize() + " in der Warteschlange, " + load.threads() + + " Kerne, Timeout " + load.timeoutSeconds() + " s)", + null); + RenderStats stats = manager.stats(); + String photos = stats.developedCount() + " entwickelt, " + stats.failedCount() + " fehlgeschlagen, " + + stats.rejectedCount() + " abgelehnt"; + if (stats.developedCount() > 0) { + RenderStats.Timings avg = stats.average(); + photos += "; Ø " + seconds(avg.total()) + " (Warten " + avg.queued() + " ms, Snapshot " + avg.snapshot() + + " ms, Raytracing " + seconds(avg.trace()) + ", Dithern " + avg.dither() + " ms), max. " + + seconds(stats.maxTotal()) + ", Snapshot max. " + stats.maxSnapshot() + " ms"; + } + line(sender, "Fotos seit Start", photos, null); + line(sender, "Debug-Logging", DebugLog.enabled() ? "an" : "aus (config.yml: debug)", null); + + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + MapManager.Storage storage = MapManager.storage(); + String text = storage.photos() + (storage.photos() == 1 ? " Foto, " : " Fotos, ") + + String.format(Locale.GERMANY, "%.1f MB", storage.bytes() / 1048576.0) + " in " + + storage.folder(); + try { + Bukkit.getScheduler().runTask(plugin, () -> line(sender, "Gespeichert", text, null)); + } catch (IllegalPluginAccessException ignored) { + // disabled meanwhile + } + }); + } + + private static void coverageLine(CommandSender sender, String name, SetupManager.Coverage c) { + String text = (c.total() - c.missing()) + " / " + c.total() + " vorhanden"; + if (c.missing() > 0) { + text += ", fehlen z. B.: " + String.join(", ", c.sample()) + " — Pack älter als der Server?"; + } + line(sender, " " + name, text, c.missing() > 0 ? NamedTextColor.RED : null); + } + + private static void line(CommandSender sender, String key, String value, NamedTextColor valueColor) { + sender.sendMessage(Component.text(key + ": ", NamedTextColor.GRAY) + .append(Component.text(value, valueColor != null ? valueColor : NamedTextColor.WHITE))); + } + + private static String seconds(long ms) { + return String.format(Locale.GERMANY, "%.2f s", ms / 1000.0); + } + + // ---- photo ---- + + private static void photo(CommandSender sender, String label, String[] args) { + Player target; + if (args.length >= 2) { + List found = players(sender, args[1]); + if (found == null) return; + if (found.size() != 1) { + error(sender, "Bitte genau einen Spieler angeben."); + return; + } + target = found.getFirst(); + } else if (sender instanceof Player player) { + target = player; + } else { + error(sender, "Von der Konsole aus: /" + label + " photo (fotografiert aus dessen Sicht)."); + return; + } + String who = target.equals(sender) ? "" : " aus der Sicht von " + target.getName(); + Component refusal = PhotoService.takeDebugPhoto(target, timings -> { + if (timings == null) { + error(sender, "Testfoto" + who + " fehlgeschlagen (Details im Server-Log)."); + return; + } + sender.sendMessage(Component.text( + "Testfoto" + who + " entwickelt in " + seconds(timings.total()) + ": Warten " + timings.queued() + + " ms, Snapshot " + timings.snapshot() + " ms (Main-Thread), Raytracing " + + seconds(timings.trace()) + ", Dithern " + timings.dither() + " ms.", + NamedTextColor.GREEN)); + }); + if (refusal != null) { + sender.sendMessage(refusal); + return; + } + sender.sendMessage(Component.text( + "Testfoto" + who + " ausgelöst (ohne Film), die Karte landet im Inventar von " + target.getName() + ".", + NamedTextColor.GRAY)); + } + + // ---- give ---- + + private static void give(CommandSender sender, String label, String[] args) { + if (args.length < 3) { + error(sender, "Verwendung: /" + label + " give [Anzahl]"); + return; + } + List targets = players(sender, args[1]); + if (targets == null) return; + String kind = args[2].toLowerCase(Locale.ROOT); + boolean camera = kind.equals("camera") || kind.equals("kamera"); + if (!camera && !kind.equals("film")) { + error(sender, "Unbekanntes Item „" + args[2] + "“ — möglich sind camera und film."); + return; + } + int amount = 1; + if (args.length >= 4) { + try { + amount = Integer.parseInt(args[3]); + } catch (NumberFormatException e) { + amount = -1; + } + if (amount < 1 || amount > MAX_GIVE) { + error(sender, "Die Anzahl muss eine Zahl von 1 bis " + MAX_GIVE + " sein."); + return; + } + } + for (Player target : targets) { + if (camera) { + for (int i = 0; i < amount; i++) CameraItems.give(target, CameraItems.createCamera(0)); + } else { + ItemStack film = CameraItems.createFilm(); + film.setAmount(amount); + CameraItems.give(target, film); + } + } + String item = camera + ? (amount == 1 ? "Kamera (leer)" : "Kameras (leer)") + : (amount == 1 ? "Filmrolle" : "Filmrollen"); + String who = targets.size() == 1 ? targets.getFirst().getName() : targets.size() + " Spieler"; + sender.sendMessage(Component.text(amount + " " + item + " an " + who + " gegeben.", NamedTextColor.GREEN)); + } + + /** Online players for a name or selector, or null after telling the sender why there are none. */ + private static List players(CommandSender sender, String arg) { + if (arg.startsWith("@")) { + List found = new ArrayList<>(); + try { + for (Entity e : Bukkit.selectEntities(sender, arg)) { + if (e instanceof Player p) found.add(p); + } + } catch (IllegalArgumentException e) { + error(sender, "Ungültiger Selektor „" + arg + "“."); + return null; + } + if (found.isEmpty()) { + error(sender, "Der Selektor „" + arg + "“ trifft keinen Spieler."); + return null; + } + return found; + } + Player player = Bukkit.getPlayerExact(arg); + if (player == null) { + error(sender, "Spieler „" + arg + "“ ist nicht online."); + return null; + } + return List.of(player); + } + + // ---- reload ---- + + private static void reload(CommandSender sender) { + Main plugin = Main.getInstance(); + if (!plugin.reload(sender)) { + String task = plugin.getSetup().task(); + error( + sender, + "PixelPics wird gerade schon eingerichtet" + (task != null ? " (" + task + ")" : "") + + " — bitte kurz warten."); + return; + } + sender.sendMessage(Component.text( + "config.yml neu geladen; Resource Pack und Modelle werden im Hintergrund neu eingelesen …", + NamedTextColor.GRAY)); + } + + // ---- cleanup ---- + + /** + * {@code /pixelpic cleanup [days]} dry-run; {@code /pixelpic cleanup confirm [days]} deletes photos that no saved + * or loaded item references (see {@link MapUsageScan}) and that nobody has seen for {@code days} days. + */ + private static void cleanup(CommandSender sender, String label, String[] args) { boolean confirm = false; int days = DEFAULT_CLEANUP_DAYS; for (int i = 1; i < args.length; i++) { - if (args[i].equalsIgnoreCase("confirm")) confirm = true; - else { - try { - days = Math.max(0, Integer.parseInt(args[i])); - } catch (NumberFormatException ignored) { - } + if (args[i].equalsIgnoreCase("confirm")) { + confirm = true; + continue; } + try { + days = Integer.parseInt(args[i]); + } catch (NumberFormatException e) { + days = -1; + } + if (days < 0) { + error( + sender, + "„" + args[i] + "“ ist keine gültige Anzahl Tage. Verwendung: /" + label + + " cleanup [confirm] [Tage]"); + return; + } + } + if (cleanupRunning) { + sender.sendMessage(Component.text("Es läuft bereits ein Aufräum-Scan.", NamedTextColor.YELLOW)); + return; } // Loaded state now, saved world and player data off the main thread, loaded state again before deciding. @@ -102,24 +443,22 @@ public class PixelPicsCommand implements CommandExecutor { Bukkit.getScheduler().runTask(plugin, () -> { cleanupRunning = false; if (finalOnDisk == null) { - sender.sendMessage(Component.text( - "Konnte nicht alle Weltdaten lesen, es wurde nichts gelöscht: " + finalError, - NamedTextColor.RED)); + error(sender, "Konnte nicht alle Weltdaten lesen, es wurde nichts gelöscht: " + finalError); return; } Set inUse = new HashSet<>(liveBefore); inUse.addAll(finalOnDisk); inUse.addAll(MapUsageScan.live()); - finishCleanup(sender, inUse, finalConfirm, finalDays); + finishCleanup(sender, label, inUse, finalConfirm, finalDays); }); } catch (IllegalPluginAccessException e) { cleanupRunning = false; // disabled meanwhile } }); - return true; } - private static void finishCleanup(CommandSender sender, Set inUse, boolean confirm, int days) { + private static void finishCleanup( + CommandSender sender, String label, Set inUse, boolean confirm, int days) { long cutoff = System.currentTimeMillis() - days * 86_400_000L; List candidates = MapManager.listStored().stream() .filter(s -> !inUse.contains(s.id()) && s.lastModified() < cutoff) @@ -127,7 +466,7 @@ public class PixelPicsCommand implements CommandExecutor { if (candidates.isEmpty()) { sender.sendMessage(Component.text( - "Keine aufräumbaren Aufnahmen gefunden (nirgends mehr vorhanden und seit " + days + "Keine aufräumbaren Fotos gefunden (nirgends mehr vorhanden und seit " + days + " Tagen nicht gesehen).", NamedTextColor.YELLOW)); return; @@ -135,14 +474,14 @@ public class PixelPicsCommand implements CommandExecutor { if (!confirm) { sender.sendMessage(Component.text( - candidates.size() + " Aufnahme(n) könnten gelöscht werden: in keiner geladenen oder gespeicherten" + candidates.size() + " Foto(s) könnten gelöscht werden: in keiner geladenen oder gespeicherten" + " Welt, keinem Spielerinventar/Enderchest (auch offline) und keinem Container/Shulker/" + "Bündel gefunden und seit " + days + " Tagen nicht geladen oder angesehen.", NamedTextColor.YELLOW)); sender.sendMessage(Component.text( "Achtung: Nicht erkannt werden Karten in gerade nicht geladenen Welten und in Speichern anderer" - + " Plugins (z. B. Rucksäcke, Auktionshäuser). Zum Löschen: /pixelPic cleanup confirm " - + days, + + " Plugins (z. B. Rucksäcke, Auktionshäuser). Zum Löschen: /" + label + + " cleanup confirm " + days, NamedTextColor.GRAY)); return; } @@ -151,6 +490,10 @@ public class PixelPicsCommand implements CommandExecutor { for (MapManager.StoredMap s : candidates) { if (MapManager.delete(s.id())) deleted++; } - sender.sendMessage(Component.text(deleted + " Aufnahme(n) gelöscht.", NamedTextColor.GREEN)); + sender.sendMessage(Component.text(deleted + " Foto(s) gelöscht.", NamedTextColor.GREEN)); + } + + private static void error(CommandSender sender, String message) { + sender.sendMessage(Component.text(message, NamedTextColor.RED)); } } diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java index 186248f..4d8096c 100644 --- a/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java +++ b/src/main/java/eu/mhsl/minecraft/pixelpics/utils/MapManager.java @@ -292,6 +292,25 @@ public class MapManager { return result; } + /** Number of stored photos and the bytes of their files (thread-safe: only reads the folder). */ + public record Storage(int photos, long bytes, String folder) {} + + public static Storage storage() { + File folder = imageFolder(); + File[] files = folder.listFiles(); + Set ids = new HashSet<>(); + long bytes = 0; + if (files != null) { + for (File f : files) { + Matcher m = FILE_ID.matcher(f.getName()); + if (!m.matches()) continue; + ids.add(Integer.parseInt(m.group(1))); + bytes += f.length(); + } + } + return new Storage(ids.size(), bytes, folder.getPath()); + } + private static long lastModified(File f) { return f.isFile() ? f.lastModified() : 0L; } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 7156566..45e4fc0 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -3,13 +3,17 @@ version: '${version}' main: eu.mhsl.minecraft.pixelpics.Main api-version: '1.21' commands: - pixelPic: + pixelpic: + description: "PixelPics: how the camera works, admin tools" + aliases: [pixelpics] permission: "pixelpic.use" - usage: "/pixelPic [cleanup [confirm] [days]]" + usage: "/pixelpic help" permissions: pixelpic.use: - description: "Allows running /pixelPic (the render branch itself requires pixelpic.admin)" + description: "Allows /pixelpic help (how camera, film and photos work)" default: true pixelpic.admin: - description: "Allows the /pixelPic debug render (no camera/film) and cleanup management" + description: "Allows the admin subcommands of /pixelpic: info, photo, give, reload, cleanup" default: op + children: + pixelpic.use: true