PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.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 All 50 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.9.0, at includes/abilities/class-abilities-registrar.php

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