Improved Configuration and YAML Management (#2966)
# Description of Changes **What was changed:** - **Configuration Updates:** Replaced all calls to `GeneralUtils.saveKeyToConfig` with the new `GeneralUtils.saveKeyToSettings` method across multiple classes (e.g., `LicenseKeyChecker`, `InitialSetup`, `SettingsController`, etc.). This update ensures consistent management of configuration settings. - **File Path and Exception Handling:** Updated file path handling in `SPDFApplication` by creating `Path` objects from string paths and logging these paths for clarity. Also refined exception handling by catching more specific exceptions (e.g., using `IOException` instead of a generic `Exception`). - **Analytics Flag and Rate Limiting:** Changed the analytics flag in the application properties from a `String` to a `Boolean`, and updated related logic in `AppConfig` and `PostHogService`. The rate-limiting property retrieval in `AppConfig` was also refined for clarity. - **YAML Configuration Management:** Replaced the previous manual, line-based YAML merging logic in `ConfigInitializer` with a new `YamlHelper` class. This helper leverages the SnakeYAML engine to load, update, and save YAML configurations more robustly while preserving comments and formatting. **Why the change was made:** - **Improved Maintainability:** Consolidating configuration update logic into a single utility method (`saveKeyToSettings`) reduces code duplication and simplifies future maintenance. - **Enhanced Robustness:** The new `YamlHelper` class ensures that configuration files are merged accurately and safely, minimizing risks of data loss or format corruption. - **Better Type Safety and Exception Handling:** Switching the analytics flag to a Boolean and refining exception handling improves code robustness and debugging efficiency. - **Clarity and Consistency:** Standardizing file path handling and logging practices enhances code readability across the project. **Challenges encountered:** - **YAML Merging Complexity:** Integrating the new `YamlHelper` required careful handling to preserve existing settings, comments, and formatting during merges. - **Type Conversion and Backward Compatibility:** Updating the analytics flag from a string to a Boolean required extensive testing to ensure backward compatibility and proper functionality. - **Exception Granularity:** Refactoring exception handling from a generic to a more specific approach involved a detailed review to cover all edge cases. Closes #<issue_number> --- ## Checklist - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md) (if applicable) - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [x] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
@@ -96,9 +96,9 @@ public class AppConfig {
|
||||
|
||||
@Bean(name = "rateLimit")
|
||||
public boolean rateLimit() {
|
||||
String appName = System.getProperty("rateLimit");
|
||||
if (appName == null) appName = System.getenv("rateLimit");
|
||||
return (appName != null) ? Boolean.valueOf(appName) : false;
|
||||
String rateLimit = System.getProperty("rateLimit");
|
||||
if (rateLimit == null) rateLimit = System.getenv("rateLimit");
|
||||
return (rateLimit != null) ? Boolean.valueOf(rateLimit) : false;
|
||||
}
|
||||
|
||||
@Bean(name = "RunningInDocker")
|
||||
@@ -170,16 +170,14 @@ public class AppConfig {
|
||||
@Bean(name = "analyticsPrompt")
|
||||
@Scope("request")
|
||||
public boolean analyticsPrompt() {
|
||||
return applicationProperties.getSystem().getEnableAnalytics() == null
|
||||
|| "undefined".equals(applicationProperties.getSystem().getEnableAnalytics());
|
||||
return applicationProperties.getSystem().getEnableAnalytics() == null;
|
||||
}
|
||||
|
||||
@Bean(name = "analyticsEnabled")
|
||||
@Scope("request")
|
||||
public boolean analyticsEnabled() {
|
||||
if (applicationProperties.getEnterpriseEdition().isEnabled()) return true;
|
||||
return applicationProperties.getSystem().getEnableAnalytics() != null
|
||||
&& Boolean.parseBoolean(applicationProperties.getSystem().getEnableAnalytics());
|
||||
return applicationProperties.getSystem().isAnalyticsEnabled();
|
||||
}
|
||||
|
||||
@Bean(name = "StirlingPDFLabel")
|
||||
|
||||
@@ -9,7 +9,6 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@@ -37,7 +36,6 @@ public class ConfigInitializer {
|
||||
log.info("Created settings file from template");
|
||||
} else {
|
||||
// 2) Merge existing file with the template
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
URL templateResource = getClass().getClassLoader().getResource("settings.yml.template");
|
||||
if (templateResource == null) {
|
||||
throw new IOException("Resource not found: settings.yml.template");
|
||||
@@ -49,160 +47,33 @@ public class ConfigInitializer {
|
||||
Files.copy(in, tempTemplatePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
// 2a) Read lines from both files
|
||||
List<String> templateLines = Files.readAllLines(tempTemplatePath);
|
||||
List<String> mainLines = Files.readAllLines(settingsPath);
|
||||
// Copy setting.yaml to a temp location so we can read lines
|
||||
Path settingTempPath = Files.createTempFile("settings", ".yaml");
|
||||
try (InputStream in = Files.newInputStream(destPath)) {
|
||||
Files.copy(in, settingTempPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
// 2b) Merge lines
|
||||
List<String> mergedLines = mergeYamlLinesWithTemplate(templateLines, mainLines);
|
||||
YamlHelper settingsTemplateFile = new YamlHelper(tempTemplatePath);
|
||||
YamlHelper settingsFile = new YamlHelper(settingTempPath);
|
||||
|
||||
// 2c) Only write if there's an actual difference
|
||||
if (!mergedLines.equals(mainLines)) {
|
||||
Files.write(settingsPath, mergedLines);
|
||||
boolean changesMade =
|
||||
settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile);
|
||||
if (changesMade) {
|
||||
settingsTemplateFile.save(destPath);
|
||||
log.info("Settings file updated based on template changes.");
|
||||
} else {
|
||||
log.info("No changes detected; settings file left as-is.");
|
||||
}
|
||||
|
||||
Files.deleteIfExists(tempTemplatePath);
|
||||
Files.deleteIfExists(settingTempPath);
|
||||
}
|
||||
|
||||
// 3) Ensure custom settings file exists
|
||||
Path customSettingsPath = Paths.get(InstallationPathConfig.getCustomSettingsPath());
|
||||
if (!Files.exists(customSettingsPath)) {
|
||||
if (Files.notExists(customSettingsPath)) {
|
||||
Files.createFile(customSettingsPath);
|
||||
log.info("Created custom_settings file: {}", customSettingsPath.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge logic that: - Reads the template lines block-by-block (where a "block" = a key and all
|
||||
* the lines that belong to it), - If the main file has that key, we keep the main file's block
|
||||
* (preserving whitespace + inline comments). - Otherwise, we insert the template's block. - We
|
||||
* also remove keys from main that no longer exist in the template.
|
||||
*
|
||||
* @param templateLines lines from settings.yml.template
|
||||
* @param mainLines lines from the existing settings.yml
|
||||
* @return merged lines
|
||||
*/
|
||||
private List<String> mergeYamlLinesWithTemplate(
|
||||
List<String> templateLines, List<String> mainLines) {
|
||||
|
||||
// 1) Parse template lines into an ordered map: path -> Block
|
||||
LinkedHashMap<String, Block> templateBlocks = parseYamlBlocks(templateLines);
|
||||
|
||||
// 2) Parse main lines into a map: path -> Block
|
||||
LinkedHashMap<String, Block> mainBlocks = parseYamlBlocks(mainLines);
|
||||
|
||||
// 3) Build the final list by iterating template blocks in order
|
||||
List<String> merged = new ArrayList<>();
|
||||
for (Map.Entry<String, Block> entry : templateBlocks.entrySet()) {
|
||||
String path = entry.getKey();
|
||||
Block templateBlock = entry.getValue();
|
||||
|
||||
if (mainBlocks.containsKey(path)) {
|
||||
// If main has the same block, prefer main's lines
|
||||
merged.addAll(mainBlocks.get(path).lines);
|
||||
} else {
|
||||
// Otherwise, add the template block
|
||||
merged.addAll(templateBlock.lines);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a list of lines into a map of "path -> Block" where "Block" is all lines that belong to
|
||||
* that key (including subsequent indented lines). Very naive approach that may not work with
|
||||
* advanced YAML.
|
||||
*/
|
||||
private LinkedHashMap<String, Block> parseYamlBlocks(List<String> lines) {
|
||||
LinkedHashMap<String, Block> blocks = new LinkedHashMap<>();
|
||||
|
||||
Block currentBlock = null;
|
||||
String currentPath = null;
|
||||
|
||||
for (String line : lines) {
|
||||
if (isLikelyKeyLine(line)) {
|
||||
// Found a new "key: ..." line
|
||||
if (currentBlock != null && currentPath != null) {
|
||||
blocks.put(currentPath, currentBlock);
|
||||
}
|
||||
currentBlock = new Block();
|
||||
currentBlock.lines.add(line);
|
||||
currentPath = computePathForLine(line);
|
||||
} else {
|
||||
// Continuation of current block (comments, blank lines, sub-lines)
|
||||
if (currentBlock == null) {
|
||||
// If file starts with comments/blank lines, treat as "header block" with path
|
||||
// ""
|
||||
currentBlock = new Block();
|
||||
currentPath = "";
|
||||
}
|
||||
currentBlock.lines.add(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBlock != null && currentPath != null) {
|
||||
blocks.put(currentPath, currentBlock);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the line is likely "key:" or "key: value", ignoring comments/blank. Skips lines
|
||||
* starting with "-" or "#".
|
||||
*/
|
||||
private boolean isLikelyKeyLine(String line) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("-")) {
|
||||
return false;
|
||||
}
|
||||
int colonIdx = trimmed.indexOf(':');
|
||||
return (colonIdx > 0); // someKey:
|
||||
}
|
||||
|
||||
// For a line like "security: ", returns "security" or "security.enableLogin"
|
||||
// by looking at indentation. Very naive.
|
||||
private static final Deque<String> pathStack = new ArrayDeque<>();
|
||||
private static int currentIndentLevel = 0;
|
||||
|
||||
private String computePathForLine(String line) {
|
||||
// count leading spaces
|
||||
int leadingSpaces = 0;
|
||||
for (char c : line.toCharArray()) {
|
||||
if (c == ' ') leadingSpaces++;
|
||||
else break;
|
||||
}
|
||||
// assume 2 spaces = 1 indent
|
||||
int indentLevel = leadingSpaces / 2;
|
||||
|
||||
String trimmed = line.trim();
|
||||
int colonIdx = trimmed.indexOf(':');
|
||||
String keyName = trimmed.substring(0, colonIdx).trim();
|
||||
|
||||
// pop stack until we match the new indent level
|
||||
while (currentIndentLevel >= indentLevel && !pathStack.isEmpty()) {
|
||||
pathStack.pop();
|
||||
currentIndentLevel--;
|
||||
}
|
||||
|
||||
// push the new key
|
||||
pathStack.push(keyName);
|
||||
currentIndentLevel = indentLevel;
|
||||
|
||||
// build path by reversing the stack
|
||||
String[] arr = pathStack.toArray(new String[0]);
|
||||
List<String> reversed = Arrays.asList(arr);
|
||||
Collections.reverse(reversed);
|
||||
return String.join(".", reversed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple holder for the lines that comprise a "block" (i.e. a key and its subsequent lines).
|
||||
*/
|
||||
private static class Block {
|
||||
List<String> lines = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class InitialSetup {
|
||||
if (!GeneralUtils.isValidUUID(uuid)) {
|
||||
// Generating a random UUID as the secret key
|
||||
uuid = UUID.randomUUID().toString();
|
||||
GeneralUtils.saveKeyToConfig("AutomaticallyGenerated.UUID", uuid);
|
||||
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.UUID", uuid);
|
||||
applicationProperties.getAutomaticallyGenerated().setUUID(uuid);
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ public class InitialSetup {
|
||||
if (!GeneralUtils.isValidUUID(secretKey)) {
|
||||
// Generating a random UUID as the secret key
|
||||
secretKey = UUID.randomUUID().toString();
|
||||
GeneralUtils.saveKeyToConfig("AutomaticallyGenerated.key", secretKey);
|
||||
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.key", secretKey);
|
||||
applicationProperties.getAutomaticallyGenerated().setKey(secretKey);
|
||||
}
|
||||
}
|
||||
@@ -64,8 +64,8 @@ public class InitialSetup {
|
||||
"0.36.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) {
|
||||
Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled();
|
||||
if (!csrf) {
|
||||
GeneralUtils.saveKeyToConfig("security.csrfDisabled", false, false);
|
||||
GeneralUtils.saveKeyToConfig("system.enableAnalytics", "true", false);
|
||||
GeneralUtils.saveKeyToSettings("security.csrfDisabled", false);
|
||||
GeneralUtils.saveKeyToSettings("system.enableAnalytics", true);
|
||||
applicationProperties.getSecurity().setCsrfDisabled(false);
|
||||
}
|
||||
}
|
||||
@@ -76,14 +76,14 @@ public class InitialSetup {
|
||||
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
|
||||
if (StringUtils.isEmpty(termsUrl)) {
|
||||
String defaultTermsUrl = "https://www.stirlingpdf.com/terms-and-conditions";
|
||||
GeneralUtils.saveKeyToConfig("legal.termsAndConditions", defaultTermsUrl, false);
|
||||
GeneralUtils.saveKeyToSettings("legal.termsAndConditions", defaultTermsUrl);
|
||||
applicationProperties.getLegal().setTermsAndConditions(defaultTermsUrl);
|
||||
}
|
||||
// Initialize Privacy Policy
|
||||
String privacyUrl = applicationProperties.getLegal().getPrivacyPolicy();
|
||||
if (StringUtils.isEmpty(privacyUrl)) {
|
||||
String defaultPrivacyUrl = "https://www.stirlingpdf.com/privacy-policy";
|
||||
GeneralUtils.saveKeyToConfig("legal.privacyPolicy", defaultPrivacyUrl, false);
|
||||
GeneralUtils.saveKeyToSettings("legal.privacyPolicy", defaultPrivacyUrl);
|
||||
applicationProperties.getLegal().setPrivacyPolicy(defaultPrivacyUrl);
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ public class InitialSetup {
|
||||
appVersion = props.getProperty("version");
|
||||
} catch (Exception e) {
|
||||
}
|
||||
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
|
||||
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
|
||||
GeneralUtils.saveKeyToConfig("AutomaticallyGenerated.appVersion", appVersion, false);
|
||||
}
|
||||
}
|
||||
|
||||
479
src/main/java/stirling/software/SPDF/config/YamlHelper.java
Normal file
479
src/main/java/stirling/software/SPDF/config/YamlHelper.java
Normal file
@@ -0,0 +1,479 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.snakeyaml.engine.v2.api.Dump;
|
||||
import org.snakeyaml.engine.v2.api.DumpSettings;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
import org.snakeyaml.engine.v2.api.StreamDataWriter;
|
||||
import org.snakeyaml.engine.v2.common.FlowStyle;
|
||||
import org.snakeyaml.engine.v2.common.ScalarStyle;
|
||||
import org.snakeyaml.engine.v2.composer.Composer;
|
||||
import org.snakeyaml.engine.v2.nodes.MappingNode;
|
||||
import org.snakeyaml.engine.v2.nodes.Node;
|
||||
import org.snakeyaml.engine.v2.nodes.NodeTuple;
|
||||
import org.snakeyaml.engine.v2.nodes.ScalarNode;
|
||||
import org.snakeyaml.engine.v2.nodes.SequenceNode;
|
||||
import org.snakeyaml.engine.v2.nodes.Tag;
|
||||
import org.snakeyaml.engine.v2.parser.ParserImpl;
|
||||
import org.snakeyaml.engine.v2.scanner.StreamReader;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class YamlHelper {
|
||||
|
||||
// YAML dump settings with comment support and block flow style
|
||||
private static final DumpSettings DUMP_SETTINGS =
|
||||
DumpSettings.builder()
|
||||
.setDumpComments(true)
|
||||
.setWidth(Integer.MAX_VALUE)
|
||||
.setDefaultFlowStyle(FlowStyle.BLOCK)
|
||||
.build();
|
||||
|
||||
private final String yamlContent; // Stores the entire YAML content as a string
|
||||
|
||||
private LoadSettings loadSettings =
|
||||
LoadSettings.builder()
|
||||
.setUseMarks(true)
|
||||
.setMaxAliasesForCollections(Integer.MAX_VALUE)
|
||||
.setAllowRecursiveKeys(true)
|
||||
.setParseComments(true)
|
||||
.build();
|
||||
|
||||
private Path originalFilePath;
|
||||
private Node updatedRootNode;
|
||||
|
||||
// Constructor with custom LoadSettings and YAML string
|
||||
public YamlHelper(LoadSettings loadSettings, String yamlContent) {
|
||||
this.loadSettings = loadSettings;
|
||||
this.yamlContent = yamlContent;
|
||||
}
|
||||
|
||||
// Constructor that reads YAML from a file path
|
||||
public YamlHelper(Path originalFilePath) throws IOException {
|
||||
this.yamlContent = Files.readString(originalFilePath);
|
||||
this.originalFilePath = originalFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates values in the target YAML based on values from the source YAML. It ensures that only
|
||||
* existing keys in the target YAML are updated.
|
||||
*
|
||||
* @return true if at least one key was updated, false otherwise.
|
||||
*/
|
||||
public boolean updateValuesFromYaml(YamlHelper sourceYaml, YamlHelper targetYaml) {
|
||||
boolean updated = false;
|
||||
Set<String> sourceKeys = sourceYaml.getAllKeys();
|
||||
Set<String> targetKeys = targetYaml.getAllKeys();
|
||||
|
||||
for (String key : sourceKeys) {
|
||||
String[] keyArray = key.split("\\.");
|
||||
|
||||
Object newValue = sourceYaml.getValueByExactKeyPath(keyArray);
|
||||
Object currentValue = targetYaml.getValueByExactKeyPath(keyArray);
|
||||
if (newValue != null
|
||||
&& (!newValue.equals(currentValue) || !sourceKeys.equals(targetKeys))) {
|
||||
boolean updatedKey = targetYaml.updateValue(Arrays.asList(keyArray), newValue);
|
||||
if (updatedKey) updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a value in the YAML structure.
|
||||
*
|
||||
* @param keys The hierarchical keys leading to the value.
|
||||
* @param newValue The new value to set.
|
||||
* @return true if the value was updated, false otherwise.
|
||||
*/
|
||||
public boolean updateValue(List<String> keys, Object newValue) {
|
||||
return updateValue(getRootNode(), keys, newValue);
|
||||
}
|
||||
|
||||
private boolean updateValue(Node node, List<String> keys, Object newValue) {
|
||||
if (!(node instanceof MappingNode mappingNode)) return false;
|
||||
|
||||
List<NodeTuple> updatedTuples = new ArrayList<>();
|
||||
boolean updated = false;
|
||||
|
||||
for (NodeTuple tuple : mappingNode.getValue()) {
|
||||
ScalarNode keyNode = (tuple.getKeyNode() instanceof ScalarNode sk) ? sk : null;
|
||||
if (keyNode == null || !keyNode.getValue().equals(keys.get(0))) {
|
||||
updatedTuples.add(tuple);
|
||||
continue;
|
||||
}
|
||||
|
||||
Node valueNode = tuple.getValueNode();
|
||||
|
||||
if (keys.size() == 1) {
|
||||
Tag tag = valueNode.getTag();
|
||||
Node newValueNode = null;
|
||||
|
||||
if (isAnyInteger(newValue)) {
|
||||
newValueNode =
|
||||
new ScalarNode(Tag.INT, String.valueOf(newValue), ScalarStyle.PLAIN);
|
||||
} else if (isFloat(newValue)) {
|
||||
Object floatValue = Float.valueOf(String.valueOf(newValue));
|
||||
newValueNode =
|
||||
new ScalarNode(
|
||||
Tag.FLOAT, String.valueOf(floatValue), ScalarStyle.PLAIN);
|
||||
} else if ("true".equals(newValue) || "false".equals(newValue)) {
|
||||
newValueNode =
|
||||
new ScalarNode(Tag.BOOL, String.valueOf(newValue), ScalarStyle.PLAIN);
|
||||
} else if (newValue instanceof List<?> list) {
|
||||
List<Node> sequenceNodes = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
Object obj = String.valueOf(item);
|
||||
if (isAnyInteger(item)) {
|
||||
tag = Tag.INT;
|
||||
} else if (isFloat(item)) {
|
||||
obj = Float.valueOf(String.valueOf(item));
|
||||
tag = Tag.FLOAT;
|
||||
} else if ("true".equals(item) || "false".equals(item)) {
|
||||
tag = Tag.BOOL;
|
||||
} else if (item == null || "null".equals(item)) {
|
||||
tag = Tag.NULL;
|
||||
} else {
|
||||
tag = Tag.STR;
|
||||
}
|
||||
sequenceNodes.add(
|
||||
new ScalarNode(tag, String.valueOf(obj), ScalarStyle.PLAIN));
|
||||
}
|
||||
newValueNode = new SequenceNode(Tag.SEQ, sequenceNodes, FlowStyle.FLOW);
|
||||
} else if (tag == Tag.NULL) {
|
||||
if ("true".equals(newValue)
|
||||
|| "false".equals(newValue)
|
||||
|| newValue instanceof Boolean) {
|
||||
tag = Tag.BOOL;
|
||||
}
|
||||
newValueNode = new ScalarNode(tag, String.valueOf(newValue), ScalarStyle.PLAIN);
|
||||
} else {
|
||||
newValueNode = new ScalarNode(tag, String.valueOf(newValue), ScalarStyle.PLAIN);
|
||||
}
|
||||
copyComments(valueNode, newValueNode);
|
||||
|
||||
updatedTuples.add(new NodeTuple(keyNode, newValueNode));
|
||||
updated = true;
|
||||
} else if (valueNode instanceof MappingNode) {
|
||||
updated = updateValue(valueNode, keys.subList(1, keys.size()), newValue);
|
||||
updatedTuples.add(tuple);
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
mappingNode.getValue().clear();
|
||||
mappingNode.getValue().addAll(updatedTuples);
|
||||
}
|
||||
setNewNode(node);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a value based on an exact key path.
|
||||
*
|
||||
* @param keys The key hierarchy leading to the value.
|
||||
* @return The value if found, otherwise null.
|
||||
*/
|
||||
public Object getValueByExactKeyPath(String... keys) {
|
||||
return getValueByExactKeyPath(getRootNode(), new ArrayDeque<>(List.of(keys)));
|
||||
}
|
||||
|
||||
private Object getValueByExactKeyPath(Node node, Deque<String> keyQueue) {
|
||||
if (!(node instanceof MappingNode mappingNode)) return null;
|
||||
|
||||
String currentKey = keyQueue.poll();
|
||||
if (currentKey == null) return null;
|
||||
|
||||
for (NodeTuple tuple : mappingNode.getValue()) {
|
||||
if (tuple.getKeyNode() instanceof ScalarNode keyNode
|
||||
&& keyNode.getValue().equals(currentKey)) {
|
||||
if (keyQueue.isEmpty()) {
|
||||
Node valueNode = tuple.getValueNode();
|
||||
|
||||
if (valueNode instanceof ScalarNode scalarValueNode) {
|
||||
return scalarValueNode.getValue();
|
||||
} else if (valueNode instanceof MappingNode subMapping) {
|
||||
return getValueByExactKeyPath(subMapping, keyQueue);
|
||||
} else if (valueNode instanceof SequenceNode sequenceNode) {
|
||||
List<Object> valuesList = new ArrayList<>();
|
||||
for (Node o : sequenceNode.getValue()) {
|
||||
if (o instanceof ScalarNode scalarValue) {
|
||||
valuesList.add(scalarValue.getValue());
|
||||
}
|
||||
}
|
||||
return valuesList;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return getValueByExactKeyPath(tuple.getValueNode(), keyQueue);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Set<String> cachedKeys;
|
||||
|
||||
/**
|
||||
* Retrieves the set of all keys present in the YAML structure. Keys are returned as
|
||||
* dot-separated paths for nested keys.
|
||||
*
|
||||
* @return A set containing all keys in dot notation.
|
||||
*/
|
||||
public Set<String> getAllKeys() {
|
||||
if (cachedKeys == null) {
|
||||
cachedKeys = getAllKeys(getRootNode());
|
||||
}
|
||||
return cachedKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all keys from the YAML node recursively.
|
||||
*
|
||||
* @param node The current YAML node.
|
||||
* @param currentPath The accumulated path of keys.
|
||||
* @param allKeys The set storing all collected keys.
|
||||
*/
|
||||
private Set<String> getAllKeys(Node node) {
|
||||
Set<String> allKeys = new LinkedHashSet<>();
|
||||
collectKeys(node, "", allKeys);
|
||||
return allKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively traverses the YAML structure to collect all keys.
|
||||
*
|
||||
* @param node The current node in the YAML structure.
|
||||
* @param currentPath The accumulated key path.
|
||||
* @param allKeys The set storing collected keys.
|
||||
*/
|
||||
private void collectKeys(Node node, String currentPath, Set<String> allKeys) {
|
||||
if (node instanceof MappingNode mappingNode) {
|
||||
for (NodeTuple tuple : mappingNode.getValue()) {
|
||||
if (tuple.getKeyNode() instanceof ScalarNode keyNode) {
|
||||
String newPath =
|
||||
currentPath.isEmpty()
|
||||
? keyNode.getValue()
|
||||
: currentPath + "." + keyNode.getValue();
|
||||
allKeys.add(newPath);
|
||||
collectKeys(tuple.getValueNode(), newPath, allKeys);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the root node of the YAML document. If a new node was previously set, it is
|
||||
* returned instead.
|
||||
*
|
||||
* @return The root node of the YAML structure.
|
||||
*/
|
||||
private Node getRootNode() {
|
||||
if (this.updatedRootNode != null) {
|
||||
return this.updatedRootNode;
|
||||
}
|
||||
Composer composer = new Composer(loadSettings, getParserImpl());
|
||||
Optional<Node> rootNodeOpt = composer.getSingleNode();
|
||||
if (rootNodeOpt.isPresent()) {
|
||||
return rootNodeOpt.get();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a new root node, allowing modifications to be tracked.
|
||||
*
|
||||
* @param newRootNode The modified root node.
|
||||
*/
|
||||
public void setNewNode(Node newRootNode) {
|
||||
this.updatedRootNode = newRootNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current root node (either the original or the updated one).
|
||||
*
|
||||
* @return The root node.
|
||||
*/
|
||||
public Node getUpdatedRootNode() {
|
||||
if (this.updatedRootNode == null) {
|
||||
this.updatedRootNode = getRootNode();
|
||||
}
|
||||
return this.updatedRootNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the YAML parser.
|
||||
*
|
||||
* @return The configured parser.
|
||||
*/
|
||||
private ParserImpl getParserImpl() {
|
||||
return new ParserImpl(loadSettings, getStreamReader());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stream reader for the YAML content.
|
||||
*
|
||||
* @return The configured stream reader.
|
||||
*/
|
||||
private StreamReader getStreamReader() {
|
||||
return new StreamReader(loadSettings, yamlContent);
|
||||
}
|
||||
|
||||
public MappingNode save(Path saveFilePath) throws IOException {
|
||||
if (!saveFilePath.equals(originalFilePath)) {
|
||||
Files.writeString(saveFilePath, convertNodeToYaml(getUpdatedRootNode()));
|
||||
}
|
||||
return (MappingNode) getUpdatedRootNode();
|
||||
}
|
||||
|
||||
public void saveOverride(Path saveFilePath) throws IOException {
|
||||
Files.writeString(saveFilePath, convertNodeToYaml(getUpdatedRootNode()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a YAML node back to a YAML-formatted string.
|
||||
*
|
||||
* @param rootNode The root node to be converted.
|
||||
* @return A YAML-formatted string.
|
||||
*/
|
||||
public String convertNodeToYaml(Node rootNode) {
|
||||
StringWriter writer = new StringWriter();
|
||||
StreamDataWriter streamDataWriter =
|
||||
new StreamDataWriter() {
|
||||
@Override
|
||||
public void write(String str) {
|
||||
writer.write(str);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(String str, int off, int len) {
|
||||
writer.write(str, off, len);
|
||||
}
|
||||
};
|
||||
|
||||
new Dump(DUMP_SETTINGS).dumpNode(rootNode, streamDataWriter);
|
||||
return writer.toString();
|
||||
}
|
||||
|
||||
private static boolean isParsable(String value, Function<String, ?> parser) {
|
||||
try {
|
||||
parser.apply(value);
|
||||
return true;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given object is an integer.
|
||||
*
|
||||
* @param object The object to check.
|
||||
* @return True if the object represents an integer, false otherwise.
|
||||
*/
|
||||
@SuppressWarnings("UnnecessaryTemporaryOnConversionFromString")
|
||||
public static boolean isInteger(Object object) {
|
||||
if (object instanceof Integer
|
||||
|| object instanceof Short
|
||||
|| object instanceof Byte
|
||||
|| object instanceof Long) {
|
||||
return true;
|
||||
}
|
||||
if (object instanceof String str) {
|
||||
return isParsable(str, Integer::parseInt);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given object is a floating-point number.
|
||||
*
|
||||
* @param object The object to check.
|
||||
* @return True if the object represents a float, false otherwise.
|
||||
*/
|
||||
@SuppressWarnings("UnnecessaryTemporaryOnConversionFromString")
|
||||
public static boolean isFloat(Object object) {
|
||||
return (object instanceof Float || object instanceof Double)
|
||||
|| (object instanceof String str && isParsable(str, Float::parseFloat));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given object is a short integer.
|
||||
*
|
||||
* @param object The object to check.
|
||||
* @return True if the object represents a short integer, false otherwise.
|
||||
*/
|
||||
@SuppressWarnings("UnnecessaryTemporaryOnConversionFromString")
|
||||
public static boolean isShort(Object object) {
|
||||
return (object instanceof Long)
|
||||
|| (object instanceof String str && isParsable(str, Short::parseShort));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given object is a byte.
|
||||
*
|
||||
* @param object The object to check.
|
||||
* @return True if the object represents a byte, false otherwise.
|
||||
*/
|
||||
@SuppressWarnings("UnnecessaryTemporaryOnConversionFromString")
|
||||
public static boolean isByte(Object object) {
|
||||
return (object instanceof Long)
|
||||
|| (object instanceof String str && isParsable(str, Byte::parseByte));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given object is a long integer.
|
||||
*
|
||||
* @param object The object to check.
|
||||
* @return True if the object represents a long integer, false otherwise.
|
||||
*/
|
||||
@SuppressWarnings("UnnecessaryTemporaryOnConversionFromString")
|
||||
public static boolean isLong(Object object) {
|
||||
return (object instanceof Long)
|
||||
|| (object instanceof String str && isParsable(str, Long::parseLong));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an object is any type of integer (short, byte, long, or int).
|
||||
*
|
||||
* @param object The object to check.
|
||||
* @return True if the object represents an integer type, false otherwise.
|
||||
*/
|
||||
public static boolean isAnyInteger(Object object) {
|
||||
return isInteger(object) || isShort(object) || isByte(object) || isLong(object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies comments from an old node to a new one.
|
||||
*
|
||||
* @param oldNode The original node with comments.
|
||||
* @param newValueNode The new node to which comments should be copied.
|
||||
*/
|
||||
private void copyComments(Node oldNode, Node newValueNode) {
|
||||
if (oldNode == null || newValueNode == null) return;
|
||||
if (oldNode.getBlockComments() != null) {
|
||||
newValueNode.setBlockComments(oldNode.getBlockComments());
|
||||
}
|
||||
if (oldNode.getInLineComments() != null) {
|
||||
newValueNode.setInLineComments(oldNode.getInLineComments());
|
||||
}
|
||||
if (oldNode.getEndComments() != null) {
|
||||
newValueNode.setEndComments(oldNode.getEndComments());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user