PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-statistics.php

class-statistics.php in ActivityPub trunk, at includes/class-statistics.php

971 lines 31.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Statistics class file.
4 *
5 * Collects and stores ActivityPub statistics for monthly/annual reports.
6 *
7 * @package Activitypub
8 */
9
10 namespace Activitypub;
11
12 use Activitypub\Collection\Actors;
13 use Activitypub\Collection\Followers;
14 use Activitypub\Collection\Outbox;
15 use Activitypub\Comment;
16
17 /**
18 * Statistics class.
19 *
20 * Handles collection and storage of ActivityPub statistics.
21 */
22 class Statistics {
23
24 /**
25 * Option prefix for statistics storage.
26 *
27 * @var string
28 */
29 const OPTION_PREFIX = 'activitypub_stats_';
30
31 /**
32 * Get the start and end date strings for a given month.
33 *
34 * @param int $year The year.
35 * @param int $month The month (1-12).
36 *
37 * @return array { start: string, end: string } in 'Y-m-d H:i:s' format.
38 */
39 public static function get_month_date_range( $year, $month ) {
40 $last_day = (int) \gmdate( 't', \gmmktime( 0, 0, 0, $month, 1, $year ) );
41
42 return array(
43 'start' => \sprintf( '%d-%02d-01 00:00:00', $year, $month ),
44 'end' => \sprintf( '%d-%02d-%02d 23:59:59', $year, $month, $last_day ),
45 );
46 }
47
48 /**
49 * Get the option name for monthly stats.
50 *
51 * @param int $user_id The user ID.
52 * @param int $year The year.
53 * @param int $month The month.
54 *
55 * @return string The option name.
56 */
57 public static function get_monthly_option_name( $user_id, $year, $month ) {
58 return \sprintf( '%s%d_%d_%02d', self::OPTION_PREFIX, $user_id, $year, $month );
59 }
60
61 /**
62 * Get the option name for annual stats.
63 *
64 * @param int $user_id The user ID.
65 * @param int $year The year.
66 *
67 * @return string The option name.
68 */
69 public static function get_annual_option_name( $user_id, $year ) {
70 return \sprintf( '%s%d_%d_annual', self::OPTION_PREFIX, $user_id, $year );
71 }
72
73 /**
74 * Get monthly statistics.
75 *
76 * @param int $user_id The user ID.
77 * @param int $year The year.
78 * @param int $month The month.
79 *
80 * @return array|false The monthly stats array or false if not found.
81 */
82 public static function get_monthly_stats( $user_id, $year, $month ) {
83 return \get_option( self::get_monthly_option_name( $user_id, $year, $month ), false );
84 }
85
86 /**
87 * Get annual summary statistics.
88 *
89 * @param int $user_id The user ID.
90 * @param int $year The year.
91 *
92 * @return array|false The annual stats array or false if not found.
93 */
94 public static function get_annual_summary( $user_id, $year ) {
95 return \get_option( self::get_annual_option_name( $user_id, $year ), false );
96 }
97
98 /**
99 * Save monthly statistics.
100 *
101 * @param int $user_id The user ID.
102 * @param int $year The year.
103 * @param int $month The month.
104 * @param array $stats The stats array.
105 *
106 * @return bool True on success, false on failure.
107 */
108 public static function save_monthly_stats( $user_id, $year, $month, $stats ) {
109 // Invalidate the REST API transient cache so fresh data is served.
110 \delete_transient( 'activitypub_stats_' . $user_id );
111
112 return \update_option( self::get_monthly_option_name( $user_id, $year, $month ), $stats, false );
113 }
114
115 /**
116 * Save annual summary statistics.
117 *
118 * @param int $user_id The user ID.
119 * @param int $year The year.
120 * @param array $stats The stats array.
121 *
122 * @return bool True on success, false on failure.
123 */
124 public static function save_annual_summary( $user_id, $year, $stats ) {
125 return \update_option( self::get_annual_option_name( $user_id, $year ), $stats, false );
126 }
127
128 /**
129 * Collect monthly statistics for a user.
130 *
131 * @param int $user_id The user ID.
132 * @param int $year The year.
133 * @param int $month The month.
134 *
135 * @return array The collected stats.
136 */
137 public static function collect_monthly_stats( $user_id, $year, $month ) {
138 $month = (int) $month;
139 $year = (int) $year;
140
141 if ( $month < 1 || $month > 12 ) {
142 return array();
143 }
144
145 $range = self::get_month_date_range( $year, $month );
146 $start = $range['start'];
147 $end = $range['end'];
148
149 // Count new followers gained this month (by post_date in followers table).
150 $followers_count = Followers::count_in_range( $user_id, $start, $end );
151
152 $stats = array(
153 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ),
154 'followers_count' => $followers_count,
155 'followers_total' => self::get_follower_count( $user_id ),
156 'top_posts' => self::get_top_posts( $user_id, $start, $end, 5 ),
157 'top_multiplicator' => self::get_top_multiplicator( $user_id, $start, $end ),
158 'collected_at' => \gmdate( 'Y-m-d H:i:s' ),
159 );
160
161 // Add counts for each comment type dynamically.
162 foreach ( \array_keys( self::get_comment_types_for_stats() ) as $type ) {
163 $stats[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type );
164 }
165
166 self::save_monthly_stats( $user_id, $year, $month, $stats );
167
168 return $stats;
169 }
170
171 /**
172 * Compile annual summary from monthly stats.
173 *
174 * @param int $user_id The user ID.
175 * @param int $year The year.
176 *
177 * @return array The annual summary.
178 */
179 public static function compile_annual_summary( $user_id, $year ) {
180 // Initialize totals dynamically based on registered comment types.
181 $comment_types = \array_keys( self::get_comment_types_for_stats() );
182 $totals = array( 'posts_count' => 0 );
183 foreach ( $comment_types as $type ) {
184 $totals[ $type . '_count' ] = 0;
185 }
186
187 $most_active_month = null;
188 $most_active_engagement = 0;
189 $first_month_stats = null;
190 $last_month_stats = null;
191 $all_multiplicators = array();
192
193 for ( $month = 1; $month <= 12; $month++ ) {
194 $stats = self::get_monthly_stats( $user_id, $year, $month );
195
196 if ( ! $stats ) {
197 continue;
198 }
199
200 // Track first and last months with data.
201 if ( ! $first_month_stats ) {
202 $first_month_stats = $stats;
203 }
204 $last_month_stats = $stats;
205
206 // Sum totals dynamically.
207 $totals['posts_count'] += $stats['posts_count'] ?? 0;
208 foreach ( $comment_types as $type ) {
209 $key = $type . '_count';
210 $totals[ $key ] += $stats[ $key ] ?? 0;
211 }
212
213 // Calculate engagement for this month (sum of all comment type counts).
214 $engagement = 0;
215 foreach ( $comment_types as $type ) {
216 $engagement += $stats[ $type . '_count' ] ?? 0;
217 }
218
219 if ( $engagement > $most_active_engagement ) {
220 $most_active_engagement = $engagement;
221 $most_active_month = $month;
222 }
223
224 // Aggregate multiplicators.
225 if ( ! empty( $stats['top_multiplicator'] ) && ! empty( $stats['top_multiplicator']['url'] ) ) {
226 $url = $stats['top_multiplicator']['url'];
227 if ( ! isset( $all_multiplicators[ $url ] ) ) {
228 $all_multiplicators[ $url ] = array(
229 'name' => $stats['top_multiplicator']['name'],
230 'url' => $url,
231 'count' => 0,
232 );
233 }
234 $all_multiplicators[ $url ]['count'] += $stats['top_multiplicator']['count'] ?? 0;
235 }
236 }
237
238 // Find top multiplicator for the year.
239 $top_multiplicator = null;
240 if ( ! empty( $all_multiplicators ) ) {
241 \usort(
242 $all_multiplicators,
243 function ( $a, $b ) {
244 return $b['count'] - $a['count'];
245 }
246 );
247 $top_multiplicator = \reset( $all_multiplicators );
248 }
249
250 // Build summary with dynamic comment type counts.
251 // Calculate followers_start: total at start of first month (total minus gained that month).
252 // Monthly stats store: followers_count (gained this month), followers_total (total at end of month).
253 $followers_start = 0;
254 if ( $first_month_stats ) {
255 $followers_start = ( $first_month_stats['followers_total'] ?? 0 ) - ( $first_month_stats['followers_count'] ?? 0 );
256 }
257
258 // Get top posts for the full year.
259 $year_start = \sprintf( '%d-01-01 00:00:00', $year );
260 $year_end = \sprintf( '%d-12-31 23:59:59', $year );
261
262 $summary = array(
263 'posts_count' => $totals['posts_count'],
264 'most_active_month' => $most_active_month,
265 'followers_start' => $followers_start,
266 'followers_end' => $last_month_stats ? ( $last_month_stats['followers_total'] ?? 0 ) : self::get_follower_count( $user_id ),
267 'followers_net_change' => 0,
268 'top_multiplicator' => $top_multiplicator,
269 'top_posts' => self::get_top_posts( $user_id, $year_start, $year_end, 5 ),
270 'compiled_at' => \gmdate( 'Y-m-d H:i:s' ),
271 );
272
273 // Add comment type totals dynamically.
274 foreach ( $comment_types as $type ) {
275 $summary[ $type . '_count' ] = $totals[ $type . '_count' ];
276 }
277
278 $summary['followers_net_change'] = $summary['followers_end'] - $summary['followers_start'];
279
280 self::save_annual_summary( $user_id, $year, $summary );
281
282 return $summary;
283 }
284
285 /**
286 * Count published posts in a date range.
287 *
288 * Counts published posts in ActivityPub-enabled post types.
289 *
290 * @param int $user_id The user ID.
291 * @param string $start Start date (Y-m-d H:i:s).
292 * @param string $end End date (Y-m-d H:i:s).
293 *
294 * @return int The post count.
295 */
296 public static function count_federated_posts_in_range( $user_id, $start, $end ) {
297 global $wpdb;
298
299 $post_subquery = self::get_post_ids_subquery( $user_id, $start, $end );
300
301 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
302 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
303 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
304 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
305 $count = $wpdb->get_var(
306 "SELECT COUNT(*) FROM ({$post_subquery}) AS posts"
307 );
308 // phpcs:enable
309
310 return (int) $count;
311 }
312
313 /**
314 * Count engagement (likes, reposts, comments, quotes) in a date range.
315 *
316 * @param int $user_id The user ID.
317 * @param string $start Start date (Y-m-d H:i:s).
318 * @param string $end End date (Y-m-d H:i:s).
319 * @param string|null $type Optional. The engagement type ('like', 'repost', 'comment', 'quote').
320 *
321 * @return int The engagement count.
322 */
323 public static function count_engagement_in_range( $user_id, $start, $end, $type = null ) {
324 global $wpdb;
325
326 // Use a subquery to avoid loading all post IDs into memory.
327 $post_subquery = self::get_post_ids_subquery( $user_id );
328
329 $type_clause = '';
330 if ( $type ) {
331 $type_clause = $wpdb->prepare( ' AND c.comment_type = %s', $type );
332 } else {
333 // Get all comment types tracked in statistics (includes federated comments via filter).
334 $comment_types = \array_keys( self::get_comment_types_for_stats() );
335 if ( ! empty( $comment_types ) ) {
336 $placeholders_types = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) );
337 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
338 $type_clause = $wpdb->prepare( " AND c.comment_type IN ($placeholders_types)", $comment_types );
339 }
340 }
341
342 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
343 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
344 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
345 $count = $wpdb->get_var(
346 $wpdb->prepare(
347 "SELECT COUNT(DISTINCT c.comment_ID) FROM {$wpdb->comments} c
348 INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id
349 WHERE c.comment_post_ID IN ({$post_subquery})
350 AND cm.meta_key = 'protocol'
351 AND cm.meta_value = 'activitypub'
352 AND c.comment_date_gmt >= %s
353 AND c.comment_date_gmt <= %s
354 {$type_clause}",
355 $start,
356 $end
357 )
358 );
359 // phpcs:enable
360
361 return (int) $count;
362 }
363
364 /**
365 * Get top performing posts in a date range.
366 *
367 * @param int $user_id The user ID.
368 * @param string $start Start date (Y-m-d H:i:s).
369 * @param string $end End date (Y-m-d H:i:s).
370 * @param int $limit Maximum number of posts to return.
371 *
372 * @return array Array of top posts with engagement data.
373 */
374 public static function get_top_posts( $user_id, $start, $end, $limit = 5 ) {
375 global $wpdb;
376
377 // Use a subquery with date range to only consider posts published in the period.
378 $post_subquery = self::get_post_ids_subquery( $user_id, $start, $end );
379
380 // Use the same comment type source as all other statistics methods.
381 $comment_types = \array_keys( self::get_comment_types_for_stats() );
382 if ( empty( $comment_types ) ) {
383 return array();
384 }
385
386 $placeholders_types = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) );
387
388 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
389 $type_clause = $wpdb->prepare( "AND c.comment_type IN ({$placeholders_types})", $comment_types );
390
391 // Get engagement counts per post (only engagement within the date range).
392 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
393 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
394 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
395 $results = $wpdb->get_results(
396 $wpdb->prepare(
397 "SELECT c.comment_post_ID as post_id, COUNT(c.comment_ID) as engagement_count
398 FROM {$wpdb->comments} c
399 INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id
400 WHERE c.comment_post_ID IN ({$post_subquery})
401 AND cm.meta_key = 'protocol'
402 AND cm.meta_value = 'activitypub'
403 {$type_clause}
404 AND c.comment_date_gmt >= %s
405 AND c.comment_date_gmt <= %s
406 GROUP BY c.comment_post_ID
407 ORDER BY engagement_count DESC
408 LIMIT %d",
409 $start,
410 $end,
411 $limit
412 ),
413 ARRAY_A
414 );
415 // phpcs:enable
416
417 $top_posts = array();
418 foreach ( $results as $result ) {
419 $post = \get_post( $result['post_id'] );
420 if ( $post ) {
421 $top_posts[] = array(
422 'post_id' => $result['post_id'],
423 'title' => \html_entity_decode( \get_the_title( $post ), ENT_QUOTES, 'UTF-8' ),
424 'url' => \get_permalink( $post ),
425 'edit_url' => \get_edit_post_link( $post, 'raw' ),
426 'engagement_count' => (int) $result['engagement_count'],
427 );
428 }
429 }
430
431 return $top_posts;
432 }
433
434 /**
435 * Get the top multiplicator (actor who boosted content the most) in a date range.
436 *
437 * @param int $user_id The user ID.
438 * @param string $start Start date (Y-m-d H:i:s).
439 * @param string $end End date (Y-m-d H:i:s).
440 *
441 * @return array|null Actor data or null if none found.
442 */
443 public static function get_top_multiplicator( $user_id, $start, $end ) {
444 global $wpdb;
445
446 // Use a subquery to avoid loading all post IDs into memory.
447 $post_subquery = self::get_post_ids_subquery( $user_id );
448
449 // Get actor who boosted the most.
450 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
451 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
452 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
453 $result = $wpdb->get_row(
454 $wpdb->prepare(
455 "SELECT c.comment_author as name, c.comment_author_url as url, COUNT(c.comment_ID) as boost_count
456 FROM {$wpdb->comments} c
457 INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id
458 WHERE c.comment_post_ID IN ({$post_subquery})
459 AND cm.meta_key = 'protocol'
460 AND cm.meta_value = 'activitypub'
461 AND c.comment_type = 'repost'
462 AND c.comment_date_gmt >= %s
463 AND c.comment_date_gmt <= %s
464 GROUP BY c.comment_author_url
465 ORDER BY boost_count DESC
466 LIMIT 1",
467 $start,
468 $end
469 ),
470 ARRAY_A
471 );
472 // phpcs:enable
473
474 if ( ! $result || empty( $result['url'] ) ) {
475 return null;
476 }
477
478 return array(
479 'name' => $result['name'],
480 'url' => $result['url'],
481 'count' => (int) $result['boost_count'],
482 );
483 }
484
485 /**
486 * Get current follower count for a user.
487 *
488 * @param int $user_id The user ID.
489 *
490 * @return int The follower count.
491 */
492 public static function get_follower_count( $user_id ) {
493 return Followers::count( $user_id );
494 }
495
496 /**
497 * Get all active user IDs that have ActivityPub enabled.
498 *
499 * @return int[] Array of user IDs including BLOG_USER_ID if enabled.
500 */
501 public static function get_active_user_ids() {
502 return Actors::get_all_ids();
503 }
504
505 /**
506 * Get statistics for the current period.
507 *
508 * Always queries live data for the current period to include recent engagement.
509 *
510 * @param int $user_id The user ID.
511 * @param string $period The period ('month', 'year', 'all').
512 *
513 * @return array The statistics.
514 */
515 public static function get_current_stats( $user_id, $period = 'month' ) {
516 $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
517
518 switch ( $period ) {
519 case 'year':
520 $start = \gmdate( 'Y-01-01 00:00:00', $now );
521 $end = \gmdate( 'Y-12-31 23:59:59', $now );
522 break;
523
524 case 'all':
525 $start = '1970-01-01 00:00:00';
526 $end = \gmdate( 'Y-m-d 23:59:59', $now );
527 break;
528
529 case 'month':
530 default:
531 $start = \gmdate( 'Y-m-01 00:00:00', $now );
532 $end = \gmdate( 'Y-m-t 23:59:59', $now );
533 break;
534 }
535
536 $stats = array(
537 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ),
538 'followers_total' => self::get_follower_count( $user_id ),
539 'top_posts' => self::get_top_posts( $user_id, $start, $end, 3 ),
540 'top_multiplicator' => self::get_top_multiplicator( $user_id, $start, $end ),
541 'period' => $period,
542 'start' => $start,
543 'end' => $end,
544 );
545
546 // Add counts for each comment type dynamically.
547 foreach ( \array_keys( self::get_comment_types_for_stats() ) as $type ) {
548 $stats[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type );
549 }
550
551 return $stats;
552 }
553
554 /**
555 * Get rolling monthly breakdown (last X months).
556 *
557 * Returns stats for the last X months, crossing year boundaries as needed.
558 *
559 * @param int $user_id The user ID.
560 * @param int $num_months Optional. Number of months to return. Defaults to 12.
561 *
562 * @return array Array of monthly stats ordered chronologically.
563 */
564 public static function get_rolling_monthly_breakdown( $user_id, $num_months = 12 ) {
565 $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
566 $months = array();
567 $comment_types = \array_keys( self::get_comment_types_for_stats() );
568
569 // Start from (num_months - 1) months ago and go to current month.
570 for ( $i = $num_months - 1; $i >= 0; $i-- ) {
571 $timestamp = \strtotime( "-{$i} months", $now );
572 $year = (int) \gmdate( 'Y', $timestamp );
573 $month = (int) \gmdate( 'n', $timestamp );
574
575 $month_data = self::get_month_data( $user_id, $year, $month, $comment_types );
576 $month_data['year'] = $year;
577 $month_data['month'] = $month;
578
579 $months[] = $month_data;
580 }
581
582 return $months;
583 }
584
585 /**
586 * Get data for a single month.
587 *
588 * @param int $user_id The user ID.
589 * @param int $year The year.
590 * @param int $month The month.
591 * @param array $comment_types Array of comment type slugs.
592 *
593 * @return array Month data with posts_count, engagement, and type counts.
594 */
595 private static function get_month_data( $user_id, $year, $month, $comment_types ) {
596 $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
597 $current_year = (int) \gmdate( 'Y', $now );
598 $current_month = (int) \gmdate( 'n', $now );
599
600 // Always query live for the current month to include recent engagement.
601 $is_current_month = ( $year === $current_year && $month === $current_month );
602
603 // Check for stored monthly stats first (but not for current month).
604 $stored_stats = $is_current_month ? false : self::get_monthly_stats( $user_id, $year, $month );
605
606 if ( $stored_stats ) {
607 // Use stored data.
608 $engagement = 0;
609 foreach ( $comment_types as $type ) {
610 $engagement += $stored_stats[ $type . '_count' ] ?? 0;
611 }
612
613 $month_data = array(
614 'month' => $month,
615 'posts_count' => $stored_stats['posts_count'] ?? 0,
616 'engagement' => $engagement,
617 );
618
619 // Add counts for each comment type from stored stats.
620 foreach ( $comment_types as $type ) {
621 $month_data[ $type . '_count' ] = $stored_stats[ $type . '_count' ] ?? 0;
622 }
623 } else {
624 // Query live data.
625 $range = self::get_month_date_range( $year, $month );
626 $start = $range['start'];
627 $end = $range['end'];
628
629 $month_data = array(
630 'month' => $month,
631 'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ),
632 'engagement' => 0,
633 );
634
635 // Query each type and sum for total engagement (avoids extra N+1 total query).
636 foreach ( $comment_types as $type ) {
637 $type_count = self::count_engagement_in_range( $user_id, $start, $end, $type );
638 $month_data[ $type . '_count' ] = $type_count;
639 $month_data['engagement'] += $type_count;
640 }
641 }
642
643 return $month_data;
644 }
645
646 /**
647 * Get period-over-period comparison (current month vs previous month).
648 *
649 * Reuses pre-computed current stats when available to avoid duplicate queries.
650 * Falls back to live queries for current month if no stats are provided.
651 *
652 * @param int $user_id The user ID.
653 * @param array|null $current_stats Optional. Pre-computed current month stats from get_current_stats().
654 *
655 * @return array Comparison data with current values and changes from previous month.
656 */
657 public static function get_period_comparison( $user_id, $current_stats = null ) {
658 $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
659
660 // Previous month (handles year boundary).
661 $prev_timestamp = \strtotime( '-1 month', $now );
662 $prev_year = (int) \gmdate( 'Y', $prev_timestamp );
663 $prev_month = (int) \gmdate( 'n', $prev_timestamp );
664
665 // Check for stored stats (only for previous month - current month is always live).
666 $prev_stats = self::get_monthly_stats( $user_id, $prev_year, $prev_month );
667
668 // Reuse pre-computed current stats or query live.
669 if ( $current_stats ) {
670 $current_posts = $current_stats['posts_count'] ?? 0;
671 } else {
672 $current_start = \gmdate( 'Y-m-01 00:00:00', $now );
673 $current_end = \gmdate( 'Y-m-t 23:59:59', $now );
674 $current_posts = self::count_federated_posts_in_range( $user_id, $current_start, $current_end );
675 }
676
677 $current_start = $current_start ?? \gmdate( 'Y-m-01 00:00:00', $now );
678 $current_end = $current_end ?? \gmdate( 'Y-m-t 23:59:59', $now );
679
680 $current_followers = Followers::count_in_range( $user_id, $current_start, $current_end );
681
682 // Get previous month data (from stored stats or live query).
683 if ( $prev_stats ) {
684 $prev_posts = $prev_stats['posts_count'] ?? 0;
685 $prev_followers = $prev_stats['followers_count'] ?? 0;
686 } else {
687 $prev_start = \gmdate( 'Y-m-01 00:00:00', $prev_timestamp );
688 $prev_end = \gmdate( 'Y-m-t 23:59:59', $prev_timestamp );
689 $prev_posts = self::count_federated_posts_in_range( $user_id, $prev_start, $prev_end );
690 $prev_followers = Followers::count_in_range( $user_id, $prev_start, $prev_end );
691 }
692
693 $comparison = array(
694 'posts' => array(
695 'current' => $current_posts,
696 'change' => $current_posts - $prev_posts,
697 ),
698 'followers' => array(
699 'current' => $current_followers,
700 'change' => $current_followers - $prev_followers,
701 ),
702 );
703
704 // Add comparison for each comment type tracked in statistics (includes federated comments).
705 $comment_types = \array_keys( self::get_comment_types_for_stats() );
706 foreach ( $comment_types as $type ) {
707 // Reuse pre-computed current stats or query live.
708 if ( $current_stats && isset( $current_stats[ $type . '_count' ] ) ) {
709 $current_count = $current_stats[ $type . '_count' ];
710 } else {
711 $current_count = self::count_engagement_in_range( $user_id, $current_start, $current_end, $type );
712 }
713
714 // Use stored stats for previous month if available.
715 if ( $prev_stats ) {
716 $prev_count = $prev_stats[ $type . '_count' ] ?? 0;
717 } else {
718 $prev_count = self::count_engagement_in_range( $user_id, $prev_start, $prev_end, $type );
719 }
720
721 $comparison[ $type ] = array(
722 'current' => $current_count,
723 'change' => $current_count - $prev_count,
724 );
725 }
726
727 return $comparison;
728 }
729
730 /**
731 * Get comment types to track in statistics.
732 *
733 * By default includes all registered ActivityPub comment types.
734 * Use the 'activitypub_stats_comment_types' filter to add additional types.
735 *
736 * @return array Array of comment type data with slug, label, and singular.
737 */
738 public static function get_comment_types_for_stats() {
739 $comment_types = Comment::get_comment_types();
740 $result = array();
741
742 foreach ( $comment_types as $slug => $type ) {
743 $result[ $slug ] = array(
744 'slug' => $slug,
745 'label' => $type['label'] ?? \ucfirst( $slug ),
746 'singular' => $type['singular'] ?? \ucfirst( $slug ),
747 );
748 }
749
750 // Add federated comments (replies) which use the standard 'comment' type.
751 if ( ! isset( $result['comment'] ) ) {
752 $result['comment'] = array(
753 'slug' => 'comment',
754 'label' => \__( 'Comments', 'activitypub' ),
755 'singular' => \__( 'Comment', 'activitypub' ),
756 );
757 }
758
759 /**
760 * Filter the comment types tracked in statistics.
761 *
762 * Allows adding additional comment types to be tracked
763 * in the statistics dashboard.
764 *
765 * @param array $result Array of comment type data with slug, label, and singular.
766 */
767 return \apply_filters( 'activitypub_stats_comment_types', $result );
768 }
769
770 /**
771 * Backfill historical statistics for all active users.
772 *
773 * This method processes statistics in batches to avoid timeouts.
774 * It only collects stats for completed months (not the current month).
775 *
776 * @param int $batch_size Optional. Number of months to process per batch. Default 12.
777 * @param int $user_index Optional. The current user index being processed. Default 0.
778 * @param int $year Optional. The year being processed. Default 0 (will determine earliest year).
779 * @param int $month Optional. The month being processed. Default 1.
780 *
781 * @return array|null Array with batch info if more processing needed, null if complete.
782 */
783 public static function backfill_historical_stats( $batch_size = 12, $user_index = 0, $year = 0, $month = 1 ) {
784 $user_ids = self::get_active_user_ids();
785
786 if ( empty( $user_ids ) || $user_index >= \count( $user_ids ) ) {
787 return null; // All done.
788 }
789
790 $user_id = $user_ids[ $user_index ];
791 $now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
792 $current_year = (int) \gmdate( 'Y', $now );
793 $current_month = (int) \gmdate( 'n', $now );
794
795 // Determine the earliest year with data if not set.
796 if ( 0 === $year ) {
797 $year = self::get_earliest_data_year( $user_id );
798 if ( ! $year ) {
799 // No data for this user, move to next user.
800 return array(
801 'batch_size' => $batch_size,
802 'user_index' => $user_index + 1,
803 'year' => 0,
804 'month' => 1,
805 );
806 }
807 }
808
809 $months_processed = 0;
810
811 // Process months for this user.
812 while ( $months_processed < $batch_size ) {
813 // Skip the current month - it's still in progress and should always be queried live.
814 // Only process completed months (before the current month).
815 if ( $year > $current_year || ( $year === $current_year && $month >= $current_month ) ) {
816 // Move to next user.
817 return array(
818 'batch_size' => $batch_size,
819 'user_index' => $user_index + 1,
820 'year' => 0,
821 'month' => 1,
822 );
823 }
824
825 // Check if stats already exist for this month.
826 $existing = self::get_monthly_stats( $user_id, $year, $month );
827 if ( ! $existing ) {
828 // Collect stats for this month.
829 self::collect_monthly_stats( $user_id, $year, $month );
830 }
831
832 ++$months_processed;
833 ++$month;
834
835 // Move to next year if needed.
836 if ( $month > 12 ) {
837 $month = 1;
838 ++$year;
839 }
840 }
841
842 // More months to process for this user.
843 return array(
844 'batch_size' => $batch_size,
845 'user_index' => $user_index,
846 'year' => $year,
847 'month' => $month,
848 );
849 }
850
851 /**
852 * Get a prepared SQL subquery that returns post IDs for a user.
853 *
854 * This avoids loading all post IDs into PHP memory by using a SQL subquery
855 * that can be embedded in other queries via IN (...).
856 *
857 * @param int $user_id The user ID.
858 * @param string|null $start Optional start date (Y-m-d H:i:s).
859 * @param string|null $end Optional end date (Y-m-d H:i:s).
860 *
861 * @return string Prepared SQL subquery string.
862 */
863 private static function get_post_ids_subquery( $user_id, $start = null, $end = null ) {
864 global $wpdb;
865
866 $post_types = (array) \get_option( 'activitypub_support_post_types', array( 'post' ) );
867 $type_placeholders = \implode( ', ', \array_fill( 0, \count( $post_types ), '%s' ) );
868 $params = $post_types;
869
870 $author_clause = '';
871 if ( Actors::BLOG_USER_ID !== $user_id ) {
872 $author_clause = ' AND post_author = %d';
873 $params[] = $user_id;
874 }
875
876 $date_clause = '';
877 if ( $start && $end ) {
878 // Use COALESCE to fall back to post_date when post_date_gmt is empty.
879 $date_clause = " AND COALESCE(NULLIF(post_date_gmt, '0000-00-00 00:00:00'), post_date) >= %s AND COALESCE(NULLIF(post_date_gmt, '0000-00-00 00:00:00'), post_date) <= %s";
880 $params[] = $start;
881 $params[] = $end;
882 }
883
884 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
885 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
886 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
887 return $wpdb->prepare(
888 "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$type_placeholders}){$author_clause}{$date_clause}",
889 $params
890 );
891 // phpcs:enable
892 }
893
894 /**
895 * Get the earliest year that has ActivityPub data for a user.
896 *
897 * @param int $user_id The user ID.
898 *
899 * @return int|null The earliest year with data, or null if no data.
900 */
901 private static function get_earliest_data_year( $user_id ) {
902 global $wpdb;
903
904 // Use a subquery to avoid loading all post IDs into memory.
905 $post_subquery = self::get_post_ids_subquery( $user_id );
906
907 // Find earliest comment with ActivityPub protocol.
908 // The $post_subquery is already prepared via $wpdb->prepare() in get_post_ids_subquery(),
909 // so the outer query is safe despite not using prepare() itself.
910 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
911 // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
912 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
913 // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
914 $earliest_date = $wpdb->get_var(
915 "SELECT MIN(c.comment_date_gmt) FROM {$wpdb->comments} c
916 INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id
917 WHERE c.comment_post_ID IN ({$post_subquery})
918 AND cm.meta_key = 'protocol'
919 AND cm.meta_value = 'activitypub'"
920 );
921 // phpcs:enable
922
923 if ( ! $earliest_date ) {
924 // No ActivityPub data, check outbox instead.
925 $outbox_args = array(
926 'post_type' => Outbox::POST_TYPE,
927 'posts_per_page' => 1,
928 'orderby' => 'date',
929 'order' => 'ASC',
930 // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
931 'meta_query' => array(
932 array(
933 'key' => '_activitypub_activity_type',
934 'value' => 'Create',
935 ),
936 ),
937 );
938
939 if ( Actors::BLOG_USER_ID !== $user_id ) {
940 $outbox_args['author'] = $user_id;
941 }
942
943 $earliest_outbox = \get_posts( $outbox_args );
944
945 /*
946 * Prefer `post_date_gmt`; fall back to local `post_date` when the GMT field
947 * is empty or the MySQL zero-date (rare corruption seen on some production
948 * sites). The zero-date is a truthy string, so it has to be detected
949 * explicitly rather than via `?:`.
950 *
951 * Convert the local `post_date` fallback to GMT first so the later
952 * `strtotime()` + `gmdate()` flow consistently derives the year in UTC.
953 */
954 $earliest_date = '';
955 if ( $earliest_outbox ) {
956 $gmt = $earliest_outbox[0]->post_date_gmt;
957 $earliest_date = ( empty( $gmt ) || '0000-00-00 00:00:00' === $gmt )
958 ? \get_gmt_from_date( $earliest_outbox[0]->post_date )
959 : $gmt;
960 }
961 }
962
963 $timestamp = \strtotime( (string) $earliest_date );
964 if ( false === $timestamp ) {
965 return null;
966 }
967
968 return (int) \gmdate( 'Y', $timestamp );
969 }
970 }
971