diff --git a/.gitignore b/.gitignore index 5d19ae2..8a57975 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea/ target/ -ConfigurationMaster.iml \ No newline at end of file +ConfigurationMaster.iml +*.yml \ No newline at end of file diff --git a/API/pom.xml b/API/pom.xml new file mode 100644 index 0000000..67594b8 --- /dev/null +++ b/API/pom.xml @@ -0,0 +1,30 @@ + + + + ConfigurationMaster + com.github.thatsmusic99 + v2.0.0-rc.3 + + 4.0.0 + + ConfigurationMaster-API + v2.0.0-rc.3 + + + 8 + 8 + + + + + + org.yaml + snakeyaml + 2.0 + provided + + + + \ No newline at end of file diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/Example.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/Example.java new file mode 100644 index 0000000..485743d --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/Example.java @@ -0,0 +1,13 @@ +package io.github.thatsmusic99.configurationmaster.annotations; + +import java.lang.annotation.*; + +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Repeatable(Examples.class) +public @interface Example { + + String key(); + + String value(); +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/Examples.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/Examples.java new file mode 100644 index 0000000..c923a8c --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/Examples.java @@ -0,0 +1,12 @@ +package io.github.thatsmusic99.configurationmaster.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Examples { + Example[] value(); +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/Option.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/Option.java new file mode 100644 index 0000000..edd4092 --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/Option.java @@ -0,0 +1,24 @@ +package io.github.thatsmusic99.configurationmaster.annotations; + + +import io.github.thatsmusic99.configurationmaster.annotations.handlers.DefaultOptionHandler; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Option { + + String path() default ""; + + String comment() default ""; + + String section() default ""; + + boolean lenient() default false; + + Class optionHandler() default DefaultOptionHandler.class; +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/OptionHandler.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/OptionHandler.java new file mode 100644 index 0000000..cbf2c13 --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/OptionHandler.java @@ -0,0 +1,27 @@ +package io.github.thatsmusic99.configurationmaster.annotations; + +import io.github.thatsmusic99.configurationmaster.api.ConfigFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public interface OptionHandler { + + Object get( + @NotNull ConfigFile file, + @NotNull String name + ); + + void set( + @NotNull ConfigFile file, + @NotNull String name, + @NotNull Object value + ); + + void addDefault( + @NotNull ConfigFile file, + @NotNull String name, + @NotNull Object value, + @Nullable String section, + @Nullable String comment + ); +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/BooleanOptionHandler.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/BooleanOptionHandler.java new file mode 100644 index 0000000..6d4374b --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/BooleanOptionHandler.java @@ -0,0 +1,12 @@ +package io.github.thatsmusic99.configurationmaster.annotations.handlers; + +import io.github.thatsmusic99.configurationmaster.api.ConfigFile; +import org.jetbrains.annotations.NotNull; + +public class BooleanOptionHandler extends DefaultOptionHandler { + + @Override + public Object get(@NotNull ConfigFile file, @NotNull String name) { + return file.getBoolean(name); + } +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/DefaultOptionHandler.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/DefaultOptionHandler.java new file mode 100644 index 0000000..4e5b36c --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/DefaultOptionHandler.java @@ -0,0 +1,25 @@ +package io.github.thatsmusic99.configurationmaster.annotations.handlers; + +import io.github.thatsmusic99.configurationmaster.annotations.OptionHandler; +import io.github.thatsmusic99.configurationmaster.api.ConfigFile; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class DefaultOptionHandler implements OptionHandler { + + + @Override + public Object get(@NotNull ConfigFile file, @NotNull String name) { + return file.get(name); + } + + @Override + public void set(@NotNull ConfigFile file, @NotNull String name, @NotNull Object value) { + file.set(name, value); + } + + @Override + public void addDefault(@NotNull ConfigFile file, @NotNull String name, @NotNull Object value, @Nullable String section, @Nullable String comment) { + file.addDefault(name, value, section, comment); + } +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/FloatOptionHandler.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/FloatOptionHandler.java new file mode 100644 index 0000000..132d372 --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/FloatOptionHandler.java @@ -0,0 +1,12 @@ +package io.github.thatsmusic99.configurationmaster.annotations.handlers; + +import io.github.thatsmusic99.configurationmaster.api.ConfigFile; +import org.jetbrains.annotations.NotNull; + +public class FloatOptionHandler extends DefaultOptionHandler { + + @Override + public Object get(@NotNull ConfigFile file, @NotNull String name) { + return file.getFloat(name); + } +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/IntegerOptionHandler.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/IntegerOptionHandler.java new file mode 100644 index 0000000..d2a6b7a --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/IntegerOptionHandler.java @@ -0,0 +1,12 @@ +package io.github.thatsmusic99.configurationmaster.annotations.handlers; + +import io.github.thatsmusic99.configurationmaster.api.ConfigFile; +import org.jetbrains.annotations.NotNull; + +public class IntegerOptionHandler extends DefaultOptionHandler { + + @Override + public Object get(@NotNull ConfigFile file, @NotNull String name) { + return file.getInteger(name); + } +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/LongOptionHandler.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/LongOptionHandler.java new file mode 100644 index 0000000..8e1d165 --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/LongOptionHandler.java @@ -0,0 +1,12 @@ +package io.github.thatsmusic99.configurationmaster.annotations.handlers; + +import io.github.thatsmusic99.configurationmaster.api.ConfigFile; +import org.jetbrains.annotations.NotNull; + +public class LongOptionHandler extends DefaultOptionHandler { + + @Override + public Object get(@NotNull ConfigFile file, @NotNull String name) { + return file.getLong(name); + } +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/StringOptionHandler.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/StringOptionHandler.java new file mode 100644 index 0000000..a904736 --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/annotations/handlers/StringOptionHandler.java @@ -0,0 +1,12 @@ +package io.github.thatsmusic99.configurationmaster.annotations.handlers; + +import io.github.thatsmusic99.configurationmaster.api.ConfigFile; +import org.jetbrains.annotations.NotNull; + +public class StringOptionHandler extends DefaultOptionHandler { + + @Override + public Object get(@NotNull ConfigFile file, @NotNull String name) { + return file.getString(name); + } +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/CommentWriter.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/CommentWriter.java new file mode 100644 index 0000000..787dbbd --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/CommentWriter.java @@ -0,0 +1,163 @@ +package io.github.thatsmusic99.configurationmaster.api; + +import io.github.thatsmusic99.configurationmaster.api.comments.Comment; +import io.github.thatsmusic99.configurationmaster.api.comments.Section; + +import java.util.ArrayList; +import java.util.List; + +class CommentWriter { + + private final ConfigFile config; + // The currently written lines of the file. + private List currentLines; + + protected CommentWriter(ConfigFile config) { + this.config = config; + this.currentLines = new ArrayList<>(); + } + + /** + * Initiates the comment writing process. + */ + protected void writeComments(List currentLines) { + this.currentLines = currentLines; + + // For each comment to be made... + for (String path : config.getComments().keySet()) { + // Write the comment at the specified path + writeComment(path, path.split("\\."), 0, 0); + } + + // However, if there's any comments left, write them in. + for (Comment comment : config.getPendingComments()) { + + String str = comment.getComment(); + if (str.isEmpty()) { + currentLines.add(""); + continue; + } + currentLines.add(""); + String[] rawComment = str.split("\n"); + for (String commentPart : rawComment) { + if (commentPart.isEmpty()) { + currentLines.add(""); + continue; + } + + // If we have a comment section here, use that instead + if (comment instanceof Section) { + String section = commentPart.split(": ")[1]; + StringBuilder length = new StringBuilder(); + length.append("###"); + for (int j = 0; j < section.length(); j++) { + length.append("#"); + } + length.append("###"); + currentLines.add(length.toString()); + currentLines.add("# " + section + " #"); + currentLines.add(length.toString()); + } else { + currentLines.add("# " + commentPart); + } + } + } + } + + /** + * Method used to write a specified comment. + * + * @param path The path the comment must be written at. + * @param divisions The number of sections the part can be split up into. + * @param iteration How far we're down the pathway (in terms of different sections). + * @param startingLine The line we're starting from. + */ + private void writeComment(String path, String[] divisions, int iteration, int startingLine) { + StringBuilder indent = new StringBuilder(); + for (int j = 0; j < iteration; j++) indent.append(" "); + + // Go through each line in the file + for (int i = startingLine; i < currentLines.size(); i++) { + String line = currentLines.get(i); + + // If the line doesn't have an equal or larger indent, then the line could not be found. + if (!line.startsWith(indent.toString())) return; + + // If it's already a comment, leave it be. + if (line.startsWith("#")) continue; + + // If it's not an option (e.g. option: or 'option':), continue + if (!(line.startsWith(indent + divisions[iteration] + ":") || + line.startsWith(indent + "'" + divisions[iteration] + "':"))) continue; + + // Increment the iteration + iteration += 1; + + // If the + if (iteration != divisions.length) { + writeComment(path, divisions, iteration, i + 1); + continue; + } + + // Get the current line we're on + int currentLine = i; + + // + List comments = config.getComments().get(path); + if (comments == null || comments.isEmpty()) continue; + + for (Comment comment : comments) { + + // Add an empty line before a single- + if (iteration == 1) { + currentLines.add(currentLine, ""); + currentLine++; + } + + if (comment == null || comment.getComment().isEmpty()) { + currentLines.add(currentLine, ""); + } else { + + String commentStr = comment.getComment(); + String[] commentParts = commentStr.split("\n"); + + if (comment instanceof Section) { + + // Get the maximum length + int max = 0; + for (String commentPart : commentParts) { + max = Math.max(commentPart.length(), max); + } + + // Build the + StringBuilder length = new StringBuilder(); + length.append("###"); + for (int j = 0; j < max; j++) { + length.append("#"); + } + length.append("###"); + currentLines.add(currentLine, length.toString()); + currentLine++; + for (String commentPart : commentParts) { + currentLines.add(currentLine, "# " + commentPart + " #"); + currentLine++; + } + currentLines.add(currentLine, length.toString()); + currentLine++; + continue; + } + + for (String commentPart : commentParts) { + currentLines.add(currentLine, indent + "# " + commentPart); + currentLine++; + } + } + } + break; + } + } + + protected List getLines() { + return currentLines; + } +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/ConfigFile.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/ConfigFile.java new file mode 100644 index 0000000..ffa017e --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/ConfigFile.java @@ -0,0 +1,488 @@ +package io.github.thatsmusic99.configurationmaster.api; + +import io.github.thatsmusic99.configurationmaster.annotations.Example; +import io.github.thatsmusic99.configurationmaster.annotations.Option; +import io.github.thatsmusic99.configurationmaster.annotations.OptionHandler; +import io.github.thatsmusic99.configurationmaster.annotations.handlers.*; +import io.github.thatsmusic99.configurationmaster.api.comments.Comment; +import io.github.thatsmusic99.configurationmaster.impl.CMConfigSection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.error.YAMLException; +import org.yaml.snakeyaml.representer.Representer; + +import java.io.*; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import java.util.function.Function; +import java.util.logging.Logger; + +/** + * Represents a specialised YAML file in ConfigurationMaster.

+ * + * It can be initialised using the following methods:
+ * + *
    + *
  1. {@link ConfigFile#loadConfig(File)} - this loads a file with safety precautions. + * If the file contains a syntax error, the API will print an error, + * rename the file temporarily and load a new empty file.
    + * + * It is recommended to use this if you want your users to + * not lose their progress on a config file if they make a + * single mistake. + *
  2. + * + *
  3. {@link io.github.thatsmusic99.configurationmaster.api.ConfigFile#ConfigFile(File)} + * - this loads a file without the safety precautions taken above. + * This is recommended if you want to handle YAMLExceptions.
  4. + * + *
  5. Simply extend the class. This will not take any safety precautions, similarly to using the constructor.
  6. + *
+ * + */ +public class ConfigFile extends CMConfigSection { + + @NotNull private static final HashMap, Class> REGISTERED_HANDLERS = new HashMap<>(); + @NotNull private final Yaml yaml; + @NotNull private final File file; + @NotNull private final CommentWriter writer; + @NotNull protected List pendingComments; + @NotNull protected HashMap> comments; + @NotNull protected HashSet examples; + @NotNull protected List lenientSections; + @NotNull protected Function optionNameTranslator; + @NotNull protected List permittedClasses; + @Nullable private Title title; + private boolean isNew; + protected boolean verbose; + protected boolean reloading; + protected static Logger logger = new CMLogger(); + + static { + REGISTERED_HANDLERS.put(boolean.class, BooleanOptionHandler.class); + REGISTERED_HANDLERS.put(Boolean.class, BooleanOptionHandler.class); + REGISTERED_HANDLERS.put(float.class, FloatOptionHandler.class); + REGISTERED_HANDLERS.put(Float.class, FloatOptionHandler.class); + REGISTERED_HANDLERS.put(int.class, IntegerOptionHandler.class); + REGISTERED_HANDLERS.put(Integer.class, IntegerOptionHandler.class); + REGISTERED_HANDLERS.put(long.class, LongOptionHandler.class); + REGISTERED_HANDLERS.put(Long.class, LongOptionHandler.class); + REGISTERED_HANDLERS.put(String.class, StringOptionHandler.class); + } + + /** + * Used to initialise a config file without safety precautions taken by the API. + * + * @param file The config file to be loaded. + * @see ConfigFile#loadConfig(File) + * @throws YAMLException if the file being loaded contains syntax errors. + */ + public ConfigFile(@NotNull File file) throws IOException, IllegalAccessException { + this(file, name -> name.replaceAll("_", "-").toLowerCase()); + } + + public ConfigFile(@NotNull File file, @NotNull Function optionNameTranslator) throws IOException, IllegalAccessException { + + // Load the YAML configuration + yaml = getYaml(); + + // Set up the file itself + this.file = file; + this.optionNameTranslator = optionNameTranslator; + this.isNew = false; + this.reloading = false; + this.title = null; + + // Set up internal variables + writer = new CommentWriter(this); + pendingComments = new ArrayList<>(); + comments = new HashMap<>(); + examples = new HashSet<>(); + lenientSections = new ArrayList<>(); + permittedClasses = new ArrayList<>(); + } + + /** + * Used to load a config file with safety precautions taken by the API.
+ * This safety precaution checks for syntax errors in the YAML file.
+ * If there is one, it is renamed and an empty file is loaded.
+ * + * @param file The file to be loaded. + * @return the ConfigFile instance of the file or backup file. + */ + public static ConfigFile loadConfig(File file) throws Exception { + ConfigFile configFile = new ConfigFile(file); + configFile.createFile(); + configFile.loadContent(); + return configFile; + } + + public void load() throws Exception { + + // Before we do anything + preFileCreation(); + + // If the file doesn't already exist, create it + createFile(); + + // Read the file content + loadContent(); + + // Load the default options + addDefaults(); + + // Move everything to the new options + moveToNew(); + + // Then save! + save(); + + // Carry out any extra operations post-save + postSave(); + } + + public void preFileCreation() { + } + + protected void loadFromString(String content) throws IOException { + try { + + // Load everything + Map map = this.yaml.load(content); + if (map != null) { + mapToCM(map); + } + + } catch (YAMLException exception) { + + File tempFile = new File(file.getParentFile(), + file.getName().substring(0, file.getName().lastIndexOf(".")) + + "-errored-" + System.currentTimeMillis() + ".yml"); + + Files.copy(file.toPath(), tempFile.toPath()); + + logger.severe("YAMLException caught - there is a syntax error in the config."); + exception.printStackTrace(); + } + } + + /** + * Used to load options + */ + public void addDefaults() throws Exception { + + handleAnnotations((field, option) -> { + + if (!canProcessOption(field, option)) return; + + // Also get the field value and treat it as the default option + final Object defaultOpt = field.get(this); + + // Get the option metadata + final String name = option.path().isEmpty() ? optionNameTranslator.apply(field.getName()) : option.path(); + final String comment = option.comment().isEmpty() ? null : option.comment(); + final String section = option.section().isEmpty() ? null : option.section(); + final boolean lenient = option.lenient(); + Class optionHandlerClass = option.optionHandler(); + if (optionHandlerClass == DefaultOptionHandler.class) { + optionHandlerClass = REGISTERED_HANDLERS.getOrDefault(field.getType(), DefaultOptionHandler.class); + } + OptionHandler handler = optionHandlerClass.getConstructor().newInstance(); + + // If there's examples to add, add them + for (Example example : field.getAnnotationsByType(Example.class)) { + addExample(name + "." + example.key(), example.value()); + } + + // If it's a lenient field, then make it lenient + if (lenient) { + if (comment != null) addComment(name, comment); + makeSectionLenient(name); + } + + // Add the default + if (defaultOpt != null) handler.addDefault(this, name, defaultOpt, section, comment); + + // Set the result + field.set(this, handler.get(this, name)); + }); + } + + public boolean canProcessOption(final @NotNull Field field, final @NotNull Option option) { + return true; + } + + public void loadContent() throws IOException { + loadFromString(readFileContent()); + } + + public void moveToNew() { + } + + public void postSave() { + } + + /** + * Saves all changes made to the config to the actual file. + * + * @throws IOException if something went wrong writing to the file. + */ + public void save() throws Exception { + try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(Files.newOutputStream(file.toPath()), StandardCharsets.UTF_8))) { + String saved = saveToString(); + writer.write(saved); + isNew = false; + this.existingValues.clear(); + } + } + + protected String readFileContent() throws IOException { + + StringBuilder content = new StringBuilder(); + + // Load the reader + try (BufferedReader reader = new BufferedReader(new InputStreamReader(Files.newInputStream(file.toPath()), StandardCharsets.UTF_8))) { + + String line; + while ((line = reader.readLine()) != null) { + content.append(line).append("\n"); + } + if (content.length() == 0) { + isNew = true; + } + } + + return content.toString(); + } + + /** + * Reloads the configuration and updates all values stored inside it.

+ * + * @throws IOException if something went wrong saving the file. + */ + public void reload() throws Exception { + debug("Reloading the configuration file " + file.getName() + "..."); + reloading = true; + + // Reset the defaults + HashMap allDefaults = new LinkedHashMap<>(); + addDefaults(allDefaults); + + // Create the file + createFile(); + + // Reset internal values + existingValues.clear(); + clear(); + + // Load file content + loadContent(); + + // Add defaults + for (String path : allDefaults.keySet()) { + + // Make sure it's not in a lenient section + String parentPath = getParentPath(path); + if (lenientSections.contains(parentPath)) { + makeSectionLenient(parentPath); + continue; + } + + if (!examples.contains(path) || contains(path) || isNew) { + addDefault(path, allDefaults.get(path)); + } + } + addDefaults(); + + moveToNew(); + save(); + postSave(); + + reloading = false; + } + + /** + * Returns whether the loaded file is brand new or not.
+ * + * This is determined by whether a new file was created or if the file itself is empty. + * + * @return true if the file is newly loaded, false if not. + */ + public boolean isNew() { + return isNew; + } + + public boolean isReloading() { + return reloading; + } + + protected void createFile() throws IOException { + + // If the file doesn't already exist, create it + if (!file.exists()) { + if (!file.createNewFile()) { + throw new IOException("Failed to create " + file.getName() + "!"); + } + + // It's a new file + isNew = true; + } + } + + public void updateAnnotations() throws Exception { + + handleAnnotations((field, option) -> { + + // Also get the field value and treat it as the default option + final Object value = field.get(this); + + // Get the option metadata + final String name = option.path().isEmpty() ? optionNameTranslator.apply(field.getName()) : option.path(); + + // Update the values + set(name, value); + }); + } + + private void handleAnnotations(OptionConsumer consumer) throws Exception { + // Before doing anything else, check the fields + Field[] fields = getClass().getFields(); + for (Field field : fields) { + + // Check if a field has the option annotation, add it + if (!field.isAnnotationPresent(Option.class)) continue; + final Option option = field.getAnnotation(Option.class); + + // + consumer.accept(field, option); + } + } + + public String saveToString() throws Exception { + + // Update the annotations + updateAnnotations(); + + // Convert the map + String dump = this.yaml.dump(convertToMap()); + if (dump.equals("{}")) dump = ""; + + // Write the comments + writer.writeComments(new ArrayList<>(Arrays.asList(dump.split("\n")))); + + // Write to the lines + StringBuilder result = new StringBuilder(); + writer.getLines().forEach(line -> result.append(line).append("\n")); + + // Write the title and result + return (title != null ? title + "\n" : "") + result; + } + + public @NotNull HashMap> getComments() { + return comments; + } + + /** + * Get all comments that have yet to be added. These will be added when the next default/example is set. + * + * @return Comments waiting to be added. + */ + public @NotNull List getPendingComments() { + return pendingComments; + } + + public @NotNull HashSet getExamples() { + return examples; + } + + public @NotNull List getLenientSections() { + return lenientSections; + } + + public @Nullable Title getTitle() { + return title; + } + + public void setTitle(@Nullable Title title) { + this.title = title; + } + + public @NotNull File getFile() { + return file; + } + + /** + * Enables the debugging mode to track what is happening within the API. + * + * @return The ConfigFile object with debugging being enabled. + */ + public ConfigFile enableDebugging() { + this.verbose = true; + return this; + } + + public void debug(String message) { + if (verbose) logger.info(message); + } + + private Yaml getYaml() { + + // Initialise dump options + DumperOptions options = new DumperOptions(); + options.setIndent(2); + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + + // Initialise representer + Representer representerClone; + try { + representerClone = new Representer(options); + } catch (NoSuchMethodError ex) { + try { + representerClone = Representer.class.getConstructor().newInstance(); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException | + NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + representerClone.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + + // Initialise YAML + Yaml yaml; + try { + LoaderOptions loader = new LoaderOptions(); + loader.setCodePointLimit(1024 * 1024 * 100); + loader.setTagInspector(tag -> this.permittedClasses.contains(tag.getClassName())); + yaml = new Yaml(new SafeConstructor(loader), representerClone, options, loader); + } catch (Exception | NoSuchMethodError | NoClassDefFoundError ex) { + // YOLO + try { + yaml = new Yaml(SafeConstructor.class.getConstructor().newInstance(), representerClone, options); + } catch (InstantiationException | IllegalAccessException | InvocationTargetException | + NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + return yaml; + } + + private static class CMLogger extends Logger { + + // if you can't be 'em, join 'em + protected CMLogger() { + super("ConfigurationMaster", null); + } + } + + private interface OptionConsumer { + + void accept(T t, R r) throws Exception; + } +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/ConfigSection.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/ConfigSection.java new file mode 100644 index 0000000..6b356c6 --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/ConfigSection.java @@ -0,0 +1,52 @@ +package io.github.thatsmusic99.configurationmaster.api; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public interface ConfigSection extends MemorySection { + + /** + * Adds a default value to an option in the config file. + * + * If the option does not exist, it will be added with the provided default value. + * + * However, if the option does exist, it will only be adjusted to its correct position. The value inside does not change. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param value The default value to be used if the option doesn't already exist. + */ + void addDefault(@NotNull String path, @Nullable Object value); + + void addDefault(@NotNull String path, @Nullable Object value, @Nullable String comment); + + void addDefault(@NotNull String path, @Nullable Object value, @Nullable String section, @Nullable String comment); + + void addComment(@NotNull String path, @NotNull String comment); + + void addComment(@NotNull String comment); + + void addComments(@NotNull String path, @NotNull String... comments); + + void moveTo(@NotNull String oldPath, @NotNull String newPath); + + void moveTo(@NotNull String oldPath, @NotNull String newPath, @NotNull ConfigFile otherFile); + + void addSection(@NotNull String section); + + void addSection(@NotNull String path, @NotNull String section); + + void addExample(@NotNull String path, @Nullable Object value); + + void addExample(@NotNull String path, @Nullable Object value, @Nullable String comment); + + void createExampleSection(@NotNull String path); + + void forceExample(@NotNull String path, @Nullable Object value); + + void forceExample(@NotNull String path, @Nullable Object value, @Nullable String comment); + + void makeSectionLenient(@NotNull String path); + + ConfigSection createConfigSection(@NotNull String path); +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/MemorySection.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/MemorySection.java new file mode 100644 index 0000000..1e05a97 --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/MemorySection.java @@ -0,0 +1,327 @@ +package io.github.thatsmusic99.configurationmaster.api; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * A MemorySection represents a section in the configuration that holds data. + */ +public interface MemorySection extends Map { + + /** + * Returns a string at a specified path. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @return The string stored at the path. If nothing is found, a value of null is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + default @Nullable String getString(@NotNull String path) { + return getString(path, null); + } + + /** + * Returns a string at a specified path. If it is not found, the default value is returned. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param defaultValue The default value to be returned if nothing is found. + * @return The string stored at the path. If nothing is found, the default value is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + String getString(@NotNull String path, @Nullable String defaultValue); + + /** + * Returns an integer at a specified path. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @return The integer stored at the path. If nothing is found, a value of 0 is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + default int getInteger(@NotNull String path) { + return getInteger(path, 0); + } + + /** + * Returns an integer at a specified path. If it is not found, the default value is returned. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param defaultValue The default value to be returned if nothing is found. + * @return The integer stored at the path. If nothing is found, the default value is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + int getInteger(@NotNull String path, int defaultValue); + + + /** + * Returns a double at a specified path. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @return The double stored at the path. If nothing is found, a value of 0.0 is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + default double getDouble(@NotNull String path) { + return getDouble(path, 0.0); + } + + /** + * Returns a double at a specified path. If it is not found, the default value is returned. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param defaultValue The default value to be returned if nothing is found. + * @return The double stored at the path. If nothing is found, the default value is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + double getDouble(@NotNull String path, double defaultValue); + + /** + * Returns an object at a specified path. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @return The object stored at the path. If nothing is found, a null value is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + default Object get(@NotNull String path) { + return get(path, null); + } + + /** + * Returns an object at a specified path. If it is not found, the default value is returned. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param defaultValue The default value to be returned if nothing is found. + * @return The object stored at the path. If nothing is found, the default value is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + Object get(@NotNull String path, @Nullable Object defaultValue); + + /** + * Returns a boolean at a specified path. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @return The boolean stored at the path. If nothing is found, false is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + default boolean getBoolean(@NotNull String path) { + return getBoolean(path, false); + } + + /** + * Returns a boolean at a specified path. If it is not found, the default value is returned. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param defaultValue The default value to be returned if nothing is found. + * @return The boolean stored at the path. If nothing is found, the default value is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + boolean getBoolean(@NotNull String path, boolean defaultValue); + + /** + * Returns a long at a specified path. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @return The long stored at the path. If nothing is found, a value of 0 is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + default long getLong(@NotNull String path) { + return getLong(path, 0); + } + + /** + * Returns a long at a specified path. If it is not found, the default value is returned. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param defaultValue The default value to be returned if nothing is found. + * @return The long stored at the path. If nothing is found, the default value is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + long getLong(@NotNull String path, long defaultValue); + + /** + * Returns a short at a specified path. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @return The short stored at the path. If nothing is found, a value of 0 is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + default short getShort(@NotNull String path) { + return getShort(path, (short) 0); + } + + /** + * Returns a short at a specified path. If it is not found, the default value is returned. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param defaultValue The default value to be returned if nothing is found. + * @return The short stored at the path. If nothing is found, the default value is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + short getShort(@NotNull String path, short defaultValue); + + /** + * Returns a byte at a specified path. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @return The byte stored at the path. If nothing is found, a value of 0 is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + default byte getByte(@NotNull String path) { + return getByte(path, (byte) 0); + } + + /** + * Returns a byte at a specified path. If it is not found, the default value is returned. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param defaultValue The default value to be returned if nothing is found. + * @return The byte stored at the path. If nothing is found, the default value is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + byte getByte(@NotNull String path, byte defaultValue); + + /** + * Returns a float at a specified path. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @return The float stored at the path. If nothing is found, a value of 0 is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + default float getFloat(@NotNull String path) { + return getFloat(path, 0f); + } + + /** + * Returns a float at a specified path. If it is not found, the default value is returned. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param defaultValue The default value to be returned if nothing is found. + * @return The float stored at the path. If nothing is found, the default value is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + float getFloat(@NotNull String path, float defaultValue); + + /** + * Returns a configuration file at a specified path. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @return The configuration section stored at the path. If nothing is found, a null value is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + default ConfigSection getConfigSection(@NotNull String path) { + return getConfigSection(path, null); + } + + /** + * Returns a configuration section at a specified path. If it is not found, the default value is returned. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param defaultValue The default value to be returned if nothing is found. + * @return The configuration section stored at the path. If nothing is found, the default value is returned. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + ConfigSection getConfigSection(@NotNull String path, @Nullable ConfigSection defaultValue); + + /** + * Returns whether or not the memory section contains a specific path. + * + * @param path The path to search for. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @return true if the memory section contains the path, false if not. + */ + boolean contains(@NotNull String path); + + /** + * Returns a list at a given path. If the provided path does not point to a list but a different data type, + * a new list is created containing that single element. If the path itself is not found, an empty list is created. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param The list type you want returned. + * @return The list stored at the path. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + @NotNull + default List getList(@NotNull String path) { + return getList(path, new ArrayList<>()); + } + + /** + * Returns a string list at a given path. If the provided path does not point to a list but a different data type, + * a new list is created containing that single element. If the path itself is not found, an empty list is created. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @return The string list stored at the path. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + @NotNull + default List getStringList(@NotNull String path) { + return getList(path, new ArrayList<>()); + } + + /** + * Returns a list at a given path. If the provided path does not point to a list but a different data type, + * a new list is created containing that single element. If the path itself is not found, the default value is returned. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param defaultValue The default value to be returned if nothing is found. + * @param The list type you want returned. + * @return The list stored at the path. + * @throws NullPointerException if the path is null (not if it wasn't found). + */ + List getList(@NotNull String path, @Nullable List defaultValue); + + /** + * Sets a value at a specified path. + * + * @param path The path of the option itself. + * To indicate for an option to be placed inside different sections, use a . delimiter, e.g. section.option + * @param object The object the option will be set to. + */ + void set(@NotNull String path, @Nullable Object object); + + /** + * Returns a list of option/config section keys stored within this memory section. + * + * @param deep true if options within configuration sections should be included, false if you just want to collect + * keys from this memory section only. + * @return A list of keys to the given paths. + */ + default List getKeys(boolean deep) { + return getKeys(deep, false); + } + + List getKeys(boolean deep, boolean useExisting); + + /** + * The path of this section. + * + * @return The full path - periods included - of this section. + */ + String getPath(); + + +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/Title.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/Title.java new file mode 100644 index 0000000..4b40e63 --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/Title.java @@ -0,0 +1,227 @@ +package io.github.thatsmusic99.configurationmaster.api; + +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.StringJoiner; + +/** + * A subsection of the API which is used to make pretty + * titles easier to make and implement. + */ +public class Title { + + private final List parts; + private int width; + private boolean addPadding; + + /** + * The constructor used to initialise the title object. + */ + public Title() { + width = 75; + parts = new ArrayList<>(); + addPadding = true; + } + + /** + * Changes the character width of the title object. Default value is 75.

+ * + * This method cannot be used after using {@link Title#addLine(String)}, + * {@link Title#addLine(String, Pos)}, {@link Title#addSolidLine()} or + * {@link Title#addSolidLine(char)}. + * + * @param width The width to be set. + * @return the modified title object. + * @throws IllegalStateException when the width is adjusted after title parts have been added. + */ + public Title withWidth(int width) { + if (!parts.isEmpty()) + throw new IllegalStateException("Cannot adjust the title width after title content has been added!"); + this.width = width; + return this; + } + + /** + * Changes whether or not an extra # should be added at + * the end of each line. The API does this by default.

+ * + * This method cannot be used after using {@link Title#addLine(String)}, + * {@link Title#addLine(String, Pos)}, {@link Title#addSolidLine()} or + * {@link Title#addSolidLine(char)}. + * + * @param padding Whether or not to add padding (#). + * @return the modified title object. + * @throws IllegalStateException when the padding is adjusted after title parts have been added. + */ + public Title withPadding(boolean padding) { + if (!parts.isEmpty()) + throw new IllegalStateException("Cannot adjust padding status after title content has been added!"); + this.addPadding = padding; + return this; + } + + /** + * Adds a solid line of # characters that match the title width. + * + * @return The modified title object. + */ + public Title addSolidLine() { + return addSolidLine('#'); + } + + /** + * Adds a solid line of a specified character that matches the title width, + * but adjusted so padding can be included (or not). + * + * @param character The character to make a solid line of. + * @return The modified title object. + */ + public Title addSolidLine(char character) { + parts.add(new LineTitlePart(character)); + return this; + } + + /** + * Adds a text line oriented to the left. + * + * @param content The text to be added to the title. + * @return The modified title object. + * @throws NullPointerException if the content is null. + * @see #addLine(String, Pos) + */ + public Title addLine(@NotNull String content) { + return addLine(content, Pos.LEFT); + } + + /** + * Adds a text line oriented to the right. + * + * @param content The text to be added to the title. + * @param position The position it is oriented to. + * @return The modified title object. + * @throws NullPointerException if the content or position is null. + * @throws IllegalArgumentException if a word in the content is longer than what the title can accept (width - 4) + * @see #addLine(String) + */ + public Title addLine(@NotNull String content, @NotNull Pos position) { + // Null checks + Objects.requireNonNull(content, "Title content must not be null!"); + Objects.requireNonNull(position, "Position must not be null!"); + // If all the stuff added fits on one line, just dump it in. + if (content.length() < width - 3) { + parts.add(new TextTitlePart(content, position)); + return this; + } + // However, if it doesn't, shorten it down. + StringJoiner joiner = new StringJoiner(" "); + for (String word : content.split(" ")) { + // If the word itself is waaaay too long, throw an error. + if (word.length() > width - 4) { + throw new IllegalArgumentException(String.format("Word %s of size %s is too long to be fit into the title (%s)!", word, word.length(), width - 4)); + } + if ((joiner + " " + word).length() < width - 3) { + joiner.add(word); + continue; + } + parts.add(new TextTitlePart(joiner.toString(), position)); + joiner = new StringJoiner(" ").add(word); + } + if (joiner.length() == 0) return this; + parts.add(new TextTitlePart(joiner.toString(), position)); + return this; + } + + /** + * Converts the title to a string. + * + * @return the string representation of the title. + */ + public String toString() { + StringBuilder builder = new StringBuilder(); + for (TitlePart part : parts) { + if (builder.length() != 0) builder.append("\n"); + builder.append(part.toString()); + } + return builder.toString(); + } + + private String align(String content, Pos position) { + int remainder = width - 4 - content.length(); + switch (position) { + case LEFT: + return "# " + content + repeat(" ", remainder); + case RIGHT: + return "# " + repeat(" ", remainder) + content; + case CENTER: + return "# " + repeat(" ", remainder / 2) + content + repeat(" ", + remainder % 2 == 1 ? remainder / 2 + 1 : remainder / 2); + } + return content; + } + + /** + * Used to represent an alignment position in a title text part. + */ + public enum Pos { + LEFT, + CENTER, + RIGHT + } + + private class TextTitlePart extends TitlePart { + private final String content; + + public TextTitlePart(String content, Pos pos) { + super(pos); + this.content = content; + } + + @Override + public String toString() { + return align(content, position) + (addPadding ? " #" : ""); + } + } + + private class LineTitlePart extends TitlePart { + + private final char character; + + public LineTitlePart(char character) { + super(Pos.LEFT); + this.character = character; + } + + @Override + public String toString() { + if (character == '#') { + return repeat("#", width); + } else { + return "# " + repeat(String.valueOf(character), width - 4) + (addPadding ? " #" : ""); + } + } + } + + /** + * Used to represent a title part to be added. + */ + public abstract static class TitlePart { + protected Pos position; + + public TitlePart(Pos pos) { + this.position = pos; + } + + public abstract String toString(); + } + + private static String repeat(String str, int count) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < count; i++) { + builder.append(str); + } + return builder.toString(); + } +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/comments/Comment.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/comments/Comment.java new file mode 100644 index 0000000..f843b28 --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/comments/Comment.java @@ -0,0 +1,14 @@ +package io.github.thatsmusic99.configurationmaster.api.comments; + +public class Comment { + + private final String comment; + + public Comment(String comment) { + this.comment = comment; + } + + public String getComment() { + return comment; + } +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/comments/Section.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/comments/Section.java new file mode 100644 index 0000000..4037dfe --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/api/comments/Section.java @@ -0,0 +1,8 @@ +package io.github.thatsmusic99.configurationmaster.api.comments; + +public class Section extends Comment { + + public Section(String comment) { + super(comment); + } +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/impl/CMConfigSection.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/impl/CMConfigSection.java new file mode 100644 index 0000000..e4da352 --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/impl/CMConfigSection.java @@ -0,0 +1,344 @@ +package io.github.thatsmusic99.configurationmaster.impl; + +import io.github.thatsmusic99.configurationmaster.api.ConfigFile; +import io.github.thatsmusic99.configurationmaster.api.ConfigSection; +import io.github.thatsmusic99.configurationmaster.api.comments.Comment; +import io.github.thatsmusic99.configurationmaster.api.comments.Section; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +public class CMConfigSection extends CMMemorySection implements ConfigSection { + + public CMConfigSection() { + super(); + } + + public CMConfigSection(String path, ConfigFile file) { + super(path, file); + } + + public void addDefault(@NotNull String path, @Nullable Object defaultOption) { + addDefault(path, defaultOption, null, null); + } + + public void addDefault(@NotNull String path, @Nullable Object defaultOption, @Nullable String comment) { + addDefault(path, defaultOption, null, comment); + } + + public void addDefault(@NotNull String path, @Nullable Object defaultOption, @Nullable String section, @Nullable String comment) { + + // Null checks + Objects.requireNonNull(path, "The path cannot be null!"); + + // Get the full path of the key in question + String fullPath = getPathWithKey(path); + + // Get the section to be created - if it's null, create it + CMMemorySection cmSection = getSectionInternal(path); + if (cmSection == null) cmSection = createSectionInternal(path); + String key = getKey(path); + + if (!getParent().isReloading()) { + + addPendingCommentsToPath(path); + + final List comments = new ArrayList<>(); + + // Then handle the comments for the actual option + if (getParent().getComments().containsKey(fullPath)) comments.addAll(getParent().getComments().get(fullPath)); + + // Add the section + if (section != null) comments.add(new Section(section)); + + // Add the comment + if (comment != null) comments.add(new Comment(comment)); + + // Clear any pending comments + getParent().getPendingComments().clear(); + + // If there's comments to add, + if (comments.size() > 0) getParent().getComments().put(fullPath, comments); + } + + // Add the defaults + cmSection.defaults.put(key, defaultOption); + cmSection.put(key, cmSection.existingValues.getOrDefault(key, defaultOption)); + } + + @Override + public void addComment(@NotNull String comment) { + if (getParent().isReloading()) return; + getParent().getPendingComments().add(new Comment(comment)); + } + + @Override + public void moveTo(@NotNull String oldPath, @NotNull String newPath) { + moveTo(oldPath, newPath, getParent()); + } + + @Override + public void moveTo(@NotNull String oldPath, @NotNull String newPath, @NotNull ConfigFile otherFile) { + Objects.requireNonNull(oldPath, "The old path cannot be null!"); + Objects.requireNonNull(newPath, "The new path cannot be null!"); + Objects.requireNonNull(otherFile, "The file being transferred to cannot be null!"); + + if (!contains(oldPath)) return; + CMMemorySection oldCmSection = getSectionInternal(oldPath, false); + if (oldCmSection == null) return; + CMMemorySection newCmSection = otherFile.getSectionInternal(newPath); + if (newCmSection == null) newCmSection = otherFile.createSectionInternal(newPath); + String oldKey = oldPath.substring(oldPath.lastIndexOf('.') + 1); + Object movingValue = oldCmSection.existingValues.get(oldKey); + String newKey = newPath.substring(newPath.lastIndexOf('.') + 1); + newCmSection.put(newKey, movingValue); + oldCmSection.set(oldKey, null); + } + + @Override + public void addComment(@NotNull String path, @NotNull String comment) { + addComment(path, comment, true); + } + + private void addComment(final @NotNull String path, final @NotNull String comment, final boolean addPending) { + Objects.requireNonNull(path, "The path cannot be null!"); + Objects.requireNonNull(comment, "The comment cannot be null!"); + if (getParent().isReloading()) return; + if (addPending) addPendingCommentsToPath(path); + + // If a specified path already has comments, add this one onto the existing comment, otherwise just add it + if (getParent().getComments().containsKey(path)) { + getParent().getComments().get(path).add(new Comment(comment)); + } else { + getParent().getComments().put(getPathWithKey(path), new ArrayList<>(Collections.singletonList(new Comment(comment)))); + } + } + + @Override + public void addComments(@NotNull String path, @NotNull String... comments) { + Objects.requireNonNull(path, "The path cannot be null!"); + Objects.requireNonNull(comments, "The comments array cannot be null!"); + + for (String comment : comments) addComment(path, comment); + } + + @Override + public void addExample(@NotNull String path, Object object) { + addExample(path, object, null); + } + + @Override + public void addExample(@NotNull String path, Object object, @Nullable String comment) { + Objects.requireNonNull(path, "The path cannot be null!"); + + // Check if any of the sections are lenient + String[] sections = path.split("\\."); + List visited = new ArrayList<>(); + for (String section : sections) { + visited.add(section); + String parentPath = String.join(".", visited); + + // If it's lenient, check if it exists + if (getParent().getLenientSections().contains(parentPath)) { + CMConfigSection parentSection = (CMConfigSection) getSectionInternal(parentPath, false); + if (parentSection == null || parentSection.existingValues.get(section) == null) { + forceExample(path, object, comment); + return; + } + } + } + + getParent().getExamples().add(getPathWithKey(path)); + } + + @Override + public void createExampleSection(@NotNull String path) { + Objects.requireNonNull(path, "The path cannot be null!"); + + getParent().getExamples().add(getPathWithKey(path)); + // See if the base section exists - if not, force it + CMMemorySection section = getSectionInternal(path); + if (section == null) createConfigSection(path); + } + + @Override + public void forceExample(@NotNull String path, @Nullable Object value) { + forceExample(path, value, null); + } + + @Override + public void forceExample(@NotNull String path, @Nullable Object value, @Nullable String comment) { + Objects.requireNonNull(path, "The path cannot be null!"); + getParent().getExamples().add(getPathWithKey(path)); + addDefault(path, value, null, comment); + } + + @Override + public void makeSectionLenient(@NotNull String path) { + // TODO - allow null/empty path to signify making the whole ass file lenient + Objects.requireNonNull(path, "The path cannot be null!"); + // TODO - don't use internals here + CMConfigSection section = (CMConfigSection) getSectionInternal(path + ".haha"); + if (section == null) section = createSectionInternal(path + ".haha"); + section.forceExistingIntoActual(); + if (getParent().getLenientSections().contains(getPathWithKey(path))) return; + getParent().getLenientSections().add(getPathWithKey(path)); + + // Add it as a default option to the parent section + CMConfigSection parent = (CMConfigSection) getSectionInternal(path); + String key = getKey(path); + if (parent != null) parent.defaults.put(key, new CMConfigSection(parent.getPathWithKey(key), getParent())); + + // Check for any pending comments + if (getParent().isReloading()) return; + String fullPath = getPathWithKey(path); + + addPendingCommentsToPath(path); + + final List comments = new ArrayList<>(); + + // Then handle the comments for the actual option + if (getParent().getComments().containsKey(fullPath)) comments.addAll(getParent().getComments().get(fullPath)); + + // Clear any pending comments + getParent().getPendingComments().clear(); + + // If there's comments to add, + if (comments.size() > 0) getParent().getComments().put(fullPath, comments); + } + + private void forceExistingIntoActual() { + if (!getParent().isNew()) { + clear(); + } + for (String key : existingValues.keySet()) { + if (existingValues.get(key) instanceof CMConfigSection) { + ((CMConfigSection) existingValues.get(key)).forceExistingIntoActual(); + } + put(key, existingValues.get(key)); + } + } + + @Override + public void addSection(@NotNull String section) { + if (getParent().isReloading()) return; + getParent().getPendingComments().add(new Section(section)); + } + + @Override + public void addSection(@NotNull String path, @NotNull String section) { + addSection(path, section, true); + } + + private void addSection(final @NotNull String path, final @NotNull String section, final boolean addPending) { + Objects.requireNonNull(path, "The path cannot be null!"); + Objects.requireNonNull(section, "The section cannot be null!"); + + if (getParent().isReloading()) return; + if (addPending) addPendingCommentsToPath(path); + // If a specified path already has comments, add this one onto the existing comment, otherwise just add it + if (getParent().getComments().containsKey(path)) { + getParent().getComments().get(path).add(new Section(section)); + } else { + getParent().getComments().put(getPathWithKey(path), new ArrayList<>(Collections.singletonList(new Section(section)))); + } + } + + protected CMConfigSection createSectionInternal(@NotNull String path) { + return createConfigSection(path.substring(0, path.lastIndexOf('.'))); + } + + @Override + public CMConfigSection createConfigSection(@NotNull String path) { + Objects.requireNonNull(path, "The path must not be null!"); + String[] sections = path.split("\\."); + CMConfigSection toEdit = this; + for (String section : sections) { + Object option = toEdit.get(section); + if (option == null) { + option = new CMConfigSection( + toEdit.getPath().length() == 0 ? section : toEdit.getPath() + "." + section, + toEdit.getParent()); + toEdit.put(section, option); + toEdit = (CMConfigSection) option; + } else if (option instanceof CMConfigSection) { + toEdit = (CMConfigSection) option; + } else { + throw new IllegalStateException(path + " cannot be made into a configuration section due to already containing data!"); + } + } + return toEdit; + } + + protected Map convertToMap() { + LinkedHashMap map = new LinkedHashMap<>(); + for (String path : keySet()) { + if (get(path) instanceof CMConfigSection) { + map.put(path, ((CMConfigSection) get(path)).convertToMap()); + } else { + map.put(path, get(path)); + } + } + return map; + } + + protected void mapToCM(Map map) { + for (Object keyObj : map.keySet()) { + if (keyObj == null) keyObj = "null"; + String key = keyObj.toString(); + Object value = map.get(keyObj); + if (value instanceof Map) { + CMConfigSection section = new CMConfigSection(getPathWithKey(key), getParent()); + section.mapToCM((Map) value); + existingValues.put(key, section); + } else { + existingValues.put(key, value); + } + } + } + + protected String getParentPath(String path) { + int finalIndex = path.lastIndexOf('.'); + if (finalIndex != -1) { + return path.substring(0, finalIndex); + } + return null; + } + + private String getPathWithKey(String key) { + if (getPath().isEmpty()) return key; + return getPath() + "." + key; + } + + protected void addDefaults(HashMap map) { + for (String key : keySet()) { + if (get(key) instanceof CMConfigSection) { + ((CMConfigSection) get(key)).addDefaults(map); + } else { + map.put(getPathWithKey(key), defaults.get(key)); + } + } + + // If the section is empty but lenient, keep it + if (getParent().getLenientSections().contains(getPath()) && keySet().size() == 0) { + map.put(getPath(), this); + } + } + + private void addPendingCommentsToPath(final @NotNull String path) { + + // Get the root path for pending commands + final int index = path.indexOf('.'); + final String root = path.substring(0, index == -1 ? path.length() : index); + + for (Comment pendingComment : getParent().getPendingComments()) { + if (pendingComment instanceof Section) { + addSection(root, pendingComment.getComment(), false); + } else { + addComment(root, pendingComment.getComment(), false); + } + } + getParent().getPendingComments().clear(); + } +} diff --git a/API/src/main/java/io/github/thatsmusic99/configurationmaster/impl/CMMemorySection.java b/API/src/main/java/io/github/thatsmusic99/configurationmaster/impl/CMMemorySection.java new file mode 100644 index 0000000..2411382 --- /dev/null +++ b/API/src/main/java/io/github/thatsmusic99/configurationmaster/impl/CMMemorySection.java @@ -0,0 +1,212 @@ +package io.github.thatsmusic99.configurationmaster.impl; + +import io.github.thatsmusic99.configurationmaster.api.ConfigFile; +import io.github.thatsmusic99.configurationmaster.api.ConfigSection; +import io.github.thatsmusic99.configurationmaster.api.MemorySection; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.*; + +public class CMMemorySection extends LinkedHashMap implements MemorySection { + + protected LinkedHashMap defaults; + protected LinkedHashMap existingValues; + protected LinkedHashMap actualValues; + protected String path; + protected ConfigFile parent; + + CMMemorySection() { + if (this instanceof ConfigFile) { + this.path = ""; + init(); + } else { + throw new IllegalStateException("SHUT UP. SHUT UP"); + } + } + + CMMemorySection(String path, ConfigFile parent) { + this.path = path; + this.parent = parent; + init(); + } + + private void init() { + this.defaults = new LinkedHashMap<>(); + this.existingValues = new LinkedHashMap<>(); + } + + @Override + public String getString(@NotNull String path, @Nullable String defaultValue) { + Object result = get(path, defaultValue); + if (result == null) return null; + return String.valueOf(result); + } + + @Override + public int getInteger(@NotNull String path, int defaultValue) { + String result = getString(path); + try { + return result == null ? defaultValue : Integer.parseInt(result); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + @Override + public double getDouble(@NotNull String path, double defaultValue) { + String result = getString(path); + try { + return result == null ? defaultValue : Double.parseDouble(result); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + @Override + public Object get(@NotNull String path, @Nullable Object defaultValue) { + CMMemorySection section = getSectionInternal(path, false); + if (section == null) return defaultValue; + String key = getKey(path); + return section.getOrDefault(key, section.existingValues.getOrDefault(key, defaultValue)); + } + + @Override + public boolean getBoolean(@NotNull String path, boolean defaultValue) { + String result = getString(path); + if (result == null) return defaultValue; + if (!(result.equalsIgnoreCase("false") || result.equalsIgnoreCase("true"))) return defaultValue; + return result.equalsIgnoreCase("true"); + } + + @Override + public long getLong(@NotNull String path, long defaultValue) { + String result = getString(path); + try { + return result == null ? defaultValue : Long.parseLong(result); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + @Override + public short getShort(@NotNull String path, short defaultValue) { + String result = getString(path); + try { + return result == null ? defaultValue : Short.parseShort(result); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + @Override + public byte getByte(@NotNull String path, byte defaultValue) { + String result = getString(path); + try { + return result == null ? defaultValue : Byte.parseByte(result); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + @Override + public float getFloat(@NotNull String path, float defaultValue) { + String result = getString(path); + try { + return result == null ? defaultValue : Float.parseFloat(result); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + @Override + public ConfigSection getConfigSection(@NotNull String path, @Nullable ConfigSection defaultValue) { + Object value = get(path, defaultValue); + return value instanceof ConfigSection ? (ConfigSection) value : defaultValue; + } + + @Override + public boolean contains(@NotNull String path) { + CMMemorySection section = getSectionInternal(path, false); + if (section == null) return false; + String key = getKey(path); + return section.existingValues.containsKey(key) || section.containsKey(key); + } + + @Override + public List getList(@NotNull String path, @Nullable List defaultValue) { + Object value = get(path, defaultValue); + if (value == null) return defaultValue; + if (value.getClass().isArray()) { + value = Arrays.asList((Object[]) value); + } else if (!(value instanceof List)) { + value = new ArrayList<>(Collections.singletonList(value)); + } + return (List) value; + } + + @Override + public void set(@NotNull String path, @Nullable Object object) { + CMMemorySection section = getSectionInternal(path); + if (section == null) { + if (object == null) return; + section = getParent().createSectionInternal(path); + } + String key = getKey(path); + if (object == null) { + section.remove(key); + return; + } + section.put(key, object); + } + + @Nullable + protected CMMemorySection getSectionInternal(@NotNull String path) { + return getSectionInternal(path, true); + } + + protected CMMemorySection getSectionInternal(@NotNull String path, boolean add) { + Objects.requireNonNull(path, "Path must not be null!"); + CMMemorySection section = this; + while (path.indexOf('.') != -1 && section != null) { + String key = path.substring(0, path.indexOf('.')); + path = path.substring(path.indexOf('.') + 1); + CMMemorySection tempSection; + if (section.existingValues.get(key) instanceof CMConfigSection) { + tempSection = (CMMemorySection) section.getConfigSection(key, (CMConfigSection) section.existingValues.get(key)); + } else { + tempSection = (CMMemorySection) section.getConfigSection(key); + } + if (tempSection != null && add) section.putIfAbsent(key, tempSection); + section = tempSection; + } + return section; + } + + @Override + public List getKeys(boolean deep, boolean useExisting) { + List keys = new ArrayList<>(); + HashMap map = useExisting ? existingValues : this; + for (String path : map.keySet()) { + if (deep && map.get(path) instanceof CMConfigSection) { + keys.addAll(((CMConfigSection) map.get(path)).getKeys(true)); + } else { + keys.add(path); + } + } + return keys; + } + + @Override + public String getPath() { + return path; + } + + protected ConfigFile getParent() { + return this instanceof ConfigFile ? (ConfigFile) this : parent; + } + + protected String getKey(String path) { + return path.substring(path.lastIndexOf('.') + 1); + } +} diff --git a/API/src/test/java/io/github/thatsmusic99/configurationmaster/AdvancedTeleportTest.java b/API/src/test/java/io/github/thatsmusic99/configurationmaster/AdvancedTeleportTest.java new file mode 100644 index 0000000..f5eafcb --- /dev/null +++ b/API/src/test/java/io/github/thatsmusic99/configurationmaster/AdvancedTeleportTest.java @@ -0,0 +1,313 @@ +package io.github.thatsmusic99.configurationmaster; + +import io.github.thatsmusic99.configurationmaster.api.ConfigFile; +import io.github.thatsmusic99.configurationmaster.api.Title; +import org.junit.Assert; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; + +public class AdvancedTeleportTest { + + @Test + public void initATConfig() throws Exception { + File file = new File("test-config.yml"); + if (!file.exists()) { + file.createNewFile(); + } + ConfigFile config = ConfigFile.loadConfig(file); + config.setTitle(new Title().withWidth(100).addSolidLine() + .addLine("-<( AdvancedTeleport )>-", Title.Pos.CENTER) + .addLine("Made by Niestrat99 and Thatsmusic99", Title.Pos.CENTER) + .addLine("") + .addSolidLine('-') + .addLine("A rapidly growing teleportation plugin looking to break the boundaries of traditional teleport plugins.") + .addLine("") + .addLine("SpigotMC - https://www.spigotmc.org/resources/advanced-teleport.64139/") + .addLine("Wiki - https://github.com/Niestrat99/AT-Rewritten/wiki") + .addLine("Discord - https://discord.gg/mgWbbN4") + .addSolidLine()); + + config.addComment("Another comment at the very top for all you lads :)"); + config.addDefault("use-basic-teleport-features", true, "Features", "Whether basic teleportation features should be enabled or not." + + "\nThis includes /tpa, /tpahere, /tpblock, /tpunblock and /back." + + "\nThis does not disable the command for other plugins - if you want other plugins to use the provided commands, use Bukkit's commands.yml file." + + "\nPlease refer to https://bukkit.gamepedia.com/Commands.yml for this!"); + + config.addDefault("use-warps", true, "Whether warps should be enabled in the plugin."); + config.addDefault("use-spawn", true, "Whether the plugin should modify spawn/spawn properties."); + config.addDefault("use-randomtp", true, "Whether the plugin should allow random teleportation."); + config.addDefault("use-homes", true, "Whether homes should be enabled in the plugin."); + config.addDefault("disabled-commands", new ArrayList<>(), "The commands that AT should not register upon starting up.\n" + + "In other words, this gives up the command for other plugins to use.\n" + + "NOTE: If you are using Essentials with AT and want AT to give up its commands to Essentials, Essentials does NOT go down without a fight. Jesus Christ. You'll need to restart the server for anything to change."); + + config.addSection("Teleport Requesting"); + config.addDefault("request-lifetime", 60, "\u2770 How long tpa and tpahere requests last before expiring."); + config.addDefault("allow-multiple-requests", true, "Whether or not the plugin should enable the use of multiple requests.\n" + + "When enabled, user 1 may get TPA requests from user 2 and 3, but user 1 is prompted to select a specific request.\n" + + "When this is disabled and user 1 receives requests from user 2 and then 3, they will only have user 3's request to respond to."); + config.addDefault("notify-on-expire", true, "Let the player know when their request has timed out or been displaced by another user's request.\n" + + "Displacement only occurs when allow-multiple-requests is disabled."); + // addDefault("tpa-restrict-movement-on", "requester"); + // addDefault("tpahere-restrict-movement-on", "requester"); + + config.addDefault("warm-up-timer-duration", 3, "Warm-Up Timers", "The number of seconds it takes for the teleportation to take place following confirmation.\n" + + "(i.e. \"You will teleport in 3 seconds!\")\n" + + "This acts as the default option for the per-command warm-ups."); + config.addDefault("cancel-warm-up-on-rotation", true, "Whether or not teleportation should be cancelled if the player rotates or moves."); + config.addDefault("cancel-warm-up-on-movement", true, "Whether or not teleportation should be cancelled upon movement only."); + + config.addComment("per-command-warm-ups", "Command-specific warm-ups."); + config.addDefault("per-command-warm-ups.tpa", "default", "Warm-up timer for /tpa."); + config.addDefault("per-command-warm-ups.tpahere", "default", "Warm-up timer for /tpahere"); + config.addDefault("per-command-warm-ups.tpr", "default", "Warm-up timer for /tpr, or /rtp."); + config.addDefault("per-command-warm-ups.warp", "default", "Warm-up timer for /warp"); + config.addDefault("per-command-warm-ups.spawn", "default", "Warm-up timer for /spawn"); + config.addDefault("per-command-warm-ups.home", "default", "Warm-up timer for /home"); + config.addDefault("per-command-warm-ups.back", "default", "Warm-up timer for /back"); + + config.addDefault("cooldown-duration", 5, "Cooldowns", "How long before the user can use a command again.\n" + + "This stops users spamming commands repeatedly.\n" + + "This is also the default cooldown period for all commands."); + config.addDefault("add-cooldown-duration-to-warm-up", true, "Adds the warm-up duration to the cooldown duration.\n" + + "For example, if the cooldown duration was 5 seconds but the warm-up was 3, the cooldown becomes 8 seconds long."); + config.addDefault("apply-cooldown-to-all-commands", false, "Whether or not the cooldown of one command will stop a user from using all commands.\n" + + "For example, if a player used /tpa with a cooldown of 10 seconds but then used /tpahere with a cooldown of 5, the 10-second cooldown would still apply.\n" + + "On the other hand, if a player used /tpahere, the cooldown of 5 seconds would apply to /tpa and other commands."); + config.addDefault("apply-cooldown-after", "request", "When to apply the cooldown\n" + + "Options include:\n" + + "- request - Cooldown starts as soon as any teleport command is made and still applies even if no teleport takes place (i.e. cancelled by movement or not accepted).\n" + + "- accept - Cooldown starts only when the teleport request is accepted (with /tpyes) and still applies even if no teleport takes place (i.e. cancelled by movement).\n" + + "- teleport - Cooldown starts only when the teleport actually happens.\n" + + "Note:\n" + + "'request' and 'accept' behave the same for /rtp, /back, /spawn, /warp, and /home\n" + + "cooldown for /tpall always starts when the command is ran, regardless if any player accepts or teleports"); + + config.addComment("per-command-cooldowns", "Command-specific cooldowns."); + config.addDefault("per-command-cooldowns.tpa", "default", "Cooldown for /tpa."); + config.addDefault("per-command-cooldowns.tpahere", "default", "Cooldown for /tpahere"); + config.addDefault("per-command-cooldowns.tpr", "default", "Cooldown for /tpr, or /rtp."); + config.addDefault("per-command-cooldowns.warp", "default", "Cooldown for /warp"); + config.addDefault("per-command-cooldowns.spawn", "default", "Cooldown for /spawn"); + config.addDefault("per-command-cooldowns.home", "default", "Cooldown for /home"); + config.addDefault("per-command-cooldowns.back", "default", "Cooldown for /back"); + // addDefault("per-command-cooldowns.sethome", "default", "Cooldown for /sethome"); + // addDefault("per-command-cooldowns.setwarp", "default", "Cooldown for /setwarp"); + + config.addDefault("cost-amount", 100.0, "Teleportation Costs", "The amount it costs to teleport somewhere." + + "\nIf you want to use Vault Economy, use 100.0 to charge $100." + + "\nIf you want to use Minecraft EXP points, use 10EXP for 10 EXP Points." + + "\nIf you want to use Minecraft EXP levels, use 5LVL for 5 levels." + + "\nIf you want to use items, use the format MATERIAL:AMOUNT or MATERIAL:AMOUNT:BYTE." + + "\nFor example, on 1.13+, ORANGE_WOOL:3 for 3 orange wool, but on versions before 1.13, WOOL:3:1." + + "\nIf you're on a legacy version and unsure on what byte to use, see https://minecraftitemids.com/types" + + "\nTo use multiple methods of charging, use a ; - e.g. '100.0;10LVL' for $100 and 10 EXP levels." + + "\nTo disable, just put an empty string, i.e. ''"); + + config.addComment("per-command-cost", "Command-specific costs."); + config.addDefault("per-command-cost.tpa", "default", "Cost for /tpa."); + config.addDefault("per-command-cost.tpahere", "default", "Cost for /tpahere."); + config.addDefault("per-command-cost.tpr", "default", "Cost for /tpr, or /rtp."); + config.addDefault("per-command-cost.warp", "default", "Cost for /warp"); + config.addDefault("per-command-cost.spawn", "default", "Cost for /spawn"); + config.addDefault("per-command-cost.home", "default", "Cost for /home"); + config.addDefault("per-command-cost.back", "default", "Cost for /back"); + //addDefault("per-command-cost.sethome", "default", "Cost for /sethome"); + //addDefault("pet-command-cost.setwarp", "default", "Cost for /setwarp"); + + config.addSection("SQL Storage"); + + config.addDefault("use-mysql", false, "Whether the plugin should use SQL storage or not.\n" + + "By default, AT uses SQLite storage, which stores data in a .db file locally."); + config.addDefault("mysql-host", "127.0.0.1", "The MySQL host to connect to."); + config.addDefault("mysql-port", 3306, "The port to connect to."); + config.addDefault("mysql-database", "database", "The database to connect to."); + config.addDefault("mysql-username", "username", "The username to use when connecting."); + config.addDefault("mysql-password", "password", "The password to use when connecting."); + config.addDefault("mysql-table-prefix", "advancedtp", "The prefix of all AT tables. \n" + + "If you're on Bungee, you may want to add your server's name to the end."); + + config.addDefault("enable-distance-limitations", false, "Distance Limitations", + "Enables the distance limiter to stop players teleporting over a large distance.\n" + + "This is only applied when people are teleporting in the same world."); + config.addDefault("maximum-teleport-distance", 1000, "The maximum distance that a player can teleport.\n" + + "This is the default distance applied to all commands when specified."); + config.addDefault("monitor-all-teleports-distance", false, "Whether or not all teleportations - not just AT's - should be checked for distance."); + + config.addComment("per-command-distance-limitations", "Determines the distance limit for each command."); + config.addDefault("per-command-distance-limitations.tpa", "default", "Distance limit for /tpa"); + config.addDefault("per-command-distance-limitations.tpahere", "default", "Distance limit for /tpahere"); + config.addDefault("per-command-distance-limitations.tpr", "default", "Distance limit for /tpr"); + config.addDefault("per-command-distance-limitations.warp", "default", "Distance limit for /warp"); + config.addDefault("per-command-distance-limitations.spawn", "default", "Distance limit for /spawn"); + config.addDefault("per-command-distance-limitations.home", "default", "Distance limit for /home"); + config.addDefault("per-command-distance-limitations.back", "default", "Distance limit for /back"); + + config.addSection("Teleportation Limitations"); + + config.addComment("WARNING: A lot of the options below are considered advanced and use special syntax that is not often accepted in YAML.\n" + + "When using such options, wrap them in quotes: ''\n" + + "As an example, 'stop-teleportation-out:world,world_nether'"); + + config.addDefault("enable-teleport-limitations", false, + "Enables teleport limitations. This means cross-world or even world teleportation can be limited within specific worlds."); + config.addDefault("monitor-all-teleports-limitations", false, "Whether or not all teleportation - not just AT's - should be checked to see if teleportation is allowed."); + + config.addComment("world-rules", "The teleportation rules defined for each world.\n" + + "Rules include:\n" + + "- stop-teleportation-out - Stops players teleporting to another world when they are in this world.\n" + + "- stop-teleportation-within - Stops players teleporting within the world.\n" + + "- stop-teleportation-into - Stops players teleporting into this world.\n" + + "To combine multiple rules, use a ; - e.g. stop-teleportation-out;stop-teleportation-within\n" + + "For out and into rules, you can make it so that rules only initiate when in or going to a specific world using :, e.g. stop-teleportation-out:world stops players teleporting to \"world\" in the world they're currently in.\n" + + "To do the opposite (i.e. initiates the rule when users are not in the specified world), use !, e.g. stop-teleportation-into!world stops teleportation into a specific world if they are not in \"world\". If ! and : are used in the same rule, then : is given top priority." + + "To make this rule work with multiple worlds, use a comma (,), e.g. stop-teleportation-into:world,world_nether"); + + config.makeSectionLenient("world-rules"); + config.addDefault("world-rules.default", "stop-teleportation-within"); + config.addExample("world-rules.world", "default"); + config.addExample("world-rules.world_nether", "stop-teleportation-into!world", "Stops people teleporting into the Nether if they're not coming from \"world\""); + + config.addComment("command-rules", "The teleportation rules defined for each AT command.\n" + + "Rules include:\n" + + "- override - The command will override world rules and run regardless.\n" + + "- ignore - The command will refuse to run regardless of world rules.\n" + + "To combine multiple rules, use a ;.\n" + + "To make rules behave differently in different worlds, use : to initiate the rule in a specific world (e.g. override:world to make the command override \"world\"'s rules.)\n" + + "To initiate rules outside of a specific world, use ! (e.g. override!world to make the command override world rules everywhere but in world)\n" + + "To use multiple worlds, use a comma (,).\n" + + "By default, all commands will comply with the world rules. If no rules are specified, they will comply.\n" + + "All worlds specified will be considered the world in which the player is currently in. For worlds being teleported to, add > to the start of the world name.\n" + + "For example, ignore:world,>world_nether will not run if the player is in \"world\" or if the player is going into the Nether."); + config.addDefault("command-rules.tpa", ""); + config.addDefault("command-rules.tpahere", ""); + config.addDefault("command-rules.tpr", ""); + config.addDefault("command-rules.warp", ""); + config.addDefault("command-rules.spawn", ""); + config.addDefault("command-rules.home", ""); + config.addDefault("command-rules.back", ""); + + config.addDefault("maximum-x", 5000, "RandomTP", "The maximum X coordinate to go up to when selecting a random location."); + config.addDefault("maximum-z", 5000, "The maximum Z coordinate to go up to when selecting a random location."); + config.addDefault("minimum-x", -5000, "The minimum X coordinate to go down to when selecting a random location."); + config.addDefault("minimum-z", -5000, "The minimum Z coordinate to go down to when selecting a random location."); + config.addDefault("use-world-border", true, "When WorldBorder is installed, AT will check the border of each world instead rather than using the minimum and maximum coordinates."); + config.addDefault("use-rapid-response", true, "Use the new rapid response system for RTP.\n" + + "This means valid locations are prepared before a user chooses to use /tpr or interact with a sign, meaning they are ready for use and can instantly TP a player.\n" + + "This feature allows you to use the \"tpr\" death option in the death management section further down.\n" + + "IMPORTANT NOTE - this feature only works on the Paper server type and any of its forks. It is not considered safe to use on Spigot or Bukkit."); + config.addDefault("prepared-locations-limit", 3, "How many locations can be prepared per world when using AT's Rapid Response system.\n" + + "These are immediately prepared upon startup and when a world is loaded."); + config.addDefault("ignore-world-generators", new ArrayList<>(Arrays.asList( + "us.talabrek.ultimateskyblock.world.SkyBlockChunkGenerator", + "us.talabrek.ultimateskyblock.world.SkyBlockNetherChunkGenerator", + "world.bentobox.bskyblock.generators.ChunkGeneratorWorld", + "world.bentobox.acidisland.world.ChunkGeneratorWorld", + "world.bentobox.oneblock.generators.ChunkGeneratorWorld", + "com.wasteofplastic.askyblock.generators.ChunkGeneratorWorld", + "com.wasteofplastic.acidisland.generators.ChunkGeneratorWorld", + "b.a", + "com.chaseoes.voidworld.VoidWorld.VoidWorldGenerator", + "club.bastonbolado.voidgenerator.EmptyChunkGenerator")), "AT's Rapid Response system automatically loads locations for each world, but can be problematic on some worlds, mostly SkyBlock worlds.\n" + + "In response, this list acts as pro-active protection and ignores worlds generated using the following generators.\n" + + "This is provided as an option so you can have control over which worlds have locations load."); + config.addDefault("avoid-blocks", new ArrayList<>(Arrays.asList("WATER", "LAVA", "STATIONARY_WATER", "STATIONARY_LAVA")), + "Blocks that people must not be able to land in when using /tpr."); + config.addDefault("avoid-biomes", new ArrayList<>(Arrays.asList("OCEAN", "DEEP_OCEAN")), "Biomes that the plugin should avoid when searching for a location."); + config.addDefault("whitelist-worlds", false, "Whether or not /tpr should only be used in the worlds listed below."); + config.addDefault("redirect-to-whitelisted-worlds", true, "Whether or not players should be directed to a whitelisted world when using /tpr.\n" + + "When this option is disabled and the player tries to use /tpr in a non-whitelisted world, the command simply won't work."); + config.addDefault("allowed-worlds", new ArrayList<>(Arrays.asList("world", "world_nether")), "Worlds you can use /tpr in.\n" + + "If a player uses /tpr in a world that doesn't allow it, they will be teleported in the first world on the list instead.\n" + + "To make this feature effective, turn on \"whitelist-worlds\" above."); + + + config.addDefault("default-homes-limit", -1, "Homes", "The default maximum of homes people can have.\n" + + "This can be overridden by giving people permissions such as at.member.homes.10.\n" + + "To disable this, use -1 as provided by default."); + config.addDefault("add-bed-to-homes", true, "Whether or not the bed home should be added to /homes."); + config.addDefault("deny-homes-if-over-limit", false, "Whether or not players should be denied access to some of their homes if they exceed their homes limit.\n" + + "The homes denied access to will end up being their most recently set homes.\n" + + "For example, having homes A, B, C, D and E with a limit of 3 will deny access to D and E."); + config.addDefault("hide-homes-if-denied", false, "If homes should be hidden from /homes should they be denied access.\n" + + "If this is false, they will be greyed out in the /homes list."); + + config.addDefault("tpa-request-received", "none", "Notifications/Sounds", + "The sound played when a player receives a teleportation (tpa) request.\n" + + "For 1.16+, check https://hub.spigotmc.org/javadocs/spigot/org/bukkit/Sound.html for a list of sounds you can use\n" + + "For 1.15 and below, check https://www.spigotmc.org/threads/sounds-spigot-1-7-1-14-4-sound-enums.340452/ for a list of sounds down to 1.7.\n" + + "(Friendly reminder that 1.7.x is not supported though!)\n" + + "Set to \"none\" if you want no sound playing."); + config.addDefault("tpa-request-sent", "none", "The sound played when a player sends a teleportation (tpa) request."); + config.addDefault("tpahere-request-received", "none", "The sound played when a player receives a teleportation (tpahere) request."); + config.addDefault("tpahere-request-sent", "none", "The sound played when a player sends a teleportation (tpahere) request."); + + config.addDefault("used-teleport-causes", new ArrayList<>(Arrays.asList("COMMAND", "PLUGIN", "SPECTATE")), "Back", + "The teleport causes that the plugin must listen to allow players to teleport back to the previous location.\n" + + "You can see a full list of these causes at https://hub.spigotmc.org/javadocs/spigot/org/bukkit/event/player/PlayerTeleportEvent.TeleportCause.html"); + config.addDefault("back-search-radius", 5, "The cubic radius to search for a safe block when using /back.\n" + + "If a player teleports from an unsafe location and uses /back to return to it, the plugin will search all blocks within this radius to see if it is a safe place for the player to be moved to.\n" + + "It is recommend to avoid setting this option too high as this can have a worst case execution time of O(n^3) (e.g. run 27 times, 64, 125, 216 and so on).\n" + + "To disable, either set to 0 or -1."); + + + config.addDefault("teleport-to-spawn-on-first-join", true, "Spawn Management", + "Whether the player should be teleported to the spawnpoint when they join for the first time."); + config.addDefault("teleport-to-spawn-on-every-join", false, + "Whether the player should be teleported to the spawnpoint every time they join."); + + config.addComment("death-management", "Determines how and where players teleport when they die.\n" + + "Options include:\n" + + "- spawn - Teleports the player to the spawnpoint of either the world or specified by the plugin.\n" + + "- bed - Teleports to the player's bed.\n" + + "- anchor - 1.16+ only, teleports to the player's respawn anchor. However, due to limitations with Spigot's API, it may or may not always work. (add Player#getRespawnAnchor pls)\n" + + "- warp:Warp Name - Teleports the player to a specified warp. For example, if you want to teleport to Hub, you'd type warp:Hub\n" + + "- tpr - Teleports the player to a random location. Can only be used when the rapid response system is enabled." + + "- {default} - Uses the default respawn option, which is spawn unless set differently.\n" + + "If you're using EssentialsX Spawn and want AT to take over respawn mechanics, set respawn-listener-priority in EssX's config.yml file to lowest."); + + config.makeSectionLenient("death-management"); + config.addDefault("death-management.default", "spawn"); + config.addExample("death-management.world", "{default}"); + config.addExample("death-management.special-world", "warp:Special"); + config.addExample("death-management.another-world", "bed"); + + config.addDefault("default-permissions", new ArrayList<>(Arrays.asList("at.member.*", "at.member.warp.*")), "Permissions", + "The default permissions given to users without OP.\n" + + "By default, Advanced Teleport allows users without OP to use all member features.\n" + + "This allows for permission management without a permissions plugin, especially if a user doesn't understand how such plugins work.\n" + + "However, if you have a permissions plugin and Vault installed, you cannot make admin permissions work by default."); + config.addDefault("allow-admin-permissions-as-default-perms", false, "Allows admin permissions to be allowed as default permissions by default.\n" + + "If you want to use admin permissions, it's often recommended to use a permissions plugin such as LuckPerms.\n" + + "Do not enable this if you are unsure of the risks this option proposes."); + + config.save(); + System.out.println("Intermission"); + config.reload(); + + Assert.assertTrue(config.getBoolean("use-basic-teleport-features")); + Assert.assertTrue(config.getBoolean("use-warps")); + Assert.assertTrue(config.getBoolean("use-randomtp")); + Assert.assertTrue(config.getBoolean("use-homes")); + Assert.assertEquals(new ArrayList<>(), config.getStringList("disabled-commands")); + + Assert.assertEquals(60, config.getInteger("request-lifetime")); + Assert.assertTrue(config.getBoolean("allow-multiple-requests")); + Assert.assertTrue(config.getBoolean("notify-on-expire")); + + Assert.assertEquals(3, config.getInteger("warm-up-timer-duration")); + Assert.assertTrue(config.getBoolean("cancel-warm-up-on-rotation")); + Assert.assertTrue(config.getBoolean("cancel-warm-up-on-movement")); + + Assert.assertEquals("default", config.getString("per-command-warm-ups.tpa")); + + config.set("cooldown-duration", "60"); + Assert.assertEquals("60", config.getString("cooldown-duration")); + Assert.assertEquals(60, config.getInteger("cooldown-duration")); + Assert.assertEquals(new ArrayList<>(), config.getList("disabled-commands")); + } +} diff --git a/API/src/test/java/io/github/thatsmusic99/configurationmaster/AnnotatedConfigTest.java b/API/src/test/java/io/github/thatsmusic99/configurationmaster/AnnotatedConfigTest.java new file mode 100644 index 0000000..811f0d7 --- /dev/null +++ b/API/src/test/java/io/github/thatsmusic99/configurationmaster/AnnotatedConfigTest.java @@ -0,0 +1,19 @@ +package io.github.thatsmusic99.configurationmaster; + +import io.github.thatsmusic99.configurationmaster.config.ExampleConfig; +import org.junit.Test; + +import java.io.File; + +public class AnnotatedConfigTest { + + @Test + public void testAnnotatedConfig() throws Exception { + ExampleConfig config = new ExampleConfig(new File("test-config.yml")); + config.load(); + assert config.COMBAT_DURATION == 30; + + config.COMBAT_DURATION = 20; + config.save(); + } +} diff --git a/API/src/test/java/io/github/thatsmusic99/configurationmaster/CommandWhitelistTest.java b/API/src/test/java/io/github/thatsmusic99/configurationmaster/CommandWhitelistTest.java new file mode 100644 index 0000000..bc1c012 --- /dev/null +++ b/API/src/test/java/io/github/thatsmusic99/configurationmaster/CommandWhitelistTest.java @@ -0,0 +1,65 @@ +package io.github.thatsmusic99.configurationmaster; + +import io.github.thatsmusic99.configurationmaster.api.ConfigFile; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Sample configuration setup for CommandWhitelist by YouHaveTrouble + */ +public class CommandWhitelistTest { + + @Test + public void doCommandWhitelistTests() throws Exception { + ConfigFile config = ConfigFile.loadConfig(new File("command-whitelist.yml")); + + config.addDefault("messages.prefix", "CommandWhitelist > "); + config.addDefault("messages.command_denied", "No such command."); + config.addDefault("messages.subcommand_denied", "You cannot use this subcommand"); + config.addDefault("messages.no_permission", "You don't have permission to do this."); + config.addDefault("messages.no_such_subcommand", "No subcommand by that name."); + config.addDefault("messages.config_reloaded", "Configuration reloaded."); + config.addDefault("messages.added_to_whitelist", "Whitelisted command %s for permission %s"); + config.addDefault("messages.removed_from_whitelist", "Removed command %s from permission %s"); + config.addDefault("messages.group_doesnt_exist", "Group doesn't exist or error occured"); + + config.addComment("messages", "Messages use MiniMessage formatting (https://docs.adventure.kyori.net/minimessage.html#format)"); + + config.addDefault("use_protocollib", false, "Do not enable if you don't have issues with aliased commands.\nThis requires server restart to take effect."); + + config.makeSectionLenient("groups"); + List exampleCommands = new ArrayList<>(); + exampleCommands.add("example"); + List exampleSubCommands = new ArrayList<>(); + exampleSubCommands.add("example of"); + + config.addExample("groups.example.commands", exampleCommands, "This is the WHITELIST of commands that players will be able to see/use in the group \"example\""); + config.addExample("groups.example.subcommands", exampleSubCommands, "This is the BLACKLIST of subcommands that players will NOT be able to see/use in the group \"example\""); + config.addComment("groups.example", "All groups except from default require commandwhitelist.group. permission\ncommandwhitelist.group.example in this case\n If you wish to leave the list empty, put \"commands: []\" or \"subcommands: []\""); + + + List defaultCommands = new ArrayList<>(); + defaultCommands.add("help"); + defaultCommands.add("spawn"); + defaultCommands.add("bal"); + defaultCommands.add("balance"); + defaultCommands.add("baltop"); + defaultCommands.add("pay"); + defaultCommands.add("r"); + defaultCommands.add("msg"); + defaultCommands.add("tpa"); + defaultCommands.add("tpahere"); + defaultCommands.add("tpaccept"); + defaultCommands.add("tpdeny"); + defaultCommands.add("warp"); + List defaultSubcommands = new ArrayList<>(); + defaultSubcommands.add("help about"); + + + config.save(); + } +} diff --git a/API/src/test/java/io/github/thatsmusic99/configurationmaster/config/ExampleConfig.java b/API/src/test/java/io/github/thatsmusic99/configurationmaster/config/ExampleConfig.java new file mode 100644 index 0000000..0db457a --- /dev/null +++ b/API/src/test/java/io/github/thatsmusic99/configurationmaster/config/ExampleConfig.java @@ -0,0 +1,35 @@ +package io.github.thatsmusic99.configurationmaster.config; + +import io.github.thatsmusic99.configurationmaster.annotations.Example; +import io.github.thatsmusic99.configurationmaster.annotations.Option; +import io.github.thatsmusic99.configurationmaster.api.ConfigFile; +import io.github.thatsmusic99.configurationmaster.api.ConfigSection; +import org.jetbrains.annotations.NotNull; +import org.yaml.snakeyaml.error.YAMLException; + +import java.io.File; +import java.io.IOException; + +public class ExampleConfig extends ConfigFile { + + @Option(comment = "How long a player is considered to be in combat for.", section = "Combat") + public int COMBAT_DURATION = 30; + + @Option(comment = "Players who should not be in combat and why.", lenient = true) + @Example(key = "Thatsmusic99", value = "Git gud") + @Example(key = "Niestrat99", value = "Too handsome") + public ConfigSection DENIED_PLAYERS; + @Option(comment = "How long the grace period lasts.") + public int GRACE_PERIOD = 30; + + /** + * Used to load a config file without safety precautions taken by the API. + * + * @param file The config file to be loaded. + * @throws YAMLException if the file being loaded contains syntax errors. + * @see ConfigFile#loadConfig(File) + */ + public ExampleConfig(@NotNull File file) throws IOException, IllegalAccessException { + super(file); + } +} diff --git a/Bukkit/ConfigurationMaster-Bukkit.iml b/Bukkit/ConfigurationMaster-Bukkit.iml new file mode 100644 index 0000000..fa63d4b --- /dev/null +++ b/Bukkit/ConfigurationMaster-Bukkit.iml @@ -0,0 +1,12 @@ + + + + + + + SPIGOT + + + + + \ No newline at end of file diff --git a/Bukkit/pom.xml b/Bukkit/pom.xml new file mode 100644 index 0000000..b6ae390 --- /dev/null +++ b/Bukkit/pom.xml @@ -0,0 +1,58 @@ + + + + ConfigurationMaster + com.github.thatsmusic99 + v2.0.0-rc.3 + + 4.0.0 + + ConfigurationMaster-Bukkit + v2.0.0-rc.3 + + + 8 + 8 + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.3.0 + + false + false + /tmp + + + + package + + shade + + + + + + + + + + com.github.thatsmusic99 + ConfigurationMaster-API + v2.0.0-rc.2 + + + + org.spigotmc + spigot-api + 1.16.2-R0.1-SNAPSHOT + provided + + + + \ No newline at end of file diff --git a/src/main/java/io/github/thatsmusic99/configurationmaster/CMFile.java b/Bukkit/src/main/java/io/github/thatsmusic99/configurationmaster/CMFile.java similarity index 98% rename from src/main/java/io/github/thatsmusic99/configurationmaster/CMFile.java rename to Bukkit/src/main/java/io/github/thatsmusic99/configurationmaster/CMFile.java index d4e96bd..c3ef8cf 100644 --- a/src/main/java/io/github/thatsmusic99/configurationmaster/CMFile.java +++ b/Bukkit/src/main/java/io/github/thatsmusic99/configurationmaster/CMFile.java @@ -1,13 +1,15 @@ package io.github.thatsmusic99.configurationmaster; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.io.*; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; /** * CMFile is the specialised configuration file used by @@ -33,7 +35,9 @@ * conventions and to make it easier for others to use and understand. * * @author Holly (Thatsmusic99) + * @deprecated Please use the ConfigFile class instead. */ +@Deprecated public abstract class CMFile { // The actual configuration file. @@ -90,6 +94,9 @@ public CMFile(Plugin plugin, String name) { * @param name The name of the config file. */ public CMFile(Plugin plugin, File folder, String name) { + plugin.getLogger().severe("This plugin is using the CMFile class in ConfigurationMaster. " + + "This class is being removed in the next major release as it has been replaced by the standalone ConfigFile." + + "Please urge the developer to update their ConfigurationMaster code as soon as possible."); this.plugin = plugin; this.folder = folder; this.name = name; @@ -1124,9 +1131,9 @@ public boolean isNew() { * * @return The config file as a FileConfiguration object. */ - @Nullable + @Deprecated public FileConfiguration getConfig() { - return config; + return YamlConfiguration.loadConfiguration(configFile); } /** diff --git a/src/main/java/io/github/thatsmusic99/configurationmaster/ConfigurationMaster.java b/Bukkit/src/main/java/io/github/thatsmusic99/configurationmaster/ConfigurationMaster.java similarity index 100% rename from src/main/java/io/github/thatsmusic99/configurationmaster/ConfigurationMaster.java rename to Bukkit/src/main/java/io/github/thatsmusic99/configurationmaster/ConfigurationMaster.java diff --git a/src/main/resources/plugin.yml b/Bukkit/src/main/resources/plugin.yml similarity index 85% rename from src/main/resources/plugin.yml rename to Bukkit/src/main/resources/plugin.yml index 3228800..bdd50bd 100644 --- a/src/main/resources/plugin.yml +++ b/Bukkit/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ main: io.github.thatsmusic99.configurationmaster.ConfigurationMaster name: ConfigurationMaster -version: 1.0.2 +version: 2.0.0-BETA-3 author: Thatsmusic99 api-version: 1.13 \ No newline at end of file diff --git a/pom.xml b/pom.xml index 8c80932..ef18c5a 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,8 @@ com.github.thatsmusic99 ConfigurationMaster - v1.0.2 + pom + v2.0.0-rc.3 @@ -22,6 +23,10 @@ + + API + Bukkit + @@ -31,19 +36,18 @@ - - org.spigotmc - spigot-api - 1.16.2-R0.1-SNAPSHOT + org.jetbrains + annotations + 24.0.1 provided - org.jetbrains - annotations - 13.0 + junit + junit + 4.13.2 + test - \ No newline at end of file