PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/modules/Migration/MigrationModule.php +686 -68 1.1.81.3.3 View file →
@@ -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,69 +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 "&times;" 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 - // Says what the button does and what it does NOT do. The old copy
175 - // mentioned only the import while the REST route also deactivated the
176 - // source — this notice is the first and most-seen touchpoint, so it
177 - // undersold a destructive action. Deactivation is now opt-in, and the
178 - // copy states that rather than leaving it to be inferred. (#189)
179 - echo '<p style="margin:0 0 12px;color:#475569;font-size:13px;">'
180 - . esc_html__( 'Import your existing settings instead of configuring everything by hand. You choose what happens to the old plugin — switch it off (recommended, since two page caches conflict) or leave it running. Pick a source to migrate:', 'xspeed' )
181 - . '</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>';
182 349
183 - // One row per detected source: label + value count + its own Import button.
184 - echo '<div style="display:flex;flex-direction:column;gap:8px;">';
185 - foreach ( $detected as $s ) {
186 - // Query arg BEFORE the hash so the dashboard still reads #migration.
187 - $src_url = $base_url . '&source=' . rawurlencode( $s['id'] ) . '#migration';
188 - 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;">';
189 - $mapped = (int) ( $s['mapped_count'] ?? 0 );
190 - echo '<span style="font-size:13px;color:#1e293b;"><strong>' . esc_html( $s['label'] ) . '</strong>'
191 - . ' <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' ) . '">'
192 380 . esc_html(
193 381 sprintf(
194 - /* translators: %d: number of settings xSpeed will actually import. */
195 - _n( 'imports %d setting', 'imports %d settings', $mapped, 'xspeed' ),
196 - $mapped
382 + /* translators: %s: brand name. */
383 + __( 'Migrate to %s', 'xspeed' ),
384 + $brand
197 385 )
198 386 )
199 - . '</span></span>';
200 - echo '<a href="' . esc_url( $src_url ) . '" class="button button-primary xspeed-mig-cta" style="flex:0 0 auto;">'
201 - . 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 + ) . ' &nbsp; ';
403 + }
404 + echo '<a href="' . esc_url( $panel_url ) . '">' . esc_html__( 'View migration details', 'xspeed' ) . '</a>';
202 405 echo '</div>';
203 406 }
204 - echo '</div>';
407 + echo '</div>'; // .act
205 408
206 - // Footer: open the full panel + dismiss the whole notice.
207 - echo '<p style="margin:12px 0 0;display:flex;gap:16px;align-items:center;">';
208 - echo '<a href="' . esc_url( $panel_url ) . '" style="font-size:13px;">' . esc_html__( 'Open Migration panel', 'xspeed' ) . '</a>';
209 - echo '<a href="' . esc_url( $dismiss_url ) . '" style="font-size:13px;color:#94a3b8;text-decoration:none;">' . esc_html__( 'Dismiss', 'xspeed' ) . '</a>';
210 - echo '</p>';
409 + echo '</div>'; // .in
211 410
212 - 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>';
213 437 }
214 438
215 439 /**
216 440 * Detected sources that are still actionable — not dismissed and not
@@ -224,8 +448,18 @@
224 448 private function detected_sources( array $dismissed = array() ): array {
225 449 if ( in_array( 'all', $dismissed, true ) ) {
226 450 return array();
227 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 + }
228 462 $out = array();
229 463 foreach ( Migration::status() as $s ) {
230 464 if ( empty( $s['detected'] ) || ! empty( $s['imported'] ) || in_array( $s['id'], $dismissed, true ) ) {
231 465 continue;
@@ -236,8 +470,37 @@
236 470 usort( $out, static fn( $a, $b ) => (int) $b['mapped_count'] <=> (int) $a['mapped_count'] );
237 471 return $out;
238 472 }
239 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 +
240 503 /** Inline brand logo SVG when white-label supplies one; else empty. */
241 504 private function branding_logo(): string {
242 505 $brand = apply_filters( 'xspeed_branding', array() );
243 506 return isset( $brand['logo_svg'] ) && is_string( $brand['logo_svg'] ) ? $brand['logo_svg'] : '';
@@ -242,8 +505,273 @@
242 505 $brand = apply_filters( 'xspeed_branding', array() );
243 506 return isset( $brand['logo_svg'] ) && is_string( $brand['logo_svg'] ) ? $brand['logo_svg'] : '';
244 507 }
245 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 +
246 774 /** Persist the per-source dismissal when the user clicks our Dismiss link. */
247 775 public function handle_dismiss(): void {
248 776 if ( ! isset( $_GET[ self::DISMISS_ARG ] ) || ! current_user_can( 'manage_options' ) ) {
249 777 return;
@@ -274,8 +802,14 @@
274 802 * @param string[] $dismissed Source ids the user has dismissed.
275 803 * @return array{id:string,label:string,value_count:int}|null
276 804 */
277 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 + }
278 812 $best = null;
279 813 foreach ( Migration::status() as $s ) {
280 814 if ( empty( $s['detected'] ) || in_array( $s['id'], $dismissed, true ) ) {
281 815 continue;
@@ -301,9 +835,14 @@
301 835 */
302 836 private function brand_color(): string {
303 837 $brand = apply_filters( 'xspeed_branding', array() );
304 838 $color = isset( $brand['brand_color'] ) ? (string) $brand['brand_color'] : '';
305 - 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';
306 845 }
307 846
308 847 public function rest_routes(): array {
309 848 return array(
@@ -360,13 +899,21 @@
360 899
361 900 public function rest_preview( \WP_REST_Request $request ) {
362 901 $params = $request->get_json_params();
363 902 $source = isset( $params['source'] ) ? (string) $params['source'] : '';
364 - $patch = Migration::preview( $source );
365 - if ( null === $patch ) {
903 + $full = Migration::preview_with_notes( $source );
904 + if ( null === $full ) {
366 905 return new \WP_Error( 'xspeed_pro_mig_no_source', 'Source not detected or unknown.', array( 'status' => 404 ) );
367 906 }
368 - 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 + );
369 916 }
370 917
371 918 public function rest_apply( \WP_REST_Request $request ) {
372 919 $params = $request->get_json_params();
@@ -408,21 +955,12 @@
408 955
409 956 /*
410 957 * Did the import actually cover anything?
411 958 *
412 - * Gate on `applied`, NOT on `ok`. `ok` is update_option()'s return
413 - * value, which is FALSE when the stored value did not change — so a
414 - * re-import of settings already in place reports ok:false on every
415 - * module while having succeeded completely. Gating on `ok` therefore
416 - * refused to deactivate after a perfectly good second import, which
417 - * is how this read on a live site: {"cache":{"ok":false,"applied":
418 - * ["cache_expiry","excluded_urls"]}}.
419 - *
420 - * `applied` lists the keys the import decided were meaningful, so a
421 - * non-empty one means the source really was read and mapped. An empty
422 - * $results (unknown source, nothing meaningful) still blocks
423 - * deactivation, which is the case that matters: never switch a plugin
424 - * off on the back of an import that did nothing. (#189)
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)
425 963 */
426 964 $imported_something = false;
427 965 foreach ( (array) $results as $info ) {
428 966 if ( is_array( $info ) && ! empty( $info['applied'] ) ) {
@@ -430,11 +968,18 @@
430 968 break;
431 969 }
432 970 }
433 971
434 - $refused = '';
435 - $refused_message = '';
436 - if ( $deactivate && $imported_something ) {
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 ) ) {
437 982 if ( ! $this->can_deactivate( $source ) ) {
438 983 // Not an error: the import succeeded and is the thing the user
439 984 // asked for. Report the refusal so the panel can say why the
440 985 // plugin is still on rather than silently implying it is off.
@@ -458,8 +1003,15 @@
458 1003 $source_label
459 1004 );
460 1005 } else {
461 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 + }
462 1014 }
463 1015 }
464 1016
465 1017 // The user declined (or was refused) and the source is still running.
@@ -581,11 +1133,69 @@
581 1133 */
582 1134 $network_wide = is_multisite() && is_plugin_active_for_network( $file );
583 1135 deactivate_plugins( $file, false, $network_wide );
584 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 +
585 1150 return ! is_plugin_active( $file );
586 1151 }
587 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 +
588 1198 public function cli_commands(): array {
589 1199 return array(
590 1200 array(
591 1201 'name' => 'xspeed migrate',
@@ -590,8 +1200,9 @@
590 1200 array(
591 1201 'name' => 'xspeed migrate',
592 1202 'callback' => array( $this, 'cli_handler' ),
593 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.',
594 1205 'synopsis' => array(
595 1206 array(
596 1207 'type' => 'positional',
597 1208 'name' => 'action',
@@ -691,8 +1302,15 @@
691 1302 if ( $want_off && $imported_something && $still_on ) {
692 1303 if ( $this->deactivate_source( $src ) ) {
693 1304 \WP_CLI::log( sprintf( 'Deactivated %s.', $src ) );
694 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();
695 1313 } else {
696 1314 \WP_CLI::warning( sprintf( 'Could not deactivate %s.', $src ) );
697 1315 }
698 1316 }