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 / thinkrank.php

thinkrank.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.9.0, at thinkrank.php

557 lines 18.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Plugin Name: ThinkRank
5 * Plugin URI: https://thinkrank.ai/
6 * Description: AI-native SEO plugin for WordPress. Automate and enhance your SEO with cutting-edge AI while maintaining editorial control.
7 * Version: 2.9.0
8 * Author: WPDeveloper
9 * Author URI: https://wpdeveloper.com/
10 * License: GPL v2 or later
11 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
12 * Text Domain: thinkrank
13 * Domain Path: /languages
14 * Requires at least: 6.0
15 * Requires PHP: 7.4
16 *
17 * @package ThinkRank
18 * @version 2.9.0
19 * @since 1.0.0
20 */
21
22 declare(strict_types=1);
23
24 // Prevent direct access
25 if (!defined('ABSPATH')) {
26 exit;
27 }
28
29 // Define plugin constants
30 define('THINKRANK_VERSION', '2.9.0');
31 define('THINKRANK_PLUGIN_FILE', __FILE__);
32 define('THINKRANK_PLUGIN_DIR', plugin_dir_path(__FILE__));
33 define('THINKRANK_PLUGIN_URL', plugin_dir_url(__FILE__));
34 define('THINKRANK_PLUGIN_BASENAME', plugin_basename(__FILE__));
35
36 // Minimum requirements check
37 if (version_compare(PHP_VERSION, '7.4', '<')) {
38 add_action('admin_notices', function () {
39 echo '<div class="notice notice-error"><p>';
40 echo esc_html__('ThinkRank requires PHP 7.4 or higher. Please upgrade your PHP version.', 'thinkrank');
41 echo '</p></div>';
42 });
43 return;
44 }
45
46 if (version_compare(get_bloginfo('version'), '6.0', '<')) {
47 add_action('admin_notices', function () {
48 echo '<div class="notice notice-error"><p>';
49 echo esc_html__('ThinkRank requires WordPress 6.0 or higher. Please upgrade your WordPress installation.', 'thinkrank');
50 echo '</p></div>';
51 });
52 return;
53 }
54
55 // Autoloader
56 require_once THINKRANK_PLUGIN_DIR . 'includes/class-autoloader.php';
57
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 /**
74 * Main ThinkRank Plugin Class
75 *
76 * Follows Single Responsibility Principle - only handles plugin initialization
77 *
78 * @since 1.0.0
79 */
80 final class ThinkRank {
81
82 /**
83 * Plugin instance (Singleton Pattern)
84 *
85 * @var ThinkRank|null
86 */
87 private static ?ThinkRank $instance = null;
88
89 /**
90 * Plugin components
91 *
92 * @var array
93 */
94 private array $components = [];
95
96 /**
97 * Get plugin instance (Singleton Pattern)
98 *
99 * @return ThinkRank
100 */
101 public static function get_instance(): ThinkRank {
102 if (null === self::$instance) {
103 self::$instance = new self();
104 }
105 return self::$instance;
106 }
107
108 /**
109 * Private constructor to prevent direct instantiation
110 */
111 private function __construct() {
112 $this->init_autoloader();
113 $this->init_hooks();
114 }
115
116 /**
117 * Prevent cloning
118 */
119 private function __clone() {
120 }
121
122 /**
123 * Prevent unserialization
124 *
125 * @throws \Exception On failure.
126 */
127 public function __wakeup() {
128 throw new \Exception('Cannot unserialize singleton');
129 }
130
131 /**
132 * Initialize autoloader
133 *
134 * @return void
135 */
136 private function init_autoloader(): void {
137 ThinkRank\Core\Autoloader::register();
138 }
139
140 /**
141 * Initialize WordPress hooks
142 *
143 * @return void
144 */
145 private function init_hooks(): void {
146 register_activation_hook(__FILE__, [$this, 'activate']);
147 register_deactivation_hook(__FILE__, [$this, 'deactivate']);
148
149 add_action('plugins_loaded', [$this, 'init']);
150 add_filter('plugin_action_links_' . THINKRANK_PLUGIN_BASENAME, [$this, 'add_action_links']);
151 }
152
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 /**
172 * Initialize plugin components
173 *
174 * @return void
175 */
176 public function init(): void {
177 try {
178 $this->maybe_update_database();
179 $this->register_sitemap_cron_listeners();
180 $this->clear_retired_brand_visibility_cron();
181 $this->load_components();
182 $this->init_components();
183 $this->load_template_functions();
184
185 do_action('thinkrank_loaded');
186 } catch (\Exception $e) {
187 $this->handle_error($e);
188 }
189 }
190
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 /**
330 * Load plugin components (Dependency Injection Container pattern)
331 *
332 * @return void
333 */
334 private function load_components(): void {
335 $this->components = [
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(),
347 'security_headers' => new ThinkRank\Core\Security_Headers(),
348 'asset_optimizer' => new ThinkRank\Core\Asset_Optimizer(),
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(),
357 'frontend_seo' => new ThinkRank\Frontend\SEO_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(),
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(),
382 ];
383 }
384
385 /**
386 * Initialize all components
387 *
388 * @return void
389 */
390 private function init_components(): void {
391 foreach ($this->components as $component) {
392 if (method_exists($component, 'init')) {
393 $component->init();
394 }
395 }
396 }
397
398 /**
399 * Load template functions for themes
400 *
401 * @return void
402 */
403 private function load_template_functions(): void {
404 require_once THINKRANK_PLUGIN_DIR . 'includes/frontend/template-functions.php';
405 }
406
407 /**
408 * Get component instance
409 *
410 * @param string $component Component name
411 * @return object|null
412 */
413 public function get_component(string $component): ?object {
414 return $this->components[$component] ?? null;
415 }
416
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 /**
455 * Plugin activation
456 *
457 * @return void
458 */
459 public function activate(): void {
460 try {
461 $activator = new ThinkRank\Core\Activator();
462 $activator->activate();
463
464 // Flush rewrite rules
465 flush_rewrite_rules();
466 } catch (\Exception $e) {
467 $this->handle_error($e);
468 wp_die(
469 esc_html__('ThinkRank activation failed. Please check your server logs.', 'thinkrank'),
470 esc_html__('Plugin Activation Error', 'thinkrank'),
471 ['back_link' => true]
472 );
473 }
474 }
475
476 /**
477 * Plugin deactivation
478 *
479 * @return void
480 */
481 public function deactivate(): void {
482 try {
483 $deactivator = new ThinkRank\Core\Deactivator();
484 $deactivator->deactivate();
485
486 // Flush rewrite rules
487 flush_rewrite_rules();
488 } catch (\Exception $e) {
489 $this->handle_error($e);
490 }
491 }
492
493 /**
494 * Handle errors consistently
495 *
496 * Logs errors and surfaces them via _doing_it_wrong() when
497 * WP_DEBUG is enabled, helping developers diagnose issues.
498 *
499 * @param \Exception $e Exception to handle
500 * @return void
501 */
502 private function handle_error(\Exception $e): void {
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 }
515 }
516
517 /**
518 * Get plugin version
519 *
520 * @return string
521 */
522 public function get_version(): string {
523 return THINKRANK_VERSION;
524 }
525
526 /**
527 * Get plugin directory path
528 *
529 * @return string
530 */
531 public function get_plugin_dir(): string {
532 return THINKRANK_PLUGIN_DIR;
533 }
534
535 /**
536 * Get plugin URL
537 *
538 * @return string
539 */
540 public function get_plugin_url(): string {
541 return THINKRANK_PLUGIN_URL;
542 }
543 }
544
545 /**
546 * Initialize the plugin
547 *
548 * @return ThinkRank
549 */
550 // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed -- plugin bootstrap: the accessor belongs next to the class it returns.
551 function thinkrank(): ThinkRank {
552 return ThinkRank::get_instance();
553 }
554
555 // Start the plugin
556 thinkrank();
557