PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.1
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.1
6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 5.2 5.2.1 All 155 releases
← All changes | class/class-mainwp-utility.php +955 -23 5.2.1 → 6.2.1 View file →
@@ -6,8 +6,13 @@
6 6 */
7 7
8 8 namespace MainWP\Dashboard;
9 9
10 +// Exit if accessed directly.
11 +if ( ! defined( 'ABSPATH' ) ) {
12 + exit;
13 +}
14 +
10 15 // phpcs:disable WordPress.DB.RestrictedFunctions, WordPress.WP.AlternativeFunctions, WordPress.PHP.NoSilencedErrors, Generic.Metrics.CyclomaticComplexity -- Using cURL functions.
11 16
12 17 /**
13 18 * Class MainWP_Utility
@@ -16,8 +21,26 @@
16 21 */
17 22 class MainWP_Utility { // phpcs:ignore Generic.Classes.OpeningBraceSameLine.ContentAfterBrace -- NOSONAR.
18 23
19 24 /**
25 + * Prefix for short-term notice options.
26 + */
27 + const SHORT_TERM_NOTICE_OPTION_PREFIX = '_mainwp_temp_notice_';
28 +
29 +
30 + /**
31 + * Prefix for per-user short-term notice options.
32 + */
33 + const USER_SHORT_TERM_NOTICE_OPTION_PREFIX = '_mainwp_user_temp_notice_';
34 +
35 +
36 + /**
37 + * New short term notice TTL.
38 + */
39 + const SHORT_TERM_NOTICE_TTL = MONTH_IN_SECONDS;
40 +
41 +
42 + /**
20 43 * Yoast SEO is enabled return true else return null.
21 44 *
22 45 * @static
23 46 * @var boolean $enabled_wp_seo If Yoast SEO is enabled return true else return null.
@@ -110,18 +133,17 @@
110 133 return substr( $haystack, - $length ) === $needle;
111 134 }
112 135
113 136 /**
114 - * Method get_nice_url()
137 + * Get a nice URL.
115 138 *
116 - * Grab url.
139 + * @param string $pUrl Website URL.
140 + * @param bool $showHttp Whether to show HTTP.
141 + * @param bool $removeTrailingSlash Whether to removev the trailing slash.
117 142 *
118 - * @param string $pUrl Website URL.
119 - * @param bool $showHttp Show HTTP.
120 - *
121 - * @return string $url.
143 + * @return string The formatted URL.
122 144 */
123 - public static function get_nice_url( $pUrl, $showHttp = false ) {
145 + public static function get_nice_url( $pUrl, $showHttp = false, $removeTrailingSlash = false ) {
124 146 $url = $pUrl;
125 147
126 148 if ( static::starts_with( $url, 'http://' ) ) {
127 149 if ( ! $showHttp ) {
@@ -126,14 +148,14 @@
126 148 if ( static::starts_with( $url, 'http://' ) ) {
127 149 if ( ! $showHttp ) {
128 150 $url = substr( $url, 7 );
129 151 }
130 - } elseif ( static::starts_with( $pUrl, 'https://' ) ) {
152 + } elseif ( static::starts_with( $url, 'https://' ) ) {
131 153 if ( ! $showHttp ) {
132 154 $url = substr( $url, 8 );
133 155 }
134 156 } elseif ( $showHttp ) {
135 - $url = 'http://' . $url;
157 + $url = 'http://' . $url; // phpcs:ignore -- NOSONAR -compatible.
136 158 }
137 159
138 160 if ( static::ends_with( $url, '/' ) ) {
139 161 if ( ! $showHttp ) {
@@ -139,11 +161,15 @@
139 161 if ( ! $showHttp ) {
140 162 $url = substr( $url, 0, strlen( $url ) - 1 );
141 163 }
142 164 } else {
143 - $url = $url . '/';
165 + $url .= '/';
144 166 }
145 167
168 + if ( $removeTrailingSlash ) {
169 + $url = rtrim( $url, '/' );
170 + }
171 +
146 172 return $url;
147 173 }
148 174
149 175 /**
@@ -388,8 +414,87 @@
388 414 return date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp );
389 415 }
390 416
391 417 /**
418 + * Method format_timezone()
419 + *
420 + * Format the given timestamp.
421 + *
422 + * @param mixed $timestamp Timestamp to format.
423 + * @param mixed $with_tz_info Return date time with timezone infor.
424 + * @param mixed $use_tzformat Input tz format, to support display tz child site format.
425 + *
426 + * @return string Formatted timestamp.
427 + */
428 + public static function format_timezone( $timestamp, $with_tz_info = false, $use_tzformat = false ) { // phpcs:ignore -- NOSONAR - complex.
429 + $tzinfo = '';
430 + if ( false !== $use_tzformat ) {
431 + if ( is_array( $use_tzformat ) && ( isset( $use_tzformat['timezone_string'] ) || isset( $use_tzformat['gmt_offset'] ) || isset( $use_tzformat['date_format'] ) || isset( $use_tzformat['time_format'] ) ) ) {
432 + $wp_timezone = ! empty( $use_tzformat['timezone_string'] ) ? $use_tzformat['timezone_string'] : '';
433 +
434 + $format = '';
435 + if ( ! empty( $use_tzformat['date_format'] ) ) {
436 + $format .= $use_tzformat['date_format'] . ' ';
437 + }
438 + if ( ! empty( $use_tzformat['time_format'] ) ) {
439 + $format .= $use_tzformat['time_format'] . ' ';
440 + }
441 + $format = rtrim( $format );
442 +
443 + if ( empty( $wp_timezone ) ) {
444 + $gmt = ! empty( $use_tzformat['gmt_offset'] ) ? $use_tzformat['gmt_offset'] : 0;
445 + return date_i18n( $format, $timestamp, $gmt );
446 + }
447 +
448 + $datetime = new \DateTime( '@' . $timestamp );
449 + $datetime->setTimezone( new \DateTimeZone( $wp_timezone ) );
450 +
451 + return $datetime->format( $format );
452 + }
453 + return '';
454 + }
455 +
456 + $wp_timezone = static::clean_wp_timezone_string( get_option( 'timezone_string' ) );
457 +
458 + if ( ! $wp_timezone ) {
459 + if ( $with_tz_info ) {
460 + $tzinfo = ' ( UTC ' . get_option( 'gmt_offset' ) . ' )';
461 + }
462 + return static::format_timestamp( static::get_timestamp( $timestamp ) ) . $tzinfo;
463 + }
464 +
465 + if ( $with_tz_info ) {
466 + $tzinfo = ' ( ' . $wp_timezone . ' )';
467 + }
468 +
469 + $datetime = new \DateTime( '@' . $timestamp );
470 + $datetime->setTimezone( new \DateTimeZone( $wp_timezone ) );
471 +
472 + $format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) . $tzinfo;
473 + return $datetime->format( $format );
474 + }
475 +
476 + /**
477 + * Method clean_wp_timezone_string().
478 + *
479 + * @param mixed $raw Raw tz string to clean.
480 + * @return string Clean tz string.
481 + */
482 + public static function clean_wp_timezone_string( $raw ) {
483 + // If it's already a valid timezone, just return it.
484 + if ( in_array( $raw, timezone_identifiers_list(), true ) ) {
485 + return $raw;
486 + }
487 + // Try to find a valid timezone inside the messy string.
488 + foreach ( timezone_identifiers_list() as $tz ) {
489 + if ( strpos( $raw, $tz ) !== false ) {
490 + return $tz;
491 + }
492 + }
493 + return '';
494 + }
495 +
496 + /**
392 497 * Method format_timestamp()
393 498 *
394 499 * Format the given timestamp.
395 500 *
@@ -401,8 +506,79 @@
401 506 return date_i18n( get_option( 'date_format' ), $timestamp );
402 507 }
403 508
404 509 /**
510 + * Method format_time()
511 + *
512 + * Format the given timestamp.
513 + *
514 + * @param mixed $timestamp Timestamp to format.
515 + *
516 + * @return string Formatted timestamp.
517 + */
518 + public static function format_time( $timestamp ) {
519 + return date_i18n( get_option( 'time_format' ), $timestamp );
520 + }
521 +
522 + /**
523 + * Get last sync information.
524 + *
525 + * Retrieves synchronization timestamp for global view or specific site.
526 + * For global view, checks all sites and returns the most recent sync.
527 + * For specific site, returns that site's last sync timestamp.
528 + *
529 + * @param int|null $site_id Optional. Site ID to get sync info for. Null for global view.
530 + * @return array {
531 + * Last sync information.
532 + *
533 + * @type int $timestamp Unix timestamp of last sync (0 if never synced).
534 + * @type string $formatted Formatted date/time string using WordPress date/time format.
535 + * @type string $status Sync status (only for global view): 'all_synced', 'not_synced', or false.
536 + * @type string $message Human-readable sync message.
537 + * }
538 + */
539 + public static function get_last_sync_info( $site_id = null ) {
540 + $timestamp = 0;
541 + $status = false;
542 +
543 + if ( null === $site_id ) {
544 + $result = MainWP_DB_Common::instance()->get_last_sync_status();
545 + $status = $result['sync_status'];
546 + $timestamp = $result['last_sync'];
547 +
548 + if ( 'all_synced' === $status ) {
549 + $timestamp = get_option( 'mainwp_last_synced_all_sites', $timestamp );
550 + }
551 + } else {
552 + $site_id = absint( $site_id );
553 + if ( $site_id > 0 ) {
554 + $website = MainWP_DB::instance()->get_website_by_id( $site_id );
555 + if ( $website && ! empty( $website->dtsSync ) ) {
556 + $timestamp = $website->dtsSync;
557 + }
558 + }
559 + }
560 +
561 + $formatted = '';
562 + $message = '';
563 +
564 + if ( $timestamp ) {
565 + $formatted = static::format_timestamp( static::get_timestamp( $timestamp ) );
566 + /* translators: %s: formatted date/time */
567 + $message = sprintf( esc_html__( 'Last synchronization completed on: %s', 'mainwp' ), $formatted );
568 + } else {
569 + $message = esc_html__( 'Not yet synchronized', 'mainwp' );
570 + }
571 +
572 + return array(
573 + 'timestamp' => $timestamp,
574 + 'formatted' => $formatted,
575 + 'status' => $status,
576 + 'message' => $message,
577 + );
578 + }
579 +
580 + /**
405 581 * Format duration time to show.
406 582 *
407 583 * @param float $time timestamp.
408 584 * @return mixed result.
@@ -423,10 +599,131 @@
423 599 }
424 600 return '<bdi>' . esc_html( $formatted_dura ) . '</bdi>';
425 601 }
426 602
603 + /**
604 + * Get UTC timestamp by date string.
605 + *
606 + * @param string $dt_str date.
607 + * @param int $add_days Add days.
608 + *
609 + * @return mixed Local timestamp.
610 + */
611 + public static function get_utc_timestamp_by_date( $dt_str, $add_days = 0 ) {
427 612
613 + $tz = wp_timezone(); // site timezone.
614 + $day = new \DateTimeImmutable( $dt_str, $tz );
615 +
616 + if ( is_numeric( $add_days ) && $add_days > 0 ) {
617 + $day = $day->modify( '+' . $add_days . ' day' );
618 + }
619 +
620 + return $day->setTimezone( new \DateTimeZone( 'UTC' ) )->getTimestamp();
621 + }
622 +
623 +
428 624 /**
625 + * Converts a UTC timestamp (integer or float) to a local date string
626 + * using the site's timezone (handles DST automatically).
627 + *
628 + * Supports:
629 + * - Integer seconds timestamps (e.g. 1696930123)
630 + * - Float seconds with microseconds (e.g. 1696930123.123456)
631 + *
632 + * Uses WordPress `wp_timezone()` to determine the local timezone.
633 + *
634 + * @since 6.0.0
635 + *
636 + * @param int|float|string $utc_time UTC timestamp in seconds. Can be integer (seconds)
637 + * or float (seconds with microseconds).
638 + * @param string $format_str PHP date format string. Default 'Y-m-d'.
639 + * @param int $add_days Optional. Number of days to add (can be negative). Default 0.
640 + *
641 + * @return string Formatted local date string, or empty string on invalid input.
642 + *
643 + * @example
644 + * // From plain timestamp:
645 + * echo MyClass::get_local_date_by_utc_timestamp(1696930123, 'Y-m-d H:i:s');
646 + * // → "2023-10-10 15:35:23" (depending on site timezone)
647 + *
648 + * @example
649 + * // From microtime (float seconds):
650 + * echo MyClass::get_local_date_by_utc_timestamp(1696930123.123456, 'Y-m-d H:i:s.v');
651 + * // → "2023-10-10 15:35:23.123" (microseconds preserved)
652 + */
653 + public static function get_local_date_by_utc_timestamp( $utc_time, $format_str = 'Y-m-d', $add_days = 0 ) {
654 + if ( ! is_numeric( $utc_time ) ) {
655 + return '';
656 + }
657 +
658 + $tz = wp_timezone();
659 + $utc_zone = new \DateTimeZone( 'UTC' );
660 +
661 + // Detect float (has fractional seconds).
662 + if ( is_float( $utc_time ) || strpos( (string) $utc_time, '.' ) !== false ) {
663 + $dt_utc = \DateTimeImmutable::createFromFormat( 'U.u', sprintf( '%.6F', $utc_time ), $utc_zone );
664 + } else {
665 + $dt_utc = ( new \DateTimeImmutable( '@' . $utc_time ) )->setTimezone( $utc_zone );
666 + }
667 +
668 + if ( $add_days ) {
669 + $dt_utc = $dt_utc->modify( sprintf( '%+d day', $add_days ) );
670 + }
671 +
672 + return $dt_utc->setTimezone( $tz )->format( $format_str );
673 + }
674 +
675 +
676 + /**
677 + * Compute day/offset values and UTC datetime string for a local input time.
678 + *
679 + * - Automatically uses WordPress site timezone if none provided.
680 + * - Converts the local datetime to UTC for MySQL compatibility.
681 + * - Returns microsecond constants for daily grouping and offset adjustments.
682 + *
683 + * @param string $from_date_local 'Y-m-d H:i:s' in local timezone.
684 + * @param string|null $local_timezone Optional. PHP timezone ID (e.g. 'Asia/Ho_Chi_Minh').
685 + * @return array {
686 + * @type int $day_micros Microseconds in one day (86400000000).
687 + * @type int $offset_micro Timezone offset in microseconds (e.g. +25200000000).
688 + * @type string $from_date_utc UTC datetime string ('Y-m-d H:i:s') for MySQL.
689 + * @type string $local_timezone The timezone actually used.
690 + * }
691 + */
692 + public static function get_time_context( $from_date_local, $local_timezone = null ) {
693 + if ( empty( $local_timezone ) ) {
694 + if ( function_exists( 'wp_timezone' ) ) {
695 + $tz_obj = wp_timezone();
696 + $local_timezone = $tz_obj->getName();
697 + } else {
698 + $local_timezone = get_option( 'timezone_string' ) ? get_option( 'timezone_string' ) : 'UTC';
699 + }
700 + }
701 +
702 + $MICRO = 1000000;
703 + $SECONDS_PER_DAY = 86400;
704 + $day_micros = $SECONDS_PER_DAY * $MICRO;
705 +
706 + $tz = new \DateTimeZone( $local_timezone );
707 +
708 + // 👇 End of local day instead of start.
709 + $dt = new \DateTimeImmutable( $from_date_local . ' 23:59:59', $tz );
710 +
711 + $offset_seconds = $tz->getOffset( $dt );
712 + $offset_micro = $offset_seconds * $MICRO;
713 +
714 + $from_date_utc = $dt->setTimezone( new \DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
715 +
716 + return array(
717 + 'day_micros' => $day_micros,
718 + 'offset_micro' => $offset_micro,
719 + 'from_date_utc' => $from_date_utc,
720 + 'local_timezone' => $local_timezone,
721 + );
722 + }
723 +
724 +
725 + /**
429 726 * Method human_filesize()
430 727 *
431 728 * Convert to human readable file size format,
432 729 * (B|kB|MB|GB|TB|PB|EB|ZB|YB).
@@ -586,8 +883,22 @@
586 883 return false;
587 884 }
588 885
589 886 /**
887 + * Method delete_user_option()
888 + *
889 + * @param string $option_name Option name.
890 + *
891 + * @return void.
892 + */
893 + public static function delete_user_option( $option_name ) {
894 + $user = wp_get_current_user();
895 + if ( $user ) {
896 + delete_user_option( $user->ID, $option_name );
897 + }
898 + }
899 +
900 + /**
590 901 * Method remove_preslash_spaces()
591 902 *
592 903 * Remove spaces before slashes.
593 904 *
@@ -913,8 +1224,38 @@
913 1224 return true;
914 1225 }
915 1226
916 1227 /**
1228 + * Un-hide a MainWP message for the current user.
1229 + *
1230 + * Removes the notice ID from the saved notice status so the message
1231 + * can be displayed again.
1232 + *
1233 + * @param mixed $notice_id Notice ID.
1234 + *
1235 + * @return bool True if the notice was un-hidden, otherwise false.
1236 + */
1237 + public static function unhide_mainwp_message( $notice_id ) {
1238 + $status = get_user_option( 'mainwp_notice_saved_status' );
1239 +
1240 + if ( ! is_array( $status ) || ! isset( $status[ $notice_id ] ) ) {
1241 + return false;
1242 + }
1243 +
1244 + unset( $status[ $notice_id ] );
1245 +
1246 + if ( empty( $status ) ) {
1247 + return delete_user_option( get_current_user_id(), 'mainwp_notice_saved_status' );
1248 + }
1249 +
1250 + return update_user_option(
1251 + get_current_user_id(),
1252 + 'mainwp_notice_saved_status',
1253 + $status
1254 + );
1255 + }
1256 +
1257 + /**
917 1258 * Method get_hide_notice_status()
918 1259 *
919 1260 * Check whenther or not to show the MainWP Message.
920 1261 *
@@ -959,8 +1300,241 @@
959 1300 return $content;
960 1301 }
961 1302
962 1303 /**
1304 + * Method set_short_term_notice()
1305 + *
1306 + * Set new temporary notice.
1307 + *
1308 + * @param string $monitor Monitor.
1309 + * @param string $noti_key Notice key.
1310 + * @param int $checked_at Notice checked at.
1311 + *
1312 + * @return void.
1313 + */
1314 + public static function set_short_term_notice( $monitor, $noti_key, $checked_at ) {
1315 + if ( empty( $monitor ) || empty( $noti_key ) ) {
1316 + return;
1317 + }
1318 + $noti_handle_name = static::SHORT_TERM_NOTICE_OPTION_PREFIX . $monitor . '_' . $noti_key;
1319 + static::update_option( $noti_handle_name, $checked_at );
1320 + }
1321 +
1322 +
1323 + /**
1324 + * Method is_short_term_notice()
1325 + *
1326 + * Check whether the short-term notice is still active.
1327 + *
1328 + * @param string $monitor Monitor name.
1329 + * @param string $noti_key Notice key.
1330 + *
1331 + * @return boolean true|false.
1332 + */
1333 + public static function is_short_term_notice( $monitor, $noti_key ) {
1334 +
1335 + if ( empty( $monitor ) || empty( $noti_key ) ) {
1336 + return false;
1337 + }
1338 +
1339 + $noti_handle_name = static::SHORT_TERM_NOTICE_OPTION_PREFIX . $monitor . '_' . $noti_key;
1340 +
1341 + $notice_time = get_option( $noti_handle_name );
1342 +
1343 + if ( false === $notice_time ) {
1344 + return false;
1345 + }
1346 +
1347 + if ( ! is_numeric( $notice_time ) ) {
1348 + delete_option( $noti_handle_name );
1349 + }
1350 +
1351 + if ( time() - (int) $notice_time < self::SHORT_TERM_NOTICE_TTL ) {
1352 + if ( static::is_short_term_dismissed_notice( $monitor, $noti_key ) ) {
1353 + return false;
1354 + }
1355 + return true;
1356 + } else {
1357 + delete_option( $noti_handle_name );
1358 + }
1359 + return false;
1360 + }
1361 +
1362 +
1363 + /**
1364 + * Method is_short_term_dismissed_notice()
1365 + *
1366 + * Check whether the short-term notice is user dismissed.
1367 + *
1368 + * @param string $monitor Monitor.
1369 + * @param string $noti_key Notice key.
1370 + *
1371 + * @return boolean true|false.
1372 + */
1373 + public static function is_short_term_dismissed_notice( $monitor, $noti_key ) {
1374 + if ( empty( $monitor ) || empty( $noti_key ) ) {
1375 + return false;
1376 + }
1377 + $user_noti_handle = static::USER_SHORT_TERM_NOTICE_OPTION_PREFIX . $monitor . '_' . $noti_key;
1378 + $user = wp_get_current_user();
1379 + if ( $user && ! empty( $user->ID ) ) {
1380 + return 'dismissed' === get_option( $user_noti_handle . '_user_' . $user->ID );
1381 + }
1382 + return false;
1383 + }
1384 +
1385 +
1386 + /**
1387 + * Purge short-term notices.
1388 + *
1389 + * @param string $monitor Optional monitor name. When provided, only
1390 + * notices for the specified monitor are purged.
1391 + * @param bool $expired_only Whether to purge only expired notices.
1392 + *
1393 + * @return void
1394 + */
1395 + public static function purge_short_term_notices( $monitor = '', $expired_only = true ) { // phpcs:ignore -- NOSONAR - complex.
1396 +
1397 + $opt_prefix = self::SHORT_TERM_NOTICE_OPTION_PREFIX;
1398 +
1399 + $prefix = '' !== $monitor ? $monitor . '_' : '';
1400 +
1401 + $timeout = time() - self::SHORT_TERM_NOTICE_TTL;
1402 +
1403 + $db = MainWP_DB::instance()->get_wpdb_instance();
1404 +
1405 + $sql = $db->prepare(
1406 + "
1407 + SELECT option_name
1408 + FROM {$db->options}
1409 + WHERE option_name LIKE %s
1410 + ",
1411 + $db->esc_like( $opt_prefix . (string) $prefix ) . '%',
1412 + );
1413 +
1414 + if ( $expired_only ) {
1415 + $sql .= $db->prepare(
1416 + ' AND CAST(option_value AS UNSIGNED) <= %d ',
1417 + $timeout
1418 + );
1419 + }
1420 +
1421 + $options = $db->get_col( $sql );
1422 +
1423 + foreach ( $options as $option_name ) {
1424 + delete_option( $option_name );
1425 + }
1426 + }
1427 +
1428 +
1429 + /**
1430 + * Purge short-term notices for a specific issue.
1431 + *
1432 + * @param string $monitor Monitor name.
1433 + * @param string $issue_code Issue code.
1434 + *
1435 + * @return bool True if the operation completed, false if the input is invalid.
1436 + */
1437 + public static function delete_short_term_notices_for_issue( $monitor, $issue_code ) {
1438 +
1439 + if ( empty( $monitor ) || empty( $issue_code ) ) {
1440 + return false;
1441 + }
1442 +
1443 + $opt_prefix = self::SHORT_TERM_NOTICE_OPTION_PREFIX;
1444 + $prefix = $monitor . '_' . $issue_code;
1445 +
1446 + $db = MainWP_DB::instance()->get_wpdb_instance();
1447 +
1448 + $sql = $db->prepare(
1449 + "
1450 + SELECT option_name
1451 + FROM {$db->options}
1452 + WHERE option_name LIKE %s
1453 + ",
1454 + $db->esc_like( $opt_prefix . $prefix ) . '%'
1455 + );
1456 +
1457 + $options = $db->get_col( $sql );
1458 +
1459 + foreach ( $options as $option_name ) {
1460 + delete_option( $option_name ); // Delete options and cache.
1461 + }
1462 +
1463 + return true;
1464 + }
1465 +
1466 + /**
1467 + * Method dismiss_short_term_notice()
1468 + *
1469 + * Hide short term notice.
1470 + *
1471 + * @param string $monitor_noti_key Notice key.
1472 + *
1473 + * @return void.
1474 + */
1475 + public static function dismiss_short_term_notice( $monitor_noti_key ) {
1476 + $noti_handle_name = static::USER_SHORT_TERM_NOTICE_OPTION_PREFIX . $monitor_noti_key;
1477 + $user = wp_get_current_user();
1478 + if ( $user && ! empty( $user->ID ) ) {
1479 + static::update_option( $noti_handle_name . '_user_' . $user->ID, 'dismissed' );
1480 + }
1481 + }
1482 +
1483 + /**
1484 + * Delete user short-term notice options for the specified monitor.
1485 + *
1486 + * Optionally preserves the dismissed state for the specified issue codes.
1487 + *
1488 + * @param string $monitor Monitor name.
1489 + * @param array $preserved_issues Issue codes whose dismissed state should be preserved.
1490 + *
1491 + * @return void
1492 + */
1493 + public static function delete_short_term_notice_options( $monitor, $preserved_issues = array() ) {
1494 +
1495 + if ( empty( $monitor ) ) {
1496 + return;
1497 + }
1498 +
1499 + $db = MainWP_DB::instance()->get_wpdb_instance();
1500 +
1501 + $sql = "
1502 + SELECT option_name
1503 + FROM {$db->options}
1504 + WHERE option_name LIKE %s
1505 + ";
1506 +
1507 + $args = array(
1508 + $db->esc_like(
1509 + static::USER_SHORT_TERM_NOTICE_OPTION_PREFIX . $monitor . '_'
1510 + ) . '%',
1511 + );
1512 +
1513 + // Preserve the user's dismissed state for the specified issues.
1514 + if ( ! empty( $preserved_issues ) && is_array( $preserved_issues ) ) {
1515 + foreach ( $preserved_issues as $issue_code ) {
1516 + $sql .= ' AND option_name NOT LIKE %s';
1517 + $args[] = $db->esc_like(
1518 + static::USER_SHORT_TERM_NOTICE_OPTION_PREFIX . $monitor . '_' . $issue_code . '_user_'
1519 + ) . '%';
1520 + }
1521 + }
1522 +
1523 + $option_names = $db->get_col(
1524 + $db->prepare( $sql, ...$args )
1525 + );
1526 +
1527 + if ( empty( $option_names ) ) {
1528 + return;
1529 + }
1530 +
1531 + foreach ( $option_names as $option_name ) {
1532 + delete_option( $option_name );
1533 + }
1534 + }
1535 +
1536 + /**
963 1537 * Method update_flash_message()
964 1538 *
965 1539 * Check whenther or not to show the MainWP Message.
966 1540 *
@@ -997,9 +1571,13 @@
997 1571 $sorter = array();
998 1572 $ret = array();
999 1573 reset( $arr );
1000 1574 foreach ( $arr as $ii => $val ) {
1001 - $sorter[ $ii ] = $val[ $key ];
1575 + if ( isset( $val[ $key ] ) ) {
1576 + $sorter[ $ii ] = $val[ $key ];
1577 + } elseif ( SORT_NUMERIC === $sort_flag ) {
1578 + $sorter[ $ii ] = count( $sorter );
1579 + }
1002 1580 }
1003 1581 asort( $sorter, $sort_flag );
1004 1582 foreach ( $sorter as $ii => $val ) {
1005 1583 $ret[ $ii ] = $arr[ $ii ];
@@ -1114,19 +1692,30 @@
1114 1692 * @return array Health status value.
1115 1693 */
1116 1694 public static function get_site_health( $issue_counts ) {
1117 1695
1118 - if ( empty( $issue_counts ) ) {
1119 - $issue_counts = array(
1696 + // Coerce non-array/empty/partial input to a full set of counts so a scalar
1697 + // (e.g. a JSON-decoded string) or a missing key never fatals or warns.
1698 + $issue_counts = array_merge(
1699 + array(
1120 1700 'good' => 0,
1121 1701 'recommended' => 0,
1122 1702 'critical' => 0,
1123 - );
1124 - }
1703 + ),
1704 + is_array( $issue_counts ) ? $issue_counts : array()
1705 + );
1125 1706
1126 - $totalTests = intval( $issue_counts['good'] ) + intval( $issue_counts['recommended'] ) + intval( $issue_counts['critical'] ) * 1.5;
1127 - $failedTests = intval( $issue_counts['recommended'] ) * 0.5 + $issue_counts['critical'] * 1.5;
1707 + // Normalize each counter to a non-negative integer before arithmetic so a
1708 + // non-numeric or negative stored value cannot fatal or skew the score.
1709 + // is_numeric() first: intval() maps a non-empty array to 1, which would
1710 + // count a malformed nested value as a real issue.
1711 + $good = is_numeric( $issue_counts['good'] ) ? max( 0, intval( $issue_counts['good'] ) ) : 0;
1712 + $recommended = is_numeric( $issue_counts['recommended'] ) ? max( 0, intval( $issue_counts['recommended'] ) ) : 0;
1713 + $critical = is_numeric( $issue_counts['critical'] ) ? max( 0, intval( $issue_counts['critical'] ) ) : 0;
1128 1714
1715 + $totalTests = $good + $recommended + $critical * 1.5;
1716 + $failedTests = $recommended * 0.5 + $critical * 1.5;
1717 +
1129 1718 if ( empty( $totalTests ) ) {
1130 1719 $val = 100;
1131 1720 } else {
1132 1721 $val = 100 - ceil( ( $failedTests / $totalTests ) * 100 );
@@ -1141,9 +1730,9 @@
1141 1730 }
1142 1731
1143 1732 return array(
1144 1733 'val' => $val,
1145 - 'critical' => $issue_counts['critical'],
1734 + 'critical' => $critical,
1146 1735 );
1147 1736 }
1148 1737
1149 1738
@@ -1274,12 +1863,8 @@
1274 1863 if ( MainWP_System_Utility::can_edit_website( $website ) ) {
1275 1864 $error = '';
1276 1865 try {
1277 1866 $information = MainWP_Connect::fetch_url_authed( $website, 'check_abandoned', array( 'which' => $which ) );
1278 - if ( is_array( $information ) && isset( $information['sync'] ) && ! empty( $information['sync'] ) ) {
1279 - MainWP_Sync::sync_information_array( $website, $information['sync'] );
1280 - unset( $information['sync'] );
1281 - }
1282 1867 } catch ( MainWP_Exception $e ) {
1283 1868 $error = $e->getMessage();
1284 1869 }
1285 1870
@@ -1326,9 +1911,9 @@
1326 1911 */
1327 1912 public static function get_siteview_mode() {
1328 1913 $viewmode = get_user_option( 'mainwp_sitesviewmode' );
1329 1914 if ( 'grid' !== $viewmode && 'table' !== $viewmode ) {
1330 - $viewmode = 'grid';
1915 + $viewmode = 'table';
1331 1916 }
1332 1917 return $viewmode;
1333 1918 }
1334 1919
@@ -1707,6 +2292,353 @@
1707 2292 return $value;
1708 2293 },
1709 2294 $data
1710 2295 );
2296 + }
2297 +
2298 + /**
2299 + * String ends by.
2300 + *
2301 + * @param mixed $str str.
2302 + * @param mixed $ends ends.
2303 + * @return bool value value.
2304 + */
2305 + public static function string_ends_by( $str, $ends ) {
2306 + if ( function_exists( '\str_ends_with' ) ) {
2307 + return \str_ends_with( $str, $ends );
2308 + } else {
2309 + $ends_len = strlen( $ends );
2310 + if ( $ends_len > strlen( $str ) ) {
2311 + return false;
2312 + }
2313 + return substr( $str, -$ends_len ) === $ends;
2314 + }
2315 + }
2316 + /**
2317 + * Returns number in shorter format.
2318 + *
2319 + * @param int $number str.
2320 + * @return string $number Shorer number.
2321 + */
2322 + public static function short_number_format( $number ) {
2323 + if ( $number > 999 && $number < 1000000 ) {
2324 + // Anything between 1000 and 1000000.
2325 + $number = number_format( $number / 1000, 1 ) . 'K';
2326 + } elseif ( $number >= 1000000000 ) {
2327 + // 1000000 or higher.
2328 + $number = number_format( $number / 1000000, 2 ) . 'M';
2329 + }
2330 + return $number;
2331 + }
2332 +
2333 + /**
2334 + * Returns date in time ago format
2335 + *
2336 + * @param mixed $ptime Date stamp.
2337 + * @return string $string Time elapsed string.
2338 + */
2339 + public static function time_elapsed_string( $ptime ) {
2340 + $etime = time() - $ptime;
2341 +
2342 + if ( $etime < 1 ) {
2343 + return '0 seconds';
2344 + }
2345 +
2346 + $a = array(
2347 + 365 * 24 * 60 * 60 => 'year',
2348 + 30 * 24 * 60 * 60 => 'month',
2349 + 24 * 60 * 60 => 'day',
2350 + 60 * 60 => 'hour',
2351 + 60 => 'minute',
2352 + 1 => 'second',
2353 + );
2354 + $a_plural = array(
2355 + 'year' => 'years',
2356 + 'month' => 'months',
2357 + 'day' => 'days',
2358 + 'hour' => 'hours',
2359 + 'minute' => 'minutes',
2360 + 'second' => 'seconds',
2361 + );
2362 +
2363 + foreach ( $a as $secs => $str ) {
2364 + $d = $etime / $secs;
2365 + if ( $d >= 1 ) {
2366 + $r = round( $d );
2367 + return $r . ' ' . ( $r > 1 ? $a_plural[ $str ] : $str ) . ' ago';
2368 + }
2369 + }
2370 + }
2371 +
2372 + /**
2373 + * Returns language as flag.
2374 + *
2375 + * @param string $language Language code.
2376 + * @return void
2377 + */
2378 + public static function get_language_code_as_flag( $language ) {
2379 + // For flag extraction, remove trailing _formal or _informal if present.
2380 + $flag_language = preg_replace( '/_(formal|informal)$/', '', $language );
2381 + // Get the last 2 characters of the flag language code.
2382 + $last_two_chars = ! empty( $flag_language ) ? substr( $flag_language, -2 ) : '';
2383 + // Convert to lowercase.
2384 + $lowercase_last_two_chars = strtolower( $last_two_chars );
2385 + $lowercase_flag_language = strtolower( $flag_language );
2386 +
2387 + // Get display name using the original language string.
2388 + $display_language = function_exists( 'locale_get_display_name' ) ? locale_get_display_name( $language ) : $language;
2389 +
2390 + // Adjust special country codes.
2391 + if ( 'et' === $lowercase_last_two_chars ) {
2392 + $lowercase_last_two_chars = 'ee';
2393 + }
2394 + if ( 'sq' === $lowercase_last_two_chars ) {
2395 + $lowercase_last_two_chars = 'al';
2396 + }
2397 + if ( 'ab' === $lowercase_last_two_chars ) {
2398 + $lowercase_last_two_chars = 'dz';
2399 + }
2400 +
2401 + $stacked_flags = array(
2402 + 'ca' => array(
2403 + 'primary' => 'es',
2404 + 'secondary' => 'ad',
2405 + ),
2406 + );
2407 + // Only stack flags for explicit Catalan locales to avoid affecting Canada.
2408 + $stacked_flag_locales = array(
2409 + 'ca' => array(
2410 + 'ca',
2411 + ),
2412 + );
2413 +
2414 + if ( isset( $stacked_flags[ $lowercase_last_two_chars ] ) ) {
2415 + $should_stack = true;
2416 + if ( isset( $stacked_flag_locales[ $lowercase_last_two_chars ] ) ) {
2417 + $should_stack = in_array( $lowercase_flag_language, $stacked_flag_locales[ $lowercase_last_two_chars ], true );
2418 + }
2419 +
2420 + if ( $should_stack ) {
2421 + $primary_flag = $stacked_flags[ $lowercase_last_two_chars ]['primary'];
2422 + $secondary_flag = $stacked_flags[ $lowercase_last_two_chars ]['secondary'];
2423 +
2424 + echo '<span data-tooltip="' . esc_html__( 'Site Language: ', 'mainwp' ) . esc_attr( $display_language ) . '" data-position="left center" data-inverted="">';
2425 + echo '<span class="mainwp-flag-stack">';
2426 + echo '<i class="small ' . esc_attr( $primary_flag ) . ' flag mainwp-flag-stack__flag mainwp-flag-stack__flag--primary"></i>';
2427 + echo '<i class="small ' . esc_attr( $secondary_flag ) . ' flag mainwp-flag-stack__flag mainwp-flag-stack__flag--secondary"></i>';
2428 + echo '</span>';
2429 + echo '</span>';
2430 + return;
2431 + }
2432 + }
2433 +
2434 + echo '<span data-tooltip="' . esc_html__( 'Site Language: ', 'mainwp' ) . esc_attr( $display_language ) . '" data-position="left center" data-inverted=""><i class="small ' . esc_attr( $lowercase_last_two_chars ) . ' flag"></i></span>';
2435 + }
2436 +
2437 + /**
2438 + * Returns icon for the site indexability status.
2439 + *
2440 + * @param int $status Status, 1 or 0.
2441 + * @return void.
2442 + */
2443 + public static function get_site_index_option_icon( $status ) {
2444 + $icon = '';
2445 + $tooltip = '';
2446 + if ( isset( $status ) && '' !== $status ) {
2447 + if ( 1 === intval( $status ) ) {
2448 + $icon = 'green dot circle outline';
2449 + $tooltip = 'Search engines can index this site.';
2450 + } elseif ( 0 === intval( $status ) ) {
2451 + $icon = 'red ban';
2452 + $tooltip = 'This site is blocking search engines.';
2453 + }
2454 + } else {
2455 + $icon = 'grey circle';
2456 + $tooltip = 'Indexing status unknown. Resync the site or check manually in WordPress Settings > Reading.';
2457 + }
2458 + echo '<span data-tooltip="' . $tooltip . '" data-position="left center" data-inverted=""><i class="' . $icon . ' icon"></i></span>'; //phpcs:ignore -- ok.
2459 + }
2460 +
2461 + /**
2462 + * Returns the appropriate Fomantic UI color class based on number of updates
2463 + *
2464 + * @param int $update_count Number of available updates.
2465 + *
2466 + * @return string CSS class for the element
2467 + */
2468 + public static function mainwp_get_update_count_class( $update_count ) {
2469 + // Convert to integer using intval().
2470 + $update_count = intval( $update_count );
2471 +
2472 + // Ensure count is not negative.
2473 + if ( 0 > $update_count ) {
2474 + $update_count = 0;
2475 + }
2476 +
2477 + if ( 0 === $update_count ) {
2478 + return 'grey';
2479 + } elseif ( $update_count >= 1 && $update_count <= 3 ) {
2480 + return 'yellow';
2481 + } elseif ( $update_count >= 4 && $update_count <= 5 ) {
2482 + return 'orange';
2483 + } else {
2484 + return 'red';
2485 + }
2486 + }
2487 +
2488 + /**
2489 + * Display site name and URL with optional WP Admin link.
2490 + *
2491 + * @param object|int $site Site object or Site ID.
2492 + * @param bool $wp_admin Whether to show WP Admin link.
2493 + * @param bool $print_content Whether to print or return the content.
2494 + * @return string HTML markup for site display.
2495 + */
2496 + public static function mainwp_display_site( $site = '', $wp_admin = true, $print_content = false ) {
2497 + if ( empty( $site ) ) {
2498 + return '';
2499 + }
2500 +
2501 + if ( static::ctype_digit( $site ) ) {
2502 + $website = MainWP_DB::instance()->get_website_by_id( $site );
2503 + } elseif ( is_object( $site ) && isset( $site->id ) ) {
2504 + $website = $site;
2505 + } else {
2506 + return '';
2507 + }
2508 +
2509 + if ( ! $website ) {
2510 + return '';
2511 + }
2512 +
2513 + $site_name = esc_html( stripslashes( $website->name ) );
2514 + $site_url = esc_url( $website->url );
2515 + $nice_url = esc_html( static::get_nice_url( $website->url ) );
2516 +
2517 + $html = '<div class="mainwp-site-display">';
2518 +
2519 + // WP Admin link (if enabled and user has permission).
2520 + if ( $wp_admin && \mainwp_current_user_can( 'dashboard', 'access_wpadmin_on_child_sites' ) ) {
2521 + $admin_url = MainWP_Site_Open::get_open_site_url( $website->id, '', false );
2522 + $html .= '<a href="' . esc_url( $admin_url ) . '" class="open_newwindow_wpadmin" target="_blank" data-tooltip="' . esc_attr__( 'Go to WP Admin', 'mainwp' ) . '" data-position="top left" data-inverted=""><i class="sign in icon"></i></a> ';
2523 + } elseif ( $wp_admin ) {
2524 + $html .= '<i class="sign in icon"></i> ';
2525 + }
2526 +
2527 + // Site name with dashboard link.
2528 + $html .= '<a href="' . esc_url( admin_url( 'admin.php?page=managesites&dashboard=' . intval( $website->id ) ) ) . '">' . $site_name . '</a>';
2529 +
2530 + // Site URL.
2531 + $html .= '<div><span class="ui small text">';
2532 + $html .= '<a href="' . $site_url . '" class="mainwp-may-hide-referrer open_site_url ui grey text" target="_blank">' . $nice_url . '</a>';
2533 + $html .= '</span></div>';
2534 +
2535 + $html .= '</div>';
2536 +
2537 + if ( $print_content ) {
2538 + echo $html; // phpcs:ignore -- ok.
2539 + return '';
2540 + }
2541 +
2542 + return $html;
2543 + }
2544 +
2545 + /**
2546 + * Generate a sortable BIGINT for WordPress versions.
2547 + * Newer versions produce larger numbers.
2548 + * Ordering matches WP core version_compare().
2549 + *
2550 + * @param string $version Version value.
2551 + */
2552 + public function wp_versions_order_num( $version ) {
2553 +
2554 + $v = strtolower( trim( $version ) );
2555 +
2556 + // 1) Normalize WordPress aliases.
2557 + $v = str_replace(
2558 + array( '-alpha', '-beta', '-rc' ),
2559 + array( '-a', '-b', '-rc' ),
2560 + $v
2561 + );
2562 +
2563 + // nightly / dev / trunk → dev.
2564 + if ( preg_match( '/-(nightly|dev|trunk)/', $v ) ) {
2565 + $v = preg_replace( '/-.+$/', '-dev', $v );
2566 + }
2567 +
2568 + // unknown tags → dev.
2569 + if ( preg_match( '/-[a-z]+/', $v ) && ! preg_match( '/-(a|b|rc|dev)/', $v ) ) {
2570 + $v = preg_replace( '/-.+$/', '-dev', $v );
2571 + }
2572 +
2573 + // 2) Extract numeric base version.
2574 + $base = explode( '-', $v, 2 )[0];
2575 + $parts = array_map( 'intval', explode( '.', $base ) );
2576 +
2577 + if ( count( $parts ) < 4 ) {
2578 + $parts = array_pad( $parts, 4, 0 );
2579 + }
2580 +
2581 + $versionNum =
2582 + ( $parts[0] << 24 ) |
2583 + ( $parts[1] << 16 ) |
2584 + ( $parts[2] << 8 ) |
2585 + $parts[3];
2586 +
2587 + // 3) Release channel rank (lower = newer).
2588 + $rank = 1; // stable.
2589 + if ( strpos( $v, '-rc' ) !== false ) {
2590 + $rank = 2;
2591 + } elseif ( strpos( $v, '-b' ) !== false ) {
2592 + $rank = 3;
2593 + } elseif ( strpos( $v, '-a' ) !== false ) {
2594 + $rank = 4;
2595 + } elseif ( strpos( $v, '-dev' ) !== false ) {
2596 + $rank = 5;
2597 + }
2598 +
2599 + // 4) Prerelease / build number.
2600 + $build = 0;
2601 + if ( preg_match( '/rc(\d+)/', $v, $m ) ) {
2602 + $build = (int) $m[1];
2603 + } elseif ( preg_match( '/b(\d+)/', $v, $m ) ) { // NOSONAR - same above ok.
2604 + $build = (int) $m[1];
2605 + } elseif ( preg_match( '/a(\d+)/', $v, $m ) ) { // NOSONAR - same above ok.
2606 + $build = (int) $m[1];
2607 + }
2608 +
2609 + // 5) Compose sortable BIGINT.
2610 + return ( $versionNum << 32 )
2611 + | ( $rank << 29 )
2612 + | min( $build, ( 1 << 29 ) - 1 );
2613 + }
2614 +
2615 + /**
2616 + * Method handle gen_rand_id().
2617 + *
2618 + * @param string $str Input string.
2619 + * @param bool $more_entropy More entropy False if a robust prefix is required, default false.
2620 + * @return string Random ID 8 characters.
2621 + */
2622 + public static function gen_rand_id( $str = '', $more_entropy = false ) {
2623 + return hash( 'crc32b', uniqid( $str, $more_entropy ? true : false ) );
2624 + }
2625 +
2626 + /**
2627 + * Decode and validate JSON array.
2628 + *
2629 + * @param mixed $data Data to decode.
2630 + */
2631 + public static function get_decoded_array( $data ) {
2632 + $decoded = ! empty( $data ) ? json_decode( $data, true ) : array();
2633 + return is_array( $decoded ) ? $decoded : array();
2634 + }
2635 +
2636 + /**
2637 + * Method get_use_cron()
2638 + *
2639 + * @return bool Whether use cron.
2640 + */
2641 + public static function get_use_cron() {
2642 + return ( get_option( 'mainwp_wp_cron' ) === false ) || ( (int) get_option( 'mainwp_wp_cron' ) === 1 ) ? 1 : 0;
1711 2643 }
1712 2644 }