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 | includes/admin/importers/class-import-controller.php +128 -8 2.0.02.9.0 View file →
@@ -35,13 +35,28 @@
35 35 */
36 36 protected $rest_base = 'import';
37 37
38 38 /**
39 - * Allowed plugin slugs
39 + * Source plugins ThinkRank can migrate FROM.
40 + *
41 + * Kept separate from ALLOWED_PLUGINS because cleanup() deletes the source
42 + * plugin's live data: the native ThinkRank slug must never reach it, or the
43 + * endpoint gains a path that wipes our own meta and options.
40 44 */
41 - private const ALLOWED_PLUGINS = ['yoast', 'rankmath', 'seopress', 'aioseo'];
45 + private const SOURCE_PLUGINS = ['yoast', 'rankmath', 'seopress', 'aioseo', 'squirrly'];
42 46
43 47 /**
48 + * Snapshot slug for ThinkRank's own data (export / backup / restore).
49 + */
50 + private const NATIVE_PLUGIN = Thinkrank_Exporter::SLUG;
51 +
52 + /**
53 + * Allowed plugin slugs for the snapshot endpoints (export, migrate,
54 + * snapshot delete). Includes the native slug; cleanup uses SOURCE_PLUGINS.
55 + */
56 + private const ALLOWED_PLUGINS = ['yoast', 'rankmath', 'seopress', 'aioseo', 'squirrly', 'thinkrank'];
57 +
58 + /**
44 59 * Allowed export/migrate types. Must cover every type the exporters and
45 60 * the frontend workflow (useImportWorkflow.js EXPORT_TYPES) can send —
46 61 * 404_logs was missing, so Rank Math's 404 Monitor could never be
47 62 * exported or migrated through REST.
@@ -48,8 +63,24 @@
48 63 */
49 64 private const ALLOWED_TYPES = ['postmeta', 'termmeta', 'usermeta', 'redirections', '404_logs', 'settings'];
50 65
51 66 /**
67 + * Types the export and migrate endpoints accept.
68 + *
69 + * The fixed list above plus anything Pro registered through
70 + * `thinkrank_export_types` — without this a Pro type would be exportable in
71 + * principle and rejected at the route.
72 + *
73 + * @return string[]
74 + */
75 + private function get_allowed_types(): array {
76 + return array_values(array_unique(array_merge(
77 + self::ALLOWED_TYPES,
78 + Thinkrank_Exporter::get_exportable_types()
79 + )));
80 + }
81 +
82 + /**
52 83 * Register REST routes
53 84 *
54 85 * @return void
55 86 */
@@ -58,8 +89,16 @@
58 89 [
59 90 'methods' => \WP_REST_Server::READABLE,
60 91 'callback' => [$this, 'detect'],
61 92 'permission_callback' => [$this, 'check_permissions'],
93 + 'args' => [
94 + 'refresh' => [
95 + 'type' => 'boolean',
96 + 'default' => false,
97 + 'sanitize_callback' => 'rest_sanitize_boolean',
98 + 'description' => __('Discard the cached scan and re-read the database. Set by the Re-scan button; the screen\'s own load leaves it off so repeat navigation stays instant.', 'thinkrank'),
99 + ],
100 + ],
62 101 ],
63 102 ]);
64 103
65 104 register_rest_route($this->namespace, '/' . $this->rest_base . '/export', [
@@ -96,9 +135,10 @@
96 135 'args' => [
97 136 'plugin' => [
98 137 'required' => true,
99 138 'type' => 'string',
100 - 'enum' => self::ALLOWED_PLUGINS,
139 + // Source plugins only — see SOURCE_PLUGINS.
140 + 'enum' => self::SOURCE_PLUGINS,
101 141 'sanitize_callback' => 'sanitize_text_field',
102 142 ],
103 143 // Required to proceed while the snapshot still holds
104 144 // extended data with no migration path (see cleanup()).
@@ -137,20 +177,33 @@
137 177 return current_user_can('manage_options');
138 178 }
139 179
140 180 /**
141 - * GET /import/detect — Detect source plugins and existing snapshots
181 + * GET /import/detect — Detect source plugins, ThinkRank's own exportable
182 + * data, and existing snapshots
142 183 *
184 + * `refresh` drops the hour-long detection transient before scanning. Without
185 + * it the Re-scan button could not do the one thing it exists for: pick up a
186 + * source plugin the user just installed, activated or added data to.
187 + *
143 188 * @param \WP_REST_Request $request Request object
144 189 * @return \WP_REST_Response
145 190 */
146 191 public function detect(\WP_REST_Request $request): \WP_REST_Response {
147 192 $detector = new Import_Detector();
193 +
194 + if ($request->get_param('refresh')) {
195 + $detector->clear_cache();
196 + }
197 +
148 198 $detected = $detector->detect();
149 199 $snapshots = Snapshot_Store::get_available_snapshots();
150 200
151 201 return new \WP_REST_Response([
152 202 'detected' => $detected,
203 + // ThinkRank's own exportable data, reported separately from the
204 + // source plugins the user can migrate FROM.
205 + 'native' => $detector->detect_native(),
153 206 'snapshots' => $snapshots,
154 207 ], 200);
155 208 }
156 209
@@ -169,8 +222,15 @@
169 222 if (is_wp_error($exporter)) {
170 223 return $exporter;
171 224 }
172 225
226 + // Start a run from an empty slot. update_manifest() merges into whatever
227 + // manifest is already there, so without this the file the user ends up
228 + // downloading carries the union of this run and every run before it.
229 + if ((bool) $request->get_param('reset')) {
230 + Snapshot_Store::delete_snapshot($plugin);
231 + }
232 +
173 233 $result = $exporter->export_chunk($type, $page);
174 234
175 235 // If this type is complete and it's the last type, finalize
176 236 if (!$result['has_more'] && $request->get_param('is_last_type')) {
@@ -191,9 +251,10 @@
191 251 return new \WP_REST_Response(['snapshots' => $snapshots], 200);
192 252 }
193 253
194 254 /**
195 - * POST /import/migrate — Migrate a batch from snapshot to ThinkRank meta
255 + * POST /import/migrate — Migrate a batch from snapshot to ThinkRank meta,
256 + * or restore one from ThinkRank's own export
196 257 *
197 258 * @param \WP_REST_Request $request Request object
198 259 * @return \WP_REST_Response
199 260 */
@@ -200,11 +261,12 @@
200 261 public function migrate(\WP_REST_Request $request): \WP_REST_Response {
201 262 $plugin = $request->get_param('plugin');
202 263 $type = $request->get_param('type');
203 264 $page = (int) $request->get_param('page');
265 + $conflict = (string) $request->get_param('conflict');
204 266
205 267 $migrator = new Snapshot_Migrator();
206 - $result = $migrator->migrate_chunk($plugin, $type, $page);
268 + $result = $migrator->migrate_chunk($plugin, $type, $page, $conflict);
207 269
208 270 // If migration is complete for all types, update manifest
209 271 if (!$result['has_more'] && $request->get_param('is_last_type')) {
210 272 $migrator->update_manifest_migration_info($plugin);
@@ -233,8 +295,19 @@
233 295 $plugin = $request->get_param('plugin');
234 296 $force = (bool) $request->get_param('force');
235 297 $deleted = 0;
236 298
299 + // Belt and braces on top of the route's SOURCE_PLUGINS enum: this
300 + // endpoint deletes the SOURCE plugin's live meta and options, so
301 + // pointing it at ThinkRank would delete the user's own SEO data.
302 + if ($plugin === self::NATIVE_PLUGIN) {
303 + return new \WP_Error(
304 + 'thinkrank_cleanup_not_applicable',
305 + __('Cleanup removes a source plugin\'s data and does not apply to ThinkRank\'s own export. Use DELETE /import/snapshot to discard the snapshot.', 'thinkrank'),
306 + ['status' => 400]
307 + );
308 + }
309 +
237 310 // Gate: block while the snapshot holds preserved-but-unapplied extended
238 311 // data, unless the caller explicitly forces the cleanup.
239 312 if (!$force) {
240 313 $migrator = new Snapshot_Migrator();
@@ -270,8 +343,9 @@
270 343 'yoast' => '_yoast_wpseo_',
271 344 'rankmath' => 'rank_math_',
272 345 'seopress' => '_seopress_',
273 346 'aioseo' => null, // Custom table
347 + 'squirrly' => '_sq_', // Fallback meta only; the SEO is in the qss table below
274 348 ];
275 349
276 350 $prefix = $prefix_map[$plugin] ?? null;
277 351
@@ -298,8 +372,9 @@
298 372 $usermeta_prefix_map = [
299 373 'yoast' => 'wpseo_',
300 374 'rankmath' => 'rank_math_',
301 375 'seopress' => '_seopress_',
376 + 'squirrly' => '_sq_',
302 377 ];
303 378 $usermeta_prefix = $usermeta_prefix_map[$plugin] ?? $prefix;
304 379 $deleted += (int) $wpdb->query(
305 380 $wpdb->prepare(
@@ -314,8 +389,9 @@
314 389 $option_keys_map = [
315 390 'yoast' => ['wpseo', 'wpseo_titles', 'wpseo_social', 'wpseo_taxonomy_meta'],
316 391 'rankmath' => ['rank-math-options-general', 'rank-math-options-titles', 'rank-math-options-sitemap', 'rank-math-options-instant-indexing'],
317 392 'seopress' => ['seopress_titles_option_name', 'seopress_social_option_name', 'seopress_advanced_option_name', 'seopress_xml_sitemap_option_name', 'seopress_instant_indexing_option_name'],
393 + 'squirrly' => ['sq_options'],
318 394 ];
319 395 foreach ($option_keys_map[$plugin] ?? [] as $option_name) {
320 396 if (delete_option($option_name)) {
321 397 $deleted++;
@@ -322,8 +398,26 @@
322 398 }
323 399 }
324 400 }
325 401
402 + if ($plugin === 'squirrly') {
403 + // Drop Squirrly's custom tables: per-URL SEO, Advanced Pack
404 + // redirects and their logs, reusable JSON-LD templates.
405 + foreach (['qss', 'qss_redirects', 'qss_redirects_logs', 'qss_jsonld'] as $suffix) {
406 + $table = $wpdb->prefix . $suffix;
407 + $table_exists = $wpdb->get_var(
408 + $wpdb->prepare("SHOW TABLES LIKE %s", $table)
409 + );
410 + if ($table_exists) {
411 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
412 + $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
413 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
414 + $wpdb->query("DROP TABLE {$table}");
415 + $deleted += $count;
416 + }
417 + }
418 + }
419 +
326 420 if ($plugin === 'aioseo') {
327 421 // Drop the AIOSEO custom tables the exporter reads (posts + Pro terms).
328 422 foreach (['aioseo_posts', 'aioseo_terms'] as $suffix) {
329 423 $table = $wpdb->prefix . $suffix;
@@ -399,8 +493,12 @@
399 493 case 'seopress':
400 494 return new SEOPress_Exporter();
401 495 case 'aioseo':
402 496 return new AIOSEO_Exporter();
497 + case Squirrly_Exporter::SLUG:
498 + return new Squirrly_Exporter();
499 + case Thinkrank_Exporter::SLUG:
500 + return new Thinkrank_Exporter();
403 501 default:
404 502 return new \WP_Error('invalid_plugin', 'Unsupported plugin: ' . $plugin, ['status' => 400]);
405 503 }
406 504 }
@@ -420,9 +518,9 @@
420 518 ],
421 519 'type' => [
422 520 'required' => true,
423 521 'type' => 'string',
424 - 'enum' => self::ALLOWED_TYPES,
522 + 'enum' => $this->get_allowed_types(),
425 523 'sanitize_callback' => 'sanitize_text_field',
426 524 ],
427 525 'page' => [
428 526 'required' => true,
@@ -434,8 +532,19 @@
434 532 'required' => false,
435 533 'type' => 'boolean',
436 534 'default' => false,
437 535 ],
536 + // Set on the first chunk of a run to discard whatever is already in
537 + // the snapshot slot. Without it a run inherits the previous one's
538 + // types: the manifest is merged into, never replaced, so a type the
539 + // user deselected (or a file they uploaded and chose not to
540 + // restore) stays in the snapshot and is streamed by
541 + // /export/download, which sends every type the manifest lists.
542 + 'reset' => [
543 + 'required' => false,
544 + 'type' => 'boolean',
545 + 'default' => false,
546 + ],
438 547 ];
439 548 }
440 549
441 550 /**
@@ -450,12 +559,23 @@
450 559 'type' => 'string',
451 560 'enum' => self::ALLOWED_PLUGINS,
452 561 'sanitize_callback' => 'sanitize_text_field',
453 562 ],
563 + // Defaults to skip, which is the safe answer for an import from
564 + // another plugin: its data must never clobber something already
565 + // set here. A restore from a ThinkRank backup passes overwrite —
566 + // getting the saved values back is the entire point of it.
567 + 'conflict' => [
568 + 'required' => false,
569 + 'type' => 'string',
570 + 'enum' => [Snapshot_Migrator::CONFLICT_SKIP, Snapshot_Migrator::CONFLICT_OVERWRITE],
571 + 'default' => Snapshot_Migrator::CONFLICT_SKIP,
572 + 'sanitize_callback' => 'sanitize_text_field',
573 + ],
454 574 'type' => [
455 575 'required' => true,
456 576 'type' => 'string',
457 - 'enum' => self::ALLOWED_TYPES,
577 + 'enum' => $this->get_allowed_types(),
458 578 'sanitize_callback' => 'sanitize_text_field',
459 579 ],
460 580 'page' => [
461 581 'required' => true,