PluginProbe
SlimStat Analytics / trunk
SlimStat Analytics vtrunk
5.5.0 5.4.12 4.7.4 4.7.4.1 4.7.5 4.7.5.1 4.7.5.2 4.7.5.3 4.7.6 4.7.6.1 4.7.7 4.7.8 4.7.8.1 4.7.8.2 4.7.8.3 4.7.9 4.7.9.1 4.8 4.8.1 4.8.2 4.8.3 4.8.4 4.8.4.1 4.8.5 4.8.5.1 All 212 releases
wp-slimstat / admin / index.php

index.php in SlimStat Analytics trunk, at admin/index.php

3,748 lines 171.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 use SlimStat\Services\GeoService;
4 use SlimStat\Components\DateRangeHelper;
5 use SlimStat\Services\Admin\Notification\NotificationFactory;
6 class wp_slimstat_admin
7 {
8 public static $screens_info = [];
9 public static $config_url = '';
10 public static $current_screen = 'slimview1';
11 public static $page_location = 'slimstat';
12 public static $meta_user_reports = [];
13 public static $settings = [];
14 public static $user_reports = [];
15 public static $admin_notice = '';
16 public static $main_menu_slug = 'slimview1';
17
18 /**
19 * Dimensions for which the filter-options search uses unanchored LIKE
20 * (%needle%) instead of the default left-anchored prefix match. These are
21 * either multi-token fields (notes, category) or free-form strings where
22 * users naturally search fragments (user_agent, outbound_resource URLs).
23 * resource + referer are URL-like for the same reason: a user searching a
24 * path fragment ("pricing") shouldn't have to type the leading slash to
25 * match "/pricing/" — the prefix anchor made that fail. (#18)
26 */
27 private const FILTER_SEARCH_SUBSTRING_DIMENSIONS = [
28 'notes', 'searchterms', 'content_type', 'category', 'author', 'outbound_resource', 'user_agent', 'resource', 'referer',
29 ];
30
31 protected static $data_for_column = [
32 'url' => [],
33 'sql' => [],
34 'count' => [],
35 ];
36
37 /**
38 * Init -- Sets things up.
39 */
40 public static function init()
41 {
42 // Redirect to the pro settings
43 add_action('admin_menu', function () {
44 if (is_admin() && isset($_GET['page']) && 'slimpro' === $_GET['page'] && wp_slimstat::pro_is_installed()) {
45 wp_safe_redirect(admin_url('admin.php?page=slimconfig&tab=7'));
46 exit();
47 }
48 });
49
50 // Action for reset layout
51 add_action('admin_post_slimstat_reset_layout', ['wp_slimstat_admin', 'handle_reset_layout']);
52
53 // Define the default screens
54 $has_network_reports = get_user_option('meta-box-order_slimstat_page_slimlayout-network', 1);
55
56 self::$screens_info = [
57 'slimview1' => [
58 'is_report_group' => true,
59 'show_in_sidebar' => true,
60 'title' => __('Real-time', 'wp-slimstat'),
61 'capability' => 'can_view',
62 'callback' => [self::class, 'wp_slimstat_include_view'],
63 ],
64 'slimview2' => [
65 'is_report_group' => true,
66 'show_in_sidebar' => true,
67 'title' => __('Overview', 'wp-slimstat'),
68 'capability' => 'can_view',
69 'callback' => [self::class, 'wp_slimstat_include_view'],
70 ],
71 'slimview3' => [
72 'is_report_group' => true,
73 'show_in_sidebar' => true,
74 'title' => __('Audience', 'wp-slimstat'),
75 'capability' => 'can_view',
76 'callback' => [self::class, 'wp_slimstat_include_view'],
77 ],
78 'slimview4' => [
79 'is_report_group' => true,
80 'show_in_sidebar' => true,
81 'title' => __('Site Analysis', 'wp-slimstat'),
82 'capability' => 'can_view',
83 'callback' => [self::class, 'wp_slimstat_include_view'],
84 ],
85 'slimview5' => [
86 'is_report_group' => true,
87 'show_in_sidebar' => true,
88 'title' => __('Traffic Sources', 'wp-slimstat'),
89 'capability' => 'can_view',
90 'callback' => [self::class, 'wp_slimstat_include_view'],
91 ],
92 'slimview6' => [
93 'is_report_group' => true,
94 'show_in_sidebar' => true,
95 'title' => __('Goals & Funnels', 'wp-slimstat'),
96 // Optional page-intro lead: any screen that declares one gets a
97 // framing H1 (from 'title') + this lead above its report boxes.
98 'lead' => __('Define the conversions that matter, then string them into funnels to see where visitors drop off.', 'wp-slimstat'),
99 'capability' => 'can_view',
100 'callback' => [self::class, 'wp_slimstat_include_view'],
101 ],
102 'slimemail' => [
103 'is_report_group' => false,
104 'show_in_sidebar' => true,
105 'title' => wp_slimstat::pro_is_installed() ? __('Email Report', 'wp-slimstat') : __('Email Report (pro)', 'wp-slimstat'),
106 'capability' => 'can_view',
107 'callback' => [self::class, 'wp_slimstat_include_email_report'],
108 ],
109 'slimlayout' => [
110 'is_report_group' => false,
111 'show_in_sidebar' => true,
112 'title' => __('Customize', 'wp-slimstat'),
113 'capability' => 'can_customize',
114 'callback' => [self::class, 'wp_slimstat_include_layout'],
115 ],
116 'slimconfig' => [
117 'is_report_group' => false,
118 'show_in_sidebar' => true,
119 'title' => __('Settings', 'wp-slimstat'),
120 'capability' => 'can_admin',
121 'callback' => [self::class, 'wp_slimstat_include_config'],
122 ],
123 'slimpro' => [
124 'is_report_group' => false,
125 'show_in_sidebar' => current_user_can('manage_options'),
126 'title' => apply_filters('slimstat_upgrade_to_pro_title', __('Upgrade to Pro', 'wp-slimstat')),
127 'capability' => 'can_admin',
128 'callback' => [self::class, 'wp_slimstat_pro'],
129 ],
130 'dashboard' => [
131 'is_report_group' => true,
132 'show_in_sidebar' => false,
133 'title' => __('WordPress Dashboard', 'wp-slimstat'),
134 'capability' => '',
135 'callback' => '', // No callback and capabilities are needed if show_in_sidebar is false
136 ],
137 'inactive' => [
138 'is_report_group' => true,
139 'show_in_sidebar' => false,
140 'title' => __('Inactive Reports'),
141 'capability' => '',
142 'callback' => '', // No callback and capabilities are needed if show_in_sidebar is false
143 ],
144 ];
145 self::$screens_info = apply_filters('slimstat_screens_info', self::$screens_info);
146
147 // If the plugin was network activated, the tables might not have been created for this specific site
148 $table_list = wp_slimstat::$wpdb->get_results(sprintf("SHOW TABLES LIKE '%sslim_stats'", $GLOBALS['wpdb']->prefix));
149 if (empty($table_list)) {
150 self::init_environment();
151 }
152
153 // Settings URL
154 if (!is_network_admin()) {
155 self::$config_url = get_admin_url($GLOBALS['blog_id'], 'admin.php?page=slimconfig&amp;tab=');
156 } else {
157 self::$config_url = network_admin_url('admin.php?page=slimconfig&amp;tab=');
158 }
159
160 // Current Screen
161 if (!empty($_REQUEST['page']) && array_key_exists($_REQUEST['page'], self::$screens_info)) {
162 self::$current_screen = $_REQUEST['page'];
163 }
164
165 // Page Location
166 if ('no' != wp_slimstat::$settings['use_separate_menu']) {
167 self::$page_location = 'admin';
168 }
169
170 // Is the menu position setting being updated?
171 if (!empty($_POST['slimstat_update_settings']) && wp_verify_nonce($_POST['slimstat_update_settings'], 'slimstat_update_settings') && !empty($_POST['options']['use_separate_menu'])) {
172 wp_slimstat::$settings['use_separate_menu'] = ('on' == $_POST['options']['use_separate_menu']) ? 'on' : 'no';
173 }
174
175 // Retrieve this user's custom report assignment (Customizer)
176 // Superadmins can customize the layout at network level, to override per-site settings
177 self::$meta_user_reports = get_user_option('meta-box-order_' . wp_slimstat_admin::$page_location . '_page_slimlayout-network', 1);
178
179 // No network-wide settings found
180 if (empty(self::$meta_user_reports)) {
181 self::$meta_user_reports = get_user_option('meta-box-order_' . wp_slimstat_admin::$page_location . '_page_slimlayout', $GLOBALS['current_user']->ID);
182 }
183
184 // WPMU - New blog created
185 $active_sitewide_plugins = get_site_option('active_sitewide_plugins');
186 if (!empty($active_sitewide_plugins['wp-slimstat/wp-slimstat.php'])) {
187 add_action('wpmu_new_blog', [self::class, 'new_blog']);
188 }
189
190 // WPMU - Blog Deleted
191 add_filter('wpmu_drop_tables', [self::class, 'drop_tables'], 10, 2);
192
193 // Display a notice that hightlights this version's features
194 if (!empty($_GET['page']) && false !== strpos($_GET['page'], 'slimview') && (!empty(self::$admin_notice) && 'on' == wp_slimstat::$settings['notice_latest_news'] && is_super_admin())) {
195 add_action('admin_notices', [self::class, 'show_latest_news']);
196
197 }
198
199 // Remove spammers from the database
200 if ('on' == wp_slimstat::$settings['ignore_spammers']) {
201 add_action('transition_comment_status', [self::class, 'remove_spam'], 15, 3);
202 }
203
204 // Add a menu to the admin bar
205 if ('no' != wp_slimstat::$settings['use_separate_menu'] && is_admin_bar_showing()) {
206 add_action('admin_bar_menu', [self::class, 'add_menu_to_adminbar'], 100);
207 add_action('admin_enqueue_scripts', [self::class, 'enqueue_adminbar_styles']);
208 add_action('wp_enqueue_scripts', [self::class, 'enqueue_adminbar_styles']);
209 }
210
211 // Inject the modern Goals & Funnels shared DOM fragments (confirm sheet,
212 // goal drawer, funnel builder) exactly once per admin page, and only on
213 // pages that actually render slim_p9_01 / slim_p9_02. The check here
214 // re-uses the same helper as the asset enqueue gate.
215 add_action('admin_footer', [self::class, 'print_goals_funnels_dom']);
216
217 if (function_exists('is_network_admin') && !is_network_admin()) {
218 // Add the appropriate entries to the admin menu, if this user can view/admin Slimstat
219 add_action('admin_menu', [self::class, 'add_menus']);
220
221 // Display the column in the Edit Posts / Pages screen
222 if ('on' == wp_slimstat::$settings['add_posts_column']) {
223 $post_types = get_post_types(['public' => true, 'show_ui' => true], 'names');
224 include_once(plugin_dir_path(__FILE__) . 'view/wp-slimstat-reports.php');
225 include_once(plugin_dir_path(__FILE__) . 'view/wp-slimstat-db.php');
226
227 foreach ($post_types as $a_post_type) {
228 add_filter(sprintf('manage_%s_posts_columns', $a_post_type), [self::class, 'add_column_header']);
229 add_action(sprintf('manage_%s_posts_custom_column', $a_post_type), [self::class, 'add_post_column'], 10, 2);
230 }
231
232 if (false !== strpos($_SERVER['REQUEST_URI'], 'edit.php')) {
233 add_action('admin_enqueue_scripts', [self::class, 'wp_slimstat_stylesheet']);
234 add_action('wp', [self::class, 'init_data_for_column']);
235 }
236 }
237
238 // Update the table structure and options, if needed
239 if (!empty(wp_slimstat::$settings['version']) && SLIMSTAT_ANALYTICS_VERSION != wp_slimstat::$settings['version']) {
240 add_action('admin_init', [self::class, 'update_tables_and_options']);
241 }
242 }
243
244 // Initialize Reports system for SlimStat pages and AJAX requests
245 $is_slimstat_page = (!empty($_GET['page']) && 0 === strpos($_GET['page'], 'slim'));
246 $is_slimstat_ajax = (!empty($_POST['action']) && (
247 'slimstat_load_report' === $_POST['action'] ||
248 'slimstat_get_live_analytics_data' === $_POST['action']
249 ));
250
251 if ($is_slimstat_page || $is_slimstat_ajax) {
252 // Initialize the new Reports system FIRST before legacy system loads
253 \SlimStat\Reports\Bootstrap::get_instance()->init();
254 }
255
256 // Load the library of functions to generate the reports
257 if ($is_slimstat_page || (!empty($_POST['action']) && 'slimstat_load_report' == $_POST['action'])) {
258 include_once(plugin_dir_path(__FILE__) . 'view/wp-slimstat-reports.php');
259 wp_slimstat_reports::init();
260
261 if (!empty($_POST['report_id'])) {
262 $report_id = sanitize_title($_POST['report_id'], 'slim_p0_00');
263
264 if (!empty(wp_slimstat_reports::$reports[$report_id])) {
265 add_action('wp_ajax_slimstat_load_report', ['wp_slimstat_reports', 'callback_wrapper'], 10, 2);
266 }
267 }
268 }
269
270 // Dashboard Widgets
271 if ('on' == wp_slimstat::$settings['add_dashboard_widgets']) {
272 $sanitized_uri = sanitize_url(wp_unslash($_SERVER['REQUEST_URI']));
273 $request_length = strlen($sanitized_uri);
274 $temp = $request_length - 10;
275
276 if (false !== strpos($sanitized_uri, '/wp-admin/index.php') || ($temp >= 0 && $temp <= $request_length && false !== strpos($sanitized_uri, '/wp-admin/', $temp))) {
277 add_action('admin_enqueue_scripts', [self::class, 'wp_slimstat_enqueue_scripts']);
278 add_action('admin_enqueue_scripts', [self::class, 'wp_slimstat_stylesheet']);
279 }
280
281 add_action('wp_dashboard_setup', [self::class, 'add_dashboard_widgets']);
282 }
283
284 // AJAX Handlers
285 if (defined('DOING_AJAX') && DOING_AJAX) {
286 $ajax_actions = [
287 'slimstat_notice_latest_news' => 'notices_handler',
288 'slimstat_notice_geolite' => 'notices_handler',
289 'slimstat_notice_browscap' => 'notices_handler',
290 'slimstat_notice_browscap_fileinfo' => 'notices_handler',
291 'slimstat_notice_caching' => 'notices_handler',
292 'slimstat_manage_filters' => 'manage_filters',
293 'slimstat_delete_pageview' => 'delete_pageview',
294 'slimstat_update_geoip_database' => 'update_geoip_database',
295 'slimstat_check_geoip_database' => 'check_geoip_database',
296 'slimstat_get_filter_options' => 'get_filter_options',
297 'slimstat_get_online_visitors' => 'get_online_visitors',
298 'slimstat_get_adminbar_stats' => 'get_adminbar_stats',
299 'slimstat_save_goal' => 'ajax_save_goal',
300 'slimstat_delete_goal' => 'ajax_delete_goal',
301 'slimstat_save_funnel' => 'ajax_save_funnel',
302 'slimstat_delete_funnel' => 'ajax_delete_funnel',
303 'slimstat_load_funnel_data' => 'ajax_load_funnel_data',
304 'slimstat_test_funnel_step' => 'ajax_test_funnel_step',
305 ];
306 foreach ($ajax_actions as $action => $handler) {
307 add_action('wp_ajax_' . $action, [self::class, $handler]);
308 }
309
310 // Live Analytics AJAX handler is registered via init_hooks() in Bootstrap
311 // No need to call it separately here - it's already registered
312 }
313
314 // Schedule a daily cron job to purge the data
315 if (!wp_next_scheduled('wp_slimstat_purge')) {
316 wp_schedule_event(time(), 'twicedaily', 'wp_slimstat_purge');
317 }
318
319 // Schedule a daily cron job to regenerate IP hashing salt (for GDPR compliance)
320 if (!wp_next_scheduled('wp_slimstat_generate_daily_salt')) {
321 wp_schedule_event(time(), 'daily', 'wp_slimstat_generate_daily_salt');
322 }
323
324 // Schedule a weekly cron job to update geoip database automatically
325 if (!wp_next_scheduled('wp_slimstat_update_geoip_database')) {
326 $nextRunInterval = wp_slimstat::get_schedule_interval('weekly');
327 wp_schedule_event(time() + $nextRunInterval, 'weekly', 'wp_slimstat_update_geoip_database');
328 }
329
330 // Fallback: if WP-Cron is disabled or scheduling failed, trigger a non-blocking direct update
331 // This ensures environments with DISABLE_WP_CRON still receive GeoIP database updates
332 $cron_disabled = (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) || !wp_next_scheduled('wp_slimstat_update_geoip_database');
333 $geoip_provider = \wp_slimstat::resolve_geolocation_provider();
334 if ($cron_disabled && false !== $geoip_provider && is_admin() && !wp_doing_ajax()
335 && current_user_can(\wp_slimstat::$settings['capability_can_admin'])) {
336 // Update if DB is missing or last update is older than the most recent past scheduled window
337 $last_update = (int) get_option('slimstat_last_geoip_dl', 0);
338
339 // Calculate the most recent "first Tuesday + 2 days" that has already passed
340 $this_month_update = strtotime('first Tuesday of this month') + (86400 * 2);
341 $current_time = time();
342
343 // If this month's update window hasn't arrived yet, use last month's window
344 if ($current_time < $this_month_update) {
345 $this_update = strtotime('first Tuesday of last month') + (86400 * 2);
346 } else {
347 $this_update = $this_month_update;
348 }
349
350 $needs_update = $last_update < $this_update;
351 $db_missing = false;
352 if (!$needs_update) {
353 // Time check passed — only check DB existence if time says we're current
354 try {
355 $uses_db = in_array($geoip_provider, \SlimStat\Services\GeoService::DB_PROVIDERS, true);
356 if ($uses_db) {
357 $service = new \SlimStat\Services\Geolocation\GeolocationService($geoip_provider, []);
358 $db_missing = !file_exists($service->getProvider()->getDbPath());
359 }
360 } catch (\Throwable $e) {
361 $db_missing = true;
362 }
363 }
364
365 if ($needs_update || $db_missing) {
366 // Fire admin-ajax in a non-blocking way to run the existing update handler
367 $ajax_url = admin_url('admin-ajax.php');
368 // Forward only WordPress authentication cookies for security
369 $cookie_header = '';
370 if (!headers_sent() && $_COOKIE !== [] && is_array($_COOKIE)) {
371 $pairs = [];
372 // Only forward WordPress authentication cookies
373 $allowed_cookie_prefixes = [
374 'wordpress_logged_in_',
375 'wordpress_sec_',
376 'wp-settings-',
377 'wp-settings-time-',
378 ];
379 foreach ($_COOKIE as $k => $v) {
380 $is_allowed = false;
381 foreach ($allowed_cookie_prefixes as $prefix) {
382 if (strpos($k, $prefix) === 0) {
383 $is_allowed = true;
384 break;
385 }
386 }
387 if ($is_allowed) {
388 $pairs[] = rawurlencode($k) . '=' . rawurlencode(sanitize_text_field(wp_unslash($v)));
389 }
390 }
391 $cookie_header = implode('; ', $pairs);
392 }
393 $args = [
394 'timeout' => 0.01,
395 'blocking' => false,
396 'body' => [
397 'action' => 'slimstat_update_geoip_database',
398 'security' => wp_create_nonce('slimstat_geoip_action'),
399 ],
400 'headers' => $cookie_header !== '' && $cookie_header !== '0' ? ['Cookie' => $cookie_header] : [],
401 ];
402 // Best-effort call; ignore response
403 wp_safe_remote_post($ajax_url, $args);
404 }
405 }
406
407 // Add style to the admin menu
408 add_action('admin_head', [self::class, 'styling_admin_menu']);
409
410 // Add lock export button in report header
411 add_filter('slimstat_report_header_buttons', fn ($_header_buttons, $_report_id) => self::add_lock_export_button($_header_buttons, $_report_id), 10, 2);
412
413 self::register_goals_funnels_header_hooks();
414
415 // Sync index options with actual DB state — skip SHOW INDEX if option already confirmed
416 foreach (self::get_index_definitions() as $def) {
417 if ('yes' === get_option($def['option'])) {
418 continue;
419 }
420 $exists = wp_slimstat::$wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = '%s'", $GLOBALS['wpdb']->prefix, $def['name']));
421 if (!empty($exists)) {
422 update_option($def['option'], 'yes');
423 }
424 }
425
426 self::register_index_hooks();
427
428 // Register the combined notice
429 add_action('admin_notices', ['wp_slimstat_admin', 'show_indexes_notice']);
430
431 // Initialize notification system
432 if (class_exists('SlimStat\\Services\\Admin\\Notification\\NotificationManager')) {
433 new \SlimStat\Services\Admin\Notification\NotificationManager();
434 }
435 // Initialize cron manager for notifications
436 if (class_exists('SlimStat\\Services\\CronEventManager')) {
437 new \SlimStat\Services\CronEventManager();
438 }
439 }
440
441 // END: init
442
443 /**
444 * Add style to the admin menu
445 */
446 public static function styling_admin_menu()
447 {
448 if (!wp_slimstat::pro_is_installed()) {
449 echo '<style> a.wp-slimstat-upgrade-to-pro {background-color: #f22f46 !important;color: #fff !important;font-weight: 600 !important;} </style>';
450 }
451 // The time-limited "New" badge on the Goals & Funnels item renders in the
452 // global sidebar, so its style must load on every admin page (not just
453 // slimview6). Tiny, so always emit it. (#20)
454 echo '<style> #adminmenu .slimstat-gf-new-badge {display:inline-block;margin-inline-start:6px;padding:0 6px;border-radius:9px;background:var(--wp-admin-theme-color,#2271b1);color:#fff;font-size:9px;font-weight:600;line-height:16px;text-transform:uppercase;letter-spacing:.03em;vertical-align:middle;} </style>';
455 }
456
457 /**
458 * "New" badge HTML for the Goals & Funnels sidebar item, shown for 15 days
459 * after the feature became available on this site, then it disappears.
460 * Returns '' once the window elapses. The window is anchored the first time
461 * the menu builds after this version ships, so existing installs start their
462 * countdown then. (#20)
463 */
464 private static function goals_funnels_new_badge()
465 {
466 $since = (int) get_option('slimstat_goals_funnels_since', 0);
467 if ($since <= 0) {
468 $since = time();
469 update_option('slimstat_goals_funnels_since', $since);
470 }
471 if ((time() - $since) >= (15 * DAY_IN_SECONDS)) {
472 return '';
473 }
474 return ' <span class="slimstat-gf-new-badge">' . esc_html__('New', 'wp-slimstat') . '</span>';
475 }
476
477 /**
478 * Clears the purge cron job
479 */
480 public static function deactivate()
481 {
482 wp_clear_scheduled_hook('wp_slimstat_purge');
483 wp_clear_scheduled_hook('wp_slimstat_update_geoip_database');
484 }
485
486 /**
487 * Reset layout
488 */
489 public static function handle_reset_layout()
490 {
491 // Check nonce
492 if (!wp_verify_nonce($_REQUEST['_wpnonce'], 'reset_layout')) {
493 wp_die(__('Sorry, you are not allowed to access this page.', 'wp-slimstat'));
494 }
495
496 $GLOBALS['wpdb']->query(sprintf("DELETE FROM %susermeta WHERE meta_key LIKE '%%meta-box-order_admin_page_slimlayout%%'", $GLOBALS['wpdb']->prefix));
497 $GLOBALS['wpdb']->query(sprintf("DELETE FROM %susermeta WHERE meta_key LIKE '%%mmetaboxhidden_admin_page_slimview%%'", $GLOBALS['wpdb']->prefix));
498 $GLOBALS['wpdb']->query(sprintf("DELETE FROM %susermeta WHERE meta_key LIKE '%%meta-box-order_slimstat%%'", $GLOBALS['wpdb']->prefix));
499 $GLOBALS['wpdb']->query(sprintf("DELETE FROM %susermeta WHERE meta_key LIKE '%%metaboxhidden_slimstat%%'", $GLOBALS['wpdb']->prefix));
500 $GLOBALS['wpdb']->query(sprintf("DELETE FROM %susermeta WHERE meta_key LIKE '%%closedpostboxes_slimstat%%'", $GLOBALS['wpdb']->prefix));
501
502 // Redirect to layout page
503 wp_safe_redirect(admin_url('admin.php?page=slimlayout'));
504 die();
505 }
506
507 /**
508 * Support for WP MU network activations
509 */
510 public static function new_blog($_blog_id)
511 {
512 switch_to_blog($_blog_id);
513 self::init_environment();
514 restore_current_blog();
515 }
516
517 // END: new_blog
518
519 /**
520 * Support for WP MU site deletion
521 */
522 public static function drop_tables($_tables = [], $_blog_id = 1)
523 {
524 $_tables['slim_events'] = $GLOBALS['wpdb']->prefix . 'slim_events';
525 $_tables['slim_stats'] = $GLOBALS['wpdb']->prefix . 'slim_stats';
526
527 $_tables['slim_events_archive'] = $GLOBALS['wpdb']->prefix . 'slim_events_archive';
528 $_tables['slim_stats_archive'] = $GLOBALS['wpdb']->prefix . 'slim_stats_archive';
529
530 return $_tables;
531 }
532
533 // END: drop_tables
534
535 /**
536 * Creates tables, initializes options and schedules purge cron
537 */
538 public static function init_environment()
539 {
540 if (function_exists('apply_filters')) {
541 $my_wpdb = apply_filters('slimstat_custom_wpdb', $GLOBALS['wpdb']);
542 }
543
544 // Create the tables
545 self::init_tables($my_wpdb);
546
547 // Initialize atomic visit ID counter (fix for issue #155 - performance regression)
548 \SlimStat\Tracker\VisitIdGenerator::initializeCounter();
549
550 // Ensure country/dt index exists for performance
551 $has_index = $my_wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = 'idx_country_dt'", $GLOBALS['wpdb']->prefix));
552 if (!$has_index || 0 === count($has_index)) {
553 $my_wpdb->query(sprintf('CREATE INDEX idx_country_dt ON %sslim_stats (country, dt)', $GLOBALS['wpdb']->prefix));
554 }
555 update_option('slimstat_country_dt_indexed', 'yes');
556
557 // --- Add (dt, screen_width, screen_height) index for Top Screen Resolutions ---
558 $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));
559 if (empty($dt_screen_index)) {
560 $my_wpdb->query(sprintf('CREATE INDEX idx_dt_screen_width_screen_height ON %sslim_stats (dt, screen_width, screen_height)', $GLOBALS['wpdb']->prefix));
561 }
562 update_option('slimstat_dt_screen_indexed', 'yes');
563
564 // --- Add (dt, browser, browser_version) index for Top Browsers ---
565 $dt_browser_index = $my_wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = 'idx_dt_browser_browser_version'", $GLOBALS['wpdb']->prefix));
566 if (empty($dt_browser_index)) {
567 $my_wpdb->query(sprintf('CREATE INDEX idx_dt_browser_browser_version ON %sslim_stats (dt, browser, browser_version)', $GLOBALS['wpdb']->prefix));
568 }
569 update_option('slimstat_dt_browser_indexed', 'yes');
570
571 // --- Add (dt, platform) index for Top Platforms ---
572 $dt_platform_index = $my_wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = 'idx_dt_platform'", $GLOBALS['wpdb']->prefix));
573 if (empty($dt_platform_index)) {
574 $my_wpdb->query(sprintf('CREATE INDEX idx_dt_platform ON %sslim_stats (dt, platform)', $GLOBALS['wpdb']->prefix));
575 }
576 update_option('slimstat_dt_platform_indexed', 'yes');
577
578 // --- Add (dt, visit_id) covering index for visitor counter queries ---
579 $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));
580 if (empty($dt_visit_index)) {
581 $my_wpdb->query(sprintf('CREATE INDEX %sstats_dt_visit_idx ON %sslim_stats (dt, visit_id)', $GLOBALS['wpdb']->prefix, $GLOBALS['wpdb']->prefix));
582 }
583 update_option('slimstat_dt_visit_indexed', 'yes');
584
585 // Hard-flush rewrite rules so the adblock bypass rewrite is written to .htaccess.
586 // Caching plugins (WP Rocket, W3TC) route requests via .htaccess before WordPress
587 // loads — a soft flush (false) only updates the DB and would not help.
588 flush_rewrite_rules();
589
590 return true;
591 }
592
593 // END: init_environment
594
595 /**
596 * Creates and populates tables, if they aren't already there.
597 */
598 public static function init_tables($_wpdb = '')
599 {
600 // Is InnoDB available?
601 $have_innodb = $_wpdb->get_results("SHOW VARIABLES LIKE 'have_innodb'", ARRAY_A);
602 $use_innodb = (!empty($have_innodb[0]) && 'YES' == $have_innodb[0]['Value']) ? 'ENGINE=InnoDB' : '';
603
604 // Table that stores the actual data about visits
605 $stats_table_sql = "
606 CREATE TABLE IF NOT EXISTS {$GLOBALS['wpdb']->prefix}slim_stats (
607 id INT UNSIGNED NOT NULL auto_increment,
608 ip VARCHAR(39) DEFAULT NULL,
609 other_ip VARCHAR(39) DEFAULT NULL,
610 username VARCHAR(256) DEFAULT NULL,
611 email VARCHAR(256) DEFAULT NULL,
612
613 country VARCHAR(16) DEFAULT NULL,
614 location VARCHAR(36) DEFAULT NULL,
615 city VARCHAR(256) DEFAULT NULL,
616
617 referer VARCHAR(2048) DEFAULT NULL,
618 resource VARCHAR(2048) DEFAULT NULL,
619 searchterms VARCHAR(2048) DEFAULT NULL,
620 notes VARCHAR(2048) DEFAULT NULL,
621 visit_id INT UNSIGNED NOT NULL DEFAULT 0,
622 server_latency INT(10) UNSIGNED DEFAULT 0,
623 page_performance INT(10) UNSIGNED DEFAULT 0,
624
625 browser VARCHAR(40) DEFAULT NULL,
626 browser_version VARCHAR(15) DEFAULT NULL,
627 browser_type TINYINT UNSIGNED DEFAULT 0,
628 platform VARCHAR(15) DEFAULT NULL,
629 language VARCHAR(5) DEFAULT NULL,
630 fingerprint VARCHAR(256) DEFAULT NULL,
631 user_agent VARCHAR(2048) DEFAULT NULL,
632
633 resolution VARCHAR(12) DEFAULT NULL,
634 screen_width SMALLINT UNSIGNED DEFAULT 0,
635 screen_height SMALLINT UNSIGNED DEFAULT 0,
636
637 content_type VARCHAR(64) DEFAULT NULL,
638 category VARCHAR(256) DEFAULT NULL,
639 author VARCHAR(64) DEFAULT NULL,
640 content_id BIGINT(20) UNSIGNED DEFAULT 0,
641
642 outbound_resource VARCHAR(2048) DEFAULT NULL,
643
644 tz_offset SMALLINT DEFAULT 0,
645 dt_out INT(10) UNSIGNED DEFAULT 0,
646 dt INT(10) UNSIGNED DEFAULT 0,
647
648 CONSTRAINT PRIMARY KEY (id),
649 INDEX {$GLOBALS['wpdb']->prefix}slim_stats_dt_idx (dt),
650 INDEX {$GLOBALS['wpdb']->prefix}stats_resource_idx( resource( 20 ) ),
651 INDEX {$GLOBALS['wpdb']->prefix}stats_browser_idx( browser( 10 ) ),
652 INDEX {$GLOBALS['wpdb']->prefix}stats_searchterms_idx( searchterms( 15 ) ),
653 INDEX {$GLOBALS['wpdb']->prefix}stats_fingerprint_idx( fingerprint( 20 ) ),
654 INDEX {$GLOBALS['wpdb']->prefix}stats_dt_visit_idx (dt, visit_id)
655 ) COLLATE utf8_general_ci {$use_innodb}";
656
657 // This table will track outbound links (clicks on links to external sites)
658 $events_table_sql = "
659 CREATE TABLE IF NOT EXISTS {$GLOBALS['wpdb']->prefix}slim_events (
660 event_id INT(10) NOT NULL AUTO_INCREMENT,
661 type TINYINT UNSIGNED DEFAULT 0,
662 event_description VARCHAR(64) DEFAULT NULL,
663 notes VARCHAR(256) DEFAULT NULL,
664 position VARCHAR(32) DEFAULT NULL,
665 id INT UNSIGNED NOT NULL DEFAULT 0,
666 dt INT(10) UNSIGNED DEFAULT 0,
667
668 CONSTRAINT PRIMARY KEY (event_id),
669 INDEX {$GLOBALS['wpdb']->prefix}slim_stat_events_idx (dt),
670 CONSTRAINT fk_{$GLOBALS['wpdb']->prefix}slim_events_id FOREIGN KEY (id) REFERENCES {$GLOBALS['wpdb']->prefix}slim_stats(id) ON UPDATE CASCADE ON DELETE CASCADE
671 ) COLLATE utf8_general_ci {$use_innodb}";
672
673 $archive_table_sql = "
674 CREATE TABLE IF NOT EXISTS {$GLOBALS['wpdb']->prefix}slim_stats_archive
675 LIKE {$GLOBALS['wpdb']->prefix}slim_stats";
676
677 $events_archive_table_sql = "
678 CREATE TABLE IF NOT EXISTS {$GLOBALS['wpdb']->prefix}slim_events_archive (
679 event_id INT(10) NOT NULL AUTO_INCREMENT,
680 type TINYINT UNSIGNED DEFAULT 0,
681 event_description VARCHAR(64) DEFAULT NULL,
682 notes VARCHAR(256) DEFAULT NULL,
683 position VARCHAR(32) DEFAULT NULL,
684 id INT UNSIGNED NOT NULL DEFAULT 0,
685 dt INT(10) UNSIGNED DEFAULT 0,
686
687 CONSTRAINT PRIMARY KEY (event_id),
688 INDEX {$GLOBALS['wpdb']->prefix}slim_stat_events_archive_idx (dt)
689 ) COLLATE utf8_general_ci {$use_innodb}";
690
691 // Ok, let's create the table structure
692 self::_create_table($stats_table_sql, $GLOBALS['wpdb']->prefix . 'slim_stats', $_wpdb);
693 self::_create_table($events_table_sql, $GLOBALS['wpdb']->prefix . 'slim_events', $_wpdb);
694 self::_create_table($archive_table_sql, $GLOBALS['wpdb']->prefix . 'slim_stats_archive', $_wpdb);
695 self::_create_table($events_archive_table_sql, $GLOBALS['wpdb']->prefix . 'slim_events_archive', $_wpdb);
696
697 // Let's save the version in the database
698 if (empty(wp_slimstat::$settings['version'])) {
699 wp_slimstat::$settings['version'] = SLIMSTAT_ANALYTICS_VERSION;
700 }
701
702 $index_defs = [
703 ['name' => 'idx_country_dt', 'sql' => sprintf('CREATE INDEX idx_country_dt ON %sslim_stats (country, dt)', $GLOBALS['wpdb']->prefix), 'option' => 'slimstat_country_dt_indexed'],
704 ['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'],
705 ['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'],
706 ['name' => 'idx_dt_platform', 'sql' => sprintf('CREATE INDEX idx_dt_platform ON %sslim_stats (dt, platform)', $GLOBALS['wpdb']->prefix), 'option' => 'slimstat_dt_platform_indexed'],
707 // Speeds up "Currently Online" queries using dt_out > NOW()-300
708 ['name' => 'idx_dt_out', 'sql' => sprintf('CREATE INDEX idx_dt_out ON %sslim_stats (dt_out)', $GLOBALS['wpdb']->prefix), 'option' => 'slimstat_dt_out_indexed'],
709 ];
710 foreach ($index_defs as $idx) {
711 $exists = $_wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = '%s'", $GLOBALS['wpdb']->prefix, $idx['name']));
712 if (empty($exists)) {
713 $_wpdb->query($idx['sql']);
714 }
715 update_option($idx['option'], 'yes');
716 }
717
718 }
719
720 // END: init_tables
721
722 /**
723 * Updates stuff around as needed (table schema, options, settings, files, etc)
724 */
725 public static function update_tables_and_options()
726 {
727 $my_wpdb = apply_filters('slimstat_custom_wpdb', $GLOBALS['wpdb']);
728
729 // --- Updates for version 4.8.2 ---
730 if (version_compare(wp_slimstat::$settings['version'], '4.8.2', '<')) {
731 // Add new email column to database
732 $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats ADD COLUMN email VARCHAR(255) DEFAULT NULL AFTER username', $GLOBALS['wpdb']->prefix));
733 $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats_archive ADD COLUMN email VARCHAR(255) DEFAULT NULL AFTER username', $GLOBALS['wpdb']->prefix));
734 }
735
736 // --- END: Updates for version 4.8.2 ---
737
738 // --- Updates for version 4.8.4 ---
739 if (version_compare(wp_slimstat::$settings['version'], '4.8.4', '<')) {
740 // Switch option to track WP users (from track to ignore)
741 wp_slimstat::$settings['ignore_wp_users'] = (!empty(wp_slimstat::$settings['track_users']) && 'no' == wp_slimstat::$settings['track_users']) ? 'on' : 'no';
742
743 // Remove unused options
744 unset(wp_slimstat::$settings['track_users']);
745 unset(wp_slimstat::$settings['enable_javascript']);
746 unset(wp_slimstat::$settings['honor_dnt_header']);
747 unset(wp_slimstat::$settings['no_maxmind_warning']);
748 unset(wp_slimstat::$settings['no_browscap_warning']);
749 unset(wp_slimstat::$settings['use_european_separators']);
750 unset($wp_slimstat::$settings['date_format']);
751 unset($wp_slimstat::$settings['time_format']);
752 unset($wp_slimstat::$settings['expand_details']);
753
754 // Add table indexes for improved performance (idempotent)
755 $indexes = [
756 ['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)],
757 ['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)],
758 ['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)],
759 ['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)],
760 ];
761 foreach ($indexes as $index) {
762 $check_index = wp_slimstat::$wpdb->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = '%s'", $GLOBALS['wpdb']->prefix, $index['name']));
763 if (empty($check_index)) {
764 wp_slimstat::$wpdb->query($index['sql']);
765 }
766 }
767 wp_slimstat::$settings['db_indexes'] = 'on';
768 }
769
770 // --- END: Updates for version 4.8.4 ---
771
772 // --- Updates for version 4.8.4.1 ---
773 if (version_compare(wp_slimstat::$settings['version'], '4.8.4.1', '<')) {
774 // Goodbye, browser plugins
775 wp_slimstat::$wpdb->query(sprintf('ALTER TABLE %sslim_stats DROP COLUMN plugins', $GLOBALS['wpdb']->prefix));
776
777 // Hello there, fingerprint and timezone offset
778 $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats ADD COLUMN fingerprint VARCHAR(256) DEFAULT NULL AFTER language', $GLOBALS['wpdb']->prefix));
779 $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats_archive ADD COLUMN fingerprint VARCHAR(255) DEFAULT NULL AFTER language', $GLOBALS['wpdb']->prefix));
780 $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats ADD COLUMN tz_offset SMALLINT DEFAULT 0 AFTER outbound_resource', $GLOBALS['wpdb']->prefix));
781 $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats_archive ADD COLUMN tz_offset SMALLINT DEFAULT 0 AFTER outbound_resource', $GLOBALS['wpdb']->prefix));
782 }
783
784 // --- END: Updates for version 4.8.4.1 ---
785
786 // --- Updates for version 4.8.8 ---
787 if (version_compare(wp_slimstat::$settings['version'], '4.8.8', '<')) {
788 // Adding new index on the 'fingerprint' column for improved performance
789 if ('on' == wp_slimstat::$settings['db_indexes']) {
790 $my_wpdb->query(sprintf('ALTER TABLE %sslim_stats ADD INDEX %sstats_fingerprint_idx( fingerprint( 20 ) )', $GLOBALS['wpdb']->prefix, $GLOBALS['wpdb']->prefix));
791 }
792
793 $my_wpdb->query(sprintf("UPDATE %sslim_stats SET notes = CONCAT( '[', REPLACE( notes, ';', '][' ), ']' ) WHERE notes NOT LIKE '[%%'", $GLOBALS['wpdb']->prefix));
794 }
795
796 // --- Updates for version 5.4.0 ---
797 if (version_compare(wp_slimstat::$settings['version'], '5.4.0', '<')) {
798 // Migrate legacy 'adblock' tracking method to 'adblock_bypass' (renamed in v5.3.0)
799 if (!empty(wp_slimstat::$settings['tracking_request_method']) && 'adblock' === wp_slimstat::$settings['tracking_request_method']) {
800 wp_slimstat::$settings['tracking_request_method'] = 'adblock_bypass';
801 }
802
803 // Default use_separate_menu to 'on' if not already set
804 if (empty(wp_slimstat::$settings['use_separate_menu'])) {
805 wp_slimstat::$settings['use_separate_menu'] = 'on';
806 }
807 }
808
809 // --- Updates for version 5.4.1 ---
810 // Fix admin bar migration: empty('no') returned false in 5.4.0, missing users with legacy 'no' value
811 // Safe because this runs once (version bumps to 5.4.1 after), users who disable later are already on 5.4.1+
812 if (version_compare(wp_slimstat::$settings['version'], '5.4.1', '<')) {
813 wp_slimstat::$settings['use_separate_menu'] = 'on';
814 }
815
816 // --- Updates for version 5.4.3 ---
817 if (version_compare(wp_slimstat::$settings['version'], '5.4.3', '<')) {
818 // Add (dt, visit_id) covering index for visitor counter queries
819 $idx_name = $GLOBALS['wpdb']->prefix . 'stats_dt_visit_idx';
820 $check = $my_wpdb->get_results(sprintf(
821 "SHOW INDEX FROM %sslim_stats WHERE Key_name = '%s'",
822 $GLOBALS['wpdb']->prefix, $idx_name
823 ));
824 if (empty($check)) {
825 $result = $my_wpdb->query(sprintf(
826 'CREATE INDEX %s ON %sslim_stats (dt, visit_id)',
827 $idx_name, $GLOBALS['wpdb']->prefix
828 ));
829 if ($result !== false) {
830 update_option('slimstat_dt_visit_indexed', 'yes');
831 }
832 // If fails (large table timeout), show_indexes_notice() surfaces a retry button
833 } else {
834 update_option('slimstat_dt_visit_indexed', 'yes');
835 }
836 }
837
838 // --- Goals & Funnels composite indexes for query performance ---
839 // These three indexes are also registered as AbstractIndexMigration classes
840 // (Create{Goal,Funnel}QueriesIndex / CreateEventsNotesDtIndex in
841 // src/Migration/MigrationService.php) which provide the retry UI; keep the
842 // index name + columns here in sync with those classes.
843 if (empty(wp_slimstat::$settings['goals_indexes'])) {
844 $goal_indexes = [
845 ['table' => 'slim_stats', 'name' => 'idx_goal_queries', 'sql' => 'ADD INDEX idx_goal_queries (resource(191), dt, fingerprint(20))'],
846 ['table' => 'slim_stats', 'name' => 'idx_funnel_queries', 'sql' => 'ADD INDEX idx_funnel_queries (fingerprint(20), dt, resource(191))'],
847 ['table' => 'slim_events', 'name' => 'idx_events_notes_dt', 'sql' => 'ADD INDEX idx_events_notes_dt (dt, notes(64))'],
848 ];
849 $goal_indexes_built = true;
850 foreach ($goal_indexes as $idx) {
851 $table = $GLOBALS['wpdb']->prefix . $idx['table'];
852 $exists = wp_slimstat::$wpdb->get_results(
853 wp_slimstat::$wpdb->prepare("SHOW INDEX FROM {$table} WHERE Key_name = %s", $idx['name'])
854 );
855 if (empty($exists)) {
856 // ALTER can time out on very large tables. Track the result so we
857 // only mark this complete once every index is present; a failure
858 // leaves goals_indexes unset so the modern migration system
859 // (MigrationService) surfaces a one-click retry notice. (#318)
860 if (false === wp_slimstat::$wpdb->query("ALTER TABLE {$table} {$idx['sql']}")) {
861 $goal_indexes_built = false;
862 }
863 }
864 }
865 if ($goal_indexes_built) {
866 wp_slimstat::$settings['goals_indexes'] = 'on';
867 }
868 }
869
870 // Clear stale query cache transients on upgrade to prevent data inconsistencies
871 // (e.g., cached $pageviews causing percentage >100% in reports — see #270)
872 $GLOBALS['wpdb']->query(
873 "DELETE FROM {$GLOBALS['wpdb']->options} WHERE option_name LIKE '_transient_wp_slimstat_cache_%' OR option_name LIKE '_transient_timeout_wp_slimstat_cache_%' LIMIT 1000"
874 );
875
876 // Rotate the goals/funnels cache version on upgrade so pre-fix cached
877 // results (e.g. goal "uniques" that excluded NULL-fingerprint visitors)
878 // are recomputed immediately rather than lingering for the 5–15 min
879 // transient TTL after the uniques identity changed. (#3)
880 update_option('slimstat_goals_cache_ver', (string) microtime(true), false);
881
882 // Now we can update the version stored in the database
883 wp_slimstat::$settings['version'] = SLIMSTAT_ANALYTICS_VERSION;
884 wp_slimstat::$settings['notice_latest_news'] = 'on';
885 wp_slimstat::update_option('slimstat_options', wp_slimstat::$settings);
886
887 return true;
888 }
889
890 // END: update_tables_and_options
891
892 public static function add_dashboard_widgets()
893 {
894 // If this user is whitelisted, we use the minimum capability
895 $minimum_capability = 'read';
896 if (false === strpos(wp_slimstat::$settings['can_view'], (string) $GLOBALS['current_user']->user_login) && !empty(wp_slimstat::$settings['capability_can_view'])) {
897 $minimum_capability = wp_slimstat::$settings['capability_can_view'];
898 }
899
900 if (!current_user_can($minimum_capability)) {
901 return;
902 }
903
904 // Initialize the new Reports system FIRST before legacy system loads
905 \SlimStat\Reports\Bootstrap::get_instance()->init();
906
907 // The Reports library is only loaded on the plugin's screens
908 include_once(plugin_dir_path(__FILE__) . 'view/wp-slimstat-reports.php');
909 wp_slimstat_reports::init();
910
911 if (!empty(wp_slimstat_reports::$user_reports['dashboard']) && is_array(wp_slimstat_reports::$user_reports['dashboard'])) {
912 foreach (wp_slimstat_reports::$user_reports['dashboard'] as $a_report_id) {
913 if (empty(wp_slimstat_reports::$reports[$a_report_id])) {
914 continue;
915 }
916 // Force compact rendering on the WP Dashboard for goals/funnels so
917 // drawer/builder/confirm-sheet markup never mounts inside the widget.
918 // Mutation is kept local: we only re-bind the registry field when
919 // registering this specific widget, avoiding cross-request leaks.
920 if ('slim_p9_01' === $a_report_id || 'slim_p9_02' === $a_report_id) {
921 wp_slimstat_reports::$reports[$a_report_id]['callback_args']['is_widget'] = true;
922 }
923 wp_add_dashboard_widget($a_report_id, wp_slimstat_reports::$reports[$a_report_id]['title'], ['wp_slimstat_reports', 'callback_wrapper']);
924 }
925 }
926 }
927
928 // END: add_dashboard_widgets
929
930 /**
931 * Removes 'spammers' from the database when the corresponding comments are marked as spam
932 */
933 public static function remove_spam($_new_status = '', $_old_status = '', $_comment = '')
934 {
935 $my_wpdb = apply_filters('slimstat_custom_wpdb', $GLOBALS['wpdb']);
936
937 if ('spam' == $_new_status && !empty($_comment->comment_author) && !empty($_comment->comment_author_IP)) {
938 $my_wpdb->query(wp_slimstat::$wpdb->prepare("
939 DELETE ts
940 FROM {$GLOBALS['wpdb']->prefix}slim_stats ts
941 WHERE username = %s OR INET_NTOA(ip) = %s", $_comment->comment_author, $_comment->comment_author_IP));
942 }
943 }
944
945 // END: remove_spam
946
947 /**
948 * Loads a custom stylesheet file for the administration panels
949 */
950 public static function wp_slimstat_stylesheet($_hook = '')
951 {
952 wp_register_style('wp-slimstat', plugins_url('/admin/assets/css/admin.css', __DIR__), false, SLIMSTAT_ANALYTICS_VERSION);
953 wp_enqueue_style('wp-slimstat');
954
955 wp_register_style(
956 'wp-slimstat-header-modern',
957 plugins_url('/admin/assets/css/header-modern.css', __DIR__),
958 ['wp-slimstat'],
959 SLIMSTAT_ANALYTICS_VERSION
960 );
961 wp_enqueue_style('wp-slimstat-header-modern');
962
963 // Goals & Funnels CSS — only loaded on screens that actually render those reports.
964 // Honors slimlayout/Customize drag by inspecting the user's resolved report layout.
965 if (self::needs_goals_funnels_assets()) {
966 wp_register_style(
967 'wp-slimstat-tokens',
968 plugins_url('/admin/assets/css/tokens.css', __DIR__),
969 [],
970 SLIMSTAT_ANALYTICS_VERSION
971 );
972 wp_enqueue_style('wp-slimstat-tokens');
973
974 wp_register_style(
975 'wp-slimstat-goals-funnels',
976 plugins_url('/admin/assets/css/goals-funnels.css', __DIR__),
977 ['wp-slimstat', 'wp-slimstat-tokens'],
978 SLIMSTAT_ANALYTICS_VERSION
979 );
980 wp_enqueue_style('wp-slimstat-goals-funnels');
981 }
982
983 if (!empty(wp_slimstat::$settings['custom_css'])) {
984 wp_add_inline_style('wp-slimstat', wp_slimstat::$settings['custom_css']);
985 }
986 }
987
988 /**
989 * Returns true when the current admin context renders slim_p9_01 or slim_p9_02.
990 * Covers the direct slimview6 page, the WP dashboard, and screens that have
991 * Goals/Funnels dragged in via the Customizer.
992 *
993 * @since 5.5.0
994 */
995 public static function needs_goals_funnels_assets()
996 {
997 // Only memoize `true` — a `false` answer is provisional until the reports
998 // registry has loaded. Caching `false` too early (e.g. during
999 // admin_enqueue_scripts on index.php, before wp_slimstat_reports::init()
1000 // runs) would cause the dashboard widget path to miss its own assets.
1001 static $memo = null;
1002 if ($memo === true) {
1003 return true;
1004 }
1005
1006 if (!empty($_GET['page']) && 'slimview6' === $_GET['page']) {
1007 return $memo = true;
1008 }
1009
1010 if (!class_exists('wp_slimstat_reports', false)) {
1011 return false;
1012 }
1013
1014 $pagenow = $GLOBALS['pagenow'] ?? '';
1015 if ('index.php' === $pagenow) {
1016 $dashboard_reports = wp_slimstat_reports::$user_reports['dashboard'] ?? [];
1017 if (in_array('slim_p9_01', (array) $dashboard_reports, true)
1018 || in_array('slim_p9_02', (array) $dashboard_reports, true)) {
1019 return $memo = true;
1020 }
1021 }
1022
1023 $current = self::$current_screen;
1024 if (!empty($current)) {
1025 $reports_on_screen = wp_slimstat_reports::$user_reports[$current] ?? [];
1026 if (in_array('slim_p9_01', (array) $reports_on_screen, true)
1027 || in_array('slim_p9_02', (array) $reports_on_screen, true)) {
1028 return $memo = true;
1029 }
1030 }
1031
1032 return false;
1033 }
1034
1035 /**
1036 * Emits the Goals & Funnels shared DOM (confirm sheet, goal drawer, funnel
1037 * builder) once per admin page, gated on the same helper as asset enqueue.
1038 *
1039 * @since 5.5.0
1040 */
1041 public static function print_goals_funnels_dom()
1042 {
1043 static $printed = false;
1044 if ($printed) {
1045 return;
1046 }
1047 if (!self::needs_goals_funnels_assets()) {
1048 return;
1049 }
1050
1051 $printed = true;
1052 $dimensions = self::get_goal_dimensions();
1053 // Funnel steps offer only action-oriented dimensions; goals keep the full
1054 // list (a "Country = gb" goal is legitimate). (#17)
1055 $funnel_step_dimensions = self::get_funnel_step_dimensions();
1056 $operators = self::get_goal_operators();
1057 $operator_labels = self::get_goal_operator_labels();
1058
1059 $partials_dir = plugin_dir_path(__FILE__) . 'view/partials/goals-funnels/';
1060 include $partials_dir . 'confirm-sheet.php';
1061 include $partials_dir . 'goal-drawer.php';
1062 include $partials_dir . 'funnel-builder.php';
1063 }
1064
1065 // END: wp_slimstat_stylesheet
1066
1067 /**
1068 * Adds a shared body class to all Slimstat admin screens.
1069 */
1070 public static function add_admin_body_class($classes)
1071 {
1072 return $classes . ' slimstat-admin-page';
1073 }
1074
1075 /**
1076 * Loads user-defined stylesheet code
1077 */
1078 public static function wp_slimstat_userdefined_stylesheet()
1079 {
1080 echo '<style type="text/css" media="screen">' . wp_slimstat::$settings['custom_css'] . '</style>';
1081 }
1082
1083 // END: wp_slimstat_userdefined_stylesheet
1084
1085 /**
1086 * Enqueues Javascript and styles needed in the admin
1087 */
1088 public static function wp_slimstat_enqueue_scripts($_hook = '')
1089 {
1090 $current_screen = get_current_screen();
1091 if ($current_screen && false !== strpos((string) ($current_screen->id ?? ''), 'slim')) {
1092 wp_enqueue_script('dashboard');
1093 wp_enqueue_script('jquery-ui-datepicker');
1094 wp_enqueue_script('jquery-ui-sortable');
1095 }
1096
1097 // Enqueue the built-in code editor to use on the Settings
1098 if ($current_screen) {
1099 wp_enqueue_code_editor(['type' => 'text/html']);
1100 }
1101
1102 // Enqueue date range picker assets for report pages
1103 $should_load_datepicker = false;
1104 if (isset($_GET['page'])) {
1105 $page = sanitize_text_field($_GET['page']);
1106 if (false !== strpos($page, 'slim') && false === strpos($page, 'setting')) {
1107 $should_load_datepicker = true;
1108 }
1109 }
1110
1111 if ($should_load_datepicker) {
1112
1113 // Enqueue moment.js
1114 wp_enqueue_script('slimstat-moment', plugins_url('/admin/assets/js/daterangepicker/moment.min.js', __DIR__), [], '2.30.2', true);
1115
1116 // Enqueue daterangepicker
1117 wp_enqueue_script('slimstat-daterangepicker', plugins_url('/admin/assets/js/daterangepicker/daterangepicker.min.js', __DIR__), ['jquery', 'slimstat-moment'], '3.1.0', true);
1118
1119 // Enqueue our custom date picker
1120 wp_enqueue_script('slimstat-custom-datepicker', plugins_url('/admin/assets/js/daterangepicker/slimstat-daterangepicker.js', __DIR__), ['jquery', 'slimstat-daterangepicker'], SLIMSTAT_ANALYTICS_VERSION, true);
1121
1122 // Enqueue date picker styles
1123 wp_enqueue_style('slimstat-daterangepicker-base', plugins_url('/admin/assets/css/daterangepicker/daterangepicker.css', __DIR__), [], '3.1.0');
1124 wp_enqueue_style('slimstat-daterangepicker-custom', plugins_url('/admin/assets/css/daterangepicker/slimstat-datepicker-styles.css', __DIR__), ['slimstat-daterangepicker-base'], SLIMSTAT_ANALYTICS_VERSION);
1125
1126 // Localize date picker script
1127 $datepicker_params = [
1128 'ajax_url' => admin_url('admin-ajax.php'),
1129 'clear_cache_nonce' => wp_create_nonce('slimstat_clear_cache'),
1130 'options' => [
1131 'wp_timezone' => DateRangeHelper::get_wp_timezone(),
1132 'start_of_week' => DateRangeHelper::get_week_start(),
1133 'date_format' => DateRangeHelper::get_date_format()
1134 ],
1135 'strings' => DateRangeHelper::get_localized_strings()
1136 ];
1137 wp_localize_script('slimstat-custom-datepicker', 'SlimStatDatePicker', $datepicker_params);
1138 }
1139
1140 // Shared wp.i18n accessor (window.wpSlimstatI18n) for every admin script
1141 // that carries translatable strings. Depends on wp-i18n; the scripts below
1142 // depend on this handle so the accessor is defined before they run.
1143 wp_enqueue_script('slimstat-i18n', plugins_url('/admin/assets/js/i18n.js', __DIR__), ['wp-i18n'], SLIMSTAT_ANALYTICS_VERSION, true);
1144
1145 // slimstat-i18n dependency + script translations so admin.js's __() strings
1146 // (combobox labels, etc.) load their JSON translations at runtime. Without
1147 // this, the strings are extracted into the .pot but never translated.
1148 wp_enqueue_script('slimstat_admin', plugins_url('/admin/assets/js/admin.js', __DIR__), ['jquery-ui-dialog', 'slimstat-i18n'], SLIMSTAT_ANALYTICS_VERSION, true);
1149 self::set_slimstat_script_translations('slimstat_admin');
1150
1151 // Enqueue notification assets if notifications are enabled
1152 if (wp_slimstat::$settings['display_notifications'] == 'on') {
1153 wp_enqueue_style('slimstat_notifications', plugins_url('/admin/assets/css/notifications.css', __DIR__), [], SLIMSTAT_ANALYTICS_VERSION);
1154 wp_enqueue_style('slimstat_header_notifications', plugins_url('/admin/assets/css/header-notifications.css', __DIR__), [], SLIMSTAT_ANALYTICS_VERSION);
1155 wp_enqueue_script('slimstat_notifications', plugins_url('/admin/assets/js/notifications.js', __DIR__), ['jquery'], SLIMSTAT_ANALYTICS_VERSION, false);
1156
1157 // Pass notification data to Javascript
1158 $notification_params = [
1159 'ajax_url' => admin_url('admin-ajax.php'),
1160 'nonce' => wp_create_nonce('wp_rest'),
1161 ];
1162 wp_localize_script('slimstat_notifications', 'slimstat_admin', $notification_params);
1163 }
1164
1165 // Pass some information to Javascript
1166 $params = [
1167 'async_load' => empty(wp_slimstat::$settings['async_load']) ? 'no' : wp_slimstat::$settings['async_load'],
1168 'datepicker_image' => plugins_url('/admin/assets/images/datepicker.png', __DIR__),
1169 'refresh_interval' => intval(wp_slimstat::$settings['refresh_interval']),
1170 'page_location' => self::$page_location,
1171 'clear_cache_nonce' => wp_create_nonce('slimstat_clear_cache'),
1172 'goals_nonce' => wp_create_nonce('slimstat_goals_nonce'),
1173 'ajax_url' => admin_url('admin-ajax.php'),
1174 // Shared with the filter form-builder so value-less operators (is_empty/
1175 // is_not_empty) are never treated as a "remove filter" signal. See #305.
1176 // Guarded: this method also runs on the Dashboard-widget path, where
1177 // wp_slimstat_db may not be included — fall back to the literal list.
1178 'valueless_operators' => class_exists('wp_slimstat_db') ? wp_slimstat_db::$valueless_operators : ['is_empty', 'is_not_empty'],
1179 // Canonical date/misc filter keys, shared with SlimStatGetFiltersForAjax() so it
1180 // strips the same non-column keys when harvesting filters for a sub-report (#22).
1181 'non_column_filter_keys' => class_exists('wp_slimstat_db')
1182 ? wp_slimstat_db::NON_COLUMN_FILTER_KEYS
1183 : ['strtotime', 'minute', 'hour', 'day', 'month', 'year', 'interval', 'interval_hours', 'interval_minutes', 'limit_results', 'start_from'],
1184 // WP-locale number separators so JS-rendered (lazily-loaded) funnel tabs
1185 // match the server's number_format_i18n() output instead of the browser
1186 // locale's toLocaleString().
1187 'number_format' => [
1188 'decimal_point' => is_object($GLOBALS['wp_locale'] ?? null) ? ($GLOBALS['wp_locale']->number_format['decimal_point'] ?? '.') : '.',
1189 'thousands_sep' => is_object($GLOBALS['wp_locale'] ?? null) ? ($GLOBALS['wp_locale']->number_format['thousands_sep'] ?? ',') : ',',
1190 ],
1191 ];
1192 wp_localize_script('slimstat_admin', 'SlimStatAdminParams', $params);
1193
1194 // Goals & Funnels AJAX handlers — gated to screens that actually render those reports.
1195 if (self::needs_goals_funnels_assets()) {
1196 wp_enqueue_script(
1197 'slimstat-goals-funnels',
1198 plugins_url('/admin/assets/js/goals-funnels.js', __DIR__),
1199 ['jquery', 'slimstat_admin', 'slimstat-i18n'],
1200 SLIMSTAT_ANALYTICS_VERSION,
1201 true
1202 );
1203 self::set_slimstat_script_translations('slimstat-goals-funnels');
1204 }
1205 }
1206
1207 // END: wp_slimstat_enqueue_scripts
1208
1209 /**
1210 * Registers JS translations for one of our scripts so its wp.i18n strings
1211 * load their JSON language pack at runtime. Shared by every enqueued script
1212 * that carries translatable strings (admin.js, goals-funnels.js).
1213 */
1214 private static function set_slimstat_script_translations(string $handle): void
1215 {
1216 if (function_exists('wp_set_script_translations')) {
1217 wp_set_script_translations($handle, 'wp-slimstat', plugin_dir_path(__DIR__) . 'languages');
1218 }
1219 }
1220
1221 /**
1222 * Adds a new entry in the admin menu, to view the stats
1223 */
1224 public static function add_menus($_s = '')
1225 {
1226 global $submenu;
1227
1228 // If this user is whitelisted, we use the minimum capability
1229 $minimum_capability = 'read';
1230 if (is_network_admin()) {
1231 $minimum_capability = 'manage_network';
1232 } elseif (false === strpos(wp_slimstat::$settings['can_view'], (string) $GLOBALS['current_user']->user_login) && !empty(wp_slimstat::$settings['capability_can_view'])) {
1233 $minimum_capability = wp_slimstat::$settings['capability_can_view'];
1234 }
1235
1236 // Find the first available location (screens with no reports assigned to them are hidden from the nav)
1237 $parent = '';
1238 if (is_array(self::$meta_user_reports)) {
1239 foreach (self::$screens_info as $a_screen_id => $a_screen_info) {
1240 if (!empty(self::$meta_user_reports[$a_screen_id]) && $a_screen_info['show_in_sidebar']) {
1241 $parent = $a_screen_id;
1242 break;
1243 }
1244 }
1245 }
1246
1247 // If no parent was found in the user meta, use the first available screen as the parent
1248 if (empty($parent) && !empty(self::$screens_info)) {
1249 $parent = array_key_first(self::$screens_info);
1250 }
1251
1252 // Don't show the menu if no screens are available at all
1253 if (empty($parent) || !isset(self::$screens_info[$parent])) {
1254 return null;
1255 }
1256
1257 self::$main_menu_slug = $parent;
1258
1259 // Build menu title with notification badge
1260 $menu_title = __('SlimStat', 'wp-slimstat');
1261 if (class_exists(NotificationFactory::class) && wp_slimstat::$settings['display_notifications'] === 'on') {
1262 $notification_count = NotificationFactory::getNewNotificationCount();
1263 if ($notification_count > 0) {
1264 $menu_title .= sprintf(
1265 ' <span class="update-plugins count-%d"><span class="plugin-count">%s</span></span>',
1266 $notification_count,
1267 number_format_i18n($notification_count)
1268 );
1269 }
1270 }
1271
1272 // Add the main menu
1273 add_menu_page(
1274 __('SlimStat', 'wp-slimstat'),
1275 $menu_title,
1276 $minimum_capability,
1277 $parent,
1278 [self::class, 'wp_slimstat_include_view'],
1279 'dashicons-chart-area'
1280 );
1281
1282 foreach (self::$screens_info as $a_screen_id => $a_screen_info) {
1283 if (isset(self::$meta_user_reports[$a_screen_id]) && empty(self::$meta_user_reports[$a_screen_id])) {
1284 continue;
1285 }
1286
1287 $minimum_capability = 'read';
1288 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']])) {
1289 $minimum_capability = wp_slimstat::$settings['capability_' . $a_screen_info['capability']];
1290 }
1291
1292 if ($a_screen_info['show_in_sidebar']) {
1293 // Sidebar label may carry the time-limited "New" badge; the page
1294 // title (browser tab) stays plain. (#20)
1295 $menu_label = $a_screen_info['title'];
1296 if ('slimview6' === $a_screen_id) {
1297 $menu_label .= self::goals_funnels_new_badge();
1298 }
1299 $new_entry[] = add_submenu_page(
1300 $parent,
1301 $a_screen_info['title'],
1302 $menu_label,
1303 $minimum_capability,
1304 $a_screen_id,
1305 $a_screen_info['callback']
1306 );
1307 }
1308 }
1309
1310 if (isset($submenu[$parent])) {
1311 array_walk($submenu[$parent], function (&$item) {
1312 if (isset($item[2]) && 'slimpro' === $item[2]) {
1313 $item[4] = isset($item[4]) ? $item[4] . ' wp-slimstat-upgrade-to-pro' : ' wp-slimstat-upgrade-to-pro';
1314 }
1315 });
1316 }
1317
1318 // Load styles and Javascript needed to make the reports look nice and interactive
1319 foreach ($new_entry as $a_entry) {
1320 add_action('load-' . $a_entry, [self::class, 'wp_slimstat_stylesheet']);
1321 add_action('load-' . $a_entry, [self::class, 'wp_slimstat_enqueue_scripts']);
1322 add_action('load-' . $a_entry, [self::class, 'contextual_help']);
1323 add_action('load-' . $a_entry, function () {
1324 add_filter('admin_body_class', [wp_slimstat_admin::class, 'add_admin_body_class']);
1325 });
1326 }
1327
1328 return $_s;
1329 }
1330
1331 // END: add_menus
1332
1333 /**
1334 * Enqueue admin bar modal styles globally (admin + frontend)
1335 */
1336 public static function enqueue_adminbar_styles()
1337 {
1338 if (is_admin_bar_showing()) {
1339 wp_enqueue_style(
1340 'slimstat-adminbar',
1341 plugins_url('/admin/assets/css/admin-bar-modal.css', __DIR__),
1342 [],
1343 SLIMSTAT_ANALYTICS_VERSION
1344 );
1345
1346 // Enqueue admin bar realtime JS for stats auto-refresh (frontend + admin)
1347 // On frontend: self-polls every minute
1348 // On admin: defers to admin.js slimstat:minute_pulse
1349 wp_enqueue_script(
1350 'slimstat-adminbar-realtime',
1351 plugins_url('/admin/assets/js/adminbar-realtime.js', __DIR__),
1352 [],
1353 SLIMSTAT_ANALYTICS_VERSION,
1354 true
1355 );
1356
1357 wp_localize_script('slimstat-adminbar-realtime', 'SlimStatAdminBar', [
1358 'ajax_url' => admin_url('admin-ajax.php'),
1359 'security' => wp_create_nonce('meta-box-order'),
1360 'is_pro' => wp_slimstat::pro_is_installed(),
1361 'i18n' => [
1362 'was_last_day' => esc_html__('was %s last day', 'wp-slimstat'),
1363 'online_users' => esc_html__('Online Users', 'wp-slimstat'),
1364 'count_label' => esc_html__('Count', 'wp-slimstat'),
1365 'now' => esc_html__('Now', 'wp-slimstat'),
1366 'min_ago' => esc_html__('min ago', 'wp-slimstat'),
1367 ],
1368 ]);
1369 }
1370 }
1371
1372 // END: enqueue_adminbar_styles
1373
1374 /**
1375 * Adds a new entry in the WordPress Admin Bar with stats modal
1376 */
1377 public static function add_menu_to_adminbar()
1378 {
1379 // If this user is whitelisted, we use the minimum capability
1380 $minimum_capability = 'read';
1381 if (is_network_admin()) {
1382 $minimum_capability = 'manage_network';
1383 } elseif (false === strpos(wp_slimstat::$settings['can_view'], (string) $GLOBALS['current_user']->user_login) && !empty(wp_slimstat::$settings['capability_can_view'])) {
1384 $minimum_capability = wp_slimstat::$settings['capability_can_view'];
1385 }
1386
1387 if (!current_user_can($minimum_capability)) {
1388 return;
1389 }
1390
1391 $wpdb = wp_slimstat::$wpdb;
1392 $table = "{$GLOBALS['wpdb']->prefix}slim_stats";
1393 $today_start = mktime(0, 0, 0);
1394 $yesterday_start = $today_start - 86400;
1395 $yesterday_end = $today_start - 1;
1396
1397 // Sessions Today (unique sessions - using visit_id for anonymous/hashed IP compatibility)
1398 $sessions_today = (int) $wpdb->get_var($wpdb->prepare(
1399 "SELECT COUNT(DISTINCT visit_id) FROM {$table} WHERE dt >= %d AND visit_id > 0",
1400 $today_start
1401 ));
1402
1403 // Views Today (pageviews)
1404 $views_today = (int) $wpdb->get_var($wpdb->prepare(
1405 "SELECT COUNT(id) FROM {$table} WHERE dt >= %d",
1406 $today_start
1407 ));
1408
1409 // Yesterday's sessions (unique sessions - using visit_id for anonymous/hashed IP compatibility)
1410 $sessions_yesterday = (int) $wpdb->get_var($wpdb->prepare(
1411 "SELECT COUNT(DISTINCT visit_id) FROM {$table} WHERE dt BETWEEN %d AND %d AND visit_id > 0",
1412 $yesterday_start, $yesterday_end
1413 ));
1414
1415 // Yesterday's views
1416 $views_yesterday = (int) $wpdb->get_var($wpdb->prepare(
1417 "SELECT COUNT(id) FROM {$table} WHERE dt BETWEEN %d AND %d",
1418 $yesterday_start, $yesterday_end
1419 ));
1420
1421 // Referrals Today (external referrers only)
1422 $site_host = parse_url(home_url(), PHP_URL_HOST);
1423 $referrals_today = (int) $wpdb->get_var($wpdb->prepare(
1424 "SELECT COUNT(id) FROM {$table} WHERE dt >= %d AND referer IS NOT NULL AND referer NOT LIKE %s",
1425 $today_start, '%' . $wpdb->esc_like($site_host) . '%'
1426 ));
1427
1428 // Referrals Yesterday
1429 $referrals_yesterday = (int) $wpdb->get_var($wpdb->prepare(
1430 "SELECT COUNT(id) FROM {$table} WHERE dt BETWEEN %d AND %d AND referer IS NOT NULL AND referer NOT LIKE %s",
1431 $yesterday_start, $yesterday_end, '%' . $wpdb->esc_like($site_host) . '%'
1432 ));
1433
1434 // Online Users — same 30-minute window query as header.php
1435 $current_minute_start = (int) floor(wp_slimstat::now() / 60) * 60;
1436 $window_minutes = 30;
1437 $window_start = $current_minute_start - (($window_minutes - 1) * 60);
1438
1439 $online_count = (int) $wpdb->get_var($wpdb->prepare(
1440 "SELECT COUNT(*) FROM (
1441 SELECT visit_id, MAX(
1442 CASE
1443 WHEN dt_out IS NOT NULL AND dt_out > 0 AND dt_out >= dt THEN dt_out
1444 ELSE dt
1445 END
1446 ) AS last_activity
1447 FROM {$table}
1448 WHERE visit_id > 0
1449 AND (dt >= %d OR (dt_out IS NOT NULL AND dt_out >= %d))
1450 GROUP BY visit_id
1451 HAVING (FLOOR(last_activity / 60) * 60 + 59) >= %d
1452 ) live_sessions",
1453 $window_start, $window_start, $window_start
1454 ));
1455 $online_count = max(0, $online_count);
1456
1457 // Determine premium status early (needed for chart data)
1458 $is_pro = wp_slimstat::pro_is_installed();
1459
1460 // Query minute-by-minute data for the CSS bar chart (30-minute window)
1461 // Reuse LiveAnalyticsReport's session-spanning query for consistent data (#221)
1462 if ($is_pro) {
1463 $live_report = new \SlimStat\Reports\Types\Analytics\LiveAnalyticsReport();
1464 $chart_result = $live_report->get_users_chart_data();
1465 $minute_data = $chart_result['data'];
1466 $max_count = $chart_result['max_value'];
1467 } else {
1468 // Fake placeholder data for non-Pro users
1469 $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];
1470 $max_count = 10;
1471 }
1472
1473 // Build chart HTML
1474 $chart_bars = '';
1475 $total_bars = count($minute_data);
1476 foreach ($minute_data as $i => $count) {
1477 $height_pct = round(($count / $max_count) * 100);
1478 $is_peak = ($count === $max_count && $count > 0);
1479 $bar_class = $is_peak ? ' slimstat-adminbar__chart-bar--peak' : '';
1480 $minutes_ago = $total_bars - 1 - $i; // 29 for first bar, 0 for last bar
1481 $time_text = $minutes_ago === 0
1482 ? esc_html__('Now', 'wp-slimstat')
1483 : sprintf('%d %s', $minutes_ago, esc_html__('min ago', 'wp-slimstat'));
1484 $chart_bars .= sprintf(
1485 '<div class="slimstat-adminbar__chart-bar%s" style="height:%d%%" data-count="%d" data-minutes-ago="%d">'
1486 . '<span class="slimstat-adminbar__chart-tooltip">'
1487 . '<strong>%s</strong>'
1488 . '%s: %d<br>'
1489 . '%s'
1490 . '</span></div>',
1491 $bar_class,
1492 $count > 0 ? max($height_pct, 3) : 0, // 0% for empty, min 3% for non-zero
1493 $count,
1494 $minutes_ago,
1495 esc_html__('Online Users', 'wp-slimstat'),
1496 esc_html__('Count', 'wp-slimstat'),
1497 $count,
1498 $time_text
1499 );
1500 }
1501 $view_url = get_admin_url($GLOBALS['blog_id'], 'admin.php?page=');
1502 $overview_url = $view_url . 'slimview2';
1503 $upgrade_url = 'https://wp-slimstat.com/pricing/?utm_source=wp-slimstat&utm_medium=link&utm_campaign=adminbar';
1504
1505 // Add parent node
1506 $GLOBALS['wp_admin_bar']->add_menu([
1507 'id' => 'slimstat-header',
1508 'title' => '<span class="ab-icon dashicons dashicons-chart-area" style="font-size:1rem;margin-top:3px"></span>'
1509 . sprintf(__('Online: %s', 'wp-slimstat'), '<span id="slimstat-adminbar-online-header">' . number_format_i18n($online_count) . '</span>'),
1510 'href' => $overview_url,
1511 ]);
1512
1513 // Add stats grid node
1514 // For non-Pro users, show fake data for Views and Referrals
1515 $views_display = $is_pro ? number_format_i18n($views_today) : '248';
1516 $views_yesterday_display = $is_pro ? number_format_i18n($views_yesterday) : '312';
1517 $referrals_display = $is_pro ? number_format_i18n($referrals_today) : '18';
1518 $referrals_yesterday_display = $is_pro ? number_format_i18n($referrals_yesterday) : '24';
1519 $blur_class = $is_pro ? '' : ' slimstat-adminbar__stat-card--blur';
1520
1521 $stats_html = '<div class="slimstat-adminbar__stats-grid">'
1522 // Online Users (top left)
1523 . '<div class="slimstat-adminbar__stat-card">'
1524 . '<div class="slimstat-adminbar__stat-title">' . esc_html__('Online Users', 'wp-slimstat')
1525 . ' <span class="slimstat-adminbar__realtime-dot"></span></div>'
1526 . '<div class="slimstat-adminbar__stat-count" id="slimstat-adminbar-online-count">' . number_format_i18n($online_count) . '</div>'
1527 . '<div class="slimstat-adminbar__realtime-badge">'
1528 . '<span class="slimstat-adminbar__realtime-pulse"></span> '
1529 . esc_html__('Realtime', 'wp-slimstat') . '</div>'
1530 . '</div>'
1531 // Sessions Today (top right)
1532 . '<div class="slimstat-adminbar__stat-card">'
1533 . '<div class="slimstat-adminbar__stat-title">' . esc_html__('Sessions Today', 'wp-slimstat') . '</div>'
1534 . '<div class="slimstat-adminbar__stat-count" id="slimstat-adminbar-sessions-count">' . number_format_i18n($sessions_today) . '</div>'
1535 . '<div class="slimstat-adminbar__stat-comparison" id="slimstat-adminbar-sessions-compare">'
1536 . sprintf(esc_html__('was %s last day', 'wp-slimstat'), number_format_i18n($sessions_yesterday))
1537 . '</div></div>'
1538 // Views Today (bottom left) - blur for non-Pro
1539 . '<div class="slimstat-adminbar__stat-card' . $blur_class . '">'
1540 . '<div class="slimstat-adminbar__stat-title">' . esc_html__('Views Today', 'wp-slimstat') . '</div>'
1541 . '<div class="slimstat-adminbar__stat-count" id="slimstat-adminbar-views-count">' . $views_display . '</div>'
1542 . '<div class="slimstat-adminbar__stat-comparison" id="slimstat-adminbar-views-compare">'
1543 . sprintf(esc_html__('was %s last day', 'wp-slimstat'), $views_yesterday_display)
1544 . '</div></div>'
1545 // Referrals Today (bottom right) - blur for non-Pro
1546 . '<div class="slimstat-adminbar__stat-card' . $blur_class . '">'
1547 . '<div class="slimstat-adminbar__stat-title">' . esc_html__('Referrals Today', 'wp-slimstat') . '</div>'
1548 . '<div class="slimstat-adminbar__stat-count" id="slimstat-adminbar-referrals-count">' . $referrals_display . '</div>'
1549 . '<div class="slimstat-adminbar__stat-comparison" id="slimstat-adminbar-referrals-compare">'
1550 . sprintf(esc_html__('was %s last day', 'wp-slimstat'), $referrals_yesterday_display)
1551 . '</div></div>'
1552 . '</div>';
1553
1554 $GLOBALS['wp_admin_bar']->add_node([
1555 'id' => 'slimstat-adminbar-stats',
1556 'parent' => 'slimstat-header',
1557 'title' => $stats_html,
1558 'meta' => ['class' => 'slimstat-adminbar__stats-wrapper'],
1559 ]);
1560
1561 // Add chart node
1562 $chart_wrapper_class = $is_pro ? 'slimstat-adminbar__chart-container' : 'slimstat-adminbar__chart-container slimstat-adminbar__chart-blur';
1563 $chart_html = '<div class="' . $chart_wrapper_class . '">'
1564 . '<div class="slimstat-adminbar__chart-bars" id="slimstat-adminbar-chart-bars">' . $chart_bars . '</div>'
1565 . '</div>';
1566
1567 $GLOBALS['wp_admin_bar']->add_node([
1568 'id' => 'slimstat-adminbar-chart',
1569 'parent' => 'slimstat-header',
1570 'title' => $chart_html,
1571 'meta' => ['class' => 'slimstat-adminbar__chart-wrapper'],
1572 ]);
1573
1574 // Add CTA node (free users only)
1575 if (!$is_pro) {
1576 $cta_html = '<div class="slimstat-adminbar__cta">'
1577 . '<div class="slimstat-adminbar__cta-text">'
1578 . esc_html__('Unlock the Full Power of SlimStat Analytics', 'wp-slimstat')
1579 . '</div>'
1580 . '<a href="' . esc_url($upgrade_url) . '" target="_blank" class="slimstat-adminbar__cta-button">'
1581 . esc_html__('Unlock SlimStat Pro', 'wp-slimstat') . '</a>'
1582 . '</div>';
1583
1584 $GLOBALS['wp_admin_bar']->add_node([
1585 'id' => 'slimstat-adminbar-cta',
1586 'parent' => 'slimstat-header',
1587 'title' => $cta_html,
1588 'meta' => ['class' => 'slimstat-adminbar__cta-wrapper'],
1589 ]);
1590 }
1591
1592 // Add footer node
1593 $footer_html = '<div class="slimstat-adminbar__footer">'
1594 . '<div class="slimstat-adminbar__footer-logo">'
1595 . '<svg width="20" height="20" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">'
1596 . '<path fill-rule="evenodd" clip-rule="evenodd" d="M0 15C0 6.71582 6.7069 0 14.9801 0C20.2546 0 24.8865 2.72788 27.5572 6.84316L19.371 15.1743H19.3643V15.1877C19.0765 15.4893 18.5946 15.496 18.2934 15.2011C18.2599 15.1743 18.2331 15.1408 18.2064 15.1005L15.9239 11.9638C13.9627 9.27614 10.047 9.03485 7.77787 11.4678L0.589029 19.1756C0.194112 17.8217 0 16.4142 0 15ZM2.69079 23.5858C5.40167 27.4665 9.89302 30.0067 14.9801 30.0067C23.2533 30.0067 29.9602 23.2909 29.9602 15.0067C29.9602 13.7399 29.8062 12.5134 29.5117 11.3405L22.604 18.3646C20.3148 20.7172 16.466 20.4424 14.5316 17.7949L12.2491 14.6582C12.0015 14.3231 11.5329 14.2426 11.1916 14.4906C11.1514 14.5174 11.1179 14.5509 11.0845 14.5845L2.69079 23.5858Z" fill="#F22F46"/>'
1597 . '</svg>'
1598 . '<span class="slimstat-adminbar__footer-brand">SlimStat</span>'
1599 . '</div>'
1600 . '<a href="' . esc_url($overview_url) . '" class="slimstat-adminbar__footer-link">'
1601 . esc_html__('Explore Details', 'wp-slimstat')
1602 . ' <span class="dashicons dashicons-external" style="font-size:12px"></span>'
1603 . '</a></div>';
1604
1605 $GLOBALS['wp_admin_bar']->add_node([
1606 'id' => 'slimstat-adminbar-footer',
1607 'parent' => 'slimstat-header',
1608 'title' => $footer_html,
1609 'meta' => ['class' => 'slimstat-adminbar__footer-wrapper'],
1610 ]);
1611 }
1612
1613 // END: add_menu_to_adminbar
1614
1615 /**
1616 * Includes the appropriate panel to view the stats
1617 */
1618 public static function wp_slimstat_include_view()
1619 {
1620 include(__DIR__ . '/view/index.php');
1621 }
1622
1623 // END: wp_slimstat_include_view
1624
1625 /**
1626 * Includes the screen to arrange the reports
1627 */
1628 public static function wp_slimstat_include_layout()
1629 {
1630 include(__DIR__ . '/view/layout.php');
1631 }
1632
1633 // END: wp_slimstat_include_layout
1634
1635 /**
1636 * Includes the email report screen
1637 */
1638 public static function wp_slimstat_include_email_report()
1639 {
1640 include(__DIR__ . '/view/email-report.php');
1641 }
1642
1643 // END: wp_slimstat_include_email_report
1644
1645 /**
1646 * Handles the upgrade to pro from the free version
1647 */
1648 public static function wp_slimstat_pro()
1649 {
1650 include(__DIR__ . '/view/upgrade-pro.php');
1651 }
1652
1653 // END: wp_slimstat_include_addons
1654
1655 /**
1656 * Includes the appropriate panel to configure Slimstat
1657 */
1658 public static function wp_slimstat_include_config()
1659 {
1660 include(__DIR__ . '/config/index.php');
1661 }
1662
1663 // END: wp_slimstat_include_config
1664
1665 /**
1666 * Retrieves all the information to be used in the custom column on posts, pages and CPTs
1667 */
1668 public static function init_data_for_column()
1669 {
1670 if (!is_array($GLOBALS['wp_query']->posts)) {
1671 return 0;
1672 }
1673
1674 foreach ($GLOBALS['wp_query']->posts as $a_post) {
1675 self::$data_for_column['url'][$a_post->ID] = parse_url(get_permalink($a_post->ID));
1676 self::$data_for_column['url'][$a_post->ID] = self::$data_for_column['url'][$a_post->ID]['path'] . (empty(self::$data_for_column['url'][$a_post->ID]['query']) ? '' : '?' . self::$data_for_column['url'][$a_post->ID]['query']);
1677 self::$data_for_column['sql'][$a_post->ID] = self::$data_for_column['url'][$a_post->ID] . '%';
1678 }
1679
1680 /**
1681 * https://wordpress.org/support/topic/you-have-an-error-in-your-sql-syntax-22/#post-12565619
1682 */
1683 if (empty(self::$data_for_column) || empty(self::$data_for_column['url'])) {
1684 return 0;
1685 }
1686
1687 wp_slimstat_db::init('interval equals -' . wp_slimstat::$settings['posts_column_day_interval']);
1688
1689 $column = ('on' == wp_slimstat::$settings['posts_column_pageviews']) ? 'id' : 'ip';
1690 $where = wp_slimstat_db::get_combined_where('(' . implode(' OR ', array_fill(1, count(self::$data_for_column['url']), 'resource LIKE %s')) . ')', '*', true);
1691
1692 $sql = wp_slimstat::$wpdb->prepare("
1693 SELECT resource, COUNT( DISTINCT {$column} ) as counthits
1694 FROM {$GLOBALS['wpdb']->prefix}slim_stats
1695 WHERE " . $where . '
1696 GROUP BY resource
1697 LIMIT 0, ' . wp_slimstat_db::$filters_normalized['misc']['limit_results'], self::$data_for_column['sql']);
1698
1699 $results = wp_slimstat_db::get_results($sql);
1700
1701 foreach (self::$data_for_column['url'] as $post_id => $a_url) {
1702 self::$data_for_column['count'][$post_id] = 0;
1703
1704 foreach ($results as $i => $a_row) {
1705 if (false !== strpos($a_row['resource'], (string) $a_url)) {
1706 self::$data_for_column['count'][$post_id] += $a_row['counthits'];
1707 unset($results[$i]);
1708 }
1709 }
1710 }
1711
1712 return null;
1713 }
1714
1715 // END: init_data_for_column
1716
1717 /**
1718 * Adds a new column header to the Posts panel (to show the number of pageviews for each post)
1719 */
1720 public static function add_column_header($_columns = [])
1721 {
1722 if (empty(wp_slimstat::$settings['posts_column_day_interval'])) {
1723 wp_slimstat::$settings['posts_column_day_interval'] = 28;
1724 }
1725
1726 if ('on' == wp_slimstat::$settings['posts_column_pageviews']) {
1727 $_columns['wp-slimstat'] = '<span class="slimstat-icon" title="' . sprintf(__('Pageviews in the last %s days', 'wp-slimstat'), wp_slimstat::$settings['posts_column_day_interval']) . '"><span class="screen-reader-text">' . __('Views') . '</span></span>';
1728 } else {
1729 $_columns['wp-slimstat'] = '<span class="slimstat-icon" title="' . sprintf(__('Unique IPs in the last %s days', 'wp-slimstat'), wp_slimstat::$settings['posts_column_day_interval']) . '"></span>';
1730 }
1731
1732 return $_columns;
1733 }
1734
1735 // END: add_comment_column_header
1736
1737 /**
1738 * Adds a new column to the Posts management panel
1739 */
1740 public static function add_post_column($_column_name, $_post_id)
1741 {
1742 if ('wp-slimstat' != $_column_name || empty(self::$data_for_column['url'][$_post_id])) {
1743 return 0;
1744 }
1745
1746 $count = empty(self::$data_for_column['count'][$_post_id]) ? 0 : self::$data_for_column['count'][$_post_id];
1747
1748 echo '<a href="' . wp_slimstat_reports::fs_url('resource starts_with ' . self::$data_for_column['url'][$_post_id] . '&&&interval equals -' . wp_slimstat::$settings['posts_column_day_interval']) . '">' . $count . '</a>';
1749 return null;
1750 }
1751
1752 // END: add_column
1753
1754 /**
1755 * Displays an alert message
1756 */
1757 public static function show_message($_message = '', $_type = 'info', $_dismiss_handle = '')
1758 {
1759 if (empty($_message)) {
1760 return 0;
1761 }
1762
1763 $_message = wpautop(wp_kses_post($_message));
1764
1765 if (!empty($_dismiss_handle)) {
1766 echo '<div id="slimstat-notice-' . esc_attr($_dismiss_handle) . '" class="notice is-dismissible slimstat-notice notice-' . esc_attr($_type) . '">' . $_message . '</div>';
1767 } else {
1768 echo '<div class="notice notice-' . esc_attr($_type) . ' slimstat-notice">' . $_message . '</div>';
1769 }
1770
1771 return null;
1772 }
1773
1774 // END: show_message
1775
1776 /**
1777 * Displays a message related to the current version of Slimstat
1778 */
1779 public static function show_latest_news()
1780 {
1781 self::show_message(self::$admin_notice, 'info', 'latest-news');
1782 }
1783
1784 // END: show_latest_news
1785
1786
1787 /**
1788 * Handles the Ajax request to hide the admin notice
1789 */
1790 public static function notices_handler()
1791 {
1792 $tag = current_filter();
1793
1794 if (!empty($tag) && current_user_can('manage_options') && wp_verify_nonce($_POST['security'], 'meta-box-order')) {
1795 $tag = str_replace('wp_ajax_slimstat_', '', $tag);
1796 wp_slimstat::$settings[$tag] = 'no';
1797
1798 // Save the default values in the database
1799 wp_slimstat::update_option('slimstat_options', wp_slimstat::$settings);
1800 }
1801
1802 exit();
1803 }
1804
1805 // END: notices_handler
1806
1807 // ---- Goals & Funnels CRUD ---- //
1808
1809 /**
1810 * Invalidates all goal/funnel/visitor caches by incrementing version.
1811 * Works with both wp_options and external object cache (Redis/Memcached).
1812 *
1813 * When no persistent object cache is present, orphaned version-keyed
1814 * transient rows in wp_options aren't cleaned up until WordPress's
1815 * `delete_expired_transients` cron fires (every 12h). We GC them here
1816 * so frequently-edited installs don't accumulate hundreds of rows.
1817 */
1818 private static function clear_goals_cache()
1819 {
1820 // Sub-second precision avoids collisions when two saves land in the
1821 // same second (time() granularity was causing cache-miss no-ops).
1822 update_option('slimstat_goals_cache_ver', (string) microtime(true), false);
1823
1824 if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
1825 return;
1826 }
1827
1828 global $wpdb;
1829 if (!$wpdb) {
1830 return;
1831 }
1832
1833 $like_goal = $wpdb->esc_like('_transient_slimstat_goal_') . '%';
1834 $like_goal_timeout = $wpdb->esc_like('_transient_timeout_slimstat_goal_') . '%';
1835 $like_funnel = $wpdb->esc_like('_transient_slimstat_funnel_') . '%';
1836 $like_funnel_t = $wpdb->esc_like('_transient_timeout_slimstat_funnel_') . '%';
1837 // Unique-visitor denominator transients (CR math) — version-keyed since 5.5.0
1838 // so they accumulate one row per date range; sweep them here too.
1839 $like_uv = $wpdb->esc_like('_transient_slimstat_uv_') . '%';
1840 $like_uv_timeout = $wpdb->esc_like('_transient_timeout_slimstat_uv_') . '%';
1841
1842 // LIMIT 1000 mirrors update_tables_and_options()'s bounded transient sweep so
1843 // an install with many accumulated rows doesn't run an unbounded synchronous
1844 // DELETE on an admin save.
1845 $wpdb->query($wpdb->prepare(
1846 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s LIMIT 1000",
1847 $like_goal,
1848 $like_goal_timeout,
1849 $like_funnel,
1850 $like_funnel_t,
1851 $like_uv,
1852 $like_uv_timeout
1853 ));
1854 }
1855
1856 /**
1857 * Returns validated goal dimensions available for selection.
1858 */
1859 public static function get_goal_dimensions()
1860 {
1861 return [
1862 'resource' => __('Page URL', 'wp-slimstat'),
1863 'content_type' => __('Content Type', 'wp-slimstat'),
1864 'content_id' => __('Content ID', 'wp-slimstat'),
1865 'searchterms' => __('Search Terms', 'wp-slimstat'),
1866 'country' => __('Country', 'wp-slimstat'),
1867 'browser' => __('Browser', 'wp-slimstat'),
1868 'platform' => __('Operating System', 'wp-slimstat'),
1869 'referer' => __('Referer', 'wp-slimstat'),
1870 'username' => __('Username', 'wp-slimstat'),
1871 'event_notes' => __('Event', 'wp-slimstat'),
1872 ];
1873 }
1874
1875 /**
1876 * Dimensions allowed for FUNNEL STEPS — a subset of get_goal_dimensions()
1877 * restricted to action/journey-oriented entities. Attribute dimensions
1878 * (Country, Browser, Operating System, Referer, Username) describe WHO a
1879 * visitor is, not what they DID, so they make no sense as a funnel step
1880 * ("Homepage → Chrome → Checkout") and stay reserved for goals. Sliced from
1881 * get_goal_dimensions() via array_intersect_key so labels + order never
1882 * drift from the canonical list. (#17)
1883 */
1884 public static function get_funnel_step_dimensions()
1885 {
1886 $allowed = ['resource', 'content_type', 'content_id', 'searchterms', 'event_notes'];
1887 return array_intersect_key(self::get_goal_dimensions(), array_flip($allowed));
1888 }
1889
1890 /**
1891 * Returns validated goal operators.
1892 */
1893 public static function get_goal_operators()
1894 {
1895 return ['equals', 'is_not_equal_to', 'contains', 'does_not_contain', 'starts_with', 'ends_with', 'matches', 'is_empty', 'is_not_empty'];
1896 }
1897
1898 /**
1899 * Returns an operator-key => human-label map, sourced from wp_slimstat_db's
1900 * canonical operator-names table. Falls back to the key when the DB class is
1901 * not yet loaded (admin_footer hooks may fire before reports init in rare paths).
1902 *
1903 * @since 5.5.0
1904 */
1905 public static function get_goal_operator_labels()
1906 {
1907 $labels = [];
1908 $has_db = class_exists('wp_slimstat_db');
1909 foreach (self::get_goal_operators() as $op) {
1910 $labels[$op] = ($has_db && !empty(wp_slimstat_db::$operator_names[$op]))
1911 ? wp_slimstat_db::$operator_names[$op]
1912 : $op;
1913 }
1914 return $labels;
1915 }
1916
1917 /**
1918 * Sanitizes and validates a goal definition array.
1919 *
1920 * Accepts raw slashed input (callers hand through `$_POST` directly) and
1921 * runs wp_unslash() before the per-field sanitizers so admin-entered values
1922 * containing quotes or backslashes round-trip correctly.
1923 */
1924 private static function sanitize_goal($raw, $is_funnel_step = false)
1925 {
1926 if (!is_array($raw)) {
1927 return false;
1928 }
1929 $raw = wp_unslash($raw);
1930 // Funnel steps accept only action-oriented dimensions; goals accept all.
1931 // Validating server-side keeps an attribute dimension from being POSTed
1932 // past the (already-restricted) builder dropdown. (#17)
1933 $dimensions = $is_funnel_step ? self::get_funnel_step_dimensions() : self::get_goal_dimensions();
1934 $operators = self::get_goal_operators();
1935
1936 $goal = [
1937 // A provided id is honoured only so callers can match an existing
1938 // record for update; new records get a server-assigned id via
1939 // next_record_id() (never a client value, never microtime — which
1940 // collides on sub-ms saves and overflows on 32-bit PHP).
1941 'id' => !empty($raw['id']) ? intval($raw['id']) : 0,
1942 'name' => !empty($raw['name']) ? sanitize_text_field($raw['name']) : '',
1943 'dimension' => !empty($raw['dimension']) && isset($dimensions[$raw['dimension']]) ? $raw['dimension'] : '',
1944 'operator' => !empty($raw['operator']) && in_array($raw['operator'], $operators, true) ? $raw['operator'] : '',
1945 'value' => isset($raw['value']) ? sanitize_text_field($raw['value']) : '',
1946 'active' => isset($raw['active']) ? (bool) $raw['active'] : true,
1947 ];
1948
1949 if (empty($goal['name']) || empty($goal['dimension']) || empty($goal['operator'])) {
1950 return false;
1951 }
1952
1953 // A value-bearing operator must carry a non-empty value; otherwise the
1954 // query builder emits an unbound "%s" placeholder that breaks the SQL and
1955 // silently reports 0. Only the valueless operators may omit a value — sourced
1956 // from the shared wp_slimstat_db list (guarded; the db class may not be loaded
1957 // yet on the save path) so it never drifts. See #305.
1958 $valueless = class_exists('wp_slimstat_db') ? wp_slimstat_db::$valueless_operators : ['is_empty', 'is_not_empty'];
1959 if ('' === $goal['value'] && !in_array($goal['operator'], $valueless, true)) {
1960 return false;
1961 }
1962
1963 return $goal;
1964 }
1965
1966 /**
1967 * Returns a collision-free id for a new goal/funnel record: max existing id + 1.
1968 * Replaces the old microtime()-based id (which collided on sub-millisecond
1969 * saves and overflowed to 0 on 32-bit PHP), and is never client-supplied.
1970 */
1971 private static function next_record_id(array $records)
1972 {
1973 $max = 0;
1974 foreach ($records as $record) {
1975 if (isset($record['id']) && (int) $record['id'] > $max) {
1976 $max = (int) $record['id'];
1977 }
1978 }
1979 return $max + 1;
1980 }
1981
1982 /**
1983 * AJAX: Save (create/update) a goal.
1984 */
1985 public static function ajax_save_goal()
1986 {
1987 check_ajax_referer('slimstat_goals_nonce', 'security');
1988
1989 if (!current_user_can(wp_slimstat::$settings['capability_can_admin'])) {
1990 wp_send_json_error(['message' => __('Insufficient permissions', 'wp-slimstat')]);
1991 }
1992
1993 $goal = self::sanitize_goal($_POST);
1994 if (!$goal) {
1995 wp_send_json_error(['message' => __('Invalid goal definition', 'wp-slimstat')]);
1996 }
1997
1998 $goals = get_option('slimstat_goals', []);
1999 $max_goals = apply_filters('slimstat_max_goals', 1);
2000
2001 // Update only when a client-supplied id matches an existing goal; anything
2002 // else is a create. New records get a server-assigned id (next_record_id)
2003 // so a client can't force a collision/overwrite by sending an arbitrary id.
2004 // NOTE: this read-modify-write of the slimstat_goals option assumes a single
2005 // editor — concurrent admin saves can still last-writer-win (acceptable for
2006 // an admin-only, low-frequency setting).
2007 $found = false;
2008 if (!empty($goal['id'])) {
2009 foreach ($goals as $i => $existing) {
2010 if ((int) $existing['id'] === (int) $goal['id']) {
2011 $goals[$i] = $goal;
2012 $found = true;
2013 break;
2014 }
2015 }
2016 }
2017
2018 if (!$found) {
2019 // Hard cap on total stored goals (active + paused). Paused goals don't
2020 // count against the active-tier limit below, so without this they could
2021 // grow the slimstat_goals option without bound.
2022 $hard_cap = (int) apply_filters('slimstat_goals_hard_cap', 50);
2023 if (count($goals) >= $hard_cap) {
2024 wp_send_json_error(['message' => __('Too many goals stored. Delete unused goals before adding more.', 'wp-slimstat')]);
2025 }
2026 $goal['id'] = self::next_record_id($goals);
2027 }
2028
2029 // Count active goals in the state that *would* result from this save.
2030 // For updates, $goals already reflects the incoming edit; for creates,
2031 // append hypothetically for the check only. This catches the bypass
2032 // where activating a previously-paused goal on update slipped past the
2033 // limit because the old guard only ran on creates.
2034 $post_save = $found ? $goals : array_merge($goals, [$goal]);
2035 $active_count = count(array_filter($post_save, function ($g) {
2036 return !empty($g['active']);
2037 }));
2038
2039 if ($active_count > $max_goals) {
2040 wp_send_json_error([
2041 'message' => sprintf(
2042 /* translators: %d is the max goal count for the tier */
2043 __('Goal limit reached (%d). Upgrade to Pro for more goals.', 'wp-slimstat'),
2044 $max_goals
2045 ),
2046 ]);
2047 }
2048
2049 if (!$found) {
2050 $goals[] = $goal;
2051 }
2052
2053 update_option('slimstat_goals', $goals, false);
2054 self::clear_goals_cache();
2055 wp_send_json_success(['goals' => $goals]);
2056 }
2057
2058 /**
2059 * AJAX: Delete a goal by ID.
2060 */
2061 public static function ajax_delete_goal()
2062 {
2063 check_ajax_referer('slimstat_goals_nonce', 'security');
2064
2065 if (!current_user_can(wp_slimstat::$settings['capability_can_admin'])) {
2066 wp_send_json_error(['message' => __('Insufficient permissions', 'wp-slimstat')]);
2067 }
2068
2069 $goal_id = isset($_POST['goal_id']) ? intval(wp_unslash($_POST['goal_id'])) : 0;
2070 if ($goal_id <= 0) {
2071 wp_send_json_error(['message' => __('Invalid goal id', 'wp-slimstat')]);
2072 }
2073
2074 $goals = get_option('slimstat_goals', []);
2075 $filtered = array_values(array_filter($goals, function ($g) use ($goal_id) {
2076 return isset($g['id']) && (int) $g['id'] !== $goal_id;
2077 }));
2078
2079 if (count($filtered) === count($goals)) {
2080 wp_send_json_error(['message' => __('Goal not found', 'wp-slimstat')], 404);
2081 }
2082
2083 update_option('slimstat_goals', $filtered, false);
2084 self::clear_goals_cache();
2085 wp_send_json_success(['goals' => $filtered]);
2086 }
2087
2088 /**
2089 * AJAX: Save (create/update) a funnel.
2090 */
2091 public static function ajax_save_funnel()
2092 {
2093 check_ajax_referer('slimstat_goals_nonce', 'security');
2094
2095 if (!current_user_can(wp_slimstat::$settings['capability_can_admin'])) {
2096 wp_send_json_error(['message' => __('Insufficient permissions', 'wp-slimstat')]);
2097 }
2098
2099 $max_funnels = apply_filters('slimstat_max_funnels', 0);
2100 if ($max_funnels <= 0) {
2101 wp_send_json_error(['message' => __('Funnels require SlimStat Pro', 'wp-slimstat')]);
2102 }
2103
2104 $raw_steps = isset($_POST['steps']) && is_array($_POST['steps']) ? $_POST['steps'] : [];
2105 if (count($raw_steps) < 2 || count($raw_steps) > 5) {
2106 wp_send_json_error(['message' => __('Funnels require 2-5 steps', 'wp-slimstat')]);
2107 }
2108
2109 $steps = [];
2110 foreach ($raw_steps as $raw_step) {
2111 $step = self::sanitize_goal($raw_step, true);
2112 if (!$step) {
2113 wp_send_json_error(['message' => __('Invalid step definition', 'wp-slimstat')]);
2114 }
2115 $steps[] = $step;
2116 }
2117
2118 $raw_funnel_name = isset($_POST['funnel_name']) ? wp_unslash((string) $_POST['funnel_name']) : '';
2119 $incoming_id = !empty($_POST['funnel_id']) ? intval(wp_unslash($_POST['funnel_id'])) : 0;
2120 $funnel = [
2121 // Provisional id; reassigned with a server-side value for creates below
2122 // (never microtime — it collides on sub-ms saves / overflows on 32-bit).
2123 'id' => $incoming_id,
2124 'name' => sanitize_text_field($raw_funnel_name),
2125 'steps' => $steps,
2126 ];
2127
2128 if (empty($funnel['name'])) {
2129 wp_send_json_error(['message' => __('Funnel name is required', 'wp-slimstat')]);
2130 }
2131
2132 $funnels = get_option('slimstat_funnels', []);
2133
2134 // Update only when a client-supplied id matches an existing funnel; else create.
2135 $found = false;
2136 if ($incoming_id > 0) {
2137 foreach ($funnels as $i => $existing) {
2138 if ((int) $existing['id'] === $incoming_id) {
2139 $funnels[$i] = $funnel;
2140 $found = true;
2141 break;
2142 }
2143 }
2144 }
2145
2146 if (!$found) {
2147 if (count($funnels) >= $max_funnels) {
2148 wp_send_json_error([
2149 'message' => sprintf(
2150 __('Funnel limit reached (%d).', 'wp-slimstat'),
2151 $max_funnels
2152 ),
2153 ]);
2154 }
2155 $funnel['id'] = self::next_record_id($funnels);
2156 $funnels[] = $funnel;
2157 }
2158
2159 update_option('slimstat_funnels', $funnels, false);
2160 self::clear_goals_cache();
2161 wp_send_json_success(['funnels' => $funnels]);
2162 }
2163
2164 /**
2165 * AJAX: Delete a funnel by ID.
2166 */
2167 public static function ajax_delete_funnel()
2168 {
2169 check_ajax_referer('slimstat_goals_nonce', 'security');
2170
2171 if (!current_user_can(wp_slimstat::$settings['capability_can_admin'])) {
2172 wp_send_json_error(['message' => __('Insufficient permissions', 'wp-slimstat')]);
2173 }
2174
2175 $funnel_id = isset($_POST['funnel_id']) ? intval(wp_unslash($_POST['funnel_id'])) : 0;
2176 if ($funnel_id <= 0) {
2177 wp_send_json_error(['message' => __('Invalid funnel id', 'wp-slimstat')]);
2178 }
2179
2180 $funnels = get_option('slimstat_funnels', []);
2181 $filtered = array_values(array_filter($funnels, function ($f) use ($funnel_id) {
2182 return isset($f['id']) && (int) $f['id'] !== $funnel_id;
2183 }));
2184
2185 if (count($filtered) === count($funnels)) {
2186 wp_send_json_error(['message' => __('Funnel not found', 'wp-slimstat')], 404);
2187 }
2188
2189 update_option('slimstat_funnels', $filtered, false);
2190 self::clear_goals_cache();
2191 wp_send_json_success(['funnels' => $filtered]);
2192 }
2193
2194 /**
2195 * AJAX: Return per-step results + summary for a single funnel by id.
2196 * Used by the funnel tab bar's lazy-load — inactive tabs fetch on click.
2197 *
2198 * @since 5.5.0
2199 */
2200 public static function ajax_load_funnel_data()
2201 {
2202 check_ajax_referer('slimstat_goals_nonce', 'security');
2203
2204 if (!self::check_ajax_view_capability()) {
2205 return;
2206 }
2207
2208 $funnel_id = intval($_POST['funnel_id'] ?? 0);
2209 $funnels = get_option('slimstat_funnels', []);
2210 $funnel = null;
2211 foreach ($funnels as $f) {
2212 if (intval($f['id']) === $funnel_id) {
2213 $funnel = $f;
2214 break;
2215 }
2216 }
2217
2218 if (!$funnel) {
2219 wp_send_json_error(['message' => __('Funnel not found', 'wp-slimstat')], 404);
2220 }
2221
2222 // Hydrate the DB layer (columns + the on-screen date range). This AJAX
2223 // action isn't covered by the admin bootstrap's init(), so without it the
2224 // date filter collapses to `dt BETWEEN 0 AND 0` and every step returns 0 —
2225 // which is why only the first, server-rendered funnel showed data. (#8)
2226 self::ensure_goals_db_initialized();
2227
2228 $step_results = wp_slimstat_db::get_funnel_results($funnel);
2229
2230 // Summary: step count + total conversion rate (null when step 1 had no visitors,
2231 // so the UI renders "No matching visitors" instead of a fake 100%).
2232 $step_one_visitors = $step_results[0]['visitors'] ?? 0;
2233 $total_cr = null;
2234 if ($step_one_visitors > 0) {
2235 $total_cr = (count($step_results) > 1)
2236 ? $step_results[count($step_results) - 1]['pct']
2237 : 100;
2238 }
2239
2240 $unreachable_count = 0;
2241 foreach ($step_results as $step) {
2242 if (!empty($step['unreachable'])) {
2243 $unreachable_count++;
2244 }
2245 }
2246
2247 wp_send_json_success([
2248 'funnel_id' => $funnel_id,
2249 'steps' => $step_results,
2250 'summary' => [
2251 'step_count' => count($step_results),
2252 'total_cr' => $total_cr,
2253 'unreachable_count' => $unreachable_count,
2254 ],
2255 ]);
2256 }
2257
2258 /**
2259 * AJAX: Return the unique-visitor count for a single funnel step rule.
2260 * Powers the builder's per-step "Test" affordance. A single step IS the
2261 * same shape as a goal, so we forward to get_goal_results().
2262 *
2263 * @since 5.5.0
2264 */
2265 public static function ajax_test_funnel_step()
2266 {
2267 check_ajax_referer('slimstat_goals_nonce', 'security');
2268
2269 // Builder-only action: it runs an arbitrary admin-supplied rule (including
2270 // REGEXP) against slim_stats and each distinct rule misses the cache, so it
2271 // is gated on the admin capability rather than the broader view capability.
2272 if (!current_user_can(wp_slimstat::$settings['capability_can_admin'])) {
2273 wp_send_json_error(['message' => __('Insufficient permissions', 'wp-slimstat')], 403);
2274 }
2275
2276 $step = self::sanitize_goal($_POST, true);
2277 if (!$step) {
2278 wp_send_json_error(['message' => __('Step is missing required fields', 'wp-slimstat')]);
2279 }
2280
2281 // Force a stable id derived from the rule so repeat Test-clicks on the
2282 // same rule hit the get_goal_results() transient. sanitize_goal() default
2283 // id is microtime-based (unique per call), which defeats caching here.
2284 $step['id'] = crc32($step['dimension'] . '|' . $step['operator'] . '|' . (string) $step['value']);
2285
2286 // Same as ajax_load_funnel_data: initialize the DB layer + date range so
2287 // the rule is tested against the selected window instead of
2288 // `dt BETWEEN 0 AND 0` (which returned "0 matches" for pages that clearly
2289 // exist, e.g. /contact). (#6)
2290 self::ensure_goals_db_initialized();
2291
2292 $data = wp_slimstat_db::get_goal_results($step);
2293
2294 wp_send_json_success([
2295 'visitors' => (int) ($data['uniques'] ?? 0),
2296 'total' => (int) ($data['total'] ?? 0),
2297 ]);
2298 }
2299
2300 /**
2301 * Resolve the report date-picker's POSTed window into [start, end] Unix
2302 * timestamps. Shared by get_filter_options() and ensure_goals_db_initialized()
2303 * so autosuggest, the funnel-step Test, and funnel lazy-load all read the
2304 * same range. Falls back to the last 28 days when nothing valid is supplied.
2305 *
2306 * @return array{0:?int,1:?int} [start, end] timestamps (null when unresolved).
2307 */
2308 private static function resolve_requested_date_range()
2309 {
2310 $type = sanitize_text_field(wp_unslash($_POST['time_range_type'] ?? 'last_28_days'));
2311 $from = sanitize_text_field(wp_unslash($_POST['time_range_from'] ?? ''));
2312 $to = sanitize_text_field(wp_unslash($_POST['time_range_to'] ?? ''));
2313
2314 $start = null;
2315 $end = null;
2316 if ('custom' === $type && '' !== $from && '' !== $to) {
2317 $start = strtotime($from);
2318 $end = strtotime($to . ' 23:59:59');
2319 } else {
2320 $range = DateRangeHelper::get_range_by_preset($type);
2321 if ($range) {
2322 $start = $range['start'];
2323 $end = $range['end'];
2324 }
2325 }
2326
2327 // Fallback to last 28 days when no valid range was supplied/parsed.
2328 if (empty($start) || empty($end)) {
2329 $range = DateRangeHelper::get_range_by_preset('last_28_days');
2330 if ($range) {
2331 $start = $range['start'];
2332 $end = $range['end'];
2333 }
2334 }
2335
2336 // Clamp the end to "now", mirroring the SSR funnel render (wp-slimstat-db.php:852):
2337 // presets return "today 23:59:59" (a future time), so without this the active
2338 // (SSR) funnel queries [start..now] while an AJAX-loaded twin queries
2339 // [start..23:59:59]. That gives different counts AND different cache-key hour
2340 // buckets, so two identical funnels disagree. Clamping makes every goals/funnels
2341 // AJAX window end at the same "now" as the SSR render. A custom past range is
2342 // unaffected (its end is already < now). (#1)
2343 $now = (int) date_i18n('U');
2344 return [$start ? (int) $start : null, $end ? min((int) $end, $now) : null];
2345 }
2346
2347 /**
2348 * Hydrate wp_slimstat_db for the goals/funnels Test + lazy-load AJAX actions.
2349 *
2350 * These actions are NOT covered by the admin bootstrap that normally calls
2351 * wp_slimstat_db::init() (only slimview pages + slimstat_load_report are —
2352 * see the $is_slimstat_ajax guard). Without init(), $columns_names and
2353 * $filters_normalized['utime'] stay empty, so get_combined_where() builds
2354 * `dt BETWEEN 0 AND 0` (matching nothing) and emits undefined-array-key
2355 * notices. We init() to populate columns + defaults, then pin the date
2356 * window to the report's selected range (mirrors get_filter_options). (#6/#8)
2357 */
2358 private static function ensure_goals_db_initialized()
2359 {
2360 if (!class_exists('wp_slimstat_db')) {
2361 include_once plugin_dir_path(__FILE__) . 'view/wp-slimstat-db.php';
2362 }
2363
2364 // Prefer the exact window the server-rendered funnel already used, posted back
2365 // as gf_utime_start/end. Reusing it verbatim makes the AJAX funnel/Test query
2366 // the IDENTICAL [start,end] (and funnel cache key) as the SSR render, so two
2367 // identical funnels share one result instead of re-resolving the preset in the
2368 // site timezone while the SSR path used legacy UTC day boundaries. (#1)
2369 $pinned_start = isset($_POST['gf_utime_start']) ? (int) $_POST['gf_utime_start'] : 0;
2370 $pinned_end = isset($_POST['gf_utime_end']) ? (int) $_POST['gf_utime_end'] : 0;
2371 if ($pinned_start > 0 && $pinned_end > 0) {
2372 $start = $pinned_start;
2373 $end = $pinned_end;
2374 } else {
2375 list($start, $end) = self::resolve_requested_date_range();
2376 }
2377
2378 // init() populates $columns_names/$operator_names plus a default
2379 // $filters_normalized; then pin utime to the requested range.
2380 wp_slimstat_db::init();
2381 if (!isset(wp_slimstat_db::$filters_normalized['utime']) || !is_array(wp_slimstat_db::$filters_normalized['utime'])) {
2382 wp_slimstat_db::$filters_normalized['utime'] = [];
2383 }
2384 if (!empty($start) && !empty($end)) {
2385 wp_slimstat_db::$filters_normalized['utime']['start'] = (int) $start;
2386 wp_slimstat_db::$filters_normalized['utime']['end'] = (int) $end;
2387 }
2388 }
2389
2390 // END: Goals & Funnels CRUD
2391
2392 /**
2393 * Deletes a given pageview from the database
2394 */
2395 public static function delete_pageview()
2396 {
2397 $my_wpdb = apply_filters('slimstat_custom_wpdb', $GLOBALS['wpdb']);
2398 $pageview_id = intval($_POST['pageview_id']);
2399
2400 // Delete page view if user has enough access
2401 $current_user_can_delete = (current_user_can(wp_slimstat::$settings['capability_can_admin']) && !is_network_admin());
2402 if (!$current_user_can_delete || !wp_verify_nonce($_POST['security'], 'meta-box-order')) {
2403 return;
2404 }
2405 $my_wpdb->query(sprintf('DELETE ts FROM %sslim_stats ts WHERE ts.id = %d', $GLOBALS['wpdb']->prefix, $pageview_id));
2406 exit();
2407 }
2408
2409 // END: delete_pageview
2410
2411 /**
2412 * Deletes a given pageview from the database
2413 */
2414 public static function rmdir($path)
2415 {
2416 if (!file_exists($path)) {
2417 return true;
2418 }
2419
2420 if (!is_dir($path)) {
2421 return unlink($path);
2422 }
2423
2424 foreach (scandir($path) as $a_item) {
2425 if ('.' === $a_item || '..' === $a_item) {
2426 continue;
2427 }
2428
2429 if (!wp_slimstat_admin::rmdir($path . DIRECTORY_SEPARATOR . $a_item)) {
2430 return false;
2431 }
2432 }
2433
2434 return rmdir($path);
2435 }
2436
2437 // END: delete_pageview
2438
2439 /**
2440 * Handles the Ajax requests to load, save or delete existing filters
2441 */
2442 public static function manage_filters()
2443 {
2444 check_ajax_referer('meta-box-order', 'security');
2445
2446 // If this user is whitelisted, we use the minimum capability
2447 $minimum_capability = 'read';
2448 if (false === strpos(wp_slimstat::$settings['can_view'], (string) $GLOBALS['current_user']->user_login) && !empty(wp_slimstat::$settings['capability_can_view'])) {
2449 $minimum_capability = wp_slimstat::$settings['capability_can_view'];
2450 }
2451
2452 if (!current_user_can($minimum_capability)) {
2453 return;
2454 }
2455
2456 // Initialize the new Reports system FIRST before legacy system loads
2457 \SlimStat\Reports\Bootstrap::get_instance()->init();
2458
2459 include_once(plugin_dir_path(__FILE__) . 'view/wp-slimstat-reports.php');
2460 wp_slimstat_reports::init();
2461
2462 $saved_filters = get_option('slimstat_filters', []);
2463
2464 switch (sanitize_key(wp_unslash($_POST['type'] ?? ''))) {
2465 case 'save':
2466 $new_filter = json_decode(stripslashes_deep(sanitize_text_field($_POST['filter_array'])), true);
2467
2468 // Check if this filter is already saved
2469 foreach ($saved_filters as $a_saved_filter) {
2470 $filter_found = 0;
2471
2472 if (count($a_saved_filter) !== count($new_filter) || count(array_intersect_key($a_saved_filter, $new_filter)) !== count($new_filter)) {
2473 $filter_found = 1;
2474 continue;
2475 }
2476
2477 foreach ($a_saved_filter as $a_key => $a_value) {
2478 $filter_found += ($a_value == $new_filter[$a_key]) ? 0 : 1;
2479 }
2480
2481 if (0 == $filter_found) {
2482 echo __('Already saved', 'wp-slimstat');
2483 break;
2484 }
2485 }
2486
2487 if (empty($saved_filters) || $filter_found > 0) {
2488 $saved_filters[] = $new_filter;
2489 update_option('slimstat_filters', $saved_filters);
2490 echo __('Saved', 'wp-slimstat');
2491 }
2492
2493 break;
2494
2495 case 'delete':
2496 unset($saved_filters[intval($_POST['filter_id'])]);
2497 update_option('slimstat_filters', $saved_filters);
2498
2499 // no break here - We want to return the new list of filters!
2500
2501 default:
2502 echo '<div id="slim_filters_overlay">';
2503 foreach ($saved_filters as $a_filter_id => $a_filter_data) {
2504
2505 $filter_html = [];
2506 $filter_strings = [];
2507 foreach ($a_filter_data as $a_filter_label => $a_filter_details) {
2508 $filter_value_no_slashes = htmlentities(str_replace('\\', '', $a_filter_details[1]), ENT_QUOTES, 'UTF-8');
2509 $filter_html[] = strtolower(wp_slimstat_db::$columns_names[$a_filter_label][0]) . ' ' . __(str_replace('_', ' ', $a_filter_details[0]), 'wp-slimstat') . ' ' . $filter_value_no_slashes;
2510 $filter_strings[] = sprintf('%s %s %s', $a_filter_label, $a_filter_details[0], $filter_value_no_slashes);
2511 }
2512
2513 echo '<p><a class="slimstat-font-cancel slimstat-delete-filter" data-filter-id="' . esc_attr($a_filter_id) . '" title="' . __('Delete this filter', 'wp-slimstat') . '" href="#"></a> <a class="slimstat-filter-link" data-reset-filters="true" href="' . wp_slimstat_reports::fs_url(implode('&&&', $filter_strings)) . '">' . implode(', ', $filter_html) . '</a></p>';
2514 }
2515
2516 echo '</div>';
2517 break;
2518 }
2519
2520 exit();
2521 }
2522
2523 // END: manage_filters
2524
2525 /**
2526 * Check AJAX capability for view access.
2527 * Returns true if user has permission, sends JSON error and returns false otherwise.
2528 *
2529 * @since 5.4.3
2530 * @return bool
2531 */
2532 private static function check_ajax_view_capability()
2533 {
2534 $minimum_capability = 'read';
2535 if (false === strpos(wp_slimstat::$settings['can_view'], (string) $GLOBALS['current_user']->user_login) && !empty(wp_slimstat::$settings['capability_can_view'])) {
2536 $minimum_capability = wp_slimstat::$settings['capability_can_view'];
2537 }
2538
2539 if (!current_user_can($minimum_capability)) {
2540 wp_send_json_error(['message' => esc_html__('Insufficient permissions', 'wp-slimstat')], 403);
2541 return false;
2542 }
2543
2544 return true;
2545 }
2546
2547 /**
2548 * Get online visitors count (30-minute window, session-spanning).
2549 * Shared by get_online_visitors() and get_adminbar_stats().
2550 *
2551 * @since 5.4.3
2552 * @return int
2553 */
2554 private static function query_online_count()
2555 {
2556 $wpdb = wp_slimstat::$wpdb;
2557 $table = "{$GLOBALS['wpdb']->prefix}slim_stats";
2558 $current_minute_start = (int) floor(wp_slimstat::now() / 60) * 60;
2559 $window_start = $current_minute_start - (29 * 60); // 30-minute window
2560
2561 $count = (int) $wpdb->get_var($wpdb->prepare(
2562 "SELECT COUNT(*) FROM (
2563 SELECT visit_id, MAX(
2564 CASE
2565 WHEN dt_out IS NOT NULL AND dt_out > 0 AND dt_out >= dt THEN dt_out
2566 ELSE dt
2567 END
2568 ) AS last_activity
2569 FROM {$table}
2570 WHERE visit_id > 0
2571 AND (dt >= %d OR (dt_out IS NOT NULL AND dt_out >= %d))
2572 GROUP BY visit_id
2573 HAVING (FLOOR(last_activity / 60) * 60 + 59) >= %d
2574 ) live_sessions",
2575 $window_start, $window_start, $window_start
2576 ));
2577
2578 return max(0, $count);
2579 }
2580
2581 /**
2582 * AJAX handler to get current online visitors count
2583 */
2584 public static function get_online_visitors()
2585 {
2586 check_ajax_referer('meta-box-order', 'security');
2587 if (!self::check_ajax_view_capability()) {
2588 return;
2589 }
2590
2591 $online_visitors = self::query_online_count();
2592
2593 wp_send_json_success([
2594 'count' => $online_visitors,
2595 'formatted' => number_format_i18n($online_visitors)
2596 ]);
2597 }
2598
2599 // END: get_online_visitors
2600
2601 /**
2602 * AJAX handler: Returns all admin bar modal stats in a single request.
2603 * Called every minute by adminbar-realtime.js and admin.js.
2604 *
2605 * @since 5.4.3
2606 */
2607 public static function get_adminbar_stats()
2608 {
2609 check_ajax_referer('meta-box-order', 'security');
2610 if (!self::check_ajax_view_capability()) {
2611 return;
2612 }
2613
2614 $wpdb = wp_slimstat::$wpdb;
2615 $table = "{$GLOBALS['wpdb']->prefix}slim_stats";
2616 $is_pro = wp_slimstat::pro_is_installed();
2617
2618 // --- Online count (always fresh — fast indexed query) ---
2619 $online_count = self::query_online_count();
2620
2621 // --- Today stats (transient-cached, 60s TTL) ---
2622 $blog_id = get_current_blog_id();
2623 $transient_key = 'slimstat_adminbar_today_' . $blog_id;
2624 $today_stats = get_transient($transient_key);
2625
2626 if (false === $today_stats) {
2627 $today_start = strtotime('today', current_time('timestamp'));
2628 $yesterday_start = $today_start - DAY_IN_SECONDS;
2629 $yesterday_end = $today_start - 1;
2630 $site_host = parse_url(home_url(), PHP_URL_HOST);
2631 $referer_like = '%' . $wpdb->esc_like($site_host) . '%';
2632
2633 // Sessions + views: 2 queries instead of 4 using conditional aggregates
2634 $row = $wpdb->get_row($wpdb->prepare(
2635 "SELECT
2636 COUNT(DISTINCT CASE WHEN dt >= %d AND visit_id > 0 THEN visit_id END) AS sessions_today,
2637 COUNT(DISTINCT CASE WHEN dt BETWEEN %d AND %d AND visit_id > 0 THEN visit_id END) AS sessions_yesterday,
2638 SUM(CASE WHEN dt >= %d THEN 1 ELSE 0 END) AS views_today,
2639 SUM(CASE WHEN dt BETWEEN %d AND %d THEN 1 ELSE 0 END) AS views_yesterday
2640 FROM {$table}
2641 WHERE dt >= %d",
2642 $today_start,
2643 $yesterday_start, $yesterday_end,
2644 $today_start,
2645 $yesterday_start, $yesterday_end,
2646 $yesterday_start
2647 ));
2648
2649 // Referrals: 1 query with conditional aggregates
2650 $ref_row = $wpdb->get_row($wpdb->prepare(
2651 "SELECT
2652 SUM(CASE WHEN dt >= %d THEN 1 ELSE 0 END) AS referrals_today,
2653 SUM(CASE WHEN dt BETWEEN %d AND %d THEN 1 ELSE 0 END) AS referrals_yesterday
2654 FROM {$table}
2655 WHERE dt >= %d AND referer IS NOT NULL AND referer NOT LIKE %s",
2656 $today_start,
2657 $yesterday_start, $yesterday_end,
2658 $yesterday_start,
2659 $referer_like
2660 ));
2661
2662 $today_stats = [
2663 'sessions' => (int) ($row->sessions_today ?? 0),
2664 'sessions_yesterday' => (int) ($row->sessions_yesterday ?? 0),
2665 'views' => (int) ($row->views_today ?? 0),
2666 'views_yesterday' => (int) ($row->views_yesterday ?? 0),
2667 'referrals' => (int) ($ref_row->referrals_today ?? 0),
2668 'referrals_yesterday' => (int) ($ref_row->referrals_yesterday ?? 0),
2669 ];
2670
2671 $ttl = max(60 - (current_time('timestamp') % 60), 1); // align to next minute boundary
2672 set_transient($transient_key, $today_stats, $ttl);
2673 }
2674
2675 // --- Chart data (uses LiveAnalyticsReport's own 60s transient) ---
2676 $chart_data = null;
2677 if ($is_pro) {
2678 try {
2679 $live_report = new \SlimStat\Reports\Types\Analytics\LiveAnalyticsReport();
2680 $chart_result = $live_report->get_users_chart_data();
2681 $chart_data = [
2682 'data' => $chart_result['data'],
2683 'max_value' => $chart_result['max_value'],
2684 'peak_index' => $chart_result['peak_index'],
2685 ];
2686 } catch (\Exception $e) {
2687 // Graceful degradation — return stats without chart data
2688 $chart_data = null;
2689 }
2690 }
2691
2692 // --- Build response ---
2693 $response = [
2694 'online' => [
2695 'count' => $online_count,
2696 'formatted' => number_format_i18n($online_count),
2697 ],
2698 'sessions' => [
2699 'count' => $today_stats['sessions'],
2700 'formatted' => number_format_i18n($today_stats['sessions']),
2701 'yesterday' => number_format_i18n($today_stats['sessions_yesterday']),
2702 ],
2703 'is_pro' => $is_pro,
2704 ];
2705
2706 if ($is_pro) {
2707 $response['views'] = [
2708 'count' => $today_stats['views'],
2709 'formatted' => number_format_i18n($today_stats['views']),
2710 'yesterday' => number_format_i18n($today_stats['views_yesterday']),
2711 ];
2712 $response['referrals'] = [
2713 'count' => $today_stats['referrals'],
2714 'formatted' => number_format_i18n($today_stats['referrals']),
2715 'yesterday' => number_format_i18n($today_stats['referrals_yesterday']),
2716 ];
2717 $response['chart'] = $chart_data;
2718 }
2719
2720 wp_send_json_success($response);
2721 }
2722
2723 // END: get_adminbar_stats
2724
2725 /**
2726 * Helper function to get icon URL for filter options
2727 */
2728 private static function get_filter_icon_url($dimension, $value)
2729 {
2730 $icon_url = '';
2731
2732 switch ($dimension) {
2733 case 'country':
2734 // Country flags are SVG files named by country code (lowercase)
2735 $country_code = strtolower($value);
2736 $flag_rel = '/admin/assets/images/flags/' . $country_code . '.svg';
2737 $flag_path = SLIMSTAT_ANALYTICS_DIR . $flag_rel;
2738 if (is_readable($flag_path)) {
2739 $icon_url = SLIMSTAT_ANALYTICS_URL . $flag_rel;
2740 }
2741 break;
2742
2743 case 'browser':
2744 // Browser icons are PNG files named by browser name (lowercase)
2745 $browser_name = strtolower($value);
2746 $browser_rel = '/admin/assets/images/browsers/' . $browser_name . '.png';
2747 $browser_path = SLIMSTAT_ANALYTICS_DIR . $browser_rel;
2748 if (is_readable($browser_path)) {
2749 $icon_url = SLIMSTAT_ANALYTICS_URL . $browser_rel;
2750 }
2751 break;
2752
2753 case 'language':
2754 // Language flags use the last part of the language code (e.g., en-US -> us)
2755 $language_parts = explode('-', $value);
2756 $last_part = strtolower(end($language_parts));
2757 $flag_rel = '/admin/assets/images/flags/' . $last_part . '.svg';
2758 $flag_path = SLIMSTAT_ANALYTICS_DIR . $flag_rel;
2759 if (is_readable($flag_path)) {
2760 $icon_url = SLIMSTAT_ANALYTICS_URL . $flag_rel;
2761 }
2762 break;
2763
2764 case 'platform':
2765 // Platform/OS icons are WEBP files with abbreviated names
2766 $os_map = [
2767 'win' => 'win',
2768 'windows' => 'win',
2769 'mac' => 'mac',
2770 'macosx' => 'mac',
2771 'linux' => 'lin',
2772 'ubuntu' => 'ubu',
2773 'android' => 'and',
2774 'ios' => 'ios',
2775 'chrome os' => 'chr',
2776 'chromeos' => 'chr',
2777 ];
2778
2779 $os_lower = strtolower($value);
2780 $os_icon = null;
2781
2782 // Check if exact match exists in map
2783 if (isset($os_map[$os_lower])) {
2784 $os_icon = $os_map[$os_lower];
2785 } else {
2786 // Check if value contains any of the keys
2787 foreach ($os_map as $key => $icon) {
2788 if (strpos($os_lower, $key) !== false) {
2789 $os_icon = $icon;
2790 break;
2791 }
2792 }
2793 }
2794
2795 if ($os_icon) {
2796 $os_rel = '/admin/assets/images/os/' . $os_icon . '.webp';
2797 $os_path = SLIMSTAT_ANALYTICS_DIR . $os_rel;
2798 if (is_readable($os_path)) {
2799 $icon_url = SLIMSTAT_ANALYTICS_URL . $os_rel;
2800 }
2801 }
2802 break;
2803
2804 case 'username':
2805 // For users, we'll use WordPress gravatar
2806 // This will be handled separately in the JavaScript
2807 break;
2808 }
2809
2810 return $icon_url;
2811 }
2812
2813 /**
2814 * AJAX handler to get distinct filter options for a selected dimension
2815 */
2816 public static function get_filter_options()
2817 {
2818 check_ajax_referer('meta-box-order', 'security');
2819
2820 // If this user is whitelisted, we use the minimum capability
2821 $minimum_capability = 'read';
2822 if (false === strpos(wp_slimstat::$settings['can_view'], (string) $GLOBALS['current_user']->user_login) && !empty(wp_slimstat::$settings['capability_can_view'])) {
2823 $minimum_capability = wp_slimstat::$settings['capability_can_view'];
2824 }
2825
2826 if (!current_user_can($minimum_capability)) {
2827 wp_send_json_error('Insufficient permissions');
2828 return;
2829 }
2830
2831 $dimension = sanitize_text_field($_POST['dimension'] ?? '');
2832
2833 // Validate dimension exists in columns_names
2834 include_once(plugin_dir_path(__FILE__) . 'view/wp-slimstat-db.php');
2835
2836 // We only need the columns_names array, not the full init with filters
2837 if (empty(wp_slimstat_db::$columns_names)) {
2838 wp_slimstat_db::$columns_names = [
2839 'id' => ['ID', 'number'],
2840 'ip' => ['IP', 'varchar'],
2841 'other_ip' => ['Other IP', 'varchar'],
2842 'username' => ['Username', 'varchar'],
2843 'email' => ['Email', 'varchar'],
2844 'country' => ['Country', 'varchar'],
2845 'location' => ['Location', 'varchar'],
2846 'city' => ['City', 'varchar'],
2847 'referer' => ['Referer', 'varchar'],
2848 'resource' => ['Resource', 'varchar'],
2849 'searchterms' => ['Search Terms', 'varchar'],
2850 'notes' => ['Notes', 'varchar'],
2851 'visit_id' => ['Visit ID', 'number'],
2852 'server_latency' => ['Server Latency', 'number'],
2853 'page_performance' => ['Page Performance', 'number'],
2854 'browser' => ['Browser', 'varchar'],
2855 'browser_version' => ['Browser Version', 'varchar'],
2856 'browser_type' => ['Browser Type', 'number'],
2857 'platform' => ['Platform', 'varchar'],
2858 'language' => ['Language', 'varchar'],
2859 'fingerprint' => ['Fingerprint', 'varchar'],
2860 'user_agent' => ['User Agent', 'varchar'],
2861 'resolution' => ['Resolution', 'varchar'],
2862 'screen_width' => ['Screen Width', 'number'],
2863 'screen_height' => ['Screen Height', 'number'],
2864 'content_type' => ['Content Type', 'varchar'],
2865 'category' => ['Category', 'varchar'],
2866 'author' => ['Author', 'varchar'],
2867 'content_id' => ['Content ID', 'number'],
2868 'outbound_resource' => ['Outbound Resource', 'varchar'],
2869 'tz_offset' => ['Timezone Offset', 'number'],
2870 'dt_out' => ['Date Time Out', 'number'],
2871 'dt' => ['Date Time', 'number'],
2872 ];
2873 }
2874
2875 if (empty($dimension) || !isset(wp_slimstat_db::$columns_names[$dimension])) {
2876 wp_send_json_error('Invalid dimension');
2877 return;
2878 }
2879
2880 // Resolve the report date picker's window (shared with the goals/funnels
2881 // AJAX handlers so autosuggest, Test, and lazy-load all read one range).
2882 list($time_start, $time_end) = self::resolve_requested_date_range();
2883
2884 // Get distinct values for this dimension via SlimStat\Utils\Query abstraction
2885 $table_name = $GLOBALS['wpdb']->prefix . 'slim_stats';
2886
2887 // Limit results to prevent overwhelming the dropdown (filterable for customization)
2888 $limit = apply_filters('slimstat_filter_options_limit', 500, $dimension);
2889 $limit = absint($limit); // Ensure it's a positive integer
2890
2891 // Enforce reasonable bounds to prevent abuse
2892 if ($limit < 1 || $limit > 5000) {
2893 $limit = 500; // Reset to default if out of reasonable range
2894 }
2895
2896 // Sanitize column name to prevent SQL injection (only allow known columns)
2897 $allowed_columns = array_keys(wp_slimstat_db::$columns_names);
2898 if (!in_array($dimension, $allowed_columns, true)) {
2899 wp_send_json_error('Invalid column');
2900 return;
2901 }
2902
2903 // Additional sanitization layer for column name (defense in depth)
2904 $safe_dimension = esc_sql($dimension);
2905
2906 // Get distinct non-empty values
2907 $column_type = wp_slimstat_db::$columns_names[$dimension][1];
2908
2909 // Optional server-side search (layer 2 of #298). Only applies to varchar
2910 // columns — searching numeric dimensions falls back to the legacy DISTINCT.
2911 $search_raw = $_POST['search'] ?? '';
2912 $search = '';
2913 if (is_string($search_raw)) {
2914 $search = trim(sanitize_text_field($search_raw));
2915 if (strlen($search) < 2 || strlen($search) > 64) {
2916 $search = '';
2917 }
2918 }
2919 if ($column_type !== 'varchar') {
2920 $search = '';
2921 }
2922
2923 // Cache lookup (layer 3 of #298). Key must account for anything that
2924 // changes the result set: blog, DB host (External DB addon), capability
2925 // gate, dimension, hour-bucketed time range, effective limit, and search.
2926 $cache_key = self::build_filter_options_cache_key(
2927 $dimension,
2928 $time_start,
2929 $time_end,
2930 $search,
2931 $limit
2932 );
2933 $cached = self::filter_options_cache_get($cache_key);
2934 if (is_array($cached)) {
2935 wp_send_json_success($cached);
2936 exit();
2937 }
2938
2939 // Build SQL query directly to avoid Query class interference with global filters
2940 $where_clauses = [];
2941
2942 // Apply time range filter
2943 if (!empty($time_start) && !empty($time_end)) {
2944 $where_clauses[] = $GLOBALS['wpdb']->prepare('dt BETWEEN %d AND %d', intval($time_start), intval($time_end));
2945 }
2946
2947 if ($column_type === 'varchar') {
2948 // Exclude NULLs and empty strings for varchar columns
2949 $where_clauses[] = $safe_dimension . ' IS NOT NULL';
2950 $where_clauses[] = $safe_dimension . " <> ''";
2951 } else {
2952 // Exclude NULLs and zeros for numeric columns
2953 $where_clauses[] = $safe_dimension . ' IS NOT NULL';
2954 $where_clauses[] = $safe_dimension . ' <> 0';
2955 }
2956
2957 // Append LIKE filter when a server-side search term was supplied.
2958 if ($search !== '') {
2959 $like_pattern = self::build_filter_search_like($dimension, $search);
2960 $where_clauses[] = wp_slimstat::$wpdb->prepare($safe_dimension . ' LIKE %s', $like_pattern);
2961 }
2962
2963 $where_sql = !empty($where_clauses) ? 'WHERE ' . implode(' AND ', $where_clauses) : '';
2964
2965 // Rank matches by relevance (exact, then prefix, then contains) so the LIMIT
2966 // keeps and orders the values the user actually typed first. (#21)
2967 $order_sql = self::build_filter_search_order($safe_dimension, $search);
2968
2969 $sql = sprintf(
2970 'SELECT DISTINCT %s as value FROM %s %s ORDER BY %s LIMIT %d',
2971 $safe_dimension,
2972 $table_name,
2973 $where_sql,
2974 $order_sql,
2975 $limit
2976 );
2977
2978 // Execute query — use wp_slimstat::$wpdb so External DB addon
2979 // queries the correct database.
2980 $results = wp_slimstat::$wpdb->get_results($sql, ARRAY_A);
2981
2982 // Check for database errors
2983 if (wp_slimstat::$wpdb->last_error) {
2984 wp_send_json_error('Database query failed');
2985 return;
2986 }
2987
2988 // Ensure results is an array
2989 if (!is_array($results)) {
2990 $results = [];
2991 }
2992
2993 // Split multi-value columns into individual values.
2994 // These columns store multiple entries in a single DB field:
2995 // outbound_resource: "url1;;;url2;;;url3"
2996 // notes: "[tag1][tag2][tag3]"
2997 // category: "1,5,12"
2998 $multi_value_separators = [
2999 'outbound_resource' => ';;;',
3000 'category' => ',',
3001 ];
3002
3003 if (isset($multi_value_separators[$dimension])) {
3004 $separator = $multi_value_separators[$dimension];
3005 $expanded = [];
3006 foreach ($results as $row) {
3007 if (empty($row['value'])) continue;
3008 foreach (explode($separator, $row['value']) as $val) {
3009 $val = trim($val);
3010 if ($val !== '') $expanded[] = ['value' => $val];
3011 }
3012 }
3013 $results = $expanded;
3014 } elseif ($dimension === 'notes') {
3015 $expanded = [];
3016 foreach ($results as $row) {
3017 if (empty($row['value'])) continue;
3018 preg_match_all('/\[([^\]]+)\]/', $row['value'], $matches);
3019 foreach ($matches[1] as $val) {
3020 $val = trim($val);
3021 if ($val !== '') $expanded[] = ['value' => $val];
3022 }
3023 }
3024 $results = $expanded;
3025 }
3026
3027 // After splitting multi-value rows, re-filter split segments by the
3028 // server-side search term so the caller gets only matching segments,
3029 // not every segment from rows where any sibling matched.
3030 if ($search !== '' && (isset($multi_value_separators[$dimension]) || $dimension === 'notes')) {
3031 $has_mb = function_exists('mb_strtolower');
3032 $needle = $has_mb ? mb_strtolower($search) : strtolower($search);
3033 $is_substring = self::filter_search_is_substring($dimension);
3034 $results = array_values(array_filter($results, function ($row) use ($needle, $is_substring, $has_mb) {
3035 $haystack = $has_mb ? mb_strtolower($row['value']) : strtolower($row['value']);
3036 return $is_substring ? (strpos($haystack, $needle) !== false) : (strpos($haystack, $needle) === 0);
3037 }));
3038 }
3039
3040 // Cap expanded results to prevent explosion from splitting
3041 if (count($results) > $limit) {
3042 $results = array_slice($results, 0, $limit);
3043 }
3044
3045 $options = [];
3046 $seen_values = []; // Track values to prevent duplicates (case-insensitive)
3047 $dimensions_with_icons = ['country', 'browser', 'language', 'platform', 'username'];
3048 $has_icons = in_array($dimension, $dimensions_with_icons, true);
3049
3050 foreach ($results as $row) {
3051 if (!empty($row['value'])) {
3052 // Sanitize output to prevent XSS
3053 $sanitized_value = sanitize_text_field($row['value']);
3054
3055 // Trim whitespace
3056 $sanitized_value = trim($sanitized_value);
3057
3058 // Skip empty values after trimming
3059 if (empty($sanitized_value)) {
3060 continue;
3061 }
3062
3063 // Check for duplicates using case-insensitive comparison
3064 $value_key = strtolower($sanitized_value);
3065 if (isset($seen_values[$value_key])) {
3066 continue; // Skip duplicate
3067 }
3068
3069 // Mark this value as seen
3070 $seen_values[$value_key] = true;
3071
3072 // Limit individual option length to prevent DOM issues
3073 if (strlen($sanitized_value) > 255) {
3074 $sanitized_value = substr($sanitized_value, 0, 255) . '...';
3075 }
3076
3077 if ($has_icons) {
3078 // Return object with value and icon
3079 $icon_url = self::get_filter_icon_url($dimension, $sanitized_value);
3080
3081 // For username, get user gravatar
3082 if ($dimension === 'username' && empty($icon_url)) {
3083 $user = get_user_by('login', $sanitized_value);
3084 if ($user) {
3085 $icon_url = get_avatar_url($user->ID, ['size' => 32]);
3086 } else {
3087 $icon_url = get_avatar_url($sanitized_value, ['size' => 32]);
3088 }
3089 }
3090
3091 $options[] = [
3092 'value' => $sanitized_value,
3093 'label' => $sanitized_value,
3094 'icon' => $icon_url
3095 ];
3096 } else {
3097 // Return simple string for backward compatibility
3098 $options[] = $sanitized_value;
3099 }
3100 }
3101 }
3102
3103 self::filter_options_cache_set(
3104 $cache_key,
3105 $options,
3106 self::filter_options_cache_ttl($time_end)
3107 );
3108
3109 wp_send_json_success($options);
3110 exit();
3111 }
3112
3113 /**
3114 * Whether the server-side filter search uses an unanchored (%needle%) LIKE for
3115 * this dimension instead of the default left-anchored prefix (needle%). Single
3116 * source of truth for the search-anchor decision (#298), shared by the SQL LIKE
3117 * builder and the post-split segment re-filter.
3118 */
3119 private static function filter_search_is_substring(string $dimension): bool
3120 {
3121 return in_array($dimension, self::FILTER_SEARCH_SUBSTRING_DIMENSIONS, true);
3122 }
3123
3124 /**
3125 * Build the escaped, anchored LIKE pattern for a server-side filter search (#298).
3126 * The needle is run through wpdb::esc_like so SQL LIKE metacharacters (% _) are
3127 * matched literally; the caller still passes the result through wpdb::prepare.
3128 */
3129 private static function build_filter_search_like(string $dimension, string $search): string
3130 {
3131 $escaped = wp_slimstat::$wpdb->esc_like($search);
3132 return self::filter_search_is_substring($dimension) ? '%' . $escaped . '%' : $escaped . '%';
3133 }
3134
3135 /**
3136 * Build the ORDER BY expression for a server-side filter search so matches rank
3137 * by relevance: exact first, then left-anchored prefix, then any other (substring)
3138 * match, alphabetical within each tier. Without this, ORDER BY column-only +
3139 * LIMIT could truncate or bury the exact/prefix values a user typed behind
3140 * incidental contains-matches (e.g. "/pricing" surfacing unrelated paths instead
3141 * of /pricing, /pricing/, /pricing?utm…). The column name is already validated
3142 * against the allowed-columns whitelist by the caller; the term is bound via
3143 * prepare(). (#21)
3144 */
3145 private static function build_filter_search_order(string $safe_dimension, string $search): string
3146 {
3147 if ('' === $search) {
3148 return $safe_dimension . ' ASC';
3149 }
3150 $prefix_like = wp_slimstat::$wpdb->esc_like($search) . '%';
3151 return wp_slimstat::$wpdb->prepare(
3152 'CASE WHEN ' . $safe_dimension . ' = %s THEN 0 WHEN ' . $safe_dimension . ' LIKE %s THEN 1 ELSE 2 END, ' . $safe_dimension . ' ASC',
3153 $search,
3154 $prefix_like
3155 );
3156 }
3157
3158 /**
3159 * Composite cache key for get_filter_options(). Must include every variable
3160 * that changes the result set: blog (multisite), DB host (External DB addon
3161 * can point to another DB), capability gate (per-role visibility), dimension,
3162 * hour-bucketed time range (increases hit rate for rolling windows), the
3163 * effective limit (respects third-party `slimstat_filter_options_limit`
3164 * consumers), and the search term.
3165 */
3166 private static function build_filter_options_cache_key(
3167 string $dimension,
3168 ?int $time_start,
3169 ?int $time_end,
3170 string $search,
3171 int $limit
3172 ): string {
3173 $blog_id = function_exists('get_current_blog_id') ? (int) get_current_blog_id() : 0;
3174 $dbhost = '';
3175 if (isset(wp_slimstat::$wpdb) && is_object(wp_slimstat::$wpdb) && isset(wp_slimstat::$wpdb->dbhost)) {
3176 $dbhost = (string) wp_slimstat::$wpdb->dbhost;
3177 }
3178 $dbhost_hash = substr(md5($dbhost), 0, 8);
3179 $can_view = (string) (wp_slimstat::$settings['can_view'] ?? '');
3180 $capability = (string) (wp_slimstat::$settings['capability_can_view'] ?? '');
3181 $capability_hash = substr(md5($capability . '|' . $can_view), 0, 8);
3182 $ts_start_bucket = $time_start ? (int) floor((int) $time_start / 3600) : 0;
3183 $ts_end_bucket = $time_end ? (int) floor((int) $time_end / 3600) : 0;
3184 $search_hash = $search === '' ? '' : substr(md5($search), 0, 8);
3185 return sprintf(
3186 'fopts_%d_%s_%s_%s_%d_%d_%d_%s',
3187 $blog_id,
3188 $dbhost_hash,
3189 $capability_hash,
3190 $dimension,
3191 $ts_start_bucket,
3192 $ts_end_bucket,
3193 $limit,
3194 $search_hash
3195 );
3196 }
3197
3198 private static function filter_options_cache_ttl(?int $time_end): int
3199 {
3200 // Historical data (range ends > 1h ago) never changes — cache it longer.
3201 if ($time_end && (int) $time_end < (time() - 3600)) {
3202 return 3600;
3203 }
3204 return 300;
3205 }
3206
3207 private static function filter_options_cache_get(string $key)
3208 {
3209 if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
3210 $found = false;
3211 $data = wp_cache_get($key, 'slimstat_filter_options', false, $found);
3212 return $found ? $data : null;
3213 }
3214 $data = get_transient('slimstat_' . $key);
3215 return $data === false ? null : $data;
3216 }
3217
3218 private static function filter_options_cache_set(string $key, $data, int $ttl): void
3219 {
3220 if (function_exists('wp_using_ext_object_cache') && wp_using_ext_object_cache()) {
3221 wp_cache_set($key, $data, 'slimstat_filter_options', $ttl);
3222 return;
3223 }
3224 set_transient('slimstat_' . $key, $data, $ttl);
3225 }
3226
3227 // END: get_filter_options
3228
3229 public static function update_geoip_database()
3230 {
3231 check_ajax_referer('slimstat_geoip_action', 'security');
3232
3233 if (!current_user_can(\wp_slimstat::$settings['capability_can_admin'])) {
3234 wp_send_json_error(__('Permission denied', 'wp-slimstat'));
3235 return;
3236 }
3237
3238 try {
3239 $provider = \wp_slimstat::resolve_geolocation_provider();
3240 if (false === $provider) {
3241 wp_send_json_error(__('Geolocation is disabled.', 'wp-slimstat'));
3242 return;
3243 }
3244 if ('cloudflare' === $provider) {
3245 update_option('slimstat_last_geoip_dl', time());
3246 wp_send_json_success(__('Cloudflare geolocation does not require a database.', 'wp-slimstat'));
3247 return;
3248 }
3249
3250 // License validation is handled by the MaxMind provider; do not pre-check here
3251
3252 $service = new \SlimStat\Services\Geolocation\GeolocationService($provider, []);
3253 $ok = $service->updateDatabase();
3254
3255 if ($ok) {
3256 update_option('slimstat_last_geoip_dl', time());
3257 wp_send_json_success(__('GeoIP Database Successfully Updated!', 'wp-slimstat'));
3258 } else {
3259 // Log the error for debugging
3260 $error_message = __('Failed to update GeoIP Database.', 'wp-slimstat');
3261 if ('maxmind' === $provider) {
3262 $error_message .= ' ' . __('Please check your MaxMind license key and try again.', 'wp-slimstat');
3263 }
3264 $geoip_error = get_option('slimstat_geoip_error', []);
3265 if (!empty($geoip_error) && !empty($geoip_error['error'])) {
3266 $error_message .= ' ' . sprintf(__('Details: %s', 'wp-slimstat'), $geoip_error['error']);
3267 }
3268 wp_send_json_error($error_message);
3269 }
3270 } catch (\Throwable $exception) {
3271 \wp_slimstat::log('GeoIP update AJAX error: ' . $exception->getMessage() . ' in ' . $exception->getFile() . ':' . $exception->getLine(), 'error');
3272 wp_send_json_error(__('An unexpected error occurred while updating the GeoIP database.', 'wp-slimstat'));
3273 }
3274 }
3275
3276 public static function check_geoip_database()
3277 {
3278 check_ajax_referer('slimstat_geoip_action', 'security');
3279
3280 if (!current_user_can(\wp_slimstat::$settings['capability_can_admin'])) {
3281 wp_send_json_error(__('Permission denied', 'wp-slimstat'));
3282 return;
3283 }
3284
3285 try {
3286 $provider = \wp_slimstat::resolve_geolocation_provider();
3287 if (false === $provider) {
3288 wp_send_json_error(__('Geolocation is disabled.', 'wp-slimstat'));
3289 return;
3290 }
3291 if ('cloudflare' === $provider) {
3292 wp_send_json_success(__('Cloudflare geolocation is active. No database to check.', 'wp-slimstat'));
3293 return;
3294 }
3295 $service = new \SlimStat\Services\Geolocation\GeolocationService($provider, []);
3296 $exists = file_exists($service->getProvider()->getDbPath());
3297 $result = [ 'notice' => $exists ? __('GeoIP Database is present and ready.', 'wp-slimstat') : __('GeoIP Database not found.', 'wp-slimstat') ];
3298
3299 wp_send_json_success($result['notice']);
3300 } catch (\Throwable $exception) {
3301 \wp_slimstat::log('GeoIP check AJAX error: ' . $exception->getMessage() . ' in ' . $exception->getFile() . ':' . $exception->getLine(), 'error');
3302 wp_send_json_error(__('An unexpected error occurred while checking the GeoIP database.', 'wp-slimstat'));
3303 }
3304 }
3305
3306 /**
3307 * Contextual help
3308 */
3309 public static function contextual_help()
3310 {
3311 $screen = get_current_screen();
3312
3313 $screen->add_help_tab(
3314 [
3315 'id' => 'wp-slimstat-definitions',
3316 'title' => __('Definitions', 'wp-slimstat'),
3317 'content' => '<ul>
3318 <li><b>' . __('Pageview', 'wp-slimstat') . '</b>: ' . __('A request to load a single HTML file ("page"). This should be contrasted with a "hit", which refers to a request for any file from a web server. Slimstat logs a pageview each time the tracking code is executed', 'wp-slimstat') . '</li>
3319 <li><b>' . __('(Human) Visit', 'wp-slimstat') . '</b>: ' . __("A period of interaction between a visitor's browser and your website, ending when the browser is closed or when the user has been inactive on that site for 30 minutes", 'wp-slimstat') . '</li>
3320 <li><b>' . __('Known Visitor', 'wp-slimstat') . '</b>: ' . __('Any user who has left a comment on your blog, and is thus identified by WordPress as a returning visitor', 'wp-slimstat') . '</li>
3321 <li><b>' . __('Unique IP', 'wp-slimstat') . '</b>: ' . __('Used to differentiate between multiple requests to download a file from one internet address (IP) and requests originating from many distinct addresses; since this measurement looks only at the internet address a pageview came from, it is useful, but not perfect', 'wp-slimstat') . '</li>
3322 <li><b>' . __('Originating IP', 'wp-slimstat') . '</b>: ' . __('the originating IP address of a client connecting to a web server through an HTTP proxy or load balancer', 'wp-slimstat') . '</li>
3323 <li><b>' . __('Direct Traffic', 'wp-slimstat') . '</b>: ' . __('All those people showing up to your Web site by typing in the URL of your Web site coming or from a bookmark; some people also call this "default traffic" or "ambient traffic"', 'wp-slimstat') . '</li>
3324 <li><b>' . __('Search Engine', 'wp-slimstat') . '</b>: ' . __('Google, Yahoo, MSN, Ask, others; this bucket will include both your organic as well as your paid (PPC/SEM) traffic, so be aware of that', 'wp-slimstat') . '</li>
3325 <li><b>' . __('Search Terms', 'wp-slimstat') . '</b>: ' . __('Keywords used by your visitors to find your website on a search engine', 'wp-slimstat') . '</li>
3326 <li><b>' . __('SERP', 'wp-slimstat') . '</b>: ' . __('Short for search engine results page, the Web page that a search engine returns with the results of its search. The value shown represents your rank (or position) within that list of results', 'wp-slimstat') . '</li>
3327 <li><b>' . __('User Agent', 'wp-slimstat') . '</b>: ' . __('Any program used for accessing a website; this includes browsers, robots, spiders and any other program that was used to retrieve information from the site', 'wp-slimstat') . '</li>
3328 <li><b>' . __('Outbound Link', 'wp-slimstat') . '</b>: ' . __('A link from one domain to another is said to be outbound from its source anchor and inbound to its target. This report lists all the links to other websites followed by your visitors.', 'wp-slimstat') . '</li>
3329 </ul>',
3330 ]
3331 );
3332 $screen->add_help_tab(
3333 [
3334 'id' => 'wp-slimstat-basic-filters',
3335 'title' => __('Basic Filters', 'wp-slimstat'),
3336 'content' => '<ul>
3337 <li><b>' . __('Browser', 'wp-slimstat') . '</b>: ' . __('User agent (Firefox, Chrome, ...)', 'wp-slimstat') . '</li>
3338 <li><b>' . __('Country Code', 'wp-slimstat') . '</b>: ' . __('2-letter code (us, ru, de, it, ...)', 'wp-slimstat') . '</li>
3339 <li><b>' . __('IP', 'wp-slimstat') . '</b>: ' . __("Visitor's public IP address", 'wp-slimstat') . '</li>
3340 <li><b>' . __('Search Terms', 'wp-slimstat') . '</b>: ' . __('Keywords used by your visitors to find your website on a search engine', 'wp-slimstat') . '</li>
3341 <li><b>' . __('Language Code', 'wp-slimstat') . '</b>: ' . __('Please refer to this <a target="_blank" href="https://msdn.microsoft.com/en-us/library/ee825488(v=cs.20).aspx">language culture page</a> (first column) for more information', 'wp-slimstat') . '</li>
3342 <li><b>' . __('Operating System', 'wp-slimstat') . '</b>: ' . __('Accepts identifiers like win7, win98, macosx, ...; please refer to <a target="_blank" href="https://php.net/manual/en/function.get-browser.php">this manual page</a> for more information', 'wp-slimstat') . '</li>
3343 <li><b>' . __('Permalink', 'wp-slimstat') . '</b>: ' . __('URL accessed on your site', 'wp-slimstat') . '</li>
3344 <li><b>' . __('Referer', 'wp-slimstat') . '</b>: ' . __('Complete address of the referrer page', 'wp-slimstat') . '</li>
3345 <li><b>' . __("Visitor's Name", 'wp-slimstat') . '</b>: ' . __("Visitors' names according to the cookie set by WordPress after they leave a comment", 'wp-slimstat') . '</li>
3346 </ul>',
3347 ]
3348 );
3349
3350 $screen->add_help_tab(
3351 [
3352 'id' => 'wp-slimstat-advanced-filters',
3353 'title' => __('Advanced Filters', 'wp-slimstat'),
3354 'content' => '<ul>
3355 <li><b>' . __('Browser Version', 'wp-slimstat') . '</b>: ' . __('user agent version (9.0, 11, ...)', 'wp-slimstat') . '</li>
3356 <li><b>' . __('Browser Type', 'wp-slimstat') . '</b>: ' . __('1 = search engine crawler, 2 = mobile device, 3 = syndication reader, 0 = all others', 'wp-slimstat') . '</li>
3357 <li><b>' . __('Pageview Attributes', 'wp-slimstat') . '</b>: ' . __('this field is set to <em>[pre]</em> if the resource has been accessed through <a target="_blank" href="https://developer.mozilla.org/en/Link_prefetching_FAQ">Link Prefetching</a> or similar techniques', 'wp-slimstat') . '</li>
3358 <li><b>' . __('Post Author', 'wp-slimstat') . '</b>: ' . __('author associated to that post/page when the resource was accessed', 'wp-slimstat') . '</li>
3359 <li><b>' . __('Post Category ID', 'wp-slimstat') . '</b>: ' . __('ID of the category/term associated to the resource, when available', 'wp-slimstat') . '</li>
3360 <li><b>' . __('Originating IP', 'wp-slimstat') . '</b>: ' . __("visitor's originating IP address, if available", 'wp-slimstat') . '</li>
3361 <li><b>' . __('Resource Content Type', 'wp-slimstat') . '</b>: ' . __('post, page, cpt:<em>custom-post-type</em>, cpt:attachment, singular, post_type_archive, tag, taxonomy, category, date, author, archive, search, feed, home; please refer to the <a target="_blank" href="https://codex.wordpress.org/Conditional_Tags">Conditional Tags</a> manual page for more information', 'wp-slimstat') . '</li>
3362 <li><b>' . __('Screen Resolution', 'wp-slimstat') . '</b>: ' . __('viewport width and height (1024x768, 800x600, ...)', 'wp-slimstat') . '</li>
3363 <li><b>' . __('Visit ID', 'wp-slimstat') . '</b>: ' . __('generally used in conjunction with <em>is not empty</em>, identifies human visitors', 'wp-slimstat') . '</li>
3364 <li><b>' . __('Date Filters', 'wp-slimstat') . '</b>: ' . __('you can specify the timeframe by entering a number in the <em>interval</em> field; use -1 to indicate <em>to date</em> (i.e., day=1, month=1, year=blank, interval=-1 will set a year-to-date filter)', 'wp-slimstat') . '</li>
3365 <li><b>' . __('SERP Position', 'wp-slimstat') . '</b>: ' . __('set the filter to Referer contains cd=N&, where N is the position you are looking for', 'wp-slimstat') . '</li>
3366 </ul>',
3367 ]
3368 );
3369 return null;
3370 }
3371
3372 // END: contextual_help
3373
3374 /**
3375 * Creates a table in the database
3376 */
3377 protected static function _create_table($_sql = '', $_tablename = '', $_wpdb = '')
3378 {
3379 $_wpdb->query($_sql);
3380
3381 // Let's make sure this table was actually created
3382 foreach ($_wpdb->get_col(sprintf("SHOW TABLES LIKE '%s'", $_tablename), 0) as $a_table) {
3383 if ($a_table == $_tablename) {
3384 return true;
3385 }
3386 }
3387
3388 return false;
3389 }
3390
3391 // END: _create_table
3392
3393 public static function get_template($template, $args = [], $return = false)
3394 {
3395 // Push Args - use EXTR_SKIP to prevent variable overwriting for security
3396 if (is_array($args) && isset($args)) :
3397 extract($args, EXTR_SKIP);
3398 endif;
3399
3400 // Check Load single file or array list
3401 if (is_string($template)) {
3402 $template = explode(' ', $template);
3403 }
3404
3405 // Load File
3406 foreach ($template as $file) {
3407 $template_file = WP_PLUGIN_DIR . sprintf('/wp-slimstat/admin/view/partials/%s.php', $file);
3408
3409 if (!file_exists($template_file)) {
3410 continue;
3411 }
3412
3413 if ($return) {
3414 ob_start();
3415 require $template_file;
3416
3417 return ob_get_clean();
3418 }
3419
3420 // include File
3421 include $template_file;
3422 }
3423
3424 return null;
3425 }
3426
3427 public static function add_lock_export_button($_header_buttons = '', $_report_id = '')
3428 {
3429 // If the pro is active don't show it
3430 $pro_plugin_slug = 'wp-slimstat-pro/wp-slimstat-pro.php';
3431 if (is_plugin_active($pro_plugin_slug)) {
3432 return $_header_buttons;
3433 }
3434
3435 // Define which reports get this new functionality
3436 $callback_args = \wp_slimstat_reports::$reports[$_report_id]['callback_args'] ?? [];
3437 if (empty($callback_args) || !array_key_exists('raw', $callback_args)) {
3438 return $_header_buttons;
3439 }
3440
3441 // A report may declare itself non-exportable (a bool or a presence probe).
3442 // Don't offer an "Export" upgrade link where there's nothing to export —
3443 // a download-styled control that only routes to pricing is bait (FN-7/FN-17).
3444 if (array_key_exists('exportable', $callback_args)) {
3445 $exportable = is_callable($callback_args['exportable'])
3446 ? (bool) call_user_func($callback_args['exportable'])
3447 : (bool) $callback_args['exportable'];
3448 if (!$exportable) {
3449 return $_header_buttons;
3450 }
3451 }
3452 $utm_medium = empty($_report_id) ? 'report-unknown' : $_report_id;
3453 return '<a class="slimstat-upgrade-pro slimstat-filter-link slimstat-filter-temp button-export-to-xls slimstat-font-download is-not-pro noslimstat" title="' . __('Upgrade to Pro', 'wp-slimstat-pro') . '" href="https://wp-slimstat.com/pricing/?utm_source=admin&utm_medium=' . $utm_medium . '&utm_campaign=export" target="_blank"><span class="dashicons dashicons-download"></span>' . __('Export', 'wp-slimstat-pro') . '</a> ' . $_header_buttons;
3454 }
3455
3456 /**
3457 * Goals & Funnels: surface the usage pill + CTA inside the postbox header
3458 * (left of the refresh / lock-export icons), and the subtitle directly under
3459 * the <h3>. Card partials no longer render this chrome themselves.
3460 */
3461 public static function register_goals_funnels_header_hooks(): void
3462 {
3463 add_filter('slimstat_report_header_buttons', [self::class, 'inject_goals_funnels_header_actions'], 20, 2);
3464 add_filter('slimstat_report_header_after_title', [self::class, 'inject_goals_funnels_header_subtitle'], 10, 2);
3465 }
3466
3467 /**
3468 * Prepends the Goals/Funnels usage pill + "+ Add" CTA into the
3469 * .slimstat-header-buttons container so they sit on the LEFT side.
3470 * Empty for the Free locked Funnels branch (render helper returns '').
3471 */
3472 public static function inject_goals_funnels_header_actions($_header_buttons = '', $_report_id = '')
3473 {
3474 if ('slim_p9_01' === $_report_id) {
3475 $actions = \wp_slimstat_reports::render_goals_card_actions();
3476 return $actions . $_header_buttons;
3477 }
3478 if ('slim_p9_02' === $_report_id) {
3479 $actions = \wp_slimstat_reports::render_funnels_card_actions();
3480 return $actions . $_header_buttons;
3481 }
3482 return $_header_buttons;
3483 }
3484
3485 /**
3486 * Renders the Goals/Funnels card subtitle directly under the postbox <h3>.
3487 * Strings match the previous in-card markup so existing translations carry over.
3488 */
3489 public static function inject_goals_funnels_header_subtitle($_html = '', $_report_id = '')
3490 {
3491 if ('slim_p9_01' === $_report_id) {
3492 return '<p class="slimstat-gf-postbox-subtitle">' . esc_html__('A Goal is one question you ask of your traffic.', 'wp-slimstat') . '</p>';
3493 }
3494 if ('slim_p9_02' === $_report_id) {
3495 return '<p class="slimstat-gf-postbox-subtitle">' . 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') . '</p>';
3496 }
3497 return $_html;
3498 }
3499
3500 public static function add_header()
3501 {
3502 if (isset($_GET['page']) && ('slimlayout' === $_GET['page'] || 'slimconfig' === $_GET['page'])) {
3503 return self::get_template('header', ['is_pro' => wp_slimstat::pro_is_installed()]);
3504 }
3505
3506 return null;
3507 }
3508
3509 /**
3510 * Index definitions for all AJAX-managed database indexes.
3511 * Each entry maps an AJAX action (nonce) to its index metadata.
3512 */
3513 private static function get_index_definitions(): array
3514 {
3515 $prefix = $GLOBALS['wpdb']->prefix;
3516 return [
3517 'slimstat_add_country_dt_index' => [
3518 'name' => 'idx_country_dt',
3519 'columns' => 'country, dt',
3520 'option' => 'slimstat_country_dt_indexed',
3521 ],
3522 'slimstat_add_dt_screen_index' => [
3523 'name' => 'idx_dt_screen_width_screen_height',
3524 'columns' => 'dt, screen_width, screen_height',
3525 'option' => 'slimstat_dt_screen_indexed',
3526 ],
3527 'slimstat_add_dt_browser_index' => [
3528 'name' => 'idx_dt_browser_browser_version',
3529 'columns' => 'dt, browser, browser_version',
3530 'option' => 'slimstat_dt_browser_indexed',
3531 ],
3532 'slimstat_add_dt_platform_index' => [
3533 'name' => 'idx_dt_platform',
3534 'columns' => 'dt, platform',
3535 'option' => 'slimstat_dt_platform_indexed',
3536 ],
3537 'slimstat_add_dt_out_index' => [
3538 'name' => 'idx_dt_out',
3539 'columns' => 'dt_out',
3540 'option' => 'slimstat_dt_out_indexed',
3541 ],
3542 'slimstat_add_dt_visit_index' => [
3543 'name' => $prefix . 'stats_dt_visit_idx',
3544 'columns' => 'dt, visit_id',
3545 'option' => 'slimstat_dt_visit_indexed',
3546 ],
3547 ];
3548 }
3549
3550 /**
3551 * Generic AJAX handler for ensuring a database index exists.
3552 */
3553 private static function ajax_ensure_index(string $nonce, string $index_name, string $columns, string $option_key): void
3554 {
3555 check_ajax_referer($nonce);
3556 if (!current_user_can('manage_options')) {
3557 wp_send_json_error(__('Insufficient permissions.', 'wp-slimstat'));
3558 }
3559 $wpdb = wp_slimstat::$wpdb;
3560 $table = $GLOBALS['wpdb']->prefix . 'slim_stats';
3561 $exists = $wpdb->get_results(sprintf("SHOW INDEX FROM %s WHERE Key_name = '%s'", $table, $index_name));
3562 if (!empty($exists)) {
3563 update_option($option_key, 'yes');
3564 wp_send_json_success(__('Index already exists.', 'wp-slimstat'));
3565 }
3566 $result = $wpdb->query(sprintf('CREATE INDEX %s ON %s (%s)', $index_name, $table, $columns));
3567 if (false !== $result) {
3568 update_option($option_key, 'yes');
3569 wp_send_json_success(__('Index added successfully.', 'wp-slimstat'));
3570 }
3571 wp_send_json_error(__('Unable to add index.', 'wp-slimstat'));
3572 }
3573
3574 /**
3575 * Register AJAX hooks for all index management actions.
3576 */
3577 public static function register_index_hooks(): void
3578 {
3579 foreach (self::get_index_definitions() as $action => $def) {
3580 add_action('wp_ajax_' . $action, function () use ($action, $def) {
3581 self::ajax_ensure_index($action, $def['name'], $def['columns'], $def['option']);
3582 });
3583 }
3584 }
3585
3586 public static function show_indexes_notice()
3587 {
3588 // If new migration system is active, suppress legacy performance notice
3589 if (class_exists(\SlimStat\Migration\Admin\MigrationAdmin::class)) {
3590 return;
3591 }
3592
3593 if (!current_user_can('manage_options')) {
3594 return;
3595 }
3596 $indexes = [
3597 [
3598 'option' => 'slimstat_dt_out_indexed',
3599 'id' => 'dt-out',
3600 'label' => __('Currently Online Reports', 'wp-slimstat'),
3601 'desc' => __('Index on <code>dt_out</code>', 'wp-slimstat'),
3602 'key' => 'idx_dt_out',
3603 'ajax' => 'slimstat_add_dt_out_index',
3604 'btn' => __('Apply', 'wp-slimstat'),
3605 ],
3606 [
3607 'option' => 'slimstat_country_dt_indexed',
3608 'id' => 'country-dt',
3609 'label' => __('World Map & Country Reports', 'wp-slimstat'),
3610 'desc' => __('Index on <code>country</code> and <code>dt</code>', 'wp-slimstat'),
3611 'key' => 'idx_country_dt',
3612 'ajax' => 'slimstat_add_country_dt_index',
3613 'btn' => __('Apply', 'wp-slimstat'),
3614 ],
3615 [
3616 'option' => 'slimstat_dt_screen_indexed',
3617 'id' => 'dt-screen',
3618 'label' => __('Screen Resolution Reports', 'wp-slimstat'),
3619 'desc' => __('Index on <code>dt</code>, <code>screen_width</code>, <code>screen_height</code>', 'wp-slimstat'),
3620 'key' => 'idx_dt_screen_width_screen_height',
3621 'ajax' => 'slimstat_add_dt_screen_index',
3622 'btn' => __('Apply', 'wp-slimstat'),
3623 ],
3624 [
3625 'option' => 'slimstat_dt_browser_indexed',
3626 'id' => 'dt-browser',
3627 'label' => __('Browser Reports', 'wp-slimstat'),
3628 'desc' => __('Index on <code>dt</code>, <code>browser</code>, <code>browser_version</code>', 'wp-slimstat'),
3629 'key' => 'idx_dt_browser_browser_version',
3630 'ajax' => 'slimstat_add_dt_browser_index',
3631 'btn' => __('Apply', 'wp-slimstat'),
3632 ],
3633 [
3634 'option' => 'slimstat_dt_platform_indexed',
3635 'id' => 'dt-platform',
3636 'label' => __('Platform Reports', 'wp-slimstat'),
3637 'desc' => __('Index on <code>dt</code>, <code>platform</code>', 'wp-slimstat'),
3638 'key' => 'idx_dt_platform',
3639 'ajax' => 'slimstat_add_dt_platform_index',
3640 'btn' => __('Apply', 'wp-slimstat'),
3641 ],
3642 [
3643 'option' => 'slimstat_dt_visit_indexed',
3644 'id' => 'dt-visit',
3645 'label' => __('Visitor Counter Performance', 'wp-slimstat'),
3646 'desc' => __('Index on <code>dt</code>, <code>visit_id</code>', 'wp-slimstat'),
3647 'key' => $GLOBALS['wpdb']->prefix . 'stats_dt_visit_idx',
3648 'ajax' => 'slimstat_add_dt_visit_index',
3649 'btn' => __('Apply', 'wp-slimstat'),
3650 ],
3651 ];
3652
3653 $pending = array_filter($indexes, function ($idx) {
3654 $db = wp_slimstat::$wpdb;
3655 $exists = $db->get_results(sprintf("SHOW INDEX FROM %sslim_stats WHERE Key_name = '%s'", $GLOBALS['wpdb']->prefix, $idx['key']));
3656 return empty($exists);
3657 });
3658 if ([] === $pending) {
3659 return;
3660 }
3661 $ajax_url = admin_url('admin-ajax.php');
3662
3663 // Generate nonces for each AJAX action
3664 $nonces = [];
3665 foreach ($pending as $idx) {
3666 $nonces[$idx['ajax']] = wp_create_nonce($idx['ajax']);
3667 }
3668
3669 echo '<div class="notice slimstat-indexes-notice slimstat-notice" style="border-left: 6px solid #0073aa; background: #fff; box-shadow: 0 2px 8px #0001; padding: 24px 24px 16px 24px; margin-bottom: 24px; position: relative; min-width: 400px; max-width: 700px;">';
3670 echo '<h2 style="margin-top:0; font-size:1.3em; color:#0073aa;">' . __('Improve SlimStat Report Performance', 'wp-slimstat') . '</h2>';
3671 echo '<p style="margin-bottom:18px;">' . __('To speed up SlimStat reports, please apply the following database optimizations. These changes are safe and will not affect your data.', 'wp-slimstat') . '</p>';
3672 echo '<ul id="slimstat-index-list" style="list-style:none; margin:0 0 18px 0; padding:0;">';
3673 foreach ($pending as $idx) {
3674 echo '<li id="slimstat-index-' . $idx['id'] . '" style="margin-bottom:12px; display:flex; align-items:center;">'
3675 . '<div style="flex:1 1 0;">'
3676 . '<div class="slimstat-index-label" style="font-weight:600;">' . $idx['label'] . '</div>'
3677 . '<div class="slimstat-index-desc" style="color:#666; font-size:0.97em; margin-top:2px;">' . $idx['desc'] . '</div>'
3678 . '</div>'
3679 . '<span class="slimstat-index-lamp" style="margin-left:18px; min-width:30px; display:inline-block; font-size:1.5em; vertical-align:middle;">'
3680 . '<span class="dashicons dashicons-lightbulb" style="color:#ccc;"></span>'
3681 . '</span>'
3682 . '<span class="slimstat-index-status" style="margin-left:10px; min-width:120px; display:inline-block;"></span>'
3683 . '</li>';
3684 }
3685 echo '</ul>';
3686 echo '<div id="slimstat-index-progress-bar" style="height:8px; background:#e5e5e5; border-radius:4px; overflow:hidden; margin-bottom:10px;">'
3687 . '<div id="slimstat-index-progress" style="height:100%; width:0; background:linear-gradient(90deg,#0073aa,#00c3aa); transition:width 0.4s;"></div>'
3688 . '</div>';
3689 echo '<button class="button button-primary" id="slimstat-apply-all" style="margin-bottom:10px; min-width:120px; font-size:1.1em;">' . __('Apply All', 'wp-slimstat') . '</button>';
3690 echo '<div style="color:#888; font-size:0.95em;">' . __('Do not close this tab until all optimizations are complete.', 'wp-slimstat') . '</div>';
3691 echo '</div>';
3692 ?>
3693 <script>
3694 jQuery(function($){
3695 var indexes = <?php echo wp_json_encode(array_values($pending)); ?>;
3696 var nonces = <?php echo wp_json_encode($nonces); ?>;
3697 var total = indexes.length, done = 0;
3698 function updateProgress() {
3699 var percent = Math.round((done/total)*100);
3700 $('#slimstat-index-progress').css('width', percent+'%');
3701 if (done === total) setTimeout(function(){ $('.slimstat-indexes-notice').fadeOut(); }, 2000);
3702 }
3703 function markDone(id) {
3704 var lamp = $('#slimstat-index-'+id+' .slimstat-index-lamp .dashicons');
3705 lamp.css('color','#ffc107'); // yellow lamp
3706 lamp.addClass('slimstat-lamp-on');
3707 $('#slimstat-index-'+id).css('opacity',0.9);
3708 }
3709 $('#slimstat-apply-all').on('click', function(e){
3710 e.preventDefault();
3711 var btn = $(this);
3712 btn.prop('disabled', true);
3713 window.onbeforeunload = function(){ return '<?php echo esc_js(__('Please wait for SlimStat optimizations to finish.', 'wp-slimstat')); ?>'; };
3714 function next(i) {
3715 if (i >= indexes.length) {
3716 window.onbeforeunload = null;
3717 return;
3718 }
3719 var idx = indexes[i];
3720 var li = $('#slimstat-index-'+idx.id);
3721 li.find('.slimstat-index-status').html('<span style="color:#0073aa;">' + '<?php echo esc_js(__('In progress...', 'wp-slimstat')); ?>' + '</span> <span class="spinner is-active" style="float:none;display:inline-block;vertical-align:middle;"></span>');
3722 $.post('<?php echo $ajax_url; ?>', {
3723 action: idx.ajax,
3724 _ajax_nonce: nonces[idx.ajax]
3725 }, function(response){
3726 if (response.success) {
3727 markDone(idx.id);
3728 done++;
3729 li.find('.slimstat-index-status').html('<span style="color:green;">' + '<?php echo esc_js(__('Done!', 'wp-slimstat')); ?>' + '</span>');
3730 updateProgress();
3731 next(i+1);
3732 } else {
3733 li.find('.slimstat-index-status').html('<span style="color:red;">' + '<?php echo esc_js(__('Error: ', 'wp-slimstat')); ?>' + '</span>' + (response.data || ''));
3734 btn.prop('disabled', false);
3735 window.onbeforeunload = null;
3736 }
3737 });
3738 }
3739 next(0);
3740 });
3741 });
3742 </script>
3743 <?php
3744 }
3745
3746 }
3747 // END: class declaration
3748