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

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