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

434 lines 15.4 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\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;
25 use ThinkRank\Abilities\Content\List_Content_Types;
26 use ThinkRank\Abilities\Content\Update_Post_Seo;
27 use ThinkRank\Abilities\Content\Update_Term_Seo;
28 use ThinkRank\Abilities\Content\Generate_Content_Brief;
29 use ThinkRank\Abilities\Content\Get_Pillar_Content;
30 use ThinkRank\Abilities\Content\Update_Pillar_Content;
31 use ThinkRank\Abilities\Import\Detect_Import_Sources;
32 use ThinkRank\Abilities\Import\Get_Import_Status;
33 use ThinkRank\Abilities\Import\Run_Seo_Import;
34 use ThinkRank\Abilities\Import\Preview_Seo_Import;
35 use ThinkRank\Abilities\Settings\Get_Global_Settings;
36 use ThinkRank\Abilities\Settings\Get_Robots_Txt;
37 use ThinkRank\Abilities\Settings\Get_Sitemap_Settings;
38 use ThinkRank\Abilities\Settings\Update_Global_Settings;
39 use ThinkRank\Abilities\Settings\Update_Robots_Txt;
40 use ThinkRank\Abilities\Settings\Update_Sitemap_Settings;
41 use ThinkRank\Abilities\Settings\Get_Schema_Settings;
42 use ThinkRank\Abilities\Settings\Update_Schema_Settings;
43 use ThinkRank\Abilities\Settings\Get_Site_Identity_Settings;
44 use ThinkRank\Abilities\Settings\Update_Site_Identity_Settings;
45 use ThinkRank\Abilities\Settings\Get_Social_Meta_Settings;
46 use ThinkRank\Abilities\Settings\Update_Social_Meta_Settings;
47 use ThinkRank\Abilities\Settings\Get_Image_Seo_Settings;
48 use ThinkRank\Abilities\Settings\Update_Image_Seo_Settings;
49 use ThinkRank\Abilities\Settings\Get_Llms_Txt_Settings;
50 use ThinkRank\Abilities\Settings\Update_Llms_Txt_Settings;
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;
62 use ThinkRank\Abilities\Settings\Update_Robots_Meta_Settings;
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;
66 use ThinkRank\Abilities\Settings\Update_Instant_Indexing_Settings;
67 use ThinkRank\Abilities\Settings\Get_Author_Archives_Settings;
68 use ThinkRank\Abilities\Settings\Update_Author_Archives_Settings;
69 use ThinkRank\Abilities\Settings\Get_Email_Report_Settings;
70 use ThinkRank\Abilities\Settings\Update_Email_Report_Settings;
71 use ThinkRank\Abilities\Settings\Send_Email_Report_Test;
72 use ThinkRank\Abilities\Settings\Get_Social_Platforms_Settings;
73 use ThinkRank\Abilities\Settings\Update_Social_Platforms_Settings;
74 use ThinkRank\Abilities\Indexing\Submit_Urls_To_Index;
75 use ThinkRank\Abilities\Indexing\Get_Instant_Indexing_History;
76 use ThinkRank\Abilities\Analysis\Get_Llms_Txt_Status;
77 use ThinkRank\Abilities\Analysis\Generate_Llms_Txt;
78 use ThinkRank\Abilities\Analysis\Publish_Llms_Txt;
79 use ThinkRank\Abilities\Analysis\Get_Seo_Analytics_Data;
80 use ThinkRank\Abilities\Analysis\Get_Seo_Opportunities;
81 use ThinkRank\Abilities\Analysis\Get_Seo_Score;
82 use ThinkRank\Abilities\Analysis\Get_Seo_Analyzer;
83 use ThinkRank\Abilities\Analysis\Run_Seo_Analyzer;
84 use ThinkRank\Abilities\Analysis\Get_Performance_Data;
85 use ThinkRank\Abilities\Analysis\Get_Integrations_Status;
86 use ThinkRank\Abilities\Analysis\Get_Connection_Status;
87 use ThinkRank\Abilities\Analysis\Bulk_Analyze_And_Save;
88 use ThinkRank\Core\Settings;
89
90 /**
91 * Registers ThinkRank abilities with the WordPress Abilities API.
92 *
93 * Abilities are **always** registered when the Abilities API is available —
94 * registration is decoupled from the `enable_mcp` toggle (see #187). Each
95 * ability is a permission-checked read/write surface (its own
96 * `current_user_can()` callback runs on every call), so registration alone
97 * exposes nothing; it only makes ThinkRank discoverable to generic WordPress
98 * Abilities clients (e.g. an external connector) the same way WordPress core
99 * abilities are. The `enable_mcp` toggle gates only ThinkRank's own MCP server
100 * ({@see \ThinkRank\Mcp\Mcp_Manager}) — the endpoint, discovery documents, and
101 * OAuth surface. The MCP server reads this abilities registry as its tool
102 * catalog. The Abilities API itself ships in `dependencies/` and no-ops
103 * gracefully when missing.
104 *
105 * A kill switch remains available via the `thinkrank_abilities_api_enabled`
106 * filter (defaults to `true`; see {@see Ability_Base::abilities_enabled()}).
107 */
108 class Abilities_Registrar {
109
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 /**
128 * Initialize the registrar (called by the plugin's component container).
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 *
139 * @return void
140 */
141 public function init() {
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;
171 }
172
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;
210 }
211
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 /**
310 * Register the ThinkRank ability category.
311 *
312 * @return void
313 */
314 public function register_category() {
315 if ( function_exists( 'wp_has_ability_category' ) && wp_has_ability_category( 'thinkrank' ) ) {
316 return;
317 }
318
319 if ( function_exists( 'wp_register_ability_category' ) ) {
320 wp_register_ability_category(
321 'thinkrank',
322 [
323 'label' => __( 'ThinkRank', 'thinkrank' ),
324 'description' => __( 'SEO settings, metadata, and analysis abilities powered by ThinkRank.', 'thinkrank' ),
325 ]
326 );
327 }
328 }
329
330 /**
331 * Register ThinkRank abilities.
332 *
333 * @return void
334 */
335 public function register_abilities() {
336 if ( ! Ability_Base::abilities_enabled() ) {
337 return;
338 }
339
340 $abilities = [
341 new List_Content_Types(),
342 new List_Content_Items(),
343 new Get_Global_Settings(),
344 new Update_Global_Settings(),
345 new Get_Post_Seo(),
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(),
351 new Get_Term_Seo(),
352 new Update_Term_Seo(),
353 new Get_Post_Seo_Checks(),
354 new Get_Term_Seo_Checks(),
355 new Get_Robots_Txt(),
356 new Update_Robots_Txt(),
357 new Get_Sitemap_Settings(),
358 new Update_Sitemap_Settings(),
359 new Get_Schema_Settings(),
360 new Update_Schema_Settings(),
361 new Get_Site_Identity_Settings(),
362 new Update_Site_Identity_Settings(),
363 new Get_Social_Meta_Settings(),
364 new Update_Social_Meta_Settings(),
365 new Get_Image_Seo_Settings(),
366 new Update_Image_Seo_Settings(),
367 new Get_Llms_Txt_Settings(),
368 new Update_Llms_Txt_Settings(),
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(),
380 new Update_Robots_Meta_Settings(),
381 new Get_Ai_Budget(),
382 new Update_Ai_Budget(),
383 new Get_Instant_Indexing_Settings(),
384 new Update_Instant_Indexing_Settings(),
385 new Get_Author_Archives_Settings(),
386 new Update_Author_Archives_Settings(),
387 new Get_Email_Report_Settings(),
388 new Update_Email_Report_Settings(),
389 new Send_Email_Report_Test(),
390 new Get_Social_Platforms_Settings(),
391 new Update_Social_Platforms_Settings(),
392 new Submit_Urls_To_Index(),
393 new Get_Instant_Indexing_History(),
394 new Get_Llms_Txt_Status(),
395 new Generate_Llms_Txt(),
396 new Publish_Llms_Txt(),
397 new Get_Seo_Analytics_Data(),
398 new Get_Seo_Opportunities(),
399 new Get_Seo_Score(),
400 new Get_Seo_Analyzer(),
401 new Run_Seo_Analyzer(),
402 new Get_Performance_Data(),
403 new Get_Integrations_Status(),
404 new Get_Connection_Status(),
405 new Bulk_Analyze_And_Save(),
406 new Generate_Content_Brief(),
407 new Get_Pillar_Content(),
408 new Update_Pillar_Content(),
409 new Detect_Import_Sources(),
410 new Get_Import_Status(),
411 new Run_Seo_Import(),
412 new Preview_Seo_Import(),
413 ];
414
415 $abilities = apply_filters( 'thinkrank_register_abilities', $abilities );
416
417 foreach ( $abilities as $ability ) {
418 if ( ! $ability instanceof Ability_Base ) {
419 continue;
420 }
421
422 if ( ! $ability->meets_capability_policy() || ! $ability->is_enabled() ) {
423 continue;
424 }
425
426 if ( function_exists( 'wp_has_ability' ) && wp_has_ability( $ability->get_id() ) ) {
427 continue;
428 }
429
430 $ability->register();
431 }
432 }
433 }
434