Class AbstractConfigEntity

java.lang.Object
com.ultikits.ultitools.abstracts.AbstractConfigEntity

public abstract class AbstractConfigEntity extends Object
Abstract class representing a configuration entity.

Precondition for subclasses (#363, counts re-measured for #510): the constructor must be cheap and free of side effects. For any class declaring at least one @ConfigEntry field, the framework constructs and discards throwaway instances of this class on the paths below, and a constructor that opens a file, registers a listener, or otherwise does real work pays that cost again on every one of those events, purely to be thrown away:

Measured totals per operation: init() 2, reload() 2, a panel write 2, save() 1, and the shutdown check 1 per configuration (2 when it goes on to save). save() and the shutdown check construct nothing before 6.3.0 and are the two paths a module author is most likely to consider exempt. A class with zero @ConfigEntry fields constructs nothing on any of them. The documented super(configFilePath)-only idiom is unaffected - each construction is a single, trivial reflective call.

Saved-state snapshot (#510, since 6.3.0): every entity remembers what its file on disk holds as of the last time the framework read or wrote it - after init(UltiToolsPlugin) (including a first-boot defaults write), after reload(), after every successful save(), and after every successful updateProperties(JsonObject) - together with a SHA-256 fingerprint of the file at that same point. The snapshot is derived from the file's text, plus this class's declared defaults for keys the text does not contain, and never from this instance's fields, so no unwritten in-memory change can ever be recorded as saved. The shutdown save (ConfigManager#saveAll()) writes only the entities for which isModifiedSinceSnapshot() is true, so an operator's edit to a file whose configuration no module code changed survives a restart. An explicit save() call still writes unconditionally.

Thread safety (#510): the framework's own read, write, snapshot and comparison paths - init(UltiToolsPlugin)'s and reload()'s load, save(), updateProperties(JsonObject), validateProposedProperties(JsonObject) and the two snapshot checks - run under this entity's own monitor, which ConfigManager#saveAll() also holds across its check-then-save of this entity. A panel write arriving on the WebSocket thread and the shutdown save therefore each see the other's whole effect or none of it. Field setters in module code are not synchronized by the framework.

That monitor is held across file I/O and across the constructions listed above, so it is also a new direction of blocking: a panel write on the WebSocket thread holds it across validation, the write and the snapshot, and a module calling save() on the server thread waits for that span. Both are bounded by small configuration files and by the cheap-constructor precondition.

  • Field Details

    • LOGGER

      private static final Logger LOGGER
    • WIDENING_ORDER

      private static final List<Class<?>> WIDENING_ORDER
      The numeric wrappers in JLS 5.1.2 widening order: every conversion from an earlier entry to a later one is a widening primitive conversion, and no other conversion between them is.
    • WIDENERS

      private static final List<Function<Number,Object>> WIDENERS
      Converts a Number to the wrapper at the same index of WIDENING_ORDER.
    • configFilePath

      private final String configFilePath
    • changeListeners

      private final List<ConfigChangeListener> changeListeners
    • ultiToolsPlugin

      private UltiToolsPlugin ultiToolsPlugin
    • config

      private org.bukkit.configuration.file.YamlConfiguration config
    • savedSnapshot

      private volatile String savedSnapshot
      The canonical form (see canonicalize(String)) of the text on disk as of the last snapshot point (#510), or null before the first successful snapshot - which isModifiedSinceSnapshot() treats as modified, so shutdown keeps the pre-#510 "save it" behaviour for an entity that was never in sync with its file. A serialized string, never a reference to or shallow copy of the field values: a module that mutates a collection field in place (UltiChat's auto-reply rules map) must still be detected as changed.
    • savedFileFingerprint

      private volatile String savedFileFingerprint
      Fingerprint of the file on disk as of the last snapshot point (#510), see fingerprintOf(File); null before the first snapshot.
    • lastLoadUnparseable

      private volatile boolean lastLoadUnparseable
      Whether the last attempt to read this configuration's file failed to parse (#510). While set, the framework does not know what the file holds, so isModifiedSinceSnapshot() reports false and the shutdown save leaves the file alone rather than overwriting an operator's broken file with the in-memory state. Cleared by the next successful load or save.
    • lastInitIncomplete

      private volatile boolean lastInitIncomplete
      Whether the last init(com.ultikits.ultitools.abstracts.UltiToolsPlugin) did not run to the end of its validation -- set when it starts, cleared only after validateFields() succeeds. A caller that catches the IOException of a failed write-back (as ConfigManager does) is otherwise left with an entity whose fields hold the file's new values unvalidated (#533). Kept on the entity so that it lives and dies with it; nothing else has to remember to clear it (Codex round 3 on #536).
    • bindingRanges

      private final Map<String,Map<String,Predicate<Long>>> bindingRanges
      The value ranges a config binding (#531) imposes on this entity's keys, by @ConfigEntry path, then by rule text. A panel write (updateProperties(com.google.gson.JsonObject), validateProposedProperties(com.google.gson.JsonObject)) is refused when it sets a bound key outside its range, the same way as a @Range violation. init(com.ultikits.ultitools.abstracts.UltiToolsPlugin) and reload() do not consult this: the binding step handles an invalid value there (it refuses the module at load and keeps the running value on reload).
  • Constructor Details

    • AbstractConfigEntity

      public AbstractConfigEntity(String configFilePath)
      Constructor for AbstractConfigEntity.
      Parameters:
      configFilePath - the path to the configuration file, for example: config/config.yml
  • Method Details

    • save

      public void save() throws IOException
      Saves the configuration to the file.

      An explicit call always writes, whether or not anything changed since the last snapshot. Only the shutdown save (ConfigManager#saveAll()) is conditional on isModifiedSinceSnapshot() (#510). A successful write refreshes the snapshot; a failed write leaves the previous snapshot in place, so the entity stays modified and the shutdown save retries it.

      Throws:
      IOException - if an I/O error occurs
    • applyFieldsTo

      private void applyFieldsTo(org.bukkit.configuration.file.YamlConfiguration target)
      Copies every non-null @ConfigEntry field, serialized through its declared parser, onto target. The one serialization path shared by save(), renderSaveText() and canonicalizeOnce(String, AbstractConfigEntity, java.util.List), so the shutdown comparison renders exactly what save() would write (#510).
      Parameters:
      target - the configuration to write the serialized field values into
    • renderSaveText

      private String renderSaveText()
      Renders the exact text save() would write right now, without writing it and without touching the live config (which toJsonObject() still reports to the panel). The live configuration is copied through its own YAML text - comments included - and the fields are applied to the copy through applyFieldsTo(YamlConfiguration), the same path save() uses.
      Returns:
      the rendered text, or null if the live configuration's own text cannot be parsed back
    • canonicalize

      private String canonicalize(String text)
      Brings a configuration text to the form this entity would give it after reading it and saving it again (#510): the text is parsed, a throwaway instance of this class (one per call, see the construction counts in this class's own javadoc) loads every present @ConfigEntry key through its parser exactly as init(UltiToolsPlugin) does (absent keys keep that instance's declared defaults), and the instance's fields are applied back onto the parsed text through applyFieldsTo(YamlConfiguration). Two passes make the result stable, because a default filled in for an absent key by the first pass is re-read through the parser by the second.

      Both sides of the shutdown comparison go through this: the snapshot canonicalizes the text on disk, isModifiedSinceSnapshot() canonicalizes what save() would write now. Parser quirks therefore cancel out (for example DefaultConfigParser reading the integers of a YAML list back as strings), while any in-memory value the file does not hold - a field a partial panel write did not touch, a key missing from a reloaded file - still differs.

      Parameters:
      text - a YAML text, possibly null
      Returns:
      the canonical text, or null if text is null or cannot be parsed
    • configEntryFields

      private List<Field> configEntryFields()
      Every @ConfigEntry field this class declares or inherits, in ReflectionUtil.getFields(Class) order.
      Returns:
      the annotated fields, possibly empty
    • renderParsed

      private String renderParsed(String text)
      Parses text and renders it back, with no field applied - the whole of canonicalize(String) for a class that declares no @ConfigEntry field.
      Parameters:
      text - a YAML text
      Returns:
      the re-rendered text, or null if it cannot be parsed
    • canonicalizeOnce

      private String canonicalizeOnce(String text, AbstractConfigEntity probe, List<Field> configFields)
      Parameters:
      text - a YAML text, possibly null
      probe - the throwaway instance this pass reads text into
      configFields - this class's @ConfigEntry fields, never empty
      Returns:
      the text after one read-and-render pass, or null if it cannot be parsed
    • takeSnapshot

      private void takeSnapshot()
      Records the snapshot and the on-disk file fingerprint (#510). Called, under this entity's monitor, right after init(UltiToolsPlugin) or reload() has read the file and right after save() or updateProperties(JsonObject) has written it - at each of those points the live config holds exactly the file's content, and the snapshot is derived from that text alone.

      Never throws. If the snapshot cannot be computed, it is cleared rather than left stale, so the entity counts as modified and shutdown saves it exactly as it did before #510, and a WARNING says so; a fingerprint that cannot be computed is recorded as "unreadable".

    • isModifiedSinceSnapshot

      @Internal public final boolean isModifiedSinceSnapshot()
      Whether this entity's current state differs from what its file held when the framework last read or wrote it (#510): the canonical form of the text save() would write now is compared with the snapshot. This is what the shutdown save uses to decide whether to write this configuration at all.

      Framework-internal: this method is called only by ConfigManager#saveAll() and is public solely because ConfigManager lives in another package. Module code should not call it.

      An entity that was never initialized has nothing to save and reports false. An entity with no snapshot yet (its first-boot defaults write failed, or its snapshot could not be computed) reports true, preserving the pre-#510 behaviour of saving it at shutdown. A map field whose entries were only reordered also reports true, because serialization follows the map's iteration order; saving it is harmless and matches the pre-#510 behaviour.

      Two cases deliberately report false. An entity whose file failed to parse the last time it was read (isLastLoadUnparseable()) is never written by the shutdown save: the framework does not know what that file holds, so overwriting it with the in-memory state would destroy an operator's broken-but-recoverable file. And a key the file does not contain whose in-memory value equals this class's declared default is indistinguishable from the file's own implied state; it is not written at shutdown, and the next load produces the same value anyway.

      Returns:
      true if the shutdown save should write this configuration
      Since:
      6.3.0
    • isLastLoadUnparseable

      @Internal public final boolean isLastLoadUnparseable()
      Whether the last attempt to read this configuration's file failed to parse (#510) - in practice, whether the file on disk holds invalid YAML. The shutdown save skips such a configuration and says so, instead of overwriting a file the framework could not read.

      Framework-internal: this method is called only by ConfigManager#saveAll() and is public solely because ConfigManager lives in another package. Module code should not call it.

      Returns:
      true if the last load of this configuration's file failed to parse, and no successful load or save has happened since
      Since:
      6.3.0
    • isLastInitIncomplete

      @Internal public final boolean isLastInitIncomplete()
      Whether the last init(com.ultikits.ultitools.abstracts.UltiToolsPlugin) failed before its field validation completed -- for example because writing back a missing key threw an IOException, which ConfigManager logs and continues past. The fields may then hold values that their validation annotations never checked. The config-bound @Scheduled/@CmdCD step (#531) does not apply values from such an entity: it refuses the module at load and keeps the running value on reload.

      Thread contract: the marker is set by init and read by the binding step on the main server thread, where the framework and the first-party modules run both. An init or reload() run off the main thread is not synchronized with the binding step; confining them to the main thread is tracked in UltiKits/UltiTools-Reborn#538.

      Framework-internal: public solely because the binding step lives in another package. Module code should not call it.

      Returns:
      true if the last init did not complete its validation
      Since:
      6.3.0
    • addBindingRange

      @Internal public final void addBindingRange(String key, String rule, Predicate<Long> isValid)
      Records that key is bound by a config-bound @Scheduled or @CmdCD (#531), so that a panel write setting it to a value isValid rejects is refused, naming the key, the value and rule. The binding step calls this when it resolves the binding at load. Recording the same rule twice for a key keeps one.

      Framework-internal: public solely because the binding step lives in another package. Module code should not call it.

      Parameters:
      key - the @ConfigEntry path, or the field name when the path is empty
      rule - the range, as a sentence, for the refusal message
      isValid - whether a value, in seconds (null for a null boxed field), is inside the range
      Since:
      6.3.0
    • isFileModifiedSinceSnapshot

      @Internal public final boolean isFileModifiedSinceSnapshot()
    • fingerprintOf

      private static String fingerprintOf(File file)
      Fingerprints a configuration file by the SHA-256 digest of its bytes (#510). A content hash rather than size plus modification time: a typical operator edit changes one value to another of the same length (60 to 90, true to TRUE), which size alone cannot see, and modification time is coarse or preserved on common paths (two-second resolution on FAT and many network shares, cp -p, rsync -t, editors that restore it). Configuration files are small and this runs only at load, save and shutdown.
      Parameters:
      file - the configuration file, possibly null or missing
      Returns:
      "absent" for a missing file, "unreadable" if it cannot be read, or the Base64 SHA-256 digest of its contents
    • init

      public final void init(UltiToolsPlugin ultiToolsPlugin) throws IOException
      Initializes the configuration entity.
      Parameters:
      ultiToolsPlugin - the plugin instance
      Throws:
      IOException - if an I/O error occurs
    • readConfigValue

      private static Object readConfigValue(Field field, ConfigEntry annotation, Object raw)
      The one conversion from a raw YAML value to the value stored in a @ConfigEntry field: the entry's parser, then widenToFieldType(java.lang.Class<?>, java.lang.Object). Every place that reads the file into a field goes through here -- init(com.ultikits.ultitools.abstracts.UltiToolsPlugin), reload() and the #510 snapshot probe in canonicalizeOnce -- so the three cannot drift apart again. Round 2 of #531 gate-1 CR-01 found the snapshot probe still unwidened: a Long field made every snapshot fail, and the shutdown save then overwrote operator edits (the #510 defect, reinstated).
      Parameters:
      field - the target @ConfigEntry field
      annotation - its @ConfigEntry
      raw - the value SnakeYAML returned for the entry's path, never null
      Returns:
      the value to store in field
    • widenToFieldType

      static Object widenToFieldType(Class<?> fieldType, Object value)
      Gives a boxed numeric field exactly the widening conversions its primitive already gets.

      SnakeYAML hands back an Integer for a whole number such as 1800. Field.set widens that into a long or double field, but it refuses the same value for a Long, Double or Float field (measured: IllegalArgumentException: Can not set java.lang.Long field ... to java.lang.Integer). A boxed field therefore loaded on the first boot, when the key was missing and the field default was written, and threw on every later boot and on every reload (#531, gate-1 CR-01).

      Only the JLS 5.1.2 widening primitive conversions are applied, so a boxed field accepts exactly what its primitive accepts: Short from Byte; Integer from Byte/Short; Long from Byte/Short/Integer; Float from any integral value; Double from any integral value or a Float. Anything else -- a narrowing conversion, a non-numeric value, a field that is not a numeric wrapper -- is returned unchanged, so Field.set refuses it exactly as before.

      Parameters:
      fieldType - the declared type of the target field
      value - the parsed value
      Returns:
      value widened to fieldType, or value itself
    • splitComment

      private static List<String> splitComment(String comment)
      Splits a @ConfigEntry.comment() value into one List element per line, in declaration order, ready for ConfigurationSection's comment-writing API. No blank leading element is added (Claude's Discretion, D-07) - it would produce a diff on every regenerated file for a purely cosmetic gain, contrary to D-01's touch-as-little-as-possible posture.
      Parameters:
      comment - the raw comment() attribute value, possibly empty
      Returns:
      one element per line, or an empty list if comment is blank
    • updateProperties

      public void updateProperties(com.google.gson.JsonObject jsonObject) throws IOException
      Updates the properties of the configuration entity.

      The field traversal and path-derivation must match init() / save() / reload() exactly, or a write can silently no-op: all four methods walk @ConfigEntry fields, and this method previously diverged from them in two ways -- using getDeclaredFields() instead of ReflectionUtil.getFields(Class) (missing inherited fields), and not normalizing an empty path to the field name. The second divergence was the more hidden one: omitting path on @ConfigEntry is a supported style, init/save read and write it by field name, and toJsonObject() also sends it to the panel by field name -- but this method was looking for an empty-string key in the JSON, which never exists, so the field was silently skipped while config.save still ran and the caller still received success.

      Since 6.3.0 (SILENT-14, closing CR-01) this method validates the full post-update field state - the same validateFields() init(UltiToolsPlugin)/reload() already use - before either config.set(...) or config.save(...) runs. A violating value refuses with ConfigurationException instead of being written: the operator's file is left byte-identical, and every field this call touched is restored to the value it held before the call, so memory never disagrees with disk (D-01, D-04). Unlike reload(), this entity keeps running after a refusal, so its in-memory state must not be left holding a rejected value.

      Parameters:
      jsonObject - the JSON object containing the new properties
      Throws:
      IOException - if an I/O error occurs
      ConfigurationException - with ErrorCode.CONFIG_VALIDATION_FAILED if the post-update field state violates a @Range/ @NotEmpty/@Size/@Pattern constraint - the file is not written and touched fields are restored
    • validateProposedProperties

      public void validateProposedProperties(com.google.gson.JsonObject jsonObject)
      Validates that applying jsonObject's touched fields would NOT violate any @Range/@NotEmpty/@Size/@Pattern constraint, without persisting anything to disk or leaving any field changed (#358 Part 2).

      Used by ConfigManager.loadFromJson(String) to validate every entity touched by a multi-file panel-pushed batch BEFORE persisting any of them - a refusal on entity N must not leave entities 1..N-1 already written to disk. This method always restores every field it touched, whether validation passes or fails; the caller is expected to call updateProperties(JsonObject) itself afterward (which re-validates - cheap on the documented idiom - and persists) once every entity in its own batch has passed this check.

      Parameters:
      jsonObject - the JSON object containing the candidate new properties
      Throws:
      ConfigurationException - with ErrorCode.CONFIG_VALIDATION_FAILED if the candidate post-update field state would violate a validation constraint
    • applyAndValidate

      private void applyAndValidate(com.google.gson.JsonObject jsonObject, List<Field> touchedFieldsOut, List<Object> previousValuesOut)
      Applies every jsonObject field this class declares via @ConfigEntry to this instance, then validates the resulting state via validateFields(). Never itself persists or restores anything - callers decide what happens next. touchedFieldsOut/previousValuesOut are populated even when validateFields() throws, so a caller can still restore exactly what this call touched.
      Parameters:
      jsonObject - the JSON object containing the candidate new properties
      touchedFieldsOut - populated, in application order, with every field this call applied
      previousValuesOut - populated in the same order with each field's pre-call value
      Throws:
      ConfigurationException - with ErrorCode.CONFIG_VALIDATION_FAILED if the post-update field state violates a constraint
    • validateBindingRanges

      private void validateBindingRanges(List<Field> touchedFields)
      Refuses a panel write that sets a key bound by a config binding (#531) outside that binding's range, in the same shape as a @Range violation. Only the fields this write touched are checked: a bound field left as it was may hold a value a refused reload kept out of use, and editing another key must not fail because of it.
      Parameters:
      touchedFields - the fields this write applied
      Throws:
      ConfigurationException - with ErrorCode.CONFIG_VALIDATION_FAILED naming each bound key, its value and its range
    • toJsonObject

      public com.google.gson.JsonObject toJsonObject()
      Converts the configuration entity to a JSON object.
      Returns:
      the JSON object representation of the configuration entity
    • getComments

      public com.google.gson.JsonObject getComments()
      Gets the comments of the configuration entity.
      Returns:
      a JSON object containing the comments
    • validateFields

      protected void validateFields()
      Validates all fields annotated with validation annotations (@Range, @NotEmpty, @Size, @Pattern). A violation refuses this config's module instead of rewriting the value - the operator's file is never modified (D-01). Every violating field is collected and named in a single refusal; the module author must fix the value(s) on disk and restart.
      Throws:
      ConfigurationException - with ErrorCode.CONFIG_VALIDATION_FAILED if any field violates its validation constraint, or if this config class cannot be constructed through either of the two framework-supported idioms (D-03)
    • ensureConstructable

      private void ensureConstructable()
      Constructs and discards an instance of this config class through the same two-step fallback ConfigManager.registerAll uses at registration time - a (String) constructor first, then an accessible no-arg constructor. Existence, not the constructed value, is what this proves: the framework needs every registered config class to still be buildable through one of its two documented idioms (D-02). Neither resolving is a genuine config-class error (D-03).
      Throws:
      ConfigurationException - if neither constructor resolves
    • constructSibling

      private AbstractConfigEntity constructSibling()
      Constructs a fresh instance of this config class through the (String) constructor, or failing that the no-arg constructor - the two idioms ensureConstructable() proves - for ensureConstructable() and for the throwaway reader canonicalizeOnce(String, AbstractConfigEntity, java.util.List) uses (#510).
      Returns:
      a new, uninitialized instance of this entity's class
      Throws:
      ConfigurationException - if neither constructor resolves
    • validateSingleField

      private String validateSingleField(Field field) throws IllegalAccessException
      Describes the single validation constraint field violates, if any.
      Parameters:
      field - the field to check, already made accessible
      Returns:
      a violation description naming the field, its actual value (redacted for @Pattern on a secret-shaped field name, T-04-04) and the broken constraint, or null if the field's value satisfies its annotations
      Throws:
      IllegalAccessException
    • isSecretShapedFieldName

      private boolean isSecretShapedFieldName(String fieldName)
      Whether a field name looks like it stores a secret. Only @Pattern violations echo an arbitrary string value; @Range/@Size violations always echo a number or a length, and @NotEmpty violations are empty by definition, so neither can leak a secret verbatim (T-04-04).

      Widened in 6.3.0 (04-REVIEW.md WR-03) to also cover key/auth/ private/cert, accepting the resulting false positives (a field merely named e.g. publicKey is redacted too) as the safer default, per Phase 2 D-15's fail-closed preference. The reason for the widening is new, not cosmetic: before the write-path refusal added by this same 6.3.0 change, a @Pattern refusal message went only to the local console; now both remote config-write handlers forward it verbatim to UltiPanel over the WebSocket (T-04-56), so a name-heuristic miss here leaks the server, not just the console.

    • isRangeViolation

      private boolean isRangeViolation(Field field, Object value)
    • isNotEmptyViolation

      private boolean isNotEmptyViolation(Field field, Object value)
    • isSizeViolation

      private boolean isSizeViolation(Field field, Object value)
    • isPatternViolation

      private boolean isPatternViolation(Field field, Object value)
    • getValueLength

      private int getValueLength(Object value)
    • addChangeListener

      public void addChangeListener(ConfigChangeListener listener)
      Adds a configuration change listener. The listener will be notified when the configuration is reloaded.
      Parameters:
      listener - the listener to add
    • removeChangeListener

      public void removeChangeListener(ConfigChangeListener listener)
      Removes a configuration change listener.
      Parameters:
      listener - the listener to remove
    • clearChangeListeners

      public void clearChangeListeners()
      Removes all configuration change listeners.
    • getChangeListenerCount

      public int getChangeListenerCount()
      Gets the number of registered change listeners.
      Returns:
      the number of listeners
    • notifyChangeListeners

      protected void notifyChangeListeners()
      Notifies all registered listeners about the configuration change. Individual listener exceptions do not affect other listeners.
    • reload

      public void reload() throws IOException
      Reloads the configuration from file and notifies all listeners.
      Throws:
      IOException - if an I/O error occurs