PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.0 1.2.1 All 27 releases
xspeed / includes / modules / Migration / MigrationModule.php

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

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