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

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