← All changes
|
includes/abilities/class-abilities-registrar.php
+225
-10
1.26.0
→
2.10.0
View file →
| @@ -8,13 +8,21 @@ | ||
| 8 | 8 | declare(strict_types=1); |
| 9 | 9 | |
| 10 | 10 | namespace ThinkRank\Abilities; |
| 11 | 11 | |
| 12 | +if ( ! defined( 'ABSPATH' ) ) { | |
| 13 | + exit; // Exit if accessed directly. | |
| 14 | +} | |
| 15 | + | |
| 12 | 16 | use ThinkRank\Abilities\Analysis\Get_Post_Seo_Checks; |
| 13 | 17 | use ThinkRank\Abilities\Analysis\Get_Term_Seo_Checks; |
| 14 | 18 | use ThinkRank\Abilities\Content\Get_Post_Seo; |
| 15 | 19 | use ThinkRank\Abilities\Content\Get_Term_Seo; |
| 16 | 20 | use ThinkRank\Abilities\Content\List_Content_Items; |
| 21 | +use ThinkRank\Abilities\Content\Get_Duplicate_Snippets; | |
| 22 | +use ThinkRank\Abilities\Content\Get_Thin_Content; | |
| 23 | +use ThinkRank\Abilities\Content\List_Snippet_Issues; | |
| 24 | +use ThinkRank\Abilities\Content\Update_Thin_Content_Settings; | |
| 17 | 25 | use ThinkRank\Abilities\Content\List_Content_Types; |
| 18 | 26 | use ThinkRank\Abilities\Content\Update_Post_Seo; |
| 19 | 27 | use ThinkRank\Abilities\Content\Update_Term_Seo; |
| 20 | 28 | use ThinkRank\Abilities\Content\Generate_Content_Brief; |
| @@ -40,10 +48,22 @@ | ||
| 40 | 48 | use ThinkRank\Abilities\Settings\Update_Image_Seo_Settings; |
| 41 | 49 | use ThinkRank\Abilities\Settings\Get_Llms_Txt_Settings; |
| 42 | 50 | use ThinkRank\Abilities\Settings\Update_Llms_Txt_Settings; |
| 43 | 51 | use ThinkRank\Abilities\Settings\Get_Robots_Meta_Settings; |
| 52 | +use ThinkRank\Abilities\Links\Get_External_Links_Settings; | |
| 53 | +use ThinkRank\Abilities\Links\Update_External_Links_Settings; | |
| 54 | +use ThinkRank\Abilities\Matrix\Get_Content_Type_Matrix; | |
| 55 | +use ThinkRank\Abilities\Matrix\Update_Content_Type_Matrix; | |
| 56 | +use ThinkRank\Abilities\Media\List_Images; | |
| 57 | +use ThinkRank\Abilities\Media\Get_Image_Alt_Text; | |
| 58 | +use ThinkRank\Abilities\Media\Update_Image_Alt_Text; | |
| 59 | +use ThinkRank\Abilities\Media\Fill_Missing_Alt_Text; | |
| 60 | +use ThinkRank\Abilities\Content\Get_Post_Content; | |
| 61 | +use ThinkRank\Abilities\Maintenance\Purge_Caches; | |
| 44 | 62 | use ThinkRank\Abilities\Settings\Update_Robots_Meta_Settings; |
| 45 | 63 | use ThinkRank\Abilities\Settings\Get_Instant_Indexing_Settings; |
| 64 | +use ThinkRank\Abilities\Settings\Get_Ai_Budget; | |
| 65 | +use ThinkRank\Abilities\Settings\Update_Ai_Budget; | |
| 46 | 66 | use ThinkRank\Abilities\Settings\Update_Instant_Indexing_Settings; |
| 47 | 67 | use ThinkRank\Abilities\Settings\Get_Author_Archives_Settings; |
| 48 | 68 | use ThinkRank\Abilities\Settings\Update_Author_Archives_Settings; |
| 49 | 69 | use ThinkRank\Abilities\Settings\Get_Email_Report_Settings; |
| @@ -56,9 +76,8 @@ | ||
| 56 | 76 | use ThinkRank\Abilities\Analysis\Get_Llms_Txt_Status; |
| 57 | 77 | use ThinkRank\Abilities\Analysis\Generate_Llms_Txt; |
| 58 | 78 | use ThinkRank\Abilities\Analysis\Publish_Llms_Txt; |
| 59 | 79 | use ThinkRank\Abilities\Analysis\Get_Seo_Analytics_Data; |
| 60 | -use ThinkRank\Abilities\Analysis\Get_Seo_Insights; | |
| 61 | 80 | use ThinkRank\Abilities\Analysis\Get_Seo_Opportunities; |
| 62 | 81 | use ThinkRank\Abilities\Analysis\Get_Seo_Score; |
| 63 | 82 | use ThinkRank\Abilities\Analysis\Get_Seo_Analyzer; |
| 64 | 83 | use ThinkRank\Abilities\Analysis\Run_Seo_Analyzer; |
| @@ -67,12 +86,8 @@ | ||
| 67 | 86 | use ThinkRank\Abilities\Analysis\Get_Connection_Status; |
| 68 | 87 | use ThinkRank\Abilities\Analysis\Bulk_Analyze_And_Save; |
| 69 | 88 | use ThinkRank\Core\Settings; |
| 70 | 89 | |
| 71 | -if ( ! defined( 'ABSPATH' ) ) { | |
| 72 | - exit; // Exit if accessed directly. | |
| 73 | -} | |
| 74 | - | |
| 75 | 90 | /** |
| 76 | 91 | * Registers ThinkRank abilities with the WordPress Abilities API. |
| 77 | 92 | * |
| 78 | 93 | * Abilities are **always** registered when the Abilities API is available — |
| @@ -92,22 +107,207 @@ | ||
| 92 | 107 | */ |
| 93 | 108 | class Abilities_Registrar { |
| 94 | 109 | |
| 95 | 110 | /** |
| 111 | + * Ability-name prefixes that mark an ability as ThinkRank's. The free | |
| 112 | + * plugin owns `thinkrank/`; Pro registers under `thinkrank-pro/` via the | |
| 113 | + * `thinkrank_register_abilities` filter. | |
| 114 | + */ | |
| 115 | + public const ABILITY_PREFIXES = [ 'thinkrank/', 'thinkrank-pro/' ]; | |
| 116 | + | |
| 117 | + /** | |
| 118 | + * Whether the registration replay (see {@see self::ensure_registered()}) | |
| 119 | + * has already run this request. One attempt only — a replay that produced | |
| 120 | + * nothing will not produce anything on the second try either, and the MCP | |
| 121 | + * server asks for the tool list more than once per request. | |
| 122 | + * | |
| 123 | + * @var bool | |
| 124 | + */ | |
| 125 | + private static $replayed = false; | |
| 126 | + | |
| 127 | + /** | |
| 96 | 128 | * Initialize the registrar (called by the plugin's component container). |
| 97 | 129 | * |
| 130 | + * Deliberately does NOT bail on a missing `wp_register_ability`: the | |
| 131 | + * Abilities API is a set of GLOBAL functions loaded under | |
| 132 | + * `function_exists` guards, so which copy owns them — ours in | |
| 133 | + * `dependencies/`, another plugin's, or core's — is decided by load order, | |
| 134 | + * not by us. A copy that lands after `plugins_loaded` would have made this | |
| 135 | + * an early return and left ThinkRank permanently unregistered (see #241). | |
| 136 | + * The callbacks themselves are guarded instead, so hooking unconditionally | |
| 137 | + * is free when no Abilities API ever shows up. | |
| 138 | + * | |
| 98 | 139 | * @return void |
| 99 | 140 | */ |
| 100 | 141 | public function init() { |
| 101 | - if ( ! function_exists( 'wp_register_ability' ) ) { | |
| 102 | - return; | |
| 142 | + add_action( 'wp_abilities_api_categories_init', [ $this, 'register_category' ] ); | |
| 143 | + add_action( 'wp_abilities_api_init', [ $this, 'register_abilities' ] ); | |
| 144 | + } | |
| 145 | + | |
| 146 | + /** | |
| 147 | + * Guarantee ThinkRank's abilities are in the registry, replaying | |
| 148 | + * registration once if they are not. | |
| 149 | + * | |
| 150 | + * `wp_abilities_api_init` fires exactly once, from the lazy registry | |
| 151 | + * singleton of whichever Abilities API copy owns the globals. If a foreign | |
| 152 | + * copy owns them and fires its init under a different name or at a moment | |
| 153 | + * when our hook is not attached yet, our callback never runs: the registry | |
| 154 | + * is populated by everyone else and `tools/list` answers with an empty | |
| 155 | + * array while auth, discovery and `initialize` all report success (#241). | |
| 156 | + * | |
| 157 | + * Calling `wp_get_abilities()` here forces that lazy init, so by the time | |
| 158 | + * we decide to replay, `wp_abilities_api_init` has fired and | |
| 159 | + * `wp_register_ability()` will accept our registrations. Registration is | |
| 160 | + * idempotent (each ability is skipped when `wp_has_ability()` already | |
| 161 | + * knows it), so a replay after a *successful* hook run is a no-op anyway. | |
| 162 | + * | |
| 163 | + * @param callable|null $replay Optional replay routine, for tests. Default | |
| 164 | + * is this class's own category + abilities | |
| 165 | + * registration. | |
| 166 | + * @return int Number of ThinkRank abilities registered afterwards. | |
| 167 | + */ | |
| 168 | + public static function ensure_registered( ?callable $replay = null ): int { | |
| 169 | + if ( ! function_exists( 'wp_get_abilities' ) ) { | |
| 170 | + return 0; | |
| 103 | 171 | } |
| 104 | 172 | |
| 105 | - add_action( 'wp_abilities_api_categories_init', [ $this, 'register_category' ] ); | |
| 106 | - add_action( 'wp_abilities_api_init', [ $this, 'register_abilities' ] ); | |
| 173 | + // Forces the registry's lazy init (and with it `wp_abilities_api_init`). | |
| 174 | + $count = self::count_registered(); | |
| 175 | + if ( $count > 0 || self::$replayed ) { | |
| 176 | + return $count; | |
| 177 | + } | |
| 178 | + | |
| 179 | + // Before the registry has initialized, `wp_register_ability()` refuses | |
| 180 | + // the registration and calls `_doing_it_wrong()`. Nothing to replay yet. | |
| 181 | + if ( ! function_exists( 'did_action' ) || ! did_action( 'wp_abilities_api_init' ) ) { | |
| 182 | + return $count; | |
| 183 | + } | |
| 184 | + | |
| 185 | + self::$replayed = true; | |
| 186 | + | |
| 187 | + if ( ! Ability_Base::abilities_enabled() ) { | |
| 188 | + return 0; | |
| 189 | + } | |
| 190 | + | |
| 191 | + if ( null === $replay ) { | |
| 192 | + $registrar = new self(); | |
| 193 | + $replay = static function () use ( $registrar ) { | |
| 194 | + $registrar->register_category(); | |
| 195 | + $registrar->register_abilities(); | |
| 196 | + }; | |
| 197 | + } | |
| 198 | + | |
| 199 | + $replay(); | |
| 200 | + | |
| 201 | + $count = self::count_registered(); | |
| 202 | + | |
| 203 | + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { | |
| 204 | + error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic. | |
| 205 | + '[TR-MCP] ThinkRank abilities were missing from the registry; replayed registration. ' . self::summary() | |
| 206 | + ); | |
| 207 | + } | |
| 208 | + | |
| 209 | + return $count; | |
| 107 | 210 | } |
| 108 | 211 | |
| 109 | 212 | /** |
| 213 | + * How many ThinkRank abilities the registry currently holds. | |
| 214 | + * | |
| 215 | + * @return int | |
| 216 | + */ | |
| 217 | + public static function count_registered(): int { | |
| 218 | + if ( ! function_exists( 'wp_get_abilities' ) ) { | |
| 219 | + return 0; | |
| 220 | + } | |
| 221 | + | |
| 222 | + $count = 0; | |
| 223 | + foreach ( wp_get_abilities() as $ability ) { | |
| 224 | + if ( ! is_object( $ability ) || ! method_exists( $ability, 'get_name' ) ) { | |
| 225 | + continue; | |
| 226 | + } | |
| 227 | + foreach ( self::ABILITY_PREFIXES as $prefix ) { | |
| 228 | + if ( 0 === strpos( (string) $ability->get_name(), $prefix ) ) { | |
| 229 | + ++$count; | |
| 230 | + break; | |
| 231 | + } | |
| 232 | + } | |
| 233 | + } | |
| 234 | + | |
| 235 | + return $count; | |
| 236 | + } | |
| 237 | + | |
| 238 | + /** | |
| 239 | + * Which file defines the global Abilities API functions for this request. | |
| 240 | + * A path outside ThinkRank's `dependencies/` means a foreign copy owns the | |
| 241 | + * registry — the precondition for #241. | |
| 242 | + * | |
| 243 | + * @return string Absolute path, or '' when the API is absent/unresolvable. | |
| 244 | + */ | |
| 245 | + public static function owner_path(): string { | |
| 246 | + if ( ! function_exists( 'wp_get_abilities' ) ) { | |
| 247 | + return ''; | |
| 248 | + } | |
| 249 | + | |
| 250 | + try { | |
| 251 | + $reflection = new \ReflectionFunction( 'wp_get_abilities' ); | |
| 252 | + return (string) $reflection->getFileName(); | |
| 253 | + } catch ( \ReflectionException $e ) { | |
| 254 | + return ''; | |
| 255 | + } | |
| 256 | + } | |
| 257 | + | |
| 258 | + /** | |
| 259 | + * Diagnostic snapshot of the Abilities API as this request sees it. Feeds | |
| 260 | + * the MCP self-test and the debug log so "no tools registered" is | |
| 261 | + * distinguishable from "tools filtered out" without shell access. | |
| 262 | + * | |
| 263 | + * @return array{api_available:bool, owner:string, foreign:bool, hook_fired:bool, total:int, thinkrank:int, replayed:bool} | |
| 264 | + */ | |
| 265 | + public static function diagnostics(): array { | |
| 266 | + $available = function_exists( 'wp_get_abilities' ); | |
| 267 | + $owner = self::owner_path(); | |
| 268 | + $bundled = defined( 'THINKRANK_PLUGIN_DIR' ) ? THINKRANK_PLUGIN_DIR : ''; | |
| 269 | + | |
| 270 | + // Read the registry BEFORE `hook_fired`: `wp_get_abilities()` forces the | |
| 271 | + // lazy singleton's init (which fires `wp_abilities_api_init`). Reading | |
| 272 | + // `did_action()` first would report `hook_fired => false` in the same | |
| 273 | + // snapshot that already counts registered abilities — an internally | |
| 274 | + // inconsistent line for the exact support scenario this feeds. | |
| 275 | + $total = $available ? count( wp_get_abilities() ) : 0; | |
| 276 | + $thinkrank = self::count_registered(); | |
| 277 | + | |
| 278 | + return [ | |
| 279 | + 'api_available' => $available, | |
| 280 | + 'owner' => $owner, | |
| 281 | + 'foreign' => ( '' !== $owner && '' !== $bundled && 0 !== strpos( $owner, $bundled ) ), | |
| 282 | + 'hook_fired' => function_exists( 'did_action' ) ? (bool) did_action( 'wp_abilities_api_init' ) : false, | |
| 283 | + 'total' => $total, | |
| 284 | + 'thinkrank' => $thinkrank, | |
| 285 | + 'replayed' => self::$replayed, | |
| 286 | + ]; | |
| 287 | + } | |
| 288 | + | |
| 289 | + /** | |
| 290 | + * One-line, human-readable form of {@see self::diagnostics()}. | |
| 291 | + * | |
| 292 | + * @return string | |
| 293 | + */ | |
| 294 | + public static function summary(): string { | |
| 295 | + $d = self::diagnostics(); | |
| 296 | + | |
| 297 | + return sprintf( | |
| 298 | + 'Abilities API: %s; owner: %s%s; abilities total: %d, thinkrank: %d; init fired: %s; replayed: %s', | |
| 299 | + $d['api_available'] ? 'present' : 'missing', | |
| 300 | + '' !== $d['owner'] ? $d['owner'] : 'unknown', | |
| 301 | + $d['foreign'] ? ' (foreign copy — not ThinkRank\'s bundled runtime)' : '', | |
| 302 | + $d['total'], | |
| 303 | + $d['thinkrank'], | |
| 304 | + $d['hook_fired'] ? 'yes' : 'no', | |
| 305 | + $d['replayed'] ? 'yes' : 'no' | |
| 306 | + ); | |
| 307 | + } | |
| 308 | + | |
| 309 | + /** | |
| 110 | 310 | * Register the ThinkRank ability category. |
| 111 | 311 | * |
| 112 | 312 | * @return void |
| 113 | 313 | */ |
| @@ -143,8 +343,12 @@ | ||
| 143 | 343 | new Get_Global_Settings(), |
| 144 | 344 | new Update_Global_Settings(), |
| 145 | 345 | new Get_Post_Seo(), |
| 146 | 346 | new Update_Post_Seo(), |
| 347 | + new List_Snippet_Issues(), | |
| 348 | + new Get_Duplicate_Snippets(), | |
| 349 | + new Get_Thin_Content(), | |
| 350 | + new Update_Thin_Content_Settings(), | |
| 147 | 351 | new Get_Term_Seo(), |
| 148 | 352 | new Update_Term_Seo(), |
| 149 | 353 | new Get_Post_Seo_Checks(), |
| 150 | 354 | new Get_Term_Seo_Checks(), |
| @@ -162,9 +366,21 @@ | ||
| 162 | 366 | new Update_Image_Seo_Settings(), |
| 163 | 367 | new Get_Llms_Txt_Settings(), |
| 164 | 368 | new Update_Llms_Txt_Settings(), |
| 165 | 369 | new Get_Robots_Meta_Settings(), |
| 370 | + new Get_External_Links_Settings(), | |
| 371 | + new Update_External_Links_Settings(), | |
| 372 | + new Get_Content_Type_Matrix(), | |
| 373 | + new Update_Content_Type_Matrix(), | |
| 374 | + new List_Images(), | |
| 375 | + new Get_Image_Alt_Text(), | |
| 376 | + new Update_Image_Alt_Text(), | |
| 377 | + new Fill_Missing_Alt_Text(), | |
| 378 | + new Get_Post_Content(), | |
| 379 | + new Purge_Caches(), | |
| 166 | 380 | new Update_Robots_Meta_Settings(), |
| 381 | + new Get_Ai_Budget(), | |
| 382 | + new Update_Ai_Budget(), | |
| 167 | 383 | new Get_Instant_Indexing_Settings(), |
| 168 | 384 | new Update_Instant_Indexing_Settings(), |
| 169 | 385 | new Get_Author_Archives_Settings(), |
| 170 | 386 | new Update_Author_Archives_Settings(), |
| @@ -178,9 +394,8 @@ | ||
| 178 | 394 | new Get_Llms_Txt_Status(), |
| 179 | 395 | new Generate_Llms_Txt(), |
| 180 | 396 | new Publish_Llms_Txt(), |
| 181 | 397 | new Get_Seo_Analytics_Data(), |
| 182 | - new Get_Seo_Insights(), | |
| 183 | 398 | new Get_Seo_Opportunities(), |
| 184 | 399 | new Get_Seo_Score(), |
| 185 | 400 | new Get_Seo_Analyzer(), |
| 186 | 401 | new Run_Seo_Analyzer(), |