diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8bfadef..e924b4d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,11 +28,11 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v3 with: distribution: 'microsoft' - java-version: 17 + java-version: 21 - name: Validate Gradle Wrapper uses: gradle/wrapper-validation-action@v1 @@ -45,10 +45,11 @@ jobs: - name: Install rename run: sudo apt-get install -y rename - - name: Rename file - run: | - cd ./build/libs/ - rename -f 's/-all//;' '' * +# This is only needed when using shadow jar +# - name: Rename file +# run: | +# cd ./build/libs/ +# rename -f 's/-all//;' '' * - name: Archive plugin jars on GitHub uses: actions/upload-artifact@v3 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e0f15db --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 9c236a3..0000000 --- a/build.gradle +++ /dev/null @@ -1,137 +0,0 @@ -plugins { - id 'kr.entree.spigradle' version '2.4.3' - id 'com.github.johnrengelman.shadow' version '7.1.2' - id 'java' -} - -group = project.property("group") -version = project.property("version") - -repositories { - mavenLocal() - mavenCentral() - spigot() - maven { - name = 'spigotmc-repo' - url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' - } - maven { - name = 'sonatype' - url = 'https://oss.sonatype.org/content/groups/public/' - } -} - -dependencies { - implementation 'org.bstats:bstats-bukkit:3.0.0' - compileOnly spigot(project.property("spigotApiVersion")) -} - -spigot { - name = project.property("pluginName") - authors = project.property("authors").split("\\s*,\\s*") - apiVersion = project.property("spigotApiVersion") - load = STARTUP - commands { - autosort { - description 'Toggles auto-sorting options.' - permission 'automaticinventory.sortchests' - usage '/AutoSort' - } - depositall { - aliases 'da', 'dumpitems', 'dumploot', 'depositloot' - description 'Deposits your non-hotbar inventory into any nearby chests containing matching items.' - permission 'automaticinventory.depositall' - usage '/DepositAll' - } - quickdeposit { - description 'Toggles quick deposit (shift+left click on chests).' - permission 'automaticinventory.quickdeposit' - usage '/quickdeposit' - } - autorefill { - description 'Toggles quto refill, which refills your hotbar slots when items are depleted or break.' - permission 'automaticinventory.refillstacks' - usage '/autorefill' - } - } - permissions { - 'automaticinventory.admin.*' { - description 'Grants all administrative privileges.' - children = [ - 'automaticinventory.user.*': true - ] - } - 'automaticinventory.user.*' { - description 'Grants all user privileges.' - children = [ - 'automaticinventory.sortinventory': true, - 'automaticinventory.sortchests': true, - 'automaticinventory.refillstacks': true, - 'automaticinventory.quickdeposit': true, - 'automaticinventory.depositall': true, - ] - } - 'automaticinventory.sortinventory' { - description 'Grants permission to auto-sort personal inventory.' - defaults 'true' - } - 'automaticinventory.sortchests' { - description 'Grants permission to auto-sort chest content.' - defaults 'true' - } - 'automaticinventory.refillstacks' { - description 'Grants permission to auto-refill depleted hotbar stacks.' - defaults 'true' - } - 'automaticinventory.quickdeposit' { - description 'Grants permission to auto-deposit matching items into a chest with shift-right-click.' - defaults 'true' - } - 'automaticinventory.depositall' { - description 'Grants permission to use /depositall.' - defaults 'true' - } - } - debug { - eula = true - buildVersion = project.property("mcVersion") - } -} - -shadowJar { - //archiveClassifier.set("") - relocate 'org.bstats', 'dev.chaws.automaticinventory' -} - -def targetJavaVersion = 17 -java { - def javaVersion = JavaVersion.toVersion(targetJavaVersion) - sourceCompatibility = javaVersion - targetCompatibility = javaVersion - if (JavaVersion.current() < javaVersion) { - toolchain.languageVersion = JavaLanguageVersion.of(targetJavaVersion) - } -} - -tasks.withType(JavaCompile).configureEach { - if (targetJavaVersion >= 10 || JavaVersion.current().isJava10Compatible()) { - options.release = targetJavaVersion - } -} - -tasks { - assemble.dependsOn(shadowJar) -} - -// Not sure why this is needed to debug -// https://github.com/spigradle/spigradle/issues/84 -//tasks.withType(Copy).all { duplicatesStrategy 'exclude' } - -processResources { - def props = [version: version] - inputs.properties props - filteringCharset 'UTF-8' - filesMatching('plugin.yml') { - expand props - } -} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..5a63115 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,137 @@ +import xyz.jpenilla.resourcefactory.bukkit.BukkitPluginYaml +import xyz.jpenilla.resourcefactory.bukkit.Permission +//import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import java.net.URI + +plugins { + `java-library` + // Check for new versions at https://plugins.gradle.org/plugin/io.papermc.paperweight.userdev + id("io.papermc.paperweight.userdev") version "1.7.1" + // Adds runServer and runMojangMappedServer tasks for testing + id("xyz.jpenilla.run-paper") version "2.3.0" + // Generates plugin.yml based on the Gradle config + id("xyz.jpenilla.resource-factory-bukkit-convention") version "1.1.1" +// id("com.github.johnrengelman.shadow") version "8.1.1" +} + +group = "dev.chaws.automaticinventory" +version = "4.0.0" +description = "Automatic Inventory PaperMC Plugin" + +repositories { + mavenLocal() + mavenCentral() + maven { + name = "papermc" + url = URI("https://repo.papermc.io/repository/maven-public/") + } + maven { + name = "spigotmc" + url = URI("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") + } + maven { + name = "bstats" + url = URI("https://oss.sonatype.org/content/groups/public/") + } +} + +java { + toolchain.languageVersion.set(JavaLanguageVersion.of(21)) +} + +dependencies { + paperweight.paperDevBundle("1.21-R0.1-SNAPSHOT") + // paperweight.foliaDevBundle("1.21-R0.1-SNAPSHOT") + // paperweight.devBundle("com.example.paperfork", "1.21-R0.1-SNAPSHOT") + implementation("org.bstats:bstats-bukkit:3.0.2") + + // Add ASM dependency to support Java 21 class files + implementation("org.ow2.asm:asm:9.7") + implementation("org.ow2.asm:asm-commons:9.7") +} + +// Option 1) +// For >=1.20.5 when you don"t care about supporting spigot +// paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArtifactConfiguration.MOJANG_PRODUCTION + +// Option 2) +// For 1.20.4 or below, or when you care about supporting Spigot on >=1.20.5 +// Configure reobfJar to run when invoking the build task +paperweight.reobfArtifactConfiguration = io.papermc.paperweight.userdev.ReobfArtifactConfiguration.REOBF_PRODUCTION +tasks { + assemble { +// dependsOn(shadowJar) + dependsOn(reobfJar) + } + +// named("shadowJar") { +// +// relocate("org.bstats", "dev.chaws.automaticinventory.org.bstats") +// } +} + +bukkitPluginYaml { + main = "dev.chaws.automaticinventory.AutomaticInventory" + // TODO: Try POSTWORLD + load = BukkitPluginYaml.PluginLoadOrder.STARTUP + authors = listOf("Chaws", "Pugabyte", "AllTheCode", "RoboMWM", "Big_Scary") + apiVersion = "1.21" + commands.register("autosort") { + description = "Toggles auto-sorting options." + permission = "automaticinventory.sortchests" + usage = "/AutoSort" + } + commands.register("depositall") { + aliases = listOf("da", "dumpitems", "dumploot", "depositloot") + description = "Deposits your non-hotbar inventory into any nearby chests containing matching items." + permission = "automaticinventory.depositall" + usage = "/DepositAll" + } + commands.register("quickdeposit") { + description = "Toggles quick deposit (shift+left click on chests)." + permission = "automaticinventory.quickdeposit" + usage = "/quickdeposit" + } + commands.register("autorefill") { + description = "Toggles auto refill, which refills your hotbar slots when items are depleted or break." + permission = "automaticinventory.refillstacks" + usage = "/autorefill" + } + + permissions.register("automaticinventory.admin.*") { + description = "Grants all administrative privileges." + children = mapOf( + "automaticinventory.user.*" to true + ) + } + permissions.register("automaticinventory.user.*") { + description = "Grants all user privileges." + children = mapOf( + "automaticinventory.sortinventory" to true, + "automaticinventory.sortchests" to true, + "automaticinventory.refillstacks" to true, + "automaticinventory.quickdeposit" to true, + "automaticinventory.depositall" to true + ) + } + permissions.register("automaticinventory.sortinventory") { + description = "Grants permission to auto-sort personal inventory." + default = Permission.Default.TRUE + } + permissions.register("automaticinventory.sortchests") { + description = "Grants permission to auto-sort chest content." + default = Permission.Default.TRUE + } + permissions.register("automaticinventory.refillstacks") { + description = "Grants permission to auto-refill depleted hotbar stacks." + default = Permission.Default.TRUE + } + permissions.register("automaticinventory.quickdeposit") { + description = "Grants permission to auto-deposit matching items into a chest with shift-right-click." + default = Permission.Default.TRUE + } + permissions.register("automaticinventory.depositall") { + description = "Grants permission to use /depositall." + default = Permission.Default.TRUE + } +} diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index b3d2c0e..0000000 --- a/gradle.properties +++ /dev/null @@ -1,7 +0,0 @@ -group = dev.chaws -packageName = automaticinventory -pluginName = automaticinventory -authors = Big_Scary, RoboMWM, AllTheCode, Pugabyte, Chaws -mcVersion = 1.19.2 -spigotApiVersion = 1.19 -version = 3.3.0 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7454180..e644113 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2e6e589..a441313 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 index c53aefa..b740cf1 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -32,10 +32,10 @@ # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +80,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +131,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,11 +198,15 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ @@ -205,6 +214,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/gradlew.bat b/gradlew.bat index 107acd3..25da30d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +41,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index ab6ee35..0000000 --- a/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'automaticinventory' diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..e846f18 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,8 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven("https://repo.papermc.io/repository/maven-public/") + } +} + +rootProject.name = "automaticinventory" diff --git a/src/main/java/dev/chaws/automaticinventory/AutomaticInventory.java b/src/main/java/dev/chaws/automaticinventory/AutomaticInventory.java index 989b63f..2ad6e9f 100644 --- a/src/main/java/dev/chaws/automaticinventory/AutomaticInventory.java +++ b/src/main/java/dev/chaws/automaticinventory/AutomaticInventory.java @@ -6,7 +6,7 @@ import dev.chaws.automaticinventory.configuration.PlayerConfig; import dev.chaws.automaticinventory.listeners.*; import dev.chaws.automaticinventory.messaging.LocalizedMessages; -import org.bstats.bukkit.Metrics; +import dev.chaws.automaticinventory.utilities.Metrics; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; @@ -46,7 +46,7 @@ public void onEnable() { this.registerCommand("quickdeposit", new QuickDepositCommand()); try { - new Metrics(this, 3547); + new Metrics(this, 16822); } catch (Throwable ignored) { } } diff --git a/src/main/java/dev/chaws/automaticinventory/listeners/RefillStacksListener.java b/src/main/java/dev/chaws/automaticinventory/listeners/RefillStacksListener.java index 2d5d7b2..f25e7e9 100644 --- a/src/main/java/dev/chaws/automaticinventory/listeners/RefillStacksListener.java +++ b/src/main/java/dev/chaws/automaticinventory/listeners/RefillStacksListener.java @@ -110,6 +110,7 @@ private void tryRefillStackInHand(Player player, EquipmentSlot slot) { if (GlobalConfig.instance.autoRefillExcludedItems.contains(stack.getType())) { return; } + if (stack.getAmount() == 1) { var inventory = player.getInventory(); AutomaticInventory.instance.getServer().getScheduler().scheduleSyncDelayedTask( @@ -120,10 +121,11 @@ private void tryRefillStackInHand(Player player, EquipmentSlot slot) { } private EquipmentSlot getSlotWithItemStack(PlayerInventory inventory, ItemStack brokenItem) { - if (ItemUtilities.itemsAreSimilar(inventory.getItemInMainHand(), brokenItem)) { + if (inventory.getItemInMainHand().isSimilar(brokenItem)) { return EquipmentSlot.HAND; } - if (ItemUtilities.itemsAreSimilar(inventory.getItemInOffHand(), brokenItem)) { + + if (inventory.getItemInOffHand().isSimilar(brokenItem)) { return EquipmentSlot.OFF_HAND; } diff --git a/src/main/java/dev/chaws/automaticinventory/tasks/AutoRefillHotBarTask.java b/src/main/java/dev/chaws/automaticinventory/tasks/AutoRefillHotBarTask.java index 5292846..2513644 100644 --- a/src/main/java/dev/chaws/automaticinventory/tasks/AutoRefillHotBarTask.java +++ b/src/main/java/dev/chaws/automaticinventory/tasks/AutoRefillHotBarTask.java @@ -37,7 +37,8 @@ public void run() { if (itemInSlot == null) { continue; } - if (ItemUtilities.itemsAreSimilar(itemInSlot, this.stackToReplace)) { + + if (itemInSlot.isSimilar(this.stackToReplace)) { var stackSize = itemInSlot.getAmount(); if (stackSize < bestMatchStackSize) { bestMatchStack = itemInSlot; diff --git a/src/main/java/dev/chaws/automaticinventory/tasks/InventorySorter.java b/src/main/java/dev/chaws/automaticinventory/tasks/InventorySorter.java index 40213bd..ae3a31f 100644 --- a/src/main/java/dev/chaws/automaticinventory/tasks/InventorySorter.java +++ b/src/main/java/dev/chaws/automaticinventory/tasks/InventorySorter.java @@ -1,5 +1,6 @@ package dev.chaws.automaticinventory.tasks; +import org.bukkit.craftbukkit.util.CraftMagicNumbers; import org.bukkit.event.inventory.InventoryType; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; @@ -32,7 +33,7 @@ public void run() { } } - Collections.sort(stacks, new StackComparator()); + stacks.sort(new StackComparator()); for (var i = 1; i < stacks.size(); i++) { var prevStack = stacks.get(i - 1); var thisStack = stacks.get(i); @@ -72,9 +73,21 @@ public int compare(ItemStack a, ItemStack b) { return result; } - result = Byte.compare(b.getData().getData(), a.getData().getData()); - if (result != 0) { - return result; + //noinspection removal + var aData = a.getData(); + //noinspection removal + var bData = b.getData(); + + //noinspection deprecation + var aDataData = aData == null ? null : aData.getData(); + //noinspection deprecation + var bDataData = bData == null ? null : bData.getData(); + + if (aDataData != null && bDataData != null) { + result = Byte.compare(bDataData, aDataData); + if (result != 0) { + return result; + } } result = Integer.compare(b.getAmount(), a.getAmount()); diff --git a/src/main/java/dev/chaws/automaticinventory/utilities/ItemUtilities.java b/src/main/java/dev/chaws/automaticinventory/utilities/ItemUtilities.java index 1385af3..d98b468 100644 --- a/src/main/java/dev/chaws/automaticinventory/utilities/ItemUtilities.java +++ b/src/main/java/dev/chaws/automaticinventory/utilities/ItemUtilities.java @@ -1,6 +1,5 @@ package dev.chaws.automaticinventory.utilities; -import org.bukkit.enchantments.Enchantment; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.PotionMeta; @@ -8,9 +7,11 @@ public class ItemUtilities { public static String getSignature(ItemStack stack) { var signature = stack.getType().name(); if (stack.getMaxStackSize() > 1) { - var data = stack.getData(); + // getData will not actually be removed according to the spigot forums + //noinspection removal + var data = stack.getData(); if (data != null) { - // getData will not actually be deprecated according to the spigot forums + // getData will not actually be removed according to the spigot forums //noinspection deprecation signature += "." + String.valueOf(data.getData()); } @@ -19,53 +20,35 @@ public static String getSignature(ItemStack stack) { var meta = stack.getItemMeta(); if (meta != null && meta.hasDisplayName()) { // Append the name of the item is there is a custom name given to it - signature += "." + meta.getDisplayName(); + signature += "." + meta.displayName(); } //differentiate potion types. Original credit to pugabyte: https://github.com/Pugabyte/AutomaticInventory/commit/01bbdbfa0ea1bc7dc397fc8a8ff625f3f22e1ed6 //Modified to use PotionType instead of PotionEffectType in signature if (stack.getType().toString().toLowerCase().contains("potion")) { - var potionData = ((PotionMeta) stack.getItemMeta()).getBasePotionData(); - signature += "." + potionData.getType(); - if (potionData.isExtended()) { - signature += ".extended"; + var itemMeta = stack.getItemMeta(); + if (itemMeta == null) { + return signature; } - if (potionData.isUpgraded()) { - signature += ".upgraded"; - } - } - return signature; - } + if (!(itemMeta instanceof PotionMeta potionMeta)) { + return signature; + } - public static boolean itemsAreSimilar(ItemStack a, ItemStack b) { - if (a.getType() == b.getType()) { - return - !a.containsEnchantment(Enchantment.LOOT_BONUS_BLOCKS) && - !a.containsEnchantment(Enchantment.SILK_TOUCH) && - !a.containsEnchantment(Enchantment.LOOT_BONUS_MOBS) && - !a.containsEnchantment(Enchantment.ARROW_INFINITE); + var potionType = potionMeta.getBasePotionType(); + if (potionType == null) { + return signature; + } - //a will _not_ have itemMeta if it is a vanilla tool with no damage. -// if(a.hasItemMeta() != b.hasItemMeta()) return false; -// -// //compare metadata -// if(a.hasItemMeta()) -// { -// if(!b.hasItemMeta()) return false; -// -// ItemMeta meta1 = a.getItemMeta(); -// ItemMeta meta2 = b.getItemMeta(); -// -// //compare names -// if(meta1.hasDisplayName()) -// { -// if(!meta2.hasDisplayName()) return false; -// return meta1.getDisplayName().equals(meta2.getDisplayName()); -// } -// } + signature += "." + potionType; + if (potionType.isExtendable()) { + signature += ".extendable"; + } + if (potionType.isUpgradeable()) { + signature += ".upgradable"; + } } - return false; + return signature; } } diff --git a/src/main/java/dev/chaws/automaticinventory/utilities/Metrics.java b/src/main/java/dev/chaws/automaticinventory/utilities/Metrics.java new file mode 100644 index 0000000..02debac --- /dev/null +++ b/src/main/java/dev/chaws/automaticinventory/utilities/Metrics.java @@ -0,0 +1,880 @@ +/* + * This Metrics class was auto-generated and can be copied into your project if you are + * not using a build tool like Gradle or Maven for dependency management. + * + * IMPORTANT: You are not allowed to modify this class, except changing the package. + * + * Disallowed modifications include but are not limited to: + * - Remove the option for users to opt-out + * - Change the frequency for data submission + * - Obfuscate the code (every obfuscator should allow you to make an exception for specific files) + * - Reformat the code (if you use a linter, add an exception) + * + * Violations will result in a ban of your plugin and account from bStats. + */ +package dev.chaws.automaticinventory.utilities; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.lang.reflect.Method; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.Callable; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.stream.Collectors; +import java.util.zip.GZIPOutputStream; +import javax.net.ssl.HttpsURLConnection; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.java.JavaPlugin; + +// https://github.com/Bastian/bStats-Metrics/blob/single-file/bukkit/Metrics.java +@SuppressWarnings("ALL") +public class Metrics { + + private final Plugin plugin; + + private final MetricsBase metricsBase; + + /** + * Creates a new Metrics instance. + * + * @param plugin Your plugin instance. + * @param serviceId The id of the service. It can be found at What is my plugin id? + */ + public Metrics(JavaPlugin plugin, int serviceId) { + this.plugin = plugin; + // Get the config file + File bStatsFolder = new File(plugin.getDataFolder().getParentFile(), "bStats"); + File configFile = new File(bStatsFolder, "config.yml"); + YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); + if (!config.isSet("serverUuid")) { + config.addDefault("enabled", true); + config.addDefault("serverUuid", UUID.randomUUID().toString()); + config.addDefault("logFailedRequests", false); + config.addDefault("logSentData", false); + config.addDefault("logResponseStatusText", false); + // Inform the server owners about bStats + config + .options() + .setHeader( + List.of( + "bStats (https://bStats.org) collects some basic information for plugin authors, like how", + "many people use their plugin and their total player count. It's recommended to keep bStats", + "enabled, but if you're not comfortable with this, you can turn this setting off. There is no", + "performance penalty associated with having metrics enabled, and data sent to bStats is fully", + "anonymous." + ) + ) + .copyDefaults(true); + try { + config.save(configFile); + } catch (IOException ignored) { + } + } + // Load the data + boolean enabled = config.getBoolean("enabled", true); + String serverUUID = config.getString("serverUuid"); + boolean logErrors = config.getBoolean("logFailedRequests", false); + boolean logSentData = config.getBoolean("logSentData", false); + boolean logResponseStatusText = config.getBoolean("logResponseStatusText", false); + metricsBase = + new MetricsBase( + "bukkit", + serverUUID, + serviceId, + enabled, + this::appendPlatformData, + this::appendServiceData, + submitDataTask -> Bukkit.getScheduler().runTask(plugin, submitDataTask), + plugin::isEnabled, + (message, error) -> this.plugin.getLogger().log(Level.WARNING, message, error), + (message) -> this.plugin.getLogger().log(Level.INFO, message), + logErrors, + logSentData, + logResponseStatusText); + } + + /** Shuts down the underlying scheduler service. */ + public void shutdown() { + metricsBase.shutdown(); + } + + /** + * Adds a custom chart. + * + * @param chart The chart to add. + */ + public void addCustomChart(CustomChart chart) { + metricsBase.addCustomChart(chart); + } + + private void appendPlatformData(JsonObjectBuilder builder) { + builder.appendField("playerAmount", getPlayerAmount()); + builder.appendField("onlineMode", Bukkit.getOnlineMode() ? 1 : 0); + builder.appendField("bukkitVersion", Bukkit.getVersion()); + builder.appendField("bukkitName", Bukkit.getName()); + builder.appendField("javaVersion", System.getProperty("java.version")); + builder.appendField("osName", System.getProperty("os.name")); + builder.appendField("osArch", System.getProperty("os.arch")); + builder.appendField("osVersion", System.getProperty("os.version")); + builder.appendField("coreCount", Runtime.getRuntime().availableProcessors()); + } + + private void appendServiceData(JsonObjectBuilder builder) { + builder.appendField("pluginVersion", plugin.getDescription().getVersion()); + } + + private int getPlayerAmount() { + try { + // Around MC 1.8 the return type was changed from an array to a collection, + // This fixes java.lang.NoSuchMethodError: + // org.bukkit.Bukkit.getOnlinePlayers()Ljava/util/Collection; + Method onlinePlayersMethod = Class.forName("org.bukkit.Server").getMethod("getOnlinePlayers"); + return onlinePlayersMethod.getReturnType().equals(Collection.class) + ? ((Collection) onlinePlayersMethod.invoke(Bukkit.getServer())).size() + : ((Player[]) onlinePlayersMethod.invoke(Bukkit.getServer())).length; + } catch (Exception e) { + // Just use the new method if the reflection failed + return Bukkit.getOnlinePlayers().size(); + } + } + + public static class MetricsBase { + + /** The version of the Metrics class. */ + public static final String METRICS_VERSION = "3.0.2"; + + private static final String REPORT_URL = "https://bStats.org/api/v2/data/%s"; + + private final ScheduledExecutorService scheduler; + + private final String platform; + + private final String serverUuid; + + private final int serviceId; + + private final Consumer appendPlatformDataConsumer; + + private final Consumer appendServiceDataConsumer; + + private final Consumer submitTaskConsumer; + + private final Supplier checkServiceEnabledSupplier; + + private final BiConsumer errorLogger; + + private final Consumer infoLogger; + + private final boolean logErrors; + + private final boolean logSentData; + + private final boolean logResponseStatusText; + + private final Set customCharts = new HashSet<>(); + + private final boolean enabled; + + /** + * Creates a new MetricsBase class instance. + * + * @param platform The platform of the service. + * @param serviceId The id of the service. + * @param serverUuid The server uuid. + * @param enabled Whether or not data sending is enabled. + * @param appendPlatformDataConsumer A consumer that receives a {@code JsonObjectBuilder} and + * appends all platform-specific data. + * @param appendServiceDataConsumer A consumer that receives a {@code JsonObjectBuilder} and + * appends all service-specific data. + * @param submitTaskConsumer A consumer that takes a runnable with the submit task. This can be + * used to delegate the data collection to a another thread to prevent errors caused by + * concurrency. Can be {@code null}. + * @param checkServiceEnabledSupplier A supplier to check if the service is still enabled. + * @param errorLogger A consumer that accepts log message and an error. + * @param infoLogger A consumer that accepts info log messages. + * @param logErrors Whether or not errors should be logged. + * @param logSentData Whether or not the sent data should be logged. + * @param logResponseStatusText Whether or not the response status text should be logged. + */ + public MetricsBase( + String platform, + String serverUuid, + int serviceId, + boolean enabled, + Consumer appendPlatformDataConsumer, + Consumer appendServiceDataConsumer, + Consumer submitTaskConsumer, + Supplier checkServiceEnabledSupplier, + BiConsumer errorLogger, + Consumer infoLogger, + boolean logErrors, + boolean logSentData, + boolean logResponseStatusText) { + ScheduledThreadPoolExecutor scheduler = + new ScheduledThreadPoolExecutor(1, task -> new Thread(task, "bStats-Metrics")); + // We want delayed tasks (non-periodic) that will execute in the future to be + // cancelled when the scheduler is shutdown. + // Otherwise, we risk preventing the server from shutting down even when + // MetricsBase#shutdown() is called + scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + this.scheduler = scheduler; + this.platform = platform; + this.serverUuid = serverUuid; + this.serviceId = serviceId; + this.enabled = enabled; + this.appendPlatformDataConsumer = appendPlatformDataConsumer; + this.appendServiceDataConsumer = appendServiceDataConsumer; + this.submitTaskConsumer = submitTaskConsumer; + this.checkServiceEnabledSupplier = checkServiceEnabledSupplier; + this.errorLogger = errorLogger; + this.infoLogger = infoLogger; + this.logErrors = logErrors; + this.logSentData = logSentData; + this.logResponseStatusText = logResponseStatusText; + checkRelocation(); + if (enabled) { + // WARNING: Removing the option to opt-out will get your plugin banned from + // bStats + startSubmitting(); + } + } + + public void addCustomChart(CustomChart chart) { + this.customCharts.add(chart); + } + + public void shutdown() { + scheduler.shutdown(); + } + + private void startSubmitting() { + final Runnable submitTask = + () -> { + if (!enabled || !checkServiceEnabledSupplier.get()) { + // Submitting data or service is disabled + scheduler.shutdown(); + return; + } + if (submitTaskConsumer != null) { + submitTaskConsumer.accept(this::submitData); + } else { + this.submitData(); + } + }; + // Many servers tend to restart at a fixed time at xx:00 which causes an uneven + // distribution of requests on the + // bStats backend. To circumvent this problem, we introduce some randomness into + // the initial and second delay. + // WARNING: You must not modify and part of this Metrics class, including the + // submit delay or frequency! + // WARNING: Modifying this code will get your plugin banned on bStats. Just + // don't do it! + long initialDelay = (long) (1000 * 60 * (3 + Math.random() * 3)); + long secondDelay = (long) (1000 * 60 * (Math.random() * 30)); + scheduler.schedule(submitTask, initialDelay, TimeUnit.MILLISECONDS); + scheduler.scheduleAtFixedRate( + submitTask, initialDelay + secondDelay, 1000 * 60 * 30, TimeUnit.MILLISECONDS); + } + + private void submitData() { + final JsonObjectBuilder baseJsonBuilder = new JsonObjectBuilder(); + appendPlatformDataConsumer.accept(baseJsonBuilder); + final JsonObjectBuilder serviceJsonBuilder = new JsonObjectBuilder(); + appendServiceDataConsumer.accept(serviceJsonBuilder); + JsonObjectBuilder.JsonObject[] chartData = + customCharts.stream() + .map(customChart -> customChart.getRequestJsonObject(errorLogger, logErrors)) + .filter(Objects::nonNull) + .toArray(JsonObjectBuilder.JsonObject[]::new); + serviceJsonBuilder.appendField("id", serviceId); + serviceJsonBuilder.appendField("customCharts", chartData); + baseJsonBuilder.appendField("service", serviceJsonBuilder.build()); + baseJsonBuilder.appendField("serverUUID", serverUuid); + baseJsonBuilder.appendField("metricsVersion", METRICS_VERSION); + JsonObjectBuilder.JsonObject data = baseJsonBuilder.build(); + scheduler.execute( + () -> { + try { + // Send the data + sendData(data); + } catch (Exception e) { + // Something went wrong! :( + if (logErrors) { + errorLogger.accept("Could not submit bStats metrics data", e); + } + } + }); + } + + private void sendData(JsonObjectBuilder.JsonObject data) throws Exception { + if (logSentData) { + infoLogger.accept("Sent bStats metrics data: " + data.toString()); + } + String url = String.format(REPORT_URL, platform); + HttpsURLConnection connection = (HttpsURLConnection) new URL(url).openConnection(); + // Compress the data to save bandwidth + byte[] compressedData = compress(data.toString()); + connection.setRequestMethod("POST"); + connection.addRequestProperty("Accept", "application/json"); + connection.addRequestProperty("Connection", "close"); + connection.addRequestProperty("Content-Encoding", "gzip"); + connection.addRequestProperty("Content-Length", String.valueOf(compressedData.length)); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("User-Agent", "Metrics-Service/1"); + connection.setDoOutput(true); + try (DataOutputStream outputStream = new DataOutputStream(connection.getOutputStream())) { + outputStream.write(compressedData); + } + StringBuilder builder = new StringBuilder(); + try (BufferedReader bufferedReader = + new BufferedReader(new InputStreamReader(connection.getInputStream()))) { + String line; + while ((line = bufferedReader.readLine()) != null) { + builder.append(line); + } + } + if (logResponseStatusText) { + infoLogger.accept("Sent data to bStats and received response: " + builder); + } + } + + /** Checks that the class was properly relocated. */ + private void checkRelocation() { + // You can use the property to disable the check in your test environment + if (System.getProperty("bstats.relocatecheck") == null + || !System.getProperty("bstats.relocatecheck").equals("false")) { + // Maven's Relocate is clever and changes strings, too. So we have to use this + // little "trick" ... :D + final String defaultPackage = + new String(new byte[] {'o', 'r', 'g', '.', 'b', 's', 't', 'a', 't', 's'}); + final String examplePackage = + new String(new byte[] {'y', 'o', 'u', 'r', '.', 'p', 'a', 'c', 'k', 'a', 'g', 'e'}); + // We want to make sure no one just copy & pastes the example and uses the wrong + // package names + if (MetricsBase.class.getPackage().getName().startsWith(defaultPackage) + || MetricsBase.class.getPackage().getName().startsWith(examplePackage)) { + throw new IllegalStateException("bStats Metrics class has not been relocated correctly!"); + } + } + } + + /** + * Gzips the given string. + * + * @param str The string to gzip. + * @return The gzipped string. + */ + private static byte[] compress(final String str) throws IOException { + if (str == null) { + return null; + } + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(outputStream)) { + gzip.write(str.getBytes(StandardCharsets.UTF_8)); + } + return outputStream.toByteArray(); + } + } + + public static class SimplePie extends CustomChart { + + private final Callable callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public SimplePie(String chartId, Callable callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JsonObjectBuilder.JsonObject getChartData() throws Exception { + String value = callable.call(); + if (value == null || value.isEmpty()) { + // Null = skip the chart + return null; + } + return new JsonObjectBuilder().appendField("value", value).build(); + } + } + + public static class MultiLineChart extends CustomChart { + + private final Callable> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public MultiLineChart(String chartId, Callable> callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JsonObjectBuilder.JsonObject getChartData() throws Exception { + JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); + Map map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + boolean allSkipped = true; + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() == 0) { + // Skip this invalid + continue; + } + allSkipped = false; + valuesBuilder.appendField(entry.getKey(), entry.getValue()); + } + if (allSkipped) { + // Null = skip the chart + return null; + } + return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); + } + } + + public static class AdvancedPie extends CustomChart { + + private final Callable> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public AdvancedPie(String chartId, Callable> callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JsonObjectBuilder.JsonObject getChartData() throws Exception { + JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); + Map map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + boolean allSkipped = true; + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() == 0) { + // Skip this invalid + continue; + } + allSkipped = false; + valuesBuilder.appendField(entry.getKey(), entry.getValue()); + } + if (allSkipped) { + // Null = skip the chart + return null; + } + return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); + } + } + + public static class SimpleBarChart extends CustomChart { + + private final Callable> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public SimpleBarChart(String chartId, Callable> callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JsonObjectBuilder.JsonObject getChartData() throws Exception { + JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); + Map map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + for (Map.Entry entry : map.entrySet()) { + valuesBuilder.appendField(entry.getKey(), new int[] {entry.getValue()}); + } + return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); + } + } + + public static class AdvancedBarChart extends CustomChart { + + private final Callable> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public AdvancedBarChart(String chartId, Callable> callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JsonObjectBuilder.JsonObject getChartData() throws Exception { + JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); + Map map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + boolean allSkipped = true; + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue().length == 0) { + // Skip this invalid + continue; + } + allSkipped = false; + valuesBuilder.appendField(entry.getKey(), entry.getValue()); + } + if (allSkipped) { + // Null = skip the chart + return null; + } + return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); + } + } + + public static class DrilldownPie extends CustomChart { + + private final Callable>> callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public DrilldownPie(String chartId, Callable>> callable) { + super(chartId); + this.callable = callable; + } + + @Override + public JsonObjectBuilder.JsonObject getChartData() throws Exception { + JsonObjectBuilder valuesBuilder = new JsonObjectBuilder(); + Map> map = callable.call(); + if (map == null || map.isEmpty()) { + // Null = skip the chart + return null; + } + boolean reallyAllSkipped = true; + for (Map.Entry> entryValues : map.entrySet()) { + JsonObjectBuilder valueBuilder = new JsonObjectBuilder(); + boolean allSkipped = true; + for (Map.Entry valueEntry : map.get(entryValues.getKey()).entrySet()) { + valueBuilder.appendField(valueEntry.getKey(), valueEntry.getValue()); + allSkipped = false; + } + if (!allSkipped) { + reallyAllSkipped = false; + valuesBuilder.appendField(entryValues.getKey(), valueBuilder.build()); + } + } + if (reallyAllSkipped) { + // Null = skip the chart + return null; + } + return new JsonObjectBuilder().appendField("values", valuesBuilder.build()).build(); + } + } + + public abstract static class CustomChart { + + private final String chartId; + + protected CustomChart(String chartId) { + if (chartId == null) { + throw new IllegalArgumentException("chartId must not be null"); + } + this.chartId = chartId; + } + + public JsonObjectBuilder.JsonObject getRequestJsonObject( + BiConsumer errorLogger, boolean logErrors) { + JsonObjectBuilder builder = new JsonObjectBuilder(); + builder.appendField("chartId", chartId); + try { + JsonObjectBuilder.JsonObject data = getChartData(); + if (data == null) { + // If the data is null we don't send the chart. + return null; + } + builder.appendField("data", data); + } catch (Throwable t) { + if (logErrors) { + errorLogger.accept("Failed to get data for custom chart with id " + chartId, t); + } + return null; + } + return builder.build(); + } + + protected abstract JsonObjectBuilder.JsonObject getChartData() throws Exception; + } + + public static class SingleLineChart extends CustomChart { + + private final Callable callable; + + /** + * Class constructor. + * + * @param chartId The id of the chart. + * @param callable The callable which is used to request the chart data. + */ + public SingleLineChart(String chartId, Callable callable) { + super(chartId); + this.callable = callable; + } + + @Override + protected JsonObjectBuilder.JsonObject getChartData() throws Exception { + int value = callable.call(); + if (value == 0) { + // Null = skip the chart + return null; + } + return new JsonObjectBuilder().appendField("value", value).build(); + } + } + + /** + * An extremely simple JSON builder. + * + *

While this class is neither feature-rich nor the most performant one, it's sufficient enough + * for its use-case. + */ + public static class JsonObjectBuilder { + + private StringBuilder builder = new StringBuilder(); + + private boolean hasAtLeastOneField = false; + + public JsonObjectBuilder() { + builder.append("{"); + } + + /** + * Appends a null field to the JSON. + * + * @param key The key of the field. + * @return A reference to this object. + */ + public JsonObjectBuilder appendNull(String key) { + appendFieldUnescaped(key, "null"); + return this; + } + + /** + * Appends a string field to the JSON. + * + * @param key The key of the field. + * @param value The value of the field. + * @return A reference to this object. + */ + public JsonObjectBuilder appendField(String key, String value) { + if (value == null) { + throw new IllegalArgumentException("JSON value must not be null"); + } + appendFieldUnescaped(key, "\"" + escape(value) + "\""); + return this; + } + + /** + * Appends an integer field to the JSON. + * + * @param key The key of the field. + * @param value The value of the field. + * @return A reference to this object. + */ + public JsonObjectBuilder appendField(String key, int value) { + appendFieldUnescaped(key, String.valueOf(value)); + return this; + } + + /** + * Appends an object to the JSON. + * + * @param key The key of the field. + * @param object The object. + * @return A reference to this object. + */ + public JsonObjectBuilder appendField(String key, JsonObject object) { + if (object == null) { + throw new IllegalArgumentException("JSON object must not be null"); + } + appendFieldUnescaped(key, object.toString()); + return this; + } + + /** + * Appends a string array to the JSON. + * + * @param key The key of the field. + * @param values The string array. + * @return A reference to this object. + */ + public JsonObjectBuilder appendField(String key, String[] values) { + if (values == null) { + throw new IllegalArgumentException("JSON values must not be null"); + } + String escapedValues = + Arrays.stream(values) + .map(value -> "\"" + escape(value) + "\"") + .collect(Collectors.joining(",")); + appendFieldUnescaped(key, "[" + escapedValues + "]"); + return this; + } + + /** + * Appends an integer array to the JSON. + * + * @param key The key of the field. + * @param values The integer array. + * @return A reference to this object. + */ + public JsonObjectBuilder appendField(String key, int[] values) { + if (values == null) { + throw new IllegalArgumentException("JSON values must not be null"); + } + String escapedValues = + Arrays.stream(values).mapToObj(String::valueOf).collect(Collectors.joining(",")); + appendFieldUnescaped(key, "[" + escapedValues + "]"); + return this; + } + + /** + * Appends an object array to the JSON. + * + * @param key The key of the field. + * @param values The integer array. + * @return A reference to this object. + */ + public JsonObjectBuilder appendField(String key, JsonObject[] values) { + if (values == null) { + throw new IllegalArgumentException("JSON values must not be null"); + } + String escapedValues = + Arrays.stream(values).map(JsonObject::toString).collect(Collectors.joining(",")); + appendFieldUnescaped(key, "[" + escapedValues + "]"); + return this; + } + + /** + * Appends a field to the object. + * + * @param key The key of the field. + * @param escapedValue The escaped value of the field. + */ + private void appendFieldUnescaped(String key, String escapedValue) { + if (builder == null) { + throw new IllegalStateException("JSON has already been built"); + } + if (key == null) { + throw new IllegalArgumentException("JSON key must not be null"); + } + if (hasAtLeastOneField) { + builder.append(","); + } + builder.append("\"").append(escape(key)).append("\":").append(escapedValue); + hasAtLeastOneField = true; + } + + /** + * Builds the JSON string and invalidates this builder. + * + * @return The built JSON string. + */ + public JsonObject build() { + if (builder == null) { + throw new IllegalStateException("JSON has already been built"); + } + JsonObject object = new JsonObject(builder.append("}").toString()); + builder = null; + return object; + } + + /** + * Escapes the given string like stated in https://www.ietf.org/rfc/rfc4627.txt. + * + *

This method escapes only the necessary characters '"', '\'. and '\u0000' - '\u001F'. + * Compact escapes are not used (e.g., '\n' is escaped as "\u000a" and not as "\n"). + * + * @param value The value to escape. + * @return The escaped value. + */ + private static String escape(String value) { + final StringBuilder builder = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"') { + builder.append("\\\""); + } else if (c == '\\') { + builder.append("\\\\"); + } else if (c <= '\u000F') { + builder.append("\\u000").append(Integer.toHexString(c)); + } else if (c <= '\u001F') { + builder.append("\\u00").append(Integer.toHexString(c)); + } else { + builder.append(c); + } + } + return builder.toString(); + } + + /** + * A super simple representation of a JSON object. + * + *

This class only exists to make methods of the {@link JsonObjectBuilder} type-safe and not + * allow a raw string inputs for methods like {@link JsonObjectBuilder#appendField(String, + * JsonObject)}. + */ + public static class JsonObject { + + private final String value; + + private JsonObject(String value) { + this.value = value; + } + + @Override + public String toString() { + return value; + } + } + } +} \ No newline at end of file