Class UltiToolsPlugin

java.lang.Object
com.ultikits.ultitools.abstracts.UltiToolsPlugin
All Implemented Interfaces:
Configurable, IPlugin, Localized

public abstract class UltiToolsPlugin extends Object implements IPlugin, Localized, Configurable
Abstract class representing a plugin module.
Version:
1.0.0
Author:
wisdomme
  • Field Details

    • LANGUAGE_EXTENSIONS

      private static final String[] LANGUAGE_EXTENSIONS
      Language file extensions, in the order they are tried.

      .json stays first so a module shipping both keeps exactly the behaviour it had before 6.3.0. .yml and .yaml were added for #389: eight modules ship YAML, and the loader silently produced an empty dictionary for every one of them.

    • LOGGER

      private static final Logger LOGGER
      Named logger for the D-03 per-module reload line -- see RELOAD_LOG_MESSAGE_KEY.
    • SILENT_LANGUAGE_LOGGER

      private static final Logger SILENT_LANGUAGE_LOGGER
      Receives nothing: languageLog() hands it out while a language is resolved without writing (#460), so a construction-time pass logs nothing and the committing pass after the load gates logs everything exactly once.
    • RELOAD_LOG_MESSAGE_KEY

      static final String RELOAD_LOG_MESSAGE_KEY
      Framework i18n key (this class's own lang/en.json/lang/zh.json catalogue, not a module's) for the per-module reload line reloadSelf() logs after its three steps (D-03). Package-private so UltiToolsPluginLifecycleHookTest can assert both shipped catalogues actually carry a translation for it, rather than duplicating the literal string between production and test code.
      See Also:
    • LANGUAGE_REPLACED_LOG_KEY

      static final String LANGUAGE_REPLACED_LOG_KEY
      Framework i18n key for the one line logged per official language file restored to the bundled version (#459; #608 and the maintainer decision of 2026-10-04, "official language file edited in place": restored at every start, the edited copy backed up, the operator told to copy, rename and select instead). Arguments: the restored file, the module, the backup. Package-private so a test can assert both shipped catalogues translate it.
      See Also:
    • RELOAD_FAILED_LOG_MESSAGE_KEY

      static final String RELOAD_FAILED_LOG_MESSAGE_KEY
      Framework i18n key for the SEVERE line reloadSelf() logs, naming the module and the cause, when any of its steps or the module's own onReload() throws (#509). The success line RELOAD_LOG_MESSAGE_KEY is never logged for that reload. Package-private for the same catalogue-coverage test as that key.
      See Also:
    • RELOAD_PARTIAL_LOG_MESSAGE_KEY

      static final String RELOAD_PARTIAL_LOG_MESSAGE_KEY
      Framework i18n key for the WARNING line logged instead of RELOAD_LOG_MESSAGE_KEY when the module's reload hook recorded parts that did not reload in its ReloadReport (#529). Package-private for the same catalogue-coverage test as that key.
      See Also:
    • RELOAD_REFUSED_OFF_THREAD_REASON

      static final String RELOAD_REFUSED_OFF_THREAD_REASON
      The partial reason in the report reloadWithReport() returns when the call was refused because it was made off the server thread (#538). Not an i18n key: nothing reaches the operator through it on the framework's own reload paths, which run on the server thread; it is for a module that calls reloadWithReport() itself.
      See Also:
    • LANGUAGE_CHANGE_PENDING_KEY

      static final String LANGUAGE_CHANGE_PENDING_KEY
      Framework i18n key for the partial-reload reason a per-module reload records when the language setting in the framework's config.yml on disk differs from the one the framework runs with (#502). Package-private for the catalogue-coverage test.
      See Also:
    • PLACEHOLDER_PATTERN

      private static final Pattern PLACEHOLDER_PATTERN
      Matches a java.util.Formatter conversion specifier, e.g. %s in "Hello, %s!", or %1$s for an explicit argument index. Used only by placeholderArity(String), one of the two placeholder-loss detectors applyPlaceholderArityOverride(java.io.File, byte[], java.lang.String, java.lang.String) combines (see that method's own javadoc) for the D-05 per-key comparison; not a change to how placeholders are substituted anywhere -- every parameterised i18n(...) value in THIS FRAMEWORK's own two catalogues ( src/main/resources/lang/en.json/zh.json) is formatted with String.format, never MessageFormat (measured: 0 {n}-style placeholders in either file, and 0 across any i18n(...) call site in this repository).

      That measurement is scoped to THIS repository's own two catalogues -- it says nothing about the module catalogues this same mechanism actually protects. Corrected 2026-09-22: an earlier revision of this javadoc reported the same 0-count without that scoping qualifier, which read as "no {n}-style placeholder exists anywhere this mechanism looks" and was wrong for the broader population the mechanism serves. An install-wide sweep of all 15 module jars' lang/*.json/lang/*.yml catalogues against their on-disk copies (34 file pairs) found the opposite: 0 %s/%d-arity mismatches anywhere, and every brace-delimited span in that corpus is a {0}/{1}-style positional or {PLAYER}/{WORLD}-style named token -- the modules use String#replace-driven substitution exclusively, never String.format. BRACE_TOKEN_PATTERN and missingBracePlaceholder(java.lang.String, java.lang.String) exist to cover that population; this pattern and placeholderArity(java.lang.String) continue to cover only the framework's own.

      Codex round 6, P2: deliberately narrower than the full Formatter grammar -- %s/%d (optionally explicit-indexed) or a literal %%, with NO flags, width or precision component. The earlier, more permissive pattern accepted a space as a flag character, so ordinary text like "Progress: 90% done" matched "% d" as a (bogus) %d conversion with a space flag -- an operator's customised value containing an innocent '%' could be reported as an arity mismatch against differently-worded bundled text and silently overwritten at runtime, even though the key carries no real String.format placeholder at all. Since this framework never emits anything outside %s/%d/%% (per the measurement above), restricting the pattern to exactly those forms eliminates the false-positive class rather than patching the specific "% d" instance -- no genuine placeholder this framework uses stops matching.

    • BRACE_TOKEN_PATTERN

      private static final Pattern BRACE_TOKEN_PATTERN
      Matches a brace-style placeholder token as used by the MODULE lang catalogues this mechanism protects -- {0}/{1} positional or {PLAYER}/ {WORLD} named, substituted with plain String#replace, never java.util.Formatter. See PLACEHOLDER_PATTERN's javadoc for the install-wide measurement establishing this is the modules' actual dialect, and #524 for the real-machine report this pattern and missingBracePlaceholder(java.lang.String, java.lang.String) exist to fix.

      The captured group requires the braces to contain ONLY a bare decimal integer or a bare identifier (a letter/underscore followed by letters, digits or underscores) -- no whitespace or other punctuation. This is deliberately strict, for the same reason PLACEHOLDER_PATTERN was narrowed in Codex round 6: an operator's own decorative or explanatory text that happens to contain a brace must never be mistaken for a placeholder token, and this pattern alone cannot make that guarantee -- missingBracePlaceholder(java.lang.String, java.lang.String)'s one-directional comparison is what actually protects a decorative brace the disk value adds on its own (see that method's javadoc). Measured against the install-wide sweep above: every brace-delimited span across all 34 module file pairs matches this pattern with nothing left over, so this shape is not hand-picked -- it is what the corpus actually contains.

    • ARITY_GSON

      private static final com.google.gson.Gson ARITY_GSON
      A private, independent JSON reader for the D-05 placeholder-arity comparison only -- not a change to Language's own Gson usage, which stays entirely inside Language.java (untouched by this plan).
    • ARITY_MAP_TYPE

      private static final Type ARITY_MAP_TYPE
    • language

      private Language language
    • shippedCatalogues

      private volatile List<Language> shippedCatalogues
      The language catalogues the module's jar ships, read once on first use (see shippedCatalogueTexts(java.lang.String)).
    • customCatalogues

      private final Map<String,UltiToolsPlugin.CustomCatalogue> customCatalogues
      The operator's custom language files last read for comment recognition (see customCatalogueTexts(java.lang.String)), by path, each with the modification time and size it was read at, so a file is parsed again only after it changed.
    • version

      private final String version
    • pluginName

      private final String pluginName
    • authors

      private final List<String> authors
    • loadAfter

      private final List<String> loadAfter
    • minUltiToolsVersion

      private final int minUltiToolsVersion
    • mainClass

      private final String mainClass
    • identifyString

      private final String identifyString
    • resourceFolderPath

      private String resourceFolderPath
    • context

      private SimpleContainer context
    • dataScope

      private DataScope dataScope
    • languageDryRun

      private boolean languageDryRun
      True only while the constructor resolves this module's language (#460): the provenance decision is computed but nothing is written, renamed or recorded, and nothing is logged. commitLanguageProvenance() re-resolves with writes once the load gates accepted this candidate. Construction is single-threaded, so a plain field is enough.
    • languageMigrationOnly

      private boolean languageMigrationOnly
      True only while commitLanguageProvenance() migrates the language files of languages other than the one being resolved (Codex review of #566): the provenance writes happen, but no dictionary is built for them, so no per-key placeholder warning is logged about a file that is not in use.
  • Constructor Details

    • UltiToolsPlugin

      protected UltiToolsPlugin()
      Constructor for UltiToolsPlugin. For module development only.
    • UltiToolsPlugin

      public UltiToolsPlugin(String pluginName, String version, List<String> authors, List<String> loadAfter, int minUltiToolsVersion, String mainClass, String resourceFolderPath)
      Constructor for UltiToolsPlugin. For plugin connector.
      Parameters:
      pluginName - the name of the plugin
      version - the version of the plugin
      authors - the authors of the plugin
      loadAfter - the plugins which should be loaded before this plugin
      minUltiToolsVersion - the minimum version of UltiTools required by this plugin
      mainClass - the main class of the plugin
      resourceFolderPath - the path to the resource folder
  • Method Details

    • initializeLanguage

      private Language initializeLanguage()
      Resolves this module's language during construction without writing anything (#460): no language file is refreshed, replaced or backed up, no provenance is recorded, and nothing is logged. The returned language is the one the commit will produce when its writes succeed; commitLanguageProvenance() performs the writes and replaces it once the load gates have accepted this candidate, so a rejected candidate leaves the shared files untouched.
      Returns:
      the language this module will use once its provenance decision is committed
    • commitLanguageProvenance

      @Internal public final void commitLanguageProvenance()
      Commits this module's language provenance decision (#460): re-resolves the language with its writes enabled -- the refresh of an untouched language file and its provenance record -- and makes the result this module's language. Called by PluginManager once the load gates have accepted this candidate, before its container is assembled; construction only computed the decision, so a candidate the gates reject never writes to the language files it shares with the accepted version.

      It is also the only point that extracts the module's lang/ resources the disk lacks, with their hashes recorded as at any extraction (#567 item 1): construction extracts only config/ and res/, and resolves a missing lang/ file from the jar's copy without writing it. A copy the load gates reject never reaches this method, so it cannot write a language file.

      Not part of the module-facing API. Public only because PluginManager lives in another package, like setContext(com.ultikits.ultitools.context.SimpleContainer). Calling it again re-runs the same resolution a reload's language step runs, so it is harmless but pointless.

      Since:
      6.3.0
    • migrateBundledLanguageFiles

      private void migrateBundledLanguageFiles()
      Applies the provenance decision to every language file this module's jar bundles and the disk holds, not only the configured language's (Codex review of #566): on the first start after an upgrade, an unrecorded lang/zh.json of a server running en is replaced and recorded too (#459), so an edit made to it afterwards is kept as a customisation when the operator later switches language. The configured language is resolved afterwards, as before; its file is then already migrated. Codes come from the jar's own lang/ entries.

      Every dictionary builder reachable from here -- readLanguageFile(java.io.File, java.lang.String) and applyPlaceholderArityOverride(java.io.File, byte[], java.lang.String, java.lang.String) -- returns an empty dictionary while languageMigrationOnly is set, so no unused catalogue is parsed (Codex review of #566, run 2).

    • migrateBundledLanguageFile

      private void migrateBundledLanguageFile(String code, String extension)
    • languageLog

      private PluginLogger languageLog()
      The logger the language-resolution methods write through: this module's logger, except during the construction-time pass (#460), when it discards everything so each message is logged once, by the committing pass.
    • newSilentLogger

      private static Logger newSilentLogger()
    • resolveLanguageCode

      private String resolveLanguageCode()
      Resolves the official language code to load, consulting Localized.supported() before createLanguageFromPath(String) picks a file (D-20/D-21/WIRE-10), and logs a warning when the configured name has no official base. See officialLanguageFor(String, boolean) for the rule.
      Returns:
      the official language code to load
    • officialLanguageFor

      private String officialLanguageFor(String configured, boolean warn)
      The official language configured is based on (#608). An official code this module ships (Localized.supported()) is its own base; a custom name is based on the shipped code it starts with followed by a hyphen (zh-myserver on zh, see Localized.officialLanguageOf(java.lang.String, java.util.List<java.lang.String>)); any other name falls back to "en" when the module ships it, otherwise to the first shipped code, with one warning when warn is set. An empty supported() is "no information": configured is returned unchanged and nothing is logged, as before 6.3.0.
    • customLanguageName

      private static String customLanguageName(String configured, String official)
      The custom language name in use (#608): the configured name when it is not the official language it resolves to and is a safe language-code token (Localized.isSafeLanguageCode(java.lang.String)), so it can never name a path outside lang/; otherwise null. A module that ships no languages has no official base and no custom name.
    • readCustomLanguageFile

      private Language readCustomLanguageFile(String folderPath, String name, String officialCode)
      Reads the operator's custom language file <folderPath>/lang/<name><extension>, trying each of LANGUAGE_EXTENSIONS in order (#608).

      Read-only by construction: a custom file belongs to the operator (maintainer decision 2026-10-04), so this method never calls loadLanguageFromDisk(java.lang.String, java.lang.String, java.lang.String) -- whose provenance step can refresh, restore or back up a file -- and never writes, renames, backs up or records anything. The module jar never ships a file under a custom name either, so saveResources(boolean) and migrateBundledLanguageFiles() never reach one. The canonical lang/ containment guard applies as for every other language read.

      A key whose custom value lost a placeholder relative to the official base's bundled value uses the bundled value, with one warning naming the file and the key (overrideLostPlaceholders(java.util.Map<java.lang.String, java.lang.String>, java.util.Map<java.lang.String, java.lang.String>, java.util.function.BiConsumer<java.lang.String, java.lang.String>); gate-1 review R1-2 of plan 17-69): a custom file written for an older release must not make a message fail to format after an upgrade.

      Returns:
      the custom language, or null when no such file exists or none is readable
    • createLanguageFromPath

      private Language createLanguageFromPath(String folderPath)
      Creates the language from the given resource folder: the operator's custom language file when one is selected and present, falling back key by key to the official language it is based on -- the official file on disk, falling back to the jar's copy (#608).
      Parameters:
      folderPath - the resource folder path
      Returns:
      Language object
    • createOfficialLanguage

      private Language createOfficialLanguage(String folderPath, String resolvedCode, boolean warnIfMissing)
      Resolves the official language resolvedCode from disk and jar, as before #608.
      Parameters:
      warnIfMissing - whether to log that no language is loadable (not when a custom file is in use)
      Returns:
      the official language, or null when none is loadable
    • resolveDiskLanguage

      private Language resolveDiskLanguage(String folderPath, String code)
      Resolves the on-disk language catalogue for code, trying each of LANGUAGE_EXTENSIONS in order and returning the first that exists -- independently of whichever extension resolveJarLanguage(String) resolves for the same code (13-REVIEW round 5, own deep review of 0ddc95f, finding 1).
      Returns:
      the resolved on-disk language, or null if none of the extensions exist on disk
    • bundledDictionary

      private Map<String,String> bundledDictionary(String code)
      The jar-bundled dictionary of code, from the first of LANGUAGE_EXTENSIONS the jar ships, for the placeholder comparison of a custom file; empty when the jar ships none.
    • resolveJarLanguage

      private Language resolveJarLanguage(String code)
      Resolves the jar-bundled language catalogue for code, trying each of LANGUAGE_EXTENSIONS in order and returning the first that exists -- independently of whichever extension resolveDiskLanguage(String, String) resolves for the same code (13-REVIEW round 5, own deep review of 0ddc95f, finding 1).
      Returns:
      the resolved jar-bundled language, or null if none of the extensions exist in the jar
    • shippedCatalogueTexts

      @Internal List<String> shippedCatalogueTexts(String key)
      The texts the language catalogues this module's jar ships hold for key: one entry per shipped catalogue - every language code under the jar's lang/, every extension the loader reads - that holds the key, in code order. The config layer uses them to recognise a comment line the framework wrote in any language (maintainer decision 2026-10-04, "only the framework's own comments are rewritten", #604).

      Read-only by construction: it reads the module's own code source through loadLanguageFromJar(java.lang.String, java.lang.String) and nothing else - never the extracted language files under the module's folder, the resource hash record or a language backup - so it performs none of the language-provenance writes a language load makes, and logging a catalogue it cannot read is its only effect. The catalogues are read once per module instance: the jar does not change while the module is loaded.

      Parameters:
      key - the catalogue key
      Returns:
      the shipped texts for key, possibly empty, never null
    • shippedCatalogues

      private List<Language> shippedCatalogues()
    • customCatalogueTexts

      @Internal List<String> customCatalogueTexts(String key)
      The texts the operator's custom language files in this module's lang/ folder hold for key (#615 item 1; contract-batching decision of 2026-10-06): one entry per custom file that holds the key, in file name order. A custom file is a language file -- .json, .yml or .yaml -- whose name is a safe language-code token (Localized.isSafeLanguageCode(java.lang.String)) that the module does not ship (Localized.supported(), which a module that overrides it must keep equal to its jar's lang/ codes), such as zh-myserver.yml: the files an operator selects as a custom language (#608). The config layer adds them to the texts it recognises as framework-written comments, so a comment the framework wrote while the operator's custom language was selected keeps following later language switches.

      Read-only by construction, like readCustomLanguageFile(java.lang.String, java.lang.String, java.lang.String): it lists the folder and parses the files, and never writes, renames, backs up, refreshes or records anything -- no provenance step and no placeholder guard, and it logs nothing of its own. A file that cannot be parsed contributes no text; for a .yml/ .yaml file Bukkit's YAML reader itself logs the parse failure, even when the file is not the selected language (a documented limitation, gate 2 of PR #636: an unusable file left in lang/ is the operator's to fix or remove). A file is parsed again only after its modification time or size changed, so the texts follow the operator's edits; a file replaced by one of the same size with its old modification time kept is noticed at the next start (documented limitation, same review).

      Only the operator's CURRENT custom texts are known: no record of earlier renderings is kept (maintainer decision of 2026-10-06, option A). A comment written from a text the operator has since edited in the custom file is therefore the operator's own and stays as it is.

      Parameters:
      key - the catalogue key
      Returns:
      the custom files' texts for key, possibly empty, never null
      Since:
      6.3.0
    • customCatalogue

      private Language customCatalogue(File langDir, File file, Set<String> shippedCodes)
      The parsed custom file file, or null when it is not a custom language file or not readable.
    • parseCustomCatalogue

      private static Language parseCustomCatalogue(File file, String extension) throws IOException
      Throws:
      IOException
    • loadLanguageFromDisk

      private Language loadLanguageFromDisk(String folderPath, String code, String extension)
      Reads <folderPath>/lang/<code><extension> if it exists, else null.

      D-05/D-06/D-07 (#441): before returning, applies the recorded-provenance decision -- an upgraded server whose language file was never touched since extraction gets it silently replaced by the current jar's copy; one an operator customised is left alone, with a per-key warning for any key whose placeholder count moved out from under it. Layered in front of the resolution split, not inside Language: resolveLanguageWithProvenance(java.lang.String, java.io.File, java.lang.String, java.lang.String) decides which bytes/dictionary this method returns, and Language.withFallback(com.ultikits.ultitools.entities.Language) (unchanged) still owns filling a key this dictionary lacks entirely.

      16-05 / CodeQL java/zipslip alert #11 (CWE-22): code is only lightly restricted by Localized.languageCodeOf(String)'s character allowlist when it comes from a scanned jar/directory entry -- and not restricted at all when it comes from a hostile config.yml language: value, since resolveLanguageCode() returns an unmatched configured code UNCHANGED whenever Localized.supported() is empty. This is therefore the last line of defence: before this file is read (or, deeper in resolveLanguageWithProvenance(java.lang.String, java.io.File, java.lang.String, java.lang.String), overwritten), its canonical path must stay inside <folderPath>/lang's own canonical path -- mirroring the guard saveResources(boolean) already applies to extracted jar entries. A violation degrades to the same null (“not loadable”) outcome as a missing file, never a thrown exception.

    • jarCopyAsIfExtracted

      private Language jarCopyAsIfExtracted(String resourcePath, String extension)
      The language a lang/ file would hold once extracted: the jar's copy of resourcePath, parsed as readLanguageFile(java.io.File, java.lang.String) parses the extracted file; null when the jar does not ship it (#567 item 1). Reads the jar only; writes nothing.
    • isWithinDirectory

      private static boolean isWithinDirectory(File baseDir, File candidate)
      Verifies that candidate's canonical path is contained within baseDir's own canonical path -- the same Zip Slip guard saveResources(boolean) already applies to extracted jar entries, generalized here for every other place an untrusted language code is turned into a File (16-05 / CodeQL java/zipslip alert #11, CWE-22). Neither baseDir nor candidate needs to exist: File.getCanonicalPath() is defined for a non-existent path too, normalizing .. segments lexically wherever the real filesystem does not need to be consulted (the same property saveResources() already relies on for a file it is about to create).

      Returns false (never throws) on any I/O failure resolving either canonical path, since an uncanonicalizable path is not a basis for trusting containment either.

      Parameters:
      baseDir - the directory candidate must resolve inside
      candidate - the file built from an untrusted language code
      Returns:
      whether candidate's canonical path is baseDir's canonical path or a descendant of it
    • resolveLanguageWithProvenance

      private Language resolveLanguageWithProvenance(String folderPath, File file, String resourcePath, String extension)
      Applies the D-05/D-06 recorded-provenance decision to a single on-disk language file. Four branches, mutually exclusive:
      1. Recorded hash present and equal to the disk file's current hash -- never touched by the operator since extraction: overwrite the disk file with the current jar's bytes, re-record the new (jar) hash as the baseline, and log one informative line.
      2. Recorded hash present and different from the disk file's current hash: this by ITSELF is NOT evidence of an edit -- see the branch-2 code comment below (Codex finding, thread PRRT_kwDOIcF9Es6i2kao, P2) for why. When the disk bytes equal the jar's the record is repaired; when they differ, the official file was edited in place and is restored like branch 4 (#608, maintainer decision 2026-10-04: restored at every start, edited copy backed up, one log line).
      3. No recorded hash, but the disk bytes already equal the jar's -- provably unmodified, unknown provenance only because an older jar (pre-#441) extracted it: record the hash as the new baseline and enter the normal mechanism, with no overwrite this pass (D-06). Never adopt an unequal disk hash as a baseline -- that would silently overwrite a real customisation on the next jar change.
      4. No recorded hash and the disk bytes differ from the jar's -- unknown provenance: the file is replaced by the jar's copy and recorded, the old file kept as a backup and named in the log (#459, maintainer 2026-09-29, question 3 option 2; see restoreOfficialLanguageFile(java.io.File, byte[], java.io.File, java.lang.String, java.lang.String)). If the replacement fails the file is kept and the per-key placeholder-arity override applies, as before.
      A jar entry absent for this exact resourcePath (D-05's stated exception) short- circuits before any of the four branches: the disk file is left alone and nothing is recorded, since there is nothing to compare against. This is also why an operator's custom language file (#608) can never reach any branch: a custom name is by definition not a language the jar ships, and custom files are read only by readCustomLanguageFile(java.lang.String, java.lang.String, java.lang.String).

      Every branch above derives its conclusion from an ACTUAL disk-bytes-vs-jar-bytes comparison, never from the record alone -- branches 3 and 4 already did (their own classification IS a content comparison); branch 2 was corrected to do the same (see its own code comment). Since #459, branch 4 no longer concludes "customisation" at all, and since #608 neither does branch 2: an official file whose bytes differ from the jar's is restored, with a backup. The record is provenance BOOKKEEPING, not the source of truth for whether a file has been customised; it can go stale (most deterministically, since the previous round, when the sidecar itself is symlinked or read-only and its own write is refused) without that staleness ever being able to cause a PERMANENT misclassification.

      Parameters:
      folderPath - the module's on-disk resource folder root
      file - the on-disk language file, already confirmed to exist
      resourcePath - the extracted resource's path relative to the resource folder (e.g. "lang/en.json"), matching saveResources()'s own keys
      extension - the language file extension (".json", ".yml" or ".yaml")
      Returns:
      the language that loadLanguageFromDisk(java.lang.String, java.lang.String, java.lang.String) should treat as "the disk language"
    • recordUnlessDryRun

      private void recordUnlessDryRun(File resourceFolder, String resourcePath, String hash)
      Records hash for resourcePath, except during the construction-time pass, which writes nothing (#460).
    • restoreOfficialLanguageFile

      private Language restoreOfficialLanguageFile(File file, byte[] jarBytes, File resourceFolder, String resourcePath, String extension)
      Branches 2 and 4 of resolveLanguageWithProvenance(java.lang.String, java.io.File, java.lang.String, java.lang.String): an official language file whose bytes differ from the jar's copy -- an unrecorded one (#459, maintainer 2026-09-29, question 3 option 2) or one edited in place after it was recorded (#608, maintainer decision 2026-10-04: official language files are framework-owned and restored at every start) -- is replaced by the jar's copy and recorded; the previous file is kept as a backup beside it and one catalogue line names both and tells the operator to copy, rename and select a custom file instead. It is only ever called for a resource the jar ships, so it can never touch an operator's custom-named file. During the construction-time pass (#460) it writes nothing and returns the jar's dictionary, the language a replacement yields.

      Steps, each undone if a later one fails, so a failure at any step leaves the original file in place, byte-identical, with nothing recorded and no backup left behind:

      1. copy the file to a backup name that does not exist yet (copyToFreshBackup(java.io.File)). A copy rather than a rename, so the language file is never absent: a crash here leaves the original in place and, at worst, one extra backup;
      2. write the jar's bytes to a temporary file in the same folder and move it over the original atomically (writeBytes(java.io.File, byte[]), the refresh path branch 1 uses; it refuses a read-only or symbolic-link file, which an operator pinned). On failure the backup this call created is deleted -- it is a copy, so nothing is lost;
      3. record the new hash and read the record back. On failure the backup is moved back over the file, restoring the original bytes;
      4. log one line naming the file and its backup.
      The language returned is the jar's dictionary after a replacement, and the kept file's dictionary with the placeholder-arity guard (the pre-#459 behaviour) after a failure.
    • copyToFreshBackup

      private File copyToFreshBackup(File file)
      Copies file to the first backup name that does not exist yet -- <name>.bak, then <name>.1.bak, <name>.2.bak -- never overwriting anything: a name is skipped when any file or link exists there, and the copy itself refuses an existing target, so a name taken in between is skipped too. The naming is shared with the framework's own language files (OfficialLanguageFiles.copyToFreshBackup(java.io.File)).
      Returns:
      the backup created, or null if none could be (the reason is logged)
    • recordReplacedFile

      private boolean recordReplacedFile(File file, File resourceFolder, String resourcePath)
      Records file's current hash and reads the record back.
      Returns:
      whether the record now holds exactly that hash
    • deleteOwnBackup

      private void deleteOwnBackup(File backup)
      Deletes the backup copyToFreshBackup(java.io.File) created in this same replacement attempt, after the replacement itself failed. It is a copy of a file that is still in place, so nothing is lost, and leaving it would add one more backup on every start that fails the same way.
    • restoreFromBackup

      private void restoreFromBackup(File backup, File file)
      Moves the backup this attempt created back over file, restoring the original bytes, after the provenance record could not be written. Atomic where the filesystem supports it.
    • resolveNeverTouchedSinceExtraction

      private Language resolveNeverTouchedSinceExtraction(File file, byte[] jarBytes, String jarHash, String diskHash, File resourceFolder, String resourcePath, String extension)
      Branch 1 of resolveLanguageWithProvenance(java.lang.String, java.io.File, java.lang.String, java.lang.String): the recorded hash equals the disk file's current hash -- the operator has never touched this file since it was extracted, so it is safe to overwrite from the current jar. Extracted into its own method (PMD NPathComplexity: once branch 2 in the caller gained its own content-comparison check for the Codex finding on UltiToolsPlugin.java:515, thread PRRT_kwDOIcF9Es6i2kao, this branch's own nested writeBytes/try/catch shape pushed the combined method over Codacy's threshold; splitting it out is a structural change only, the behaviour below is unchanged from before that finding).

      Codex round 1, P2: skip the write entirely when the bundled content has not actually changed since it was last synced -- the common case on every restart after the first successful sync, not an edge case. Rewriting identical bytes and logging "has been updated" every single time is misleading, touches the file's mtime for no reason, and fails needlessly on an installation that hardens module resources read-only after provisioning.

      Only records the new baseline and logs success once the write is CONFIRMED to have landed -- re-hashes the bytes actually on disk afterward (mirroring saveResources()'s own pattern) rather than trusting the precomputed jarHash, so a partial/failed write can never be misreported as success (WR-01). Codex round 2, P2: record(...) swallows its own IOException and returns void, so a caller cannot otherwise tell a sidecar write failure from success -- the record is read back to confirm it actually persisted before claiming success in the log; logging "has been updated" when the file WAS refreshed but the sidecar was NOT would misrepresent provenance tracking as healthy. Deliberately not reverted on failure: a second write introduces its own atomicity risk for a genuinely rare failure, and the next boot's hash mismatch safely falls into branch 2's own content-comparison fix instead, rather than a second write attempt here.

      Codex round on UltiToolsPlugin.java:498, P2: this used to be an unconditional readLanguageFile(...), skipping the per-key placeholder-arity guard on EXACTLY the paths where a stale value is most likely -- writeBytes(file, jarBytes) returning false (read-only file, symlink, unpreservable ownership, or an I/O failure mid-write) leaves the OLD disk bytes in place while jarBytes has already moved on, so a key whose bundled arity shrank (two placeholders to one) can still carry the old, wider disk value. A stale disk value with MORE placeholders than the call site now supplies throws MissingFormatArgumentException at format time -- extra bundled arguments over a narrower stale value are silently ignored by String.format(java.lang.String, java.lang.Object...), not an error. Applying the override UNCONDITIONALLY -- not only on the writeBytes-failed path -- means the write's own outcome stops mattering for this guarantee: when the write succeeded, disk and jar bytes are now identical, so every key's arity trivially matches and this is a no-op past the extra parse; when it failed, this is precisely the protection this method exists to provide.

      Parameters:
      file - the on-disk language file, already confirmed to exist
      jarBytes - the current bundled bytes for this resource, already confirmed non-null by the caller
      jarHash - jarBytes's own SHA-256 digest, already computed by the caller
      diskHash - file's current SHA-256 digest, already computed by the caller -- equal to the recorded provenance hash, which is what routed the caller into this method in the first place
      resourceFolder - the module's resource folder root (the sidecar's own location)
      resourcePath - the extracted resource's path relative to the resource folder
      extension - the language file extension (".json", ".yml" or ".yaml")
      Returns:
      the resolved language for this file
    • readLanguageFile

      private Language readLanguageFile(File file, String extension)
      Reads an on-disk language file exactly as the pre-#441 loadLanguageFromDisk did -- extracted unchanged so both provenance branches that keep the disk file's own parse (branches 1 and 3 in resolveLanguageWithProvenance(java.lang.String, java.io.File, java.lang.String, java.lang.String)) share the same reading logic the jar-absent short-circuit also uses.
    • applyPlaceholderArityOverride

      private Language applyPlaceholderArityOverride(File file, byte[] jarBytes, String extension, String resourcePath)
      Builds the disk language for the "operator customisation" branches (2 and 4 in resolveLanguageWithProvenance(java.lang.String, java.io.File, java.lang.String, java.lang.String)): the disk dictionary stays authoritative for every key, except a key the jar's value proves has LOST a placeholder relative to the disk value -- that key is overridden to the jar's value and warned about once, naming the module, the file and the key but never either value (T-16-04-02). A key present only in the jar is deliberately left out of the returned dictionary: Language.withFallback(com.ultikits.ultitools.entities.Language) (unchanged) already resolves a key the disk dictionary lacks entirely, and duplicating that here would just be a second, redundant path to the same answer.

      Two independent detectors decide "lost a placeholder", evaluated SEPARATELY rather than through one shared code path (#524):

      • placeholderArity(java.lang.String) for %s/%d -- SYMMETRIC equality of the highest required String.format argument position. Either side moving, in either direction, is a mismatch: both sides use the same dialect, so any count change at all means the parameter shape changed.
      • missingBracePlaceholder(java.lang.String, java.lang.String) for {0}/{PLAYER}-style tokens -- ONE-DIRECTIONAL presence, not a count. It fires only when a token the JAR value contains is ABSENT from the disk value; a disk value that keeps every bundled token but adds a token of its own, or rewords everything around them, never fires.
      The two cannot share one code path: collapsing them into a single "count differs" rule would make the brace detector symmetric too, and a symmetric brace rule fires on an operator's own customisation the moment its OWN brace count doesn't happen to match the jar's -- exactly the false-positive class PLACEHOLDER_PATTERN's own javadoc already documents Codex round 6 eliminating for the % dialect (a permissive pattern reading ordinary text as a placeholder). One-directional token presence is the brace dialect's equivalent fix: it flags a slot that disappeared under the operator and nothing else -- never an operator who added something of their own.

      What an operator loses in the one case this DOES fire: nothing structural -- their file is never touched, never rewritten. That one message renders with the bundled wording (not their customised wording) until they notice the warning and re-add the missing token themselves.

    • overrideLostPlaceholders

      @Internal public static Map<String,String> overrideLostPlaceholders(Map<String,String> own, Map<String,String> official, BiConsumer<String,String> mismatch)
      The two-detector placeholder comparison of applyPlaceholderArityOverride(java.io.File, byte[], java.lang.String, java.lang.String) (#441, #524), as a pure function shared with an operator's custom language file (#608) and the framework's own custom file: every key of own whose value lost a placeholder relative to official's value for the same key -- a different %s/%d arity (placeholderArity(java.lang.String)), a malformed format-argument index, a different conversion at a position both values use (conversionMismatch(java.lang.String, java.lang.String), #615 item 3), or a {TOKEN} the official value has and own's lacks (missingBracePlaceholder(java.lang.String, java.lang.String)) -- is replaced by the official value, and mismatch is told the key and the reason phrase (never either value). Keys own lacks are left out: Language.withFallback(com.ultikits.ultitools.entities.Language) resolves them. In memory only: nothing is read or written here.

      Not part of the module-facing API. Public only because the framework class reads its own custom file in another package.

      Parameters:
      own - the operator's dictionary (an edited official file, or a custom language file)
      official - the bundled dictionary of the same, or the base, language
      mismatch - receives (key, reason) for each replaced key; the reason completes the sentence "Language key 'k' in 'file' ... " ("has a different placeholder count than the current bundled version" and the like)
      Returns:
      own with each such key replaced by the official value
      Since:
      6.3.0
    • lostPlaceholderReason

      private static String lostPlaceholderReason(String ownValue, String officialValue)
      Why ownValue lost a placeholder relative to officialValue, or null if it did not.
    • warnLostPlaceholder

      private void warnLostPlaceholder(String key, String fileLabel, String reason)
    • placeholderArity

      private static int placeholderArity(String value)
      Returns the highest String.format argument POSITION value requires -- not the count of distinct positions used (Codex round 2, P2). "%2$s" alone requires an args array of length (at least) 2, since String.format demands every position up to the highest one referenced be present, even if a lower position (here, 1) is never itself rendered -- so its arity is 2, not 1. Counting DISTINCT positions (a one-element set for both "%2$s" alone and "%s" alone) would wrongly call those two equal.

      A value repeating one EXPLICIT index twice (e.g. "%1$s and %1$s again") still has arity one, since both conversions consume the SAME argument and neither raises the highest-position watermark past 1; a naive occurrence count would call it two and warn on a rewording that changed nothing about the message's parameter shape. An unindexed conversion (this framework's own catalogues use only this form) consumes the NEXT sequential position, so two unindexed %s conversions in one value require arity two. %% (a literal percent) consumes no argument and is excluded -- PLACEHOLDER_PATTERN itself (Codex round 6, P2) no longer matches anything outside %s/%d/%% at all, so %n and every other Formatter conversion this framework never emits cannot reach this method to begin with.

      Parameters:
      value - a language value, or null
      Returns:
      the highest argument position value's String.format conversions require, or 0 for null or a value with none
    • conversionMismatch

      private static boolean conversionMismatch(String ownValue, String officialValue)
      Reports whether ownValue and officialValue use a different String.format conversion for an argument position both of them use (#615 item 3; maintainer decision 2026-10-06): %d where the other has %s at the same position, explicit (%2$d) or implied by the order of unindexed conversions. Runs only after placeholderArity(java.lang.String) found the same count, and sees exactly what that count sees (PLACEHOLDER_PATTERN: %s and %d, optionally indexed; %% is not a conversion).

      The rule (decided by plan 17-77, revision 0): for every position BOTH values use, the set of conversion characters at that position must be equal. A position only one value uses is left to the count comparison, so a value that keeps the bundled count but drops or reorders arguments with explicit indices keeps the outcome it had before this check existed; conversions in a different order with the same explicit indices are the same conversions. Equality, not "would it throw": %s for a bundled %d would format, but the maintainer's decision compares the types, so the text that reaches a player is the one whose argument types the module was written for.

      Parameters:
      ownValue - the operator's value
      officialValue - the bundled value of the same key
      Returns:
      true if a shared position carries different conversions
    • placeholderConversions

      private static Map<Integer,Set<Character>> placeholderConversions(String value)
      The String.format conversions of value by argument position: an explicit index ( %2$s) names its position, an unindexed conversion takes the next ordinary position, as java.util.Formatter counts them. Called only after placeholderArity(java.lang.String) parsed the same value, so a malformed index has already been reported.
    • missingBracePlaceholder

      private static boolean missingBracePlaceholder(String diskValue, String jarValue)
      Reports whether jarValue contains a brace-style placeholder token (see BRACE_TOKEN_PATTERN) that diskValue does not -- ONE-DIRECTIONALLY, and by TOKEN PRESENCE, not by count (#524). See applyPlaceholderArityOverride(java.io.File, byte[], java.lang.String, java.lang.String)'s own javadoc for why this is deliberately asymmetric, unlike placeholderArity(java.lang.String)'s symmetric check: an operator's value that keeps every bundled token -- however reworded, recoloured, or however many of its OWN extra tokens it adds -- must never be flagged. Only a token that existed in the bundled value and is genuinely gone from the operator's value counts as a loss.

      A jar value with no brace tokens at all can never report a loss (there is nothing to lose), independent of whatever the disk value happens to contain -- this is what keeps a module's %s/%d-only keys, and any decorative brace an operator adds on their own, outside this check entirely.

      Parameters:
      diskValue - the operator's on-disk value for this key
      jarValue - the current bundled value for the same key
      Returns:
      true if jarValue has at least one brace token absent from diskValue
    • bracePlaceholderTokens

      private static Set<String> bracePlaceholderTokens(String value)
      Returns the distinct brace-style placeholder tokens (see BRACE_TOKEN_PATTERN) found in value -- e.g. {"0", "PLAYER"} for "Hi {PLAYER}, you are #{0}". Iteration order is insertion order and otherwise unused: the only caller (missingBracePlaceholder(java.lang.String, java.lang.String)) only ever calls containsAll on the result.
      Parameters:
      value - a language value, or null
      Returns:
      the distinct token names found, or an empty set for null or a value with none
    • readFlatDictionary

      private static Map<String,String> readFlatDictionary(File file, String extension)
      Reads a flat key -> value dictionary from a language file on disk, for the placeholder-arity comparison only -- Language itself exposes no way to enumerate its keys, so this is a small, independent read of the same file formats, not a change to Language's own parsing. Degrades to an empty map on any read/parse failure (an empty file is "no keys", never an error, per D-05).
    • readFlatDictionary

      private static Map<String,String> readFlatDictionary(byte[] bytes, String extension)
      Reads a flat key -> value dictionary from jar-bundled bytes, mirroring the file overload above for the jar side of the comparison.
    • readFlatDictionary

      private static Map<String,String> readFlatDictionary(Reader reader, String extension)
    • writeBytes

      private boolean writeBytes(File file, byte[] bytes)
      Writes bytes to file, returning whether the write actually succeeded (WR-01) -- the caller must not record a new provenance baseline or log a success line for a write that threw partway through.

      Round following PosixAttributePreserver.java:104 (symlink finding on ResourceHashSidecar.java:320, thread PRRT_kwDOIcF9Es6i2I49, P2): this method's entire operation -- the operator-pinned-read-only refusal, the symlink refusal, the temp-file-then-atomic-move mechanism, and the POSIX attribute preservation -- now lives in PosixAttributePreserver.replaceInPlace(java.io.File, byte[], com.ultikits.ultitools.utils.PosixAttributePreserver.ReplaceInPlaceListener), shared with ResourceHashSidecar#writeAll's identical contract, so the two write paths cannot disagree on any of these properties again the way they disagreed on attribute preservation (round 3), an unreadable-attributes edge case (round 6), and symlink handling (this round) one at a time. This method is now a thin PosixAttributePreserver.ReplaceInPlaceListener supplying this class's own getLogger()/getPluginName() warning text; the full behavioural contract (what each refusal means, why each is conservative rather than best-effort) is documented on PosixAttributePreserver.replaceInPlace(java.io.File, byte[], com.ultikits.ultitools.utils.PosixAttributePreserver.ReplaceInPlaceListener) and PosixAttributePreserver.ReplaceInPlaceListener themselves, not duplicated here.

      The symlink refusal's exact semantics, unchanged from Codex round 11, P2: a symlinked language file (an operator-managed shared-translations layout, e.g. lang/en.json pointing at a shared store) is left completely untouched -- REFUSE, not write-through to the link's target. See PosixAttributePreserver.ReplaceInPlaceListener.onSymbolicLink().

    • readEmbeddedResourceBytes

      private byte[] readEmbeddedResourceBytes(String resourcePath)
      Reads the raw bytes of resourcePath (e.g. "lang/en.json") from this module's own CodeSource location, or null if it is not present there. Mirrors loadLanguageFromJar(String, String)'s directory/jar dual branch exactly (13-REVIEW CR-01, issue #412 follow-up) but returns raw bytes instead of a parsed Language, so the D-05/D-06 provenance decision can hash and, in the overwrite branch, write those exact bytes to disk.
    • readEmbeddedResourceBytesFromDirectory

      private byte[] readEmbeddedResourceBytesFromDirectory(File location, String resourcePath)
      The exploded-classpath (directory CodeSource) branch of readEmbeddedResourceBytes(String), split out to keep that method's NPath complexity under the Codacy/PMD threshold once the 16-05 / CodeQL java/zipslip alert #11 containment guard was added -- purely a structural split, the guard's own behaviour is unchanged.
      Parameters:
      location - the directory CodeSource location
      resourcePath - the resource path to read, e.g. "lang/en.json"
      Returns:
      the resource's raw bytes, or null if absent, escaping, or unreadable
    • loadLanguageFromJar

      private Language loadLanguageFromJar(String code, String extension)
      Reads lang/<code><extension> from the module's own CodeSource location if present, else null. Mirrors Localized.scanLangResources(URL)'s directory/jar branch (13-REVIEW CR-01, issue #412 follow-up): an exploded classpath (dev workspace, IDE launch, or a module test that instantiates a real UltiToolsPlugin subclass) has a directory as its CodeSource, and Localized.supported() already scans that directory directly -- this method must not disagree by unconditionally trying (and failing) to open the directory as a JarFile first.

      The resource path is built with '/' for the jar-entry lookup and File.separator for the on-disk lookup: jar entry names always use a forward slash, so the separator form would silently find nothing on a Windows host, and the reverse holds for a real file path.

    • resolveCodeSourceFile

      private static File resolveCodeSourceFile(URL location)
      Resolves a CodeSource location as a File, decoding any percent-escaped characters (spaces, non-ASCII, etc.) that URL.getPath() does not decode on its own (13-REVIEW WR-03) -- passing an undecoded %20... path straight to File(String) or JarFile(File) finds nothing when the module is installed under a path containing a space or other URI-escaped character. Delegates to CodeSourceLocations.toFile(URL), the resolver Localized.scanLangResources(URL) also uses, so language discovery and the dictionary reader cannot disagree about where the module's JAR is (#676 case 26); that resolver falls back to the previous raw-path rule when the location cannot be read as a file: URI.
      Parameters:
      location - the CodeSource.getLocation() URL, never null
      Returns:
      the resolved location as a File
    • parseLanguageResource

      private static Language parseLanguageResource(BufferedReader reader, String extension) throws IOException
      Parses a lang/* resource already opened as a BufferedReader -- shared by both the on-disk (exploded directory) and in-jar branches of loadLanguageFromJar(String, String) so the two stay in sync.
      Throws:
      IOException
    • loadPluginConfiguration

      private org.bukkit.configuration.file.YamlConfiguration loadPluginConfiguration()
      Loads the plugin configuration from plugin.yml
      Returns:
      YamlConfiguration object with default values if loading fails
    • setContext

      @Internal public void setContext(SimpleContainer context)
      Injects the IoC container created for this plugin.

      Called by PluginManager while a module is being loaded, before registerSelf() runs. It is not part of the module-facing API — a module that calls it replaces the container the framework already wired up, losing every bean that was injected into it.

      Deliberately still public: PluginManager lives in another package, so this cannot be narrowed to package-private, and deleting it outright would remove a public method, which the compatibility policy forbids in a PATCH release. The annotation is a signal to humans and IDEs; it enforces nothing at runtime.

      Parameters:
      context - the container created for this plugin
    • setDataScope

      @Internal public void setDataScope(DataScope scope)
      Injects the DataScope credential PluginManager minted for this module (D-17). Called by PluginManager right after minting, before wireAop runs -- the same lifecycle point setContext(com.ultikits.ultitools.context.SimpleContainer) documents.

      Deliberately still public, for the same reason setContext(com.ultikits.ultitools.context.SimpleContainer) is: PluginManager lives in another package and cannot be narrowed to package-private, and the compatibility policy forbids removing a public method in a PATCH release. The annotation is a signal to humans and IDEs; it enforces nothing at runtime.

      Parameters:
      scope - the scope minted for this plugin
      Since:
      6.3.0
    • getConfigManager

      public static ConfigManager getConfigManager()
      Returns:
      the config manager
    • getListenerManager

      public static ListenerManager getListenerManager()
      Returns:
      the listener manager
    • getCommandManager

      public static CommandManager getCommandManager()
      Returns:
      the command manager
    • getPluginManager

      public static PluginManager getPluginManager()
      Returns:
      the plugin manager
    • prepareConfigConverters

      private void prepareConfigConverters()
      Prepares converters and checks only enabled auto-config entities before resources are extracted.
    • initConfig

      private void initConfig() throws IOException
      Initializes the configuration entity.
      Throws:
      IOException
    • diffGetAllConfigsOverride

      private void diffGetAllConfigsOverride()
      Diffs a Configurable.getAllConfigs() override against what auto-registration already registered for this module (D-06 / SILENT-18 / #336), called once right after package-scan auto-registration finishes.

      An empty override (the interface default - the module never wrote Configurable.getAllConfigs()) has nothing to compare and nothing to log. A non-empty override whose every configFilePath was already registered by the package scan is pure redundancy, logged at Level.FINE only. A non-empty override naming a configFilePath the scan never registered is real capability loss - #336's warning-only ask cannot tell these two cases apart, so this refuses the module and names every missing entity instead of guessing.

      A directory-backed entity is the exception to the path comparison. Auto-registration expands a configFilePath that names a directory under getResourceFolderPath() (the same isDirectory() test ConfigManager#register applies) into one registration per file inside it, and registers nothing for an empty directory, so the unexpanded path is never a registered key. Such an override entity counts as found when its class is among the config classes package-scan auto-registration selected - the same ConverterRegistry.prepareSelectedConfigs(com.ultikits.ultitools.abstracts.UltiToolsPlugin, java.lang.String[], java.lang.ClassLoader) selection over the same scan packages and class loader initConfig() hands to ConfigManager#registerAll. An entity class the scan did not select is still refused. This method only reads registrations, the selected classes and whether a path is a directory; it never writes or creates a configuration file.

      Throws:
      ConfigurationException - if the override names a configFilePath auto-registration never registered
    • namesConfigDirectory

      private boolean namesConfigDirectory(String configFilePath)
      Parameters:
      configFilePath - a configFilePath from a Configurable.getAllConfigs() override
      Returns:
      whether it names an existing directory under this module's resource folder, the test ConfigManager#register uses to expand a directory-backed entity
    • getInputStream

      private InputStream getInputStream() throws IOException
      Throws:
      IOException
    • resolveJarFileNameForError

      private String resolveJarFileNameForError()
      Best-effort resolution of this module's own JAR file name, for the name:-missing refusal message only. Never throws -- falls back to the class name if the code source is unavailable (e.g. when running from unpacked classes in a test).
      Returns:
      the JAR file name, or this class's name if it cannot be determined
    • getConfigFolder

      protected final String getConfigFolder()
    • getConfigFile

      protected final File getConfigFile(String path)
    • getConfig

      public <T extends AbstractConfigEntity> T getConfig(Class<T> configType)
      Description copied from interface: Configurable
      Get config by config type.
      Specified by:
      getConfig in interface Configurable
      Type Parameters:
      T - Config type
      Parameters:
      configType - Config type
      Returns:
      Config
    • getConfig

      public <T extends AbstractConfigEntity> T getConfig(String path, Class<T> configType)
      Description copied from interface: Configurable
      Get config by config path and config type.
      Specified by:
      getConfig in interface Configurable
      Type Parameters:
      T - Config type
      Parameters:
      path - Config path
      configType - Config type
      Returns:
      Config
    • getConfigs

      public <T extends AbstractConfigEntity> List<T> getConfigs(Class<T> configType)
    • saveConfig

      public <T extends AbstractConfigEntity> void saveConfig(String path, Class<T> configType) throws IOException
      Saves this module's configuration registered at path through AbstractConfigEntity.save(): only the settings the module changed since the last load or save are written, and only where the file still holds the value the module started from; every other byte of the file stays as the operator left it, and a change that cannot be written stays in memory and is named in one warning. It cannot overwrite operator content: a value the operator edited, deleted or wrote unusably is never written over (maintainer decision 2026-10-04). Use it for a change the operator asked for through the module, or for shipped text re-rendered after a language switch.
      Specified by:
      saveConfig in interface Configurable
      Type Parameters:
      T - the configuration entity type
      Parameters:
      path - the registered configuration file path, for example config/config.yml
      configType - the configuration entity class
      Throws:
      IOException - if publishing the verified file fails
    • saveResources

      private void saveResources(boolean languageFiles)
      Extracts this module's embedded res/lang/config jar resources into resourceFolderPath. Guarantees canonical-path validation before every write: each extracted entry's resolved destination is checked against the resource folder's own canonical path, and any entry whose path would resolve outside it (a Zip Slip attempt) is skipped with a warning rather than written.

      Every file this method actually extracts gets its provenance recorded, in one batch via ResourceHashSidecar.recordAll(File, Map) after the whole extraction pass completes (Codex round 4, P2) -- across all three prefixes, D-07 -- so a later boot can tell "the operator edited this" from "an old jar extracted this and nobody has touched it since" (#441, D-05/D-06). A file this method skips (already present on disk) gets no record here: the skip already means the file predates this mechanism, or was already decided on by loadLanguageFromDisk(java.lang.String, java.lang.String, java.lang.String) on a previous boot.

      Two halves (#567 item 1). The constructors pass false: config/ and res/ only, because initConfig() reads them right after. commitLanguageProvenance() passes true: lang/ only, once the load gates accepted this copy. A copy the gates reject is never committed, so it writes no language file; its construction-time language reads the jar's copy instead (see loadLanguageFromDisk(java.lang.String, java.lang.String, java.lang.String)).

      Parameters:
      languageFiles - true for the lang/ resources, false for config/ and res/
    • getDataOperator

      public final <T extends BaseDataEntity<String>> DataOperator<T> getDataOperator(Class<T> dataClazz)
      Gets data operator. Refuses outright if dataClazz is not registered to this module (D-14) -- checked against the same DataScope minted for this module at load, via the same refusal DataStore.getOperator(DataScope, Class) builds, so the exception type, error code, and message shape are identical regardless of which entry point a caller reaches.

      02-13 (CR-03): before this, dataScope.owns(...) was checked here inline and this method then delegated to the deprecated getOperator(UltiToolsPlugin, Class) overload directly -- so DataStore.getOperator(DataScope, Class), the method D-17/02-07 built specifically as the credential-typed supported path, had zero real callers anywhere in the framework. This now routes through it, so production actually uses the path it was built for. dataScope is normally non-null by the time any module calls this (set by PluginManager right after minting, before registerSelf() runs); the null fallback below only covers a bare instance constructed outside the normal PluginManager load flow (e.g. a test), where the deprecated overload's own checkOwnership(...) call still refuses correctly on its own.

      Type Parameters:
      T - the type of the data entity
      Parameters:
      dataClazz - the class of the data entity
      Returns:
      the data operator
      Throws:
      DataAccessException - if dataClazz is not registered to this module
    • getLanguageCode

      public final String getLanguageCode()
      The official language this module's messages are based on: a language code this module ships, such as en or zh.

      Use it for behaviour that depends on the language, such as choosing which shipped text to write into a configuration file or which example content to create. An operator may select a custom language file in plugins/UltiTools/config.yml: a copy of an official file under a new name that starts with the official code and a hyphen, such as zh-myserver. For that setting this method returns zh. A name that starts with no shipped code resolves to en when the module ships it, otherwise to its first shipped code. A module that ships no language files gets the configured name unchanged. The configured name itself is getConfiguredLanguage().

      Returns:
      the official language code, or null if no language is configured and the module ships no language files
      Since:
      6.3.0 returns the official base of a custom language name (#608); before 6.3.0 it returned the configured name
    • getConfiguredLanguage

      public final String getConfiguredLanguage()
      The language setting of plugins/UltiTools/config.yml as the framework runs with it: an official code such as zh, or the name of an operator's custom language file such as zh-myserver. Messages come from lang/<name>.* in this module's folder when that file exists, and from the official language getLanguageCode() returns for every key the file lacks.
      Returns:
      the configured language name, or null if none is configured
      Since:
      6.3.0
    • getLanguage

      public final Language getLanguage()
      Returns:
      the language
    • i18n

      public String i18n(String str)
      Parameters:
      str - the string to be localized
      Returns:
      the localized string
    • i18n

      public final String i18n(String code, String str)
      Description copied from interface: Localized
      Returns a localized string for the given default text. The code parameter is not honoured by the framework's own implementation -- UltiToolsPlugin overrides this method final and resolves directly against the module's already-loaded Language, discarding code entirely. Language selection happens once at load time, via Localized.supported(), not per call. str is returned unchanged when it is absent as a key from that already-loaded dictionary.
      Specified by:
      i18n in interface Localized
      Parameters:
      code - language code -- not honoured by the framework's own i18n(String, String) override
      str - default display text, also the dictionary lookup key
      Returns:
      a localized string, or str unchanged if the key is absent from the loaded dictionary
    • isNewerVersionThan

      public boolean isNewerVersionThan(UltiToolsPlugin plugin)
      Parameters:
      plugin - the plugin to be checked
      Returns:
      whether the plugin is newer than the given plugin
    • onUnregister

      protected void onUnregister()
      Extension point for a module's own unload cleanup.

      Called by unregisterSelf() before the framework unregisters this plugin's commands and listeners (D-02), so the module's own beans are still alive while this runs. unregisterSelf() is final and always calls this hook and the framework's own unregistration afterward -- a module cannot skip either by overriding unregisterSelf() itself, because that is no longer possible (D-01). The default body does nothing; override this method, not unregisterSelf(), to add cleanup work.

      This hook may throw anything: an unchecked exception, an Error, or a checked exception it does not declare (Lombok @SneakyThrows, or a rethrow helper). Whatever it throws, unregisterSelf() still unregisters this module's own commands and listeners before the exception propagates (WR-01, 16-REVIEW-lifecycle.md) -- a throwing hook is surfaced to the caller, not swallowed, but it cannot skip the framework's own cleanup the way an unguarded super call could. The exception reaches the caller unchanged: the same instance, of the same type, never wrapped. If a cleanup step also fails, that failure does not replace this hook's own exception: it is attached to it via Throwable.addSuppressed(java.lang.Throwable) instead, so the module author's own failure is always the one that propagates and is never silently lost behind a secondary framework failure (Codex review on #457, issue #484).

    • unregisterSelf

      public final void unregisterSelf()
      Unloads this module's own registrations: runs onUnregister(), then unregisters this module's commands, then its listeners. final, so a module cannot skip either cleanup step.

      Every step runs whatever an earlier step threw, including a checked exception the hook does not declare. When a step failed, the first failure, in that order, is rethrown afterwards as the same instance and type, never wrapped; every later failure is attached to it via Throwable.addSuppressed(java.lang.Throwable). A checked exception therefore reaches the caller although this method declares none.

      Specified by:
      unregisterSelf in interface IPlugin
    • runUnregisterStep

      private static Throwable runUnregisterStep(Throwable previousFailure, Runnable step)
      Runs one unregisterSelf() cleanup step, collecting any failure onto previousFailure instead of letting it replace or be replaced by another step's failure.

      onUnregister(), CommandManager#unregisterAll and ListenerManager#unregisterAll declare no checked exceptions, but module code can still throw one without declaring it (Lombok @SneakyThrows, or a rethrow helper). Catching only RuntimeException | Error let such an exception escape this barrier and skip every later step, leaving the module's commands and listeners registered after its unload. So this catches Exception | Error: every Throwable a step can really throw (this is a deliberate cleanup barrier, not a broad catch: unregisterSelf() always runs every remaining step and always rethrows exactly one collected failure unchanged, per its own javadoc).

      Parameters:
      previousFailure - the failure already collected from an earlier step, or null if every earlier step (if any) succeeded
      step - the cleanup step to run
      Returns:
      previousFailure, with step's own failure (if any) attached to it via Throwable.addSuppressed(java.lang.Throwable); or, if previousFailure was null, step's own failure, or null if step completed normally
    • rethrowUnchanged

      private static <T extends Throwable> void rethrowUnchanged(Throwable failure) throws T
      Throws failure itself -- the same instance, of its own type, unwrapped -- although it may be a checked exception this method's callers do not declare. unregisterSelf() uses it to hand a hook's undeclared checked exception to its caller unchanged.
      Type Parameters:
      T - inferred as an unchecked type at the call site, so no throws clause is needed
      Parameters:
      failure - the collected failure to rethrow
      Throws:
      T - always: failure
    • onReload

      protected void onReload()
      Extension point for a module's own reload work.

      Called by reloadSelf() after the framework's own reload steps -- config reload, config-bound timing refresh (#531), language-catalogue refresh and @ConditionalOnConfig drift report (D-02) -- so a real-work override sees the already-reloaded configuration rather than the stale one. The framework-owned per-module reload line (D-03) is logged only after this hook returned; if it throws, a failure line naming the module is logged instead and the exception propagates to the caller (#509). reloadSelf() is final and always runs its own steps first; a module cannot skip them by overriding reloadSelf() itself, because that is no longer possible (D-01). The default body does nothing; override this method, not reloadSelf(), to add reload work.

    • onReload

      protected void onReload(ReloadReport report)
      Extension point for a module's own reload work, with a way to report a partial reload (#529).

      Called by reloadSelf() and reloadWithReport() at the point onReload() is documented to run, with a fresh ReloadReport. A module that reloads only part of what it should records each part that did not reload with ReloadReport.partial(String) and returns normally; the framework then reports the reload as partial, naming those parts, instead of as a success. The default body calls onReload(), so a module that overrides only that method behaves exactly as before 6.3.0. Override one of the two, not both: an override of this method replaces the call to onReload() unless it calls super.onReload(report).

      Parameters:
      report - the report for this reload, to record the parts that did not reload
      Since:
      6.3.0
    • reloadSelf

      public final void reloadSelf()
      Reload this plugin's configuration and language files.

      Also reports (but does not act on) any @ConditionalOnConfig drift: the condition is evaluated once, at component-scan time during startup, so a reload can only log that a watched key has changed direction since then -- it never registers, unregisters, or rebuilds anything (issue #392, D-01). Right after the configuration reload, and only if it succeeded, the module's config-bound @Scheduled tasks and @CmdCD cooldowns pick up their reloaded values (#531; see PluginManager#applyReloadedConfigBindings); a value that step refuses and keeps, or a part of it that fails, is recorded in the ReloadReport as a part that did not reload (#595). final and always runs its own steps, then calls onReload() -- a module can no longer skip any of this by overriding reloadSelf() itself, because that override point no longer exists (D-01). The hook it calls is onReload(ReloadReport), whose default body calls onReload().

      Only when every step and the hook returned does it log one framework-owned line naming this module (D-03): an INFO success line, or a WARNING naming the parts that did not reload when the hook recorded any in its ReloadReport (#529). If anything throws, it logs one SEVERE line naming the module and the cause instead, never the success line, and rethrows the failure unchanged, so a caller can report it -- /ul reload <name> replies failure, and a full /ul reload carries on with the next module and names this one in its summary (#509).

      The signature is unchanged since 6.2; reloadWithReport() runs the same reload and returns the report.

      Specified by:
      reloadSelf in interface IPlugin
    • reloadWithReport

      public final ReloadReport reloadWithReport()
      Reloads this module exactly as reloadSelf() does, and returns what its reload hook reported (#529).

      /ul reload and /ul reload <name> call this to tell the operator which parts of a module did not reload. A module's own reload command can call it for the same reason.

      Returns:
      the report of this reload, never null; empty when every part reloaded
      Throws:
      RuntimeException - whatever a reload step or the module's hook threw, after it was logged (a checked exception the hook threw without declaring it propagates unchanged in the same way)
      Error - whatever a reload step or the module's hook threw, after it was logged
      Since:
      6.3.0
    • performReload

      private ReloadReport performReload()
      The body of reloadSelf() and reloadWithReport(). Private, so both public entry points always run exactly this.
      Returns:
      the report the module's hook filled in; when the call was refused off the server thread (#538), a report whose only partial reason is that refusal
    • runReloadSteps

      private void runReloadSteps(ReloadReport report)
      The reload steps, in order, ending with the module's own hook.
      Parameters:
      report - the report handed to the module's hook
    • pendingLanguageSetting

      private String pendingLanguageSetting()
      The framework's language setting as it is in config.yml on disk now, when it differs from the value the framework runs with (#502).

      The framework reads its configuration once and re-reads it only on a full /ul reload. A missing, unreadable or unparseable file, or one without the key, is not a pending change: that is reported, if at all, by the full reload that reads it.

      Returns:
      the value on disk, or null if it is the running value or cannot be read
    • describeFailure

      private static String describeFailure(Throwable failure)
      A failure's message for a one-line report, or its class name when it carries none.
      Parameters:
      failure - the failure to describe
      Returns:
      a non-null description
    • getLogger

      public PluginLogger getLogger()
      Returns:
      plugin logger