diff --git a/ansico-stat-plugin/ansico-stat-plugin.php b/ansico-stat-plugin/ansico-stat-plugin.php index 97b1abe..7de918d 100644 --- a/ansico-stat-plugin/ansico-stat-plugin.php +++ b/ansico-stat-plugin/ansico-stat-plugin.php @@ -3,7 +3,7 @@ * Plugin Name: Ansico Stat Plugin * Plugin URI: https://ansico.dk/Ansico/Ansico-Stat-plugin * Description: Simple WP plugin with basic website statistics. - * Version: 1.1.0.1 + * Version: 1.1.1 * Author: Andreas Andersen (Ansico) * Author URI: https://ansico.dk * Text Domain: ansico-stat-plugin @@ -19,7 +19,7 @@ if (!defined('ABSPATH')) { if (!class_exists('Ansico_Stat_Plugin')) { class Ansico_Stat_Plugin { - const VERSION = '1.1.0.1'; + const VERSION = '1.1.1'; const OPTION_KEY = 'ansico_stat_plugin_settings'; const INSTALL_OPTION_KEY = 'ansico_stat_plugin_install_date'; const TOTAL_META_KEY = 'post_views_count'; @@ -30,7 +30,11 @@ if (!class_exists('Ansico_Stat_Plugin')) { const MENU_SLUG_STATS = 'ansico-stat-plugin'; const MENU_SLUG_YEARLY = 'ansico-stat-plugin-yearly'; const MENU_SLUG_SINGLE = 'ansico-stat-plugin-single'; + const MENU_SLUG_LIFETIME = 'ansico-stat-plugin-lifetime'; const COOKIE_TTL_MESSAGE = 1800; + const VISITOR_COOKIE_NAME = 'ansico_stat_visitor_id'; + const VISITOR_COOKIE_TTL = 34560000; // 400 days + const SESSION_COOKIE_NAME = 'ansico_stat_session'; public function __construct() { register_activation_hook(__FILE__, [__CLASS__, 'activate']); @@ -50,6 +54,13 @@ if (!class_exists('Ansico_Stat_Plugin')) { add_action('admin_post_ansico_stat_export_single_csv', [$this, 'handle_export_single_csv']); add_action('admin_post_ansico_stat_reset_all', [$this, 'handle_reset_all_views']); add_action('admin_post_ansico_stat_save_settings', [$this, 'handle_save_settings']); + add_action('admin_post_ansico_stat_google_connect', [$this, 'handle_google_connect']); + add_action('admin_post_ansico_stat_google_disconnect', [$this, 'handle_google_disconnect']); + add_action('admin_post_ansico_stat_google_oauth_callback', [$this, 'handle_google_oauth_callback']); + add_action('admin_post_ansico_stat_google_generate_verification_token', [$this, 'handle_google_generate_verification_token']); + add_action('admin_post_ansico_stat_google_verify_site', [$this, 'handle_google_verify_site']); + add_action('admin_post_ansico_stat_google_sync_month', [$this, 'handle_google_sync_month']); + add_action('wp_head', [$this, 'render_google_site_verification_meta_tag'], 1); add_action('admin_init', [$this, 'register_admin_columns']); add_action('restrict_manage_posts', [$this, 'render_admin_view_filter']); @@ -67,6 +78,13 @@ if (!class_exists('Ansico_Stat_Plugin')) { $page_daily_table = self::page_daily_table_name(); $referral_table = self::referral_table_name(); $dimension_table = self::dimension_table_name(); + $landing_table = self::landing_table_name(); + $unique_total_table = self::unique_total_table_name(); + $unique_landing_table = self::unique_landing_table_name(); + $unique_page_table = self::unique_page_table_name(); + $unique_referral_table = self::unique_referral_table_name(); + $unique_dimension_table = self::unique_dimension_table_name(); + $search_query_table = self::search_query_table_name(); $sql_daily = "CREATE TABLE {$daily_table} ( stat_date date NOT NULL, @@ -106,12 +124,16 @@ if (!class_exists('Ansico_Stat_Plugin')) { category varchar(32) NOT NULL, source_url text NULL, source_host varchar(191) NULL, + landing_page_key varchar(191) NULL, + landing_page_label varchar(255) NULL, + landing_page_url text NULL, visits bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), - UNIQUE KEY unique_referral (stat_date, category, source_host(191), source_url(191)), + UNIQUE KEY unique_referral (stat_date, category, source_host(191), source_url(191), landing_page_key), KEY stat_date (stat_date), KEY category (category), - KEY source_host (source_host) + KEY source_host (source_host), + KEY landing_page_key (landing_page_key(191)) ) {$charset_collate};"; $sql_dimensions = "CREATE TABLE {$dimension_table} ( @@ -124,11 +146,103 @@ if (!class_exists('Ansico_Stat_Plugin')) { KEY views (views) ) {$charset_collate};"; + $sql_landing = "CREATE TABLE {$landing_table} ( + stat_date date NOT NULL, + page_key varchar(191) NOT NULL, + page_type varchar(32) NOT NULL, + object_id bigint(20) unsigned NULL DEFAULT NULL, + page_label varchar(255) NOT NULL, + page_url text NULL, + visits bigint(20) unsigned NOT NULL DEFAULT 0, + PRIMARY KEY (stat_date, page_key), + KEY stat_date (stat_date), + KEY page_type (page_type), + KEY object_id (object_id), + KEY visits (visits) + ) {$charset_collate};"; + + $sql_unique_total = "CREATE TABLE {$unique_total_table} ( + period_type varchar(10) NOT NULL, + period_key varchar(16) NOT NULL, + visitor_hash varchar(64) NOT NULL, + first_seen datetime NOT NULL, + PRIMARY KEY (period_type, period_key, visitor_hash), + KEY period_lookup (period_type, period_key) + ) {$charset_collate};"; + + $sql_unique_page = "CREATE TABLE {$unique_page_table} ( + period_type varchar(10) NOT NULL, + period_key varchar(16) NOT NULL, + page_key varchar(191) NOT NULL, + visitor_hash varchar(64) NOT NULL, + first_seen datetime NOT NULL, + PRIMARY KEY (period_type, period_key, page_key, visitor_hash), + KEY period_page_lookup (period_type, period_key, page_key) + ) {$charset_collate};"; + + $sql_unique_landing = "CREATE TABLE {$unique_landing_table} ( + period_type varchar(10) NOT NULL, + period_key varchar(16) NOT NULL, + page_key varchar(191) NOT NULL, + visitor_hash varchar(64) NOT NULL, + first_seen datetime NOT NULL, + PRIMARY KEY (period_type, period_key, page_key, visitor_hash), + KEY period_landing_lookup (period_type, period_key, page_key) + ) {$charset_collate};"; + + $sql_unique_referral = "CREATE TABLE {$unique_referral_table} ( + period_type varchar(10) NOT NULL, + period_key varchar(16) NOT NULL, + category varchar(32) NOT NULL, + source_host varchar(191) NOT NULL DEFAULT '', + source_url varchar(191) NOT NULL DEFAULT '', + landing_page_key varchar(191) NOT NULL DEFAULT '', + visitor_hash varchar(64) NOT NULL, + first_seen datetime NOT NULL, + PRIMARY KEY (period_type, period_key, category, source_host, source_url, landing_page_key, visitor_hash), + KEY period_referral_lookup (period_type, period_key, category) + ) {$charset_collate};"; + + $sql_unique_dimension = "CREATE TABLE {$unique_dimension_table} ( + period_type varchar(10) NOT NULL, + period_key varchar(16) NOT NULL, + dimension_type varchar(32) NOT NULL, + dimension_value varchar(191) NOT NULL, + visitor_hash varchar(64) NOT NULL, + first_seen datetime NOT NULL, + PRIMARY KEY (period_type, period_key, dimension_type, dimension_value, visitor_hash), + KEY period_dimension_lookup (period_type, period_key, dimension_type, dimension_value) + ) {$charset_collate};"; + + $sql_search_queries = "CREATE TABLE {$search_query_table} ( + month_key char(7) NOT NULL, + property_url varchar(255) NOT NULL, + query_text varchar(255) NOT NULL, + clicks bigint(20) unsigned NOT NULL DEFAULT 0, + impressions bigint(20) unsigned NOT NULL DEFAULT 0, + ctr decimal(8,6) NOT NULL DEFAULT 0, + position decimal(10,4) NOT NULL DEFAULT 0, + updated_at datetime NOT NULL, + PRIMARY KEY (month_key, property_url, query_text), + KEY property_month_lookup (property_url, month_key), + KEY clicks (clicks), + KEY impressions (impressions) + ) {$charset_collate};"; + dbDelta($sql_daily); dbDelta($sql_post_daily); dbDelta($sql_page_daily); dbDelta($sql_referral); dbDelta($sql_dimensions); + dbDelta($sql_landing); + dbDelta($sql_unique_total); + dbDelta($sql_unique_page); + dbDelta($sql_unique_landing); + dbDelta($sql_unique_referral); + dbDelta($sql_unique_dimension); + dbDelta($sql_search_queries); + + self::ensure_referral_table_schema($referral_table, $charset_collate); if (!get_option(self::INSTALL_OPTION_KEY)) { update_option(self::INSTALL_OPTION_KEY, current_time('Y-m-d'), false); @@ -140,6 +254,68 @@ if (!class_exists('Ansico_Stat_Plugin')) { // Keep data on deactivation. } + protected static function ensure_referral_table_schema($referral_table, $charset_collate) { + global $wpdb; + + $columns = $wpdb->get_col("SHOW COLUMNS FROM {$referral_table}", 0); + if (!is_array($columns)) { + return; + } + + if (!in_array('landing_page_key', $columns, true)) { + $wpdb->query("ALTER TABLE {$referral_table} ADD COLUMN landing_page_key varchar(191) NULL AFTER source_host"); + } + + if (!in_array('landing_page_label', $columns, true)) { + $wpdb->query("ALTER TABLE {$referral_table} ADD COLUMN landing_page_label varchar(255) NULL AFTER landing_page_key"); + } + + if (!in_array('landing_page_url', $columns, true)) { + $wpdb->query("ALTER TABLE {$referral_table} ADD COLUMN landing_page_url text NULL AFTER landing_page_label"); + } + + $indexes = $wpdb->get_results("SHOW INDEX FROM {$referral_table}", ARRAY_A); + $has_landing_index = false; + $has_unique_referral = false; + + if (is_array($indexes)) { + foreach ($indexes as $index) { + $key_name = (string) ($index['Key_name'] ?? ''); + $column_name = (string) ($index['Column_name'] ?? ''); + + if ($key_name === 'unique_referral') { + $has_unique_referral = true; + if ($column_name === 'landing_page_key') { + $has_landing_index = true; + } + } + } + } + + if ($has_unique_referral && !$has_landing_index) { + $wpdb->query("ALTER TABLE {$referral_table} DROP INDEX unique_referral"); + $has_unique_referral = false; + } + + if (!$has_unique_referral) { + $wpdb->query("ALTER TABLE {$referral_table} ADD UNIQUE KEY unique_referral (stat_date, category, source_host(191), source_url(191), landing_page_key)"); + } + + $landing_key_index_exists = false; + if (is_array($indexes)) { + foreach ($indexes as $index) { + if ((string) ($index['Key_name'] ?? '') === 'landing_page_key') { + $landing_key_index_exists = true; + break; + } + } + } + + if (!$landing_key_index_exists) { + $wpdb->query("ALTER TABLE {$referral_table} ADD KEY landing_page_key (landing_page_key(191))"); + } + } + public function maybe_upgrade_schema() { $stored_version = (string) get_option(self::SCHEMA_VERSION_OPTION_KEY, ''); if ($stored_version === self::VERSION) { @@ -179,6 +355,41 @@ if (!class_exists('Ansico_Stat_Plugin')) { return $wpdb->prefix . 'ansico_stat_dimensions'; } + public static function landing_table_name() { + global $wpdb; + return $wpdb->prefix . 'ansico_stat_landing_daily'; + } + + public static function unique_total_table_name() { + global $wpdb; + return $wpdb->prefix . 'ansico_stat_unique_total'; + } + + public static function unique_page_table_name() { + global $wpdb; + return $wpdb->prefix . 'ansico_stat_unique_page'; + } + + public static function unique_landing_table_name() { + global $wpdb; + return $wpdb->prefix . 'ansico_stat_unique_landing'; + } + + public static function unique_referral_table_name() { + global $wpdb; + return $wpdb->prefix . 'ansico_stat_unique_referral'; + } + + public static function unique_dimension_table_name() { + global $wpdb; + return $wpdb->prefix . 'ansico_stat_unique_dimension'; + } + + public static function search_query_table_name() { + global $wpdb; + return $wpdb->prefix . 'ansico_stat_search_queries'; + } + protected function get_default_track_post_types() { $post_types = get_post_types(['public' => true], 'names'); $post_types = array_filter($post_types, function($post_type) { @@ -194,18 +405,29 @@ if (!class_exists('Ansico_Stat_Plugin')) { } $settings = wp_parse_args($settings, [ - 'visitor_counting_mode' => 'count_all', - 'exclude_known_bots' => 1, - 'top_list_rows' => 10, - 'referral_rows' => 25, - 'revisit_minutes' => 30, - 'frontend_label' => 'Views', - 'track_post_types' => $this->get_default_track_post_types(), - 'track_front_page' => 1, - 'track_posts_page' => 1, - 'track_archives' => 1, - 'track_search' => 1, - 'track_404' => 1, + 'visitor_counting_mode' => 'count_all', + 'exclude_known_bots' => 1, + 'top_list_rows' => 10, + 'referral_rows' => 0, + 'revisit_minutes' => 30, + 'frontend_label' => 'Views', + 'track_post_types' => $this->get_default_track_post_types(), + 'track_front_page' => 1, + 'track_posts_page' => 1, + 'track_archives' => 1, + 'track_search' => 1, + 'track_404' => 1, + 'google_client_id' => '', + 'google_client_secret' => '', + 'google_property' => '', + 'google_access_token' => '', + 'google_refresh_token' => '', + 'google_token_expires_at' => 0, + 'google_connected_email' => '', + 'google_site_verification_token' => '', + 'google_site_verification_method' => 'META', + 'google_last_sync_month' => '', + 'ipinfo_token' => '', ]); if (!is_array($settings['track_post_types'])) { @@ -220,6 +442,21 @@ if (!class_exists('Ansico_Stat_Plugin')) { $settings['frontend_label'] = 'Views'; } + $settings['google_client_id'] = sanitize_text_field((string) $settings['google_client_id']); + $settings['google_client_secret'] = sanitize_text_field((string) $settings['google_client_secret']); + $settings['google_property'] = sanitize_text_field((string) $settings['google_property']); + $settings['google_access_token'] = sanitize_text_field((string) $settings['google_access_token']); + $settings['google_refresh_token'] = sanitize_text_field((string) $settings['google_refresh_token']); + $settings['google_token_expires_at'] = max(0, (int) $settings['google_token_expires_at']); + $settings['google_connected_email'] = sanitize_email((string) $settings['google_connected_email']); + $settings['google_site_verification_token'] = sanitize_text_field((string) $settings['google_site_verification_token']); + $settings['google_site_verification_method'] = strtoupper(sanitize_key((string) $settings['google_site_verification_method'])); + if (!in_array($settings['google_site_verification_method'], ['META', 'DNS_TXT'], true)) { + $settings['google_site_verification_method'] = 'META'; + } + $settings['google_last_sync_month'] = sanitize_text_field((string) $settings['google_last_sync_month']); + $settings['ipinfo_token'] = sanitize_text_field((string) $settings['ipinfo_token']); + return $settings; } @@ -275,6 +512,84 @@ if (!class_exists('Ansico_Stat_Plugin')) { return ''; } + protected function get_client_ip_address() { + $keys = [ + 'HTTP_CF_CONNECTING_IP', + 'HTTP_X_FORWARDED_FOR', + 'HTTP_CLIENT_IP', + 'REMOTE_ADDR', + ]; + + foreach ($keys as $key) { + if (empty($_SERVER[$key])) { + continue; + } + + $raw_value = sanitize_text_field((string) wp_unslash($_SERVER[$key])); + $parts = array_map('trim', explode(',', $raw_value)); + foreach ($parts as $part) { + $ip = filter_var($part, FILTER_VALIDATE_IP); + if ($ip) { + return $ip; + } + } + } + + return ''; + } + + protected function lookup_country_code_via_ipinfo(string $ip_address) { + $settings = $this->get_settings(); + $token = trim((string) ($settings['ipinfo_token'] ?? '')); + if ($token === '' || $ip_address === '' || !filter_var($ip_address, FILTER_VALIDATE_IP)) { + return ''; + } + + if (filter_var($ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return ''; + } + + $cache_key = 'ansico_country_' . md5($ip_address); + $cached = get_transient($cache_key); + if (is_string($cached) && preg_match('/^[A-Z]{2}$/', $cached)) { + return $cached; + } + + $response = wp_remote_get( + 'https://api.ipinfo.io/lite/' . rawurlencode($ip_address) . '?token=' . rawurlencode($token), + [ + 'timeout' => 5, + 'user-agent' => 'Ansico Stat Plugin/' . self::VERSION . '; ' . home_url('/'), + 'headers' => [ + 'Accept' => 'application/json', + ], + ] + ); + + if (is_wp_error($response)) { + return ''; + } + + $code = (int) wp_remote_retrieve_response_code($response); + if ($code < 200 || $code >= 300) { + return ''; + } + + $data = json_decode((string) wp_remote_retrieve_body($response), true); + if (!is_array($data)) { + return ''; + } + + $country_code = strtoupper(sanitize_text_field((string) ($data['country_code'] ?? ''))); + if (!preg_match('/^[A-Z]{2}$/', $country_code)) { + return ''; + } + + set_transient($cache_key, $country_code, DAY_IN_SECONDS * 30); + + return $country_code; + } + protected function get_country_code() { $candidates = [ $_SERVER['HTTP_CF_IPCOUNTRY'] ?? '', @@ -290,23 +605,51 @@ if (!class_exists('Ansico_Stat_Plugin')) { } } + $ipinfo_code = $this->lookup_country_code_via_ipinfo($this->get_client_ip_address()); + if ($ipinfo_code !== '') { + return $ipinfo_code; + } + return 'Unknown'; } + protected function get_country_flag_emoji(string $country_code) { + $country_code = strtoupper(trim($country_code)); + if (!preg_match('/^[A-Z]{2}$/', $country_code)) { + return ''; + } + + $base = 127397; + $first = mb_chr($base + ord($country_code[0]), 'UTF-8'); + $second = mb_chr($base + ord($country_code[1]), 'UTF-8'); + + if (!is_string($first) || !is_string($second)) { + return ''; + } + + return $first . $second; + } + protected function get_country_label(string $country_code) { $country_code = strtoupper(trim($country_code)); if ($country_code === '' || $country_code === 'UNKNOWN') { return __('Unknown', 'ansico-stat-plugin'); } + $label = ''; if (function_exists('locale_get_display_region')) { $label = locale_get_display_region('-' . $country_code, get_locale()); - if (is_string($label) && $label !== '') { - return $label; + if (!is_string($label)) { + $label = ''; } } - return $country_code; + if ($label === '') { + $label = $country_code; + } + + $flag = $this->get_country_flag_emoji($country_code); + return $flag !== '' ? $flag . ' ' . $label : $label; } protected function get_device_type() { @@ -378,10 +721,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { return 'Other'; } - protected function increment_dimension_views(string $today) { - global $wpdb; - $table = self::dimension_table_name(); - + protected function get_current_dimension_values() { $dimensions = [ 'country' => $this->get_country_code(), 'device' => $this->get_device_type(), @@ -389,6 +729,191 @@ if (!class_exists('Ansico_Stat_Plugin')) { 'os' => $this->get_os_name(), ]; + if (is_search()) { + $search_query = trim((string) get_search_query()); + $dimensions['internal_search'] = $search_query !== '' ? $search_query : __('(empty search)', 'ansico-stat-plugin'); + } + + return $dimensions; + } + + protected function get_period_keys(string $today) { + return [ + 'day' => $today, + 'month' => substr($today, 0, 7), + 'year' => substr($today, 0, 4), + ]; + } + + protected function get_or_create_visitor_hash() { + $cookie_name = self::VISITOR_COOKIE_NAME; + $existing = isset($_COOKIE[$cookie_name]) ? sanitize_text_field((string) wp_unslash($_COOKIE[$cookie_name])) : ''; + if (preg_match('/^[A-Za-z0-9_-]{12,64}$/', $existing)) { + return $existing; + } + + $visitor_hash = wp_generate_uuid4(); + $visitor_hash = str_replace('-', '', $visitor_hash); + if (!headers_sent()) { + setcookie($cookie_name, $visitor_hash, time() + self::VISITOR_COOKIE_TTL, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true); + $_COOKIE[$cookie_name] = $visitor_hash; + } + + return $visitor_hash; + } + + protected function is_new_session_visit() { + $cookie_name = self::SESSION_COOKIE_NAME; + $existing = isset($_COOKIE[$cookie_name]) ? sanitize_text_field((string) wp_unslash($_COOKIE[$cookie_name])) : ''; + $is_new = ($existing === ''); + if (!headers_sent()) { + setcookie($cookie_name, '1', time() + $this->get_cookie_ttl(), COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true); + $_COOKIE[$cookie_name] = '1'; + } + return $is_new; + } + + protected function register_landing_visit(string $today, string $visitor_hash, array $page_context) { + global $wpdb; + + $page_key = substr(sanitize_text_field((string) ($page_context['page_key'] ?? '')), 0, 191); + if ($page_key === '') { + return; + } + + $landing_table = self::landing_table_name(); + $page_type = substr(sanitize_key((string) ($page_context['page_type'] ?? 'archive')), 0, 32); + $object_id = isset($page_context['object_id']) ? (int) $page_context['object_id'] : 0; + $page_label = sanitize_text_field((string) ($page_context['label'] ?? '')); + $page_url = isset($page_context['url']) ? esc_url_raw((string) $page_context['url']) : ''; + + $wpdb->query($wpdb->prepare( + "INSERT INTO {$landing_table} (stat_date, page_key, page_type, object_id, page_label, page_url, visits) + VALUES (%s, %s, %s, %d, %s, %s, 1) + ON DUPLICATE KEY UPDATE visits = visits + 1, page_label = VALUES(page_label), page_url = VALUES(page_url)", + $today, + $page_key, + $page_type, + $object_id, + $page_label, + $page_url + )); + + if ($visitor_hash === '') { + return; + } + + $unique_landing_table = self::unique_landing_table_name(); + $timestamp = current_time('mysql'); + foreach ($this->get_period_keys($today) as $period_type => $period_key) { + $wpdb->query($wpdb->prepare( + "INSERT IGNORE INTO {$unique_landing_table} (period_type, period_key, page_key, visitor_hash, first_seen) VALUES (%s, %s, %s, %s, %s)", + $period_type, + $period_key, + $page_key, + $visitor_hash, + $timestamp + )); + } + } + + protected function register_unique_visitor(string $today, string $visitor_hash, array $page_context, array $referer_data, array $dimensions) { + global $wpdb; + + if ($visitor_hash === '' || empty($page_context['page_key'])) { + return; + } + + $periods = $this->get_period_keys($today); + $timestamp = current_time('mysql'); + $page_key = substr(sanitize_text_field((string) $page_context['page_key']), 0, 191); + $category = substr(sanitize_key((string) ($referer_data['category'] ?? 'direct')), 0, 32); + $source_host = substr(sanitize_text_field((string) ($referer_data['source_host'] ?? '')), 0, 191); + $source_url = substr(esc_url_raw((string) ($referer_data['source_url'] ?? '')), 0, 191); + $landing_page_key = substr(sanitize_text_field((string) ($page_context['page_key'] ?? '')), 0, 191); + + $unique_total_table = self::unique_total_table_name(); + $unique_page_table = self::unique_page_table_name(); + $unique_referral_table = self::unique_referral_table_name(); + $unique_dimension_table = self::unique_dimension_table_name(); + + foreach ($periods as $period_type => $period_key) { + $wpdb->query($wpdb->prepare( + "INSERT IGNORE INTO {$unique_total_table} (period_type, period_key, visitor_hash, first_seen) VALUES (%s, %s, %s, %s)", + $period_type, + $period_key, + $visitor_hash, + $timestamp + )); + + $wpdb->query($wpdb->prepare( + "INSERT IGNORE INTO {$unique_page_table} (period_type, period_key, page_key, visitor_hash, first_seen) VALUES (%s, %s, %s, %s, %s)", + $period_type, + $period_key, + $page_key, + $visitor_hash, + $timestamp + )); + + if ($period_type === 'day' && (int) $wpdb->rows_affected > 0) { + if (($page_context['kind'] ?? '') === 'singular' && !empty($page_context['post_id'])) { + $post_daily_table = self::post_daily_table_name(); + $wpdb->query($wpdb->prepare( + "UPDATE {$post_daily_table} SET unique_visitors = unique_visitors + 1 WHERE post_id = %d AND stat_date = %s", + (int) $page_context['post_id'], + $today + )); + } elseif (!empty($page_context['page_key'])) { + $page_daily_table = self::page_daily_table_name(); + $wpdb->query($wpdb->prepare( + "UPDATE {$page_daily_table} SET unique_visitors = unique_visitors + 1 WHERE page_key = %s AND stat_date = %s", + $page_key, + $today + )); + } + } + + $wpdb->query($wpdb->prepare( + "INSERT IGNORE INTO {$unique_referral_table} (period_type, period_key, category, source_host, source_url, landing_page_key, visitor_hash, first_seen) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", + $period_type, + $period_key, + $category, + $source_host, + $source_url, + $landing_page_key, + $visitor_hash, + $timestamp + )); + + foreach ($dimensions as $dimension_type => $dimension_value) { + $dimension_type = substr(sanitize_key((string) $dimension_type), 0, 32); + $dimension_value = substr(sanitize_text_field((string) $dimension_value), 0, 191); + if ($dimension_type === '') { + continue; + } + if ($dimension_value === '') { + $dimension_value = 'Unknown'; + } + + $wpdb->query($wpdb->prepare( + "INSERT IGNORE INTO {$unique_dimension_table} (period_type, period_key, dimension_type, dimension_value, visitor_hash, first_seen) VALUES (%s, %s, %s, %s, %s, %s)", + $period_type, + $period_key, + $dimension_type, + $dimension_value, + $visitor_hash, + $timestamp + )); + } + } + } + + protected function increment_dimension_views(string $today) { + global $wpdb; + $table = self::dimension_table_name(); + + $dimensions = $this->get_current_dimension_values(); + foreach ($dimensions as $dimension_type => $dimension_value) { $dimension_value = substr(sanitize_text_field((string) $dimension_value), 0, 191); if ($dimension_value === '') { @@ -450,6 +975,18 @@ if (!class_exists('Ansico_Stat_Plugin')) { return true; } + protected function get_landing_page_data(array $page_context) { + $landing_page_key = substr(sanitize_text_field((string) ($page_context['page_key'] ?? '')), 0, 191); + $landing_page_label = sanitize_text_field((string) ($page_context['label'] ?? '')); + $landing_page_url = isset($page_context['url']) ? esc_url_raw((string) $page_context['url']) : ''; + + return [ + 'landing_page_key' => $landing_page_key, + 'landing_page_label' => $landing_page_label, + 'landing_page_url' => $landing_page_url, + ]; + } + protected function get_referer_data() { $referer = isset($_SERVER['HTTP_REFERER']) ? trim((string) wp_unslash($_SERVER['HTTP_REFERER'])) : ''; $site_host = wp_parse_url(home_url(), PHP_URL_HOST); @@ -533,33 +1070,25 @@ if (!class_exists('Ansico_Stat_Plugin')) { return; } - $view_cookie_name = self::COOKIE_PREFIX . md5($page_context['cookie_key']); - $unique_cookie_name = self::COOKIE_PREFIX . 'unique_' . md5($page_context['cookie_key'] . '|' . current_time('Y-m-d')); - $is_unique_visitor = !isset($_COOKIE[$unique_cookie_name]); - - if (isset($_COOKIE[$view_cookie_name])) { + $cookie_name = self::COOKIE_PREFIX . md5($page_context['cookie_key']); + if (isset($_COOKIE[$cookie_name])) { return; } $referer_data = $this->get_referer_data(); + $visitor_hash = $this->get_or_create_visitor_hash(); + if ($this->is_new_session_visit()) { + $this->register_landing_visit(current_time('Y-m-d'), $visitor_hash, $page_context); + } if ($page_context['kind'] === 'singular') { - $this->increment_post_views((int) $page_context['post_id'], $referer_data, $is_unique_visitor); + $this->increment_post_views((int) $page_context['post_id'], $page_context, $referer_data, $visitor_hash); } else { - $this->increment_non_singular_views($page_context, $referer_data, $is_unique_visitor); + $this->increment_non_singular_views($page_context, $referer_data, $visitor_hash); } if (!headers_sent()) { - setcookie($view_cookie_name, '1', time() + $this->get_cookie_ttl(), COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true); - $_COOKIE[$view_cookie_name] = '1'; - - if ($is_unique_visitor) { - $expires = strtotime('tomorrow', current_time('timestamp')); - if ($expires <= time()) { - $expires = time() + DAY_IN_SECONDS; - } - setcookie($unique_cookie_name, '1', $expires, COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true); - $_COOKIE[$unique_cookie_name] = '1'; - } + setcookie($cookie_name, '1', time() + $this->get_cookie_ttl(), COOKIEPATH ?: '/', COOKIE_DOMAIN, is_ssl(), true); + $_COOKIE[$cookie_name] = '1'; } } @@ -698,7 +1227,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { return null; } - protected function increment_post_views(int $post_id, array $referer_data = [], bool $is_unique_visitor = false) { + protected function increment_post_views(int $post_id, array $page_context = [], array $referer_data = [], string $visitor_hash = '') { global $wpdb; $today = current_time('Y-m-d'); @@ -718,32 +1247,36 @@ if (!class_exists('Ansico_Stat_Plugin')) { )); $wpdb->query($wpdb->prepare( - "INSERT INTO {$post_daily_table} (post_id, stat_date, views, unique_visitors) - VALUES (%d, %s, 1, %d) - ON DUPLICATE KEY UPDATE views = views + 1, unique_visitors = unique_visitors + VALUES(unique_visitors)", + "INSERT INTO {$post_daily_table} (post_id, stat_date, views) + VALUES (%d, %s, 1) + ON DUPLICATE KEY UPDATE views = views + 1", $post_id, - $today, - $is_unique_visitor ? 1 : 0 + $today )); $category = isset($referer_data['category']) ? sanitize_key($referer_data['category']) : 'direct'; $source_url = isset($referer_data['source_url']) ? esc_url_raw((string) $referer_data['source_url']) : ''; $source_host = isset($referer_data['source_host']) ? sanitize_text_field((string) $referer_data['source_host']) : ''; + $landing_page_data = $this->get_landing_page_data($page_context); $wpdb->query($wpdb->prepare( - "INSERT INTO {$referral_table} (stat_date, category, source_url, source_host, visits) - VALUES (%s, %s, %s, %s, 1) - ON DUPLICATE KEY UPDATE visits = visits + 1", + "INSERT INTO {$referral_table} (stat_date, category, source_url, source_host, landing_page_key, landing_page_label, landing_page_url, visits) + VALUES (%s, %s, %s, %s, %s, %s, %s, 1) + ON DUPLICATE KEY UPDATE visits = visits + 1, landing_page_label = VALUES(landing_page_label), landing_page_url = VALUES(landing_page_url)", $today, $category, $source_url, - $source_host + $source_host, + $landing_page_data['landing_page_key'], + $landing_page_data['landing_page_label'], + $landing_page_data['landing_page_url'] )); + $this->register_unique_visitor($today, $visitor_hash, $page_context, $referer_data, $this->get_current_dimension_values()); $this->increment_dimension_views($today); } - protected function increment_non_singular_views(array $page_context, array $referer_data = [], bool $is_unique_visitor = false) { + protected function increment_non_singular_views(array $page_context, array $referer_data = [], string $visitor_hash = '') { global $wpdb; $today = current_time('Y-m-d'); @@ -765,32 +1298,36 @@ if (!class_exists('Ansico_Stat_Plugin')) { $url = isset($page_context['url']) ? esc_url_raw((string) $page_context['url']) : ''; $wpdb->query($wpdb->prepare( - "INSERT INTO {$page_daily_table} (page_key, page_type, object_id, page_label, page_url, stat_date, views, unique_visitors) - VALUES (%s, %s, %s, %s, %s, %s, 1, %d) - ON DUPLICATE KEY UPDATE views = views + 1, unique_visitors = unique_visitors + VALUES(unique_visitors), page_label = VALUES(page_label), page_url = VALUES(page_url)", + "INSERT INTO {$page_daily_table} (page_key, page_type, object_id, page_label, page_url, stat_date, views) + VALUES (%s, %s, %s, %s, %s, %s, 1) + ON DUPLICATE KEY UPDATE views = views + 1, page_label = VALUES(page_label), page_url = VALUES(page_url)", $page_key, $page_type, $object_id, $label, $url, - $today, - $is_unique_visitor ? 1 : 0 + $today )); $category = isset($referer_data['category']) ? sanitize_key($referer_data['category']) : 'direct'; $source_url = isset($referer_data['source_url']) ? esc_url_raw((string) $referer_data['source_url']) : ''; $source_host = isset($referer_data['source_host']) ? sanitize_text_field((string) $referer_data['source_host']) : ''; + $landing_page_data = $this->get_landing_page_data($page_context); $wpdb->query($wpdb->prepare( - "INSERT INTO {$referral_table} (stat_date, category, source_url, source_host, visits) - VALUES (%s, %s, %s, %s, 1) - ON DUPLICATE KEY UPDATE visits = visits + 1", + "INSERT INTO {$referral_table} (stat_date, category, source_url, source_host, landing_page_key, landing_page_label, landing_page_url, visits) + VALUES (%s, %s, %s, %s, %s, %s, %s, 1) + ON DUPLICATE KEY UPDATE visits = visits + 1, landing_page_label = VALUES(landing_page_label), landing_page_url = VALUES(landing_page_url)", $today, $category, $source_url, - $source_host + $source_host, + $landing_page_data['landing_page_key'], + $landing_page_data['landing_page_label'], + $landing_page_data['landing_page_url'] )); + $this->register_unique_visitor($today, $visitor_hash, $page_context, $referer_data, $this->get_current_dimension_values()); $this->increment_dimension_views($today); } @@ -940,6 +1477,15 @@ if (!class_exists('Ansico_Stat_Plugin')) { [$this, 'render_yearly_stats_page'] ); + add_submenu_page( + self::MENU_SLUG_STATS, + __('Lifetime statistics', 'ansico-stat-plugin'), + __('Lifetime statistics', 'ansico-stat-plugin'), + 'manage_options', + self::MENU_SLUG_LIFETIME, + [$this, 'render_lifetime_stats_page'] + ); + add_submenu_page( self::MENU_SLUG_STATS, __('Single page statistics', 'ansico-stat-plugin'), @@ -964,6 +1510,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { 'index.php', 'toplevel_page_' . self::MENU_SLUG_STATS, 'ansico-stat-plugin_page_' . self::MENU_SLUG_YEARLY, + 'ansico-stat-plugin_page_' . self::MENU_SLUG_LIFETIME, 'ansico-stat-plugin_page_' . self::MENU_SLUG_SINGLE, 'ansico-stat-plugin_page_' . self::MENU_SLUG_SETTINGS, ], true)) { @@ -976,12 +1523,20 @@ if (!class_exists('Ansico_Stat_Plugin')) { [], self::VERSION ); + + wp_enqueue_script( + 'ansico-stat-admin', + plugin_dir_url(__FILE__) . 'assets/js/ansico-stat-admin.js', + [], + self::VERSION, + true + ); } public function register_dashboard_widget() { wp_add_dashboard_widget( 'ansico_stat_dashboard_widget', - __('Ansico Stat Plugin: Daily Total Views', 'ansico-stat-plugin'), + __('Ansico Stat Plugin: Daily views and unique visitors', 'ansico-stat-plugin'), [$this, 'render_dashboard_widget'] ); } @@ -1007,11 +1562,58 @@ if (!class_exists('Ansico_Stat_Plugin')) { return '—'; } + return $this->format_url_display_link($url, $url); + } + + protected function get_truncated_url_text(string $text, int $limit = 100) { + $text = trim($text); + if ($text === '') { + return $text; + } + + if (function_exists('mb_strlen') && function_exists('mb_substr')) { + if (mb_strlen($text) <= $limit) { + return $text; + } + + return rtrim(mb_substr($text, 0, $limit)) . '...'; + } + + if (strlen($text) <= $limit) { + return $text; + } + + return rtrim(substr($text, 0, $limit)) . '...'; + } + + protected function format_url_display_link(string $url, string $label = '') { + $url = trim($url); + $label = trim($label); + + if ($url === '' && $label === '') { + return '—'; + } + + if ($label === '') { + $label = $url; + } + + $display_text = $this->get_truncated_url_text($label, 100); + $copy_label = esc_attr__('Copy URL', 'ansico-stat-plugin'); + $copied_label = esc_attr__('Copied', 'ansico-stat-plugin'); + + if ($url === '') { + return sprintf('%2$s', esc_attr($label), esc_html($display_text)); + } + return sprintf( - '%3$s', + '%3$s', esc_url($url), + esc_attr($label), + esc_html($display_text), esc_attr($url), - esc_html($url) + $copy_label, + $copied_label ); } @@ -1023,7 +1625,17 @@ if (!class_exists('Ansico_Stat_Plugin')) { echo '
'; echo '

' . esc_html(sprintf(__('Total tracked views in the last 30 days: %s', 'ansico-stat-plugin'), number_format_i18n($monthly_total))) . '

'; echo '
'; - echo $this->render_chart_markup($chart_data, 'ansico-stat-widget-chart', ['is_widget' => true]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo $this->render_multi_line_chart_markup($chart_data, 'ansico-stat-widget-chart', [ + 'aria_label' => __('Daily views and unique visitors (last 30 days)', 'ansico-stat-plugin'), + 'height' => 250, + 'width' => 920, + 'chart_top' => 12, + 'chart_bottom' => 198, + 'chart_left' => 44, + 'chart_right' => 888, + 'x_label_y' => 226, + 'max_x_labels' => 8, + ]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo '
'; echo '

' . esc_html__('Top 10 views in the last 30 days', 'ansico-stat-plugin') . '

'; @@ -1206,11 +1818,211 @@ if (!class_exists('Ansico_Stat_Plugin')) { return ob_get_clean(); } - protected function render_bar_chart_markup(array $chart_data, string $chart_id, string $aria_label = '') { + + protected function render_multi_line_chart_markup(array $chart_data, string $chart_id, array $args = []) { + $args = wp_parse_args($args, [ + 'label_key' => 'label', + 'x_fallback_key' => 'date', + 'views_label' => __('Views', 'ansico-stat-plugin'), + 'unique_label' => __('Unique visitors', 'ansico-stat-plugin'), + 'aria_label' => __('Views and unique visitors chart', 'ansico-stat-plugin'), + 'height' => 240, + 'width' => 860, + 'chart_top' => 18, + 'chart_bottom' => 190, + 'chart_left' => 34, + 'chart_right' => 830, + 'x_label_y' => 216, + 'tick_count' => 5, + 'max_x_labels' => 8, + ]); + + if (empty($chart_data)) { + return '

' . esc_html__('No tracked data for this period.', 'ansico-stat-plugin') . '

'; + } + + $labels = []; + $full_labels = []; + $views = []; + $unique = []; + foreach ($chart_data as $row) { + $label = ''; + if (!empty($row[$args['label_key']])) { + $label = (string) $row[$args['label_key']]; + } elseif (!empty($row[$args['x_fallback_key']])) { + $label = (string) $row[$args['x_fallback_key']]; + } + $full_label = ''; + if (!empty($row['tooltip_label'])) { + $full_label = (string) $row['tooltip_label']; + } elseif (!empty($row[$args['x_fallback_key']])) { + $full_label = (string) $row[$args['x_fallback_key']]; + } else { + $full_label = $label; + } + $labels[] = $label; + $full_labels[] = $full_label; + $views[] = (int) ($row['views'] ?? $row['total_views'] ?? 0); + $unique[] = (int) ($row['unique_visitors'] ?? $row['total_unique'] ?? 0); + } + + $max = max(array_merge($views, $unique, [0])); + if ($max <= 0) { + return '

' . esc_html__('No tracked data for this period.', 'ansico-stat-plugin') . '

'; + } + + $height = (int) $args['height']; + $width = (int) $args['width']; + $chart_top = (float) $args['chart_top']; + $chart_bottom = (float) $args['chart_bottom']; + $chart_left = (float) $args['chart_left']; + $chart_right = (float) $args['chart_right']; + $count = count($labels); + $step_x = $count > 1 ? ($chart_right - $chart_left) / ($count - 1) : 0; + + $build_series = function(array $values, string $series_key, string $value_label) use ($count, $labels, $full_labels, $step_x, $chart_left, $chart_right, $chart_bottom, $chart_top, $max) { + $poly_points = []; + $circles = []; + foreach ($values as $index => $value) { + $x = $count > 1 ? $chart_left + ($step_x * $index) : (($chart_left + $chart_right) / 2); + $y = $chart_bottom; + if ($max > 0) { + $y = $chart_bottom - (($value / $max) * ($chart_bottom - $chart_top)); + } + $x = round($x, 2); + $y = round($y, 2); + $poly_points[] = $x . ',' . $y; + $circles[] = [ + 'x' => $x, + 'y' => $y, + 'label' => (string) ($labels[$index] ?? ''), + 'tooltip_label' => (string) ($full_labels[$index] ?? ($labels[$index] ?? '')), + 'value' => (int) $value, + 'series_key' => $series_key, + 'series_label' => $value_label, + ]; + } + return [ + 'points' => implode(' ', $poly_points), + 'circles' => $circles, + ]; + }; + + $view_series = $build_series($views, 'views', (string) $args['views_label']); + $unique_series = $build_series($unique, 'unique', (string) $args['unique_label']); + + $y_ticks = []; + $tick_count = max(2, (int) $args['tick_count']); + for ($i = 0; $i < $tick_count; $i++) { + $ratio = $tick_count > 1 ? $i / ($tick_count - 1) : 0; + $value = (int) round($max * (1 - $ratio)); + $y = $chart_top + (($chart_bottom - $chart_top) * $ratio); + $y_ticks[] = [ + 'value' => $value, + 'y' => round($y, 2), + ]; + } + + ob_start(); + ?> +
+
+
+ + + + + + + + + + + + + + + + + + + $label) : + if ($index % $label_every !== 0 && $index !== ($count - 1)) { + continue; + } + $x = $count > 1 ? $chart_left + ($step_x * $index) : (($chart_left + $chart_right) / 2); + ?> + + + +
+ +
+ + 'vertical', + ]); + + $orientation = $args['orientation'] === 'horizontal' ? 'horizontal' : 'vertical'; + $values = []; foreach ($chart_data as $row) { $values[] = (int) ($row['views'] ?? $row['total_views'] ?? 0); @@ -1218,26 +2030,62 @@ if (!class_exists('Ansico_Stat_Plugin')) { $max = max($values ?: [0]); $sum = array_sum($values); + $item_count = count($chart_data); + $slot_width = 52; + $bar_width = 28; + $row_height = 26; + + if ($item_count >= 10) { + $slot_width = 40; + $bar_width = 18; + $row_height = 22; + } elseif ($item_count >= 8) { + $slot_width = 44; + $bar_width = 20; + $row_height = 24; + } elseif ($item_count >= 6) { + $slot_width = 48; + $bar_width = 24; + $row_height = 25; + } ob_start(); ?> -
+

-
- 0 && $value > 0) ? max(6, (int) round(($value / $max) * 140)) : 0; - ?> -
-
-
-
-
- -
+ +
+ 0 && $value > 0) ? max(2, (int) round(($value / $max) * 100)) : 0; + ?> +
+
+
+
+
+
+
+ +
+ +
+ 0 && $value > 0) ? max(6, (int) round(($value / $max) * 140)) : 0; + ?> +
+
+
+
+
+ +
+ + +
+ ' . esc_html__('No lifetime data has been tracked yet.', 'ansico-stat-plugin') . '

'; + return; + } + ?> + + + + + + + + + + + + + + + + + +
+ get_results( + "SELECT stat_date, SUM(views) AS total_views + FROM {$daily_table} + GROUP BY stat_date + ORDER BY stat_date ASC", + ARRAY_A + ); + + $unique_rows = $wpdb->get_results($wpdb->prepare( + "SELECT period_key, COUNT(*) AS total_unique + FROM {$unique_table} + WHERE period_type = %s + GROUP BY period_key + ORDER BY period_key ASC", + 'day' + ), ARRAY_A); + + $labels = [ + 1 => __('Monday', 'ansico-stat-plugin'), + 2 => __('Tuesday', 'ansico-stat-plugin'), + 3 => __('Wednesday', 'ansico-stat-plugin'), + 4 => __('Thursday', 'ansico-stat-plugin'), + 5 => __('Friday', 'ansico-stat-plugin'), + 6 => __('Saturday', 'ansico-stat-plugin'), + 7 => __('Sunday', 'ansico-stat-plugin'), + ]; + + $map = []; + foreach ($labels as $day_number => $label) { + $map[$day_number] = [ + 'label' => $label, + 'views' => 0, + 'unique_visitors' => 0, + ]; + } + + if (is_array($view_rows)) { + foreach ($view_rows as $row) { + $date = (string) ($row['stat_date'] ?? ''); + if ($date === '') { + continue; + } + $weekday = (int) wp_date('N', strtotime($date)); + if (!isset($map[$weekday])) { + continue; + } + $map[$weekday]['views'] += (int) ($row['total_views'] ?? 0); + } + } + + if (is_array($unique_rows)) { + foreach ($unique_rows as $row) { + $date = (string) ($row['period_key'] ?? ''); + if ($date === '') { + continue; + } + $weekday = (int) wp_date('N', strtotime($date)); + if (!isset($map[$weekday])) { + continue; + } + $map[$weekday]['unique_visitors'] += (int) ($row['total_unique'] ?? 0); + } + } + + $total_views = 0; + $total_unique = 0; + foreach ($map as $row) { + $total_views += (int) $row['views']; + $total_unique += (int) $row['unique_visitors']; + } + + $rows = []; + foreach ($map as $day_number => $row) { + $views = (int) $row['views']; + $unique = (int) $row['unique_visitors']; + $rows[] = [ + 'day_number' => $day_number, + 'label' => (string) $row['label'], + 'views' => $views, + 'unique_visitors' => $unique, + 'views_percent' => $total_views > 0 ? number_format_i18n(($views / $total_views) * 100, 1) . '%' : '0%', + 'unique_percent' => $total_unique > 0 ? number_format_i18n(($unique / $total_unique) * 100, 1) . '%' : '0%', + ]; + } + + return $rows; + } + + protected function render_lifetime_weekday_distribution_table(array $rows) { + if (empty($rows)) { + echo '

' . esc_html__('No weekday distribution data has been tracked yet.', 'ansico-stat-plugin') . '

'; + return; + } + ?> + + + + + + + + + + + + + + + + + + + + + +
+ (int) ($summary['direct'] ?? 0), 'unique_visitors' => 0]; + $search = is_array($summary['search'] ?? null) ? $summary['search'] : ['visits' => (int) ($summary['search'] ?? 0), 'unique_visitors' => 0]; + $social = is_array($summary['social'] ?? null) ? $summary['social'] : ['visits' => (int) ($summary['social'] ?? 0), 'unique_visitors' => 0]; + $website = is_array($summary['website'] ?? null) ? $summary['website'] : ['visits' => (int) ($summary['website'] ?? 0), 'unique_visitors' => 0]; + + $total = (int) ($direct['visits'] ?? 0) + (int) ($search['visits'] ?? 0) + (int) ($social['visits'] ?? 0) + (int) ($website['visits'] ?? 0); + $unique_total = (int) ($direct['unique_visitors'] ?? 0) + (int) ($search['unique_visitors'] ?? 0) + (int) ($social['unique_visitors'] ?? 0) + (int) ($website['unique_visitors'] ?? 0); ?> - + + + - - - - + + + +
format_percent((int) ($summary['direct'] ?? 0), $total)); ?>
format_percent((int) ($summary['search'] ?? 0), $total)); ?>
format_percent((int) ($summary['social'] ?? 0), $total)); ?>
format_percent((int) ($summary['website'] ?? 0), $total)); ?>
format_percent((int) ($direct['visits'] ?? 0), $total)); ?>format_percent((int) ($direct['unique_visitors'] ?? 0), $unique_total)); ?>
format_percent((int) ($search['visits'] ?? 0), $total)); ?>format_percent((int) ($search['unique_visitors'] ?? 0), $unique_total)); ?>
format_percent((int) ($social['visits'] ?? 0), $total)); ?>format_percent((int) ($social['unique_visitors'] ?? 0), $unique_total)); ?>
format_percent((int) ($website['visits'] ?? 0), $total)); ?>format_percent((int) ($website['unique_visitors'] ?? 0), $unique_total)); ?>
$threshold) { + $classes = trim('ansico-table-scroll-wrap ' . $extra_class); + $style = 'max-height:470px; overflow-y:auto; overflow-x:auto; display:block; width:100%;'; + echo '
'; + return true; + } + + return false; + } + + protected function maybe_close_scrollable_table_wrapper(bool $opened) { + if ($opened) { + echo '
'; + } + } + protected function render_dimension_table(array $rows, string $value_label, string $views_label = '') { if ($views_label === '') { $views_label = __('Views', 'ansico-stat-plugin'); @@ -1857,10 +3347,9 @@ if (!class_exists('Ansico_Stat_Plugin')) { return; } - $total = 0; - foreach ($rows as $row) { - $total += (int) ($row['total_views'] ?? 0); - } + $total = $this->get_rows_total($rows, 'total_views'); + $unique_total = $this->get_rows_total($rows, 'total_unique'); + $opened_scroll = $this->maybe_open_scrollable_table_wrapper(count($rows)); ?> @@ -1868,12 +3357,14 @@ if (!class_exists('Ansico_Stat_Plugin')) { - + + + $row) : ?> - + + +
@@ -1887,11 +3378,52 @@ if (!class_exists('Ansico_Stat_Plugin')) { ?> format_percent($count, $total)); ?>format_percent($unique, $unique_total)); ?>
+ maybe_close_scrollable_table_wrapper($opened_scroll); ?> + ' . esc_html($empty_message) . '

'; + return; + } + + $unique_total = $this->get_rows_total($rows, 'total_unique'); + $opened_scroll = $this->maybe_open_scrollable_table_wrapper(count($rows)); + ?> + + + + + + + + + + + $row) : ?> + + + + + + + + + +
format_landing_page_link((string) ($row['page_url'] ?? ''), (string) ($row['page_label'] ?? '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>format_percent($unique, $unique_total)); ?>
+ maybe_close_scrollable_table_wrapper($opened_scroll); ?> ' . esc_html__('No tracked 404 views for this month.', 'ansico-stat-plugin') . '

'; return; } + $opened_scroll = $this->maybe_open_scrollable_table_wrapper(count($rows)); ?> @@ -1907,6 +3440,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { + @@ -1915,10 +3449,12 @@ if (!class_exists('Ansico_Stat_Plugin')) { +
format_external_link((string) ($row['page_url'] ?? '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ maybe_close_scrollable_table_wrapper($opened_scroll); ?> ' . esc_html__('No views tracked for this period.', 'ansico-stat-plugin') . '

'; return; } + $opened_scroll = $this->maybe_open_scrollable_table_wrapper(count($rows)); ?> @@ -1935,6 +3472,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { + @@ -1951,14 +3489,29 @@ if (!class_exists('Ansico_Stat_Plugin')) { +
format_post_link($post_id, $title); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+ maybe_close_scrollable_table_wrapper($opened_scroll); ?> get_truncated_url_text($label, 100)); + } + + return $this->format_url_display_link($url, $label); + } + protected function render_referral_sources_table(array $rows, string $label) { echo '

' . esc_html($label) . '

'; if (empty($rows)) { @@ -1966,1177 +3519,779 @@ if (!class_exists('Ansico_Stat_Plugin')) { return; } ?> - + get_rows_total($rows, 'total_visits'); $unique_total = $this->get_rows_total($rows, 'total_unique'); ?> +
+ - + + + - + + + +
format_external_link((string) $row['source_url']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>format_landing_page_link((string) ($row['landing_page_url'] ?? ''), (string) ($row['landing_page_label'] ?? '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> format_percent($count, $total)); ?>format_percent($unique, $unique_total)); ?>
+
get_settings(); + return !empty($settings['google_access_token']) || !empty($settings['google_refresh_token']); } - protected function get_single_stats_date_range() { - $default_end = current_time('Y-m-d'); - $default_start = $this->date_days_ago(29); - - $start = isset($_GET['start_date']) ? $this->sanitize_stats_date((string) wp_unslash($_GET['start_date'])) : ''; - $end = isset($_GET['end_date']) ? $this->sanitize_stats_date((string) wp_unslash($_GET['end_date'])) : ''; - - if ($start === '') { - $start = $default_start; + protected function get_google_connection_status_label() { + $settings = $this->get_settings(); + if (!$this->is_google_connected()) { + return __('Not connected', 'ansico-stat-plugin'); } - if ($end === '') { - $end = $default_end; + if (!empty($settings['google_token_expires_at']) && (int) $settings['google_token_expires_at'] <= time()) { + return __('Connected, token refresh needed', 'ansico-stat-plugin'); } - if (strtotime($start) > strtotime($end)) { - [$start, $end] = [$end, $start]; + return __('Connected', 'ansico-stat-plugin'); + } + + protected function get_google_site_url_prefix() { + return trailingslashit(home_url('/')); + } + + protected function get_google_url_prefix_property() { + $site_url = $this->get_google_site_url_prefix(); + if (!preg_match('#^https?://#i', $site_url)) { + $site_url = 'https://' . ltrim($site_url, '/'); + } + return trailingslashit($site_url); + } + + protected function get_google_auto_verification_property() { + $selected_property = $this->get_google_selected_property(); + if ($selected_property !== '' && !$this->is_google_domain_property($selected_property)) { + return $selected_property; + } + return $this->get_google_url_prefix_property(); + } + + protected function get_google_selected_property() { + $settings = $this->get_settings(); + $property = sanitize_text_field((string) ($settings['google_property'] ?? '')); + return $property !== '' ? $property : $this->get_google_site_url_prefix(); + } + + protected function is_google_domain_property(string $property = '') { + if ($property === '') { + $property = $this->get_google_selected_property(); + } + return strpos($property, 'sc-domain:') === 0; + } + + protected function get_google_verification_request_details(string $property = '') { + $property = $property !== '' ? $property : $this->get_google_selected_property(); + if ($this->is_google_domain_property($property)) { + return [ + 'identifier' => substr($property, strlen('sc-domain:')), + 'site_type' => 'INET_DOMAIN', + 'method' => 'DNS_TXT', + ]; } return [ - 'start' => $start, - 'end' => $end, - 'days' => max(1, (int) floor((strtotime($end) - strtotime($start)) / DAY_IN_SECONDS) + 1), - 'is_custom' => isset($_GET['start_date']) || isset($_GET['end_date']), + 'identifier' => $property, + 'site_type' => 'SITE', + 'method' => 'META', ]; } - protected function build_single_stats_admin_url(string $target_url, string $start_date = '', string $end_date = '') { + protected function build_google_oauth_authorization_url() { + $settings = $this->get_settings(); + if (empty($settings['google_client_id']) || empty($settings['google_client_secret'])) { + return ''; + } + + $state = wp_generate_password(20, false, false); + set_transient('ansico_stat_google_oauth_state_' . get_current_user_id(), $state, 15 * MINUTE_IN_SECONDS); + $args = [ - 'page' => self::MENU_SLUG_SINGLE, - 'target_url' => $target_url !== '' ? $target_url : home_url('/'), + 'client_id' => $settings['google_client_id'], + 'redirect_uri' => $this->get_google_redirect_uri(), + 'response_type' => 'code', + 'scope' => implode(' ', $this->get_google_auth_scopes()), + 'access_type' => 'offline', + 'prompt' => 'consent', + 'include_granted_scopes' => 'true', + 'state' => $state, ]; - if ($start_date !== '') { - $args['start_date'] = $start_date; - } - if ($end_date !== '') { - $args['end_date'] = $end_date; - } - return add_query_arg($args, admin_url('admin.php')); + + return add_query_arg($args, 'https://accounts.google.com/o/oauth2/v2/auth'); } - public function register_frontend_admin_bar_link($wp_admin_bar) { - if (is_admin() || !is_admin_bar_showing() || !current_user_can('manage_options') || !$this->is_trackable_request()) { - return; - } + protected function update_google_settings(array $changes) { + $settings = $this->get_settings(); + $settings = array_merge($settings, $changes); + update_option(self::OPTION_KEY, $settings, false); + return $settings; + } - $page_context = $this->get_current_page_context(); - if (empty($page_context) || empty($page_context['url'])) { - return; - } - - $target_url = $this->normalize_target_url((string) $page_context['url']); - if ($target_url === '') { - return; - } - - $wp_admin_bar->add_node([ - 'id' => 'ansico-stat-single-page', - 'parent' => 'top-secondary', - 'title' => '' . esc_html__('Page stats', 'ansico-stat-plugin') . '', - 'href' => esc_url($this->build_single_stats_admin_url($target_url)), - 'meta' => [ - 'class' => 'ansico-stat-admin-bar-link', - 'title' => esc_attr__('Open statistics for this page', 'ansico-stat-plugin'), - ], + protected function clear_google_connection_data() { + $this->update_google_settings([ + 'google_access_token' => '', + 'google_refresh_token' => '', + 'google_token_expires_at' => 0, + 'google_connected_email' => '', + 'google_property' => '', + 'google_site_verification_token' => '', + 'google_last_sync_month' => '', ]); } - protected function resolve_single_stats_target(string $url) { - global $wpdb; - - $normalized_url = $this->normalize_target_url($url); - if ($normalized_url === '') { - return [ - 'success' => false, - 'message' => __('Please enter a valid URL.', 'ansico-stat-plugin'), - 'requested_url' => $url, - 'normalized_url' => '', - ]; + protected function get_google_access_token() { + $settings = $this->get_settings(); + if (!empty($settings['google_access_token']) && (!empty($settings['google_token_expires_at']) && (int) $settings['google_token_expires_at'] > time() + 60)) { + return $settings['google_access_token']; } - $site_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); - $target_host = strtolower((string) wp_parse_url($normalized_url, PHP_URL_HOST)); - if ($site_host === '' || $target_host === '' || preg_replace('/^www\./', '', $site_host) !== preg_replace('/^www\./', '', $target_host)) { - return [ - 'success' => false, - 'message' => __('The URL must belong to this WordPress site.', 'ansico-stat-plugin'), - 'requested_url' => $url, - 'normalized_url' => $normalized_url, - ]; + if (empty($settings['google_refresh_token']) || empty($settings['google_client_id']) || empty($settings['google_client_secret'])) { + return ''; } - $post_id = url_to_postid($normalized_url); - if ($post_id > 0) { - $post = get_post($post_id); - if ($this->is_trackable_post($post)) { - return [ - 'success' => true, - 'requested_url' => $url, - 'normalized_url' => $normalized_url, - 'target' => [ - 'kind' => 'singular', - 'post_id' => (int) $post_id, - 'post_type' => sanitize_key((string) $post->post_type), - 'page_key' => 'post:' . (int) $post_id, - 'page_type' => sanitize_key((string) $post->post_type), - 'object_id' => (int) $post_id, - 'label' => get_the_title($post_id) ?: __('(no title)', 'ansico-stat-plugin'), - 'url' => get_permalink($post_id) ?: $normalized_url, - 'resolved_via' => 'post', - ], - ]; - } + $response = wp_remote_post('https://oauth2.googleapis.com/token', [ + 'timeout' => 20, + 'body' => [ + 'client_id' => $settings['google_client_id'], + 'client_secret' => $settings['google_client_secret'], + 'refresh_token' => $settings['google_refresh_token'], + 'grant_type' => 'refresh_token', + ], + ]); + + if (is_wp_error($response)) { + return ''; } - $front_url = $this->normalize_target_url(home_url('/')); - if ($normalized_url === $front_url) { - return [ - 'success' => true, - 'requested_url' => $url, - 'normalized_url' => $normalized_url, - 'target' => [ - 'kind' => 'archive', - 'page_key' => 'front_page', - 'page_type' => 'front_page', - 'object_id' => 0, - 'label' => __('Front page', 'ansico-stat-plugin'), - 'url' => home_url('/'), - 'resolved_via' => 'front_page', - ], - ]; + $code = (int) wp_remote_retrieve_response_code($response); + $body = json_decode((string) wp_remote_retrieve_body($response), true); + if ($code < 200 || $code >= 300 || !is_array($body) || empty($body['access_token'])) { + return ''; } - $posts_page_id = (int) get_option('page_for_posts'); - if ($posts_page_id > 0) { - $posts_page_url = $this->normalize_target_url((string) get_permalink($posts_page_id)); - if ($posts_page_url !== '' && $normalized_url === $posts_page_url) { - return [ - 'success' => true, - 'requested_url' => $url, - 'normalized_url' => $normalized_url, - 'target' => [ - 'kind' => 'archive', - 'page_key' => 'home', - 'page_type' => 'home', - 'object_id' => $posts_page_id, - 'label' => __('Posts page', 'ansico-stat-plugin'), - 'url' => get_permalink($posts_page_id) ?: $normalized_url, - 'resolved_via' => 'posts_page', - ], - ]; - } - } + $this->update_google_settings([ + 'google_access_token' => sanitize_text_field((string) $body['access_token']), + 'google_token_expires_at' => time() + max(60, (int) ($body['expires_in'] ?? 3600)), + ]); - $page_daily_table = self::page_daily_table_name(); - $url_variants = array_values(array_unique(array_filter([ - $normalized_url, - untrailingslashit($normalized_url), - trailingslashit($normalized_url), - ]))); - - if (!empty($url_variants)) { - $placeholders = implode(',', array_fill(0, count($url_variants), '%s')); - $sql = "SELECT page_key, page_type, object_id, page_label, page_url\n FROM {$page_daily_table}\n WHERE page_url IN ({$placeholders})\n ORDER BY stat_date DESC\n LIMIT 1"; - $row = $wpdb->get_row($wpdb->prepare($sql, $url_variants), ARRAY_A); - if (is_array($row) && !empty($row['page_key'])) { - return [ - 'success' => true, - 'requested_url' => $url, - 'normalized_url' => $normalized_url, - 'target' => [ - 'kind' => 'archive', - 'page_key' => (string) $row['page_key'], - 'page_type' => sanitize_key((string) ($row['page_type'] ?? 'archive')), - 'object_id' => !empty($row['object_id']) ? (int) $row['object_id'] : 0, - 'label' => sanitize_text_field((string) ($row['page_label'] ?? $normalized_url)), - 'url' => !empty($row['page_url']) ? esc_url_raw((string) $row['page_url']) : $normalized_url, - 'resolved_via' => 'page_daily', - ], - ]; - } - } - - return [ - 'success' => false, - 'message' => __('No tracked page or post was found for that URL yet. Try opening the page on the site first so the plugin can register it, and then update this report again.', 'ansico-stat-plugin'), - 'requested_url' => $url, - 'normalized_url' => $normalized_url, - ]; + return sanitize_text_field((string) $body['access_token']); } - protected function get_target_lifetime_views(array $target) { - global $wpdb; - - if (($target['kind'] ?? '') === 'singular') { - return (int) get_post_meta((int) $target['post_id'], self::TOTAL_META_KEY, true); + protected function google_api_request($method, $url, $body = null, $access_token = '') { + if ($access_token === '') { + $access_token = $this->get_google_access_token(); + } + if ($access_token === '') { + return new WP_Error('ansico_google_missing_token', __('Google access token is missing.', 'ansico-stat-plugin')); } - $table = self::page_daily_table_name(); - $views = $wpdb->get_var($wpdb->prepare( - "SELECT SUM(views) FROM {$table} WHERE page_key = %s", - (string) $target['page_key'] - )); - - return (int) $views; - } - - - protected function get_target_lifetime_unique_visitors(array $target) { - global $wpdb; - - if (($target['kind'] ?? '') === 'singular') { - $table = self::post_daily_table_name(); - $uniques = $wpdb->get_var($wpdb->prepare( - "SELECT SUM(unique_visitors) FROM {$table} WHERE post_id = %d", - (int) $target['post_id'] - )); - return (int) $uniques; - } - - $table = self::page_daily_table_name(); - $uniques = $wpdb->get_var($wpdb->prepare( - "SELECT SUM(unique_visitors) FROM {$table} WHERE page_key = %s", - (string) $target['page_key'] - )); - return (int) $uniques; - } - - protected function get_target_period_unique_visitors(array $target, string $start, string $end) { - global $wpdb; - - if (($target['kind'] ?? '') === 'singular') { - $table = self::post_daily_table_name(); - $uniques = $wpdb->get_var($wpdb->prepare( - "SELECT SUM(unique_visitors) FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s", - (int) $target['post_id'], - $start, - $end - )); - return (int) $uniques; - } - - $table = self::page_daily_table_name(); - $uniques = $wpdb->get_var($wpdb->prepare( - "SELECT SUM(unique_visitors) FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s", - (string) $target['page_key'], - $start, - $end - )); - return (int) $uniques; - } - - protected function get_target_daily_chart_data_between(array $target, string $start, string $end, string $metric = 'views') { - global $wpdb; - - $metric = $metric === 'unique_visitors' ? 'unique_visitors' : 'views'; - if (($target['kind'] ?? '') === 'singular') { - $table = self::post_daily_table_name(); - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT stat_date, {$metric} AS metric_value FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s ORDER BY stat_date ASC", - (int) $target['post_id'], - $start, - $end - ), ARRAY_A); - } else { - $table = self::page_daily_table_name(); - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT stat_date, {$metric} AS metric_value FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s ORDER BY stat_date ASC", - (string) $target['page_key'], - $start, - $end - ), ARRAY_A); - } - - $indexed = []; - foreach ((array) $rows as $row) { - $indexed[(string) $row['stat_date']] = (int) $row['metric_value']; - } - - $data = []; - $cursor = strtotime($start); - $end_ts = strtotime($end); - while ($cursor <= $end_ts) { - $date = gmdate('Y-m-d', $cursor); - $data[] = [ - 'date' => $date, - 'views' => $indexed[$date] ?? 0, - ]; - $cursor = strtotime('+1 day', $cursor); - } - - return $data; - } - - protected function get_target_monthly_chart_data_between(array $target, string $start, string $end) { - global $wpdb; - - if (($target['kind'] ?? '') === 'singular') { - $table = self::post_daily_table_name(); - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT DATE_FORMAT(stat_date, '%%Y-%%m') AS month_key, SUM(views) AS total_views FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s GROUP BY month_key ORDER BY month_key ASC", - (int) $target['post_id'], - $start, - $end - ), ARRAY_A); - } else { - $table = self::page_daily_table_name(); - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT DATE_FORMAT(stat_date, '%%Y-%%m') AS month_key, SUM(views) AS total_views FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s GROUP BY month_key ORDER BY month_key ASC", - (string) $target['page_key'], - $start, - $end - ), ARRAY_A); - } - - $indexed = []; - foreach ((array) $rows as $row) { - $indexed[(string) $row['month_key']] = (int) $row['total_views']; - } - - $data = []; - $cursor = strtotime(gmdate('Y-m-01', strtotime($start))); - $end_month = strtotime(gmdate('Y-m-01', strtotime($end))); - while ($cursor <= $end_month) { - $month_key = gmdate('Y-m', $cursor); - $data[] = [ - 'label' => wp_date('M Y', $cursor), - 'views' => $indexed[$month_key] ?? 0, - ]; - $cursor = strtotime('+1 month', $cursor); - } - - return $data; - } - - protected function get_target_weekday_breakdown_between(array $target, string $start, string $end) { - $daily_data = $this->get_target_daily_chart_data_between($target, $start, $end, 'views'); - $weekday_rows = [ - __('Mon', 'ansico-stat-plugin') => 0, - __('Tue', 'ansico-stat-plugin') => 0, - __('Wed', 'ansico-stat-plugin') => 0, - __('Thu', 'ansico-stat-plugin') => 0, - __('Fri', 'ansico-stat-plugin') => 0, - __('Sat', 'ansico-stat-plugin') => 0, - __('Sun', 'ansico-stat-plugin') => 0, - ]; - $weekday_keys = array_keys($weekday_rows); - - foreach ($daily_data as $row) { - $timestamp = strtotime((string) ($row['date'] ?? '')); - if (!$timestamp) { - continue; - } - $weekday_index = (int) gmdate('N', $timestamp) - 1; - if (isset($weekday_keys[$weekday_index])) { - $weekday_rows[$weekday_keys[$weekday_index]] += (int) ($row['views'] ?? 0); - } - } - - $data = []; - foreach ($weekday_rows as $label => $views) { - $data[] = [ - 'label' => $label, - 'views' => (int) $views, - ]; - } - - return $data; - } - - protected function render_dual_chart_markup(array $views_data, array $unique_data, string $chart_id, string $aria_label = '') { - if ($aria_label === '') { - $aria_label = __('Views and unique visitors chart', 'ansico-stat-plugin'); - } - - $count = max(count($views_data), count($unique_data)); - if ($count < 1) { - return '

' . esc_html__('No chart data available.', 'ansico-stat-plugin') . '

'; - } - - $values = []; - foreach ($views_data as $row) { - $values[] = (int) ($row['views'] ?? 0); - } - foreach ($unique_data as $row) { - $values[] = (int) ($row['views'] ?? 0); - } - $max = max(1, !empty($values) ? max($values) : 1); - $width = 920; - $height = 240; - $chart_top = 20; - $chart_bottom = 185; - $chart_left = 26; - $chart_right = 830; - $stepX = $count > 1 ? ($chart_right - $chart_left) / ($count - 1) : ($chart_right - $chart_left); - - $build_points = static function(array $series, string $series_label) use ($chart_top, $chart_bottom, $chart_left, $stepX, $max) { - $points = []; - foreach (array_values($series) as $index => $row) { - $value = (int) ($row['views'] ?? 0); - $x = $chart_left + ($stepX * $index); - $y = $value > 0 ? $chart_top + (($chart_bottom - $chart_top) * (1 - ($value / $max))) : $chart_bottom; - $label = (string) ($row['label'] ?? $row['date'] ?? ''); - $points[] = [ - 'x' => $x, - 'y' => $y, - 'label' => $label, - 'value' => $value, - 'series_label' => $series_label, - ]; - } - return $points; - }; - - $view_points = $build_points($views_data, __('Views', 'ansico-stat-plugin')); - $unique_points = $build_points($unique_data, __('Unique visitors', 'ansico-stat-plugin')); - $view_path = implode(' ', array_map(static function($point) { - return $point['x'] . ',' . $point['y']; - }, $view_points)); - $unique_path = implode(' ', array_map(static function($point) { - return $point['x'] . ',' . $point['y']; - }, $unique_points)); - - ob_start(); - ?> -
-
- - - - - - - - - - - - - - - - - - - - - - -
- -
-
- - date_days_ago($days - 1); - $end = current_time('Y-m-d'); - - if (($target['kind'] ?? '') === 'singular') { - $table = self::post_daily_table_name(); - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT stat_date, unique_visitors FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s ORDER BY stat_date ASC", - (int) $target['post_id'], - $start, - $end - ), ARRAY_A); - } else { - $table = self::page_daily_table_name(); - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT stat_date, unique_visitors FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s ORDER BY stat_date ASC", - (string) $target['page_key'], - $start, - $end - ), ARRAY_A); - } - - $indexed = []; - foreach ((array) $rows as $row) { - $indexed[(string) $row['stat_date']] = (int) $row['unique_visitors']; - } - - $data = []; - $cursor = strtotime($start); - $end_ts = strtotime($end); - while ($cursor <= $end_ts) { - $date = gmdate('Y-m-d', $cursor); - $data[] = [ - 'date' => $date, - 'views' => $indexed[$date] ?? 0, - ]; - $cursor = strtotime('+1 day', $cursor); - } - - return $data; - } - - protected function get_target_period_views(array $target, string $start, string $end) { - global $wpdb; - - if (($target['kind'] ?? '') === 'singular') { - $table = self::post_daily_table_name(); - $views = $wpdb->get_var($wpdb->prepare( - "SELECT SUM(views) FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s", - (int) $target['post_id'], - $start, - $end - )); - return (int) $views; - } - - $table = self::page_daily_table_name(); - $views = $wpdb->get_var($wpdb->prepare( - "SELECT SUM(views) FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s", - (string) $target['page_key'], - $start, - $end - )); - return (int) $views; - } - - protected function get_target_daily_chart_data(array $target, int $days = 60) { - global $wpdb; - - $days = max(1, $days); - $start = $this->date_days_ago($days - 1); - $end = current_time('Y-m-d'); - - if (($target['kind'] ?? '') === 'singular') { - $table = self::post_daily_table_name(); - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT stat_date, views FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s ORDER BY stat_date ASC", - (int) $target['post_id'], - $start, - $end - ), ARRAY_A); - } else { - $table = self::page_daily_table_name(); - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT stat_date, views FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s ORDER BY stat_date ASC", - (string) $target['page_key'], - $start, - $end - ), ARRAY_A); - } - - $indexed = []; - foreach ((array) $rows as $row) { - $indexed[(string) $row['stat_date']] = (int) $row['views']; - } - - $data = []; - $cursor = strtotime($start); - $end_ts = strtotime($end); - while ($cursor <= $end_ts) { - $date = gmdate('Y-m-d', $cursor); - $data[] = [ - 'date' => $date, - 'views' => $indexed[$date] ?? 0, - ]; - $cursor = strtotime('+1 day', $cursor); - } - - return $data; - } - - protected function get_target_monthly_chart_data(array $target, int $months = 12) { - global $wpdb; - - $months = max(1, $months); - $current_month = strtotime(wp_date('Y-m-01', current_time('timestamp'))); - $start_month = strtotime('-' . ($months - 1) . ' months', $current_month); - $start = wp_date('Y-m-01', $start_month); - $end = wp_date('Y-m-t', current_time('timestamp')); - - if (($target['kind'] ?? '') === 'singular') { - $table = self::post_daily_table_name(); - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT DATE_FORMAT(stat_date, '%%Y-%%m') AS month_key, SUM(views) AS total_views\n FROM {$table}\n WHERE post_id = %d AND stat_date BETWEEN %s AND %s\n GROUP BY month_key\n ORDER BY month_key ASC", - (int) $target['post_id'], - $start, - $end - ), ARRAY_A); - } else { - $table = self::page_daily_table_name(); - $rows = $wpdb->get_results($wpdb->prepare( - "SELECT DATE_FORMAT(stat_date, '%%Y-%%m') AS month_key, SUM(views) AS total_views\n FROM {$table}\n WHERE page_key = %s AND stat_date BETWEEN %s AND %s\n GROUP BY month_key\n ORDER BY month_key ASC", - (string) $target['page_key'], - $start, - $end - ), ARRAY_A); - } - - $indexed = []; - foreach ((array) $rows as $row) { - $indexed[(string) $row['month_key']] = (int) $row['total_views']; - } - - $data = []; - $cursor = $start_month; - while ($cursor <= $current_month) { - $month_key = wp_date('Y-m', $cursor); - $data[] = [ - 'label' => wp_date('M Y', $cursor), - 'views' => $indexed[$month_key] ?? 0, - ]; - $cursor = strtotime('+1 month', $cursor); - } - - return $data; - } - - protected function get_target_best_day(array $target) { - global $wpdb; - - if (($target['kind'] ?? '') === 'singular') { - $table = self::post_daily_table_name(); - $row = $wpdb->get_row($wpdb->prepare( - "SELECT stat_date, views FROM {$table} WHERE post_id = %d ORDER BY views DESC, stat_date ASC LIMIT 1", - (int) $target['post_id'] - ), ARRAY_A); - } else { - $table = self::page_daily_table_name(); - $row = $wpdb->get_row($wpdb->prepare( - "SELECT stat_date, views FROM {$table} WHERE page_key = %s ORDER BY views DESC, stat_date ASC LIMIT 1", - (string) $target['page_key'] - ), ARRAY_A); - } - - return is_array($row) ? $row : []; - } - - protected function get_target_first_tracked_date(array $target) { - global $wpdb; - - if (($target['kind'] ?? '') === 'singular') { - $table = self::post_daily_table_name(); - $date = $wpdb->get_var($wpdb->prepare( - "SELECT MIN(stat_date) FROM {$table} WHERE post_id = %d", - (int) $target['post_id'] - )); - } else { - $table = self::page_daily_table_name(); - $date = $wpdb->get_var($wpdb->prepare( - "SELECT MIN(stat_date) FROM {$table} WHERE page_key = %s", - (string) $target['page_key'] - )); - } - - return is_string($date) ? $date : ''; - } - - - protected function get_target_total_tracked_days(array $target) { - global $wpdb; - - if (($target['kind'] ?? '') === 'singular') { - $table = self::post_daily_table_name(); - $count = $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$table} WHERE post_id = %d", - (int) $target['post_id'] - )); - } else { - $table = self::page_daily_table_name(); - $count = $wpdb->get_var($wpdb->prepare( - "SELECT COUNT(*) FROM {$table} WHERE page_key = %s", - (string) $target['page_key'] - )); - } - - return (int) $count; - } - - - protected function get_target_average_unique_visitors_per_day(array $target, int $days) { - $days = max(1, $days); - $uniques = $this->get_target_period_unique_visitors($target, $this->date_days_ago($days - 1), current_time('Y-m-d')); - return $uniques / $days; - } - - protected function get_target_unique_period_comparison(array $target, int $days) { - $days = max(1, $days); - $current_end = current_time('Y-m-d'); - $current_start = $this->date_days_ago($days - 1); - $previous_end = $this->date_days_ago($days); - $previous_start = $this->date_days_ago(($days * 2) - 1); - - $current_uniques = $this->get_target_period_unique_visitors($target, $current_start, $current_end); - $previous_uniques = $this->get_target_period_unique_visitors($target, $previous_start, $previous_end); - $change = $current_uniques - $previous_uniques; - $percent_change = null; - if ($previous_uniques > 0) { - $percent_change = (($current_uniques - $previous_uniques) / $previous_uniques) * 100; - } elseif ($current_uniques > 0) { - $percent_change = 100.0; - } - - return [ - 'days' => $days, - 'current_views' => (int) $current_uniques, - 'previous_views' => (int) $previous_uniques, - 'change' => (int) $change, - 'percent_change' => $percent_change, - 'current_start' => $current_start, - 'current_end' => $current_end, - 'previous_start' => $previous_start, - 'previous_end' => $previous_end, - ]; - } - - protected function get_target_average_views_per_day(array $target, int $days) { - $days = max(1, $days); - $views = $this->get_target_period_views($target, $this->date_days_ago($days - 1), current_time('Y-m-d')); - return $views / $days; - } - - protected function get_target_period_comparison(array $target, int $days) { - $days = max(1, $days); - $current_end = current_time('Y-m-d'); - $current_start = $this->date_days_ago($days - 1); - $previous_end = $this->date_days_ago($days); - $previous_start = $this->date_days_ago(($days * 2) - 1); - - $current_views = $this->get_target_period_views($target, $current_start, $current_end); - $previous_views = $this->get_target_period_views($target, $previous_start, $previous_end); - $change = $current_views - $previous_views; - $percent_change = null; - if ($previous_views > 0) { - $percent_change = (($current_views - $previous_views) / $previous_views) * 100; - } elseif ($current_views > 0) { - $percent_change = 100.0; - } - - return [ - 'days' => $days, - 'current_views' => (int) $current_views, - 'previous_views' => (int) $previous_views, - 'change' => (int) $change, - 'percent_change' => $percent_change, - 'current_start' => $current_start, - 'current_end' => $current_end, - 'previous_start' => $previous_start, - 'previous_end' => $previous_end, - ]; - } - - protected function format_change_percent($percent_change) { - if ($percent_change === null) { - return __('New data', 'ansico-stat-plugin'); - } - - $prefix = $percent_change > 0 ? '+' : ''; - return $prefix . number_format_i18n((float) $percent_change, 1) . '%'; - } - - protected function get_target_weekday_breakdown(array $target, int $days = 90) { - $days = max(1, $days); - $daily_data = $this->get_target_daily_chart_data($target, $days); - $weekday_rows = [ - __('Mon', 'ansico-stat-plugin') => 0, - __('Tue', 'ansico-stat-plugin') => 0, - __('Wed', 'ansico-stat-plugin') => 0, - __('Thu', 'ansico-stat-plugin') => 0, - __('Fri', 'ansico-stat-plugin') => 0, - __('Sat', 'ansico-stat-plugin') => 0, - __('Sun', 'ansico-stat-plugin') => 0, - ]; - $weekday_keys = array_keys($weekday_rows); - - foreach ($daily_data as $row) { - $timestamp = strtotime((string) ($row['date'] ?? '')); - if (!$timestamp) { - continue; - } - $weekday_index = (int) gmdate('N', $timestamp) - 1; - if (!isset($weekday_keys[$weekday_index])) { - continue; - } - $weekday_rows[$weekday_keys[$weekday_index]] += (int) ($row['views'] ?? 0); - } - - $data = []; - foreach ($weekday_rows as $label => $views) { - $data[] = [ - 'label' => $label, - 'views' => (int) $views, - ]; - } - - return $data; - } - - protected function build_single_export_url(string $target_url, string $start_date = '', string $end_date = '') { $args = [ - 'action' => 'ansico_stat_export_single_csv', - 'target_url' => $target_url, + 'method' => strtoupper($method), + 'timeout' => 20, + 'headers' => [ + 'Authorization' => 'Bearer ' . $access_token, + 'Accept' => 'application/json', + ], ]; - if ($start_date !== '') { - $args['start_date'] = $start_date; - } - if ($end_date !== '') { - $args['end_date'] = $end_date; + + if ($body !== null) { + $args['headers']['Content-Type'] = 'application/json'; + $args['body'] = wp_json_encode($body); } - return wp_nonce_url( - add_query_arg($args, admin_url('admin-post.php')), - 'ansico_stat_export_single_csv' - ); - } + $response = wp_remote_request($url, $args); + if (is_wp_error($response)) { + return $response; + } - protected function render_single_stats_summary_cards(array $items) { - echo '
'; - foreach ($items as $item) { - echo '
'; - echo '
' . esc_html((string) ($item['label'] ?? '')) . '
'; - echo '
' . esc_html((string) ($item['value'] ?? '')) . '
'; - if (!empty($item['description'])) { - echo '
' . esc_html((string) $item['description']) . '
'; + $code = (int) wp_remote_retrieve_response_code($response); + $raw_body = (string) wp_remote_retrieve_body($response); + $decoded = $raw_body !== '' ? json_decode($raw_body, true) : []; + if ($code < 200 || $code >= 300) { + $message = __('Google API request failed.', 'ansico-stat-plugin'); + if (is_array($decoded) && !empty($decoded['error']['message'])) { + $message = sanitize_text_field((string) $decoded['error']['message']); } - echo '
'; + return new WP_Error('ansico_google_api_error', $message, ['status' => $code, 'body' => $decoded]); } - echo '
'; + + return is_array($decoded) ? $decoded : []; } - public function render_single_page_stats_page() { + protected function get_google_sites() { + $result = $this->google_api_request('GET', 'https://www.googleapis.com/webmasters/v3/sites'); + if (is_wp_error($result)) { + return $result; + } + return is_array($result['siteEntry'] ?? null) ? $result['siteEntry'] : []; + } + + protected function get_google_selected_site_permission() { + $site_url = $this->get_google_selected_property(); + $sites = $this->get_google_sites(); + if (is_wp_error($sites)) { + return ''; + } + foreach ($sites as $site) { + if (($site['siteUrl'] ?? '') === $site_url) { + return sanitize_text_field((string) ($site['permissionLevel'] ?? '')); + } + } + return ''; + } + + + protected function get_google_api_diagnostic_message($error, string $api_label) { + if (!is_wp_error($error)) { + return ''; + } + + $message = (string) $error->get_error_message(); + $lower = strtolower($message); + if (strpos($lower, 'access not configured') !== false || strpos($lower, 'has not been used in project') !== false || strpos($lower, 'api has not been used') !== false) { + return sprintf(__('The %s is probably not enabled in Google Cloud yet.', 'ansico-stat-plugin'), $api_label); + } + if (strpos($lower, 'insufficient authentication scopes') !== false || strpos($lower, 'request had insufficient authentication scopes') !== false) { + return __('Reconnect with Google so the plugin can request the required permissions again.', 'ansico-stat-plugin'); + } + if (strpos($lower, 'forbidden') !== false || strpos($lower, 'permission') !== false) { + return __('Google accepted the login, but the connected account does not currently have permission for this action.', 'ansico-stat-plugin'); + } + if (strpos($lower, 'invalid client') !== false || strpos($lower, 'deleted_client') !== false) { + return __('The OAuth client ID or secret appears invalid. Recheck the credentials in Google Cloud.', 'ansico-stat-plugin'); + } + if (strpos($lower, 'redirect_uri_mismatch') !== false) { + return __('The redirect URI in Google Cloud does not match the redirect URI shown in this plugin.', 'ansico-stat-plugin'); + } + return $message !== '' ? $message : __('The Google API returned an unknown error.', 'ansico-stat-plugin'); + } + + protected function get_google_setup_diagnostics() { + $settings = $this->get_settings(); + $diagnostics = []; + + $diagnostics[] = [ + 'label' => __('OAuth client credentials', 'ansico-stat-plugin'), + 'status' => (!empty($settings['google_client_id']) && !empty($settings['google_client_secret'])) ? 'success' : 'error', + 'message' => (!empty($settings['google_client_id']) && !empty($settings['google_client_secret'])) + ? __('Client ID and client secret are saved.', 'ansico-stat-plugin') + : __('Add and save both the Google OAuth client ID and client secret first.', 'ansico-stat-plugin'), + ]; + + if (!$this->is_google_connected()) { + $diagnostics[] = [ + 'label' => __('OAuth connection', 'ansico-stat-plugin'), + 'status' => 'warning', + 'message' => __('Not connected yet. If the Connect button fails before returning here, check the OAuth consent screen and authorized redirect URI in Google Cloud.', 'ansico-stat-plugin'), + ]; + $diagnostics[] = [ + 'label' => __('Search Console API', 'ansico-stat-plugin'), + 'status' => 'warning', + 'message' => __('This can be tested after you connect Google.', 'ansico-stat-plugin'), + ]; + $diagnostics[] = [ + 'label' => __('Site Verification API', 'ansico-stat-plugin'), + 'status' => 'warning', + 'message' => __('This can be tested after you connect Google.', 'ansico-stat-plugin'), + ]; + return $diagnostics; + } + + $access_token = $this->get_google_access_token(); + if ($access_token === '') { + $diagnostics[] = [ + 'label' => __('OAuth connection', 'ansico-stat-plugin'), + 'status' => 'error', + 'message' => __('The plugin is marked as connected, but it could not refresh the Google access token. Try reconnecting with Google.', 'ansico-stat-plugin'), + ]; + return $diagnostics; + } + + $diagnostics[] = [ + 'label' => __('OAuth connection', 'ansico-stat-plugin'), + 'status' => 'success', + 'message' => !empty($settings['google_connected_email']) + ? sprintf(__('Connected as %s.', 'ansico-stat-plugin'), $settings['google_connected_email']) + : __('Connected and the access token is working.', 'ansico-stat-plugin'), + ]; + + $sites_result = $this->google_api_request('GET', 'https://www.googleapis.com/webmasters/v3/sites', null, $access_token); + $diagnostics[] = [ + 'label' => __('Search Console API', 'ansico-stat-plugin'), + 'status' => is_wp_error($sites_result) ? 'error' : 'success', + 'message' => is_wp_error($sites_result) + ? $this->get_google_api_diagnostic_message($sites_result, __('Search Console API', 'ansico-stat-plugin')) + : __('The plugin can list Search Console properties successfully.', 'ansico-stat-plugin'), + ]; + + $verification_result = $this->google_api_request('GET', 'https://www.googleapis.com/siteVerification/v1/webResource', null, $access_token); + $diagnostics[] = [ + 'label' => __('Site Verification API', 'ansico-stat-plugin'), + 'status' => is_wp_error($verification_result) ? 'error' : 'success', + 'message' => is_wp_error($verification_result) + ? $this->get_google_api_diagnostic_message($verification_result, __('Site Verification API', 'ansico-stat-plugin')) + : __('The plugin can reach the Site Verification API successfully.', 'ansico-stat-plugin'), + ]; + + $property = $this->get_google_selected_property(); + $diagnostics[] = [ + 'label' => __('Selected property', 'ansico-stat-plugin'), + 'status' => $property !== '' ? 'success' : 'warning', + 'message' => $property !== '' + ? sprintf(__('Current property: %s', 'ansico-stat-plugin'), $property) + : __('Choose a Search Console property and save the settings.', 'ansico-stat-plugin'), + ]; + + return $diagnostics; + } + + protected function render_google_setup_diagnostics() { + $diagnostics = $this->get_google_setup_diagnostics(); + $status_labels = [ + 'success' => __('OK', 'ansico-stat-plugin'), + 'warning' => __('Check', 'ansico-stat-plugin'), + 'error' => __('Problem', 'ansico-stat-plugin'), + ]; + + echo ''; + echo ''; + foreach ($diagnostics as $row) { + $status = (string) ($row['status'] ?? 'warning'); + $label = (string) ($status_labels[$status] ?? $status_labels['warning']); + $color = $status === 'success' ? '#2271b1' : ($status === 'error' ? '#b32d2e' : '#996800'); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
' . esc_html__('Check', 'ansico-stat-plugin') . '' . esc_html__('Status', 'ansico-stat-plugin') . '' . esc_html__('Details', 'ansico-stat-plugin') . '
' . esc_html((string) ($row['label'] ?? '')) . '' . esc_html($label) . '' . esc_html((string) ($row['message'] ?? '')) . '
'; + } + + protected function get_google_site_verification_status() { + $settings = $this->get_settings(); + if (!$this->is_google_connected()) { + return __('Not connected', 'ansico-stat-plugin'); + } + $permission = $this->get_google_selected_site_permission(); + if ($permission !== '') { + return sprintf(__('Verified in Search Console (%s)', 'ansico-stat-plugin'), $permission); + } + if (!empty($settings['google_site_verification_token'])) { + if ($this->is_google_domain_property()) { + return __('DNS verification token generated, waiting for DNS verification', 'ansico-stat-plugin'); + } + return __('Verification token generated, waiting for verification', 'ansico-stat-plugin'); + } + return __('Not verified in Search Console yet', 'ansico-stat-plugin'); + } + + protected function get_google_search_console_help_text() { + return __('Use the Connect with Google button below for a Site Kit-style OAuth flow. First save your Google client ID and client secret, then connect, choose a property, verify ownership, and sync the selected month of search queries.', 'ansico-stat-plugin'); + } + + protected function get_google_connection_help_text() { + if ($this->is_google_connected()) { + return __('Google is already connected. You can reconnect to refresh permissions or disconnect and connect a different Google account.', 'ansico-stat-plugin'); + } + + return __('After you save the client ID and client secret, click Connect with Google. WordPress will send you to Google, and the access and refresh tokens will be stored automatically when you return.', 'ansico-stat-plugin'); + } + + public function render_google_site_verification_meta_tag() { + $settings = $this->get_settings(); + if (empty($settings['google_site_verification_token']) || is_admin()) { + return; + } + echo " +" . '' . " +"; + } + + public function handle_google_connect() { if (!current_user_can('manage_options')) { - wp_die(esc_html__('You do not have permission to access this page.', 'ansico-stat-plugin')); + wp_die(esc_html__('You do not have permission to connect Google Search Console.', 'ansico-stat-plugin')); + } + check_admin_referer('ansico_stat_google_connect'); + $url = $this->build_google_oauth_authorization_url(); + if ($url === '') { + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=missing-credentials')); + exit; + } + wp_safe_redirect($url); + exit; + } + + public function handle_google_disconnect() { + if (!current_user_can('manage_options')) { + wp_die(esc_html__('You do not have permission to disconnect Google Search Console.', 'ansico-stat-plugin')); + } + check_admin_referer('ansico_stat_google_disconnect'); + $this->clear_google_connection_data(); + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=disconnected')); + exit; + } + + public function handle_google_oauth_callback() { + if (!current_user_can('manage_options')) { + wp_die(esc_html__('You do not have permission to complete Google authentication.', 'ansico-stat-plugin')); } - $requested_url = $this->get_requested_single_stats_url(); - $date_range = $this->get_single_stats_date_range(); - $range_start = (string) $date_range['start']; - $range_end = (string) $date_range['end']; - $range_days = (int) $date_range['days']; - $result = $this->resolve_single_stats_target($requested_url); - $target = !empty($result['success']) ? (array) $result['target'] : []; - $daily_chart = !empty($target) ? $this->get_target_daily_chart_data_between($target, $range_start, $range_end, 'views') : []; - $unique_daily_chart = !empty($target) ? $this->get_target_daily_chart_data_between($target, $range_start, $range_end, 'unique_visitors') : []; - $monthly_chart = !empty($target) ? $this->get_target_monthly_chart_data_between($target, $range_start, $range_end) : []; - $weekday_chart = !empty($target) ? $this->get_target_weekday_breakdown_between($target, $range_start, $range_end) : []; - $lifetime_views = !empty($target) ? $this->get_target_lifetime_views($target) : 0; - $lifetime_unique_visitors = !empty($target) ? $this->get_target_lifetime_unique_visitors($target) : 0; - $views_today = !empty($target) ? $this->get_target_period_views($target, current_time('Y-m-d'), current_time('Y-m-d')) : 0; - $unique_today = !empty($target) ? $this->get_target_period_unique_visitors($target, current_time('Y-m-d'), current_time('Y-m-d')) : 0; - $range_views = !empty($target) ? $this->get_target_period_views($target, $range_start, $range_end) : 0; - $range_unique = !empty($target) ? $this->get_target_period_unique_visitors($target, $range_start, $range_end) : 0; - $best_day = !empty($target) ? $this->get_target_best_day($target) : []; - $first_tracked = !empty($target) ? $this->get_target_first_tracked_date($target) : ''; - $tracked_days = !empty($target) ? $this->get_target_total_tracked_days($target) : 0; - $avg_range = $range_days > 0 ? ($range_views / $range_days) : 0; - $avg_unique_range = $range_days > 0 ? ($range_unique / $range_days) : 0; - $compare_range = !empty($target) ? $this->get_target_period_comparison($target, $range_days) : []; - $unique_compare_range = !empty($target) ? $this->get_target_unique_period_comparison($target, $range_days) : []; - $views_7 = !empty($target) ? $this->get_target_period_views($target, $this->date_days_ago(6), current_time('Y-m-d')) : 0; - $views_30 = !empty($target) ? $this->get_target_period_views($target, $this->date_days_ago(29), current_time('Y-m-d')) : 0; - $unique_7 = !empty($target) ? $this->get_target_period_unique_visitors($target, $this->date_days_ago(6), current_time('Y-m-d')) : 0; - $unique_30 = !empty($target) ? $this->get_target_period_unique_visitors($target, $this->date_days_ago(29), current_time('Y-m-d')) : 0; - $export_url = !empty($target) ? $this->build_single_export_url($requested_url, $range_start, $range_end) : ''; - $reset_range_url = $this->build_single_stats_admin_url($requested_url); - $post_type_label = ''; - if (!empty($target) && ($target['kind'] ?? '') === 'singular') { - $post_type_obj = get_post_type_object((string) ($target['post_type'] ?? '')); - $post_type_label = $post_type_obj && !empty($post_type_obj->labels->singular_name) ? (string) $post_type_obj->labels->singular_name : (string) ($target['post_type'] ?? ''); + $state = isset($_GET['state']) ? sanitize_text_field(wp_unslash($_GET['state'])) : ''; + $saved_state = get_transient('ansico_stat_google_oauth_state_' . get_current_user_id()); + delete_transient('ansico_stat_google_oauth_state_' . get_current_user_id()); + if ($state === '' || !$saved_state || !hash_equals((string) $saved_state, $state)) { + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=invalid-state')); + exit; } + + if (!empty($_GET['error'])) { + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=oauth-denied')); + exit; + } + + $code = isset($_GET['code']) ? sanitize_text_field(wp_unslash($_GET['code'])) : ''; + $settings = $this->get_settings(); + if ($code === '' || empty($settings['google_client_id']) || empty($settings['google_client_secret'])) { + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=oauth-failed')); + exit; + } + + $response = wp_remote_post('https://oauth2.googleapis.com/token', [ + 'timeout' => 20, + 'body' => [ + 'code' => $code, + 'client_id' => $settings['google_client_id'], + 'client_secret' => $settings['google_client_secret'], + 'redirect_uri' => $this->get_google_redirect_uri(), + 'grant_type' => 'authorization_code', + ], + ]); + if (is_wp_error($response)) { + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=oauth-failed')); + exit; + } + $body = json_decode((string) wp_remote_retrieve_body($response), true); + if ((int) wp_remote_retrieve_response_code($response) < 200 || (int) wp_remote_retrieve_response_code($response) >= 300 || !is_array($body) || empty($body['access_token'])) { + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=oauth-failed')); + exit; + } + + $email = ''; + if (!empty($body['id_token'])) { + $parts = explode('.', (string) $body['id_token']); + if (count($parts) === 3) { + $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true); + if (is_array($payload) && !empty($payload['email'])) { + $email = sanitize_email((string) $payload['email']); + } + } + } + + $this->update_google_settings([ + 'google_access_token' => sanitize_text_field((string) $body['access_token']), + 'google_refresh_token' => !empty($body['refresh_token']) ? sanitize_text_field((string) $body['refresh_token']) : $settings['google_refresh_token'], + 'google_token_expires_at' => time() + max(60, (int) ($body['expires_in'] ?? 3600)), + 'google_connected_email' => $email, + ]); + + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=connected')); + exit; + } + + public function handle_google_generate_verification_token() { + if (!current_user_can('manage_options')) { + wp_die(esc_html__('You do not have permission to generate a Google verification token.', 'ansico-stat-plugin')); + } + check_admin_referer('ansico_stat_google_generate_verification_token'); + + $verification_property = $this->get_google_auto_verification_property(); + $details = $this->get_google_verification_request_details($verification_property); + $result = $this->google_api_request('POST', 'https://www.googleapis.com/siteVerification/v1/token', [ + 'site' => [ + 'type' => $details['site_type'], + 'identifier' => $details['identifier'], + ], + 'verificationMethod' => $details['method'], + ]); + if (is_wp_error($result) || empty($result['token'])) { + $message = is_wp_error($result) ? $result->get_error_message() : __('Could not generate a Google site verification token.', 'ansico-stat-plugin'); + set_transient('ansico_stat_google_notice_' . get_current_user_id(), [ + 'type' => 'error', + 'message' => $message, + ], MINUTE_IN_SECONDS * 5); + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=token-failed')); + exit; + } + + $this->update_google_settings([ + 'google_site_verification_token' => sanitize_text_field((string) $result['token']), + 'google_site_verification_method' => sanitize_key((string) $details['method']), + ]); + set_transient('ansico_stat_google_notice_' . get_current_user_id(), [ + 'type' => 'success', + 'message' => __('Google site verification token generated for the URL-prefix property.', 'ansico-stat-plugin'), + ], MINUTE_IN_SECONDS * 5); + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=token-generated')); + exit; + } + + public function handle_google_verify_site() { + if (!current_user_can('manage_options')) { + wp_die(esc_html__('You do not have permission to verify the site in Google Search Console.', 'ansico-stat-plugin')); + } + check_admin_referer('ansico_stat_google_verify_site'); + + $property = $this->get_google_auto_verification_property(); + $details = $this->get_google_verification_request_details($property); + $result = $this->google_api_request('PUT', 'https://www.googleapis.com/webmasters/v3/sites/' . rawurlencode($property), null); + if (is_wp_error($result) && !in_array($result->get_error_code(), ['http_409', 'http_201'], true)) { + wp_safe_redirect(add_query_arg([ + 'page' => 'ansico-stat-plugin', + 'tab' => 'settings', + 'google_status' => 'verify-failed', + 'google_message' => rawurlencode($result->get_error_message()), + ], admin_url('admin.php'))); + exit; + } + $result = $this->google_api_request('POST', 'https://www.googleapis.com/siteVerification/v1/webResource?verificationMethod=' . rawurlencode($details['method']), [ + 'site' => [ + 'type' => $details['site_type'], + 'identifier' => $details['identifier'], + ], + ]); + + if (is_wp_error($result)) { + set_transient('ansico_stat_google_notice_' . get_current_user_id(), [ + 'type' => 'error', + 'message' => $result->get_error_message(), + ], MINUTE_IN_SECONDS * 5); + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=verify-failed')); + exit; + } + + // Try to add the verified property to Search Console. + $this->google_api_request('PUT', 'https://www.googleapis.com/webmasters/v3/sites/' . rawurlencode($property), null); + $this->update_google_settings([ + 'google_property' => $property, + ]); + + set_transient('ansico_stat_google_notice_' . get_current_user_id(), [ + 'type' => 'success', + 'message' => __('Site verification completed.', 'ansico-stat-plugin'), + ], MINUTE_IN_SECONDS * 5); + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&google-status=verified')); + exit; + } + + + + protected function get_google_available_properties() { + $sites = $this->get_google_sites(); + if (is_wp_error($sites)) { + return []; + } + + $properties = []; + foreach ($sites as $site) { + $site_url = sanitize_text_field((string) ($site['siteUrl'] ?? '')); + if ($site_url === '') { + continue; + } + $properties[] = [ + 'siteUrl' => $site_url, + 'permissionLevel' => sanitize_text_field((string) ($site['permissionLevel'] ?? '')), + ]; + } + + usort($properties, function ($a, $b) { + return strcmp($a['siteUrl'], $b['siteUrl']); + }); + + return $properties; + } + + protected function get_month_date_range(string $month_key) { + $month_key = preg_match('/^\d{4}-\d{2}$/', $month_key) ? $month_key : wp_date('Y-m', current_time('timestamp')); + $start = $month_key . '-01'; + $end = wp_date('Y-m-t', strtotime($start)); + return [$start, $end]; + } + + protected function sync_google_search_queries_for_month(string $month_key, int $row_limit = 50) { + global $wpdb; + + $settings = $this->get_settings(); + $property = sanitize_text_field((string) ($settings['google_property'] ?? '')); + if ($property === '') { + return new WP_Error('ansico_google_missing_property', __('No Google Search Console property has been selected yet.', 'ansico-stat-plugin')); + } + + [$start_date, $end_date] = $this->get_month_date_range($month_key); + $endpoint = 'https://www.googleapis.com/webmasters/v3/sites/' . rawurlencode($property) . '/searchAnalytics/query'; + $result = $this->google_api_request('POST', $endpoint, [ + 'startDate' => $start_date, + 'endDate' => $end_date, + 'dimensions' => ['query'], + 'rowLimit' => max(1, min(250, $row_limit)), + 'dataState' => 'final', + ]); + + if (is_wp_error($result)) { + return $result; + } + + $table = self::search_query_table_name(); + $wpdb->delete($table, [ + 'month_key' => $month_key, + 'property_url' => $property, + ], ['%s', '%s']); + + $rows = is_array($result['rows'] ?? null) ? $result['rows'] : []; + $timestamp = current_time('mysql'); + foreach ($rows as $row) { + $query = sanitize_text_field((string) (($row['keys'][0] ?? ''))); + if ($query === '') { + continue; + } + $wpdb->replace($table, [ + 'month_key' => $month_key, + 'property_url' => $property, + 'query_text' => mb_substr($query, 0, 255), + 'clicks' => max(0, (int) round((float) ($row['clicks'] ?? 0))), + 'impressions' => max(0, (int) round((float) ($row['impressions'] ?? 0))), + 'ctr' => (float) ($row['ctr'] ?? 0), + 'position' => (float) ($row['position'] ?? 0), + 'updated_at' => $timestamp, + ], ['%s', '%s', '%s', '%d', '%d', '%f', '%f', '%s']); + } + + $this->update_google_settings([ + 'google_last_sync_month' => $month_key, + ]); + + return count($rows); + } + + protected function get_google_search_queries_for_month(string $month_key, int $limit = 50) { + global $wpdb; + $settings = $this->get_settings(); + $property = sanitize_text_field((string) ($settings['google_property'] ?? '')); + if ($property === '') { + return []; + } + + $table = self::search_query_table_name(); + return $wpdb->get_results($wpdb->prepare( + "SELECT query_text, clicks, impressions, ctr, position, updated_at + FROM {$table} + WHERE month_key = %s AND property_url = %s + ORDER BY clicks DESC, impressions DESC, query_text ASC + LIMIT %d", + $month_key, + $property, + max(1, min(250, $limit)) + ), ARRAY_A) ?: []; + } + + protected function render_google_search_queries_table(array $rows, string $empty_message = '') { + if ($empty_message === '') { + $empty_message = __('No Google Search Console query data has been imported for this month yet.', 'ansico-stat-plugin'); + } + + if (empty($rows)) { + echo '

' . esc_html($empty_message) . '

'; + return; + } + $opened_scroll = $this->maybe_open_scrollable_table_wrapper(count($rows)); ?> -
-

-

- -
-
- -
-
- - -
-
- - -
-
- - -
-
- -
-
-

-
-
- - -
-

-
- -
-
-
-

-

-

-
-
- - -
-
- render_single_stats_summary_cards([ - [ - 'label' => __('Views in selected period', 'ansico-stat-plugin'), - 'value' => number_format_i18n($range_views), - 'description' => sprintf(__('Avg. %s per day', 'ansico-stat-plugin'), number_format_i18n($avg_range, 1)), - ], - [ - 'label' => __('Unique visitors in selected period', 'ansico-stat-plugin'), - 'value' => number_format_i18n($range_unique), - 'description' => sprintf(__('Avg. %s per day', 'ansico-stat-plugin'), number_format_i18n($avg_unique_range, 1)), - ], - [ - 'label' => __('Views today', 'ansico-stat-plugin'), - 'value' => number_format_i18n($views_today), - 'description' => __('Today only', 'ansico-stat-plugin'), - ], - [ - 'label' => __('Unique visitors today', 'ansico-stat-plugin'), - 'value' => number_format_i18n($unique_today), - 'description' => __('Today only', 'ansico-stat-plugin'), - ], - [ - 'label' => __('Views last 7 days', 'ansico-stat-plugin'), - 'value' => number_format_i18n($views_7), - 'description' => __('Rolling 7-day total', 'ansico-stat-plugin'), - ], - [ - 'label' => __('Unique visitors last 7 days', 'ansico-stat-plugin'), - 'value' => number_format_i18n($unique_7), - 'description' => __('Rolling 7-day total', 'ansico-stat-plugin'), - ], - [ - 'label' => __('Views last 30 days', 'ansico-stat-plugin'), - 'value' => number_format_i18n($views_30), - 'description' => __('Rolling 30-day total', 'ansico-stat-plugin'), - ], - [ - 'label' => __('Unique visitors last 30 days', 'ansico-stat-plugin'), - 'value' => number_format_i18n($unique_30), - 'description' => __('Rolling 30-day total', 'ansico-stat-plugin'), - ], - [ - 'label' => __('Lifetime views', 'ansico-stat-plugin'), - 'value' => number_format_i18n($lifetime_views), - 'description' => $first_tracked !== '' ? sprintf(__('Tracked since %s', 'ansico-stat-plugin'), wp_date(get_option('date_format'), strtotime($first_tracked))) : '', - ], - [ - 'label' => __('Lifetime unique visitors', 'ansico-stat-plugin'), - 'value' => number_format_i18n($lifetime_unique_visitors), - 'description' => __('Stored from version 1.0.0.6 and forward', 'ansico-stat-plugin'), - ], - [ - 'label' => __('Best day', 'ansico-stat-plugin'), - 'value' => !empty($best_day['views']) ? number_format_i18n((int) $best_day['views']) : '0', - 'description' => !empty($best_day['stat_date']) ? wp_date(get_option('date_format'), strtotime((string) $best_day['stat_date'])) : __('No tracked data yet', 'ansico-stat-plugin'), - ], - [ - 'label' => __('Tracked days with views', 'ansico-stat-plugin'), - 'value' => number_format_i18n($tracked_days), - 'description' => __('Days stored in the daily statistics table', 'ansico-stat-plugin'), - ], - ]); ?> -

-
- -
-

- render_dual_chart_markup($daily_chart, $unique_daily_chart, 'ansico-single-target-dual-chart', __('Views and unique visitors for selected page', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> -
- -
-

- render_chart_markup($daily_chart, 'ansico-single-target-daily-chart'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> -
- -
-

- render_chart_markup($unique_daily_chart, 'ansico-single-target-unique-daily-chart'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> -
- -
-

- render_bar_chart_markup($monthly_chart, 'ansico-single-target-monthly-chart', __('Monthly totals for selected page', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> -
- -
-

- render_bar_chart_markup($weekday_chart, 'ansico-single-target-weekday-chart', __('Views by weekday for selected page', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> -
- -
-

- - - - - - - - - - - - - - - - - - - - - - - - - - -
format_change_percent($compare_range['percent_change'] ?? null)); ?>
format_change_percent($unique_compare_range['percent_change'] ?? null)); ?>
-
- -
+ + + + + + + + + + + + + $row) : ?> + + + + + + + + + + +
+ maybe_close_scrollable_table_wrapper($opened_scroll); ?> sync_google_search_queries_for_month($month); + if (is_wp_error($result)) { + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_STATS . '&month=' . rawurlencode($month) . '&google-sync=failed')); + exit; + } + + wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_STATS . '&month=' . rawurlencode($month) . '&google-sync=success')); + exit; + } + public function render_settings_page() { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to access this page.', 'ansico-stat-plugin')); } $settings = $this->get_settings(); + $google_properties = $this->is_google_connected() ? $this->get_google_available_properties() : []; + $google_auth_url = $this->build_google_oauth_authorization_url(); ?>

@@ -3150,12 +4305,24 @@ if (!class_exists('Ansico_Stat_Plugin')) {

+ + + +

+ +

+ + + +

+

+ @@ -3230,11 +4397,12 @@ if (!class_exists('Ansico_Stat_Plugin')) {

+ - + @@ -3243,6 +4411,115 @@ if (!class_exists('Ansico_Stat_Plugin')) { + +
+

+

get_google_search_console_help_text()); ?>

+

+ + + +

+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

@@ -3268,33 +4545,54 @@ if (!class_exists('Ansico_Stat_Plugin')) { $settings = $this->get_settings(); $top_rows = max(1, min(100, (int) $settings['top_list_rows'])); - $referral_rows_limit = max(1, min(200, (int) $settings['referral_rows'])); $chart_data = $this->get_daily_chart_data(60); + foreach ($chart_data as &$chart_row) { + if (!empty($chart_row['date'])) { + $timestamp = strtotime((string) $chart_row['date']); + if ($timestamp) { + $chart_row['label'] = wp_date('j', $timestamp); + $chart_row['tooltip_label'] = wp_date('j. F Y', $timestamp); + } + } + } + unset($chart_row); $selected_month = $this->get_selected_month(); $monthly_rows = $this->get_monthly_top_posts($selected_month, $top_rows); $referral_summary = $this->get_referral_summary_for_month($selected_month); $pie_summary = [ - __('Direct', 'ansico-stat-plugin') => (int) $referral_summary['direct'], - __('Search engines', 'ansico-stat-plugin') => (int) $referral_summary['search'], - __('Social media', 'ansico-stat-plugin') => (int) $referral_summary['social'], - __('Other websites', 'ansico-stat-plugin') => (int) $referral_summary['website'], + __('Direct', 'ansico-stat-plugin') => (int) ($referral_summary['direct']['visits'] ?? 0), + __('Search engines', 'ansico-stat-plugin') => (int) ($referral_summary['search']['visits'] ?? 0), + __('Social media', 'ansico-stat-plugin') => (int) ($referral_summary['social']['visits'] ?? 0), + __('Other websites', 'ansico-stat-plugin') => (int) ($referral_summary['website']['visits'] ?? 0), ]; - $search_referrals = $this->get_referral_sources_for_month($selected_month, 'search', $referral_rows_limit); - $social_referrals = $this->get_referral_sources_for_month($selected_month, 'social', $referral_rows_limit); - $website_referrals = $this->get_referral_sources_for_month($selected_month, 'website', $referral_rows_limit); + $search_referrals = $this->get_referral_sources_for_month($selected_month, 'search'); + $social_referrals = $this->get_referral_sources_for_month($selected_month, 'social'); + $website_referrals = $this->get_referral_sources_for_month($selected_month, 'website'); $monthly_404_rows = $this->get_top_404_pages_for_month($selected_month, $top_rows); $monthly_country_rows = $this->get_dimension_rows_for_month($selected_month, 'country', $top_rows); $monthly_device_rows = $this->get_dimension_rows_for_month($selected_month, 'device', max(3, $top_rows)); $monthly_browser_rows = $this->get_dimension_rows_for_month($selected_month, 'browser', $top_rows); $monthly_os_rows = $this->get_dimension_rows_for_month($selected_month, 'os', $top_rows); + $monthly_landing_rows = $this->get_monthly_top_landing_pages($selected_month, $top_rows); + $monthly_internal_search_rows = $this->get_dimension_rows_for_month($selected_month, 'internal_search', $top_rows); + $monthly_search_query_rows = $this->get_google_search_queries_for_month($selected_month, max(25, $top_rows)); ?>

+ + + +

+ +

+ + +
-

- render_chart_markup($chart_data, 'ansico-stat-admin-chart'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +

+ render_multi_line_chart_markup($chart_data, 'ansico-stat-admin-chart', ['aria_label' => __('Daily views and unique visitors (last 60 days)', 'ansico-stat-plugin')]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
@@ -3306,6 +4604,46 @@ if (!class_exists('Ansico_Stat_Plugin')) { render_top_posts_table($monthly_rows, __('Monthly views', 'ansico-stat-plugin')); ?>
+
+

+ render_month_navigation($selected_month); ?> + render_landing_pages_table($monthly_landing_rows, __('No landing pages tracked for this month yet.', 'ansico-stat-plugin')); ?> +
+ +
+

+ render_month_navigation($selected_month); ?> +
+
render_dimension_table($monthly_internal_search_rows, __('Search query', 'ansico-stat-plugin')); ?>
+
render_pie_chart_markup($this->get_top_5_plus_others_segments($monthly_internal_search_rows), 'ansico-monthly-internal-search-pie', __('Monthly internal search queries', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+
+
+ +
+
+

+ is_google_connected() && !empty($settings['google_property'])) : ?> +
+ + + + +
+ +
+ render_month_navigation($selected_month); ?> + is_google_connected()) : ?> +

+ +

+ + +

+ + render_google_search_queries_table($monthly_search_query_rows, __('No Google Search Console query data has been synced for this month yet.', 'ansico-stat-plugin')); ?> + +
+

render_month_navigation($selected_month); ?> @@ -3331,17 +4669,19 @@ if (!class_exists('Ansico_Stat_Plugin')) {
- is_unknown_only_dimension_rows($monthly_country_rows)) : ?>

render_month_navigation($selected_month); ?> -

+

+ is_unknown_only_dimension_rows($monthly_country_rows)) : ?> +

+
render_dimension_table($monthly_country_rows, __('Country', 'ansico-stat-plugin')); ?>
-
render_bar_chart_markup($monthly_country_rows, 'ansico-monthly-country-chart', __('Monthly countries', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+
render_pie_chart_markup($this->get_top_5_plus_others_segments($monthly_country_rows), 'ansico-monthly-country-pie', __('Monthly countries', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+
-

@@ -3357,7 +4697,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { render_month_navigation($selected_month); ?>
render_dimension_table($monthly_browser_rows, __('Browser', 'ansico-stat-plugin')); ?>
-
render_bar_chart_markup($monthly_browser_rows, 'ansico-monthly-browser-chart', __('Monthly browsers', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+
render_pie_chart_markup($this->get_top_5_plus_others_segments($monthly_browser_rows), 'ansico-monthly-browser-pie', __('Monthly browsers', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
@@ -3366,7 +4706,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { render_month_navigation($selected_month); ?>
render_dimension_table($monthly_os_rows, __('Operating system', 'ansico-stat-plugin')); ?>
-
render_bar_chart_markup($monthly_os_rows, 'ansico-monthly-os-chart', __('Monthly operating systems', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+
render_pie_chart_markup($this->get_top_5_plus_others_segments($monthly_os_rows), 'ansico-monthly-os-pie', __('Monthly operating systems', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
@@ -3386,17 +4726,19 @@ if (!class_exists('Ansico_Stat_Plugin')) { $yearly_device_rows = $this->get_dimension_rows_for_year($selected_year, 'device', max(3, $top_rows)); $yearly_browser_rows = $this->get_dimension_rows_for_year($selected_year, 'browser', $top_rows); $yearly_os_rows = $this->get_dimension_rows_for_year($selected_year, 'os', $top_rows); + $yearly_internal_search_rows = $this->get_dimension_rows_for_year($selected_year, 'internal_search', $top_rows); $yearly_referral_summary = $this->get_referral_summary_for_year($selected_year); $yearly_month_chart = $this->get_monthly_totals_for_year($selected_year); + $yearly_landing_rows = $this->get_yearly_top_landing_pages($selected_year, $top_rows); ?>

-

+

render_year_navigation($selected_year); ?> - render_bar_chart_markup($yearly_month_chart, 'ansico-yearly-month-bar', __('Yearly views by month', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> + render_multi_line_chart_markup($yearly_month_chart, 'ansico-yearly-month-chart', ['aria_label' => __('Yearly views and unique visitors by month', 'ansico-stat-plugin'), 'max_x_labels' => 12]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
@@ -3405,32 +4747,67 @@ if (!class_exists('Ansico_Stat_Plugin')) { render_top_posts_table($yearly_rows, __('Yearly views', 'ansico-stat-plugin')); ?>
+
+

+ render_year_navigation($selected_year); ?> + render_landing_pages_table($yearly_landing_rows, __('No landing pages tracked for this year yet.', 'ansico-stat-plugin')); ?> +
+ +
+

+ render_year_navigation($selected_year); ?> +
+
render_dimension_table($yearly_internal_search_rows, __('Search query', 'ansico-stat-plugin')); ?>
+
render_pie_chart_markup($this->get_top_5_plus_others_segments($yearly_internal_search_rows), 'ansico-yearly-internal-search-pie', __('Yearly internal search queries', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+
+
+

render_year_navigation($selected_year); ?>
render_referral_summary_table([ - 'direct' => (int) ($yearly_referral_summary['Direct'] ?? 0), - 'search' => (int) ($yearly_referral_summary['Search engines'] ?? 0), - 'social' => (int) ($yearly_referral_summary['Social media'] ?? 0), - 'website' => (int) ($yearly_referral_summary['Other websites'] ?? 0), + 'direct' => [ + 'visits' => (int) ($yearly_referral_summary['Direct']['visits'] ?? 0), + 'unique_visitors' => (int) ($yearly_referral_summary['Direct']['unique_visitors'] ?? 0), + ], + 'search' => [ + 'visits' => (int) ($yearly_referral_summary['Search']['visits'] ?? 0), + 'unique_visitors' => (int) ($yearly_referral_summary['Search']['unique_visitors'] ?? 0), + ], + 'social' => [ + 'visits' => (int) ($yearly_referral_summary['Social']['visits'] ?? 0), + 'unique_visitors' => (int) ($yearly_referral_summary['Social']['unique_visitors'] ?? 0), + ], + 'website' => [ + 'visits' => (int) ($yearly_referral_summary['Other websites']['visits'] ?? 0), + 'unique_visitors' => (int) ($yearly_referral_summary['Other websites']['unique_visitors'] ?? 0), + ], ]); ?>
-
render_pie_chart_markup($yearly_referral_summary, 'ansico-yearly-referral-pie', __('Yearly referred visitors', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+
render_pie_chart_markup([ + __('Direct', 'ansico-stat-plugin') => (int) ($yearly_referral_summary['Direct']['visits'] ?? 0), + __('Search engines', 'ansico-stat-plugin') => (int) ($yearly_referral_summary['Search']['visits'] ?? 0), + __('Social media', 'ansico-stat-plugin') => (int) ($yearly_referral_summary['Social']['visits'] ?? 0), + __('Other websites', 'ansico-stat-plugin') => (int) ($yearly_referral_summary['Other websites']['visits'] ?? 0), + ], 'ansico-yearly-referral-pie', __('Yearly referred visitors', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
- is_unknown_only_dimension_rows($yearly_country_rows)) : ?>

render_year_navigation($selected_year); ?> +

+ is_unknown_only_dimension_rows($yearly_country_rows)) : ?> +

+
render_dimension_table($yearly_country_rows, __('Country', 'ansico-stat-plugin')); ?>
-
render_bar_chart_markup($yearly_country_rows, 'ansico-yearly-country-chart', __('Yearly countries', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+
render_pie_chart_markup($this->get_top_5_plus_others_segments($yearly_country_rows), 'ansico-yearly-country-pie', __('Yearly countries', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+
-

@@ -3446,7 +4823,7 @@ if (!class_exists('Ansico_Stat_Plugin')) { render_year_navigation($selected_year); ?>
render_dimension_table($yearly_browser_rows, __('Browser', 'ansico-stat-plugin')); ?>
-
render_bar_chart_markup($yearly_browser_rows, 'ansico-yearly-browser-chart', __('Yearly browsers', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+
render_pie_chart_markup($this->get_top_5_plus_others_segments($yearly_browser_rows), 'ansico-yearly-browser-pie', __('Yearly browsers', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
@@ -3455,86 +4832,530 @@ if (!class_exists('Ansico_Stat_Plugin')) { render_year_navigation($selected_year); ?>
render_dimension_table($yearly_os_rows, __('Operating system', 'ansico-stat-plugin')); ?>
-
render_bar_chart_markup($yearly_os_rows, 'ansico-yearly-os-chart', __('Yearly operating systems', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+
render_pie_chart_markup($this->get_top_5_plus_others_segments($yearly_os_rows), 'ansico-yearly-os-pie', __('Yearly operating systems', 'ansico-stat-plugin')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
get_lifetime_yearly_totals(); + $weekday_rows = $this->get_lifetime_weekday_distribution(); + ?> +
+

+

+ +
+

+ render_lifetime_yearly_chart_markup($lifetime_chart, 'ansico-lifetime-yearly-chart'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +
+ +
+

+ render_lifetime_yearly_table($lifetime_chart); ?> +
+ +
+

+

+ render_lifetime_weekday_grouped_bar_chart($weekday_rows, 'ansico-lifetime-weekday-grouped-bar'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +
+ +
+

+ render_lifetime_weekday_distribution_table($weekday_rows); ?> +
+
+ date_days_ago(29); + $start = isset($_GET['start_date']) ? $this->sanitize_stats_date((string) wp_unslash($_GET['start_date'])) : ''; + $end = isset($_GET['end_date']) ? $this->sanitize_stats_date((string) wp_unslash($_GET['end_date'])) : ''; + if ($start === '') { + $start = $default_start; + } + if ($end === '') { + $end = $default_end; + } + if (strtotime($start) > strtotime($end)) { + [$start, $end] = [$end, $start]; + } + return [ + 'start' => $start, + 'end' => $end, + 'days' => max(1, (int) floor((strtotime($end) - strtotime($start)) / DAY_IN_SECONDS) + 1), + ]; + } + + protected function build_single_stats_admin_url(string $target_url, string $start_date = '', string $end_date = '') { + $args = [ + 'page' => self::MENU_SLUG_SINGLE, + 'target_url' => $target_url !== '' ? $target_url : home_url('/'), + ]; + if ($start_date !== '') { + $args['start_date'] = $start_date; + } + if ($end_date !== '') { + $args['end_date'] = $end_date; + } + return add_query_arg($args, admin_url('admin.php')); + } + + public function register_frontend_admin_bar_link($wp_admin_bar) { + if (is_admin() || !is_admin_bar_showing() || !current_user_can('manage_options') || !$this->is_trackable_request()) { + return; + } + $page_context = $this->get_current_page_context(); + if (empty($page_context['url'])) { + return; + } + $target_url = $this->normalize_target_url((string) $page_context['url']); + if ($target_url === '') { + return; + } + $wp_admin_bar->add_node([ + 'id' => 'ansico-stat-single-page', + 'parent' => 'top-secondary', + 'title' => '' . esc_html__('Page stats', 'ansico-stat-plugin') . '', + 'href' => esc_url($this->build_single_stats_admin_url($target_url)), + 'meta' => [ + 'class' => 'ansico-stat-admin-bar-link', + 'title' => esc_attr__('Open statistics for this page', 'ansico-stat-plugin'), + ], + ]); + } + + protected function resolve_single_stats_target(string $url) { + global $wpdb; + $normalized_url = $this->normalize_target_url($url); + if ($normalized_url === '') { + return ['success' => false, 'message' => __('Please enter a valid URL.', 'ansico-stat-plugin')]; + } + $site_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST)); + $target_host = strtolower((string) wp_parse_url($normalized_url, PHP_URL_HOST)); + if ($site_host === '' || $target_host === '' || preg_replace('/^www\\./', '', $site_host) !== preg_replace('/^www\\./', '', $target_host)) { + return ['success' => false, 'message' => __('The URL must belong to this WordPress site.', 'ansico-stat-plugin')]; + } + $post_id = url_to_postid($normalized_url); + if ($post_id > 0) { + $post = get_post($post_id); + if ($this->is_trackable_post($post)) { + return [ + 'success' => true, + 'target' => [ + 'kind' => 'singular', + 'post_id' => (int) $post_id, + 'page_key' => 'post:' . (int) $post_id, + 'label' => get_the_title($post_id) ?: __('(no title)', 'ansico-stat-plugin'), + 'url' => get_permalink($post_id) ?: $normalized_url, + ], + ]; + } + } + $front_url = $this->normalize_target_url(home_url('/')); + if ($normalized_url === $front_url) { + return [ + 'success' => true, + 'target' => [ + 'kind' => 'archive', + 'page_key' => 'front_page', + 'label' => __('Front page', 'ansico-stat-plugin'), + 'url' => home_url('/'), + ], + ]; + } + $table = self::page_daily_table_name(); + $variants = array_values(array_unique(array_filter([$normalized_url, untrailingslashit($normalized_url), trailingslashit($normalized_url)]))); + if (!empty($variants)) { + $placeholders = implode(',', array_fill(0, count($variants), '%s')); + $row = $wpdb->get_row($wpdb->prepare("SELECT page_key, page_label, page_url FROM {$table} WHERE page_url IN ({$placeholders}) ORDER BY stat_date DESC LIMIT 1", $variants), ARRAY_A); + if (is_array($row) && !empty($row['page_key'])) { + return [ + 'success' => true, + 'target' => [ + 'kind' => 'archive', + 'page_key' => (string) $row['page_key'], + 'label' => sanitize_text_field((string) ($row['page_label'] ?? $normalized_url)), + 'url' => !empty($row['page_url']) ? esc_url_raw((string) $row['page_url']) : $normalized_url, + ], + ]; + } + } + return ['success' => false, 'message' => __('No tracked page or post was found for that URL yet.', 'ansico-stat-plugin')]; + } + + protected function get_target_lifetime_views(array $target) { + global $wpdb; + if (($target['kind'] ?? '') === 'singular') { + return (int) get_post_meta((int) $target['post_id'], self::TOTAL_META_KEY, true); + } + $table = self::page_daily_table_name(); + return (int) $wpdb->get_var($wpdb->prepare("SELECT SUM(views) FROM {$table} WHERE page_key = %s", (string) $target['page_key'])); + } + + protected function get_target_lifetime_unique_visitors(array $target) { + global $wpdb; + $table = self::unique_page_table_name(); + return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(DISTINCT visitor_hash) FROM {$table} WHERE page_key = %s", (string) $target['page_key'])); + } + + protected function get_target_period_views(array $target, string $start, string $end) { + global $wpdb; + if (($target['kind'] ?? '') === 'singular') { + $table = self::post_daily_table_name(); + return (int) $wpdb->get_var($wpdb->prepare("SELECT SUM(views) FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s", (int) $target['post_id'], $start, $end)); + } + $table = self::page_daily_table_name(); + return (int) $wpdb->get_var($wpdb->prepare("SELECT SUM(views) FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s", (string) $target['page_key'], $start, $end)); + } + + protected function get_target_period_unique_visitors(array $target, string $start, string $end) { + global $wpdb; + if (($target['kind'] ?? '') === 'singular') { + $table = self::post_daily_table_name(); + return (int) $wpdb->get_var($wpdb->prepare("SELECT SUM(unique_visitors) FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s", (int) $target['post_id'], $start, $end)); + } + $table = self::page_daily_table_name(); + return (int) $wpdb->get_var($wpdb->prepare("SELECT SUM(unique_visitors) FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s", (string) $target['page_key'], $start, $end)); + } + + protected function get_target_daily_combined_chart_data_between(array $target, string $start, string $end) { + global $wpdb; + if (($target['kind'] ?? '') === 'singular') { + $table = self::post_daily_table_name(); + $rows = $wpdb->get_results($wpdb->prepare("SELECT stat_date, views, unique_visitors FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s ORDER BY stat_date ASC", (int) $target['post_id'], $start, $end), ARRAY_A); + } else { + $table = self::page_daily_table_name(); + $rows = $wpdb->get_results($wpdb->prepare("SELECT stat_date, views, unique_visitors FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s ORDER BY stat_date ASC", (string) $target['page_key'], $start, $end), ARRAY_A); + } + $indexed = []; + foreach ((array) $rows as $row) { + $indexed[(string) $row['stat_date']] = [ + 'views' => (int) ($row['views'] ?? 0), + 'unique_visitors' => (int) ($row['unique_visitors'] ?? 0), + ]; + } + $data = []; + $cursor = strtotime($start); + $end_ts = strtotime($end); + while ($cursor <= $end_ts) { + $date = gmdate('Y-m-d', $cursor); + $data[] = [ + 'date' => $date, + 'label' => wp_date('j M', $cursor), + 'tooltip_label' => wp_date(get_option('date_format'), $cursor), + 'views' => (int) ($indexed[$date]['views'] ?? 0), + 'unique_visitors' => (int) ($indexed[$date]['unique_visitors'] ?? 0), + ]; + $cursor = strtotime('+1 day', $cursor); + } + return $data; + } + + protected function get_target_monthly_combined_chart_data_between(array $target, string $start, string $end) { + global $wpdb; + if (($target['kind'] ?? '') === 'singular') { + $table = self::post_daily_table_name(); + $rows = $wpdb->get_results($wpdb->prepare("SELECT DATE_FORMAT(stat_date, '%%Y-%%m') AS month_key, SUM(views) AS total_views, SUM(unique_visitors) AS total_unique FROM {$table} WHERE post_id = %d AND stat_date BETWEEN %s AND %s GROUP BY month_key ORDER BY month_key ASC", (int) $target['post_id'], $start, $end), ARRAY_A); + } else { + $table = self::page_daily_table_name(); + $rows = $wpdb->get_results($wpdb->prepare("SELECT DATE_FORMAT(stat_date, '%%Y-%%m') AS month_key, SUM(views) AS total_views, SUM(unique_visitors) AS total_unique FROM {$table} WHERE page_key = %s AND stat_date BETWEEN %s AND %s GROUP BY month_key ORDER BY month_key ASC", (string) $target['page_key'], $start, $end), ARRAY_A); + } + $indexed = []; + foreach ((array) $rows as $row) { + $indexed[(string) $row['month_key']] = [ + 'views' => (int) ($row['total_views'] ?? 0), + 'unique_visitors' => (int) ($row['total_unique'] ?? 0), + ]; + } + $data = []; + $cursor = strtotime(gmdate('Y-m-01', strtotime($start))); + $end_month = strtotime(gmdate('Y-m-01', strtotime($end))); + while ($cursor <= $end_month) { + $month_key = gmdate('Y-m', $cursor); + $data[] = [ + 'label' => wp_date('M Y', $cursor), + 'tooltip_label' => wp_date('F Y', $cursor), + 'views' => (int) ($indexed[$month_key]['views'] ?? 0), + 'unique_visitors' => (int) ($indexed[$month_key]['unique_visitors'] ?? 0), + ]; + $cursor = strtotime('+1 month', $cursor); + } + return $data; + } + + protected function get_target_weekday_breakdown_between(array $target, string $start, string $end) { + $daily = $this->get_target_daily_combined_chart_data_between($target, $start, $end); + $map = [ + __('Mon', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0], + __('Tue', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0], + __('Wed', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0], + __('Thu', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0], + __('Fri', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0], + __('Sat', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0], + __('Sun', 'ansico-stat-plugin') => ['views' => 0, 'unique_visitors' => 0], + ]; + $keys = array_keys($map); + foreach ($daily as $row) { + $timestamp = strtotime((string) ($row['date'] ?? '')); + if (!$timestamp) { + continue; + } + $index = (int) gmdate('N', $timestamp) - 1; + if (isset($keys[$index])) { + $map[$keys[$index]]['views'] += (int) ($row['views'] ?? 0); + $map[$keys[$index]]['unique_visitors'] += (int) ($row['unique_visitors'] ?? 0); + } + } + $rows = []; + foreach ($map as $label => $vals) { + $rows[] = ['label' => $label, 'views' => (int) $vals['views'], 'unique_visitors' => (int) $vals['unique_visitors']]; + } + return $rows; + } + + protected function get_target_best_day(array $target) { + global $wpdb; + if (($target['kind'] ?? '') === 'singular') { + $table = self::post_daily_table_name(); + $row = $wpdb->get_row($wpdb->prepare("SELECT stat_date, views, unique_visitors FROM {$table} WHERE post_id = %d ORDER BY views DESC, stat_date ASC LIMIT 1", (int) $target['post_id']), ARRAY_A); + } else { + $table = self::page_daily_table_name(); + $row = $wpdb->get_row($wpdb->prepare("SELECT stat_date, views, unique_visitors FROM {$table} WHERE page_key = %s ORDER BY views DESC, stat_date ASC LIMIT 1", (string) $target['page_key']), ARRAY_A); + } + return is_array($row) ? $row : []; + } + + protected function get_target_first_tracked_date(array $target) { + global $wpdb; + if (($target['kind'] ?? '') === 'singular') { + $table = self::post_daily_table_name(); + $date = $wpdb->get_var($wpdb->prepare("SELECT MIN(stat_date) FROM {$table} WHERE post_id = %d", (int) $target['post_id'])); + } else { + $table = self::page_daily_table_name(); + $date = $wpdb->get_var($wpdb->prepare("SELECT MIN(stat_date) FROM {$table} WHERE page_key = %s", (string) $target['page_key'])); + } + return is_string($date) ? $date : ''; + } + + protected function get_target_total_tracked_days(array $target) { + global $wpdb; + if (($target['kind'] ?? '') === 'singular') { + $table = self::post_daily_table_name(); + return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE post_id = %d", (int) $target['post_id'])); + } + $table = self::page_daily_table_name(); + return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE page_key = %s", (string) $target['page_key'])); + } + + protected function build_single_export_url(string $target_url, string $start_date = '', string $end_date = '') { + $args = ['action' => 'ansico_stat_export_single_csv', 'target_url' => $target_url]; + if ($start_date !== '') $args['start_date'] = $start_date; + if ($end_date !== '') $args['end_date'] = $end_date; + $url = add_query_arg($args, admin_url('admin-post.php')); + return wp_nonce_url($url, 'ansico_stat_export_single_csv'); + } + + protected function render_single_stats_summary_cards(array $items) { + echo '
'; + foreach ($items as $item) { + echo '
'; + echo '

' . esc_html((string) ($item['label'] ?? '')) . '

'; + echo '

' . esc_html((string) ($item['value'] ?? '0')) . '

'; + if (!empty($item['description'])) { + echo '

' . esc_html((string) $item['description']) . '

'; + } + echo '
'; + } + echo '
'; + } + + public function render_single_page_stats_page() { + if (!current_user_can('manage_options')) { + return; + } + $requested_url = $this->get_requested_single_stats_url(); + $range = $this->get_single_stats_date_range(); + $result = $this->resolve_single_stats_target($requested_url); + $target = !empty($result['success']) ? (array) ($result['target'] ?? []) : []; + $range_start = $range['start']; + $range_end = $range['end']; + $daily_chart = !empty($target) ? $this->get_target_daily_combined_chart_data_between($target, $range_start, $range_end) : []; + $monthly_chart = !empty($target) ? $this->get_target_monthly_combined_chart_data_between($target, $range_start, $range_end) : []; + $weekday_rows = !empty($target) ? $this->get_target_weekday_breakdown_between($target, $range_start, $range_end) : []; + $lifetime_views = !empty($target) ? $this->get_target_lifetime_views($target) : 0; + $lifetime_unique = !empty($target) ? $this->get_target_lifetime_unique_visitors($target) : 0; + $period_views = !empty($target) ? $this->get_target_period_views($target, $range_start, $range_end) : 0; + $period_unique = !empty($target) ? $this->get_target_period_unique_visitors($target, $range_start, $range_end) : 0; + $best_day = !empty($target) ? $this->get_target_best_day($target) : []; + $tracked_days = !empty($target) ? $this->get_target_total_tracked_days($target) : 0; + $first_tracked = !empty($target) ? $this->get_target_first_tracked_date($target) : ''; + $export_url = !empty($target) ? $this->build_single_export_url($requested_url, $range_start, $range_end) : ''; + ?> +
+
+
+

+

+
+
+ +
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+ + +

+ +
+
+ +

+ +
+
+ + render_single_stats_summary_cards([ + ['label' => __('Views in selected period', 'ansico-stat-plugin'), 'value' => number_format_i18n($period_views)], + ['label' => __('Unique visitors in selected period', 'ansico-stat-plugin'), 'value' => number_format_i18n($period_unique)], + ['label' => __('Lifetime views', 'ansico-stat-plugin'), 'value' => number_format_i18n($lifetime_views)], + ['label' => __('Lifetime unique visitors', 'ansico-stat-plugin'), 'value' => number_format_i18n($lifetime_unique)], + ['label' => __('Tracked days', 'ansico-stat-plugin'), 'value' => number_format_i18n($tracked_days), 'description' => $first_tracked !== '' ? sprintf(__('Since %s', 'ansico-stat-plugin'), wp_date(get_option('date_format'), strtotime($first_tracked))) : ''], + ['label' => __('Best day', 'ansico-stat-plugin'), 'value' => !empty($best_day['views']) ? number_format_i18n((int) $best_day['views']) : '0', 'description' => !empty($best_day['stat_date']) ? wp_date(get_option('date_format'), strtotime((string) $best_day['stat_date'])) : ''], + ]); ?> + +
+
+
+

+

+
+ render_multi_line_chart_markup($daily_chart, 'ansico-single-daily-chart', ['label_key' => 'label', 'x_fallback_key' => 'date', 'aria_label' => __('Views and unique visitors for selected page', 'ansico-stat-plugin'), 'max_x_labels' => 10]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +
+ +
+
+

+

+
+ render_multi_line_chart_markup($monthly_chart, 'ansico-single-monthly-chart', ['label_key' => 'label', 'x_fallback_key' => 'label', 'aria_label' => __('Monthly totals for selected page', 'ansico-stat-plugin'), 'max_x_labels' => 12]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +
+ +
+
+

+

+
+ render_lifetime_weekday_grouped_bar_chart($weekday_rows, 'ansico-single-weekday-chart'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +
+
+ +
+ get_requested_single_stats_url(); + $range = $this->get_single_stats_date_range(); $result = $this->resolve_single_stats_target($requested_url); - if (empty($result['success']) || empty($result['target'])) { - wp_die(esc_html__('No tracked page could be resolved for export.', 'ansico-stat-plugin')); + if (empty($result['success'])) { + wp_safe_redirect($this->build_single_stats_admin_url($requested_url, $range['start'], $range['end'])); + exit; } - - $target = (array) $result['target']; - $start_date = isset($_GET['start_date']) ? $this->sanitize_stats_date((string) wp_unslash($_GET['start_date'])) : ''; - $end_date = isset($_GET['end_date']) ? $this->sanitize_stats_date((string) wp_unslash($_GET['end_date'])) : ''; - if ($start_date === '') { - $start_date = $this->date_days_ago(29); - } - if ($end_date === '') { - $end_date = current_time('Y-m-d'); - } - if (strtotime($start_date) > strtotime($end_date)) { - [$start_date, $end_date] = [$end_date, $start_date]; - } - $daily_rows = $this->get_target_daily_chart_data_between($target, $start_date, $end_date, 'views'); - $range_days = max(1, (int) floor((strtotime($end_date) - strtotime($start_date)) / DAY_IN_SECONDS) + 1); - $compare_7 = $this->get_target_period_comparison($target, 7); - $compare_30 = $this->get_target_period_comparison($target, 30); - $compare_range = $this->get_target_period_comparison($target, $range_days); - $unique_compare_7 = $this->get_target_unique_period_comparison($target, 7); - $unique_compare_30 = $this->get_target_unique_period_comparison($target, 30); - $unique_compare_range = $this->get_target_unique_period_comparison($target, $range_days); - $filename_slug = sanitize_title((string) ($target['label'] ?? 'page-stats')); + $target = (array) ($result['target'] ?? []); + $rows = $this->get_target_daily_combined_chart_data_between($target, $range['start'], $range['end']); + $filename_slug = sanitize_title((string) ($target['label'] ?? 'single-page')); if ($filename_slug === '') { - $filename_slug = 'page-stats'; + $filename_slug = 'single-page'; } - $filename = 'ansico-single-page-' . $filename_slug . '-' . wp_date('Y-m-d-H-i-s') . '.csv'; - nocache_headers(); header('Content-Type: text/csv; charset=utf-8'); - header('Content-Disposition: attachment; filename=' . $filename); - + header('Content-Disposition: attachment; filename="ansico-single-page-' . $filename_slug . '-' . wp_date('Y-m-d-H-i-s') . '.csv"'); $output = fopen('php://output', 'w'); - fputcsv($output, ['Selected URL', (string) ($target['url'] ?? '')]); - fputcsv($output, ['Label', (string) ($target['label'] ?? '')]); - fputcsv($output, ['Type', (string) (($target['kind'] ?? '') === 'singular' ? ($target['post_type'] ?? 'post') : ($target['page_type'] ?? 'page'))]); - fputcsv($output, ['Selected period start', $start_date]); - fputcsv($output, ['Selected period end', $end_date]); - fputcsv($output, ['Selected period views', (int) $this->get_target_period_views($target, $start_date, $end_date)]); - fputcsv($output, ['Selected period unique visitors', (int) $this->get_target_period_unique_visitors($target, $start_date, $end_date)]); - fputcsv($output, ['Previous comparable period views', (int) ($compare_range['previous_views'] ?? 0)]); - fputcsv($output, ['Previous comparable period unique visitors', (int) ($unique_compare_range['previous_views'] ?? 0)]); - fputcsv($output, ['Lifetime views', (int) $this->get_target_lifetime_views($target)]); - fputcsv($output, ['Views today', (int) $this->get_target_period_views($target, current_time('Y-m-d'), current_time('Y-m-d'))]); - fputcsv($output, ['Unique visitors today', (int) $this->get_target_period_unique_visitors($target, current_time('Y-m-d'), current_time('Y-m-d'))]); - fputcsv($output, ['Views last 7 days', (int) ($compare_7['current_views'] ?? 0)]); - fputcsv($output, ['Unique visitors last 7 days', (int) ($unique_compare_7['current_views'] ?? 0)]); - fputcsv($output, ['Previous 7 days', (int) ($compare_7['previous_views'] ?? 0)]); - fputcsv($output, ['Previous unique visitors 7 days', (int) ($unique_compare_7['previous_views'] ?? 0)]); - fputcsv($output, ['Views last 30 days', (int) ($compare_30['current_views'] ?? 0)]); - fputcsv($output, ['Unique visitors last 30 days', (int) ($unique_compare_30['current_views'] ?? 0)]); - fputcsv($output, ['Previous 30 days', (int) ($compare_30['previous_views'] ?? 0)]); - fputcsv($output, ['Previous unique visitors 30 days', (int) ($unique_compare_30['previous_views'] ?? 0)]); - fputcsv($output, []); - fputcsv($output, ['Date', 'Views', 'Unique visitors']); - foreach ($daily_rows as $row) { - fputcsv($output, [ - (string) ($row['date'] ?? ''), - (int) ($row['views'] ?? 0), - (int) $this->get_target_period_unique_visitors($target, (string) ($row['date'] ?? ''), (string) ($row['date'] ?? '')), - ]); + if ($output === false) { + exit; + } + fputcsv($output, ['date', 'views', 'unique_visitors']); + foreach ($rows as $row) { + fputcsv($output, [(string) ($row['date'] ?? ''), (int) ($row['views'] ?? 0), (int) ($row['unique_visitors'] ?? 0)]); } fclose($output); exit; @@ -3610,27 +5431,40 @@ if (!class_exists('Ansico_Stat_Plugin')) { check_admin_referer('ansico_stat_save_settings'); $allowed_post_types = $this->get_default_track_post_types(); - $submitted_post_types = isset($_POST['track_post_types']) && is_array($_POST['track_post_types']) ? array_map('sanitize_key', wp_unslash($_POST['track_post_types'])) : []; + $existing_settings = $this->get_settings(); + $settings_section = isset($_POST['settings_section']) ? sanitize_key(wp_unslash($_POST['settings_section'])) : 'tracking'; + $submitted_post_types = ($settings_section === 'tracking' && isset($_POST['track_post_types']) && is_array($_POST['track_post_types'])) ? array_map('sanitize_key', wp_unslash($_POST['track_post_types'])) : $existing_settings['track_post_types']; $submitted_post_types = array_values(array_intersect($allowed_post_types, $submitted_post_types)); - $visitor_counting_mode = isset($_POST['visitor_counting_mode']) ? sanitize_key(wp_unslash($_POST['visitor_counting_mode'])) : 'count_all'; + $visitor_counting_mode = ($settings_section === 'tracking' && isset($_POST['visitor_counting_mode'])) ? sanitize_key(wp_unslash($_POST['visitor_counting_mode'])) : $existing_settings['visitor_counting_mode']; if (!in_array($visitor_counting_mode, ['count_all', 'exclude_admins', 'exclude_all_logged_in'], true)) { - $visitor_counting_mode = 'count_all'; + $visitor_counting_mode = $existing_settings['visitor_counting_mode']; } $settings = [ - 'visitor_counting_mode' => $visitor_counting_mode, - 'exclude_known_bots' => isset($_POST['exclude_known_bots']) ? 1 : 0, - 'top_list_rows' => isset($_POST['top_list_rows']) ? max(1, min(100, (int) $_POST['top_list_rows'])) : 10, - 'referral_rows' => isset($_POST['referral_rows']) ? max(1, min(200, (int) $_POST['referral_rows'])) : 25, - 'revisit_minutes' => isset($_POST['revisit_minutes']) ? max(1, min(10080, (int) $_POST['revisit_minutes'])) : 30, - 'frontend_label' => isset($_POST['frontend_label']) ? sanitize_text_field(wp_unslash($_POST['frontend_label'])) : 'Views', - 'track_post_types' => $submitted_post_types, - 'track_front_page' => isset($_POST['track_front_page']) ? 1 : 0, - 'track_posts_page' => isset($_POST['track_posts_page']) ? 1 : 0, - 'track_archives' => isset($_POST['track_archives']) ? 1 : 0, - 'track_search' => isset($_POST['track_search']) ? 1 : 0, - 'track_404' => isset($_POST['track_404']) ? 1 : 0, + 'visitor_counting_mode' => $visitor_counting_mode, + 'exclude_known_bots' => $settings_section === 'tracking' ? (isset($_POST['exclude_known_bots']) ? 1 : 0) : $existing_settings['exclude_known_bots'], + 'top_list_rows' => ($settings_section === 'tracking' && isset($_POST['top_list_rows'])) ? max(1, min(100, (int) $_POST['top_list_rows'])) : $existing_settings['top_list_rows'], + 'referral_rows' => 0, + 'revisit_minutes' => ($settings_section === 'tracking' && isset($_POST['revisit_minutes'])) ? max(1, min(10080, (int) $_POST['revisit_minutes'])) : $existing_settings['revisit_minutes'], + 'frontend_label' => ($settings_section === 'tracking' && isset($_POST['frontend_label'])) ? sanitize_text_field(wp_unslash($_POST['frontend_label'])) : $existing_settings['frontend_label'], + 'track_post_types' => $submitted_post_types, + 'track_front_page' => $settings_section === 'tracking' ? (isset($_POST['track_front_page']) ? 1 : 0) : $existing_settings['track_front_page'], + 'track_posts_page' => $settings_section === 'tracking' ? (isset($_POST['track_posts_page']) ? 1 : 0) : $existing_settings['track_posts_page'], + 'track_archives' => isset($_POST['track_archives']) ? 1 : (array_key_exists('track_archives', $_POST) ? 0 : $existing_settings['track_archives']), + 'track_search' => isset($_POST['track_search']) ? 1 : (array_key_exists('track_search', $_POST) ? 0 : $existing_settings['track_search']), + 'track_404' => isset($_POST['track_404']) ? 1 : (array_key_exists('track_404', $_POST) ? 0 : $existing_settings['track_404']), + 'ipinfo_token' => ($settings_section === 'tracking' && isset($_POST['ipinfo_token'])) ? sanitize_text_field(wp_unslash($_POST['ipinfo_token'])) : $existing_settings['ipinfo_token'], + 'google_client_id' => ($settings_section === 'google' && isset($_POST['google_client_id'])) ? sanitize_text_field(wp_unslash($_POST['google_client_id'])) : $existing_settings['google_client_id'], + 'google_client_secret' => ($settings_section === 'google' && isset($_POST['google_client_secret'])) ? sanitize_text_field(wp_unslash($_POST['google_client_secret'])) : $existing_settings['google_client_secret'], + 'google_property' => ($settings_section === 'google' && isset($_POST['google_property'])) ? sanitize_text_field(wp_unslash($_POST['google_property'])) : $existing_settings['google_property'], + 'google_access_token' => $existing_settings['google_access_token'], + 'google_refresh_token' => $existing_settings['google_refresh_token'], + 'google_token_expires_at' => $existing_settings['google_token_expires_at'], + 'google_connected_email' => $existing_settings['google_connected_email'], + 'google_site_verification_token' => $existing_settings['google_site_verification_token'], + 'google_site_verification_method' => $existing_settings['google_site_verification_method'], + 'google_last_sync_month' => $existing_settings['google_last_sync_month'], ]; update_option(self::OPTION_KEY, $settings, false); @@ -3656,6 +5490,13 @@ if (!class_exists('Ansico_Stat_Plugin')) { $wpdb->query('TRUNCATE TABLE ' . self::page_daily_table_name()); $wpdb->query('TRUNCATE TABLE ' . self::referral_table_name()); $wpdb->query('TRUNCATE TABLE ' . self::dimension_table_name()); + $wpdb->query('TRUNCATE TABLE ' . self::landing_table_name()); + $wpdb->query('TRUNCATE TABLE ' . self::unique_total_table_name()); + $wpdb->query('TRUNCATE TABLE ' . self::unique_page_table_name()); + $wpdb->query('TRUNCATE TABLE ' . self::unique_landing_table_name()); + $wpdb->query('TRUNCATE TABLE ' . self::unique_referral_table_name()); + $wpdb->query('TRUNCATE TABLE ' . self::unique_dimension_table_name()); + $wpdb->query('TRUNCATE TABLE ' . self::search_query_table_name()); update_option(self::INSTALL_OPTION_KEY, current_time('Y-m-d'), false); wp_safe_redirect(admin_url('admin.php?page=' . self::MENU_SLUG_SETTINGS . '&reset=success')); diff --git a/ansico-stat-plugin/assets/css/ansico-stat-admin.css b/ansico-stat-plugin/assets/css/ansico-stat-admin.css index d3f56ca..6d9b3c4 100644 --- a/ansico-stat-plugin/assets/css/ansico-stat-admin.css +++ b/ansico-stat-plugin/assets/css/ansico-stat-admin.css @@ -1,464 +1,195 @@ -.ansico-stat-admin-page .ansico-stat-card { - background: #fff; - border: 1px solid #dcdcde; - border-radius: 8px; - padding: 20px; - margin: 18px 0; +.ansico-stat-admin-page, +.ansico-stat-admin-wrap { color:#1e293b; } +.ansico-stat-cards { + display:grid; + grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); + gap:16px; + margin:20px 0 24px; +} +.ansico-stat-card { + background:#fff; + border:1px solid #dcdcde; + border-radius:16px; + box-shadow:0 8px 24px rgba(15,23,42,.06); + padding:20px; } - .ansico-stat-card-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - flex-wrap: wrap; + display:flex; + justify-content:space-between; + gap:12px; + align-items:flex-start; + margin-bottom:14px; } - -.ansico-stat-chart-wrap { - width: 100%; - overflow-x: auto; +.ansico-stat-card-header h2, +.ansico-stat-card-header h3, +.ansico-stat-card h2, +.ansico-stat-card h3 { margin:0 0 4px; } +.ansico-stat-card-header p { margin:0; color:#64748b; } +.ansico-stat-card-value { + margin:0; + font-size:28px; + line-height:1.2; + font-weight:700; + color:#0f172a; } - -.ansico-stat-chart { - width: 100%; - min-height: 160px; - display: block; - background: #fff; +.ansico-stat-chart-wrap, +.ansico-stat-bar-chart, +.ansico-stat-pie-chart, +.ansico-stat-grouped-bar-chart { + position:relative; + overflow:visible; } - -.ansico-stat-month-block { - margin-top: 24px; -} - -.ansico-stat-table th, -.ansico-stat-table td { - vertical-align: top; -} - -.ansico-stat-widget p { - margin-top: 0; -} - -.ansico-stat-month-nav { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - margin: 16px 0; -} - -.ansico-stat-top-list { - margin: 0 0 12px 18px; -} - -.ansico-stat-top-list li { - margin-bottom: 6px; -} - - -.ansico-stat-two-column { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(280px, 420px); - gap: 20px; - align-items: start; -} - -.ansico-stat-bar-chart-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(44px, 1fr)); - gap: 10px; - align-items: end; - min-height: 190px; - padding: 12px; - border: 1px solid #dcdcde; - border-radius: 8px; - background: #fff; -} - -.ansico-stat-bar-item { - display: flex; - flex-direction: column; - align-items: center; - justify-content: end; - gap: 6px; - min-width: 0; -} - -.ansico-stat-bar { - width: 100%; - max-width: 36px; - min-height: 4px; - border-radius: 6px 6px 0 0; - background: #2271b1; -} - -.ansico-stat-bar-value { - font-size: 11px; - line-height: 1.2; -} - -.ansico-stat-bar-label { - font-size: 11px; - line-height: 1.2; - text-align: center; - word-break: break-word; -} - -.ansico-stat-pie-wrap { - display: grid; - grid-template-columns: minmax(180px, 260px) minmax(0, 1fr); - gap: 16px; - align-items: center; -} - -.ansico-stat-pie-legend { - display: grid; - gap: 8px; -} - -.ansico-stat-legend-item { - display: flex; - gap: 8px; - align-items: center; - font-size: 13px; -} - -.ansico-stat-legend-swatch { - width: 12px; - height: 12px; - border-radius: 999px; - display: inline-block; - flex: 0 0 12px; -} - -@media (max-width: 900px) { - .ansico-stat-two-column, - .ansico-stat-pie-wrap { - grid-template-columns: 1fr; - } -} - - -.ansico-stat-chart-table-wrap { - margin-top: 12px; - max-height: 240px; - overflow: auto; - border: 1px solid #dcdcde; - border-radius: 8px; -} - -.ansico-stat-chart-table th, -.ansico-stat-chart-table td { - white-space: nowrap; -} - -.ansico-stat-chart-wrap circle { - cursor: pointer; -} - - -.ansico-stat-chart-wrap { - position: relative; - max-width: 100%; - overflow-x: auto; -} - -.ansico-stat-line-chart { - position: relative; - min-width: 0; -} - - - -.ansico-stat-table { - width: 100%; - table-layout: fixed; - display: block; - overflow-x: auto; - max-width: 100%; -} - -.ansico-stat-table th, -.ansico-stat-table td { - word-break: break-word; - overflow-wrap: anywhere; -} - -.ansico-stat-table a { - word-break: break-all; - overflow-wrap: anywhere; -} - - -.ansico-stat-line-chart-enhanced svg { - display: block; - filter: drop-shadow(0 2px 10px rgba(34, 113, 177, 0.08)); -} - -.ansico-stat-line-chart-enhanced .ansico-stat-chart-point { - transition: transform .12s ease, filter .12s ease; - cursor: pointer; -} - -.ansico-stat-line-chart-enhanced .ansico-stat-chart-point:hover { - transform: scale(1.08); - filter: drop-shadow(0 2px 8px rgba(34, 113, 177, 0.25)); -} - -.ansico-stat-pie-chart-shell { - position: relative; -} - -.ansico-stat-pie-segment { - transition: opacity .12s ease, transform .12s ease; - cursor: pointer; -} - -.ansico-stat-pie-segment:hover { - opacity: 0.92; -} - - - - - - -.ansico-stat-line-chart-enhanced .ansico-stat-chart-point { - vector-effect: non-scaling-stroke; -} - - -.ansico-stat-line-chart-enhanced .ansico-stat-chart-point-hit { - cursor: pointer; -} - -.ansico-stat-line-chart-enhanced .ansico-stat-chart-point-hit:focus { - outline: none; -} - - -.ansico-stat-line-chart-enhanced .ansico-stat-chart-point-hit { - cursor: pointer; -} - - -.ansico-stat-chart-wrap-enhanced { - position: relative; - padding: 10px 12px 6px; - margin: 0 -12px; - overflow: visible; -} - -.ansico-stat-chart-wrap-enhanced .ansico-stat-line-chart { - overflow: visible; -} - - -.ansico-stat-widget-total { - margin: 0 0 8px 0; - font-size: 14px; -} - -.ansico-stat-widget-chart-wrap { - margin: 0 0 10px 0; -} - -#ansico-stat-widget-chart-wrap, -#ansico-stat-widget-chart { - max-width: 100%; -} - -.ansico-stat-widget .ansico-stat-chart-wrap { - margin-top: 0; -} - -.ansico-stat-widget .ansico-stat-chart-wrap-enhanced { - padding-top: 2px; - margin-top: 0; -} - -.ansico-stat-widget .ansico-stat-line-chart-enhanced svg { - height: 260px; -} - - -.ansico-stat-widget-chart-wrap { - margin: -4px 0 8px 0; -} - -.ansico-stat-widget .ansico-stat-chart-wrap-enhanced { - padding: 0 !important; - margin: -6px 0 0 0 !important; -} - - -.ansico-stat-widget { - margin-top: 0; -} - -.ansico-stat-widget-total { - margin: 0 0 2px 0; - font-size: 16px; - font-weight: 600; - line-height: 1.3; -} - -.ansico-stat-widget-chart-wrap { - margin: -8px 0 6px 0; -} - -.ansico-stat-chart-wrap-widget { - padding: 0 !important; - margin: -10px 0 0 0 !important; -} - -.ansico-stat-line-chart-widget svg { - height: 250px !important; -} - -.ansico-stat-line-chart-widget .ansico-stat-chart-point { - r: 3; -} - -.ansico-stat-widget .ansico-stat-top-list { - margin-top: 6px; -} - -.ansico-stat-bar-chart-wrap { position: relative; } - -.ansico-stat-pie-chart-shell .ansico-stat-chart-tooltip, -.ansico-stat-bar-chart-wrap .ansico-stat-chart-tooltip, -.ansico-stat-chart-wrap-enhanced - - - - - - +.ansico-stat-line-chart, +.ansico-stat-pie-chart-shell { overflow:visible; } .ansico-stat-chart-tooltip { - position: absolute !important; - display: none; - opacity: 0; - background: rgba(17,24,39,0.95) !important; - color: #fff !important; - padding: 6px 8px !important; - border-radius: 6px !important; - font-size: 12px !important; - line-height: 1.2 !important; - pointer-events: none !important; - white-space: nowrap !important; - z-index: 9999 !important; - box-shadow: 0 4px 16px rgba(0,0,0,.15); - max-width: min(260px, calc(100% - 16px)); + position:absolute; + z-index:9999; + pointer-events:none; + max-width:260px; + padding:8px 10px; + border-radius:10px; + background:rgba(15,23,42,.94); + color:#fff; + font-size:12px; + line-height:1.35; + box-shadow:0 10px 30px rgba(2,6,23,.28); + white-space:normal; +} +.ansico-stat-chart-wrap svg, +.ansico-stat-line-chart svg { overflow:visible; } +.ansico-single-page-shell { + max-width:1280px; + margin-right:24px; +} +.ansico-single-page-hero { + display:flex; + justify-content:space-between; + align-items:flex-start; + gap:16px; + margin:16px 0 18px; +} +.ansico-single-page-hero h1 { margin:0 0 8px; font-size:30px; line-height:1.2; } +.ansico-single-page-hero .description { margin:0; color:#64748b; font-size:14px; } +.ansico-single-page-toolbar, +.ansico-single-page-target-card { + background:linear-gradient(180deg,#ffffff 0%,#f8fafc 100%); + border:1px solid #dbe4ee; + border-radius:18px; + box-shadow:0 10px 24px rgba(15,23,42,.05); + padding:18px 20px; + margin-bottom:18px; +} +.ansico-single-filter-form { + display:grid; + grid-template-columns:minmax(320px,2.4fr) repeat(2,minmax(140px,1fr)) auto; + gap:14px; + align-items:end; +} +.ansico-single-filter-field { display:flex; flex-direction:column; gap:6px; } +.ansico-single-filter-field label { + font-size:12px; + font-weight:600; + letter-spacing:.02em; + text-transform:uppercase; + color:#475569; +} +.ansico-single-filter-field input[type="url"], +.ansico-single-filter-field input[type="date"] { + width:100%; + min-height:42px; + border-radius:10px; +} +.ansico-single-filter-field-url input[type="url"] { min-width:0; } +.ansico-single-filter-actions { + display:flex; + gap:10px; + flex-wrap:wrap; + align-items:center; +} +.ansico-single-page-target-meta h2 { + margin:10px 0 6px; + font-size:22px; +} +.ansico-single-page-target-meta a { + color:#2271b1; + text-decoration:none; + word-break:break-all; +} +.ansico-single-page-target-badge { + display:inline-flex; + align-items:center; + gap:6px; + min-height:28px; + padding:0 10px; + border-radius:999px; + background:#e8f1fb; + color:#0f5b9a; + font-size:12px; + font-weight:700; + text-transform:uppercase; + letter-spacing:.03em; +} +.ansico-single-page-grid { + display:grid; + grid-template-columns:repeat(12,minmax(0,1fr)); + gap:18px; +} +.ansico-single-chart-card { padding:20px; } +.ansico-single-chart-card-wide { grid-column:1 / -1; } +@media (max-width: 980px) { + .ansico-single-filter-form { grid-template-columns:1fr 1fr; } + .ansico-single-filter-field-url, + .ansico-single-filter-actions { grid-column:1 / -1; } +} +@media (max-width: 640px) { + .ansico-single-page-shell { margin-right:0; } + .ansico-single-filter-form { grid-template-columns:1fr; } + .ansico-single-filter-field-url, + .ansico-single-filter-actions { grid-column:auto; } } -.ansico-stat-url-form-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 12px; - align-items: end; +.ansico-stat-admin-page > .ansico-stat-card { + margin: 0 0 24px; } - -.ansico-stat-url-field { - min-width: 0; +.ansico-stat-admin-page > .ansico-stat-card:last-of-type { + margin-bottom: 0; } - -.ansico-stat-url-action .button { - min-width: 110px; +.ansico-stat-two-column { + display:grid; + grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); + gap:24px; + align-items:start; } - -.ansico-stat-summary-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 14px; - margin-top: 16px; +.ansico-stat-pie-wrap, +.ansico-stat-pie-chart-shell { + position:relative; } - -.ansico-stat-summary-card { - border: 1px solid #dcdcde; - border-radius: 8px; - padding: 14px; - background: #f9f9f9; +.ansico-stat-pie-wrap { + display:grid; + gap:16px; } - -.ansico-stat-summary-label { - font-size: 12px; - line-height: 1.4; - color: #50575e; - margin-bottom: 6px; +.ansico-stat-pie-chart-shell { + min-height:180px; } - -.ansico-stat-summary-value { - font-size: 28px; - line-height: 1.2; - font-weight: 600; +.ansico-stat-pie-chart-shell svg { + display:block; + overflow:visible; } - -.ansico-stat-summary-description { - font-size: 12px; - line-height: 1.4; - color: #50575e; - margin-top: 6px; +.ansico-stat-pie-legend { + display:grid; + gap:8px; } - -.ansico-stat-target-badge { - display: inline-flex; - align-items: center; - padding: 6px 10px; - border-radius: 999px; - background: #eef4fb; - color: #1d4f82; - font-size: 12px; - font-weight: 600; +.ansico-stat-legend-item { + display:flex; + align-items:flex-start; + gap:8px; } - -@media (max-width: 782px) { - .ansico-stat-url-form-row { - grid-template-columns: 1fr; - } -} - - -.ansico-stat-url-form-row { - display: grid; - grid-template-columns: minmax(0, 1fr) 160px 160px auto; - gap: 12px; - align-items: end; -} - -.ansico-stat-date-field input { - width: 100%; -} - -.ansico-stat-dual-chart-legend { - display: flex; - gap: 18px; - flex-wrap: wrap; - margin-top: 12px; - font-size: 13px; -} - -.ansico-stat-dual-chart-legend span { - display: inline-flex; - align-items: center; - gap: 8px; -} - -.ansico-stat-dual-chart-swatch { - display: inline-block; - width: 22px; - height: 0; - border-top: 3px solid #2271b1; -} - -.ansico-stat-dual-chart-swatch-unique { - border-top-color: #8c8f94; - border-top-style: dashed; -} - -@media (max-width: 960px) { - .ansico-stat-url-form-row { - grid-template-columns: 1fr; - } +.ansico-stat-legend-swatch { + display:inline-block; + width:12px; + height:12px; + border-radius:999px; + flex:0 0 12px; + margin-top:3px; } diff --git a/ansico-stat-plugin/assets/js/ansico-stat-admin.js b/ansico-stat-plugin/assets/js/ansico-stat-admin.js new file mode 100644 index 0000000..849cb2d --- /dev/null +++ b/ansico-stat-plugin/assets/js/ansico-stat-admin.js @@ -0,0 +1,7 @@ +(function(){ + document.addEventListener('DOMContentLoaded', function(){ + document.querySelectorAll('.ansico-stat-chart-tooltip').forEach(function(tooltip){ + tooltip.setAttribute('role','tooltip'); + }); + }); +})(); diff --git a/ansico-stat-plugin/readme.txt b/ansico-stat-plugin/readme.txt index 6c6531e..543dc11 100644 --- a/ansico-stat-plugin/readme.txt +++ b/ansico-stat-plugin/readme.txt @@ -5,7 +5,7 @@ Tags: analytics, statistics, views, post views, dashboard Requires at least: 6.0 Tested up to: 6.9.4 Requires PHP: 7.4 -Stable tag: 1.1.0.1 +Stable tag: 1.1.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -27,7 +27,6 @@ Features include: * Device, browser, and operating system breakdowns * Bot exclusion and logged-in user exclusion rules * Reset statistics from the settings page -* Single page statistics page with URL-based lookup, date range filters, per-page charts, unique visitor tracking from version 1.0.0.6 forward, and a frontend admin bar shortcut Support and documentation: https://ansico.dk/Ansico/Ansico-Stat-plugin @@ -59,27 +58,39 @@ https://ansico.dk/Ansico/Ansico-Stat-plugin == Changelog == -= 1.1.0.1 = -- Added visible data points to the combined views and unique visitors chart. -- Added hover tooltips to the combined chart so each point can be read directly. -- Updated the plugin version to 1.1.0.1. += 1.1.1 = +* Stable release based on version 1.1.0.8. -= 1.0.0.7 = -* Added date range filters and a combined views/unique visitors chart on the single page statistics screen. -* Added a frontend admin bar shortcut that opens statistics for the current page directly in wp-admin. -* Updated single-page CSV export to include the selected date range. += 1.1.0.8 = +* Fixed pie chart tooltips so they render inside the chart area again. +* Added consistent spacing between sections on monthly, yearly, and lifetime statistics pages. +* Fixed chart tooltip positioning so hover labels stay inside the chart area. +* Refreshed the Single page statistics layout with a cleaner card-based design. + + += 1.0.0.14 = +* Fixed Google site verification token generation by handling URL-prefix properties with META verification and domain properties with DNS TXT verification. +* Added clearer Google verification status messages and guidance for reconnecting after OAuth scope changes or waiting for DNS propagation. + += 1.0.0.13 = +* Added a visible Google Search Console settings section with OAuth credentials, property selection, connection status, and verification controls. +* Added a monthly Google search queries table with sync from Search Console for the selected month. + += 1.0.0.12 = +* Added visible Monthly and Yearly top landing pages tables in the admin statistics screens. + += 1.0.0.10 = +* Added anonymous unique visitor tracking alongside visits for monthly and yearly statistics. +* Added unique visitor columns to top content, 404, referral, browser, operating system, device, and country tables. +* Updated the browser and operating system charts to use Top 5 + Others pie charts. -= 1.0.0.6 = -* Added true per-page unique visitor tracking stored per day for posts, pages, and tracked non-singular pages. -* Extended the single page statistics screen and CSV export with unique visitor metrics and charts. = 1.0.0.5 = -* Expanded the single page statistics screen with more page-specific KPIs, weekday distribution, and period-over-period comparisons. -* Added CSV export for the selected page URL so the report can be downloaded directly from the single page statistics screen. +* Keeps the Monthly/Yearly browsers and operating systems top-10 bar charts on a single row by using narrower bars and horizontal overflow when needed. = 1.0.0.4 = -* Added a single page statistics admin page where you can enter a site URL and view statistics for that specific tracked page or post. -* Added per-page daily and monthly charts plus lifetime and recent-period summaries. +* Added landing page tracking to referral rows so external referrals can be linked to the destination page on your site. +* Updated referral tables to show the landing page for each tracked referral URL. = 1.0.0 = * First public release. @@ -89,17 +100,25 @@ https://ansico.dk/Ansico/Ansico-Stat-plugin == Upgrade Notice == -= 1.0.0.7 = -Adds custom date ranges, a combined page chart, and a frontend admin bar shortcut for the current page report. += 1.0.0.14 = +Fixes Google site verification token generation and improves verification guidance for URL-prefix and domain properties. + += 1.0.0.13 = +* Added a visible Google Search Console settings section with OAuth credentials, property selection, connection status, and verification controls. +* Added a monthly Google search queries table with sync from Search Console for the selected month. + += 1.0.0.12 = +* Added visible Monthly and Yearly top landing pages tables in the admin statistics screens. + += 1.0.0.10 = +Adds anonymous unique visitor tracking and shows unique visitor totals alongside visits in the reports. -= 1.0.0.6 = -Adds true per-page unique visitor tracking for new data and shows those metrics on the single page statistics screen. = 1.0.0.5 = -Adds more page-specific KPIs and CSV export on the single page statistics screen. +Improves the browser and operating system top-10 bar charts so they stay on one row more reliably. = 1.0.0.4 = -Adds a single page statistics screen with URL-based lookup. +Adds landing page tracking for referral rows. = 1.0.0 = First public release. diff --git a/assets/banner-1544x500 (1).png:Zone.Identifier b/assets/banner-1544x500 (1).png:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/assets/banner-1544x500 (1).png:Zone.Identifier differ diff --git a/assets/banner-1544x500.png b/assets/banner-1544x500.png new file mode 100644 index 0000000..47042be Binary files /dev/null and b/assets/banner-1544x500.png differ diff --git a/assets/icon-256x256 (1).png:Zone.Identifier b/assets/icon-256x256 (1).png:Zone.Identifier new file mode 100644 index 0000000..d6c1ec6 Binary files /dev/null and b/assets/icon-256x256 (1).png:Zone.Identifier differ diff --git a/assets/icon-256x256.png b/assets/icon-256x256.png new file mode 100644 index 0000000..2be8b4f Binary files /dev/null and b/assets/icon-256x256.png differ diff --git a/readme.txt b/readme.txt new file mode 100644 index 0000000..543dc11 --- /dev/null +++ b/readme.txt @@ -0,0 +1,124 @@ +=== Ansico Stat Plugin === +Contributors: aphandersen +Donate link: https://ansico.dk +Tags: analytics, statistics, views, post views, dashboard +Requires at least: 6.0 +Tested up to: 6.9.4 +Requires PHP: 7.4 +Stable tag: 1.1.1 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +Simple WP plugin with basic website statistics. + +== Description == + +Ansico Stat Plugin is a lightweight statistics plugin for WordPress that tracks views across your site and provides admin-friendly reporting in the dashboard and in dedicated statistics pages. + +Features include: + +* Track views for posts, pages, and selected public custom post types +* Track views for front page, posts page, archives, taxonomies, search results, and 404 pages +* Admin-only frontend view counter +* Monthly and yearly statistics pages +* Daily total views charts +* Top views by month and year +* Referral source statistics +* Device, browser, and operating system breakdowns +* Bot exclusion and logged-in user exclusion rules +* Reset statistics from the settings page + +Support and documentation: +https://ansico.dk/Ansico/Ansico-Stat-plugin + +== Installation == + +1. Upload the plugin folder to the `/wp-content/plugins/` directory, or upload the ZIP from the WordPress admin. +2. Activate the plugin through the 'Plugins' screen in WordPress. +3. Go to `Ansico Stat Plugin` in the admin menu to configure settings and view statistics. + +== Frequently Asked Questions == + += Does it work without Post Views Counter? = + +Yes. Ansico Stat Plugin is fully independent. + += Can I exclude administrators or logged-in users? = + +Yes. You can choose whether to count everyone, exclude administrators only, or exclude all logged-in users. + += Can I choose what to track? = + +Yes. You can choose which post types and page types the plugin should track. + += Where can I get support? = + +Support URL: +https://ansico.dk/Ansico/Ansico-Stat-plugin + +== Changelog == + += 1.1.1 = +* Stable release based on version 1.1.0.8. + += 1.1.0.8 = +* Fixed pie chart tooltips so they render inside the chart area again. +* Added consistent spacing between sections on monthly, yearly, and lifetime statistics pages. +* Fixed chart tooltip positioning so hover labels stay inside the chart area. +* Refreshed the Single page statistics layout with a cleaner card-based design. + + += 1.0.0.14 = +* Fixed Google site verification token generation by handling URL-prefix properties with META verification and domain properties with DNS TXT verification. +* Added clearer Google verification status messages and guidance for reconnecting after OAuth scope changes or waiting for DNS propagation. + += 1.0.0.13 = +* Added a visible Google Search Console settings section with OAuth credentials, property selection, connection status, and verification controls. +* Added a monthly Google search queries table with sync from Search Console for the selected month. + += 1.0.0.12 = +* Added visible Monthly and Yearly top landing pages tables in the admin statistics screens. + += 1.0.0.10 = +* Added anonymous unique visitor tracking alongside visits for monthly and yearly statistics. +* Added unique visitor columns to top content, 404, referral, browser, operating system, device, and country tables. +* Updated the browser and operating system charts to use Top 5 + Others pie charts. + + += 1.0.0.5 = +* Keeps the Monthly/Yearly browsers and operating systems top-10 bar charts on a single row by using narrower bars and horizontal overflow when needed. + += 1.0.0.4 = +* Added landing page tracking to referral rows so external referrals can be linked to the destination page on your site. +* Updated referral tables to show the landing page for each tracked referral URL. + += 1.0.0 = +* First public release. +* Added tooltips to line, bar, and pie charts. +* Added monthly and yearly statistics pages. +* Added configurable tracking settings and frontend counter display. + +== Upgrade Notice == + += 1.0.0.14 = +Fixes Google site verification token generation and improves verification guidance for URL-prefix and domain properties. + += 1.0.0.13 = +* Added a visible Google Search Console settings section with OAuth credentials, property selection, connection status, and verification controls. +* Added a monthly Google search queries table with sync from Search Console for the selected month. + += 1.0.0.12 = +* Added visible Monthly and Yearly top landing pages tables in the admin statistics screens. + += 1.0.0.10 = +Adds anonymous unique visitor tracking and shows unique visitor totals alongside visits in the reports. + + += 1.0.0.5 = +Improves the browser and operating system top-10 bar charts so they stay on one row more reliably. + += 1.0.0.4 = +Adds landing page tracking for referral rows. + += 1.0.0 = +First public release.