PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 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 All 33 releases
xspeed / includes / modules / Migration / MigrationModule.php

MigrationModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.7, at includes/modules/Migration/MigrationModule.php

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