'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 '
'; // Says what the button does and what it does NOT do. The old copy // mentioned only the import while the REST route also deactivated the // source — this notice is the first and most-seen touchpoint, so it // undersold a destructive action. Deactivation is now opt-in, and the // copy states that rather than leaving it to be inferred. (#189) echo '

' . esc_html__( 'Import your existing settings instead of configuring everything by hand. You choose what happens to the old plugin — switch it off (recommended, since two page caches conflict) or leave it running. 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 ) ); } 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 ) ); } /* * Deactivating the source is the CALLER's decision, and it defaults to * NO. (#189) * * This used to happen unconditionally: the request carried only * `source`, so the server could not distinguish "the user clicked * through our warning" from any other POST to this route. The only * guard rail was an InlineConfirm in the React client, which is the * wrong layer for a destructive action — and WP-CLI and MCP, hitting * the same product action, did the opposite and left the plugin on. * * Defaulting to false rather than true is what makes the documented * contract true again (docs/user/advanced-migration.md said migration * "never changes" the old plugin) and matches the house rule that we * never modify another plugin's state on our own initiative. The panel * now passes deactivate:true explicitly after its confirm, so the * common path is unchanged for users. */ $deactivate = ! empty( $params['deactivate_source'] ); $results = Migration::apply( $source ); $deactivated = false; $source_label = ''; foreach ( Migration::status() as $s ) { if ( $s['id'] === $source ) { $source_label = (string) $s['label']; break; } } /* * Did the import actually cover anything? * * Gate on `applied`, NOT on `ok`. `ok` is update_option()'s return * value, which is FALSE when the stored value did not change — so a * re-import of settings already in place reports ok:false on every * module while having succeeded completely. Gating on `ok` therefore * refused to deactivate after a perfectly good second import, which * is how this read on a live site: {"cache":{"ok":false,"applied": * ["cache_expiry","excluded_urls"]}}. * * `applied` lists the keys the import decided were meaningful, so a * non-empty one means the source really was read and mapped. An empty * $results (unknown source, nothing meaningful) still blocks * deactivation, which is the case that matters: never switch a plugin * off on the back of an import that did nothing. (#189) */ $imported_something = false; foreach ( (array) $results as $info ) { if ( is_array( $info ) && ! empty( $info['applied'] ) ) { $imported_something = true; break; } } $refused = ''; $refused_message = ''; if ( $deactivate && $imported_something ) { if ( ! $this->can_deactivate( $source ) ) { // Not an error: the import succeeded and is the thing the user // asked for. Report the refusal so the panel can say why the // plugin is still on rather than silently implying it is off. $refused = 'insufficient_capability'; // Name WHO can do it, not just that the caller cannot. On a // network-activated source the answer is specifically a network // administrator, and a site admin has no way to work that out // from a bare capability code. (#189 AC5) $file = Migration::plugin_file( $source ); $network_scoped = is_multisite() && '' !== $file && is_plugin_active_for_network( $file ); $refused_message = $network_scoped ? sprintf( /* translators: %s: source plugin label. */ __( '%s is activated across the whole network, so only a network administrator can switch it off. Your settings were imported — ask a network administrator to deactivate it.', 'xspeed' ), $source_label ) : sprintf( /* translators: %s: source plugin label. */ __( 'Your account can change settings but not switch plugins off, so %s is still active. Your settings were imported — ask an administrator to deactivate it.', 'xspeed' ), $source_label ); } else { $deactivated = $this->deactivate_source( $source ); } } // The user declined (or was refused) and the source is still running. // Record it so the warning OUTLIVES this screen — see pending_source(). // Called on every import, not just the declining ones: the helper // checks the plugin's live state and clears itself when it is off, so // a later "import and switch" also resolves an earlier warning. if ( $imported_something ) { Migration::remember_active_source( $source, $source_label ); } 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, // Empty unless we were asked to deactivate and declined to. // The panel needs to distinguish "you didn't ask" from "you // asked and you may not", or it would report the source as // still active with no explanation. 'refused' => $refused, // A ready-to-show sentence naming who CAN do it. The panel // prints this verbatim rather than mapping codes to copy, so // the network-vs-site distinction stays in one place. 'refused_message' => $refused_message, ) ); } /** * May the CURRENT user switch this source plugin off? * * The route itself only requires `manage_options` (the module default), * which is right for importing settings — that writes nothing but our own * options. Deactivating somebody else's plugin is a different act, and WP * core guards its own plugins screen with `activate_plugins`, escalating * to `manage_network_plugins` for a network-active plugin. * * Without this check a subsite Administrator — who has manage_options but * neither of those — could deactivate a NETWORK-ACTIVE caching plugin * across every site in the network with one REST call. Reproduced on a * live multisite install for #189; core would have refused the same user * on wp-admin/plugins.php. * * @param string $source Source id. */ private function can_deactivate( string $source ): bool { $file = Migration::plugin_file( $source ); if ( '' === $file ) { return false; } foreach ( array( 'plugin.php' ) as $inc ) { require_once ABSPATH . 'wp-admin/includes/' . $inc; } // Network-active plugins are a network-level object: deactivating one // affects every site, so it needs the network capability regardless of // how much power the caller holds on this one site. if ( is_multisite() && is_plugin_active_for_network( $file ) ) { return current_user_can( 'manage_network_plugins' ); } return current_user_can( 'activate_plugins' ); } /** * Deactivate the source caching plugin (network-wide on multisite). * Returns true only if it was active and is now off. * * Callers MUST gate this on can_deactivate() — it performs no capability * check of its own, because the CLI path resolves permission differently * (a WP-CLI operator is root by definition and has no current user). * * @param string $source Source id. * @return bool */ private function deactivate_source( string $source ): bool { // One home for this map, shared with Migration::status()'s active // flag. A private copy here could drift and deactivate a plugin the // panel had reported as inactive. (#189) $file = Migration::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; } /* * Be EXPLICIT about scope rather than leaving $network_wide at null. * * Core evaluates `( false !== $network_wide ) && is_plugin_active_for_network()`, * and `false !== null` is true — so the default silently takes the * network-wide branch. That is the correct scope for a network-active * plugin (a per-site deactivation would not turn it off anyway), but * it should be a decision we state, not a fact of PHP's comparison * rules. can_deactivate() has already required the matching * capability for whichever branch this picks. (#189) */ $network_wide = is_multisite() && is_plugin_active_for_network( $file ); deactivate_plugins( $file, false, $network_wide ); 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, ), array( 'type' => 'flag', 'name' => 'deactivate-source', 'description' => 'After a successful import, also deactivate the source plugin. Off by default: running two page caches at once breaks both, but switching off another plugin is your call, not ours.', '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 ) { /* * "failed" was a lie. `ok` is update_option()'s return, * which is false when the stored value did not CHANGE — so * re-importing settings already in place printed * "failed" for every module beside the list of fields it * had just imported correctly. Report what actually * happened instead. (#189) */ $applied = (array) ( $info['applied'] ?? array() ); if ( empty( $applied ) ) { $state = 'nothing to import'; } elseif ( ! empty( $info['ok'] ) ) { $state = 'imported'; } else { $state = 'already up to date'; } \WP_CLI::log( sprintf( '%-20s %-18s %s', $mod, $state, implode( ',', $applied ) ) ); } /* * Same contract as REST: deactivate only when asked. This path * used to never deactivate AND never say so, so an operator * (or an AI through MCP `run_command`) finished with two page * caches live on the site and nothing in the output to say it. * That is the failure mode the troubleshooting docs describe * as breaking caching for both plugins. (#189) * * No capability check here: a WP-CLI caller is root by * definition and there is no current user to test. The gate * that matters is on the REST route, which is the one a * browser can reach. */ // `applied`, not `ok` — see rest_apply() for why ok:false is a // normal outcome of a successful re-import. $imported_something = false; foreach ( (array) $r as $info ) { if ( is_array( $info ) && ! empty( $info['applied'] ) ) { $imported_something = true; break; } } // WP-CLI normalises --deactivate-source to a 'deactivate-source' // key; accept the underscore spelling too so MCP callers passing // options as JSON don't have to guess which one we mean. $want_off = ! empty( $assoc['deactivate-source'] ) || ! empty( $assoc['deactivate_source'] ); $file = Migration::plugin_file( $src ); require_once ABSPATH . 'wp-admin/includes/plugin.php'; $still_on = '' !== $file && is_plugin_active( $file ); if ( $want_off && $imported_something && $still_on ) { if ( $this->deactivate_source( $src ) ) { \WP_CLI::log( sprintf( 'Deactivated %s.', $src ) ); $still_on = false; } else { \WP_CLI::warning( sprintf( 'Could not deactivate %s.', $src ) ); } } if ( $still_on ) { \WP_CLI::warning( sprintf( '%s is still active. Two page caches running together fight over the drop-in and can break caching for both — deactivate it, or re-run with --deactivate-source.', $src ) ); } // Same persistent record as the REST path, so a CLI or MCP // import that leaves the source running also raises the Health // warning — the three surfaces must end in the same state for // the same input. (#189 AC4, AC10) if ( $imported_something ) { $label = ''; foreach ( Migration::status() as $s ) { if ( $s['id'] === $src ) { $label = (string) $s['label']; break; } } Migration::remember_active_source( $src, $label ); } \WP_CLI::success( 'Import complete.' ); return; default: // Without this, an unrecognised action fell out of the switch // and returned success with no output — indistinguishable from // "ran fine, nothing to report", and ok:true over MCP. \WP_CLI::error( sprintf( 'Unknown action "%s". Expected: status | preview --source= | apply --source=.', $action ) ); } } }