[], 'sql' => [], 'count' => [], ]; /** * Init -- Sets things up. */ public static function init() { // Redirect to the pro settings add_action('admin_menu', function () { if (is_admin() && isset($_GET['page']) && 'slimpro' === $_GET['page'] && wp_slimstat::pro_is_installed()) { wp_safe_redirect(admin_url('admin.php?page=slimconfig&tab=7')); exit(); } }); // Action for reset layout add_action('admin_post_slimstat_reset_layout', ['wp_slimstat_admin', 'handle_reset_layout']); // Define the default screens $has_network_reports = get_user_option('meta-box-order_slimstat_page_slimlayout-network', 1); self::$screens_info = [ 'slimview1' => [ 'is_report_group' => true, 'show_in_sidebar' => true, 'title' => __('Real-time', 'wp-slimstat'), 'capability' => 'can_view', 'callback' => [self::class, 'wp_slimstat_include_view'], ], 'slimview2' => [ 'is_report_group' => true, 'show_in_sidebar' => true, 'title' => __('Overview', 'wp-slimstat'), 'capability' => 'can_view', 'callback' => [self::class, 'wp_slimstat_include_view'], ], 'slimview3' => [ 'is_report_group' => true, 'show_in_sidebar' => true, 'title' => __('Audience', 'wp-slimstat'), 'capability' => 'can_view', 'callback' => [self::class, 'wp_slimstat_include_view'], ], 'slimview4' => [ 'is_report_group' => true, 'show_in_sidebar' => true, 'title' => __('Site Analysis', 'wp-slimstat'), 'capability' => 'can_view', 'callback' => [self::class, 'wp_slimstat_include_view'], ], 'slimview5' => [ 'is_report_group' => true, 'show_in_sidebar' => true, 'title' => __('Traffic Sources', 'wp-slimstat'), 'capability' => 'can_view', 'callback' => [self::class, 'wp_slimstat_include_view'], ], 'slimview6' => [ 'is_report_group' => true, 'show_in_sidebar' => true, 'title' => __('Goals & Funnels', 'wp-slimstat'), // Optional page-intro lead: any screen that declares one gets a // framing H1 (from 'title') + this lead above its report boxes. 'lead' => __('Define the conversions that matter, then string them into funnels to see where visitors drop off.', 'wp-slimstat'), 'capability' => 'can_view', 'callback' => [self::class, 'wp_slimstat_include_view'], ], 'slimemail' => [ 'is_report_group' => false, 'show_in_sidebar' => true, 'title' => wp_slimstat::pro_is_installed() ? __('Email Report', 'wp-slimstat') : __('Email Report (pro)', 'wp-slimstat'), 'capability' => 'can_view', 'callback' => [self::class, 'wp_slimstat_include_email_report'], ], 'slimlayout' => [ 'is_report_group' => false, 'show_in_sidebar' => true, 'title' => __('Customize', 'wp-slimstat'), 'capability' => 'can_customize', 'callback' => [self::class, 'wp_slimstat_include_layout'], ], 'slimconfig' => [ 'is_report_group' => false, 'show_in_sidebar' => true, 'title' => __('Settings', 'wp-slimstat'), 'capability' => 'can_admin', 'callback' => [self::class, 'wp_slimstat_include_config'], ], 'slimpro' => [ 'is_report_group' => false, 'show_in_sidebar' => current_user_can('manage_options'), 'title' => apply_filters('slimstat_upgrade_to_pro_title', __('Upgrade to Pro', 'wp-slimstat')), 'capability' => 'can_admin', 'callback' => [self::class, 'wp_slimstat_pro'], ], 'dashboard' => [ 'is_report_group' => true, 'show_in_sidebar' => false, 'title' => __('WordPress Dashboard', 'wp-slimstat'), 'capability' => '', 'callback' => '', // No callback and capabilities are needed if show_in_sidebar is false ], 'inactive' => [ 'is_report_group' => true, 'show_in_sidebar' => false, 'title' => __('Inactive Reports'), 'capability' => '', 'callback' => '', // No callback and capabilities are needed if show_in_sidebar is false ], ]; self::$screens_info = apply_filters('slimstat_screens_info', self::$screens_info); // If the plugin was network activated, the tables might not have been created for this specific site $table_list = wp_slimstat::$wpdb->get_results(sprintf("SHOW TABLES LIKE '%sslim_stats'", $GLOBALS['wpdb']->prefix)); if (empty($table_list)) { self::init_environment(); } // Settings URL if (!is_network_admin()) { self::$config_url = get_admin_url($GLOBALS['blog_id'], 'admin.php?page=slimconfig&tab='); } else { self::$config_url = network_admin_url('admin.php?page=slimconfig&tab='); } // Current Screen if (!empty($_REQUEST['page']) && array_key_exists($_REQUEST['page'], self::$screens_info)) { self::$current_screen = $_REQUEST['page']; } // Page Location if ('no' != wp_slimstat::$settings['use_separate_menu']) { self::$page_location = 'admin'; } // Is the menu position setting being updated? if (!empty($_POST['slimstat_update_settings']) && wp_verify_nonce($_POST['slimstat_update_settings'], 'slimstat_update_settings') && !empty($_POST['options']['use_separate_menu'])) { wp_slimstat::$settings['use_separate_menu'] = ('on' == $_POST['options']['use_separate_menu']) ? 'on' : 'no'; } // Retrieve this user's custom report assignment (Customizer) // Superadmins can customize the layout at network level, to override per-site settings self::$meta_user_reports = get_user_option('meta-box-order_' . wp_slimstat_admin::$page_location . '_page_slimlayout-network', 1); // No network-wide settings found if (empty(self::$meta_user_reports)) { self::$meta_user_reports = get_user_option('meta-box-order_' . wp_slimstat_admin::$page_location . '_page_slimlayout', $GLOBALS['current_user']->ID); } // WPMU - New blog created $active_sitewide_plugins = get_site_option('active_sitewide_plugins'); if (!empty($active_sitewide_plugins['wp-slimstat/wp-slimstat.php'])) { add_action('wpmu_new_blog', [self::class, 'new_blog']); } // WPMU - Blog Deleted add_filter('wpmu_drop_tables', [self::class, 'drop_tables'], 10, 2); // Display a notice that hightlights this version's features if (!empty($_GET['page']) && false !== strpos($_GET['page'], 'slimview') && (!empty(self::$admin_notice) && 'on' == wp_slimstat::$settings['notice_latest_news'] && is_super_admin())) { add_action('admin_notices', [self::class, 'show_latest_news']); } // Remove spammers from the database if ('on' == wp_slimstat::$settings['ignore_spammers']) { add_action('transition_comment_status', [self::class, 'remove_spam'], 15, 3); } // Add a menu to the admin bar if ('no' != wp_slimstat::$settings['use_separate_menu'] && is_admin_bar_showing()) { add_action('admin_bar_menu', [self::class, 'add_menu_to_adminbar'], 100); add_action('admin_enqueue_scripts', [self::class, 'enqueue_adminbar_styles']); add_action('wp_enqueue_scripts', [self::class, 'enqueue_adminbar_styles']); } // Inject the modern Goals & Funnels shared DOM fragments (confirm sheet, // goal drawer, funnel builder) exactly once per admin page, and only on // pages that actually render slim_p9_01 / slim_p9_02. The check here // re-uses the same helper as the asset enqueue gate. add_action('admin_footer', [self::class, 'print_goals_funnels_dom']); if (function_exists('is_network_admin') && !is_network_admin()) { // Add the appropriate entries to the admin menu, if this user can view/admin Slimstat add_action('admin_menu', [self::class, 'add_menus']); // Display the column in the Edit Posts / Pages screen if ('on' == wp_slimstat::$settings['add_posts_column']) { $post_types = get_post_types(['public' => true, 'show_ui' => true], 'names'); include_once(plugin_dir_path(__FILE__) . 'view/wp-slimstat-reports.php'); include_once(plugin_dir_path(__FILE__) . 'view/wp-slimstat-db.php'); foreach ($post_types as $a_post_type) { add_filter(sprintf('manage_%s_posts_columns', $a_post_type), [self::class, 'add_column_header']); add_action(sprintf('manage_%s_posts_custom_column', $a_post_type), [self::class, 'add_post_column'], 10, 2); } if (false !== strpos($_SERVER['REQUEST_URI'], 'edit.php')) { add_action('admin_enqueue_scripts', [self::class, 'wp_slimstat_stylesheet']); add_action('wp', [self::class, 'init_data_for_column']); } } // Update the table structure and options, if needed if (!empty(wp_slimstat::$settings['version']) && SLIMSTAT_ANALYTICS_VERSION != wp_slimstat::$settings['version']) { add_action('admin_init', [self::class, 'update_tables_and_options']); } } // Initialize Reports system for SlimStat pages and AJAX requests $is_slimstat_page = (!empty($_GET['page']) && 0 === strpos($_GET['page'], 'slim')); $is_slimstat_ajax = (!empty($_POST['action']) && ( 'slimstat_load_report' === $_POST['action'] || 'slimstat_get_live_analytics_data' === $_POST['action'] )); if ($is_slimstat_page || $is_slimstat_ajax) { // Initialize the new Reports system FIRST before legacy system loads \SlimStat\Reports\Bootstrap::get_instance()->init(); } // Load the library of functions to generate the reports if ($is_slimstat_page || (!empty($_POST['action']) && 'slimstat_load_report' == $_POST['action'])) { include_once(plugin_dir_path(__FILE__) . 'view/wp-slimstat-reports.php'); wp_slimstat_reports::init(); if (!empty($_POST['report_id'])) { $report_id = sanitize_title($_POST['report_id'], 'slim_p0_00'); if (!empty(wp_slimstat_reports::$reports[$report_id])) { add_action('wp_ajax_slimstat_load_report', ['wp_slimstat_reports', 'callback_wrapper'], 10, 2); } } } // Dashboard Widgets if ('on' == wp_slimstat::$settings['add_dashboard_widgets']) { $sanitized_uri = sanitize_url(wp_unslash($_SERVER['REQUEST_URI'])); $request_length = strlen($sanitized_uri); $temp = $request_length - 10; if (false !== strpos($sanitized_uri, '/wp-admin/index.php') || ($temp >= 0 && $temp <= $request_length && false !== strpos($sanitized_uri, '/wp-admin/', $temp))) { add_action('admin_enqueue_scripts', [self::class, 'wp_slimstat_enqueue_scripts']); add_action('admin_enqueue_scripts', [self::class, 'wp_slimstat_stylesheet']); } add_action('wp_dashboard_setup', [self::class, 'add_dashboard_widgets']); } // AJAX Handlers if (defined('DOING_AJAX') && DOING_AJAX) { $ajax_actions = [ 'slimstat_notice_latest_news' => 'notices_handler', 'slimstat_notice_geolite' => 'notices_handler', 'slimstat_notice_browscap' => 'notices_handler', 'slimstat_notice_browscap_fileinfo' => 'notices_handler', 'slimstat_notice_caching' => 'notices_handler', 'slimstat_manage_filters' => 'manage_filters', 'slimstat_delete_pageview' => 'delete_pageview', 'slimstat_update_geoip_database' => 'update_geoip_database', 'slimstat_check_geoip_database' => 'check_geoip_database', 'slimstat_get_filter_options' => 'get_filter_options', 'slimstat_get_online_visitors' => 'get_online_visitors', 'slimstat_get_adminbar_stats' => 'get_adminbar_stats', 'slimstat_save_goal' => 'ajax_save_goal', 'slimstat_delete_goal' => 'ajax_delete_goal', 'slimstat_save_funnel' => 'ajax_save_funnel', 'slimstat_delete_funnel' => 'ajax_delete_funnel', 'slimstat_load_funnel_data' => 'ajax_load_funnel_data', 'slimstat_test_funnel_step' => 'ajax_test_funnel_step', ]; foreach ($ajax_actions as $action => $handler) { add_action('wp_ajax_' . $action, [self::class, $handler]); } // Live Analytics AJAX handler is registered via init_hooks() in Bootstrap // No need to call it separately here - it's already registered } // Schedule a daily cron job to purge the data if (!wp_next_scheduled('wp_slimstat_purge')) { wp_schedule_event(time(), 'twicedaily', 'wp_slimstat_purge'); } // Schedule a daily cron job to regenerate IP hashing salt (for GDPR compliance) if (!wp_next_scheduled('wp_slimstat_generate_daily_salt')) { wp_schedule_event(time(), 'daily', 'wp_slimstat_generate_daily_salt'); } // Schedule a weekly cron job to update geoip database automatically if (!wp_next_scheduled('wp_slimstat_update_geoip_database')) { $nextRunInterval = wp_slimstat::get_schedule_interval('weekly'); wp_schedule_event(time() + $nextRunInterval, 'weekly', 'wp_slimstat_update_geoip_database'); } // Fallback: if WP-Cron is disabled or scheduling failed, trigger a non-blocking direct update // This ensures environments with DISABLE_WP_CRON still receive GeoIP database updates $cron_disabled = (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) || !wp_next_scheduled('wp_slimstat_update_geoip_database'); $geoip_provider = \wp_slimstat::resolve_geolocation_provider(); if ($cron_disabled && false !== $geoip_provider && is_admin() && !wp_doing_ajax() && current_user_can(\wp_slimstat::$settings['capability_can_admin'])) { // Update if DB is missing or last update is older than the most recent past scheduled window $last_update = (int) get_option('slimstat_last_geoip_dl', 0); // Calculate the most recent "first Tuesday + 2 days" that has already passed $this_month_update = strtotime('first Tuesday of this month') + (86400 * 2); $current_time = time(); // If this month's update window hasn't arrived yet, use last month's window if ($current_time < $this_month_update) { $this_update = strtotime('first Tuesday of last month') + (86400 * 2); } else { $this_update = $this_month_update; } $needs_update = $last_update < $this_update; $db_missing = false; if (!$needs_update) { // Time check passed — only check DB existence if time says we're current try { $uses_db = in_array($geoip_provider, \SlimStat\Services\GeoService::DB_PROVIDERS, true); if ($uses_db) { $service = new \SlimStat\Services\Geolocation\GeolocationService($geoip_provider, []); $db_missing = !file_exists($service->getProvider()->getDbPath()); } } catch (\Throwable $e) { $db_missing = true; } } if ($needs_update || $db_missing) { // Fire admin-ajax in a non-blocking way to run the existing update handler $ajax_url = admin_url('admin-ajax.php'); // Forward only WordPress authentication cookies for security $cookie_header = ''; if (!headers_sent() && $_COOKIE !== [] && is_array($_COOKIE)) { $pairs = []; // Only forward WordPress authentication cookies $allowed_cookie_prefixes = [ 'wordpress_logged_in_', 'wordpress_sec_', 'wp-settings-', 'wp-settings-time-', ]; foreach ($_COOKIE as $k => $v) { $is_allowed = false; foreach ($allowed_cookie_prefixes as $prefix) { if (strpos($k, $prefix) === 0) { $is_allowed = true; break; } } if ($is_allowed) { $pairs[] = rawurlencode($k) . '=' . rawurlencode(sanitize_text_field(wp_unslash($v))); } } $cookie_header = implode('; ', $pairs); } $args = [ 'timeout' => 0.01, 'blocking' => false, 'body' => [ 'action' => 'slimstat_update_geoip_database', 'security' => wp_create_nonce('slimstat_geoip_action'), ], 'headers' => $cookie_header !== '' && $cookie_header !== '0' ? ['Cookie' => $cookie_header] : [], ]; // Best-effort call; ignore response wp_safe_remote_post($ajax_url, $args); } } // Add style to the admin menu add_action('admin_head', [self::class, 'styling_admin_menu']); // Add lock export button in report header add_filter('slimstat_report_header_buttons', fn ($_header_buttons, $_report_id) => self::add_lock_export_button($_header_buttons, $_report_id), 10, 2); self::register_goals_funnels_header_hooks(); // Sync index options with actual DB state — skip SHOW INDEX if option already confirmed foreach (self::get_index_definitions() as $def) { if ('yes' === get_option($def['option'])) { continue; } $exists = wp_slimstat::$wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = '%s'", $GLOBALS['wpdb']->prefix, $def['name'])); if (!empty($exists)) { update_option($def['option'], 'yes'); } } self::register_index_hooks(); // Register the combined notice add_action('admin_notices', ['wp_slimstat_admin', 'show_indexes_notice']); // Initialize notification system if (class_exists('SlimStat\\Services\\Admin\\Notification\\NotificationManager')) { new \SlimStat\Services\Admin\Notification\NotificationManager(); } // Initialize cron manager for notifications if (class_exists('SlimStat\\Services\\CronEventManager')) { new \SlimStat\Services\CronEventManager(); } } // END: init /** * Add style to the admin menu */ public static function styling_admin_menu() { if (!wp_slimstat::pro_is_installed()) { echo ''; } // The time-limited "New" badge on the Goals & Funnels item renders in the // global sidebar, so its style must load on every admin page (not just // slimview6). Tiny, so always emit it. (#20) echo ''; } /** * "New" badge HTML for the Goals & Funnels sidebar item, shown for 15 days * after the feature became available on this site, then it disappears. * Returns '' once the window elapses. The window is anchored the first time * the menu builds after this version ships, so existing installs start their * countdown then. (#20) */ private static function goals_funnels_new_badge() { $since = (int) get_option('slimstat_goals_funnels_since', 0); if ($since <= 0) { $since = time(); update_option('slimstat_goals_funnels_since', $since); } if ((time() - $since) >= (15 * DAY_IN_SECONDS)) { return ''; } return ' ' . esc_html__('New', 'wp-slimstat') . ''; } /** * Clears the purge cron job */ public static function deactivate() { wp_clear_scheduled_hook('wp_slimstat_purge'); wp_clear_scheduled_hook('wp_slimstat_update_geoip_database'); } /** * Reset layout */ public static function handle_reset_layout() { // Check nonce if (!wp_verify_nonce($_REQUEST['_wpnonce'], 'reset_layout')) { wp_die(__('Sorry, you are not allowed to access this page.', 'wp-slimstat')); } $GLOBALS['wpdb']->query(sprintf("DELETE FROM %susermeta WHERE meta_key LIKE '%%meta-box-order_admin_page_slimlayout%%'", $GLOBALS['wpdb']->prefix)); $GLOBALS['wpdb']->query(sprintf("DELETE FROM %susermeta WHERE meta_key LIKE '%%mmetaboxhidden_admin_page_slimview%%'", $GLOBALS['wpdb']->prefix)); $GLOBALS['wpdb']->query(sprintf("DELETE FROM %susermeta WHERE meta_key LIKE '%%meta-box-order_slimstat%%'", $GLOBALS['wpdb']->prefix)); $GLOBALS['wpdb']->query(sprintf("DELETE FROM %susermeta WHERE meta_key LIKE '%%metaboxhidden_slimstat%%'", $GLOBALS['wpdb']->prefix)); $GLOBALS['wpdb']->query(sprintf("DELETE FROM %susermeta WHERE meta_key LIKE '%%closedpostboxes_slimstat%%'", $GLOBALS['wpdb']->prefix)); // Redirect to layout page wp_safe_redirect(admin_url('admin.php?page=slimlayout')); die(); } /** * Support for WP MU network activations */ public static function new_blog($_blog_id) { switch_to_blog($_blog_id); self::init_environment(); restore_current_blog(); } // END: new_blog /** * Support for WP MU site deletion */ public static function drop_tables($_tables = [], $_blog_id = 1) { $_tables['slim_events'] = $GLOBALS['wpdb']->prefix . 'slim_events'; $_tables['slim_stats'] = $GLOBALS['wpdb']->prefix . 'slim_stats'; $_tables['slim_events_archive'] = $GLOBALS['wpdb']->prefix . 'slim_events_archive'; $_tables['slim_stats_archive'] = $GLOBALS['wpdb']->prefix . 'slim_stats_archive'; return $_tables; } // END: drop_tables /** * Creates tables, initializes options and schedules purge cron */ public static function init_environment() { if (function_exists('apply_filters')) { $my_wpdb = apply_filters('slimstat_custom_wpdb', $GLOBALS['wpdb']); } // Create the tables self::init_tables($my_wpdb); // Initialize atomic visit ID counter (fix for issue #155 - performance regression) \SlimStat\Tracker\VisitIdGenerator::initializeCounter(); // Ensure country/dt index exists for performance $has_index = $my_wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = 'idx_country_dt'", $GLOBALS['wpdb']->prefix)); if (!$has_index || 0 === count($has_index)) { $my_wpdb->query(sprintf('CREATE INDEX idx_country_dt ON %sslim_stats (country, dt)', $GLOBALS['wpdb']->prefix)); } update_option('slimstat_country_dt_indexed', 'yes'); // --- Add (dt, screen_width, screen_height) index for Top Screen Resolutions --- $dt_screen_index = $my_wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = 'idx_dt_screen_width_screen_height'", $GLOBALS['wpdb']->prefix)); if (empty($dt_screen_index)) { $my_wpdb->query(sprintf('CREATE INDEX idx_dt_screen_width_screen_height ON %sslim_stats (dt, screen_width, screen_height)', $GLOBALS['wpdb']->prefix)); } update_option('slimstat_dt_screen_indexed', 'yes'); // --- Add (dt, browser, browser_version) index for Top Browsers --- $dt_browser_index = $my_wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = 'idx_dt_browser_browser_version'", $GLOBALS['wpdb']->prefix)); if (empty($dt_browser_index)) { $my_wpdb->query(sprintf('CREATE INDEX idx_dt_browser_browser_version ON %sslim_stats (dt, browser, browser_version)', $GLOBALS['wpdb']->prefix)); } update_option('slimstat_dt_browser_indexed', 'yes'); // --- Add (dt, platform) index for Top Platforms --- $dt_platform_index = $my_wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = 'idx_dt_platform'", $GLOBALS['wpdb']->prefix)); if (empty($dt_platform_index)) { $my_wpdb->query(sprintf('CREATE INDEX idx_dt_platform ON %sslim_stats (dt, platform)', $GLOBALS['wpdb']->prefix)); } update_option('slimstat_dt_platform_indexed', 'yes'); // --- Add (dt, visit_id) covering index for visitor counter queries --- $dt_visit_index = $my_wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = '%sstats_dt_visit_idx'", $GLOBALS['wpdb']->prefix, $GLOBALS['wpdb']->prefix)); if (empty($dt_visit_index)) { $my_wpdb->query(sprintf('CREATE INDEX %sstats_dt_visit_idx ON %sslim_stats (dt, visit_id)', $GLOBALS['wpdb']->prefix, $GLOBALS['wpdb']->prefix)); } update_option('slimstat_dt_visit_indexed', 'yes'); // Hard-flush rewrite rules so the adblock bypass rewrite is written to .htaccess. // Caching plugins (WP Rocket, W3TC) route requests via .htaccess before WordPress // loads — a soft flush (false) only updates the DB and would not help. flush_rewrite_rules(); return true; } // END: init_environment /** * Creates and populates tables, if they aren't already there. */ public static function init_tables($_wpdb = '') { // Is InnoDB available? $have_innodb = $_wpdb->get_results("SHOW VARIABLES LIKE 'have_innodb'", ARRAY_A); $use_innodb = (!empty($have_innodb[0]) && 'YES' == $have_innodb[0]['Value']) ? 'ENGINE=InnoDB' : ''; // Table that stores the actual data about visits $stats_table_sql = " CREATE TABLE IF NOT EXISTS {$GLOBALS['wpdb']->prefix}slim_stats ( id INT UNSIGNED NOT NULL auto_increment, ip VARCHAR(39) DEFAULT NULL, other_ip VARCHAR(39) DEFAULT NULL, username VARCHAR(256) DEFAULT NULL, email VARCHAR(256) DEFAULT NULL, country VARCHAR(16) DEFAULT NULL, location VARCHAR(36) DEFAULT NULL, city VARCHAR(256) DEFAULT NULL, referer VARCHAR(2048) DEFAULT NULL, resource VARCHAR(2048) DEFAULT NULL, searchterms VARCHAR(2048) DEFAULT NULL, notes VARCHAR(2048) DEFAULT NULL, visit_id INT UNSIGNED NOT NULL DEFAULT 0, server_latency INT(10) UNSIGNED DEFAULT 0, page_performance INT(10) UNSIGNED DEFAULT 0, browser VARCHAR(40) DEFAULT NULL, browser_version VARCHAR(15) DEFAULT NULL, browser_type TINYINT UNSIGNED DEFAULT 0, platform VARCHAR(15) DEFAULT NULL, language VARCHAR(5) DEFAULT NULL, fingerprint VARCHAR(256) DEFAULT NULL, user_agent VARCHAR(2048) DEFAULT NULL, resolution VARCHAR(12) DEFAULT NULL, screen_width SMALLINT UNSIGNED DEFAULT 0, screen_height SMALLINT UNSIGNED DEFAULT 0, content_type VARCHAR(64) DEFAULT NULL, category VARCHAR(256) DEFAULT NULL, author VARCHAR(64) DEFAULT NULL, content_id BIGINT(20) UNSIGNED DEFAULT 0, outbound_resource VARCHAR(2048) DEFAULT NULL, tz_offset SMALLINT DEFAULT 0, dt_out INT(10) UNSIGNED DEFAULT 0, dt INT(10) UNSIGNED DEFAULT 0, CONSTRAINT PRIMARY KEY (id), INDEX {$GLOBALS['wpdb']->prefix}slim_stats_dt_idx (dt), INDEX {$GLOBALS['wpdb']->prefix}stats_resource_idx( resource( 20 ) ), INDEX {$GLOBALS['wpdb']->prefix}stats_browser_idx( browser( 10 ) ), INDEX {$GLOBALS['wpdb']->prefix}stats_searchterms_idx( searchterms( 15 ) ), INDEX {$GLOBALS['wpdb']->prefix}stats_fingerprint_idx( fingerprint( 20 ) ), INDEX {$GLOBALS['wpdb']->prefix}stats_dt_visit_idx (dt, visit_id) ) COLLATE utf8_general_ci {$use_innodb}"; // This table will track outbound links (clicks on links to external sites) $events_table_sql = " CREATE TABLE IF NOT EXISTS {$GLOBALS['wpdb']->prefix}slim_events ( event_id INT(10) NOT NULL AUTO_INCREMENT, type TINYINT UNSIGNED DEFAULT 0, event_description VARCHAR(64) DEFAULT NULL, notes VARCHAR(256) DEFAULT NULL, position VARCHAR(32) DEFAULT NULL, id INT UNSIGNED NOT NULL DEFAULT 0, dt INT(10) UNSIGNED DEFAULT 0, CONSTRAINT PRIMARY KEY (event_id), INDEX {$GLOBALS['wpdb']->prefix}slim_stat_events_idx (dt), CONSTRAINT fk_{$GLOBALS['wpdb']->prefix}slim_events_id FOREIGN KEY (id) REFERENCES {$GLOBALS['wpdb']->prefix}slim_stats(id) ON UPDATE CASCADE ON DELETE CASCADE ) COLLATE utf8_general_ci {$use_innodb}"; $archive_table_sql = " CREATE TABLE IF NOT EXISTS {$GLOBALS['wpdb']->prefix}slim_stats_archive LIKE {$GLOBALS['wpdb']->prefix}slim_stats"; $events_archive_table_sql = " CREATE TABLE IF NOT EXISTS {$GLOBALS['wpdb']->prefix}slim_events_archive ( event_id INT(10) NOT NULL AUTO_INCREMENT, type TINYINT UNSIGNED DEFAULT 0, event_description VARCHAR(64) DEFAULT NULL, notes VARCHAR(256) DEFAULT NULL, position VARCHAR(32) DEFAULT NULL, id INT UNSIGNED NOT NULL DEFAULT 0, dt INT(10) UNSIGNED DEFAULT 0, CONSTRAINT PRIMARY KEY (event_id), INDEX {$GLOBALS['wpdb']->prefix}slim_stat_events_archive_idx (dt) ) COLLATE utf8_general_ci {$use_innodb}"; // Ok, let's create the table structure self::_create_table($stats_table_sql, $GLOBALS['wpdb']->prefix . 'slim_stats', $_wpdb); self::_create_table($events_table_sql, $GLOBALS['wpdb']->prefix . 'slim_events', $_wpdb); self::_create_table($archive_table_sql, $GLOBALS['wpdb']->prefix . 'slim_stats_archive', $_wpdb); self::_create_table($events_archive_table_sql, $GLOBALS['wpdb']->prefix . 'slim_events_archive', $_wpdb); // Let's save the version in the database if (empty(wp_slimstat::$settings['version'])) { wp_slimstat::$settings['version'] = SLIMSTAT_ANALYTICS_VERSION; } $index_defs = [ ['name' => 'idx_country_dt', 'sql' => sprintf('CREATE INDEX idx_country_dt ON %sslim_stats (country, dt)', $GLOBALS['wpdb']->prefix), 'option' => 'slimstat_country_dt_indexed'], ['name' => 'idx_dt_screen_width_screen_height', 'sql' => sprintf('CREATE INDEX idx_dt_screen_width_screen_height ON %sslim_stats (dt, screen_width, screen_height)', $GLOBALS['wpdb']->prefix), 'option' => 'slimstat_dt_screen_indexed'], ['name' => 'idx_dt_browser_browser_version', 'sql' => sprintf('CREATE INDEX idx_dt_browser_browser_version ON %sslim_stats (dt, browser, browser_version)', $GLOBALS['wpdb']->prefix), 'option' => 'slimstat_dt_browser_indexed'], ['name' => 'idx_dt_platform', 'sql' => sprintf('CREATE INDEX idx_dt_platform ON %sslim_stats (dt, platform)', $GLOBALS['wpdb']->prefix), 'option' => 'slimstat_dt_platform_indexed'], // Speeds up "Currently Online" queries using dt_out > NOW()-300 ['name' => 'idx_dt_out', 'sql' => sprintf('CREATE INDEX idx_dt_out ON %sslim_stats (dt_out)', $GLOBALS['wpdb']->prefix), 'option' => 'slimstat_dt_out_indexed'], ]; foreach ($index_defs as $idx) { $exists = $_wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = '%s'", $GLOBALS['wpdb']->prefix, $idx['name'])); if (empty($exists)) { $_wpdb->query($idx['sql']); } update_option($idx['option'], 'yes'); } } // END: init_tables /** * Updates stuff around as needed (table schema, options, settings, files, etc) */ public static function update_tables_and_options() { $my_wpdb = apply_filters('slimstat_custom_wpdb', $GLOBALS['wpdb']); // --- Updates for version 4.8.2 --- if (version_compare(wp_slimstat::$settings['version'], '4.8.2', '<')) { // Add new email column to database $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats ADD COLUMN email VARCHAR(255) DEFAULT NULL AFTER username', $GLOBALS['wpdb']->prefix)); $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats_archive ADD COLUMN email VARCHAR(255) DEFAULT NULL AFTER username', $GLOBALS['wpdb']->prefix)); } // --- END: Updates for version 4.8.2 --- // --- Updates for version 4.8.4 --- if (version_compare(wp_slimstat::$settings['version'], '4.8.4', '<')) { // Switch option to track WP users (from track to ignore) wp_slimstat::$settings['ignore_wp_users'] = (!empty(wp_slimstat::$settings['track_users']) && 'no' == wp_slimstat::$settings['track_users']) ? 'on' : 'no'; // Remove unused options unset(wp_slimstat::$settings['track_users']); unset(wp_slimstat::$settings['enable_javascript']); unset(wp_slimstat::$settings['honor_dnt_header']); unset(wp_slimstat::$settings['no_maxmind_warning']); unset(wp_slimstat::$settings['no_browscap_warning']); unset(wp_slimstat::$settings['use_european_separators']); unset($wp_slimstat::$settings['date_format']); unset($wp_slimstat::$settings['time_format']); unset($wp_slimstat::$settings['expand_details']); // Add table indexes for improved performance (idempotent) $indexes = [ ['name' => $GLOBALS['wpdb']->prefix . 'stats_resource_idx', 'sql' => sprintf('ALTER TABLE %sslim_stats ADD INDEX %sstats_resource_idx( resource( 20 ) )', $GLOBALS['wpdb']->prefix, $GLOBALS['wpdb']->prefix)], ['name' => $GLOBALS['wpdb']->prefix . 'stats_browser_idx', 'sql' => sprintf('ALTER TABLE %sslim_stats ADD INDEX %sstats_browser_idx( browser( 10 ) )', $GLOBALS['wpdb']->prefix, $GLOBALS['wpdb']->prefix)], ['name' => $GLOBALS['wpdb']->prefix . 'stats_searchterms_idx', 'sql' => sprintf('ALTER TABLE %sslim_stats ADD INDEX %sstats_searchterms_idx( searchterms( 15 ) )', $GLOBALS['wpdb']->prefix, $GLOBALS['wpdb']->prefix)], ['name' => $GLOBALS['wpdb']->prefix . 'stats_fingerprint_idx', 'sql' => sprintf('ALTER TABLE %sslim_stats ADD INDEX %sstats_fingerprint_idx( fingerprint( 20 ) )', $GLOBALS['wpdb']->prefix, $GLOBALS['wpdb']->prefix)], ]; foreach ($indexes as $index) { $check_index = wp_slimstat::$wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = '%s'", $GLOBALS['wpdb']->prefix, $index['name'])); if (empty($check_index)) { wp_slimstat::$wpdb->query($index['sql']); } } wp_slimstat::$settings['db_indexes'] = 'on'; } // --- END: Updates for version 4.8.4 --- // --- Updates for version 4.8.4.1 --- if (version_compare(wp_slimstat::$settings['version'], '4.8.4.1', '<')) { // Goodbye, browser plugins wp_slimstat::$wpdb->query(sprintf('ALTER TABLE %sslim_stats DROP COLUMN plugins', $GLOBALS['wpdb']->prefix)); // Hello there, fingerprint and timezone offset $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats ADD COLUMN fingerprint VARCHAR(256) DEFAULT NULL AFTER language', $GLOBALS['wpdb']->prefix)); $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats_archive ADD COLUMN fingerprint VARCHAR(255) DEFAULT NULL AFTER language', $GLOBALS['wpdb']->prefix)); $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats ADD COLUMN tz_offset SMALLINT DEFAULT 0 AFTER outbound_resource', $GLOBALS['wpdb']->prefix)); $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats_archive ADD COLUMN tz_offset SMALLINT DEFAULT 0 AFTER outbound_resource', $GLOBALS['wpdb']->prefix)); } // --- END: Updates for version 4.8.4.1 --- // --- Updates for version 4.8.8 --- if (version_compare(wp_slimstat::$settings['version'], '4.8.8', '<')) { // Adding new index on the 'fingerprint' column for improved performance if ('on' == wp_slimstat::$settings['db_indexes']) { $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats ADD INDEX %sstats_fingerprint_idx( fingerprint( 20 ) )', $GLOBALS['wpdb']->prefix, $GLOBALS['wpdb']->prefix)); } $my_wpdb->query(sprintf("UPDATE %sslim_stats SET notes = CONCAT( '[', REPLACE( notes, ';', '][' ), ']' ) WHERE notes NOT LIKE '[%%'", $GLOBALS['wpdb']->prefix)); } // --- Updates for version 5.4.0 --- if (version_compare(wp_slimstat::$settings['version'], '5.4.0', '<')) { // Migrate legacy 'adblock' tracking method to 'adblock_bypass' (renamed in v5.3.0) if (!empty(wp_slimstat::$settings['tracking_request_method']) && 'adblock' === wp_slimstat::$settings['tracking_request_method']) { wp_slimstat::$settings['tracking_request_method'] = 'adblock_bypass'; } // Default use_separate_menu to 'on' if not already set if (empty(wp_slimstat::$settings['use_separate_menu'])) { wp_slimstat::$settings['use_separate_menu'] = 'on'; } } // --- Updates for version 5.4.1 --- // Fix admin bar migration: empty('no') returned false in 5.4.0, missing users with legacy 'no' value // Safe because this runs once (version bumps to 5.4.1 after), users who disable later are already on 5.4.1+ if (version_compare(wp_slimstat::$settings['version'], '5.4.1', '<')) { wp_slimstat::$settings['use_separate_menu'] = 'on'; } // --- Updates for version 5.4.3 --- if (version_compare(wp_slimstat::$settings['version'], '5.4.3', '<')) { // Add (dt, visit_id) covering index for visitor counter queries $idx_name = $GLOBALS['wpdb']->prefix . 'stats_dt_visit_idx'; $check = $my_wpdb->get_results(sprintf( "SHOW INDEX FROM %sslim_stats WHERE Key_name = '%s'", $GLOBALS['wpdb']->prefix, $idx_name )); if (empty($check)) { $result = $my_wpdb->query(sprintf( 'CREATE INDEX %s ON %sslim_stats (dt, visit_id)', $idx_name, $GLOBALS['wpdb']->prefix )); if ($result !== false) { update_option('slimstat_dt_visit_indexed', 'yes'); } // If fails (large table timeout), show_indexes_notice() surfaces a retry button } else { update_option('slimstat_dt_visit_indexed', 'yes'); } } // --- Goals & Funnels composite indexes for query performance --- // These three indexes are also registered as AbstractIndexMigration classes // (Create{Goal,Funnel}QueriesIndex / CreateEventsNotesDtIndex in // src/Migration/MigrationService.php) which provide the retry UI; keep the // index name + columns here in sync with those classes. if (empty(wp_slimstat::$settings['goals_indexes'])) { $goal_indexes = [ ['table' => 'slim_stats', 'name' => 'idx_goal_queries', 'sql' => 'ADD INDEX idx_goal_queries (resource(191), dt, fingerprint(20))'], ['table' => 'slim_stats', 'name' => 'idx_funnel_queries', 'sql' => 'ADD INDEX idx_funnel_queries (fingerprint(20), dt, resource(191))'], ['table' => 'slim_events', 'name' => 'idx_events_notes_dt', 'sql' => 'ADD INDEX idx_events_notes_dt (dt, notes(64))'], ]; $goal_indexes_built = true; foreach ($goal_indexes as $idx) { $table = $GLOBALS['wpdb']->prefix . $idx['table']; $exists = wp_slimstat::$wpdb->get_results( wp_slimstat::$wpdb->prepare("SHOW INDEX FROM {$table} WHERE Key_name = %s", $idx['name']) ); if (empty($exists)) { // ALTER can time out on very large tables. Track the result so we // only mark this complete once every index is present; a failure // leaves goals_indexes unset so the modern migration system // (MigrationService) surfaces a one-click retry notice. (#318) if (false === wp_slimstat::$wpdb->query("ALTER TABLE {$table} {$idx['sql']}")) { $goal_indexes_built = false; } } } if ($goal_indexes_built) { wp_slimstat::$settings['goals_indexes'] = 'on'; } } // Clear stale query cache transients on upgrade to prevent data inconsistencies // (e.g., cached $pageviews causing percentage >100% in reports — see #270) $GLOBALS['wpdb']->query( "DELETE FROM {$GLOBALS['wpdb']->options} WHERE option_name LIKE '_transient_wp_slimstat_cache_%' OR option_name LIKE '_transient_timeout_wp_slimstat_cache_%' LIMIT 1000" ); // Rotate the goals/funnels cache version on upgrade so pre-fix cached // results (e.g. goal "uniques" that excluded NULL-fingerprint visitors) // are recomputed immediately rather than lingering for the 5–15 min // transient TTL after the uniques identity changed. (#3) update_option('slimstat_goals_cache_ver', (string) microtime(true), false); // Now we can update the version stored in the database wp_slimstat::$settings['version'] = SLIMSTAT_ANALYTICS_VERSION; wp_slimstat::$settings['notice_latest_news'] = 'on'; wp_slimstat::update_option('slimstat_options', wp_slimstat::$settings); return true; } // END: update_tables_and_options public static function add_dashboard_widgets() { // If this user is whitelisted, we use the minimum capability $minimum_capability = 'read'; if (false === strpos(wp_slimstat::$settings['can_view'], (string) $GLOBALS['current_user']->user_login) && !empty(wp_slimstat::$settings['capability_can_view'])) { $minimum_capability = wp_slimstat::$settings['capability_can_view']; } if (!current_user_can($minimum_capability)) { return; } // Initialize the new Reports system FIRST before legacy system loads \SlimStat\Reports\Bootstrap::get_instance()->init(); // The Reports library is only loaded on the plugin's screens include_once(plugin_dir_path(__FILE__) . 'view/wp-slimstat-reports.php'); wp_slimstat_reports::init(); if (!empty(wp_slimstat_reports::$user_reports['dashboard']) && is_array(wp_slimstat_reports::$user_reports['dashboard'])) { foreach (wp_slimstat_reports::$user_reports['dashboard'] as $a_report_id) { if (empty(wp_slimstat_reports::$reports[$a_report_id])) { continue; } // Force compact rendering on the WP Dashboard for goals/funnels so // drawer/builder/confirm-sheet markup never mounts inside the widget. // Mutation is kept local: we only re-bind the registry field when // registering this specific widget, avoiding cross-request leaks. if ('slim_p9_01' === $a_report_id || 'slim_p9_02' === $a_report_id) { wp_slimstat_reports::$reports[$a_report_id]['callback_args']['is_widget'] = true; } wp_add_dashboard_widget($a_report_id, wp_slimstat_reports::$reports[$a_report_id]['title'], ['wp_slimstat_reports', 'callback_wrapper']); } } } // END: add_dashboard_widgets /** * Removes 'spammers' from the database when the corresponding comments are marked as spam */ public static function remove_spam($_new_status = '', $_old_status = '', $_comment = '') { $my_wpdb = apply_filters('slimstat_custom_wpdb', $GLOBALS['wpdb']); if ('spam' == $_new_status && !empty($_comment->comment_author) && !empty($_comment->comment_author_IP)) { $my_wpdb->query(wp_slimstat::$wpdb->prepare(" DELETE ts FROM {$GLOBALS['wpdb']->prefix}slim_stats ts WHERE username = %s OR INET_NTOA(ip) = %s", $_comment->comment_author, $_comment->comment_author_IP)); } } // END: remove_spam /** * Loads a custom stylesheet file for the administration panels */ public static function wp_slimstat_stylesheet($_hook = '') { wp_register_style('wp-slimstat', plugins_url('/admin/assets/css/admin.css', __DIR__), false, SLIMSTAT_ANALYTICS_VERSION); wp_enqueue_style('wp-slimstat'); wp_register_style( 'wp-slimstat-header-modern', plugins_url('/admin/assets/css/header-modern.css', __DIR__), ['wp-slimstat'], SLIMSTAT_ANALYTICS_VERSION ); wp_enqueue_style('wp-slimstat-header-modern'); // Goals & Funnels CSS — only loaded on screens that actually render those reports. // Honors slimlayout/Customize drag by inspecting the user's resolved report layout. if (self::needs_goals_funnels_assets()) { wp_register_style( 'wp-slimstat-tokens', plugins_url('/admin/assets/css/tokens.css', __DIR__), [], SLIMSTAT_ANALYTICS_VERSION ); wp_enqueue_style('wp-slimstat-tokens'); wp_register_style( 'wp-slimstat-goals-funnels', plugins_url('/admin/assets/css/goals-funnels.css', __DIR__), ['wp-slimstat', 'wp-slimstat-tokens'], SLIMSTAT_ANALYTICS_VERSION ); wp_enqueue_style('wp-slimstat-goals-funnels'); } if (!empty(wp_slimstat::$settings['custom_css'])) { wp_add_inline_style('wp-slimstat', wp_slimstat::$settings['custom_css']); } } /** * Returns true when the current admin context renders slim_p9_01 or slim_p9_02. * Covers the direct slimview6 page, the WP dashboard, and screens that have * Goals/Funnels dragged in via the Customizer. * * @since 5.5.0 */ public static function needs_goals_funnels_assets() { // Only memoize `true` — a `false` answer is provisional until the reports // registry has loaded. Caching `false` too early (e.g. during // admin_enqueue_scripts on index.php, before wp_slimstat_reports::init() // runs) would cause the dashboard widget path to miss its own assets. static $memo = null; if ($memo === true) { return true; } if (!empty($_GET['page']) && 'slimview6' === $_GET['page']) { return $memo = true; } if (!class_exists('wp_slimstat_reports', false)) { return false; } $pagenow = $GLOBALS['pagenow'] ?? ''; if ('index.php' === $pagenow) { $dashboard_reports = wp_slimstat_reports::$user_reports['dashboard'] ?? []; if (in_array('slim_p9_01', (array) $dashboard_reports, true) || in_array('slim_p9_02', (array) $dashboard_reports, true)) { return $memo = true; } } $current = self::$current_screen; if (!empty($current)) { $reports_on_screen = wp_slimstat_reports::$user_reports[$current] ?? []; if (in_array('slim_p9_01', (array) $reports_on_screen, true) || in_array('slim_p9_02', (array) $reports_on_screen, true)) { return $memo = true; } } return false; } /** * Emits the Goals & Funnels shared DOM (confirm sheet, goal drawer, funnel * builder) once per admin page, gated on the same helper as asset enqueue. * * @since 5.5.0 */ public static function print_goals_funnels_dom() { static $printed = false; if ($printed) { return; } if (!self::needs_goals_funnels_assets()) { return; } $printed = true; $dimensions = self::get_goal_dimensions(); // Funnel steps offer only action-oriented dimensions; goals keep the full // list (a "Country = gb" goal is legitimate). (#17) $funnel_step_dimensions = self::get_funnel_step_dimensions(); $operators = self::get_goal_operators(); $operator_labels = self::get_goal_operator_labels(); $partials_dir = plugin_dir_path(__FILE__) . 'view/partials/goals-funnels/'; include $partials_dir . 'confirm-sheet.php'; include $partials_dir . 'goal-drawer.php'; include $partials_dir . 'funnel-builder.php'; } // END: wp_slimstat_stylesheet /** * Adds a shared body class to all Slimstat admin screens. */ public static function add_admin_body_class($classes) { return $classes . ' slimstat-admin-page'; } /** * Loads user-defined stylesheet code */ public static function wp_slimstat_userdefined_stylesheet() { echo ''; } // END: wp_slimstat_userdefined_stylesheet /** * Enqueues Javascript and styles needed in the admin */ public static function wp_slimstat_enqueue_scripts($_hook = '') { $current_screen = get_current_screen(); if ($current_screen && false !== strpos((string) ($current_screen->id ?? ''), 'slim')) { wp_enqueue_script('dashboard'); wp_enqueue_script('jquery-ui-datepicker'); wp_enqueue_script('jquery-ui-sortable'); } // Enqueue the built-in code editor to use on the Settings if ($current_screen) { wp_enqueue_code_editor(['type' => 'text/html']); } // Enqueue date range picker assets for report pages $should_load_datepicker = false; if (isset($_GET['page'])) { $page = sanitize_text_field($_GET['page']); if (false !== strpos($page, 'slim') && false === strpos($page, 'setting')) { $should_load_datepicker = true; } } if ($should_load_datepicker) { // Enqueue moment.js wp_enqueue_script('slimstat-moment', plugins_url('/admin/assets/js/daterangepicker/moment.min.js', __DIR__), [], '2.30.2', true); // Enqueue daterangepicker wp_enqueue_script('slimstat-daterangepicker', plugins_url('/admin/assets/js/daterangepicker/daterangepicker.min.js', __DIR__), ['jquery', 'slimstat-moment'], '3.1.0', true); // Enqueue our custom date picker wp_enqueue_script('slimstat-custom-datepicker', plugins_url('/admin/assets/js/daterangepicker/slimstat-daterangepicker.js', __DIR__), ['jquery', 'slimstat-daterangepicker'], SLIMSTAT_ANALYTICS_VERSION, true); // Enqueue date picker styles wp_enqueue_style('slimstat-daterangepicker-base', plugins_url('/admin/assets/css/daterangepicker/daterangepicker.css', __DIR__), [], '3.1.0'); wp_enqueue_style('slimstat-daterangepicker-custom', plugins_url('/admin/assets/css/daterangepicker/slimstat-datepicker-styles.css', __DIR__), ['slimstat-daterangepicker-base'], SLIMSTAT_ANALYTICS_VERSION); // Localize date picker script $datepicker_params = [ 'ajax_url' => admin_url('admin-ajax.php'), 'clear_cache_nonce' => wp_create_nonce('slimstat_clear_cache'), 'options' => [ 'wp_timezone' => DateRangeHelper::get_wp_timezone(), 'start_of_week' => DateRangeHelper::get_week_start(), 'date_format' => DateRangeHelper::get_date_format() ], 'strings' => DateRangeHelper::get_localized_strings() ]; wp_localize_script('slimstat-custom-datepicker', 'SlimStatDatePicker', $datepicker_params); } // Shared wp.i18n accessor (window.wpSlimstatI18n) for every admin script // that carries translatable strings. Depends on wp-i18n; the scripts below // depend on this handle so the accessor is defined before they run. wp_enqueue_script('slimstat-i18n', plugins_url('/admin/assets/js/i18n.js', __DIR__), ['wp-i18n'], SLIMSTAT_ANALYTICS_VERSION, true); // slimstat-i18n dependency + script translations so admin.js's __() strings // (combobox labels, etc.) load their JSON translations at runtime. Without // this, the strings are extracted into the .pot but never translated. wp_enqueue_script('slimstat_admin', plugins_url('/admin/assets/js/admin.js', __DIR__), ['jquery-ui-dialog', 'slimstat-i18n'], SLIMSTAT_ANALYTICS_VERSION, true); self::set_slimstat_script_translations('slimstat_admin'); // Enqueue notification assets if notifications are enabled if (wp_slimstat::$settings['display_notifications'] == 'on') { wp_enqueue_style('slimstat_notifications', plugins_url('/admin/assets/css/notifications.css', __DIR__), [], SLIMSTAT_ANALYTICS_VERSION); wp_enqueue_style('slimstat_header_notifications', plugins_url('/admin/assets/css/header-notifications.css', __DIR__), [], SLIMSTAT_ANALYTICS_VERSION); wp_enqueue_script('slimstat_notifications', plugins_url('/admin/assets/js/notifications.js', __DIR__), ['jquery'], SLIMSTAT_ANALYTICS_VERSION, false); // Pass notification data to Javascript $notification_params = [ 'ajax_url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('wp_rest'), ]; wp_localize_script('slimstat_notifications', 'slimstat_admin', $notification_params); } // Pass some information to Javascript $params = [ 'async_load' => empty(wp_slimstat::$settings['async_load']) ? 'no' : wp_slimstat::$settings['async_load'], 'datepicker_image' => plugins_url('/admin/assets/images/datepicker.png', __DIR__), 'refresh_interval' => intval(wp_slimstat::$settings['refresh_interval']), 'page_location' => self::$page_location, 'clear_cache_nonce' => wp_create_nonce('slimstat_clear_cache'), 'goals_nonce' => wp_create_nonce('slimstat_goals_nonce'), 'ajax_url' => admin_url('admin-ajax.php'), // Shared with the filter form-builder so value-less operators (is_empty/ // is_not_empty) are never treated as a "remove filter" signal. See #305. // Guarded: this method also runs on the Dashboard-widget path, where // wp_slimstat_db may not be included — fall back to the literal list. 'valueless_operators' => class_exists('wp_slimstat_db') ? wp_slimstat_db::$valueless_operators : ['is_empty', 'is_not_empty'], // Canonical date/misc filter keys, shared with SlimStatGetFiltersForAjax() so it // strips the same non-column keys when harvesting filters for a sub-report (#22). 'non_column_filter_keys' => class_exists('wp_slimstat_db') ? wp_slimstat_db::NON_COLUMN_FILTER_KEYS : ['strtotime', 'minute', 'hour', 'day', 'month', 'year', 'interval', 'interval_hours', 'interval_minutes', 'limit_results', 'start_from'], // WP-locale number separators so JS-rendered (lazily-loaded) funnel tabs // match the server's number_format_i18n() output instead of the browser // locale's toLocaleString(). 'number_format' => [ 'decimal_point' => is_object($GLOBALS['wp_locale'] ?? null) ? ($GLOBALS['wp_locale']->number_format['decimal_point'] ?? '.') : '.', 'thousands_sep' => is_object($GLOBALS['wp_locale'] ?? null) ? ($GLOBALS['wp_locale']->number_format['thousands_sep'] ?? ',') : ',', ], ]; wp_localize_script('slimstat_admin', 'SlimStatAdminParams', $params); // Goals & Funnels AJAX handlers — gated to screens that actually render those reports. if (self::needs_goals_funnels_assets()) { wp_enqueue_script( 'slimstat-goals-funnels', plugins_url('/admin/assets/js/goals-funnels.js', __DIR__), ['jquery', 'slimstat_admin', 'slimstat-i18n'], SLIMSTAT_ANALYTICS_VERSION, true ); self::set_slimstat_script_translations('slimstat-goals-funnels'); } } // END: wp_slimstat_enqueue_scripts /** * Registers JS translations for one of our scripts so its wp.i18n strings * load their JSON language pack at runtime. Shared by every enqueued script * that carries translatable strings (admin.js, goals-funnels.js). */ private static function set_slimstat_script_translations(string $handle): void { if (function_exists('wp_set_script_translations')) { wp_set_script_translations($handle, 'wp-slimstat', plugin_dir_path(__DIR__) . 'languages'); } } /** * Adds a new entry in the admin menu, to view the stats */ public static function add_menus($_s = '') { global $submenu; // If this user is whitelisted, we use the minimum capability $minimum_capability = 'read'; if (is_network_admin()) { $minimum_capability = 'manage_network'; } elseif (false === strpos(wp_slimstat::$settings['can_view'], (string) $GLOBALS['current_user']->user_login) && !empty(wp_slimstat::$settings['capability_can_view'])) { $minimum_capability = wp_slimstat::$settings['capability_can_view']; } // Find the first available location (screens with no reports assigned to them are hidden from the nav) $parent = ''; if (is_array(self::$meta_user_reports)) { foreach (self::$screens_info as $a_screen_id => $a_screen_info) { if (!empty(self::$meta_user_reports[$a_screen_id]) && $a_screen_info['show_in_sidebar']) { $parent = $a_screen_id; break; } } } // If no parent was found in the user meta, use the first available screen as the parent if (empty($parent) && !empty(self::$screens_info)) { $parent = array_key_first(self::$screens_info); } // Don't show the menu if no screens are available at all if (empty($parent) || !isset(self::$screens_info[$parent])) { return null; } self::$main_menu_slug = $parent; // Build menu title with notification badge $menu_title = __('SlimStat', 'wp-slimstat'); if (class_exists(NotificationFactory::class) && wp_slimstat::$settings['display_notifications'] === 'on') { $notification_count = NotificationFactory::getNewNotificationCount(); if ($notification_count > 0) { $menu_title .= sprintf( ' %s', $notification_count, number_format_i18n($notification_count) ); } } // Add the main menu add_menu_page( __('SlimStat', 'wp-slimstat'), $menu_title, $minimum_capability, $parent, [self::class, 'wp_slimstat_include_view'], 'dashicons-chart-area' ); foreach (self::$screens_info as $a_screen_id => $a_screen_info) { if (isset(self::$meta_user_reports[$a_screen_id]) && empty(self::$meta_user_reports[$a_screen_id])) { continue; } $minimum_capability = 'read'; if (!empty($a_screen_info['capability']) && false === strpos(wp_slimstat::$settings[$a_screen_info['capability']], (string) $GLOBALS['current_user']->user_login) && !empty(wp_slimstat::$settings['capability_' . $a_screen_info['capability']])) { $minimum_capability = wp_slimstat::$settings['capability_' . $a_screen_info['capability']]; } if ($a_screen_info['show_in_sidebar']) { // Sidebar label may carry the time-limited "New" badge; the page // title (browser tab) stays plain. (#20) $menu_label = $a_screen_info['title']; if ('slimview6' === $a_screen_id) { $menu_label .= self::goals_funnels_new_badge(); } $new_entry[] = add_submenu_page( $parent, $a_screen_info['title'], $menu_label, $minimum_capability, $a_screen_id, $a_screen_info['callback'] ); } } if (isset($submenu[$parent])) { array_walk($submenu[$parent], function (&$item) { if (isset($item[2]) && 'slimpro' === $item[2]) { $item[4] = isset($item[4]) ? $item[4] . ' wp-slimstat-upgrade-to-pro' : ' wp-slimstat-upgrade-to-pro'; } }); } // Load styles and Javascript needed to make the reports look nice and interactive foreach ($new_entry as $a_entry) { add_action('load-' . $a_entry, [self::class, 'wp_slimstat_stylesheet']); add_action('load-' . $a_entry, [self::class, 'wp_slimstat_enqueue_scripts']); add_action('load-' . $a_entry, [self::class, 'contextual_help']); add_action('load-' . $a_entry, function () { add_filter('admin_body_class', [wp_slimstat_admin::class, 'add_admin_body_class']); }); } return $_s; } // END: add_menus /** * Enqueue admin bar modal styles globally (admin + frontend) */ public static function enqueue_adminbar_styles() { if (is_admin_bar_showing()) { wp_enqueue_style( 'slimstat-adminbar', plugins_url('/admin/assets/css/admin-bar-modal.css', __DIR__), [], SLIMSTAT_ANALYTICS_VERSION ); // Enqueue admin bar realtime JS for stats auto-refresh (frontend + admin) // On frontend: self-polls every minute // On admin: defers to admin.js slimstat:minute_pulse wp_enqueue_script( 'slimstat-adminbar-realtime', plugins_url('/admin/assets/js/adminbar-realtime.js', __DIR__), [], SLIMSTAT_ANALYTICS_VERSION, true ); wp_localize_script('slimstat-adminbar-realtime', 'SlimStatAdminBar', [ 'ajax_url' => admin_url('admin-ajax.php'), 'security' => wp_create_nonce('meta-box-order'), 'is_pro' => wp_slimstat::pro_is_installed(), 'i18n' => [ 'was_last_day' => esc_html__('was %s last day', 'wp-slimstat'), 'online_users' => esc_html__('Online Users', 'wp-slimstat'), 'count_label' => esc_html__('Count', 'wp-slimstat'), 'now' => esc_html__('Now', 'wp-slimstat'), 'min_ago' => esc_html__('min ago', 'wp-slimstat'), ], ]); } } // END: enqueue_adminbar_styles /** * Adds a new entry in the WordPress Admin Bar with stats modal */ public static function add_menu_to_adminbar() { // If this user is whitelisted, we use the minimum capability $minimum_capability = 'read'; if (is_network_admin()) { $minimum_capability = 'manage_network'; } elseif (false === strpos(wp_slimstat::$settings['can_view'], (string) $GLOBALS['current_user']->user_login) && !empty(wp_slimstat::$settings['capability_can_view'])) { $minimum_capability = wp_slimstat::$settings['capability_can_view']; } if (!current_user_can($minimum_capability)) { return; } $wpdb = wp_slimstat::$wpdb; $table = "{$GLOBALS['wpdb']->prefix}slim_stats"; $today_start = mktime(0, 0, 0); $yesterday_start = $today_start - 86400; $yesterday_end = $today_start - 1; // Sessions Today (unique sessions - using visit_id for anonymous/hashed IP compatibility) $sessions_today = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(DISTINCT visit_id) FROM {$table} WHERE dt >= %d AND visit_id > 0", $today_start )); // Views Today (pageviews) $views_today = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(id) FROM {$table} WHERE dt >= %d", $today_start )); // Yesterday's sessions (unique sessions - using visit_id for anonymous/hashed IP compatibility) $sessions_yesterday = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(DISTINCT visit_id) FROM {$table} WHERE dt BETWEEN %d AND %d AND visit_id > 0", $yesterday_start, $yesterday_end )); // Yesterday's views $views_yesterday = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(id) FROM {$table} WHERE dt BETWEEN %d AND %d", $yesterday_start, $yesterday_end )); // Referrals Today (external referrers only) $site_host = parse_url(home_url(), PHP_URL_HOST); $referrals_today = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(id) FROM {$table} WHERE dt >= %d AND referer IS NOT NULL AND referer NOT LIKE %s", $today_start, '%' . $wpdb->esc_like($site_host) . '%' )); // Referrals Yesterday $referrals_yesterday = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(id) FROM {$table} WHERE dt BETWEEN %d AND %d AND referer IS NOT NULL AND referer NOT LIKE %s", $yesterday_start, $yesterday_end, '%' . $wpdb->esc_like($site_host) . '%' )); // Online Users — same 30-minute window query as header.php $current_minute_start = (int) floor(wp_slimstat::now() / 60) * 60; $window_minutes = 30; $window_start = $current_minute_start - (($window_minutes - 1) * 60); $online_count = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM ( SELECT visit_id, MAX( CASE WHEN dt_out IS NOT NULL AND dt_out > 0 AND dt_out >= dt THEN dt_out ELSE dt END ) AS last_activity FROM {$table} WHERE visit_id > 0 AND (dt >= %d OR (dt_out IS NOT NULL AND dt_out >= %d)) GROUP BY visit_id HAVING (FLOOR(last_activity / 60) * 60 + 59) >= %d ) live_sessions", $window_start, $window_start, $window_start )); $online_count = max(0, $online_count); // Determine premium status early (needed for chart data) $is_pro = wp_slimstat::pro_is_installed(); // Query minute-by-minute data for the CSS bar chart (30-minute window) // Reuse LiveAnalyticsReport's session-spanning query for consistent data (#221) if ($is_pro) { $live_report = new \SlimStat\Reports\Types\Analytics\LiveAnalyticsReport(); $chart_result = $live_report->get_users_chart_data(); $minute_data = $chart_result['data']; $max_count = $chart_result['max_value']; } else { // Fake placeholder data for non-Pro users $minute_data = [3, 5, 4, 7, 6, 8, 5, 9, 7, 6, 8, 10, 7, 5, 6, 8, 9, 7, 6, 5, 8, 10, 9, 7, 6, 8, 5, 7, 6, 8]; $max_count = 10; } // Build chart HTML $chart_bars = ''; $total_bars = count($minute_data); foreach ($minute_data as $i => $count) { $height_pct = round(($count / $max_count) * 100); $is_peak = ($count === $max_count && $count > 0); $bar_class = $is_peak ? ' slimstat-adminbar__chart-bar--peak' : ''; $minutes_ago = $total_bars - 1 - $i; // 29 for first bar, 0 for last bar $time_text = $minutes_ago === 0 ? esc_html__('Now', 'wp-slimstat') : sprintf('%d %s', $minutes_ago, esc_html__('min ago', 'wp-slimstat')); $chart_bars .= sprintf( '
', $bar_class, $count > 0 ? max($height_pct, 3) : 0, // 0% for empty, min 3% for non-zero $count, $minutes_ago, esc_html__('Online Users', 'wp-slimstat'), esc_html__('Count', 'wp-slimstat'), $count, $time_text ); } $view_url = get_admin_url($GLOBALS['blog_id'], 'admin.php?page='); $overview_url = $view_url . 'slimview2'; $upgrade_url = 'https://wp-slimstat.com/pricing/?utm_source=wp-slimstat&utm_medium=link&utm_campaign=adminbar'; // Add parent node $GLOBALS['wp_admin_bar']->add_menu([ 'id' => 'slimstat-header', 'title' => '' . sprintf(__('Online: %s', 'wp-slimstat'), '' . number_format_i18n($online_count) . ''), 'href' => $overview_url, ]); // Add stats grid node // For non-Pro users, show fake data for Views and Referrals $views_display = $is_pro ? number_format_i18n($views_today) : '248'; $views_yesterday_display = $is_pro ? number_format_i18n($views_yesterday) : '312'; $referrals_display = $is_pro ? number_format_i18n($referrals_today) : '18'; $referrals_yesterday_display = $is_pro ? number_format_i18n($referrals_yesterday) : '24'; $blur_class = $is_pro ? '' : ' slimstat-adminbar__stat-card--blur'; $stats_html = ''; $GLOBALS['wp_admin_bar']->add_node([ 'id' => 'slimstat-adminbar-stats', 'parent' => 'slimstat-header', 'title' => $stats_html, 'meta' => ['class' => 'slimstat-adminbar__stats-wrapper'], ]); // Add chart node $chart_wrapper_class = $is_pro ? 'slimstat-adminbar__chart-container' : 'slimstat-adminbar__chart-container slimstat-adminbar__chart-blur'; $chart_html = '' . esc_html__('A Goal is one question you ask of your traffic.', 'wp-slimstat') . '
'; } if ('slim_p9_02' === $_report_id) { return '' . esc_html__('String 2 to 5 steps into a journey. A funnel shows the conversion rate and exact drop-off at each stage.', 'wp-slimstat') . '
'; } return $_html; } public static function add_header() { if (isset($_GET['page']) && ('slimlayout' === $_GET['page'] || 'slimconfig' === $_GET['page'])) { return self::get_template('header', ['is_pro' => wp_slimstat::pro_is_installed()]); } return null; } /** * Index definitions for all AJAX-managed database indexes. * Each entry maps an AJAX action (nonce) to its index metadata. */ private static function get_index_definitions(): array { $prefix = $GLOBALS['wpdb']->prefix; return [ 'slimstat_add_country_dt_index' => [ 'name' => 'idx_country_dt', 'columns' => 'country, dt', 'option' => 'slimstat_country_dt_indexed', ], 'slimstat_add_dt_screen_index' => [ 'name' => 'idx_dt_screen_width_screen_height', 'columns' => 'dt, screen_width, screen_height', 'option' => 'slimstat_dt_screen_indexed', ], 'slimstat_add_dt_browser_index' => [ 'name' => 'idx_dt_browser_browser_version', 'columns' => 'dt, browser, browser_version', 'option' => 'slimstat_dt_browser_indexed', ], 'slimstat_add_dt_platform_index' => [ 'name' => 'idx_dt_platform', 'columns' => 'dt, platform', 'option' => 'slimstat_dt_platform_indexed', ], 'slimstat_add_dt_out_index' => [ 'name' => 'idx_dt_out', 'columns' => 'dt_out', 'option' => 'slimstat_dt_out_indexed', ], 'slimstat_add_dt_visit_index' => [ 'name' => $prefix . 'stats_dt_visit_idx', 'columns' => 'dt, visit_id', 'option' => 'slimstat_dt_visit_indexed', ], ]; } /** * Generic AJAX handler for ensuring a database index exists. */ private static function ajax_ensure_index(string $nonce, string $index_name, string $columns, string $option_key): void { check_ajax_referer($nonce); if (!current_user_can('manage_options')) { wp_send_json_error(__('Insufficient permissions.', 'wp-slimstat')); } $wpdb = wp_slimstat::$wpdb; $table = $GLOBALS['wpdb']->prefix . 'slim_stats'; $exists = $wpdb->get_results(sprintf("SHOW INDEX FROM %s WHERE Key_name = '%s'", $table, $index_name)); if (!empty($exists)) { update_option($option_key, 'yes'); wp_send_json_success(__('Index already exists.', 'wp-slimstat')); } $result = $wpdb->query(sprintf('CREATE INDEX %s ON %s (%s)', $index_name, $table, $columns)); if (false !== $result) { update_option($option_key, 'yes'); wp_send_json_success(__('Index added successfully.', 'wp-slimstat')); } wp_send_json_error(__('Unable to add index.', 'wp-slimstat')); } /** * Register AJAX hooks for all index management actions. */ public static function register_index_hooks(): void { foreach (self::get_index_definitions() as $action => $def) { add_action('wp_ajax_' . $action, function () use ($action, $def) { self::ajax_ensure_index($action, $def['name'], $def['columns'], $def['option']); }); } } public static function show_indexes_notice() { // If new migration system is active, suppress legacy performance notice if (class_exists(\SlimStat\Migration\Admin\MigrationAdmin::class)) { return; } if (!current_user_can('manage_options')) { return; } $indexes = [ [ 'option' => 'slimstat_dt_out_indexed', 'id' => 'dt-out', 'label' => __('Currently Online Reports', 'wp-slimstat'), 'desc' => __('Index ondt_out', 'wp-slimstat'),
'key' => 'idx_dt_out',
'ajax' => 'slimstat_add_dt_out_index',
'btn' => __('Apply', 'wp-slimstat'),
],
[
'option' => 'slimstat_country_dt_indexed',
'id' => 'country-dt',
'label' => __('World Map & Country Reports', 'wp-slimstat'),
'desc' => __('Index on country and dt', 'wp-slimstat'),
'key' => 'idx_country_dt',
'ajax' => 'slimstat_add_country_dt_index',
'btn' => __('Apply', 'wp-slimstat'),
],
[
'option' => 'slimstat_dt_screen_indexed',
'id' => 'dt-screen',
'label' => __('Screen Resolution Reports', 'wp-slimstat'),
'desc' => __('Index on dt, screen_width, screen_height', 'wp-slimstat'),
'key' => 'idx_dt_screen_width_screen_height',
'ajax' => 'slimstat_add_dt_screen_index',
'btn' => __('Apply', 'wp-slimstat'),
],
[
'option' => 'slimstat_dt_browser_indexed',
'id' => 'dt-browser',
'label' => __('Browser Reports', 'wp-slimstat'),
'desc' => __('Index on dt, browser, browser_version', 'wp-slimstat'),
'key' => 'idx_dt_browser_browser_version',
'ajax' => 'slimstat_add_dt_browser_index',
'btn' => __('Apply', 'wp-slimstat'),
],
[
'option' => 'slimstat_dt_platform_indexed',
'id' => 'dt-platform',
'label' => __('Platform Reports', 'wp-slimstat'),
'desc' => __('Index on dt, platform', 'wp-slimstat'),
'key' => 'idx_dt_platform',
'ajax' => 'slimstat_add_dt_platform_index',
'btn' => __('Apply', 'wp-slimstat'),
],
[
'option' => 'slimstat_dt_visit_indexed',
'id' => 'dt-visit',
'label' => __('Visitor Counter Performance', 'wp-slimstat'),
'desc' => __('Index on dt, visit_id', 'wp-slimstat'),
'key' => $GLOBALS['wpdb']->prefix . 'stats_dt_visit_idx',
'ajax' => 'slimstat_add_dt_visit_index',
'btn' => __('Apply', 'wp-slimstat'),
],
];
$pending = array_filter($indexes, function ($idx) {
$db = wp_slimstat::$wpdb;
$exists = $db->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = '%s'", $GLOBALS['wpdb']->prefix, $idx['key']));
return empty($exists);
});
if ([] === $pending) {
return;
}
$ajax_url = admin_url('admin-ajax.php');
// Generate nonces for each AJAX action
$nonces = [];
foreach ($pending as $idx) {
$nonces[$idx['ajax']] = wp_create_nonce($idx['ajax']);
}
echo '' . __('To speed up SlimStat reports, please apply the following database optimizations. These changes are safe and will not affect your data.', 'wp-slimstat') . '
'; echo '