PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.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 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/admin/importers/class-import-controller.php +220 -9 2.1.1 → 2.10.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'];
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 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,9 @@
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
274 389 ];
275 390
276 391 $prefix = $prefix_map[$plugin] ?? null;
277 392
@@ -298,8 +413,9 @@
298 413 $usermeta_prefix_map = [
299 414 'yoast' => 'wpseo_',
300 415 'rankmath' => 'rank_math_',
301 416 'seopress' => '_seopress_',
417 + 'squirrly' => '_sq_',
302 418 ];
303 419 $usermeta_prefix = $usermeta_prefix_map[$plugin] ?? $prefix;
304 420 $deleted += (int) $wpdb->query(
305 421 $wpdb->prepare(
@@ -314,8 +430,9 @@
314 430 $option_keys_map = [
315 431 'yoast' => ['wpseo', 'wpseo_titles', 'wpseo_social', 'wpseo_taxonomy_meta'],
316 432 'rankmath' => ['rank-math-options-general', 'rank-math-options-titles', 'rank-math-options-sitemap', 'rank-math-options-instant-indexing'],
317 433 'seopress' => ['seopress_titles_option_name', 'seopress_social_option_name', 'seopress_advanced_option_name', 'seopress_xml_sitemap_option_name', 'seopress_instant_indexing_option_name'],
434 + 'squirrly' => ['sq_options'],
318 435 ];
319 436 foreach ($option_keys_map[$plugin] ?? [] as $option_name) {
320 437 if (delete_option($option_name)) {
321 438 $deleted++;
@@ -322,8 +439,26 @@
322 439 }
323 440 }
324 441 }
325 442
443 + if ($plugin === 'squirrly') {
444 + // Drop Squirrly's custom tables: per-URL SEO, Advanced Pack
445 + // redirects and their logs, reusable JSON-LD templates.
446 + foreach (['qss', 'qss_redirects', 'qss_redirects_logs', 'qss_jsonld'] as $suffix) {
447 + $table = $wpdb->prefix . $suffix;
448 + $table_exists = $wpdb->get_var(
449 + $wpdb->prepare("SHOW TABLES LIKE %s", $table)
450 + );
451 + if ($table_exists) {
452 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
453 + $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
454 + // 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.
455 + $wpdb->query("DROP TABLE {$table}");
456 + $deleted += $count;
457 + }
458 + }
459 + }
460 +
326 461 if ($plugin === 'aioseo') {
327 462 // Drop the AIOSEO custom tables the exporter reads (posts + Pro terms).
328 463 foreach (['aioseo_posts', 'aioseo_terms'] as $suffix) {
329 464 $table = $wpdb->prefix . $suffix;
@@ -367,8 +502,58 @@
367 502 ], 200);
368 503 }
369 504
370 505 /**
506 + * POST /import/content-blocks/restore: put converted posts back.
507 + *
508 + * Restores the pre-conversion `post_content` Block_Converter kept for posts
509 + * converted while revisions were disabled. A post edited since is reported
510 + * under `modified` and left alone unless `force` is set.
511 + *
512 + * Without `post_id` it works through one page of posts per call; while
513 + * `next_after` is non-null, call again with `after` set to it.
514 + *
515 + * @since 2.10.0
516 + *
517 + * @param \WP_REST_Request $request Request object
518 + * @return \WP_REST_Response
519 + */
520 + public function restore_content_blocks(\WP_REST_Request $request): \WP_REST_Response {
521 + $post_id = (int) $request->get_param('post_id');
522 + $force = (bool) $request->get_param('force');
523 + $limit = Block_Converter::chunk_size();
524 +
525 + $ids = $post_id > 0
526 + ? [$post_id]
527 + : Block_Converter::get_backup_post_ids((int) $request->get_param('after'), $limit);
528 +
529 + $report = [
530 + 'restored' => [],
531 + 'modified' => [],
532 + 'no_backup' => [],
533 + 'errors' => [],
534 + 'next_after' => null,
535 + ];
536 +
537 + foreach ($ids as $id) {
538 + $result = Block_Converter::restore_post($id, $force);
539 +
540 + if ('error' === $result['status']) {
541 + $report['errors'][] = ['post_id' => $id, 'message' => $result['message']];
542 + continue;
543 + }
544 +
545 + $report[$result['status']][] = $id;
546 + }
547 +
548 + if ($post_id < 1 && count($ids) === $limit) {
549 + $report['next_after'] = (int) end($ids);
550 + }
551 +
552 + return new \WP_REST_Response($report, 200);
553 + }
554 +
555 + /**
371 556 * DELETE /import/snapshot — Delete snapshot data from wp_options
372 557 *
373 558 * @param \WP_REST_Request $request Request object
374 559 * @return \WP_REST_Response
@@ -399,8 +584,12 @@
399 584 case 'seopress':
400 585 return new SEOPress_Exporter();
401 586 case 'aioseo':
402 587 return new AIOSEO_Exporter();
588 + case Squirrly_Exporter::SLUG:
589 + return new Squirrly_Exporter();
590 + case Thinkrank_Exporter::SLUG:
591 + return new Thinkrank_Exporter();
403 592 default:
404 593 return new \WP_Error('invalid_plugin', 'Unsupported plugin: ' . $plugin, ['status' => 400]);
405 594 }
406 595 }
@@ -420,9 +609,9 @@
420 609 ],
421 610 'type' => [
422 611 'required' => true,
423 612 'type' => 'string',
424 - 'enum' => self::ALLOWED_TYPES,
613 + 'enum' => $this->get_allowed_types(),
425 614 'sanitize_callback' => 'sanitize_text_field',
426 615 ],
427 616 'page' => [
428 617 'required' => true,
@@ -434,8 +623,19 @@
434 623 'required' => false,
435 624 'type' => 'boolean',
436 625 'default' => false,
437 626 ],
627 + // Set on the first chunk of a run to discard whatever is already in
628 + // the snapshot slot. Without it a run inherits the previous one's
629 + // types: the manifest is merged into, never replaced, so a type the
630 + // user deselected (or a file they uploaded and chose not to
631 + // restore) stays in the snapshot and is streamed by
632 + // /export/download, which sends every type the manifest lists.
633 + 'reset' => [
634 + 'required' => false,
635 + 'type' => 'boolean',
636 + 'default' => false,
637 + ],
438 638 ];
439 639 }
440 640
441 641 /**
@@ -450,12 +650,23 @@
450 650 'type' => 'string',
451 651 'enum' => self::ALLOWED_PLUGINS,
452 652 'sanitize_callback' => 'sanitize_text_field',
453 653 ],
654 + // Defaults to skip, which is the safe answer for an import from
655 + // another plugin: its data must never clobber something already
656 + // set here. A restore from a ThinkRank backup passes overwrite —
657 + // getting the saved values back is the entire point of it.
658 + 'conflict' => [
659 + 'required' => false,
660 + 'type' => 'string',
661 + 'enum' => [Snapshot_Migrator::CONFLICT_SKIP, Snapshot_Migrator::CONFLICT_OVERWRITE],
662 + 'default' => Snapshot_Migrator::CONFLICT_SKIP,
663 + 'sanitize_callback' => 'sanitize_text_field',
664 + ],
454 665 'type' => [
455 666 'required' => true,
456 667 'type' => 'string',
457 - 'enum' => self::ALLOWED_TYPES,
668 + 'enum' => $this->get_allowed_types(),
458 669 'sanitize_callback' => 'sanitize_text_field',
459 670 ],
460 671 'page' => [
461 672 'required' => true,