PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.6
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 / class-migration.php

class-migration.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.0.6, at includes/class-migration.php

686 lines 27.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Migration — read settings from other popular caching plugins and
4 * translate them into xSpeed equivalents.
5 *
6 * Each source plugin has its own importer that returns a single
7 * normalized patch shape:
8 *
9 * array<string,array> // module-slug → settings patch
10 *
11 * which feeds straight into update_option('xspeed_module_<slug>')
12 * via the same write path the Recommendations module uses.
13 *
14 * Importers are pure: detect() reads the options table, returns
15 * what it found (or null if the source plugin's options aren't
16 * present). plan() turns that raw read into the patch. apply()
17 * writes it. preview() returns plan() without writing — used by
18 * the React panel for the "what would import" diff view.
19 *
20 * Adding a new source = adding a private detect_*() + plan_*()
21 * pair, then wiring them in `sources()`.
22 *
23 * @package XSpeed
24 */
25
26 declare(strict_types=1);
27
28 namespace XSpeed;
29
30 defined( 'ABSPATH' ) || exit;
31
32 final class Migration {
33
34 /**
35 * Public list of source plugins with metadata for the UI:
36 * [ id => [ label, detect_cb, plan_cb ] ]
37 */
38 public static function sources(): array {
39 return array(
40 'wp-rocket' => array(
41 'label' => 'WP Rocket',
42 'detect' => array( __CLASS__, 'detect_wp_rocket' ),
43 'plan' => array( __CLASS__, 'plan_wp_rocket' ),
44 ),
45 'w3-total-cache' => array(
46 'label' => 'W3 Total Cache',
47 'detect' => array( __CLASS__, 'detect_w3tc' ),
48 'plan' => array( __CLASS__, 'plan_w3tc' ),
49 ),
50 'wp-super-cache' => array(
51 'label' => 'WP Super Cache',
52 'detect' => array( __CLASS__, 'detect_wpsc' ),
53 'plan' => array( __CLASS__, 'plan_wpsc' ),
54 ),
55 'litespeed-cache' => array(
56 'label' => 'LiteSpeed Cache',
57 'detect' => array( __CLASS__, 'detect_litespeed' ),
58 'plan' => array( __CLASS__, 'plan_litespeed' ),
59 ),
60 );
61 }
62
63 /** Site option holding the list of source ids already imported. */
64 private const IMPORTED_OPTION = 'xspeed_migration_imported';
65
66 /** Source id → plugin file, for the active-state check. */
67 private const PLUGIN_FILE = array(
68 'wp-rocket' => 'wp-rocket/wp-rocket.php',
69 'w3-total-cache' => 'w3-total-cache/w3-total-cache.php',
70 'wp-super-cache' => 'wp-super-cache/wp-cache.php',
71 'litespeed-cache' => 'litespeed-cache/litespeed-cache.php',
72 );
73
74 /** Record a source as imported (idempotent). */
75 public static function mark_imported( string $id ): void {
76 $done = (array) get_option( self::IMPORTED_OPTION, array() );
77 if ( ! in_array( $id, $done, true ) ) {
78 $done[] = $id;
79 update_option( self::IMPORTED_OPTION, array_values( $done ) );
80 }
81 }
82
83 /**
84 * For each source, return { id, label, detected, value_count, mapped_count,
85 * imported, active }.
86 * - `detected` true when the source plugin's settings are present.
87 * - `value_count` raw number of keys in the source's own config — NOT
88 * how many we import; kept for diagnostics only.
89 * - `mapped_count` how many settings the importer ACTUALLY writes into
90 * xSpeed (the honest number to show users).
91 * - `imported` true once this source has been imported (so the panel
92 * shows it as done, not as a fresh Import target).
93 * - `active` whether the source plugin is still active.
94 */
95 public static function status(): array {
96 $imported = (array) get_option( self::IMPORTED_OPTION, array() );
97 if ( ! function_exists( 'is_plugin_active' ) ) {
98 require_once ABSPATH . 'wp-admin/includes/plugin.php';
99 }
100 $out = array();
101 foreach ( self::sources() as $id => $spec ) {
102 $raw = call_user_func( $spec['detect'] );
103 $detected = is_array( $raw );
104 $mapped = 0;
105 if ( $detected ) {
106 $patch = call_user_func( $spec['plan'], $raw );
107 if ( is_array( $patch ) ) {
108 $mapped = self::count_meaningful( $patch );
109 }
110 }
111 $file = self::PLUGIN_FILE[ $id ] ?? '';
112 $out[] = array(
113 'id' => $id,
114 'label' => $spec['label'],
115 'detected' => $detected,
116 'value_count' => $detected ? count( $raw ) : 0,
117 'mapped_count' => $mapped,
118 'imported' => in_array( $id, $imported, true ),
119 'active' => '' !== $file && is_plugin_active( $file ),
120 );
121 }
122 return $out;
123 }
124
125 /**
126 * Count the settings in a plan patch that will MEANINGFULLY change the
127 * config — i.e. the ones actually enabled / non-empty in the source.
128 *
129 * A `plan_*` patch always emits every mapped field, including the ones the
130 * source has turned OFF (`false`) or left empty. Counting those inflated
131 * the "settings available to migrate" number — a source with 2 settings on
132 * still reported ~12 because the importer listed every false mapping.
133 * Disabled (`false`) booleans and empty arrays/strings/zeros contribute
134 * nothing on import, so they're excluded from the count. (FBS-82449)
135 *
136 * @param array<string,mixed> $patch Plan patch (module slug => values).
137 * @return int Number of enabled / non-empty settings.
138 */
139 private static function count_meaningful( array $patch ): int {
140 $count = 0;
141 foreach ( $patch as $vals ) {
142 if ( is_array( $vals ) ) {
143 $count += count( self::meaningful_values( $vals ) );
144 }
145 }
146 return $count;
147 }
148
149 /**
150 * Filter one module's plan values down to the ones that meaningfully change
151 * the config: enabled (`true`) booleans, non-empty arrays, and non-empty
152 * scalars. Disabled toggles, empty lists, and zero/empty scalars are
153 * dropped — they represent "nothing to import" for that setting. Shared by
154 * the count (status) and the write (apply) so both agree. (FBS-82449)
155 *
156 * @param array<string,mixed> $values One module's mapped values.
157 * @return array<string,mixed> Only the meaningful entries.
158 */
159 private static function meaningful_values( array $values ): array {
160 $out = array();
161 foreach ( $values as $key => $value ) {
162 if ( is_bool( $value ) ) {
163 if ( $value ) {
164 $out[ $key ] = $value; // Only an enabled toggle imports.
165 }
166 } elseif ( is_array( $value ) ) {
167 if ( ! empty( $value ) ) {
168 $out[ $key ] = $value; // Non-empty list (e.g. excluded_urls).
169 }
170 } elseif ( '' !== $value && null !== $value && 0 !== $value && '0' !== $value ) {
171 $out[ $key ] = $value; // Non-empty scalar (e.g. cache_expiry).
172 }
173 }
174 return $out;
175 }
176
177 /**
178 * Return the patch that `apply()` would write, without writing.
179 */
180 public static function preview( string $source_id ): ?array {
181 $src = self::sources()[ $source_id ] ?? null;
182 if ( null === $src ) {
183 return null;
184 }
185 $raw = call_user_func( $src['detect'] );
186 if ( ! is_array( $raw ) ) {
187 return null;
188 }
189 return call_user_func( $src['plan'], $raw );
190 }
191
192 /**
193 * Read source settings + write the translated patch. Returns the
194 * per-module write results, same shape as Recommendations::apply.
195 */
196 public static function apply( string $source_id ): array {
197 $patch = self::preview( $source_id );
198 if ( null === $patch ) {
199 return array();
200 }
201 $results = array();
202 foreach ( $patch as $slug => $values ) {
203 if ( ! is_string( $slug ) || ! is_array( $values ) ) {
204 continue;
205 }
206 // Import only the settings that are actually enabled / non-empty in
207 // the source. A plan patch emits every mapped field including the
208 // ones the source turned OFF; merging those `false`/empty values
209 // would silently DISABLE settings the user already had on in xSpeed.
210 // Migration is additive — it never clobbers existing config with a
211 // source's disabled value. (FBS-82449)
212 $meaningful = self::meaningful_values( $values );
213 if ( empty( $meaningful ) ) {
214 continue;
215 }
216 $option = 'xspeed_module_' . $slug;
217 $cur = (array) get_option( $option, array() );
218 $next = array_merge( $cur, $meaningful );
219 $ok = update_option( $option, $next );
220 $results[ $slug ] = array(
221 'ok' => (bool) $ok,
222 'applied' => array_keys( $meaningful ),
223 );
224 }
225 if ( ! empty( $results ) ) {
226 self::mark_imported( $source_id );
227 }
228 return $results;
229 }
230
231 // ─────────────────────────── WP Rocket ───────────────────────────
232
233 public static function detect_wp_rocket(): ?array {
234 $opt = get_option( 'wp_rocket_settings', null );
235 return is_array( $opt ) ? $opt : null;
236 }
237
238 /**
239 * Translate WP Rocket's `wp_rocket_settings` array into our module
240 * settings. Only safe-to-port booleans + counts; behaviorally
241 * different toggles (Critical CSS, RUCSS) skip — Pro handles those.
242 *
243 * @param array $r raw wp_rocket_settings.
244 */
245 public static function plan_wp_rocket( array $r ): array {
246 $patch = array();
247 // Page caching.
248 $patch['cache'] = array(
249 'enabled' => ! empty( $r['cache_logged_user'] ) || ! isset( $r['cache_logged_user'] ),
250 'expiry_hours' => isset( $r['purge_cron_interval'] ) ? max( 1, (int) ( $r['purge_cron_interval'] / 3600 ) ) : 24,
251 );
252 // Excluded URLs / cookies — both are arrays of strings in WP Rocket.
253 if ( ! empty( $r['cache_reject_uri'] ) && is_array( $r['cache_reject_uri'] ) ) {
254 $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_uri'] ) ) );
255 }
256 if ( ! empty( $r['cache_reject_cookies'] ) && is_array( $r['cache_reject_cookies'] ) ) {
257 $patch['cache']['excluded_cookies'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_cookies'] ) ) );
258 }
259
260 // Minify.
261 $patch['minify'] = array(
262 'minify_html' => ! empty( $r['minify_html'] ),
263 'minify_css' => ! empty( $r['minify_css'] ),
264 'minify_js' => ! empty( $r['minify_js'] ),
265 'combine_css' => ! empty( $r['minify_concatenate_css'] ),
266 'combine_js' => ! empty( $r['minify_concatenate_js'] ),
267 'defer_js' => ! empty( $r['defer_all_js'] ),
268 );
269
270 // Lazy load.
271 $patch['lazy'] = array(
272 'lazy_images' => ! empty( $r['lazyload'] ),
273 'lazy_iframes' => ! empty( $r['lazyload_iframes'] ),
274 'lazy_videos' => ! empty( $r['lazyload_youtube'] ),
275 );
276
277 // GZIP — WP Rocket writes its own .htaccess; we infer the toggle from "do_cloudflare" etc.
278 if ( isset( $r['do_caching_mobile_files'] ) ) {
279 $patch['cache']['mobile_separate'] = (bool) $r['do_caching_mobile_files'];
280 }
281
282 // Preloader.
283 if ( ! empty( $r['manual_preload'] ) || ! empty( $r['sitemap_preload'] ) ) {
284 $patch['preloader'] = array(
285 'enabled' => true,
286 'schedule' => 'daily',
287 );
288 if ( ! empty( $r['sitemap_preload_url'] ) && is_array( $r['sitemap_preload_url'] ) ) {
289 $patch['preloader']['sitemap_urls'] = array_values( array_filter( array_map( 'strval', $r['sitemap_preload_url'] ) ) );
290 }
291 }
292
293 // CDN — WP Rocket stores CDN hosts in cdn_cnames (array).
294 if ( ! empty( $r['cdn'] ) && ! empty( $r['cdn_cnames'] ) && is_array( $r['cdn_cnames'] ) ) {
295 $first = (string) ( $r['cdn_cnames'][0] ?? '' );
296 if ( '' !== $first ) {
297 $patch['cdn'] = array(
298 'enabled' => true,
299 'cdn_url' => $first,
300 );
301 }
302 }
303
304 return $patch;
305 }
306
307 // ─────────────────────────── W3 Total Cache ──────────────────────
308
309 public static function detect_w3tc(): ?array {
310 // W3 Total Cache does NOT store its config in the options table — it
311 // writes a PHP file at wp-content/w3tc-config/master.php whose body
312 // is a short PHP guard followed by a JSON blob of dotted-key settings
313 // (pgcache.enabled, minify.html.enable, …). Reading w3tc_config /
314 // w3tc_master_settings options always returned null, so detection
315 // failed on every install. Read + parse the config file instead.
316 $cfg = self::read_w3tc_config_file();
317 if ( is_array( $cfg ) && ! empty( $cfg ) ) {
318 return $cfg;
319 }
320 // Defensive fallback for any build that did persist an options blob.
321 $opt = get_option( 'w3tc_config', null );
322 if ( ! is_array( $opt ) ) {
323 $opt = get_option( 'w3tc_master_settings', null );
324 }
325 return is_array( $opt ) ? $opt : null;
326 }
327
328 /**
329 * Parse W3TC's master config file into a flat dotted-key array.
330 * Format: a short PHP guard (a php-open, exit, php-close) immediately
331 * followed by a JSON object. We strip everything up to and including the
332 * PHP closing tag, then JSON-decode the remainder.
333 *
334 * @return array|null parsed config, or null if the file is missing/unreadable.
335 */
336 private static function read_w3tc_config_file(): ?array {
337 if ( ! defined( 'WP_CONTENT_DIR' ) ) {
338 return null;
339 }
340 $path = WP_CONTENT_DIR . '/w3tc-config/master.php';
341 if ( ! is_readable( $path ) ) {
342 return null;
343 }
344 $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading another plugin's local config file; WP_Filesystem is overkill for a one-shot read.
345 if ( false === $raw || '' === $raw ) {
346 return null;
347 }
348 // Drop the leading PHP guard and decode the JSON tail. The pattern
349 // matches up to the first PHP closing tag; built from a char-code so
350 // no literal close tag appears in this source file.
351 $close_tag = '?' . '>';
352 $json = preg_replace( '/^.*?' . preg_quote( $close_tag, '/' ) . '/s', '', $raw );
353 $cfg = json_decode( trim( (string) $json ), true );
354 return is_array( $cfg ) ? $cfg : null;
355 }
356
357 public static function plan_w3tc( array $r ): array {
358 $patch = array();
359 $patch['cache'] = array(
360 'enabled' => ! empty( $r['pgcache.enabled'] ),
361 'expiry_hours' => isset( $r['pgcache.lifetime'] ) ? max( 1, (int) ( (int) $r['pgcache.lifetime'] / 3600 ) ) : 24,
362 );
363 if ( ! empty( $r['pgcache.reject.uri'] ) && is_array( $r['pgcache.reject.uri'] ) ) {
364 $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['pgcache.reject.uri'] ) ) );
365 }
366
367 $patch['minify'] = array(
368 'minify_html' => ! empty( $r['minify.html.enable'] ),
369 'minify_css' => ! empty( $r['minify.css.enable'] ),
370 'minify_js' => ! empty( $r['minify.js.enable'] ),
371 );
372
373 if ( ! empty( $r['objectcache.enabled'] ) && ! empty( $r['objectcache.engine'] ) ) {
374 $patch['object-cache'] = array(
375 'backend' => 'memcached' === $r['objectcache.engine'] ? 'memcached' : 'redis',
376 );
377 if ( ! empty( $r['objectcache.servers'] ) && is_array( $r['objectcache.servers'] ) ) {
378 $first = (string) ( $r['objectcache.servers'][0] ?? '' );
379 if ( false !== strpos( $first, ':' ) ) {
380 [ $host, $port ] = explode( ':', $first, 2 );
381 if ( 'redis' === ( $patch['object-cache']['backend'] ?? '' ) ) {
382 $patch['object-cache']['redis_host'] = $host;
383 $patch['object-cache']['redis_port'] = (int) $port;
384 } else {
385 $patch['object-cache']['memcached_host'] = $host;
386 $patch['object-cache']['memcached_port'] = (int) $port;
387 }
388 }
389 }
390 }
391
392 if ( ! empty( $r['browsercache.enabled'] ) ) {
393 $patch['browser-cache'] = array(
394 'enabled' => true,
395 );
396 }
397
398 return $patch;
399 }
400
401 // ─────────────────────────── WP Super Cache ──────────────────────
402
403 public static function detect_wpsc(): ?array {
404 // WP Super Cache stores its settings as PHP globals in
405 // wp-content/wp-cache-config.php (NOT the options table — the old
406 // get_option('wp_cache_enabled') reads always returned null). Parse
407 // the config file for the globals plan_wpsc() needs. If the file
408 // doesn't exist yet (plugin active but never configured), fall back
409 // to a minimal "active" marker so the source still appears in the UI
410 // and a default import is possible.
411 $cfg = self::read_wpsc_config_file();
412 if ( is_array( $cfg ) && ! empty( $cfg ) ) {
413 return $cfg;
414 }
415 if ( self::plugin_active( 'wp-super-cache/wp-cache.php' ) ) {
416 // Active but unconfigured — expose the on/off intent only.
417 return array( 'wp_cache_enabled' => defined( 'WPCACHEHOME' ) );
418 }
419 return null;
420 }
421
422 /**
423 * Parse the WP Super Cache config file for the globals we map. The file
424 * is plain PHP assigning `$wp_cache_* = …;` lines; we extract them with a
425 * regex rather than including the file (including it would define
426 * constants / run code in our request).
427 *
428 * @return array|null name => value for the recognised globals, or null.
429 */
430 private static function read_wpsc_config_file(): ?array {
431 if ( ! defined( 'WP_CONTENT_DIR' ) ) {
432 return null;
433 }
434 $path = WP_CONTENT_DIR . '/wp-cache-config.php';
435 if ( ! is_readable( $path ) ) {
436 return null;
437 }
438 $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- one-shot read of another plugin's config file.
439 if ( false === $raw || '' === $raw ) {
440 return null;
441 }
442 $out = array();
443 $keys = array( 'wp_cache_enabled', 'wp_cache_mod_rewrite', 'wp_cache_mobile_enabled', 'wp_cache_not_logged_in', 'wp_cache_make_known_anon' );
444 foreach ( $keys as $key ) {
445 // Match `$key = 1;` / `$key = '1';` / `$key = true;` etc.
446 if ( preg_match( '/\$' . preg_quote( $key, '/' ) . '\s*=\s*([^;]+);/', $raw, $m ) ) {
447 $val = trim( $m[1], " \t'\"" );
448 $out[ $key ] = in_array( strtolower( $val ), array( '1', 'true' ), true );
449 }
450 }
451 return ! empty( $out ) ? $out : null;
452 }
453
454 /** Thin wrapper so detection works before admin plugin.php is loaded. */
455 private static function plugin_active( string $plugin ): bool {
456 $active = (array) get_option( 'active_plugins', array() );
457 if ( in_array( $plugin, $active, true ) ) {
458 return true;
459 }
460 // Network-activated (multisite).
461 $network = (array) get_site_option( 'active_sitewide_plugins', array() );
462 return isset( $network[ $plugin ] );
463 }
464
465 public static function plan_wpsc( array $r ): array {
466 return array(
467 'cache' => array(
468 'enabled' => ! empty( $r['wp_cache_enabled'] ),
469 'mobile_separate' => ! empty( $r['wp_cache_mobile_enabled'] ),
470 ),
471 );
472 }
473
474 // ─────────────────────────── LiteSpeed Cache ─────────────────────
475
476 /**
477 * Read LiteSpeed Cache settings into a flat `name => value` array keyed
478 * by LiteSpeed's dotted setting names (cache, cache-mobile, optm-*,
479 * media-*, object-*, cdn-*, …) — the shape plan_litespeed() expects.
480 *
481 * Storage has changed across LiteSpeed versions:
482 * - v4+ (current): ONE option PER setting, named `litespeed.conf.<name>`
483 * (e.g. litespeed.conf.cache, litespeed.conf.cache-mobile). There is
484 * NO single `litespeed.conf` blob — reading that key returns null,
485 * which is why detection used to fail on every modern install.
486 * - v3 and earlier: a single serialized array under `litespeed.conf`
487 * (or the legacy `litespeed-cache-conf`).
488 * We handle all three: try the per-option family first (the common case
489 * today), then fall back to the legacy single-blob options.
490 *
491 * @return array|null raw conf (name => value), or null when absent.
492 */
493 public static function detect_litespeed(): ?array {
494 global $wpdb;
495
496 // v4+: individual `litespeed.conf.<name>` options. Pull them all and
497 // strip the prefix so keys match what plan_litespeed() reads.
498 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-time settings-import scan of another plugin's option rows by name prefix; no WP API bulk-reads by option_name LIKE, and caching a single migration-time read is pointless.
499 $rows = $wpdb->get_results(
500 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'litespeed.conf.%'",
501 ARRAY_A
502 );
503 if ( ! empty( $rows ) ) {
504 $conf = array();
505 foreach ( $rows as $row ) {
506 $name = substr( (string) $row['option_name'], strlen( 'litespeed.conf.' ) );
507 if ( '' === $name || '_version' === $name ) {
508 continue;
509 }
510 // option_value is stored serialized by WP; maybe_unserialize
511 // gives back arrays (list settings) or scalars as-is.
512 $conf[ $name ] = maybe_unserialize( $row['option_value'] );
513 }
514 if ( ! empty( $conf ) ) {
515 return $conf;
516 }
517 }
518
519 // v3 / legacy: a single serialized array.
520 $opt = get_option( 'litespeed.conf', null );
521 if ( ! is_array( $opt ) ) {
522 $opt = get_option( 'litespeed-cache-conf', null );
523 }
524 return is_array( $opt ) && ! empty( $opt ) ? $opt : null;
525 }
526
527 /**
528 * Translate LiteSpeed's `litespeed.conf` into xSpeed module patches.
529 * LiteSpeed uses dotted keys; values are mostly bool-ish (1/0/'1').
530 * We map only the settings that have a clean xSpeed equivalent and
531 * leave the rest untouched so nothing is silently mis-imported.
532 *
533 * @param array $r raw litespeed.conf.
534 */
535 public static function plan_litespeed( array $r ): array {
536 $on = static function ( $key ) use ( $r ): bool {
537 return isset( $r[ $key ] ) && ! empty( $r[ $key ] );
538 };
539 // LiteSpeed list fields are stored as either a newline-delimited
540 // string or an array. Normalize to a clean string[] either way.
541 $list = static function ( $key ) use ( $r ): array {
542 $v = $r[ $key ] ?? null;
543 if ( is_string( $v ) ) {
544 $v = preg_split( '/\r\n|\r|\n/', $v );
545 }
546 if ( ! is_array( $v ) ) {
547 return array();
548 }
549 return array_values( array_filter( array_map( 'trim', array_map( 'strval', $v ) ) ) );
550 };
551 $set_list = static function ( array &$dest, string $dest_key, array $vals ): void {
552 if ( $vals ) {
553 $dest[ $dest_key ] = $vals;
554 }
555 };
556
557 $patch = array();
558
559 // ── Page cache ────────────────────────────────────────────────
560 $patch['cache'] = array(
561 'enabled' => $on( 'cache' ) || $on( 'cache-priv' ),
562 'mobile_separate' => $on( 'cache-mobile' ),
563 );
564 // TTL: LiteSpeed stores cache-ttl_pub in seconds → xSpeed wants hours.
565 if ( isset( $r['cache-ttl_pub'] ) && (int) $r['cache-ttl_pub'] > 0 ) {
566 $patch['cache']['cache_expiry'] = max( 1, min( 720, (int) ( (int) $r['cache-ttl_pub'] / 3600 ) ) );
567 }
568 // Excluded URIs / cookies / user-agents / dropped query strings.
569 $set_list( $patch['cache'], 'excluded_urls', $list( 'cache-exc' ) );
570 $set_list( $patch['cache'], 'excluded_cookies', $list( 'cache-exc_cookies' ) );
571 $set_list( $patch['cache'], 'bypass_user_agents', $list( 'cache-exc_useragent' ) );
572 // LiteSpeed "drop query string" list ≈ xSpeed ignored_query_params.
573 $set_list( $patch['cache'], 'ignored_query_params', $list( 'cache-drop_qs' ) );
574
575 // ── Minify / optimization ─────────────────────────────────────
576 $patch['minify'] = array(
577 'minify_html' => $on( 'optm-html_min' ),
578 'minify_css' => $on( 'optm-css_min' ),
579 'minify_js' => $on( 'optm-js_min' ),
580 'combine_css' => $on( 'optm-css_comb' ),
581 'combine_js' => $on( 'optm-js_comb' ),
582 'defer_js' => $on( 'optm-js_defer' ),
583 // LiteSpeed "Delay JS" (optm-js_defer === 2 in some versions, or
584 // the dedicated optm-js_delay flag) → xSpeed delay_js.
585 'delay_js' => $on( 'optm-js_delay' ) || ( isset( $r['optm-js_defer'] ) && (int) $r['optm-js_defer'] === 2 ),
586 // Async/“load CSS asynchronously” — LiteSpeed CCSS async.
587 'async_css' => $on( 'optm-css_async' ),
588 // Remove query strings from static resources.
589 'remove_query_strings' => $on( 'optm-qs_rm' ),
590 );
591 // Defer/delay exclusion list — merge LiteSpeed's JS defer + delay
592 // exclude lists into xSpeed's single defer_js_excluded.
593 $defer_exc = array_values( array_unique( array_merge( $list( 'optm-js_defer_exc' ), $list( 'optm-js_delay_exc' ) ) ) );
594 $set_list( $patch['minify'], 'defer_js_excluded', $defer_exc );
595
596 // ── Lazy load (media) ─────────────────────────────────────────
597 $patch['lazy'] = array(
598 'lazy_images' => $on( 'media-lazy' ),
599 'lazy_iframes' => $on( 'media-iframe_lazy' ),
600 // LiteSpeed has no separate HTML5-video lazy toggle; mirror the
601 // image setting so video preload follows the same intent.
602 'lazy_videos' => $on( 'media-lazy' ),
603 // "Add Missing Sizes" → add_missing_dimensions (anti-CLS).
604 'add_missing_dimensions' => $on( 'media-add_missing_sizes' ),
605 );
606 $set_list( $patch['lazy'], 'excluded_images', $list( 'media-lazy_exc' ) );
607
608 // ── Fonts ─────────────────────────────────────────────────────
609 // LiteSpeed "Font Display Optimization" (optm-localize_style /
610 // optm-css_font_display) → xSpeed font-display: swap.
611 if ( $on( 'optm-css_font_display' ) || $on( 'optm-localize' ) ) {
612 $patch['fonts'] = array( 'font_display_swap' => true );
613 }
614
615 // ── Disable bloat ─────────────────────────────────────────────
616 // Only map the one LiteSpeed "remove" toggle with a clean xSpeed
617 // equivalent: removing the emoji + oEmbed scripts ≈ disable_oembed.
618 // (LiteSpeed's optm-emoji_rm strips the wp-emoji + wp-embed pair.)
619 // jQuery-migrate / dashicons / XML-RPC / RSS / REST aren't
620 // LiteSpeed-managed, so we don't guess at them.
621 if ( $on( 'optm-emoji_rm' ) ) {
622 $patch['bloat'] = array( 'disable_oembed' => true );
623 }
624
625 // ── Browser cache (LiteSpeed: cache-browser) ──────────────────
626 if ( $on( 'cache-browser' ) ) {
627 $patch['browser-cache'] = array( 'enabled' => true );
628 if ( isset( $r['cache-ttl_browser'] ) && (int) $r['cache-ttl_browser'] > 0 ) {
629 $patch['browser-cache']['asset_ttl'] = (int) $r['cache-ttl_browser'];
630 }
631 }
632
633 // ── Object cache ──────────────────────────────────────────────
634 if ( $on( 'object' ) ) {
635 $kind = isset( $r['object-kind'] ) && (int) $r['object-kind'] === 1 ? 'redis' : 'memcached';
636 $patch['object-cache'] = array( 'backend' => $kind );
637 if ( ! empty( $r['object-host'] ) ) {
638 $host_key = 'redis' === $kind ? 'redis_host' : 'memcached_host';
639 $patch['object-cache'][ $host_key ] = (string) $r['object-host'];
640 }
641 if ( ! empty( $r['object-port'] ) ) {
642 $port_key = 'redis' === $kind ? 'redis_port' : 'memcached_port';
643 $patch['object-cache'][ $port_key ] = (int) $r['object-port'];
644 }
645 if ( 'redis' === $kind && isset( $r['object-db_id'] ) ) {
646 $patch['object-cache']['redis_database'] = max( 0, min( 15, (int) $r['object-db_id'] ) );
647 }
648 if ( ! empty( $r['object-pswd'] ) && 'redis' === $kind ) {
649 $patch['object-cache']['redis_password'] = (string) $r['object-pswd'];
650 }
651 if ( ! empty( $r['object-global_groups'] ) || ! empty( $r['object-persistent'] ) ) {
652 $patch['object-cache']['persistent'] = $on( 'object-persistent' );
653 }
654 }
655
656 // ── Image conversion (Pro Images module) ──────────────────────
657 // LiteSpeed media-webp / next-gen image generation → xSpeed Images.
658 if ( $on( 'img_optm-webp' ) || $on( 'media-webp' ) || $on( 'img_optm-auto' ) ) {
659 $patch['images'] = array(
660 'webp' => $on( 'img_optm-webp' ) || $on( 'media-webp' ),
661 'avif' => $on( 'img_optm-avif' ),
662 );
663 }
664
665 // ── CDN ───────────────────────────────────────────────────────
666 if ( $on( 'cdn' ) ) {
667 $cdn_url = '';
668 if ( ! empty( $r['cdn-mapping'] ) && is_array( $r['cdn-mapping'] ) ) {
669 $first = $r['cdn-mapping'][0] ?? array();
670 // LiteSpeed cdn-mapping rows use the 'url' sub-key (array form)
671 // or a bare URL string (legacy).
672 $cdn_url = is_array( $first ) ? (string) ( $first['url'] ?? ( $first['cdn_url'] ?? '' ) ) : (string) $first;
673 }
674 if ( '' !== $cdn_url ) {
675 $patch['cdn'] = array(
676 'enabled' => true,
677 'cdn_url' => $cdn_url,
678 );
679 $set_list( $patch['cdn'], 'excluded_patterns', $list( 'cdn-exclude' ) );
680 }
681 }
682
683 return $patch;
684 }
685 }
686