| @@ -26,11 +26,11 @@ | ||
| 26 | 26 | public const VERSION = '1.0.0'; |
| 27 | 27 | |
| 28 | 28 | public function ui_metadata(): array { |
| 29 | 29 | return array( |
| 30 | - 'label' => 'Migration', | |
| 30 | + 'label' => __( 'Migration', 'xspeed' ), | |
| 31 | 31 | 'icon' => 'Import', |
| 32 | - 'description' => 'Import settings from WP Rocket, W3 Total Cache, or WP Super Cache.', | |
| 32 | + 'description' => __( 'Import settings from WP Rocket, W3 Total Cache, or WP Super Cache.', 'xspeed' ), | |
| 33 | 33 | 'custom_panel' => 'MigrationPanel', |
| 34 | 34 | ); |
| 35 | 35 | } |
| 36 | 36 | |
| @@ -61,10 +61,69 @@ | ||
| 61 | 61 | // Dashboard nudge: when another caching plugin is detected, offer a |
| 62 | 62 | // one-click import — the same "we noticed you use X" prompt other |
| 63 | 63 | // plugins show. Renders on standard WP admin screens (NOT xSpeed's |
| 64 | 64 | // own pages, where the Migration panel already covers it). |
| 65 | - add_action( 'admin_notices', array( $this, 'maybe_render_notice' ) ); | |
| 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 | + } | |
| 66 | 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' ) ); | |
| 67 | 126 | // Sidebar attention badge: surface the count of importable plugins on |
| 68 | 127 | // the Migration nav item so the user knows there's an action to take. |
| 69 | 128 | add_filter( 'xspeed_module_descriptor', array( $this, 'add_sidebar_badge' ), 10, 2 ); |
| 70 | 129 | } |
| @@ -105,8 +164,17 @@ | ||
| 105 | 164 | * conservative: skipped on xSpeed's own screens, for users without |
| 106 | 165 | * manage_options, and once dismissed. |
| 107 | 166 | */ |
| 108 | 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 | + | |
| 109 | 177 | if ( ! current_user_can( 'manage_options' ) ) { |
| 110 | 178 | return; |
| 111 | 179 | } |
| 112 | 180 | // Don't double up on xSpeed's own pages — the Migration panel is right there. |
| @@ -148,64 +216,225 @@ | ||
| 148 | 216 | $brand, |
| 149 | 217 | $count |
| 150 | 218 | ); |
| 151 | 219 | |
| 220 | + $rendered = true; | |
| 152 | 221 | $brand_color = $this->brand_color(); |
| 153 | - // Brand-color the Import CTAs (override WP's default blue primary). | |
| 154 | - // This notice is echoed directly (not through wp_kses), so an inline | |
| 155 | - // <style> block is fine here. | |
| 156 | - echo '<style>.xspeed-migration-notice .xspeed-mig-cta.button-primary{' | |
| 157 | - . 'background:' . esc_attr( $brand_color ) . ' !important;' | |
| 158 | - . 'border-color:' . esc_attr( $brand_color ) . ' !important;box-shadow:none !important;' | |
| 159 | - . 'box-sizing:border-box !important;min-height:36px !important;max-height:36px !important;height:36px !important;line-height:1 !important;padding-top:0;padding-bottom:0;display:inline-flex;align-items:center;}' | |
| 160 | - . '.xspeed-migration-notice .xspeed-mig-cta.button-primary:hover{filter:brightness(1.15);}' | |
| 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)}}' | |
| 161 | 303 | . '</style>'; |
| 162 | - echo '<div class="notice xspeed-migration-notice" style="padding:0;border:1px solid #e2e8f0;border-left:4px solid ' . esc_attr( $brand_color ) . ';border-radius:8px;overflow:hidden;background:#fff;">'; | |
| 163 | - echo '<div style="padding:16px 18px;">'; | |
| 164 | 304 | |
| 165 | - // Header row: brand mark + heading. | |
| 166 | - echo '<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">'; | |
| 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 | + | |
| 167 | 319 | $logo = $this->branding_logo(); |
| 168 | - if ( '' !== $logo ) { | |
| 169 | - echo '<span style="display:inline-flex;width:24px;height:24px;flex:0 0 24px;">' . $logo . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- logo is a sanitized inline SVG from branding, escaped at source. | |
| 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>'; | |
| 170 | 338 | } |
| 171 | - echo '<strong style="font-size:14px;color:#1e293b;">' . esc_html( $heading ) . '</strong>'; | |
| 172 | 339 | echo '</div>'; |
| 173 | 340 | |
| 174 | - echo '<p style="margin:0 0 12px;color:#475569;font-size:13px;">' | |
| 175 | - . esc_html__( 'Import your existing settings in one click instead of configuring everything by hand. Pick a source to migrate:', 'xspeed' ) | |
| 176 | - . '</p>'; | |
| 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>'; | |
| 177 | 349 | |
| 178 | - // One row per detected source: label + value count + its own Import button. | |
| 179 | - echo '<div style="display:flex;flex-direction:column;gap:8px;">'; | |
| 180 | - foreach ( $detected as $s ) { | |
| 181 | - // Query arg BEFORE the hash so the dashboard still reads #migration. | |
| 182 | - $src_url = $base_url . '&source=' . rawurlencode( $s['id'] ) . '#migration'; | |
| 183 | - echo '<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;">'; | |
| 184 | - $mapped = (int) ( $s['mapped_count'] ?? 0 ); | |
| 185 | - echo '<span style="font-size:13px;color:#1e293b;"><strong>' . esc_html( $s['label'] ) . '</strong>' | |
| 186 | - . ' <span style="color:#94a3b8;">' | |
| 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' ) . '">' | |
| 187 | 380 | . esc_html( |
| 188 | 381 | sprintf( |
| 189 | - /* translators: %d: number of settings xSpeed will actually import. */ | |
| 190 | - _n( 'imports %d setting', 'imports %d settings', $mapped, 'xspeed' ), | |
| 191 | - $mapped | |
| 382 | + /* translators: %s: brand name. */ | |
| 383 | + __( 'Migrate to %s', 'xspeed' ), | |
| 384 | + $brand | |
| 192 | 385 | ) |
| 193 | 386 | ) |
| 194 | - . '</span></span>'; | |
| 195 | - echo '<a href="' . esc_url( $src_url ) . '" class="button button-primary xspeed-mig-cta" style="flex:0 0 auto;">' | |
| 196 | - . esc_html__( 'Import', 'xspeed' ) . '</a>'; | |
| 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>'; | |
| 197 | 405 | echo '</div>'; |
| 198 | 406 | } |
| 199 | - echo '</div>'; | |
| 407 | + echo '</div>'; // .act | |
| 200 | 408 | |
| 201 | - // Footer: open the full panel + dismiss the whole notice. | |
| 202 | - echo '<p style="margin:12px 0 0;display:flex;gap:16px;align-items:center;">'; | |
| 203 | - echo '<a href="' . esc_url( $panel_url ) . '" style="font-size:13px;">' . esc_html__( 'Open Migration panel', 'xspeed' ) . '</a>'; | |
| 204 | - echo '<a href="' . esc_url( $dismiss_url ) . '" style="font-size:13px;color:#94a3b8;text-decoration:none;">' . esc_html__( 'Dismiss', 'xspeed' ) . '</a>'; | |
| 205 | - echo '</p>'; | |
| 409 | + echo '</div>'; // .in | |
| 206 | 410 | |
| 207 | - echo '</div></div>'; | |
| 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>'; | |
| 208 | 437 | } |
| 209 | 438 | |
| 210 | 439 | /** |
| 211 | 440 | * Detected sources that are still actionable — not dismissed and not |
| @@ -219,8 +448,18 @@ | ||
| 219 | 448 | private function detected_sources( array $dismissed = array() ): array { |
| 220 | 449 | if ( in_array( 'all', $dismissed, true ) ) { |
| 221 | 450 | return array(); |
| 222 | 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 | + } | |
| 223 | 462 | $out = array(); |
| 224 | 463 | foreach ( Migration::status() as $s ) { |
| 225 | 464 | if ( empty( $s['detected'] ) || ! empty( $s['imported'] ) || in_array( $s['id'], $dismissed, true ) ) { |
| 226 | 465 | continue; |
| @@ -231,8 +470,37 @@ | ||
| 231 | 470 | usort( $out, static fn( $a, $b ) => (int) $b['mapped_count'] <=> (int) $a['mapped_count'] ); |
| 232 | 471 | return $out; |
| 233 | 472 | } |
| 234 | 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 | + | |
| 235 | 503 | /** Inline brand logo SVG when white-label supplies one; else empty. */ |
| 236 | 504 | private function branding_logo(): string { |
| 237 | 505 | $brand = apply_filters( 'xspeed_branding', array() ); |
| 238 | 506 | return isset( $brand['logo_svg'] ) && is_string( $brand['logo_svg'] ) ? $brand['logo_svg'] : ''; |
| @@ -237,8 +505,273 @@ | ||
| 237 | 505 | $brand = apply_filters( 'xspeed_branding', array() ); |
| 238 | 506 | return isset( $brand['logo_svg'] ) && is_string( $brand['logo_svg'] ) ? $brand['logo_svg'] : ''; |
| 239 | 507 | } |
| 240 | 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 | + | |
| 241 | 774 | /** Persist the per-source dismissal when the user clicks our Dismiss link. */ |
| 242 | 775 | public function handle_dismiss(): void { |
| 243 | 776 | if ( ! isset( $_GET[ self::DISMISS_ARG ] ) || ! current_user_can( 'manage_options' ) ) { |
| 244 | 777 | return; |
| @@ -269,8 +802,14 @@ | ||
| 269 | 802 | * @param string[] $dismissed Source ids the user has dismissed. |
| 270 | 803 | * @return array{id:string,label:string,value_count:int}|null |
| 271 | 804 | */ |
| 272 | 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 | + } | |
| 273 | 812 | $best = null; |
| 274 | 813 | foreach ( Migration::status() as $s ) { |
| 275 | 814 | if ( empty( $s['detected'] ) || in_array( $s['id'], $dismissed, true ) ) { |
| 276 | 815 | continue; |
| @@ -296,9 +835,14 @@ | ||
| 296 | 835 | */ |
| 297 | 836 | private function brand_color(): string { |
| 298 | 837 | $brand = apply_filters( 'xspeed_branding', array() ); |
| 299 | 838 | $color = isset( $brand['brand_color'] ) ? (string) $brand['brand_color'] : ''; |
| 300 | - return preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color ) ? $color : '#1e1e1e'; | |
| 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'; | |
| 301 | 845 | } |
| 302 | 846 | |
| 303 | 847 | public function rest_routes(): array { |
| 304 | 848 | return array( |
| @@ -355,27 +899,23 @@ | ||
| 355 | 899 | |
| 356 | 900 | public function rest_preview( \WP_REST_Request $request ) { |
| 357 | 901 | $params = $request->get_json_params(); |
| 358 | 902 | $source = isset( $params['source'] ) ? (string) $params['source'] : ''; |
| 359 | - $patch = Migration::preview( $source ); | |
| 360 | - if ( null === $patch ) { | |
| 903 | + $full = Migration::preview_with_notes( $source ); | |
| 904 | + if ( null === $full ) { | |
| 361 | 905 | return new \WP_Error( 'xspeed_pro_mig_no_source', 'Source not detected or unknown.', array( 'status' => 404 ) ); |
| 362 | 906 | } |
| 363 | - return rest_ensure_response( array( 'patch' => $patch ) ); | |
| 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 | + ); | |
| 364 | 916 | } |
| 365 | 917 | |
| 366 | - /** | |
| 367 | - * Source id → its plugin file (folder/main.php), so we can deactivate the | |
| 368 | - * source after a successful import. Running two page caches at once causes | |
| 369 | - * double-caching / conflicting drop-ins, so the source must be turned off. | |
| 370 | - */ | |
| 371 | - private const SOURCE_PLUGIN_FILE = array( | |
| 372 | - 'wp-rocket' => 'wp-rocket/wp-rocket.php', | |
| 373 | - 'w3-total-cache' => 'w3-total-cache/w3-total-cache.php', | |
| 374 | - 'wp-super-cache' => 'wp-super-cache/wp-cache.php', | |
| 375 | - 'litespeed-cache' => 'litespeed-cache/litespeed-cache.php', | |
| 376 | - ); | |
| 377 | - | |
| 378 | 918 | public function rest_apply( \WP_REST_Request $request ) { |
| 379 | 919 | $params = $request->get_json_params(); |
| 380 | 920 | $source = isset( $params['source'] ) ? (string) $params['source'] : ''; |
| 381 | 921 | if ( '' === $source ) { |
| @@ -380,15 +920,33 @@ | ||
| 380 | 920 | $source = isset( $params['source'] ) ? (string) $params['source'] : ''; |
| 381 | 921 | if ( '' === $source ) { |
| 382 | 922 | return new \WP_Error( 'xspeed_pro_mig_no_source', 'Provide a source id.', array( 'status' => 400 ) ); |
| 383 | 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 | + | |
| 384 | 945 | $results = Migration::apply( $source ); |
| 385 | 946 | |
| 386 | - // After a successful import, deactivate the source plugin — two page | |
| 387 | - // caches running together double-cache and fight over the drop-in. | |
| 388 | - // We report what we did so the panel can tell the user plainly. | |
| 389 | - $deactivated = false; | |
| 390 | - $source_label = ''; | |
| 947 | + $deactivated = false; | |
| 948 | + $source_label = ''; | |
| 391 | 949 | foreach ( Migration::status() as $s ) { |
| 392 | 950 | if ( $s['id'] === $source ) { |
| 393 | 951 | $source_label = (string) $s['label']; |
| 394 | 952 | break; |
| @@ -393,12 +951,79 @@ | ||
| 393 | 951 | $source_label = (string) $s['label']; |
| 394 | 952 | break; |
| 395 | 953 | } |
| 396 | 954 | } |
| 397 | - if ( ! empty( $results ) ) { | |
| 398 | - $deactivated = $this->deactivate_source( $source ); | |
| 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 | + } | |
| 399 | 970 | } |
| 400 | 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 | + | |
| 401 | 1026 | if ( class_exists( '\\XSpeed\\Activity_Log' ) && ! empty( $results ) ) { |
| 402 | 1027 | \XSpeed\Activity_Log::record( |
| 403 | 1028 | 'migration_applied', |
| 404 | 1029 | $deactivated |
| @@ -409,24 +1034,77 @@ | ||
| 409 | 1034 | } |
| 410 | 1035 | |
| 411 | 1036 | return rest_ensure_response( |
| 412 | 1037 | array( |
| 413 | - 'results' => $results, | |
| 414 | - 'deactivated' => $deactivated, | |
| 415 | - 'source_label' => $source_label, | |
| 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, | |
| 416 | 1050 | ) |
| 417 | 1051 | ); |
| 418 | 1052 | } |
| 419 | 1053 | |
| 420 | 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 | + /** | |
| 421 | 1092 | * Deactivate the source caching plugin (network-wide on multisite). |
| 422 | 1093 | * Returns true only if it was active and is now off. |
| 423 | 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 | + * | |
| 424 | 1099 | * @param string $source Source id. |
| 425 | 1100 | * @return bool |
| 426 | 1101 | */ |
| 427 | 1102 | private function deactivate_source( string $source ): bool { |
| 428 | - $file = self::SOURCE_PLUGIN_FILE[ $source ] ?? ''; | |
| 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 ); | |
| 429 | 1107 | if ( '' === $file ) { |
| 430 | 1108 | return false; |
| 431 | 1109 | } |
| 432 | 1110 | // deactivate_plugins() fires each plugin's deactivation hook, and some |
| @@ -440,12 +1118,84 @@ | ||
| 440 | 1118 | } |
| 441 | 1119 | if ( ! is_plugin_active( $file ) ) { |
| 442 | 1120 | return false; |
| 443 | 1121 | } |
| 444 | - deactivate_plugins( $file ); // network-wide if it was network-active. | |
| 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 | + | |
| 445 | 1150 | return ! is_plugin_active( $file ); |
| 446 | 1151 | } |
| 447 | 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 | + | |
| 448 | 1198 | public function cli_commands(): array { |
| 449 | 1199 | return array( |
| 450 | 1200 | array( |
| 451 | 1201 | 'name' => 'xspeed migrate', |
| @@ -450,8 +1200,9 @@ | ||
| 450 | 1200 | array( |
| 451 | 1201 | 'name' => 'xspeed migrate', |
| 452 | 1202 | 'callback' => array( $this, 'cli_handler' ), |
| 453 | 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.', | |
| 454 | 1205 | 'synopsis' => array( |
| 455 | 1206 | array( |
| 456 | 1207 | 'type' => 'positional', |
| 457 | 1208 | 'name' => 'action', |
| @@ -462,8 +1213,14 @@ | ||
| 462 | 1213 | 'type' => 'assoc', |
| 463 | 1214 | 'name' => 'source', |
| 464 | 1215 | 'optional' => true, |
| 465 | 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 | + ), | |
| 466 | 1223 | ), |
| 467 | 1224 | ), |
| 468 | 1225 | ); |
| 469 | 1226 | } |
| @@ -490,10 +1247,99 @@ | ||
| 490 | 1247 | if ( empty( $r ) ) { |
| 491 | 1248 | \WP_CLI::error( 'Nothing imported.' ); |
| 492 | 1249 | } |
| 493 | 1250 | foreach ( $r as $mod => $info ) { |
| 494 | - \WP_CLI::log( sprintf( '%-20s %s — %s', $mod, $info['ok'] ? 'ok' : 'failed', implode( ',', $info['applied'] ) ) ); | |
| 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 ) ) ); | |
| 495 | 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 | + | |
| 496 | 1342 | \WP_CLI::success( 'Import complete.' ); |
| 497 | 1343 | return; |
| 498 | 1344 | default: |
| 499 | 1345 | // Without this, an unrecognised action fell out of the switch |