'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;
}
}
}