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
← All changes | thinkrank.php +307 -44 1.0.02.9.0 View file →
@@ -1,22 +1,22 @@
1 1 <?php
2 +
2 3 /**
3 4 * Plugin Name: ThinkRank
4 - * Plugin URI: https://thinkrank.ai/wordpress-plugin
5 + * Plugin URI: https://thinkrank.ai/
5 6 * Description: AI-native SEO plugin for WordPress. Automate and enhance your SEO with cutting-edge AI while maintaining editorial control.
6 - * Version: 1.0.0
7 - * Author: rupok
8 - * Author URI: https://thinkrank.ai
7 + * Version: 2.9.0
8 + * Author: WPDeveloper
9 + * Author URI: https://wpdeveloper.com/
9 10 * License: GPL v2 or later
10 11 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
11 12 * Text Domain: thinkrank
12 13 * Domain Path: /languages
13 14 * Requires at least: 6.0
14 - * Tested up to: 6.8
15 - * Requires PHP: 8.0
15 + * Requires PHP: 7.4
16 16 *
17 17 * @package ThinkRank
18 - * @version 1.0.0
18 + * @version 2.9.0
19 19 * @since 1.0.0
20 20 */
21 21
22 22 declare(strict_types=1);
@@ -26,9 +26,9 @@
26 26 exit;
27 27 }
28 28
29 29 // Define plugin constants
30 -define('THINKRANK_VERSION', '1.0.0');
30 +define('THINKRANK_VERSION', '2.9.0');
31 31 define('THINKRANK_PLUGIN_FILE', __FILE__);
32 32 define('THINKRANK_PLUGIN_DIR', plugin_dir_path(__FILE__));
33 33 define('THINKRANK_PLUGIN_URL', plugin_dir_url(__FILE__));
34 34 define('THINKRANK_PLUGIN_BASENAME', plugin_basename(__FILE__));
@@ -33,12 +33,12 @@
33 33 define('THINKRANK_PLUGIN_URL', plugin_dir_url(__FILE__));
34 34 define('THINKRANK_PLUGIN_BASENAME', plugin_basename(__FILE__));
35 35
36 36 // Minimum requirements check
37 -if (version_compare(PHP_VERSION, '8.0', '<')) {
38 - add_action('admin_notices', function() {
37 +if (version_compare(PHP_VERSION, '7.4', '<')) {
38 + add_action('admin_notices', function () {
39 39 echo '<div class="notice notice-error"><p>';
40 - echo esc_html__('ThinkRank requires PHP 8.0 or higher. Please upgrade your PHP version.', 'thinkrank');
40 + echo esc_html__('ThinkRank requires PHP 7.4 or higher. Please upgrade your PHP version.', 'thinkrank');
41 41 echo '</p></div>';
42 42 });
43 43 return;
44 44 }
@@ -43,9 +43,9 @@
43 43 return;
44 44 }
45 45
46 46 if (version_compare(get_bloginfo('version'), '6.0', '<')) {
47 - add_action('admin_notices', function() {
47 + add_action('admin_notices', function () {
48 48 echo '<div class="notice notice-error"><p>';
49 49 echo esc_html__('ThinkRank requires WordPress 6.0 or higher. Please upgrade your WordPress installation.', 'thinkrank');
50 50 echo '</p></div>';
51 51 });
@@ -55,8 +55,23 @@
55 55 // Autoloader
56 56 require_once THINKRANK_PLUGIN_DIR . 'includes/class-autoloader.php';
57 57
58 58 /**
59 + * Bundled AI Building Blocks (Abilities API + MCP Adapter).
60 + *
61 + * Loaded through the Jetpack Autoloader so that if the same libraries are also
62 + * shipped by another plugin — or land in WordPress core — the newest copy wins
63 + * and loads once, with no fatal class collisions. This lets ThinkRank serve its
64 + * MCP endpoint out of the box, without requiring the standalone MCP Adapter and
65 + * Abilities API plugins. See docs/mcp-server.md for the update procedure.
66 + */
67 +$thinkrank_mcp_runtime = THINKRANK_PLUGIN_DIR . 'dependencies/vendor/autoload_packages.php';
68 +if (is_readable($thinkrank_mcp_runtime)) {
69 + require_once $thinkrank_mcp_runtime;
70 +}
71 +unset($thinkrank_mcp_runtime);
72 +
73 +/**
59 74 * Main ThinkRank Plugin Class
60 75 *
61 76 * Follows Single Responsibility Principle - only handles plugin initialization
62 77 *
@@ -62,9 +77,9 @@
62 77 *
63 78 * @since 1.0.0
64 79 */
65 80 final class ThinkRank {
66 -
81 +
67 82 /**
68 83 * Plugin instance (Singleton Pattern)
69 84 *
70 85 * @var ThinkRank|null
@@ -69,9 +84,9 @@
69 84 *
70 85 * @var ThinkRank|null
71 86 */
72 87 private static ?ThinkRank $instance = null;
73 -
88 +
74 89 /**
75 90 * Plugin components
76 91 *
77 92 * @var array
@@ -76,9 +91,9 @@
76 91 *
77 92 * @var array
78 93 */
79 94 private array $components = [];
80 -
95 +
81 96 /**
82 97 * Get plugin instance (Singleton Pattern)
83 98 *
84 99 * @return ThinkRank
@@ -88,9 +103,9 @@
88 103 self::$instance = new self();
89 104 }
90 105 return self::$instance;
91 106 }
92 -
107 +
93 108 /**
94 109 * Private constructor to prevent direct instantiation
95 110 */
96 111 private function __construct() {
@@ -96,21 +111,24 @@
96 111 private function __construct() {
97 112 $this->init_autoloader();
98 113 $this->init_hooks();
99 114 }
100 -
115 +
101 116 /**
102 117 * Prevent cloning
103 118 */
104 - private function __clone() {}
105 -
119 + private function __clone() {
120 + }
121 +
106 122 /**
107 123 * Prevent unserialization
124 + *
125 + * @throws \Exception On failure.
108 126 */
109 127 public function __wakeup() {
110 128 throw new \Exception('Cannot unserialize singleton');
111 129 }
112 -
130 +
113 131 /**
114 132 * Initialize autoloader
115 133 *
116 134 * @return void
@@ -117,9 +135,9 @@
117 135 */
118 136 private function init_autoloader(): void {
119 137 ThinkRank\Core\Autoloader::register();
120 138 }
121 -
139 +
122 140 /**
123 141 * Initialize WordPress hooks
124 142 *
125 143 * @return void
@@ -128,11 +146,30 @@
128 146 register_activation_hook(__FILE__, [$this, 'activate']);
129 147 register_deactivation_hook(__FILE__, [$this, 'deactivate']);
130 148
131 149 add_action('plugins_loaded', [$this, 'init']);
150 + add_filter('plugin_action_links_' . THINKRANK_PLUGIN_BASENAME, [$this, 'add_action_links']);
132 151 }
133 -
152 +
134 153 /**
154 + * Add a "Dashboard" link to the plugin action links on the Plugins page.
155 + *
156 + * @param array $links Existing plugin action links.
157 + * @return array
158 + */
159 + public function add_action_links(array $links): array {
160 + $dashboard_link = sprintf(
161 + '<a href="%s">%s</a>',
162 + esc_url(admin_url('admin.php?page=thinkrank')),
163 + esc_html__('Dashboard', 'thinkrank')
164 + );
165 +
166 + array_unshift($links, $dashboard_link);
167 +
168 + return $links;
169 + }
170 +
171 + /**
135 172 * Initialize plugin components
136 173 *
137 174 * @return void
138 175 */
@@ -137,8 +174,11 @@
137 174 * @return void
138 175 */
139 176 public function init(): void {
140 177 try {
178 + $this->maybe_update_database();
179 + $this->register_sitemap_cron_listeners();
180 + $this->clear_retired_brand_visibility_cron();
141 181 $this->load_components();
142 182 $this->init_components();
143 183 $this->load_template_functions();
144 184
@@ -146,10 +186,148 @@
146 186 } catch (\Exception $e) {
147 187 $this->handle_error($e);
148 188 }
149 189 }
150 -
190 +
151 191 /**
192 + * Register the sitemap regeneration WP-Cron listeners.
193 + *
194 + * Runs on plugins_loaded (via init()), so the callbacks exist on every
195 + * request — including WP-Cron, which never fires rest_api_init and therefore
196 + * never builds the Sitemap REST endpoint (whose constructor would otherwise
197 + * be the only place the scheduled regeneration hooks get a listener). The
198 + * generator is built lazily inside the callback so this stays cheap on the
199 + * vast majority of requests where no regeneration is due.
200 + *
201 + * @return void
202 + */
203 + private function register_sitemap_cron_listeners(): void {
204 + add_action('thinkrank_regenerate_sitemap', static function () {
205 + (new ThinkRank\SEO\Sitemap_Generator())->auto_regenerate_sitemap();
206 + });
207 + add_action('thinkrank_regenerate_sitemap_settings', static function () {
208 + (new ThinkRank\SEO\Sitemap_Generator())->regenerate_sitemap_from_settings();
209 + });
210 +
211 + add_action('shutdown', [$this, 'maybe_take_over_sitemap_regeneration'], 100);
212 + }
213 +
214 + /**
215 + * Rebuild the sitemap in-request when WP-Cron has not delivered.
216 + *
217 + * WP-Cron only runs when a request arrives, so with DISABLE_WP_CRON set, a
218 + * host blocking loopback requests, or very little traffic, the scheduled
219 + * regeneration never fires and the sitemap silently stops updating (#629).
220 + * Once the event is overdue by the generator's grace period, an admin, REST
221 + * or WP-CLI request takes the work over so the site converges on its own.
222 + *
223 + * Front-end requests are deliberately excluded: this runs on `shutdown`,
224 + * after the response, but generation on a large site is not free and a
225 + * visitor should never pay for it. Admin traffic is what a site with broken
226 + * cron reliably still has — the sitemap goes stale right after someone
227 + * publishes something, and that someone is in wp-admin.
228 + *
229 + * @since 2.2.1
230 + * @return void
231 + */
232 + public function maybe_take_over_sitemap_regeneration(): void {
233 + // is_admin() is true for admin-ajax.php and REST_REQUEST for public
234 + // core routes, both of which anonymous front-end traffic reaches — so
235 + // without the logged-in test a visitor could still pay for the rebuild
236 + // this method documents as never being theirs to pay for. WP-CLI has no
237 + // user, and is trusted by definition.
238 + $eligible = (defined('WP_CLI') && WP_CLI)
239 + || (
240 + is_user_logged_in()
241 + && (
242 + is_admin()
243 + || (defined('REST_REQUEST') && REST_REQUEST)
244 + )
245 + );
246 +
247 + if (!$eligible) {
248 + return;
249 + }
250 +
251 + // Cheap autoloaded-option read, so the vast majority of requests stop
252 + // here without building the generator.
253 + if (!ThinkRank\SEO\Sitemap_Generator::has_overdue_regeneration()) {
254 + return;
255 + }
256 +
257 + // `shutdown` runs after the output buffers are flushed, but flushed is
258 + // not delivered: on FPM the connection stays open until the process
259 + // ends, so without this the browser — or the REST call the sitemap
260 + // screen just made — waits out the whole generation. Hand the response
261 + // back first, then rebuild.
262 + $this->close_request();
263 +
264 + // Read-only construction: the auto-generation hooks are pointless this
265 + // late in the request and would only add duplicate callbacks.
266 + (new ThinkRank\SEO\Sitemap_Generator(false))->run_overdue_regeneration();
267 + }
268 +
269 + /**
270 + * Deliver the response and let the request keep working without the client.
271 + *
272 + * A no-op on SAPIs that cannot do it, where the caller simply pays for the
273 + * work as before.
274 + *
275 + * @since 2.2.1
276 + * @return void
277 + */
278 + private function close_request(): void {
279 + if (defined('WP_CLI') && WP_CLI) {
280 + return;
281 + }
282 +
283 + if (function_exists('fastcgi_finish_request')) {
284 + fastcgi_finish_request();
285 + return;
286 + }
287 +
288 + if (function_exists('litespeed_finish_request')) {
289 + litespeed_finish_request();
290 + }
291 + }
292 +
293 + /**
294 + * Unschedule the cron events left behind by the removed Brand Visibility
295 + * feature.
296 + *
297 + * A site that ran a Brand Visibility check can still carry its drain tick
298 + * and watchdog. Their callbacks, and the watchdog's custom recurrence, no
299 + * longer exist, so the events would only fire into nothing.
300 + * wp_unschedule_hook() walks the autoloaded cron array and writes nothing
301 + * when the hook is absent, so this costs nothing once they are gone.
302 + * Deactivation and uninstall clear the same hooks (cleanup-manifest.php).
303 + *
304 + * @return void
305 + */
306 + private function clear_retired_brand_visibility_cron(): void {
307 + foreach (['thinkrank_bv_tick', 'thinkrank_bv_watchdog'] as $hook) {
308 + wp_unschedule_hook($hook);
309 + }
310 + }
311 +
312 + /**
313 + * Check if database schema needs updating and run migrations
314 + *
315 + * Standard WordPress pattern: compare stored db_version against current,
316 + * run dbDelta if stale. This handles schema changes (new tables, new columns)
317 + * without requiring plugin deactivation/reactivation.
318 + *
319 + * @since 1.10.0
320 + * @return void
321 + */
322 + private function maybe_update_database(): void {
323 + $schema = new ThinkRank\Database\Database_Schema();
324 + if ($schema->needs_update()) {
325 + $schema->create_tables();
326 + }
327 + }
328 +
329 + /**
152 330 * Load plugin components (Dependency Injection Container pattern)
153 331 *
154 332 * @return void
155 333 */
@@ -154,22 +332,57 @@
154 332 * @return void
155 333 */
156 334 private function load_components(): void {
157 335 $this->components = [
158 - 'database' => new ThinkRank\Core\Database(),
159 - 'settings' => new ThinkRank\Core\Settings(),
336 + 'database' => new ThinkRank\Core\Database(),
337 + // Settings::instance() rather than a fresh object: the container
338 + // and the singleton were otherwise two different Settings, each
339 + // with its own memo, because instance() resolves the container and
340 + // this array is assigned only after every constructor above has
341 + // run. Anything resolving the singleton during that window got a
342 + // standalone instance that outlived the request, so a hook
343 + // registered from the component's init() ran against an object no
344 + // consumer read through (#516).
345 + 'settings' => ThinkRank\Core\Settings::instance(),
346 + 'role_manager' => new ThinkRank\Core\Role_Manager(),
160 347 'security_headers' => new ThinkRank\Core\Security_Headers(),
161 348 'asset_optimizer' => new ThinkRank\Core\Asset_Optimizer(),
162 - 'api' => new ThinkRank\API\Manager(),
163 - 'admin' => new ThinkRank\Admin\Manager(),
164 - 'ai' => new ThinkRank\AI\Manager(),
349 + 'usage_tracker' => new ThinkRank\Core\Usage_Tracker_Manager(),
350 + 'api' => new ThinkRank\API\Manager(),
351 + 'admin' => new ThinkRank\Admin\Manager(),
352 + 'blocks' => new ThinkRank\Editor\Blocks_Manager(),
353 + 'elementor' => new ThinkRank\Editor\Elementor_Manager(),
354 + 'bricks_elements' => new ThinkRank\Editor\Bricks_Elements_Manager(),
355 + 'beaver_modules' => new ThinkRank\Editor\Beaver_Modules_Manager(),
356 + 'ai' => new ThinkRank\AI\Manager(),
165 357 'frontend_seo' => new ThinkRank\Frontend\SEO_Manager(),
166 - 'seo_notice' => new ThinkRank\Admin\SEO_Notice(),
167 - 'integrations' => new ThinkRank\Integrations\Manager(),
358 + 'seo_notice' => new ThinkRank\Admin\SEO_Notice(),
359 + 'search_visibility_notice' => new ThinkRank\Admin\Search_Visibility_Notice(),
360 + 'webroot_writable_notice' => new ThinkRank\Admin\Webroot_Writable_Notice(),
168 361 'performance_collector' => new ThinkRank\SEO\Performance_Data_Collector(),
362 + 'query_guard' => new ThinkRank\SEO\Query_Guard(),
363 + 'feeds' => new ThinkRank\SEO\Feed_Manager(),
364 + 'sitemap_stylesheet' => new ThinkRank\SEO\Sitemap_Stylesheet(),
365 + 'oembed' => new ThinkRank\SEO\Oembed_Manager(),
366 + 'content_visibility' => new ThinkRank\SEO\Content_Visibility(),
367 + 'instant_indexing' => new ThinkRank\SEO\Instant_Indexing_Manager(),
368 + 'instant_indexing_reconciler' => new ThinkRank\SEO\Instant_Indexing_Reconciler(),
369 + 'author_archives' => new ThinkRank\SEO\Author_Archives_Manager(),
370 + 'seo_analyzer' => new ThinkRank\SEO\SEO_Analyzer(),
371 + // Bulk Snippets' persisted issue index: invalidation hooks must run
372 + // on every request, since posts are edited everywhere but there.
373 + 'snippet_index' => new ThinkRank\SEO\Snippet_Index(),
374 + 'email_report' => new ThinkRank\SEO\Email_Report_Manager(),
375 + 'google_oauth' => new ThinkRank\Integrations\Google_OAuth_Proxy(),
376 + 'multilingual' => new ThinkRank\Integrations\Multilingual_Manager(),
377 + 'ai_traffic' => new ThinkRank\SEO\Ai_Traffic_Tracker(),
378 + 'analytics' => new ThinkRank\SEO\Analytics_Manager(),
379 + 'schema_conflict_health_check' => new ThinkRank\Diagnostics\Schema_Conflict_Health_Check(),
380 + 'abilities' => new ThinkRank\Abilities\Abilities_Registrar(),
381 + 'mcp' => new ThinkRank\Mcp\Mcp_Manager(),
169 382 ];
170 383 }
171 -
384 +
172 385 /**
173 386 * Initialize all components
174 387 *
175 388 * @return void
@@ -189,12 +402,12 @@
189 402 */
190 403 private function load_template_functions(): void {
191 404 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/template-functions.php';
192 405 }
193 -
406 +
194 407 /**
195 408 * Get component instance
196 - *
409 + *
197 410 * @param string $component Component name
198 411 * @return object|null
199 412 */
200 413 public function get_component(string $component): ?object {
@@ -199,10 +412,47 @@
199 412 */
200 413 public function get_component(string $component): ?object {
201 414 return $this->components[$component] ?? null;
202 415 }
203 -
416 +
204 417 /**
418 + * Shared Analytics Manager instance (lazy)
419 + *
420 + * @var ThinkRank\SEO\Analytics_Manager|null
421 + */
422 + private ?ThinkRank\SEO\Analytics_Manager $analytics_manager = null;
423 +
424 + /**
425 + * Get the shared Analytics Manager, with Google clients initialized.
426 + *
427 + * Consumers (including the Pro plugin, which probes for this accessor)
428 + * should use this instead of constructing their own Analytics_Manager:
429 + * each fresh construction re-reads/decrypts settings and re-initializes
430 + * the Google API clients.
431 + *
432 + * @since 1.18.0
433 + * @return ThinkRank\SEO\Analytics_Manager
434 + */
435 + public function get_analytics_manager(): ThinkRank\SEO\Analytics_Manager {
436 + if ($this->analytics_manager === null) {
437 + // Reuse the registered component. Constructing a second instance
438 + // here would work, but only the registered one has had init() run
439 + // on it, so the token-refresh cron would be scheduled against a
440 + // different object than the one callers actually use.
441 + $component = $this->components['analytics'] ?? null;
442 +
443 + $this->analytics_manager = $component instanceof ThinkRank\SEO\Analytics_Manager
444 + ? $component
445 + : new ThinkRank\SEO\Analytics_Manager();
446 +
447 + // init() defers initialize_clients() to the `init` hook; callers
448 + // that arrive earlier still need working clients.
449 + $this->analytics_manager->initialize_clients();
450 + }
451 + return $this->analytics_manager;
452 + }
453 +
454 + /**
205 455 * Plugin activation
206 456 *
207 457 * @return void
208 458 */
@@ -209,12 +459,11 @@
209 459 public function activate(): void {
210 460 try {
211 461 $activator = new ThinkRank\Core\Activator();
212 462 $activator->activate();
213 -
463 +
214 464 // Flush rewrite rules
215 465 flush_rewrite_rules();
216 -
217 466 } catch (\Exception $e) {
218 467 $this->handle_error($e);
219 468 wp_die(
220 469 esc_html__('ThinkRank activation failed. Please check your server logs.', 'thinkrank'),
@@ -222,9 +471,9 @@
222 471 ['back_link' => true]
223 472 );
224 473 }
225 474 }
226 -
475 +
227 476 /**
228 477 * Plugin deactivation
229 478 *
230 479 * @return void
@@ -232,27 +481,40 @@
232 481 public function deactivate(): void {
233 482 try {
234 483 $deactivator = new ThinkRank\Core\Deactivator();
235 484 $deactivator->deactivate();
236 -
485 +
237 486 // Flush rewrite rules
238 487 flush_rewrite_rules();
239 -
240 488 } catch (\Exception $e) {
241 489 $this->handle_error($e);
242 490 }
243 491 }
244 -
492 +
245 493 /**
246 494 * Handle errors consistently
247 495 *
496 + * Logs errors and surfaces them via _doing_it_wrong() when
497 + * WP_DEBUG is enabled, helping developers diagnose issues.
498 + *
248 499 * @param \Exception $e Exception to handle
249 500 * @return void
250 501 */
251 502 private function handle_error(\Exception $e): void {
252 - // Error handling without debug output
503 + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
504 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
505 + error_log('ThinkRank: ' . $e->getMessage());
506 + }
507 +
508 + if (defined('WP_DEBUG') && WP_DEBUG) {
509 + _doing_it_wrong(
510 + __METHOD__,
511 + esc_html($e->getMessage()),
512 + esc_html(THINKRANK_VERSION)
513 + );
514 + }
253 515 }
254 -
516 +
255 517 /**
256 518 * Get plugin version
257 519 *
258 520 * @return string
@@ -259,9 +521,9 @@
259 521 */
260 522 public function get_version(): string {
261 523 return THINKRANK_VERSION;
262 524 }
263 -
525 +
264 526 /**
265 527 * Get plugin directory path
266 528 *
267 529 * @return string
@@ -268,9 +530,9 @@
268 530 */
269 531 public function get_plugin_dir(): string {
270 532 return THINKRANK_PLUGIN_DIR;
271 533 }
272 -
534 +
273 535 /**
274 536 * Get plugin URL
275 537 *
276 538 * @return string
@@ -284,8 +546,9 @@
284 546 * Initialize the plugin
285 547 *
286 548 * @return ThinkRank
287 549 */
550 +// phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed -- plugin bootstrap: the accessor belongs next to the class it returns.
288 551 function thinkrank(): ThinkRank {
289 552 return ThinkRank::get_instance();
290 553 }
291 554