__( 'Site Report', 'aoedia' ), 'description' => __( 'Reports on the last N days: posts published (by category), drafts, scheduled posts, stale drafts, posts without an excerpt; comments received, waiting and marked as spam, and the most commented posts; unused categories and tags used on at most one post; and, for administrators, Aoedia requests and tokens per user. Choose only the sections you need.', 'aoedia' ), 'category' => 'aoedia-content', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'days' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 365, 'description' => __( 'Optional: How many days back to report on. Default 30.', 'aoedia' ), ), 'sections' => array( 'type' => 'array', 'items' => array( 'type' => 'string', 'enum' => AOEDIA_REPORT_SECTIONS, ), 'uniqueItems' => true, 'description' => __( 'Optional: content, comments, taxonomies and/or usage. Default all.', 'aoedia' ), ), ), 'additionalProperties' => false, 'default' => array(), ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'period' => array( 'type' => 'object' ), 'content' => array( 'type' => 'object' ), 'comments' => array( 'type' => 'object' ), 'taxonomies' => array( 'type' => 'object' ), 'usage' => array( 'type' => 'object' ), ), ), 'execute_callback' => 'aoedia_ability_site_report', // The report lists other people's drafts. 'permission_callback' => static function (): bool { return current_user_can( 'edit_others_posts' ); }, 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), 'show_in_rest' => true, ), ) ); } /** * Executes aoedia/site-report. * * @param array $input The ability input. * @return array The report. */ function aoedia_ability_site_report( $input = array() ): array { $input = is_array( $input ) ? $input : array(); $days = isset( $input['days'] ) ? min( 365, max( 1, (int) $input['days'] ) ) : 30; $sections = ! empty( $input['sections'] ) ? array_intersect( AOEDIA_REPORT_SECTIONS, (array) $input['sections'] ) : AOEDIA_REPORT_SECTIONS; $since = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ); $report = array( 'period' => array( 'days' => $days, 'from' => wp_date( 'Y-m-d', time() - $days * DAY_IN_SECONDS ), 'to' => wp_date( 'Y-m-d' ), ), ); if ( in_array( 'content', $sections, true ) ) { $report['content'] = aoedia_report_content( $since ); } if ( in_array( 'comments', $sections, true ) ) { $report['comments'] = aoedia_report_comments( $since ); } if ( in_array( 'taxonomies', $sections, true ) ) { $report['taxonomies'] = aoedia_report_taxonomies(); } if ( in_array( 'usage', $sections, true ) ) { $report['usage'] = current_user_can( 'manage_options' ) ? aoedia_report_usage( $since ) : array( 'note' => 'Only administrators can see Aoedia usage.' ); } return $report; } /** * Counts posts matching a query without loading them. * * @param array $args WP_Query arguments. * @return int The number of posts. */ function aoedia_report_count_posts( array $args ): int { $query = new WP_Query( array_merge( array( 'post_type' => 'post', 'posts_per_page' => 1, 'fields' => 'ids', ), $args ) ); return (int) $query->found_posts; } /** * Builds the content section of the report. * * @param string $since Start of the period, as a GMT MySQL date. * @return array The section. */ function aoedia_report_content( string $since ): array { $in_period = array( 'post_status' => 'publish', 'date_query' => array( array( 'column' => 'post_date_gmt', 'after' => $since, ), ), ); // Enough IDs to count categories on any normal blog. $published_ids = get_posts( array_merge( $in_period, array( 'post_type' => 'post', 'posts_per_page' => 2000, // phpcs:ignore WordPress.WP.PostsPerPage -- IDs only, for counting. 'fields' => 'ids', 'no_found_rows' => true, ) ) ); $by_category = array(); foreach ( $published_ids ? wp_get_object_terms( $published_ids, 'category', array( 'fields' => 'all_with_object_id' ) ) : array() as $term ) { $name = aoedia_plain_text( $term->name ); $by_category[ $name ] = ( $by_category[ $name ] ?? 0 ) + 1; } arsort( $by_category ); $stale = get_posts( array( 'post_type' => 'post', 'post_status' => 'draft', 'posts_per_page' => 10, 'orderby' => 'modified', 'order' => 'ASC', 'date_query' => array( // Local time: drafts that were never updated have no GMT modified date. array( 'column' => 'post_modified', 'before' => wp_date( 'Y-m-d H:i:s', time() - AOEDIA_STALE_DRAFT_DAYS * DAY_IN_SECONDS ), ), ), ) ); $without_excerpt = count( array_filter( $published_ids, static function ( int $id ): bool { return '' === trim( get_post_field( 'post_excerpt', $id ) ); } ) ); return array( 'published' => count( $published_ids ), 'by_category' => array_map( static function ( string $name, int $count ): array { return array( 'name' => $name, 'count' => $count, ); }, array_keys( $by_category ), array_values( $by_category ) ), 'without_excerpt' => $without_excerpt, 'drafts' => aoedia_report_count_posts( array( 'post_status' => 'draft' ) ), 'pending_review' => aoedia_report_count_posts( array( 'post_status' => 'pending' ) ), 'scheduled' => aoedia_report_count_posts( array( 'post_status' => 'future' ) ), 'stale_drafts' => array_map( static function ( WP_Post $post ): array { return array( 'id' => $post->ID, 'title' => aoedia_plain_title( $post ), 'modified' => (string) get_post_modified_time( 'Y-m-d', false, $post ), ); }, $stale ), ); } /** * Builds the comments section of the report. * * @param string $since Start of the period, as a GMT MySQL date. * @return array The section. */ function aoedia_report_comments( string $since ): array { $in_period = array( 'type' => 'comment', 'date_query' => array( array( 'column' => 'comment_date_gmt', 'after' => $since, ), ), ); $approved = get_comments( array_merge( $in_period, array( 'status' => 'approve', 'fields' => 'ids', 'number' => 2000, ) ) ); $per_post = array(); foreach ( $approved as $comment_id ) { $post_id = (int) get_comment( $comment_id )->comment_post_ID; $per_post[ $post_id ] = ( $per_post[ $post_id ] ?? 0 ) + 1; } arsort( $per_post ); $most_commented = array(); foreach ( array_slice( $per_post, 0, 5, true ) as $post_id => $count ) { $most_commented[] = array( 'id' => $post_id, 'title' => aoedia_plain_title( $post_id ), 'comments' => $count, ); } $counts = wp_count_comments(); return array( 'received' => count( $approved ) + (int) get_comments( array_merge( $in_period, array( 'status' => 'hold', 'count' => true, ) ) ), 'approved' => count( $approved ), 'spam' => (int) get_comments( array_merge( $in_period, array( 'status' => 'spam', 'count' => true, ) ) ), 'waiting_now' => (int) $counts->moderated, 'most_commented' => $most_commented, ); } /** * Builds the taxonomies section of the report. * * @return array The section. */ function aoedia_report_taxonomies(): array { $categories = get_terms( array( 'taxonomy' => 'category', 'hide_empty' => false, ) ); $unused = array(); foreach ( is_array( $categories ) ? $categories : array() as $category ) { if ( 0 === (int) $category->count && (int) get_option( 'default_category' ) !== $category->term_id ) { $unused[] = array( 'id' => $category->term_id, 'name' => aoedia_plain_text( $category->name ), ); } } $rare_tags = get_terms( array( 'taxonomy' => 'post_tag', 'hide_empty' => false, 'orderby' => 'count', 'order' => 'ASC', 'number' => 200, ) ); $rare_tags = array_values( array_filter( is_array( $rare_tags ) ? $rare_tags : array(), static function ( WP_Term $tag ): bool { return (int) $tag->count <= 1; } ) ); return array( 'categories' => (int) wp_count_terms( array( 'taxonomy' => 'category' ) ), 'tags' => (int) wp_count_terms( array( 'taxonomy' => 'post_tag' ) ), 'unused_categories' => $unused, // Tags on at most one post: candidates for merging or deleting. 'rare_tags' => count( $rare_tags ), 'rare_tags_list' => array_map( static function ( WP_Term $tag ): array { return array( 'id' => $tag->term_id, 'name' => aoedia_plain_text( $tag->name ), 'count' => (int) $tag->count, ); }, array_slice( $rare_tags, 0, 20 ) ), ); } /** * Builds the Aoedia usage section of the report. * * @param string $since Start of the period, as a GMT MySQL date. * @return array The section. */ function aoedia_report_usage( string $since ): array { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Plugin table; a report is always fresh. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT user_id, COUNT(*) AS requests, COALESCE( SUM( tokens ), 0 ) AS tokens FROM %i WHERE event = 'request' AND created_at >= %s GROUP BY user_id ORDER BY tokens DESC", aoedia_log_table(), $since ) ); $by_user = array(); foreach ( $rows as $row ) { $user = get_userdata( (int) $row->user_id ); $by_user[] = array( 'user' => $user ? $user->display_name : '#' . $row->user_id, 'requests' => (int) $row->requests, 'tokens' => (int) $row->tokens, ); } return array( 'requests' => array_sum( wp_list_pluck( $by_user, 'requests' ) ), 'tokens' => array_sum( wp_list_pluck( $by_user, 'tokens' ) ), 'by_user' => $by_user, ); }