| 1 |
<?php |
| 2 |
/** |
| 3 |
* Migration module — one-click settings import from other caching |
| 4 |
* plugins (WP Rocket, W3 Total Cache, WP Super Cache). |
| 5 |
* |
| 6 |
* Tier: Pro per FEATURES.md "Migration" — both rows tagged Pro |
| 7 |
* (cross-plugin importer is a non-trivial value-add; LiteSpeed |
| 8 |
* doesn't have one). |
| 9 |
* |
| 10 |
* @package XSpeed |
| 11 |
*/ |
| 12 |
|
| 13 |
declare(strict_types=1); |
| 14 |
|
| 15 |
namespace XSpeed\Modules\Migration; |
| 16 |
|
| 17 |
defined( 'ABSPATH' ) || exit; |
| 18 |
|
| 19 |
use XSpeed\Module; |
| 20 |
use XSpeed\Migration; |
| 21 |
|
| 22 |
final class MigrationModule extends Module { |
| 23 |
|
| 24 |
public const SLUG = 'migration'; |
| 25 |
public const TIER = self::TIER_FREE; |
| 26 |
public const VERSION = '1.0.0'; |
| 27 |
|
| 28 |
public function ui_metadata(): array { |
| 29 |
return array( |
| 30 |
'label' => __( 'Migration', 'xspeed' ), |
| 31 |
'icon' => 'Import', |
| 32 |
'description' => __( 'Import settings from WP Rocket, W3 Total Cache, or WP Super Cache.', 'xspeed' ), |
| 33 |
'custom_panel' => 'MigrationPanel', |
| 34 |
); |
| 35 |
} |
| 36 |
|
| 37 |
public function settings_schema(): array { |
| 38 |
return array(); |
| 39 |
} |
| 40 |
|
| 41 |
/** |
| 42 |
* Per-user meta key recording which detected source the user dismissed |
| 43 |
* the migration notice for. Keyed by source so dismissing the LiteSpeed |
| 44 |
* prompt doesn't hide a later WP Rocket prompt. |
| 45 |
*/ |
| 46 |
private const DISMISS_META = 'xspeed_migration_notice_dismissed'; |
| 47 |
|
| 48 |
/** Query arg used by the one-click dismiss link. */ |
| 49 |
private const DISMISS_ARG = 'xspeed_dismiss_migration'; |
| 50 |
|
| 51 |
/** |
| 52 |
* Per-user list of source ids the user has already SEEN (by opening the |
| 53 |
* Migration panel). Seen sources don't count toward the sidebar badge — |
| 54 |
* the badge means "new importable plugins you haven't looked at yet", so |
| 55 |
* it clears once the user visits the page. A plugin installed LATER is |
| 56 |
* still un-seen, so it re-badges. |
| 57 |
*/ |
| 58 |
private const SEEN_META = 'xspeed_migration_seen_sources'; |
| 59 |
|
| 60 |
public function boot(): void { |
| 61 |
// Dashboard nudge: when another caching plugin is detected, offer a |
| 62 |
// one-click import — the same "we noticed you use X" prompt other |
| 63 |
// plugins show. Renders on standard WP admin screens (NOT xSpeed's |
| 64 |
// own pages, where the Migration panel already covers it). |
| 65 |
/* |
| 66 |
* Priority 1 put this ABOVE everything, including the page title. |
| 67 |
* |
| 68 |
* On plugins.php (and most core screens) `admin_notices` fires before |
| 69 |
* the `.wrap` div and the <h1>, so a very early notice is emitted into |
| 70 |
* the gap under the screen-options bar — outside the page's own |
| 71 |
* container, floating above the heading it belongs to. Hooking last |
| 72 |
* instead keeps it inside the normal notice flow, below the title and |
| 73 |
* above the rest, without leaving the notice system. |
| 74 |
*/ |
| 75 |
/* |
| 76 |
* Every screen EXCEPT plugins.php, which gets the later hook below — |
| 77 |
* otherwise this fires first and wins the render-once guard, putting |
| 78 |
* the notice back above the title on exactly the screen we moved it |
| 79 |
* off. |
| 80 |
* |
| 81 |
* Priority 1 so it leads the notice stack: other plugins hook at the |
| 82 |
* default 10, and a migration offer buried under three review prompts |
| 83 |
* and an upsell is one nobody reads. This is safe here in a way it was |
| 84 |
* not on plugins.php — on the screens that keep this hook, the notice |
| 85 |
* area is the page's own, so being first within it means first inside |
| 86 |
* the layout rather than adrift above the heading. |
| 87 |
*/ |
| 88 |
if ( ! $this->is_plugins_screen() ) { |
| 89 |
add_action( 'admin_notices', array( $this, 'maybe_render_notice' ), 1 ); |
| 90 |
} |
| 91 |
/* |
| 92 |
* plugins.php renders its <h1> AFTER admin_notices has already fired, |
| 93 |
* so nothing hooked there can sit below the title — the notice lands |
| 94 |
* in the gap under the screen-options bar instead, above the heading |
| 95 |
* it belongs to. This screen offers `pre_current_active_plugins`, |
| 96 |
* which runs inside `.wrap` just after the title, so the notice is |
| 97 |
* moved there and the admin_notices copy stands down (see the guard in |
| 98 |
* maybe_render_notice, which renders once per request). |
| 99 |
*/ |
| 100 |
// Priority 1 for the same reason: this hook carries WordPress's own |
| 101 |
// "Plugin activated." messages too, and ours should lead them. |
| 102 |
add_action( 'pre_current_active_plugins', array( $this, 'maybe_render_notice' ), 1 ); |
| 103 |
|
| 104 |
/* |
| 105 |
* Core's "WordPress x.y is available" nag is hooked to admin_notices |
| 106 |
* at priority 3, so on plugins.php — where our card moved to the |
| 107 |
* later `pre_current_active_plugins` to stay under the title — the nag |
| 108 |
* prints first and ours lands beneath it. |
| 109 |
* |
| 110 |
* Re-hook the nag to the same later action so the two share a |
| 111 |
* container and the order is ours, then core's. It is re-added rather |
| 112 |
* than dropped: suppressing a core update prompt to win a slot would |
| 113 |
* trade the user's security notice for our marketing, which is not a |
| 114 |
* trade we get to make. |
| 115 |
*/ |
| 116 |
if ( $this->is_plugins_screen() ) { |
| 117 |
add_action( 'admin_init', array( $this, 'reorder_core_update_nag' ) ); |
| 118 |
} |
| 119 |
add_action( 'admin_init', array( $this, 'handle_dismiss' ) ); |
| 120 |
// Migrate straight from the notice. The Import control used to be a |
| 121 |
// LINK to the dashboard, so "Import" meant "go to another screen and |
| 122 |
// find the button again" — the migration never started from the place |
| 123 |
// that offered it. This endpoint runs the same apply() the panel and |
| 124 |
// the CLI use, so all three behave identically. |
| 125 |
add_action( 'wp_ajax_xspeed_migrate_now', array( $this, 'ajax_migrate_now' ) ); |
| 126 |
// Sidebar attention badge: surface the count of importable plugins on |
| 127 |
// the Migration nav item so the user knows there's an action to take. |
| 128 |
add_filter( 'xspeed_module_descriptor', array( $this, 'add_sidebar_badge' ), 10, 2 ); |
| 129 |
} |
| 130 |
|
| 131 |
/** |
| 132 |
* Badge the Migration module's sidebar item with the count of importable |
| 133 |
* caching plugins the user hasn't SEEN or dismissed yet — surfaces at a |
| 134 |
* glance how many NEW sources they could migrate from. Opening the panel |
| 135 |
* marks sources seen (see rest_status), so the badge clears after a visit. |
| 136 |
* Other modules untouched. |
| 137 |
* |
| 138 |
* @param array $entry Module descriptor being built. |
| 139 |
* @param object $module The module instance. |
| 140 |
* @return array |
| 141 |
*/ |
| 142 |
public function add_sidebar_badge( array $entry, $module ): array { |
| 143 |
if ( ( $entry['slug'] ?? '' ) !== self::SLUG ) { |
| 144 |
return $entry; |
| 145 |
} |
| 146 |
$uid = get_current_user_id(); |
| 147 |
$dismissed = (array) get_user_meta( $uid, self::DISMISS_META, true ); |
| 148 |
$seen = (array) get_user_meta( $uid, self::SEEN_META, true ); |
| 149 |
$count = 0; |
| 150 |
foreach ( $this->detected_sources( $dismissed ) as $s ) { |
| 151 |
if ( ! in_array( $s['id'], $seen, true ) ) { |
| 152 |
++$count; |
| 153 |
} |
| 154 |
} |
| 155 |
if ( $count > 0 ) { |
| 156 |
$entry['badge'] = $count; |
| 157 |
} |
| 158 |
return $entry; |
| 159 |
} |
| 160 |
|
| 161 |
/** |
| 162 |
* Render the migration nudge on the dashboard when exactly one importable |
| 163 |
* source is detected and the user hasn't dismissed it. Kept deliberately |
| 164 |
* conservative: skipped on xSpeed's own screens, for users without |
| 165 |
* manage_options, and once dismissed. |
| 166 |
*/ |
| 167 |
public function maybe_render_notice(): void { |
| 168 |
// Two hooks feed this on plugins.php — admin_notices for every other |
| 169 |
// screen, pre_current_active_plugins so this one lands below the |
| 170 |
// title. Whichever fires first wins; the second is a no-op rather |
| 171 |
// than a duplicate card. |
| 172 |
static $rendered = false; |
| 173 |
if ( $rendered ) { |
| 174 |
return; |
| 175 |
} |
| 176 |
|
| 177 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 178 |
return; |
| 179 |
} |
| 180 |
// Don't double up on xSpeed's own pages — the Migration panel is right there. |
| 181 |
if ( class_exists( '\\XSpeed\\Admin' ) && \XSpeed\Admin::is_plugin_page() ) { |
| 182 |
return; |
| 183 |
} |
| 184 |
|
| 185 |
$dismissed = (array) get_user_meta( get_current_user_id(), self::DISMISS_META, true ); |
| 186 |
$detected = $this->detected_sources( $dismissed ); |
| 187 |
if ( empty( $detected ) ) { |
| 188 |
return; |
| 189 |
} |
| 190 |
|
| 191 |
$brand = $this->branding_name(); |
| 192 |
$base_url = admin_url( 'admin.php?page=xspeed' ); |
| 193 |
// The dashboard selects the panel from the URL hash. The hash must be |
| 194 |
// the LAST thing in the URL — any query arg (e.g. ?source=…) has to go |
| 195 |
// BEFORE the '#', or it becomes part of the fragment ("migration?source=…") |
| 196 |
// which no module slug matches, so the app falls back to the first |
| 197 |
// panel (#cache). That was the "Import goes to #cache" bug. |
| 198 |
$panel_url = $base_url . '#migration'; |
| 199 |
$dismiss_url = wp_nonce_url( |
| 200 |
add_query_arg( self::DISMISS_ARG, 'all' ), |
| 201 |
'xspeed_dismiss_migration_all' |
| 202 |
); |
| 203 |
$count = count( $detected ); |
| 204 |
|
| 205 |
// Branded card. All inline-styled (admin-notice context has no |
| 206 |
// bundled stylesheet) but mapped to DESIGN.md tokens: accent #2563eb, |
| 207 |
// neutral text #1e293b / #475569, rounded-lg, comfortable padding. |
| 208 |
$heading = sprintf( |
| 209 |
/* translators: %d: number of detected caching plugins. */ |
| 210 |
_n( |
| 211 |
'Migrate to %1$s — %2$d caching plugin detected', |
| 212 |
'Migrate to %1$s — %2$d caching plugins detected', |
| 213 |
$count, |
| 214 |
'xspeed' |
| 215 |
), |
| 216 |
$brand, |
| 217 |
$count |
| 218 |
); |
| 219 |
|
| 220 |
$rendered = true; |
| 221 |
$brand_color = $this->brand_color(); |
| 222 |
$nonce = wp_create_nonce( 'xspeed_migrate_now' ); |
| 223 |
|
| 224 |
/* |
| 225 |
* The guarantee is the headline; the product name is secondary. |
| 226 |
* |
| 227 |
* The old notice led with the mechanism ("Import your existing |
| 228 |
* settings instead of configuring everything by hand…") and spent |
| 229 |
* three lines on caveats before offering anything — the reader met the |
| 230 |
* hedging before the offer. It also put the source rows in a sub-card |
| 231 |
* even when there was only one, and its Import control was an `<a>` |
| 232 |
* that NAVIGATED to the dashboard: pressing the button in the notice |
| 233 |
* did not migrate, it moved you to a screen where you had to find the |
| 234 |
* button again. |
| 235 |
* |
| 236 |
* The consequence ("migrating deactivates X") sits under the buttons |
| 237 |
* rather than inside a dialog after the click. |
| 238 |
*/ |
| 239 |
$single = 1 === $count ? $detected[0] : null; |
| 240 |
|
| 241 |
/** |
| 242 |
* Filter the "book a call" destination shown beside the migrate CTA. |
| 243 |
* |
| 244 |
* Returning '' hides the button — a white-label build or a reseller |
| 245 |
* who cannot staff the call has to be able to drop it. It ships with a |
| 246 |
* destination so the control is real out of the box rather than a |
| 247 |
* promise nobody answers. |
| 248 |
* |
| 249 |
* @param string $url Booking URL, or '' to hide the control. |
| 250 |
*/ |
| 251 |
$call_url = (string) apply_filters( |
| 252 |
'xspeed_migration_call_url', |
| 253 |
'https://xspeedcache.com/support/' |
| 254 |
); |
| 255 |
|
| 256 |
/** |
| 257 |
* Filter the speed-guarantee wording, or remove it. |
| 258 |
* |
| 259 |
* Returning '' drops the badge and falls back to a plain benefit |
| 260 |
* headline — white-label builds and resellers who cannot honour a |
| 261 |
* guarantee must be able to switch it off. |
| 262 |
* |
| 263 |
* @param string $label Badge text. |
| 264 |
*/ |
| 265 |
$guarantee = (string) apply_filters( 'xspeed_migration_guarantee_label', __( 'Speed guarantee', 'xspeed' ) ); |
| 266 |
|
| 267 |
echo '<style>' |
| 268 |
. '.xspeed-mig{position:relative;padding:0!important;border:1px solid #e2e8f0!important;' |
| 269 |
. 'border-left:4px solid ' . esc_attr( $brand_color ) . '!important;background:#fff;}' |
| 270 |
. '.xspeed-mig__in{display:flex;align-items:flex-start;gap:16px;padding:18px 44px 18px 20px;flex-wrap:wrap;}' |
| 271 |
. '.xspeed-mig__ico{flex:0 0 44px;width:44px;height:44px;border-radius:10px;display:inline-flex;' |
| 272 |
. 'align-items:center;justify-content:center;background:' . esc_attr( $brand_color ) . ';color:#fff;}' |
| 273 |
. '.xspeed-mig__body{flex:1 1 420px;min-width:0;}' |
| 274 |
. '.xspeed-mig__hrow{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:0 0 5px;}' |
| 275 |
. '.xspeed-mig__h{margin:0;font-size:17px;font-weight:600;color:#0f172a;line-height:1.35;letter-spacing:-.01em;}' |
| 276 |
. '.xspeed-mig__badge{display:inline-flex;align-items:center;gap:5px;font-size:12px;font-weight:600;' |
| 277 |
. 'padding:4px 10px;border-radius:999px;background:' . esc_attr( $brand_color ) . '14;color:' . esc_attr( $brand_color ) . ';}' |
| 278 |
. '.xspeed-mig__p{margin:0!important;font-size:13.5px!important;color:#475569!important;line-height:1.55;}' |
| 279 |
. '.xspeed-mig__meta{margin:7px 0 0!important;font-size:12px!important;color:#94a3b8!important;}' |
| 280 |
. '.xspeed-mig__act{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-end;gap:8px;margin-left:auto;}' |
| 281 |
. '.xspeed-mig__btns{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end;}' |
| 282 |
. '.xspeed-mig__cta{background:' . esc_attr( $brand_color ) . '!important;border:1px solid ' . esc_attr( $brand_color ) . '!important;' |
| 283 |
. 'color:#fff!important;box-shadow:none!important;height:44px!important;line-height:1!important;padding:0 22px!important;' |
| 284 |
. 'display:inline-flex!important;align-items:center;gap:8px;border-radius:8px!important;font-size:14px!important;font-weight:600;}' |
| 285 |
. '.xspeed-mig__cta:hover{filter:brightness(1.12);}' |
| 286 |
. '.xspeed-mig__cta[disabled]{opacity:.6;cursor:default;}' |
| 287 |
. '.xspeed-mig__ghost{display:inline-flex;align-items:center;gap:8px;height:44px;padding:0 16px;' |
| 288 |
. 'border:1px solid #cbd5e1;border-radius:8px;background:#fff;color:#334155!important;font-size:13.5px;text-decoration:none!important;box-shadow:none;}' |
| 289 |
. '.xspeed-mig__ghost:hover{border-color:#94a3b8;color:#0f172a;}' |
| 290 |
. '.xspeed-mig__sub{font-size:12px!important;color:#94a3b8!important;text-align:right;}' |
| 291 |
. '.xspeed-mig__sub a{color:#64748b!important;text-decoration:none!important;box-shadow:none;}' |
| 292 |
. '.xspeed-mig__sub a:hover{text-decoration:underline!important;}' |
| 293 |
. '.xspeed-mig__x{position:absolute;top:11px;right:11px;width:28px;height:28px;border:0;' |
| 294 |
. 'background:transparent;color:#94a3b8!important;cursor:pointer;padding:0;text-decoration:none!important;box-shadow:none;' |
| 295 |
. 'border-radius:6px;display:inline-flex;align-items:center;justify-content:center;transition:color .15s;}' |
| 296 |
. '.xspeed-mig__x:hover{color:#0f172a!important;}' |
| 297 |
. '.xspeed-mig__rows{display:flex;flex-direction:column;gap:8px;padding:0 20px 18px;}' |
| 298 |
. '.xspeed-mig__row{display:flex;align-items:center;justify-content:space-between;gap:12px;' |
| 299 |
. 'padding:10px 14px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;}' |
| 300 |
. '.xspeed-mig__spin{display:inline-block;width:14px;height:14px;border:2px solid #ffffff66;' |
| 301 |
. 'border-top-color:#fff;border-radius:50%;animation:xspeed-mig-spin .7s linear infinite;}' |
| 302 |
. '@keyframes xspeed-mig-spin{to{transform:rotate(360deg)}}' |
| 303 |
. '</style>'; |
| 304 |
|
| 305 |
echo '<div class="notice xspeed-mig" data-nonce="' . esc_attr( $nonce ) . '">'; |
| 306 |
|
| 307 |
// Close control, top-right, as an ordinary dismiss link so it still |
| 308 |
// works with JavaScript unavailable. |
| 309 |
echo '<a href="' . esc_url( $dismiss_url ) . '" class="xspeed-mig__x" aria-label="' |
| 310 |
. esc_attr__( 'Dismiss this notice', 'xspeed' ) . '">' |
| 311 |
// Drawn glyph rather than the "×" character: at 12px the |
| 312 |
// entity renders as stray punctuation, off-centre and weight- |
| 313 |
// mismatched against the rest of the card. |
| 314 |
. '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>' |
| 315 |
. '</a>'; |
| 316 |
|
| 317 |
echo '<div class="xspeed-mig__in">'; |
| 318 |
|
| 319 |
$logo = $this->branding_logo(); |
| 320 |
echo '<span class="xspeed-mig__ico">'; |
| 321 |
echo '' !== $logo |
| 322 |
? $logo // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sanitized inline SVG from branding, escaped at source. |
| 323 |
: $this->brand_mark(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- our own bundled icon.svg, read from disk and size-capped in brand_mark(). |
| 324 |
echo '</span>'; |
| 325 |
|
| 326 |
echo '<div class="xspeed-mig__body">'; |
| 327 |
|
| 328 |
echo '<div class="xspeed-mig__hrow">'; |
| 329 |
echo '<span class="xspeed-mig__h">' . esc_html( |
| 330 |
'' !== $guarantee |
| 331 |
? __( "Guaranteed faster load times — or we'll tune it for you, free.", 'xspeed' ) |
| 332 |
: __( 'Faster load times, without configuring anything by hand.', 'xspeed' ) |
| 333 |
) . '</span>'; |
| 334 |
if ( '' !== $guarantee ) { |
| 335 |
echo '<span class="xspeed-mig__badge">' |
| 336 |
. '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>' |
| 337 |
. esc_html( $guarantee ) . '</span>'; |
| 338 |
} |
| 339 |
echo '</div>'; |
| 340 |
|
| 341 |
// Product name secondary, benefits folded into one sentence. |
| 342 |
echo '<p class="xspeed-mig__p">' . esc_html( |
| 343 |
sprintf( |
| 344 |
/* translators: %s: brand name, e.g. xSpeed Cache. */ |
| 345 |
__( '%s imports your settings in one click, then handles caching and optimization with AI-powered control.', 'xspeed' ), |
| 346 |
$brand |
| 347 |
) |
| 348 |
) . '</p>'; |
| 349 |
|
| 350 |
if ( null !== $single ) { |
| 351 |
$mapped = (int) ( $single['mapped_count'] ?? 0 ); |
| 352 |
echo '<p class="xspeed-mig__meta">' . esc_html( |
| 353 |
sprintf( |
| 354 |
/* translators: 1: detected plugin name, 2: number of settings. */ |
| 355 |
_n( |
| 356 |
'%1$s detected · %2$d setting ready to import', |
| 357 |
'%1$s detected · %2$d settings ready to import', |
| 358 |
$mapped, |
| 359 |
'xspeed' |
| 360 |
), |
| 361 |
$single['label'], |
| 362 |
$mapped |
| 363 |
) |
| 364 |
) . '</p>'; |
| 365 |
} else { |
| 366 |
echo '<p class="xspeed-mig__meta">' . esc_html( $heading ) . '</p>'; |
| 367 |
} |
| 368 |
echo '</div>'; // .body |
| 369 |
|
| 370 |
echo '<div class="xspeed-mig__act">'; |
| 371 |
echo '<div class="xspeed-mig__btns">'; |
| 372 |
if ( '' !== $call_url ) { |
| 373 |
echo '<a href="' . esc_url( $call_url ) . '" class="xspeed-mig__ghost" target="_blank" rel="noopener noreferrer">' |
| 374 |
. '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/></svg>' |
| 375 |
. esc_html__( 'Prefer we handle it? Book a call', 'xspeed' ) . '</a>'; |
| 376 |
} |
| 377 |
if ( null !== $single ) { |
| 378 |
echo '<button type="button" class="button button-primary xspeed-mig__cta" data-source="' . esc_attr( $single['id'] ) . '"' |
| 379 |
. ' data-deactivate="' . ( ! empty( $single['active'] ) ? '1' : '0' ) . '">' |
| 380 |
. esc_html( |
| 381 |
sprintf( |
| 382 |
/* translators: %s: brand name. */ |
| 383 |
__( 'Migrate to %s', 'xspeed' ), |
| 384 |
$brand |
| 385 |
) |
| 386 |
) |
| 387 |
. '</button>'; |
| 388 |
} |
| 389 |
echo '</div>'; |
| 390 |
|
| 391 |
// The consequence sits UNDER the buttons, before the click — not in a |
| 392 |
// dialog after it. |
| 393 |
if ( null !== $single ) { |
| 394 |
echo '<div class="xspeed-mig__sub">'; |
| 395 |
if ( ! empty( $single['active'] ) ) { |
| 396 |
echo esc_html( |
| 397 |
sprintf( |
| 398 |
/* translators: %s: detected plugin name. */ |
| 399 |
__( 'Migrating deactivates %s', 'xspeed' ), |
| 400 |
$single['label'] |
| 401 |
) |
| 402 |
) . ' '; |
| 403 |
} |
| 404 |
echo '<a href="' . esc_url( $panel_url ) . '">' . esc_html__( 'View migration details', 'xspeed' ) . '</a>'; |
| 405 |
echo '</div>'; |
| 406 |
} |
| 407 |
echo '</div>'; // .act |
| 408 |
|
| 409 |
echo '</div>'; // .in |
| 410 |
|
| 411 |
// Several sources: one row each, since there is no single obvious |
| 412 |
// action to promote. |
| 413 |
if ( null === $single ) { |
| 414 |
echo '<div class="xspeed-mig__rows">'; |
| 415 |
foreach ( $detected as $s ) { |
| 416 |
$mapped = (int) ( $s['mapped_count'] ?? 0 ); |
| 417 |
echo '<div class="xspeed-mig__row">'; |
| 418 |
echo '<span style="font-size:13px;color:#1e293b;"><strong>' . esc_html( $s['label'] ) . '</strong> ' |
| 419 |
. '<span style="color:#94a3b8;">' . esc_html( |
| 420 |
sprintf( |
| 421 |
/* translators: %d: number of settings imported. */ |
| 422 |
_n( 'imports %d setting', 'imports %d settings', $mapped, 'xspeed' ), |
| 423 |
$mapped |
| 424 |
) |
| 425 |
) . '</span></span>'; |
| 426 |
echo '<button type="button" class="button button-primary xspeed-mig__cta" data-source="' . esc_attr( $s['id'] ) . '"' |
| 427 |
. ' data-deactivate="' . ( ! empty( $s['active'] ) ? '1' : '0' ) . '" style="height:36px!important;padding:0 16px!important;font-size:13px!important;">' |
| 428 |
. esc_html__( 'Migrate', 'xspeed' ) . '</button>'; |
| 429 |
echo '</div>'; |
| 430 |
} |
| 431 |
echo '</div>'; |
| 432 |
} |
| 433 |
|
| 434 |
$this->print_notice_script(); |
| 435 |
|
| 436 |
echo '</div>'; |
| 437 |
} |
| 438 |
|
| 439 |
/** |
| 440 |
* Detected sources that are still actionable — not dismissed and not |
| 441 |
* already imported — richest first. These are what the dashboard notice |
| 442 |
* and the sidebar badge count: "new caching plugins you could migrate |
| 443 |
* from". Once imported, a source drops out. |
| 444 |
* |
| 445 |
* @param string[] $dismissed Dismissed source ids ('all' hides every one). |
| 446 |
* @return array<int,array{id:string,label:string,mapped_count:int}> |
| 447 |
*/ |
| 448 |
private function detected_sources( array $dismissed = array() ): array { |
| 449 |
if ( in_array( 'all', $dismissed, true ) ) { |
| 450 |
return array(); |
| 451 |
} |
| 452 |
// Migration is an optional collaborator, not a hard dependency: this |
| 453 |
// runs on `admin_notices`, which fires on EVERY admin screen. If |
| 454 |
// includes/class-migration.php is unreadable — a partial plugin |
| 455 |
// update, a stale opcache file map, a bad deploy — the autoloader |
| 456 |
// no-ops silently and the static call below fatals, taking wp-admin |
| 457 |
// down with it. That includes plugins.php, so the owner cannot even |
| 458 |
// deactivate us to recover. Degrade to "no sources detected" instead. |
| 459 |
if ( ! class_exists( '\\XSpeed\\Migration' ) ) { |
| 460 |
return array(); |
| 461 |
} |
| 462 |
$out = array(); |
| 463 |
foreach ( Migration::status() as $s ) { |
| 464 |
if ( empty( $s['detected'] ) || ! empty( $s['imported'] ) || in_array( $s['id'], $dismissed, true ) ) { |
| 465 |
continue; |
| 466 |
} |
| 467 |
$out[] = $s; |
| 468 |
} |
| 469 |
// Order by the honest mapped count (what we actually import). |
| 470 |
usort( $out, static fn( $a, $b ) => (int) $b['mapped_count'] <=> (int) $a['mapped_count'] ); |
| 471 |
return $out; |
| 472 |
} |
| 473 |
|
| 474 |
/** |
| 475 |
* The bundled xSpeed mark, inlined so it can take the tile's colour. |
| 476 |
* |
| 477 |
* `assets/icon.svg` is `fill="currentColor"`, which an `<img>` cannot |
| 478 |
* recolour — it would paint black on the brand tile. Inlining lets the |
| 479 |
* parent's `color` drive the fill, the same trick the admin menu and the |
| 480 |
* boot skeleton use on their own surfaces (DESIGN.md §24.28). |
| 481 |
* |
| 482 |
* Read from disk rather than duplicated here: the mark is a tracked build |
| 483 |
* output and a second copy in PHP is the drift that section warns about. |
| 484 |
* Falls back to a bolt glyph if the file is missing (a partial deploy), |
| 485 |
* because a notice with no icon is better than a fatal. |
| 486 |
*/ |
| 487 |
private function brand_mark(): string { |
| 488 |
$path = XSPEED_DIR . 'assets/icon.svg'; |
| 489 |
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own bundled asset; WP_Filesystem needs credentials unavailable when rendering a notice. |
| 490 |
$svg = is_readable( $path ) ? (string) @file_get_contents( $path ) : ''; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable file falls back below. |
| 491 |
if ( '' === $svg || false === strpos( $svg, '<svg' ) ) { |
| 492 |
return '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>'; |
| 493 |
} |
| 494 |
// Size it for the tile; the file ships at 20x20. |
| 495 |
return (string) preg_replace( |
| 496 |
'/\swidth="[^"]*"\s+height="[^"]*"/', |
| 497 |
' width="24" height="24"', |
| 498 |
$svg, |
| 499 |
1 |
| 500 |
); |
| 501 |
} |
| 502 |
|
| 503 |
/** Inline brand logo SVG when white-label supplies one; else empty. */ |
| 504 |
private function branding_logo(): string { |
| 505 |
$brand = apply_filters( 'xspeed_branding', array() ); |
| 506 |
return isset( $brand['logo_svg'] ) && is_string( $brand['logo_svg'] ) ? $brand['logo_svg'] : ''; |
| 507 |
} |
| 508 |
|
| 509 |
/** |
| 510 |
* Move core's update nag below our notice on plugins.php. |
| 511 |
* |
| 512 |
* Only on this screen, only that one callback, and only when it is |
| 513 |
* actually registered — every other notice on every other screen is left |
| 514 |
* exactly where its owner put it. The nag still renders; it renders |
| 515 |
* second. |
| 516 |
*/ |
| 517 |
public function reorder_core_update_nag(): void { |
| 518 |
if ( ! has_action( 'admin_notices', 'update_nag' ) ) { |
| 519 |
return; |
| 520 |
} |
| 521 |
remove_action( 'admin_notices', 'update_nag', 3 ); |
| 522 |
add_action( 'pre_current_active_plugins', 'update_nag', 2 ); |
| 523 |
} |
| 524 |
|
| 525 |
/** |
| 526 |
* Are we rendering plugins.php (or its network equivalent)? |
| 527 |
* |
| 528 |
* Checked from `boot()`, which runs before `admin_init`, so `get_current_screen()` |
| 529 |
* is not available yet — the global WordPress sets while resolving the |
| 530 |
* request is, and it is what admin-side code keys on this early. |
| 531 |
*/ |
| 532 |
private function is_plugins_screen(): bool { |
| 533 |
global $pagenow; |
| 534 |
return 'plugins.php' === $pagenow; |
| 535 |
} |
| 536 |
|
| 537 |
/** |
| 538 |
* Human name for a module slug, for copy the user reads. |
| 539 |
* |
| 540 |
* The notice showed the raw slug ("object-cache"), which is an internal |
| 541 |
* identifier — it tells someone nothing about which feature needs their |
| 542 |
* attention. Unknown slugs fall back to a title-cased form rather than |
| 543 |
* being dropped, so a Pro module added later still reads sensibly. |
| 544 |
*/ |
| 545 |
private static function module_label( string $slug ): string { |
| 546 |
$labels = array( |
| 547 |
'cache' => __( 'Page Cache', 'xspeed' ), |
| 548 |
'minify' => __( 'Minify', 'xspeed' ), |
| 549 |
'lazy' => __( 'Lazy Load', 'xspeed' ), |
| 550 |
'preloader' => __( 'Preloader', 'xspeed' ), |
| 551 |
'browser-cache' => __( 'Browser Cache', 'xspeed' ), |
| 552 |
'object-cache' => __( 'Object Cache', 'xspeed' ), |
| 553 |
'gzip' => __( 'Compression', 'xspeed' ), |
| 554 |
'bloat' => __( 'Bloat Removal', 'xspeed' ), |
| 555 |
'cdn' => __( 'CDN', 'xspeed' ), |
| 556 |
); |
| 557 |
if ( isset( $labels[ $slug ] ) ) { |
| 558 |
return $labels[ $slug ]; |
| 559 |
} |
| 560 |
return ucwords( str_replace( array( '-', '_' ), ' ', $slug ) ); |
| 561 |
} |
| 562 |
|
| 563 |
/** |
| 564 |
* Migrate from the notice, without leaving the screen. |
| 565 |
* |
| 566 |
* The notice's Import control was an `<a href>` pointing at the dashboard, |
| 567 |
* so pressing it navigated to the Migration panel and asked the user to |
| 568 |
* find the button again. The offer and the action lived on different |
| 569 |
* screens. |
| 570 |
* |
| 571 |
* Dispatches through rest_apply() rather than reimplementing it: the |
| 572 |
* import, the deactivation gate, the activity record and the partial- |
| 573 |
* failure reporting are all decided in one place, so this path cannot |
| 574 |
* drift from the panel, WP-CLI or MCP. Deactivation stays the caller's |
| 575 |
* explicit choice and is passed through as such. (#189) |
| 576 |
*/ |
| 577 |
public function ajax_migrate_now(): void { |
| 578 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 579 |
wp_send_json_error( array( 'message' => __( 'You do not have permission to migrate settings.', 'xspeed' ) ), 403 ); |
| 580 |
} |
| 581 |
check_ajax_referer( 'xspeed_migrate_now', 'nonce' ); |
| 582 |
|
| 583 |
$source = isset( $_POST['source'] ) ? sanitize_key( wp_unslash( $_POST['source'] ) ) : ''; |
| 584 |
if ( '' === $source ) { |
| 585 |
wp_send_json_error( array( 'message' => __( 'No source selected.', 'xspeed' ) ), 400 ); |
| 586 |
} |
| 587 |
$deactivate = ! empty( $_POST['deactivate'] ); |
| 588 |
|
| 589 |
// Same request shape rest_apply() reads, so one implementation serves |
| 590 |
// the panel, the CLI, MCP and this notice. |
| 591 |
$request = new \WP_REST_Request( 'POST', '/xspeed/v1/migration/apply' ); |
| 592 |
$request->set_body( (string) wp_json_encode( |
| 593 |
array( |
| 594 |
'source' => $source, |
| 595 |
'deactivate_source' => (bool) $deactivate, |
| 596 |
) |
| 597 |
) ); |
| 598 |
$request->set_header( 'content-type', 'application/json' ); |
| 599 |
|
| 600 |
$result = $this->rest_apply( $request ); |
| 601 |
if ( is_wp_error( $result ) ) { |
| 602 |
wp_send_json_error( array( 'message' => $result->get_error_message() ), 400 ); |
| 603 |
} |
| 604 |
|
| 605 |
$data = $result instanceof \WP_REST_Response ? $result->get_data() : (array) $result; |
| 606 |
|
| 607 |
// Report the SAME three outcomes the panel distinguishes: clean, |
| 608 |
// partial, and "imported but the old plugin is still running". A |
| 609 |
// notice that only ever said "Done" would hide a half-applied import. |
| 610 |
$failed = array(); |
| 611 |
foreach ( (array) ( $data['results'] ?? array() ) as $slug => $r ) { |
| 612 |
if ( empty( $r['ok'] ) ) { |
| 613 |
// Human label, not the raw slug — "Object Cache", not |
| 614 |
// "object-cache" — and the message trimmed of its own |
| 615 |
// terminator so the sentence does not end in "6379..". |
| 616 |
$msg = trim( (string) ( $r['message'] ?? '' ) ); |
| 617 |
$msg = '' !== $msg ? rtrim( $msg, '. ' ) : __( 'it could not be enabled', 'xspeed' ); |
| 618 |
// Strip the engine's "Could not enable: " prefix; the sentence |
| 619 |
// around it already says that. |
| 620 |
$msg = (string) preg_replace( '/^could not enable:\s*/i', '', $msg ); |
| 621 |
$failed[] = self::module_label( (string) $slug ) . ' — ' . $msg; |
| 622 |
} |
| 623 |
} |
| 624 |
|
| 625 |
wp_send_json_success( |
| 626 |
array( |
| 627 |
'label' => (string) ( $data['source_label'] ?? '' ), |
| 628 |
'deactivated' => ! empty( $data['deactivated'] ), |
| 629 |
'refused' => (string) ( $data['refused_message'] ?? '' ), |
| 630 |
'failed' => $failed, |
| 631 |
'panel_url' => admin_url( 'admin.php?page=xspeed' ) . '#migration', |
| 632 |
) |
| 633 |
); |
| 634 |
} |
| 635 |
|
| 636 |
/** |
| 637 |
* The notice's own behaviour: migrate in place, then report the outcome |
| 638 |
* where the offer was. |
| 639 |
* |
| 640 |
* Inline rather than an enqueued file because admin_notices renders on |
| 641 |
* every admin screen and this is a few lines — a separate request for it |
| 642 |
* would cost more than it saves. No jQuery: the notice must work on |
| 643 |
* screens that do not load it. |
| 644 |
*/ |
| 645 |
private function print_notice_script(): void { |
| 646 |
$ajax = esc_url_raw( admin_url( 'admin-ajax.php' ) ); |
| 647 |
?> |
| 648 |
<script> |
| 649 |
(function () { |
| 650 |
var box = document.currentScript && document.currentScript.closest('.xspeed-mig'); |
| 651 |
if (!box) { return; } |
| 652 |
var nonce = box.getAttribute('data-nonce') || ''; |
| 653 |
|
| 654 |
/** |
| 655 |
* Render the outcome in the SAME card shape as the offer — icon tile, |
| 656 |
* heading, body, meta — rather than dropping to a bare paragraph. The |
| 657 |
* result is the last thing the user sees from this feature; a plain |
| 658 |
* sentence where a designed card was reads as something having gone |
| 659 |
* wrong even when nothing did. |
| 660 |
* |
| 661 |
* `tone` colours only the tile and the left rule: |
| 662 |
* ok — the import succeeded. Also covers the case where an |
| 663 |
* optional module could not enable itself (no Redis for the |
| 664 |
* object cache, say): the migration did what it was asked, |
| 665 |
* and the server's own limits are not a failure of it. That |
| 666 |
* detail rides along in the meta line as "Skipped: …", |
| 667 |
* which is a footnote rather than a task. |
| 668 |
* partial — kept for a genuinely mixed result. |
| 669 |
* error — nothing was imported |
| 670 |
*/ |
| 671 |
function say(tone, title, body, meta) { |
| 672 |
var colour = tone === 'error' ? '#dc2626' : (tone === 'partial' ? '#d97706' : '#16a34a'); |
| 673 |
var glyph = tone === 'error' |
| 674 |
? '<path d="M18 6 6 18M6 6l12 12"/>' |
| 675 |
: (tone === 'partial' |
| 676 |
? '<path d="M12 9v4M12 17h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/>' |
| 677 |
: '<path d="M20 6 9 17l-5-5"/>'); |
| 678 |
|
| 679 |
box.style.borderLeftColor = colour; |
| 680 |
box.innerHTML = |
| 681 |
'<div class="xspeed-mig__in">' + |
| 682 |
'<span class="xspeed-mig__ico" style="background:' + colour + '">' + |
| 683 |
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' + |
| 684 |
'stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">' + glyph + '</svg>' + |
| 685 |
'</span>' + |
| 686 |
'<div class="xspeed-mig__body">' + |
| 687 |
'<div class="xspeed-mig__hrow"><span class="xspeed-mig__h">' + title + '</span></div>' + |
| 688 |
(body ? '<p class="xspeed-mig__p">' + body + '</p>' : '') + |
| 689 |
(meta ? '<p class="xspeed-mig__meta">' + meta + '</p>' : '') + |
| 690 |
'</div>' + |
| 691 |
'</div>'; |
| 692 |
} |
| 693 |
|
| 694 |
box.addEventListener('click', function (e) { |
| 695 |
var btn = e.target.closest('.xspeed-mig__cta'); |
| 696 |
if (!btn) { return; } |
| 697 |
e.preventDefault(); |
| 698 |
if (btn.disabled) { return; } |
| 699 |
|
| 700 |
var label = btn.textContent; |
| 701 |
btn.disabled = true; |
| 702 |
btn.innerHTML = '<span class="xspeed-mig__spin"></span> <?php echo esc_js( __( 'Migrating…', 'xspeed' ) ); ?>'; |
| 703 |
|
| 704 |
var body = new URLSearchParams(); |
| 705 |
body.set('action', 'xspeed_migrate_now'); |
| 706 |
body.set('nonce', nonce); |
| 707 |
body.set('source', btn.getAttribute('data-source') || ''); |
| 708 |
body.set('deactivate', btn.getAttribute('data-deactivate') || '0'); |
| 709 |
|
| 710 |
fetch(<?php echo wp_json_encode( $ajax ); ?>, { |
| 711 |
method: 'POST', |
| 712 |
credentials: 'same-origin', |
| 713 |
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 714 |
body: body.toString() |
| 715 |
}) |
| 716 |
.then(function (r) { return r.json(); }) |
| 717 |
.then(function (res) { |
| 718 |
if (!res || !res.success) { |
| 719 |
throw new Error((res && res.data && res.data.message) || '<?php echo esc_js( __( 'Migration failed.', 'xspeed' ) ); ?>'); |
| 720 |
} |
| 721 |
var d = res.data || {}; |
| 722 |
var name = d.label || '<?php echo esc_js( __( 'the source plugin', 'xspeed' ) ); ?>'; |
| 723 |
var panel = '<a href="' + d.panel_url + '"><?php echo esc_js( __( 'Review imported settings', 'xspeed' ) ); ?></a>'; |
| 724 |
|
| 725 |
// Three outcomes, because a flat "Done" would hide a |
| 726 |
// half-applied import or a plugin still running. |
| 727 |
if (d.failed && d.failed.length) { |
| 728 |
// The import SUCCEEDED — one optional group could not be |
| 729 |
// enabled (no Redis for the object cache, say). Reporting |
| 730 |
// that in red read as a failed migration and sent people |
| 731 |
// looking for a problem that was not there. |
| 732 |
say( |
| 733 |
'ok', |
| 734 |
name + ' <?php echo esc_js( __( 'settings are now running in xSpeed.', 'xspeed' ) ); ?>', |
| 735 |
(d.deactivated |
| 736 |
? '<?php echo esc_js( __( 'It has been switched off, so the two page caches cannot conflict.', 'xspeed' ) ); ?>' |
| 737 |
: (d.refused || '<?php echo esc_js( __( 'It is still active — switch it off when you are ready.', 'xspeed' ) ); ?>')), |
| 738 |
'<?php echo esc_js( __( 'Skipped:', 'xspeed' ) ); ?> ' + d.failed.join('; ') + '. ' + panel |
| 739 |
); |
| 740 |
return; |
| 741 |
} |
| 742 |
if (d.deactivated) { |
| 743 |
say( |
| 744 |
'ok', |
| 745 |
name + ' <?php echo esc_js( __( 'settings are now running in xSpeed.', 'xspeed' ) ); ?>', |
| 746 |
'<?php echo esc_js( __( 'It has been switched off, so the two page caches cannot conflict.', 'xspeed' ) ); ?>', |
| 747 |
panel |
| 748 |
); |
| 749 |
return; |
| 750 |
} |
| 751 |
say( |
| 752 |
'ok', |
| 753 |
name + ' <?php echo esc_js( __( 'settings are now running in xSpeed.', 'xspeed' ) ); ?>', |
| 754 |
d.refused || '<?php echo esc_js( __( 'It is still active — running two page caches conflicts, so switch it off when you are ready.', 'xspeed' ) ); ?>', |
| 755 |
panel |
| 756 |
); |
| 757 |
}) |
| 758 |
.catch(function (err) { |
| 759 |
btn.disabled = false; |
| 760 |
btn.textContent = label; |
| 761 |
say( |
| 762 |
'error', |
| 763 |
'<?php echo esc_js( __( 'Migration could not finish.', 'xspeed' ) ); ?>', |
| 764 |
(err.message || '') + ' <?php echo esc_js( __( 'Nothing was changed — you can try again.', 'xspeed' ) ); ?>', |
| 765 |
'' |
| 766 |
); |
| 767 |
}); |
| 768 |
}); |
| 769 |
})(); |
| 770 |
</script> |
| 771 |
<?php |
| 772 |
} |
| 773 |
|
| 774 |
/** Persist the per-source dismissal when the user clicks our Dismiss link. */ |
| 775 |
public function handle_dismiss(): void { |
| 776 |
if ( ! isset( $_GET[ self::DISMISS_ARG ] ) || ! current_user_can( 'manage_options' ) ) { |
| 777 |
return; |
| 778 |
} |
| 779 |
$source = sanitize_key( wp_unslash( $_GET[ self::DISMISS_ARG ] ) ); |
| 780 |
if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'xspeed_dismiss_migration_' . $source ) ) { |
| 781 |
return; |
| 782 |
} |
| 783 |
$uid = get_current_user_id(); |
| 784 |
$dismissed = (array) get_user_meta( $uid, self::DISMISS_META, true ); |
| 785 |
if ( ! in_array( $source, $dismissed, true ) ) { |
| 786 |
$dismissed[] = $source; |
| 787 |
update_user_meta( $uid, self::DISMISS_META, $dismissed ); |
| 788 |
} |
| 789 |
// Redirect to drop the query args so a reload doesn't re-trigger. |
| 790 |
wp_safe_redirect( remove_query_arg( array( self::DISMISS_ARG, '_wpnonce' ) ) ); |
| 791 |
exit; |
| 792 |
} |
| 793 |
|
| 794 |
/** |
| 795 |
* The single most relevant detected source to nudge about, or null. |
| 796 |
* Picks the detected source with the most settings (the richest import), |
| 797 |
* skipping any the user has already dismissed — so dismissing the top |
| 798 |
* prompt surfaces the next source rather than going silent while another |
| 799 |
* importable plugin is still present. Only one prompt at a time keeps the |
| 800 |
* dashboard uncluttered. |
| 801 |
* |
| 802 |
* @param string[] $dismissed Source ids the user has dismissed. |
| 803 |
* @return array{id:string,label:string,value_count:int}|null |
| 804 |
*/ |
| 805 |
private function top_detected_source( array $dismissed = array() ): ?array { |
| 806 |
// Same optional-collaborator guard as detected_sources(): this feeds |
| 807 |
// admin-render paths, so a missing class must degrade to "nothing to |
| 808 |
// prompt about" rather than fatal. See detected_sources(). |
| 809 |
if ( ! class_exists( '\\XSpeed\\Migration' ) ) { |
| 810 |
return null; |
| 811 |
} |
| 812 |
$best = null; |
| 813 |
foreach ( Migration::status() as $s ) { |
| 814 |
if ( empty( $s['detected'] ) || in_array( $s['id'], $dismissed, true ) ) { |
| 815 |
continue; |
| 816 |
} |
| 817 |
if ( null === $best || (int) $s['value_count'] > (int) $best['value_count'] ) { |
| 818 |
$best = $s; |
| 819 |
} |
| 820 |
} |
| 821 |
return $best; |
| 822 |
} |
| 823 |
|
| 824 |
/** Brand name honoring Pro white-label, falling back to "xSpeed". */ |
| 825 |
private function branding_name(): string { |
| 826 |
$brand = apply_filters( 'xspeed_branding', array() ); |
| 827 |
return isset( $brand['name'] ) && '' !== $brand['name'] ? (string) $brand['name'] : 'xSpeed'; |
| 828 |
} |
| 829 |
|
| 830 |
/** |
| 831 |
* Brand/logo color for the notice accent + Import buttons. White-label |
| 832 |
* sites can set `brand_color` via the xspeed_branding filter; otherwise |
| 833 |
* we use the xSpeed logo color (near-black), not the design blue accent — |
| 834 |
* the notice should match the on-screen logo. (FBS-82379) |
| 835 |
*/ |
| 836 |
private function brand_color(): string { |
| 837 |
$brand = apply_filters( 'xspeed_branding', array() ); |
| 838 |
$color = isset( $brand['brand_color'] ) ? (string) $brand['brand_color'] : ''; |
| 839 |
// Falls back to the product's own primary — the muted teal that |
| 840 |
// `--primary` carries in assets/theme.css. This notice renders on |
| 841 |
// stock admin screens, OUTSIDE #xspeed-app, so the CSS variable does |
| 842 |
// not resolve here and the value has to be literal. Keep the two in |
| 843 |
// step: a drifting hex is a notice that stops looking like the plugin. |
| 844 |
return preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color ) ? $color : '#2AA7A0'; |
| 845 |
} |
| 846 |
|
| 847 |
public function rest_routes(): array { |
| 848 |
return array( |
| 849 |
array( |
| 850 |
'path' => '/status', |
| 851 |
'methods' => 'GET', |
| 852 |
'callback' => array( $this, 'rest_status' ), |
| 853 |
), |
| 854 |
array( |
| 855 |
'path' => '/preview', |
| 856 |
'methods' => 'POST', |
| 857 |
'callback' => array( $this, 'rest_preview' ), |
| 858 |
), |
| 859 |
array( |
| 860 |
'path' => '/apply', |
| 861 |
'methods' => 'POST', |
| 862 |
'callback' => array( $this, 'rest_apply' ), |
| 863 |
), |
| 864 |
); |
| 865 |
} |
| 866 |
|
| 867 |
public function rest_status( \WP_REST_Request $request ) { |
| 868 |
$status = Migration::status(); |
| 869 |
// Opening the Migration panel triggers this call — treat it as the |
| 870 |
// user having SEEN every currently-detected source, which clears the |
| 871 |
// sidebar count badge. Record the detected ids against the user. |
| 872 |
$this->mark_sources_seen( $status ); |
| 873 |
return rest_ensure_response( array( 'sources' => $status ) ); |
| 874 |
} |
| 875 |
|
| 876 |
/** |
| 877 |
* Record the currently-detected source ids as seen for this user, so the |
| 878 |
* sidebar badge stops counting them. Merges with any prior seen set. |
| 879 |
* |
| 880 |
* @param array $status Output of Migration::status(). |
| 881 |
*/ |
| 882 |
private function mark_sources_seen( array $status ): void { |
| 883 |
$uid = get_current_user_id(); |
| 884 |
if ( ! $uid ) { |
| 885 |
return; |
| 886 |
} |
| 887 |
$seen = (array) get_user_meta( $uid, self::SEEN_META, true ); |
| 888 |
$add = array(); |
| 889 |
foreach ( $status as $s ) { |
| 890 |
if ( ! empty( $s['detected'] ) ) { |
| 891 |
$add[] = $s['id']; |
| 892 |
} |
| 893 |
} |
| 894 |
$merged = array_values( array_unique( array_merge( $seen, $add ) ) ); |
| 895 |
if ( $merged !== $seen ) { |
| 896 |
update_user_meta( $uid, self::SEEN_META, $merged ); |
| 897 |
} |
| 898 |
} |
| 899 |
|
| 900 |
public function rest_preview( \WP_REST_Request $request ) { |
| 901 |
$params = $request->get_json_params(); |
| 902 |
$source = isset( $params['source'] ) ? (string) $params['source'] : ''; |
| 903 |
$full = Migration::preview_with_notes( $source ); |
| 904 |
if ( null === $full ) { |
| 905 |
return new \WP_Error( 'xspeed_pro_mig_no_source', 'Source not detected or unknown.', array( 'status' => 404 ) ); |
| 906 |
} |
| 907 |
// `notes` carries the lossy-conversion warnings the panel renders |
| 908 |
// beside the plan, so a value we had to round is stated rather than |
| 909 |
// presented as an exact import. (#224 F2) |
| 910 |
return rest_ensure_response( |
| 911 |
array( |
| 912 |
'patch' => $full['patch'], |
| 913 |
'notes' => $full['notes'], |
| 914 |
) |
| 915 |
); |
| 916 |
} |
| 917 |
|
| 918 |
public function rest_apply( \WP_REST_Request $request ) { |
| 919 |
$params = $request->get_json_params(); |
| 920 |
$source = isset( $params['source'] ) ? (string) $params['source'] : ''; |
| 921 |
if ( '' === $source ) { |
| 922 |
return new \WP_Error( 'xspeed_pro_mig_no_source', 'Provide a source id.', array( 'status' => 400 ) ); |
| 923 |
} |
| 924 |
|
| 925 |
/* |
| 926 |
* Deactivating the source is the CALLER's decision, and it defaults to |
| 927 |
* NO. (#189) |
| 928 |
* |
| 929 |
* This used to happen unconditionally: the request carried only |
| 930 |
* `source`, so the server could not distinguish "the user clicked |
| 931 |
* through our warning" from any other POST to this route. The only |
| 932 |
* guard rail was an InlineConfirm in the React client, which is the |
| 933 |
* wrong layer for a destructive action — and WP-CLI and MCP, hitting |
| 934 |
* the same product action, did the opposite and left the plugin on. |
| 935 |
* |
| 936 |
* Defaulting to false rather than true is what makes the documented |
| 937 |
* contract true again (docs/user/advanced-migration.md said migration |
| 938 |
* "never changes" the old plugin) and matches the house rule that we |
| 939 |
* never modify another plugin's state on our own initiative. The panel |
| 940 |
* now passes deactivate:true explicitly after its confirm, so the |
| 941 |
* common path is unchanged for users. |
| 942 |
*/ |
| 943 |
$deactivate = ! empty( $params['deactivate_source'] ); |
| 944 |
|
| 945 |
$results = Migration::apply( $source ); |
| 946 |
|
| 947 |
$deactivated = false; |
| 948 |
$source_label = ''; |
| 949 |
foreach ( Migration::status() as $s ) { |
| 950 |
if ( $s['id'] === $source ) { |
| 951 |
$source_label = (string) $s['label']; |
| 952 |
break; |
| 953 |
} |
| 954 |
} |
| 955 |
|
| 956 |
/* |
| 957 |
* Did the import actually cover anything? |
| 958 |
* |
| 959 |
* `applied` lists the meaningful keys the import attempted, so it is the |
| 960 |
* right signal for the activity and Health records below. Deactivation |
| 961 |
* has a stronger gate: every attempted result must also report `ok`, so a |
| 962 |
* partial import never switches the source off. (#189, #224) |
| 963 |
*/ |
| 964 |
$imported_something = false; |
| 965 |
foreach ( (array) $results as $info ) { |
| 966 |
if ( is_array( $info ) && ! empty( $info['applied'] ) ) { |
| 967 |
$imported_something = true; |
| 968 |
break; |
| 969 |
} |
| 970 |
} |
| 971 |
|
| 972 |
$refused = ''; |
| 973 |
$refused_message = ''; |
| 974 |
$import_completed = Migration::completed_successfully( (array) $results ); |
| 975 |
// Gate the handover on the modules that MATTER, not on every one. |
| 976 |
// completed_successfully() is all-or-nothing, so a host without Redis |
| 977 |
// — where the object cache can never enable — vetoed the deactivation |
| 978 |
// the notice had already promised, and the user was left running two |
| 979 |
// page caches. safe_to_hand_over() ignores the optional extras and |
| 980 |
// still refuses if page caching itself did not take. (#189) |
| 981 |
if ( $deactivate && Migration::safe_to_hand_over( (array) $results ) ) { |
| 982 |
if ( ! $this->can_deactivate( $source ) ) { |
| 983 |
// Not an error: the import succeeded and is the thing the user |
| 984 |
// asked for. Report the refusal so the panel can say why the |
| 985 |
// plugin is still on rather than silently implying it is off. |
| 986 |
$refused = 'insufficient_capability'; |
| 987 |
|
| 988 |
// Name WHO can do it, not just that the caller cannot. On a |
| 989 |
// network-activated source the answer is specifically a network |
| 990 |
// administrator, and a site admin has no way to work that out |
| 991 |
// from a bare capability code. (#189 AC5) |
| 992 |
$file = Migration::plugin_file( $source ); |
| 993 |
$network_scoped = is_multisite() && '' !== $file && is_plugin_active_for_network( $file ); |
| 994 |
$refused_message = $network_scoped |
| 995 |
? sprintf( |
| 996 |
/* translators: %s: source plugin label. */ |
| 997 |
__( '%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' ), |
| 998 |
$source_label |
| 999 |
) |
| 1000 |
: sprintf( |
| 1001 |
/* translators: %s: source plugin label. */ |
| 1002 |
__( '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' ), |
| 1003 |
$source_label |
| 1004 |
); |
| 1005 |
} else { |
| 1006 |
$deactivated = $this->deactivate_source( $source ); |
| 1007 |
if ( $deactivated ) { |
| 1008 |
// Switching the source off runs ITS teardown, which |
| 1009 |
// removes WP_CACHE and can take the shared drop-in file |
| 1010 |
// with it — leaving our own page cache configured-on but |
| 1011 |
// not actually serving. Re-assert both. (#219) |
| 1012 |
$this->restore_own_environment(); |
| 1013 |
} |
| 1014 |
} |
| 1015 |
} |
| 1016 |
|
| 1017 |
// The user declined (or was refused) and the source is still running. |
| 1018 |
// Record it so the warning OUTLIVES this screen — see pending_source(). |
| 1019 |
// Called on every import, not just the declining ones: the helper |
| 1020 |
// checks the plugin's live state and clears itself when it is off, so |
| 1021 |
// a later "import and switch" also resolves an earlier warning. |
| 1022 |
if ( $imported_something ) { |
| 1023 |
Migration::remember_active_source( $source, $source_label ); |
| 1024 |
} |
| 1025 |
|
| 1026 |
if ( class_exists( '\\XSpeed\\Activity_Log' ) && ! empty( $results ) ) { |
| 1027 |
\XSpeed\Activity_Log::record( |
| 1028 |
'migration_applied', |
| 1029 |
$deactivated |
| 1030 |
? sprintf( 'Imported settings from %1$s and deactivated it.', $source_label ) |
| 1031 |
: sprintf( 'Imported settings from %s.', $source_label ), |
| 1032 |
\XSpeed\Activity_Log::INFO |
| 1033 |
); |
| 1034 |
} |
| 1035 |
|
| 1036 |
return rest_ensure_response( |
| 1037 |
array( |
| 1038 |
'results' => $results, |
| 1039 |
'deactivated' => $deactivated, |
| 1040 |
'source_label' => $source_label, |
| 1041 |
// Empty unless we were asked to deactivate and declined to. |
| 1042 |
// The panel needs to distinguish "you didn't ask" from "you |
| 1043 |
// asked and you may not", or it would report the source as |
| 1044 |
// still active with no explanation. |
| 1045 |
'refused' => $refused, |
| 1046 |
// A ready-to-show sentence naming who CAN do it. The panel |
| 1047 |
// prints this verbatim rather than mapping codes to copy, so |
| 1048 |
// the network-vs-site distinction stays in one place. |
| 1049 |
'refused_message' => $refused_message, |
| 1050 |
) |
| 1051 |
); |
| 1052 |
} |
| 1053 |
|
| 1054 |
/** |
| 1055 |
* May the CURRENT user switch this source plugin off? |
| 1056 |
* |
| 1057 |
* The route itself only requires `manage_options` (the module default), |
| 1058 |
* which is right for importing settings — that writes nothing but our own |
| 1059 |
* options. Deactivating somebody else's plugin is a different act, and WP |
| 1060 |
* core guards its own plugins screen with `activate_plugins`, escalating |
| 1061 |
* to `manage_network_plugins` for a network-active plugin. |
| 1062 |
* |
| 1063 |
* Without this check a subsite Administrator — who has manage_options but |
| 1064 |
* neither of those — could deactivate a NETWORK-ACTIVE caching plugin |
| 1065 |
* across every site in the network with one REST call. Reproduced on a |
| 1066 |
* live multisite install for #189; core would have refused the same user |
| 1067 |
* on wp-admin/plugins.php. |
| 1068 |
* |
| 1069 |
* @param string $source Source id. |
| 1070 |
*/ |
| 1071 |
private function can_deactivate( string $source ): bool { |
| 1072 |
$file = Migration::plugin_file( $source ); |
| 1073 |
if ( '' === $file ) { |
| 1074 |
return false; |
| 1075 |
} |
| 1076 |
|
| 1077 |
foreach ( array( 'plugin.php' ) as $inc ) { |
| 1078 |
require_once ABSPATH . 'wp-admin/includes/' . $inc; |
| 1079 |
} |
| 1080 |
|
| 1081 |
// Network-active plugins are a network-level object: deactivating one |
| 1082 |
// affects every site, so it needs the network capability regardless of |
| 1083 |
// how much power the caller holds on this one site. |
| 1084 |
if ( is_multisite() && is_plugin_active_for_network( $file ) ) { |
| 1085 |
return current_user_can( 'manage_network_plugins' ); |
| 1086 |
} |
| 1087 |
|
| 1088 |
return current_user_can( 'activate_plugins' ); |
| 1089 |
} |
| 1090 |
|
| 1091 |
/** |
| 1092 |
* Deactivate the source caching plugin (network-wide on multisite). |
| 1093 |
* Returns true only if it was active and is now off. |
| 1094 |
* |
| 1095 |
* Callers MUST gate this on can_deactivate() — it performs no capability |
| 1096 |
* check of its own, because the CLI path resolves permission differently |
| 1097 |
* (a WP-CLI operator is root by definition and has no current user). |
| 1098 |
* |
| 1099 |
* @param string $source Source id. |
| 1100 |
* @return bool |
| 1101 |
*/ |
| 1102 |
private function deactivate_source( string $source ): bool { |
| 1103 |
// One home for this map, shared with Migration::status()'s active |
| 1104 |
// flag. A private copy here could drift and deactivate a plugin the |
| 1105 |
// panel had reported as inactive. (#189) |
| 1106 |
$file = Migration::plugin_file( $source ); |
| 1107 |
if ( '' === $file ) { |
| 1108 |
return false; |
| 1109 |
} |
| 1110 |
// deactivate_plugins() fires each plugin's deactivation hook, and some |
| 1111 |
// (e.g. WP Super Cache) call admin-only helpers like get_home_path() |
| 1112 |
// in theirs. Those live in wp-admin/includes/file.php — NOT loaded |
| 1113 |
// during a REST request — so without these includes the deactivation |
| 1114 |
// hook fatals with "undefined function get_home_path()". Load the |
| 1115 |
// admin plumbing first so any source plugin's teardown runs cleanly. |
| 1116 |
foreach ( array( 'plugin.php', 'file.php', 'misc.php' ) as $inc ) { |
| 1117 |
require_once ABSPATH . 'wp-admin/includes/' . $inc; |
| 1118 |
} |
| 1119 |
if ( ! is_plugin_active( $file ) ) { |
| 1120 |
return false; |
| 1121 |
} |
| 1122 |
|
| 1123 |
/* |
| 1124 |
* Be EXPLICIT about scope rather than leaving $network_wide at null. |
| 1125 |
* |
| 1126 |
* Core evaluates `( false !== $network_wide ) && is_plugin_active_for_network()`, |
| 1127 |
* and `false !== null` is true — so the default silently takes the |
| 1128 |
* network-wide branch. That is the correct scope for a network-active |
| 1129 |
* plugin (a per-site deactivation would not turn it off anyway), but |
| 1130 |
* it should be a decision we state, not a fact of PHP's comparison |
| 1131 |
* rules. can_deactivate() has already required the matching |
| 1132 |
* capability for whichever branch this picks. (#189) |
| 1133 |
*/ |
| 1134 |
$network_wide = is_multisite() && is_plugin_active_for_network( $file ); |
| 1135 |
deactivate_plugins( $file, false, $network_wide ); |
| 1136 |
|
| 1137 |
/* |
| 1138 |
* The source's teardown just rewrote the very state the detector |
| 1139 |
* memoizes for the request -- WP Rocket truncates advanced-cache.php |
| 1140 |
* to 0 bytes and W3TC strips WP_CACHE, both from inside the call |
| 1141 |
* above. Without dropping the memo, restore_own_environment() asks a |
| 1142 |
* report taken while the source still held the field, sees "foreign", |
| 1143 |
* and refuses -- so the handover deactivated the source and then |
| 1144 |
* declined to take over, which is the outcome #391 describes. |
| 1145 |
*/ |
| 1146 |
if ( class_exists( '\\XSpeed\\Page_Cache_Detector' ) ) { |
| 1147 |
\XSpeed\Page_Cache_Detector::invalidate(); |
| 1148 |
} |
| 1149 |
|
| 1150 |
return ! is_plugin_active( $file ); |
| 1151 |
} |
| 1152 |
|
| 1153 |
/** |
| 1154 |
* Put our own drop-in and WP_CACHE back after the source plugin's |
| 1155 |
* teardown, when we are the one that should own them. |
| 1156 |
* |
| 1157 |
* A source plugin's deactivation routine cleans up "the page cache |
| 1158 |
* environment" without checking whose it is. W3 Total Cache is the |
| 1159 |
* clearest case: PgCache_Environment.php strips EVERY |
| 1160 |
* `define( 'WP_CACHE', … )` line from wp-config.php with a blanket |
| 1161 |
* regex, so it deletes the line xSpeed wrote when the wizard enabled |
| 1162 |
* caching. wp-content/advanced-cache.php survives, but WordPress never |
| 1163 |
* loads it without the constant, and the cache silently degrades to the |
| 1164 |
* slow in-PHP path — measured at 78ms vs 16ms TTFB on an otherwise |
| 1165 |
* identical request. |
| 1166 |
* |
| 1167 |
* Runs at exactly one moment -- the user asked to import from another |
| 1168 |
* plugin AND switch it off, and we just switched it off -- so it turns |
| 1169 |
* caching ON rather than only re-asserting an existing setting. It used |
| 1170 |
* to return early unless cache_enabled was already set, which it almost |
| 1171 |
* never is here: the site was being cached by the plugin we just |
| 1172 |
* deactivated. That is how a migration could end with the source gone |
| 1173 |
* and nothing serving. (#218, #219, #391) |
| 1174 |
* |
| 1175 |
* Not a licence to trample: toggle() still refuses on an occupied field, |
| 1176 |
* so a page cache we were not asked to replace is left alone. |
| 1177 |
*/ |
| 1178 |
private function restore_own_environment(): void { |
| 1179 |
if ( ! class_exists( '\\XSpeed\\Cache' ) || ! class_exists( '\\XSpeed\\Settings' ) ) { |
| 1180 |
return; |
| 1181 |
} |
| 1182 |
|
| 1183 |
/* |
| 1184 |
* Turn caching ON, rather than only re-asserting it when it was |
| 1185 |
* already on. This runs at exactly one moment: the user asked us to |
| 1186 |
* import from another plugin AND switch it off, and we just did. A |
| 1187 |
* handover that ends with the old cache gone and no new one is not a |
| 1188 |
* handover -- and cache_enabled is nearly always empty here, because |
| 1189 |
* the site was being cached by the plugin we just deactivated. That |
| 1190 |
* early return is why #391 ended with nothing serving. |
| 1191 |
* |
| 1192 |
* toggle() still refuses if the field is genuinely occupied, so this |
| 1193 |
* cannot trample a cache we were not asked to replace. |
| 1194 |
*/ |
| 1195 |
\XSpeed\Cache::toggle( true ); |
| 1196 |
} |
| 1197 |
|
| 1198 |
public function cli_commands(): array { |
| 1199 |
return array( |
| 1200 |
array( |
| 1201 |
'name' => 'xspeed migrate', |
| 1202 |
'callback' => array( $this, 'cli_handler' ), |
| 1203 |
'shortdesc' => 'Import settings from another caching plugin.', |
| 1204 |
'ai_hint' => 'Import settings from another caching plugin (WP Rocket, W3 Total Cache, LiteSpeed, WP Super Cache). Use when a site is switching to xSpeed and the user does not want to reconfigure by hand.', |
| 1205 |
'synopsis' => array( |
| 1206 |
array( |
| 1207 |
'type' => 'positional', |
| 1208 |
'name' => 'action', |
| 1209 |
'options' => array( 'status', 'preview', 'apply' ), |
| 1210 |
'optional' => true, |
| 1211 |
), |
| 1212 |
array( |
| 1213 |
'type' => 'assoc', |
| 1214 |
'name' => 'source', |
| 1215 |
'optional' => true, |
| 1216 |
), |
| 1217 |
array( |
| 1218 |
'type' => 'flag', |
| 1219 |
'name' => 'deactivate-source', |
| 1220 |
'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.', |
| 1221 |
'optional' => true, |
| 1222 |
), |
| 1223 |
), |
| 1224 |
), |
| 1225 |
); |
| 1226 |
} |
| 1227 |
|
| 1228 |
public function cli_handler( array $args, array $assoc ): void { |
| 1229 |
$action = $args[0] ?? 'status'; |
| 1230 |
switch ( $action ) { |
| 1231 |
case 'status': |
| 1232 |
foreach ( Migration::status() as $s ) { |
| 1233 |
\WP_CLI::log( sprintf( '%-20s %s %d values', $s['id'], $s['detected'] ? 'DETECTED' : 'missing ', $s['value_count'] ) ); |
| 1234 |
} |
| 1235 |
return; |
| 1236 |
case 'preview': |
| 1237 |
$src = (string) ( $assoc['source'] ?? '' ); |
| 1238 |
$p = Migration::preview( $src ); |
| 1239 |
if ( null === $p ) { |
| 1240 |
\WP_CLI::error( 'Source not detected or unknown: ' . $src ); |
| 1241 |
} |
| 1242 |
\WP_CLI::log( wp_json_encode( $p, JSON_PRETTY_PRINT ) ); |
| 1243 |
return; |
| 1244 |
case 'apply': |
| 1245 |
$src = (string) ( $assoc['source'] ?? '' ); |
| 1246 |
$r = Migration::apply( $src ); |
| 1247 |
if ( empty( $r ) ) { |
| 1248 |
\WP_CLI::error( 'Nothing imported.' ); |
| 1249 |
} |
| 1250 |
foreach ( $r as $mod => $info ) { |
| 1251 |
/* |
| 1252 |
* "failed" was a lie. `ok` is update_option()'s return, |
| 1253 |
* which is false when the stored value did not CHANGE — so |
| 1254 |
* re-importing settings already in place printed |
| 1255 |
* "failed" for every module beside the list of fields it |
| 1256 |
* had just imported correctly. Report what actually |
| 1257 |
* happened instead. (#189) |
| 1258 |
*/ |
| 1259 |
$applied = (array) ( $info['applied'] ?? array() ); |
| 1260 |
if ( empty( $applied ) ) { |
| 1261 |
$state = 'nothing to import'; |
| 1262 |
} elseif ( ! empty( $info['ok'] ) ) { |
| 1263 |
$state = 'imported'; |
| 1264 |
} else { |
| 1265 |
$state = 'already up to date'; |
| 1266 |
} |
| 1267 |
\WP_CLI::log( sprintf( '%-20s %-18s %s', $mod, $state, implode( ',', $applied ) ) ); |
| 1268 |
} |
| 1269 |
|
| 1270 |
/* |
| 1271 |
* Same contract as REST: deactivate only when asked. This path |
| 1272 |
* used to never deactivate AND never say so, so an operator |
| 1273 |
* (or an AI through MCP `run_command`) finished with two page |
| 1274 |
* caches live on the site and nothing in the output to say it. |
| 1275 |
* That is the failure mode the troubleshooting docs describe |
| 1276 |
* as breaking caching for both plugins. (#189) |
| 1277 |
* |
| 1278 |
* No capability check here: a WP-CLI caller is root by |
| 1279 |
* definition and there is no current user to test. The gate |
| 1280 |
* that matters is on the REST route, which is the one a |
| 1281 |
* browser can reach. |
| 1282 |
*/ |
| 1283 |
// `applied`, not `ok` — see rest_apply() for why ok:false is a |
| 1284 |
// normal outcome of a successful re-import. |
| 1285 |
$imported_something = false; |
| 1286 |
foreach ( (array) $r as $info ) { |
| 1287 |
if ( is_array( $info ) && ! empty( $info['applied'] ) ) { |
| 1288 |
$imported_something = true; |
| 1289 |
break; |
| 1290 |
} |
| 1291 |
} |
| 1292 |
|
| 1293 |
// WP-CLI normalises --deactivate-source to a 'deactivate-source' |
| 1294 |
// key; accept the underscore spelling too so MCP callers passing |
| 1295 |
// options as JSON don't have to guess which one we mean. |
| 1296 |
$want_off = ! empty( $assoc['deactivate-source'] ) || ! empty( $assoc['deactivate_source'] ); |
| 1297 |
$file = Migration::plugin_file( $src ); |
| 1298 |
|
| 1299 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 1300 |
$still_on = '' !== $file && is_plugin_active( $file ); |
| 1301 |
|
| 1302 |
if ( $want_off && $imported_something && $still_on ) { |
| 1303 |
if ( $this->deactivate_source( $src ) ) { |
| 1304 |
\WP_CLI::log( sprintf( 'Deactivated %s.', $src ) ); |
| 1305 |
$still_on = false; |
| 1306 |
// Same handover the REST route performs. Without it |
| 1307 |
// the CLI switched the source off and stopped there, |
| 1308 |
// leaving the husk of its drop-in and no page cache |
| 1309 |
// at all -- reported as "Success: Import complete." |
| 1310 |
// CLI and REST must not disagree about what |
| 1311 |
// --deactivate-source means. (#391) |
| 1312 |
$this->restore_own_environment(); |
| 1313 |
} else { |
| 1314 |
\WP_CLI::warning( sprintf( 'Could not deactivate %s.', $src ) ); |
| 1315 |
} |
| 1316 |
} |
| 1317 |
|
| 1318 |
if ( $still_on ) { |
| 1319 |
\WP_CLI::warning( |
| 1320 |
sprintf( |
| 1321 |
'%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.', |
| 1322 |
$src |
| 1323 |
) |
| 1324 |
); |
| 1325 |
} |
| 1326 |
|
| 1327 |
// Same persistent record as the REST path, so a CLI or MCP |
| 1328 |
// import that leaves the source running also raises the Health |
| 1329 |
// warning — the three surfaces must end in the same state for |
| 1330 |
// the same input. (#189 AC4, AC10) |
| 1331 |
if ( $imported_something ) { |
| 1332 |
$label = ''; |
| 1333 |
foreach ( Migration::status() as $s ) { |
| 1334 |
if ( $s['id'] === $src ) { |
| 1335 |
$label = (string) $s['label']; |
| 1336 |
break; |
| 1337 |
} |
| 1338 |
} |
| 1339 |
Migration::remember_active_source( $src, $label ); |
| 1340 |
} |
| 1341 |
|
| 1342 |
\WP_CLI::success( 'Import complete.' ); |
| 1343 |
return; |
| 1344 |
default: |
| 1345 |
// Without this, an unrecognised action fell out of the switch |
| 1346 |
// and returned success with no output — indistinguishable from |
| 1347 |
// "ran fine, nothing to report", and ok:true over MCP. |
| 1348 |
\WP_CLI::error( |
| 1349 |
sprintf( |
| 1350 |
'Unknown action "%s". Expected: status | preview --source=<id> | apply --source=<id>.', |
| 1351 |
$action |
| 1352 |
) |
| 1353 |
); |
| 1354 |
} |
| 1355 |
} |
| 1356 |
} |
| 1357 |
|