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
← All changes | uninstall.php +171 -47 1.30.0 → 2.14.2 View file →
@@ -35,18 +35,66 @@
35 35 *
36 36 * @return void
37 37 */
38 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 {
39 68 // Check if user wants to keep data. Default to TRUE (preserve) to match the
40 69 // Settings class default and the documented UI behavior — only nuke data when
41 70 // the user has explicitly enabled "Delete all data on uninstall".
42 71 $keep_data = (bool) get_option('thinkrank_keep_data_on_uninstall', true);
43 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 +
44 91 if (!$keep_data) {
45 92 self::remove_database_tables();
46 93 self::remove_options();
47 94 self::remove_user_meta();
48 95 self::remove_post_meta();
96 + self::remove_term_meta();
49 97 }
50 98
51 99 self::clear_caches();
52 100 self::remove_cron_jobs();
@@ -54,8 +102,51 @@
54 102 self::mark_uninstalled();
55 103 }
56 104
57 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 + /**
58 149 * Record that the user deliberately uninstalled the plugin.
59 150 *
60 151 * ThinkRank Pro auto-installs and activates the free plugin whenever it
61 152 * finds it missing, which silently re-ran the activator and recreated every
@@ -98,21 +189,19 @@
98 189 $wpdb->prefix . 'thinkrank_seo_local',
99 190 $wpdb->prefix . 'thinkrank_email_report_logs',
100 191 // AI Visibility Tables. These were registered in Database_Schema but
101 192 // never listed here, so an uninstall left them behind — bv_tasks in
102 - // particular holds the full text of every AI answer (#302).
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.
103 196 $wpdb->prefix . 'thinkrank_ai_traffic',
104 197 $wpdb->prefix . 'thinkrank_brand_visibility_checks',
105 198 $wpdb->prefix . 'thinkrank_bv_runs',
106 199 $wpdb->prefix . 'thinkrank_bv_tasks',
107 - // Rank Tracker Tables
108 - $wpdb->prefix . 'thinkrank_rank_tracking',
109 - $wpdb->prefix . 'thinkrank_tracked_keywords',
110 - // Pro feature tables (redirections, broken links, local SEO)
111 - $wpdb->prefix . 'thinkrank_redirects',
112 - $wpdb->prefix . 'thinkrank_404_logs',
113 - $wpdb->prefix . 'thinkrank_broken_links',
114 - $wpdb->prefix . 'thinkrank_locations',
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).
115 204 ];
116 205
117 206 foreach ($tables as $table) {
118 207 // Check WordPress version for %i support (introduced in 6.2)
@@ -135,17 +224,23 @@
135 224 *
136 225 * @return void
137 226 */
138 227 private static function remove_options(): void {
139 - // Remove all ThinkRank options using wildcard delete for completeness
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.
140 234 global $wpdb;
141 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
142 236 $wpdb->query(
143 237 $wpdb->prepare(
144 238 "DELETE FROM {$wpdb->options}
145 - WHERE option_name LIKE %s
239 + WHERE (option_name LIKE %s AND option_name NOT LIKE %s)
146 240 OR option_name LIKE %s",
147 241 $wpdb->esc_like('thinkrank_') . '%',
242 + $wpdb->esc_like('thinkrank_pro_') . '%',
148 243 // Appsero / WP Insights telemetry options (wpins_thinkrank_*)
149 244 $wpdb->esc_like('wpins_thinkrank_') . '%'
150 245 )
151 246 );
@@ -155,15 +250,38 @@
155 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
156 251 $wpdb->query(
157 252 $wpdb->prepare(
158 253 "DELETE FROM {$wpdb->options}
159 - WHERE option_name LIKE %s
160 - OR option_name LIKE %s",
254 + WHERE (option_name LIKE %s AND option_name NOT LIKE %s)
255 + OR (option_name LIKE %s AND option_name NOT LIKE %s)",
161 256 $wpdb->esc_like('_transient_thinkrank_') . '%',
162 - $wpdb->esc_like('_transient_timeout_thinkrank_') . '%'
257 + $wpdb->esc_like('_transient_thinkrank_pro_') . '%',
258 + $wpdb->esc_like('_transient_timeout_thinkrank_') . '%',
259 + $wpdb->esc_like('_transient_timeout_thinkrank_pro_') . '%'
163 260 )
164 261 );
165 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 + }
166 284 }
167 285
168 286 /**
169 287 * Remove user meta data
@@ -208,8 +326,35 @@
208 326 }
209 327
210 328
211 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 + /**
212 357 * Clear all caches
213 358 *
214 359 * @return void
215 360 */
@@ -225,14 +370,12 @@
225 370 *
226 371 * @return void
227 372 */
228 373 private static function remove_cron_jobs(): void {
229 - $cron_jobs = [
230 - 'thinkrank_cache_cleanup',
231 - 'thinkrank_usage_analytics',
232 - ];
233 -
234 - foreach ($cron_jobs as $job) {
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) {
235 378 wp_clear_scheduled_hook($job);
236 379 }
237 380 }
238 381
@@ -247,35 +390,16 @@
247 390 // short-circuit on the next install and never re-grant thinkrank_access,
248 391 // locking administrators out of the admin menu.
249 392 delete_option('thinkrank_caps_version');
250 393
251 - // Keep in sync with ThinkRank\Core\Capability_Manager::capabilities().
252 - // The plugin autoloader isn't available during uninstall, so the slugs are
253 - // mirrored here. The trailing legacy slugs were granted by older versions
254 - // and are removed too so historical roles are fully cleaned.
255 - $capabilities = [
256 - 'thinkrank_access',
257 - 'thinkrank_site_identity',
258 - 'thinkrank_analytics',
259 - 'thinkrank_performance',
260 - 'thinkrank_global_seo',
261 - 'thinkrank_image_seo',
262 - 'thinkrank_schema',
263 - 'thinkrank_social_media',
264 - 'thinkrank_crawling',
265 - 'thinkrank_instant_indexing',
266 - 'thinkrank_author_archives',
267 - 'thinkrank_content_tools',
268 - 'thinkrank_internal_links',
269 - 'thinkrank_settings',
270 - 'thinkrank_manage_roles',
271 - // Legacy slugs (pre-Role Manager) — remove if still present.
272 - 'thinkrank_manage_settings',
273 - 'thinkrank_view_analytics',
274 - 'thinkrank_manage_credits',
275 - 'thinkrank_use_ai_features',
276 - ];
277 -
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 +
278 402 $roles = wp_roles();
279 403
280 404 foreach ($roles->roles as $role_name => $role_info) {
281 405 $role = get_role($role_name);