__( 'Comment', 'logged-ai' ), 'core' => __( 'Core', 'logged-ai' ), 'coupon' => __( 'Coupon', 'logged-ai' ), 'network' => __( 'Network', 'logged-ai' ), 'order' => __( 'Order', 'logged-ai' ), 'plugin' => __( 'Plugin', 'logged-ai' ), 'post' => __( 'Post', 'logged-ai' ), 'product' => __( 'Product', 'logged-ai' ), 'refund' => __( 'Refund', 'logged-ai' ), 'site' => __( 'Site', 'logged-ai' ), 'term' => __( 'Term', 'logged-ai' ), 'theme' => __( 'Theme', 'logged-ai' ), 'user' => __( 'User', 'logged-ai' ), 'object' => __( 'Object', 'logged-ai' ) )[ $object_type ] ?? ucfirst( str_replace( '_', ' ', $object_type ) ); if ( 'post' === $object_type && in_array( $event['code'] ?? '', array( 'content.post_created', 'content.post_changed', 'content.post_deleted' ), true ) ) { $recorded_type = $event['detail']['facts']['post_type'] ?? ''; $type = array( 'post' => __( 'Post', 'logged-ai' ), 'page' => __( 'Page', 'logged-ai' ), 'attachment' => __( 'Media item', 'logged-ai' ) )[ $recorded_type ] ?? __( 'Content', 'logged-ai' ); } $id = $event['object_id'] ?? null; return empty( $id ) ? sprintf( /* translators: %s: object type. */ __( '%s (identifier not captured)', 'logged-ai' ), $type ) : sprintf( /* translators: 1: object type, 2: recorded object identifier. */ __( '%1$s #%2$s', 'logged-ai' ), $type, $id ); } public static function actor( array $event ): string { return null !== ( $event['actor_id'] ?? null ) ? sprintf( /* translators: %s: recorded user identifier. */ __( 'User #%s', 'logged-ai' ), $event['actor_id'] ) : ( 'system' === ( $event['actor_kind'] ?? '' ) ? __( 'WordPress background task', 'logged-ai' ) : __( 'Actor not recorded', 'logged-ai' ) ); } public static function outcome( string $outcome ): string { return array( 'stored_change' => __( 'Change recorded', 'logged-ai' ), 'completed' => __( 'Completed', 'logged-ai' ), 'failed' => __( 'Operation failed', 'logged-ai' ), 'attempted' => __( 'Attempt recorded', 'logged-ai' ), 'unknown' => __( 'Result unknown', 'logged-ai' ) )[ $outcome ] ?? ucfirst( str_replace( '_', ' ', $outcome ) ); } private static function review_prompt( string $code ): string { return match ( $code ) { 'identity.roles_changed' => __( 'Confirm that this access change was intentional.', 'logged-ai' ), 'identity.application_password_created' => __( 'Check that you recognise this application access.', 'logged-ai' ), 'configuration.plugin_updated', 'configuration.plugin_deactivated' => __( 'Compare this change with your planned maintenance.', 'logged-ai' ), 'configuration.option_changed' => __( 'Check this against your recent site configuration changes.', 'logged-ai' ), 'identity.authentication_failed_burst', 'identity.account_auth_failed_burst' => __( 'Review the captured sign-in failures and their context.', 'logged-ai' ), default => __( 'Open the captured evidence and check whether this was expected.', 'logged-ai' ), }; } /** Counts only the supplied records; never silently presents a full-site trend. */ public static function chart( array $events, string $id = 'activity', bool $compact = false ): void { $categories = array(); $hours = array(); $severity = array(); foreach ( $events as $event ) { $category = (string) ( $event['category'] ?? 'other' ); $categories[ $category ] = ( $categories[ $category ] ?? 0 ) + 1; $hour = substr( (string) $event['occurred_at'], 0, 13 ) . ':00'; $hours[ $hour ] = ( $hours[ $hour ] ?? 0 ) + 1; $level = (string) ( $event['severity'] ?? 'unknown' ); $severity[ $level ] = ( $severity[ $level ] ?? 0 ) + 1; } arsort( $categories ); ksort( $hours ); arsort( $severity ); $total = count( $events ); $names = array( 'identity' => __( 'Users & access', 'logged-ai' ), 'content' => __( 'Content', 'logged-ai' ), 'configuration' => __( 'Site & plugins', 'logged-ai' ), 'commerce' => __( 'Commerce', 'logged-ai' ), 'privacy' => __( 'Privacy', 'logged-ai' ), 'other' => __( 'Other activity', 'logged-ai' ) ); $leading = array_key_first( $categories ); echo '

' . esc_html__( 'ACTIVITY ANALYSIS', 'logged-ai' ) . '

' . esc_html__( 'See the pattern.', 'logged-ai' ) . '

' ) . esc_html( $total ? sprintf( /* translators: 1: leading activity category, 2: category records, 3: total sample records. */ _n( '%1$s leads this sample, with %2$d of %3$d record.', '%1$s leads this sample, with %2$d of %3$d records.', $total, 'logged-ai' ), $names[ $leading ] ?? ucfirst( $leading ), $categories[ $leading ], $total ) : __( 'No retained events in this selection.', 'logged-ai' ) ) . '

' . esc_html( sprintf( /* translators: %d: record count. */ _n( '%d record', '%d records', $total, 'logged-ai' ), $total ) ) . '
'; echo ( '
' ); foreach ( array( 'category' => $categories, 'hours' => $hours, 'severity' => $severity ) as $kind => $values ) { echo '
' . esc_html( (string) $total ) . ( '' . wp_kses( __( 'retained
records', 'logged-ai' ), array( 'br' => array() ) ) . '
'; foreach ( $values as $key => $value ) { $label = 'category' === $kind ? ( $names[ $key ] ?? ucfirst( $key ) ) : ( 'hours' === $kind ? substr( $key, 5 ) . ' UTC' : self::severity( $key ) ); $percent = $total ? round( 100 * $value / $total, 1 ) : 0; echo '
' . esc_html( $label ) . '' . esc_html( (string) $value ) . ' ' . esc_html( (string) $percent ) . '%
'; } if ( ! $values ) { echo ( '

' . esc_html__( 'No chart data in this selection.', 'logged-ai' ) . '

' ); } echo '
'; } echo ( '

' . esc_html__( 'This selection only. Recorded hours show occupied UTC buckets, not a continuous traffic trend. Missing activity is not counted.', 'logged-ai' ) . '

' ); } /** Current directory context is deliberately separate from historical evidence. */ private static array $profiles = array(); private static array $profile_context = array(); public static function reset_profiles(): void { self::$profiles = array(); self::$profile_context = array(); } public static function profiles_valid(): bool { if ( ! self::$profile_context ) { return true; } if ( self::$profile_context !== array( get_current_blog_id(), get_current_user_id() ) ) { return false; } foreach ( self::$profiles as $id => $profile ) { if ( $profile && ( ! self::may_show_person( (int) $id ) ) ) { return false; } } return self::$profile_context === array( get_current_blog_id(), get_current_user_id() ); } private static function may_show_person( int $id ): bool { return $id > 0 && ( get_current_user_id() === $id || current_user_can( 'list_users' ) ) && is_user_member_of_blog( $id, get_current_blog_id() ); } private static function profile( mixed $id ): array { $id = (int) $id; if ( $id < 1 || ! function_exists( 'get_userdata' ) ) { return array(); } $context = array( get_current_blog_id(), get_current_user_id() ); if ( self::$profile_context && self::$profile_context !== $context ) { return array(); } self::$profile_context = $context; if ( array_key_exists( $id, self::$profiles ) ) { return self::$profiles[ $id ]; } self::$profiles[ $id ] = array(); if ( ! self::may_show_person( $id ) ) { return array(); } $user = get_userdata( $id ); if ( ! $user || (int) $user->ID !== $id ) { return array(); } $name = trim( (string) $user->display_name ); // Display local initials only; plugin pages never request an avatar provider. $name = $name ?: sprintf( /* translators: %s: recorded user identifier. */ __( 'User #%s', 'logged-ai' ), $id ); if ( self::$profile_context !== array( get_current_blog_id(), get_current_user_id() ) || ! self::may_show_person( $id ) ) { return array(); } return self::$profiles[ $id ] = array( 'name' => $name ); } private static function initials( string $name ): string { $parts = preg_split( '/\s+/u', trim( $name ) ) ?: array(); $letters = ''; foreach ( array_slice( $parts, 0, 2 ) as $part ) { if ( preg_match( '/^./us', $part, $match ) ) { $letters .= $match[0]; } } return function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $letters, 'UTF-8' ) : strtoupper( $letters ); } /** Local decorative initials: the adjacent, escaped name supplies its accessible label. */ public static function person( array $event, bool $subject = false ): string { $id = $event[ $subject ? 'subject_id' : 'actor_id' ] ?? null; $profile = self::profile( $id ); $system = ! $subject && 'system' === ( $event['actor_kind'] ?? '' ) && ! $id; $name = $profile['name'] ?? ( $id ? sprintf( /* translators: %s: recorded user identifier. */ __( 'User #%s', 'logged-ai' ), $id ) : ( $system ? 'WordPress' : __( 'Unknown actor', 'logged-ai' ) ) ); $meta = $id ? sprintf( /* translators: %s: recorded user identifier. */ __( 'User #%s', 'logged-ai' ), $id ) : ( $system ? __( 'Background task', 'logged-ai' ) : __( 'Identity not captured', 'logged-ai' ) ); $avatar = ''; } /** * Render current, permission-checked item information separately from retained evidence. * * @param array $event Authorized retained event; never modified by this presentation. * @param bool $detail Whether the full event inspector is being rendered. * @return void */ public static function content_target( array $event, bool $detail = false ): void { $target = ContentTarget::resolve( $event ); $history = ItemHistory::event_url( $event ); if ( ! $target ) { ActivityChanges::render( $event ); self::history_link( $history ); return; } echo '
'; if ( '' !== ( $target['title'] ?? '' ) ) { echo '
' . esc_html__( 'Current title', 'logged-ai' ) . '' . esc_html( $target['title'] ) . '
'; } ActivityChanges::render( $event ); $changes = self::content_changes( $event ); if ( $changes ) { echo '

' . esc_html( implode( ' · ', $changes ) ) . '

'; } if ( ( $detail || 'available' !== ( $target['state'] ?? '' ) ) && '' !== ( $target['message'] ?? '' ) ) { echo '

' . esc_html( $target['message'] ) . '

'; } if ( '' !== ( $target['url'] ?? '' ) || '' !== ( $target['revision_url'] ?? '' ) ) { echo '
'; foreach ( array( array( 'url', 'action_label' ), array( 'revision_url', 'revision_label' ) ) as $action ) { if ( '' !== ( $target[ $action[0] ] ?? '' ) ) { /* translators: %s: action such as Open content or View revision. */ $label = sprintf( __( '%s (opens in a new tab)', 'logged-ai' ), $target[ $action[1] ] ); echo '' . esc_html( $target[ $action[1] ] ) . ''; } } echo '' . esc_html__( 'Opens in a new tab', 'logged-ai' ) . '
'; } self::history_link( $history ); if ( $detail ) { if ( '' !== ( $target['revision_message'] ?? '' ) ) { echo '

' . esc_html( $target['revision_message'] ) . '

'; } if ( ! empty( $target['revision_url'] ) && array_intersect( array( 'post_name', 'post_date', 'post_date_gmt', 'comment_status', 'ping_status' ), array_column( $event['detail']['diffs'] ?? array(), 'field' ) ) ) { echo '

' . esc_html__( 'WordPress revisions may omit URL slug, publication date and discussion settings. Check the captured field differences below; those values remain redacted.', 'logged-ai' ) . '

'; } echo '

' . esc_html__( 'Current item details are shown only with your WordPress access. They can differ from this recorded event. Logged.ai does not store the title or article text here.', 'logged-ai' ) . '

'; } echo '
'; } /** * Render a secondary navigation action after the item's identifying context. * * @param string $url Fixed current-site item-history URL. */ private static function history_link( string $url ): void { if ( '' !== $url ) { echo '

' . esc_html__( 'View item activity', 'logged-ai' ) . '

'; } } /** * Summarize supported content changes without exposing redacted values. * * @param array $event Authorized retained event. * @return array Human-readable changes, taken only from retained field names/statuses. */ private static function content_changes( array $event ): array { if ( 'content.post_changed' !== ( $event['code'] ?? '' ) ) { return array(); } $names = array( 'post_title' => __( 'Title changed', 'logged-ai' ), 'post_content' => __( 'Content changed', 'logged-ai' ), 'post_excerpt' => __( 'Excerpt changed', 'logged-ai' ), 'post_author' => __( 'Author changed', 'logged-ai' ), 'post_password' => __( 'Password protection changed', 'logged-ai' ), 'post_parent' => __( 'Parent changed', 'logged-ai' ), 'menu_order' => __( 'Menu order changed', 'logged-ai' ), 'post_name' => __( 'URL slug changed', 'logged-ai' ), 'post_date' => __( 'Publication date changed', 'logged-ai' ), 'post_date_gmt' => __( 'Publication date changed', 'logged-ai' ), 'comment_status' => __( 'Comment settings changed', 'logged-ai' ), 'ping_status' => __( 'Pingback settings changed', 'logged-ai' ), ); $changes = array(); foreach ( $event['detail']['diffs'] ?? array() as $diff ) { $field = $diff['field'] ?? ''; if ( isset( $names[ $field ] ) ) { $changes[ $field ] = $names[ $field ]; } elseif ( 'post_status' === $field && is_string( $diff['before'] ?? null ) && is_string( $diff['after'] ?? null ) && 'redacted' !== ( $diff['availability'] ?? '' ) ) { /* translators: 1: recorded prior post status, 2: recorded new post status. */ $changes[ $field ] = sprintf( __( 'Status: %1$s → %2$s', 'logged-ai' ), self::post_status( $diff['before'] ), self::post_status( $diff['after'] ) ); } } return array_values( array_unique( $changes ) ); } /** * Label a captured content field without changing its stored identifier. * * @param string $field Retained field identifier. * @return string Translated label, or the original identifier for other schemas. */ public static function content_field( string $field ): string { return array( 'post_title' => __( 'Title', 'logged-ai' ), 'post_content' => __( 'Content', 'logged-ai' ), 'post_excerpt' => __( 'Excerpt', 'logged-ai' ), 'post_status' => __( 'Status', 'logged-ai' ), 'post_author' => __( 'Author', 'logged-ai' ), 'post_password' => __( 'Password protection', 'logged-ai' ), 'post_parent' => __( 'Parent', 'logged-ai' ), 'menu_order' => __( 'Menu order', 'logged-ai' ), 'post_name' => __( 'URL slug', 'logged-ai' ), 'post_date' => __( 'Publication date (site time)', 'logged-ai' ), 'post_date_gmt' => __( 'Publication date (UTC)', 'logged-ai' ), 'comment_status' => __( 'Comment settings', 'logged-ai' ), 'ping_status' => __( 'Pingback settings', 'logged-ai' ), )[ $field ] ?? $field; } /** * Label common recorded statuses; preserve custom status identifiers. * * @param string $status Retained status identifier. * @return string Human-readable status. */ private static function post_status( string $status ): string { return array( 'draft' => __( 'Draft', 'logged-ai' ), 'publish' => __( 'Published', 'logged-ai' ), 'pending' => __( 'Pending review', 'logged-ai' ), 'private' => __( 'Private', 'logged-ai' ), 'future' => __( 'Scheduled', 'logged-ai' ), 'trash' => __( 'Trash', 'logged-ai' ) )[ $status ] ?? $status; } private static function source_label( string $channel ): string { return array( 'wp_admin' => __( 'WordPress admin', 'logged-ai' ), 'cron' => __( 'Scheduled task', 'logged-ai' ), 'xmlrpc' => 'XML-RPC', 'rest' => 'REST API', 'cli' => __( 'Command line', 'logged-ai' ), 'frontend' => __( 'Site front end', 'logged-ai' ) )[ $channel ] ?? ucfirst( str_replace( '_', ' ', $channel ) ); } public static function severity( string $level ): string { return array( 'info' => __( 'Info', 'logged-ai' ), 'notice' => __( 'Notice', 'logged-ai' ), 'warning' => __( 'Warning', 'logged-ai' ), 'high' => __( 'High', 'logged-ai' ), 'critical' => __( 'Critical', 'logged-ai' ), 'unknown' => __( 'Unknown', 'logged-ai' ) )[ $level ] ?? ucfirst( $level ); } private static function review( array $event ): bool { return in_array( $event['severity'], array( 'warning', 'high', 'critical' ), true ); } private static function row_attributes( array $event ): void { echo ' data-lai-activity-row data-actor-id="' . esc_attr( (string) ( $event['actor_id'] ?? '' ) ) . '" data-review="' . ( self::review( $event ) ? '1' : '0' ) . '" data-failed="' . ( 'failed' === $event['outcome'] ? '1' : '0' ) . '" data-accounts="' . ( 'identity' === $event['category'] ? '1' : '0' ) . '"'; } private static function peek( array $event, TimeDisplay $time, string $url, string $list_url ): void { echo ( '
' . esc_html__( 'Quick details', 'logged-ai' ) . '
' . esc_html__( 'Event reference', 'logged-ai' ) . '
#' ) . esc_html( $event['id'] ) . ( '
' . esc_html__( 'Recorded severity', 'logged-ai' ) . '
' ) . esc_html( self::severity( $event['severity'] ) ) . ( '
' . esc_html__( 'Exact time ·', 'logged-ai' ) . ' ' ) . esc_html( $time->zone() ) . '
' . esc_html( $time->text( $event['occurred_at'] ) ) . ( '
' . esc_html__( 'Event type', 'logged-ai' ) . '
' ) . esc_html( $event['code'] ) . '
' . esc_html__( 'Open full evidence', 'logged-ai' ) . ' ' ) . wp_kses( Design::icon( 'arrow' ), Design::allowed_markup() ) . ''; if ( ! empty( $event['actor_id'] ) ) { $actor_url = remove_query_arg( array( 'event', 'cursor' ), add_query_arg( 'actor_id', $event['actor_id'], $list_url ) ); echo '' . esc_html__( 'More by this user', 'logged-ai' ) . '' ); } echo '
'; } public static function results( array $events, TimeDisplay $time, string $list_url ): void { DesignPro::invitation( 'report', 'activity', __( 'Reuse this view with a saved search.', 'logged-ai' ) ); $counts = array( 'all' => count( $events ), 'review' => 0, 'failed' => 0, 'accounts' => 0 ); $actors = array(); foreach ( $events as $event ) { $counts['review'] += (int) self::review( $event ); $counts['failed'] += (int) ( 'failed' === $event['outcome'] ); $counts['accounts'] += (int) ( 'identity' === $event['category'] ); if ( ! empty( $event['actor_id'] ) ) { $actors[ $event['actor_id'] ] = true; } } echo ( '

' . esc_html__( 'Recent activity', 'logged-ai' ) . '

' ) . esc_html( sprintf( /* translators: %d: number of records on this page. */ _n( '%d record on this page', '%d records on this page', count( $events ), 'logged-ai' ), count( $events ) ) ) . '
'; if ( ! $events ) { echo ( '

' . esc_html__( 'No matching changes recorded.', 'logged-ai' ) . '

' . esc_html__( 'Try a wider date range or clear a filter. Collection gaps can also produce an empty result.', 'logged-ai' ) . '

' ); return; } echo ( '
' . esc_html__( 'RECORDED CHANGES', 'logged-ai' ) . '' ) . esc_html( (string) $counts['all'] ) . ( '' . esc_html__( 'On this page', 'logged-ai' ) . '
' . esc_html__( 'ACTING USERS', 'logged-ai' ) . '' ) . count( $actors ) . ( '' . esc_html__( 'Distinct recorded user IDs', 'logged-ai' ) . '
' . esc_html__( 'NEEDS A LOOK', 'logged-ai' ) . '' ) . esc_html( (string) $counts['review'] ) . ( '' . esc_html__( 'Warning severity or higher', 'logged-ai' ) . '
' ); foreach ( $events as $event ) { $url = add_query_arg( 'event', $event['id'], $list_url ); $review = self::review( $event ); echo '
' . wp_kses( self::person( $event ), Design::allowed_markup() ) . '
' . esc_html( self::source_label( $event['channel'] ) ) . '' . esc_html( self::outcome( $event['outcome'] ) ) . '

' . esc_html( Design::label( $event['code'] ) ) . ( '

' . esc_html__( 'Affected', 'logged-ai' ) . '' ) . ( ! empty( $event['subject_id'] ) ? wp_kses( self::person( $event, true ), Design::allowed_markup() ) : '' . esc_html( self::object( $event ) ) . '' ) . '
'; self::content_target( $event ); if ( $review ) { echo '

' . esc_html( self::review_prompt( $event['code'] ) ) . '

'; } self::peek( $event, $time, $url, $list_url ); echo '
' . wp_kses( Design::icon( 'arrow' ), Design::allowed_markup() ) . '
'; } echo ( '
' ); foreach ( $events as $event ) { $url = add_query_arg( 'event', $event['id'], $list_url ); echo ''; } echo ( '
' . esc_html__( 'What happened', 'logged-ai' ) . '' . esc_html__( 'Who', 'logged-ai' ) . '' . esc_html__( 'Affected object', 'logged-ai' ) . '' . esc_html__( 'When', 'logged-ai' ) . '' . esc_html__( 'Result', 'logged-ai' ) . '' . esc_html__( 'Source / severity', 'logged-ai' ) . '
' . esc_html( Design::label( $event['code'] ) ) . '' . esc_html( $event['code'] . ' / #' . $event['id'] ) . ''; self::peek( $event, $time, $url, $list_url ); echo '' . wp_kses( self::person( $event ), Design::allowed_markup() ) . '' . ( ! empty( $event['subject_id'] ) ? wp_kses( self::person( $event, true ), Design::allowed_markup() ) : esc_html( self::object( $event ) ) ); self::content_target( $event ); echo '' . esc_html( self::review( $event ) ? sprintf( /* translators: %s: recorded outcome. */ __( 'Review: %s', 'logged-ai' ), self::outcome( $event['outcome'] ) ) : self::outcome( $event['outcome'] ) ) . '' . esc_html( $event['channel'] . ' / ' . $event['severity'] ) . '

' . esc_html( sprintf( /* translators: %s: display timezone. */ __( 'Names show current WordPress profiles when your access permits; recorded user IDs remain the evidence. Initials are generated locally without loading profile photos. Times: %s. Observations do not establish intent.', 'logged-ai' ), $time->zone() ) ) . '

' ); } public static function request_chart( array $groups ): void { if ( ! $groups ) { return; } $counts = array(); foreach ( $groups as $group ) { $family = $group['family']; $counts[ $family ] = ( $counts[ $family ] ?? 0 ) + (int) $group['admitted_count']; } arsort( $counts ); $max = max( $counts ); echo ( '

' . esc_html__( 'OBSERVED REQUEST PATTERNS', 'logged-ai' ) . '

' . esc_html__( 'What is reaching WordPress?', 'logged-ai' ) . '

' . esc_html__( 'Compare admitted findings in the retained minute groups below.', 'logged-ai' ) . '

' ); foreach ( $counts as $family => $count ) { $label = PresentationTranslations::text( RequestsClassifier::rules()[ $family ]['label'] ?? __( 'Retained request family', 'logged-ai' ) ); echo '
' . esc_html( $label ) . '' . esc_html( (string) $count ) . ( ' ' . esc_html__( 'observations', 'logged-ai' ) . '
'; } echo ( '

' . esc_html__( 'Bar lengths are relative to the largest family in this selection. Findings are lower bounds, not visitor counts or proof of an attack.', 'logged-ai' ) . '

' ); } }