Admin screen ID to module ID map (request-local). */ private static $admin_screens = []; /** @var array Admin post type to module ID map (request-local). */ private static $admin_post_types = []; /** @var bool|null Cached `is_plugin_active()` result for the Pro plugin. */ private static $pro_active = null; /** * Register an exact WordPress admin screen as belonging to a module, or * to a Section (keyed by its `SECTION_ID`) for notification targeting. */ public static function register_admin_screen( string $module_id, string $screen_id ): void { if ( '' !== $module_id && '' !== $screen_id ) { self::$admin_screens[ $screen_id ] = $module_id; } } /** Register an admin post type as belonging to a module. */ public static function register_admin_post_type( string $module_id, string $post_type ): void { if ( '' !== $module_id && '' !== $post_type ) { self::$admin_post_types[ $post_type ] = $module_id; } } /** Determine whether an admin screen belongs to a module. */ public static function is_admin_screen_for_module( string $module_id, $screen ): bool { if ( ! is_object( $screen ) ) { return false; } $screen_id = isset( $screen->id ) ? (string) $screen->id : ''; if ( isset( self::$admin_screens[ $screen_id ] ) ) { return $module_id === self::$admin_screens[ $screen_id ]; } $post_type = isset( $screen->post_type ) ? (string) $screen->post_type : ''; return isset( self::$admin_post_types[ $post_type ] ) && $module_id === self::$admin_post_types[ $post_type ]; } /** * Resolve a registered screen's canonical public notification source. * * For a screen registered under a real catalogue module ID, this returns * that module's public `slug` (e.g. `db-cleaner`). Sections have no * catalogue entry of their own — `register_admin_screen()` is also used * to map a Section's page hook to its `SECTION_ID` (e.g. `content`), * which is returned as-is since there is no registry lookup to resolve it * against. */ public static function get_module_slug_for_screen( $screen ): ?string { $module_id = self::$admin_screens[ $screen->id ] ?? null; if ( null === $module_id && ! empty( $screen->post_type ) ) { $module_id = self::$admin_post_types[ $screen->post_type ] ?? null; } if ( null === $module_id ) { return null; } $module = self::get_module( $module_id ); return $module['slug'] ?? $module_id; } /** * Get the full list of registered capabilities, keyed by canonical ID. * * Free defines the canonical catalogue first; Pro may enrich individual * definitions (most importantly, supply `runtime_target` for capabilities * it implements) via the `adminoptimizer_module_registry` filter. The * filtered result is re-normalized so enrichment can never bypass * required defaults/classification or silently mutate a canonical ID. * * @return array */ public static function get_modules(): array { if ( null === self::$modules ) { $modules = apply_filters( 'adminoptimizer_module_registry', self::get_raw_modules( true ) ); self::$modules = self::normalize_modules( is_array( $modules ) ? $modules : [] ); } return self::$modules; } /** * Get the canonical catalogue without translating presentation metadata. * * This is deliberately separate from `get_modules()` because activation * checks run while modules are registering their `init` hooks during * `plugins_loaded`. Calling gettext from that path would trigger WordPress's * early `_load_textdomain_just_in_time` warning. * * @return array */ private static function get_runtime_modules(): array { if ( null === self::$runtime_modules ) { $modules = apply_filters( 'adminoptimizer_module_registry', self::get_raw_modules( false ) ); self::$runtime_modules = self::normalize_modules( is_array( $modules ) ? $modules : [] ); } return self::$runtime_modules; } /** * Get a single capability definition by canonical ID. * * The `adminoptimizer_module_registry_module` filter may adjust the * definition returned here, but its result is re-normalized just like * the whole-catalogue filter in `get_modules()` — the filter cannot * bypass required defaults, hand back an invalid `tier`/`classification`/ * `config_mode`, or rewrite the canonical `id` out from under the * requested `$module_id`. * * @param string $module_id Module ID. * * @return array|null */ public static function get_module( string $module_id ): ?array { $modules = self::get_modules(); $module = $modules[ $module_id ] ?? null; $filtered = apply_filters( 'adminoptimizer_module_registry_module', $module, $module_id ); if ( ! is_array( $filtered ) ) { return null; } return self::normalize_module_definition( $module_id, $filtered ); } /** * Whether a module ID exists in the registry. * * @param string $module_id Module ID. * * @return bool */ public static function has_module( string $module_id ): bool { return isset( self::get_modules()[ $module_id ] ); } /** * Register a new canonical capability definition. * * This is the explicit, imperative counterpart to the * `adminoptimizer_module_registry` filter — a controlled seam through * which Pro/extensions may *contribute* capability metadata without * owning or replacing the registry itself. It only ever adds a * definition; it never lets a caller construct anything, and it never * exposes the underlying storage for direct mutation. * * Rules: * * - Fails if `$id` already exists. This method registers new * capabilities only; it can never overwrite an existing canonical * definition (use `extend_module()` for that). * - `$definition` must carry a non-empty `name` — the minimum * canonical metadata needed for the entry to mean anything on its * own (Overview/Section presentation requires at least a label). * - The definition is run through the same normalization every other * definition goes through, so an invalid `tier`/`classification`/ * `config_mode` is corrected to its default rather than accepted * verbatim, and `id` is always pinned to the requested `$id`. * - Ensures `get_modules()` has been populated first, so a call made * before the registry's first read still lands in the cache the * very first `get_modules()` call returns. * * @param string $id Canonical capability ID, not already registered. * @param array $definition Capability definition (pre-normalization). * * @return bool True on success; false if `$id` is empty, already * registered, or `$definition` lacks a usable `name`. */ public static function register_module( string $id, array $definition ): bool { if ( '' === $id ) { return false; } self::get_modules(); // Ensure the cache is populated before mutating it. if ( self::has_module( $id ) ) { return false; } if ( empty( $definition['name'] ) || ! is_string( $definition['name'] ) ) { return false; } self::$modules[ $id ] = self::normalize_module_definition( $id, $definition ); return true; } /** * Register an additional option to include in this module's backups. * * A module's primary `option` metadata is included automatically. Use this * method for secondary options owned by the same module. Registration is * request-local and idempotent, and therefore must happen before an export * or import reads the option-name list. * * @param string $module_id Canonical capability ID, already registered. * @param string $option_name WordPress option name to export and import. * * @return bool True when the option is registered; false for an unknown * module or an empty option name. */ public static function register_backup_option( string $module_id, string $option_name ): bool { self::get_modules(); $option_name = trim( $option_name ); if ( '' === $option_name || ! self::has_module( $module_id ) ) { return false; } $backup_options = self::$modules[ $module_id ]['backup_options'] ?? []; $backup_options[] = $option_name; self::$modules[ $module_id ]['backup_options'] = array_values( array_unique( $backup_options ) ); return true; } /** * Get every option registered for export/import backups. * * @return string[] */ public static function get_backup_option_names(): array { $option_names = []; foreach ( self::get_modules() as $module ) { $option_names = array_merge( $option_names, $module['backup_options'] ?? [] ); } return array_values( array_unique( $option_names ) ); } /** * Extend an existing canonical capability definition. * * This is how Pro contributes to a capability Free already defined — * most importantly supplying `runtime_target` for a `pro` capability, * or Pro-owned settings/behavior metadata for a * `free_with_pro_options` one — without creating a second identity for * it and without reaching into the registry's storage directly. * * Rules: * * - Fails if `$id` does not already exist. This method extends * existing capabilities only; it never creates one (use * `register_module()` for that). * - Only the fields present in `$changes` are merged onto the existing * definition — every field the caller omits is left untouched. * - The canonical `id` can never be changed: it is always re-pinned to * `$id` regardless of what `$changes` contains. * - The merged result is run through the same normalization as every * other definition, so `$changes` cannot introduce an invalid * `tier`/`classification`/`config_mode`/`runtime_target`. * * @param string $id Canonical capability ID, already registered. * @param array $changes Fields to merge onto the existing definition. * * @return bool True on success; false if `$id` is not already registered. */ public static function extend_module( string $id, array $changes ): bool { self::get_modules(); // Ensure the cache is populated before mutating it. if ( ! self::has_module( $id ) ) { return false; } $changes['id'] = $id; $merged = array_merge( self::$modules[ $id ], $changes ); self::$modules[ $id ] = self::normalize_module_definition( $id, $merged ); return true; } /** * Get all modules belonging to a given Section. * * @param string $section Canonical Section ID. * * @return array */ public static function get_modules_by_section( string $section ): array { return array_filter( self::get_modules(), static function ( $module ) use ( $section ) { return $module['section'] === $section; } ); } /** * Get the capabilities presented beneath a registry container. * * `parent` describes presentation hierarchy only. It does not imply an * activation or runtime dependency; use `requires` for that purpose. * Catalogue order is preserved so Sections can render children without * maintaining a second ordering list. * * @param string $parent_id Canonical ID of the presentation container. * * @return array */ public static function get_modules_by_parent( string $parent_id ): array { return array_filter( self::get_modules(), static function ( $module ) use ( $parent_id ) { return $module['parent'] === $parent_id; } ); } /** * Get runtime-safe child definitions for an owning Section. * * Unlike get_modules_by_parent(), this reads the untranslated runtime * catalogue and is therefore safe while Sections boot during * `plugins_loaded`. * * @param string $parent_id Canonical ID of the presentation container. * * @return array */ public static function get_runtime_modules_by_parent( string $parent_id ): array { return array_filter( self::get_runtime_modules(), static function ( $module ) use ( $parent_id ) { return $module['parent'] === $parent_id; } ); } /** * Get all modules belonging to a given tier. * * @param string $tier Tier value. * * @return array */ public static function get_modules_by_tier( string $tier ): array { return array_filter( self::get_modules(), static function ( $module ) use ( $tier ) { return $module['tier'] === $tier; } ); } /** * Get all modules with a given lifecycle classification. * * @param string $classification One of the CLASSIFICATIONS constants. * * @return array */ public static function get_modules_by_classification( string $classification ): array { return array_filter( self::get_modules(), static function ( $module ) use ( $classification ) { return $module['classification'] === $classification; } ); } /** * Get the tier of a module. * * @param string $module_id Module ID. * * @return string */ public static function get_tier( string $module_id ): string { $module = self::get_module( $module_id ); return $module['tier'] ?? self::DEFAULT_TIER; } /** * Whether a capability's canonical identity belongs to Pro (its tier is * `pro` or `free_with_pro_options`'s Pro-only extension surface). * * This is product ownership, not runtime availability — see * `is_available()`. * * @param string $module_id Module ID. * * @return bool */ public static function is_pro_module( string $module_id ): bool { return self::get_tier( $module_id ) === 'pro'; } /** * Whether a capability is available in the Free edition (i.e. Free owns * and implements its canonical runtime). * * @param string $module_id Module ID. * * @return bool */ public static function is_free_module( string $module_id ): bool { $tier = self::get_tier( $module_id ); return in_array( $tier, [ 'free', 'free_with_pro_options' ], true ); } /** * Whether the Admin Optimizer Pro plugin is active. * * Plugin-activation state, not capability ownership — see `is_pro_module()`. * Cached for the life of the request. * * @return bool */ public static function is_pro_active(): bool { if ( null !== self::$pro_active ) { return self::$pro_active; } if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } self::$pro_active = is_plugin_active( 'admin-optimizer-pro/admin-optimizer-pro.php' ); return self::$pro_active; } /** * Canonical runtime-availability check. * * Availability answers "can this capability's implementation run in this * installation right now?" — it is deliberately independent of tier. The * current implementation-availability rule for ordinary `MODULE` * capabilities is simple and intentionally so for this phase: * * available === ( classification === MODULE ) AND ( runtime_target !== null ) * * A `pro` capability becomes available once Pro enriches its definition * with a `runtime_target`; a `free`/`free_with_pro_options` capability * becomes available once its own runtime target is wired up (a later * Phase 3.4 step — every `runtime_target` in this file is currently * `null`, so nothing reports available yet regardless of tier). This is * the current rule, not a permanent one — a future availability need * (environment checks, feature flags, etc.) is a later design question, * not solved here. `SECTION_FEATURE` and `NATIVE` capabilities are never * "available" in this sense; they are not ordinary `Runtime_Loader` * construction targets at all. * * @param string $module_id Module ID. * * @return bool */ public static function is_available( string $module_id ): bool { $module = self::resolve_runtime_module( $module_id ); if ( null === $module || ! empty( $module['retired'] ) ) { return false; } if ( self::MODULE !== $module['classification'] ) { return false; } return null !== $module['runtime_target']; } /** * @deprecated Use is_available(). Retained for legacy call sites during * the v3 transition. * * @param string $module_id Module ID. * * @return bool */ public static function is_module_available( string $module_id ): bool { return self::is_available( $module_id ); } /** * Get the toggle key (option array key) for a module, or null if it has none. * * @param string $module_id Module ID. * * @return string|null */ public static function get_toggle_key( string $module_id ): ?string { $module = self::get_module( $module_id ); return $module['toggle'] ?? null; } /** * Whether a capability is retained only for legacy state migration. * * @param string $module_reference Module ID, slug, or toggle key. * * @return bool */ public static function is_retired( string $module_reference ): bool { $module = self::resolve_runtime_module( $module_reference ); return null !== $module && ! empty( $module['retired'] ); } /** * Whether a capability has a persisted ON/OFF toggle. * * Only `ACTIVATION_TOGGLE` capabilities with a non-empty string toggle key * are toggleable. NATIVE and `ACTIVATION_ALWAYS` capabilities are not. * * @param string $module_reference Module ID, slug, or toggle key. * * @return bool */ public static function is_toggleable( string $module_reference ): bool { $module = self::resolve_runtime_module( $module_reference ); if ( null === $module || ! empty( $module['retired'] ) || self::ACTIVATION_TOGGLE !== $module['activation_mode'] ) { return false; } return is_string( $module['toggle'] ) && '' !== $module['toggle']; } /** * Whether a string is a registered module's toggle key (a MODULES_OPTION array key). * * @param string $toggle_key Toggle key. * * @return bool */ public static function is_valid_toggle_key( string $toggle_key ): bool { foreach ( self::get_modules() as $module ) { if ( empty( $module['retired'] ) && ( $module['toggle'] ?? null ) === $toggle_key ) { return true; } } return false; } /** * Get a module definition by its public-facing slug (e.g. 'seo-manager'). * * @param string $slug Module slug. * * @return array|null */ public static function get_module_by_slug( string $slug ): ?array { foreach ( self::get_modules() as $module ) { if ( ( $module['slug'] ?? null ) === $slug ) { return $module; } } return null; } /** * Resolve any known module reference (canonical ID, public slug, or * toggle key) to its full definition. * * This is the single internal lookup every public API that accepts a * flexible "module reference" (`is_enabled()`, `resolve_toggle_key()`, * `activate_module()`, ...) should route through, so the three * reference forms — e.g. `seo_manager`, `seo-manager`, * `enable_seo_manager` — consistently resolve to the same capability * everywhere rather than each call site reimplementing its own subset * of the lookup. * * @param string $module_reference Module ID, slug, or toggle key. * * @return array|null */ private static function resolve_module( string $module_reference ): ?array { $module = self::get_module( $module_reference ); if ( null !== $module ) { return $module; } $module = self::get_module_by_slug( $module_reference ); if ( null !== $module ) { return $module; } foreach ( self::get_modules() as $candidate ) { if ( ( $candidate['toggle'] ?? null ) === $module_reference ) { return $candidate; } } return null; } /** * Resolve a module reference without loading translated metadata. * * @param string $module_reference Module ID, slug, or toggle key. * * @return array|null */ private static function resolve_runtime_module( string $module_reference ): ?array { if ( null !== self::$modules ) { return self::resolve_module( $module_reference ); } return self::resolve_module_from( self::get_runtime_modules(), $module_reference ); } /** * Resolve a flexible reference against an already-built catalogue. * * @param array $modules Normalized module definitions. * @param string $module_reference Module ID, slug, or toggle key. * * @return array|null */ private static function resolve_module_from( array $modules, string $module_reference ): ?array { if ( isset( $modules[ $module_reference ] ) ) { return $modules[ $module_reference ]; } foreach ( $modules as $candidate ) { if ( ( $candidate['slug'] ?? null ) === $module_reference ) { return $candidate; } if ( ( $candidate['toggle'] ?? null ) === $module_reference ) { return $candidate; } } return null; } /** * Resolve any known module reference (ID, slug, or toggle key) to its toggle key. * * @param string $module_reference Module ID, slug, or toggle key. * * @return string|null */ public static function resolve_toggle_key( string $module_reference ): ?string { $module = self::resolve_module( $module_reference ); return $module['toggle'] ?? null; } /** * Normalize a raw persisted option value into canonical ON/OFF state. * * Value-aware truthiness — presence of the option key is not itself * activation. This is the single interpretation used by every public * status API so "key exists but stores 0" can never read as ON. * * absent / null / '' / false / 0 / '0' -> OFF * true / 1 / '1' -> ON * any other non-empty value -> ON (existing explicit truthy choice) * * @param mixed $value Raw persisted value, or null if the key is absent. * * @return bool */ private static function normalize_bool_state( $value ): bool { if ( null === $value || '' === $value ) { return false; } if ( is_string( $value ) && '0' === $value ) { return false; } return (bool) $value; } /** Get the WordPress option name used to persist module activation state. */ private static function get_modules_option_name(): string { return defined( __NAMESPACE__ . '\\Admin\\MODULES_OPTION' ) ? constant( __NAMESPACE__ . '\\Admin\\MODULES_OPTION' ) : 'adminoptim-modules'; } /** Register request-local cache invalidation for every WordPress write path. */ private static function register_modules_option_cache_hooks(): void { if ( self::$modules_option_cache_hooks_registered ) { return; } $modules_option = self::get_modules_option_name(); add_action( 'updated_option_' . $modules_option, [ self::class, 'clear_modules_option_cache' ], 10, 0 ); add_action( 'added_option_' . $modules_option, [ self::class, 'clear_modules_option_cache' ], 10, 0 ); add_action( 'deleted_option_' . $modules_option, [ self::class, 'clear_modules_option_cache' ], 10, 0 ); self::$modules_option_cache_hooks_registered = true; } /** * Get the cached, normalized module activation option. * * @return array */ private static function get_modules_option(): array { self::register_modules_option_cache_hooks(); if ( null === self::$modules_option ) { $stored = get_option( self::get_modules_option_name(), [] ); self::$modules_option = is_array( $stored ) ? $stored : []; } return self::$modules_option; } /** Clear the request-local activation-state cache after an option change. */ public static function clear_modules_option_cache(): void { self::$modules_option = null; } /** * Canonical enabled/disabled state for a capability. * * Uses value-aware interpretation of the persisted MODULES_OPTION entry: * an explicit `0`/`'0'`/`false`/`null`/`''` is OFF even though the key is * present; a missing key falls back to the capability's `fresh_default`. * `ACTIVATION_ALWAYS` capabilities bypass persisted state, while NATIVE * infrastructure remains unconditional and non-toggleable. Retired * capabilities may expose an explicitly stored historical toggle value for * migration, but never use `fresh_default` when that value is absent. * * @param string $module_id Module ID, slug, or toggle key. * * @return bool */ public static function is_enabled( string $module_id ): bool { $module = self::resolve_runtime_module( $module_id ); if ( null === $module ) { return false; } $retired = ! empty( $module['retired'] ); if ( ! $retired && self::ACTIVATION_ALWAYS === $module['activation_mode'] ) { return true; } if ( ! $retired && self::NATIVE === $module['classification'] ) { return true; } if ( self::ACTIVATION_TOGGLE !== $module['activation_mode'] || ! is_string( $module['toggle'] ) || '' === $module['toggle'] ) { return false; } $stored = self::get_modules_option(); if ( ! array_key_exists( $module['toggle'], $stored ) ) { return $retired ? false : $module['fresh_default']; } return self::normalize_bool_state( $stored[ $module['toggle'] ] ); } /** * @deprecated Legacy key-presence semantics; use is_enabled(). Retained * only so existing call sites keep compiling during the v3 * transition — it now delegates to the corrected canonical * value-aware interpretation instead of treating key * presence as activation. * * @param string $module_name Module ID, slug, or toggle key. * * @return bool */ public static function is_module_activated( string $module_name ): bool { return self::is_enabled( $module_name ); } /** * Whether a capability's declared dependencies are currently satisfied. * * `requires` holds the IDs of other capabilities this one depends on; * satisfaction currently means each declared dependency is directly * `is_enabled()`. No definition in the current catalogue populates * `requires`, so this is intentionally a flat, non-recursive check — * there is no transitive `can_run()` walk, and no cycle handling. Adopt * a deeper resolution strategy only once an actual dependency case in * the catalogue needs it; do not speculatively generalize this ahead of * that evidence. * * @param string $module_id Module ID. * * @return bool */ public static function dependencies_satisfied( string $module_id ): bool { $module = self::resolve_runtime_module( $module_id ); if ( null === $module || empty( $module['requires'] ) ) { return true; } foreach ( $module['requires'] as $required_id ) { if ( ! self::is_enabled( (string) $required_id ) ) { return false; } } return true; } /** * Canonical status for a capability. * * This is the single authoritative status evaluation. It distinguishes * "disabled" from "unavailable" from "unmet dependency" — all of * which are different reasons a capability does not run — and returns * `not_applicable` for `SECTION_FEATURE`/`NATIVE` entries, which are not * ordinary runnable capabilities. * * @param string $module_id Module ID. * * @return string One of the STATUS_* constants. */ public static function get_status( string $module_id ): string { $module = self::get_module( $module_id ); if ( null === $module ) { return self::STATUS_DISABLED; } if ( ! empty( $module['retired'] ) ) { return self::STATUS_RETIRED; } if ( self::MODULE !== $module['classification'] ) { return self::STATUS_NOT_APPLICABLE; } if ( ! self::is_enabled( $module_id ) ) { return self::STATUS_DISABLED; } if ( ! self::is_available( $module_id ) ) { return self::STATUS_UNAVAILABLE; } if ( ! self::dependencies_satisfied( $module_id ) ) { return self::STATUS_UNMET_DEPENDENCY; } return self::STATUS_ENABLED; } /** * Canonical runtime-eligibility check for `Runtime_Loader`. * * Answers the one question a loader needs: "should this ordinary MODULE * be constructed?" Equivalent to `get_status() === STATUS_ENABLED`, * expressed as a boolean for direct loader consumption. * * @param string $module_id Module ID. * * @return bool */ public static function can_run( string $module_id ): bool { $module = self::resolve_runtime_module( $module_id ); if ( null === $module || ! empty( $module['retired'] ) || self::MODULE !== $module['classification'] ) { return false; } return self::is_enabled( $module_id ) && null !== $module['runtime_target'] && self::dependencies_satisfied( $module_id ); } /** * Whether a `SECTION_FEATURE` capability's optional supporting class is * eligible to be constructed by its owning Section. * * Mirrors `can_run()`'s enabled/dependency gating, but for the * `SECTION_FEATURE` lifecycle instead of `MODULE`. `SECTION_FEATURE` * capabilities remain Section-owned rather than centrally * loader-constructed; this only tells the owning Section whether it is * safe to build the capability's supporting class, not that the loader * itself did so. * * @param string $module_id Module ID. * * @return bool */ public static function can_run_section_feature( string $module_id ): bool { $module = self::resolve_runtime_module( $module_id ); if ( null === $module || ! empty( $module['retired'] ) || self::SECTION_FEATURE !== $module['classification'] ) { return false; } return self::is_enabled( $module_id ) && null !== $module['runtime_target'] && self::dependencies_satisfied( $module_id ); } /** * Get a capability's runtime target class, or null if none is available. * * Always null for `NATIVE` entries, and null for a `pro` capability * until Pro enriches the definition. A `SECTION_FEATURE` entry may carry * an optional supporting class here; it is Section-constructed via * `Runtime_Loader::boot_section_feature()`, never by ordinary `MODULE` * construction. Never requires the target class to exist merely to read * this value. * * @param string $module_id Module ID. * * @return string|null */ public static function get_runtime_target( string $module_id ): ?string { $module = self::resolve_runtime_module( $module_id ); if ( null === $module || ! empty( $module['retired'] ) ) { return null; } return $module['runtime_target'] ?? null; } /** * Activate a particular module. * * @param string $module_name Module ID, slug, or toggle key (e.g. 'seo-manager' or 'enable_seo_manager'). * * @return true|\WP_Error True on success, or if the module was already activated. \WP_Error if the module name is invalid or activation fails. */ public static function activate_module( string $module_name ) { return self::set_enabled( $module_name, true ); } /** * Canonical write primitive for a capability's ON/OFF state. * * This is the single place in the registry that mutates the persisted * MODULES_OPTION array. It merges onto the existing stored option rather * than replacing it, so unrelated module/Section-feature/compatibility * keys already present are always preserved (Phase 3.5B.2 §8). Any * surface that needs to change a capability's stored ON/OFF value — * `activate_module()`, the shared Free activation AJAX handler, future * Section module toggles — must route through this method rather than * reading/writing MODULES_OPTION itself. * * @param string $module_reference Module ID, slug, or toggle key. * @param bool $enabled Desired ON (true) / OFF (false) state. * * @return true|\WP_Error True on success (including a no-op when already * at the desired state). \WP_Error if the module * reference is invalid or the write fails. */ public static function set_enabled( string $module_reference, bool $enabled ) { $module = self::resolve_module( sanitize_text_field( $module_reference ) ); if ( null === $module ) { return new \WP_Error( 'invalid_module', 'Invalid module name.' ); } $toggle_key = $module['toggle'] ?? null; if ( ! self::is_toggleable( $module['id'] ) || ! is_string( $toggle_key ) ) { return new \WP_Error( 'not_toggleable', 'This capability cannot be enabled or disabled.' ); } if ( self::is_enabled( $toggle_key ) === $enabled ) { return true; } $modules_option = self::get_modules_option_name(); $modules = self::get_modules_option(); $modules[ $toggle_key ] = $enabled; if ( ! update_option( $modules_option, $modules ) ) { return new \WP_Error( 'activation_failed', 'Failed to update module state.' ); } self::$modules_option = $modules; /** * Fires after a canonical capability activation state changes. * * Lifecycle consumers use this shared transition seam to stop work when * an ordinary module is disabled without requiring its runtime instance * to exist on that request. * * @param string $module_id Canonical capability ID. * @param bool $enabled New canonical state. */ do_action( 'adminoptim_module_state_changed', $module['id'], $enabled ); return true; } /** * Get the dedicated option name for a module, or null if it has none. * * @param string $module_id Module ID. * * @return string|null */ public static function get_option_name( string $module_id ): ?string { $module = self::get_module( $module_id ); return $module['option'] ?? null; } /** * Get the canonical admin URL for a module within its owning Section. * * @param string $module_id Canonical Module_Registry ID. * @param array $query_args Optional additional query arguments, such as a settings tab. * * @return string Empty when the module is not registered. */ public static function get_module_url( string $module_id, array $query_args = [] ): string { $module = self::get_module( $module_id ); if ( null === $module ) { return ''; } return add_query_arg( array_merge( [ 'page' => 'admin-optimizer-' . $module['section'], 'module' => $module['id'], ], $query_args ), admin_url( 'admin.php' ) ); } /** * Get the WordPress capability required to configure a module's settings * surface. This is separate from the capability required to enter an Admin * Optimizer Section or change a module's global activation state. * * @param string $module_id Canonical Module_Registry ID. * @return string */ public static function get_settings_capability( string $module_id ): string { $module = self::get_module( $module_id ); return $module['settings_capability'] ?? self::DEFAULT_SETTINGS_CAPABILITY; } /** * Get the WordPress capability required to globally enable or disable a * module. This does not authorize access to that module's settings or * resources; those remain independently governed by their own policies. * * @param string $module_id Canonical Module_Registry ID. * @return string */ public static function get_activation_capability( string $module_id ): string { $module = self::get_module( $module_id ); return $module['activation_capability'] ?? self::DEFAULT_ACTIVATION_CAPABILITY; } /** * Navigable ordinary modules for the admin-wide command palette. * * Read-only projection of the existing catalogue — it introduces no new * capability data and simply filters/reshapes `get_modules()` the same * way the Overview section cards already do: only `MODULE` * classification with `is_available()` true, which already excludes * `SECTION_FEATURE`, `NATIVE`, retired, and Pro-gated-in-Free * (`runtime_target: null`) capabilities. * * @return array */ public static function get_navigable_modules_for_commands(): array { $navigable = []; foreach ( self::get_modules() as $module ) { if ( self::MODULE !== $module['classification'] || ! self::is_available( $module['id'] ) ) { continue; } $navigable[] = [ 'id' => $module['id'], 'slug' => $module['id'], 'label' => $module['name'], 'section' => $module['section'], 'url' => self::get_module_url( $module['id'] ), ]; } return $navigable; } /** * Raw (un-normalized) capability catalogue. * * Metadata only — no module implementation is loaded/required here. * Pro-only entries (`tier => 'pro'`) intentionally omit any Pro class * reference; their `runtime_target` stays null until Pro enriches this * definition through the `adminoptimizer_module_registry` filter. * * @return array */ private static function get_raw_modules( bool $localize = true ): array { return [ /* ---------------------------------------------------------------- */ /* Performance */ /* ---------------------------------------------------------------- */ 'db_cleaner' => [ 'id' => 'db_cleaner', 'name' => ( $localize ? __( 'Database Cleaner', 'admin-optimizer' ) : 'Database Cleaner' ), 'slug' => 'db-cleaner', 'section' => 'performance', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_db_cleaner', 'option' => 'adminoptim_db_cleaner', 'category' => 'performance', 'description' => ( $localize ? __( 'Schedule regular optimization and cleaning up of the WP database to improve the performance of the site.', 'admin-optimizer' ) : 'Schedule regular optimization and cleaning up of the WP database to improve the performance of the site.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/database-cleaner/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\DB_Cleaner::class, ], 'heartbeat_control' => [ 'id' => 'heartbeat_control', 'name' => ( $localize ? __( 'Heartbeat Control', 'admin-optimizer' ) : 'Heartbeat Control' ), 'slug' => 'heartbeat-control', 'section' => 'performance', 'tier' => 'free', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_heartbeat_control', 'option' => 'adminoptim_heartbeat_control', 'category' => 'performance', 'description' => ( $localize ? __( 'Modify the interval of the WordPress heartbeat API to reduce CPU load on the server.', 'admin-optimizer' ) : 'Modify the interval of the WordPress heartbeat API to reduce CPU load on the server.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/heartbeat-control/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Heartbeat_Control::class, ], 'disable_emojis' => [ 'id' => 'disable_emojis', 'name' => ( $localize ? __( 'Disable Emojis', 'admin-optimizer' ) : 'Disable Emojis' ), 'slug' => 'disable-emojis', 'section' => 'performance', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'disable_emojis', 'option' => null, 'category' => 'performance', 'description' => ( $localize ? __( 'Disable emoji support and remove emoji scripts and styles from your site.', 'admin-optimizer' ) : 'Disable emoji support and remove emoji scripts and styles from your site.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/disable-emojis/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, ], 'disable_jquery_migrate' => [ 'id' => 'disable_jquery_migrate', 'name' => ( $localize ? __( 'Disable jQuery Migrate', 'admin-optimizer' ) : 'Disable jQuery Migrate' ), 'slug' => 'disable-jquery-migrate', 'section' => 'performance', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'disable_jquery_migrate', 'option' => null, 'category' => 'performance', 'description' => ( $localize ? __( 'Remove script bloat from your site.', 'admin-optimizer' ) : 'Remove script bloat from your site.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/disable-jquery-migrate/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, ], 'disable_404_redirect' => [ 'id' => 'disable_404_redirect', 'name' => ( $localize ? __( 'Disable 404 URL Redirect', 'admin-optimizer' ) : 'Disable 404 URL Redirect' ), 'slug' => 'disable-404-redirect', 'section' => 'performance', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'disable_404_redirect', 'option' => null, 'category' => 'performance', 'description' => ( $localize ? __( 'Disable the guessing of a redirect URL for a 404 request and return the 404 page without performing a redirect.', 'admin-optimizer' ) : 'Disable the guessing of a redirect URL for a 404 request and return the 404 page without performing a redirect.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/disable-404-url-redirect/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, ], 'remove_oembed' => [ 'id' => 'remove_oembed', 'name' => ( $localize ? __( 'Remove oEmbed Links', 'admin-optimizer' ) : 'Remove oEmbed Links' ), 'slug' => 'remove-oembed', 'section' => 'performance', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'remove_oembed', 'option' => null, 'category' => 'performance', 'description' => ( $localize ? __( 'Remove links used for embedding your content on other sites.', 'admin-optimizer' ) : 'Remove links used for embedding your content on other sites.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/remove-oembed-links/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, ], /* ---------------------------------------------------------------- */ /* Security */ /* ---------------------------------------------------------------- */ 'security_monitor' => [ 'id' => 'security_monitor', 'name' => ( $localize ? __( 'Security Monitor', 'admin-optimizer' ) : 'Security Monitor' ), 'slug' => 'security-monitor', 'section' => 'security', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'activation_mode' => self::ACTIVATION_ALWAYS, 'toggle' => null, 'option' => 'adminoptim_security_monitor', 'category' => 'security', 'description' => ( $localize ? __( 'Site health, monitor file integrity, login activity, and content changes, and get alerted when something looks wrong.', 'admin-optimizer' ) : 'Site health, monitor file integrity, login activity, and content changes, and get alerted when something looks wrong.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/security-monitor/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Security_Monitor::class, ], 'block_login' => [ 'id' => 'block_login', 'name' => ( $localize ? __( 'Block Failed Login', 'admin-optimizer' ) : 'Block Failed Login' ), 'slug' => 'block-login', 'section' => 'security', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_block_login', 'option' => 'adminoptim_block_login', 'category' => 'security', 'description' => ( $localize ? __( 'Block the user from login after repeated failed login attempts.', 'admin-optimizer' ) : 'Block the user from login after repeated failed login attempts.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/block-failed-login/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Block_Login::class, ], 'two_factor_authentication' => [ 'id' => 'two_factor_authentication', 'name' => ( $localize ? __( 'Two-Factor Authentication', 'admin-optimizer' ) : 'Two-Factor Authentication' ), 'slug' => 'two-factor-authentication', 'section' => 'security', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_2fa', 'option' => 'adminoptim_2fa', 'category' => 'security', 'description' => ( $localize ? __( 'Enable two factor authentication (2FA) for all users.', 'admin-optimizer' ) : 'Enable two factor authentication (2FA) for all users.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/two-factor-authentication/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Two_Factor_Authentication::class, ], 'custom_login_url' => [ 'id' => 'custom_login_url', 'name' => ( $localize ? __( 'Custom Login URL', 'admin-optimizer' ) : 'Custom Login URL' ), 'slug' => 'custom-login-url', 'section' => 'security', 'tier' => 'free', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_custom_login_url', 'option' => 'adminoptim_custom_login', 'category' => 'security', 'description' => ( $localize ? __( 'Hide the wp-login page and create a custom login URL for all users.', 'admin-optimizer' ) : 'Hide the wp-login page and create a custom login URL for all users.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/custom-login-url/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Custom_Login_Url::class, ], 'disable_xmlrpc' => [ 'id' => 'disable_xmlrpc', 'name' => ( $localize ? __( 'Disable XML-RPC', 'admin-optimizer' ) : 'Disable XML-RPC' ), 'slug' => 'disable-xmlrpc', 'section' => 'security', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'disable_xmlrpc', 'option' => null, 'category' => 'security', 'description' => ( $localize ? __( 'Disable XML-RPC for the whole site.', 'admin-optimizer' ) : 'Disable XML-RPC for the whole site.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/disable-xml-rpc/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'disable_user_account' => [ 'id' => 'disable_user_account', 'name' => ( $localize ? __( 'Disable User Account', 'admin-optimizer' ) : 'Disable User Account' ), 'slug' => 'disable-user-account', 'section' => 'users', 'tier' => 'free', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'activation_mode' => self::ACTIVATION_ALWAYS, 'toggle' => null, 'option' => 'adminoptim_disable_user_account', 'category' => 'security', 'description' => ( $localize ? __( 'Disable user accounts and prevent them from logging in.', 'admin-optimizer' ) : 'Disable user accounts and prevent them from logging in.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/disable-user-accounts/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Disable_User_Account::class, ], 'user_switching' => [ 'id' => 'user_switching', 'name' => ( $localize ? __( 'User Switching', 'admin-optimizer' ) : 'User Switching' ), 'slug' => 'user-switching', 'section' => 'users', 'tier' => 'pro', 'classification' => self::MODULE, 'config_mode' => self::INLINE, 'toggle' => 'enable_user_switching', 'option' => 'adminoptim_user_switching', 'category' => 'users', 'description' => ( $localize ? __( 'Temporarily switch into non-privileged user accounts to verify role and admin-access behavior.', 'admin-optimizer' ) : 'Temporarily switch into non-privileged user accounts to verify role and admin-access behavior.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/user-switching/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, // Pro-only: enriched by Pro via the registry filter. ], 'hide_wp_version' => [ 'id' => 'hide_wp_version', 'name' => ( $localize ? __( 'Hide WP Version', 'admin-optimizer' ) : 'Hide WP Version' ), 'slug' => 'hide-wp-version', 'section' => 'security', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'hide_wp_version', 'option' => null, 'category' => 'security', 'description' => ( $localize ? __( 'Remove the WP version from the header.', 'admin-optimizer' ) : 'Remove the WP version from the header.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/hide-wp-version/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, ], 'remove_pingback_header' => [ 'id' => 'remove_pingback_header', 'name' => ( $localize ? __( 'Remove Pingback HTTP Header', 'admin-optimizer' ) : 'Remove Pingback HTTP Header' ), 'slug' => 'remove-pingback-header', 'section' => 'security', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'remove_pingback_header', 'option' => null, 'category' => 'security', 'description' => ( $localize ? __( "Remove 'X-Pingback' from the HTTP header.", 'admin-optimizer' ) : "Remove 'X-Pingback' from the HTTP header." ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/remove-pingback-http-header/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, ], 'remove_powered_by_header' => [ 'id' => 'remove_powered_by_header', 'name' => ( $localize ? __( 'Remove Powered By HTTP Header', 'admin-optimizer' ) : 'Remove Powered By HTTP Header' ), 'slug' => 'remove-powered-by-header', 'section' => 'security', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'remove_powered_by_header', 'option' => null, 'category' => 'security', 'description' => ( $localize ? __( "Remove 'X-Powered-By' from the HTTP header.", 'admin-optimizer' ) : "Remove 'X-Powered-By' from the HTTP header." ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/remove-powered-by-http-header/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, ], 'remove_rest_api_link' => [ 'id' => 'remove_rest_api_link', 'name' => ( $localize ? __( 'Remove REST API Link', 'admin-optimizer' ) : 'Remove REST API Link' ), 'slug' => 'remove-rest-api-link', 'section' => 'security', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'remove_rest_api_link', 'option' => null, 'category' => 'security', 'description' => ( $localize ? __( 'Remove the REST API link from the header.', 'admin-optimizer' ) : 'Remove the REST API link from the header.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/remove-rest-api-link/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'remove_rsd' => [ 'id' => 'remove_rsd', 'name' => ( $localize ? __( 'Remove Really Simple Discovery Link', 'admin-optimizer' ) : 'Remove Really Simple Discovery Link' ), 'slug' => 'remove-rsd', 'section' => 'security', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'remove_rsd', 'option' => null, 'category' => 'security', 'description' => ( $localize ? __( 'Remove the Really Simple Discovery (RSD) link tag from the header.', 'admin-optimizer' ) : 'Remove the Really Simple Discovery (RSD) link tag from the header.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/remove-really-simple-discovery-rsd-link/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, ], 'remove_shortlink' => [ 'id' => 'remove_shortlink', 'name' => ( $localize ? __( 'Remove Shortlink', 'admin-optimizer' ) : 'Remove Shortlink' ), 'slug' => 'remove-shortlink', 'section' => 'security', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'remove_shortlink', 'option' => null, 'category' => 'security', 'description' => ( $localize ? __( 'Remove the shortlink from the header.', 'admin-optimizer' ) : 'Remove the shortlink from the header.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/remove-shortlink/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, ], /* ---------------------------------------------------------------- */ /* SEO / Content & Media */ /* ---------------------------------------------------------------- */ 'post_cloner' => [ 'id' => 'post_cloner', 'name' => ( $localize ? __( 'Post Cloning', 'admin-optimizer' ) : 'Post Cloning' ), 'slug' => 'post-cloner', 'section' => 'content', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::INLINE, 'toggle' => 'enable_post_cloner', 'option' => 'adminoptim_post_cloner', 'category' => 'content', 'description' => ( $localize ? __( 'Duplicate posts, pages, and custom posts with a single click.', 'admin-optimizer' ) : 'Duplicate posts, pages, and custom posts with a single click.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/post-cloner/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, 'retired' => true, ], 'seo_manager' => [ 'id' => 'seo_manager', 'name' => ( $localize ? __( 'SEO Manager', 'admin-optimizer' ) : 'SEO Manager' ), 'slug' => 'seo-manager', 'section' => 'seo', // Free core + Pro-gated advanced options (Phase 2 §5): one // canonical identity, never split into seo_manager / seo_manager_pro. 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_seo_manager', 'option' => 'adminoptim_seo_manager', 'category' => 'seo', 'description' => ( $localize ? __( 'Manage SEO metadata, social tags, schema, and AI-assisted SEO suggestions.', 'admin-optimizer' ) : 'Manage SEO metadata, social tags, schema, and AI-assisted SEO suggestions.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/modules/seo-manager/', 'supports_ai' => true, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\SEO_Manager::class, ], 'content_refresh' => [ 'id' => 'content_refresh', 'name' => ( $localize ? __( 'Content Refresh', 'admin-optimizer' ) : 'Content Refresh' ), 'slug' => 'content-refresh', 'section' => 'content', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_post_republisher', 'option' => 'adminoptim_post_republisher', 'category' => 'content', 'description' => ( $localize ? __( 'Refresh stale-but-ranking content with a new updated content and publish date while preserving SEO value.', 'admin-optimizer' ) : 'Refresh stale-but-ranking content with a new updated content and publish date while preserving SEO value.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/post-republisher/', 'supports_ai' => true, 'fresh_default' => false, 'runtime_target' => null, 'retired' => true, ], 'content_refresher' => [ 'id' => 'content_refresher', 'name' => ( $localize ? __( 'Content Refresher', 'admin-optimizer' ) : 'Content Refresher' ), 'slug' => 'content-refresher', 'section' => 'content', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_content_refresher', 'option' => 'adminoptim_content_refresher', 'category' => 'content', 'description' => ( $localize ? __( 'Refresh stale-but-ranking content with a new updated content and publish date while preserving SEO value.', 'admin-optimizer' ) : 'Refresh stale-but-ranking content with a new updated content and publish date while preserving SEO value.' ), 'parent' => 'content_management', 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/content-refresher/', 'supports_ai' => true, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Content_Refresher::class, ], 'media_manager' => [ 'id' => 'media_manager', 'name' => ( $localize ? __( 'Media Manager', 'admin-optimizer' ) : 'Media Manager' ), 'slug' => 'media-manager', 'section' => 'media', 'tier' => 'pro', 'classification' => self::MODULE, 'config_mode' => self::DESTINATION, 'toggle' => 'enable_media_manager', 'option' => 'adminoptim_media_manager', 'category' => 'media', 'description' => ( $localize ? __( 'Organize, audit, and maintain your WordPress Media Library.', 'admin-optimizer' ) : 'Organize, audit, and maintain your WordPress Media Library.' ), 'parent' => 'media_management', 'requires' => [], 'website' => '', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, // Pro-only: enriched by Pro via the registry filter. ], 'xml_sitemap' => [ 'id' => 'xml_sitemap', 'name' => ( $localize ? __( 'XML Sitemap & Google News Sitemap', 'admin-optimizer' ) : 'XML Sitemap & Google News Sitemap' ), 'slug' => 'xml-sitemap', 'section' => 'seo', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_xml_sitemap', 'option' => 'adminoptim_xml_sitemap', 'category' => 'seo', 'description' => ( $localize ? __( 'Enable and manage XML Sitemap and Google News Sitemap for your site.', 'admin-optimizer' ) : 'Enable and manage XML Sitemap and Google News Sitemap for your site.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/xml-sitemap/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\XML_Sitemap::class, ], 'redirect_manager' => [ 'id' => 'redirect_manager', 'name' => ( $localize ? __( 'Redirect Manager', 'admin-optimizer' ) : 'Redirect Manager' ), 'slug' => 'redirect-manager', 'section' => 'seo', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_redirect_manager', 'option' => 'adminoptim_redirect_manager', 'category' => 'seo', 'description' => ( $localize ? __( 'Enable and manage URL redirection for your site.', 'admin-optimizer' ) : 'Enable and manage URL redirection for your site.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/redirect-manager/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Redirect_Manager::class, ], 'publish_missed_schedule' => [ 'id' => 'publish_missed_schedule', 'name' => ( $localize ? __( 'Auto Publish Missed Schedule', 'admin-optimizer' ) : 'Auto Publish Missed Schedule' ), 'slug' => 'publish-missed-schedule', 'section' => 'content', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'enable_publish_missed_posts', 'option' => null, 'category' => 'content', 'description' => ( $localize ? __( 'Check posts every 15 minutes and auto publish posts with missed schedule.', 'admin-optimizer' ) : 'Check posts every 15 minutes and auto publish posts with missed schedule.' ), 'parent' => 'content_management', 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/auto-publish-posts/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Publish_Missed_Schedule::class, ], 'disable_comments' => [ 'id' => 'disable_comments', 'name' => ( $localize ? __( 'Disable Comments', 'admin-optimizer' ) : 'Disable Comments' ), 'slug' => 'disable-comments', 'section' => 'content', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'enable_disable_comments', 'option' => null, 'category' => 'content', 'description' => ( $localize ? __( 'Disable comments and pings across all post types and remove comment-related admin interfaces.', 'admin-optimizer' ) : 'Disable comments and pings across all post types and remove comment-related admin interfaces.' ), 'parent' => 'content_management', 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/disable-comments/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'modified_date' => [ 'id' => 'modified_date', 'name' => ( $localize ? __( 'Lock Post Modified Date', 'admin-optimizer' ) : 'Lock Post Modified Date' ), 'slug' => 'modified-date', 'section' => 'content', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'manage_post_modified_date', 'option' => 'adminoptim_modified_date', 'category' => 'content', 'description' => ( $localize ? __( 'Change or prevent updating of the last modified date for each post.', 'admin-optimizer' ) : 'Change or prevent updating of the last modified date for each post.' ), 'parent' => 'content_management', 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/lock-modified-date/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Modified_Date::class, ], 'content_author_policy' => [ 'id' => 'content_author_policy', 'name' => ( $localize ? __( 'Content Author Policy', 'admin-optimizer' ) : 'Content Author Policy' ), 'slug' => 'content-author-policy', 'section' => 'content', 'tier' => 'pro', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_content_author_policy', 'option' => 'adminoptim_content_author_policy', 'category' => 'content', 'description' => ( $localize ? __( 'Control which roles may be recorded as the public author of published content.', 'admin-optimizer' ) : 'Control which roles may be recorded as the public author of published content.' ), 'parent' => 'content_management', 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/content-author-policy/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, // Pro-only: enriched by Pro via the registry filter. ], 'svg_upload' => [ 'id' => 'svg_upload', 'name' => ( $localize ? __( 'SVG Image Upload', 'admin-optimizer' ) : 'SVG Image Upload' ), 'slug' => 'svg-upload', 'section' => 'media', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'enable_svg_upload', 'option' => null, 'category' => 'media', 'description' => ( $localize ? __( 'Enable upload of SVG files.', 'admin-optimizer' ) : 'Enable upload of SVG files.' ), 'parent' => 'media_management', 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/enable-svg-image-upload/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\SVG_Upload::class, ], 'image_alt_text' => [ 'id' => 'image_alt_text', 'name' => ( $localize ? __( 'Set Image Filename As Alt Text', 'admin-optimizer' ) : 'Set Image Filename As Alt Text' ), 'slug' => 'image-alt-text', 'section' => 'media', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'enable_image_alt_text', 'option' => null, 'category' => 'media', 'description' => ( $localize ? __( 'Auto set image filename as alt text.', 'admin-optimizer' ) : 'Auto set image filename as alt text.' ), 'parent' => 'media_management', 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/set-image-filename-as-alt-text/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'ads_robots_txt' => [ 'id' => 'ads_robots_txt', 'name' => ( $localize ? __( 'Robots.txt & Ads.txt Manager', 'admin-optimizer' ) : 'Robots.txt & Ads.txt Manager' ), 'slug' => 'ads-robots-txt', 'section' => 'seo', 'tier' => 'free', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_txt_management', 'option' => 'adminoptim_ads_robots_txt', 'category' => 'seo', 'description' => ( $localize ? __( 'Manage robots.txt, ads.txt, and app-ads.txt directly in WordPress.', 'admin-optimizer' ) : 'Manage robots.txt, ads.txt, and app-ads.txt directly in WordPress.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/robots-ads-app-ads-txt/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Ads_Robots_Txt::class, ], 'limit_image_size' => [ 'id' => 'limit_image_size', 'name' => ( $localize ? __( 'Limit Image Upload File Size', 'admin-optimizer' ) : 'Limit Image Upload File Size' ), 'slug' => 'limit-image-size', 'section' => 'media', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_limit_image_upload_size', 'option' => 'adminoptim_limit_image_size', 'category' => 'media', 'description' => ( $localize ? __( 'Limit the image upload file size.', 'admin-optimizer' ) : 'Limit the image upload file size.' ), 'parent' => 'media_management', 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/limit-image-upload-file-size/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Limit_Image_Size::class, ], 'image_underscore_to_hyphen' => [ 'id' => 'image_underscore_to_hyphen', 'name' => ( $localize ? __( 'Convert Underscore In Image Filename To Hyphen', 'admin-optimizer' ) : 'Convert Underscore In Image Filename To Hyphen' ), 'slug' => 'image-underscore-to-hyphen', 'section' => 'media', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'enable_image_underscore_to_hyphen', 'option' => null, 'category' => 'media', 'description' => ( $localize ? __( 'Auto sanitize the image filename and convert underscore (_) to hyphen (-) during image upload.', 'admin-optimizer' ) : 'Auto sanitize the image filename and convert underscore (_) to hyphen (-) during image upload.' ), 'parent' => 'media_management', 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/convert-underscore-in-filename-to-hyphen/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'auto_add_anchor' => [ 'id' => 'auto_add_anchor', 'name' => ( $localize ? __( 'Auto Add HTML Anchor To Heading Block', 'admin-optimizer' ) : 'Auto Add HTML Anchor To Heading Block' ), 'slug' => 'auto-add-anchor', 'section' => 'content', 'tier' => 'free', // Phase 2 §13: destined for Custom Code in Phase 3.8; KEEP here for now. 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'enable_auto_add_anchor', 'option' => null, 'category' => 'content', 'description' => ( $localize ? __( 'Transform your WordPress headings into clickable link targets.', 'admin-optimizer' ) : 'Transform your WordPress headings into clickable link targets.' ), 'parent' => 'content_management', 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/auto-add-html-anchor/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'disable_category_archive' => [ 'id' => 'disable_category_archive', 'name' => ( $localize ? __( 'Disable Category Archive Page', 'admin-optimizer' ) : 'Disable Category Archive Page' ), 'slug' => 'disable-category-archive', 'section' => 'seo', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'disable_category_archive', 'option' => null, 'category' => 'seo', 'description' => ( $localize ? __( 'Disable all Category archive pages in the site frontend.', 'admin-optimizer' ) : 'Disable all Category archive pages in the site frontend.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/disable-category-archive-page/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'disable_tag_archive' => [ 'id' => 'disable_tag_archive', 'name' => ( $localize ? __( 'Disable Tag Archive Page', 'admin-optimizer' ) : 'Disable Tag Archive Page' ), 'slug' => 'disable-tag-archive', 'section' => 'seo', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'disable_tag_archive', 'option' => null, 'category' => 'seo', 'description' => ( $localize ? __( 'Disable all Tag archive pages in the site frontend.', 'admin-optimizer' ) : 'Disable all Tag archive pages in the site frontend.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/disable-tag-archive-page/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'disable_author_archive' => [ 'id' => 'disable_author_archive', 'name' => ( $localize ? __( 'Disable Author Archive Page', 'admin-optimizer' ) : 'Disable Author Archive Page' ), 'slug' => 'disable-author-archive', 'section' => 'seo', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'disable_author_archive', 'option' => null, 'category' => 'seo', 'description' => ( $localize ? __( 'Disable all Author archive pages in the site frontend.', 'admin-optimizer' ) : 'Disable all Author archive pages in the site frontend.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/disable-author-archive-page/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'disable_date_archive' => [ 'id' => 'disable_date_archive', 'name' => ( $localize ? __( 'Disable Date Archive Page', 'admin-optimizer' ) : 'Disable Date Archive Page' ), 'slug' => 'disable-date-archive', 'section' => 'seo', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'disable_date_archive', 'option' => null, 'category' => 'seo', 'description' => ( $localize ? __( 'Disable all Date archive pages in the site frontend.', 'admin-optimizer' ) : 'Disable all Date archive pages in the site frontend.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/disable-date-archive-page/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], /* ---------------------------------------------------------------- */ /* Post Types & Fields */ /* ---------------------------------------------------------------- */ 'custom_fields_builder' => [ 'id' => 'custom_fields_builder', 'name' => ( $localize ? __( 'Custom Fields Builder', 'admin-optimizer' ) : 'Custom Fields Builder' ), 'slug' => 'custom-fields-builder', 'section' => 'post-types-fields', 'tier' => 'pro', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_custom_fields_builder', 'option' => 'adminoptim_custom_fields_builder', 'category' => 'custom-fields', 'description' => ( $localize ? __( 'A powerful builder that allows you to easily add custom fields and content for various Post Types.', 'admin-optimizer' ) : 'A powerful builder that allows you to easily add custom fields and content for various Post Types.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/custom-fields-builder/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, // Pro-only: enriched by Pro via the registry filter. ], 'custom_post_status' => [ 'id' => 'custom_post_status', 'name' => ( $localize ? __( 'Custom Post Status', 'admin-optimizer' ) : 'Custom Post Status' ), 'slug' => 'custom-post-status', 'section' => 'post-types-fields', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_custom_post_status', 'option' => 'adminoptim_custom_post_status', 'backup_options' => [ 'adminoptim_post_status_post_types', 'adminoptim_post_status_user_roles', ], 'category' => 'custom-fields', 'description' => ( $localize ? __( 'Enable the creation of custom post status.', 'admin-optimizer' ) : 'Enable the creation of custom post status.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/custom-post-status/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Post_Status::class, ], 'custom_post_types' => [ 'id' => 'custom_post_types', 'name' => ( $localize ? __( 'Custom Post Types', 'admin-optimizer' ) : 'Custom Post Types' ), 'slug' => 'custom-post-types', 'section' => 'post-types-fields', 'tier' => 'free', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_custom_post_types', 'option' => 'adminoptim-post-types', 'category' => 'custom-fields', 'description' => ( $localize ? __( 'Enable the creation of custom post types.', 'admin-optimizer' ) : 'Enable the creation of custom post types.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/custom-post-type/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Post_Types::class, ], 'custom_taxonomies' => [ 'id' => 'custom_taxonomies', 'name' => ( $localize ? __( 'Custom Taxonomies', 'admin-optimizer' ) : 'Custom Taxonomies' ), 'slug' => 'custom-taxonomies', 'section' => 'post-types-fields', 'tier' => 'free', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_custom_taxonomies', 'option' => 'adminoptim-custom-taxonomies', 'category' => 'custom-fields', 'description' => ( $localize ? __( 'Enable the creation of custom taxonomies.', 'admin-optimizer' ) : 'Enable the creation of custom taxonomies.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/custom-taxonomies/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Taxonomies::class, ], /* ---------------------------------------------------------------- */ /* Admin & Users */ /* ---------------------------------------------------------------- */ 'hide_update_notice' => [ 'id' => 'hide_update_notice', 'name' => ( $localize ? __( 'Hide Update Notice', 'admin-optimizer' ) : 'Hide Update Notice' ), 'slug' => 'hide-update-notice', 'section' => 'users', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'hide_update_notices', 'option' => null, 'category' => 'admin', 'description' => ( $localize ? __( 'Only show update notices to Administrators and those with update capabilities.', 'admin-optimizer' ) : 'Only show update notices to Administrators and those with update capabilities.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/hide-update-notice/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'disable_gutenberg_editor' => [ 'id' => 'disable_gutenberg_editor', 'name' => ( $localize ? __( 'Content Editor', 'admin-optimizer' ) : 'Content Editor' ), 'slug' => 'disable-gutenberg-editor', 'section' => 'content', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => null, 'activation_mode' => self::ACTIVATION_ALWAYS, 'option' => 'adminoptim_disable_gutenberg', 'category' => 'content', 'description' => ( $localize ? __( 'Disable the Gutenberg Block Editor and restore the Classic editor as the default editor.', 'admin-optimizer' ) : 'Disable the Gutenberg Block Editor and restore the Classic editor as the default editor.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/disable-gutenberg-editor/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Disable_Gutenberg::class, ], 'page_ordering' => [ 'id' => 'page_ordering', 'name' => ( $localize ? __( 'Page Ordering', 'admin-optimizer' ) : 'Page Ordering' ), 'slug' => 'page-ordering', 'section' => 'content', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'enable_page_ordering', 'option' => null, 'category' => 'content', 'description' => ( $localize ? __( 'Order your pages or hierarchical custom post types with drag and drop, right from the Pages list.', 'admin-optimizer' ) : 'Order your pages or hierarchical custom post types with drag and drop, right from the Pages list.' ), 'parent' => 'content_management', 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/page-ordering/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Page_Ordering::class, ], 'user_login_tracking' => [ 'id' => 'user_login_tracking', 'name' => ( $localize ? __( 'Track User Last Login Date', 'admin-optimizer' ) : 'Track User Last Login Date' ), 'slug' => 'user-login-tracking', 'section' => 'users', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'enable_user_login_tracking', 'option' => null, 'category' => 'users', 'description' => ( $localize ? __( "Track and display the user's last login date.", 'admin-optimizer' ) : "Track and display the user's last login date." ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/last-login-date/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'user_registration_date' => [ 'id' => 'user_registration_date', 'name' => ( $localize ? __( 'Display User Registration Date', 'admin-optimizer' ) : 'Display User Registration Date' ), 'slug' => 'user-registration-date', 'section' => 'users', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'display_user_registration_date', 'option' => null, 'category' => 'users', 'description' => ( $localize ? __( 'Track and show the user registration date.', 'admin-optimizer' ) : 'Track and show the user registration date.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/display-user-registration-date/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'disable_user_signup_notification' => [ 'id' => 'disable_user_signup_notification', 'name' => ( $localize ? __( 'Disable New User Signup Notification', 'admin-optimizer' ) : 'Disable New User Signup Notification' ), 'slug' => 'disable-user-signup-notification', 'section' => 'users', 'tier' => 'free', 'classification' => self::SECTION_FEATURE, 'config_mode' => self::INLINE, 'toggle' => 'disable_user_signup_notification', 'option' => null, 'category' => 'users', 'description' => ( $localize ? __( 'Disable the option to send the new user an email when their account was created.', 'admin-optimizer' ) : 'Disable the option to send the new user an email when their account was created.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/disable-new-user-signup-notification/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => null, ], 'user_access_manager' => [ 'id' => 'user_access_manager', 'name' => ( $localize ? __( 'User Manager', 'admin-optimizer' ) : 'User Manager' ), 'slug' => 'user-access-manager', 'section' => 'users', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'activation_mode' => self::ACTIVATION_ALWAYS, 'toggle' => null, 'option' => 'adminoptim_user_access_manager', 'backup_options' => [ 'adminoptim_block_admin_access', 'adminoptim_user_access_manager_roles', ], 'category' => 'users', 'description' => ( $localize ? __( 'Manage supported WordPress roles, custom capabilities, multiple-role assignment, admin toolbar visibility, and integrated admin access controls.', 'admin-optimizer' ) : 'Manage supported WordPress roles, custom capabilities, multiple-role assignment, admin toolbar visibility, and integrated admin access controls.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/user-access-manager/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\User_Access_Manager::class, ], /* ---------------------------------------------------------------- */ /* System Tools */ /* ---------------------------------------------------------------- */ 'ai_connectors' => [ 'id' => 'ai_connectors', 'name' => ( $localize ? __( 'AI Connectors', 'admin-optimizer' ) : 'AI Connectors' ), 'slug' => 'ai-connectors', 'section' => 'automations', 'tier' => 'pro', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'activation_mode' => self::ACTIVATION_ALWAYS, 'toggle' => null, 'option' => 'adminoptim_ai_connectors', 'category' => 'system', 'description' => ( $localize ? __( 'Connect to AI providers, manage API keys, and view usage cost and request logs.', 'admin-optimizer' ) : 'Connect to AI providers, manage API keys, and view usage cost and request logs.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/ai-connectors/', 'supports_ai' => true, 'fresh_default' => false, 'runtime_target' => null, // Pro-only: enriched by Pro via the registry filter. ], 'smtp_mail' => [ 'id' => 'smtp_mail', 'name' => ( $localize ? __( 'Email & SMTP', 'admin-optimizer' ) : 'Email & SMTP' ), 'slug' => 'smtp-mail', 'section' => 'system', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_smtp_mail', 'option' => 'adminoptim_smtp', 'category' => 'system', 'description' => ( $localize ? __( 'Set up SMTP email delivery in WordPress and send emails directly from the admin.', 'admin-optimizer' ) : 'Set up SMTP email delivery in WordPress and send emails directly from the admin.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/smtp-mail/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\SMTP_Email::class, ], 'form_builder' => [ 'id' => 'form_builder', 'name' => ( $localize ? __( 'Form Builder', 'admin-optimizer' ) : 'Form Builder' ), 'slug' => 'form-builder', 'section' => 'forms', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::DESTINATION, 'toggle' => 'enable_form_builder', 'option' => 'adminoptim_forms_builder', 'category' => 'system', 'description' => ( $localize ? __( 'Create forms to display on your website.', 'admin-optimizer' ) : 'Create forms to display on your website.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/form-builder/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Form_Builder::class, ], 'custom_code' => [ 'id' => 'custom_code', 'name' => ( $localize ? __( 'Custom Code', 'admin-optimizer' ) : 'Custom Code' ), 'slug' => 'custom-code', 'section' => 'system', 'tier' => 'free_with_pro_options', 'classification' => self::MODULE, 'config_mode' => self::PANEL, // The Custom Code CPT and all of its write handlers are owned by // the theme-customization capability. Keep that module-level // boundary explicit even though the containing System Section uses // manage_options for access and activation controls. 'settings_capability' => 'edit_theme_options', // Enabling or disabling frontend code remains a site-wide // administrative/deployment action, independent of CPT editing. 'activation_capability' => 'manage_options', 'toggle' => 'enable_custom_code', // Content/workflow module: canonical state lives in the // ao_custom_code CPT, _ao_cc_* meta, ao_cc_* caches and // generated files. OPTION_NAME is a legacy Base_Module loading // identity, not a detailed settings store. 'option' => null, 'category' => 'system', 'description' => ( $localize ? __( 'Add custom CSS, HTML and JavaScript code snippets to your website.', 'admin-optimizer' ) : 'Add custom CSS, HTML and JavaScript code snippets to your website.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/custom-code/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Custom_Code::class, ], 'site_traffic_manager' => [ 'id' => 'site_traffic_manager', 'name' => ( $localize ? __( 'Site Traffic Manager', 'admin-optimizer' ) : 'Site Traffic Manager' ), 'slug' => 'site-traffic-manager', 'section' => 'system', 'tier' => 'free', 'classification' => self::MODULE, 'config_mode' => self::PANEL, 'toggle' => 'enable_site_traffic_manager', 'option' => 'adminoptim_site_traffic_manager', 'backup_options' => [ 'adminoptim_consent_manager' ], 'category' => 'system', 'description' => ( $localize ? __( 'Track page views for posts, pages, and custom post types, and expose the data for other modules to use.', 'admin-optimizer' ) : 'Track page views for posts, pages, and custom post types, and expose the data for other modules to use.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/site-traffic-manager/', 'supports_ai' => false, 'fresh_default' => false, 'runtime_target' => \Yipresser\AdminOptimizer\Modules\Site_Traffic_Manager::class, ], /* ---------------------------------------------------------------- */ /* Infrastructure (not ordinary MODULE loader targets) */ /* ---------------------------------------------------------------- */ 'export_import' => [ 'id' => 'export_import', 'name' => ( $localize ? __( 'Export and Import', 'admin-optimizer' ) : 'Export and Import' ), 'slug' => 'export-import', 'section' => 'overview', 'tier' => 'free', 'classification' => self::NATIVE, 'config_mode' => self::PANEL, 'toggle' => null, 'option' => null, 'category' => 'infrastructure', 'description' => ( $localize ? __( 'Export and import Admin Optimizer settings between sites.', 'admin-optimizer' ) : 'Export and import Admin Optimizer settings between sites.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/export-import/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, ], 'migrator' => [ 'id' => 'migrator', 'name' => ( $localize ? __( 'Migration', 'admin-optimizer' ) : 'Migration' ), 'slug' => 'migration', 'section' => 'overview', 'tier' => 'free', 'classification' => self::NATIVE, 'config_mode' => self::PANEL, 'toggle' => null, 'option' => null, 'category' => 'infrastructure', 'description' => ( $localize ? __( 'Migrate from other plugins into Admin Optimizer.', 'admin-optimizer' ) : 'Migrate from other plugins into Admin Optimizer.' ), 'requires' => [], 'website' => 'https://www.adminoptimizer.com/docs/migration/', 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, ], 'license' => [ 'id' => 'license', 'name' => ( $localize ? __( 'License', 'admin-optimizer' ) : 'License' ), 'slug' => 'license', 'section' => 'overviewo', 'tier' => 'pro', 'classification' => self::NATIVE, 'config_mode' => self::PANEL, 'toggle' => null, 'option' => null, 'category' => 'infrastructure', 'description' => ( $localize ? __( 'Activate and manage your Admin Optimizer Pro license.', 'admin-optimizer' ) : 'Activate and manage your Admin Optimizer Pro license.' ), 'requires' => [], 'website' => ao_get_upgrade_url( 'license-card' ), 'supports_ai' => false, 'fresh_default' => true, 'runtime_target' => null, // Pro-only: enriched by Pro via the registry filter. ], ]; } /** * Normalize and validate the (possibly Pro-enriched) capability catalogue. * * Ensures every entry has the expected keys, sane defaults, and that its * `id` matches its array key, so malformed/partial entries — including * third-party or Pro additions via the `adminoptimizer_module_registry` * filter — never break consumers. Applied to the *final* enriched array, * not only the raw Free catalogue, so Pro enrichment cannot bypass * required defaults/classification. The canonical `id` is always forced * back to the array key so an enrichment callback cannot silently * rewrite a capability's identity. * * @param array $modules Raw module catalogue keyed by module ID. * * @return array */ private static function normalize_modules( array $modules ): array { $normalized = []; foreach ( $modules as $module_id => $module ) { $normalized[ $module_id ] = self::normalize_module_definition( (string) $module_id, $module ); } return $normalized; } /** * Normalize and validate a single capability definition. * * Shared by `normalize_modules()` (the whole-catalogue filter pass) and * `get_module()` (the single-definition filter pass), so both enrichment * seams — `adminoptimizer_module_registry` and * `adminoptimizer_module_registry_module` — are held to the same * invariants instead of the per-module filter being able to hand back * an un-renormalized, potentially invalid definition. `id` is always * forced to the requested canonical `$module_id`, so no filter can * rewrite a capability's identity out from under its array key. * * @param string $module_id Canonical module ID (the array key it is stored under). * @param mixed $module Raw definition, expected to be an array. * * @return array */ private static function normalize_module_definition( string $module_id, $module ): array { $module = is_array( $module ) ? $module : []; $tier = $module['tier'] ?? self::DEFAULT_TIER; if ( ! in_array( $tier, self::TIERS, true ) ) { $tier = self::DEFAULT_TIER; } $classification = $module['classification'] ?? self::DEFAULT_CLASSIFICATION; if ( ! in_array( $classification, self::CLASSIFICATIONS, true ) ) { $classification = self::DEFAULT_CLASSIFICATION; } $config_mode = $module['config_mode'] ?? self::DEFAULT_CONFIG_MODE; if ( ! in_array( $config_mode, self::CONFIG_MODES, true ) ) { $config_mode = self::DEFAULT_CONFIG_MODE; } $settings_capability = $module['settings_capability'] ?? self::DEFAULT_SETTINGS_CAPABILITY; $settings_capability = is_string( $settings_capability ) ? sanitize_key( $settings_capability ) : ''; if ( '' === $settings_capability ) { $settings_capability = self::DEFAULT_SETTINGS_CAPABILITY; } $activation_capability = $module['activation_capability'] ?? self::DEFAULT_ACTIVATION_CAPABILITY; $activation_capability = is_string( $activation_capability ) ? sanitize_key( $activation_capability ) : ''; if ( '' === $activation_capability ) { $activation_capability = self::DEFAULT_ACTIVATION_CAPABILITY; } $runtime_target = $module['runtime_target'] ?? null; if ( null !== $runtime_target && ! is_string( $runtime_target ) ) { $runtime_target = null; } $parent = $module['parent'] ?? null; if ( ! is_string( $parent ) ) { $parent = null; } else { $parent = sanitize_key( $parent ); $parent = '' !== $parent && $module_id !== $parent ? $parent : null; } $toggle = $module['toggle'] ?? null; if ( ! is_string( $toggle ) ) { $toggle = null; } else { $toggle = sanitize_key( $toggle ); $toggle = '' !== $toggle ? $toggle : null; } $activation_mode = $module['activation_mode'] ?? null; if ( ! in_array( $activation_mode, self::ACTIVATION_MODES, true ) ) { $activation_mode = is_string( $toggle ) ? self::ACTIVATION_TOGGLE : self::ACTIVATION_NONE; } if ( self::NATIVE === $classification ) { $activation_mode = self::ACTIVATION_NONE; } if ( self::ACTIVATION_TOGGLE !== $activation_mode ) { $toggle = null; } $backup_options = $module['backup_options'] ?? []; if ( is_string( $backup_options ) ) { $backup_options = [ $backup_options ]; } if ( ! is_array( $backup_options ) ) { $backup_options = []; } if ( isset( $module['option'] ) && is_string( $module['option'] ) ) { $backup_options[] = $module['option']; } $backup_options = array_values( array_unique( array_filter( array_map( static function ( $option_name ) { return is_string( $option_name ) ? trim( $option_name ) : ''; }, $backup_options ) ) ) ); return [ 'id' => $module_id, 'name' => $module['name'] ?? $module_id, 'slug' => $module['slug'] ?? $module_id, 'section' => $module['section'] ?? '', 'tier' => $tier, 'classification' => $classification, 'config_mode' => $config_mode, 'settings_capability' => $settings_capability, 'activation_capability' => $activation_capability, 'activation_mode' => $activation_mode, 'toggle' => $toggle, 'option' => $module['option'] ?? null, 'backup_options' => $backup_options, 'category' => $module['category'] ?? '', 'description' => $module['description'] ?? '', 'parent' => $parent, 'requires' => is_array( $module['requires'] ?? null ) ? $module['requires'] : [], 'website' => $module['website'] ?? '', 'supports_ai' => ! empty( $module['supports_ai'] ), 'fresh_default' => ! empty( $module['fresh_default'] ), 'runtime_target' => $runtime_target, 'retired' => ! empty( $module['retired'] ), ]; } }