From 6db36bff76e13ac773ea3e2fdb28b0d91cea6c79 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Mon, 16 Feb 2026 21:44:17 +0000
Subject: [PATCH 1/4] Implement DevoteMe Minecraft plugin
- Fetches and caches VOTD and Devotion from DevoteMe API.
- Uses Adventure MiniMessage for all text formatting.
- Implements /votd and /devotion commands.
- Supports VOTD holograms with DecentHolograms (soft dependency).
- Persists hologram locations in votd-locations.yml.
- Automatic content refresh every 12 hours.
Co-authored-by: benrobson <15405528+benrobson@users.noreply.github.com>
---
.gitignore | 3 +
pom.xml | 57 +++++
.../devoteme/minecraft/DevoteMePlugin.java | 118 ++++++++++
.../minecraft/api/DevoteMeApiClient.java | 41 ++++
.../minecraft/api/DevoteMeParser.java | 72 ++++++
.../minecraft/cache/ContentCache.java | 15 ++
.../minecraft/commands/DevotionCommand.java | 73 ++++++
.../minecraft/commands/VotdCommand.java | 219 ++++++++++++++++++
.../holograms/DecentHologramsService.java | 145 ++++++++++++
.../minecraft/holograms/HologramService.java | 12 +
.../minecraft/listeners/LoginListener.java | 37 +++
.../devoteme/minecraft/model/Devotion.java | 5 +
.../com/devoteme/minecraft/model/Votd.java | 3 +
.../minecraft/storage/VotdLocationStore.java | 106 +++++++++
.../devoteme/minecraft/util/BookBuilder.java | 46 ++++
.../com/devoteme/minecraft/util/Text.java | 36 +++
src/main/resources/config.yml | 33 +++
src/main/resources/plugin.yml | 21 ++
.../minecraft/api/DevoteMeParserTest.java | 31 +++
19 files changed, 1073 insertions(+)
create mode 100644 .gitignore
create mode 100644 pom.xml
create mode 100644 src/main/java/com/devoteme/minecraft/DevoteMePlugin.java
create mode 100644 src/main/java/com/devoteme/minecraft/api/DevoteMeApiClient.java
create mode 100644 src/main/java/com/devoteme/minecraft/api/DevoteMeParser.java
create mode 100644 src/main/java/com/devoteme/minecraft/cache/ContentCache.java
create mode 100644 src/main/java/com/devoteme/minecraft/commands/DevotionCommand.java
create mode 100644 src/main/java/com/devoteme/minecraft/commands/VotdCommand.java
create mode 100644 src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java
create mode 100644 src/main/java/com/devoteme/minecraft/holograms/HologramService.java
create mode 100644 src/main/java/com/devoteme/minecraft/listeners/LoginListener.java
create mode 100644 src/main/java/com/devoteme/minecraft/model/Devotion.java
create mode 100644 src/main/java/com/devoteme/minecraft/model/Votd.java
create mode 100644 src/main/java/com/devoteme/minecraft/storage/VotdLocationStore.java
create mode 100644 src/main/java/com/devoteme/minecraft/util/BookBuilder.java
create mode 100644 src/main/java/com/devoteme/minecraft/util/Text.java
create mode 100644 src/main/resources/config.yml
create mode 100644 src/main/resources/plugin.yml
create mode 100644 src/test/java/com/devoteme/minecraft/api/DevoteMeParserTest.java
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0bc2f9a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+target/
+*.jar
+.maven-docker-cache/
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..f2d1470
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,57 @@
+
+ 4.0.0
+
+ com.devoteme
+ DevoteMeMC
+ 1.0.0
+ DevoteMeMC
+
+
+ 17
+ UTF-8
+
+
+
+
+ papermc
+ https://repo.papermc.io/repository/maven-public/
+
+
+
+
+
+ io.papermc.paper
+ paper-api
+ 1.20.6-R0.1-SNAPSHOT
+ provided
+
+
+
+ com.google.code.gson
+ gson
+ 2.11.0
+ provided
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ 5.10.2
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ ${java.version}
+ ${java.version}
+
+
+
+
+
diff --git a/src/main/java/com/devoteme/minecraft/DevoteMePlugin.java b/src/main/java/com/devoteme/minecraft/DevoteMePlugin.java
new file mode 100644
index 0000000..c73e505
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/DevoteMePlugin.java
@@ -0,0 +1,118 @@
+package com.devoteme.minecraft;
+
+import com.devoteme.minecraft.api.DevoteMeApiClient;
+import com.devoteme.minecraft.api.DevoteMeParser;
+import com.devoteme.minecraft.cache.ContentCache;
+import com.devoteme.minecraft.commands.DevotionCommand;
+import com.devoteme.minecraft.commands.VotdCommand;
+import com.devoteme.minecraft.holograms.DecentHologramsService;
+import com.devoteme.minecraft.holograms.HologramService;
+import com.devoteme.minecraft.listeners.LoginListener;
+import com.devoteme.minecraft.model.Votd;
+import com.devoteme.minecraft.storage.VotdLocationStore;
+import com.devoteme.minecraft.util.Text;
+import org.bukkit.Bukkit;
+import org.bukkit.command.PluginCommand;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.java.JavaPlugin;
+
+import java.util.concurrent.CompletableFuture;
+
+public class DevoteMePlugin extends JavaPlugin {
+
+ private DevoteMeApiClient api;
+ private ContentCache cache;
+ private VotdLocationStore locationStore;
+ private HologramService holograms;
+
+ @Override
+ public void onEnable() {
+ saveDefaultConfig();
+
+ this.cache = new ContentCache();
+ this.api = new DevoteMeApiClient(
+ getConfig().getString("devoteme.baseUrl", ""),
+ getConfig().getString("devoteme.apiKey", "")
+ );
+ this.locationStore = new VotdLocationStore(this);
+
+ // Holograms (soft)
+ this.holograms = new DecentHologramsService(this, cache, locationStore);
+
+ // listeners
+ Bukkit.getPluginManager().registerEvents(new LoginListener(this), this);
+
+ // commands
+ PluginCommand votd = getCommand("votd");
+ if (votd != null) {
+ VotdCommand cmd = new VotdCommand(this, cache, locationStore, holograms);
+ votd.setExecutor(cmd);
+ votd.setTabCompleter(cmd);
+ }
+
+ PluginCommand devotion = getCommand("devotion");
+ if (devotion != null) {
+ DevotionCommand cmd = new DevotionCommand(this, cache);
+ devotion.setExecutor(cmd);
+ }
+
+ // Load stored hologram locations
+ locationStore.load();
+
+ // Initial refresh now
+ refreshAllAsync().whenComplete((v, ex) -> {
+ if (ex != null) getLogger().warning("Initial refresh failed: " + ex.getMessage());
+ Bukkit.getScheduler().runTask(this, () -> holograms.updateAllFromCache());
+ });
+
+ // Schedule repeating refresh every refreshHours
+ long hours = getConfig().getLong("devoteme.refreshHours", 12);
+ if (hours < 1) hours = 12;
+ long ticks = hours * 60L * 60L * 20L;
+
+ Bukkit.getScheduler().runTaskTimerAsynchronously(this, () -> {
+ refreshAllAsync().whenComplete((v, ex) -> {
+ if (ex != null) getLogger().warning("Scheduled refresh failed: " + ex.getMessage());
+ Bukkit.getScheduler().runTask(this, () -> holograms.updateAllFromCache());
+ });
+ }, ticks, ticks);
+ }
+
+ public CompletableFuture refreshAllAsync() {
+ CompletableFuture a = refreshVotdAsync();
+ CompletableFuture b = refreshDevotionAsync();
+ return CompletableFuture.allOf(a, b);
+ }
+
+ public CompletableFuture refreshVotdAsync() {
+ return api.getJson("/votd/get")
+ .thenApply(DevoteMeParser::parseVotd)
+ .thenAccept(cache::setVotd);
+ }
+
+ public CompletableFuture refreshDevotionAsync() {
+ return api.getJson("/devotion/get")
+ .thenApply(DevoteMeParser::parseDevotion)
+ .thenAccept(cache::setDevotion);
+ }
+
+ public void sendVotdTo(Player p) {
+ Votd v = cache.getVotd();
+ if (v == null) {
+ p.sendMessage(Text.render(getConfig().getString("messages.loading", "Loading...")));
+ return;
+ }
+
+ for (String line : getConfig().getStringList("votd.chatFormat")) {
+ p.sendMessage(Text.render(line, v));
+ }
+ }
+
+ public void sendNoPerm(org.bukkit.command.CommandSender sender) {
+ sender.sendMessage(Text.render(getConfig().getString("messages.noPermission", "No permission.")));
+ }
+
+ public ContentCache getCache() { return cache; }
+ public VotdLocationStore getLocationStore() { return locationStore; }
+ public HologramService getHolograms() { return holograms; }
+}
diff --git a/src/main/java/com/devoteme/minecraft/api/DevoteMeApiClient.java b/src/main/java/com/devoteme/minecraft/api/DevoteMeApiClient.java
new file mode 100644
index 0000000..a4718cd
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/api/DevoteMeApiClient.java
@@ -0,0 +1,41 @@
+package com.devoteme.minecraft.api;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.concurrent.CompletableFuture;
+
+public class DevoteMeApiClient {
+ private final HttpClient client = HttpClient.newHttpClient();
+ private final String baseUrl;
+ private final String apiKeyOrNull;
+
+ public DevoteMeApiClient(String baseUrl, String apiKey) {
+ String b = baseUrl == null ? "" : baseUrl.trim();
+ this.baseUrl = b.endsWith("/") ? b.substring(0, b.length() - 1) : b;
+ this.apiKeyOrNull = (apiKey == null || apiKey.isBlank()) ? null : apiKey.trim();
+ }
+
+ public CompletableFuture getJson(String path) {
+ if (baseUrl.isEmpty()) {
+ return CompletableFuture.failedFuture(new IllegalStateException("devoteme.baseUrl is empty"));
+ }
+
+ HttpRequest.Builder req = HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl + path))
+ .GET()
+ .header("Accept", "application/json");
+
+ if (apiKeyOrNull != null) {
+ req.header("x-access-token", apiKeyOrNull);
+ }
+
+ return client.sendAsync(req.build(), HttpResponse.BodyHandlers.ofString())
+ .thenApply(resp -> {
+ int sc = resp.statusCode();
+ if (sc < 200 || sc >= 300) throw new RuntimeException("HTTP " + sc + " for " + path);
+ return resp.body();
+ });
+ }
+}
diff --git a/src/main/java/com/devoteme/minecraft/api/DevoteMeParser.java b/src/main/java/com/devoteme/minecraft/api/DevoteMeParser.java
new file mode 100644
index 0000000..a498fbe
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/api/DevoteMeParser.java
@@ -0,0 +1,72 @@
+package com.devoteme.minecraft.api;
+
+import com.google.gson.*;
+import com.devoteme.minecraft.model.Devotion;
+import com.devoteme.minecraft.model.Votd;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+
+public class DevoteMeParser {
+ public static Votd parseVotd(String json) {
+ JsonElement el = JsonParser.parseString(json);
+ JsonObject o = el.isJsonObject() ? el.getAsJsonObject() : new JsonObject();
+
+ String reference = pick(o, "reference", "ref", "scriptureReference", "title");
+ String verse = pick(o, "verse", "content", "text", "scripture", "passage");
+ String link = pick(o, "link", "url", "passageUrl");
+ String date = pick(o, "date", "day", "displayDate");
+
+ if (date == null || date.isBlank()) {
+ date = LocalDate.now().toString();
+ }
+
+ if (reference == null) reference = "Verse of the Day";
+ if (verse == null) verse = "(No verse content returned by API)";
+ if (link == null) link = "";
+
+ return new Votd(reference, verse, link, date);
+ }
+
+ public static Devotion parseDevotion(String json) {
+ JsonElement el = JsonParser.parseString(json);
+ JsonObject o = el.isJsonObject() ? el.getAsJsonObject() : new JsonObject();
+
+ String title = pick(o, "title", "heading");
+ String reading = pick(o, "reading", "themeVerse", "verse", "scripture");
+ String bible = pick(o, "bibleInOneYear", "soulFood", "bibleReadingPlan", "plan");
+
+ List content = new ArrayList<>();
+
+ // content as array of paragraphs
+ JsonElement c = o.get("content");
+ if (c != null && c.isJsonArray()) {
+ for (JsonElement p : c.getAsJsonArray()) {
+ if (p != null && p.isJsonPrimitive()) content.add(p.getAsString());
+ }
+ } else if (c != null && c.isJsonPrimitive()) {
+ content.add(c.getAsString());
+ } else {
+ // fallback possible keys
+ String body = pick(o, "body", "text");
+ if (body != null) content.add(body);
+ }
+
+ if (title == null) title = "Today's Devotion";
+ if (reading == null) reading = "";
+ if (bible == null) bible = "";
+
+ return new Devotion(title, reading, content, bible);
+ }
+
+ private static String pick(JsonObject o, String... keys) {
+ for (String k : keys) {
+ if (!o.has(k)) continue;
+ JsonElement e = o.get(k);
+ if (e == null || e.isJsonNull()) continue;
+ if (e.isJsonPrimitive()) return e.getAsString();
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/devoteme/minecraft/cache/ContentCache.java b/src/main/java/com/devoteme/minecraft/cache/ContentCache.java
new file mode 100644
index 0000000..0e77c70
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/cache/ContentCache.java
@@ -0,0 +1,15 @@
+package com.devoteme.minecraft.cache;
+
+import com.devoteme.minecraft.model.Devotion;
+import com.devoteme.minecraft.model.Votd;
+
+public class ContentCache {
+ private volatile Votd votd;
+ private volatile Devotion devotion;
+
+ public Votd getVotd() { return votd; }
+ public Devotion getDevotion() { return devotion; }
+
+ public void setVotd(Votd v) { this.votd = v; }
+ public void setDevotion(Devotion d) { this.devotion = d; }
+}
diff --git a/src/main/java/com/devoteme/minecraft/commands/DevotionCommand.java b/src/main/java/com/devoteme/minecraft/commands/DevotionCommand.java
new file mode 100644
index 0000000..2cb97e0
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/commands/DevotionCommand.java
@@ -0,0 +1,73 @@
+package com.devoteme.minecraft.commands;
+
+import com.devoteme.minecraft.DevoteMePlugin;
+import com.devoteme.minecraft.model.Devotion;
+import com.devoteme.minecraft.util.BookBuilder;
+import com.devoteme.minecraft.util.Text;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.text.format.TextDecoration;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandExecutor;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class DevotionCommand implements CommandExecutor {
+ private final DevoteMePlugin plugin;
+ private final com.devoteme.minecraft.cache.ContentCache cache;
+
+ public DevotionCommand(DevoteMePlugin plugin, com.devoteme.minecraft.cache.ContentCache cache) {
+ this.plugin = plugin;
+ this.cache = cache;
+ }
+
+ @Override
+ public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) {
+ if (!(sender instanceof Player p)) return true;
+ if (!p.hasPermission("devoteme.devotion")) {
+ plugin.sendNoPerm(p);
+ return true;
+ }
+
+ Devotion d = cache.getDevotion();
+ if (d == null) {
+ p.sendMessage(Text.render(plugin.getConfig().getString("messages.loading", "Loading...")));
+ return true;
+ }
+
+ List body = new ArrayList<>();
+ body.add(Component.text(d.title(), NamedTextColor.GOLD, TextDecoration.BOLD));
+ if (d.reading() != null && !d.reading().isBlank()) {
+ body.add(Component.text(d.reading(), NamedTextColor.DARK_AQUA, TextDecoration.ITALIC));
+ }
+ body.add(Component.empty());
+
+ for (String para : d.content()) {
+ if (para == null || para.isBlank()) continue;
+ body.add(Component.text(para, NamedTextColor.BLACK));
+ }
+
+ Component footer = null;
+ if (d.bibleInOneYear() != null && !d.bibleInOneYear().isBlank()) {
+ footer = Component.text()
+ .append(Component.text("Bible in One Year", NamedTextColor.GOLD, TextDecoration.BOLD))
+ .append(Component.newline())
+ .append(Component.newline())
+ .append(Component.text(d.bibleInOneYear(), NamedTextColor.BLACK))
+ .build();
+ }
+
+ BookBuilder.openBook(
+ p,
+ "Devotion",
+ "DevoteMe",
+ body,
+ footer
+ );
+
+ return true;
+ }
+}
diff --git a/src/main/java/com/devoteme/minecraft/commands/VotdCommand.java b/src/main/java/com/devoteme/minecraft/commands/VotdCommand.java
new file mode 100644
index 0000000..91c88ce
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/commands/VotdCommand.java
@@ -0,0 +1,219 @@
+package com.devoteme.minecraft.commands;
+
+import com.devoteme.minecraft.DevoteMePlugin;
+import com.devoteme.minecraft.cache.ContentCache;
+import com.devoteme.minecraft.holograms.HologramService;
+import com.devoteme.minecraft.storage.VotdLocationStore;
+import com.devoteme.minecraft.util.Text;
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.command.*;
+import org.bukkit.entity.Player;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+public class VotdCommand implements CommandExecutor, TabCompleter {
+
+ private final DevoteMePlugin plugin;
+ private final ContentCache cache;
+ private final VotdLocationStore store;
+ private final HologramService holograms;
+
+ public VotdCommand(DevoteMePlugin plugin, ContentCache cache, VotdLocationStore store, HologramService holograms) {
+ this.plugin = plugin;
+ this.cache = cache;
+ this.store = store;
+ this.holograms = holograms;
+ }
+
+ @Override
+ public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) {
+ if (!(sender instanceof Player p)) return true;
+
+ if (!p.hasPermission("devoteme.votd")) {
+ plugin.sendNoPerm(p);
+ return true;
+ }
+
+ if (args.length == 0) {
+ if (cache.getVotd() == null) {
+ plugin.refreshVotdAsync().whenComplete((v, ex) -> {
+ if (ex == null) {
+ Bukkit.getScheduler().runTask(plugin, () -> holograms.updateAllFromCache());
+ }
+ });
+ }
+ plugin.sendVotdTo(p);
+ return true;
+ }
+
+ if (!args[0].equalsIgnoreCase("manage")) {
+ plugin.sendVotdTo(p);
+ return true;
+ }
+
+ if (!p.hasPermission("devoteme.votd.manage")) {
+ plugin.sendNoPerm(p);
+ return true;
+ }
+
+ if (args.length < 2) {
+ p.sendMessage(Text.render("Usage: /votd manage "));
+ return true;
+ }
+
+ String sub = args[1].toLowerCase(Locale.ROOT);
+
+ switch (sub) {
+ case "add" -> {
+ if (!holograms.isEnabled()) {
+ p.sendMessage(Text.render(plugin.getConfig().getString("messages.hologramsDisabled")));
+ return true;
+ }
+ if (args.length < 3) {
+ p.sendMessage(Text.render("Usage: /votd manage add "));
+ return true;
+ }
+ String name = args[2].toLowerCase(Locale.ROOT);
+
+ if (store.exists(name)) {
+ p.sendMessage(Text.render("That name already exists."));
+ return true;
+ }
+
+ Location loc = p.getLocation().clone();
+ loc.setX(Math.floor(loc.getX()) + 0.5);
+ loc.setZ(Math.floor(loc.getZ()) + 0.5);
+
+ store.put(name, loc);
+ store.save();
+
+ holograms.createOrUpdate(name, loc);
+ p.sendMessage(Text.render("Added VOTD hologram location: " + name + ""));
+ }
+
+ case "remove" -> {
+ if (!holograms.isEnabled()) {
+ p.sendMessage(Text.render(plugin.getConfig().getString("messages.hologramsDisabled")));
+ return true;
+ }
+ if (args.length < 3) {
+ p.sendMessage(Text.render("Usage: /votd manage remove "));
+ return true;
+ }
+ String name = args[2].toLowerCase(Locale.ROOT);
+
+ if (!store.exists(name)) {
+ p.sendMessage(Text.render("No such location: " + name + ""));
+ return true;
+ }
+
+ holograms.delete(name);
+ store.remove(name);
+ store.save();
+
+ p.sendMessage(Text.render("Removed: " + name + ""));
+ }
+
+ case "removenearest" -> {
+ if (!holograms.isEnabled()) {
+ p.sendMessage(Text.render(plugin.getConfig().getString("messages.hologramsDisabled")));
+ return true;
+ }
+ double radius = 5.0;
+ if (args.length >= 3) {
+ try { radius = Double.parseDouble(args[2]); } catch (NumberFormatException ignored) {}
+ }
+
+ String nearest = store.findNearestName(p.getLocation(), radius);
+ if (nearest == null) {
+ p.sendMessage(Text.render("No saved VOTD location within " + radius + " blocks."));
+ return true;
+ }
+
+ holograms.delete(nearest);
+ store.remove(nearest);
+ store.save();
+
+ p.sendMessage(Text.render("Removed nearest: " + nearest + ""));
+ }
+
+ case "list" -> {
+ var all = store.getAll();
+ if (all.isEmpty()) {
+ p.sendMessage(Text.render("No VOTD hologram locations saved."));
+ return true;
+ }
+
+ p.sendMessage(Text.render("VOTD Locations:"));
+ for (var e : all.entrySet()) {
+ Location l = e.getValue();
+ p.sendMessage(Text.render("- " + e.getKey() + " (" +
+ l.getWorld().getName() + " " +
+ String.format(Locale.ROOT, "%.1f %.1f %.1f", l.getX(), l.getY(), l.getZ()) + ")"));
+ }
+ }
+
+ case "reset" -> {
+ if (!holograms.isEnabled()) {
+ p.sendMessage(Text.render(plugin.getConfig().getString("messages.hologramsDisabled")));
+ return true;
+ }
+ holograms.deleteAllManaged();
+ store.clear();
+ store.save();
+
+ p.sendMessage(Text.render("All VOTD hologram locations cleared."));
+ }
+
+ case "refresh" -> {
+ p.sendMessage(Text.render("Refreshing DevoteMe content..."));
+ plugin.refreshAllAsync().whenComplete((v, ex) -> {
+ Bukkit.getScheduler().runTask(plugin, () -> {
+ if (ex != null) {
+ p.sendMessage(Text.render("Refresh failed: " + ex.getMessage() + ""));
+ return;
+ }
+ holograms.updateAllFromCache();
+ p.sendMessage(Text.render("Refreshed."));
+ });
+ });
+ }
+
+ default -> p.sendMessage(Text.render("Unknown subcommand."));
+ }
+
+ return true;
+ }
+
+ @Override
+ public List onTabComplete(CommandSender sender, Command cmd, String alias, String[] args) {
+ if (!(sender instanceof Player p)) return List.of();
+
+ if (args.length == 1) {
+ return startsWith(List.of("manage"), args[0]);
+ }
+
+ if (args.length == 2 && args[0].equalsIgnoreCase("manage")) {
+ return startsWith(List.of("add", "remove", "removeNearest", "list", "reset", "refresh"), args[1]);
+ }
+
+ if (args.length == 3 && args[0].equalsIgnoreCase("manage")) {
+ String sub = args[1].toLowerCase(Locale.ROOT);
+ if (sub.equals("remove")) {
+ return startsWith(new ArrayList<>(store.getAll().keySet()), args[2]);
+ }
+ }
+
+ return List.of();
+ }
+
+ private List startsWith(List options, String token) {
+ String t = token == null ? "" : token.toLowerCase(Locale.ROOT);
+ return options.stream()
+ .filter(s -> s.toLowerCase(Locale.ROOT).startsWith(t))
+ .sorted()
+ .collect(Collectors.toList());
+ }
+}
diff --git a/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java b/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java
new file mode 100644
index 0000000..6d0b844
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java
@@ -0,0 +1,145 @@
+package com.devoteme.minecraft.holograms;
+
+import com.devoteme.minecraft.cache.ContentCache;
+import com.devoteme.minecraft.model.Votd;
+import com.devoteme.minecraft.storage.VotdLocationStore;
+import com.devoteme.minecraft.util.Text;
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.java.JavaPlugin;
+
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+public class DecentHologramsService implements HologramService {
+
+ private final JavaPlugin plugin;
+ private final ContentCache cache;
+ private final VotdLocationStore store;
+
+ private final boolean enabled;
+
+ // Reflection handles
+ private Class> dhapiClass;
+ private Method getHologram;
+ private Method createHologram;
+ private Method removeHologram;
+ private Method setHologramLines;
+
+ public DecentHologramsService(JavaPlugin plugin, ContentCache cache, VotdLocationStore store) {
+ this.plugin = plugin;
+ this.cache = cache;
+ this.store = store;
+
+ boolean cfg = plugin.getConfig().getBoolean("holograms.enabled", true);
+ Plugin dh = Bukkit.getPluginManager().getPlugin("DecentHolograms");
+
+ if (!cfg || dh == null || !dh.isEnabled()) {
+ this.enabled = false;
+ return;
+ }
+
+ boolean success = false;
+ try {
+ this.dhapiClass = Class.forName("eu.decentsoftware.holograms.api.DHAPI");
+ this.getHologram = dhapiClass.getMethod("getHologram", String.class);
+ this.createHologram = dhapiClass.getMethod("createHologram", String.class, Location.class);
+ this.removeHologram = dhapiClass.getMethod("removeHologram", String.class);
+
+ Method tmpSet = null;
+ for (Method m : dhapiClass.getMethods()) {
+ if (!m.getName().equals("setHologramLines")) continue;
+ Class>[] p = m.getParameterTypes();
+ if (p.length == 2 && List.class.isAssignableFrom(p[1])) {
+ tmpSet = m;
+ break;
+ }
+ }
+ if (tmpSet == null) throw new NoSuchMethodException("DHAPI.setHologramLines not found");
+ this.setHologramLines = tmpSet;
+
+ success = true;
+ plugin.getLogger().info("DecentHolograms detected. VOTD holograms enabled.");
+ } catch (Throwable t) {
+ plugin.getLogger().warning("DecentHolograms found but API reflection failed. Disabling holograms: " + t.getMessage());
+ }
+ this.enabled = success;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ @Override
+ public void createOrUpdate(String name, Location loc) {
+ if (!enabled) return;
+ String id = id(name);
+
+ try {
+ Object holo = getHologram.invoke(null, id);
+ if (holo == null) {
+ holo = createHologram.invoke(null, id, loc);
+ } else {
+ removeHologram.invoke(null, id);
+ holo = createHologram.invoke(null, id, loc);
+ }
+
+ List lines = buildLines();
+ setHologramLines.invoke(null, holo, lines);
+ } catch (Throwable t) {
+ plugin.getLogger().warning("Failed to create/update hologram " + id + ": " + t.getMessage());
+ }
+ }
+
+ @Override
+ public void delete(String name) {
+ if (!enabled) return;
+ String id = id(name);
+ try {
+ removeHologram.invoke(null, id);
+ } catch (Throwable t) {
+ // ignore
+ }
+ }
+
+ @Override
+ public void deleteAllManaged() {
+ if (!enabled) return;
+ for (String name : store.getAll().keySet()) {
+ delete(name);
+ }
+ }
+
+ @Override
+ public void updateAllFromCache() {
+ if (!enabled) return;
+ for (Map.Entry e : store.getAll().entrySet()) {
+ createOrUpdate(e.getKey(), e.getValue());
+ }
+ }
+
+ private String id(String name) {
+ return "devoteme_votd_" + name.toLowerCase(Locale.ROOT);
+ }
+
+ private List buildLines() {
+ Votd v = cache.getVotd();
+ List templates = plugin.getConfig().getStringList("holograms.lines");
+
+ List out = new ArrayList<>();
+ if (v == null) {
+ out.add("§eLoading Verse of the Day...");
+ return out;
+ }
+
+ for (String t : templates) {
+ out.add(Text.toLegacy(t, v));
+ }
+ return out;
+ }
+}
diff --git a/src/main/java/com/devoteme/minecraft/holograms/HologramService.java b/src/main/java/com/devoteme/minecraft/holograms/HologramService.java
new file mode 100644
index 0000000..dc74e56
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/holograms/HologramService.java
@@ -0,0 +1,12 @@
+package com.devoteme.minecraft.holograms;
+
+import org.bukkit.Location;
+
+public interface HologramService {
+ boolean isEnabled();
+
+ void createOrUpdate(String name, Location loc);
+ void delete(String name);
+ void deleteAllManaged();
+ void updateAllFromCache();
+}
diff --git a/src/main/java/com/devoteme/minecraft/listeners/LoginListener.java b/src/main/java/com/devoteme/minecraft/listeners/LoginListener.java
new file mode 100644
index 0000000..1ca0784
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/listeners/LoginListener.java
@@ -0,0 +1,37 @@
+package com.devoteme.minecraft.listeners;
+
+import com.devoteme.minecraft.DevoteMePlugin;
+import com.devoteme.minecraft.model.Votd;
+import com.devoteme.minecraft.util.Text;
+import org.bukkit.Bukkit;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerJoinEvent;
+
+public class LoginListener implements Listener {
+ private final DevoteMePlugin plugin;
+
+ public LoginListener(DevoteMePlugin plugin) {
+ this.plugin = plugin;
+ }
+
+ @EventHandler
+ public void onJoin(PlayerJoinEvent e) {
+ if (!plugin.getConfig().getBoolean("login.sendVotd", true)) return;
+
+ Player p = e.getPlayer();
+ if (!p.hasPermission("devoteme.votd")) return;
+
+ Bukkit.getScheduler().runTaskLater(plugin, () -> {
+ Votd v = plugin.getCache().getVotd();
+ if (v == null) {
+ p.sendMessage(Text.render(plugin.getConfig().getString("messages.loading", "Loading...")));
+ return;
+ }
+ for (String line : plugin.getConfig().getStringList("login.format")) {
+ p.sendMessage(Text.render(line, v));
+ }
+ }, 2L);
+ }
+}
diff --git a/src/main/java/com/devoteme/minecraft/model/Devotion.java b/src/main/java/com/devoteme/minecraft/model/Devotion.java
new file mode 100644
index 0000000..d3fe895
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/model/Devotion.java
@@ -0,0 +1,5 @@
+package com.devoteme.minecraft.model;
+
+import java.util.List;
+
+public record Devotion(String title, String reading, List content, String bibleInOneYear) {}
diff --git a/src/main/java/com/devoteme/minecraft/model/Votd.java b/src/main/java/com/devoteme/minecraft/model/Votd.java
new file mode 100644
index 0000000..379ce26
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/model/Votd.java
@@ -0,0 +1,3 @@
+package com.devoteme.minecraft.model;
+
+public record Votd(String reference, String verse, String link, String dateText) {}
diff --git a/src/main/java/com/devoteme/minecraft/storage/VotdLocationStore.java b/src/main/java/com/devoteme/minecraft/storage/VotdLocationStore.java
new file mode 100644
index 0000000..b363e7a
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/storage/VotdLocationStore.java
@@ -0,0 +1,106 @@
+package com.devoteme.minecraft.storage;
+
+import org.bukkit.Bukkit;
+import org.bukkit.Location;
+import org.bukkit.World;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.plugin.java.JavaPlugin;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.*;
+
+public class VotdLocationStore {
+ private final JavaPlugin plugin;
+ private final File file;
+ private YamlConfiguration yml;
+
+ private final Map locations = new HashMap<>();
+
+ public VotdLocationStore(JavaPlugin plugin) {
+ this.plugin = plugin;
+ this.file = new File(plugin.getDataFolder(), "votd-locations.yml");
+ }
+
+ public void load() {
+ if (!file.exists()) {
+ yml = new YamlConfiguration();
+ yml.set("locations", new HashMap<>());
+ save();
+ }
+ yml = YamlConfiguration.loadConfiguration(file);
+ locations.clear();
+
+ var sec = yml.getConfigurationSection("locations");
+ if (sec == null) return;
+
+ for (String name : sec.getKeys(false)) {
+ String base = "locations." + name + ".";
+ String worldName = yml.getString(base + "world");
+ World w = worldName == null ? null : Bukkit.getWorld(worldName);
+ if (w == null) continue;
+
+ double x = yml.getDouble(base + "x");
+ double y = yml.getDouble(base + "y");
+ double z = yml.getDouble(base + "z");
+ float yaw = (float) yml.getDouble(base + "yaw", 0.0);
+ float pitch = (float) yml.getDouble(base + "pitch", 0.0);
+
+ Location loc = new Location(w, x, y, z, yaw, pitch);
+ locations.put(name.toLowerCase(Locale.ROOT), loc);
+ }
+ }
+
+ public void save() {
+ if (yml == null) yml = new YamlConfiguration();
+ yml.set("locations", null);
+
+ for (var e : locations.entrySet()) {
+ String name = e.getKey();
+ Location loc = e.getValue();
+ String base = "locations." + name + ".";
+ yml.set(base + "world", loc.getWorld().getName());
+ yml.set(base + "x", loc.getX());
+ yml.set(base + "y", loc.getY());
+ yml.set(base + "z", loc.getZ());
+ yml.set(base + "yaw", loc.getYaw());
+ yml.set(base + "pitch", loc.getPitch());
+ }
+
+ try {
+ yml.save(file);
+ } catch (IOException ex) {
+ plugin.getLogger().warning("Failed to save votd-locations.yml: " + ex.getMessage());
+ }
+ }
+
+ public boolean exists(String name) { return locations.containsKey(norm(name)); }
+ public Location get(String name) { return locations.get(norm(name)); }
+ public Map getAll() { return Collections.unmodifiableMap(locations); }
+
+ public void put(String name, Location loc) { locations.put(norm(name), loc); }
+ public void remove(String name) { locations.remove(norm(name)); }
+
+ public void clear() { locations.clear(); }
+
+ public String findNearestName(Location at, double radius) {
+ if (at.getWorld() == null) return null;
+ double best = radius * radius;
+ String bestName = null;
+
+ for (var e : locations.entrySet()) {
+ Location l = e.getValue();
+ if (l.getWorld() == null || !l.getWorld().equals(at.getWorld())) continue;
+ double d2 = l.distanceSquared(at);
+ if (d2 <= best) {
+ best = d2;
+ bestName = e.getKey();
+ }
+ }
+ return bestName;
+ }
+
+ private String norm(String s) {
+ return s == null ? "" : s.toLowerCase(Locale.ROOT);
+ }
+}
diff --git a/src/main/java/com/devoteme/minecraft/util/BookBuilder.java b/src/main/java/com/devoteme/minecraft/util/BookBuilder.java
new file mode 100644
index 0000000..46cadf1
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/util/BookBuilder.java
@@ -0,0 +1,46 @@
+package com.devoteme.minecraft.util;
+
+import net.kyori.adventure.inventory.Book;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
+import org.bukkit.entity.Player;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class BookBuilder {
+
+ private static final int PAGE_CHAR_LIMIT = 250;
+
+ public static void openBook(Player p, String title, String author, List body, Component footer) {
+ List pages = new ArrayList<>();
+ Component currentPage = Component.empty();
+ int charCount = 0;
+
+ for (Component part : body) {
+ String plain = PlainTextComponentSerializer.plainText().serialize(part);
+ if (charCount + plain.length() > PAGE_CHAR_LIMIT && charCount > 0) {
+ pages.add(currentPage);
+ currentPage = Component.empty();
+ charCount = 0;
+ }
+ currentPage = currentPage.append(part).append(Component.newline());
+ charCount += plain.length() + 1;
+ }
+
+ if (charCount > 0) {
+ pages.add(currentPage);
+ }
+
+ if (footer != null) {
+ pages.add(footer);
+ }
+
+ if (pages.isEmpty()) {
+ pages.add(Component.text("(No content)"));
+ }
+
+ Book book = Book.book(Component.text(title), Component.text(author), pages);
+ p.openBook(book);
+ }
+}
diff --git a/src/main/java/com/devoteme/minecraft/util/Text.java b/src/main/java/com/devoteme/minecraft/util/Text.java
new file mode 100644
index 0000000..683f743
--- /dev/null
+++ b/src/main/java/com/devoteme/minecraft/util/Text.java
@@ -0,0 +1,36 @@
+package com.devoteme.minecraft.util;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.minimessage.MiniMessage;
+import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
+import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
+import com.devoteme.minecraft.model.Votd;
+
+public class Text {
+ private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage();
+ private static final LegacyComponentSerializer LEGACY_SERIALIZER = LegacyComponentSerializer.legacySection();
+
+ public static Component render(String template, Votd v) {
+ if (template == null) return Component.empty();
+ return MINI_MESSAGE.deserialize(template,
+ Placeholder.unparsed("reference", safe(v.reference())),
+ Placeholder.unparsed("verse", safe(v.verse())),
+ Placeholder.unparsed("link", safe(v.link())),
+ Placeholder.unparsed("date", safe(v.dateText()))
+ );
+ }
+
+ public static Component render(String text) {
+ if (text == null) return Component.empty();
+ return MINI_MESSAGE.deserialize(text);
+ }
+
+ public static String toLegacy(String template, Votd v) {
+ if (template == null) return "";
+ return LEGACY_SERIALIZER.serialize(render(template, v));
+ }
+
+ private static String safe(String s) {
+ return s == null ? "" : s;
+ }
+}
diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml
new file mode 100644
index 0000000..827de32
--- /dev/null
+++ b/src/main/resources/config.yml
@@ -0,0 +1,33 @@
+devoteme:
+ baseUrl: "https://your-devoteme-api-domain.com"
+ apiKey: "" # optional; set if you require x-access-token
+ refreshHours: 12
+
+login:
+ sendVotd: true
+ format:
+ - "Verse of the Day"
+ - ""
+ - ""
+ - ""
+ - "Use /votd to view again."
+
+votd:
+ chatFormat:
+ - "Verse of the Day ()"
+ - ""
+ - ""
+ - "'>"
+
+holograms:
+ enabled: true
+ lines:
+ - "Verse of the Day"
+ - ""
+ - ""
+ - ""
+
+messages:
+ loading: "DevoteMe content is loading..."
+ noPermission: "No permission."
+ hologramsDisabled: "Holograms are disabled or DecentHolograms is missing."
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
new file mode 100644
index 0000000..98b59c7
--- /dev/null
+++ b/src/main/resources/plugin.yml
@@ -0,0 +1,21 @@
+name: DevoteMeMC
+version: 1.0.0
+main: com.devoteme.minecraft.DevoteMePlugin
+api-version: "1.20"
+softdepend: [DecentHolograms]
+
+commands:
+ votd:
+ description: View the Verse of the Day
+ usage: /votd [manage]
+ devotion:
+ description: Open today's devotion
+ usage: /devotion
+
+permissions:
+ devoteme.votd:
+ default: true
+ devoteme.devotion:
+ default: true
+ devoteme.votd.manage:
+ default: op
diff --git a/src/test/java/com/devoteme/minecraft/api/DevoteMeParserTest.java b/src/test/java/com/devoteme/minecraft/api/DevoteMeParserTest.java
new file mode 100644
index 0000000..7aa5116
--- /dev/null
+++ b/src/test/java/com/devoteme/minecraft/api/DevoteMeParserTest.java
@@ -0,0 +1,31 @@
+package com.devoteme.minecraft.api;
+
+import com.devoteme.minecraft.model.Devotion;
+import com.devoteme.minecraft.model.Votd;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class DevoteMeParserTest {
+
+ @Test
+ public void testParseVotd() {
+ String json = "{\"reference\": \"John 3:16\", \"verse\": \"For God so loved the world...\", \"link\": \"https://bible.com/1\", \"date\": \"2024-05-20\"}";
+ Votd votd = DevoteMeParser.parseVotd(json);
+ assertEquals("John 3:16", votd.reference());
+ assertEquals("For God so loved the world...", votd.verse());
+ assertEquals("https://bible.com/1", votd.link());
+ assertEquals("2024-05-20", votd.dateText());
+ }
+
+ @Test
+ public void testParseDevotion() {
+ String json = "{\"title\": \"Today's Heading\", \"reading\": \"Psalm 23\", \"content\": [\"Para 1\", \"Para 2\"], \"bibleInOneYear\": \"Year 1\"}";
+ Devotion devotion = DevoteMeParser.parseDevotion(json);
+ assertEquals("Today's Heading", devotion.title());
+ assertEquals("Psalm 23", devotion.reading());
+ assertEquals(2, devotion.content().size());
+ assertEquals("Para 1", devotion.content().get(0));
+ assertEquals("Year 1", devotion.bibleInOneYear());
+ }
+}
From 42d39e6cf8bee0edb8553edd4035238976b74c69 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Tue, 17 Feb 2026 09:16:15 +0000
Subject: [PATCH 2/4] Implement DevoteMe Minecraft plugin with documentation
- Fetches and caches VOTD and Devotion from DevoteMe API.
- Uses Adventure MiniMessage for all text formatting.
- Implements /votd and /devotion commands.
- Supports VOTD holograms with DecentHolograms (soft dependency).
- Persists hologram locations in votd-locations.yml.
- Automatic content refresh every 12 hours.
- Comprehensive README.md with user documentation.
Co-authored-by: benrobson <15405528+benrobson@users.noreply.github.com>
---
README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 55 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index d5eb05d..93d4237 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,56 @@
# DevoteMe-Minecraft
-The DevoteMe Minecraft module for the DevoteMe product suite.
+
+The DevoteMe Minecraft module is a Paper plugin that connects to your DevoteMe API and provides a daily "Verse of the Day" (VOTD) and a daily devotional experience directly in-game.
+
+## Features
+
+- **Verse of the Day (VOTD):** Automatically sent to players on login and viewable on-demand via `/votd`.
+- **Daily Devotion:** Opens a rich, paginated book GUI using `/devotion`.
+- **MiniMessage Formatting:** Full support for modern Minecraft text formatting, including gradients, bold text, and hover/click events.
+- **Caching & Reliability:** Fetches content from the DevoteMe API and caches it in memory. If the API is down, the plugin continues to serve the last successful result.
+- **Automated Refreshes:** Refreshes content every 12 hours (configurable) to keep the experience fresh.
+- **Hologram Support:** Integrated with **DecentHolograms** to display the VOTD at multiple locations across your server.
+- **Hologram Management:** In-game commands to create, list, and remove VOTD holograms without editing configuration files.
+
+## Installation
+
+1. Download the latest `DevoteMeMC.jar`.
+2. Place the jar in your server's `plugins` folder.
+3. Restart the server to generate the default configuration.
+4. Edit `plugins/DevoteMeMC/config.yml` to set your `baseUrl` and optional `apiKey`.
+5. (Optional) Install **DecentHolograms** if you wish to use the hologram features.
+6. Run `/votd manage refresh` to fetch the initial content.
+
+## Commands
+
+### Player Commands
+- `/votd` - View the current Verse of the Day.
+- `/devotion` - Open today's devotion in a book GUI.
+
+### Admin Commands
+- `/votd manage add ` - Create a VOTD hologram at your current location.
+- `/votd manage remove ` - Remove a specific VOTD hologram.
+- `/votd manage removeNearest [radius]` - Remove the closest VOTD hologram within a radius (default 5 blocks).
+- `/votd manage list` - List all saved VOTD hologram locations.
+- `/votd manage reset` - Delete all VOTD holograms and clear saved locations.
+- `/votd manage refresh` - Manually force a refresh of the VOTD and Devotion from the API.
+
+## Permissions
+
+- `devoteme.votd` - Allows viewing the Verse of the Day (Default: true).
+- `devoteme.devotion` - Allows viewing the daily devotion (Default: true).
+- `devoteme.votd.manage` - Allows access to all `/votd manage` subcommands (Default: op).
+
+## Configuration
+
+The plugin uses **Adventure MiniMessage** for all messages. You can use tags like ``, ``, ``, and ``.
+
+### Placeholders
+In `config.yml`, you can use the following placeholders in VOTD-related messages and holograms:
+- `` - The scripture reference (e.g., John 3:16).
+- `` - The text of the verse.
+- `` - The display date for the content.
+- `` - A URL to the full passage.
+
+## Data Storage
+Hologram locations are stored in `plugins/DevoteMeMC/votd-locations.yml`. This file is managed automatically via commands, but can be manually edited if necessary.
From 4c155e8c812926440b0b14098a5448b36a26fa5e Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Wed, 18 Feb 2026 10:54:08 +0000
Subject: [PATCH 3/4] Improve pagination, hologram placement, and text wrapping
- Improved BookBuilder pagination to split long paragraphs and increased page limit to 400.
- Updated hologram placement to target the block the player is looking at.
- Implemented automatic line wrapping for verse content in holograms (40 chars).
- Increased default 'removenearest' radius to 7.0 for better accuracy.
- Added unit tests for text wrapping logic.
Co-authored-by: benrobson <15405528+benrobson@users.noreply.github.com>
---
.../minecraft/commands/VotdCommand.java | 13 +++--
.../holograms/DecentHologramsService.java | 10 +++-
.../devoteme/minecraft/util/BookBuilder.java | 47 ++++++++++++++++++-
.../com/devoteme/minecraft/util/Text.java | 22 +++++++++
.../com/devoteme/minecraft/util/TextTest.java | 25 ++++++++++
5 files changed, 111 insertions(+), 6 deletions(-)
create mode 100644 src/test/java/com/devoteme/minecraft/util/TextTest.java
diff --git a/src/main/java/com/devoteme/minecraft/commands/VotdCommand.java b/src/main/java/com/devoteme/minecraft/commands/VotdCommand.java
index 91c88ce..bfd0af5 100644
--- a/src/main/java/com/devoteme/minecraft/commands/VotdCommand.java
+++ b/src/main/java/com/devoteme/minecraft/commands/VotdCommand.java
@@ -82,9 +82,14 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String
return true;
}
- Location loc = p.getLocation().clone();
- loc.setX(Math.floor(loc.getX()) + 0.5);
- loc.setZ(Math.floor(loc.getZ()) + 0.5);
+ org.bukkit.block.Block target = p.getTargetBlockExact(5);
+ Location loc;
+ if (target != null) {
+ loc = target.getLocation().add(0.5, 1.2, 0.5);
+ } else {
+ loc = p.getLocation().clone().add(p.getLocation().getDirection().multiply(2));
+ loc.setY(loc.getY() + 1.2);
+ }
store.put(name, loc);
store.save();
@@ -121,7 +126,7 @@ public boolean onCommand(CommandSender sender, Command cmd, String label, String
p.sendMessage(Text.render(plugin.getConfig().getString("messages.hologramsDisabled")));
return true;
}
- double radius = 5.0;
+ double radius = 7.0;
if (args.length >= 3) {
try { radius = Double.parseDouble(args[2]); } catch (NumberFormatException ignored) {}
}
diff --git a/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java b/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java
index 6d0b844..081b316 100644
--- a/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java
+++ b/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java
@@ -138,7 +138,15 @@ private List buildLines() {
}
for (String t : templates) {
- out.add(Text.toLegacy(t, v));
+ if (t.contains("") || t.contains("{verse}")) {
+ List wrapped = Text.wrap(v.verse(), 40);
+ for (String verseLine : wrapped) {
+ String lineTemplate = t.replace("", verseLine).replace("{verse}", verseLine);
+ out.add(Text.toLegacy(lineTemplate, v));
+ }
+ } else {
+ out.add(Text.toLegacy(t, v));
+ }
}
return out;
}
diff --git a/src/main/java/com/devoteme/minecraft/util/BookBuilder.java b/src/main/java/com/devoteme/minecraft/util/BookBuilder.java
index 46cadf1..1d096db 100644
--- a/src/main/java/com/devoteme/minecraft/util/BookBuilder.java
+++ b/src/main/java/com/devoteme/minecraft/util/BookBuilder.java
@@ -10,7 +10,27 @@
public class BookBuilder {
- private static final int PAGE_CHAR_LIMIT = 250;
+ private static final int PAGE_CHAR_LIMIT = 400;
+
+ private static List splitComponent(Component part, String plain, int limit) {
+ List results = new ArrayList<>();
+ String[] words = plain.split(" ");
+ StringBuilder sb = new StringBuilder();
+
+ for (String word : words) {
+ if (sb.length() + word.length() + 1 > limit && sb.length() > 0) {
+ String chunkText = sb.toString().trim();
+ results.add(part.replaceText(config -> config.matchLiteral(plain).replacement(chunkText)));
+ sb = new StringBuilder();
+ }
+ sb.append(word).append(" ");
+ }
+ if (sb.length() > 0) {
+ String chunkText = sb.toString().trim();
+ results.add(part.replaceText(config -> config.matchLiteral(plain).replacement(chunkText)));
+ }
+ return results;
+ }
public static void openBook(Player p, String title, String author, List body, Component footer) {
List pages = new ArrayList<>();
@@ -19,6 +39,31 @@ public static void openBook(Player p, String title, String author, List PAGE_CHAR_LIMIT) {
+ // First, push current page if not empty
+ if (charCount > 0) {
+ pages.add(currentPage);
+ currentPage = Component.empty();
+ charCount = 0;
+ }
+
+ // Split the long part into smaller chunks
+ List chunks = splitComponent(part, plain, PAGE_CHAR_LIMIT);
+ for (int i = 0; i < chunks.size(); i++) {
+ Component chunk = chunks.get(i);
+ if (i == chunks.size() - 1) {
+ // Last chunk might fit with more stuff
+ currentPage = chunk.append(Component.newline());
+ charCount = PlainTextComponentSerializer.plainText().serialize(chunk).length() + 1;
+ } else {
+ pages.add(chunk);
+ }
+ }
+ continue;
+ }
+
if (charCount + plain.length() > PAGE_CHAR_LIMIT && charCount > 0) {
pages.add(currentPage);
currentPage = Component.empty();
diff --git a/src/main/java/com/devoteme/minecraft/util/Text.java b/src/main/java/com/devoteme/minecraft/util/Text.java
index 683f743..21d05ff 100644
--- a/src/main/java/com/devoteme/minecraft/util/Text.java
+++ b/src/main/java/com/devoteme/minecraft/util/Text.java
@@ -6,6 +6,9 @@
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import com.devoteme.minecraft.model.Votd;
+import java.util.ArrayList;
+import java.util.List;
+
public class Text {
private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage();
private static final LegacyComponentSerializer LEGACY_SERIALIZER = LegacyComponentSerializer.legacySection();
@@ -30,6 +33,25 @@ public static String toLegacy(String template, Votd v) {
return LEGACY_SERIALIZER.serialize(render(template, v));
}
+ public static List wrap(String text, int limit) {
+ List lines = new ArrayList<>();
+ if (text == null || text.isBlank()) return lines;
+
+ String[] words = text.split(" ");
+ StringBuilder sb = new StringBuilder();
+ for (String word : words) {
+ if (sb.length() + word.length() + 1 > limit && sb.length() > 0) {
+ lines.add(sb.toString().trim());
+ sb = new StringBuilder();
+ }
+ sb.append(word).append(" ");
+ }
+ if (sb.length() > 0) {
+ lines.add(sb.toString().trim());
+ }
+ return lines;
+ }
+
private static String safe(String s) {
return s == null ? "" : s;
}
diff --git a/src/test/java/com/devoteme/minecraft/util/TextTest.java b/src/test/java/com/devoteme/minecraft/util/TextTest.java
new file mode 100644
index 0000000..dfcb6a6
--- /dev/null
+++ b/src/test/java/com/devoteme/minecraft/util/TextTest.java
@@ -0,0 +1,25 @@
+package com.devoteme.minecraft.util;
+
+import org.junit.jupiter.api.Test;
+import java.util.List;
+import static org.junit.jupiter.api.Assertions.*;
+
+public class TextTest {
+
+ @Test
+ public void testWrap() {
+ String text = "This is a long sentence that should be wrapped into multiple lines.";
+ List wrapped = Text.wrap(text, 20);
+
+ assertFalse(wrapped.isEmpty());
+ for (String line : wrapped) {
+ assertTrue(line.length() <= 20, "Line too long: " + line);
+ }
+
+ assertEquals("This is a long", wrapped.get(0));
+ assertEquals("sentence that", wrapped.get(1));
+ assertEquals("should be wrapped", wrapped.get(2));
+ assertEquals("into multiple", wrapped.get(3));
+ assertEquals("lines.", wrapped.get(4));
+ }
+}
From 9268cd4c3f34ff3c03cb5dc94ac50efc480bd3af Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Fri, 20 Feb 2026 04:13:15 +0000
Subject: [PATCH 4/4] Fix hologram updates, book pagination, and Discord
timestamp issues
- Fixed hologram refresh by using teleportHologram and updating lines on existing objects.
- Optimized devotion book pagination to 320 characters per page.
- Implemented automatic stripping of Discord-style timestamps from API content.
- Refined hologram verse wrapping to 30 characters and improved style preservation.
- Added comprehensive unit tests for content cleaning logic.
Co-authored-by: benrobson <15405528+benrobson@users.noreply.github.com>
---
.../devoteme/minecraft/api/DevoteMeParser.java | 13 ++++++++++---
.../holograms/DecentHologramsService.java | 16 +++++++++++++---
.../com/devoteme/minecraft/util/BookBuilder.java | 2 +-
.../minecraft/api/DevoteMeParserTest.java | 11 +++++++++++
4 files changed, 35 insertions(+), 7 deletions(-)
diff --git a/src/main/java/com/devoteme/minecraft/api/DevoteMeParser.java b/src/main/java/com/devoteme/minecraft/api/DevoteMeParser.java
index a498fbe..e54c462 100644
--- a/src/main/java/com/devoteme/minecraft/api/DevoteMeParser.java
+++ b/src/main/java/com/devoteme/minecraft/api/DevoteMeParser.java
@@ -43,10 +43,10 @@ public static Devotion parseDevotion(String json) {
JsonElement c = o.get("content");
if (c != null && c.isJsonArray()) {
for (JsonElement p : c.getAsJsonArray()) {
- if (p != null && p.isJsonPrimitive()) content.add(p.getAsString());
+ if (p != null && p.isJsonPrimitive()) content.add(clean(p.getAsString()));
}
} else if (c != null && c.isJsonPrimitive()) {
- content.add(c.getAsString());
+ content.add(clean(c.getAsString()));
} else {
// fallback possible keys
String body = pick(o, "body", "text");
@@ -65,8 +65,15 @@ private static String pick(JsonObject o, String... keys) {
if (!o.has(k)) continue;
JsonElement e = o.get(k);
if (e == null || e.isJsonNull()) continue;
- if (e.isJsonPrimitive()) return e.getAsString();
+ if (e.isJsonPrimitive()) return clean(e.getAsString());
}
return null;
}
+
+ private static String clean(String text) {
+ if (text == null) return "";
+ // Strips common Discord-style timestamps that might be accidentally included
+ // e.g. [10:44 AM]Wednesday, 18 February 2026 10:44 AM
+ return text.replaceAll("\\[\\d{1,2}:\\d{2}\\s?[AP]M\\].*?(\\d{4}.*?\\d{1,2}:\\d{2}\\s?[AP]M|\\d{4})", "").trim();
+ }
}
diff --git a/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java b/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java
index 081b316..c766f12 100644
--- a/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java
+++ b/src/main/java/com/devoteme/minecraft/holograms/DecentHologramsService.java
@@ -85,8 +85,14 @@ public void createOrUpdate(String name, Location loc) {
if (holo == null) {
holo = createHologram.invoke(null, id, loc);
} else {
- removeHologram.invoke(null, id);
- holo = createHologram.invoke(null, id, loc);
+ // Try to teleport if it moved
+ try {
+ Method teleport = dhapiClass.getMethod("teleportHologram", String.class, Location.class);
+ teleport.invoke(null, id, loc);
+ } catch (NoSuchMethodException ignored) {
+ // If teleport method not found, we might have to remove/recreate as fallback,
+ // but we try to avoid it.
+ }
}
List lines = buildLines();
@@ -137,9 +143,13 @@ private List buildLines() {
return out;
}
+ // Prepare a version of the verse without any accidental MiniMessage tags for the hologram
+ String plainVerse = net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer.plainText()
+ .serialize(net.kyori.adventure.text.minimessage.MiniMessage.miniMessage().deserialize(v.verse()));
+
for (String t : templates) {
if (t.contains("") || t.contains("{verse}")) {
- List wrapped = Text.wrap(v.verse(), 40);
+ List wrapped = Text.wrap(plainVerse, 30); // Narrower for holograms
for (String verseLine : wrapped) {
String lineTemplate = t.replace("", verseLine).replace("{verse}", verseLine);
out.add(Text.toLegacy(lineTemplate, v));
diff --git a/src/main/java/com/devoteme/minecraft/util/BookBuilder.java b/src/main/java/com/devoteme/minecraft/util/BookBuilder.java
index 1d096db..77aebd1 100644
--- a/src/main/java/com/devoteme/minecraft/util/BookBuilder.java
+++ b/src/main/java/com/devoteme/minecraft/util/BookBuilder.java
@@ -10,7 +10,7 @@
public class BookBuilder {
- private static final int PAGE_CHAR_LIMIT = 400;
+ private static final int PAGE_CHAR_LIMIT = 320;
private static List splitComponent(Component part, String plain, int limit) {
List results = new ArrayList<>();
diff --git a/src/test/java/com/devoteme/minecraft/api/DevoteMeParserTest.java b/src/test/java/com/devoteme/minecraft/api/DevoteMeParserTest.java
index 7aa5116..ce5c34f 100644
--- a/src/test/java/com/devoteme/minecraft/api/DevoteMeParserTest.java
+++ b/src/test/java/com/devoteme/minecraft/api/DevoteMeParserTest.java
@@ -28,4 +28,15 @@ public void testParseDevotion() {
assertEquals("Para 1", devotion.content().get(0));
assertEquals("Year 1", devotion.bibleInOneYear());
}
+
+ @Test
+ public void testClean() {
+ String input = "[10:44 AM]Wednesday, 18 February 2026 10:44 AM Real Content";
+ Votd votd = DevoteMeParser.parseVotd("{\"verse\": \"" + input + "\"}");
+ assertEquals("Real Content", votd.verse());
+
+ String input2 = "[9:00 PM]Monday, 1 January 2024 Content here";
+ Votd votd2 = DevoteMeParser.parseVotd("{\"verse\": \"" + input2 + "\"}");
+ assertEquals("Content here", votd2.verse());
+ }
}