From 6faa8f1b8c9fbb204d40502f76c8c32298b92704 Mon Sep 17 00:00:00 2001 From: GoodrichDev Date: Fri, 21 Aug 2026 11:47:28 -0700 Subject: [PATCH 1/9] Use headdb.net as the managed catalog source --- README.md | 5 +++++ .../java/com/bitworksmc/headdb/core/config/Config.java | 2 +- .../bitworksmc/headdb/implementation/BaseHeadDatabase.java | 7 ++++--- headdb-core/src/main/resources/config.yml | 5 +++-- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9bf5abe..03b3959 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,11 @@ - **Flexible Querying** Search by name, ID, category, or tags. +Browse the catalog, submit heads, and read the plugin and HTTP API documentation at +[headdb.net](https://headdb.net). HeadDB 6.x downloads its managed compatibility +snapshot from `https://headdb.net/api/v1/legacy/heads.json` and falls back to the +BitworksMC GitHub snapshot if the managed service is unavailable. + --- ## ๐Ÿš€ Download & Installation diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java index 8c5746d..f078faf 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java @@ -24,7 +24,7 @@ public class Config { private static final Logger LOGGER = LoggerFactory.getLogger(Config.class); // === Default Head Textures === - private static final String DEFAULT_DATABASE_SOURCE_URL = "https://raw.githubusercontent.com/BitworksMC/HeadDB/refs/heads/master/heads.json"; + private static final String DEFAULT_DATABASE_SOURCE_URL = "https://headdb.net/api/v1/legacy/heads.json"; private static final String DEFAULT_BACK_TEXTURE = "e5da4847272582265bdaca367237c96122b139f4e597fbc6667d3fb75fea7cf6"; private static final String DEFAULT_INFO_TEXTURE = "93e5cb83cfdf42e9c4d8a3ecb4f889f6a5f418dce0a894c97e416a0eaf0d58"; private static final String DEFAULT_NEXT_TEXTURE = "62bfb7ed2bd9f1d1f85c3d6ffb1626f252c5ecfd79d51a3f56ebf8e0c3c91"; diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadDatabase.java b/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadDatabase.java index 58b5bb0..84da858 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadDatabase.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadDatabase.java @@ -28,7 +28,8 @@ public class BaseHeadDatabase implements HeadDatabase { private static final Logger LOGGER = LoggerFactory.getLogger(BaseHeadDatabase.class); private static final Gson GSON = new GsonBuilder().registerTypeAdapter(Head.class, new HeadMapper()).create(); - private static final String DEFAULT_SOURCE_URL = "https://raw.githubusercontent.com/BitworksMC/HeadDB/refs/heads/master/heads.json"; + private static final String DEFAULT_SOURCE_URL = "https://headdb.net/api/v1/legacy/heads.json"; + private static final String DEFAULT_FALLBACK_SOURCE_URL = "https://raw.githubusercontent.com/BitworksMC/HeadDB/refs/heads/master/heads.json"; private static final int CONNECT_TIMEOUT_MILLIS = 10_000; private static final int READ_TIMEOUT_MILLIS = 30_000; private final Executor executor; @@ -246,7 +247,7 @@ private static boolean isGzipEncoded(@Nullable String contentEncoding) { private static List normalizeSourceUrls(@Nullable List sourceUrls) { if (sourceUrls == null || sourceUrls.isEmpty()) { - return List.of(DEFAULT_SOURCE_URL); + return List.of(DEFAULT_SOURCE_URL, DEFAULT_FALLBACK_SOURCE_URL); } LinkedHashSet normalized = new LinkedHashSet<>(); @@ -262,7 +263,7 @@ private static List normalizeSourceUrls(@Nullable List sourceUrl } if (normalized.isEmpty()) { - return List.of(DEFAULT_SOURCE_URL); + return List.of(DEFAULT_SOURCE_URL, DEFAULT_FALLBACK_SOURCE_URL); } return List.copyOf(normalized); diff --git a/headdb-core/src/main/resources/config.yml b/headdb-core/src/main/resources/config.yml index 912f066..ecd66ec 100644 --- a/headdb-core/src/main/resources/config.yml +++ b/headdb-core/src/main/resources/config.yml @@ -107,9 +107,10 @@ controls: # Database Configuration database: # Primary URL used to fetch the head database JSON. - sourceUrl: "https://raw.githubusercontent.com/BitworksMC/HeadDB/refs/heads/master/heads.json" + sourceUrl: "https://headdb.net/api/v1/legacy/heads.json" # Optional fallback URLs that are tried in order if the primary source fails. - fallbackSourceUrls: [] + fallbackSourceUrls: + - "https://raw.githubusercontent.com/BitworksMC/HeadDB/refs/heads/master/heads.json" # Threads pool for the database threads: 1 # Threads pool for the API From ca782bcd03b7eb32469d16060baa7c2b7224e56c Mon Sep 17 00:00:00 2001 From: GoodrichDev Date: Fri, 21 Aug 2026 13:28:32 -0700 Subject: [PATCH 2/9] Sync catalog updates by revision --- README.md | 6 +- .../thesilentpro/headdb/api/model/Head.java | 9 + .../com/bitworksmc/headdb/core/HeadDB.java | 23 +- .../bitworksmc/headdb/core/config/Config.java | 22 +- .../core/factory/LegacyItemFactory.java | 2 +- .../headdb/core/factory/PaperItemFactory.java | 2 +- .../implementation/BaseHeadDatabase.java | 264 +++++++++++++++++- .../headdb/implementation/model/BaseHead.java | 13 + .../implementation/model/HeadMapper.java | 11 +- headdb-core/src/main/resources/config.yml | 13 +- .../implementation/BaseHeadDatabaseTest.java | 76 +++++ .../bitworksmc/headdb/legacy/LegacyHead.java | 6 + .../headdb/legacy/LegacyItemFactory.java | 14 +- 13 files changed, 427 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 03b3959..5745781 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ Search by name, ID, category, or tags. Browse the catalog, submit heads, and read the plugin and HTTP API documentation at -[headdb.net](https://headdb.net). HeadDB 6.x downloads its managed compatibility -snapshot from `https://headdb.net/api/v1/legacy/heads.json` and falls back to the -BitworksMC GitHub snapshot if the managed service is unavailable. +[headdb.net](https://headdb.net). The modern plugin restores its saved catalog, +then polls the managed revision feed for additions, edits, and removals. A complete +snapshot and the legacy BitworksMC GitHub catalog remain available for recovery. --- diff --git a/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/model/Head.java b/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/model/Head.java index 939fcf5..c1cb0d8 100644 --- a/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/model/Head.java +++ b/headdb-api/src/main/java/com/github/thesilentpro/headdb/api/model/Head.java @@ -16,6 +16,15 @@ public interface Head { String getTexture(); + /** + * Returns the complete skin URL used to create this head. Older API + * implementations only expose a Mojang texture hash, so that remains the + * default. + */ + default String getTextureUrl() { + return "https://textures.minecraft.net/texture/" + getTexture(); + } + String getCategory(); List getTags(); diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/HeadDB.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/HeadDB.java index 326e473..a2d49c6 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/HeadDB.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/HeadDB.java @@ -3,7 +3,6 @@ import com.github.thesilentpro.grim.listener.PageListeners; import com.github.thesilentpro.grim.page.registry.PageRegistry; import com.bitworksmc.headdb.api.HeadAPI; -import com.bitworksmc.headdb.api.HeadDatabase; import com.bitworksmc.headdb.api.model.Head; import com.bitworksmc.headdb.core.api.LegacyHeadAPIAdapter; import com.bitworksmc.headdb.core.command.HDBMainCommand; @@ -42,7 +41,7 @@ public class HeadDB extends JavaPlugin { private static final int PRELOAD_BATCH_SIZE = 250; private ConfigManager configManager; - private HeadDatabase headDatabase; + private BaseHeadDatabase headDatabase; private HeadAPI headApi; private ExecutorService databaseExecutor; private HDBSubCommandManager subCommandManager; @@ -88,6 +87,8 @@ public void onEnable() { this.headDatabase = new BaseHeadDatabase( databaseExecutor, config.getDatabaseSourceUrls(), + config.getDatabaseSyncUrl(), + getDataFolder().toPath().resolve("catalog-cache.json"), config.resolveEnabledIndexes() ); this.headDatabase.update().thenAcceptAsync(heads -> handleDatabaseUpdate(config, heads), Compatibility.getMainThreadExecutor(this)); @@ -131,13 +132,19 @@ public void onEnable() { // Start updater task if (config.isUpdaterEnabled()) { - Compatibility.runAsyncRepeating(this, () -> + long syncIntervalTicks = config.getDatabaseSyncIntervalMinutes() * 60L * 20L; + Compatibility.runAsyncRepeating(this, () -> { + int previousRevision = this.headDatabase.getCatalogRevision(); this.headDatabase.update().thenAcceptAsync(heads -> { - handleDatabaseUpdate(config, heads); - this.menuManager.registerDefaults(this, heads); - }, Compatibility.getMainThreadExecutor(this)), - 86400L * 20L, - 86400L * 20L + int currentRevision = this.headDatabase.getCatalogRevision(); + if (currentRevision != previousRevision || currentRevision < 0) { + handleDatabaseUpdate(config, heads); + this.menuManager.registerDefaults(this, heads); + } + }, Compatibility.getMainThreadExecutor(this)); + }, + syncIntervalTicks, + syncIntervalTicks ); } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java index f078faf..043185b 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java @@ -24,7 +24,9 @@ public class Config { private static final Logger LOGGER = LoggerFactory.getLogger(Config.class); // === Default Head Textures === - private static final String DEFAULT_DATABASE_SOURCE_URL = "https://headdb.net/api/v1/legacy/heads.json"; + private static final String DEFAULT_DATABASE_SOURCE_URL = "https://headdb.net/api/v1/catalog/snapshot"; + private static final String DEFAULT_DATABASE_SYNC_URL = "https://headdb.net/api/v1/catalog/changes"; + private static final String LEGACY_DATABASE_SOURCE_URL = "https://headdb.net/api/v1/legacy/heads.json"; private static final String DEFAULT_BACK_TEXTURE = "e5da4847272582265bdaca367237c96122b139f4e597fbc6667d3fb75fea7cf6"; private static final String DEFAULT_INFO_TEXTURE = "93e5cb83cfdf42e9c4d8a3ecb4f889f6a5f418dce0a894c97e416a0eaf0d58"; private static final String DEFAULT_NEXT_TEXTURE = "62bfb7ed2bd9f1d1f85c3d6ffb1626f252c5ecfd79d51a3f56ebf8e0c3c91"; @@ -38,12 +40,14 @@ public class Config { // General private long playerStorageSaveInterval; private int databaseThreads, apiThreads; + private long databaseSyncIntervalMinutes; private boolean preloadHeads, trackPage, updaterEnabled; private boolean updateCheckerEnabled, updateCheckerNotifyConsole, updateCheckerNotifyPlayers; private long updateCheckerIntervalHours; private int maxBuyAmount; private List omit; private List databaseSourceUrls = List.of(DEFAULT_DATABASE_SOURCE_URL); + private String databaseSyncUrl = DEFAULT_DATABASE_SYNC_URL; // Indexing private boolean indexingEnabled, indexById, indexByTexture, indexByCategory, indexByTag; @@ -90,6 +94,11 @@ private void loadGeneral() { preloadHeads = config.getBoolean("preloadHeads", false); databaseThreads = positiveInt("database.threads", 1); apiThreads = positiveInt("database.apiThreads", 1); + databaseSyncIntervalMinutes = positiveLong("database.syncIntervalMinutes", 15L); + databaseSyncUrl = Optional.ofNullable(config.getString("database.syncUrl", DEFAULT_DATABASE_SYNC_URL)) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .orElse(null); maxBuyAmount = positiveInt("maxBuyAmount", 2304); omit = config.getIntegerList("head.omit"); loadDatabaseSources(); @@ -106,11 +115,16 @@ private void loadGeneral() { LOGGER.trace(" - databaseThreads = {}", databaseThreads); LOGGER.trace(" - apiThreads = {}", apiThreads); LOGGER.trace(" - databaseSourceUrls = {}", databaseSourceUrls); + LOGGER.trace(" - databaseSyncUrl = {}", databaseSyncUrl); + LOGGER.trace(" - databaseSyncIntervalMinutes = {}", databaseSyncIntervalMinutes); LOGGER.trace(" - maxBuyAmount = {}", maxBuyAmount); } private void loadDatabaseSources() { String primarySource = config.getString("database.sourceUrl", DEFAULT_DATABASE_SOURCE_URL); + if (LEGACY_DATABASE_SOURCE_URL.equals(primarySource)) { + primarySource = DEFAULT_DATABASE_SOURCE_URL; + } List fallbackSources = config.getStringList("database.fallbackSourceUrls"); LinkedHashSet orderedSources = new LinkedHashSet<>(); @@ -122,6 +136,10 @@ private void loadDatabaseSources() { } } + if (DEFAULT_DATABASE_SOURCE_URL.equals(primarySource)) { + orderedSources.add(LEGACY_DATABASE_SOURCE_URL); + } + for (String source : fallbackSources) { if (source == null) { continue; @@ -448,6 +466,8 @@ public List resolveCustomCategories(List loadedHeads) { public int getDatabaseThreads() { return databaseThreads; } public int getApiThreads() { return apiThreads; } public List getDatabaseSourceUrls() { return databaseSourceUrls; } + public @Nullable String getDatabaseSyncUrl() { return databaseSyncUrl; } + public long getDatabaseSyncIntervalMinutes() { return databaseSyncIntervalMinutes; } public int getMaxBuyAmount() { return maxBuyAmount; } public List getOmit() { return omit; } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/LegacyItemFactory.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/LegacyItemFactory.java index 2fa264f..d2db308 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/LegacyItemFactory.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/LegacyItemFactory.java @@ -40,7 +40,7 @@ public ItemStack asItem(Head head) { try { PlayerTextures textures = profile.getTextures(); - textures.setSkin(URI.create("https://textures.minecraft.net/texture/" + head.getTexture()).toURL()); + textures.setSkin(URI.create(head.getTextureUrl()).toURL()); profile.setTextures(textures); } catch (IllegalArgumentException | MalformedURLException ex) { LOGGER.error("Failed to set texture for {} (ID:{} | Texture: {})", head.getName(), head.getId(), head.getTexture(), ex); diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java index cf82d96..746d143 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java @@ -38,7 +38,7 @@ public ItemStack asItem(Head head) { try { PlayerTextures textures = profile.getTextures(); - textures.setSkin(URI.create("https://textures.minecraft.net/texture/" + head.getTexture()).toURL()); + textures.setSkin(URI.create(head.getTextureUrl()).toURL()); profile.setTextures(textures); } catch (IllegalArgumentException | MalformedURLException ex) { LOGGER.error("Failed to set texture for {} (ID:{} | Texture: {})", head.getName(), head.getId(), head.getTexture(), ex); diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadDatabase.java b/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadDatabase.java index 84da858..43bde44 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadDatabase.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadDatabase.java @@ -5,6 +5,8 @@ import com.bitworksmc.headdb.implementation.model.HeadMapper; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -17,7 +19,12 @@ import java.net.HttpURLConnection; import java.net.URI; import java.net.URL; +import java.net.URLEncoder; 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.*; import java.util.concurrent.*; import java.util.stream.Collectors; @@ -28,12 +35,15 @@ public class BaseHeadDatabase implements HeadDatabase { private static final Logger LOGGER = LoggerFactory.getLogger(BaseHeadDatabase.class); private static final Gson GSON = new GsonBuilder().registerTypeAdapter(Head.class, new HeadMapper()).create(); - private static final String DEFAULT_SOURCE_URL = "https://headdb.net/api/v1/legacy/heads.json"; + private static final String DEFAULT_SOURCE_URL = "https://headdb.net/api/v1/catalog/snapshot"; private static final String DEFAULT_FALLBACK_SOURCE_URL = "https://raw.githubusercontent.com/BitworksMC/HeadDB/refs/heads/master/heads.json"; + private static final String DEFAULT_SYNC_URL = "https://headdb.net/api/v1/catalog/changes"; private static final int CONNECT_TIMEOUT_MILLIS = 10_000; private static final int READ_TIMEOUT_MILLIS = 30_000; private final Executor executor; private final List sourceUrls; + private final @Nullable String syncUrl; + private final @Nullable Path cachePath; private final EnumSet indexes; private final Object updateLock = new Object(); @@ -42,17 +52,30 @@ public class BaseHeadDatabase implements HeadDatabase { * observing heads from one update alongside indexes from another update. */ private volatile Snapshot snapshot; + private volatile int catalogRevision = -1; // track the latest load private volatile CompletableFuture> lastUpdateFuture; public BaseHeadDatabase(@Nullable Executor executor, @Nullable List sourceUrls, @Nullable Index... indexes) { + this(executor, sourceUrls, DEFAULT_SYNC_URL, null, indexes); + } + + public BaseHeadDatabase( + @Nullable Executor executor, + @Nullable List sourceUrls, + @Nullable String syncUrl, + @Nullable Path cachePath, + @Nullable Index... indexes + ) { this.executor = executor != null ? executor : Executors.newSingleThreadExecutor(r -> { Thread thread = new Thread(r, "Head Database Worker"); thread.setDaemon(true); return thread; }); this.sourceUrls = normalizeSourceUrls(sourceUrls); + this.syncUrl = syncUrl == null || syncUrl.isBlank() ? null : syncUrl.trim(); + this.cachePath = cachePath; this.indexes = indexes == null || indexes.length == 0 ? EnumSet.noneOf(Index.class) : EnumSet.copyOf(Arrays.asList(indexes)); @@ -82,21 +105,45 @@ public CompletableFuture> update() { return currentUpdate; } - lastUpdateFuture = CompletableFuture.supplyAsync(this::loadSnapshot, executor); + lastUpdateFuture = CompletableFuture.supplyAsync(this::loadDatabase, executor); return lastUpdateFuture; } } - private List loadSnapshot() { + private List loadDatabase() { + boolean restoredCache = snapshot == null && restoreCatalogCache(); + if (snapshot != null && catalogRevision >= 0 && syncUrl != null) { + try { + return loadChanges(); + } catch (IOException | RuntimeException ex) { + LOGGER.warn("Incremental catalog sync failed: {}. Trying a complete snapshot.", ex.getMessage()); + LOGGER.debug("Detailed incremental catalog sync error", ex); + } + } + + try { + return loadFullSnapshot(); + } catch (CompletionException ex) { + if (restoredCache && snapshot != null) { + LOGGER.warn("Remote catalog is unavailable; using the saved catalog revision {}.", catalogRevision); + return snapshot.heads(); + } + throw ex; + } + } + + private List loadFullSnapshot() { LOGGER.debug("Fetching heads..."); long start = System.currentTimeMillis(); Exception lastException = null; for (String sourceUrl : sourceUrls) { try { - List loadedHeads = fetchHeads(sourceUrl); - Snapshot loadedSnapshot = buildSnapshot(loadedHeads); + FetchedCatalog fetched = fetchHeads(sourceUrl); + Snapshot loadedSnapshot = buildSnapshot(fetched.heads()); this.snapshot = loadedSnapshot; + this.catalogRevision = fetched.revision(); + persistCatalogCache(loadedSnapshot.heads(), fetched.revision()); long elapsed = System.currentTimeMillis() - start; LOGGER.debug("Update took {} seconds ({}ms total)", TimeUnit.MILLISECONDS.toSeconds(elapsed), elapsed); @@ -112,7 +159,7 @@ private List loadSnapshot() { throw new CompletionException("Failed to update heads from all configured sources", lastException); } - private List fetchHeads(String sourceUrl) throws IOException { + private FetchedCatalog fetchHeads(String sourceUrl) throws IOException { URL url = URI.create(sourceUrl).toURL(); if (!(url.openConnection() instanceof HttpURLConnection request)) { throw new IOException("Unsupported database URL protocol: " + url.getProtocol()); @@ -162,12 +209,193 @@ private List fetchHeads(String sourceUrl) throws IOException { long parseTime = System.currentTimeMillis() - parseStart; LOGGER.debug("Parsed {} heads from '{}' in {}ms", fetchedHeads.size(), sourceUrl, parseTime); - return fetchedHeads; + int revision = "1".equals(request.getHeaderField("X-Catalog-Schema")) + ? parseRevision(request.getHeaderField("X-Catalog-Revision")) + : -1; + return new FetchedCatalog(fetchedHeads, revision); } finally { request.disconnect(); } } + private List loadChanges() throws IOException { + Snapshot current = Objects.requireNonNull(snapshot, "snapshot"); + int fromRevision = catalogRevision; + int toRevision = -1; + String cursor = null; + boolean changed = false; + Map headsById = new HashMap<>(Math.max(16, current.heads().size() * 2)); + for (Head head : current.heads()) { + headsById.put(head.getId(), head); + } + + do { + CatalogSyncResponse response = fetchChanges(fromRevision, cursor); + if (response.schema != 1 || response.fromRevision != fromRevision) { + throw new IOException("Unsupported or inconsistent catalog sync response"); + } + if (toRevision < 0) { + toRevision = response.toRevision; + } else if (toRevision != response.toRevision) { + throw new IOException("Catalog revision changed during pagination"); + } + if (toRevision < fromRevision) { + throw new IOException("Catalog returned an older revision"); + } + + List changes = response.changes == null + ? Collections.emptyList() + : response.changes; + for (CatalogChange change : changes) { + if (change == null || change.headId <= 0) { + throw new IOException("Catalog returned an invalid change"); + } + if ("remove".equals(change.operation)) { + changed |= headsById.remove(change.headId) != null; + } else if ("upsert".equals(change.operation) + && change.head != null + && change.head.getId() == change.headId) { + headsById.put(change.headId, change.head); + changed = true; + } else { + throw new IOException("Catalog returned an unsupported change operation"); + } + } + + if (response.hasMore && (response.nextCursor == null || response.nextCursor.isBlank())) { + throw new IOException("Catalog omitted a required continuation cursor"); + } + cursor = response.hasMore ? response.nextCursor : null; + } while (cursor != null); + + if (toRevision < 0) { + throw new IOException("Catalog returned no target revision"); + } + if (!changed && toRevision == fromRevision) { + LOGGER.debug("Catalog revision {} is already current.", fromRevision); + return current.heads(); + } + + Snapshot next = current; + if (changed) { + List mergedHeads = new ArrayList<>(headsById.values()); + mergedHeads.sort(Comparator.comparingInt(Head::getId)); + next = buildSnapshot(mergedHeads); + } + persistCatalogCache(next.heads(), toRevision); + this.catalogRevision = toRevision; + this.snapshot = next; + LOGGER.debug("Catalog sync advanced revision {} to {} with {} published heads.", + fromRevision, toRevision, next.heads().size()); + return next.heads(); + } + + private CatalogSyncResponse fetchChanges(int sinceRevision, @Nullable String cursor) throws IOException { + StringBuilder location = new StringBuilder(Objects.requireNonNull(syncUrl)); + location.append(syncUrl.contains("?") ? '&' : '?') + .append("sinceRevision=").append(sinceRevision) + .append("&limit=5000"); + if (cursor != null) { + location.append("&cursor=") + .append(URLEncoder.encode(cursor, StandardCharsets.UTF_8)); + } + + URL url = URI.create(location.toString()).toURL(); + if (!(url.openConnection() instanceof HttpURLConnection request)) { + throw new IOException("Unsupported sync URL protocol: " + url.getProtocol()); + } + request.setRequestProperty("Accept", "application/json"); + request.setRequestProperty("Accept-Encoding", "gzip"); + request.setRequestProperty("User-Agent", "HeadDB"); + request.setConnectTimeout(CONNECT_TIMEOUT_MILLIS); + request.setReadTimeout(READ_TIMEOUT_MILLIS); + request.setInstanceFollowRedirects(true); + + try { + int responseCode = request.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + throw new IOException("Catalog sync HTTP response code " + responseCode); + } + try (InputStream raw = request.getInputStream(); + InputStream in = isGzipEncoded(request.getContentEncoding()) ? new GZIPInputStream(raw) : raw; + BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8), 8192)) { + CatalogSyncResponse response = GSON.fromJson(reader, CatalogSyncResponse.class); + if (response == null) { + throw new IOException("Catalog sync payload was empty"); + } + return response; + } + } finally { + request.disconnect(); + } + } + + private boolean restoreCatalogCache() { + if (cachePath == null || !Files.isRegularFile(cachePath)) { + return false; + } + try { + JsonObject cache = JsonParser.parseString(Files.readString(cachePath, StandardCharsets.UTF_8)) + .getAsJsonObject(); + if (cache.get("schema").getAsInt() != 1) { + throw new IOException("Unsupported cache schema"); + } + int revision = cache.get("revision").getAsInt(); + List heads = GSON.fromJson(cache.get("heads"), HeadMapper.HEADS_LIST_TYPE); + if (revision < 0 || heads == null || heads.isEmpty()) { + throw new IOException("Saved catalog is incomplete"); + } + this.snapshot = buildSnapshot(heads); + this.catalogRevision = revision; + LOGGER.info("Restored {} heads from saved catalog revision {}.", heads.size(), revision); + return true; + } catch (IOException | RuntimeException ex) { + LOGGER.warn("Could not restore saved head catalog '{}': {}", cachePath, ex.getMessage()); + LOGGER.debug("Detailed saved catalog error", ex); + return false; + } + } + + private void persistCatalogCache(List heads, int revision) { + if (cachePath == null || revision < 0) { + return; + } + try { + Path absoluteCache = cachePath.toAbsolutePath(); + Path parent = Objects.requireNonNull(absoluteCache.getParent(), "Catalog cache has no parent directory"); + Files.createDirectories(parent); + Path temporary = absoluteCache.resolveSibling(absoluteCache.getFileName() + ".tmp"); + JsonObject cache = new JsonObject(); + cache.addProperty("schema", 1); + cache.addProperty("revision", revision); + cache.add("heads", GSON.toJsonTree(heads, HeadMapper.HEADS_LIST_TYPE)); + Files.writeString(temporary, GSON.toJson(cache), StandardCharsets.UTF_8); + try { + Files.move(temporary, absoluteCache, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temporary, absoluteCache, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException | RuntimeException ex) { + LOGGER.warn("Could not save the local head catalog cache: {}", ex.getMessage()); + LOGGER.debug("Detailed catalog cache write error", ex); + } + } + + private static int parseRevision(@Nullable String rawRevision) throws IOException { + if (rawRevision == null) { + throw new IOException("Catalog snapshot omitted its revision"); + } + try { + int revision = Integer.parseInt(rawRevision); + if (revision < 0) throw new NumberFormatException("negative revision"); + return revision; + } catch (NumberFormatException ex) { + throw new IOException("Catalog snapshot returned an invalid revision", ex); + } + } + private Snapshot buildSnapshot(List loadedHeads) { LOGGER.debug("Indexing heads..."); long indexStart = System.currentTimeMillis(); @@ -407,6 +635,28 @@ private boolean hasIndex(Index index) { return indexes.contains(index); } + public int getCatalogRevision() { + return catalogRevision; + } + + private record FetchedCatalog(List heads, int revision) { + } + + private static final class CatalogSyncResponse { + private int schema; + private int fromRevision; + private int toRevision; + private List changes; + private boolean hasMore; + private String nextCursor; + } + + private static final class CatalogChange { + private String operation; + private int headId; + private Head head; + } + private record Snapshot( List heads, @Nullable Map byId, diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/model/BaseHead.java b/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/model/BaseHead.java index d446aa8..4a50e77 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/model/BaseHead.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/model/BaseHead.java @@ -12,14 +12,22 @@ public class BaseHead implements Head { private final int id; private final String name; private final String texture; + private final String textureUrl; private final String category; private final List tags; private volatile ItemStack item; public BaseHead(int id, String name, String texture, String category, List tags) { + this(id, name, texture, null, category, tags); + } + + public BaseHead(int id, String name, String texture, String textureUrl, String category, List tags) { this.id = id; this.name = Objects.requireNonNull(name, "name"); this.texture = Objects.requireNonNull(texture, "texture"); + this.textureUrl = textureUrl == null || textureUrl.isBlank() + ? "https://textures.minecraft.net/texture/" + texture + : textureUrl; this.category = Objects.requireNonNull(category, "category"); this.tags = List.copyOf(Objects.requireNonNull(tags, "tags")); } @@ -54,6 +62,11 @@ public String getTexture() { return this.texture; } + @Override + public String getTextureUrl() { + return this.textureUrl; + } + @Override public String getCategory() { return this.category; diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/model/HeadMapper.java b/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/model/HeadMapper.java index 57d94db..e4cc254 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/model/HeadMapper.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/model/HeadMapper.java @@ -19,7 +19,15 @@ public Head deserialize(JsonElement json, Type typeOfT, JsonDeserializationConte for (JsonElement tagEntry : main.get("tags").getAsJsonArray()) { tags.add(tagEntry.getAsString()); } - return new BaseHead(main.get("id").getAsInt(), main.get("name").getAsString(), main.get("texture").getAsString(), main.get("category").getAsString(), tags); + JsonElement textureUrl = main.get("textureUrl"); + return new BaseHead( + main.get("id").getAsInt(), + main.get("name").getAsString(), + main.get("texture").getAsString(), + textureUrl == null || textureUrl.isJsonNull() ? null : textureUrl.getAsString(), + main.get("category").getAsString(), + tags + ); } @Override @@ -29,6 +37,7 @@ public JsonElement serialize(Head src, Type typeOfSrc, JsonSerializationContext json.addProperty("id", src.getId()); json.addProperty("name", src.getName()); json.addProperty("texture", src.getTexture()); + json.addProperty("textureUrl", src.getTextureUrl()); json.addProperty("category", src.getCategory()); JsonArray tagsArray = new JsonArray(); diff --git a/headdb-core/src/main/resources/config.yml b/headdb-core/src/main/resources/config.yml index ecd66ec..d9d43ab 100644 --- a/headdb-core/src/main/resources/config.yml +++ b/headdb-core/src/main/resources/config.yml @@ -1,6 +1,4 @@ -# The updater will update the local database every 24h since server start. -# If you want the fastest updates you can schedule a task externally(most server hosts provide a way) and disable this option. -# The remote database is updated at 00:00 UTC every day, schedule it after that. +# Periodically apply new, edited, and removed heads from the managed API. updater: true # Check GitHub Releases for newer HeadDB versions without blocking server startup. @@ -106,10 +104,15 @@ controls: # Database Configuration database: - # Primary URL used to fetch the head database JSON. - sourceUrl: "https://headdb.net/api/v1/legacy/heads.json" + # Complete catalog used on first startup or if incremental state must be rebuilt. + sourceUrl: "https://headdb.net/api/v1/catalog/snapshot" + # Revision feed used after the initial snapshot. Set to an empty string to + # disable incremental sync and download the complete source on each refresh. + syncUrl: "https://headdb.net/api/v1/catalog/changes" + syncIntervalMinutes: 15 # Optional fallback URLs that are tried in order if the primary source fails. fallbackSourceUrls: + - "https://headdb.net/api/v1/legacy/heads.json" - "https://raw.githubusercontent.com/BitworksMC/HeadDB/refs/heads/master/heads.json" # Threads pool for the database threads: 1 diff --git a/headdb-core/src/test/java/com/bitworksmc/headdb/implementation/BaseHeadDatabaseTest.java b/headdb-core/src/test/java/com/bitworksmc/headdb/implementation/BaseHeadDatabaseTest.java index 77978fd..f1243bd 100644 --- a/headdb-core/src/test/java/com/bitworksmc/headdb/implementation/BaseHeadDatabaseTest.java +++ b/headdb-core/src/test/java/com/bitworksmc/headdb/implementation/BaseHeadDatabaseTest.java @@ -6,11 +6,14 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -25,6 +28,9 @@ class BaseHeadDatabaseTest { + @TempDir + Path tempDirectory; + private static final String HEADS_JSON = """ [ {"id":1,"name":"Melon","texture":"texture-one","category":"Plants","tags":["Fruit","Summer"]}, @@ -148,6 +154,76 @@ void publishedCollectionsCannotBeMutated() { assertThrows(UnsupportedOperationException.class, () -> database.getByTags("Fruit").clear()); } + @Test + void appliesRevisionChangesAndRestoresTheSavedCatalog() { + AtomicInteger snapshotRequests = new AtomicInteger(); + server.createContext("/snapshot", exchange -> { + snapshotRequests.incrementAndGet(); + exchange.getResponseHeaders().set("X-Catalog-Schema", "1"); + exchange.getResponseHeaders().set("X-Catalog-Revision", "1"); + respond(exchange, 200, HEADS_JSON); + }); + server.createContext("/changes", exchange -> { + String query = exchange.getRequestURI().getQuery(); + if (query != null && query.contains("sinceRevision=1")) { + respond(exchange, 200, """ + { + "schema":1, + "fromRevision":1, + "toRevision":2, + "changes":[ + {"revision":2,"operation":"remove","headId":1}, + {"revision":2,"operation":"upsert","headId":3,"head":{ + "id":3, + "name":"Uploaded head", + "texture":"texture-three", + "textureUrl":"https://headdb.net/api/v1/textures/texture-three", + "category":"Decoration", + "tags":["Uploaded"] + }} + ], + "hasMore":false, + "nextCursor":null + } + """); + return; + } + respond(exchange, 200, """ + {"schema":1,"fromRevision":2,"toRevision":2,"changes":[],"hasMore":false,"nextCursor":null} + """); + }); + + Path cache = tempDirectory.resolve("catalog-cache.json"); + BaseHeadDatabase database = new BaseHeadDatabase( + databaseExecutor, + List.of(url("/snapshot")), + url("/changes"), + cache, + Index.ID + ); + assertEquals(2, database.update().join().size()); + List synced = database.update().join(); + + assertEquals(2, synced.size()); + assertNull(database.getById(1)); + assertEquals("Bread", database.getById(2).getName()); + assertEquals("https://headdb.net/api/v1/textures/texture-three", database.getById(3).getTextureUrl()); + assertEquals(2, database.getCatalogRevision()); + assertTrue(Files.isRegularFile(cache)); + + BaseHeadDatabase restored = new BaseHeadDatabase( + databaseExecutor, + List.of(url("/snapshot")), + url("/changes"), + cache, + Index.ID + ); + assertEquals(2, restored.update().join().size()); + assertEquals(2, restored.getCatalogRevision()); + assertNotNull(restored.getById(3)); + assertEquals(1, snapshotRequests.get()); + } + private BaseHeadDatabase database(String path, Index... indexes) { return new BaseHeadDatabase(databaseExecutor, List.of(url(path)), indexes); } diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHead.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHead.java index 5e9e8ed..589b462 100644 --- a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHead.java +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHead.java @@ -11,6 +11,7 @@ final class LegacyHead implements Head { private int id; private String name; private String texture; + private String textureUrl; private String category; private List tags; private transient volatile ItemStack cachedItem; @@ -30,6 +31,11 @@ public String getTexture() { return texture; } + @Override + public String getTextureUrl() { + return isEmpty(textureUrl) ? Head.super.getTextureUrl() : textureUrl; + } + @Override public String getCategory() { return category; diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java index 68f8e0e..70f59b3 100644 --- a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java @@ -49,7 +49,7 @@ static ItemStack createHead(LegacyHead head) { } meta.setLore(lore); - if (!applyBukkitProfile(meta, head.getTexture()) && !applyAuthlibProfile(meta, head.getTexture())) { + if (!applyBukkitProfile(meta, head.getTextureUrl()) && !applyAuthlibProfile(meta, head.getTextureUrl())) { meta.setOwner("MHF_Question"); } item.setItemMeta(meta); @@ -72,7 +72,8 @@ private static String replace(String value, LegacyHead head) { static ItemStack createTextureHead(String textureHash) { ItemStack item = newPlayerHead(); SkullMeta meta = (SkullMeta) item.getItemMeta(); - if (!applyBukkitProfile(meta, textureHash) && !applyAuthlibProfile(meta, textureHash)) { + String textureUrl = "https://textures.minecraft.net/texture/" + textureHash; + if (!applyBukkitProfile(meta, textureUrl) && !applyAuthlibProfile(meta, textureUrl)) { meta.setOwner("MHF_Question"); } item.setItemMeta(meta); @@ -115,7 +116,7 @@ static ItemStack newPlayerHead() { * Uses the Bukkit profile API introduced after the legacy GameProfile era. * All types are resolved reflectively so the class still loads on 1.8. */ - private static boolean applyBukkitProfile(SkullMeta meta, String textureHash) { + private static boolean applyBukkitProfile(SkullMeta meta, String textureUrl) { try { Class profileClass = Class.forName("org.bukkit.profile.PlayerProfile"); Method createProfile = org.bukkit.Bukkit.class.getMethod( @@ -123,7 +124,7 @@ private static boolean applyBukkitProfile(SkullMeta meta, String textureHash) { Object profile = createProfile.invoke(null, UUID.randomUUID(), null); Object textures = profileClass.getMethod("getTextures").invoke(profile); textures.getClass().getMethod("setSkin", URL.class).invoke( - textures, new URL("https://textures.minecraft.net/texture/" + textureHash)); + textures, new URL(textureUrl)); invokeCompatible(profile, "setTextures", textures); if (!invokeCompatible(meta, "setOwnerProfile", profile)) { @@ -141,14 +142,13 @@ private static boolean applyBukkitProfile(SkullMeta meta, String textureHash) { /** * CraftBukkit 1.8-1.20 stores Mojang's GameProfile directly in SkullMeta. */ - private static boolean applyAuthlibProfile(SkullMeta meta, String textureHash) { + private static boolean applyAuthlibProfile(SkullMeta meta, String textureUrl) { try { Class profileClass = Class.forName("com.mojang.authlib.GameProfile"); Constructor profileConstructor = profileClass.getConstructor(UUID.class, String.class); Object profile = profileConstructor.newInstance(UUID.randomUUID(), null); - String json = "{\"textures\":{\"SKIN\":{\"url\":\"https://textures.minecraft.net/texture/" - + textureHash + "\"}}}"; + String json = "{\"textures\":{\"SKIN\":{\"url\":\"" + textureUrl + "\"}}}"; String encoded = Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8)); Class propertyClass = Class.forName("com.mojang.authlib.properties.Property"); From d2a783f270e192543fde340b55850cc4ae190a33 Mon Sep 17 00:00:00 2001 From: GoodrichDev Date: Fri, 21 Aug 2026 15:02:11 -0700 Subject: [PATCH 3/9] Support HeadDB-hosted profile textures --- .../headdb/core/factory/PaperItemFactory.java | 17 ++++--- .../core/factory/TextureProfileValue.java | 38 ++++++++++++++++ .../core/factory/TextureProfileValueTest.java | 44 +++++++++++++++++++ 3 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/TextureProfileValue.java create mode 100644 headdb-core/src/test/java/com/bitworksmc/headdb/core/factory/TextureProfileValueTest.java diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java index 746d143..6b497d5 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java @@ -1,8 +1,9 @@ package com.bitworksmc.headdb.core.factory; -import com.destroystokyo.paper.profile.PlayerProfile; import com.bitworksmc.headdb.api.model.Head; import com.bitworksmc.headdb.core.HeadDB; +import com.destroystokyo.paper.profile.PlayerProfile; +import com.destroystokyo.paper.profile.ProfileProperty; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.TextDecoration; import org.bukkit.Bukkit; @@ -11,13 +12,10 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.SkullMeta; -import org.bukkit.profile.PlayerTextures; import org.jetbrains.annotations.ApiStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.net.MalformedURLException; -import java.net.URI; import java.util.*; @ApiStatus.Internal @@ -37,10 +35,12 @@ public ItemStack asItem(Head head) { PlayerProfile profile = Bukkit.createProfileExact(UUID.randomUUID(), null); try { - PlayerTextures textures = profile.getTextures(); - textures.setSkin(URI.create(head.getTextureUrl()).toURL()); - profile.setTextures(textures); - } catch (IllegalArgumentException | MalformedURLException ex) { + profile.setProperty(new ProfileProperty( + "textures", + TextureProfileValue.fromUrl(head.getTextureUrl()) + )); + meta.setPlayerProfile(profile); + } catch (IllegalArgumentException ex) { LOGGER.error("Failed to set texture for {} (ID:{} | Texture: {})", head.getName(), head.getId(), head.getTexture(), ex); return item; } @@ -61,7 +61,6 @@ public ItemStack asItem(Head head) { )); meta.lore(lore); - meta.setPlayerProfile(profile); item.setItemMeta(meta); return item; } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/TextureProfileValue.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/TextureProfileValue.java new file mode 100644 index 0000000..c73b75a --- /dev/null +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/TextureProfileValue.java @@ -0,0 +1,38 @@ +package com.bitworksmc.headdb.core.factory; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Locale; +import java.util.Set; + +final class TextureProfileValue { + + private static final Set ALLOWED_HOSTS = Set.of( + "textures.minecraft.net", + "headdb.net", + "www.headdb.net" + ); + + private TextureProfileValue() { + } + + static String fromUrl(String textureUrl) { + URI uri = URI.create(textureUrl); + String scheme = uri.getScheme(); + String host = uri.getHost(); + if (scheme == null + || !"https".equals(scheme.toLowerCase(Locale.ROOT)) + || host == null) { + throw new IllegalArgumentException("Texture URL must be an absolute HTTPS URL"); + } + if (!ALLOWED_HOSTS.contains(host.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("Texture URL host is not trusted: " + host); + } + + String payload = "{\"textures\":{\"SKIN\":{\"url\":\"" + + uri.toASCIIString() + + "\"}}}"; + return Base64.getEncoder().encodeToString(payload.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/headdb-core/src/test/java/com/bitworksmc/headdb/core/factory/TextureProfileValueTest.java b/headdb-core/src/test/java/com/bitworksmc/headdb/core/factory/TextureProfileValueTest.java new file mode 100644 index 0000000..49e2bf1 --- /dev/null +++ b/headdb-core/src/test/java/com/bitworksmc/headdb/core/factory/TextureProfileValueTest.java @@ -0,0 +1,44 @@ +package com.bitworksmc.headdb.core.factory; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class TextureProfileValueTest { + + @Test + void encodesAHeadDbTextureAsAPlayerProfileProperty() { + String url = "https://headdb.net/api/v1/textures/f6eae70943e1b01d57f48a12a766c4be3a3dddd14aa94dc9aec2d9a3a2680edb"; + String decoded = new String( + Base64.getDecoder().decode(TextureProfileValue.fromUrl(url)), + StandardCharsets.UTF_8 + ); + + assertEquals("{\"textures\":{\"SKIN\":{\"url\":\"" + url + "\"}}}", decoded); + } + + @Test + void encodesMojangTexturesThroughTheSameSafePath() { + String url = "https://textures.minecraft.net/texture/abc123"; + String decoded = new String( + Base64.getDecoder().decode(TextureProfileValue.fromUrl(url)), + StandardCharsets.UTF_8 + ); + + assertEquals("{\"textures\":{\"SKIN\":{\"url\":\"" + url + "\"}}}", decoded); + } + + @Test + void rejectsNonHttpsRelativeAndUntrustedTextureUrls() { + assertThrows(IllegalArgumentException.class, + () -> TextureProfileValue.fromUrl("http://headdb.net/texture.png")); + assertThrows(IllegalArgumentException.class, + () -> TextureProfileValue.fromUrl("/api/v1/textures/abc123")); + assertThrows(IllegalArgumentException.class, + () -> TextureProfileValue.fromUrl("https://example.com/texture.png")); + } +} From f298a3e1e30c5216a96bfade4ac6e3fa7dbb02b7 Mon Sep 17 00:00:00 2001 From: GoodrichDev Date: Fri, 21 Aug 2026 15:53:23 -0700 Subject: [PATCH 4/9] Use Paper texture API for MineSkin heads --- README.md | 10 ++++---- changelog/6.0.4.md | 13 +++++++++++ headdb-api/pom.xml | 2 +- headdb-core/pom.xml | 6 ++--- .../headdb/core/factory/PaperItemFactory.java | 23 +++++++++++++++---- .../core/factory/TextureProfileValue.java | 20 ++++++++++++---- .../core/factory/TextureProfileValueTest.java | 12 ++++++++++ headdb-legacy/pom.xml | 2 +- pom.xml | 2 +- 9 files changed, 70 insertions(+), 20 deletions(-) create mode 100644 changelog/6.0.4.md diff --git a/README.md b/README.md index 5745781..969c0aa 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,12 @@ snapshot and the legacy BitworksMC GitHub catalog remain available for recovery. ## ๐Ÿš€ Download & Installation -HeadDB 6.0.3 is distributed as two server-specific jars. Install exactly one: +HeadDB 6.0.4 is distributed as two server-specific jars. Install exactly one: | File | Server versions | Java | Purpose | |---|---|---|---| -| `HeadDB-6.0.3.jar` | Paper 1.21.0 and newer | Java 21+ | The full modern plugin and the recommended download. | -| `HeadDB-6.0.3-legacy.jar` | Bukkit-compatible 1.8.8-1.20.6 | Java 8 bytecode* | The isolated implementation for servers before 1.21. | +| `HeadDB-6.0.4.jar` | Paper 1.21.0 and newer | Java 21+ | The full modern plugin and the recommended download. | +| `HeadDB-6.0.4-legacy.jar` | Bukkit-compatible 1.8.8-1.20.6 | Java 8 bytecode* | The isolated implementation for servers before 1.21. | \* Use the Java version required by the Minecraft server. The legacy plugin itself is Java 8-compatible, but later Minecraft releases require newer Java @@ -276,8 +276,8 @@ you agree to the [Minecraft EULA](https://aka.ms/MinecraftEULA). Run `mvn clean package` from the repository root. The release files are written to: -- `headdb-core/target/HeadDB-6.0.3.jar` -- `headdb-legacy/target/HeadDB-6.0.3-legacy.jar` +- `headdb-core/target/HeadDB-6.0.4.jar` +- `headdb-legacy/target/HeadDB-6.0.4-legacy.jar` The legacy module uses `--release 8`; the modern module uses `--release 21`. Maven may run on a newer JDK when building both artifacts together. diff --git a/changelog/6.0.4.md b/changelog/6.0.4.md new file mode 100644 index 0000000..9598fb9 --- /dev/null +++ b/changelog/6.0.4.md @@ -0,0 +1,13 @@ +# HeadDB 6.0.4 Changelog + +## Fixed + +- Modern Paper servers now use Paper's supported `PlayerTextures` path for + MineSkin-published Mojang textures. +- HeadDB-hosted texture URLs remain supported only as a compatibility fallback + for an old local catalog cache while it advances to the current API revision. + +## Catalog + +- The plugin continues to restore `plugins/HeadDB/catalog-cache.json` at startup + and checks the HeadDB revision feed at the configured interval. diff --git a/headdb-api/pom.xml b/headdb-api/pom.xml index ff71c84..6bc42cf 100644 --- a/headdb-api/pom.xml +++ b/headdb-api/pom.xml @@ -6,7 +6,7 @@ com.bitworksmc HeadDB - 6.0.3 + 6.0.4 headdb-api diff --git a/headdb-core/pom.xml b/headdb-core/pom.xml index 2600b66..0b3e60d 100644 --- a/headdb-core/pom.xml +++ b/headdb-core/pom.xml @@ -6,13 +6,13 @@ com.bitworksmc HeadDB - 6.0.3 + 6.0.4 headdb-core Head Database - 6.0.3 + 6.0.4 21 @@ -46,7 +46,7 @@ com.bitworksmc headdb-api - 6.0.3 + 6.0.4 diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java index 6b497d5..40d0bf3 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java @@ -12,10 +12,13 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.SkullMeta; +import org.bukkit.profile.PlayerTextures; import org.jetbrains.annotations.ApiStatus; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.MalformedURLException; +import java.net.URI; import java.util.*; @ApiStatus.Internal @@ -35,12 +38,22 @@ public ItemStack asItem(Head head) { PlayerProfile profile = Bukkit.createProfileExact(UUID.randomUUID(), null); try { - profile.setProperty(new ProfileProperty( - "textures", - TextureProfileValue.fromUrl(head.getTextureUrl()) - )); + URI textureUri = TextureProfileValue.parseTrustedUrl(head.getTextureUrl()); + if (TextureProfileValue.isMojangUrl(textureUri)) { + PlayerTextures textures = profile.getTextures(); + textures.setSkin(textureUri.toURL()); + profile.setTextures(textures); + } else { + // Old catalog caches may still contain a HeadDB-hosted URL. Paper + // deliberately rejects those in PlayerTextures, so keep this path + // only as a compatibility fallback until the next catalog sync. + profile.setProperty(new ProfileProperty( + "textures", + TextureProfileValue.fromUrl(textureUri) + )); + } meta.setPlayerProfile(profile); - } catch (IllegalArgumentException ex) { + } catch (IllegalArgumentException | MalformedURLException ex) { LOGGER.error("Failed to set texture for {} (ID:{} | Texture: {})", head.getName(), head.getId(), head.getTexture(), ex); return item; } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/TextureProfileValue.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/TextureProfileValue.java index c73b75a..28cf1e3 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/TextureProfileValue.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/TextureProfileValue.java @@ -18,6 +18,18 @@ private TextureProfileValue() { } static String fromUrl(String textureUrl) { + return fromUrl(parseTrustedUrl(textureUrl)); + } + + static String fromUrl(URI uri) { + parseTrustedUrl(uri.toASCIIString()); + String payload = "{\"textures\":{\"SKIN\":{\"url\":\"" + + uri.toASCIIString() + + "\"}}}"; + return Base64.getEncoder().encodeToString(payload.getBytes(StandardCharsets.UTF_8)); + } + + static URI parseTrustedUrl(String textureUrl) { URI uri = URI.create(textureUrl); String scheme = uri.getScheme(); String host = uri.getHost(); @@ -29,10 +41,10 @@ static String fromUrl(String textureUrl) { if (!ALLOWED_HOSTS.contains(host.toLowerCase(Locale.ROOT))) { throw new IllegalArgumentException("Texture URL host is not trusted: " + host); } + return uri; + } - String payload = "{\"textures\":{\"SKIN\":{\"url\":\"" - + uri.toASCIIString() - + "\"}}}"; - return Base64.getEncoder().encodeToString(payload.getBytes(StandardCharsets.UTF_8)); + static boolean isMojangUrl(URI uri) { + return "textures.minecraft.net".equalsIgnoreCase(uri.getHost()); } } diff --git a/headdb-core/src/test/java/com/bitworksmc/headdb/core/factory/TextureProfileValueTest.java b/headdb-core/src/test/java/com/bitworksmc/headdb/core/factory/TextureProfileValueTest.java index 49e2bf1..17a3e93 100644 --- a/headdb-core/src/test/java/com/bitworksmc/headdb/core/factory/TextureProfileValueTest.java +++ b/headdb-core/src/test/java/com/bitworksmc/headdb/core/factory/TextureProfileValueTest.java @@ -6,7 +6,9 @@ import java.util.Base64; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class TextureProfileValueTest { @@ -32,6 +34,16 @@ void encodesMojangTexturesThroughTheSameSafePath() { assertEquals("{\"textures\":{\"SKIN\":{\"url\":\"" + url + "\"}}}", decoded); } + @Test + void identifiesOnlyMojangUrlsForPapersSupportedTexturePath() { + assertTrue(TextureProfileValue.isMojangUrl( + TextureProfileValue.parseTrustedUrl("https://textures.minecraft.net/texture/abc123") + )); + assertFalse(TextureProfileValue.isMojangUrl( + TextureProfileValue.parseTrustedUrl("https://headdb.net/api/v1/textures/abc123") + )); + } + @Test void rejectsNonHttpsRelativeAndUntrustedTextureUrls() { assertThrows(IllegalArgumentException.class, diff --git a/headdb-legacy/pom.xml b/headdb-legacy/pom.xml index f707d4f..5c32815 100644 --- a/headdb-legacy/pom.xml +++ b/headdb-legacy/pom.xml @@ -7,7 +7,7 @@ com.bitworksmc HeadDB - 6.0.3 + 6.0.4 headdb-legacy diff --git a/pom.xml b/pom.xml index b23c812..eccee31 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.bitworksmc HeadDB - 6.0.3 + 6.0.4 pom HeadDB From 4e86578254a7c2f96b94b245b9f1c767cfb94db6 Mon Sep 17 00:00:00 2001 From: GoodrichDev Date: Fri, 21 Aug 2026 16:21:09 -0700 Subject: [PATCH 5/9] Document the 6.0.4 catalog API migration --- README.md | 17 ++++++++ changelog/6.0.4.md | 103 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 969c0aa..6b9baae 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,23 @@ Found a bug or have a feature request? Open an issue: ## ๐Ÿค Using the API +HeadDB 6.0.4 uses two APIs for different purposes: + +- The **HeadDB HTTP API** at `https://headdb.net/api/v1` is the managed source + for published head data. The modern plugin downloads + `/catalog/snapshot` when it needs a complete database and polls + `/catalog/changes` with its saved revision for additions, edits, and removals. + Server owners normally do not need to call these endpoints themselves. The + HTTP API documentation is available at + [headdb.net/docs/api](https://headdb.net/docs/api). +- The **Bukkit Java API** described below is registered by the installed HeadDB + plugin. Other plugins use it to search the locally synchronized catalog and + create head items without making their own HTTP requests. + +The Java API remains the normal integration point for another Minecraft +plugin. The new HTTP API changes where HeadDB obtains and synchronizes its data; +it does not remove or replace the Bukkit service. + ### 1. Adding the Dependency HeadDB publishes its API module via our own Nexus Maven Repo. diff --git a/changelog/6.0.4.md b/changelog/6.0.4.md index 9598fb9..22f4b06 100644 --- a/changelog/6.0.4.md +++ b/changelog/6.0.4.md @@ -1,13 +1,100 @@ # HeadDB 6.0.4 Changelog -## Fixed +HeadDB 6.0.4 moves the BitworksMC fork onto the managed catalog and API at +[headdb.net](https://headdb.net). The plugin no longer depends on another +HeadDB fork or its incompatible catalog format. -- Modern Paper servers now use Paper's supported `PlayerTextures` path for - MineSkin-published Mojang textures. -- HeadDB-hosted texture URLs remain supported only as a compatibility fallback - for an old local catalog cache while it advances to the current API revision. +## New managed catalog API -## Catalog +The modern Paper plugin now gets its head data from the versioned HeadDB HTTP +API instead of treating a static JSON file as the primary database: -- The plugin continues to restore `plugins/HeadDB/catalog-cache.json` at startup - and checks the HeadDB revision feed at the configured interval. +- `GET https://headdb.net/api/v1/catalog/snapshot` returns a complete published + catalog and its revision. This is used for the first load and recovery. +- `GET https://headdb.net/api/v1/catalog/changes?sinceRevision=` + returns the additions, edits, and removals required to reach the current + revision. +- `GET https://headdb.net/api/v1/legacy/heads.json` remains available as a + complete compatibility source. + +The public catalog, endpoint reference, and OpenAPI specification are available +at [headdb.net/docs/api](https://headdb.net/docs/api) and +[headdb.net/openapi.json](https://headdb.net/openapi.json). + +This remote HTTP API is separate from HeadDB's Bukkit `HeadAPI`. The HTTP API +supplies catalog data to HeadDB; the Java API registered with Bukkit's Services +Manager remains the supported way for another server plugin to search HeadDB or +request an `ItemStack`. + +## How synchronization works + +On a modern Paper server, HeadDB now: + +1. Restores `plugins/HeadDB/catalog-cache.json` so the database can become + available without first downloading the entire catalog. +2. Downloads a complete snapshot when there is no usable local cache. +3. Records the catalog revision that produced the local database. +4. Requests only changes after that revision every 15 minutes by default. +5. Applies every page of changes as one new database view, then rebuilds the + affected menus and indexes. +6. Saves the updated catalog and revision back to disk for the next restart. + +An `upsert` change adds a new head or replaces an edited head. A `remove` +change removes the head from the live database and menus. Removed heads are not +renumbered, so IDs remain stable and a later removal does not break revision +synchronization. + +If incremental synchronization fails, HeadDB attempts a complete snapshot. If +the remote service is unavailable and a valid saved catalog was restored, the +plugin keeps serving that saved database instead of discarding it. + +The relevant settings are: + +```yaml +database: + sourceUrl: "https://headdb.net/api/v1/catalog/snapshot" + syncUrl: "https://headdb.net/api/v1/catalog/changes" + syncIntervalMinutes: 15 +``` + +Existing configurations use these defaults when the new keys are absent. +Setting `database.syncUrl` to an empty string disables incremental changes and +causes refreshes to use a complete source instead. + +## Submitted skins and Minecraft textures + +The new website accepts community submissions and can digest the head and hat +layers from an uploaded Minecraft skin. Accepted uploads are published through +MineSkin so their final texture is hosted by Mojang at +`textures.minecraft.net`. The API then supplies the Mojang texture hash and URL +to the plugin. + +Modern Paper servers use Paper's supported `PlayerTextures` path for those +Mojang URLs. This fixes the rejected-host errors and inconsistent rendering +caused by attempting to use a `headdb.net` image URL directly as a player skin. +HeadDB-hosted URLs remain supported only as a temporary compatibility fallback +for an old local catalog cache while it advances to the current revision. + +## Server-owner notes + +- Install `HeadDB-6.0.4.jar` on Paper 1.21 or newer and perform a full server + restart. Do not use `/reload`. +- The server needs outbound HTTPS access to `headdb.net` and + `textures.minecraft.net`. +- A newly accepted, edited, or removed head normally appears after the next + configured synchronization interval. Restarting also performs a sync. +- Previously created `ItemStack`s and already placed skull blocks retain the + texture stored inside them. Catalog changes affect newly created items and do + not rewrite items already present in inventories, containers, or the world. +- Use `HeadDB-6.0.4-legacy.jar` only for Bukkit-compatible servers before 1.21. + The legacy build downloads complete catalog data rather than using the modern + revision-delta implementation. + +## Reliability and compatibility + +- Catalog reads and index updates are published atomically, preventing searches + from observing a partially updated database. +- Gzip responses, paginated change feeds, connection timeouts, fallback sources, + and an on-disk recovery cache are supported. +- The existing Bukkit `HeadAPI`, search commands, menus, favorites, economy + integration, and category permissions remain compatible. From f3cdb93c5783be8147415dfeada2c79b09f4513a Mon Sep 17 00:00:00 2001 From: GoodrichDev Date: Fri, 21 Aug 2026 22:16:57 -0700 Subject: [PATCH 6/9] Changes --- changelog/6.0.4.md | 103 +----------------- .../headdb/legacy/LegacyItemFactory.java | 1 + 2 files changed, 6 insertions(+), 98 deletions(-) diff --git a/changelog/6.0.4.md b/changelog/6.0.4.md index 22f4b06..e9c5ab1 100644 --- a/changelog/6.0.4.md +++ b/changelog/6.0.4.md @@ -1,100 +1,7 @@ # HeadDB 6.0.4 Changelog -HeadDB 6.0.4 moves the BitworksMC fork onto the managed catalog and API at -[headdb.net](https://headdb.net). The plugin no longer depends on another -HeadDB fork or its incompatible catalog format. - -## New managed catalog API - -The modern Paper plugin now gets its head data from the versioned HeadDB HTTP -API instead of treating a static JSON file as the primary database: - -- `GET https://headdb.net/api/v1/catalog/snapshot` returns a complete published - catalog and its revision. This is used for the first load and recovery. -- `GET https://headdb.net/api/v1/catalog/changes?sinceRevision=` - returns the additions, edits, and removals required to reach the current - revision. -- `GET https://headdb.net/api/v1/legacy/heads.json` remains available as a - complete compatibility source. - -The public catalog, endpoint reference, and OpenAPI specification are available -at [headdb.net/docs/api](https://headdb.net/docs/api) and -[headdb.net/openapi.json](https://headdb.net/openapi.json). - -This remote HTTP API is separate from HeadDB's Bukkit `HeadAPI`. The HTTP API -supplies catalog data to HeadDB; the Java API registered with Bukkit's Services -Manager remains the supported way for another server plugin to search HeadDB or -request an `ItemStack`. - -## How synchronization works - -On a modern Paper server, HeadDB now: - -1. Restores `plugins/HeadDB/catalog-cache.json` so the database can become - available without first downloading the entire catalog. -2. Downloads a complete snapshot when there is no usable local cache. -3. Records the catalog revision that produced the local database. -4. Requests only changes after that revision every 15 minutes by default. -5. Applies every page of changes as one new database view, then rebuilds the - affected menus and indexes. -6. Saves the updated catalog and revision back to disk for the next restart. - -An `upsert` change adds a new head or replaces an edited head. A `remove` -change removes the head from the live database and menus. Removed heads are not -renumbered, so IDs remain stable and a later removal does not break revision -synchronization. - -If incremental synchronization fails, HeadDB attempts a complete snapshot. If -the remote service is unavailable and a valid saved catalog was restored, the -plugin keeps serving that saved database instead of discarding it. - -The relevant settings are: - -```yaml -database: - sourceUrl: "https://headdb.net/api/v1/catalog/snapshot" - syncUrl: "https://headdb.net/api/v1/catalog/changes" - syncIntervalMinutes: 15 -``` - -Existing configurations use these defaults when the new keys are absent. -Setting `database.syncUrl` to an empty string disables incremental changes and -causes refreshes to use a complete source instead. - -## Submitted skins and Minecraft textures - -The new website accepts community submissions and can digest the head and hat -layers from an uploaded Minecraft skin. Accepted uploads are published through -MineSkin so their final texture is hosted by Mojang at -`textures.minecraft.net`. The API then supplies the Mojang texture hash and URL -to the plugin. - -Modern Paper servers use Paper's supported `PlayerTextures` path for those -Mojang URLs. This fixes the rejected-host errors and inconsistent rendering -caused by attempting to use a `headdb.net` image URL directly as a player skin. -HeadDB-hosted URLs remain supported only as a temporary compatibility fallback -for an old local catalog cache while it advances to the current revision. - -## Server-owner notes - -- Install `HeadDB-6.0.4.jar` on Paper 1.21 or newer and perform a full server - restart. Do not use `/reload`. -- The server needs outbound HTTPS access to `headdb.net` and - `textures.minecraft.net`. -- A newly accepted, edited, or removed head normally appears after the next - configured synchronization interval. Restarting also performs a sync. -- Previously created `ItemStack`s and already placed skull blocks retain the - texture stored inside them. Catalog changes affect newly created items and do - not rewrite items already present in inventories, containers, or the world. -- Use `HeadDB-6.0.4-legacy.jar` only for Bukkit-compatible servers before 1.21. - The legacy build downloads complete catalog data rather than using the modern - revision-delta implementation. - -## Reliability and compatibility - -- Catalog reads and index updates are published atomically, preventing searches - from observing a partially updated database. -- Gzip responses, paginated change feeds, connection timeouts, fallback sources, - and an on-disk recovery cache are supported. -- The existing Bukkit `HeadAPI`, search commands, menus, favorites, economy - integration, and category permissions remain compatible. +- Moved the head catalog to the managed API at [headdb.net](https://headdb.net). +- Added revision-based updates and an on-disk recovery cache for modern Paper + servers. +- Added complete-catalog support for legacy servers from 1.8.8 to 1.20.6. +- Switched submitted skins to Mojang-hosted textures for reliable rendering. diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java index 70f59b3..63542d0 100644 --- a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java @@ -173,6 +173,7 @@ private static boolean invokeCompatible(Object target, String name, Object argum if (method.getName().equals(name) && method.getParameterTypes().length == 1 && method.getParameterTypes()[0].isInstance(argument)) { + method.setAccessible(true); method.invoke(target, argument); return true; } From 060fc02c7789e96ff1bce10b649afc745bdbcd4f Mon Sep 17 00:00:00 2001 From: GoodrichDev Date: Fri, 21 Aug 2026 22:37:24 -0700 Subject: [PATCH 7/9] Link plugin searches and submissions to website --- README.md | 4 + changelog/6.0.4.md | 2 + .../core/command/HDBSubCommandManager.java | 2 + .../core/command/sub/HDBCommandSearch.java | 32 +++- .../core/command/sub/HDBCommandSubmit.java | 42 +++++ .../bitworksmc/headdb/core/config/Config.java | 9 ++ .../bitworksmc/headdb/core/menu/MainMenu.java | 19 ++- .../core/menu/gui/CustomCategoriesGUI.java | 21 +-- .../core/menu/gui/FavoritesHeadsGUI.java | 20 +-- .../headdb/core/menu/gui/HeadsGUI.java | 20 +-- .../headdb/core/menu/gui/LocalHeadsGUI.java | 21 +-- .../headdb/core/util/WebsiteLinks.java | 110 ++++++++++++++ headdb-core/src/main/resources/config.yml | 7 + .../src/main/resources/messages/en.yml | 4 + headdb-core/src/main/resources/plugin.yml | 4 + .../headdb/core/util/WebsiteLinksTest.java | 37 +++++ headdb-legacy/pom.xml | 6 + .../headdb/legacy/LegacyHeadDB.java | 61 +++++++- .../headdb/legacy/LegacyWebsiteLinks.java | 143 ++++++++++++++++++ headdb-legacy/src/main/resources/plugin.yml | 5 +- .../headdb/legacy/LegacyWebsiteLinksTest.java | 23 +++ 21 files changed, 540 insertions(+), 52 deletions(-) create mode 100644 headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSubmit.java create mode 100644 headdb-core/src/main/java/com/bitworksmc/headdb/core/util/WebsiteLinks.java create mode 100644 headdb-core/src/test/java/com/bitworksmc/headdb/core/util/WebsiteLinksTest.java create mode 100644 headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinks.java create mode 100644 headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinksTest.java diff --git a/README.md b/README.md index 6b9baae..d759c74 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ The database loads on a background thread. - **Flexible Querying** Search by name, ID, category, or tags. +- **Website handoff** + `/hdb submit` opens the public submission form, and in-game searches can link + directly to the equivalent browser search for richer filtering and copyable commands. Browse the catalog, submit heads, and read the plugin and HTTP API documentation at [headdb.net](https://headdb.net). The modern plugin restores its saved catalog, @@ -93,6 +96,7 @@ Choose your preferred source: | `headdb.command.give` | Give a database head by command. | | `headdb.command.info` | View HeadDB and server version information. | | `headdb.command.sounds` | Toggle personal HeadDB interface sounds with `/hdb sounds`. | +| `headdb.command.submit` | Show the clickable headdb.net submission link with `/hdb submit`. | | `headdb.update.notify` | Receive a notification with the latest-release download link. | | `headdb.category.*` | Access every category. | | `headdb.category.` | Access one database or custom category. | diff --git a/changelog/6.0.4.md b/changelog/6.0.4.md index e9c5ab1..e11e3fd 100644 --- a/changelog/6.0.4.md +++ b/changelog/6.0.4.md @@ -5,3 +5,5 @@ servers. - Added complete-catalog support for legacy servers from 1.8.8 to 1.20.6. - Switched submitted skins to Mojang-hosted textures for reliable rendering. +- Added `/hdb submit`, headdb.net submission links throughout the modern menus, + and an optional post-search link that carries supported filters into the web catalog. diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/HDBSubCommandManager.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/HDBSubCommandManager.java index 9f2a46f..b06698d 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/HDBSubCommandManager.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/HDBSubCommandManager.java @@ -6,6 +6,7 @@ import com.bitworksmc.headdb.core.command.sub.HDBCommandOpen; import com.bitworksmc.headdb.core.command.sub.HDBCommandSearch; import com.bitworksmc.headdb.core.command.sub.HDBCommandSounds; +import com.bitworksmc.headdb.core.command.sub.HDBCommandSubmit; import java.util.ArrayList; import java.util.HashMap; @@ -30,6 +31,7 @@ public void registerDefaults() { register(new HDBCommandSearch(plugin)); register(new HDBCommandOpen(plugin)); register(new HDBCommandSounds(plugin)); + register(new HDBCommandSubmit(plugin)); } public void register(HDBSubCommand command) { diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSearch.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSearch.java index 707e212..e40de53 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSearch.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSearch.java @@ -6,7 +6,9 @@ import com.bitworksmc.headdb.core.menu.gui.HeadsGUI; import com.bitworksmc.headdb.core.util.Compatibility; import com.bitworksmc.headdb.core.util.PermissionUtil; +import com.bitworksmc.headdb.core.util.WebsiteLinks; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.slf4j.Logger; @@ -170,7 +172,12 @@ public void handle(CommandSender sender, String[] args) { } } - return new SearchResult(result, qName, true); + return new SearchResult( + result, + qName, + WebsiteLinks.searchUrl(plugin.getCfg().getWebsiteUrl(), nameQuery, category, tags, ids), + true + ); }).thenAcceptAsync(searchResult -> { if (!searchResult.valid) { Compatibility.playSound(player, plugin.getSoundConfig().get("failure")); @@ -181,10 +188,12 @@ public void handle(CommandSender sender, String[] args) { .toList(); if (heads == null || heads.isEmpty()) { this.plugin.getLocalization().sendMessage(player, "command.search.none"); + sendWebsiteHint(player, searchResult.websiteUrl); return; } plugin.getLocalization().sendMessage(player, "command.search.found", msg -> msg.replaceText(builder -> builder.matchLiteral("{amount}").replacement(String.valueOf(heads.size()))).replaceText(builder -> builder.matchLiteral("{name}").replacement(searchResult.name))); + sendWebsiteHint(player, searchResult.websiteUrl); HeadsGUI gui = new HeadsGUI( plugin, @@ -212,9 +221,26 @@ public List handleCompletions(CommandSender sender, String[] args) { return completions; } - private record SearchResult(List heads, String name, boolean valid) { + private void sendWebsiteHint(Player player, String url) { + if (!plugin.getCfg().isWebsiteSearchHintEnabled()) { + return; + } + Component message = plugin.getLocalization().getMessage(player.getUniqueId(), "command.search.website") + .orElseGet(() -> Component.text() + .append(Component.text("Want to refine this search faster? ", NamedTextColor.GRAY)) + .append(Component.text("Open it on headdb.net", NamedTextColor.AQUA)) + .append(Component.text(" to filter results and copy ready-to-use commands.", NamedTextColor.GRAY)) + .build()); + Compatibility.sendMessage(player, WebsiteLinks.makeClickable( + message, + url, + Component.text("Open this search on headdb.net", NamedTextColor.AQUA) + )); + } + + private record SearchResult(List heads, String name, String websiteUrl, boolean valid) { private static SearchResult invalid() { - return new SearchResult(List.of(), "", false); + return new SearchResult(List.of(), "", "", false); } } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSubmit.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSubmit.java new file mode 100644 index 0000000..569eaf8 --- /dev/null +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSubmit.java @@ -0,0 +1,42 @@ +package com.bitworksmc.headdb.core.command.sub; + +import com.bitworksmc.headdb.core.HeadDB; +import com.bitworksmc.headdb.core.command.HDBSubCommand; +import com.bitworksmc.headdb.core.util.Compatibility; +import com.bitworksmc.headdb.core.util.WebsiteLinks; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +/** Opens HeadDB's public submission page from an in-game link. */ +public class HDBCommandSubmit extends HDBSubCommand { + + private final HeadDB plugin; + + public HDBCommandSubmit(HeadDB plugin) { + super("submit", "Submit a head for review on headdb.net.", null, "submission"); + this.plugin = plugin; + } + + @Override + public void handle(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLocalization().sendMessage(sender, "noConsole"); + return; + } + + String url = WebsiteLinks.submissionUrl(plugin.getCfg().getWebsiteUrl()); + Component message = plugin.getLocalization().getMessage(player.getUniqueId(), "command.submit.link") + .orElseGet(() -> Component.text() + .append(Component.text("Have a head to share? ", NamedTextColor.GRAY)) + .append(Component.text("Submit it on headdb.net", NamedTextColor.AQUA)) + .append(Component.text(" for review.", NamedTextColor.GRAY)) + .build()); + Compatibility.sendMessage(player, WebsiteLinks.makeClickable( + message, + url, + Component.text("Open " + url, NamedTextColor.AQUA) + )); + } +} diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java index 043185b..390121a 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java @@ -3,6 +3,7 @@ import com.bitworksmc.headdb.api.model.Head; import com.bitworksmc.headdb.core.HeadDB; import com.bitworksmc.headdb.core.util.Compatibility; +import com.bitworksmc.headdb.core.util.WebsiteLinks; import com.bitworksmc.headdb.implementation.Index; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; @@ -48,6 +49,8 @@ public class Config { private List omit; private List databaseSourceUrls = List.of(DEFAULT_DATABASE_SOURCE_URL); private String databaseSyncUrl = DEFAULT_DATABASE_SYNC_URL; + private String websiteUrl = WebsiteLinks.DEFAULT_BASE_URL; + private boolean websiteSearchHintEnabled; // Indexing private boolean indexingEnabled, indexById, indexByTexture, indexByCategory, indexByTag; @@ -95,6 +98,8 @@ private void loadGeneral() { databaseThreads = positiveInt("database.threads", 1); apiThreads = positiveInt("database.apiThreads", 1); databaseSyncIntervalMinutes = positiveLong("database.syncIntervalMinutes", 15L); + websiteUrl = WebsiteLinks.normalizeBaseUrl(config.getString("website.url", WebsiteLinks.DEFAULT_BASE_URL)); + websiteSearchHintEnabled = config.getBoolean("website.searchHint.enabled", true); databaseSyncUrl = Optional.ofNullable(config.getString("database.syncUrl", DEFAULT_DATABASE_SYNC_URL)) .map(String::trim) .filter(value -> !value.isEmpty()) @@ -117,6 +122,8 @@ private void loadGeneral() { LOGGER.trace(" - databaseSourceUrls = {}", databaseSourceUrls); LOGGER.trace(" - databaseSyncUrl = {}", databaseSyncUrl); LOGGER.trace(" - databaseSyncIntervalMinutes = {}", databaseSyncIntervalMinutes); + LOGGER.trace(" - websiteUrl = {}", websiteUrl); + LOGGER.trace(" - websiteSearchHintEnabled = {}", websiteSearchHintEnabled); LOGGER.trace(" - maxBuyAmount = {}", maxBuyAmount); } @@ -468,6 +475,8 @@ public List resolveCustomCategories(List loadedHeads) { public List getDatabaseSourceUrls() { return databaseSourceUrls; } public @Nullable String getDatabaseSyncUrl() { return databaseSyncUrl; } public long getDatabaseSyncIntervalMinutes() { return databaseSyncIntervalMinutes; } + public String getWebsiteUrl() { return websiteUrl; } + public boolean isWebsiteSearchHintEnabled() { return websiteSearchHintEnabled; } public int getMaxBuyAmount() { return maxBuyAmount; } public List getOmit() { return omit; } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/MainMenu.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/MainMenu.java index 4feb10f..543841e 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/MainMenu.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/MainMenu.java @@ -13,9 +13,9 @@ import com.bitworksmc.headdb.core.storage.PlayerData; import com.bitworksmc.headdb.core.util.Compatibility; import com.bitworksmc.headdb.core.util.PermissionUtil; +import com.bitworksmc.headdb.core.util.WebsiteLinks; import com.github.thesilentpro.inputs.paper.PaperInput; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; import org.bukkit.Material; @@ -30,7 +30,6 @@ public class MainMenu extends SimplePage { private static final Logger LOGGER = LoggerFactory.getLogger(MainMenu.class); - private static final String DISCORD_URL = "https://discord.gg/j8BAsz8Ac7"; private static final int[] CATEGORY_SLOTS = {11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 29, 30, 31, 32, 33}; public MainMenu(HeadDB plugin, List heads) { @@ -239,14 +238,15 @@ private void renderInfoButton(HeadDB plugin) { return; } + String submissionUrl = WebsiteLinks.submissionUrl(plugin.getCfg().getWebsiteUrl()); Component[] lore = new Component[]{ Component.text("โ“ Didn't spot the perfect head in our collection?").color(NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false), Component.text("๐ŸŽฏ We're always adding more โ€” and you can help!").color(NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false), Component.text(""), Component.text("๐Ÿ“ฅ Submit your favorite or original heads").color(NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false), - Component.text("โœจ Directly through our community Discord!").color(NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false), + Component.text("โœจ Send it through headdb.net for review!").color(NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false), Component.text(""), - Component.text("๐Ÿ”— Discord > " + DISCORD_URL).color(NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false) + Component.text("๐Ÿ”— Submit > " + submissionUrl).color(NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false) }; ItemStack item = plugin.getHeadApi() @@ -257,10 +257,13 @@ private void renderInfoButton(HeadDB plugin) { setButton(53, new SimpleButton(item, ctx -> Compatibility.sendMessage( ctx.event().getWhoClicked(), - Component.text("Click to join: " + DISCORD_URL) - .color(NamedTextColor.AQUA) - .clickEvent(ClickEvent.openUrl(DISCORD_URL)) - .decoration(TextDecoration.UNDERLINED, true) + WebsiteLinks.makeClickable( + Component.text("Click to submit a head: " + submissionUrl) + .color(NamedTextColor.AQUA) + .decoration(TextDecoration.UNDERLINED, true), + submissionUrl, + Component.text("Open " + submissionUrl, NamedTextColor.AQUA) + ) ))); } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/CustomCategoriesGUI.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/CustomCategoriesGUI.java index 38a5550..e618fff 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/CustomCategoriesGUI.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/CustomCategoriesGUI.java @@ -7,9 +7,8 @@ import com.bitworksmc.headdb.core.menu.CustomCategoriesMenu; import com.bitworksmc.headdb.core.util.Compatibility; import com.bitworksmc.headdb.core.util.Utils; -import com.bitworksmc.headdb.core.util.Utils; +import com.bitworksmc.headdb.core.util.WebsiteLinks; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.minimessage.MiniMessage; @@ -21,11 +20,10 @@ import java.util.List; public class CustomCategoriesGUI extends SafePaginatedGUI { - private static final String DISCORD_URL = "https://discord.gg/j8BAsz8Ac7"; - public CustomCategoriesGUI(HeadDB plugin, String key, Component title, List categories) { super(new NamespacedKey(plugin, Utils.normalizeNamespacedKey("gui_" + key))); + String submissionUrl = WebsiteLinks.submissionUrl(plugin.getCfg().getWebsiteUrl()); // Chunk heads list for (List headsChunk : Utils.chunk(categories, plugin.getCfg().getHeadsMenuRows() * 9)) { CustomCategoriesMenu headsMenu = new CustomCategoriesMenu(plugin, this, title, headsChunk); @@ -55,11 +53,11 @@ public CustomCategoriesGUI(HeadDB plugin, String key, Component title, List " + DISCORD_URL) + Component.text("๐Ÿ”— Submit > " + submissionUrl) .decoration(TextDecoration.ITALIC, false) .color(NamedTextColor.YELLOW) }; @@ -81,10 +79,13 @@ public CustomCategoriesGUI(HeadDB plugin, String key, Component title, List { Compatibility.sendMessage( ctx.event().getWhoClicked(), - Component.text("Click to join: " + DISCORD_URL) - .color(NamedTextColor.AQUA) - .clickEvent(ClickEvent.openUrl(DISCORD_URL)) - .decoration(TextDecoration.UNDERLINED, true) + WebsiteLinks.makeClickable( + Component.text("Click to submit a head: " + submissionUrl) + .color(NamedTextColor.AQUA) + .decoration(TextDecoration.UNDERLINED, true), + submissionUrl, + Component.text("Open " + submissionUrl, NamedTextColor.AQUA) + ) ); })); } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/FavoritesHeadsGUI.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/FavoritesHeadsGUI.java index d10432d..fdcb47f 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/FavoritesHeadsGUI.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/FavoritesHeadsGUI.java @@ -7,8 +7,8 @@ import com.bitworksmc.headdb.core.menu.FavoritesHeadsMenu; import com.bitworksmc.headdb.core.util.Compatibility; import com.bitworksmc.headdb.core.util.Utils; +import com.bitworksmc.headdb.core.util.WebsiteLinks; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.minimessage.MiniMessage; @@ -21,11 +21,10 @@ import java.util.List; public class FavoritesHeadsGUI extends SafePaginatedGUI { - private static final String DISCORD_URL = "https://discord.gg/j8BAsz8Ac7"; - public FavoritesHeadsGUI(HeadDB plugin, String key, Component title, List heads, List items) { super(new NamespacedKey(plugin, Utils.normalizeNamespacedKey("gui_" + key))); + String submissionUrl = WebsiteLinks.submissionUrl(plugin.getCfg().getWebsiteUrl()); int pageSize = plugin.getCfg().getHeadsMenuRows() * 9; for (PageRange range : calculatePageRanges(heads.size(), items.size(), pageSize)) { List headsChunk = heads.subList(range.headFrom(), range.headTo()); @@ -58,11 +57,11 @@ public FavoritesHeadsGUI(HeadDB plugin, String key, Component title, List Component.text("๐Ÿ“ฅ Submit your favorite or original heads") .decoration(TextDecoration.ITALIC, false) .color(NamedTextColor.YELLOW), - Component.text("โœจ Directly through our community Discord!") + Component.text("โœจ Send it through headdb.net for review!") .decoration(TextDecoration.ITALIC, false) .color(NamedTextColor.YELLOW), Component.text(""), - Component.text("๐Ÿ”— Discord > " + DISCORD_URL) + Component.text("๐Ÿ”— Submit > " + submissionUrl) .decoration(TextDecoration.ITALIC, false) .color(NamedTextColor.YELLOW) }; @@ -84,10 +83,13 @@ public FavoritesHeadsGUI(HeadDB plugin, String key, Component title, List page.setButton(53, new SimpleButton(infoItem, ctx -> { Compatibility.sendMessage( ctx.event().getWhoClicked(), - Component.text("Click to join: " + DISCORD_URL) - .color(NamedTextColor.AQUA) - .clickEvent(ClickEvent.openUrl(DISCORD_URL)) - .decoration(TextDecoration.UNDERLINED, true) + WebsiteLinks.makeClickable( + Component.text("Click to submit a head: " + submissionUrl) + .color(NamedTextColor.AQUA) + .decoration(TextDecoration.UNDERLINED, true), + submissionUrl, + Component.text("Open " + submissionUrl, NamedTextColor.AQUA) + ) ); })); } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/HeadsGUI.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/HeadsGUI.java index d67c63b..e29b4e5 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/HeadsGUI.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/HeadsGUI.java @@ -6,8 +6,8 @@ import com.bitworksmc.headdb.core.menu.HeadsMenu; import com.bitworksmc.headdb.core.util.Compatibility; import com.bitworksmc.headdb.core.util.Utils; +import com.bitworksmc.headdb.core.util.WebsiteLinks; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.minimessage.MiniMessage; @@ -20,8 +20,6 @@ import java.util.List; public class HeadsGUI extends SafePaginatedGUI { - private static final String DISCORD_URL = "https://discord.gg/j8BAsz8Ac7"; - public HeadsGUI(HeadDB plugin, String key, Component title, List heads) { this(plugin, key, title, heads, null); } @@ -37,6 +35,7 @@ public HeadsGUI(HeadDB plugin, String key, Component title, List heads, @N // This icon is identical on every page. Resolve its database texture // once per GUI instead of scheduling and joining one API lookup per page. + String submissionUrl = WebsiteLinks.submissionUrl(plugin.getCfg().getWebsiteUrl()); ItemStack infoItem = null; if (plugin.getCfg().isShowInfoItem()) { Component[] infoLore = new Component[]{ @@ -50,11 +49,11 @@ public HeadsGUI(HeadDB plugin, String key, Component title, List heads, @N Component.text("๐Ÿ“ฅ Submit your favorite or original heads") .decoration(TextDecoration.ITALIC, false) .color(NamedTextColor.YELLOW), - Component.text("โœจ Directly through our community Discord!") + Component.text("โœจ Send it through headdb.net for review!") .decoration(TextDecoration.ITALIC, false) .color(NamedTextColor.YELLOW), Component.text(""), - Component.text("๐Ÿ”— Discord > " + DISCORD_URL) + Component.text("๐Ÿ”— Submit > " + submissionUrl) .decoration(TextDecoration.ITALIC, false) .color(NamedTextColor.YELLOW) }; @@ -92,10 +91,13 @@ public HeadsGUI(HeadDB plugin, String key, Component title, List heads, @N headsMenu.setButton(53, new SimpleButton(infoItem, ctx -> { Compatibility.sendMessage( ctx.event().getWhoClicked(), - Component.text("Click to join: " + DISCORD_URL) - .color(NamedTextColor.AQUA) - .clickEvent(ClickEvent.openUrl(DISCORD_URL)) - .decoration(TextDecoration.UNDERLINED, true) + WebsiteLinks.makeClickable( + Component.text("Click to submit a head: " + submissionUrl) + .color(NamedTextColor.AQUA) + .decoration(TextDecoration.UNDERLINED, true), + submissionUrl, + Component.text("Open " + submissionUrl, NamedTextColor.AQUA) + ) ); })); } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/LocalHeadsGUI.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/LocalHeadsGUI.java index b4f02db..e6c1d20 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/LocalHeadsGUI.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/LocalHeadsGUI.java @@ -6,9 +6,8 @@ import com.bitworksmc.headdb.core.menu.LocalHeadsMenu; import com.bitworksmc.headdb.core.util.Compatibility; import com.bitworksmc.headdb.core.util.Utils; -import com.bitworksmc.headdb.core.util.Utils; +import com.bitworksmc.headdb.core.util.WebsiteLinks; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.minimessage.MiniMessage; @@ -20,11 +19,10 @@ import java.util.List; public class LocalHeadsGUI extends SafePaginatedGUI { - private static final String DISCORD_URL = "https://discord.gg/j8BAsz8Ac7"; - public LocalHeadsGUI(HeadDB plugin, String key, Component title, List items) { super(new NamespacedKey(plugin, Utils.normalizeNamespacedKey("gui_" + key))); + String submissionUrl = WebsiteLinks.submissionUrl(plugin.getCfg().getWebsiteUrl()); // Chunk items list for (List itemsChunk : Utils.chunk(items, plugin.getCfg().getHeadsMenuRows() * 9)) { LocalHeadsMenu headsMenu = new LocalHeadsMenu(plugin, this, title, itemsChunk); @@ -53,11 +51,11 @@ public LocalHeadsGUI(HeadDB plugin, String key, Component title, List Component.text("๐Ÿ“ฅ Submit your favorite or original heads") .decoration(TextDecoration.ITALIC, false) .color(NamedTextColor.YELLOW), - Component.text("โœจ Directly through our community Discord!") + Component.text("โœจ Send it through headdb.net for review!") .decoration(TextDecoration.ITALIC, false) .color(NamedTextColor.YELLOW), Component.text(""), - Component.text("๐Ÿ”— Discord > " + DISCORD_URL) + Component.text("๐Ÿ”— Submit > " + submissionUrl) .decoration(TextDecoration.ITALIC, false) .color(NamedTextColor.YELLOW) }; @@ -79,10 +77,13 @@ public LocalHeadsGUI(HeadDB plugin, String key, Component title, List headsMenu.setButton(53, new SimpleButton(infoItem, ctx -> { Compatibility.sendMessage( ctx.event().getWhoClicked(), - Component.text("Click to join: " + DISCORD_URL) - .color(NamedTextColor.AQUA) - .clickEvent(ClickEvent.openUrl(DISCORD_URL)) - .decoration(TextDecoration.UNDERLINED, true) + WebsiteLinks.makeClickable( + Component.text("Click to submit a head: " + submissionUrl) + .color(NamedTextColor.AQUA) + .decoration(TextDecoration.UNDERLINED, true), + submissionUrl, + Component.text("Open " + submissionUrl, NamedTextColor.AQUA) + ) ); })); } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/util/WebsiteLinks.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/util/WebsiteLinks.java new file mode 100644 index 0000000..bd7f952 --- /dev/null +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/util/WebsiteLinks.java @@ -0,0 +1,110 @@ +package com.bitworksmc.headdb.core.util; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +/** Builds the public HeadDB website links shown by the plugin. */ +public final class WebsiteLinks { + + public static final String DEFAULT_BASE_URL = "https://headdb.net"; + + private WebsiteLinks() { + } + + public static String normalizeBaseUrl(String configuredUrl) { + if (configuredUrl == null || configuredUrl.isBlank()) { + return DEFAULT_BASE_URL; + } + + String value = configuredUrl.trim(); + while (value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + + try { + URI uri = URI.create(value); + String scheme = uri.getScheme(); + if (("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + && uri.getHost() != null) { + return value; + } + } catch (IllegalArgumentException ignored) { + // Use the official site when a server owner enters an invalid URL. + } + return DEFAULT_BASE_URL; + } + + public static String submissionUrl(String baseUrl) { + return normalizeBaseUrl(baseUrl) + "/submit"; + } + + public static String searchUrl( + String baseUrl, + String name, + String category, + Collection tags, + Collection ids + ) { + List parameters = new ArrayList<>(); + String trimmedName = name == null ? "" : name.trim(); + if (!trimmedName.isEmpty()) { + parameters.add(parameter("q", trimmedName)); + } else if (ids != null && ids.size() == 1) { + parameters.add(parameter("q", String.valueOf(ids.iterator().next()))); + } + + String categorySlug = slugify(category); + if (!categorySlug.isEmpty()) { + parameters.add(parameter("category", categorySlug)); + } + + if (tags != null) { + String tagSlugs = tags.stream() + .map(WebsiteLinks::slugify) + .filter(tag -> !tag.isEmpty()) + .distinct() + .collect(Collectors.joining(",")); + if (!tagSlugs.isEmpty()) { + parameters.add(parameter("tags", tagSlugs)); + } + } + + String url = normalizeBaseUrl(baseUrl) + "/heads"; + return parameters.isEmpty() ? url : url + "?" + String.join("&", parameters); + } + + public static Component makeClickable(Component message, String url, Component hoverText) { + return message + .clickEvent(ClickEvent.openUrl(url)) + .hoverEvent(HoverEvent.showText(hoverText)); + } + + static String slugify(String value) { + if (value == null || value.isBlank()) { + return ""; + } + return value.trim() + .toLowerCase(Locale.ROOT) + .replace("&", " and ") + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+|-+$", ""); + } + + private static String parameter(String name, String value) { + return encode(name) + "=" + encode(value); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/headdb-core/src/main/resources/config.yml b/headdb-core/src/main/resources/config.yml index d9d43ab..57101e4 100644 --- a/headdb-core/src/main/resources/config.yml +++ b/headdb-core/src/main/resources/config.yml @@ -18,6 +18,13 @@ preloadHeads: false # Example: Player is on page 10 of the category, next time they open that category they will start from that page instead of the first. trackPage: true +# Public website links shown to players. +website: + url: "https://headdb.net" + searchHint: + # Show a clickable website link after /hdb search completes. + enabled: true + # If enabled shows the info item on how you can add heads to the database.- showInfoItem: true diff --git a/headdb-core/src/main/resources/messages/en.yml b/headdb-core/src/main/resources/messages/en.yml index a332f6e..f3e18c6 100644 --- a/headdb-core/src/main/resources/messages/en.yml +++ b/headdb-core/src/main/resources/messages/en.yml @@ -99,6 +99,7 @@ command: failed: "The search failed. Check the server console for details." none: "No heads found!" found: "Found {amount} heads!" + website: "Want to refine this search faster? Open it on headdb.net to filter results and copy ready-to-use commands." filter: | ๐Ÿ”Ž Filters @@ -113,3 +114,6 @@ command: sounds: enabled: "HeadDB interface sounds enabled." disabled: "HeadDB interface sounds disabled. Run the command again to re-enable them." + + submit: + link: "Have a head to share? Submit it on headdb.net for review." diff --git a/headdb-core/src/main/resources/plugin.yml b/headdb-core/src/main/resources/plugin.yml index d0bcf77..e013bc7 100644 --- a/headdb-core/src/main/resources/plugin.yml +++ b/headdb-core/src/main/resources/plugin.yml @@ -30,6 +30,7 @@ permissions: headdb.command.give: true headdb.command.info: true headdb.command.sounds: true + headdb.command.submit: true headdb.update.notify: true headdb.category.*: true headdb.category.favorites: true @@ -51,6 +52,9 @@ permissions: headdb.command.sounds: description: Allows toggling personal HeadDB interface sounds. default: true + headdb.command.submit: + description: Shows a clickable link to the HeadDB submission page. + default: true headdb.update.notify: description: Notifies the player when a newer HeadDB release is available. default: op diff --git a/headdb-core/src/test/java/com/bitworksmc/headdb/core/util/WebsiteLinksTest.java b/headdb-core/src/test/java/com/bitworksmc/headdb/core/util/WebsiteLinksTest.java new file mode 100644 index 0000000..9c97c58 --- /dev/null +++ b/headdb-core/src/test/java/com/bitworksmc/headdb/core/util/WebsiteLinksTest.java @@ -0,0 +1,37 @@ +package com.bitworksmc.headdb.core.util; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class WebsiteLinksTest { + + @Test + void buildsSearchUrlFromSupportedPluginFilters() { + assertEquals( + "https://headdb.net/heads?q=dark+oak&category=food-and-drinks&tags=dark%2Cwood", + WebsiteLinks.searchUrl( + "https://headdb.net/", + "dark oak", + "Food & Drinks", + List.of("Dark", "Wood"), + List.of() + ) + ); + } + + @Test + void usesSingleIdAsWebsiteQueryWhenNoNameWasProvided() { + assertEquals( + "https://headdb.net/heads?q=103838", + WebsiteLinks.searchUrl("https://headdb.net", "", null, List.of(), List.of(103838)) + ); + } + + @Test + void rejectsInvalidConfiguredBaseUrl() { + assertEquals("https://headdb.net/submit", WebsiteLinks.submissionUrl("javascript:alert(1)")); + } +} diff --git a/headdb-legacy/pom.xml b/headdb-legacy/pom.xml index 5c32815..580cbde 100644 --- a/headdb-legacy/pom.xml +++ b/headdb-legacy/pom.xml @@ -43,6 +43,12 @@ + + net.md-5 + bungeecord-chat + 1.21-R0.4 + provided + com.google.code.gson gson diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadDB.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadDB.java index 6461a68..4313050 100644 --- a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadDB.java +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadDB.java @@ -15,6 +15,11 @@ import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.scheduler.BukkitRunnable; import org.bstats.bukkit.Metrics; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.ComponentBuilder; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TextComponent; import java.util.ArrayList; import java.util.Arrays; @@ -164,11 +169,31 @@ public boolean onCommand(CommandSender sender, Command command, String label, St if (args[0].equalsIgnoreCase("sounds")) { return toggleSounds(sender); } + if (args[0].equalsIgnoreCase("submit")) { + return submit(sender); + } if (args[0].equalsIgnoreCase("open")) { return open(sender, args); } - sender.sendMessage(ChatColor.RED + "Usage: /" + label + " [info|search |give [player]]"); + sender.sendMessage(ChatColor.RED + "Usage: /" + label + " [info|search |give [player]|submit]"); + return true; + } + + private boolean submit(CommandSender sender) { + if (!(sender instanceof Player)) { + sender.sendMessage(messages.get("noConsole", "Only players can use this command.")); + return true; + } + if (!sender.hasPermission("headdb.command.submit")) return denied(sender); + + String url = LegacyWebsiteLinks.submissionUrl(getConfig().getString("website.url", "https://headdb.net")); + sendWebsiteLink( + (Player) sender, + messages.get("command.submit.link", "Have a head to share? Submit it on headdb.net for review."), + url, + "Open " + url + ); return true; } @@ -228,6 +253,7 @@ private boolean search(final CommandSender sender, String[] args) { menus.openSearch((Player) sender, query, matches); sender.sendMessage(messages.get("command.search.found", "Found {amount} heads!", "amount", String.valueOf(matches.size()))); + sendSearchWebsiteHint((Player) sender, args); return; } sender.sendMessage(ChatColor.GOLD + "HeadDB matches for '" + query + "':"); @@ -350,10 +376,41 @@ private boolean denied(CommandSender sender) { return true; } + private void sendSearchWebsiteHint(Player player, String[] args) { + if (!getConfig().getBoolean("website.searchHint.enabled", true)) { + return; + } + String url = LegacyWebsiteLinks.searchUrl( + getConfig().getString("website.url", "https://headdb.net"), + args + ); + sendWebsiteLink( + player, + messages.get("command.search.website", + "Want to refine this search faster? Open it on headdb.net to filter results and copy ready-to-use commands."), + url, + "Open this search on headdb.net" + ); + } + + private void sendWebsiteLink(Player player, String text, String url, String hoverText) { + BaseComponent[] components = TextComponent.fromLegacyText(text); + ClickEvent clickEvent = new ClickEvent(ClickEvent.Action.OPEN_URL, url); + HoverEvent hoverEvent = new HoverEvent( + HoverEvent.Action.SHOW_TEXT, + new ComponentBuilder(hoverText).color(net.md_5.bungee.api.ChatColor.AQUA).create() + ); + for (BaseComponent component : components) { + component.setClickEvent(clickEvent); + component.setHoverEvent(hoverEvent); + } + player.spigot().sendMessage(components); + } + @Override public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { if (args.length == 1) { - return prefix(Arrays.asList("open", "info", "search", "give", "sounds"), args[0]); + return prefix(Arrays.asList("open", "info", "search", "give", "sounds", "submit"), args[0]); } if (args.length == 2 && args[0].equalsIgnoreCase("give")) { List players = new ArrayList(); diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinks.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinks.java new file mode 100644 index 0000000..fc90929 --- /dev/null +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinks.java @@ -0,0 +1,143 @@ +package com.bitworksmc.headdb.legacy; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +final class LegacyWebsiteLinks { + private static final String DEFAULT_BASE_URL = "https://headdb.net"; + + private LegacyWebsiteLinks() { + } + + static String submissionUrl(String configuredBaseUrl) { + return normalizeBaseUrl(configuredBaseUrl) + "/submit"; + } + + static String searchUrl(String configuredBaseUrl, String[] args) { + String category = null; + Set tags = new LinkedHashSet(); + List ids = new ArrayList(); + List names = new ArrayList(); + + for (int i = 1; i < args.length; i++) { + String token = args[i]; + String lower = token.toLowerCase(Locale.ROOT); + if (lower.equals("--any")) { + continue; + } + if (lower.startsWith("category:")) { + category = token.substring(9); + } else if (lower.startsWith("tags:")) { + String[] split = token.substring(5).split(","); + for (String tag : split) { + if (!tag.trim().isEmpty()) { + tags.add(tag.trim()); + } + } + } else if (lower.startsWith("ids:")) { + String[] split = token.substring(4).split(","); + for (String id : split) { + try { + ids.add(Integer.parseInt(id.trim())); + } catch (NumberFormatException ignored) { + // The search command already handles invalid legacy ID filters. + } + } + } else { + names.add(token); + } + } + + List parameters = new ArrayList(); + String name = join(names); + if (!name.isEmpty()) { + parameters.add(parameter("q", name)); + } else if (ids.size() == 1) { + parameters.add(parameter("q", String.valueOf(ids.get(0)))); + } + + String categorySlug = slugify(category); + if (!categorySlug.isEmpty()) { + parameters.add(parameter("category", categorySlug)); + } + + List tagSlugs = new ArrayList(); + for (String tag : tags) { + String slug = slugify(tag); + if (!slug.isEmpty()) { + tagSlugs.add(slug); + } + } + if (!tagSlugs.isEmpty()) { + parameters.add(parameter("tags", join(tagSlugs, ","))); + } + + String url = normalizeBaseUrl(configuredBaseUrl) + "/heads"; + return parameters.isEmpty() ? url : url + "?" + join(parameters, "&"); + } + + private static String normalizeBaseUrl(String configuredBaseUrl) { + if (configuredBaseUrl == null || configuredBaseUrl.trim().isEmpty()) { + return DEFAULT_BASE_URL; + } + + String value = configuredBaseUrl.trim(); + while (value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + + try { + URI uri = URI.create(value); + if (("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) + && uri.getHost() != null) { + return value; + } + } catch (IllegalArgumentException ignored) { + // Use the official site below. + } + return DEFAULT_BASE_URL; + } + + private static String slugify(String value) { + if (value == null || value.trim().isEmpty()) { + return ""; + } + return value.trim().toLowerCase(Locale.ROOT) + .replace("&", " and ") + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+|-+$", ""); + } + + private static String parameter(String name, String value) { + return encode(name) + "=" + encode(value); + } + + private static String encode(String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (UnsupportedEncodingException impossible) { + throw new IllegalStateException(impossible); + } + } + + private static String join(List values) { + return join(values, " "); + } + + private static String join(List values, String separator) { + StringBuilder result = new StringBuilder(); + for (String value : values) { + if (result.length() > 0) { + result.append(separator); + } + result.append(value); + } + return result.toString(); + } +} diff --git a/headdb-legacy/src/main/resources/plugin.yml b/headdb-legacy/src/main/resources/plugin.yml index 0859877..6223254 100644 --- a/headdb-legacy/src/main/resources/plugin.yml +++ b/headdb-legacy/src/main/resources/plugin.yml @@ -6,7 +6,7 @@ version: ${project.version} commands: headdb: - usage: /headdb [info|search|give|sounds] + usage: /headdb [info|search|give|sounds|submit] description: Search and give heads from HeadDB aliases: [hdb, headdatabase] @@ -20,6 +20,7 @@ permissions: headdb.command.info: true headdb.command.sounds: true headdb.command.open: true + headdb.command.submit: true headdb.category.*: true headdb.update.notify: true headdb.command.search: @@ -32,6 +33,8 @@ permissions: default: true headdb.command.open: default: op + headdb.command.submit: + default: true headdb.category.*: default: op headdb.category.favorites: diff --git a/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinksTest.java b/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinksTest.java new file mode 100644 index 0000000..1798052 --- /dev/null +++ b/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinksTest.java @@ -0,0 +1,23 @@ +package com.bitworksmc.headdb.legacy; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class LegacyWebsiteLinksTest { + + @Test + public void carriesSupportedSearchFiltersIntoWebsiteUrl() { + assertEquals( + "https://headdb.net/heads?q=dark+oak&category=food-and-drinks&tags=dark%2Cwood", + LegacyWebsiteLinks.searchUrl("https://headdb.net/", new String[]{ + "search", "dark", "oak", "category:Food & Drinks", "tags:dark,wood" + }) + ); + } + + @Test + public void buildsSubmissionUrlFromValidatedBaseUrl() { + assertEquals("https://headdb.net/submit", LegacyWebsiteLinks.submissionUrl("javascript:alert(1)")); + } +} From a6b36d3ee451892dff5690d4037f2933c687216a Mon Sep 17 00:00:00 2001 From: GoodrichDev Date: Sun, 23 Aug 2026 12:00:19 -0700 Subject: [PATCH 8/9] 6.1.0 --- README.md | 50 +++- changelog/6.1.0.md | 38 +++ headdb-api/pom.xml | 2 +- .../com/bitworksmc/headdb/api/HeadAPI.java | 13 + .../bitworksmc/headdb/api/HeadDatabase.java | 6 + .../headdb/api/catalog/CatalogStatus.java | 31 +++ .../headdb/api/catalog/CatalogUpdate.java | 37 +++ .../api/catalog/CatalogUpdateListener.java | 6 + .../headdb/api/search/HeadSearch.java | 89 +++++++ .../headdb/api/search/MatchMode.java | 3 + .../headdb/api/search/SearchPage.java | 27 +++ .../headdb/api/search/SearchQuery.java | 73 ++++++ .../headdb/api/search/SearchSort.java | 3 + headdb-core/pom.xml | 12 +- .../com/bitworksmc/headdb/core/HeadDB.java | 78 ++++-- .../core/command/HDBSubCommandManager.java | 12 + .../core/command/sub/HDBCommandGive.java | 68 +++++- .../core/command/sub/HDBCommandInspect.java | 72 ++++++ .../core/command/sub/HDBCommandLanguage.java | 45 ++++ .../core/command/sub/HDBCommandRecent.java | 44 ++++ .../core/command/sub/HDBCommandReload.java | 19 ++ .../core/command/sub/HDBCommandSearch.java | 83 ++++++- .../core/command/sub/HDBCommandStatus.java | 46 ++++ .../core/command/sub/HDBCommandSync.java | 35 +++ .../bitworksmc/headdb/core/config/Config.java | 17 ++ .../headdb/core/factory/ItemFactory.java | 6 + .../headdb/core/factory/PaperItemFactory.java | 25 ++ .../core/menu/CustomCategoriesMenu.java | 3 +- .../headdb/core/menu/FavoritesHeadsMenu.java | 3 +- .../headdb/core/menu/HeadsMenu.java | 6 +- .../headdb/core/menu/LocalHeadsMenu.java | 3 +- .../bitworksmc/headdb/core/menu/MainMenu.java | 3 +- .../headdb/core/menu/MenuManager.java | 74 +++--- .../headdb/core/menu/PurchaseHeadMenu.java | 3 +- .../core/menu/gui/SafePaginatedGUI.java | 3 +- .../menu/registry/ConcurrentGUIRegistry.java | 35 +++ .../menu/registry/ConcurrentPageRegistry.java | 40 ++++ .../headdb/core/storage/PlayerDAO.java | 35 ++- .../headdb/core/storage/PlayerStorage.java | 25 +- .../headdb/core/util/HDBLocalization.java | 8 + .../headdb/core/util/WebsiteLinks.java | 35 ++- .../headdb/implementation/BaseHeadAPI.java | 21 ++ .../implementation/BaseHeadDatabase.java | 93 +++++++- headdb-core/src/main/resources/config.yml | 7 + .../src/main/resources/messages/en.yml | 21 ++ .../src/main/resources/messages/es.yml | 123 ++++++++++ headdb-core/src/main/resources/plugin.yml | 26 ++ .../headdb/api/search/HeadSearchTest.java | 50 ++++ .../command/sub/HDBCommandSearchTest.java | 20 ++ .../headdb/core/util/WebsiteLinksTest.java | 12 +- headdb-legacy/pom.xml | 7 +- .../headdb/legacy/LegacyDatabase.java | 81 ++++++- .../headdb/legacy/LegacyHeadAPI.java | 18 ++ .../headdb/legacy/LegacyHeadDB.java | 224 +++++++++++++++--- .../headdb/legacy/LegacyItemFactory.java | 15 ++ .../headdb/legacy/LegacyMenuManager.java | 44 ++-- .../headdb/legacy/LegacyMessages.java | 32 ++- .../headdb/legacy/LegacyPlayerStorage.java | 81 ++++++- .../headdb/legacy/LegacyWebsiteLinks.java | 50 +++- headdb-legacy/src/main/resources/plugin.yml | 20 +- .../headdb/legacy/LegacyWebsiteLinksTest.java | 20 ++ pom.xml | 3 +- 62 files changed, 2001 insertions(+), 183 deletions(-) create mode 100644 changelog/6.1.0.md create mode 100644 headdb-api/src/main/java/com/bitworksmc/headdb/api/catalog/CatalogStatus.java create mode 100644 headdb-api/src/main/java/com/bitworksmc/headdb/api/catalog/CatalogUpdate.java create mode 100644 headdb-api/src/main/java/com/bitworksmc/headdb/api/catalog/CatalogUpdateListener.java create mode 100644 headdb-api/src/main/java/com/bitworksmc/headdb/api/search/HeadSearch.java create mode 100644 headdb-api/src/main/java/com/bitworksmc/headdb/api/search/MatchMode.java create mode 100644 headdb-api/src/main/java/com/bitworksmc/headdb/api/search/SearchPage.java create mode 100644 headdb-api/src/main/java/com/bitworksmc/headdb/api/search/SearchQuery.java create mode 100644 headdb-api/src/main/java/com/bitworksmc/headdb/api/search/SearchSort.java create mode 100644 headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandInspect.java create mode 100644 headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandLanguage.java create mode 100644 headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandRecent.java create mode 100644 headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandReload.java create mode 100644 headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandStatus.java create mode 100644 headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSync.java create mode 100644 headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/registry/ConcurrentGUIRegistry.java create mode 100644 headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/registry/ConcurrentPageRegistry.java create mode 100644 headdb-core/src/main/resources/messages/es.yml create mode 100644 headdb-core/src/test/java/com/bitworksmc/headdb/api/search/HeadSearchTest.java create mode 100644 headdb-core/src/test/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSearchTest.java diff --git a/README.md b/README.md index d759c74..01c16ca 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,16 @@ The database loads on a background thread. - **Flexible Querying** Search by name, ID, category, or tags. + Structured queries also support multiple IDs and all/any matching. +- **Operational controls** + Inspect catalog health, synchronize immediately, reload runtime settings, + inspect held heads, and browse recently assigned IDs. +- **Network-ready player storage** + Keep player settings in local SQLite or share favorites, language, and sound + preferences through MySQL. +- **Paper and Folia menus** + Modern inventories are isolated per open and their registries are safe across + region threads. The legacy jar continues to support pre-1.21 Bukkit servers. - **Website handoff** `/hdb submit` opens the public submission form, and in-game searches can link directly to the equivalent browser search for richer filtering and copyable commands. @@ -43,12 +53,12 @@ snapshot and the legacy BitworksMC GitHub catalog remain available for recovery. ## ๐Ÿš€ Download & Installation -HeadDB 6.0.4 is distributed as two server-specific jars. Install exactly one: +HeadDB 6.1.0 is distributed as two server-specific jars. Install exactly one: | File | Server versions | Java | Purpose | |---|---|---|---| -| `HeadDB-6.0.4.jar` | Paper 1.21.0 and newer | Java 21+ | The full modern plugin and the recommended download. | -| `HeadDB-6.0.4-legacy.jar` | Bukkit-compatible 1.8.8-1.20.6 | Java 8 bytecode* | The isolated implementation for servers before 1.21. | +| `HeadDB-6.1.0.jar` | Paper/Folia 1.21.0 and newer | Java 21+ | The full modern plugin and the recommended download. | +| `HeadDB-6.1.0-legacy.jar` | Bukkit-compatible 1.8.8-1.20.6 | Java 8 bytecode* | The isolated implementation for servers before 1.21. | \* Use the Java version required by the Minecraft server. The legacy plugin itself is Java 8-compatible, but later Minecraft releases require newer Java @@ -66,8 +76,8 @@ heads, custom-category, purchase, player-storage, API, localization, sound, update-checking, metrics, and database-refresh features. Its menus are implemented with Bukkit inventories so they work before Paper's modern menu APIs. Purchase amounts use inventory presets, and advanced MiniMessage effects are simplified -on older clients. Folia is not currently supported because the bundled modern -menu framework is not region-thread safe. +on older clients. The modern jar declares Folia support and avoids sharing menu +inventories between opens; page and navigation registries use concurrent state. HeadDB checks GitHub Releases for updates on startup and every 24 hours by default. Console notifications and player notifications can be configured under @@ -97,6 +107,12 @@ Choose your preferred source: | `headdb.command.info` | View HeadDB and server version information. | | `headdb.command.sounds` | Toggle personal HeadDB interface sounds with `/hdb sounds`. | | `headdb.command.submit` | Show the clickable headdb.net submission link with `/hdb submit`. | +| `headdb.command.status` | Show catalog source, revision, size, and last synchronization result. | +| `headdb.command.sync` | Run a managed-catalog synchronization immediately. | +| `headdb.command.reload` | Reload runtime messages, menus, prices, links, sounds, and categories. | +| `headdb.command.inspect` | Inspect a held or identified head and open its website record. | +| `headdb.command.recent` | Browse the catalog entries with the newest IDs. | +| `headdb.command.language` | Select a personal message language, such as `en` or `es`. | | `headdb.update.notify` | Receive a notification with the latest-release download link. | | `headdb.category.*` | Access every category. | | `headdb.category.` | Access one database or custom category. | @@ -114,6 +130,21 @@ To grant every category except local heads, grant `headdb.category.*` and explic Database category IDs are their lowercase names with spaces and symbols replaced by underscores. For example, `Food & Drinks` uses `headdb.category.food_drinks`. Custom categories use the identifier from `categories.yml` (normalized the same way). +Common command forms include `/hdb give id:123`, `/hdb give 16 id:123`, and +the administrator form `/hdb give `. Structured search +accepts quoted filters, multiple `id:` values, and `--any`, for example: + +```text +/hdb search category:"Food & Drinks" tag:dessert id:123 id:456 --any +``` + +Set `storage.player.backend` to `MYSQL` on every proxy-network server and use the +same JDBC credentials to share player preferences. Changing the storage backend, +database endpoints, worker counts, or scheduled intervals requires a restart; +other settings can be applied with `/hdb reload`. +An empty MySQL table is seeded once from an existing local SQLite player +database, so retain the local file until the first successful migration. + --- ## ๐Ÿž Reporting Issues @@ -126,7 +157,7 @@ Found a bug or have a feature request? Open an issue: ## ๐Ÿค Using the API -HeadDB 6.0.4 uses two APIs for different purposes: +HeadDB 6.1.0 uses two APIs for different purposes: - The **HeadDB HTTP API** at `https://headdb.net/api/v1` is the managed source for published head data. The modern plugin downloads @@ -262,6 +293,9 @@ Legacy compatibility: `com.github.thesilentpro.headdb.api.*` remains available a | `computeLocalHeads()` | Generate `ItemStack`s for all players known to the server. | | `computeLocalHead(UUID uniqueId)` | Generate an `ItemStack` for a specific player UUID. | | `List findKnownCategories()` | List all category names. | +| `CatalogStatus getCatalogStatus()` | Read source, revision, size, and last synchronization health. | +| `search(SearchQuery query)` | Structured, sorted, paginated local catalog search. | +| `addCatalogUpdateListener(listener)` | Observe added, edited, and removed catalog IDs. | | `ExecutorService getExecutor()` | Access the internal executor for advanced workflows. | --- @@ -297,8 +331,8 @@ you agree to the [Minecraft EULA](https://aka.ms/MinecraftEULA). Run `mvn clean package` from the repository root. The release files are written to: -- `headdb-core/target/HeadDB-6.0.4.jar` -- `headdb-legacy/target/HeadDB-6.0.4-legacy.jar` +- `headdb-core/target/HeadDB-6.1.0.jar` +- `headdb-legacy/target/HeadDB-6.1.0-legacy.jar` The legacy module uses `--release 8`; the modern module uses `--release 21`. Maven may run on a newer JDK when building both artifacts together. diff --git a/changelog/6.1.0.md b/changelog/6.1.0.md new file mode 100644 index 0000000..d28f5eb --- /dev/null +++ b/changelog/6.1.0.md @@ -0,0 +1,38 @@ +# HeadDB 6.1.0 Changelog + +## Commands and catalog operations + +- Added `/hdb status`, `/hdb sync`, `/hdb reload`, `/hdb inspect`, + `/hdb recent`, and `/hdb language` to both modern and legacy artifacts. +- Added self-targeting `/hdb give ` and `/hdb give ` forms + while preserving the administrator player-target form. +- Aligned in-game and website search with quoted filters, multiple IDs, and + all/any matching. Website copy actions now emit valid plugin commands. +- Stored catalog IDs on modern head items so held heads can be inspected + reliably and linked to their managed website records. + +## API and storage + +- Added structured, sorted, paginated Java API searches, catalog health status, + and added/edited/removed update listeners while keeping Java 8 API bytecode. +- Added optional MySQL player storage for proxy networks. SQLite remains the + default and existing local data remains compatible. +- Added English/Spanish personal language selection and server-provided message + pack discovery. + +## Paper, Folia, and reliability + +- Restored the modern Folia support declaration after isolating inventory trees + per open and replacing shared page/navigation registries with concurrent + implementations. +- Runtime reloads rebuild menus and localization safely; catalog synchronization + reports its source, revision, last success, and failure details. + +## Integrated headdb.net release + +- Added favorites, public/private collections, share pages, and YAML export. +- Added recent-update discovery, submission editing, withdrawal and resubmission, + webhook retry, head reports, published metadata edits, removal recovery, and + merge redirects. +- Added durable account submission cooldowns, application and Nginx request + throttles, and optional encrypted Restic off-host PostgreSQL backups. diff --git a/headdb-api/pom.xml b/headdb-api/pom.xml index 6bc42cf..b2586a6 100644 --- a/headdb-api/pom.xml +++ b/headdb-api/pom.xml @@ -6,7 +6,7 @@ com.bitworksmc HeadDB - 6.0.4 + 6.1.0 headdb-api diff --git a/headdb-api/src/main/java/com/bitworksmc/headdb/api/HeadAPI.java b/headdb-api/src/main/java/com/bitworksmc/headdb/api/HeadAPI.java index e96093f..f395c85 100644 --- a/headdb-api/src/main/java/com/bitworksmc/headdb/api/HeadAPI.java +++ b/headdb-api/src/main/java/com/bitworksmc/headdb/api/HeadAPI.java @@ -1,6 +1,10 @@ package com.bitworksmc.headdb.api; import com.bitworksmc.headdb.api.model.Head; +import com.bitworksmc.headdb.api.catalog.CatalogStatus; +import com.bitworksmc.headdb.api.catalog.CatalogUpdateListener; +import com.bitworksmc.headdb.api.search.SearchPage; +import com.bitworksmc.headdb.api.search.SearchQuery; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; @@ -70,6 +74,15 @@ default CompletableFuture> findByName(@NotNull String name) { @NotNull List findKnownCategories(); + /** Returns synchronization health and revision information without blocking. */ + @NotNull CatalogStatus getCatalogStatus(); + + /** Runs a structured, paginated query against the locally loaded catalog. */ + @NotNull CompletableFuture search(@NotNull SearchQuery query); + + /** Registers a listener and returns a handle that removes it when closed. */ + @NotNull AutoCloseable addCatalogUpdateListener(@NotNull CatalogUpdateListener listener); + /** * Returns the underlying executor service. */ diff --git a/headdb-api/src/main/java/com/bitworksmc/headdb/api/HeadDatabase.java b/headdb-api/src/main/java/com/bitworksmc/headdb/api/HeadDatabase.java index 3f8ff77..3b1e057 100644 --- a/headdb-api/src/main/java/com/bitworksmc/headdb/api/HeadDatabase.java +++ b/headdb-api/src/main/java/com/bitworksmc/headdb/api/HeadDatabase.java @@ -1,6 +1,8 @@ package com.bitworksmc.headdb.api; import com.bitworksmc.headdb.api.model.Head; +import com.bitworksmc.headdb.api.catalog.CatalogStatus; +import com.bitworksmc.headdb.api.catalog.CatalogUpdateListener; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -42,4 +44,8 @@ public interface HeadDatabase { // Get a head by texture (returns a head or null if not found) Head getByTexture(String texture); + + CatalogStatus getCatalogStatus(); + + AutoCloseable addCatalogUpdateListener(CatalogUpdateListener listener); } diff --git a/headdb-api/src/main/java/com/bitworksmc/headdb/api/catalog/CatalogStatus.java b/headdb-api/src/main/java/com/bitworksmc/headdb/api/catalog/CatalogStatus.java new file mode 100644 index 0000000..315bad1 --- /dev/null +++ b/headdb-api/src/main/java/com/bitworksmc/headdb/api/catalog/CatalogStatus.java @@ -0,0 +1,31 @@ +package com.bitworksmc.headdb.api.catalog; + +/** Immutable operational state for the locally synchronized HeadDB catalog. */ +public final class CatalogStatus { + private final boolean ready; + private final int revision; + private final int headCount; + private final long lastAttemptEpochMillis; + private final long lastSuccessfulUpdateEpochMillis; + private final String lastError; + private final String source; + + public CatalogStatus(boolean ready, int revision, int headCount, long lastAttemptEpochMillis, + long lastSuccessfulUpdateEpochMillis, String lastError, String source) { + this.ready = ready; + this.revision = revision; + this.headCount = headCount; + this.lastAttemptEpochMillis = lastAttemptEpochMillis; + this.lastSuccessfulUpdateEpochMillis = lastSuccessfulUpdateEpochMillis; + this.lastError = lastError; + this.source = source; + } + + public boolean isReady() { return ready; } + public int getRevision() { return revision; } + public int getHeadCount() { return headCount; } + public long getLastAttemptEpochMillis() { return lastAttemptEpochMillis; } + public long getLastSuccessfulUpdateEpochMillis() { return lastSuccessfulUpdateEpochMillis; } + public String getLastError() { return lastError; } + public String getSource() { return source; } +} diff --git a/headdb-api/src/main/java/com/bitworksmc/headdb/api/catalog/CatalogUpdate.java b/headdb-api/src/main/java/com/bitworksmc/headdb/api/catalog/CatalogUpdate.java new file mode 100644 index 0000000..cf1a7b7 --- /dev/null +++ b/headdb-api/src/main/java/com/bitworksmc/headdb/api/catalog/CatalogUpdate.java @@ -0,0 +1,37 @@ +package com.bitworksmc.headdb.api.catalog; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** A successfully published local catalog transition. */ +public final class CatalogUpdate { + private final int previousRevision; + private final int revision; + private final List addedIds; + private final List updatedIds; + private final List removedIds; + private final long completedAtEpochMillis; + + public CatalogUpdate(int previousRevision, int revision, List addedIds, + List updatedIds, List removedIds, long completedAtEpochMillis) { + this.previousRevision = previousRevision; + this.revision = revision; + this.addedIds = immutableCopy(addedIds); + this.updatedIds = immutableCopy(updatedIds); + this.removedIds = immutableCopy(removedIds); + this.completedAtEpochMillis = completedAtEpochMillis; + } + + public int getPreviousRevision() { return previousRevision; } + public int getRevision() { return revision; } + public List getAddedIds() { return addedIds; } + public List getUpdatedIds() { return updatedIds; } + public List getRemovedIds() { return removedIds; } + public long getCompletedAtEpochMillis() { return completedAtEpochMillis; } + public boolean hasChanges() { return !addedIds.isEmpty() || !updatedIds.isEmpty() || !removedIds.isEmpty(); } + + private static List immutableCopy(List values) { + return Collections.unmodifiableList(new ArrayList(values)); + } +} diff --git a/headdb-api/src/main/java/com/bitworksmc/headdb/api/catalog/CatalogUpdateListener.java b/headdb-api/src/main/java/com/bitworksmc/headdb/api/catalog/CatalogUpdateListener.java new file mode 100644 index 0000000..e58f934 --- /dev/null +++ b/headdb-api/src/main/java/com/bitworksmc/headdb/api/catalog/CatalogUpdateListener.java @@ -0,0 +1,6 @@ +package com.bitworksmc.headdb.api.catalog; + +@FunctionalInterface +public interface CatalogUpdateListener { + void onCatalogUpdate(CatalogUpdate update); +} diff --git a/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/HeadSearch.java b/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/HeadSearch.java new file mode 100644 index 0000000..925a9ba --- /dev/null +++ b/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/HeadSearch.java @@ -0,0 +1,89 @@ +package com.bitworksmc.headdb.api.search; + +import com.bitworksmc.headdb.api.model.Head; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** Platform-neutral implementation of HeadDB's structured search contract. */ +public final class HeadSearch { + private HeadSearch() { } + + public static SearchPage search(List heads, SearchQuery query) { + List matches = new ArrayList(); + for (Head head : heads) { + if (matches(head, query)) matches.add(head); + } + + Comparator comparator; + switch (query.getSort()) { + case NAME: + comparator = Comparator.comparing(Head::getName, String.CASE_INSENSITIVE_ORDER) + .thenComparingInt(Head::getId); + break; + case CATEGORY: + comparator = Comparator.comparing(Head::getCategory, String.CASE_INSENSITIVE_ORDER) + .thenComparingInt(Head::getId); + break; + case ID: + default: + comparator = Comparator.comparingInt(Head::getId); + break; + } + if (!query.isAscending()) comparator = comparator.reversed(); + matches.sort(comparator); + + int total = matches.size(); + int from = Math.min(query.getOffset(), total); + int to = Math.min(from + query.getLimit(), total); + return new SearchPage(matches.subList(from, to), total, query.getOffset(), query.getLimit()); + } + + private static boolean matches(Head head, SearchQuery query) { + List dimensions = new ArrayList(4); + if (!query.getName().isEmpty()) { + dimensions.add(head.getName().toLowerCase(Locale.ROOT) + .contains(query.getName().toLowerCase(Locale.ROOT))); + } + if (!query.getCategory().isEmpty()) { + dimensions.add(head.getCategory().equalsIgnoreCase(query.getCategory()) + || slugify(head.getCategory()).equals(slugify(query.getCategory()))); + } + if (!query.getIds().isEmpty()) dimensions.add(query.getIds().contains(head.getId())); + if (!query.getTags().isEmpty()) { + Set headTags = new HashSet(); + for (String tag : head.getTags()) headTags.add(tag.toLowerCase(Locale.ROOT)); + boolean tagMatch = query.getMatchMode() == MatchMode.ANY; + if (query.getMatchMode() == MatchMode.ALL) { + tagMatch = true; + for (String tag : query.getTags()) { + if (!headTags.contains(tag.toLowerCase(Locale.ROOT))) { tagMatch = false; break; } + } + } else { + tagMatch = false; + for (String tag : query.getTags()) { + if (headTags.contains(tag.toLowerCase(Locale.ROOT))) { tagMatch = true; break; } + } + } + dimensions.add(tagMatch); + } + if (dimensions.isEmpty()) return true; + if (query.getMatchMode() == MatchMode.ANY) { + for (Boolean value : dimensions) if (value) return true; + return false; + } + for (Boolean value : dimensions) if (!value) return false; + return true; + } + + private static String slugify(String value) { + return value.trim().toLowerCase(Locale.ROOT) + .replace("&", " and ") + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+|-+$", ""); + } +} diff --git a/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/MatchMode.java b/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/MatchMode.java new file mode 100644 index 0000000..915aeea --- /dev/null +++ b/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/MatchMode.java @@ -0,0 +1,3 @@ +package com.bitworksmc.headdb.api.search; + +public enum MatchMode { ALL, ANY } diff --git a/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/SearchPage.java b/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/SearchPage.java new file mode 100644 index 0000000..251192b --- /dev/null +++ b/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/SearchPage.java @@ -0,0 +1,27 @@ +package com.bitworksmc.headdb.api.search; + +import com.bitworksmc.headdb.api.model.Head; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class SearchPage { + private final List items; + private final int total; + private final int offset; + private final int limit; + + public SearchPage(List items, int total, int offset, int limit) { + this.items = Collections.unmodifiableList(new ArrayList(items)); + this.total = total; + this.offset = offset; + this.limit = limit; + } + + public List getItems() { return items; } + public int getTotal() { return total; } + public int getOffset() { return offset; } + public int getLimit() { return limit; } + public boolean hasMore() { return offset + items.size() < total; } +} diff --git a/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/SearchQuery.java b/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/SearchQuery.java new file mode 100644 index 0000000..664862b --- /dev/null +++ b/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/SearchQuery.java @@ -0,0 +1,73 @@ +package com.bitworksmc.headdb.api.search; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; + +/** Structured catalog query shared by modern and legacy server artifacts. */ +public final class SearchQuery { + private final String name; + private final String category; + private final List tags; + private final List ids; + private final MatchMode matchMode; + private final SearchSort sort; + private final boolean ascending; + private final int offset; + private final int limit; + + private SearchQuery(Builder builder) { + this.name = builder.name.trim(); + this.category = builder.category.trim(); + LinkedHashSet normalizedTags = new LinkedHashSet(); + for (String tag : builder.tags) { + if (tag != null && !tag.trim().isEmpty()) normalizedTags.add(tag.trim()); + } + LinkedHashSet normalizedIds = new LinkedHashSet(); + for (Integer id : builder.ids) { + if (id != null && id.intValue() >= 0) normalizedIds.add(id); + } + this.tags = Collections.unmodifiableList(new ArrayList(normalizedTags)); + this.ids = Collections.unmodifiableList(new ArrayList(normalizedIds)); + this.matchMode = builder.matchMode; + this.sort = builder.sort; + this.ascending = builder.ascending; + this.offset = Math.max(0, builder.offset); + this.limit = Math.min(500, Math.max(1, builder.limit)); + } + + public static Builder builder() { return new Builder(); } + public String getName() { return name; } + public String getCategory() { return category; } + public List getTags() { return tags; } + public List getIds() { return ids; } + public MatchMode getMatchMode() { return matchMode; } + public SearchSort getSort() { return sort; } + public boolean isAscending() { return ascending; } + public int getOffset() { return offset; } + public int getLimit() { return limit; } + + public static final class Builder { + private String name = ""; + private String category = ""; + private List tags = Collections.emptyList(); + private List ids = Collections.emptyList(); + private MatchMode matchMode = MatchMode.ALL; + private SearchSort sort = SearchSort.ID; + private boolean ascending = true; + private int offset; + private int limit = 100; + + public Builder name(String value) { this.name = value == null ? "" : value; return this; } + public Builder category(String value) { this.category = value == null ? "" : value; return this; } + public Builder tags(List value) { this.tags = value == null ? Collections.emptyList() : value; return this; } + public Builder ids(List value) { this.ids = value == null ? Collections.emptyList() : value; return this; } + public Builder matchMode(MatchMode value) { this.matchMode = value == null ? MatchMode.ALL : value; return this; } + public Builder sort(SearchSort value) { this.sort = value == null ? SearchSort.ID : value; return this; } + public Builder ascending(boolean value) { this.ascending = value; return this; } + public Builder offset(int value) { this.offset = value; return this; } + public Builder limit(int value) { this.limit = value; return this; } + public SearchQuery build() { return new SearchQuery(this); } + } +} diff --git a/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/SearchSort.java b/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/SearchSort.java new file mode 100644 index 0000000..906e81f --- /dev/null +++ b/headdb-api/src/main/java/com/bitworksmc/headdb/api/search/SearchSort.java @@ -0,0 +1,3 @@ +package com.bitworksmc.headdb.api.search; + +public enum SearchSort { ID, NAME, CATEGORY } diff --git a/headdb-core/pom.xml b/headdb-core/pom.xml index 0b3e60d..2a58aea 100644 --- a/headdb-core/pom.xml +++ b/headdb-core/pom.xml @@ -6,13 +6,13 @@ com.bitworksmc HeadDB - 6.0.4 + 6.1.0 headdb-core Head Database - 6.0.4 + 6.1.0 21 @@ -46,7 +46,7 @@ com.bitworksmc headdb-api - 6.0.4 + 6.1.0 @@ -68,6 +68,12 @@ ${sqlite.version} provided + + com.mysql + mysql-connector-j + ${mysql.version} + provided + org.jetbrains annotations diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/HeadDB.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/HeadDB.java index a2d49c6..68e62d1 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/HeadDB.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/HeadDB.java @@ -1,7 +1,6 @@ package com.bitworksmc.headdb.core; import com.github.thesilentpro.grim.listener.PageListeners; -import com.github.thesilentpro.grim.page.registry.PageRegistry; import com.bitworksmc.headdb.api.HeadAPI; import com.bitworksmc.headdb.api.model.Head; import com.bitworksmc.headdb.core.api.LegacyHeadAPIAdapter; @@ -14,6 +13,7 @@ import com.bitworksmc.headdb.core.economy.VaultEconomyProvider; import com.bitworksmc.headdb.core.factory.ItemFactoryRegistry; import com.bitworksmc.headdb.core.menu.MenuManager; +import com.bitworksmc.headdb.core.menu.registry.ConcurrentPageRegistry; import com.bitworksmc.headdb.core.storage.PlayerStorage; import com.bitworksmc.headdb.core.update.UpdateChecker; import com.bitworksmc.headdb.core.util.Compatibility; @@ -33,6 +33,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; +import java.util.concurrent.CompletableFuture; public class HeadDB extends JavaPlugin { @@ -64,23 +65,7 @@ public void onEnable() { this.localization.init(); Config config = this.configManager.getConfig(); - String econProvider = config.getEconomyProvider(); - if (econProvider != null) { - if (econProvider.equalsIgnoreCase("NONE") || econProvider.isEmpty()) { - LOGGER.debug("Economy is disabled."); - } else if (config.getEconomyProvider().equalsIgnoreCase("VAULT")) { - VaultEconomyProvider vaultProvider = new VaultEconomyProvider(getName()); - if (vaultProvider.init()) { - this.economyProvider = vaultProvider; - LOGGER.debug("Economy Provider: Vault"); - } else { - LOGGER.warn("Vault economy was enabled but no compatible provider was found. Economy features are disabled."); - } - } else { - LOGGER.warn("Unknown economy provider in the config.yml!"); - } - } - + configureEconomy(config); // Init database int databaseThreads = config.getDatabaseThreads(); this.databaseExecutor = Utils.executorService(databaseThreads, "Head Database Worker"); @@ -95,7 +80,7 @@ public void onEnable() { this.headApi = new BaseHeadAPI(config.getApiThreads(), headDatabase); this.menuManager = new MenuManager(this); this.headDatabase.onReady().thenAcceptAsync(heads -> this.menuManager.registerDefaults(this, heads), Compatibility.getMainThreadExecutor(this)); - this.playerStorage = new PlayerStorage(getDataFolder()); + this.playerStorage = new PlayerStorage(getDataFolder(), config); this.playerStorage.load(); Compatibility.runAsyncRepeating(this, this.playerStorage::save, config.getPlayerStorageSaveInterval() * 20L, config.getPlayerStorageSaveInterval() * 20L); @@ -115,7 +100,7 @@ public void onEnable() { command.setExecutor(mainCommand); command.setTabCompleter(mainCommand); - new PageListeners().register(this); + new PageListeners(ConcurrentPageRegistry.INSTANCE).register(this); if (Compatibility.IS_PAPER) { new PaperInputListener().register(this); } @@ -138,7 +123,7 @@ public void onEnable() { this.headDatabase.update().thenAcceptAsync(heads -> { int currentRevision = this.headDatabase.getCatalogRevision(); if (currentRevision != previousRevision || currentRevision < 0) { - handleDatabaseUpdate(config, heads); + handleDatabaseUpdate(this.configManager.getConfig(), heads); this.menuManager.registerDefaults(this, heads); } }, Compatibility.getMainThreadExecutor(this)); @@ -176,9 +161,9 @@ public void onDisable() { } // Closing a view fires InventoryCloseEvent, which removes that view from the // registry. Iterate a snapshot so the listener cannot mutate our iterator. - for (InventoryView view : new ArrayList<>(PageRegistry.INSTANCE.getPages().keySet())) { + for (InventoryView view : new ArrayList<>(ConcurrentPageRegistry.INSTANCE.getPages().keySet())) { view.close(); - PageRegistry.INSTANCE.remove(view); + ConcurrentPageRegistry.INSTANCE.remove(view); } if (this.playerStorage != null) { this.playerStorage.save(); @@ -223,6 +208,53 @@ public HeadAPI getHeadApi() { return headApi; } + public BaseHeadDatabase getHeadDatabase() { + return headDatabase; + } + + /** Forces a catalog synchronization and refreshes every database-backed menu. */ + public CompletableFuture> synchronizeCatalog() { + return this.headDatabase.update().thenApplyAsync(heads -> { + handleDatabaseUpdate(this.configManager.getConfig(), heads); + this.menuManager.registerDefaults(this, heads); + return heads; + }, Compatibility.getMainThreadExecutor(this)); + } + + /** Reloads message, sound, menu, website, price, and category settings safely. */ + public synchronized void reloadRuntimeConfiguration() { + reloadConfig(); + ConfigManager replacement = new ConfigManager(this); + replacement.loadAll(this); + this.configManager = replacement; + HDBLocalization replacementLocalization = new HDBLocalization(this); + replacementLocalization.init(); + this.localization = replacementLocalization; + configureEconomy(replacement.getConfig()); + List heads = this.headDatabase.getHeads(); + if (heads != null) this.menuManager.registerDefaults(this, heads); + } + + private void configureEconomy(Config config) { + this.economyProvider = null; + String econProvider = config.getEconomyProvider(); + if (econProvider == null || econProvider.equalsIgnoreCase("NONE") || econProvider.isEmpty()) { + LOGGER.debug("Economy is disabled."); + return; + } + if (econProvider.equalsIgnoreCase("VAULT")) { + VaultEconomyProvider vaultProvider = new VaultEconomyProvider(getName()); + if (vaultProvider.init()) { + this.economyProvider = vaultProvider; + LOGGER.debug("Economy Provider: Vault"); + } else { + LOGGER.warn("Vault economy was enabled but no compatible provider was found. Economy features are disabled."); + } + return; + } + LOGGER.warn("Unknown economy provider in config.yml: {}", econProvider); + } + private void handleDatabaseUpdate(Config config, List heads) { LOGGER.info("Loaded {} heads!", heads.size()); diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/HDBSubCommandManager.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/HDBSubCommandManager.java index b06698d..3b7e108 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/HDBSubCommandManager.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/HDBSubCommandManager.java @@ -7,6 +7,12 @@ import com.bitworksmc.headdb.core.command.sub.HDBCommandSearch; import com.bitworksmc.headdb.core.command.sub.HDBCommandSounds; import com.bitworksmc.headdb.core.command.sub.HDBCommandSubmit; +import com.bitworksmc.headdb.core.command.sub.HDBCommandStatus; +import com.bitworksmc.headdb.core.command.sub.HDBCommandSync; +import com.bitworksmc.headdb.core.command.sub.HDBCommandReload; +import com.bitworksmc.headdb.core.command.sub.HDBCommandInspect; +import com.bitworksmc.headdb.core.command.sub.HDBCommandRecent; +import com.bitworksmc.headdb.core.command.sub.HDBCommandLanguage; import java.util.ArrayList; import java.util.HashMap; @@ -32,6 +38,12 @@ public void registerDefaults() { register(new HDBCommandOpen(plugin)); register(new HDBCommandSounds(plugin)); register(new HDBCommandSubmit(plugin)); + register(new HDBCommandStatus(plugin)); + register(new HDBCommandSync(plugin)); + register(new HDBCommandReload(plugin)); + register(new HDBCommandInspect(plugin)); + register(new HDBCommandRecent(plugin)); + register(new HDBCommandLanguage(plugin)); } public void register(HDBSubCommand command) { diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandGive.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandGive.java index fb2d55a..e2237b6 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandGive.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandGive.java @@ -26,7 +26,7 @@ public class HDBCommandGive extends HDBSubCommand { private volatile List headNameCompletions = List.of(); public HDBCommandGive(HeadDB plugin) { - super("give", "Give a specific head to a player.", " ", "g"); + super("give", "Give a specific head to yourself or another player.", " [amount/head] [head]", "g"); this.plugin = plugin; plugin.getHeadApi().onReady().thenAccept(heads -> this.headNameCompletions = heads.stream() .map(Head::getName) @@ -35,21 +35,46 @@ public HDBCommandGive(HeadDB plugin) { .toList()); } + // /hdb give + // /hdb give // /hdb give @Override public void handle(CommandSender sender, String[] args) { - Player target = Bukkit.getPlayer(args[1]); + Player target; + int amount = 1; + int identifierStart; + String amountArgument = "1"; + + boolean targetsPlayer = args.length >= 4 && !isInteger(args[1]) && isInteger(args[2]); + if (targetsPlayer) { + target = Bukkit.getPlayer(args[1]); + amountArgument = args[2]; + identifierStart = 3; + } else { + if (!(sender instanceof Player player)) { + plugin.getLocalization().sendMessage(sender, "command.give.consoleUsage"); + return; + } + target = player; + if (args.length >= 3 && isInteger(args[1])) { + amountArgument = args[1]; + identifierStart = 2; + } else { + identifierStart = 1; + } + } + if (target == null) { plugin.getLocalization().sendMessage(sender, "invalidTarget", msg -> msg.replaceText(builder -> builder.matchLiteral("{target}").replacement(args[1]))); Compatibility.playSound(sender, plugin.getSoundConfig().get("failure")); return; } - int amount = 1; try { - amount = Integer.parseInt(args[2]); + amount = Integer.parseInt(amountArgument); } catch (NumberFormatException nfe) { - plugin.getLocalization().sendMessage(sender, "invalidNumber", msg -> msg.replaceText(builder -> builder.matchLiteral("{number}").replacement(args[2]))); + String invalidAmount = amountArgument; + plugin.getLocalization().sendMessage(sender, "invalidNumber", msg -> msg.replaceText(builder -> builder.matchLiteral("{number}").replacement(invalidAmount))); Compatibility.playSound(sender, plugin.getSoundConfig().get("failure")); return; } @@ -62,7 +87,7 @@ public void handle(CommandSender sender, String[] args) { } final int fAmount = amount; - String id = String.join(" ", Arrays.copyOfRange(args, 3, args.length)); + String id = String.join(" ", Arrays.copyOfRange(args, identifierStart, args.length)); plugin.getHeadApi().onReady() .thenCompose(ignored -> plugin.getHeadApi().findByName(id, true)) .thenCompose(optionalHead -> { @@ -115,15 +140,30 @@ public void handle(CommandSender sender, String[] args) { @Override public @Nullable List handleCompletions(CommandSender sender, String[] args) { - if (args.length == 3) { + if (args.length == 2) { + String prefix = args[1].toLowerCase(Locale.ROOT); + Stream playerNames = Bukkit.getOnlinePlayers().stream().map(Player::getName); + Stream headNames = headNameCompletions.stream(); + Stream amounts = numberCompletions.stream() + .filter(value -> Integer.parseInt(value) <= plugin.getCfg().getMaxBuyAmount()); + return Stream.concat(Stream.concat(playerNames, headNames), amounts) + .filter(value -> value.toLowerCase(Locale.ROOT).startsWith(prefix)) + .distinct() + .limit(100) + .toList(); + } + + Player explicitTarget = Bukkit.getPlayerExact(args[1]); + if (explicitTarget != null && args.length == 3) { int maximum = plugin.getCfg().getMaxBuyAmount(); return numberCompletions.stream() .filter(value -> Integer.parseInt(value) <= maximum) .toList(); } - if (args.length >= 4) { - String prefix = String.join(" ", Arrays.copyOfRange(args, 3, args.length)).trim().toLowerCase(Locale.ROOT); + int identifierStart = explicitTarget != null ? 3 : (isInteger(args[1]) ? 2 : 1); + if (args.length > identifierStart) { + String prefix = String.join(" ", Arrays.copyOfRange(args, identifierStart, args.length)).trim().toLowerCase(Locale.ROOT); Stream heads = headNameCompletions.stream(); @@ -140,4 +180,14 @@ public void handle(CommandSender sender, String[] args) { return null; } + private static boolean isInteger(String value) { + if (value == null || value.isEmpty()) return false; + int start = value.charAt(0) == '-' ? 1 : 0; + if (start == value.length()) return false; + for (int i = start; i < value.length(); i++) { + if (!Character.isDigit(value.charAt(i))) return false; + } + return true; + } + } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandInspect.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandInspect.java new file mode 100644 index 0000000..744b679 --- /dev/null +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandInspect.java @@ -0,0 +1,72 @@ +package com.bitworksmc.headdb.core.command.sub; + +import com.bitworksmc.headdb.api.model.Head; +import com.bitworksmc.headdb.core.HeadDB; +import com.bitworksmc.headdb.core.command.HDBSubCommand; +import com.bitworksmc.headdb.core.factory.ItemFactoryRegistry; +import com.bitworksmc.headdb.core.util.Compatibility; +import com.bitworksmc.headdb.core.util.WebsiteLinks; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +public final class HDBCommandInspect extends HDBSubCommand { + private final HeadDB plugin; + + public HDBCommandInspect(HeadDB plugin) { + super("inspect", "Inspect a held or identified HeadDB head.", "[head]", "head"); + this.plugin = plugin; + } + + @Override public void handle(CommandSender sender, String[] args) { + CompletableFuture> lookup; + if (args.length > 1) { + String identifier = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); + lookup = resolve(identifier); + } else if (sender instanceof Player player) { + Integer id = ItemFactoryRegistry.get().getHeadIdFromItem(player.getInventory().getItemInMainHand()); + if (id == null) { + plugin.getLocalization().sendMessage(sender, "command.inspect.notHead"); + return; + } + lookup = plugin.getHeadApi().findById(id); + } else { + plugin.getLocalization().sendMessage(sender, "command.inspect.consoleUsage"); + return; + } + + lookup.thenAcceptAsync(result -> { + if (result.isEmpty()) plugin.getLocalization().sendMessage(sender, "command.inspect.notFound"); + else sendHeadDetails(plugin, sender, result.get()); + }, Compatibility.getSenderExecutor(plugin, sender)); + } + + private CompletableFuture> resolve(String identifier) { + String numeric = identifier.toLowerCase().startsWith("id:") ? identifier.substring(3) : identifier; + try { + return plugin.getHeadApi().findById(Integer.parseInt(numeric)); + } catch (NumberFormatException ignored) { + return plugin.getHeadApi().findByTexture(identifier).thenCompose(found -> found.isPresent() + ? CompletableFuture.completedFuture(found) + : plugin.getHeadApi().findByName(identifier, false)); + } + } + + public static void sendHeadDetails(HeadDB plugin, CommandSender sender, Head head) { + String url = WebsiteLinks.headUrl(plugin.getCfg().getWebsiteUrl(), head.getId()); + Component message = Component.text() + .append(Component.text(head.getName() + " #" + head.getId(), NamedTextColor.GOLD)).appendNewline() + .append(Component.text(" Category: ", NamedTextColor.GRAY)).append(Component.text(head.getCategory(), NamedTextColor.WHITE)).appendNewline() + .append(Component.text(" Tags: ", NamedTextColor.GRAY)).append(Component.text(String.join(", ", head.getTags()), NamedTextColor.WHITE)).appendNewline() + .append(Component.text(" Give: ", NamedTextColor.GRAY)).append(Component.text("/hdb give id:" + head.getId(), NamedTextColor.GREEN)).appendNewline() + .append(WebsiteLinks.makeClickable(Component.text(" View, copy, or report on headdb.net", NamedTextColor.AQUA), + url, Component.text("Open " + url, NamedTextColor.AQUA))) + .build(); + Compatibility.sendMessage(sender, message); + } +} diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandLanguage.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandLanguage.java new file mode 100644 index 0000000..c433547 --- /dev/null +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandLanguage.java @@ -0,0 +1,45 @@ +package com.bitworksmc.headdb.core.command.sub; + +import com.bitworksmc.headdb.core.HeadDB; +import com.bitworksmc.headdb.core.command.HDBSubCommand; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.Locale; + +public final class HDBCommandLanguage extends HDBSubCommand { + private final HeadDB plugin; + + public HDBCommandLanguage(HeadDB plugin) { + super("language", "Choose your HeadDB language.", "[language]", "lang", "locale"); + this.plugin = plugin; + } + + @Override public void handle(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLocalization().sendMessage(sender, "noConsole"); + return; + } + List available = plugin.getLocalization().getAvailableLanguages(); + if (args.length == 1) { + plugin.getLocalization().sendMessage(sender, "command.language.available", msg -> msg.replaceText(builder -> + builder.matchLiteral("{languages}").replacement(String.join(", ", available)))); + return; + } + String requested = args[1].toLowerCase(Locale.ROOT); + if (!available.contains(requested)) { + plugin.getLocalization().sendMessage(sender, "command.language.invalid", msg -> msg.replaceText(builder -> + builder.matchLiteral("{language}").replacement(requested))); + return; + } + plugin.getPlayerStorage().getPlayer(player.getUniqueId()).setLanguage(requested); + plugin.getLocalization().setLanguage(player.getUniqueId(), requested); + plugin.getLocalization().sendMessage(sender, "command.language.changed", msg -> msg.replaceText(builder -> + builder.matchLiteral("{language}").replacement(requested))); + } + + @Override public List handleCompletions(CommandSender sender, String[] args) { + return plugin.getLocalization().getAvailableLanguages(); + } +} diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandRecent.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandRecent.java new file mode 100644 index 0000000..f5d3178 --- /dev/null +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandRecent.java @@ -0,0 +1,44 @@ +package com.bitworksmc.headdb.core.command.sub; + +import com.bitworksmc.headdb.api.search.SearchQuery; +import com.bitworksmc.headdb.api.search.SearchSort; +import com.bitworksmc.headdb.core.HeadDB; +import com.bitworksmc.headdb.core.command.HDBSubCommand; +import com.bitworksmc.headdb.core.menu.gui.HeadsGUI; +import com.bitworksmc.headdb.core.util.Compatibility; +import net.kyori.adventure.text.Component; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public final class HDBCommandRecent extends HDBSubCommand { + private final HeadDB plugin; + + public HDBCommandRecent(HeadDB plugin) { + super("recent", "Browse recently assigned HeadDB IDs.", "[amount]", "new"); + this.plugin = plugin; + } + + @Override public void handle(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLocalization().sendMessage(sender, "noConsole"); + return; + } + int amount = 100; + if (args.length > 1) { + try { amount = Math.min(500, Math.max(1, Integer.parseInt(args[1]))); } + catch (NumberFormatException ignored) { + plugin.getLocalization().sendMessage(sender, "invalidNumber", msg -> msg.replaceText(builder -> + builder.matchLiteral("{number}").replacement(args[1]))); + return; + } + } + SearchQuery query = SearchQuery.builder().sort(SearchSort.ID).ascending(false).limit(amount).build(); + plugin.getHeadApi().search(query).thenAcceptAsync(page -> { + HeadsGUI gui = new HeadsGUI(plugin, "recent_" + player.getUniqueId(), + plugin.getLocalization().getMessage(player.getUniqueId(), "menu.recent.name") + .orElseGet(() -> Component.text("HeadDB ยป Recently added")), page.getItems()); + gui.open(player); + Compatibility.playSound(player, plugin.getSoundConfig().get("menu.open")); + }, Compatibility.getEntityExecutor(plugin, player)); + } +} diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandReload.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandReload.java new file mode 100644 index 0000000..ba2d6f9 --- /dev/null +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandReload.java @@ -0,0 +1,19 @@ +package com.bitworksmc.headdb.core.command.sub; + +import com.bitworksmc.headdb.core.HeadDB; +import com.bitworksmc.headdb.core.command.HDBSubCommand; +import org.bukkit.command.CommandSender; + +public final class HDBCommandReload extends HDBSubCommand { + private final HeadDB plugin; + + public HDBCommandReload(HeadDB plugin) { + super("reload", "Reload messages, sounds, menus, prices, and local categories.", null); + this.plugin = plugin; + } + + @Override public void handle(CommandSender sender, String[] args) { + plugin.reloadRuntimeConfiguration(); + plugin.getLocalization().sendMessage(sender, "command.reload.success"); + } +} diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSearch.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSearch.java index e40de53..581f148 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSearch.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSearch.java @@ -21,11 +21,17 @@ public class HDBCommandSearch extends HDBSubCommand { private static final Logger LOGGER = LoggerFactory.getLogger(HDBCommandSearch.class); private final HeadDB plugin; - private final List completions = List.of("tags:", "category:", "ids:", "--any"); + private final List completions = List.of("tag:", "tags:", "category:", "id:", "ids:", "--any"); + private volatile List tagCompletions = List.of(); public HDBCommandSearch(HeadDB plugin) { super("search", "Search for specific heads.", "[tags:|category:|ids:] [head]", "s", "find"); this.plugin = plugin; + plugin.getHeadApi().onReady().thenAccept(heads -> tagCompletions = heads.stream() + .flatMap(head -> head.getTags().stream()) + .distinct() + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList()); } @Override @@ -39,8 +45,9 @@ public void handle(CommandSender sender, String[] args) { plugin.getHeadApi().onReady().thenApplyAsync(allHeads -> { // detect & strip --any // Enables loose search (match if any filter passes instead of all). - boolean any = Arrays.stream(args).anyMatch(a -> a.equalsIgnoreCase("--any")); - List parts = Arrays.stream(args, 1, args.length).filter(a -> !a.equalsIgnoreCase("--any")).toList(); + List logicalTokens = combineQuotedArguments(Arrays.copyOfRange(args, 1, args.length)); + boolean any = logicalTokens.stream().anyMatch(a -> a.equalsIgnoreCase("--any")); + List parts = logicalTokens.stream().filter(a -> !a.equalsIgnoreCase("--any")).toList(); // parse filters String category = null; @@ -52,16 +59,16 @@ public void handle(CommandSender sender, String[] args) { String lower = token.toLowerCase(Locale.ROOT); if (lower.startsWith("category:")) { category = token.substring("category:".length()); - } else if (lower.startsWith("tags:")) { - String raw = token.substring("tags:".length()); + } else if (lower.startsWith("tag:") || lower.startsWith("tags:")) { + String raw = token.substring(token.indexOf(':') + 1); if (!raw.isEmpty()) { Arrays.stream(raw.split(",")) .map(String::trim) .filter(tag -> !tag.isEmpty()) .forEach(tags::add); } - } else if (lower.startsWith("ids:")) { - String raw = token.substring("ids:".length()); + } else if (lower.startsWith("id:") || lower.startsWith("ids:")) { + String raw = token.substring(token.indexOf(':') + 1); if (!raw.isEmpty()) { for (String part : raw.split(",")) { String trimmed = part.trim(); @@ -106,6 +113,7 @@ public void handle(CommandSender sender, String[] args) { // lower all your query bits once String qCat = category == null ? null : category.toLowerCase(Locale.ROOT); + String qCatSlug = WebsiteLinks.slugify(category); String qName = nameQuery.trim().toLowerCase(Locale.ROOT); Set tagSet = tags.stream().map(t -> t.toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); Set idSet = new HashSet<>(ids); @@ -120,7 +128,8 @@ public void handle(CommandSender sender, String[] args) { String headName = h.getName().toLowerCase(Locale.ROOT); List headTags = h.getTags(); // assume a few tags only - boolean matchCat = (headCat.equals(qCat)); + boolean matchCat = qCat != null && (headCat.equals(qCat) + || WebsiteLinks.slugify(h.getCategory()).equals(qCatSlug)); boolean matchTag = (!tagSet.isEmpty() && headTags.stream().anyMatch(t -> tagSet.contains(t.toLowerCase(Locale.ROOT)))); boolean matchId = (!idSet.isEmpty() && idSet.contains(h.getId())); boolean matchName = (!qName.isEmpty() && headName.contains(qName)); @@ -133,8 +142,9 @@ public void handle(CommandSender sender, String[] args) { // ALLโ€‘mode: only add if _every_ nonโ€‘empty filter passes for (Head h : allHeads) { // category - if (qCat != null && - !h.getCategory().equalsIgnoreCase(qCat)) { + if (qCat != null + && !h.getCategory().equalsIgnoreCase(qCat) + && !WebsiteLinks.slugify(h.getCategory()).equals(qCatSlug)) { continue; } // tags @@ -175,7 +185,7 @@ public void handle(CommandSender sender, String[] args) { return new SearchResult( result, qName, - WebsiteLinks.searchUrl(plugin.getCfg().getWebsiteUrl(), nameQuery, category, tags, ids), + WebsiteLinks.searchUrl(plugin.getCfg().getWebsiteUrl(), nameQuery, category, tags, ids, any), true ); }).thenAcceptAsync(searchResult -> { @@ -218,9 +228,60 @@ public void handle(CommandSender sender, String[] args) { @Override public List handleCompletions(CommandSender sender, String[] args) { + String current = args.length == 0 ? "" : args[args.length - 1]; + String lower = current.toLowerCase(Locale.ROOT); + if (lower.startsWith("category:")) { + String prefix = lower.substring("category:".length()).replace("\"", ""); + return plugin.getHeadApi().findKnownCategories().stream() + .filter(category -> category.toLowerCase(Locale.ROOT).startsWith(prefix) + || WebsiteLinks.slugify(category).startsWith(prefix)) + .map(category -> "category:" + (category.contains(" ") ? "\"" + category + "\"" : category)) + .limit(100) + .toList(); + } + if (lower.startsWith("tag:") || lower.startsWith("tags:")) { + String filter = lower.startsWith("tags:") ? "tags:" : "tag:"; + String entered = current.substring(filter.length()); + int comma = entered.lastIndexOf(','); + String retained = comma >= 0 ? entered.substring(0, comma + 1) : ""; + String prefix = (comma >= 0 ? entered.substring(comma + 1) : entered).toLowerCase(Locale.ROOT); + return tagCompletions.stream() + .filter(tag -> tag.toLowerCase(Locale.ROOT).startsWith(prefix)) + .map(tag -> filter + retained + (tag.contains(" ") ? "\"" + tag + "\"" : tag)) + .limit(100) + .toList(); + } return completions; } + static List combineQuotedArguments(String[] raw) { + List result = new ArrayList<>(); + StringBuilder pending = new StringBuilder(); + boolean quoted = false; + for (String token : raw) { + if (!quoted) { + int quote = token.indexOf('"'); + if (quote < 0) { + result.add(token); + continue; + } + quoted = true; + pending.append(token, 0, quote).append(token.substring(quote + 1)); + } else { + pending.append(' ').append(token); + } + int endQuote = pending.indexOf("\""); + if (quoted && endQuote >= 0) { + pending.deleteCharAt(endQuote); + result.add(pending.toString()); + pending.setLength(0); + quoted = false; + } + } + if (pending.length() > 0) result.add(pending.toString()); + return result; + } + private void sendWebsiteHint(Player player, String url) { if (!plugin.getCfg().isWebsiteSearchHintEnabled()) { return; diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandStatus.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandStatus.java new file mode 100644 index 0000000..469ad7b --- /dev/null +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandStatus.java @@ -0,0 +1,46 @@ +package com.bitworksmc.headdb.core.command.sub; + +import com.bitworksmc.headdb.api.catalog.CatalogStatus; +import com.bitworksmc.headdb.core.HeadDB; +import com.bitworksmc.headdb.core.command.HDBSubCommand; +import com.bitworksmc.headdb.core.util.Compatibility; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.command.CommandSender; + +import java.time.Duration; +import java.time.Instant; + +public final class HDBCommandStatus extends HDBSubCommand { + private final HeadDB plugin; + + public HDBCommandStatus(HeadDB plugin) { + super("status", "Show catalog synchronization health.", null); + this.plugin = plugin; + } + + @Override public void handle(CommandSender sender, String[] args) { + CatalogStatus status = plugin.getHeadApi().getCatalogStatus(); + Component message = Component.text() + .append(Component.text("HeadDB catalog", NamedTextColor.GOLD)).appendNewline() + .append(Component.text(" State: ", NamedTextColor.GRAY)) + .append(Component.text(status.isReady() ? "ready" : "loading", status.isReady() ? NamedTextColor.GREEN : NamedTextColor.YELLOW)).appendNewline() + .append(Component.text(" Heads: ", NamedTextColor.GRAY)).append(Component.text(status.getHeadCount(), NamedTextColor.WHITE)).appendNewline() + .append(Component.text(" Revision: ", NamedTextColor.GRAY)).append(Component.text(status.getRevision() < 0 ? "legacy/full snapshot" : String.valueOf(status.getRevision()), NamedTextColor.WHITE)).appendNewline() + .append(Component.text(" Last success: ", NamedTextColor.GRAY)).append(Component.text(age(status.getLastSuccessfulUpdateEpochMillis()), NamedTextColor.WHITE)).appendNewline() + .append(Component.text(" Source: ", NamedTextColor.GRAY)).append(Component.text(status.getSource() == null ? "not selected" : status.getSource(), NamedTextColor.WHITE)) + .append(status.getLastError() == null ? Component.empty() : Component.newline() + .append(Component.text(" Last error: ", NamedTextColor.RED)).append(Component.text(status.getLastError(), NamedTextColor.WHITE))) + .build(); + Compatibility.sendMessage(sender, message); + } + + private static String age(long epochMillis) { + if (epochMillis <= 0) return "never"; + Duration elapsed = Duration.between(Instant.ofEpochMilli(epochMillis), Instant.now()); + if (elapsed.toMinutes() < 1) return "just now"; + if (elapsed.toHours() < 1) return elapsed.toMinutes() + "m ago"; + if (elapsed.toDays() < 1) return elapsed.toHours() + "h ago"; + return elapsed.toDays() + "d ago"; + } +} diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSync.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSync.java new file mode 100644 index 0000000..fcecb8c --- /dev/null +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSync.java @@ -0,0 +1,35 @@ +package com.bitworksmc.headdb.core.command.sub; + +import com.bitworksmc.headdb.core.HeadDB; +import com.bitworksmc.headdb.core.command.HDBSubCommand; +import com.bitworksmc.headdb.core.util.Compatibility; +import org.bukkit.command.CommandSender; + +public final class HDBCommandSync extends HDBSubCommand { + private final HeadDB plugin; + + public HDBCommandSync(HeadDB plugin) { + super("sync", "Synchronize the managed catalog now.", null, "refresh"); + this.plugin = plugin; + } + + @Override public void handle(CommandSender sender, String[] args) { + plugin.getLocalization().sendMessage(sender, "command.sync.start"); + plugin.synchronizeCatalog().whenComplete((heads, failure) -> + Compatibility.getSenderExecutor(plugin, sender).execute(() -> { + if (failure != null) { + plugin.getLocalization().sendMessage(sender, "command.sync.failed", msg -> msg.replaceText(builder -> + builder.matchLiteral("{error}").replacement(rootMessage(failure)))); + } else { + plugin.getLocalization().sendMessage(sender, "command.sync.success", msg -> msg.replaceText(builder -> + builder.matchLiteral("{amount}").replacement(String.valueOf(heads.size())))); + } + })); + } + + private static String rootMessage(Throwable failure) { + Throwable current = failure; + while (current.getCause() != null) current = current.getCause(); + return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage(); + } +} diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java index 390121a..ead1852 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/config/Config.java @@ -40,6 +40,7 @@ public class Config { // General private long playerStorageSaveInterval; + private String playerStorageBackend, playerStorageJdbcUrl, playerStorageUsername, playerStoragePassword; private int databaseThreads, apiThreads; private long databaseSyncIntervalMinutes; private boolean preloadHeads, trackPage, updaterEnabled; @@ -88,6 +89,18 @@ public void load() { private void loadGeneral() { playerStorageSaveInterval = positiveLong("storage.player.saveInterval", 1800L); + playerStorageBackend = config.getString("storage.player.backend", "SQLITE").trim().toUpperCase(Locale.ROOT); + if (!playerStorageBackend.equals("SQLITE") && !playerStorageBackend.equals("MYSQL")) { + LOGGER.warn("Unknown storage.player.backend '{}'; using SQLITE", playerStorageBackend); + playerStorageBackend = "SQLITE"; + } + playerStorageJdbcUrl = config.getString("storage.player.mysql.url", "jdbc:mysql://127.0.0.1:3306/headdb").trim(); + playerStorageUsername = config.getString("storage.player.mysql.username", "headdb"); + playerStoragePassword = config.getString("storage.player.mysql.password", ""); + if (playerStorageBackend.equals("MYSQL") && !playerStorageJdbcUrl.startsWith("jdbc:mysql:")) { + LOGGER.warn("storage.player.mysql.url must begin with 'jdbc:mysql:'; using SQLITE player storage"); + playerStorageBackend = "SQLITE"; + } updaterEnabled = config.getBoolean("updater", true); updateCheckerEnabled = config.getBoolean("updateChecker.enabled", true); updateCheckerNotifyConsole = config.getBoolean("updateChecker.notifyConsole", true); @@ -456,6 +469,10 @@ public List resolveCustomCategories(List loadedHeads) { // === Getters === public long getPlayerStorageSaveInterval() { return playerStorageSaveInterval; } + public String getPlayerStorageBackend() { return playerStorageBackend; } + public String getPlayerStorageJdbcUrl() { return playerStorageJdbcUrl; } + public String getPlayerStorageUsername() { return playerStorageUsername; } + public String getPlayerStoragePassword() { return playerStoragePassword; } public boolean isShowInfoItem() { return showInfoItem; } public boolean isHeadsMenuDividerEnabled() { return headsMenuDividerEnabled; } public int getHeadsMenuRows() { return headsMenuRows; } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/ItemFactory.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/ItemFactory.java index 5048ba8..485de10 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/ItemFactory.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/ItemFactory.java @@ -26,6 +26,12 @@ public interface ItemFactory { @Nullable UUID getIdFromItem(ItemStack item); + /** Returns the stable HeadDB catalog ID stored on a database head item. */ + @Nullable + default Integer getHeadIdFromItem(ItemStack item) { + return null; + } + Component getNameFromItem(ItemStack item); @Nullable diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java index 40d0bf3..81fae17 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/factory/PaperItemFactory.java @@ -6,12 +6,15 @@ import com.destroystokyo.paper.profile.ProfileProperty; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.NamespacedKey; import org.bukkit.OfflinePlayer; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.SkullMeta; +import org.bukkit.persistence.PersistentDataType; import org.bukkit.profile.PlayerTextures; import org.jetbrains.annotations.ApiStatus; import org.slf4j.Logger; @@ -26,9 +29,11 @@ public class PaperItemFactory implements ItemFactory { private static final Logger LOGGER = LoggerFactory.getLogger(PaperItemFactory.class); private final HeadDB plugin; + private final NamespacedKey headIdKey; public PaperItemFactory(HeadDB plugin) { this.plugin = plugin; + this.headIdKey = new NamespacedKey(plugin, "head_id"); } @Override @@ -73,6 +78,7 @@ public ItemStack asItem(Head head) { .replaceText(builder -> builder.matchLiteral("{cost}").replacement(cost) )); meta.lore(lore); + meta.getPersistentDataContainer().set(headIdKey, PersistentDataType.INTEGER, head.getId()); item.setItemMeta(meta); return item; @@ -102,6 +108,25 @@ public UUID getIdFromItem(ItemStack item) { return profile != null ? profile.getId() : null; } + @Override + public Integer getHeadIdFromItem(ItemStack item) { + if (item == null || !item.hasItemMeta()) return null; + ItemMeta meta = item.getItemMeta(); + Integer stored = meta.getPersistentDataContainer().get(headIdKey, PersistentDataType.INTEGER); + if (stored != null) return stored; + List lore = meta.lore(); + if (lore == null) return null; + for (Component line : lore) { + String text = PlainTextComponentSerializer.plainText().serialize(line).trim(); + java.util.regex.Matcher match = java.util.regex.Pattern.compile("(?i)^ID\\s*:\\s*(\\d+)$").matcher(text); + if (match.find()) { + try { return Integer.parseInt(match.group(1)); } + catch (NumberFormatException ignored) { return null; } + } + } + return null; + } + @Override public Component getNameFromItem(ItemStack item) { SkullMeta meta = (SkullMeta) item.getItemMeta(); diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/CustomCategoriesMenu.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/CustomCategoriesMenu.java index 6e598f9..30d9a5d 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/CustomCategoriesMenu.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/CustomCategoriesMenu.java @@ -5,6 +5,7 @@ import com.github.thesilentpro.grim.page.PaginatedSimplePage; import com.bitworksmc.headdb.core.HeadDB; import com.bitworksmc.headdb.core.config.CustomCategory; +import com.bitworksmc.headdb.core.menu.registry.ConcurrentPageRegistry; import com.bitworksmc.headdb.core.util.Compatibility; import com.bitworksmc.headdb.core.util.PermissionUtil; import net.kyori.adventure.text.Component; @@ -16,7 +17,7 @@ public class CustomCategoriesMenu extends PaginatedSimplePage { public CustomCategoriesMenu(HeadDB plugin, GUI gui, Component title, List categories) { - super(gui, title, 6, 48, 49, 50); + super(ConcurrentPageRegistry.INSTANCE, gui, title, 6, 48, 49, 50); preventInteraction(); for (CustomCategory category : categories) { addButton(new SimpleButton(category.getIcon(), ctx -> { diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/FavoritesHeadsMenu.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/FavoritesHeadsMenu.java index df7892f..029c6b5 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/FavoritesHeadsMenu.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/FavoritesHeadsMenu.java @@ -6,6 +6,7 @@ import com.bitworksmc.headdb.api.model.Head; import com.bitworksmc.headdb.core.HeadDB; import com.bitworksmc.headdb.core.factory.ItemFactoryRegistry; +import com.bitworksmc.headdb.core.menu.registry.ConcurrentPageRegistry; import com.bitworksmc.headdb.core.storage.PlayerData; import com.bitworksmc.headdb.core.util.Compatibility; import com.bitworksmc.headdb.core.util.PermissionUtil; @@ -20,7 +21,7 @@ public class FavoritesHeadsMenu extends PaginatedSimplePage { public FavoritesHeadsMenu(HeadDB plugin, GUI gui, Component title, List heads, List items) { - super(gui, title, 6, 48, 49, 50); + super(ConcurrentPageRegistry.INSTANCE, gui, title, 6, 48, 49, 50); preventInteraction(); for (Head head : heads) { diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/HeadsMenu.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/HeadsMenu.java index 2a4e93c..d44537a 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/HeadsMenu.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/HeadsMenu.java @@ -6,9 +6,11 @@ import com.bitworksmc.headdb.api.model.Head; import com.bitworksmc.headdb.core.HeadDB; import com.bitworksmc.headdb.core.factory.ItemFactoryRegistry; +import com.bitworksmc.headdb.core.menu.registry.ConcurrentPageRegistry; import com.bitworksmc.headdb.core.storage.PlayerData; import com.bitworksmc.headdb.core.util.Compatibility; import com.bitworksmc.headdb.core.util.PermissionUtil; +import com.bitworksmc.headdb.core.command.sub.HDBCommandInspect; import net.kyori.adventure.text.Component; import org.bukkit.entity.Player; import org.bukkit.event.inventory.ClickType; @@ -30,7 +32,7 @@ public HeadsMenu(HeadDB plugin, GUI gui, Component title, List he } public HeadsMenu(HeadDB plugin, GUI gui, Component title, List heads, @Nullable String permissionCategory) { - super(gui, title, 6, 48, 49, 50); + super(ConcurrentPageRegistry.INSTANCE, gui, title, 6, 48, 49, 50); this.plugin = plugin; this.heads = List.copyOf(heads); this.permissionCategory = permissionCategory; @@ -57,7 +59,7 @@ private synchronized void initializeButtons() { setButton(slot++, new SimpleButton(head.getItem(), ctx -> { Player player = (Player) ctx.event().getWhoClicked(); if (ctx.event().getClick() == ClickType.DROP) { - // TODO: Manage head + HDBCommandInspect.sendHeadDetails(plugin, ctx.event().getWhoClicked(), head); return; } String requiredCategory = permissionCategory != null ? permissionCategory : head.getCategory(); diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/LocalHeadsMenu.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/LocalHeadsMenu.java index a6b4d3c..9727134 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/LocalHeadsMenu.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/LocalHeadsMenu.java @@ -5,6 +5,7 @@ import com.github.thesilentpro.grim.page.PaginatedSimplePage; import com.bitworksmc.headdb.core.HeadDB; import com.bitworksmc.headdb.core.factory.ItemFactoryRegistry; +import com.bitworksmc.headdb.core.menu.registry.ConcurrentPageRegistry; import com.bitworksmc.headdb.core.storage.PlayerData; import com.bitworksmc.headdb.core.util.Compatibility; import com.bitworksmc.headdb.core.util.PermissionUtil; @@ -19,7 +20,7 @@ public class LocalHeadsMenu extends PaginatedSimplePage { public LocalHeadsMenu(HeadDB plugin, GUI gui, Component title, List items) { - super(gui, title, 6, 48, 49, 50); + super(ConcurrentPageRegistry.INSTANCE, gui, title, 6, 48, 49, 50); preventInteraction(); for (ItemStack item : items) { diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/MainMenu.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/MainMenu.java index 543841e..ef8d52a 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/MainMenu.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/MainMenu.java @@ -10,6 +10,7 @@ import com.bitworksmc.headdb.core.menu.gui.FavoritesHeadsGUI; import com.bitworksmc.headdb.core.menu.gui.HeadsGUI; import com.bitworksmc.headdb.core.menu.gui.LocalHeadsGUI; +import com.bitworksmc.headdb.core.menu.registry.ConcurrentPageRegistry; import com.bitworksmc.headdb.core.storage.PlayerData; import com.bitworksmc.headdb.core.util.Compatibility; import com.bitworksmc.headdb.core.util.PermissionUtil; @@ -33,7 +34,7 @@ public class MainMenu extends SimplePage { private static final int[] CATEGORY_SLOTS = {11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 29, 30, 31, 32, 33}; public MainMenu(HeadDB plugin, List heads) { - super(plugin.getLocalization().getConsoleMessage("menu.main.name").orElse(Component.text("HeadDB").color(NamedTextColor.RED)), 6); + super(ConcurrentPageRegistry.INSTANCE, plugin.getLocalization().getConsoleMessage("menu.main.name").orElse(Component.text("HeadDB").color(NamedTextColor.RED)), 6); preventInteraction(); renderCategoryButtons(plugin, heads); renderLocalButton(plugin); diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/MenuManager.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/MenuManager.java index 22b0d08..52101fa 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/MenuManager.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/MenuManager.java @@ -19,14 +19,14 @@ public class MenuManager { private static final Logger LOGGER = LoggerFactory.getLogger(MenuManager.class); private static final Set RESERVED_CUSTOM_CATEGORY_IDS = Set.of("local", "favorites", "custom"); - private volatile MainMenu mainMenu; - private volatile CustomCategoriesGUI customCategoriesGui; - private volatile Map guis = Map.of(); + private final HeadDB plugin; + private volatile List mainHeads = List.of(); + private volatile List customCategories = List.of(); + private volatile Map definitions = Map.of(); private volatile List categoryNames = List.of(); public MenuManager(HeadDB plugin) { - this.mainMenu = new MainMenu(plugin, List.of()); - this.customCategoriesGui = null; + this.plugin = plugin; } public void registerDefaults(HeadDB plugin) { @@ -45,20 +45,21 @@ public void registerDefaults(HeadDB plugin, List heads) { headsByCategory.computeIfAbsent(head.getCategory(), ignored -> new ArrayList<>()).add(head); } - Map updatedGuis = new HashMap<>(); + Map updatedDefinitions = new HashMap<>(); List updatedCategoryNames = new ArrayList<>(headsByCategory.size()); for (Map.Entry> entry : headsByCategory.entrySet()) { String knownCategory = entry.getKey(); try { - HeadsGUI gui = new HeadsGUI( - plugin, - knownCategory, + Component title = plugin.getLocalization().getConsoleMessage("menu.category." + knownCategory.toLowerCase(Locale.ROOT)) - .orElseGet(() -> Component.text("HeadDB ยป " + knownCategory).color(NamedTextColor.GOLD)), + .orElseGet(() -> Component.text("HeadDB ยป " + knownCategory).color(NamedTextColor.GOLD)); + MenuDefinition definition = new MenuDefinition( + knownCategory, + title, entry.getValue(), knownCategory ); - updatedGuis.put(normalizeKey(knownCategory), gui); + updatedDefinitions.put(normalizeKey(knownCategory), definition); updatedCategoryNames.add(knownCategory); } catch (Throwable ex) { LOGGER.error("Failed to register known category: {}", knownCategory, ex); @@ -73,15 +74,14 @@ public void registerDefaults(HeadDB plugin, List heads) { continue; } String customKey = normalizeKey(category.getIdentifier()); - if (RESERVED_CUSTOM_CATEGORY_IDS.contains(customKey) || updatedGuis.containsKey(customKey)) { + if (RESERVED_CUSTOM_CATEGORY_IDS.contains(customKey) || updatedDefinitions.containsKey(customKey)) { LOGGER.warn("Skipping custom category '{}' because its normalized ID '{}' is reserved or already in use.", category.getIdentifier(), customKey); continue; } customCategories.add(category); updatedCategoryNames.add(category.getIdentifier()); - updatedGuis.put(customKey, new HeadsGUI( - plugin, + updatedDefinitions.put(customKey, new MenuDefinition( "custom_" + category.getIdentifier(), plugin.getLocalization().getConsoleMessage("menu.category." + category.getIdentifier()) .orElseGet(() -> MiniMessage.miniMessage().deserialize("HeadDB ยป " + category.getName())), @@ -90,38 +90,34 @@ public void registerDefaults(HeadDB plugin, List heads) { )); } - CustomCategoriesGUI updatedCustomCategoriesGui = new CustomCategoriesGUI( - plugin, - "custom_categories", - plugin.getLocalization().getConsoleMessage("menu.customCategories.name") - .orElseGet(() -> Component.text("HeadDB ยป More Categories").color(NamedTextColor.GOLD)), - customCategories - ); - MainMenu updatedMainMenu = new MainMenu(plugin, heads); - - // Publish the complete replacement only after every menu was constructed. - this.guis = Map.copyOf(updatedGuis); - this.customCategoriesGui = updatedCustomCategoriesGui; - this.mainMenu = updatedMainMenu; + // Publish immutable menu models. A fresh inventory tree is constructed + // for each open so Folia region threads never share Bukkit inventories. + this.definitions = Map.copyOf(updatedDefinitions); + this.customCategories = List.copyOf(customCategories); + this.mainHeads = List.copyOf(heads); this.categoryNames = List.copyOf(updatedCategoryNames); } - public synchronized void register(String key, HeadsGUI menu) { - Map updated = new HashMap<>(this.guis); - updated.put(normalizeKey(key), menu); - this.guis = Map.copyOf(updated); - } - public HeadsGUI get(String key) { - return key == null ? null : this.guis.get(normalizeKey(key)); + MenuDefinition definition = key == null ? null : definitions.get(normalizeKey(key)); + if (definition == null) { + return null; + } + return new HeadsGUI(plugin, definition.key(), definition.title(), definition.heads(), definition.permissionCategory()); } public CustomCategoriesGUI getCustomCategoriesGui() { - return customCategoriesGui; + return new CustomCategoriesGUI( + plugin, + "custom_categories", + plugin.getLocalization().getConsoleMessage("menu.customCategories.name") + .orElseGet(() -> Component.text("HeadDB ยป More Categories").color(NamedTextColor.GOLD)), + customCategories + ); } public MainMenu getMainMenu() { - return this.mainMenu; + return new MainMenu(plugin, mainHeads); } public List getCategoryNames() { @@ -132,4 +128,10 @@ private static String normalizeKey(String key) { return PermissionUtil.normalizeCategory(key); } + private record MenuDefinition(String key, Component title, List heads, String permissionCategory) { + private MenuDefinition { + heads = List.copyOf(heads); + } + } + } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/PurchaseHeadMenu.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/PurchaseHeadMenu.java index 36198ed..8a0e4fd 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/PurchaseHeadMenu.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/PurchaseHeadMenu.java @@ -7,6 +7,7 @@ import com.bitworksmc.headdb.api.model.Head; import com.bitworksmc.headdb.core.HeadDB; import com.bitworksmc.headdb.core.factory.ItemFactoryRegistry; +import com.bitworksmc.headdb.core.menu.registry.ConcurrentPageRegistry; import com.bitworksmc.headdb.core.util.Compatibility; import com.bitworksmc.headdb.core.util.PermissionUtil; import com.github.thesilentpro.inputs.paper.PaperInput; @@ -33,7 +34,7 @@ public PurchaseHeadMenu(HeadDB plugin, Player player, Head head, Page parentPage } public PurchaseHeadMenu(HeadDB plugin, Player player, Head head, Page parentPage, String permissionCategory) { - super(plugin.getLocalization().getMessage(player.getUniqueId(), "menu.purchase.name").orElseGet(() -> Component.text("HeadDB ยป " + head.getName() + " ยป Purchase")).replaceText(builder -> builder.matchLiteral("{name}").replacement(head.getName())), 6); + super(ConcurrentPageRegistry.INSTANCE, plugin.getLocalization().getMessage(player.getUniqueId(), "menu.purchase.name").orElseGet(() -> Component.text("HeadDB ยป " + head.getName() + " ยป Purchase")).replaceText(builder -> builder.matchLiteral("{name}").replacement(head.getName())), 6); this.plugin = plugin; this.head = head; this.permissionCategory = permissionCategory; diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/SafePaginatedGUI.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/SafePaginatedGUI.java index bf58c73..c136449 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/SafePaginatedGUI.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/gui/SafePaginatedGUI.java @@ -1,6 +1,7 @@ package com.bitworksmc.headdb.core.menu.gui; import com.github.thesilentpro.grim.gui.PaginatedGUI; +import com.bitworksmc.headdb.core.menu.registry.ConcurrentGUIRegistry; import org.bukkit.NamespacedKey; import org.bukkit.entity.Player; @@ -10,7 +11,7 @@ abstract class SafePaginatedGUI extends PaginatedGUI { protected SafePaginatedGUI(NamespacedKey key) { - super(key); + super(new ConcurrentGUIRegistry<>(), key); } @Override diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/registry/ConcurrentGUIRegistry.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/registry/ConcurrentGUIRegistry.java new file mode 100644 index 0000000..f54a208 --- /dev/null +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/registry/ConcurrentGUIRegistry.java @@ -0,0 +1,35 @@ +package com.bitworksmc.headdb.core.menu.registry; + +import com.github.thesilentpro.grim.gui.registry.GUIRegistry; +import org.bukkit.NamespacedKey; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** Region-thread-safe per-GUI navigation state. */ +public final class ConcurrentGUIRegistry implements GUIRegistry { + private final Map> pageTracker = new ConcurrentHashMap<>(); + + @Override + public void setCurrentPage(UUID playerId, NamespacedKey key, T page) { + pageTracker.computeIfAbsent(playerId, ignored -> new ConcurrentHashMap<>()).put(key, page); + } + + @Override + public T getCurrentPage(UUID playerId, NamespacedKey key, T fallback) { + return pageTracker.computeIfAbsent(playerId, ignored -> new ConcurrentHashMap<>()) + .computeIfAbsent(key, ignored -> fallback); + } + + @Override + public Optional getCurrentPage(UUID playerId, NamespacedKey key) { + return getData(playerId).map(data -> data.get(key)); + } + + @Override + public Optional> getData(UUID playerId) { + return Optional.ofNullable(pageTracker.get(playerId)); + } +} diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/registry/ConcurrentPageRegistry.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/registry/ConcurrentPageRegistry.java new file mode 100644 index 0000000..95a3236 --- /dev/null +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/menu/registry/ConcurrentPageRegistry.java @@ -0,0 +1,40 @@ +package com.bitworksmc.headdb.core.menu.registry; + +import com.github.thesilentpro.grim.page.Page; +import com.github.thesilentpro.grim.page.registry.PageRegistry; +import org.bukkit.inventory.InventoryView; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** Region-thread-safe page tracking for Paper and Folia inventory events. */ +public final class ConcurrentPageRegistry implements PageRegistry { + public static final ConcurrentPageRegistry INSTANCE = new ConcurrentPageRegistry(); + + private final Map pages = new ConcurrentHashMap<>(); + + private ConcurrentPageRegistry() { + } + + @Override + public void register(InventoryView view, Page page) { + pages.put(view, page); + } + + @Override + public Optional get(InventoryView view) { + return Optional.ofNullable(pages.get(view)); + } + + @Override + public void remove(InventoryView view) { + pages.remove(view); + } + + @Override + public Map getPages() { + return Collections.unmodifiableMap(pages); + } +} diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/storage/PlayerDAO.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/storage/PlayerDAO.java index 4520350..cb64fe5 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/storage/PlayerDAO.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/storage/PlayerDAO.java @@ -20,6 +20,10 @@ public class PlayerDAO { private static final Path DEFAULT_DATA_FOLDER = Path.of("plugins", "HeadDB"); private final String databaseUrl; + private final String username; + private final String password; + private final boolean mysql; + private final String tableName; private final Path legacyDatabasePath; public PlayerDAO() { @@ -27,14 +31,32 @@ public PlayerDAO() { } public PlayerDAO(Path databasePath, Path legacyDatabasePath) { - this.databaseUrl = "jdbc:sqlite:" + Objects.requireNonNull(databasePath, "databasePath").toAbsolutePath(); + this("jdbc:sqlite:" + Objects.requireNonNull(databasePath, "databasePath").toAbsolutePath(), + null, null, legacyDatabasePath); + } + + public PlayerDAO(String databaseUrl, String username, String password, Path legacyDatabasePath) { + this.databaseUrl = Objects.requireNonNull(databaseUrl, "databaseUrl"); + this.username = username; + this.password = password; + this.mysql = databaseUrl.startsWith("jdbc:mysql:") || databaseUrl.startsWith("jdbc:mariadb:"); + this.tableName = mysql ? "headdb_players" : "players"; this.legacyDatabasePath = Objects.requireNonNull(legacyDatabasePath, "legacyDatabasePath").toAbsolutePath(); + if (mysql) { + try { + Class.forName("com.mysql.cj.jdbc.Driver"); + } catch (ClassNotFoundException ex) { + throw new IllegalStateException("MySQL player storage requires mysql-connector-j", ex); + } + } } public void createTable() { try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) { - stmt.execute(SqlUtils.CREATE_TABLE); + stmt.execute(mysql + ? "CREATE TABLE IF NOT EXISTS " + tableName + " (uuid VARCHAR(36) PRIMARY KEY, language VARCHAR(32), favorites TEXT, local_favorites TEXT, sound_enabled BOOLEAN)" + : SqlUtils.CREATE_TABLE); } catch (SQLException ex) { LOGGER.error("Failed to create table", ex); } @@ -44,7 +66,10 @@ public void saveAllPlayers(Map dataMap) { try (Connection conn = getConnection()) { conn.setAutoCommit(false); - try (PreparedStatement stmt = conn.prepareStatement(SqlUtils.INSERT_OR_REPLACE)) { + String upsert = mysql + ? "INSERT INTO " + tableName + " (uuid, language, favorites, local_favorites, sound_enabled) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE language=VALUES(language), favorites=VALUES(favorites), local_favorites=VALUES(local_favorites), sound_enabled=VALUES(sound_enabled)" + : SqlUtils.INSERT_OR_REPLACE; + try (PreparedStatement stmt = conn.prepareStatement(upsert)) { // Take a stable snapshot of the map. Individual PlayerData fields are // backed by thread-safe/volatile values and are snapshotted below. List players = new ArrayList<>(dataMap.values()); @@ -91,7 +116,7 @@ public Map loadAllPlayers() { try (Connection conn = getConnection(); Statement stmt = conn.createStatement(); - ResultSet rs = stmt.executeQuery(SqlUtils.SELECT_ALL)) { + ResultSet rs = stmt.executeQuery("SELECT * FROM " + tableName)) { while (rs.next()) { String rawUuid = rs.getString("uuid"); @@ -212,6 +237,6 @@ static List parseUuidList(String value, UUID playerId) { } private Connection getConnection() throws SQLException { - return DriverManager.getConnection(databaseUrl); + return mysql ? DriverManager.getConnection(databaseUrl, username, password) : DriverManager.getConnection(databaseUrl); } } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/storage/PlayerStorage.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/storage/PlayerStorage.java index dad789e..c25789b 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/storage/PlayerStorage.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/storage/PlayerStorage.java @@ -1,5 +1,6 @@ package com.bitworksmc.headdb.core.storage; +import com.bitworksmc.headdb.core.config.Config; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,16 +21,28 @@ public class PlayerStorage { private final Map data = new ConcurrentHashMap<>(); private final PlayerDAO playerDao; + private final PlayerDAO localMigrationDao; public PlayerStorage() { this(new File("plugins", "HeadDB")); } public PlayerStorage(File dataFolder) { + this(dataFolder, null); + } + + public PlayerStorage(File dataFolder, Config config) { Path pluginDataFolder = Objects.requireNonNull(dataFolder, "dataFolder").toPath().toAbsolutePath(); Path databaseDirectory = pluginDataFolder.resolve("data"); ensureDirectoryExists(databaseDirectory); - this.playerDao = new PlayerDAO(databaseDirectory.resolve("data.db"), pluginDataFolder.resolve("data.db")); + boolean mysql = config != null && "MYSQL".equals(config.getPlayerStorageBackend()); + this.playerDao = mysql + ? new PlayerDAO(config.getPlayerStorageJdbcUrl(), config.getPlayerStorageUsername(), + config.getPlayerStoragePassword(), pluginDataFolder.resolve("data.db")) + : new PlayerDAO(databaseDirectory.resolve("data.db"), pluginDataFolder.resolve("data.db")); + this.localMigrationDao = mysql && Files.isRegularFile(databaseDirectory.resolve("data.db")) + ? new PlayerDAO(databaseDirectory.resolve("data.db"), pluginDataFolder.resolve("data.db")) + : null; this.playerDao.createTable(); } @@ -42,6 +55,11 @@ public synchronized void load() { long start = System.currentTimeMillis(); Map legacyData = this.playerDao.loadLegacyPlayers(); Map currentData = this.playerDao.loadAllPlayers(); + boolean importedCurrentSqlite = false; + if (currentData.isEmpty() && localMigrationDao != null) { + currentData.putAll(localMigrationDao.loadAllPlayers()); + importedCurrentSqlite = !currentData.isEmpty(); + } // Import players missing from the v6 database while always preserving a // current row as authoritative when the same UUID exists in both files. @@ -50,8 +68,13 @@ public synchronized void load() { this.data.putAll(legacyData); this.data.putAll(currentData); + if (importedCurrentSqlite) { + LOGGER.info("Imported {} player record(s) from local SQLite into MySQL.", currentData.size()); + } if (!legacyData.isEmpty()) { LOGGER.info("Imported {} player record(s) from the pre-v6 database.", legacyData.size()); + } + if (!legacyData.isEmpty() || importedCurrentSqlite) { this.playerDao.saveAllPlayers(this.data); } LOGGER.debug("Loaded all data in {}ms", System.currentTimeMillis() - start); diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/util/HDBLocalization.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/util/HDBLocalization.java index 57b55d9..b3b9851 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/util/HDBLocalization.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/util/HDBLocalization.java @@ -21,6 +21,7 @@ import java.io.File; import java.io.IOException; import java.util.Optional; +import java.util.List; import java.util.UUID; import java.util.function.UnaryOperator; import java.util.regex.Pattern; @@ -48,6 +49,9 @@ public HDBLocalization(@NotNull HeadDB plugin) { @Override public @NotNull Optional getMessage(@NotNull UUID receiver, @NotNull String key) { + if (plugin.getPlayerStorage() != null) { + setLanguage(receiver, plugin.getPlayerStorage().getPlayer(receiver).getLanguage()); + } return super.getMessage(receiver, key).map(message -> applyPlaceholders(receiver, message)); } @@ -164,6 +168,10 @@ public void init() { } } + public List getAvailableLanguages() { + return getLanguages().keySet().stream().sorted().toList(); + } + private Component replaceArguments(Component message, @Nullable String... args) { if (args == null) { return message; diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/core/util/WebsiteLinks.java b/headdb-core/src/main/java/com/bitworksmc/headdb/core/util/WebsiteLinks.java index bd7f952..befb43b 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/core/util/WebsiteLinks.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/core/util/WebsiteLinks.java @@ -48,19 +48,33 @@ public static String submissionUrl(String baseUrl) { return normalizeBaseUrl(baseUrl) + "/submit"; } + public static String headUrl(String baseUrl, int headId) { + if (headId < 1) throw new IllegalArgumentException("headId must be positive"); + return normalizeBaseUrl(baseUrl) + "/heads/" + headId; + } + public static String searchUrl( String baseUrl, String name, String category, Collection tags, Collection ids + ) { + return searchUrl(baseUrl, name, category, tags, ids, false); + } + + public static String searchUrl( + String baseUrl, + String name, + String category, + Collection tags, + Collection ids, + boolean matchAny ) { List parameters = new ArrayList<>(); String trimmedName = name == null ? "" : name.trim(); if (!trimmedName.isEmpty()) { parameters.add(parameter("q", trimmedName)); - } else if (ids != null && ids.size() == 1) { - parameters.add(parameter("q", String.valueOf(ids.iterator().next()))); } String categorySlug = slugify(category); @@ -79,6 +93,21 @@ public static String searchUrl( } } + if (ids != null && !ids.isEmpty()) { + String idValues = ids.stream() + .filter(id -> id != null && id > 0) + .map(String::valueOf) + .distinct() + .collect(Collectors.joining(",")); + if (!idValues.isEmpty()) { + parameters.add(parameter("ids", idValues)); + } + } + + if (matchAny) { + parameters.add(parameter("match", "any")); + } + String url = normalizeBaseUrl(baseUrl) + "/heads"; return parameters.isEmpty() ? url : url + "?" + String.join("&", parameters); } @@ -89,7 +118,7 @@ public static Component makeClickable(Component message, String url, Component h .hoverEvent(HoverEvent.showText(hoverText)); } - static String slugify(String value) { + public static String slugify(String value) { if (value == null || value.isBlank()) { return ""; } diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadAPI.java b/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadAPI.java index f3beefb..3a7ea2e 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadAPI.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadAPI.java @@ -4,6 +4,11 @@ import com.bitworksmc.headdb.api.HeadAPI; import com.bitworksmc.headdb.api.HeadDatabase; import com.bitworksmc.headdb.api.model.Head; +import com.bitworksmc.headdb.api.catalog.CatalogStatus; +import com.bitworksmc.headdb.api.catalog.CatalogUpdateListener; +import com.bitworksmc.headdb.api.search.HeadSearch; +import com.bitworksmc.headdb.api.search.SearchPage; +import com.bitworksmc.headdb.api.search.SearchQuery; import com.bitworksmc.headdb.core.factory.ItemFactoryRegistry; import com.bitworksmc.headdb.core.util.Utils; import org.bukkit.Bukkit; @@ -110,6 +115,22 @@ public List findKnownCategories() { return List.copyOf(result); } + @Override + public @NotNull CatalogStatus getCatalogStatus() { + return database.getCatalogStatus(); + } + + @Override + public @NotNull CompletableFuture search(@NotNull SearchQuery query) { + Objects.requireNonNull(query, "query"); + return getHeads().thenApplyAsync(heads -> HeadSearch.search(heads, query), executor); + } + + @Override + public @NotNull AutoCloseable addCatalogUpdateListener(@NotNull CatalogUpdateListener listener) { + return database.addCatalogUpdateListener(Objects.requireNonNull(listener, "listener")); + } + @NotNull @Override public List computeLocalHeads() { diff --git a/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadDatabase.java b/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadDatabase.java index 43bde44..2598af4 100644 --- a/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadDatabase.java +++ b/headdb-core/src/main/java/com/bitworksmc/headdb/implementation/BaseHeadDatabase.java @@ -1,6 +1,9 @@ package com.bitworksmc.headdb.implementation; import com.bitworksmc.headdb.api.HeadDatabase; +import com.bitworksmc.headdb.api.catalog.CatalogStatus; +import com.bitworksmc.headdb.api.catalog.CatalogUpdate; +import com.bitworksmc.headdb.api.catalog.CatalogUpdateListener; import com.bitworksmc.headdb.api.model.Head; import com.bitworksmc.headdb.implementation.model.HeadMapper; import com.google.gson.Gson; @@ -53,6 +56,11 @@ public class BaseHeadDatabase implements HeadDatabase { */ private volatile Snapshot snapshot; private volatile int catalogRevision = -1; + private volatile long lastAttemptEpochMillis; + private volatile long lastSuccessfulUpdateEpochMillis; + private volatile String lastError; + private volatile String activeSource; + private final CopyOnWriteArrayList updateListeners = new CopyOnWriteArrayList<>(); // track the latest load private volatile CompletableFuture> lastUpdateFuture; @@ -105,7 +113,20 @@ public CompletableFuture> update() { return currentUpdate; } - lastUpdateFuture = CompletableFuture.supplyAsync(this::loadDatabase, executor); + Snapshot previousSnapshot = snapshot; + int previousRevision = catalogRevision; + lastAttemptEpochMillis = System.currentTimeMillis(); + lastUpdateFuture = CompletableFuture.supplyAsync(this::loadDatabase, executor) + .whenComplete((heads, failure) -> { + if (failure != null) { + lastError = rootMessage(failure); + return; + } + lastError = null; + lastSuccessfulUpdateEpochMillis = System.currentTimeMillis(); + CatalogUpdate update = describeUpdate(previousSnapshot, snapshot, previousRevision, catalogRevision); + if (update.hasChanges() || previousRevision != catalogRevision) notifyUpdateListeners(update); + }); return lastUpdateFuture; } } @@ -143,6 +164,7 @@ private List loadFullSnapshot() { Snapshot loadedSnapshot = buildSnapshot(fetched.heads()); this.snapshot = loadedSnapshot; this.catalogRevision = fetched.revision(); + this.activeSource = sourceUrl; persistCatalogCache(loadedSnapshot.heads(), fetched.revision()); long elapsed = System.currentTimeMillis() - start; @@ -220,6 +242,7 @@ private FetchedCatalog fetchHeads(String sourceUrl) throws IOException { private List loadChanges() throws IOException { Snapshot current = Objects.requireNonNull(snapshot, "snapshot"); + this.activeSource = syncUrl; int fromRevision = catalogRevision; int toRevision = -1; String cursor = null; @@ -347,6 +370,7 @@ private boolean restoreCatalogCache() { } this.snapshot = buildSnapshot(heads); this.catalogRevision = revision; + this.activeSource = "cache:" + cachePath.toAbsolutePath(); LOGGER.info("Restored {} heads from saved catalog revision {}.", heads.size(), revision); return true; } catch (IOException | RuntimeException ex) { @@ -639,6 +663,73 @@ public int getCatalogRevision() { return catalogRevision; } + @Override + public CatalogStatus getCatalogStatus() { + Snapshot current = snapshot; + return new CatalogStatus( + current != null, + catalogRevision, + current == null ? 0 : current.heads().size(), + lastAttemptEpochMillis, + lastSuccessfulUpdateEpochMillis, + lastError, + activeSource + ); + } + + @Override + public AutoCloseable addCatalogUpdateListener(CatalogUpdateListener listener) { + Objects.requireNonNull(listener, "listener"); + updateListeners.add(listener); + return () -> updateListeners.remove(listener); + } + + private CatalogUpdate describeUpdate(Snapshot before, Snapshot after, int previousRevision, int revision) { + Map previous = new HashMap<>(); + if (before != null) for (Head head : before.heads()) previous.put(head.getId(), head); + Map current = new HashMap<>(); + if (after != null) for (Head head : after.heads()) current.put(head.getId(), head); + List added = new ArrayList<>(); + List updated = new ArrayList<>(); + List removed = new ArrayList<>(); + for (Map.Entry entry : current.entrySet()) { + Head old = previous.get(entry.getKey()); + if (old == null) added.add(entry.getKey()); + else if (!sameHead(old, entry.getValue())) updated.add(entry.getKey()); + } + for (Integer id : previous.keySet()) if (!current.containsKey(id)) removed.add(id); + Collections.sort(added); + Collections.sort(updated); + Collections.sort(removed); + return new CatalogUpdate(previousRevision, revision, added, updated, removed, System.currentTimeMillis()); + } + + private void notifyUpdateListeners(CatalogUpdate update) { + for (CatalogUpdateListener listener : updateListeners) { + try { + listener.onCatalogUpdate(update); + } catch (RuntimeException ex) { + LOGGER.warn("A catalog update listener failed: {}", ex.getMessage()); + LOGGER.debug("Detailed catalog listener failure", ex); + } + } + } + + private static boolean sameHead(Head left, Head right) { + return left.getId() == right.getId() + && Objects.equals(left.getName(), right.getName()) + && Objects.equals(left.getTexture(), right.getTexture()) + && Objects.equals(left.getTextureUrl(), right.getTextureUrl()) + && Objects.equals(left.getCategory(), right.getCategory()) + && Objects.equals(left.getTags(), right.getTags()); + } + + private static String rootMessage(Throwable failure) { + Throwable current = failure; + while (current.getCause() != null) current = current.getCause(); + return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage(); + } + private record FetchedCatalog(List heads, int revision) { } diff --git a/headdb-core/src/main/resources/config.yml b/headdb-core/src/main/resources/config.yml index 57101e4..5245d4a 100644 --- a/headdb-core/src/main/resources/config.yml +++ b/headdb-core/src/main/resources/config.yml @@ -137,5 +137,12 @@ indexing: storage: player: + # SQLITE stores data in plugins/HeadDB/data/data.db. MYSQL lets a server + # network share favorites, language, and sound preferences. + backend: "SQLITE" + mysql: + url: "jdbc:mysql://127.0.0.1:3306/headdb" + username: "headdb" + password: "change-me" # How often the player data is saved to disk. In seconds saveInterval: 1800 diff --git a/headdb-core/src/main/resources/messages/en.yml b/headdb-core/src/main/resources/messages/en.yml index f3e18c6..0ce7d7d 100644 --- a/headdb-core/src/main/resources/messages/en.yml +++ b/headdb-core/src/main/resources/messages/en.yml @@ -26,6 +26,8 @@ menu: remove: "{name} was REMOVED from your favorites!" search: name: "HeadDB ยป Search ยป {name}" + recent: + name: "HeadDB ยป Recently added" customCategories: name: "HeadDB ยป More Categories" purchase: @@ -85,6 +87,7 @@ command: invalidCategory: "Invalid category: {category}" give: + consoleUsage: "Console usage: /hdb give " invalidId: "Invalid head id ยป {id}" invalidAmount: "Amount must be between 1 and {max}" failed: "Could not give that head. Check the server console for details." @@ -117,3 +120,21 @@ command: submit: link: "Have a head to share? Submit it on headdb.net for review." + + sync: + start: "Synchronizing the HeadDB catalog..." + success: "Catalog synchronized successfully. {amount} heads are available." + failed: "Catalog synchronization failed: {error}" + + reload: + success: "Reloaded messages, sounds, menus, prices, website links, and local categories. Database endpoints, player-storage backend, worker counts, and task intervals still require a restart." + + inspect: + notHead: "Hold a HeadDB catalog head or provide an ID, name, or texture." + notFound: "That head is not in the current catalog." + consoleUsage: "Console usage: /hdb inspect " + + language: + available: "Available HeadDB languages: {languages}" + invalid: "Unknown language: {language}" + changed: "Your HeadDB language is now {language}." diff --git a/headdb-core/src/main/resources/messages/es.yml b/headdb-core/src/main/resources/messages/es.yml new file mode 100644 index 0000000..3f65caf --- /dev/null +++ b/headdb-core/src/main/resources/messages/es.yml @@ -0,0 +1,123 @@ +noPermission: "ยกNo tienes permiso!" +noConsole: "ยกSolo para jugadores dentro del juego!" +invalidSubCommand: "Subcomando no vรกlido ยป ${1}" +commandUsage: "Uso ยป {usage}" +invalidTarget: "No se encontrรณ al jugador ยป {target}" +invalidNumber: "Nรบmero no vรกlido ยป {number}" +databaseLoading: "La base de datos de cabezas todavรญa se estรก cargando. Intรฉntalo de nuevo en breve." + +favoritesNone: "ยกNo tienes cabezas favoritas!" +localNone: "ยกNo hay cabezas locales!" +customCategoriesNone: "ยกNo hay mรกs categorรญas!" + +purchase: + invalidFunds: "ยกNo tienes suficiente dinero!" + failed: "La compra fallรณ. Revisa la consola del servidor." + customUnsupported: "La entrada de chat personalizada requiere Paper; usa una cantidad predefinida en Spigot." + success: "Compraste {amount}x {name} por {cost}" + noEconomy: "Recibiste {amount}x {name}" + +menu: + local: + name: "HeadDB ยป Cabezas locales" + favorites: + name: "HeadDB ยป Favoritos" + add: "{name} se Aร‘ADIร“ a tus favoritos." + remove: "{name} se ELIMINร“ de tus favoritos." + search: + name: "HeadDB ยป Buscar ยป {name}" + recent: + name: "HeadDB ยป Aรฑadidas recientemente" + customCategories: + name: "HeadDB ยป Mรกs categorรญas" + purchase: + name: "HeadDB ยป {name} ยป Comprar" + main: + name: "HeadDB" + local: + name: "Cabezas locales" + favorites: + name: "Favoritos" + search: + name: "Buscar" + customCategories: + name: "Mรกs categorรญas" + category: + decoration: + name: "Decoraciรณn" + category: + alphabet: "HeadDB ยป Alfabeto" + animals: "HeadDB ยป Animales" + blocks: "HeadDB ยป Bloques" + decoration: "HeadDB ยป Decoraciรณn" + food & drinks: "HeadDB ยป Comida y bebidas" + humanoid: "HeadDB ยป Humanoide" + humans: "HeadDB ยป Humanos" + miscellaneous: "HeadDB ยป Miscelรกnea" + monsters: "HeadDB ยป Monstruos" + plants: "HeadDB ยป Plantas" + controls: + back: + name: "โ—€ Atrรกs" + lore: "Ir a la pรกgina anterior: ${{BACK}}" + info: + name: "โ„น Pรกgina ${{CURRENT}}/${{MAX}}" + lore: "Haz clic para ir al menรบ principal." + next: + name: "Siguiente โ–ถ" + lore: "Ir a la pรกgina siguiente: ${{NEXT}}" + +update: + available: |- + Hay una nueva versiรณn de HeadDB: {latest} (actual: {current}) + Descarga: {url} + +command: + open: + opening: "Abriendo la base de datos de cabezas." + invalidCategory: "Categorรญa no vรกlida: {category}" + give: + consoleUsage: "Uso desde consola: /hdb give " + invalidId: "ID de cabeza no vรกlido ยป {id}" + invalidAmount: "La cantidad debe estar entre 1 y {max}" + failed: "No se pudo entregar la cabeza. Revisa la consola." + success: "Se entregรณ {amount}x {name} a {target}" + search: + input: "Escribe la consulta y los filtros:" + manual: "La entrada por chat requiere Paper. Ejecuta /hdb search \." + start: " โณ Buscando..." + empty: "Escribe un nombre o al menos un filtro." + invalidId: "ID de cabeza no vรกlido: {id}" + failed: "La bรบsqueda fallรณ. Revisa la consola." + none: "ยกNo se encontraron cabezas!" + found: "Se encontraron {amount} cabezas." + website: "ยฟQuieres afinar la bรบsqueda? รbrela en headdb.net para filtrar y copiar comandos." + filter: | + + ๐Ÿ”Ž Filtros + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + โ€ข Nombre ยป {name} + โ€ข Categorรญa ยป {category} + โ€ข Etiquetas ยป {tags} + โ€ข IDs ยป {ids} + โ€ข Modo ยป {mode} + + sounds: + enabled: "Sonidos de la interfaz de HeadDB activados." + disabled: "Sonidos de la interfaz de HeadDB desactivados. Ejecuta el comando de nuevo para activarlos." + submit: + link: "ยฟTienes una cabeza para compartir? Envรญala en headdb.net para revisiรณn." + sync: + start: "Sincronizando el catรกlogo de HeadDB..." + success: "Catรกlogo sincronizado. Hay {amount} cabezas disponibles." + failed: "Fallรณ la sincronizaciรณn: {error}" + reload: + success: "Se recargaron mensajes, sonidos, menรบs, precios, enlaces y categorรญas locales. Los orรญgenes de datos, el almacenamiento de jugadores, los hilos y los intervalos requieren reiniciar." + inspect: + notHead: "Sostรฉn una cabeza de HeadDB o indica un ID, nombre o textura." + notFound: "Esa cabeza no estรก en el catรกlogo actual." + consoleUsage: "Uso desde consola: /hdb inspect " + language: + available: "Idiomas disponibles de HeadDB: {languages}" + invalid: "Idioma desconocido: {language}" + changed: "Tu idioma de HeadDB ahora es {language}." diff --git a/headdb-core/src/main/resources/plugin.yml b/headdb-core/src/main/resources/plugin.yml index e013bc7..4ac113e 100644 --- a/headdb-core/src/main/resources/plugin.yml +++ b/headdb-core/src/main/resources/plugin.yml @@ -9,8 +9,10 @@ softdepend: [ "Vault" ] api-version: '1.21' +folia-supported: true libraries: - "org.xerial:sqlite-jdbc:${sqlite.version}" + - "com.mysql:mysql-connector-j:${mysql.version}" spigot-id: 84967 @@ -31,6 +33,12 @@ permissions: headdb.command.info: true headdb.command.sounds: true headdb.command.submit: true + headdb.command.status: true + headdb.command.sync: true + headdb.command.reload: true + headdb.command.inspect: true + headdb.command.recent: true + headdb.command.language: true headdb.update.notify: true headdb.category.*: true headdb.category.favorites: true @@ -55,6 +63,24 @@ permissions: headdb.command.submit: description: Shows a clickable link to the HeadDB submission page. default: true + headdb.command.status: + description: Shows catalog revision, source, count, and synchronization health. + default: op + headdb.command.sync: + description: Synchronizes the managed head catalog immediately. + default: op + headdb.command.reload: + description: Reloads messages, sounds, menus, prices, website links, and local categories. + default: op + headdb.command.inspect: + description: Inspects a held or identified HeadDB head and links to its web page. + default: true + headdb.command.recent: + description: Browses the most recently assigned catalog IDs. + default: true + headdb.command.language: + description: Selects a personal HeadDB message language. + default: true headdb.update.notify: description: Notifies the player when a newer HeadDB release is available. default: op diff --git a/headdb-core/src/test/java/com/bitworksmc/headdb/api/search/HeadSearchTest.java b/headdb-core/src/test/java/com/bitworksmc/headdb/api/search/HeadSearchTest.java new file mode 100644 index 0000000..f1b4b04 --- /dev/null +++ b/headdb-core/src/test/java/com/bitworksmc/headdb/api/search/HeadSearchTest.java @@ -0,0 +1,50 @@ +package com.bitworksmc.headdb.api.search; + +import com.bitworksmc.headdb.api.model.Head; +import com.bitworksmc.headdb.implementation.model.BaseHead; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HeadSearchTest { + + private final List heads = Arrays.asList( + new BaseHead(3, "Cherry Cake", "three", "Food & Drinks", Arrays.asList("Dessert", "Pink")), + new BaseHead(1, "Oak Crate", "one", "Decoration", Arrays.asList("Wood")), + new BaseHead(2, "Dragon Egg", "two", "Blocks", Arrays.asList("Dragon", "Dark")) + ); + + @Test + void supportsAllFiltersPaginationAndCategorySlugs() { + SearchQuery query = SearchQuery.builder() + .category("food-and-drinks") + .tags(Arrays.asList("Dessert", null, "Dessert")) + .matchMode(MatchMode.ALL) + .limit(1) + .build(); + + SearchPage page = HeadSearch.search(heads, query); + assertEquals(1, page.getTotal()); + assertEquals(3, page.getItems().get(0).getId()); + assertTrue(!page.hasMore()); + } + + @Test + void supportsAnyDimensionsAndStableDescendingSort() { + SearchQuery query = SearchQuery.builder() + .name("crate") + .ids(Arrays.asList(2)) + .matchMode(MatchMode.ANY) + .sort(SearchSort.ID) + .ascending(false) + .build(); + + SearchPage page = HeadSearch.search(heads, query); + assertEquals(Arrays.asList(2, 1), Arrays.asList( + page.getItems().get(0).getId(), page.getItems().get(1).getId())); + } +} diff --git a/headdb-core/src/test/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSearchTest.java b/headdb-core/src/test/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSearchTest.java new file mode 100644 index 0000000..0aed84a --- /dev/null +++ b/headdb-core/src/test/java/com/bitworksmc/headdb/core/command/sub/HDBCommandSearchTest.java @@ -0,0 +1,20 @@ +package com.bitworksmc.headdb.core.command.sub; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HDBCommandSearchTest { + + @Test + void combinesQuotedFilterValuesFromBukkitArguments() { + assertEquals( + List.of("category:Food & Drinks", "tags:dark red,wood", "--any"), + HDBCommandSearch.combineQuotedArguments(new String[]{ + "category:\"Food", "&", "Drinks\"", "tags:\"dark", "red\",wood", "--any" + }) + ); + } +} diff --git a/headdb-core/src/test/java/com/bitworksmc/headdb/core/util/WebsiteLinksTest.java b/headdb-core/src/test/java/com/bitworksmc/headdb/core/util/WebsiteLinksTest.java index 9c97c58..258e41c 100644 --- a/headdb-core/src/test/java/com/bitworksmc/headdb/core/util/WebsiteLinksTest.java +++ b/headdb-core/src/test/java/com/bitworksmc/headdb/core/util/WebsiteLinksTest.java @@ -25,11 +25,21 @@ void buildsSearchUrlFromSupportedPluginFilters() { @Test void usesSingleIdAsWebsiteQueryWhenNoNameWasProvided() { assertEquals( - "https://headdb.net/heads?q=103838", + "https://headdb.net/heads?ids=103838", WebsiteLinks.searchUrl("https://headdb.net", "", null, List.of(), List.of(103838)) ); } + @Test + void preservesMultipleIdsAndAnyMatchMode() { + assertEquals( + "https://headdb.net/heads?tags=red%2Cblue&ids=12%2C34&match=any", + WebsiteLinks.searchUrl( + "https://headdb.net", "", null, List.of("red", "blue"), List.of(12, 34), true + ) + ); + } + @Test void rejectsInvalidConfiguredBaseUrl() { assertEquals("https://headdb.net/submit", WebsiteLinks.submissionUrl("javascript:alert(1)")); diff --git a/headdb-legacy/pom.xml b/headdb-legacy/pom.xml index 580cbde..02233ca 100644 --- a/headdb-legacy/pom.xml +++ b/headdb-legacy/pom.xml @@ -7,7 +7,7 @@ com.bitworksmc HeadDB - 6.0.4 + 6.1.0 headdb-legacy @@ -64,6 +64,11 @@ sqlite-jdbc 3.36.0.3 + + com.mysql + mysql-connector-j + ${mysql.version} + junit junit diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyDatabase.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyDatabase.java index 1eb4566..77ca5f1 100644 --- a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyDatabase.java +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyDatabase.java @@ -1,6 +1,9 @@ package com.bitworksmc.headdb.legacy; import com.bitworksmc.headdb.api.HeadDatabase; +import com.bitworksmc.headdb.api.catalog.CatalogStatus; +import com.bitworksmc.headdb.api.catalog.CatalogUpdate; +import com.bitworksmc.headdb.api.catalog.CatalogUpdateListener; import com.bitworksmc.headdb.api.model.Head; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; @@ -24,6 +27,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.Executor; +import java.util.concurrent.CopyOnWriteArrayList; final class LegacyDatabase implements HeadDatabase { private static final java.lang.reflect.Type HEAD_LIST = @@ -34,6 +38,12 @@ final class LegacyDatabase implements HeadDatabase { private final Object updateLock = new Object(); private volatile Snapshot snapshot; private volatile CompletableFuture> updateFuture; + private volatile long lastAttemptEpochMillis; + private volatile long lastSuccessfulUpdateEpochMillis; + private volatile String lastError; + private volatile String activeSource; + private final CopyOnWriteArrayList updateListeners = + new CopyOnWriteArrayList(); LegacyDatabase(String sourceUrl, Executor executor) { this(Collections.singletonList(sourceUrl), executor); @@ -56,7 +66,19 @@ public CompletableFuture> update() { if (updateFuture != null && !updateFuture.isDone()) { return updateFuture; } - updateFuture = CompletableFuture.supplyAsync(() -> load(), executor); + final Snapshot previous = snapshot; + lastAttemptEpochMillis = System.currentTimeMillis(); + updateFuture = CompletableFuture.supplyAsync(() -> load(), executor) + .whenComplete((heads, failure) -> { + if (failure != null) { + lastError = rootMessage(failure); + return; + } + lastError = null; + lastSuccessfulUpdateEpochMillis = System.currentTimeMillis(); + CatalogUpdate update = describeUpdate(previous, snapshot); + if (update.hasChanges()) notifyListeners(update); + }); return updateFuture; } } @@ -100,6 +122,7 @@ private List load(String sourceUrl) { Snapshot next = Snapshot.create(loaded); snapshot = next; + activeSource = sourceUrl; return next.heads; } catch (IOException | RuntimeException exception) { throw new CompletionException("Failed to update HeadDB from " + sourceUrl, exception); @@ -186,6 +209,62 @@ public Head getByTexture(String texture) { return current == null || texture == null ? null : current.byTexture.get(texture); } + @Override + public CatalogStatus getCatalogStatus() { + Snapshot current = snapshot; + return new CatalogStatus(current != null, -1, current == null ? 0 : current.heads.size(), + lastAttemptEpochMillis, lastSuccessfulUpdateEpochMillis, lastError, activeSource); + } + + @Override + public AutoCloseable addCatalogUpdateListener(final CatalogUpdateListener listener) { + if (listener == null) throw new NullPointerException("listener"); + updateListeners.add(listener); + return new AutoCloseable() { + @Override public void close() { updateListeners.remove(listener); } + }; + } + + private CatalogUpdate describeUpdate(Snapshot before, Snapshot after) { + Map previous = before == null + ? Collections.emptyMap() : before.byId; + Map current = after == null + ? Collections.emptyMap() : after.byId; + List added = new ArrayList(); + List updated = new ArrayList(); + List removed = new ArrayList(); + for (Map.Entry entry : current.entrySet()) { + Head old = previous.get(entry.getKey()); + if (old == null) added.add(entry.getKey()); + else if (!sameHead(old, entry.getValue())) updated.add(entry.getKey()); + } + for (Integer id : previous.keySet()) if (!current.containsKey(id)) removed.add(id); + Collections.sort(added); Collections.sort(updated); Collections.sort(removed); + return new CatalogUpdate(-1, -1, added, updated, removed, System.currentTimeMillis()); + } + + private void notifyListeners(CatalogUpdate update) { + for (CatalogUpdateListener listener : updateListeners) { + try { listener.onCatalogUpdate(update); } + catch (RuntimeException ignored) { } + } + } + + private static boolean sameHead(Head left, Head right) { + return left.getId() == right.getId() + && java.util.Objects.equals(left.getName(), right.getName()) + && java.util.Objects.equals(left.getTexture(), right.getTexture()) + && java.util.Objects.equals(left.getTextureUrl(), right.getTextureUrl()) + && java.util.Objects.equals(left.getCategory(), right.getCategory()) + && java.util.Objects.equals(left.getTags(), right.getTags()); + } + + private static String rootMessage(Throwable failure) { + Throwable current = failure; + while (current.getCause() != null) current = current.getCause(); + return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage(); + } + private static List immutableList(Collection source) { return Collections.unmodifiableList(new ArrayList(source)); } diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadAPI.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadAPI.java index 5b60b7a..f4cb056 100644 --- a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadAPI.java +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadAPI.java @@ -3,6 +3,11 @@ import com.bitworksmc.headdb.api.HeadAPI; import com.bitworksmc.headdb.api.HeadDatabase; import com.bitworksmc.headdb.api.model.Head; +import com.bitworksmc.headdb.api.catalog.CatalogStatus; +import com.bitworksmc.headdb.api.catalog.CatalogUpdateListener; +import com.bitworksmc.headdb.api.search.HeadSearch; +import com.bitworksmc.headdb.api.search.SearchPage; +import com.bitworksmc.headdb.api.search.SearchQuery; import org.bukkit.Bukkit; import org.bukkit.OfflinePlayer; import org.bukkit.inventory.ItemStack; @@ -120,6 +125,19 @@ public List findKnownCategories() { @Override public ExecutorService getExecutor() { return executor; } + @Override public CatalogStatus getCatalogStatus() { return database.getCatalogStatus(); } + + @Override + public CompletableFuture search(final SearchQuery query) { + Objects.requireNonNull(query, "query"); + return CompletableFuture.supplyAsync(() -> HeadSearch.search(heads(), query), executor); + } + + @Override + public AutoCloseable addCatalogUpdateListener(CatalogUpdateListener listener) { + return database.addCatalogUpdateListener(Objects.requireNonNull(listener, "listener")); + } + private List heads() { List result = database.getHeads(); return result == null ? Collections.emptyList() : result; diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadDB.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadDB.java index 4313050..deb1dde 100644 --- a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadDB.java +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyHeadDB.java @@ -2,6 +2,7 @@ import com.bitworksmc.headdb.api.HeadAPI; import com.bitworksmc.headdb.api.model.Head; +import com.bitworksmc.headdb.api.catalog.CatalogStatus; import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.command.Command; @@ -49,7 +50,8 @@ public void onEnable() { ensureResource("categories.yml"); ensureResource("sounds.yml"); ensureResource("messages/en.yml"); - playerStorage = new LegacyPlayerStorage(getDataFolder(), getLogger()); + ensureResource("messages/es.yml"); + playerStorage = new LegacyPlayerStorage(getDataFolder(), getLogger(), getConfig()); playerStorage.load(); messages = new LegacyMessages(getDataFolder()); sounds = new LegacySounds(getDataFolder(), playerStorage); @@ -175,14 +177,116 @@ public boolean onCommand(CommandSender sender, Command command, String label, St if (args[0].equalsIgnoreCase("open")) { return open(sender, args); } + if (args[0].equalsIgnoreCase("status")) return status(sender); + if (args[0].equalsIgnoreCase("sync") || args[0].equalsIgnoreCase("refresh")) return sync(sender); + if (args[0].equalsIgnoreCase("reload")) return reloadFeatures(sender); + if (args[0].equalsIgnoreCase("inspect") || args[0].equalsIgnoreCase("head")) return inspect(sender, args); + if (args[0].equalsIgnoreCase("recent") || args[0].equalsIgnoreCase("new")) return recent(sender, args); + if (args[0].equalsIgnoreCase("language") || args[0].equalsIgnoreCase("lang")) return language(sender, args); - sender.sendMessage(ChatColor.RED + "Usage: /" + label + " [info|search |give [player]|submit]"); + sender.sendMessage(ChatColor.RED + "Usage: /" + label + + " [info|status|search|recent|give|open|inspect|sounds|language|submit|sync|reload]"); + return true; + } + + private boolean status(CommandSender sender) { + if (!sender.hasPermission("headdb.command.status")) return denied(sender); + CatalogStatus status = api.getCatalogStatus(); + sender.sendMessage(ChatColor.GOLD + "HeadDB catalog" + ChatColor.GRAY + " โ€” " + + (status.isReady() ? ChatColor.GREEN + "ready" : ChatColor.YELLOW + "loading")); + sender.sendMessage(ChatColor.GRAY + "Heads: " + ChatColor.WHITE + status.getHeadCount()); + sender.sendMessage(ChatColor.GRAY + "Source: " + ChatColor.WHITE + + (status.getSource() == null ? "not selected" : status.getSource())); + if (status.getLastError() != null) sender.sendMessage(ChatColor.RED + "Last error: " + status.getLastError()); + return true; + } + + private boolean sync(final CommandSender sender) { + if (!sender.hasPermission("headdb.command.sync")) return denied(sender); + sender.sendMessage(message(sender, "command.sync.start", "Synchronizing the HeadDB catalog...")); + database.update().whenComplete((heads, failure) -> Bukkit.getScheduler().runTask(this, () -> { + if (failure != null) sender.sendMessage(message(sender, "command.sync.failed", "Catalog sync failed: {error}", "error", rootMessage(failure))); + else sender.sendMessage(message(sender, "command.sync.success", "Catalog synchronized. {amount} heads are available.", "amount", String.valueOf(heads.size()))); + })); + return true; + } + + private boolean reloadFeatures(CommandSender sender) { + if (!sender.hasPermission("headdb.command.reload")) return denied(sender); + reloadConfig(); + messages.reload(); + sounds = new LegacySounds(getDataFolder(), playerStorage); + economy = new LegacyEconomy(getConfig()); + LegacyItemFactory.configure(getConfig(), economy.isEnabled()); + menus = new LegacyMenuManager(this, database, api, playerStorage, messages, sounds, economy); + sender.sendMessage(message(sender, "command.reload.success", "Reloaded runtime configuration. Database and storage settings require a restart.")); + return true; + } + + private boolean inspect(CommandSender sender, String[] args) { + if (!sender.hasPermission("headdb.command.inspect")) return denied(sender); + Head head = null; + if (args.length > 1) { + String identifier = join(args, 1); + String numeric = identifier.toLowerCase(Locale.ROOT).startsWith("id:") ? identifier.substring(3) : identifier; + try { head = database.getById(Integer.parseInt(numeric)); } catch (NumberFormatException ignored) { } + if (head == null) head = database.getByTexture(identifier); + if (head == null && database.getHeads() != null) for (Head candidate : database.getHeads()) { + if (candidate.getName().equalsIgnoreCase(identifier)) { head = candidate; break; } + } + } else if (sender instanceof Player) { + Integer id = LegacyItemFactory.getHeadId(((Player) sender).getItemInHand()); + if (id != null) head = database.getById(id); + } + if (head == null) { + sender.sendMessage(message(sender, "command.inspect.notHead", "Hold a HeadDB head or provide an ID, name, or texture.")); + return true; + } + sender.sendMessage(ChatColor.GOLD + head.getName() + " #" + head.getId()); + sender.sendMessage(ChatColor.GRAY + "Category: " + ChatColor.WHITE + head.getCategory()); + sender.sendMessage(ChatColor.GRAY + "Tags: " + ChatColor.WHITE + join(head.getTags(), ", ")); + if (sender instanceof Player) { + String url = LegacyWebsiteLinks.headUrl(getConfig().getString("website.url", "https://headdb.net"), head.getId()); + sendWebsiteLink((Player) sender, ChatColor.AQUA + "View, copy, or report on headdb.net", url, "Open " + url); + } + return true; + } + + private boolean recent(CommandSender sender, String[] args) { + if (!sender.hasPermission("headdb.command.recent")) return denied(sender); + if (!(sender instanceof Player)) { sender.sendMessage(message(sender, "noConsole", "Only players can use this command.")); return true; } + int amount = 100; + if (args.length > 1) try { amount = Math.min(500, Math.max(1, Integer.parseInt(args[1]))); } + catch (NumberFormatException ignored) { sender.sendMessage(message(sender, "invalidNumber", "Invalid number: {number}", "number", args[1])); return true; } + List all = database.getHeads(); + List recent = all == null ? new ArrayList() : new ArrayList(all); + Collections.sort(recent, (left, right) -> Integer.compare(right.getId(), left.getId())); + if (recent.size() > amount) recent = new ArrayList(recent.subList(0, amount)); + menus.openSearch((Player) sender, message(sender, "menu.recent.name", "HeadDB ยป Recently added"), recent); + return true; + } + + private boolean language(CommandSender sender, String[] args) { + if (!sender.hasPermission("headdb.command.language")) return denied(sender); + if (!(sender instanceof Player)) { sender.sendMessage(message(sender, "noConsole", "Only players can use this command.")); return true; } + LegacyPlayerData data = playerStorage.get(((Player) sender).getUniqueId()); + if (args.length < 2) { + sender.sendMessage(message(sender, "command.language.available", "Available HeadDB languages: {languages}", "languages", join(new ArrayList(messages.availableLanguages()), ", "))); + return true; + } + String requested = args[1].toLowerCase(Locale.ROOT); + if (!messages.availableLanguages().contains(requested)) { + sender.sendMessage(message(sender, "command.language.invalid", "Unknown language: {language}", "language", requested)); + return true; + } + data.setLanguage(requested); + sender.sendMessage(messages.getForLanguage(requested, "command.language.changed", "Your HeadDB language is now {language}.", "language", requested)); return true; } private boolean submit(CommandSender sender) { if (!(sender instanceof Player)) { - sender.sendMessage(messages.get("noConsole", "Only players can use this command.")); + sender.sendMessage(message(sender, "noConsole", "Only players can use this command.")); return true; } if (!sender.hasPermission("headdb.command.submit")) return denied(sender); @@ -190,7 +294,7 @@ private boolean submit(CommandSender sender) { String url = LegacyWebsiteLinks.submissionUrl(getConfig().getString("website.url", "https://headdb.net")); sendWebsiteLink( (Player) sender, - messages.get("command.submit.link", "Have a head to share? Submit it on headdb.net for review."), + message(sender, "command.submit.link", "Have a head to share? Submit it on headdb.net for review."), url, "Open " + url ); @@ -199,12 +303,12 @@ private boolean submit(CommandSender sender) { private boolean open(CommandSender sender, String[] args) { if (!(sender instanceof Player)) { - sender.sendMessage(messages.get("noConsole", "Only players can use this command.")); + sender.sendMessage(message(sender, "noConsole", "Only players can use this command.")); return true; } if (!sender.hasPermission("headdb.command.open")) return denied(sender); if (!database.isReady()) { - sender.sendMessage(messages.get("databaseLoading", "The head database is still loading.")); + sender.sendMessage(message(sender, "databaseLoading", "The head database is still loading.")); return true; } Player player = (Player) sender; @@ -215,14 +319,14 @@ private boolean open(CommandSender sender, String[] args) { private boolean toggleSounds(CommandSender sender) { if (!(sender instanceof Player)) { - sender.sendMessage(messages.get("noConsole", "Only players can use this command.")); + sender.sendMessage(message(sender, "noConsole", "Only players can use this command.")); return true; } if (!sender.hasPermission("headdb.command.sounds")) return denied(sender); Player player = (Player) sender; LegacyPlayerData data = playerStorage.get(player.getUniqueId()); data.setSoundsEnabled(!data.isSoundsEnabled()); - player.sendMessage(messages.get(data.isSoundsEnabled() ? "command.sounds.enabled" : "command.sounds.disabled", + player.sendMessage(message(sender, data.isSoundsEnabled() ? "command.sounds.enabled" : "command.sounds.disabled", data.isSoundsEnabled() ? "HeadDB interface sounds enabled." : "HeadDB interface sounds disabled.")); if (data.isSoundsEnabled()) sounds.play(player, "success"); return true; @@ -251,7 +355,7 @@ private boolean search(final CommandSender sender, String[] args) { int limit = Math.max(1, getConfig().getInt("search-limit", 20)); if (sender instanceof Player) { menus.openSearch((Player) sender, query, matches); - sender.sendMessage(messages.get("command.search.found", "Found {amount} heads!", + sender.sendMessage(message(sender, "command.search.found", "Found {amount} heads!", "amount", String.valueOf(matches.size()))); sendSearchWebsiteHint((Player) sender, args); return; @@ -277,15 +381,14 @@ private List filterSearch(String[] args, CommandSender sender) { Set ids = new HashSet(); List names = new ArrayList(); boolean any = false; - for (int i = 1; i < args.length; i++) { - String token = args[i]; + for (String token : LegacyWebsiteLinks.combineQuotedArguments(args, 1)) { String lower = token.toLowerCase(Locale.ROOT); if (lower.equals("--any")) any = true; else if (lower.startsWith("category:")) category = lower.substring(9); - else if (lower.startsWith("tags:")) { - for (String tag : lower.substring(5).split(",")) if (!tag.trim().isEmpty()) tags.add(tag.trim()); - } else if (lower.startsWith("ids:")) { - for (String id : lower.substring(4).split(",")) { + else if (lower.startsWith("tag:") || lower.startsWith("tags:")) { + for (String tag : lower.substring(lower.indexOf(':') + 1).split(",")) if (!tag.trim().isEmpty()) tags.add(tag.trim()); + } else if (lower.startsWith("id:") || lower.startsWith("ids:")) { + for (String id : lower.substring(lower.indexOf(':') + 1).split(",")) { try { ids.add(Integer.parseInt(id.trim())); } catch (NumberFormatException ignored) { } } } else names.add(token); @@ -296,7 +399,8 @@ else if (lower.startsWith("tags:")) { if (all == null) return result; for (Head head : all) { boolean nameMatch = !name.isEmpty() && head.getName().toLowerCase(Locale.ROOT).contains(name); - boolean categoryMatch = category != null && head.getCategory().equalsIgnoreCase(category); + boolean categoryMatch = category != null && (head.getCategory().equalsIgnoreCase(category) + || slugify(head.getCategory()).equals(slugify(category))); boolean idMatch = !ids.isEmpty() && ids.contains(head.getId()); Set headTags = new HashSet(); for (String tag : head.getTags()) headTags.add(tag.toLowerCase(Locale.ROOT)); @@ -317,29 +421,49 @@ private boolean give(CommandSender sender, String[] args) { if (!sender.hasPermission("headdb.command.give")) { return denied(sender); } - if (args.length < 4) { - sender.sendMessage(ChatColor.RED + "Usage: /hdb give "); + if (args.length < 2) { + sender.sendMessage(ChatColor.RED + "Usage: /hdb give | | "); return true; } - Player target = Bukkit.getPlayer(args[1]); + Player target; + int amount = 1; + int identifierStart; + String amountArgument = "1"; + boolean targetsPlayer = args.length >= 4 && !isInteger(args[1]) && isInteger(args[2]); + if (targetsPlayer) { + target = Bukkit.getPlayer(args[1]); + amountArgument = args[2]; + identifierStart = 3; + } else { + if (!(sender instanceof Player)) { + sender.sendMessage(ChatColor.RED + "Console usage: /hdb give "); + return true; + } + target = (Player) sender; + if (args.length >= 3 && isInteger(args[1])) { + amountArgument = args[1]; + identifierStart = 2; + } else { + identifierStart = 1; + } + } if (target == null) { - sender.sendMessage(messages.get("invalidTarget", "Could not find player: {target}", "target", args[1])); + sender.sendMessage(message(sender, "invalidTarget", "Could not find player: {target}", "target", args[1])); return true; } - int amount; try { - amount = Integer.parseInt(args[2]); + amount = Integer.parseInt(amountArgument); } catch (NumberFormatException exception) { - sender.sendMessage(messages.get("invalidNumber", "Invalid number: {number}", "number", args[2])); + sender.sendMessage(message(sender, "invalidNumber", "Invalid number: {number}", "number", amountArgument)); return true; } int maximum = Math.max(1, getConfig().getInt("maxBuyAmount", 2304)); if (amount < 1 || amount > maximum) { - sender.sendMessage(messages.get("command.give.invalidAmount", "Amount must be between 1 and {max}", + sender.sendMessage(message(sender, "command.give.invalidAmount", "Amount must be between 1 and {max}", "max", String.valueOf(maximum))); return true; } - String identifier = join(args, 3); + String identifier = join(args, identifierStart); Head head = null; if (identifier.toLowerCase(Locale.ROOT).startsWith("id:")) { try { head = database.getById(Integer.parseInt(identifier.substring(3))); } catch (NumberFormatException ignored) { } @@ -352,7 +476,7 @@ private boolean give(CommandSender sender, String[] args) { } } if (head == null) { - sender.sendMessage(messages.get("command.give.invalidId", "Unknown head: {id}", "id", identifier)); + sender.sendMessage(message(sender, "command.give.invalidId", "Unknown head: {id}", "id", identifier)); return true; } int remaining = amount; @@ -365,17 +489,25 @@ private boolean give(CommandSender sender, String[] args) { for (ItemStack leftover : leftovers.values()) target.getWorld().dropItemNaturally(target.getLocation(), leftover); remaining -= stackSize; } - sender.sendMessage(messages.get("command.give.success", "Gave {amount}x {name} to {target}", + sender.sendMessage(message(sender, "command.give.success", "Gave {amount}x {name} to {target}", "amount", String.valueOf(amount), "name", head.getName(), "target", target.getName())); if (sender instanceof Player) sounds.play((Player) sender, "success"); return true; } private boolean denied(CommandSender sender) { - sender.sendMessage(ChatColor.RED + "You do not have permission to use that command."); + sender.sendMessage(message(sender, "noPermission", "You do not have permission to use that command.")); return true; } + private String message(CommandSender sender, String key, String fallback, String... replacements) { + if (sender instanceof Player) { + String language = playerStorage.get(((Player) sender).getUniqueId()).getLanguage(); + return messages.getForLanguage(language, key, fallback, replacements); + } + return messages.get(key, fallback, replacements); + } + private void sendSearchWebsiteHint(Player player, String[] args) { if (!getConfig().getBoolean("website.searchHint.enabled", true)) { return; @@ -386,7 +518,7 @@ private void sendSearchWebsiteHint(Player player, String[] args) { ); sendWebsiteLink( player, - messages.get("command.search.website", + message(player, "command.search.website", "Want to refine this search faster? Open it on headdb.net to filter results and copy ready-to-use commands."), url, "Open this search on headdb.net" @@ -410,7 +542,7 @@ private void sendWebsiteLink(Player player, String text, String url, String hove @Override public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { if (args.length == 1) { - return prefix(Arrays.asList("open", "info", "search", "give", "sounds", "submit"), args[0]); + return prefix(Arrays.asList("open", "info", "search", "give", "sounds", "submit", "status", "sync", "reload", "inspect", "recent", "language"), args[0]); } if (args.length == 2 && args[0].equalsIgnoreCase("give")) { List players = new ArrayList(); @@ -423,7 +555,10 @@ public List onTabComplete(CommandSender sender, Command command, String return prefix(Arrays.asList("1", "32", "64"), args[2]); } if (args.length >= 2 && args[0].equalsIgnoreCase("search")) { - return prefix(Arrays.asList("tags:", "category:", "ids:", "--any"), args[args.length - 1]); + return prefix(Arrays.asList("tag:", "tags:", "category:", "id:", "ids:", "--any"), args[args.length - 1]); + } + if (args.length == 2 && (args[0].equalsIgnoreCase("language") || args[0].equalsIgnoreCase("lang"))) { + return prefix(new ArrayList(messages.availableLanguages()), args[1]); } return Collections.emptyList(); } @@ -450,6 +585,33 @@ private static String join(String[] values, int start) { return result.toString(); } + private static String join(List values, String separator) { + StringBuilder result = new StringBuilder(); + for (String value : values) { + if (result.length() > 0) result.append(separator); + result.append(value); + } + return result.toString(); + } + + private static boolean isInteger(String value) { + if (value == null || value.length() == 0) return false; + int start = value.charAt(0) == '-' ? 1 : 0; + if (start == value.length()) return false; + for (int i = start; i < value.length(); i++) { + if (!Character.isDigit(value.charAt(i))) return false; + } + return true; + } + + private static String slugify(String value) { + if (value == null) return ""; + return value.trim().toLowerCase(Locale.ROOT) + .replace("&", " and ") + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+|-+$", ""); + } + private static String rootMessage(Throwable failure) { Throwable current = failure; while (current.getCause() != null) { diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java index 63542d0..31fd866 100644 --- a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyItemFactory.java @@ -95,6 +95,21 @@ static ItemStack prepareForGive(ItemStack source) { return item; } + static Integer getHeadId(ItemStack item) { + if (item == null || !item.hasItemMeta()) return null; + ItemMeta meta = item.getItemMeta(); + if (meta == null || !meta.hasLore()) return null; + for (String line : meta.getLore()) { + String plain = ChatColor.stripColor(line); + if (plain == null) continue; + int marker = plain.toLowerCase().indexOf("id:"); + if (marker < 0) continue; + String value = plain.substring(marker + 3).trim().split("\\s+")[0]; + try { return Integer.valueOf(value); } catch (NumberFormatException ignored) { } + } + return null; + } + /** * PLAYER_HEAD was introduced by the 1.13 material flattening. Resolve both * names dynamically so this class never links a missing enum constant. diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMenuManager.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMenuManager.java index 33e460f..ba364d0 100644 --- a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMenuManager.java +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMenuManager.java @@ -50,7 +50,7 @@ final class LegacyMenuManager implements Listener { void openMain(Player player) { MenuHolder holder = new MenuHolder("main", "", 0, Collections.emptyList()); - Inventory inventory = create(holder, 54, messages.get("menu.main.name", "HeadDB")); + Inventory inventory = create(holder, 54, message(player, "menu.main.name", "HeadDB")); int slot = 0; for (String category : api.findKnownCategories()) { if (slot >= 45) break; @@ -93,7 +93,7 @@ void openFavorites(Player player, int page) { int maxPage = maxPage(total); page = Math.max(0, Math.min(page, maxPage)); MenuHolder holder = new MenuHolder("favorites", "", page, heads); - Inventory inventory = create(holder, 54, messages.get("menu.favorites.name", "HeadDB ยป Favorites")); + Inventory inventory = create(holder, 54, message(player, "menu.favorites.name", "HeadDB ยป Favorites")); int start = page * PAGE_SIZE; for (int index = start; index < Math.min(start + PAGE_SIZE, total); index++) { int slot = index - start; @@ -110,14 +110,14 @@ void openFavorites(Player player, int page) { } } } - addControls(inventory, holder, page, maxPage); + addControls(player, inventory, holder, page, maxPage); player.openInventory(inventory); } void openCustomCategories(Player player) { if (!allowed(player, "custom")) { deny(player); return; } MenuHolder holder = new MenuHolder("custom", "", 0, Collections.emptyList()); - Inventory inventory = create(holder, 54, messages.get("menu.customCategories.name", "HeadDB ยป More Categories")); + Inventory inventory = create(holder, 54, message(player, "menu.customCategories.name", "HeadDB ยป More Categories")); YamlConfiguration config = YamlConfiguration.loadConfiguration(new File(plugin.getDataFolder(), "categories.yml")); int slot = 0; for (String key : config.getKeys(false)) { @@ -129,7 +129,7 @@ void openCustomCategories(Player player) { inventory.setItem(slot, icon); holder.actions.put(slot++, "custom-category:" + key); } - addBack(inventory, holder); + addBack(player, inventory, holder); player.openInventory(inventory); } @@ -151,7 +151,7 @@ private void openLocal(Player player, int page) { int maxPage = maxPage(players.length); page = Math.max(0, Math.min(page, maxPage)); MenuHolder holder = new MenuHolder("local", "", page, Collections.emptyList()); - Inventory inventory = create(holder, 54, messages.get("menu.local.name", "HeadDB ยป Local Heads")); + Inventory inventory = create(holder, 54, message(player, "menu.local.name", "HeadDB ยป Local Heads")); int start = page * PAGE_SIZE; for (int i = start; i < Math.min(start + PAGE_SIZE, players.length); i++) { OfflinePlayer offline = players[i]; @@ -160,7 +160,7 @@ private void openLocal(Player player, int page) { inventory.setItem(i - start, icon); holder.actions.put(i - start, "local-head:" + offline.getUniqueId()); } - addControls(inventory, holder, page, maxPage); + addControls(player, inventory, holder, page, maxPage); player.openInventory(inventory); } @@ -185,7 +185,7 @@ private void openHeads(Player player, String type, String key, List heads, inventory.setItem(i - start, icon); holder.actions.put(i - start, "head:" + head.getId()); } - addControls(inventory, holder, page, maxPage); + addControls(player, inventory, holder, page, maxPage); player.openInventory(inventory); } @@ -235,7 +235,7 @@ private void clickHead(Player player, int id, boolean favoriteClick, MenuHolder private void openPurchase(Player player, Head head) { MenuHolder holder = new MenuHolder("purchase", String.valueOf(head.getId()), 0, Collections.emptyList()); - Inventory inventory = create(holder, 27, messages.get("menu.purchase.name", "HeadDB ยป Purchase", + Inventory inventory = create(holder, 27, message(player, "menu.purchase.name", "HeadDB ยป Purchase", "name", head.getName())); int[] amounts = {1, 8, 16, 32, 64}; int[] slots = {10, 11, 13, 15, 16}; @@ -252,7 +252,7 @@ private void openPurchase(Player player, Head head) { inventory.setItem(slots[i], icon); holder.actions.put(slots[i], "purchase:" + head.getId() + ":" + amounts[i]); } - addBack(inventory, holder); + addBack(player, inventory, holder); player.openInventory(inventory); } @@ -263,7 +263,7 @@ private void purchase(Player player, String action) { if (head == null) return; double total = economy.price(head) * amount; if (!economy.purchase(player, total)) { - player.sendMessage(messages.get("purchase.invalidFunds", "You do not have enough money.")); + player.sendMessage(message(player, "purchase.invalidFunds", "You do not have enough money.")); sounds.play(player, "purchase.failed"); return; } @@ -276,7 +276,7 @@ private void purchase(Player player, String action) { remaining -= stack; } player.closeInventory(); - player.sendMessage(messages.get("purchase.success", "Bought {amount}x {name} for {cost}", + player.sendMessage(message(player, "purchase.success", "Bought {amount}x {name} for {cost}", "amount", String.valueOf(amount), "name", head.getName(), "cost", String.valueOf(total))); sounds.play(player, "purchase.completed"); } @@ -309,22 +309,24 @@ private void give(Player player, ItemStack item) { for (ItemStack leftover : leftovers.values()) player.getWorld().dropItemNaturally(player.getLocation(), leftover); } - private void addControls(Inventory inventory, MenuHolder holder, int page, int maxPage) { + private void addControls(Player player, Inventory inventory, MenuHolder holder, int page, int maxPage) { if (page > 0) { - inventory.setItem(45, item(material("ARROW", "ARROW"), ChatColor.GOLD + "Previous")); + inventory.setItem(45, item(material("ARROW", "ARROW"), message(player, "menu.controls.back.name", ChatColor.GOLD + "Previous"))); holder.actions.put(45, "back"); } - inventory.setItem(49, item(material("PAPER", "PAPER"), ChatColor.GOLD + "Page " + (page + 1) + "/" + (maxPage + 1))); + String pageName = message(player, "menu.controls.info.name", ChatColor.GOLD + "Page ${{CURRENT}}/${{MAX}}") + .replace("${{CURRENT}}", String.valueOf(page + 1)).replace("${{MAX}}", String.valueOf(maxPage + 1)); + inventory.setItem(49, item(material("PAPER", "PAPER"), pageName)); holder.actions.put(49, "main"); if (page < maxPage) { - inventory.setItem(53, item(material("ARROW", "ARROW"), ChatColor.GOLD + "Next")); + inventory.setItem(53, item(material("ARROW", "ARROW"), message(player, "menu.controls.next.name", ChatColor.GOLD + "Next"))); holder.actions.put(53, "next"); } } - private void addBack(Inventory inventory, MenuHolder holder) { + private void addBack(Player player, Inventory inventory, MenuHolder holder) { int slot = inventory.getSize() - 5; - inventory.setItem(slot, item(material("ARROW", "ARROW"), ChatColor.GOLD + "Back")); + inventory.setItem(slot, item(material("ARROW", "ARROW"), message(player, "menu.controls.back.name", ChatColor.GOLD + "Back"))); holder.actions.put(slot, "main"); } @@ -372,10 +374,14 @@ private boolean allowed(Player player, String category) { || player.hasPermission("headdb.category." + normalize(category)); } private void deny(Player player) { - player.sendMessage(messages.get("noPermission", "No permission!")); + player.sendMessage(message(player, "noPermission", "No permission!")); sounds.play(player, "noPermission"); } + private String message(Player player, String key, String fallback, String... replacements) { + return messages.getForLanguage(storage.get(player.getUniqueId()).getLanguage(), key, fallback, replacements); + } + private static final class MenuHolder implements InventoryHolder { private final String type; private final String key; diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMessages.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMessages.java index 49fe3f2..bd559e7 100644 --- a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMessages.java +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyMessages.java @@ -6,15 +6,26 @@ import java.io.File; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; +import java.util.Collections; +import java.util.Locale; final class LegacyMessages { - private final YamlConfiguration messages; + private final File messagesDirectory; + private final Map languages = new LinkedHashMap(); LegacyMessages(File dataFolder) { - messages = YamlConfiguration.loadConfiguration(new File(dataFolder, "messages/en.yml")); + messagesDirectory = new File(dataFolder, "messages"); + reload(); } String get(String key, String fallback, String... replacements) { + return getForLanguage("en", key, fallback, replacements); + } + + String getForLanguage(String language, String key, String fallback, String... replacements) { + YamlConfiguration messages = languages.get(language == null ? "en" : language.toLowerCase(Locale.ROOT)); + if (messages == null) messages = languages.get("en"); String value = messages.getString(key, fallback); for (int i = 0; i + 1 < replacements.length; i += 2) { value = value.replace("{" + replacements[i] + "}", replacements[i + 1]); @@ -22,6 +33,23 @@ String get(String key, String fallback, String... replacements) { return color(value); } + void reload() { + languages.clear(); + File[] files = messagesDirectory.listFiles((directory, name) -> name.toLowerCase(Locale.ROOT).endsWith(".yml")); + if (files != null) for (File file : files) { + String name = file.getName(); + languages.put(name.substring(0, name.length() - 4).toLowerCase(Locale.ROOT), + YamlConfiguration.loadConfiguration(file)); + } + if (!languages.containsKey("en")) { + languages.put("en", YamlConfiguration.loadConfiguration(new File(messagesDirectory, "en.yml"))); + } + } + + Set availableLanguages() { + return Collections.unmodifiableSet(languages.keySet()); + } + static String color(String value) { if (value == null) return ""; Map colors = new LinkedHashMap(); diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyPlayerStorage.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyPlayerStorage.java index 8d50252..cb33f4b 100644 --- a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyPlayerStorage.java +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyPlayerStorage.java @@ -1,6 +1,7 @@ package com.bitworksmc.headdb.legacy; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; import java.io.File; @@ -21,27 +22,48 @@ final class LegacyPlayerStorage { private final File file; private final String databaseUrl; + private final String localDatabaseUrl; private final String oldDatabaseUrl; + private final String username; + private final String password; + private final boolean mysql; + private final String tableName; private final Logger logger; private final Map players = new ConcurrentHashMap(); LegacyPlayerStorage(File dataFolder, Logger logger) { + this(dataFolder, logger, null); + } + + LegacyPlayerStorage(File dataFolder, Logger logger, FileConfiguration config) { File directory = new File(dataFolder, "data"); if (!directory.exists() && !directory.mkdirs()) { throw new IllegalStateException("Could not create " + directory); } this.file = new File(directory, "legacy-players.yml"); - this.databaseUrl = "jdbc:sqlite:" + new File(directory, "data.db").getAbsolutePath(); + this.localDatabaseUrl = "jdbc:sqlite:" + new File(directory, "data.db").getAbsolutePath(); + boolean requestedMysql = config != null && "MYSQL".equalsIgnoreCase(config.getString("storage.player.backend", "SQLITE")); + String configuredMysqlUrl = config == null ? "" : config.getString("storage.player.mysql.url", "jdbc:mysql://127.0.0.1:3306/headdb"); + this.mysql = requestedMysql && configuredMysqlUrl != null && configuredMysqlUrl.startsWith("jdbc:mysql:"); + if (requestedMysql && !mysql) logger.warning("storage.player.mysql.url must begin with 'jdbc:mysql:'; using SQLite player storage"); + this.databaseUrl = mysql + ? configuredMysqlUrl + : localDatabaseUrl; + this.username = mysql ? config.getString("storage.player.mysql.username", "headdb") : null; + this.password = mysql ? config.getString("storage.player.mysql.password", "") : null; + this.tableName = mysql ? "headdb_players" : "players"; this.oldDatabaseUrl = "jdbc:sqlite:" + new File(dataFolder, "data.db").getAbsolutePath(); this.logger = logger; try { - Class.forName("org.sqlite.JDBC"); - Connection connection = DriverManager.getConnection(databaseUrl); + Class.forName(mysql ? "com.mysql.cj.jdbc.Driver" : "org.sqlite.JDBC"); + Connection connection = getConnection(); try { - connection.createStatement().execute("CREATE TABLE IF NOT EXISTS players (uuid TEXT PRIMARY KEY, language TEXT, favorites TEXT, local_favorites TEXT, sound_enabled INTEGER)"); + connection.createStatement().execute(mysql + ? "CREATE TABLE IF NOT EXISTS headdb_players (uuid VARCHAR(36) PRIMARY KEY, language VARCHAR(32), favorites TEXT, local_favorites TEXT, sound_enabled BOOLEAN)" + : "CREATE TABLE IF NOT EXISTS players (uuid TEXT PRIMARY KEY, language TEXT, favorites TEXT, local_favorites TEXT, sound_enabled INTEGER)"); } finally { connection.close(); } } catch (Exception exception) { - logger.warning("SQLite initialization failed; YAML fallback will be used: " + exception.getMessage()); + logger.warning("Player storage initialization failed; YAML fallback will be used: " + exception.getMessage()); } } @@ -56,10 +78,13 @@ LegacyPlayerData get(UUID id) { synchronized void load() { players.clear(); if (loadSqlite()) { + if (players.isEmpty() && mysql && new File(localDatabaseUrl.substring("jdbc:sqlite:".length())).isFile()) { + importCurrentDatabase(); + } if (players.isEmpty() && new File(oldDatabaseUrl.substring("jdbc:sqlite:".length())).isFile()) { importOldDatabase(); - if (!players.isEmpty()) saveSqlite(); } + if (!players.isEmpty()) saveSqlite(); return; } if (!file.isFile()) return; @@ -101,10 +126,10 @@ synchronized void save() { private boolean loadSqlite() { try { - Connection connection = DriverManager.getConnection(databaseUrl); + Connection connection = getConnection(); try { Statement statement = connection.createStatement(); - ResultSet rows = statement.executeQuery("SELECT * FROM players"); + ResultSet rows = statement.executeQuery("SELECT * FROM " + tableName); while (rows.next()) { UUID id = UUID.fromString(rows.getString("uuid")); LegacyPlayerData data = new LegacyPlayerData(id); @@ -119,18 +144,19 @@ private boolean loadSqlite() { } finally { connection.close(); } return true; } catch (Exception exception) { - logger.warning("Could not load SQLite player data: " + exception.getMessage()); + logger.warning("Could not load player data: " + exception.getMessage()); return false; } } private boolean saveSqlite() { try { - Connection connection = DriverManager.getConnection(databaseUrl); + Connection connection = getConnection(); try { connection.setAutoCommit(false); - PreparedStatement statement = connection.prepareStatement( - "INSERT OR REPLACE INTO players (uuid, language, favorites, local_favorites, sound_enabled) VALUES (?, ?, ?, ?, ?)"); + PreparedStatement statement = connection.prepareStatement(mysql + ? "INSERT INTO headdb_players (uuid, language, favorites, local_favorites, sound_enabled) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE language=VALUES(language), favorites=VALUES(favorites), local_favorites=VALUES(local_favorites), sound_enabled=VALUES(sound_enabled)" + : "INSERT OR REPLACE INTO players (uuid, language, favorites, local_favorites, sound_enabled) VALUES (?, ?, ?, ?, ?)"); for (LegacyPlayerData data : players.values()) { statement.setString(1, data.getUniqueId().toString()); statement.setString(2, data.getLanguage()); @@ -143,11 +169,35 @@ private boolean saveSqlite() { } finally { connection.close(); } return true; } catch (SQLException exception) { - logger.warning("Could not save SQLite player data: " + exception.getMessage()); + logger.warning("Could not save player data: " + exception.getMessage()); return false; } } + private void importCurrentDatabase() { + try { + Class.forName("org.sqlite.JDBC"); + Connection connection = DriverManager.getConnection(localDatabaseUrl); + try { + ResultSet rows = connection.createStatement().executeQuery("SELECT * FROM players"); + while (rows.next()) { + UUID id = UUID.fromString(rows.getString("uuid")); + LegacyPlayerData data = new LegacyPlayerData(id); + data.setLanguage(rows.getString("language")); + int sound = rows.getInt("sound_enabled"); + data.setSoundsEnabled(rows.wasNull() || sound == 1); + data.setFavorites(parseIntegers(rows.getString("favorites"))); + data.setLocalFavorites(parseStrings(rows.getString("local_favorites"))); + players.put(id, data); + } + rows.close(); + } finally { connection.close(); } + logger.info("Imported " + players.size() + " player records from local SQLite into MySQL."); + } catch (Exception exception) { + logger.warning("Could not import local SQLite player data: " + exception.getMessage()); + } + } + private void importOldDatabase() { try { Connection connection = DriverManager.getConnection(oldDatabaseUrl); @@ -193,4 +243,9 @@ private static String join(List values) { } return result.toString(); } + + private Connection getConnection() throws SQLException { + return mysql ? DriverManager.getConnection(databaseUrl, username, password) + : DriverManager.getConnection(databaseUrl); + } } diff --git a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinks.java b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinks.java index fc90929..f1d2370 100644 --- a/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinks.java +++ b/headdb-legacy/src/main/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinks.java @@ -19,16 +19,22 @@ static String submissionUrl(String configuredBaseUrl) { return normalizeBaseUrl(configuredBaseUrl) + "/submit"; } + static String headUrl(String configuredBaseUrl, int headId) { + return normalizeBaseUrl(configuredBaseUrl) + "/heads/" + headId; + } + static String searchUrl(String configuredBaseUrl, String[] args) { String category = null; Set tags = new LinkedHashSet(); List ids = new ArrayList(); List names = new ArrayList(); + boolean matchAny = false; - for (int i = 1; i < args.length; i++) { - String token = args[i]; + List logicalArguments = combineQuotedArguments(args, 1); + for (String token : logicalArguments) { String lower = token.toLowerCase(Locale.ROOT); if (lower.equals("--any")) { + matchAny = true; continue; } if (lower.startsWith("category:")) { @@ -58,8 +64,6 @@ static String searchUrl(String configuredBaseUrl, String[] args) { String name = join(names); if (!name.isEmpty()) { parameters.add(parameter("q", name)); - } else if (ids.size() == 1) { - parameters.add(parameter("q", String.valueOf(ids.get(0)))); } String categorySlug = slugify(category); @@ -78,6 +82,15 @@ static String searchUrl(String configuredBaseUrl, String[] args) { parameters.add(parameter("tags", join(tagSlugs, ","))); } + if (!ids.isEmpty()) { + List idValues = new ArrayList(); + for (Integer id : ids) { + if (id != null && id > 0) idValues.add(String.valueOf(id)); + } + if (!idValues.isEmpty()) parameters.add(parameter("ids", join(idValues, ","))); + } + if (matchAny) parameters.add(parameter("match", "any")); + String url = normalizeBaseUrl(configuredBaseUrl) + "/heads"; return parameters.isEmpty() ? url : url + "?" + join(parameters, "&"); } @@ -140,4 +153,33 @@ private static String join(List values, String separator) { } return result.toString(); } + + static List combineQuotedArguments(String[] raw, int start) { + List result = new ArrayList(); + StringBuilder pending = new StringBuilder(); + boolean quoted = false; + for (int i = start; i < raw.length; i++) { + String token = raw[i]; + if (!quoted) { + int quote = token.indexOf('"'); + if (quote < 0) { + result.add(token); + continue; + } + quoted = true; + pending.append(token.substring(0, quote)).append(token.substring(quote + 1)); + } else { + pending.append(' ').append(token); + } + int end = pending.indexOf("\""); + if (end >= 0) { + pending.deleteCharAt(end); + result.add(pending.toString()); + pending.setLength(0); + quoted = false; + } + } + if (pending.length() > 0) result.add(pending.toString()); + return result; + } } diff --git a/headdb-legacy/src/main/resources/plugin.yml b/headdb-legacy/src/main/resources/plugin.yml index 6223254..5871be6 100644 --- a/headdb-legacy/src/main/resources/plugin.yml +++ b/headdb-legacy/src/main/resources/plugin.yml @@ -6,7 +6,7 @@ version: ${project.version} commands: headdb: - usage: /headdb [info|search|give|sounds|submit] + usage: /headdb [info|open|search|give|sounds|submit|status|sync|reload|inspect|recent|language] description: Search and give heads from HeadDB aliases: [hdb, headdatabase] @@ -21,6 +21,12 @@ permissions: headdb.command.sounds: true headdb.command.open: true headdb.command.submit: true + headdb.command.status: true + headdb.command.sync: true + headdb.command.reload: true + headdb.command.inspect: true + headdb.command.recent: true + headdb.command.language: true headdb.category.*: true headdb.update.notify: true headdb.command.search: @@ -35,6 +41,18 @@ permissions: default: op headdb.command.submit: default: true + headdb.command.status: + default: op + headdb.command.sync: + default: op + headdb.command.reload: + default: op + headdb.command.inspect: + default: true + headdb.command.recent: + default: true + headdb.command.language: + default: true headdb.category.*: default: op headdb.category.favorites: diff --git a/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinksTest.java b/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinksTest.java index 1798052..a1cdb25 100644 --- a/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinksTest.java +++ b/headdb-legacy/src/test/java/com/bitworksmc/headdb/legacy/LegacyWebsiteLinksTest.java @@ -20,4 +20,24 @@ public void carriesSupportedSearchFiltersIntoWebsiteUrl() { public void buildsSubmissionUrlFromValidatedBaseUrl() { assertEquals("https://headdb.net/submit", LegacyWebsiteLinks.submissionUrl("javascript:alert(1)")); } + + @Test + public void preservesMultipleIdsAndAnyMatchMode() { + assertEquals( + "https://headdb.net/heads?ids=12%2C34&match=any", + LegacyWebsiteLinks.searchUrl("https://headdb.net", new String[]{ + "search", "ids:12,34", "--any" + }) + ); + } + + @Test + public void combinesQuotedCategoryValues() { + assertEquals( + "https://headdb.net/heads?category=food-and-drinks", + LegacyWebsiteLinks.searchUrl("https://headdb.net", new String[]{ + "search", "category:\"Food", "&", "Drinks\"" + }) + ); + } } diff --git a/pom.xml b/pom.xml index eccee31..89bb695 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ com.bitworksmc HeadDB - 6.0.4 + 6.1.0 pom HeadDB @@ -28,6 +28,7 @@ 1.21-R0.1-SNAPSHOT 1.8.8-R0.1-SNAPSHOT 3.53.2.1 + 9.6.0 From ef517ca98f35d1527cbd0d004b1ce4acd30c554a Mon Sep 17 00:00:00 2001 From: GoodrichDev Date: Sun, 23 Aug 2026 12:51:33 -0700 Subject: [PATCH 9/9] Final changelog --- changelog/6.0.4.md | 9 --------- changelog/6.1.0.md | 11 +++++++++++ 2 files changed, 11 insertions(+), 9 deletions(-) delete mode 100644 changelog/6.0.4.md diff --git a/changelog/6.0.4.md b/changelog/6.0.4.md deleted file mode 100644 index e11e3fd..0000000 --- a/changelog/6.0.4.md +++ /dev/null @@ -1,9 +0,0 @@ -# HeadDB 6.0.4 Changelog - -- Moved the head catalog to the managed API at [headdb.net](https://headdb.net). -- Added revision-based updates and an on-disk recovery cache for modern Paper - servers. -- Added complete-catalog support for legacy servers from 1.8.8 to 1.20.6. -- Switched submitted skins to Mojang-hosted textures for reliable rendering. -- Added `/hdb submit`, headdb.net submission links throughout the modern menus, - and an optional post-search link that carries supported filters into the web catalog. diff --git a/changelog/6.1.0.md b/changelog/6.1.0.md index d28f5eb..e8feb64 100644 --- a/changelog/6.1.0.md +++ b/changelog/6.1.0.md @@ -1,5 +1,16 @@ # HeadDB 6.1.0 Changelog +## Managed catalog and compatibility + +- Moved the head catalog to the managed API at [headdb.net](https://headdb.net). +- Added revision-based updates and an on-disk recovery cache for modern Paper + servers. +- Added complete-catalog support for legacy servers from 1.8.8 to 1.20.6. +- Switched submitted skins to Mojang-hosted textures for reliable rendering. +- Added `/hdb submit`, headdb.net submission links throughout the modern menus, + and an optional post-search link that carries supported filters into the web + catalog. + ## Commands and catalog operations - Added `/hdb status`, `/hdb sync`, `/hdb reload`, `/hdb inspect`,