PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.13.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.13.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 1.27.0 1.26.0 1.25.0 All 54 releases
← All changes | includes/admin/importers/class-import-controller.php +279 -17 1.26.0 → 2.13.0 View file →
@@ -35,21 +35,60 @@
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', 'slimseo'];
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', 'slimseo', '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 63 */
49 - private const ALLOWED_TYPES = ['postmeta', 'termmeta', 'usermeta', 'redirections', '404_logs', 'settings'];
64 + private const ALLOWED_TYPES = [
65 + 'postmeta',
66 + 'termmeta',
67 + 'usermeta',
68 + 'redirections',
69 + '404_logs',
70 + 'settings',
71 + Block_Converter::TYPE,
72 + ];
50 73
51 74 /**
75 + * Types the export and migrate endpoints accept.
76 + *
77 + * The fixed list above plus anything Pro registered through
78 + * `thinkrank_export_types` — without this a Pro type would be exportable in
79 + * principle and rejected at the route.
80 + *
81 + * @return string[]
82 + */
83 + private function get_allowed_types(): array {
84 + return array_values(array_unique(array_merge(
85 + self::ALLOWED_TYPES,
86 + Thinkrank_Exporter::get_exportable_types()
87 + )));
88 + }
89 +
90 + /**
52 91 * Register REST routes
53 92 *
54 93 * @return void
55 94 */
@@ -58,8 +97,16 @@
58 97 [
59 98 'methods' => \WP_REST_Server::READABLE,
60 99 'callback' => [$this, 'detect'],
61 100 'permission_callback' => [$this, 'check_permissions'],
101 + 'args' => [
102 + 'refresh' => [
103 + 'type' => 'boolean',
104 + 'default' => false,
105 + 'sanitize_callback' => 'rest_sanitize_boolean',
106 + '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'),
107 + ],
108 + ],
62 109 ],
63 110 ]);
64 111
65 112 register_rest_route($this->namespace, '/' . $this->rest_base . '/export', [
@@ -96,9 +143,10 @@
96 143 'args' => [
97 144 'plugin' => [
98 145 'required' => true,
99 146 'type' => 'string',
100 - 'enum' => self::ALLOWED_PLUGINS,
147 + // Source plugins only — see SOURCE_PLUGINS.
148 + 'enum' => self::SOURCE_PLUGINS,
101 149 'sanitize_callback' => 'sanitize_text_field',
102 150 ],
103 151 // Required to proceed while the snapshot still holds
104 152 // extended data with no migration path (see cleanup()).
@@ -110,8 +158,41 @@
110 158 ],
111 159 ],
112 160 ]);
113 161
162 + // Undo for the Rank Math FAQ/HowTo block conversion on sites with
163 + // revisions disabled, where Block_Converter kept the original content
164 + // in post meta. With revisions on, the post's revision history is the
165 + // undo and this route has nothing to restore.
166 + register_rest_route($this->namespace, '/' . $this->rest_base . '/content-blocks/restore', [
167 + [
168 + 'methods' => \WP_REST_Server::CREATABLE,
169 + 'callback' => [$this, 'restore_content_blocks'],
170 + 'permission_callback' => [$this, 'check_permissions'],
171 + 'args' => [
172 + 'post_id' => [
173 + 'type' => 'integer',
174 + 'default' => 0,
175 + 'minimum' => 0,
176 + 'sanitize_callback' => 'absint',
177 + 'description' => __('Restore one post. Omit to restore every post with a backup, one page at a time.', 'thinkrank'),
178 + ],
179 + 'after' => [
180 + 'type' => 'integer',
181 + 'default' => 0,
182 + 'minimum' => 0,
183 + 'sanitize_callback' => 'absint',
184 + 'description' => __('Paging cursor: pass the previous response\'s next_after.', 'thinkrank'),
185 + ],
186 + 'force' => [
187 + 'type' => 'boolean',
188 + 'default' => false,
189 + 'description' => __('Restore posts that were edited after the conversion, discarding those edits.', 'thinkrank'),
190 + ],
191 + ],
192 + ],
193 + ]);
194 +
114 195 register_rest_route($this->namespace, '/' . $this->rest_base . '/snapshot', [
115 196 [
116 197 'methods' => \WP_REST_Server::DELETABLE,
117 198 'callback' => [$this, 'delete_snapshot'],
@@ -137,20 +218,33 @@
137 218 return current_user_can('manage_options');
138 219 }
139 220
140 221 /**
141 - * GET /import/detect — Detect source plugins and existing snapshots
222 + * GET /import/detect — Detect source plugins, ThinkRank's own exportable
223 + * data, and existing snapshots
142 224 *
225 + * `refresh` drops the hour-long detection transient before scanning. Without
226 + * it the Re-scan button could not do the one thing it exists for: pick up a
227 + * source plugin the user just installed, activated or added data to.
228 + *
143 229 * @param \WP_REST_Request $request Request object
144 230 * @return \WP_REST_Response
145 231 */
146 232 public function detect(\WP_REST_Request $request): \WP_REST_Response {
147 233 $detector = new Import_Detector();
234 +
235 + if ($request->get_param('refresh')) {
236 + $detector->clear_cache();
237 + }
238 +
148 239 $detected = $detector->detect();
149 240 $snapshots = Snapshot_Store::get_available_snapshots();
150 241
151 242 return new \WP_REST_Response([
152 243 'detected' => $detected,
244 + // ThinkRank's own exportable data, reported separately from the
245 + // source plugins the user can migrate FROM.
246 + 'native' => $detector->detect_native(),
153 247 'snapshots' => $snapshots,
154 248 ], 200);
155 249 }
156 250
@@ -169,8 +263,15 @@
169 263 if (is_wp_error($exporter)) {
170 264 return $exporter;
171 265 }
172 266
267 + // Start a run from an empty slot. update_manifest() merges into whatever
268 + // manifest is already there, so without this the file the user ends up
269 + // downloading carries the union of this run and every run before it.
270 + if ((bool) $request->get_param('reset')) {
271 + Snapshot_Store::delete_snapshot($plugin);
272 + }
273 +
173 274 $result = $exporter->export_chunk($type, $page);
174 275
175 276 // If this type is complete and it's the last type, finalize
176 277 if (!$result['has_more'] && $request->get_param('is_last_type')) {
@@ -191,9 +292,10 @@
191 292 return new \WP_REST_Response(['snapshots' => $snapshots], 200);
192 293 }
193 294
194 295 /**
195 - * POST /import/migrate — Migrate a batch from snapshot to ThinkRank meta
296 + * POST /import/migrate — Migrate a batch from snapshot to ThinkRank meta,
297 + * or restore one from ThinkRank's own export
196 298 *
197 299 * @param \WP_REST_Request $request Request object
198 300 * @return \WP_REST_Response
199 301 */
@@ -200,11 +302,12 @@
200 302 public function migrate(\WP_REST_Request $request): \WP_REST_Response {
201 303 $plugin = $request->get_param('plugin');
202 304 $type = $request->get_param('type');
203 305 $page = (int) $request->get_param('page');
306 + $conflict = (string) $request->get_param('conflict');
204 307
205 308 $migrator = new Snapshot_Migrator();
206 - $result = $migrator->migrate_chunk($plugin, $type, $page);
309 + $result = $migrator->migrate_chunk($plugin, $type, $page, $conflict);
207 310
208 311 // If migration is complete for all types, update manifest
209 312 if (!$result['has_more'] && $request->get_param('is_last_type')) {
210 313 $migrator->update_manifest_migration_info($plugin);
@@ -233,8 +336,19 @@
233 336 $plugin = $request->get_param('plugin');
234 337 $force = (bool) $request->get_param('force');
235 338 $deleted = 0;
236 339
340 + // Belt and braces on top of the route's SOURCE_PLUGINS enum: this
341 + // endpoint deletes the SOURCE plugin's live meta and options, so
342 + // pointing it at ThinkRank would delete the user's own SEO data.
343 + if ($plugin === self::NATIVE_PLUGIN) {
344 + return new \WP_Error(
345 + 'thinkrank_cleanup_not_applicable',
346 + __('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'),
347 + ['status' => 400]
348 + );
349 + }
350 +
237 351 // Gate: block while the snapshot holds preserved-but-unapplied extended
238 352 // data, unless the caller explicitly forces the cleanup.
239 353 if (!$force) {
240 354 $migrator = new Snapshot_Migrator();
@@ -270,8 +384,12 @@
270 384 'yoast' => '_yoast_wpseo_',
271 385 'rankmath' => 'rank_math_',
272 386 'seopress' => '_seopress_',
273 387 'aioseo' => null, // Custom table
388 + 'squirrly' => '_sq_', // Fallback meta only; the SEO is in the qss table below
389 + // Slim SEO is cleaned by exact key below: a `slim_seo` prefix also
390 + // matches Slim SEO Pro's and its add-ons' own keys (#886).
391 + 'slimseo' => null,
274 392 ];
275 393
276 394 $prefix = $prefix_map[$plugin] ?? null;
277 395
@@ -298,8 +416,9 @@
298 416 $usermeta_prefix_map = [
299 417 'yoast' => 'wpseo_',
300 418 'rankmath' => 'rank_math_',
301 419 'seopress' => '_seopress_',
420 + 'squirrly' => '_sq_',
302 421 ];
303 422 $usermeta_prefix = $usermeta_prefix_map[$plugin] ?? $prefix;
304 423 $deleted += (int) $wpdb->query(
305 424 $wpdb->prepare(
@@ -314,8 +433,9 @@
314 433 $option_keys_map = [
315 434 'yoast' => ['wpseo', 'wpseo_titles', 'wpseo_social', 'wpseo_taxonomy_meta'],
316 435 'rankmath' => ['rank-math-options-general', 'rank-math-options-titles', 'rank-math-options-sitemap', 'rank-math-options-instant-indexing'],
317 436 'seopress' => ['seopress_titles_option_name', 'seopress_social_option_name', 'seopress_advanced_option_name', 'seopress_xml_sitemap_option_name', 'seopress_instant_indexing_option_name'],
437 + 'squirrly' => ['sq_options'],
318 438 ];
319 439 foreach ($option_keys_map[$plugin] ?? [] as $option_name) {
320 440 if (delete_option($option_name)) {
321 441 $deleted++;
@@ -322,8 +442,66 @@
322 442 }
323 443 }
324 444 }
325 445
446 + if ($plugin === 'slimseo') {
447 + // Exactly the keys the exporter reads. A `slim_seo%` prefix would
448 + // also take data nothing imported (Slim SEO Link Manager's and
449 + // Auto Link's settings), and those stay with their plugin.
450 + $deleted += (int) $wpdb->query(
451 + $wpdb->prepare(
452 + "DELETE FROM {$wpdb->postmeta} WHERE meta_key IN (%s, %s, %s, %s, %s) OR meta_key LIKE %s",
453 + Slim_SEO_Exporter::META_KEY,
454 + Slim_SEO_Exporter::PRO_META_KEY,
455 + Slim_SEO_Exporter::SCHEMA_META_KEY,
456 + Slim_SEO_Exporter::ALLOW_GLOBAL_META,
457 + Slim_SEO_Exporter::OLD_PERMALINK_META,
458 + $wpdb->esc_like(Slim_SEO_Exporter::PRIMARY_TERM_PREFIX) . '%'
459 + )
460 + );
461 + $deleted += (int) $wpdb->query(
462 + $wpdb->prepare("DELETE FROM {$wpdb->termmeta} WHERE meta_key = %s", Slim_SEO_Exporter::META_KEY)
463 + );
464 + $options = [
465 + Slim_SEO_Exporter::OPTION,
466 + Slim_SEO_Exporter::REDIRECTS_OPTION,
467 + Slim_SEO_Exporter::PRO_OPTION,
468 + Slim_SEO_Exporter::SCHEMAS_OPTION,
469 + 'slim_seo_db_version',
470 + 'ss_redirection_db_version',
471 + ];
472 + foreach ($options as $option_name) {
473 + if (delete_option($option_name)) {
474 + $deleted++;
475 + }
476 + }
477 + $table = $wpdb->prefix . Slim_SEO_Exporter::LOG_404_TABLE;
478 + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table))) {
479 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal.
480 + $deleted += (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
481 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal.
482 + $wpdb->query("DROP TABLE {$table}");
483 + }
484 + }
485 +
486 + if ($plugin === 'squirrly') {
487 + // Drop Squirrly's custom tables: per-URL SEO, Advanced Pack
488 + // redirects and their logs, reusable JSON-LD templates.
489 + foreach (['qss', 'qss_redirects', 'qss_redirects_logs', 'qss_jsonld'] as $suffix) {
490 + $table = $wpdb->prefix . $suffix;
491 + $table_exists = $wpdb->get_var(
492 + $wpdb->prepare("SHOW TABLES LIKE %s", $table)
493 + );
494 + if ($table_exists) {
495 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
496 + $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
497 + // 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.
498 + $wpdb->query("DROP TABLE {$table}");
499 + $deleted += $count;
500 + }
501 + }
502 + }
503 +
326 504 if ($plugin === 'aioseo') {
327 505 // Drop the AIOSEO custom tables the exporter reads (posts + Pro terms).
328 506 foreach (['aioseo_posts', 'aioseo_terms'] as $suffix) {
329 507 $table = $wpdb->prefix . $suffix;
@@ -330,10 +508,11 @@
330 508 $table_exists = $wpdb->get_var(
331 509 $wpdb->prepare("SHOW TABLES LIKE %s", $table)
332 510 );
333 511 if ($table_exists) {
512 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
334 513 $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
335 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
514 + // 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 515 $wpdb->query("DROP TABLE {$table}");
337 516 $deleted += $count;
338 517 }
339 518 }
@@ -366,8 +545,58 @@
366 545 ], 200);
367 546 }
368 547
369 548 /**
549 + * POST /import/content-blocks/restore: put converted posts back.
550 + *
551 + * Restores the pre-conversion `post_content` Block_Converter kept for posts
552 + * converted while revisions were disabled. A post edited since is reported
553 + * under `modified` and left alone unless `force` is set.
554 + *
555 + * Without `post_id` it works through one page of posts per call; while
556 + * `next_after` is non-null, call again with `after` set to it.
557 + *
558 + * @since 2.10.0
559 + *
560 + * @param \WP_REST_Request $request Request object
561 + * @return \WP_REST_Response
562 + */
563 + public function restore_content_blocks(\WP_REST_Request $request): \WP_REST_Response {
564 + $post_id = (int) $request->get_param('post_id');
565 + $force = (bool) $request->get_param('force');
566 + $limit = Block_Converter::chunk_size();
567 +
568 + $ids = $post_id > 0
569 + ? [$post_id]
570 + : Block_Converter::get_backup_post_ids((int) $request->get_param('after'), $limit);
571 +
572 + $report = [
573 + 'restored' => [],
574 + 'modified' => [],
575 + 'no_backup' => [],
576 + 'errors' => [],
577 + 'next_after' => null,
578 + ];
579 +
580 + foreach ($ids as $id) {
581 + $result = Block_Converter::restore_post($id, $force);
582 +
583 + if ('error' === $result['status']) {
584 + $report['errors'][] = ['post_id' => $id, 'message' => $result['message']];
585 + continue;
586 + }
587 +
588 + $report[$result['status']][] = $id;
589 + }
590 +
591 + if ($post_id < 1 && count($ids) === $limit) {
592 + $report['next_after'] = (int) end($ids);
593 + }
594 +
595 + return new \WP_REST_Response($report, 200);
596 + }
597 +
598 + /**
370 599 * DELETE /import/snapshot — Delete snapshot data from wp_options
371 600 *
372 601 * @param \WP_REST_Request $request Request object
373 602 * @return \WP_REST_Response
@@ -389,15 +618,26 @@
389 618 * @param string $plugin Plugin slug
390 619 * @return Abstract_Plugin_Exporter|\WP_Error
391 620 */
392 621 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 - };
622 + switch ($plugin) {
623 + case 'yoast':
624 + return new Yoast_Exporter();
625 + case 'rankmath':
626 + return new Rankmath_Exporter();
627 + case 'seopress':
628 + return new SEOPress_Exporter();
629 + case 'aioseo':
630 + return new AIOSEO_Exporter();
631 + case Squirrly_Exporter::SLUG:
632 + return new Squirrly_Exporter();
633 + case Slim_SEO_Exporter::SLUG:
634 + return new Slim_SEO_Exporter();
635 + case Thinkrank_Exporter::SLUG:
636 + return new Thinkrank_Exporter();
637 + default:
638 + return new \WP_Error('invalid_plugin', 'Unsupported plugin: ' . $plugin, ['status' => 400]);
639 + }
400 640 }
401 641
402 642 /**
403 643 * Get argument schema for export endpoint
@@ -414,9 +654,9 @@
414 654 ],
415 655 'type' => [
416 656 'required' => true,
417 657 'type' => 'string',
418 - 'enum' => self::ALLOWED_TYPES,
658 + 'enum' => $this->get_allowed_types(),
419 659 'sanitize_callback' => 'sanitize_text_field',
420 660 ],
421 661 'page' => [
422 662 'required' => true,
@@ -428,8 +668,19 @@
428 668 'required' => false,
429 669 'type' => 'boolean',
430 670 'default' => false,
431 671 ],
672 + // Set on the first chunk of a run to discard whatever is already in
673 + // the snapshot slot. Without it a run inherits the previous one's
674 + // types: the manifest is merged into, never replaced, so a type the
675 + // user deselected (or a file they uploaded and chose not to
676 + // restore) stays in the snapshot and is streamed by
677 + // /export/download, which sends every type the manifest lists.
678 + 'reset' => [
679 + 'required' => false,
680 + 'type' => 'boolean',
681 + 'default' => false,
682 + ],
432 683 ];
433 684 }
434 685
435 686 /**
@@ -444,12 +695,23 @@
444 695 'type' => 'string',
445 696 'enum' => self::ALLOWED_PLUGINS,
446 697 'sanitize_callback' => 'sanitize_text_field',
447 698 ],
699 + // Defaults to skip, which is the safe answer for an import from
700 + // another plugin: its data must never clobber something already
701 + // set here. A restore from a ThinkRank backup passes overwrite —
702 + // getting the saved values back is the entire point of it.
703 + 'conflict' => [
704 + 'required' => false,
705 + 'type' => 'string',
706 + 'enum' => [Snapshot_Migrator::CONFLICT_SKIP, Snapshot_Migrator::CONFLICT_OVERWRITE],
707 + 'default' => Snapshot_Migrator::CONFLICT_SKIP,
708 + 'sanitize_callback' => 'sanitize_text_field',
709 + ],
448 710 'type' => [
449 711 'required' => true,
450 712 'type' => 'string',
451 - 'enum' => self::ALLOWED_TYPES,
713 + 'enum' => $this->get_allowed_types(),
452 714 'sanitize_callback' => 'sanitize_text_field',
453 715 ],
454 716 'page' => [
455 717 'required' => true,