PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / abilities / class-abilities-registrar.php

class-abilities-registrar.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.5.0, at includes/abilities/class-abilities-registrar.php

412 lines 14.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Abilities registrar.
4 *
5 * @package ThinkRank\Abilities
6 */
7
8 declare(strict_types=1);
9
10 namespace ThinkRank\Abilities;
11
12 if ( ! defined( 'ABSPATH' ) ) {
13 exit; // Exit if accessed directly.
14 }
15
16 use ThinkRank\Abilities\Analysis\Get_Post_Seo_Checks;
17 use ThinkRank\Abilities\Analysis\Get_Term_Seo_Checks;
18 use ThinkRank\Abilities\Content\Get_Post_Seo;
19 use ThinkRank\Abilities\Content\Get_Term_Seo;
20 use ThinkRank\Abilities\Content\List_Content_Items;
21 use ThinkRank\Abilities\Content\List_Content_Types;
22 use ThinkRank\Abilities\Content\Update_Post_Seo;
23 use ThinkRank\Abilities\Content\Update_Term_Seo;
24 use ThinkRank\Abilities\Content\Generate_Content_Brief;
25 use ThinkRank\Abilities\Content\Get_Pillar_Content;
26 use ThinkRank\Abilities\Content\Update_Pillar_Content;
27 use ThinkRank\Abilities\Import\Detect_Import_Sources;
28 use ThinkRank\Abilities\Import\Get_Import_Status;
29 use ThinkRank\Abilities\Import\Run_Seo_Import;
30 use ThinkRank\Abilities\Import\Preview_Seo_Import;
31 use ThinkRank\Abilities\Settings\Get_Global_Settings;
32 use ThinkRank\Abilities\Settings\Get_Robots_Txt;
33 use ThinkRank\Abilities\Settings\Get_Sitemap_Settings;
34 use ThinkRank\Abilities\Settings\Update_Global_Settings;
35 use ThinkRank\Abilities\Settings\Update_Robots_Txt;
36 use ThinkRank\Abilities\Settings\Update_Sitemap_Settings;
37 use ThinkRank\Abilities\Settings\Get_Schema_Settings;
38 use ThinkRank\Abilities\Settings\Update_Schema_Settings;
39 use ThinkRank\Abilities\Settings\Get_Site_Identity_Settings;
40 use ThinkRank\Abilities\Settings\Update_Site_Identity_Settings;
41 use ThinkRank\Abilities\Settings\Get_Social_Meta_Settings;
42 use ThinkRank\Abilities\Settings\Update_Social_Meta_Settings;
43 use ThinkRank\Abilities\Settings\Get_Image_Seo_Settings;
44 use ThinkRank\Abilities\Settings\Update_Image_Seo_Settings;
45 use ThinkRank\Abilities\Settings\Get_Llms_Txt_Settings;
46 use ThinkRank\Abilities\Settings\Update_Llms_Txt_Settings;
47 use ThinkRank\Abilities\Settings\Get_Robots_Meta_Settings;
48 use ThinkRank\Abilities\Links\Get_External_Links_Settings;
49 use ThinkRank\Abilities\Links\Update_External_Links_Settings;
50 use ThinkRank\Abilities\Matrix\Get_Content_Type_Matrix;
51 use ThinkRank\Abilities\Matrix\Update_Content_Type_Matrix;
52 use ThinkRank\Abilities\Settings\Update_Robots_Meta_Settings;
53 use ThinkRank\Abilities\Settings\Get_Instant_Indexing_Settings;
54 use ThinkRank\Abilities\Settings\Update_Instant_Indexing_Settings;
55 use ThinkRank\Abilities\Settings\Get_Author_Archives_Settings;
56 use ThinkRank\Abilities\Settings\Update_Author_Archives_Settings;
57 use ThinkRank\Abilities\Settings\Get_Email_Report_Settings;
58 use ThinkRank\Abilities\Settings\Update_Email_Report_Settings;
59 use ThinkRank\Abilities\Settings\Send_Email_Report_Test;
60 use ThinkRank\Abilities\Settings\Get_Social_Platforms_Settings;
61 use ThinkRank\Abilities\Settings\Update_Social_Platforms_Settings;
62 use ThinkRank\Abilities\Indexing\Submit_Urls_To_Index;
63 use ThinkRank\Abilities\Indexing\Get_Instant_Indexing_History;
64 use ThinkRank\Abilities\Analysis\Get_Llms_Txt_Status;
65 use ThinkRank\Abilities\Analysis\Generate_Llms_Txt;
66 use ThinkRank\Abilities\Analysis\Publish_Llms_Txt;
67 use ThinkRank\Abilities\Analysis\Get_Seo_Analytics_Data;
68 use ThinkRank\Abilities\Analysis\Get_Seo_Insights;
69 use ThinkRank\Abilities\Analysis\Get_Seo_Opportunities;
70 use ThinkRank\Abilities\Analysis\Get_Seo_Score;
71 use ThinkRank\Abilities\Analysis\Get_Seo_Analyzer;
72 use ThinkRank\Abilities\Analysis\Run_Seo_Analyzer;
73 use ThinkRank\Abilities\Analysis\Get_Performance_Data;
74 use ThinkRank\Abilities\Analysis\Get_Integrations_Status;
75 use ThinkRank\Abilities\Analysis\Get_Connection_Status;
76 use ThinkRank\Abilities\Analysis\Bulk_Analyze_And_Save;
77 use ThinkRank\Core\Settings;
78
79 /**
80 * Registers ThinkRank abilities with the WordPress Abilities API.
81 *
82 * Abilities are **always** registered when the Abilities API is available —
83 * registration is decoupled from the `enable_mcp` toggle (see #187). Each
84 * ability is a permission-checked read/write surface (its own
85 * `current_user_can()` callback runs on every call), so registration alone
86 * exposes nothing; it only makes ThinkRank discoverable to generic WordPress
87 * Abilities clients (e.g. an external connector) the same way WordPress core
88 * abilities are. The `enable_mcp` toggle gates only ThinkRank's own MCP server
89 * ({@see \ThinkRank\Mcp\Mcp_Manager}) — the endpoint, discovery documents, and
90 * OAuth surface. The MCP server reads this abilities registry as its tool
91 * catalog. The Abilities API itself ships in `dependencies/` and no-ops
92 * gracefully when missing.
93 *
94 * A kill switch remains available via the `thinkrank_abilities_api_enabled`
95 * filter (defaults to `true`; see {@see Ability_Base::abilities_enabled()}).
96 */
97 class Abilities_Registrar {
98
99 /**
100 * Ability-name prefixes that mark an ability as ThinkRank's. The free
101 * plugin owns `thinkrank/`; Pro registers under `thinkrank-pro/` via the
102 * `thinkrank_register_abilities` filter.
103 */
104 public const ABILITY_PREFIXES = [ 'thinkrank/', 'thinkrank-pro/' ];
105
106 /**
107 * Whether the registration replay (see {@see self::ensure_registered()})
108 * has already run this request. One attempt only — a replay that produced
109 * nothing will not produce anything on the second try either, and the MCP
110 * server asks for the tool list more than once per request.
111 *
112 * @var bool
113 */
114 private static $replayed = false;
115
116 /**
117 * Initialize the registrar (called by the plugin's component container).
118 *
119 * Deliberately does NOT bail on a missing `wp_register_ability`: the
120 * Abilities API is a set of GLOBAL functions loaded under
121 * `function_exists` guards, so which copy owns them — ours in
122 * `dependencies/`, another plugin's, or core's — is decided by load order,
123 * not by us. A copy that lands after `plugins_loaded` would have made this
124 * an early return and left ThinkRank permanently unregistered (see #241).
125 * The callbacks themselves are guarded instead, so hooking unconditionally
126 * is free when no Abilities API ever shows up.
127 *
128 * @return void
129 */
130 public function init() {
131 add_action( 'wp_abilities_api_categories_init', [ $this, 'register_category' ] );
132 add_action( 'wp_abilities_api_init', [ $this, 'register_abilities' ] );
133 }
134
135 /**
136 * Guarantee ThinkRank's abilities are in the registry, replaying
137 * registration once if they are not.
138 *
139 * `wp_abilities_api_init` fires exactly once, from the lazy registry
140 * singleton of whichever Abilities API copy owns the globals. If a foreign
141 * copy owns them and fires its init under a different name or at a moment
142 * when our hook is not attached yet, our callback never runs: the registry
143 * is populated by everyone else and `tools/list` answers with an empty
144 * array while auth, discovery and `initialize` all report success (#241).
145 *
146 * Calling `wp_get_abilities()` here forces that lazy init, so by the time
147 * we decide to replay, `wp_abilities_api_init` has fired and
148 * `wp_register_ability()` will accept our registrations. Registration is
149 * idempotent (each ability is skipped when `wp_has_ability()` already
150 * knows it), so a replay after a *successful* hook run is a no-op anyway.
151 *
152 * @param callable|null $replay Optional replay routine, for tests. Default
153 * is this class's own category + abilities
154 * registration.
155 * @return int Number of ThinkRank abilities registered afterwards.
156 */
157 public static function ensure_registered( ?callable $replay = null ): int {
158 if ( ! function_exists( 'wp_get_abilities' ) ) {
159 return 0;
160 }
161
162 // Forces the registry's lazy init (and with it `wp_abilities_api_init`).
163 $count = self::count_registered();
164 if ( $count > 0 || self::$replayed ) {
165 return $count;
166 }
167
168 // Before the registry has initialized, `wp_register_ability()` refuses
169 // the registration and calls `_doing_it_wrong()`. Nothing to replay yet.
170 if ( ! function_exists( 'did_action' ) || ! did_action( 'wp_abilities_api_init' ) ) {
171 return $count;
172 }
173
174 self::$replayed = true;
175
176 if ( ! Ability_Base::abilities_enabled() ) {
177 return 0;
178 }
179
180 if ( null === $replay ) {
181 $registrar = new self();
182 $replay = static function () use ( $registrar ) {
183 $registrar->register_category();
184 $registrar->register_abilities();
185 };
186 }
187
188 $replay();
189
190 $count = self::count_registered();
191
192 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
193 error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic.
194 '[TR-MCP] ThinkRank abilities were missing from the registry; replayed registration. ' . self::summary()
195 );
196 }
197
198 return $count;
199 }
200
201 /**
202 * How many ThinkRank abilities the registry currently holds.
203 *
204 * @return int
205 */
206 public static function count_registered(): int {
207 if ( ! function_exists( 'wp_get_abilities' ) ) {
208 return 0;
209 }
210
211 $count = 0;
212 foreach ( wp_get_abilities() as $ability ) {
213 if ( ! is_object( $ability ) || ! method_exists( $ability, 'get_name' ) ) {
214 continue;
215 }
216 foreach ( self::ABILITY_PREFIXES as $prefix ) {
217 if ( 0 === strpos( (string) $ability->get_name(), $prefix ) ) {
218 ++$count;
219 break;
220 }
221 }
222 }
223
224 return $count;
225 }
226
227 /**
228 * Which file defines the global Abilities API functions for this request.
229 * A path outside ThinkRank's `dependencies/` means a foreign copy owns the
230 * registry — the precondition for #241.
231 *
232 * @return string Absolute path, or '' when the API is absent/unresolvable.
233 */
234 public static function owner_path(): string {
235 if ( ! function_exists( 'wp_get_abilities' ) ) {
236 return '';
237 }
238
239 try {
240 $reflection = new \ReflectionFunction( 'wp_get_abilities' );
241 return (string) $reflection->getFileName();
242 } catch ( \ReflectionException $e ) {
243 return '';
244 }
245 }
246
247 /**
248 * Diagnostic snapshot of the Abilities API as this request sees it. Feeds
249 * the MCP self-test and the debug log so "no tools registered" is
250 * distinguishable from "tools filtered out" without shell access.
251 *
252 * @return array{api_available:bool, owner:string, foreign:bool, hook_fired:bool, total:int, thinkrank:int, replayed:bool}
253 */
254 public static function diagnostics(): array {
255 $available = function_exists( 'wp_get_abilities' );
256 $owner = self::owner_path();
257 $bundled = defined( 'THINKRANK_PLUGIN_DIR' ) ? THINKRANK_PLUGIN_DIR : '';
258
259 // Read the registry BEFORE `hook_fired`: `wp_get_abilities()` forces the
260 // lazy singleton's init (which fires `wp_abilities_api_init`). Reading
261 // `did_action()` first would report `hook_fired => false` in the same
262 // snapshot that already counts registered abilities — an internally
263 // inconsistent line for the exact support scenario this feeds.
264 $total = $available ? count( wp_get_abilities() ) : 0;
265 $thinkrank = self::count_registered();
266
267 return [
268 'api_available' => $available,
269 'owner' => $owner,
270 'foreign' => ( '' !== $owner && '' !== $bundled && 0 !== strpos( $owner, $bundled ) ),
271 'hook_fired' => function_exists( 'did_action' ) ? (bool) did_action( 'wp_abilities_api_init' ) : false,
272 'total' => $total,
273 'thinkrank' => $thinkrank,
274 'replayed' => self::$replayed,
275 ];
276 }
277
278 /**
279 * One-line, human-readable form of {@see self::diagnostics()}.
280 *
281 * @return string
282 */
283 public static function summary(): string {
284 $d = self::diagnostics();
285
286 return sprintf(
287 'Abilities API: %s; owner: %s%s; abilities total: %d, thinkrank: %d; init fired: %s; replayed: %s',
288 $d['api_available'] ? 'present' : 'missing',
289 '' !== $d['owner'] ? $d['owner'] : 'unknown',
290 $d['foreign'] ? ' (foreign copy — not ThinkRank\'s bundled runtime)' : '',
291 $d['total'],
292 $d['thinkrank'],
293 $d['hook_fired'] ? 'yes' : 'no',
294 $d['replayed'] ? 'yes' : 'no'
295 );
296 }
297
298 /**
299 * Register the ThinkRank ability category.
300 *
301 * @return void
302 */
303 public function register_category() {
304 if ( function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( 'thinkrank' ) ) {
305 return;
306 }
307
308 if ( function_exists( 'wp_register_ability_category' ) ) {
309 wp_register_ability_category(
310 'thinkrank',
311 [
312 'label' => __( 'ThinkRank', 'thinkrank' ),
313 'description' => __( 'SEO settings, metadata, and analysis abilities powered by ThinkRank.', 'thinkrank' ),
314 ]
315 );
316 }
317 }
318
319 /**
320 * Register ThinkRank abilities.
321 *
322 * @return void
323 */
324 public function register_abilities() {
325 if ( ! Ability_Base::abilities_enabled() ) {
326 return;
327 }
328
329 $abilities = [
330 new List_Content_Types(),
331 new List_Content_Items(),
332 new Get_Global_Settings(),
333 new Update_Global_Settings(),
334 new Get_Post_Seo(),
335 new Update_Post_Seo(),
336 new Get_Term_Seo(),
337 new Update_Term_Seo(),
338 new Get_Post_Seo_Checks(),
339 new Get_Term_Seo_Checks(),
340 new Get_Robots_Txt(),
341 new Update_Robots_Txt(),
342 new Get_Sitemap_Settings(),
343 new Update_Sitemap_Settings(),
344 new Get_Schema_Settings(),
345 new Update_Schema_Settings(),
346 new Get_Site_Identity_Settings(),
347 new Update_Site_Identity_Settings(),
348 new Get_Social_Meta_Settings(),
349 new Update_Social_Meta_Settings(),
350 new Get_Image_Seo_Settings(),
351 new Update_Image_Seo_Settings(),
352 new Get_Llms_Txt_Settings(),
353 new Update_Llms_Txt_Settings(),
354 new Get_Robots_Meta_Settings(),
355 new Get_External_Links_Settings(),
356 new Update_External_Links_Settings(),
357 new Get_Content_Type_Matrix(),
358 new Update_Content_Type_Matrix(),
359 new Update_Robots_Meta_Settings(),
360 new Get_Instant_Indexing_Settings(),
361 new Update_Instant_Indexing_Settings(),
362 new Get_Author_Archives_Settings(),
363 new Update_Author_Archives_Settings(),
364 new Get_Email_Report_Settings(),
365 new Update_Email_Report_Settings(),
366 new Send_Email_Report_Test(),
367 new Get_Social_Platforms_Settings(),
368 new Update_Social_Platforms_Settings(),
369 new Submit_Urls_To_Index(),
370 new Get_Instant_Indexing_History(),
371 new Get_Llms_Txt_Status(),
372 new Generate_Llms_Txt(),
373 new Publish_Llms_Txt(),
374 new Get_Seo_Analytics_Data(),
375 new Get_Seo_Insights(),
376 new Get_Seo_Opportunities(),
377 new Get_Seo_Score(),
378 new Get_Seo_Analyzer(),
379 new Run_Seo_Analyzer(),
380 new Get_Performance_Data(),
381 new Get_Integrations_Status(),
382 new Get_Connection_Status(),
383 new Bulk_Analyze_And_Save(),
384 new Generate_Content_Brief(),
385 new Get_Pillar_Content(),
386 new Update_Pillar_Content(),
387 new Detect_Import_Sources(),
388 new Get_Import_Status(),
389 new Run_Seo_Import(),
390 new Preview_Seo_Import(),
391 ];
392
393 $abilities = apply_filters( 'thinkrank_register_abilities', $abilities );
394
395 foreach ( $abilities as $ability ) {
396 if ( ! $ability instanceof Ability_Base ) {
397 continue;
398 }
399
400 if ( ! $ability->meets_capability_policy() || ! $ability->is_enabled() ) {
401 continue;
402 }
403
404 if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability->get_id() ) ) {
405 continue;
406 }
407
408 $ability->register();
409 }
410 }
411 }
412