← All changes
|
includes/admin/importers/class-import-controller.php
+179
-3
2.3.0
→
2.13.0
View file →
| @@ -41,9 +41,9 @@ | ||
| 41 | 41 | * Kept separate from ALLOWED_PLUGINS because cleanup() deletes the source |
| 42 | 42 | * plugin's live data: the native ThinkRank slug must never reach it, or the |
| 43 | 43 | * endpoint gains a path that wipes our own meta and options. |
| 44 | 44 | */ |
| 45 | - private const SOURCE_PLUGINS = ['yoast', 'rankmath', 'seopress', 'aioseo']; | |
| 45 | + private const SOURCE_PLUGINS = ['yoast', 'rankmath', 'seopress', 'aioseo', 'squirrly', 'slimseo']; | |
| 46 | 46 | |
| 47 | 47 | /** |
| 48 | 48 | * Snapshot slug for ThinkRank's own data (export / backup / restore). |
| 49 | 49 | */ |
| @@ -52,9 +52,9 @@ | ||
| 52 | 52 | /** |
| 53 | 53 | * Allowed plugin slugs for the snapshot endpoints (export, migrate, |
| 54 | 54 | * snapshot delete). Includes the native slug; cleanup uses SOURCE_PLUGINS. |
| 55 | 55 | */ |
| 56 | - private const ALLOWED_PLUGINS = ['yoast', 'rankmath', 'seopress', 'aioseo', 'thinkrank']; | |
| 56 | + private const ALLOWED_PLUGINS = ['yoast', 'rankmath', 'seopress', 'aioseo', 'squirrly', 'slimseo', 'thinkrank']; | |
| 57 | 57 | |
| 58 | 58 | /** |
| 59 | 59 | * Allowed export/migrate types. Must cover every type the exporters and |
| 60 | 60 | * the frontend workflow (useImportWorkflow.js EXPORT_TYPES) can send — |
| @@ -60,9 +60,17 @@ | ||
| 60 | 60 | * the frontend workflow (useImportWorkflow.js EXPORT_TYPES) can send — |
| 61 | 61 | * 404_logs was missing, so Rank Math's 404 Monitor could never be |
| 62 | 62 | * exported or migrated through REST. |
| 63 | 63 | */ |
| 64 | - 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 | + ]; | |
| 65 | 73 | |
| 66 | 74 | /** |
| 67 | 75 | * Types the export and migrate endpoints accept. |
| 68 | 76 | * |
| @@ -89,8 +97,16 @@ | ||
| 89 | 97 | [ |
| 90 | 98 | 'methods' => \WP_REST_Server::READABLE, |
| 91 | 99 | 'callback' => [$this, 'detect'], |
| 92 | 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 | + ], | |
| 93 | 109 | ], |
| 94 | 110 | ]); |
| 95 | 111 | |
| 96 | 112 | register_rest_route($this->namespace, '/' . $this->rest_base . '/export', [ |
| @@ -142,8 +158,41 @@ | ||
| 142 | 158 | ], |
| 143 | 159 | ], |
| 144 | 160 | ]); |
| 145 | 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 | + | |
| 146 | 195 | register_rest_route($this->namespace, '/' . $this->rest_base . '/snapshot', [ |
| 147 | 196 | [ |
| 148 | 197 | 'methods' => \WP_REST_Server::DELETABLE, |
| 149 | 198 | 'callback' => [$this, 'delete_snapshot'], |
| @@ -172,13 +221,22 @@ | ||
| 172 | 221 | /** |
| 173 | 222 | * GET /import/detect — Detect source plugins, ThinkRank's own exportable |
| 174 | 223 | * data, and existing snapshots |
| 175 | 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 | + * | |
| 176 | 229 | * @param \WP_REST_Request $request Request object |
| 177 | 230 | * @return \WP_REST_Response |
| 178 | 231 | */ |
| 179 | 232 | public function detect(\WP_REST_Request $request): \WP_REST_Response { |
| 180 | 233 | $detector = new Import_Detector(); |
| 234 | + | |
| 235 | + if ($request->get_param('refresh')) { | |
| 236 | + $detector->clear_cache(); | |
| 237 | + } | |
| 238 | + | |
| 181 | 239 | $detected = $detector->detect(); |
| 182 | 240 | $snapshots = Snapshot_Store::get_available_snapshots(); |
| 183 | 241 | |
| 184 | 242 | return new \WP_REST_Response([ |
| @@ -326,8 +384,12 @@ | ||
| 326 | 384 | 'yoast' => '_yoast_wpseo_', |
| 327 | 385 | 'rankmath' => 'rank_math_', |
| 328 | 386 | 'seopress' => '_seopress_', |
| 329 | 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, | |
| 330 | 392 | ]; |
| 331 | 393 | |
| 332 | 394 | $prefix = $prefix_map[$plugin] ?? null; |
| 333 | 395 | |
| @@ -354,8 +416,9 @@ | ||
| 354 | 416 | $usermeta_prefix_map = [ |
| 355 | 417 | 'yoast' => 'wpseo_', |
| 356 | 418 | 'rankmath' => 'rank_math_', |
| 357 | 419 | 'seopress' => '_seopress_', |
| 420 | + 'squirrly' => '_sq_', | |
| 358 | 421 | ]; |
| 359 | 422 | $usermeta_prefix = $usermeta_prefix_map[$plugin] ?? $prefix; |
| 360 | 423 | $deleted += (int) $wpdb->query( |
| 361 | 424 | $wpdb->prepare( |
| @@ -370,8 +433,9 @@ | ||
| 370 | 433 | $option_keys_map = [ |
| 371 | 434 | 'yoast' => ['wpseo', 'wpseo_titles', 'wpseo_social', 'wpseo_taxonomy_meta'], |
| 372 | 435 | 'rankmath' => ['rank-math-options-general', 'rank-math-options-titles', 'rank-math-options-sitemap', 'rank-math-options-instant-indexing'], |
| 373 | 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'], | |
| 374 | 438 | ]; |
| 375 | 439 | foreach ($option_keys_map[$plugin] ?? [] as $option_name) { |
| 376 | 440 | if (delete_option($option_name)) { |
| 377 | 441 | $deleted++; |
| @@ -378,8 +442,66 @@ | ||
| 378 | 442 | } |
| 379 | 443 | } |
| 380 | 444 | } |
| 381 | 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 | + | |
| 382 | 504 | if ($plugin === 'aioseo') { |
| 383 | 505 | // Drop the AIOSEO custom tables the exporter reads (posts + Pro terms). |
| 384 | 506 | foreach (['aioseo_posts', 'aioseo_terms'] as $suffix) { |
| 385 | 507 | $table = $wpdb->prefix . $suffix; |
| @@ -423,8 +545,58 @@ | ||
| 423 | 545 | ], 200); |
| 424 | 546 | } |
| 425 | 547 | |
| 426 | 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 | + /** | |
| 427 | 599 | * DELETE /import/snapshot — Delete snapshot data from wp_options |
| 428 | 600 | * |
| 429 | 601 | * @param \WP_REST_Request $request Request object |
| 430 | 602 | * @return \WP_REST_Response |
| @@ -455,8 +627,12 @@ | ||
| 455 | 627 | case 'seopress': |
| 456 | 628 | return new SEOPress_Exporter(); |
| 457 | 629 | case 'aioseo': |
| 458 | 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(); | |
| 459 | 635 | case Thinkrank_Exporter::SLUG: |
| 460 | 636 | return new Thinkrank_Exporter(); |
| 461 | 637 | default: |
| 462 | 638 | return new \WP_Error('invalid_plugin', 'Unsupported plugin: ' . $plugin, ['status' => 400]); |