PluginProbe
BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP / trunk
BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP vtrunk
3.1.3 3.1.2 3.1.1 3.1.0 3.0.1 3.0.0 2.4.13 2.4.12 2.4.11 2.4.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 110 releases
betterlinks / includes / Traits / Clicks.php

Clicks.php in BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP trunk, at includes/Traits/Clicks.php

596 lines 23.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace BetterLinks\Traits;
3 if ( ! defined( 'ABSPATH' ) ) { exit; }
4
5 use BetterLinks\Helper;
6
7 // phpcs:disable PluginCheck.Security.DirectDB, WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL
8
9 trait Clicks {
10 private static $transient_timeout = MINUTE_IN_SECONDS * 30; // 30 MINUTES
11
12 /**
13 * Get Clicks data within a certain time limit
14 *
15 * @param string $from The start time.
16 * @param string $to The end time.
17 *
18 * @return array
19 */
20 public function get_clicks_data( $from, $to ) {
21 $results = \BetterLinks\Helper::get_clicks_by_date( $from, $to );
22 return $results;
23 }
24
25 /**
26 * Get transient key for cached analytics data
27 *
28 * @param string $key The unique identifier for the transient key
29 * @param string $from The start time.
30 * @param string $to The end time.
31 * @param string|int $id Clicks id.
32 *
33 * @return string
34 */
35 private static function get_transient_key( $key, $from, $to, $id = null ) {
36 $transient_key = str_replace( '-', '_', $from ) . '_' . str_replace( '-', '_', $to );
37 if ( $id ) {
38 $transient_key .= '_' . $id;
39 }
40 return $key . $transient_key;
41 }
42
43 /**
44 * Get Analytics Graph Data
45 *
46 * @param $from The start time.
47 * @param $to The end time.
48 *
49 * @return array Array of total unique clicks and total clicks.
50 */
51 public function get_analytics_graph_data( $from, $to ) {
52 $transient_key = self::get_transient_key( 'btl_analytics_graph_', $from, $to );
53 if ( $results = get_transient( $transient_key ) ) {
54 return $results;
55 }
56
57 global $wpdb;
58
59 // Get excluded IPs and build safe query
60 $options = json_decode( get_option( BETTERLINKS_LINKS_OPTION_NAME ), true );
61 $excluded_ips = isset( $options['excluded_ips'] ) && is_array( $options['excluded_ips'] ) ? $options['excluded_ips'] : array();
62
63 $query_params = array( $from . ' 00:00:00', $to . ' 23:59:59' );
64 $where_conditions = array( 'created_at BETWEEN %s AND %s' );
65
66 if ( ! empty( $excluded_ips ) ) {
67 $placeholders = implode( ', ', array_fill( 0, count( $excluded_ips ), '%s' ) );
68 $where_conditions[] = "ip NOT IN ({$placeholders})";
69 $query_params = array_merge( $query_params, $excluded_ips );
70 }
71
72 $where_clause = 'WHERE ' . implode( ' AND ', $where_conditions );
73
74 // Total counts query
75 $total_query = "SELECT count(id) as click_count, DATE(created_at) as c_date FROM {$wpdb->prefix}betterlinks_clicks
76 {$where_clause} GROUP BY c_date ORDER BY c_date DESC";
77 $total_counts = $wpdb->get_results( $wpdb->prepare( $total_query, $query_params ), ARRAY_A );
78
79 // Unique counts query - use same params twice for subquery
80 $unique_query_params = array_merge( $query_params, $query_params );
81 $unique_query = "SELECT count(ip) as uniq_count, T1.c_date from ( SELECT ip, DATE( created_at ) as c_date FROM {$wpdb->prefix}betterlinks_clicks
82 {$where_clause} GROUP BY `ip`, `c_date` ) as T1 GROUP BY T1.c_date ORDER BY T1.c_date DESC";
83 $unique_counts = $wpdb->get_results( $wpdb->prepare( $unique_query, $unique_query_params ), ARRAY_A );
84
85 $results = array(
86 'total_count' => $total_counts,
87 'unique_count' => $unique_counts,
88 );
89 set_transient( $transient_key, $results, self::$transient_timeout );
90 return $results;
91 }
92
93 /**
94 * Clicks bucketed by weekday and hour, for the Timing heatmap.
95 *
96 * One row per (weekday, hour) bucket that had at least one click, with both
97 * the total click count and the distinct-visitor count. `WEEKDAY()` returns
98 * 0=Monday..6=Sunday, which matches the Mon-first grid, and `HOUR()` returns
99 * 0..23. Empty buckets are simply absent — the client fills the full grid.
100 *
101 * Uses `created_at` (site-local, like the graph aggregate) so an "18:00"
102 * bucket reads as 6pm locally rather than in UTC.
103 *
104 * @param string $from Start date (Y-m-d).
105 * @param string $to End date (Y-m-d).
106 * @return array Rows of { dow, hr, clicks, unique_clicks }.
107 */
108 public function get_analytics_timing_data( $from, $to ) {
109 $transient_key = self::get_transient_key( 'btl_analytics_timing_', $from, $to );
110 if ( $results = get_transient( $transient_key ) ) {
111 return $results;
112 }
113
114 global $wpdb;
115
116 $options = json_decode( get_option( BETTERLINKS_LINKS_OPTION_NAME ), true );
117 $excluded_ips = isset( $options['excluded_ips'] ) && is_array( $options['excluded_ips'] ) ? $options['excluded_ips'] : array();
118
119 $query_params = array( $from . ' 00:00:00', $to . ' 23:59:59' );
120 $where_conditions = array( 'created_at BETWEEN %s AND %s' );
121
122 if ( ! empty( $excluded_ips ) ) {
123 $placeholders = implode( ', ', array_fill( 0, count( $excluded_ips ), '%s' ) );
124 $where_conditions[] = "ip NOT IN ({$placeholders})";
125 $query_params = array_merge( $query_params, $excluded_ips );
126 }
127
128 $where_clause = 'WHERE ' . implode( ' AND ', $where_conditions );
129
130 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
131 $query = "SELECT WEEKDAY(created_at) as dow, HOUR(created_at) as hr, COUNT(id) as clicks, COUNT(DISTINCT ip) as unique_clicks
132 FROM {$wpdb->prefix}betterlinks_clicks {$where_clause} GROUP BY dow, hr";
133 $rows = $wpdb->get_results( $wpdb->prepare( $query, $query_params ), ARRAY_A );
134 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
135
136 $results = $rows ? $rows : array();
137 set_transient( $transient_key, $results, self::$transient_timeout );
138 return $results;
139 }
140
141 /**
142 * Audience composition for the range: human vs bot, and new vs returning.
143 *
144 * Both splits read columns that were only filled in from the release that
145 * added this report, so each half reports how many rows it could actually
146 * classify (`tracked`) alongside the counts. Clicks older than that have no
147 * bot flag and no visitor id; they are counted in `untracked` and the client
148 * shows the split as unavailable when nothing is classifiable.
149 *
150 * - bot: `bot_name` is non-empty only for detected bots.
151 * - visitors: `click_order` is 1 on a visitor's first tracked click.
152 * Distinct visitors are counted, not clicks, so one person browsing ten
153 * links is one returning visitor rather than ten.
154 *
155 * @param string $from The start date (Y-m-d).
156 * @param string $to The end date (Y-m-d).
157 *
158 * @return array {
159 * @type array $bot { @type bool $tracked, @type int $human, @type int $bot, @type int $untracked }
160 * @type array $visitors { @type bool $tracked, @type int $new, @type int $returning, @type int $untracked }
161 * }
162 */
163 public function get_analytics_audience_data( $from, $to ) {
164 $transient_key = self::get_transient_key( 'btl_analytics_audience_', $from, $to );
165 if ( $results = get_transient( $transient_key ) ) {
166 return $results;
167 }
168
169 global $wpdb;
170
171 $options = json_decode( get_option( BETTERLINKS_LINKS_OPTION_NAME ), true );
172 $excluded_ips = isset( $options['excluded_ips'] ) && is_array( $options['excluded_ips'] ) ? $options['excluded_ips'] : array();
173
174 $query_params = array( $from . ' 00:00:00', $to . ' 23:59:59' );
175 $where_conditions = array( 'created_at BETWEEN %s AND %s' );
176
177 if ( ! empty( $excluded_ips ) ) {
178 $placeholders = implode( ', ', array_fill( 0, count( $excluded_ips ), '%s' ) );
179 $where_conditions[] = "ip NOT IN ({$placeholders})";
180 $query_params = array_merge( $query_params, $excluded_ips );
181 }
182
183 $where_clause = 'WHERE ' . implode( ' AND ', $where_conditions );
184 $clicks_table = $wpdb->prefix . 'betterlinks_clicks';
185 $bot_supported = \BetterLinks\Helper::has_bot_name_column();
186
187 // Human vs bot, counted in clicks. Rows predating bot tracking have a
188 // NULL bot_name and cannot be attributed either way.
189 $bot = array(
190 'tracked' => false,
191 'human' => 0,
192 'bot' => 0,
193 'untracked' => 0,
194 );
195
196 if ( $bot_supported ) {
197 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
198 $bot_query = "SELECT
199 SUM( CASE WHEN bot_name IS NOT NULL AND bot_name <> '' THEN 1 ELSE 0 END ) AS bots,
200 SUM( CASE WHEN bot_name = '' THEN 1 ELSE 0 END ) AS humans,
201 SUM( CASE WHEN bot_name IS NULL THEN 1 ELSE 0 END ) AS untracked
202 FROM {$clicks_table} {$where_clause}";
203 $bot_row = $wpdb->get_row( $wpdb->prepare( $bot_query, $query_params ), ARRAY_A );
204 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
205
206 if ( $bot_row ) {
207 $bot['bot'] = (int) $bot_row['bots'];
208 $bot['human'] = (int) $bot_row['humans'];
209 $bot['untracked'] = (int) $bot_row['untracked'];
210 $bot['tracked'] = ( $bot['bot'] + $bot['human'] ) > 0;
211 }
212 }
213
214 // New vs returning, counted in distinct visitors. click_order is 1 on a
215 // visitor's first click and 2 on later ones; 0 means the click predates
216 // visitor tracking and cannot be classified either way.
217 //
218 // Each visitor is bucketed by their EARLIEST click in the range, so
219 // someone who arrives and comes back inside the same range counts once,
220 // as new — taking the rows at face value would count them in both halves.
221 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
222 $visitor_query = "SELECT
223 SUM( CASE WHEN first_order = 1 THEN 1 ELSE 0 END ) AS new_visitors,
224 SUM( CASE WHEN first_order = 2 THEN 1 ELSE 0 END ) AS returning_visitors
225 FROM (
226 SELECT visitor_id, MIN( click_order ) AS first_order
227 FROM {$clicks_table} {$where_clause} AND visitor_id <> '' AND click_order > 0
228 GROUP BY visitor_id
229 ) AS v";
230 $visitor_row = $wpdb->get_row( $wpdb->prepare( $visitor_query, $query_params ), ARRAY_A );
231
232 $untracked_query = "SELECT COUNT(*) FROM {$clicks_table} {$where_clause}
233 AND ( visitor_id IS NULL OR visitor_id = '' OR click_order = 0 )";
234 $untracked_count = (int) $wpdb->get_var( $wpdb->prepare( $untracked_query, $query_params ) );
235 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
236
237 $visitors = array(
238 'tracked' => false,
239 'new' => 0,
240 'returning' => 0,
241 'untracked' => 0,
242 );
243
244 $visitors['untracked'] = $untracked_count;
245
246 if ( $visitor_row ) {
247 $visitors['new'] = (int) $visitor_row['new_visitors'];
248 $visitors['returning'] = (int) $visitor_row['returning_visitors'];
249 $visitors['tracked'] = ( $visitors['new'] + $visitors['returning'] ) > 0;
250 }
251
252 $results = array(
253 'bot' => $bot,
254 'visitors' => $visitors,
255 );
256
257 set_transient( $transient_key, $results, self::$transient_timeout );
258 return $results;
259 }
260
261 /**
262 * Get Analytics Graph Data by Tag ID
263 *
264 * @param $from The start time.
265 * @param $to The end time.
266 * @param $tag_id The Tag ID.
267 *
268 * @return array Array of total unique clicks and total clicks.
269 */
270 public function get_analytics_graph_data_by_tag( $from, $to, $tag_id ) {
271 $transient_key = self::get_transient_key( 'btl_analytics_graph_by_tag_', $from, $to, $tag_id );
272 if ( $results = get_transient( $transient_key ) ) {
273 return $results;
274 }
275
276 global $wpdb;
277
278 // Get excluded IPs and build safe query parameters
279 $options = json_decode( get_option( BETTERLINKS_LINKS_OPTION_NAME ), true );
280 $excluded_ips = isset( $options['excluded_ips'] ) && is_array( $options['excluded_ips'] ) ? $options['excluded_ips'] : array();
281
282 $base_params = array( $tag_id, "{$from} 00:00:00", "{$to} 23:59:59" );
283 $where_conditions = array( "t.term_type='tags'", "t.id=%d", "c.created_at BETWEEN %s AND %s" );
284
285 if ( ! empty( $excluded_ips ) ) {
286 $placeholders = implode( ', ', array_fill( 0, count( $excluded_ips ), '%s' ) );
287 $where_conditions[] = "c.ip NOT IN ({$placeholders})";
288 $base_params = array_merge( $base_params, $excluded_ips );
289 }
290
291 $where_clause = implode( ' AND ', $where_conditions );
292
293 // Total counts query
294 $total_query = "SELECT COUNT(c.id) as click_count, DATE(c.created_at) AS c_date FROM {$wpdb->prefix}betterlinks_clicks c
295 LEFT JOIN {$wpdb->prefix}betterlinks_terms_relationships tr ON tr.link_id=c.link_id
296 LEFT JOIN {$wpdb->prefix}betterlinks_terms t ON tr.term_id=t.id
297 WHERE {$where_clause}
298 GROUP BY c_date ORDER BY c_date DESC";
299 $total_counts = $wpdb->get_results( $wpdb->prepare( $total_query, $base_params ), ARRAY_A );
300
301 // Unique counts query - duplicate params for subquery
302 $unique_params = array_merge( $base_params, $base_params );
303 $unique_query = "SELECT COUNT(ip) as uniq_count, T1.c_date FROM
304 ( SELECT ip, DATE( created_at ) AS c_date FROM {$wpdb->prefix}betterlinks_clicks c
305 LEFT JOIN {$wpdb->prefix}betterlinks_terms_relationships tr ON c.link_id=tr.link_id
306 LEFT JOIN {$wpdb->prefix}betterlinks_terms t ON tr.term_id=t.id
307 WHERE {$where_clause}
308 GROUP BY `ip`, `c_date` ) AS T1
309 GROUP BY T1.c_date ORDER BY T1.c_date DESC";
310 $unique_counts = $wpdb->get_results( $wpdb->prepare( $unique_query, $unique_params ), ARRAY_A );
311
312 $results = array(
313 'total_count' => $total_counts,
314 'unique_count' => $unique_counts,
315 );
316 set_transient( $transient_key, $results, self::$transient_timeout );
317 return $results;
318 }
319
320 /**
321 * Returns the unique analytics data by tag
322 *
323 * @return array Array of unique analytics by tag
324 */
325 public function get_analytics_unique_list_by_tag( $from, $to, $id ) {
326 $transient_key = self::get_transient_key( 'btl_analytics_unique_list_by_tag_', $from, $to, $id );
327 if ( $results = get_transient( $transient_key ) ) {
328 return $results;
329 }
330
331 global $wpdb;
332
333 $query = $wpdb->prepare(
334 "SELECT id as link_id, link_title, short_url, target_url from {$wpdb->prefix}betterlinks as links right join (select distinct link_id from {$wpdb->prefix}betterlinks_clicks where created_at between %s and %s) as clicks on clicks.link_id=links.id right join (select tr.link_id from {$wpdb->prefix}betterlinks_terms t left join {$wpdb->prefix}betterlinks_terms_relationships tr on t.ID=tr.term_id where t.term_type='tags' and t.ID=%s) tl on links.id=tl.link_id where id!=''",
335 $from . ' 00:00:00',
336 $to . ' 23:59:59',
337 $id
338 );
339 $results = $wpdb->get_results( $query, ARRAY_A );
340
341 set_transient( $transient_key, $results, self::$transient_timeout );
342 return $results;
343 }
344
345 /**
346 * Returns the unique analytics clicks
347 *
348 * @return array Array of unique analytics
349 */
350 public function get_analytics_unique_list( $from, $to ) {
351 $transient_key = self::get_transient_key( 'btl_analytics_unique_list_', $from, $to );
352 if ( $results = get_transient( $transient_key ) ) {
353 return $results;
354 }
355 global $wpdb;
356
357 $query = $wpdb->prepare(
358 "SELECT id as link_id, link_title, short_url, target_url from {$wpdb->prefix}betterlinks as links right join (select distinct link_id from {$wpdb->prefix}betterlinks_clicks where created_at between %s and %s) as clicks on clicks.link_id=links.id order by links.id desc",
359 $from . ' 00:00:00',
360 $to . ' 23:59:59',
361 );
362
363 $results = $wpdb->get_results( $query, ARRAY_A );
364
365 set_transient( $transient_key, $results, self::$transient_timeout );
366 return $results;
367 }
368
369 /**
370 * Returns individual analytics clicks within a time limit
371 *
372 * @param int|string $id Clicks id.
373 * @param string $from The start time.
374 * @param string $to The end time.
375 *
376 * @return array Array of individual analytics clicks.
377 */
378 public function get_individual_analytics_clicks( $id, $from, $to ) {
379 $transient_key = self::get_transient_key( 'btl_individual_analytics_clicks_', $from, $to, $id );
380 if ( $results = get_transient( $transient_key ) ) {
381 return $results;
382 }
383 global $wpdb;
384
385 $clicks_table = $wpdb->prefix . 'betterlinks_clicks';
386 $countries_table = $wpdb->prefix . 'betterlinks_countries';
387
388 // Get excluded IPs and build safe query parameters
389 $options = json_decode( get_option( BETTERLINKS_LINKS_OPTION_NAME ), true );
390 $excluded_ips = isset( $options['excluded_ips'] ) && is_array( $options['excluded_ips'] ) ? $options['excluded_ips'] : array();
391
392 $base_params = array( $id, $from . ' 00:00:00', $to . ' 23:59:59' );
393 $where_conditions = array( 'c.link_id=%d', 'c.created_at BETWEEN %s AND %s' );
394
395 if ( ! empty( $excluded_ips ) ) {
396 $placeholders = implode( ', ', array_fill( 0, count( $excluded_ips ), '%s' ) );
397 $where_conditions[] = "c.ip NOT IN ({$placeholders})";
398 $base_params = array_merge( $base_params, $excluded_ips );
399 }
400
401 $where_clause = implode( ' AND ', $where_conditions );
402
403 // Check if extra data tracking (including country data) is enabled
404 $is_extra_data_tracking_compatible = apply_filters( 'betterlinks/is_extra_data_tracking_compatible', false );
405
406 // Check if user_agents table exists
407 $user_agents_table = $wpdb->prefix . 'betterlinks_user_agents';
408 $user_agents_table_exists = $wpdb->get_var(
409 $wpdb->prepare(
410 "SHOW TABLES LIKE %s",
411 $user_agents_table
412 )
413 );
414
415 if ( $is_extra_data_tracking_compatible ) {
416 // Use normalized schema with JOIN to countries table (Pro version)
417 if ( $user_agents_table_exists ) {
418 $query_sql = "SELECT c.ID, c.link_id, c.ip, c.browser, c.referer, c.os, c.device, c.query_params, c.created_at,
419 co.country_code, co.country_name, ua.user_agent
420 FROM {$clicks_table} c
421 LEFT JOIN {$countries_table} co ON c.country_id = co.id
422 LEFT JOIN {$user_agents_table} ua ON c.user_agent_id = ua.id
423 WHERE {$where_clause}
424 ORDER BY c.created_at DESC";
425 $query = $wpdb->prepare( $query_sql, $base_params );
426 } else {
427 $query_sql = "SELECT c.ID, c.link_id, c.ip, c.browser, c.referer, c.os, c.device, c.query_params, c.created_at,
428 co.country_code, co.country_name, NULL as user_agent
429 FROM {$clicks_table} c
430 LEFT JOIN {$countries_table} co ON c.country_id = co.id
431 WHERE {$where_clause}
432 ORDER BY c.created_at DESC";
433 $query = $wpdb->prepare( $query_sql, $base_params );
434 }
435 } else {
436 // Basic query without country data (Free version)
437 if ( $user_agents_table_exists ) {
438 $query_sql = "SELECT c.ID, c.link_id, c.ip, c.browser, c.referer, c.created_at, ua.user_agent
439 FROM {$clicks_table} c
440 LEFT JOIN {$user_agents_table} ua ON c.user_agent_id = ua.id
441 WHERE {$where_clause}
442 ORDER BY c.created_at DESC";
443 $query = $wpdb->prepare( $query_sql, $base_params );
444 } else {
445 $query_sql = "SELECT c.ID, c.link_id, c.ip, c.browser, c.referer, c.created_at, NULL as user_agent
446 FROM {$clicks_table} c
447 WHERE {$where_clause}
448 ORDER BY c.created_at DESC";
449 $query = $wpdb->prepare( $query_sql, $base_params );
450 }
451 }
452 $results = $wpdb->get_results( $query, ARRAY_A );
453
454 // Ensure we always return an array, even if empty
455 if ( ! is_array( $results ) ) {
456 $results = array();
457 }
458
459 set_transient( $transient_key, $results, self::$transient_timeout );
460 return $results;
461 }
462
463 /**
464 * Daily clicks series for ONE link — the same aggregate as
465 * `get_analytics_graph_data()`, scoped to a single `link_id`.
466 *
467 * This is a plain COUNT/GROUP BY over the clicks table, so it needs no
468 * extra-data tracking and belongs in free: the single-link overview reads its
469 * "Total clicks" tile and its clicks-over-time chart from this, and without it
470 * both read zero while the click log right below them lists the very rows the
471 * count is missing.
472 *
473 * @param int|string $id Link id.
474 * @param string $from The start time.
475 * @param string $to The end time.
476 *
477 * @return array { total_count: rows of { click_count, c_date }, unique_count: rows of { uniq_count, c_date } }
478 */
479 public function get_individual_graph_data( $id, $from, $to ) {
480 $transient_key = self::get_transient_key( 'btl_individual_graph_data_', $from, $to, $id );
481 if ( $results = get_transient( $transient_key ) ) {
482 return $results;
483 }
484
485 global $wpdb;
486
487 // Get excluded IPs and build safe query parameters
488 $options = json_decode( get_option( BETTERLINKS_LINKS_OPTION_NAME ), true );
489 $excluded_ips = isset( $options['excluded_ips'] ) && is_array( $options['excluded_ips'] ) ? $options['excluded_ips'] : array();
490
491 $query_params = array( $id, $from . ' 00:00:00', $to . ' 23:59:59' );
492 $where_conditions = array( 'link_id=%d', 'created_at BETWEEN %s AND %s' );
493
494 if ( ! empty( $excluded_ips ) ) {
495 $placeholders = implode( ', ', array_fill( 0, count( $excluded_ips ), '%s' ) );
496 $where_conditions[] = "ip NOT IN ({$placeholders})";
497 $query_params = array_merge( $query_params, $excluded_ips );
498 }
499
500 $where_clause = 'WHERE ' . implode( ' AND ', $where_conditions );
501
502 $total_query = "SELECT count(id) as click_count, DATE(created_at) as c_date FROM {$wpdb->prefix}betterlinks_clicks
503 {$where_clause} GROUP BY c_date ORDER BY c_date DESC";
504 $total_counts = $wpdb->get_results( $wpdb->prepare( $total_query, $query_params ), ARRAY_A );
505
506 // Unique counts query - the where clause sits in the subselect, so the same
507 // params are passed once, not twice.
508 $unique_query = "SELECT count(ip) as uniq_count, T1.c_date from ( SELECT ip, DATE( created_at ) as c_date FROM {$wpdb->prefix}betterlinks_clicks
509 {$where_clause} GROUP BY `ip`, `c_date` ) as T1 GROUP BY T1.c_date ORDER BY T1.c_date DESC";
510 $unique_counts = $wpdb->get_results( $wpdb->prepare( $unique_query, $query_params ), ARRAY_A );
511
512 $results = array(
513 'total_count' => is_array( $total_counts ) ? $total_counts : array(),
514 'unique_count' => is_array( $unique_counts ) ? $unique_counts : array(),
515 );
516 set_transient( $transient_key, $results, self::$transient_timeout );
517 return $results;
518 }
519
520 /**
521 * Returns individual link details
522 *
523 * @param int|string $id link id.
524 *
525 * @return Object Object of individual link details.
526 */
527 public function get_individual_link_details( $id ) {
528 global $wpdb;
529 $query = $wpdb->prepare(
530 "SELECT link_title, short_url, target_url FROM {$wpdb->prefix}betterlinks where id=%s",
531 $id
532 );
533 return $wpdb->get_row( $query );
534 }
535
536 private function sanitize_date( $date ){
537 if( empty( $date ) ){
538 return false;
539 }
540 $date = sanitize_text_field( $date );
541 return strtotime( $date );
542 }
543
544 /**
545 * Returns individual link details
546 *
547 * @param int|string $id link id.
548 *
549 * @return Object Object of individual link details.
550 */
551 public function get_unique_clicks_count($from, $to) {
552 $transient_key = self::get_transient_key( 'btl_unique_clicks_count_', $from, $to );
553 if ( $results = get_transient( $transient_key ) ) {
554 return $results;
555 }
556 global $wpdb;
557
558 // Get excluded IPs and build safe query
559 $options = json_decode( get_option( BETTERLINKS_LINKS_OPTION_NAME ), true );
560 $excluded_ips = isset( $options['excluded_ips'] ) && is_array( $options['excluded_ips'] ) ? $options['excluded_ips'] : array();
561
562 $query_params = array( $from . ' 00:00:00', $to . ' 23:59:59' );
563 $where_conditions = array( 'created_at BETWEEN %s AND %s' );
564
565 if ( ! empty( $excluded_ips ) ) {
566 $placeholders = implode( ', ', array_fill( 0, count( $excluded_ips ), '%s' ) );
567 $where_conditions[] = "ip NOT IN ({$placeholders})";
568 $query_params = array_merge( $query_params, $excluded_ips );
569 }
570
571 $where_clause = 'WHERE ' . implode( ' AND ', $where_conditions );
572
573 $query_sql = "SELECT COUNT( DISTINCT ip ) AS count FROM {$wpdb->prefix}betterlinks_clicks {$where_clause}";
574 $query = $wpdb->prepare( $query_sql, $query_params );
575 $results = $wpdb->get_row( $query, ARRAY_A );
576 // COUNT() normally always yields a row, but get_row() returns null on a
577 // query error (and on an empty result set), and current( null ) is a
578 // TypeError on PHP 8 — fatal behind the unique-clicks analytics card.
579 $results = is_array( $results ) ? current( $results ) : 0;
580 set_transient( $transient_key, $results, self::$transient_timeout );
581 return $results;
582 }
583
584 public function get_analytics_data($from, $to) {
585 $transient_key = self::get_transient_key( 'btl_analytics_data_', $from, $to );
586 if ( $results = get_transient( $transient_key ) ) {
587 return $results;
588 }
589
590 $results = Helper::merge_clicks_count( Helper::get_clicks_count( $from, $to ) );
591 $results = wp_json_encode( $results );
592 set_transient( $transient_key, $results, self::$transient_timeout );
593 return $results;
594 }
595 }
596