PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
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 1.11.0 All 47 releases
thinkrank / includes / core / class-activator.php

class-activator.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/core/class-activator.php

368 lines 13.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Plugin Activator Class
5 *
6 * Handles plugin activation tasks
7 *
8 * @package ThinkRank\Core
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Core;
15
16 use ThinkRank\Database\Database_Schema;
17
18 // Prevent direct access
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Activator Class
25 *
26 * Single Responsibility: Handle plugin activation tasks only
27 *
28 * @since 1.0.0
29 */
30 class Activator {
31
32 /**
33 * Option the uninstaller sets to record a deliberate removal.
34 *
35 * Pro's Free_Plugin_Installer skips its silent auto-install while this is
36 * set, so activating again — the user asking for the plugin back — has to
37 * clear it. Keep in sync with uninstall.php.
38 */
39 public const UNINSTALLED_OPTION = 'thinkrank_uninstalled';
40
41 /**
42 * Plugin activation tasks
43 *
44 * @return void
45 * @throws \Exception If activation fails
46 */
47 public function activate(): void {
48 delete_option(self::UNINSTALLED_OPTION);
49
50 $this->check_requirements();
51 $this->create_database_tables();
52 // Must precede set_default_options(): it reads `thinkrank_version`,
53 // which that method creates.
54 $this->retire_sitemap_legacy_fallback();
55 $this->set_default_options();
56 $this->setup_indexnow_key();
57 $this->schedule_cron_jobs();
58 $this->restore_webroot_artifacts();
59 $this->set_activation_flag();
60
61 // Grant the admin capabilities here rather than waiting for the `init`
62 // hook Role_Manager registers, so the menu is reachable on the very
63 // first admin request after activation.
64 Capability_Manager::ensure();
65 }
66
67 /**
68 * Setup IndexNow API Key
69 *
70 * Generates a unique 128-bit key and creates the key file in the root directory.
71 *
72 * @return void
73 */
74 private function setup_indexnow_key(): void {
75 $option_name = 'thinkrank_instant_indexing_settings';
76 $settings = get_option($option_name, []);
77
78 // Check if key exists
79 if (empty($settings['api_key'])) {
80 try {
81 // Generate 128-bit key (32 hex characters)
82 // Using bin2hex(random_bytes(16)) as requested
83 $key = bin2hex(random_bytes(16));
84
85 // Save to options
86 $settings['api_key'] = $key;
87
88 // Initialize default post types if not set
89 if (!isset($settings['auto_submit_post_types'])) {
90 $settings['auto_submit_post_types'] = ['post', 'page'];
91 }
92
93 update_option($option_name, $settings);
94
95 // Create the key file in WordPress root using WP_Filesystem
96 $file_path = ABSPATH . $key . '.txt';
97 global $wp_filesystem;
98 if (!function_exists('WP_Filesystem')) {
99 require_once ABSPATH . 'wp-admin/includes/file.php';
100 }
101 WP_Filesystem();
102 if ($wp_filesystem && $wp_filesystem->is_writable(ABSPATH)) {
103 $wp_filesystem->put_contents($file_path, $key, FS_CHMOD_FILE);
104 }
105 } catch (\Exception $e) {
106 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
107 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
108 error_log('ThinkRank: Failed to create IndexNow key file: ' . $e->getMessage());
109 }
110 }
111 }
112 }
113
114 /**
115 * Check system requirements
116 *
117 * @return void
118 * @throws \Exception If requirements not met
119 */
120 private function check_requirements(): void {
121 // PHP version check
122 if (version_compare(PHP_VERSION, '7.4', '<')) {
123 throw new \Exception('ThinkRank requires PHP 7.4 or higher');
124 }
125
126 // WordPress version check
127 if (version_compare(get_bloginfo('version'), '6.0', '<')) {
128 throw new \Exception('ThinkRank requires WordPress 6.0 or higher');
129 }
130
131 // Required PHP extensions
132 $required_extensions = ['curl', 'json', 'mbstring'];
133 foreach ($required_extensions as $extension) {
134 if (!extension_loaded($extension)) {
135 throw new \Exception(sprintf("Required PHP extension '%s' is not loaded", esc_html($extension)));
136 }
137 }
138
139 // Check if we can write to WordPress root directory (for robots.txt, llms.txt, sitemaps)
140 if (!wp_is_writable(ABSPATH)) {
141 // Log warning but don't block activation — some hosts restrict ABSPATH writes
142 // and file-writing features will gracefully degrade via WP_Filesystem checks
143 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
144 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
145 error_log('ThinkRank: WordPress root directory is not writable. Some features (robots.txt, llms.txt, sitemaps) may not work.');
146 }
147 }
148 }
149
150 /**
151 * Create database tables
152 *
153 * Uses the consolidated Database_Schema class to create all 11 ThinkRank tables:
154 * - SEO Tables (7): Settings, Analysis, Keywords, Schema, Social, Performance, Local
155 * - AI/Core Tables (4): AI Cache, AI Usage, Content Briefs, SEO Scores
156 *
157 * @return void
158 * @throws \Exception If table creation fails
159 */
160 private function create_database_tables(): void {
161 try {
162 // Use the Database_Schema class to create all 11 ThinkRank tables
163 $schema = new Database_Schema();
164 $results = $schema->create_tables();
165
166 if (!$results['success']) {
167 $error_message = 'Failed to create database tables: ' . implode(', ', $results['errors']);
168 throw new \Exception($error_message);
169 }
170
171 // Add performance indexes for Phase 2 optimization
172 // Best effort: activation continues if the performance indexes
173 // cannot be created.
174 $schema->add_performance_indexes();
175
176 // Database tables created successfully
177
178 } catch (\Exception $e) {
179 throw new \Exception('Database table creation failed: ' . esc_html($e->getMessage()));
180 }
181 }
182
183
184
185 /**
186 * On a brand-new install, close the pre-2.1.1 sitemap ownership fallback
187 * before it can ever open.
188 *
189 * {@see thinkrank_webroot_sitemap_is_ours()} keeps one narrow escape hatch:
190 * a sitemap written before 2.1.1 with `enable_styling` off carries neither
191 * the marker nor our XSL href, so it can only be recognised by the name the
192 * stored settings derive. That fallback is gated on this install never
193 * having written a marked sitemap — but "never written one" describes two
194 * completely different sites:
195 *
196 * - a pre-2.1.1 install that has not regenerated since upgrading, which
197 * is exactly what the fallback exists to recover; and
198 * - a fresh install that simply has not generated yet, which cannot have
199 * a legacy file of ours on disk at all.
200 *
201 * On the second, the fallback has nothing to recover and can only delete
202 * somebody else's sitemap from one of the canonical names — #515 again, in
203 * a site that never had the problem the fallback addresses. It is not a
204 * narrow window either: `regenerate_sitemap_from_settings()` returns early
205 * while the master `enabled` flag is off, so a site with sitemaps disabled
206 * and styling saved off never records a marked write, and stays exposed for
207 * as long as it stays in that configuration.
208 *
209 * Recording the marker here on a fresh install separates the two cases. An
210 * upgrade does not reach this code — WordPress does not re-run the
211 * activation hook on update — so a genuine pre-2.1.1 site keeps the
212 * fallback until its first marked write, exactly as before.
213 *
214 * `thinkrank_version` is the signal: set_default_options() adds it only
215 * when absent and never updates it, so it is missing on the very first
216 * activation and present on every one after.
217 *
218 * @since 2.1.1
219 *
220 * @return void
221 */
222 private function retire_sitemap_legacy_fallback(): void {
223 if (get_option('thinkrank_version') !== false) {
224 return;
225 }
226
227 require_once THINKRANK_PLUGIN_DIR . 'includes/cleanup-webroot.php';
228
229 add_option(THINKRANK_SITEMAP_MARKED_WRITE_OPTION, '1', '', false);
230 }
231
232 /**
233 * Set default plugin options
234 *
235 * @return void
236 */
237 private function set_default_options(): void {
238 $default_options = [
239 'thinkrank_version' => THINKRANK_VERSION,
240
241 'thinkrank_ai_provider' => \ThinkRank\Core\Settings::AI_PROVIDER_NONE,
242 'thinkrank_cache_duration' => 3600, // 1 hour
243 'thinkrank_max_requests_per_minute' => 10,
244 'thinkrank_enable_logging' => true,
245 'thinkrank_auto_optimize' => false,
246 'thinkrank_seo_score_threshold' => 70,
247 ];
248
249 foreach ($default_options as $option_name => $option_value) {
250 if (get_option($option_name) === false) {
251 add_option($option_name, $option_value);
252 }
253 }
254 }
255
256
257 /**
258 * Republish the web-root artifacts deactivation took away.
259 *
260 * Deactivation removes the published sitemap, robots.txt and llms.txt so an
261 * inactive ThinkRank stops shadowing whatever the user switched to (#510).
262 * That is only safe if switching the plugin back on puts them back, which is
263 * what this does.
264 *
265 * Restores strictly what {@see Deactivator::REPUBLISH_OPTION} recorded as
266 * having been removed — never "everything the settings would allow", which
267 * on a fresh install would publish files the site never had.
268 *
269 * The sitemap goes through schedule_regeneration() rather than being built
270 * inline: a full rebuild on a large site is far too slow to sit inside an
271 * activation request, and the debounced hook already respects the master
272 * `enabled` flag. robots.txt and llms.txt are single small writes, so they
273 * happen here.
274 *
275 * @since 2.1.0
276 *
277 * @return void
278 */
279 private function restore_webroot_artifacts(): void {
280 $republish = get_option(Deactivator::REPUBLISH_OPTION, null);
281
282 if ($republish === null) {
283 // No recorded deactivation — a first install, or an activation that
284 // already consumed the record.
285 return;
286 }
287
288 // Consume it first. A restore that fatals must not re-run on every
289 // subsequent activation, and each entry below is independently guarded.
290 delete_option(Deactivator::REPUBLISH_OPTION);
291
292 if (!is_array($republish)) {
293 return;
294 }
295
296 try {
297 if (in_array('sitemap', $republish, true) && class_exists('ThinkRank\\SEO\\Sitemap_Generator')) {
298 // Read-only instance: the hook-registering one would bind a
299 // second set of content-change listeners to this request.
300 (new \ThinkRank\SEO\Sitemap_Generator(false))->schedule_regeneration();
301 }
302
303 if (in_array('robots', $republish, true) && class_exists('ThinkRank\\SEO\\Site_Identity_Manager')) {
304 (new \ThinkRank\SEO\Site_Identity_Manager())->sync_robots_txt_file();
305 }
306
307 if (in_array('llms', $republish, true) && class_exists('ThinkRank\\SEO\\LLMs_Txt_Manager')) {
308 $llms = new \ThinkRank\SEO\LLMs_Txt_Manager();
309 $content = $llms->get_published_content();
310
311 // write_llms_txt_to_file() enforces the enabled toggle and the
312 // delivery mode itself, so an empty document is the only case
313 // worth short-circuiting here.
314 if ($content !== '') {
315 $llms->write_llms_txt_to_file($content);
316 }
317 }
318 } catch (\Throwable $e) {
319 // A failed republish must not block activation — the user would be
320 // left unable to switch the plugin on at all. The artifacts rebuild
321 // on the next content or settings save.
322 if (defined('WP_DEBUG') && WP_DEBUG) {
323 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
324 error_log('ThinkRank: failed to restore web-root artifacts: ' . $e->getMessage());
325 }
326 }
327 }
328
329 /**
330 * Schedule cron jobs
331 *
332 * @return void
333 */
334 private function schedule_cron_jobs(): void {
335
336 // Schedule cache cleanup
337 if (!wp_next_scheduled('thinkrank_cache_cleanup')) {
338 wp_schedule_event(time(), 'daily', 'thinkrank_cache_cleanup');
339 }
340
341 // Schedule usage analytics
342 if (!wp_next_scheduled('thinkrank_usage_analytics')) {
343 wp_schedule_event(time(), 'weekly', 'thinkrank_usage_analytics');
344 }
345 }
346
347 /**
348 * Set activation flag for first-time setup
349 *
350 * @return void
351 */
352 private function set_activation_flag(): void {
353 update_option('thinkrank_activated', true);
354 update_option('thinkrank_activation_time', time());
355
356 // Set flag for showing welcome screen
357 update_option('thinkrank_show_welcome', true);
358
359 // Trigger a one-time redirect to the Setup Wizard on the next admin load,
360 // but only when the wizard has not already been completed. A short-lived
361 // transient is used so it auto-expires and never fires for bulk/network
362 // activations that skip the redirect window.
363 if (!get_option('thinkrank_setup_wizard_completed')) {
364 set_transient('thinkrank_setup_wizard_redirect', 1, 60);
365 }
366 }
367 }
368