PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.2
2.14.2 2.14.1 2.14.0 2.13.0 2.12.0 2.11.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 All 57 releases
thinkrank / uninstall.php

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

417 lines 17.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Plugin Uninstall Script
4 *
5 * Handles complete plugin removal and cleanup
6 *
7 * @package ThinkRank
8 * @since 1.0.0
9 */
10
11 declare(strict_types=1);
12
13 // Prevent direct access
14 if (!defined('WP_UNINSTALL_PLUGIN')) {
15 exit;
16 }
17
18 /**
19 * ThinkRank Uninstaller Class
20 *
21 * Single Responsibility: Handle complete plugin removal
22 */
23 class ThinkRank_Uninstaller {
24
25 /**
26 * Option that records a deliberate uninstall.
27 *
28 * Keep in sync with ThinkRank\Core\Activator::UNINSTALLED_OPTION and with
29 * ThinkRank Pro's Free_Plugin_Installer.
30 */
31 private const UNINSTALLED_OPTION = 'thinkrank_uninstalled';
32
33 /**
34 * Run uninstall process
35 *
36 * @return void
37 */
38 public static function uninstall(): void {
39 // Everything below resolves through $wpdb->prefix and $wpdb->options,
40 // i.e. the current blog. On a network install WordPress runs this once,
41 // so every other site kept its tables, its options and its stored
42 // credentials — API keys and Google refresh tokens included (#399).
43 if (is_multisite()) {
44 $site_ids = get_sites([
45 'fields' => 'ids',
46 'number' => 0,
47 'update_site_meta_cache' => false,
48 ]);
49
50 foreach ($site_ids as $site_id) {
51 switch_to_blog((int) $site_id);
52 self::uninstall_site();
53 restore_current_blog();
54 }
55
56 return;
57 }
58
59 self::uninstall_site();
60 }
61
62 /**
63 * Remove everything ThinkRank created on the current blog.
64 *
65 * @return void
66 */
67 private static function uninstall_site(): void {
68 // Check if user wants to keep data. Default to TRUE (preserve) to match the
69 // Settings class default and the documented UI behavior — only nuke data when
70 // the user has explicitly enabled "Delete all data on uninstall".
71 $keep_data = (bool) get_option('thinkrank_keep_data_on_uninstall', true);
72
73 // Files first, and regardless of $keep_data.
74 //
75 // Ordering: every ownership test in there reads state the database
76 // cleanup below is about to destroy — sitemap filenames come from the
77 // settings table, the IndexNow key file's name from an option. Run it
78 // after, and there is nothing left to identify our files by.
79 //
80 // Not gated on $keep_data because these are not the user's data. They
81 // are artifacts of a plugin that is being removed, and leaving them is
82 // actively harmful: a real file at /sitemap_index.xml is served by the
83 // web server before WordPress boots, so it goes on shadowing RankMath,
84 // Squirrly or core's own sitemap indefinitely — while rendering blank,
85 // since the XSL stylesheet it points at leaves with the plugin (#510).
86 // "Keep my data" means keep the settings for a reinstall, not keep a
87 // dead file breaking the next plugin. On reinstall they are republished
88 // from the settings that were kept.
89 self::remove_webroot_files();
90
91 if (!$keep_data) {
92 self::remove_database_tables();
93 self::remove_options();
94 self::remove_user_meta();
95 self::remove_post_meta();
96 self::remove_term_meta();
97 }
98
99 self::clear_caches();
100 self::remove_cron_jobs();
101 self::remove_capabilities();
102 self::mark_uninstalled();
103 }
104
105 /**
106 * Delete every file ThinkRank published into the WordPress web root.
107 *
108 * The sitemap, robots.txt, llms.txt and the IndexNow key file are real files
109 * in `ABSPATH`, not routes, so removing the plugin has to remove them too —
110 * otherwise the web server keeps serving a sitemap belonging to a plugin
111 * that is no longer installed, and the next SEO plugin's own sitemap can
112 * never answer (#510).
113 *
114 * @since 2.1.0
115 *
116 * @return void
117 */
118 private static function remove_webroot_files(): void {
119 // No autoloader here — WP_UNINSTALL_PLUGIN loads this file without the
120 // plugin — so the removal logic comes from the same plain function file
121 // the deactivator and the sitemap generator use. One copy, not three.
122 $shared = __DIR__ . '/includes/cleanup-webroot.php';
123
124 if (!is_readable($shared)) {
125 return;
126 }
127
128 require_once $shared;
129
130 if (function_exists('thinkrank_webroot_cleanup')) {
131 thinkrank_webroot_cleanup();
132 }
133 }
134
135 /**
136 * The shared cleanup manifest.
137 *
138 * There is no autoloader here — WP_UNINSTALL_PLUGIN loads this file without
139 * the plugin — so the cron-hook and capability lists come from a plain
140 * array file that the deactivator reads too.
141 *
142 * @return array{cron_hooks: string[], capabilities: string[]}
143 */
144 private static function manifest(): array {
145 return require __DIR__ . '/includes/cleanup-manifest.php';
146 }
147
148 /**
149 * Record that the user deliberately uninstalled the plugin.
150 *
151 * ThinkRank Pro auto-installs and activates the free plugin whenever it
152 * finds it missing, which silently re-ran the activator and recreated every
153 * table this uninstall had just dropped. Pro reads this marker and stops
154 * auto-installing, falling back to its "Install ThinkRank" notice, so a
155 * deliberate removal stays removed until the user asks for it back.
156 *
157 * Written last, after remove_options() has wiped the `thinkrank_` namespace,
158 * and cleared again by Activator on the next activation.
159 *
160 * @return void
161 */
162 private static function mark_uninstalled(): void {
163 delete_option(self::UNINSTALLED_OPTION);
164 add_option(self::UNINSTALLED_OPTION, time(), '', 'no');
165 }
166
167 /**
168 * Remove custom database tables
169 *
170 * @return void
171 */
172 private static function remove_database_tables(): void {
173 global $wpdb;
174
175 $tables = [
176 // AI/Core Tables (from Database class)
177 $wpdb->prefix . 'thinkrank_ai_cache',
178 $wpdb->prefix . 'thinkrank_ai_usage',
179 $wpdb->prefix . 'thinkrank_content_briefs',
180 $wpdb->prefix . 'thinkrank_seo_scores',
181 $wpdb->prefix . 'thinkrank_seo_performance',
182 $wpdb->prefix . 'thinkrank_instant_indexing_logs',
183 // SEO Tables (from Database_Schema)
184 $wpdb->prefix . 'thinkrank_seo_settings',
185 $wpdb->prefix . 'thinkrank_seo_analysis',
186 $wpdb->prefix . 'thinkrank_seo_keywords',
187 $wpdb->prefix . 'thinkrank_seo_schema',
188 $wpdb->prefix . 'thinkrank_seo_social',
189 $wpdb->prefix . 'thinkrank_seo_local',
190 $wpdb->prefix . 'thinkrank_email_report_logs',
191 // AI Visibility Tables. These were registered in Database_Schema but
192 // never listed here, so an uninstall left them behind — bv_tasks in
193 // particular holds the full text of every AI answer (#302). The
194 // three Brand Visibility tables are no longer created, but sites
195 // that used the feature before it was removed still have them.
196 $wpdb->prefix . 'thinkrank_ai_traffic',
197 $wpdb->prefix . 'thinkrank_brand_visibility_checks',
198 $wpdb->prefix . 'thinkrank_bv_runs',
199 $wpdb->prefix . 'thinkrank_bv_tasks',
200 // Rank Tracker, Redirections, Broken Links and Local SEO tables are
201 // created and dropped by ThinkRank Pro's own uninstaller. Free used to
202 // drop them here, which destroyed a still-active Pro install's data
203 // when only the free plugin was removed (#384).
204 ];
205
206 foreach ($tables as $table) {
207 // Check WordPress version for %i support (introduced in 6.2)
208 if (version_compare($GLOBALS['wp_version'], '6.2', '>=')) {
209 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnsupportedIdentifierPlaceholder,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
210 $wpdb->query($wpdb->prepare("DROP TABLE IF EXISTS %i", $table));
211 // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnsupportedIdentifierPlaceholder,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
212 } else {
213 // Fallback for older WordPress versions - table name is from our controlled list
214 $escaped_table = esc_sql($table);
215 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
216 $wpdb->query("DROP TABLE IF EXISTS `{$escaped_table}`");
217 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
218 }
219 }
220 }
221
222 /**
223 * Remove plugin options
224 *
225 * @return void
226 */
227 private static function remove_options(): void {
228 // Remove all ThinkRank options using wildcard delete for completeness.
229 //
230 // `thinkrank_%` also matches `thinkrank_pro_%`, so this used to delete a
231 // still-active Pro install's license key and every module setting (#384).
232 // Pro ships its own uninstaller and owns that namespace exclusively, so
233 // every pattern here excludes it.
234 global $wpdb;
235 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Uninstall cleanup requires direct database access to remove all plugin options
236 $wpdb->query(
237 $wpdb->prepare(
238 "DELETE FROM {$wpdb->options}
239 WHERE (option_name LIKE %s AND option_name NOT LIKE %s)
240 OR option_name LIKE %s",
241 $wpdb->esc_like('thinkrank_') . '%',
242 $wpdb->esc_like('thinkrank_pro_') . '%',
243 // Appsero / WP Insights telemetry options (wpins_thinkrank_*)
244 $wpdb->esc_like('wpins_thinkrank_') . '%'
245 )
246 );
247 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
248
249 // Remove transients (prefixed with _transient_, not caught by options wildcard)
250 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Core table name is safe, uninstall cleanup requires direct database access
251 $wpdb->query(
252 $wpdb->prepare(
253 "DELETE FROM {$wpdb->options}
254 WHERE (option_name LIKE %s AND option_name NOT LIKE %s)
255 OR (option_name LIKE %s AND option_name NOT LIKE %s)",
256 $wpdb->esc_like('_transient_thinkrank_') . '%',
257 $wpdb->esc_like('_transient_thinkrank_pro_') . '%',
258 $wpdb->esc_like('_transient_timeout_thinkrank_') . '%',
259 $wpdb->esc_like('_transient_timeout_thinkrank_pro_') . '%'
260 )
261 );
262 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
263
264 // Names the wildcards above cannot reach (#918).
265 //
266 // The usage tracker stores the deactivation survey under
267 // `wpins_deactivation_{reason,details}_{plugin}`, which `wpins_thinkrank_%`
268 // misses. Named exactly: widening to `wpins_%` would delete the state of
269 // every other plugin that ships the same telemetry SDK.
270 foreach (['wpins_deactivation_reason_thinkrank', 'wpins_deactivation_details_thinkrank'] as $option) {
271 delete_option($option);
272 }
273
274 // Search Console caches that versions before 2.14.2 wrote under unprefixed
275 // names (2.14.2 moved them to the `thinkrank_` prefix, see #918).
276 // They expire within three days of the upgrade, but uninstalling inside
277 // that window would otherwise leave the site's search queries behind.
278 foreach (['7d', '30d', '90d', '14d', '60d', '180d'] as $range) {
279 delete_transient('analytics_dashboard_v5_' . $range);
280 }
281 foreach (['7d', '30d', '90d'] as $range) {
282 delete_transient('seo_opportunities_' . $range);
283 }
284 }
285
286 /**
287 * Remove user meta data
288 *
289 * @return void
290 */
291 private static function remove_user_meta(): void {
292 global $wpdb;
293
294 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Core table name is safe, uninstall cleanup requires direct database access
295 $wpdb->query(
296 $wpdb->prepare(
297 "DELETE FROM {$wpdb->usermeta}
298 WHERE meta_key LIKE %s
299 OR meta_key LIKE %s",
300 $wpdb->esc_like('thinkrank_') . '%',
301 $wpdb->esc_like('_thinkrank_') . '%'
302 )
303 );
304 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
305 }
306
307 /**
308 * Remove post meta data
309 *
310 * @return void
311 */
312 private static function remove_post_meta(): void {
313 global $wpdb;
314
315 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Core table name is safe, uninstall cleanup requires direct database access
316 $wpdb->query(
317 $wpdb->prepare(
318 "DELETE FROM {$wpdb->postmeta}
319 WHERE meta_key LIKE %s
320 OR meta_key LIKE %s",
321 $wpdb->esc_like('thinkrank_') . '%',
322 $wpdb->esc_like('_thinkrank_') . '%'
323 )
324 );
325 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
326 }
327
328
329 /**
330 * Remove term meta data
331 *
332 * The plugin writes term meta from the term UI, the abilities API
333 * (Update_Term_Seo) and the importer (Snapshot_Migrator) — SEO title,
334 * meta description, the robots payload and the `_thinkrank_imported_from`
335 * marker — and there was no counterpart to remove_post_meta(), so all of
336 * it outlived the plugin (#399).
337 *
338 * @return void
339 */
340 private static function remove_term_meta(): void {
341 global $wpdb;
342
343 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Core table name is safe, uninstall cleanup requires direct database access
344 $wpdb->query(
345 $wpdb->prepare(
346 "DELETE FROM {$wpdb->termmeta}
347 WHERE meta_key LIKE %s
348 OR meta_key LIKE %s",
349 $wpdb->esc_like('thinkrank_') . '%',
350 $wpdb->esc_like('_thinkrank_') . '%'
351 )
352 );
353 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
354 }
355
356 /**
357 * Clear all caches
358 *
359 * @return void
360 */
361 private static function clear_caches(): void {
362 // Clear ThinkRank-specific object cache group
363 if (function_exists('wp_cache_flush_group')) {
364 wp_cache_flush_group('thinkrank');
365 }
366 }
367
368 /**
369 * Remove scheduled cron jobs
370 *
371 * @return void
372 */
373 private static function remove_cron_jobs(): void {
374 // Was 2 hooks against the 15 the plugin schedules, and weaker than the
375 // deactivator's 5 — so uninstalling without deactivating first left
376 // even more behind (#389).
377 foreach (self::manifest()['cron_hooks'] as $job) {
378 wp_clear_scheduled_hook($job);
379 }
380 }
381
382 /**
383 * Remove custom capabilities
384 *
385 * @return void
386 */
387 private static function remove_capabilities(): void {
388 // Runs even when the user keeps their data, so the capability-sync marker
389 // has to go with it. Leaving it behind would make Capability_Manager::ensure()
390 // short-circuit on the next install and never re-grant thinkrank_access,
391 // locking administrators out of the admin menu.
392 delete_option('thinkrank_caps_version');
393
394 // From the shared manifest. The hand-mirrored copy that used to live
395 // here had drifted from Capability_Manager::capabilities() by four
396 // slugs — thinkrank_ai_insights, thinkrank_redirections,
397 // thinkrank_broken_links and thinkrank_woocommerce were still granted
398 // to every role after an uninstall (#399). The manifest also carries
399 // the pre-Role-Manager slugs so historical roles are fully cleaned.
400 $capabilities = self::manifest()['capabilities'];
401
402 $roles = wp_roles();
403
404 foreach ($roles->roles as $role_name => $role_info) {
405 $role = get_role($role_name);
406 if ($role) {
407 foreach ($capabilities as $cap) {
408 $role->remove_cap($cap);
409 }
410 }
411 }
412 }
413 }
414
415 // Run the uninstaller
416 ThinkRank_Uninstaller::uninstall();
417