PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Migration / MigrationModule.php

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

780 lines 30.1 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',
31 'icon' => 'Import',
32 'description' => 'Import settings from WP Rocket, W3 Total Cache, or WP Super Cache.',
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 add_action( 'admin_notices', array( $this, 'maybe_render_notice' ) );
66 add_action( 'admin_init', array( $this, 'handle_dismiss' ) );
67 // Sidebar attention badge: surface the count of importable plugins on
68 // the Migration nav item so the user knows there's an action to take.
69 add_filter( 'xspeed_module_descriptor', array( $this, 'add_sidebar_badge' ), 10, 2 );
70 }
71
72 /**
73 * Badge the Migration module's sidebar item with the count of importable
74 * caching plugins the user hasn't SEEN or dismissed yet — surfaces at a
75 * glance how many NEW sources they could migrate from. Opening the panel
76 * marks sources seen (see rest_status), so the badge clears after a visit.
77 * Other modules untouched.
78 *
79 * @param array $entry Module descriptor being built.
80 * @param object $module The module instance.
81 * @return array
82 */
83 public function add_sidebar_badge( array $entry, $module ): array {
84 if ( ( $entry['slug'] ?? '' ) !== self::SLUG ) {
85 return $entry;
86 }
87 $uid = get_current_user_id();
88 $dismissed = (array) get_user_meta( $uid, self::DISMISS_META, true );
89 $seen = (array) get_user_meta( $uid, self::SEEN_META, true );
90 $count = 0;
91 foreach ( $this->detected_sources( $dismissed ) as $s ) {
92 if ( ! in_array( $s['id'], $seen, true ) ) {
93 ++$count;
94 }
95 }
96 if ( $count > 0 ) {
97 $entry['badge'] = $count;
98 }
99 return $entry;
100 }
101
102 /**
103 * Render the migration nudge on the dashboard when exactly one importable
104 * source is detected and the user hasn't dismissed it. Kept deliberately
105 * conservative: skipped on xSpeed's own screens, for users without
106 * manage_options, and once dismissed.
107 */
108 public function maybe_render_notice(): void {
109 if ( ! current_user_can( 'manage_options' ) ) {
110 return;
111 }
112 // Don't double up on xSpeed's own pages — the Migration panel is right there.
113 if ( class_exists( '\\XSpeed\\Admin' ) && \XSpeed\Admin::is_plugin_page() ) {
114 return;
115 }
116
117 $dismissed = (array) get_user_meta( get_current_user_id(), self::DISMISS_META, true );
118 $detected = $this->detected_sources( $dismissed );
119 if ( empty( $detected ) ) {
120 return;
121 }
122
123 $brand = $this->branding_name();
124 $base_url = admin_url( 'admin.php?page=xspeed' );
125 // The dashboard selects the panel from the URL hash. The hash must be
126 // the LAST thing in the URL — any query arg (e.g. ?source=…) has to go
127 // BEFORE the '#', or it becomes part of the fragment ("migration?source=…")
128 // which no module slug matches, so the app falls back to the first
129 // panel (#cache). That was the "Import goes to #cache" bug.
130 $panel_url = $base_url . '#migration';
131 $dismiss_url = wp_nonce_url(
132 add_query_arg( self::DISMISS_ARG, 'all' ),
133 'xspeed_dismiss_migration_all'
134 );
135 $count = count( $detected );
136
137 // Branded card. All inline-styled (admin-notice context has no
138 // bundled stylesheet) but mapped to DESIGN.md tokens: accent #2563eb,
139 // neutral text #1e293b / #475569, rounded-lg, comfortable padding.
140 $heading = sprintf(
141 /* translators: %d: number of detected caching plugins. */
142 _n(
143 'Migrate to %1$s — %2$d caching plugin detected',
144 'Migrate to %1$s — %2$d caching plugins detected',
145 $count,
146 'xspeed'
147 ),
148 $brand,
149 $count
150 );
151
152 $brand_color = $this->brand_color();
153 // Brand-color the Import CTAs (override WP's default blue primary).
154 // This notice is echoed directly (not through wp_kses), so an inline
155 // <style> block is fine here.
156 echo '<style>.xspeed-migration-notice .xspeed-mig-cta.button-primary{'
157 . 'background:' . esc_attr( $brand_color ) . ' !important;'
158 . 'border-color:' . esc_attr( $brand_color ) . ' !important;box-shadow:none !important;'
159 . 'box-sizing:border-box !important;min-height:36px !important;max-height:36px !important;height:36px !important;line-height:1 !important;padding-top:0;padding-bottom:0;display:inline-flex;align-items:center;}'
160 . '.xspeed-migration-notice .xspeed-mig-cta.button-primary:hover{filter:brightness(1.15);}'
161 . '</style>';
162 echo '<div class="notice xspeed-migration-notice" style="padding:0;border:1px solid #e2e8f0;border-left:4px solid ' . esc_attr( $brand_color ) . ';border-radius:8px;overflow:hidden;background:#fff;">';
163 echo '<div style="padding:16px 18px;">';
164
165 // Header row: brand mark + heading.
166 echo '<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">';
167 $logo = $this->branding_logo();
168 if ( '' !== $logo ) {
169 echo '<span style="display:inline-flex;width:24px;height:24px;flex:0 0 24px;">' . $logo . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- logo is a sanitized inline SVG from branding, escaped at source.
170 }
171 echo '<strong style="font-size:14px;color:#1e293b;">' . esc_html( $heading ) . '</strong>';
172 echo '</div>';
173
174 // Says what the button does and what it does NOT do. The old copy
175 // mentioned only the import while the REST route also deactivated the
176 // source — this notice is the first and most-seen touchpoint, so it
177 // undersold a destructive action. Deactivation is now opt-in, and the
178 // copy states that rather than leaving it to be inferred. (#189)
179 echo '<p style="margin:0 0 12px;color:#475569;font-size:13px;">'
180 . esc_html__( 'Import your existing settings instead of configuring everything by hand. You choose what happens to the old plugin — switch it off (recommended, since two page caches conflict) or leave it running. Pick a source to migrate:', 'xspeed' )
181 . '</p>';
182
183 // One row per detected source: label + value count + its own Import button.
184 echo '<div style="display:flex;flex-direction:column;gap:8px;">';
185 foreach ( $detected as $s ) {
186 // Query arg BEFORE the hash so the dashboard still reads #migration.
187 $src_url = $base_url . '&source=' . rawurlencode( $s['id'] ) . '#migration';
188 echo '<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;">';
189 $mapped = (int) ( $s['mapped_count'] ?? 0 );
190 echo '<span style="font-size:13px;color:#1e293b;"><strong>' . esc_html( $s['label'] ) . '</strong>'
191 . ' <span style="color:#94a3b8;">'
192 . esc_html(
193 sprintf(
194 /* translators: %d: number of settings xSpeed will actually import. */
195 _n( 'imports %d setting', 'imports %d settings', $mapped, 'xspeed' ),
196 $mapped
197 )
198 )
199 . '</span></span>';
200 echo '<a href="' . esc_url( $src_url ) . '" class="button button-primary xspeed-mig-cta" style="flex:0 0 auto;">'
201 . esc_html__( 'Import', 'xspeed' ) . '</a>';
202 echo '</div>';
203 }
204 echo '</div>';
205
206 // Footer: open the full panel + dismiss the whole notice.
207 echo '<p style="margin:12px 0 0;display:flex;gap:16px;align-items:center;">';
208 echo '<a href="' . esc_url( $panel_url ) . '" style="font-size:13px;">' . esc_html__( 'Open Migration panel', 'xspeed' ) . '</a>';
209 echo '<a href="' . esc_url( $dismiss_url ) . '" style="font-size:13px;color:#94a3b8;text-decoration:none;">' . esc_html__( 'Dismiss', 'xspeed' ) . '</a>';
210 echo '</p>';
211
212 echo '</div></div>';
213 }
214
215 /**
216 * Detected sources that are still actionable — not dismissed and not
217 * already imported — richest first. These are what the dashboard notice
218 * and the sidebar badge count: "new caching plugins you could migrate
219 * from". Once imported, a source drops out.
220 *
221 * @param string[] $dismissed Dismissed source ids ('all' hides every one).
222 * @return array<int,array{id:string,label:string,mapped_count:int}>
223 */
224 private function detected_sources( array $dismissed = array() ): array {
225 if ( in_array( 'all', $dismissed, true ) ) {
226 return array();
227 }
228 $out = array();
229 foreach ( Migration::status() as $s ) {
230 if ( empty( $s['detected'] ) || ! empty( $s['imported'] ) || in_array( $s['id'], $dismissed, true ) ) {
231 continue;
232 }
233 $out[] = $s;
234 }
235 // Order by the honest mapped count (what we actually import).
236 usort( $out, static fn( $a, $b ) => (int) $b['mapped_count'] <=> (int) $a['mapped_count'] );
237 return $out;
238 }
239
240 /** Inline brand logo SVG when white-label supplies one; else empty. */
241 private function branding_logo(): string {
242 $brand = apply_filters( 'xspeed_branding', array() );
243 return isset( $brand['logo_svg'] ) && is_string( $brand['logo_svg'] ) ? $brand['logo_svg'] : '';
244 }
245
246 /** Persist the per-source dismissal when the user clicks our Dismiss link. */
247 public function handle_dismiss(): void {
248 if ( ! isset( $_GET[ self::DISMISS_ARG ] ) || ! current_user_can( 'manage_options' ) ) {
249 return;
250 }
251 $source = sanitize_key( wp_unslash( $_GET[ self::DISMISS_ARG ] ) );
252 if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'xspeed_dismiss_migration_' . $source ) ) {
253 return;
254 }
255 $uid = get_current_user_id();
256 $dismissed = (array) get_user_meta( $uid, self::DISMISS_META, true );
257 if ( ! in_array( $source, $dismissed, true ) ) {
258 $dismissed[] = $source;
259 update_user_meta( $uid, self::DISMISS_META, $dismissed );
260 }
261 // Redirect to drop the query args so a reload doesn't re-trigger.
262 wp_safe_redirect( remove_query_arg( array( self::DISMISS_ARG, '_wpnonce' ) ) );
263 exit;
264 }
265
266 /**
267 * The single most relevant detected source to nudge about, or null.
268 * Picks the detected source with the most settings (the richest import),
269 * skipping any the user has already dismissed — so dismissing the top
270 * prompt surfaces the next source rather than going silent while another
271 * importable plugin is still present. Only one prompt at a time keeps the
272 * dashboard uncluttered.
273 *
274 * @param string[] $dismissed Source ids the user has dismissed.
275 * @return array{id:string,label:string,value_count:int}|null
276 */
277 private function top_detected_source( array $dismissed = array() ): ?array {
278 $best = null;
279 foreach ( Migration::status() as $s ) {
280 if ( empty( $s['detected'] ) || in_array( $s['id'], $dismissed, true ) ) {
281 continue;
282 }
283 if ( null === $best || (int) $s['value_count'] > (int) $best['value_count'] ) {
284 $best = $s;
285 }
286 }
287 return $best;
288 }
289
290 /** Brand name honoring Pro white-label, falling back to "xSpeed". */
291 private function branding_name(): string {
292 $brand = apply_filters( 'xspeed_branding', array() );
293 return isset( $brand['name'] ) && '' !== $brand['name'] ? (string) $brand['name'] : 'xSpeed';
294 }
295
296 /**
297 * Brand/logo color for the notice accent + Import buttons. White-label
298 * sites can set `brand_color` via the xspeed_branding filter; otherwise
299 * we use the xSpeed logo color (near-black), not the design blue accent —
300 * the notice should match the on-screen logo. (FBS-82379)
301 */
302 private function brand_color(): string {
303 $brand = apply_filters( 'xspeed_branding', array() );
304 $color = isset( $brand['brand_color'] ) ? (string) $brand['brand_color'] : '';
305 return preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color ) ? $color : '#1e1e1e';
306 }
307
308 public function rest_routes(): array {
309 return array(
310 array(
311 'path' => '/status',
312 'methods' => 'GET',
313 'callback' => array( $this, 'rest_status' ),
314 ),
315 array(
316 'path' => '/preview',
317 'methods' => 'POST',
318 'callback' => array( $this, 'rest_preview' ),
319 ),
320 array(
321 'path' => '/apply',
322 'methods' => 'POST',
323 'callback' => array( $this, 'rest_apply' ),
324 ),
325 );
326 }
327
328 public function rest_status( \WP_REST_Request $request ) {
329 $status = Migration::status();
330 // Opening the Migration panel triggers this call — treat it as the
331 // user having SEEN every currently-detected source, which clears the
332 // sidebar count badge. Record the detected ids against the user.
333 $this->mark_sources_seen( $status );
334 return rest_ensure_response( array( 'sources' => $status ) );
335 }
336
337 /**
338 * Record the currently-detected source ids as seen for this user, so the
339 * sidebar badge stops counting them. Merges with any prior seen set.
340 *
341 * @param array $status Output of Migration::status().
342 */
343 private function mark_sources_seen( array $status ): void {
344 $uid = get_current_user_id();
345 if ( ! $uid ) {
346 return;
347 }
348 $seen = (array) get_user_meta( $uid, self::SEEN_META, true );
349 $add = array();
350 foreach ( $status as $s ) {
351 if ( ! empty( $s['detected'] ) ) {
352 $add[] = $s['id'];
353 }
354 }
355 $merged = array_values( array_unique( array_merge( $seen, $add ) ) );
356 if ( $merged !== $seen ) {
357 update_user_meta( $uid, self::SEEN_META, $merged );
358 }
359 }
360
361 public function rest_preview( \WP_REST_Request $request ) {
362 $params = $request->get_json_params();
363 $source = isset( $params['source'] ) ? (string) $params['source'] : '';
364 $full = Migration::preview_with_notes( $source );
365 if ( null === $full ) {
366 return new \WP_Error( 'xspeed_pro_mig_no_source', 'Source not detected or unknown.', array( 'status' => 404 ) );
367 }
368 // `notes` carries the lossy-conversion warnings the panel renders
369 // beside the plan, so a value we had to round is stated rather than
370 // presented as an exact import. (#224 F2)
371 return rest_ensure_response(
372 array(
373 'patch' => $full['patch'],
374 'notes' => $full['notes'],
375 )
376 );
377 }
378
379 public function rest_apply( \WP_REST_Request $request ) {
380 $params = $request->get_json_params();
381 $source = isset( $params['source'] ) ? (string) $params['source'] : '';
382 if ( '' === $source ) {
383 return new \WP_Error( 'xspeed_pro_mig_no_source', 'Provide a source id.', array( 'status' => 400 ) );
384 }
385
386 /*
387 * Deactivating the source is the CALLER's decision, and it defaults to
388 * NO. (#189)
389 *
390 * This used to happen unconditionally: the request carried only
391 * `source`, so the server could not distinguish "the user clicked
392 * through our warning" from any other POST to this route. The only
393 * guard rail was an InlineConfirm in the React client, which is the
394 * wrong layer for a destructive action — and WP-CLI and MCP, hitting
395 * the same product action, did the opposite and left the plugin on.
396 *
397 * Defaulting to false rather than true is what makes the documented
398 * contract true again (docs/user/advanced-migration.md said migration
399 * "never changes" the old plugin) and matches the house rule that we
400 * never modify another plugin's state on our own initiative. The panel
401 * now passes deactivate:true explicitly after its confirm, so the
402 * common path is unchanged for users.
403 */
404 $deactivate = ! empty( $params['deactivate_source'] );
405
406 $results = Migration::apply( $source );
407
408 $deactivated = false;
409 $source_label = '';
410 foreach ( Migration::status() as $s ) {
411 if ( $s['id'] === $source ) {
412 $source_label = (string) $s['label'];
413 break;
414 }
415 }
416
417 /*
418 * Did the import actually cover anything?
419 *
420 * `applied` lists the meaningful keys the import attempted, so it is the
421 * right signal for the activity and Health records below. Deactivation
422 * has a stronger gate: every attempted result must also report `ok`, so a
423 * partial import never switches the source off. (#189, #224)
424 */
425 $imported_something = false;
426 foreach ( (array) $results as $info ) {
427 if ( is_array( $info ) && ! empty( $info['applied'] ) ) {
428 $imported_something = true;
429 break;
430 }
431 }
432
433 $refused = '';
434 $refused_message = '';
435 $import_completed = Migration::completed_successfully( (array) $results );
436 if ( $deactivate && $import_completed ) {
437 if ( ! $this->can_deactivate( $source ) ) {
438 // Not an error: the import succeeded and is the thing the user
439 // asked for. Report the refusal so the panel can say why the
440 // plugin is still on rather than silently implying it is off.
441 $refused = 'insufficient_capability';
442
443 // Name WHO can do it, not just that the caller cannot. On a
444 // network-activated source the answer is specifically a network
445 // administrator, and a site admin has no way to work that out
446 // from a bare capability code. (#189 AC5)
447 $file = Migration::plugin_file( $source );
448 $network_scoped = is_multisite() && '' !== $file && is_plugin_active_for_network( $file );
449 $refused_message = $network_scoped
450 ? sprintf(
451 /* translators: %s: source plugin label. */
452 __( '%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' ),
453 $source_label
454 )
455 : sprintf(
456 /* translators: %s: source plugin label. */
457 __( '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' ),
458 $source_label
459 );
460 } else {
461 $deactivated = $this->deactivate_source( $source );
462 if ( $deactivated ) {
463 // Switching the source off runs ITS teardown, which
464 // removes WP_CACHE and can take the shared drop-in file
465 // with it — leaving our own page cache configured-on but
466 // not actually serving. Re-assert both. (#219)
467 $this->restore_own_environment();
468 }
469 }
470 }
471
472 // The user declined (or was refused) and the source is still running.
473 // Record it so the warning OUTLIVES this screen — see pending_source().
474 // Called on every import, not just the declining ones: the helper
475 // checks the plugin's live state and clears itself when it is off, so
476 // a later "import and switch" also resolves an earlier warning.
477 if ( $imported_something ) {
478 Migration::remember_active_source( $source, $source_label );
479 }
480
481 if ( class_exists( '\\XSpeed\\Activity_Log' ) && ! empty( $results ) ) {
482 \XSpeed\Activity_Log::record(
483 'migration_applied',
484 $deactivated
485 ? sprintf( 'Imported settings from %1$s and deactivated it.', $source_label )
486 : sprintf( 'Imported settings from %s.', $source_label ),
487 \XSpeed\Activity_Log::INFO
488 );
489 }
490
491 return rest_ensure_response(
492 array(
493 'results' => $results,
494 'deactivated' => $deactivated,
495 'source_label' => $source_label,
496 // Empty unless we were asked to deactivate and declined to.
497 // The panel needs to distinguish "you didn't ask" from "you
498 // asked and you may not", or it would report the source as
499 // still active with no explanation.
500 'refused' => $refused,
501 // A ready-to-show sentence naming who CAN do it. The panel
502 // prints this verbatim rather than mapping codes to copy, so
503 // the network-vs-site distinction stays in one place.
504 'refused_message' => $refused_message,
505 )
506 );
507 }
508
509 /**
510 * May the CURRENT user switch this source plugin off?
511 *
512 * The route itself only requires `manage_options` (the module default),
513 * which is right for importing settings — that writes nothing but our own
514 * options. Deactivating somebody else's plugin is a different act, and WP
515 * core guards its own plugins screen with `activate_plugins`, escalating
516 * to `manage_network_plugins` for a network-active plugin.
517 *
518 * Without this check a subsite Administrator — who has manage_options but
519 * neither of those — could deactivate a NETWORK-ACTIVE caching plugin
520 * across every site in the network with one REST call. Reproduced on a
521 * live multisite install for #189; core would have refused the same user
522 * on wp-admin/plugins.php.
523 *
524 * @param string $source Source id.
525 */
526 private function can_deactivate( string $source ): bool {
527 $file = Migration::plugin_file( $source );
528 if ( '' === $file ) {
529 return false;
530 }
531
532 foreach ( array( 'plugin.php' ) as $inc ) {
533 require_once ABSPATH . 'wp-admin/includes/' . $inc;
534 }
535
536 // Network-active plugins are a network-level object: deactivating one
537 // affects every site, so it needs the network capability regardless of
538 // how much power the caller holds on this one site.
539 if ( is_multisite() && is_plugin_active_for_network( $file ) ) {
540 return current_user_can( 'manage_network_plugins' );
541 }
542
543 return current_user_can( 'activate_plugins' );
544 }
545
546 /**
547 * Deactivate the source caching plugin (network-wide on multisite).
548 * Returns true only if it was active and is now off.
549 *
550 * Callers MUST gate this on can_deactivate() — it performs no capability
551 * check of its own, because the CLI path resolves permission differently
552 * (a WP-CLI operator is root by definition and has no current user).
553 *
554 * @param string $source Source id.
555 * @return bool
556 */
557 private function deactivate_source( string $source ): bool {
558 // One home for this map, shared with Migration::status()'s active
559 // flag. A private copy here could drift and deactivate a plugin the
560 // panel had reported as inactive. (#189)
561 $file = Migration::plugin_file( $source );
562 if ( '' === $file ) {
563 return false;
564 }
565 // deactivate_plugins() fires each plugin's deactivation hook, and some
566 // (e.g. WP Super Cache) call admin-only helpers like get_home_path()
567 // in theirs. Those live in wp-admin/includes/file.php — NOT loaded
568 // during a REST request — so without these includes the deactivation
569 // hook fatals with "undefined function get_home_path()". Load the
570 // admin plumbing first so any source plugin's teardown runs cleanly.
571 foreach ( array( 'plugin.php', 'file.php', 'misc.php' ) as $inc ) {
572 require_once ABSPATH . 'wp-admin/includes/' . $inc;
573 }
574 if ( ! is_plugin_active( $file ) ) {
575 return false;
576 }
577
578 /*
579 * Be EXPLICIT about scope rather than leaving $network_wide at null.
580 *
581 * Core evaluates `( false !== $network_wide ) && is_plugin_active_for_network()`,
582 * and `false !== null` is true — so the default silently takes the
583 * network-wide branch. That is the correct scope for a network-active
584 * plugin (a per-site deactivation would not turn it off anyway), but
585 * it should be a decision we state, not a fact of PHP's comparison
586 * rules. can_deactivate() has already required the matching
587 * capability for whichever branch this picks. (#189)
588 */
589 $network_wide = is_multisite() && is_plugin_active_for_network( $file );
590 deactivate_plugins( $file, false, $network_wide );
591
592 return ! is_plugin_active( $file );
593 }
594
595 /**
596 * Put our own drop-in and WP_CACHE back after the source plugin's
597 * teardown, when we are the one that should own them.
598 *
599 * A source plugin's deactivation routine cleans up "the page cache
600 * environment" without checking whose it is. W3 Total Cache is the
601 * clearest case: PgCache_Environment.php strips EVERY
602 * `define( 'WP_CACHE', … )` line from wp-config.php with a blanket
603 * regex, so it deletes the line xSpeed wrote when the wizard enabled
604 * caching. wp-content/advanced-cache.php survives, but WordPress never
605 * loads it without the constant, and the cache silently degrades to the
606 * slow in-PHP path — measured at 78ms vs 16ms TTFB on an otherwise
607 * identical request.
608 *
609 * Only runs when the user has caching ON, and only re-asserts what we
610 * already own, so it cannot resurrect a cache the user turned off.
611 * (#218, #219)
612 */
613 private function restore_own_environment(): void {
614 if ( ! class_exists( '\\XSpeed\\Cache' ) || ! class_exists( '\\XSpeed\\Settings' ) ) {
615 return;
616 }
617
618 $opts = \XSpeed\Settings::get();
619 if ( empty( $opts['cache_enabled'] ) ) {
620 return;
621 }
622
623 // Install the drop-in BEFORE the constant that tells WordPress to
624 // load it, so the two are never briefly out of step.
625 \XSpeed\Cache::install_dropin();
626 \XSpeed\Cache::set_wp_cache_constant( true );
627 }
628
629 public function cli_commands(): array {
630 return array(
631 array(
632 'name' => 'xspeed migrate',
633 'callback' => array( $this, 'cli_handler' ),
634 'shortdesc' => 'Import settings from another caching plugin.',
635 'synopsis' => array(
636 array(
637 'type' => 'positional',
638 'name' => 'action',
639 'options' => array( 'status', 'preview', 'apply' ),
640 'optional' => true,
641 ),
642 array(
643 'type' => 'assoc',
644 'name' => 'source',
645 'optional' => true,
646 ),
647 array(
648 'type' => 'flag',
649 'name' => 'deactivate-source',
650 '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.',
651 'optional' => true,
652 ),
653 ),
654 ),
655 );
656 }
657
658 public function cli_handler( array $args, array $assoc ): void {
659 $action = $args[0] ?? 'status';
660 switch ( $action ) {
661 case 'status':
662 foreach ( Migration::status() as $s ) {
663 \WP_CLI::log( sprintf( '%-20s %s %d values', $s['id'], $s['detected'] ? 'DETECTED' : 'missing ', $s['value_count'] ) );
664 }
665 return;
666 case 'preview':
667 $src = (string) ( $assoc['source'] ?? '' );
668 $p = Migration::preview( $src );
669 if ( null === $p ) {
670 \WP_CLI::error( 'Source not detected or unknown: ' . $src );
671 }
672 \WP_CLI::log( wp_json_encode( $p, JSON_PRETTY_PRINT ) );
673 return;
674 case 'apply':
675 $src = (string) ( $assoc['source'] ?? '' );
676 $r = Migration::apply( $src );
677 if ( empty( $r ) ) {
678 \WP_CLI::error( 'Nothing imported.' );
679 }
680 foreach ( $r as $mod => $info ) {
681 /*
682 * "failed" was a lie. `ok` is update_option()'s return,
683 * which is false when the stored value did not CHANGE — so
684 * re-importing settings already in place printed
685 * "failed" for every module beside the list of fields it
686 * had just imported correctly. Report what actually
687 * happened instead. (#189)
688 */
689 $applied = (array) ( $info['applied'] ?? array() );
690 if ( empty( $applied ) ) {
691 $state = 'nothing to import';
692 } elseif ( ! empty( $info['ok'] ) ) {
693 $state = 'imported';
694 } else {
695 $state = 'already up to date';
696 }
697 \WP_CLI::log( sprintf( '%-20s %-18s %s', $mod, $state, implode( ',', $applied ) ) );
698 }
699
700 /*
701 * Same contract as REST: deactivate only when asked. This path
702 * used to never deactivate AND never say so, so an operator
703 * (or an AI through MCP `run_command`) finished with two page
704 * caches live on the site and nothing in the output to say it.
705 * That is the failure mode the troubleshooting docs describe
706 * as breaking caching for both plugins. (#189)
707 *
708 * No capability check here: a WP-CLI caller is root by
709 * definition and there is no current user to test. The gate
710 * that matters is on the REST route, which is the one a
711 * browser can reach.
712 */
713 // `applied`, not `ok` — see rest_apply() for why ok:false is a
714 // normal outcome of a successful re-import.
715 $imported_something = false;
716 foreach ( (array) $r as $info ) {
717 if ( is_array( $info ) && ! empty( $info['applied'] ) ) {
718 $imported_something = true;
719 break;
720 }
721 }
722
723 // WP-CLI normalises --deactivate-source to a 'deactivate-source'
724 // key; accept the underscore spelling too so MCP callers passing
725 // options as JSON don't have to guess which one we mean.
726 $want_off = ! empty( $assoc['deactivate-source'] ) || ! empty( $assoc['deactivate_source'] );
727 $file = Migration::plugin_file( $src );
728
729 require_once ABSPATH . 'wp-admin/includes/plugin.php';
730 $still_on = '' !== $file && is_plugin_active( $file );
731
732 if ( $want_off && $imported_something && $still_on ) {
733 if ( $this->deactivate_source( $src ) ) {
734 \WP_CLI::log( sprintf( 'Deactivated %s.', $src ) );
735 $still_on = false;
736 } else {
737 \WP_CLI::warning( sprintf( 'Could not deactivate %s.', $src ) );
738 }
739 }
740
741 if ( $still_on ) {
742 \WP_CLI::warning(
743 sprintf(
744 '%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.',
745 $src
746 )
747 );
748 }
749
750 // Same persistent record as the REST path, so a CLI or MCP
751 // import that leaves the source running also raises the Health
752 // warning — the three surfaces must end in the same state for
753 // the same input. (#189 AC4, AC10)
754 if ( $imported_something ) {
755 $label = '';
756 foreach ( Migration::status() as $s ) {
757 if ( $s['id'] === $src ) {
758 $label = (string) $s['label'];
759 break;
760 }
761 }
762 Migration::remember_active_source( $src, $label );
763 }
764
765 \WP_CLI::success( 'Import complete.' );
766 return;
767 default:
768 // Without this, an unrecognised action fell out of the switch
769 // and returned success with no output — indistinguishable from
770 // "ran fine, nothing to report", and ok:true over MCP.
771 \WP_CLI::error(
772 sprintf(
773 'Unknown action "%s". Expected: status | preview --source=<id> | apply --source=<id>.',
774 $action
775 )
776 );
777 }
778 }
779 }
780