'Migration', 'icon' => 'Import', 'description' => 'Import settings from WP Rocket, W3 Total Cache, or WP Super Cache.', 'custom_panel' => 'MigrationPanel', ); } public function settings_schema(): array { return array(); } /** * Per-user meta key recording which detected source the user dismissed * the migration notice for. Keyed by source so dismissing the LiteSpeed * prompt doesn't hide a later WP Rocket prompt. */ private const DISMISS_META = 'xspeed_migration_notice_dismissed'; /** Query arg used by the one-click dismiss link. */ private const DISMISS_ARG = 'xspeed_dismiss_migration'; /** * Per-user list of source ids the user has already SEEN (by opening the * Migration panel). Seen sources don't count toward the sidebar badge — * the badge means "new importable plugins you haven't looked at yet", so * it clears once the user visits the page. A plugin installed LATER is * still un-seen, so it re-badges. */ private const SEEN_META = 'xspeed_migration_seen_sources'; public function boot(): void { // Dashboard nudge: when another caching plugin is detected, offer a // one-click import — the same "we noticed you use X" prompt other // plugins show. Renders on standard WP admin screens (NOT xSpeed's // own pages, where the Migration panel already covers it). add_action( 'admin_notices', array( $this, 'maybe_render_notice' ) ); add_action( 'admin_init', array( $this, 'handle_dismiss' ) ); // Sidebar attention badge: surface the count of importable plugins on // the Migration nav item so the user knows there's an action to take. add_filter( 'xspeed_module_descriptor', array( $this, 'add_sidebar_badge' ), 10, 2 ); } /** * Badge the Migration module's sidebar item with the count of importable * caching plugins the user hasn't SEEN or dismissed yet — surfaces at a * glance how many NEW sources they could migrate from. Opening the panel * marks sources seen (see rest_status), so the badge clears after a visit. * Other modules untouched. * * @param array $entry Module descriptor being built. * @param object $module The module instance. * @return array */ public function add_sidebar_badge( array $entry, $module ): array { if ( ( $entry['slug'] ?? '' ) !== self::SLUG ) { return $entry; } $uid = get_current_user_id(); $dismissed = (array) get_user_meta( $uid, self::DISMISS_META, true ); $seen = (array) get_user_meta( $uid, self::SEEN_META, true ); $count = 0; foreach ( $this->detected_sources( $dismissed ) as $s ) { if ( ! in_array( $s['id'], $seen, true ) ) { ++$count; } } if ( $count > 0 ) { $entry['badge'] = $count; } return $entry; } /** * Render the migration nudge on the dashboard when exactly one importable * source is detected and the user hasn't dismissed it. Kept deliberately * conservative: skipped on xSpeed's own screens, for users without * manage_options, and once dismissed. */ public function maybe_render_notice(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } // Don't double up on xSpeed's own pages — the Migration panel is right there. if ( class_exists( '\\XSpeed\\Admin' ) && \XSpeed\Admin::is_plugin_page() ) { return; } $dismissed = (array) get_user_meta( get_current_user_id(), self::DISMISS_META, true ); $detected = $this->detected_sources( $dismissed ); if ( empty( $detected ) ) { return; } $brand = $this->branding_name(); $base_url = admin_url( 'admin.php?page=xspeed' ); // The dashboard selects the panel from the URL hash. The hash must be // the LAST thing in the URL — any query arg (e.g. ?source=…) has to go // BEFORE the '#', or it becomes part of the fragment ("migration?source=…") // which no module slug matches, so the app falls back to the first // panel (#cache). That was the "Import goes to #cache" bug. $panel_url = $base_url . '#migration'; $dismiss_url = wp_nonce_url( add_query_arg( self::DISMISS_ARG, 'all' ), 'xspeed_dismiss_migration_all' ); $count = count( $detected ); // Branded card. All inline-styled (admin-notice context has no // bundled stylesheet) but mapped to DESIGN.md tokens: accent #2563eb, // neutral text #1e293b / #475569, rounded-lg, comfortable padding. $heading = sprintf( /* translators: %d: number of detected caching plugins. */ _n( 'Migrate to %1$s — %2$d caching plugin detected', 'Migrate to %1$s — %2$d caching plugins detected', $count, 'xspeed' ), $brand, $count ); $brand_color = $this->brand_color(); // Brand-color the Import CTAs (override WP's default blue primary). // This notice is echoed directly (not through wp_kses), so an inline // '; echo '
'; echo '
'; // Header row: brand mark + heading. echo '
'; $logo = $this->branding_logo(); if ( '' !== $logo ) { echo '' . $logo . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- logo is a sanitized inline SVG from branding, escaped at source. } echo '' . esc_html( $heading ) . ''; echo '
'; echo '

' . esc_html__( 'Import your existing settings in one click instead of configuring everything by hand. Pick a source to migrate:', 'xspeed' ) . '

'; // One row per detected source: label + value count + its own Import button. echo '
'; foreach ( $detected as $s ) { // Query arg BEFORE the hash so the dashboard still reads #migration. $src_url = $base_url . '&source=' . rawurlencode( $s['id'] ) . '#migration'; echo '
'; $mapped = (int) ( $s['mapped_count'] ?? 0 ); echo '' . esc_html( $s['label'] ) . '' . ' ' . esc_html( sprintf( /* translators: %d: number of settings xSpeed will actually import. */ _n( 'imports %d setting', 'imports %d settings', $mapped, 'xspeed' ), $mapped ) ) . ''; echo '' . esc_html__( 'Import', 'xspeed' ) . ''; echo '
'; } echo '
'; // Footer: open the full panel + dismiss the whole notice. echo '

'; echo '' . esc_html__( 'Open Migration panel', 'xspeed' ) . ''; echo '' . esc_html__( 'Dismiss', 'xspeed' ) . ''; echo '

'; echo '
'; } /** * Detected sources that are still actionable — not dismissed and not * already imported — richest first. These are what the dashboard notice * and the sidebar badge count: "new caching plugins you could migrate * from". Once imported, a source drops out. * * @param string[] $dismissed Dismissed source ids ('all' hides every one). * @return array */ private function detected_sources( array $dismissed = array() ): array { if ( in_array( 'all', $dismissed, true ) ) { return array(); } $out = array(); foreach ( Migration::status() as $s ) { if ( empty( $s['detected'] ) || ! empty( $s['imported'] ) || in_array( $s['id'], $dismissed, true ) ) { continue; } $out[] = $s; } // Order by the honest mapped count (what we actually import). usort( $out, static fn( $a, $b ) => (int) $b['mapped_count'] <=> (int) $a['mapped_count'] ); return $out; } /** Inline brand logo SVG when white-label supplies one; else empty. */ private function branding_logo(): string { $brand = apply_filters( 'xspeed_branding', array() ); return isset( $brand['logo_svg'] ) && is_string( $brand['logo_svg'] ) ? $brand['logo_svg'] : ''; } /** Persist the per-source dismissal when the user clicks our Dismiss link. */ public function handle_dismiss(): void { if ( ! isset( $_GET[ self::DISMISS_ARG ] ) || ! current_user_can( 'manage_options' ) ) { return; } $source = sanitize_key( wp_unslash( $_GET[ self::DISMISS_ARG ] ) ); if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'xspeed_dismiss_migration_' . $source ) ) { return; } $uid = get_current_user_id(); $dismissed = (array) get_user_meta( $uid, self::DISMISS_META, true ); if ( ! in_array( $source, $dismissed, true ) ) { $dismissed[] = $source; update_user_meta( $uid, self::DISMISS_META, $dismissed ); } // Redirect to drop the query args so a reload doesn't re-trigger. wp_safe_redirect( remove_query_arg( array( self::DISMISS_ARG, '_wpnonce' ) ) ); exit; } /** * The single most relevant detected source to nudge about, or null. * Picks the detected source with the most settings (the richest import), * skipping any the user has already dismissed — so dismissing the top * prompt surfaces the next source rather than going silent while another * importable plugin is still present. Only one prompt at a time keeps the * dashboard uncluttered. * * @param string[] $dismissed Source ids the user has dismissed. * @return array{id:string,label:string,value_count:int}|null */ private function top_detected_source( array $dismissed = array() ): ?array { $best = null; foreach ( Migration::status() as $s ) { if ( empty( $s['detected'] ) || in_array( $s['id'], $dismissed, true ) ) { continue; } if ( null === $best || (int) $s['value_count'] > (int) $best['value_count'] ) { $best = $s; } } return $best; } /** Brand name honoring Pro white-label, falling back to "xSpeed". */ private function branding_name(): string { $brand = apply_filters( 'xspeed_branding', array() ); return isset( $brand['name'] ) && '' !== $brand['name'] ? (string) $brand['name'] : 'xSpeed'; } /** * Brand/logo color for the notice accent + Import buttons. White-label * sites can set `brand_color` via the xspeed_branding filter; otherwise * we use the xSpeed logo color (near-black), not the design blue accent — * the notice should match the on-screen logo. (FBS-82379) */ private function brand_color(): string { $brand = apply_filters( 'xspeed_branding', array() ); $color = isset( $brand['brand_color'] ) ? (string) $brand['brand_color'] : ''; return preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color ) ? $color : '#1e1e1e'; } public function rest_routes(): array { return array( array( 'path' => '/status', 'methods' => 'GET', 'callback' => array( $this, 'rest_status' ), ), array( 'path' => '/preview', 'methods' => 'POST', 'callback' => array( $this, 'rest_preview' ), ), array( 'path' => '/apply', 'methods' => 'POST', 'callback' => array( $this, 'rest_apply' ), ), ); } public function rest_status( \WP_REST_Request $request ) { $status = Migration::status(); // Opening the Migration panel triggers this call — treat it as the // user having SEEN every currently-detected source, which clears the // sidebar count badge. Record the detected ids against the user. $this->mark_sources_seen( $status ); return rest_ensure_response( array( 'sources' => $status ) ); } /** * Record the currently-detected source ids as seen for this user, so the * sidebar badge stops counting them. Merges with any prior seen set. * * @param array $status Output of Migration::status(). */ private function mark_sources_seen( array $status ): void { $uid = get_current_user_id(); if ( ! $uid ) { return; } $seen = (array) get_user_meta( $uid, self::SEEN_META, true ); $add = array(); foreach ( $status as $s ) { if ( ! empty( $s['detected'] ) ) { $add[] = $s['id']; } } $merged = array_values( array_unique( array_merge( $seen, $add ) ) ); if ( $merged !== $seen ) { update_user_meta( $uid, self::SEEN_META, $merged ); } } public function rest_preview( \WP_REST_Request $request ) { $params = $request->get_json_params(); $source = isset( $params['source'] ) ? (string) $params['source'] : ''; $patch = Migration::preview( $source ); if ( null === $patch ) { return new \WP_Error( 'xspeed_pro_mig_no_source', 'Source not detected or unknown.', array( 'status' => 404 ) ); } return rest_ensure_response( array( 'patch' => $patch ) ); } /** * Source id → its plugin file (folder/main.php), so we can deactivate the * source after a successful import. Running two page caches at once causes * double-caching / conflicting drop-ins, so the source must be turned off. */ private const SOURCE_PLUGIN_FILE = array( 'wp-rocket' => 'wp-rocket/wp-rocket.php', 'w3-total-cache' => 'w3-total-cache/w3-total-cache.php', 'wp-super-cache' => 'wp-super-cache/wp-cache.php', 'litespeed-cache' => 'litespeed-cache/litespeed-cache.php', ); public function rest_apply( \WP_REST_Request $request ) { $params = $request->get_json_params(); $source = isset( $params['source'] ) ? (string) $params['source'] : ''; if ( '' === $source ) { return new \WP_Error( 'xspeed_pro_mig_no_source', 'Provide a source id.', array( 'status' => 400 ) ); } $results = Migration::apply( $source ); // After a successful import, deactivate the source plugin — two page // caches running together double-cache and fight over the drop-in. // We report what we did so the panel can tell the user plainly. $deactivated = false; $source_label = ''; foreach ( Migration::status() as $s ) { if ( $s['id'] === $source ) { $source_label = (string) $s['label']; break; } } if ( ! empty( $results ) ) { $deactivated = $this->deactivate_source( $source ); } if ( class_exists( '\\XSpeed\\Activity_Log' ) && ! empty( $results ) ) { \XSpeed\Activity_Log::record( 'migration_applied', $deactivated ? sprintf( 'Imported settings from %1$s and deactivated it.', $source_label ) : sprintf( 'Imported settings from %s.', $source_label ), \XSpeed\Activity_Log::INFO ); } return rest_ensure_response( array( 'results' => $results, 'deactivated' => $deactivated, 'source_label' => $source_label, ) ); } /** * Deactivate the source caching plugin (network-wide on multisite). * Returns true only if it was active and is now off. * * @param string $source Source id. * @return bool */ private function deactivate_source( string $source ): bool { $file = self::SOURCE_PLUGIN_FILE[ $source ] ?? ''; if ( '' === $file ) { return false; } // deactivate_plugins() fires each plugin's deactivation hook, and some // (e.g. WP Super Cache) call admin-only helpers like get_home_path() // in theirs. Those live in wp-admin/includes/file.php — NOT loaded // during a REST request — so without these includes the deactivation // hook fatals with "undefined function get_home_path()". Load the // admin plumbing first so any source plugin's teardown runs cleanly. foreach ( array( 'plugin.php', 'file.php', 'misc.php' ) as $inc ) { require_once ABSPATH . 'wp-admin/includes/' . $inc; } if ( ! is_plugin_active( $file ) ) { return false; } deactivate_plugins( $file ); // network-wide if it was network-active. return ! is_plugin_active( $file ); } public function cli_commands(): array { return array( array( 'name' => 'xspeed migrate', 'callback' => array( $this, 'cli_handler' ), 'shortdesc' => 'Import settings from another caching plugin.', 'synopsis' => array( array( 'type' => 'positional', 'name' => 'action', 'options' => array( 'status', 'preview', 'apply' ), 'optional' => true, ), array( 'type' => 'assoc', 'name' => 'source', 'optional' => true, ), ), ), ); } public function cli_handler( array $args, array $assoc ): void { $action = $args[0] ?? 'status'; switch ( $action ) { case 'status': foreach ( Migration::status() as $s ) { \WP_CLI::log( sprintf( '%-20s %s %d values', $s['id'], $s['detected'] ? 'DETECTED' : 'missing ', $s['value_count'] ) ); } return; case 'preview': $src = (string) ( $assoc['source'] ?? '' ); $p = Migration::preview( $src ); if ( null === $p ) { \WP_CLI::error( 'Source not detected or unknown: ' . $src ); } \WP_CLI::log( wp_json_encode( $p, JSON_PRETTY_PRINT ) ); return; case 'apply': $src = (string) ( $assoc['source'] ?? '' ); $r = Migration::apply( $src ); if ( empty( $r ) ) { \WP_CLI::error( 'Nothing imported.' ); } foreach ( $r as $mod => $info ) { \WP_CLI::log( sprintf( '%-20s %s — %s', $mod, $info['ok'] ? 'ok' : 'failed', implode( ',', $info['applied'] ) ) ); } \WP_CLI::success( 'Import complete.' ); return; } } }