Code draft

class-swm-visitors.php

<?php
/**
 * Description: Website visits table.
 * PHP version 8.0.1
 *
 * @category Visits
 * @package  SiteWatchman
 * @author   Oleg Klenitsky <klenitskiy.oleg@mail.ru>
 * @version  1.0.0
 * @license  GPLv2 or later
 */

namespace sitewatchman\swm;

if ( ! defined( 'ABSPATH' ) ) {
	exit();
}

if ( ! class_exists( 'WP_List_Table' ) ) {
	require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}

/**
 * Website visits table.
 */
class SWM_Visitors extends \WP_List_Table {

	/**
	 * Description: Saves the current values variables in the array for all modules of plugin.
	 *
	 * @var array
	 */
	private static $swm_data;

	/**
	 * Constructor.
	 */
	public function __construct() {
		parent::__construct(
			array(
				'singular' => 'visitor',
				'plural'   => 'visitors',
				'ajax'     => false,
			)
		);
	}

	/**
	 * Description: Search message.
	 */
	public function no_items() {
		$prm_search = get_option( 'swm_search' );
		if ( '' === $prm_search ) {
			echo 'No items found.';
		} else {
			echo wp_kses(
				'No results were found for this filter value: <b>' . esc_html( $prm_search ) . '</b>',
				array( 'b' => array() )
			);
			update_option( 'swm_search', '' );
		}
	}
	/**
	 * Description: Saves variables for future use.
	 *
	 * @param string $name  Set name variable.
	 * @param string $value Set value variable.
	 */
	public static function swm_set( $name, $value ) {
		self::$swm_data[ $name ] = $value;
	}
	/**
	 * Description: Gets variables for future use.
	 *
	 * @param  string $name The name of the variable to get the value.
	 * @return array|false
	 */
	public static function swm_get( $name ) {
		return ( isset( self::$swm_data[ $name ] ) ) ? self::$swm_data[ $name ] : false;
	}

	/**
	 * Description: Creates column names for a table.
	 *
	 * @return array Name of columns.
	 */
	public function get_columns() {
		return array(
			'cb'         => '<input type="checkbox" />',
			'id'         => 'ID',
			'uid'        => 'UID',
			'user_login' => 'Login',
			'user_role'  => 'Role',
			'time_visit' => 'Date visit',
			'user_ip'    => 'Visitor IP',
			'black_list' => 'Black list',
			'page_visit' => 'Page visit',
			'page_from'  => 'Page from',
			'info'       => 'Info',
		);
	}

	/**
	 * Description: Determines which columns of the table can be sorted.
	 *
	 * @return array Sortable columns
	 */
	public function get_sortable_columns() {
		$sortable_columns = array(
			'id'         => array( 'id', true ),
			'uid'        => array( 'uid', true ),
			'user_login' => array( 'user_login', true ),
			'user_role'  => array( 'user_role', true ),
			'time_visit' => array( 'time_visit', true ),
			'user_ip'    => array( 'user_ip', true ),
			'page_visit' => array( 'page_visit', true ),
			'page_from'  => array( 'page_from', true ),
		);
		return $sortable_columns;
	}

	/**
	 * 9. Главный управляющий метод таблицы (Сборщик данных).
	 *
	 * Срабатывает ПЕРЕД выводом HTML-таблицы на экран. Наполняет массив $this->items.
	 * Сюда стекаются все GET-запросы от фильтров, поиска, пагинации и кликов сортировки.
	 */
	public function prepare_items() {
		global $wpdb;

		$this->process_bulk_action();

		// Обработка поисковых запросов формы.
		if ( isset( $_POST['swm_level1_search_nonce'] ) &&
			wp_verify_nonce( sanitize_key( wp_unslash( $_POST['swm_level1_search_nonce'] ) ), self::ACTION_SEARCH_LEVEL1 ) ) {
			// Проверяем и сохраняем именно filter_level1_search.
			if ( isset( $_POST['filter_level1_search'] ) ) {
				update_option( 'swm_search', sanitize_text_field( wp_unslash( $_POST['filter_level1_search'] ) ) );
			}
		}

		// 1. Получаем гарантированную строку WHERE и аргументы.
		$where_data   = $this->swm_make_where_query();
		$where_string = $where_data['query_string'];
		$where_args   = $where_data['args'];

		// 2. Определяем текущую активную вкладку (фильтр 2-го уровня)
		$current_filter = isset( $_REQUEST['filter_level2_visit'] ) ? sanitize_key( wp_unslash( $_REQUEST['filter_level2_visit'] ) ) : 'all';

		$cache_group      = 'swm_visitors_counts';
		$cache_key_suffix = md5( $where_string );

		// --- 1. Для базовой вкладки "Все" ---
		$cache_key_all = 'all_' . $cache_key_suffix;
		$all           = wp_cache_get( $cache_key_all, $cache_group );

		if ( false === $all ) {
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
			$all = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors {$where_string}",
					$where_args
				)
			);
			wp_cache_set( $cache_key_all, $all, $cache_group, 60 );
		}

		// --- 2. Для radio buttons statuses ---
		// Так как $where_string гарантированно содержит ' WHERE ...', условия статусов добавляются через AND.
		$status_query_base = "SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors " . $where_string . ' AND `status` = %s';

		// Статус: unlogged.
		$cache_key_unlogged = 'unlogged_' . $cache_key_suffix;
		$unlogged           = wp_cache_get( $cache_key_unlogged, $cache_group );

		if ( false === $unlogged ) {
			$unlogged_args = array_merge( $where_args, array( 'unlogged' ) );
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
			$unlogged = (int) $wpdb->get_var(
				$wpdb->prepare(
					// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors {$where_string} AND `status` = %s",
					$unlogged_args
				)
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
			wp_cache_set( $cache_key_unlogged, $unlogged, $cache_group, 60 );
		}

		// Статус: success.
		$cache_key_success = 'success_' . $cache_key_suffix;
		$success           = wp_cache_get( $cache_key_success, $cache_group );

		if ( false === $success ) {
			$success_args = array_merge( $where_args, array( 'success' ) );
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
			$success = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors {$where_string} AND `status` = %s",
					$success_args
				)
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

			wp_cache_set( $cache_key_success, $success, $cache_group, 60 );
		}

		// Статус: failed.
		$cache_key_failed = 'failed_' . $cache_key_suffix;
		$failed           = wp_cache_get( $cache_key_failed, $cache_group );

		if ( false === $failed ) {
			$failed_args = array_merge( $where_args, array( 'failed' ) );
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
			$failed = (int) $wpdb->get_var(
				$wpdb->prepare(
					// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
					// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors {$where_string} AND `status` = %s",
					$failed_args
				)
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

			wp_cache_set( $cache_key_failed, $failed, $cache_group, 60 );
		}

		// Статус: robot.
		$cache_key_robots = 'robots_' . $cache_key_suffix;
		$robots           = wp_cache_get( $cache_key_robots, $cache_group );

		if ( false === $robots ) {
			$robots_args = array_merge( $where_args, array( 'robot' ) );
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
			$robots = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors {$where_string} AND `status` = %s",
					$robots_args
				)
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

			wp_cache_set( $cache_key_robots, $robots, $cache_group, 60 );
		}

		// --- 3. Для черного списка ---
		$cache_key_blacklist = 'blacklist_' . $cache_key_suffix;
		$blacklist           = wp_cache_get( $cache_key_blacklist, $cache_group );

		if ( false === $blacklist ) {
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
			// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
			$blacklist = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM {$wpdb->prefix}swm_visitors {$where_string} AND `black_list` <> ''",
					$where_args
				)
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
			// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			// phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
			wp_cache_set( $cache_key_blacklist, $blacklist, $cache_group, 60 );
		}

		// Выбираем верное значение общего количества элементов для пагинации текущего таба.
		switch ( $current_filter ) {
			case 'unlogged':
				$total_items = $unlogged;
				break;
			case 'success':
				$total_items = $success;
				break;
			case 'failed':
				$total_items = $failed;
				break;
			case 'robots':
				$total_items = $robots;
				break;
			case 'blacklist':
				$total_items = $blacklist;
				break;
			case 'all':
			default:
				$total_items = $all;
				break;
		}

		// Сохраняем актуальные цифры для вывода количества в HTML-вкладках.
		$this->swm_set( 'allTotal', $all );
		$this->swm_set( 'successTotal', $success );
		$this->swm_set( 'failedTotal', $failed );
		$this->swm_set( 'visitsTotal', $unlogged );
		$this->swm_set( 'robotsTotal', $robots );
		$this->swm_set( 'blacklistTotal', $blacklist );

		// 4. Настройка лимитов отображения (Количество элементов на страницу).
		$per_page = get_user_option( 'swm_visitors_per_page' );
		if ( false === $per_page || $per_page <= 0 ) {
			$per_page = 10;
		}

		// Вычисляем корректное смещение (Offset).
		$offset = $per_page * ( $this->get_pagenum() - 1 );

		// 5. Инициализация заголовков таблицы.
		$columns               = $this->get_columns();
		$screen                = get_current_screen();
		$hidden_cols           = $screen instanceof WP_Screen ? get_user_option( 'manage' . $screen->id . 'columnshidden' ) : array();
		$hidden                = is_array( $hidden_cols ) ? $hidden_cols : array();
		$sortable              = $this->get_sortable_columns();
		$this->_column_headers = array( $columns, $hidden, $sortable );

		// 6. Обработка параметров сортировки.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$orderby = isset( $_REQUEST['orderby'] ) ? sanitize_key( wp_unslash( $_REQUEST['orderby'] ) ) : 'id';
		if ( empty( $orderby ) ) {
			$orderby = 'id';
		}
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$_order = isset( $_REQUEST['order'] ) ? sanitize_key( wp_unslash( $_REQUEST['order'] ) ) : 'desc';
		$order  = ( 'asc' === strtolower( $_order ) ) ? 'asc' : 'desc';

		// 7. Получаем отфильтрованные данные именно для текущей страницы
		$this->items = $this->swm_visit_get_data( $orderby, $order, $per_page, $offset, $where_args );

		// 8. Передаем правильные аргументы в пагинатор WordPress
		$this->set_pagination_args(
			array(
				'total_items' => $total_items,
				'per_page'    => $per_page,
				'total_pages' => ( $total_items > 0 ) ? ceil( $total_items / $per_page ) : 1,
			)
		);
	}

	/**
	 * Forms the SQL WHERE string and arguments array.
	 * Never returns an empty string; uses defaults instead.
	 *
	 * @return array Contains 'query_string' (string) and 'args' (array) for wpdb->prepare().
	 */
	/**
	 * Forms the SQL WHERE string and arguments array.
	 * Never returns an empty string; uses defaults instead.
	 *
	 * @return array Contains 'query_string' (string) and 'args' (array) for wpdb->prepare().
	 */
	private function swm_make_where_query() {
		global $wpdb;

		$where_clauses = array();
		$where_args    = array();

		// 1. Читаем параметры из универсального $_REQUEST (поддерживает и GET, и AJAX-POST).
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$_req_role = isset( $_REQUEST['filter_role'] ) ? sanitize_key( wp_unslash( $_REQUEST['filter_role'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$_req_time = isset( $_REQUEST['filter_time'] ) ? sanitize_key( wp_unslash( $_REQUEST['filter_time'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$_req_country = isset( $_REQUEST['filter_country'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['filter_country'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$_req_search = isset( $_REQUEST['filter_search'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['filter_search'] ) ) : '';

		// 2. Сбрасываем дефолтные текстовые заглушки селектов.
		if ( 'all roles' === strtolower( $_req_role ) || 'all_roles' === strtolower( $_req_role ) ) {
			$_req_role = '';
		}
		if ( 'all months' === strtolower( $_req_time ) || 'all_months' === strtolower( $_req_time ) ) {
			$_req_time = '';
		}
		if ( 'all countries' === strtolower( $_req_country ) || 'all_countries' === strtolower( $_req_country ) ) {
			$_req_country = '';
		}

		// 3. Условие для фильтра по РОЛИ.
		if ( ! empty( $_req_role ) ) {
			$role_clean = sanitize_key( $_req_role );
			if ( '0' === $role_clean || 0 === $role_clean ) {
				$where_clauses['filter_level1_role'] = 'uid <> 0 AND user_role LIKE %s';
				$where_args[]                        = $role_clean;
			} else {
				$where_clauses['filter_level1_role'] = 'user_role LIKE %s';
				$where_args[]                        = $role_clean;
			}
		} else {
			$where_clauses['filter_level1_role'] = 'user_role LIKE %s';
			$where_args[]                        = '%';
		}

		// 4. Условие для фильтра по ВРЕМЕНИ.
		if ( ! empty( $_req_time ) && strlen( $_req_time ) >= 6 ) {
			$year  = absint( substr( $_req_time, 0, 4 ) );
			$month = absint( substr( $_req_time, -2 ) );
			if ( $year > 0 && $month > 0 ) {
				$where_clauses['filter_level1_time'] = 'YEAR(time_visit) = %d AND MONTH(time_visit) = %d';
				$where_args[]                        = $year;
				$where_args[]                        = $month;
			}
		} else {
			$where_clauses['filter_level1_time'] = 'time_visit LIKE %s';
			$where_args[]                        = '%';
		}

		// 5. Условие для фильтра по СТРАНЕ.
		if ( ! empty( $_req_country ) ) {
			$where_clauses['filter_level1_country'] = 'user_ip LIKE %s';
			$where_args[]                           = '%<br>' . $wpdb->esc_like( $_req_country ) . '<br>%';
		} else {
			$where_clauses['filter_level1_country'] = 'user_ip LIKE %s';
			$where_args[]                           = '%';
		}

		// 6. Условие для живого поиска (поиск по login или ip).
		if ( ! empty( $_req_search ) ) {
			$where_clauses['filter_login_ip'] = '(user_login LIKE %s OR user_ip LIKE %s)';
			$search_wildcard                  = '%' . $wpdb->esc_like( $_req_search ) . '%';
			$where_args[]                     = $search_wildcard;
			$where_args[]                     = $search_wildcard;
		} else {
			$where_clauses['filter_login_ip'] = '(user_login LIKE %s OR user_ip LIKE %s)';
			$where_args[]                     = '%';
			$where_args[]                     = '%';
		}

		// Склеиваем все условия через AND. Строка гарантированно будет содержать плейсхолдеры.
		$query_string = ' WHERE ' . implode( ' AND ', $where_clauses );

		return array(
			'query_string' => $query_string,
			'args'         => $where_args,
		);
	}

	/**
	 * Getting data to display in the main plugin table.
	 *
	 * @param  boolean $orderby    [description].
	 * @param  boolean $order      [description].
	 * @param  integer $limit      [description].
	 * @param  integer $offset     [description].
	 * @param  array   $where_args [description].
	 * @return [type]              [description]
	 */
	private function swm_visit_get_data( $orderby = false, $order = false, $limit = 0, $offset = 0, $where_args = array() ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$_result = isset( $_REQUEST['filter_level2_visit'] ) ? sanitize_key( wp_unslash( $_REQUEST['filter_level2_visit'] ) ) : 'all';

		global $wpdb;

		// 1. Получаем базовую структуру запроса 1-го уровня.
		$where_data   = $this->swm_make_where_query();
		$query_string = $where_data['query_string'];
		$base_args    = $where_data['args'];

		// Если по какой-то причине передан пустой массив аргументов извне, страхуемся внутренним.
		if ( empty( $where_args ) ) {
			$where_args = $base_args;
		}

		// 2. Дополнительные фильтры 2-го уровня (вкладки статусов).
		$level2_clauses = array();
		switch ( $_result ) {
			case '':
			case 'all':
				$level2_clauses[] = '`status` IS NOT NULL AND LENGTH(`status`) > 0';
				break;
			case 'unlogged':
				$level2_clauses[] = "`status` = 'unlogged'";
				break;
			case 'success':
				$level2_clauses[] = "`status` = 'success'";
				break;
			case 'failed':
				$level2_clauses[] = "`status` = 'failed'";
				break;
			case 'robots':
				$level2_clauses[] = "`status` = 'robot'";
				break;
			case 'blacklist':
				$level2_clauses[] = "black_list <> ''";
				break;
		}

		// Так как $query_string гарантированно начинается с " WHERE ...",
		// новые условия второго уровня дописываются через AND к общему SQL-запросу.
		$final_where = $query_string;
		if ( ! empty( $level2_clauses ) ) {
			$final_where .= ' AND ' . implode( ' AND ', $level2_clauses );
		}

		// 3. Валидация параметров сортировки.
		$orderby = ( ! isset( $orderby ) || '' === $orderby ) ? 'time_visit' : $orderby;
		$order   = ( ! isset( $order ) || '' === $order ) ? 'DESC' : $order;

		// Валидация направления сортировки (белый список).
		$order = ( 'asc' === strtolower( $order ) ) ? 'ASC' : 'DESC';

		// Валидация колонок (белый список), чтобы избежать инъекций через ORDER BY.
		if ( ! in_array( $orderby, array( 'id', 'uid', 'user_login', 'user_role', 'time_visit', 'user_ip', 'page_visit', 'page_from' ), true ) ) {
			$orderby = 'time_visit';
		}

		// 4. Формируем аргументы.
		$final_args   = $where_args;
		$final_args[] = absint( $limit );
		$final_args[] = absint( $offset );

		// 5. Собираем правильный шаблон.
		$raw_sql = 'SELECT * FROM ' . $wpdb->prefix . 'swm_visitors ' . $final_where . ' ORDER BY ' . $orderby . ' ' . $order . ' LIMIT %d OFFSET %d';

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber

		/**
		 * Bypass strict static analysis for a complex, safely pre-validated dynamic SQL query structure.
		 *
		 * @psalm-suppress All
		 * @phpstan-ignore-next-line
		 */
		$results = $wpdb->get_results(
			$wpdb->prepare( (string) $raw_sql, $final_args ),
			'ARRAY_A'
		);
		// phpcs:enable

		return $results;
	}

	/**
	 * Вывод дефолтной колонки.
	 *
	 * @param  [type] $item        [description].
	 * @param  [type] $column_name [description].
	 * @return [type]              [description].
	 */
	public function column_default( $item, $column_name ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$_mode = isset( $_GET['mode'] ) ? sanitize_key( wp_unslash( $_GET['mode'] ) ) : '';

		switch ( $column_name ) {
			case 'id':
			case 'uid':
			case 'time_visit':
			case 'user_login':
			case 'user_role':
				return esc_html( $item[ $column_name ] );
			case 'page_visit':
			case 'page_from':
			case 'info':
				$data   = rawurldecode( $item[ $column_name ] );
				$output = ( isset( $_mode ) && 'excerpt' === $_mode ) ? $data : substr( $data, 0, 115 ) . '...';
				return wp_kses(
					$output,
					array(
						'span' => array( 'style' => true ),
						'br'   => array(),
					)
				);
			default:
				return $item[ $column_name ];
		}
	}

	/**
	 * Вывод чекбокса.
	 *
	 * @param  [type] $item [description].
	 * @return [type]       [description].
	 */
	public function column_cb( $item ) {
		return sprintf( '<input type="checkbox" name="bulk_ids[]" value="%d" />', absint( $item['id'] ) );
	}
	/**
	 * Fills table cells with data in column user_login (Login).
	 *
	 * @param  array $item Content cell of table.
	 * @return string
	 */
	public function column_user_login( $item ) {
		$user_login = ! empty( $item['user_login'] ) ? esc_html( $item['user_login'] ) : '—';

		// Если есть ID пользователя, добавляем аватар сверху.
		if ( ! empty( $item['uid'] ) ) {
			$avatar = get_avatar( $item['uid'], 30 );
			if ( $avatar ) {
				$user_login = $avatar . '<br>' . $user_login;
			}
		}
		return '<span style="display: inline-block; word-break: break-word;">' . $user_login . '</span>';
	}
	/**
	 * [column_user_ip description]
	 *
	 * @param  [type] $item [description].
	 * @return [type]       [description].
	 */
	public function column_user_ip( $item ) {
		// Безопасно получаем значение IP и приводим его к строке.
		$raw_ip = isset( $item['user_ip'] ) ? (string) $item['user_ip'] : '';

		// Если поле совсем пустое, возвращаем пустую строку.
		if ( trim( $raw_ip ) === '' ) {
			return '—';
		}

		// Разделяем строку по тегу <br>.
		$arr = explode( '<br>', $raw_ip );

		// Проверяем, удалось ли вытащить код страны (индекс 1).
		$country_code = ( isset( $arr[1] ) && strlen( trim( $arr[1] ) ) === 2 ) ? strtoupper( trim( $arr[1] ) ) : '';

		// Собираем дополнительные строки геоданных (город, регион), если они есть.
		$sub_str2 = ( isset( $arr[2] ) ) ? '<br>' . $arr[2] : '';
		$sub_str3 = ( isset( $arr[3] ) ) ? '<br>' . $arr[3] : '';
		$sub_str4 = ( isset( $arr[4] ) ) ? '<br>' . $arr[4] : '';

		// Если код страны успешно найден, формируем HTML-тег флага.
		$flag = '';
		if ( '' !== $country_code ) {
			$path_flag = plugins_url( '/images/flags/', __FILE__ ) . $country_code . '.gif';
			$flag      = ' <img src="' . esc_url( $path_flag ) . '" alt="' . esc_attr( $country_code ) . '" style="vertical-align: middle; margin-left: 5px;">';
		}

		// Собираем итоговую строку для вывода в таблицу WP_List_Table.
		$ip_address = isset( $arr[0] ) ? $arr[0] : '';
		$str        = $ip_address . '<br>' . $country_code . $flag . $sub_str2 . $sub_str3 . $sub_str4;

		return $str;
	}
	/**
	 * [column_black_list description]
	 *
	 * @param  [type] $item [description].
	 * @return [type]       [description]
	 */
	public function column_black_list( $item ) {
		// 1. Получаем ID и текущее значение ячейки (JSON строка).
		$item_id       = is_array( $item ) ? $item['id'] : $item->id;
		$current_value = is_array( $item ) ? $item['black_list'] : $item->black_list;

		// 2. ИСПРАВЛЕНО ДЛЯ AJAX: Убираем прямой URL перехода.
		// Вместо него делаем заглушку "#", но маскируем под сервисную кнопку.
		// Добавляем класс "swm-service-btn", атрибут "data-service" и "data-id" строки.
		$actions = array(
			'blacklist' => sprintf(
				'<a href="#" class="swm-service-btn" data-service="blacklist" data-id="%d">%s</a>',
				intval( $item_id ),
				'Edit'
			),
		);

		// 4. Формируем текстовое содержимое ячейки на основе JSON (Ваш оригинальный код).
		if ( empty( $current_value ) ) {
			$cell_text = '—';
		} else {
			$blacklist_data = json_decode( $current_value, true );

			if ( ! is_array( $blacklist_data ) ) {
				$cell_text = esc_html( $current_value );
			} else {
				// Красивый читаемый текст для ссылки ячейки.
				$cell_text = sprintf(
					'<strong>Start:</strong> %1$s<br><strong>End:</strong> %2$s<br><strong>Msg:</strong> %3$s',
					esc_html( $blacklist_data['ban_start_date'] ?? '' ),
					esc_html( $blacklist_data['ban_end_date'] ?? '' ),
					esc_html( $blacklist_data['ban_message'] ?? '' )
				);
			}
		}

		// 5. Возвращаем обычный текст ячейки и синюю ссылку "Edit" при наведении.
		$output = sprintf(
			'%1$s %2$s',
			$cell_text,
			$this->row_actions( $actions )
		);

		// ИСПРАВЛЕНО ДЛЯ БЕЗОПАСНОСТИ WP: Расширяем белый список тегов и атрибутов для wp_kses.
		// Если не разрешить атрибуты class, data-service и data-id, WordPress вырежет их при выводе!
		return wp_kses(
			$output,
			array(
				'strong' => array(),
				'br'     => array(),
				'div'    => array( 'class' => true ),
				'span'   => array( 'class' => true ),
				'a'      => array(
					'href'         => true,
					'class'        => true,
					'data-service' => true,
					'data-id'      => true,
				),
			)
		);
	}
	/**
	 * Description: Creates additional controls (Filters 2 level).
	 *
	 * @param string $which Displayed top or bottom.
	 */
	public function extra_tablenav( $which ) {
		if ( 'top' === $which ) {

			// 1. Проверяем общий nonce перед чтением кастомного фильтра из $_REQUEST.
			if ( isset( $_REQUEST['swm_nonce'] ) && wp_verify_nonce( wp_unslash( $_REQUEST['swm_nonce'] ), 'swm_nonce' ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
				$current_filter = isset( $_REQUEST['filter_level2_visit'] ) ? sanitize_key( wp_unslash( $_REQUEST['filter_level2_visit'] ) ) : 'all';
			} else {
				// Если nonce отсутствует (например, при первом открытии страницы), задаем значение по умолчанию.
				$current_filter = 'all';
			}

			?>
			<input class="radio-level2" id="radio-1" name="filter_level2_visit" type="radio" value="all" <?php checked( $current_filter, 'all' ); ?> onclick="swm_visit(this.id)">
			<label class="radio" for="radio-1"><?php echo esc_html( 'All Visits' ); ?> (<?php echo esc_html( $this->swm_get( 'allTotal' ) ); ?>)</label>

			<input class="radio-level2" id="radio-2" name="filter_level2_visit" type="radio" value="unlogged" <?php checked( $current_filter, 'unlogged' ); ?> onclick="swm_visit(this.id)">
			<label class="radio" for="radio-2"><?php echo esc_html( 'Unlogged' ); ?> (<?php echo esc_html( $this->swm_get( 'visitsTotal' ) ); ?>)</label>

			<input class="radio-level2" id="radio-3" name="filter_level2_visit" type="radio" value="success" <?php checked( $current_filter, 'success' ); ?> onclick="swm_visit(this.id)">
			<label class="radio" for="radio-3"><?php echo esc_html( 'Success' ); ?> (<?php echo esc_html( $this->swm_get( 'successTotal' ) ); ?>)</label>

			<input class="radio-level2" id="radio-4" name="filter_level2_visit" type="radio" value="failed" <?php checked( $current_filter, 'failed' ); ?> onclick="swm_visit(this.id)">
			<label class="radio" for="radio-4"><?php echo esc_html( 'Failed' ); ?> (<?php echo esc_html( $this->swm_get( 'failedTotal' ) ); ?>)</label>

			<input class="radio-level2" id="radio-5" name="filter_level2_visit" type="radio" value="robots" <?php checked( $current_filter, 'robots' ); ?> onclick="swm_visit(this.id)">
			<label class="radio" for="radio-5"><?php echo esc_html( 'Robots' ); ?> (<?php echo esc_html( $this->swm_get( 'robotsTotal' ) ); ?>)</label>

			<input class="radio-level2" id="radio-6" name="filter_level2_visit" type="radio" value="blacklist" <?php checked( $current_filter, 'blacklist' ); ?> onclick="swm_visit(this.id)">
			<label class="radio" for="radio-6"><?php echo esc_html( 'Black list' ); ?> (<?php echo esc_html( $this->swm_get( 'blacklistTotal' ) ); ?>)</label>
			<?php
		}

		// Переключатель видов (Compact/Extended).
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$_mode = isset( $_GET['mode'] ) ? sanitize_key( wp_unslash( $_GET['mode'] ) ) : '';
		$mode  = $_mode ? $_mode : 'list';
		$this->view_switcher( $mode );

		if ( 'bottom' === $which ) {
			// Оставляем только чистый класс моргания 'blinking', если лог не пустой.
			$class_button = ( swm_check_debug_log() ) ? 'blinking' : '';
			?>
			<div id="btns_service_container" style="display: inline-block;">
				<input type="button" value="index" id="btn_service1" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( 'index.php of site' ); ?>" style="width:75px;">
				<input type="button" value="robots" id="btn_service2" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( 'robots.txt of site' ); ?>" style="width:75px;">
				<input type="button" value="htaccess" id="btn_service3" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( '.htaccess of site' ); ?>" style="width:75px;">
				<input type="button" value="wp-config" id="btn_service4" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( 'wp-config.php of site' ); ?>" style="width:75px;">
				<input type="button" value="wp-cron" id="btn_service5" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( 'wp-cron events of site' ); ?>" style="width:75px;">
				<input type="button" value="statistic" id="btn_service6" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( 'statistic of visits to site' ); ?>" style="width:75px;">
				<input type="button" value="console" id="btn_service8" class="swm-service-btn" name="btns_service" title="<?php echo esc_attr( 'console' ); ?>" style="width:75px;">

				<!-- Теперь тут будет чисто: class="swm-service-btn" или class="swm-service-btn blinking" -->
				<input type="button" value="debug.log" id="btn_service9" class="swm-service-btn <?php echo esc_attr( $class_button ); ?>" name="btns_service" title="<?php echo esc_attr( 'error log' ); ?>" style="width:75px;">
			</div>
			<?php
		}
	}

	/**
	 * Переопределяем стандартный переключатель видов WordPress,
	 * сохраняя текущую страницу пагинации при смене режима.
	 *
	 * @param  [type] $current_mode [description].
	 */
	protected function view_switcher( $current_mode ) {
		$modes = array(
			'list'    => 'List View',
			'excerpt' => 'Excerpt View',
		);

		// Считываем текущую страницу пагинации, чтобы не сбрасывать её.
		// // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$current_paged = isset( $_REQUEST['paged'] ) ? absint( $_REQUEST['paged'] ) : 1;
		if ( $current_paged < 1 ) {
			$current_paged = 1;
		}

		?>
		<div class="view-switch">
			<?php
			foreach ( $modes as $mode => $title ) {
				$class = ( $current_mode === $mode ) ? 'current' : '';

				// Формируем правильный URL, передавая ТЕКУЩУЮ страницу paged.
				$url = add_query_arg(
					[
						'page'  => 'swm_visitors',
						'paged' => $current_paged,
						'mode'  => $mode,
					],
					admin_url( 'admin.php' )
				);

				printf(
					'<a href="%s" class="view-%s %s" title="%s"><span class="screen-reader-text">%s</span></a>',
					esc_url( $url ),
					esc_attr( $mode ),
					esc_attr( $class ),
					esc_attr( $title ),
					esc_html( $title )
				);
			}
			?>
		</div>
		<?php
	}

	/**
	 * Переопределяем стандартный вывод таблицы, чтобы гарантировать
	 * отрисовку нижних кнопок в extra_tablenav('bottom') даже при пустой БД.
	 */
	public function display() {
		// Если элементы в базе есть, отдаем управление стандартному методу WordPress.
		if ( $this->has_items() ) {
			parent::display();
			return;
		}

		// ЕСЛИ ТАБЛИЦА ПУСТАЯ: воспроизводим структуру WP вручную, но принудительно вызываем подвал.
		$singular = $this->_args['singular'];
		$this->views();
		?>
		<div class="tablenav top">
			<?php $this->extra_tablenav( 'top' ); ?>
			<br class="clear" />
		</div>

		<table class="wp-list-table <?php echo esc_attr( implode( ' ', $this->get_table_classes() ) ); ?>">
			<thead>
				<tr><?php $this->print_column_headers(); ?></tr>
			</thead>
			<tbody id="the-list" data-wp-lists="list:<?php echo esc_attr( $singular ); ?>">
				<tr class="no-items">
					<td class="colspanchange" colspan="<?php echo esc_attr( $this->get_column_count() ); ?>">
						<?php $this->no_items(); ?>
					</td>
				</tr>
			</tbody>
			<tfoot>
				<tr><?php $this->print_column_headers( false ); ?></tr>
			</footer>
		</table>

		<div class="tablenav bottom">
			<!-- МАГИЯ: WordPress бы сюда не зашел, но мы вызываем этот хук вручную! -->
			<?php $this->extra_tablenav( 'bottom' ); ?>
			<br class="clear" />
		</div>
		<?php
	}

	/**
	 * Description: Determines list of bulk actions.
	 *
	 * @return array Bulk actions
	 */
	public function get_bulk_actions() {
		$actions = array(
			'clear'  => esc_html( 'Clear' ),
			'delete' => esc_html( 'Delete' ),
			'report' => esc_html( 'Report' ),
		);
		return $actions;
	}
	/**
	 * Performs bulk actions (delete, report, clear).
	 */
	public function process_bulk_action() {
		global $wpdb;

		$affected_rows = 0;

		// 1. Считываем сервисную команду И кастомный AJAX-параметр swm_bulk_action
		$btn_service = isset( $_REQUEST['btns_service'] ) ? sanitize_key( wp_unslash( $_REQUEST['btns_service'] ) ) : '';
		$action      = isset( $_REQUEST['swm_bulk_action'] ) ? sanitize_key( wp_unslash( $_REQUEST['swm_bulk_action'] ) ) : '-1';

		// =========================================================================
		// ТОЧКА ПЕРЕХВАТА ДЛЯ EDIT (BLACKLIST): Обрабатываем ДО общей проверки nonce таблицы
		// =========================================================================
		if ( 'blacklist' === $btn_service || 'blacklist' === $action ) {
			// Проверка прав авторизованного администратора.
			if ( ! current_user_can( 'manage_options' ) ) {
				wp_send_json_error( array( 'message' => 'Security check2 failed.' ) );
			}

			$id = isset( $_REQUEST['id'] ) ? intval( $_REQUEST['id'] ) : 0;

			// Буферизируем HTML-вывод, чтобы не сломать JSON-структуру ответа.
			ob_start();
			swm_black_list( $id );
			$popup_html = ob_get_clean();

			// Отдаем чистый JSON в вашу готовую функцию swm_send_table_ajax.
			wp_send_json_success(
				array(
					'html'  => '',
					'popup' => $popup_html,
				)
			);
		}

		// Если массовое действие (delete, clear, report) не выбрано, выходим.
		if ( ! $action || '-1' === $action || 'swm_filter_table_ajax' === $action ) {
			return;
		}

		// 2. Стандартная проверка безопасности (Nonce) ДЛЯ МАССОВЫХ ОПЕРАЦИЙ (Удаление, Очистка и т.д.)
		$nonce = isset( $_REQUEST['swm_nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['swm_nonce'] ) ) : '';

		if ( ! $nonce || ! wp_verify_nonce( $nonce, 'swm_nonce' ) ) {
			wp_send_json_error( array( 'message' => 'Security check1 failed.' ) );
		}

		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => 'Security check2 failed.' ) );
		}

		// 3. Парсим строку с ID элементов визитов, разделенными запятыми.
		$bulk_ids_raw = isset( $_REQUEST['bulk_ids'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['bulk_ids'] ) ) : '';
		$ids          = wp_parse_id_list( $bulk_ids_raw );

		if ( empty( $ids ) ) {
			wp_send_json_error( array( 'message' => 'Не выбраны элементы таблицы.' ) );
		}

		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );

		switch ( $action ) {
			case 'delete':
				$query = sprintf(
					'DELETE FROM %s WHERE `id` IN (%s) AND LENGTH(`black_list`) = 0',
					$wpdb->prefix . 'swm_visitors',
					$placeholders
				);

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
				$affected_rows = $wpdb->query(
					// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
					$wpdb->prepare( $query, $ids )
				);
				break;
			case 'report':
				$query = sprintf(
					'SELECT `id`, `uid`, `user_login`, `user_role`, `time_visit`, `user_ip`, `page_visit`, `page_from`, `info` FROM %s WHERE `id` IN (%s)',
					$wpdb->prefix . 'swm_visitors',
					$placeholders
				);

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$result = $wpdb->get_results(
					$wpdb->prepare(
						// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
						$query,
						$ids
					),
					ARRAY_A
				);

				if ( ! empty( $result ) ) {
					$this->swm_create_report( $result );
					$report_url = plugins_url( 'report/report.html', __FILE__ );
					wp_send_json_success(
						array(
							'redirect_url' => $report_url,
							'html'         => '',
							'popup'        => '',
						)
					);
				}
				wp_send_json_error( array( 'message' => 'Нет данных для генерации отчета.' ) );
				break;

			case 'clear':
				// 1. Подготовка и выполнение SELECT-запроса
				$query_select = sprintf(
					'SELECT `user_ip` FROM %s WHERE `id` IN (%s)',
					$wpdb->prefix . 'swm_visitors',
					$placeholders
				);

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$visitors = $wpdb->get_results(
					$wpdb->prepare(
						// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Запрос собран динамически для оператора IN.
						$query_select,
						$ids
					),
					ARRAY_A
				);

				// 2. Подготовка и выполнение UPDATE-запроса
				$query = sprintf(
					'UPDATE %s SET `black_list` = NULL WHERE `id` IN (%s)',
					$wpdb->prefix . 'swm_visitors',
					$placeholders
				);

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$count_clear = $wpdb->query(
					$wpdb->prepare(
						// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Запрос собран динамически для оператора IN.
						$query,
						$ids
					)
				);
				$affected_rows = ( false === $count_clear ) ? 0 : $count_clear;

				if ( $count_clear && ! empty( $visitors ) ) {
					foreach ( $visitors as $visitor ) {
						$raw_ip = ! empty( $visitor['user_ip'] ) ? $visitor['user_ip'] : '';
						if ( ! empty( $raw_ip ) ) {
							$parts   = preg_split( '/[\r\n<]+/', $raw_ip );
							$user_ip = is_array( $parts ) && isset( $parts[0] ) ? trim( wp_strip_all_tags( $parts[0] ) ) : '';
							if ( filter_var( $user_ip, FILTER_VALIDATE_IP ) ) {
								swm_ip_del_from_file( $user_ip );
							}
						}
					}
				}
				break;
		}

		return array(
			'action'        => $action,
			'sent_count'    => count( $ids ),
			'affected_rows' => $affected_rows,
		);
	}

	/**
	 * Description: Create report file of swm_visitors table.
	 *
	 * @param  [type] $result [description].
	 */
	/**
	 * Создание HTML-отчета на основе выбранных строк из базы данных.
	 *
	 * @param array $result Массив строк из таблицы swm_visitors.
	 * @return void
	 */
	public function swm_create_report( $result ) {
		// Подключаем файлы ядра WP для работы с диском (критично для AJAX).
		require_once ABSPATH . 'wp-admin/includes/file.php';

		// Инициализируем внутреннюю файловую систему WordPress.
		if ( ! function_exists( 'WP_Filesystem' ) || ! WP_Filesystem() ) {
			return;
		}

		global $wp_filesystem;

		// Формируем абсолютный путь к папке отчетов.
		$path = wp_normalize_path( WP_PLUGIN_DIR . '/sitewatchman/report' );

		// Создаем директорию через объект WP_Filesystem, если её нет.
		if ( ! $wp_filesystem->is_dir( $path ) ) {
			$wp_filesystem->mkdir( $path, FS_CHMOD_DIR );
		}

		// Жестко определяем список выводимых ключей и их порядок, соответствующий шапке.
		$allowed_columns = array(
			'id',
			'uid',
			'user_login',
			'user_role',
			'time_visit',
			'user_ip',
			'page_visit',
			'page_from',
			'info',
		);

		$tbody = '';
		foreach ( $result as $tr_value ) {
			// Преобразуем строку в массив, если прилетел объект wpdb.
			$row_data = (array) $tr_value;
			$td       = '';
			$i        = 0;

			foreach ( $allowed_columns as $key ) {
				$td_value = isset( $row_data[ $key ] ) ? $row_data[ $key ] : '';

				// ОСОБАЯ ОБРАБОТКА КОЛОНКИ INFO: склеиваем её с black_list данными нарушения.
				if ( 'info' === $key ) {
					$info_output = $td_value; // Исходная строка (failed/unlogged + User-Agent).

					// Если у этой строки есть запись в black_list — парсим JSON и добавляем в отчет.
					if ( ! empty( $row_data['black_list'] ) ) {
						$black_list = json_decode( $row_data['black_list'], true );
						if ( is_array( $black_list ) ) {
							$ban_details = '<br><span style="color:red; font-weight:bold;">[BANNED]</span><br>';
							if ( ! empty( $black_list['ban_message'] ) ) {
								$ban_details .= 'Reason: ' . esc_html( $black_list['ban_message'] ) . '<br>';
							}
							if ( ! empty( $black_list['ban_start_date'] ) ) {
								$ban_details .= 'Start: ' . esc_html( $black_list['ban_start_date'] ) . '<br>';
							}
							if ( ! empty( $black_list['ban_end_date'] ) ) {
								$ban_details .= 'End: ' . esc_html( $black_list['ban_end_date'] ) . '<br>';
							}
							$info_output .= $ban_details;
						}
					}
					$td_value = $info_output;
				}

				$td .= "<td class='td$i'>" . $td_value . '</td>';
				++$i;
			}

			$tr    = '<tr>' . $td . '</tr>';
			$tbody = $tbody . $tr;
		}

		$tbody = '<tbody>' . $tbody . '</tbody>';

		$table = "
	  <table>
	  <thead>
	  <tr>
	  <th class='td0'>№</th>
	  <th class='td1'>UID</th>
	  <th class='td2'>Login</th>
	  <th class='td3'>Role</th>
	  <th class='td4'>Date visit</th>
	  <th class='td5'>Visitor IP</th>
	  <th class='td6'>Page visit</th>
	  <th class='td7'>Page from</th>
	  <th class='td8'>Info</th>
	  </tr>
	  </thead>" . $tbody . '</table>';

		$copyright = 'Belarus, Minsk © 2026. Developer: Oleg Klenitsky';
		$foot      = "<footer><img src='" . plugins_url( 'images/flags/BY.gif', __FILE__ ) . "'>" . $copyright . '</footer>';

		$template = "
	  <!doctype html>
	  <html lang='en'>
	  <head>
	    <meta charset='utf-8' />
	    <meta name='viewport' content='width=device-width, initial-scale=1' />
	    <title>Report of SiteWatchman: visitors of site</title>
	    <style type='text/css'>
	      table { table-layout: fixed; width: 100%; border: 1px solid black; border-collapse: collapse; }
	      th { text-align: center; padding: 5px; background-color: #E0E0E0; border: 1px solid black; }
	      td { text-align: left; word-wrap:break-word; padding: 5px; border: 1px solid black; }
	      .td0, .td1 { width: 5%; }
	      .td2, .td3, .td4, .td5 { width: 10%; }
	      .td6, .td7 { width: 15%; }
	      .td8 { width: 20%; }
	      .successful { font-weight: bold; color: green; }
	      .failed{ font-weight:bold; color:red; }
	      .robot{ font-weight:bold; color:black; }
	      .unlogged{ font-weight:bold; color:blue; }
	    </style>
	  </head>
	  <body>
	  <h1>Report of SiteWatchman: visitors of site (" . current_time( 'mysql' ) . ')</h1>' . $table . $foot . '</body></html>';

		// Записываем файл через put_contents файловой системы WP.
		$file_path = $path . '/report.html';
		$wp_filesystem->put_contents( $file_path, $template, FS_CHMOD_FILE );
	}
}