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

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

511 lines 18.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',
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 echo '<p style="margin:0 0 12px;color:#475569;font-size:13px;">'
175 . esc_html__( 'Import your existing settings in one click instead of configuring everything by hand. Pick a source to migrate:', 'xspeed' )
176 . '</p>';
177
178 // One row per detected source: label + value count + its own Import button.
179 echo '<div style="display:flex;flex-direction:column;gap:8px;">';
180 foreach ( $detected as $s ) {
181 // Query arg BEFORE the hash so the dashboard still reads #migration.
182 $src_url = $base_url . '&source=' . rawurlencode( $s['id'] ) . '#migration';
183 echo '<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:8px 12px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;">';
184 $mapped = (int) ( $s['mapped_count'] ?? 0 );
185 echo '<span style="font-size:13px;color:#1e293b;"><strong>' . esc_html( $s['label'] ) . '</strong>'
186 . ' <span style="color:#94a3b8;">'
187 . esc_html(
188 sprintf(
189 /* translators: %d: number of settings xSpeed will actually import. */
190 _n( 'imports %d setting', 'imports %d settings', $mapped, 'xspeed' ),
191 $mapped
192 )
193 )
194 . '</span></span>';
195 echo '<a href="' . esc_url( $src_url ) . '" class="button button-primary xspeed-mig-cta" style="flex:0 0 auto;">'
196 . esc_html__( 'Import', 'xspeed' ) . '</a>';
197 echo '</div>';
198 }
199 echo '</div>';
200
201 // Footer: open the full panel + dismiss the whole notice.
202 echo '<p style="margin:12px 0 0;display:flex;gap:16px;align-items:center;">';
203 echo '<a href="' . esc_url( $panel_url ) . '" style="font-size:13px;">' . esc_html__( 'Open Migration panel', 'xspeed' ) . '</a>';
204 echo '<a href="' . esc_url( $dismiss_url ) . '" style="font-size:13px;color:#94a3b8;text-decoration:none;">' . esc_html__( 'Dismiss', 'xspeed' ) . '</a>';
205 echo '</p>';
206
207 echo '</div></div>';
208 }
209
210 /**
211 * Detected sources that are still actionable — not dismissed and not
212 * already imported — richest first. These are what the dashboard notice
213 * and the sidebar badge count: "new caching plugins you could migrate
214 * from". Once imported, a source drops out.
215 *
216 * @param string[] $dismissed Dismissed source ids ('all' hides every one).
217 * @return array<int,array{id:string,label:string,mapped_count:int}>
218 */
219 private function detected_sources( array $dismissed = array() ): array {
220 if ( in_array( 'all', $dismissed, true ) ) {
221 return array();
222 }
223 $out = array();
224 foreach ( Migration::status() as $s ) {
225 if ( empty( $s['detected'] ) || ! empty( $s['imported'] ) || in_array( $s['id'], $dismissed, true ) ) {
226 continue;
227 }
228 $out[] = $s;
229 }
230 // Order by the honest mapped count (what we actually import).
231 usort( $out, static fn( $a, $b ) => (int) $b['mapped_count'] <=> (int) $a['mapped_count'] );
232 return $out;
233 }
234
235 /** Inline brand logo SVG when white-label supplies one; else empty. */
236 private function branding_logo(): string {
237 $brand = apply_filters( 'xspeed_branding', array() );
238 return isset( $brand['logo_svg'] ) && is_string( $brand['logo_svg'] ) ? $brand['logo_svg'] : '';
239 }
240
241 /** Persist the per-source dismissal when the user clicks our Dismiss link. */
242 public function handle_dismiss(): void {
243 if ( ! isset( $_GET[ self::DISMISS_ARG ] ) || ! current_user_can( 'manage_options' ) ) {
244 return;
245 }
246 $source = sanitize_key( wp_unslash( $_GET[ self::DISMISS_ARG ] ) );
247 if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'xspeed_dismiss_migration_' . $source ) ) {
248 return;
249 }
250 $uid = get_current_user_id();
251 $dismissed = (array) get_user_meta( $uid, self::DISMISS_META, true );
252 if ( ! in_array( $source, $dismissed, true ) ) {
253 $dismissed[] = $source;
254 update_user_meta( $uid, self::DISMISS_META, $dismissed );
255 }
256 // Redirect to drop the query args so a reload doesn't re-trigger.
257 wp_safe_redirect( remove_query_arg( array( self::DISMISS_ARG, '_wpnonce' ) ) );
258 exit;
259 }
260
261 /**
262 * The single most relevant detected source to nudge about, or null.
263 * Picks the detected source with the most settings (the richest import),
264 * skipping any the user has already dismissed — so dismissing the top
265 * prompt surfaces the next source rather than going silent while another
266 * importable plugin is still present. Only one prompt at a time keeps the
267 * dashboard uncluttered.
268 *
269 * @param string[] $dismissed Source ids the user has dismissed.
270 * @return array{id:string,label:string,value_count:int}|null
271 */
272 private function top_detected_source( array $dismissed = array() ): ?array {
273 $best = null;
274 foreach ( Migration::status() as $s ) {
275 if ( empty( $s['detected'] ) || in_array( $s['id'], $dismissed, true ) ) {
276 continue;
277 }
278 if ( null === $best || (int) $s['value_count'] > (int) $best['value_count'] ) {
279 $best = $s;
280 }
281 }
282 return $best;
283 }
284
285 /** Brand name honoring Pro white-label, falling back to "xSpeed". */
286 private function branding_name(): string {
287 $brand = apply_filters( 'xspeed_branding', array() );
288 return isset( $brand['name'] ) && '' !== $brand['name'] ? (string) $brand['name'] : 'xSpeed';
289 }
290
291 /**
292 * Brand/logo color for the notice accent + Import buttons. White-label
293 * sites can set `brand_color` via the xspeed_branding filter; otherwise
294 * we use the xSpeed logo color (near-black), not the design blue accent —
295 * the notice should match the on-screen logo. (FBS-82379)
296 */
297 private function brand_color(): string {
298 $brand = apply_filters( 'xspeed_branding', array() );
299 $color = isset( $brand['brand_color'] ) ? (string) $brand['brand_color'] : '';
300 return preg_match( '/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $color ) ? $color : '#1e1e1e';
301 }
302
303 public function rest_routes(): array {
304 return array(
305 array(
306 'path' => '/status',
307 'methods' => 'GET',
308 'callback' => array( $this, 'rest_status' ),
309 ),
310 array(
311 'path' => '/preview',
312 'methods' => 'POST',
313 'callback' => array( $this, 'rest_preview' ),
314 ),
315 array(
316 'path' => '/apply',
317 'methods' => 'POST',
318 'callback' => array( $this, 'rest_apply' ),
319 ),
320 );
321 }
322
323 public function rest_status( \WP_REST_Request $request ) {
324 $status = Migration::status();
325 // Opening the Migration panel triggers this call — treat it as the
326 // user having SEEN every currently-detected source, which clears the
327 // sidebar count badge. Record the detected ids against the user.
328 $this->mark_sources_seen( $status );
329 return rest_ensure_response( array( 'sources' => $status ) );
330 }
331
332 /**
333 * Record the currently-detected source ids as seen for this user, so the
334 * sidebar badge stops counting them. Merges with any prior seen set.
335 *
336 * @param array $status Output of Migration::status().
337 */
338 private function mark_sources_seen( array $status ): void {
339 $uid = get_current_user_id();
340 if ( ! $uid ) {
341 return;
342 }
343 $seen = (array) get_user_meta( $uid, self::SEEN_META, true );
344 $add = array();
345 foreach ( $status as $s ) {
346 if ( ! empty( $s['detected'] ) ) {
347 $add[] = $s['id'];
348 }
349 }
350 $merged = array_values( array_unique( array_merge( $seen, $add ) ) );
351 if ( $merged !== $seen ) {
352 update_user_meta( $uid, self::SEEN_META, $merged );
353 }
354 }
355
356 public function rest_preview( \WP_REST_Request $request ) {
357 $params = $request->get_json_params();
358 $source = isset( $params['source'] ) ? (string) $params['source'] : '';
359 $patch = Migration::preview( $source );
360 if ( null === $patch ) {
361 return new \WP_Error( 'xspeed_pro_mig_no_source', 'Source not detected or unknown.', array( 'status' => 404 ) );
362 }
363 return rest_ensure_response( array( 'patch' => $patch ) );
364 }
365
366 /**
367 * Source id → its plugin file (folder/main.php), so we can deactivate the
368 * source after a successful import. Running two page caches at once causes
369 * double-caching / conflicting drop-ins, so the source must be turned off.
370 */
371 private const SOURCE_PLUGIN_FILE = array(
372 'wp-rocket' => 'wp-rocket/wp-rocket.php',
373 'w3-total-cache' => 'w3-total-cache/w3-total-cache.php',
374 'wp-super-cache' => 'wp-super-cache/wp-cache.php',
375 'litespeed-cache' => 'litespeed-cache/litespeed-cache.php',
376 );
377
378 public function rest_apply( \WP_REST_Request $request ) {
379 $params = $request->get_json_params();
380 $source = isset( $params['source'] ) ? (string) $params['source'] : '';
381 if ( '' === $source ) {
382 return new \WP_Error( 'xspeed_pro_mig_no_source', 'Provide a source id.', array( 'status' => 400 ) );
383 }
384 $results = Migration::apply( $source );
385
386 // After a successful import, deactivate the source plugin — two page
387 // caches running together double-cache and fight over the drop-in.
388 // We report what we did so the panel can tell the user plainly.
389 $deactivated = false;
390 $source_label = '';
391 foreach ( Migration::status() as $s ) {
392 if ( $s['id'] === $source ) {
393 $source_label = (string) $s['label'];
394 break;
395 }
396 }
397 if ( ! empty( $results ) ) {
398 $deactivated = $this->deactivate_source( $source );
399 }
400
401 if ( class_exists( '\\XSpeed\\Activity_Log' ) && ! empty( $results ) ) {
402 \XSpeed\Activity_Log::record(
403 'migration_applied',
404 $deactivated
405 ? sprintf( 'Imported settings from %1$s and deactivated it.', $source_label )
406 : sprintf( 'Imported settings from %s.', $source_label ),
407 \XSpeed\Activity_Log::INFO
408 );
409 }
410
411 return rest_ensure_response(
412 array(
413 'results' => $results,
414 'deactivated' => $deactivated,
415 'source_label' => $source_label,
416 )
417 );
418 }
419
420 /**
421 * Deactivate the source caching plugin (network-wide on multisite).
422 * Returns true only if it was active and is now off.
423 *
424 * @param string $source Source id.
425 * @return bool
426 */
427 private function deactivate_source( string $source ): bool {
428 $file = self::SOURCE_PLUGIN_FILE[ $source ] ?? '';
429 if ( '' === $file ) {
430 return false;
431 }
432 // deactivate_plugins() fires each plugin's deactivation hook, and some
433 // (e.g. WP Super Cache) call admin-only helpers like get_home_path()
434 // in theirs. Those live in wp-admin/includes/file.php — NOT loaded
435 // during a REST request — so without these includes the deactivation
436 // hook fatals with "undefined function get_home_path()". Load the
437 // admin plumbing first so any source plugin's teardown runs cleanly.
438 foreach ( array( 'plugin.php', 'file.php', 'misc.php' ) as $inc ) {
439 require_once ABSPATH . 'wp-admin/includes/' . $inc;
440 }
441 if ( ! is_plugin_active( $file ) ) {
442 return false;
443 }
444 deactivate_plugins( $file ); // network-wide if it was network-active.
445 return ! is_plugin_active( $file );
446 }
447
448 public function cli_commands(): array {
449 return array(
450 array(
451 'name' => 'xspeed migrate',
452 'callback' => array( $this, 'cli_handler' ),
453 'shortdesc' => 'Import settings from another caching plugin.',
454 'synopsis' => array(
455 array(
456 'type' => 'positional',
457 'name' => 'action',
458 'options' => array( 'status', 'preview', 'apply' ),
459 'optional' => true,
460 ),
461 array(
462 'type' => 'assoc',
463 'name' => 'source',
464 'optional' => true,
465 ),
466 ),
467 ),
468 );
469 }
470
471 public function cli_handler( array $args, array $assoc ): void {
472 $action = $args[0] ?? 'status';
473 switch ( $action ) {
474 case 'status':
475 foreach ( Migration::status() as $s ) {
476 \WP_CLI::log( sprintf( '%-20s %s %d values', $s['id'], $s['detected'] ? 'DETECTED' : 'missing ', $s['value_count'] ) );
477 }
478 return;
479 case 'preview':
480 $src = (string) ( $assoc['source'] ?? '' );
481 $p = Migration::preview( $src );
482 if ( null === $p ) {
483 \WP_CLI::error( 'Source not detected or unknown: ' . $src );
484 }
485 \WP_CLI::log( wp_json_encode( $p, JSON_PRETTY_PRINT ) );
486 return;
487 case 'apply':
488 $src = (string) ( $assoc['source'] ?? '' );
489 $r = Migration::apply( $src );
490 if ( empty( $r ) ) {
491 \WP_CLI::error( 'Nothing imported.' );
492 }
493 foreach ( $r as $mod => $info ) {
494 \WP_CLI::log( sprintf( '%-20s %s — %s', $mod, $info['ok'] ? 'ok' : 'failed', implode( ',', $info['applied'] ) ) );
495 }
496 \WP_CLI::success( 'Import complete.' );
497 return;
498 default:
499 // Without this, an unrecognised action fell out of the switch
500 // and returned success with no output — indistinguishable from
501 // "ran fine, nothing to report", and ok:true over MCP.
502 \WP_CLI::error(
503 sprintf(
504 'Unknown action "%s". Expected: status | preview --source=<id> | apply --source=<id>.',
505 $action
506 )
507 );
508 }
509 }
510 }
511