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 / src / Modules / Chart.php

Chart.php in SlimStat Analytics trunk, at src/Modules/Chart.php

674 lines 25.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace SlimStat\Modules;
4
5 // don't load directly.
6 if (! defined('ABSPATH')) {
7 header('Status: 403 Forbidden');
8 header('HTTP/1.1 403 Forbidden');
9 exit;
10 }
11
12 use SlimStat\Components\View;
13 use SlimStat\Helpers\DataBuckets;
14 use SlimStat\Utils\Query;
15
16 class Chart
17 {
18 public const DAY = 86400;
19
20 public const YEAR = 365 * self::DAY;
21
22 private const GRANULARITIES = ['yearly', 'monthly', 'weekly', 'daily', 'hourly'];
23
24 private const CHART_TYPES = ['line', 'bar'];
25
26 private array $args = [];
27
28 private array $data = [];
29
30 private array $prevData = [];
31
32 private array $chartLabels = [];
33
34 private array $translations = [];
35
36 public function showChart(array $args): void
37 {
38 $this->init($args);
39 $this->enqueueAssets();
40 $this->renderChart();
41 }
42
43 public static function ajaxFetchChartData()
44 {
45 check_ajax_referer('slimstat_chart_nonce', 'nonce');
46
47 // Additional capability check - users must be able to view stats
48 $minimum_capability = 'read';
49 if (!current_user_can($minimum_capability)) {
50 wp_send_json_error(['message' => __('Insufficient permissions', 'wp-slimstat')]);
51 }
52
53 $args = isset($_POST['args']) ? json_decode(stripslashes($_POST['args']), true) : [];
54 $granularity = isset($_POST['granularity']) ? sanitize_text_field($_POST['granularity']) : 'daily';
55
56 if (!in_array($granularity, ['yearly', 'monthly', 'weekly', 'daily', 'hourly'], true)) {
57 wp_send_json_error(['message' => __('Invalid granularity', 'wp-slimstat')]);
58 }
59
60 // Validate and sanitize start/end timestamps
61 if (isset($args['start'])) {
62 $args['start'] = absint($args['start']);
63 }
64 if (isset($args['end'])) {
65 $args['end'] = absint($args['end']);
66 }
67
68 if (!class_exists('\wp_slimstat_db')) {
69 include_once SLIMSTAT_DIR . '/admin/view/wp-slimstat-db.php';
70 \wp_slimstat_db::init();
71 }
72
73 // Restore filters from args if provided; validate column keys against known schema
74 if (!empty($args['filters']) && is_array($args['filters'])) {
75 $allowed_columns = array_keys(\wp_slimstat_db::$columns_names);
76 foreach ($args['filters'] as $col => $val) {
77 if (in_array($col, $allowed_columns, true)) {
78 \wp_slimstat_db::$filters_normalized['columns'][$col] = $val;
79 }
80 }
81 }
82
83 \wp_slimstat_db::$filters_normalized['utime']['start'] = $args['start'];
84 \wp_slimstat_db::$filters_normalized['utime']['end'] = $args['end'];
85 \wp_slimstat_db::$filters_normalized['utime']['range'] = $args['end'] - $args['start'];
86
87 try {
88 $chart = new self();
89 $args['granularity'] = $granularity;
90 $chart->init($args);
91 $totals = [
92 'current' => [
93 'v1' => (int) ($chart->data['totals'][0]->v1 ?? 0),
94 'v2' => (int) ($chart->data['totals'][0]->v2 ?? 0),
95 ],
96 'previous' => [
97 'v1' => (int) ($chart->data['totals'][1]->v1 ?? 0),
98 'v2' => (int) ($chart->data['totals'][1]->v2 ?? 0),
99 ],
100 ];
101 wp_send_json_success([
102 'args' => $chart->args,
103 'data' => $chart->data,
104 'totals' => $totals,
105 'prev_data' => $chart->prevData,
106 'chart_labels' => $chart->chartLabels,
107 'translations' => $chart->translations,
108 ]);
109 } catch (\Exception $exception) {
110 wp_send_json_error(['message' => $exception->getMessage()]);
111 }
112 }
113
114 protected function countDays(int $start, int $end): int
115 {
116 return max(1, intval(($end - $start) / self::DAY) + 1);
117 }
118
119 private function init(array $args): void
120 {
121 $normalized = $this->normalizeArgs($args);
122 $this->args = $normalized;
123 $this->data = $this->fetchChartData($normalized);
124 $this->prevData = $this->extractPreviousData($this->data);
125 $this->translations = [
126 'previous_period' => __('-- Previous Period', 'wp-slimstat'),
127 'previous_period_tooltip' => __('Click Tap “Previous Period” to hide or show the previous period line.', 'wp-slimstat'),
128 'today' => __('Today', 'wp-slimstat'),
129 '30_days_ago' => __('30 Days ago', 'wp-slimstat'),
130 'day_ago' => __('Day ago', 'wp-slimstat'),
131 'year_ago' => __('Year ago', 'wp-slimstat'),
132 'now' => __('Now', 'wp-slimstat'),
133 ];
134 $this->chartLabels = $this->args['chart_labels'] ?? array_keys($this->data['datasets']);
135 }
136
137 private function normalizeArgs(array $args): array
138 {
139 $defaults = [
140 'start' => \wp_slimstat_db::$filters_normalized['utime']['start'],
141 'end' => \wp_slimstat_db::$filters_normalized['utime']['end'],
142 'chart_type' => 'line',
143 ];
144 $args = array_merge($defaults, $args);
145
146 // Validate chart type
147 if (!in_array($args['chart_type'], self::CHART_TYPES, true)) {
148 $args['chart_type'] = 'line';
149 }
150
151 $args['granularity'] = $this->detectGranularity($args);
152 $args['rangeDays'] = $this->countDays($args['start'], $args['end']);
153
154 // Preserve active filters for AJAX requests
155 if (!isset($args['filters'])) {
156 $args['filters'] = \wp_slimstat_db::$filters_normalized['columns'] ?? [];
157 }
158
159 // Ensure chart_data is present with defaults
160 if (!isset($args['chart_data'])) {
161 $args['chart_data'] = [
162 'data1' => 'COUNT( ip )',
163 'data2' => 'COUNT( DISTINCT ip )',
164 ];
165 }
166
167 return $args;
168 }
169
170 private function detectGranularity(array $args): string
171 {
172 if (!empty($_REQUEST['granularity']) && in_array($_REQUEST['granularity'], self::GRANULARITIES, true)) {
173 return sanitize_text_field($_REQUEST['granularity']);
174 }
175
176 $diff = $args['end'] - $args['start'];
177
178 if ($diff > 1.5 * self::YEAR) {
179 return 'yearly';
180 }
181
182 if ($diff > 90 * self::DAY) {
183 return 'monthly';
184 }
185
186 if ($diff > 7 * self::DAY) {
187 return 'weekly';
188 }
189
190 if ($diff > 2 * self::DAY) {
191 return 'daily';
192 }
193
194 return 'hourly';
195 }
196
197 private function fetchChartData(array $args): array
198 {
199 $prevArgs = $this->calculatePreviousArgs($args);
200 $sqlInfo = $this->buildSql($args, $prevArgs);
201
202 // Allow caching only if both current and previous ranges end before today
203 $todayStart = strtotime(date('Y-m-d 00:00:00'));
204 $canCacheRanges = ($args['end'] < $todayStart && $prevArgs['end'] < $todayStart);
205
206 $rowsQuery = $sqlInfo['query'];
207 $totalsQuery = $sqlInfo['totalsQuery'];
208
209 if ($rowsQuery instanceof Query) {
210 $rowsQuery->allowCaching($canCacheRanges, DAY_IN_SECONDS);
211 }
212
213 if ($totalsQuery instanceof Query) {
214 $totalsQuery->allowCaching($canCacheRanges, DAY_IN_SECONDS);
215 }
216
217 $results = $rowsQuery instanceof Query ? $rowsQuery->getAll() : [];
218 $totals = $totalsQuery instanceof Query ? $totalsQuery->getAll() : [];
219
220 return $this->processResults(
221 $results,
222 $totals,
223 $sqlInfo['params'],
224 $args['start'],
225 $args['end'],
226 $prevArgs['start'],
227 $prevArgs['end']
228 );
229 }
230
231 private function calculatePreviousArgs(array $args): array
232 {
233 $rangeSeconds = $args['end'] - $args['start'];
234
235 \wp_timezone();
236 $dtStart = (new \DateTime())->setTimestamp($args['start']);
237 $dtEnd = (new \DateTime())->setTimestamp($args['end']);
238
239 $dtStart->modify(sprintf('-%s seconds', $rangeSeconds))->setTime(0, 0, 0);
240 $dtEnd->modify(sprintf('-%s seconds', $rangeSeconds));
241
242 return [
243 'start' => $dtStart->getTimestamp(),
244 'end' => $dtEnd->getTimestamp(),
245 ];
246 }
247
248 private function buildSql(array $args, array $prevArgs): array
249 {
250 $range = $args['end'] - $args['start'];
251
252 $common = [
253 'start' => $prevArgs['start'],
254 'end' => $prevArgs['end'],
255 'range' => $range,
256 ];
257
258 switch ($args['granularity']) {
259 case 'hourly':
260 return $this->sqlFor('HOUR', $args, $common);
261 case 'daily':
262 return $this->sqlFor('DAY', $args, $common);
263 case 'monthly':
264 return $this->sqlFor('MONTH', $args, $common);
265 case 'weekly':
266 return $this->sqlFor('WEEK', $args, $common);
267 case 'yearly':
268 return $this->sqlFor('YEAR', $args, $common);
269 default:
270 throw new \WP_Error('invalid_granularity');
271 }
272 }
273
274 private function sqlFor(string $gran, array $args, array $prevArgs): array
275 {
276 $wpdb = \wp_slimstat::$wpdb ?? $GLOBALS['wpdb'];
277 $data1 = $args['chart_data']['data1'] ?? '';
278 $data2 = $args['chart_data']['data2'] ?? '';
279
280 // Validate SQL expressions to prevent SQL injection
281 $data1 = $this->validateSqlExpression($data1);
282 $data2 = $this->validateSqlExpression($data2);
283
284 // Ensure timestamps are integers (defense in depth)
285 $start = absint($args['start']);
286 $end = absint($args['end']);
287 $prevStart = absint($prevArgs['start']);
288 $prevEnd = absint($prevArgs['end']);
289
290 // Build WHERE clause from active filters (excluding time filters)
291 $filterWhere = $this->buildFilterWhere();
292
293 // Add chart-specific WHERE clause if provided.
294 // SECURITY: $args['chart_data']['where'] arrives via $_POST in the AJAX
295 // path (ajaxFetchChartData) and is later inlined into raw SQL through
296 // Query::whereRaw() with no parameter binding. To prevent SQL injection
297 // (Patchstack disclosure, CVSS 8.5), require the supplied clause to
298 // match — after whitespace normalization — one of the WHERE strings
299 // declared by a report registered in wp_slimstat_reports::$reports.
300 if (!empty($args['chart_data']['where'])) {
301 // Reject non-string input before normalization. Chart.php does not
302 // declare(strict_types=1), so casting an array (E_WARNING) or an
303 // object without __toString (fatal Error → 500) would otherwise
304 // produce noisy logs or crash the AJAX handler instead of the
305 // generic security rejection below.
306 if (!is_string($args['chart_data']['where'])) {
307 throw new \Exception(__('Invalid chart filter expression.', 'wp-slimstat'));
308 }
309 $normalized = self::normalizeSqlWhitespace($args['chart_data']['where']);
310 $allowed = self::getAllowedWhereClauses();
311 if (!isset($allowed[$normalized])) {
312 throw new \Exception(__('Invalid chart filter expression.', 'wp-slimstat'));
313 }
314 $canonical = $allowed[$normalized]; // splice trusted text, never the user-derived $normalized
315 // Wrap: allowlisted clauses may contain a top-level OR that would
316 // otherwise rebind and drop the preceding AND filters.
317 $wrapped = '(' . $canonical . ')';
318 $filterWhere = !empty($filterWhere) ? $filterWhere . ' AND ' . $wrapped : $wrapped;
319 }
320
321 // Use UNIX_TIMESTAMP difference for broad MySQL 5.0.x compatibility.
322 // The sign appears inverted vs DataBuckets.php — this is INTENTIONAL:
323 // FROM_UNIXTIME(dt) returns server-local time, but CONVERT_TZ source '+00:00'
324 // declares it as UTC. The "inverted" sign cancels the implicit timezone shift,
325 // producing actual UTC. DataBuckets then applies the correct offset for display.
326 $totalOffsetSeconds = (int) $wpdb->get_var('SELECT UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(UTC_TIMESTAMP())');
327 $sign = ($totalOffsetSeconds < 0) ? '+' : '-';
328 $abs = abs($totalOffsetSeconds);
329 $h = floor($abs / 3600);
330 $m = floor(($abs % 3600) / 60);
331 $tzOffset = sprintf('%s%02d:%02d', $sign, $h, $m);
332
333 $startOfWeek = (int) get_option('start_of_week', 1); // default Monday
334
335 switch ($gran) {
336 case 'HOUR':
337 $dtExpr = sprintf("UNIX_TIMESTAMP(DATE_FORMAT(CONVERT_TZ(FROM_UNIXTIME(dt), '+00:00', '%s'), '%%Y-%%m-%%d %%H:00:00'))", $tzOffset);
338 break;
339 case 'DAY':
340 $dtExpr = sprintf("UNIX_TIMESTAMP(DATE_FORMAT(CONVERT_TZ(FROM_UNIXTIME(dt), '+00:00', '%s'), '%%Y-%%m-%%d'))", $tzOffset);
341 break;
342 case 'MONTH':
343 $dtExpr = sprintf("UNIX_TIMESTAMP(DATE_FORMAT(CONVERT_TZ(FROM_UNIXTIME(dt), '+00:00', '%s'), '%%Y-%%m-01'))", $tzOffset);
344 break;
345 case 'WEEK':
346 $dtExpr = sprintf("UNIX_TIMESTAMP(DATE_FORMAT(DATE_SUB(CONVERT_TZ(FROM_UNIXTIME(dt), '+00:00', '%s'), INTERVAL ((DAYOFWEEK(CONVERT_TZ(FROM_UNIXTIME(dt), '+00:00', '%s')) - 1 - %d + 7) %% 7) DAY), '%%Y-%%m-%%d'))", $tzOffset, $tzOffset, $startOfWeek);
347 break;
348 case 'YEAR':
349 $dtExpr = sprintf("UNIX_TIMESTAMP(STR_TO_DATE(CONCAT(YEAR(CONVERT_TZ(FROM_UNIXTIME(dt), '+00:00', '%s')), '-01-01'), '%%Y-%%m-%%d'))", $tzOffset);
350 break;
351 default:
352 throw new \WP_Error('invalid_granularity');
353 }
354
355 $periods = [
356 'HOUR' => ['label' => 'Y/m/d H:00:00'],
357 'DAY' => ['label' => 'Y/m/d'],
358 'MONTH' => ['label' => 'F Y'],
359 'WEEK' => ['label' => 'Y/m/d'],
360 'YEAR' => ['label' => 'Y'],
361 ];
362
363 // Build main grouped query via Query builder
364 $fields = implode(",\n ", [
365 $dtExpr . ' AS dt',
366 'MIN(dt) AS sort_dt',
367 $data1 . ' AS v1',
368 $data2 . ' AS v2',
369 sprintf("CASE WHEN dt BETWEEN %s AND %s THEN 'current' ELSE 'previous' END AS period", $start, $end),
370 ]);
371
372 // Wrap the OR time ranges in an extra pair of parentheses so subsequent
373 // AND filters are applied to the whole time expression instead of
374 // binding tighter to only the latter OR clause.
375 $rowsQuery = Query::select($fields)
376 ->from($GLOBALS['wpdb']->prefix . 'slim_stats')
377 ->whereRaw('((dt BETWEEN %d AND %d) OR (dt BETWEEN %d AND %d))', [$prevArgs['start'], $prevArgs['end'], $start, $end]);
378
379 // Apply additional filters if any
380 if (!empty($filterWhere)) {
381 $rowsQuery->whereRaw($filterWhere);
382 }
383
384 $rowsQuery->groupBy($dtExpr . ', period')
385 ->orderBy('sort_dt ASC, period ASC');
386
387 // Build totals query via Query builder
388 // No CONVERT_TZ needed for totals - dt is already stored as UTC timestamp and filters use UTC
389 $totalsFields = sprintf("%s AS v1, %s AS v2, CASE WHEN dt BETWEEN %s AND %s THEN 'current' ELSE 'previous' END AS period", $data1, $data2, $start, $end);
390 // Ensure totals WHERE uses grouped OR so filters are applied correctly.
391 $totalsWhere = '((dt BETWEEN %d AND %d) OR (dt BETWEEN %d AND %d))';
392 $totalsQuery = Query::select($totalsFields)
393 ->from($GLOBALS['wpdb']->prefix . 'slim_stats')
394 ->whereRaw($totalsWhere, [$prevArgs['start'], $prevArgs['end'], $start, $end]);
395
396 // Apply additional filters if any
397 if (!empty($filterWhere)) {
398 $totalsQuery->whereRaw($filterWhere);
399 }
400
401 $totalsQuery->groupBy('period')
402 ->orderBy('period ASC');
403
404 return [
405 'query' => $rowsQuery,
406 'totalsQuery' => $totalsQuery,
407 'params' => ['label' => $periods[$gran]['label'], 'gran' => $gran],
408 ];
409 }
410
411 /**
412 * Build WHERE clause from active filters (excluding time filters)
413 *
414 * @return string SQL WHERE clause conditions or empty string
415 */
416 private function buildFilterWhere(): string
417 {
418 if (!class_exists('\wp_slimstat_db')) {
419 return '';
420 }
421
422 // Get active filters (excluding time filters)
423 if (empty(\wp_slimstat_db::$filters_normalized['columns'])) {
424 return '';
425 }
426
427 $whereClauses = [];
428
429 foreach (\wp_slimstat_db::$filters_normalized['columns'] as $column => $filterData) {
430 // Skip addon filters
431 if (false !== strpos($column, 'addon_')) {
432 continue;
433 }
434
435 $operator = $filterData[0] ?? 'equals';
436 $value = $filterData[1] ?? '';
437
438 $clause = \wp_slimstat_db::get_single_where_clause($column, $operator, $value);
439
440 if (!empty($clause)) {
441 $whereClauses[] = $clause;
442 }
443 }
444
445 if (empty($whereClauses)) {
446 return '';
447 }
448
449 return implode(' AND ', $whereClauses);
450 }
451
452 /**
453 * Validates SQL expressions to prevent SQL injection attacks.
454 * Uses a predefined metrics system for maximum security.
455 *
456 * @param string $expression The SQL expression to validate
457 * @return string The safe SQL expression
458 * @throws \Exception If the expression is invalid or potentially malicious
459 */
460 private function validateSqlExpression(string $expression): string
461 {
462 // Remove extra whitespace and normalize
463 $expression = preg_replace('/\s+/', ' ', trim($expression));
464
465 // Empty expressions default to COUNT(*)
466 if (empty($expression)) {
467 return 'COUNT(*)';
468 }
469
470 // Define allowed columns from wp_slim_stats table
471 $allowedColumns = [
472 'id', 'ip', 'other_ip', 'username', 'email',
473 'country', 'location', 'city',
474 'referer', 'resource', 'searchterms', 'notes', 'visit_id',
475 'server_latency', 'page_performance',
476 'browser', 'browser_version', 'browser_type', 'platform',
477 'language', 'fingerprint', 'user_agent',
478 'resolution', 'screen_width', 'screen_height',
479 'content_type', 'category', 'author', 'content_id',
480 'outbound_resource',
481 'tz_offset', 'dt_out', 'dt'
482 ];
483
484 // Define allowed aggregate functions
485 $allowedFunctions = ['COUNT', 'SUM', 'AVG', 'MAX', 'MIN'];
486
487 // Strict pattern matching with anchors to prevent bypass attempts
488 // Pattern 1: COUNT(*) or SUM(*) etc (no spaces allowed in function name)
489 if (preg_match('/^(COUNT|SUM|AVG|MAX|MIN)\s*\(\s*\*\s*\)$/i', $expression, $matches)) {
490 $function = strtoupper($matches[1]);
491 return $function . '(*)';
492 }
493
494 // Pattern 2: COUNT(column) or COUNT( column )
495 if (preg_match('/^(COUNT|SUM|AVG|MAX|MIN)\s*\(\s*([a-z_][a-z0-9_]*)\s*\)$/i', $expression, $matches)) {
496 $function = strtoupper($matches[1]);
497 $column = strtolower($matches[2]);
498
499 if (!in_array($function, $allowedFunctions, true)) {
500 throw new \Exception(__('Invalid SQL function in chart data expression', 'wp-slimstat'));
501 }
502
503 if (!in_array($column, $allowedColumns, true)) {
504 throw new \Exception(__('Invalid column name in chart data expression', 'wp-slimstat'));
505 }
506
507 // Use esc_sql as additional protection (though column is whitelisted)
508 return $function . '( ' . esc_sql($column) . ' )';
509 }
510
511 // Pattern 3: COUNT(DISTINCT column) or COUNT( DISTINCT column )
512 if (preg_match('/^(COUNT|SUM|AVG|MAX|MIN)\s*\(\s*DISTINCT\s+([a-z_][a-z0-9_]*)\s*\)$/i', $expression, $matches)) {
513 $function = strtoupper($matches[1]);
514 $column = strtolower($matches[2]);
515
516 if (!in_array($function, $allowedFunctions, true)) {
517 throw new \Exception(__('Invalid SQL function in chart data expression', 'wp-slimstat'));
518 }
519
520 if (!in_array($column, $allowedColumns, true)) {
521 throw new \Exception(__('Invalid column name in chart data expression', 'wp-slimstat'));
522 }
523
524 // Use esc_sql as additional protection (though column is whitelisted)
525 return $function . '( DISTINCT ' . esc_sql($column) . ' )';
526 }
527
528 // If none of the patterns match, reject the expression
529 throw new \Exception(__('Invalid SQL expression in chart data. Only whitelisted aggregate functions on valid columns are allowed.', 'wp-slimstat'));
530 }
531
532 /**
533 * Allowlist of legitimate chart `where` clauses harvested from every report
534 * registered in wp_slimstat_reports::$reports (including those added by
535 * third-party Pro addons via the `slimstat_reports_info` filter).
536 *
537 * Rebuilt per request because dynamic clauses (home_url(), date_i18n(...))
538 * are evaluated at init() time.
539 *
540 * @return array<string,string> normalized-clause => canonical clause text
541 */
542 private static function getAllowedWhereClauses(): array
543 {
544 static $cache = null;
545 if (null !== $cache) {
546 return $cache;
547 }
548
549 if (!class_exists('\wp_slimstat_reports')) {
550 $reportsFile = SLIMSTAT_DIR . '/admin/view/wp-slimstat-reports.php';
551 if (file_exists($reportsFile)) {
552 include_once $reportsFile;
553 }
554 }
555 if (!class_exists('\wp_slimstat_reports')) {
556 // Don't cache the failure — let a later call retry once the file
557 // has had a chance to load (e.g. via a downstream filter).
558 return [];
559 }
560
561 \wp_slimstat_reports::init();
562
563 $cache = [];
564 foreach ((array) \wp_slimstat_reports::$reports as $report) {
565 $where = $report['callback_args']['chart_data']['where'] ?? null;
566 // Skip non-string values defensively — a third-party report could
567 // register an array/object/null; normalizeSqlWhitespace is typed
568 // for string and Chart.php does not declare(strict_types=1).
569 if (!is_string($where) || '' === $where) {
570 continue;
571 }
572 $normalized = self::normalizeSqlWhitespace($where);
573 if ('' !== $normalized) {
574 $cache[$normalized] = $where;
575 }
576 }
577
578 return $cache;
579 }
580
581 /**
582 * Both sides of the `where` allowlist comparison must run through the
583 * same whitespace normalization for the equality check to be sound.
584 */
585 private static function normalizeSqlWhitespace(string $sql): string
586 {
587 return trim(preg_replace('/\s+/', ' ', $sql));
588 }
589
590 private function processResults(array $rows, array $totals, array $params, int $start, int $end, int $prevStart, int $prevEnd): array
591 {
592 // Normalize totals to array of stdClass for backward compatibility
593 $totalsObjects = array_map(function ($t) {
594 if (is_object($t)) {
595 return $t;
596 }
597
598 $o = new \stdClass();
599 $o->v1 = isset($t['v1']) ? (int) $t['v1'] : 0;
600 $o->v2 = isset($t['v2']) ? (int) $t['v2'] : 0;
601 $o->period = isset($t['period']) ? (string) $t['period'] : '';
602 return $o;
603 }, $totals);
604
605 $buckets = new DataBuckets($params['label'], $params['gran'], $start, $end, $prevStart, $prevEnd, $totalsObjects);
606 foreach ($rows as $row) {
607 $dt = (int) (is_object($row) ? $row->dt : ($row['dt'] ?? 0));
608 $v1 = (int) (is_object($row) ? $row->v1 : ($row['v1'] ?? 0));
609 $v2 = (int) (is_object($row) ? $row->v2 : ($row['v2'] ?? 0));
610 $period = (string) (is_object($row) ? $row->period : ($row['period'] ?? ''));
611 $buckets->addRow($dt, $v1, $v2, $period);
612 }
613
614 return $buckets->toArray();
615 }
616
617 private function extractPreviousData(array $data): array
618 {
619 $prev = $data;
620 $prev['datasets'] = $prev['datasets_prev'] ?? [];
621 unset($prev['datasets_prev']);
622
623 return $prev;
624 }
625
626 private function enqueueAssets(): void
627 {
628 wp_enqueue_script(
629 'slimstat_chartjs',
630 plugins_url('/admin/assets/js/chartjs/chart.min.js', SLIMSTAT_FILE),
631 [],
632 '4.2.1',
633 true
634 );
635 wp_enqueue_script(
636 'slimstat_chart',
637 plugins_url('/admin/assets/js/slimstat-chart.js', SLIMSTAT_FILE),
638 ['slimstat_chartjs'],
639 '1.3',
640 true
641 );
642 wp_localize_script('slimstat_chart', 'slimstat_chart_vars', [
643 // Use a relative admin-ajax path for the admin chart to avoid cross-origin issues in dev setups
644 'ajax_url' => admin_url('admin-ajax.php', 'relative'),
645 'nonce' => wp_create_nonce('slimstat_chart_nonce'),
646 'end_date' => $this->args['end'] ?? null,
647 'end_date_string' => isset($this->args['end']) ? date('Y/m/d H:i:s', $this->args['end']) : null,
648 'timezone' => get_option('timezone_string') ?: 'UTC',
649 'start_of_week' => get_option('start_of_week', 1),
650 ]);
651 }
652
653 private function renderChart(): void
654 {
655 View::load('modules/chart-view', [
656 'args' => $this->args,
657 'data' => $this->data,
658 'prevData' => $this->prevData,
659 'chartLabels' => $this->chartLabels,
660 'translations' => $this->translations,
661 ]);
662 }
663
664 /**
665 * Get supported chart types
666 *
667 * @return array<string>
668 */
669 public static function get_supported_chart_types(): array
670 {
671 return self::CHART_TYPES;
672 }
673 }
674