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 +142 -16 1.28.0 → 2.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;
@@ -330,10 +424,11 @@
330 424 $table_exists = $wpdb->get_var(
331 425 $wpdb->prepare("SHOW TABLES LIKE %s", $table)
332 426 );
333 427 if ($table_exists) {
428 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
334 429 $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
335 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
430 + // 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.
336 431 $wpdb->query("DROP TABLE {$table}");
337 432 $deleted += $count;
338 433 }
339 434 }
@@ -389,15 +484,24 @@
389 484 * @param string $plugin Plugin slug
390 485 * @return Abstract_Plugin_Exporter|\WP_Error
391 486 */
392 487 private function get_exporter(string $plugin) {
393 - return match ($plugin) {
394 - 'yoast' => new Yoast_Exporter(),
395 - 'rankmath' => new Rankmath_Exporter(),
396 - 'seopress' => new SEOPress_Exporter(),
397 - 'aioseo' => new AIOSEO_Exporter(),
398 - default => new \WP_Error('invalid_plugin', 'Unsupported plugin: ' . $plugin, ['status' => 400]),
399 - };
488 + switch ($plugin) {
489 + case 'yoast':
490 + return new Yoast_Exporter();
491 + case 'rankmath':
492 + return new Rankmath_Exporter();
493 + case 'seopress':
494 + return new SEOPress_Exporter();
495 + case 'aioseo':
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();
501 + default:
502 + return new \WP_Error('invalid_plugin', 'Unsupported plugin: ' . $plugin, ['status' => 400]);
503 + }
400 504 }
401 505
402 506 /**
403 507 * Get argument schema for export endpoint
@@ -414,9 +518,9 @@
414 518 ],
415 519 'type' => [
416 520 'required' => true,
417 521 'type' => 'string',
418 - 'enum' => self::ALLOWED_TYPES,
522 + 'enum' => $this->get_allowed_types(),
419 523 'sanitize_callback' => 'sanitize_text_field',
420 524 ],
421 525 'page' => [
422 526 'required' => true,
@@ -428,8 +532,19 @@
428 532 'required' => false,
429 533 'type' => 'boolean',
430 534 'default' => false,
431 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 + ],
432 547 ];
433 548 }
434 549
435 550 /**
@@ -444,12 +559,23 @@
444 559 'type' => 'string',
445 560 'enum' => self::ALLOWED_PLUGINS,
446 561 'sanitize_callback' => 'sanitize_text_field',
447 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 + ],
448 574 'type' => [
449 575 'required' => true,
450 576 'type' => 'string',
451 - 'enum' => self::ALLOWED_TYPES,
577 + 'enum' => $this->get_allowed_types(),
452 578 'sanitize_callback' => 'sanitize_text_field',
453 579 ],
454 580 'page' => [
455 581 'required' => true,