30 * DAY_IN_SECONDS, // Intentionally not translated: this filter runs before `init`, and calling __() that early triggers WP 6.7+ _load_textdomain_just_in_time notices. 'display' => 'Once a month (Gatewell)', ); return $schedules; } /** * Initialize scheduled events (call on plugin init). */ public static function schedule_cron() { if ( ! wp_next_scheduled( 'gatewell_aggregate_daily' ) ) { wp_schedule_event( strtotime( 'tomorrow midnight' ), 'daily', 'gatewell_aggregate_daily' ); } if ( ! wp_next_scheduled( 'gatewell_aggregate_monthly' ) ) { wp_schedule_event( strtotime( 'first day of next month midnight' ), 'gatewell_monthly', 'gatewell_aggregate_monthly' ); } if ( ! wp_next_scheduled( 'gatewell_prune_analytics' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'gatewell_prune_analytics' ); } } /** * Unschedule every event this class registers. * * @since 1.6.0 */ public static function unschedule_cron() { foreach ( self::get_cron_hooks() as $hook ) { wp_clear_scheduled_hook( $hook ); } } /** * Log an upload event. * * @param array $data Event data. * @return int|false Inserted row ID or false on failure. */ public function log_upload_event( $data ) { global $wpdb; $tables = \Gatewell\Migration::get_analytics_tables(); $defaults = array( 'post_id' => 0, 'post_type' => '', 'attachment_id' => 0, 'upload_context' => 'media-library', 'user_id' => get_current_user_id(), 'file_type' => '', 'file_size_original' => 0, 'file_size_final' => 0, 'was_cropped' => 0, 'was_compressed' => 0, 'was_rejected' => 0, 'rejection_reason' => '', 'alt_text_length' => 0, 'title_length' => 0, 'caption_length' => 0, 'description_length' => 0, 'alt_text_quality_score' => 0, 'title_quality_score' => 0, 'used_ai_alt_text' => 0, 'used_ai_title' => 0, ); $event = wp_parse_args( $data, $defaults ); // Auto-calculate quality scores from text if not provided. if ( empty( $event['alt_text_quality_score'] ) && $event['alt_text_length'] > 0 ) { // We don't have the actual text here, but score based on length + flags. $event['alt_text_quality_score'] = $this->score_from_length( $event['alt_text_length'] ); } // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned analytics table; $wpdb->insert() is the WordPress API for it, and an INSERT has no cacheable result. $result = $wpdb->insert( $tables['events'], array( 'created_at' => current_time( 'mysql' ), 'post_id' => (int) $event['post_id'], 'post_type' => sanitize_text_field( $event['post_type'] ), 'attachment_id' => (int) $event['attachment_id'], 'upload_context' => sanitize_text_field( $event['upload_context'] ), 'user_id' => (int) $event['user_id'], 'file_type' => sanitize_text_field( $event['file_type'] ), 'file_size_original' => (int) $event['file_size_original'], 'file_size_final' => (int) $event['file_size_final'], 'was_cropped' => (int) (bool) $event['was_cropped'], 'was_compressed' => (int) (bool) $event['was_compressed'], 'was_rejected' => (int) (bool) $event['was_rejected'], 'rejection_reason' => sanitize_text_field( $event['rejection_reason'] ), 'alt_text_length' => (int) $event['alt_text_length'], 'title_length' => (int) $event['title_length'], 'caption_length' => (int) $event['caption_length'], 'description_length' => (int) $event['description_length'], 'alt_text_quality_score' => min( 100, max( 0, (int) $event['alt_text_quality_score'] ) ), 'title_quality_score' => min( 100, max( 0, (int) $event['title_quality_score'] ) ), 'used_ai_alt_text' => (int) (bool) $event['used_ai_alt_text'], 'used_ai_title' => (int) (bool) $event['used_ai_title'], ), array( '%s', '%d', '%s', '%d', '%s', '%d', '%s', '%d', '%d', '%d', '%d', '%d', '%s', '%d', '%d', '%d', '%d', '%d', '%d', '%d', '%d', ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching if ( $result && empty( $event['was_rejected'] ) ) { // Accepted-upload counter; gates the one-time review prompt. update_option( 'gatewell_upload_count', (int) get_option( 'gatewell_upload_count', 0 ) + 1, false ); } return $result ? $wpdb->insert_id : false; } /** * Calculate text quality score (0-100). * * @param string $text Text to score. * @param string $field_type Field type: 'alt_text', 'title', 'caption'. * @return int Score 0-100. */ public function calculate_text_quality_score( $text, $field_type = 'alt_text' ) { if ( empty( $text ) ) { return 0; } $text = trim( $text ); $length = strlen( $text ); $score = 50; // Start at neutral // Length penalties/bonuses if ( $length < 10 ) { $score -= 50; } elseif ( $length < 20 ) { $score -= 20; } elseif ( $length >= 30 ) { $score += 20; } // Generic phrase penalty $lower_text = strtolower( $text ); foreach ( self::$generic_phrases as $phrase ) { if ( $lower_text === $phrase || preg_match( '/^' . preg_quote( $phrase, '/' ) . '\s*\d*$/i', $text ) ) { $score -= 40; break; } } // All-caps penalty if ( $text === strtoupper( $text ) && preg_match( '/[A-Z]/', $text ) ) { $score -= 20; } // Only numbers penalty if ( preg_match( '/^\d+$/', $text ) ) { $score -= 30; } // Descriptive word bonus (contains spaces = multiple words) $word_count = str_word_count( $text ); if ( $word_count >= 3 ) { $score += 10; } if ( $word_count >= 5 ) { $score += 10; } return min( 100, max( 0, $score ) ); } /** * Score based on text length alone (when text not available). * * @param int $length Text length. * @return int Score 0-100. */ private function score_from_length( $length ) { if ( $length === 0 ) return 0; if ( $length < 5 ) return 10; if ( $length < 10 ) return 25; if ( $length < 20 ) return 45; if ( $length < 30 ) return 60; if ( $length < 60 ) return 75; return 85; } /** * Aggregate events into daily summary for a given date. * * @param string $date Date string YYYY-MM-DD (defaults to yesterday). */ public function aggregate_daily( $date = '' ) { global $wpdb; if ( empty( $date ) ) { $date = gmdate( 'Y-m-d', strtotime( 'yesterday' ) ); } $tables = \Gatewell\Migration::get_analytics_tables(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names come from Migration::get_analytics_tables() -- $wpdb->prefix plus a hard-coded literal, never request data -- and a table name cannot be bound with prepare(). All runtime values are bound. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned analytics table, no WordPress API covers it. This is the nightly wp-cron aggregation pass; it reads each day exactly once, so a cache would never be hit. $events = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$tables['events']} WHERE DATE(created_at) = %s", $date ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( empty( $events ) ) { return; } $total_uploads = count( $events ); $total_rejections = 0; $ai_alt_count = 0; $ai_title_count = 0; $compression_ratios = array(); $alt_quality_scores = array(); $title_quality_scores = array(); $file_types = array(); $rejection_reasons = array(); $contexts = array(); $post_types = array(); foreach ( $events as $event ) { if ( $event->was_rejected ) { $total_rejections++; $reason = $event->rejection_reason ?: 'unknown'; $rejection_reasons[ $reason ] = ( $rejection_reasons[ $reason ] ?? 0 ) + 1; } if ( $event->used_ai_alt_text ) $ai_alt_count++; if ( $event->used_ai_title ) $ai_title_count++; if ( $event->file_size_original > 0 ) { $compression_ratios[] = ( $event->file_size_original - $event->file_size_final ) / $event->file_size_original * 100; } if ( $event->alt_text_quality_score > 0 ) { $alt_quality_scores[] = (int) $event->alt_text_quality_score; } if ( $event->title_quality_score > 0 ) { $title_quality_scores[] = (int) $event->title_quality_score; } $ft = $event->file_type ?: 'unknown'; $file_types[ $ft ] = ( $file_types[ $ft ] ?? 0 ) + 1; $ctx = $event->upload_context ?: 'unknown'; $contexts[ $ctx ] = ( $contexts[ $ctx ] ?? 0 ) + 1; $pt = $event->post_type ?: 'unknown'; $post_types[ $pt ] = ( $post_types[ $pt ] ?? 0 ) + 1; } // Top rejection reason $top_rejection = ''; if ( ! empty( $rejection_reasons ) ) { arsort( $rejection_reasons ); $top_rejection = array_key_first( $rejection_reasons ); } $avg_compression = ! empty( $compression_ratios ) ? array_sum( $compression_ratios ) / count( $compression_ratios ) : 0; $avg_alt_quality = ! empty( $alt_quality_scores ) ? array_sum( $alt_quality_scores ) / count( $alt_quality_scores ) : 0; $avg_title_quality = ! empty( $title_quality_scores ) ? array_sum( $title_quality_scores ) / count( $title_quality_scores ) : 0; // Upsert daily aggregate. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names come from Migration::get_analytics_tables() -- $wpdb->prefix plus a hard-coded literal, never request data -- and a table name cannot be bound with prepare(). All runtime values are bound. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned analytics table. This is the write side of the nightly aggregation; caching a row we are about to overwrite would be wrong. $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$tables['daily']} WHERE date = %s", $date ) ); $row_data = array( 'total_uploads' => $total_uploads, 'total_rejections' => $total_rejections, 'avg_compression_ratio' => round( $avg_compression, 2 ), 'avg_alt_text_quality' => round( $avg_alt_quality, 2 ), 'avg_title_quality' => round( $avg_title_quality, 2 ), 'ai_alt_text_used_count' => $ai_alt_count, 'ai_title_used_count' => $ai_title_count, 'top_rejection_reason' => substr( $top_rejection, 0, 255 ), 'file_type_counts' => wp_json_encode( $file_types ), 'rejection_reason_counts' => wp_json_encode( $rejection_reasons ), 'context_counts' => wp_json_encode( $contexts ), 'post_type_counts' => wp_json_encode( $post_types ), ); $row_format = array( '%d', '%d', '%f', '%f', '%f', '%d', '%d', '%s', '%s', '%s', '%s', '%s' ); if ( $existing ) { $wpdb->update( $tables['daily'], $row_data, array( 'date' => $date ), $row_format, array( '%s' ) ); } else { $row_data['date'] = $date; $row_format[] = '%s'; $wpdb->insert( $tables['daily'], $row_data, $row_format ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter // The daily rollup changed, so cached dashboard responses are stale. Analytics_Cache::flush(); } /** * Aggregate daily data into monthly summary. * * @param string $year_month Year-month string YYYY-MM (defaults to last month). */ public function aggregate_monthly( $year_month = '' ) { global $wpdb; if ( empty( $year_month ) ) { $year_month = gmdate( 'Y-m', strtotime( 'last month' ) ); } $tables = \Gatewell\Migration::get_analytics_tables(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names come from Migration::get_analytics_tables() -- $wpdb->prefix plus a hard-coded literal, never request data -- and a table name cannot be bound with prepare(). All runtime values are bound. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned analytics table. Monthly wp-cron rollup, runs once per month; nothing to cache. $daily_rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$tables['daily']} WHERE date LIKE %s", $year_month . '-%' ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter if ( empty( $daily_rows ) ) { return; } $total_uploads = 0; $total_rejections = 0; $quality_scores = array(); $ai_usage = 0; foreach ( $daily_rows as $row ) { $total_uploads += (int) $row->total_uploads; $total_rejections += (int) $row->total_rejections; $ai_usage += (int) $row->ai_alt_text_used_count + (int) $row->ai_title_used_count; if ( $row->avg_alt_text_quality > 0 ) { $quality_scores[] = (float) $row->avg_alt_text_quality; } } $avg_quality = ! empty( $quality_scores ) ? array_sum( $quality_scores ) / count( $quality_scores ) : 0; $summary = array( 'total_uploads' => $total_uploads, 'total_rejections' => $total_rejections, 'avg_quality' => round( $avg_quality, 2 ), 'ai_usage' => $ai_usage, 'days_tracked' => count( $daily_rows ), ); // `year_month` is backtick-quoted deliberately: YEAR_MONTH is a MySQL // reserved word, and leaving it bare is a syntax error. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names come from Migration::get_analytics_tables() -- $wpdb->prefix plus a hard-coded literal, never request data -- and a table name cannot be bound with prepare(). All runtime values are bound. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned analytics table; write side of the monthly rollup. $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$tables['monthly']} WHERE `year_month` = %s", $year_month ) ); $row_data = array( 'total_uploads' => $total_uploads, 'total_rejections' => $total_rejections, 'avg_quality_score' => round( $avg_quality, 2 ), 'ai_usage_count' => $ai_usage, 'summary_json' => wp_json_encode( $summary ), ); $row_format = array( '%d', '%d', '%f', '%d', '%s' ); if ( $existing ) { $wpdb->update( $tables['monthly'], $row_data, array( 'year_month' => $year_month ), $row_format, array( '%s' ) ); } else { $row_data['year_month'] = $year_month; $row_format[] = '%s'; $wpdb->insert( $tables['monthly'], $row_data, $row_format ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } /** * Prune old analytics data according to retention policy. * * - Raw events: 30 days * - Daily aggregates: 90 days * - Monthly aggregates: 2 years */ public function prune_old_data() { global $wpdb; $tables = \Gatewell\Migration::get_analytics_tables(); // Retention pruning. Every interval below is a hard-coded literal. // `year_month` must stay backtick-quoted (reserved word in MySQL). // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names come from Migration::get_analytics_tables() -- $wpdb->prefix plus a hard-coded literal, never request data -- and a table name cannot be bound with prepare(). All runtime values are bound. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned analytics tables; DELETE statements have no cacheable result. $wpdb->query( "DELETE FROM {$tables['events']} WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)" ); $wpdb->query( "DELETE FROM {$tables['daily']} WHERE date < DATE_SUB(CURDATE(), INTERVAL 90 DAY)" ); $cutoff = gmdate( 'Y-m', strtotime( '-2 years' ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM {$tables['monthly']} WHERE `year_month` < %s", $cutoff ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter Analytics_Cache::flush(); \Gatewell\Debug::log( 'Analytics data pruned', 'Gatewell' ); } /** * Clear ALL analytics data (used by settings page button). */ public function clear_all_data() { global $wpdb; $tables = \Gatewell\Migration::get_analytics_tables(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names come from Migration::get_analytics_tables() -- $wpdb->prefix plus a hard-coded literal, never request data -- and a table name cannot be bound with prepare(). All runtime values are bound. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned analytics tables; TRUNCATE has no cacheable result. foreach ( $tables as $table ) { $wpdb->query( "TRUNCATE TABLE {$table}" ); } // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter // Every cached dashboard response is now wrong; invalidate them all. Analytics_Cache::flush(); } /** * Get summary stats for a date range. * * @param int $days Number of days to look back. * @return array Stats array. */ public function get_summary( $days = 7 ) { global $wpdb; $tables = \Gatewell\Migration::get_analytics_tables(); $days = max( 1, (int) $days ); // This runs on every wp-admin dashboard load, so the two reads below are // cached for Analytics_Cache::TTL seconds. Anything that rewrites the // underlying rows bumps the cache generation, so the widget can never // show data the admin has already cleared. $cache_key = Analytics_Cache::key( 'summary_' . $days ); $rows = get_transient( $cache_key ); if ( false === $rows ) { // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names come from Migration::get_analytics_tables() -- $wpdb->prefix plus a hard-coded literal, never request data -- and a table name cannot be bound with prepare(). All runtime values are bound. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned analytics table; no WordPress API reads it. The result IS cached -- see the get_transient()/set_transient() pair wrapping this query. The sniff only recognises wp_cache_*() as caching, and a non-persistent object cache would be empty on every one of the separate AJAX requests this dashboard makes, so a short transient is the correct primitive here. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$tables['daily']} WHERE date >= DATE_SUB(CURDATE(), INTERVAL %d DAY) ORDER BY date ASC", $days ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter set_transient( $cache_key, $rows, Analytics_Cache::TTL ); } $total_uploads = 0; $total_rejections = 0; $space_saved = 0; $rejection_totals = array(); foreach ( $rows as $row ) { $total_uploads += (int) $row->total_uploads; $total_rejections += (int) $row->total_rejections; if ( ! empty( $row->rejection_reason_counts ) ) { $reasons = json_decode( $row->rejection_reason_counts, true ); if ( is_array( $reasons ) ) { foreach ( $reasons as $reason => $count ) { $rejection_totals[ $reason ] = ( $rejection_totals[ $reason ] ?? 0 ) + $count; } } } } // Also get space saved from raw events for the period (more accurate). $space_cache_key = Analytics_Cache::key( 'summary_space_' . $days ); $space_row = get_transient( $space_cache_key ); if ( false === $space_row ) { // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names come from Migration::get_analytics_tables() -- $wpdb->prefix plus a hard-coded literal, never request data -- and a table name cannot be bound with prepare(). All runtime values are bound. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned analytics table; no WordPress API reads it. The result IS cached -- see the get_transient()/set_transient() pair wrapping this query. The sniff only recognises wp_cache_*() as caching, and a non-persistent object cache would be empty on every one of the separate AJAX requests this dashboard makes, so a short transient is the correct primitive here. $space_row = $wpdb->get_row( $wpdb->prepare( "SELECT SUM(file_size_original - file_size_final) AS space_saved FROM {$tables['events']} WHERE created_at >= DATE_SUB(NOW(), INTERVAL %d DAY) AND was_rejected = 0", $days ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter set_transient( $space_cache_key, $space_row, Analytics_Cache::TTL ); } if ( $space_row && $space_row->space_saved ) { $space_saved = max( 0, (int) $space_row->space_saved ); } $top_rejection = ''; if ( ! empty( $rejection_totals ) ) { arsort( $rejection_totals ); $top_rejection = array_key_first( $rejection_totals ); } return array( 'total_uploads' => $total_uploads, 'total_rejections' => $total_rejections, 'space_saved' => $space_saved, 'top_rejection' => $top_rejection, 'days' => $days, ); } }