wp_generate_uuid4(), 'timestamp' => time(), 'post_id' => $post_id, 'post_type' => $post_type, 'status' => $status, 'action' => $action, 'cost' => $cost, 'title' => $title, 'url' => $url, 'details' => $details, ); // Field-level context belongs only to successful generations; error entries continue to rely on their details. if ( 'success' === $status && $generated_fields ) { $new_entry['generated_fields'] = $generated_fields; } $maximum_attempts = 5; // Rebuild the bounded log from the latest authoritative row after every concurrent-write conflict. for ( $attempt = 1; $attempt <= $maximum_attempts; ++$attempt ) { $option_snapshot = ai4seo_get_raw_option_snapshot( AI4SEO_LATEST_ACTIVITY_OPTION_NAME ); if ( null === $option_snapshot ) { return false; } $latest_activity = $option_snapshot['exists'] && is_array( $option_snapshot['value'] ) ? $option_snapshot['value'] : array(); array_unshift( $latest_activity, $new_entry ); // Preserve the established maximum-log behavior while applying it to the latest snapshot. if ( count( $latest_activity ) >= AI4SEO_MAX_LATEST_ACTIVITY_LOGS ) { array_pop( $latest_activity ); } $compare_and_swap_result = ai4seo_compare_and_swap_option_snapshot( AI4SEO_LATEST_ACTIVITY_OPTION_NAME, $option_snapshot, $latest_activity, false ); if ( null === $compare_and_swap_result || true === $compare_and_swap_result ) { return true === $compare_and_swap_result; } } return false; } /** * Return the generated-field registry that belongs to an activity action. * * @param string $action Latest-activity action. * @return array Field details keyed by identifier. */ function ai4seo_get_latest_activity_generated_field_registry( string $action ): array { $action = sanitize_key( $action ); // Metadata activity resolves names through the shared generation-field declarations. if ( in_array( $action, array( 'metadata-manually-generated', 'metadata-bulk-generated' ), true ) && defined( 'AI4SEO_METADATA_DETAILS' ) ) { return ai4seo_get_generation_field_descriptors( 'metadata' ); } // Media activity retains its own field identifiers and translated labels. if ( in_array( $action, array( 'attachment-attributes-manually-generated', 'attachment-attributes-bulk-generated' ), true ) && defined( 'AI4SEO_ATTACHMENT_ATTRIBUTES_DETAILS' ) ) { return ai4seo_get_generation_field_descriptors( 'attachment_attributes' ); } return array(); } /** * Normalize generated-field identifiers against the registry for an activity action. * * @param string $action Latest-activity action. * @param array $generated_fields Generated-field identifiers. * @return array Valid unique identifiers in the supplied order. */ function ai4seo_normalize_latest_activity_generated_fields( string $action, array $generated_fields ): array { $field_registry = ai4seo_get_latest_activity_generated_field_registry( $action ); $normalized_generated_fields = array(); // Use identifiers as keys while validating so duplicates collapse without changing first-seen order. foreach ( $generated_fields as $this_generated_field ) { if ( ! is_string( $this_generated_field ) ) { continue; } $this_generated_field = sanitize_key( $this_generated_field ); if ( '' === $this_generated_field || ! isset( $field_registry[ $this_generated_field ] ) ) { continue; } $normalized_generated_fields[ $this_generated_field ] = $this_generated_field; } return array_values( $normalized_generated_fields ); } /** * Return a compact localized summary of fields generated by an activity event. * * @param array $latest_activity_entry Latest-activity entry. * @return string Generated-field summary or an empty string for legacy/malformed entries. */ function ai4seo_get_latest_activity_generated_fields_summary( array $latest_activity_entry ): string { $action = sanitize_key( (string) ( $latest_activity_entry['action'] ?? '' ) ); $generated_fields = is_array( $latest_activity_entry['generated_fields'] ?? null ) ? ai4seo_normalize_latest_activity_generated_fields( $action, $latest_activity_entry['generated_fields'] ) : array(); if ( ! $generated_fields ) { return ''; } $field_registry = ai4seo_get_latest_activity_generated_field_registry( $action ); $field_names = array(); // Resolve persisted identifiers at render time so translated declaration labels remain authoritative. foreach ( $generated_fields as $this_generated_field ) { $this_field_name = sanitize_text_field( (string) ( $field_registry[ $this_generated_field ]['name'] ?? '' ) ); if ( '' !== $this_field_name ) { $field_names[] = $this_field_name; } } $num_field_names = count( $field_names ); // Keep short lists complete with translatable ordering and punctuation. if ( 1 === $num_field_names ) { return $field_names[0]; } if ( 2 === $num_field_names ) { return sprintf( /* translators: 1: First generated field name. 2: Second generated field name. */ __( '%1$s, %2$s', 'ai-for-seo' ), $field_names[0], $field_names[1] ); } if ( 3 === $num_field_names ) { return sprintf( /* translators: 1: First generated field name. 2: Second generated field name. 3: Third generated field name. */ __( '%1$s, %2$s, %3$s', 'ai-for-seo' ), $field_names[0], $field_names[1], $field_names[2] ); } $visible_field_names = array_slice( $field_names, 0, 2 ); $num_remaining_fields = $num_field_names - count( $visible_field_names ); return sprintf( /* translators: 1: First generated field name. 2: Second generated field name. 3: Number of additional generated fields. */ __( '%1$s, %2$s (+%3$s)', 'ai-for-seo' ), $visible_field_names[0], $visible_field_names[1], ai4seo_format_number_i18n( $num_remaining_fields ) ); } /** * Return independently renderable message parts for a latest-activity entry. * * @param array $latest_activity_entry Latest-activity entry. * @return array Localized summary and optional diagnostic detail. */ function ai4seo_get_latest_activity_message_parts( array $latest_activity_entry ): array { $action = sanitize_key( (string) ( $latest_activity_entry['action'] ?? '' ) ); $status = sanitize_key( (string) ( $latest_activity_entry['status'] ?? '' ) ); $details = sanitize_text_field( (string) ( $latest_activity_entry['details'] ?? '' ) ); $generated_fields_summary = ai4seo_get_latest_activity_generated_fields_summary( $latest_activity_entry ); $action_message = ''; // Preserve the established action labels while moving their selection out of the dashboard template. switch ( $action ) { case 'metadata-manually-generated': $action_message = __( 'Metadata manually generated', 'ai-for-seo' ); break; case 'metadata-bulk-generated': $action_message = __( 'Metadata generated (by SEO Autopilot)', 'ai-for-seo' ); break; case 'attachment-attributes-manually-generated': $action_message = __( 'Media attributes manually generated', 'ai-for-seo' ); break; case 'attachment-attributes-bulk-generated': $action_message = __( 'Media attributes generated (by SEO Autopilot)', 'ai-for-seo' ); break; } // Enrich only recognized actions, leaving malformed and legacy entries on the established fallback path. if ( '' !== $action_message && '' !== $generated_fields_summary ) { $action_message = sprintf( /* translators: 1: Activity action. 2: Generated field summary. */ __( '%1$s: %2$s', 'ai-for-seo' ), $action_message, $generated_fields_summary ); } // Successful entries expose diagnostics separately; error entries preserve the previous detail-first presentation. if ( 'success' === $status && '' !== $action_message ) { return array( 'summary' => $action_message, 'details' => $details, ); } return array( 'summary' => '' === $details ? $action_message : $details, 'details' => '', ); } /** * Filter latest activity entries to objects visible to the current plugin user. * * Site administrators retain the site-wide operational view. Content users only receive entries * for objects they may edit through WordPress's object-level capability mapping. * * @param array $latest_activity Latest activity entries. * @return array Visible latest activity entries in their original order. */ function ai4seo_filter_latest_activity_entries_for_current_user( array $latest_activity ): array { // Stored options and third-party filters may contain scalar rows; only structured entries can be rendered safely. $latest_activity = array_filter( $latest_activity, 'is_array' ); if ( ! ai4seo_can_administer_plugin() ) { $latest_activity = array_filter( $latest_activity, static function ( $latest_activity_entry ): bool { return ai4seo_can_edit_post( absint( $latest_activity_entry['post_id'] ?? 0 ) ); } ); } return array_values( $latest_activity ); } /** * Returns latest activity entries keyed by post ID. * * @param array $actions Optional activity actions to include. * @param array $statuses Optional activity statuses to include. * @return array Latest activity entries keyed by post ID. */ function ai4seo_get_latest_activity_entries_by_post_id( array $actions = array(), array $statuses = array() ): array { $latest_activity = ai4seo_get_option( AI4SEO_LATEST_ACTIVITY_OPTION_NAME, array() ); if ( ! is_array( $latest_activity ) || ! $latest_activity ) { return array(); } $allowed_actions = array(); foreach ( $actions as $this_action ) { $this_action = sanitize_text_field( (string) $this_action ); if ( '' !== $this_action ) { $allowed_actions[ $this_action ] = true; } } $allowed_statuses = array(); foreach ( $statuses as $this_status ) { $this_status = sanitize_text_field( (string) $this_status ); if ( '' !== $this_status ) { $allowed_statuses[ $this_status ] = true; } } $latest_activity_entries_by_post_id = array(); foreach ( $latest_activity as $this_latest_activity_entry ) { if ( ! is_array( $this_latest_activity_entry ) ) { continue; } $this_post_id = absint( $this_latest_activity_entry['post_id'] ?? 0 ); if ( ! $this_post_id || isset( $latest_activity_entries_by_post_id[ $this_post_id ] ) ) { continue; } $this_action = sanitize_text_field( (string) ( $this_latest_activity_entry['action'] ?? '' ) ); $this_status = sanitize_text_field( (string) ( $this_latest_activity_entry['status'] ?? '' ) ); if ( $allowed_actions && ! isset( $allowed_actions[ $this_action ] ) ) { continue; } if ( $allowed_statuses && ! isset( $allowed_statuses[ $this_status ] ) ) { continue; } $latest_activity_entries_by_post_id[ $this_post_id ] = $this_latest_activity_entry; } return $latest_activity_entries_by_post_id; } /** * Returns the subtext shown for entries that are present in the latest activity list. * * @param string $details_onclick JavaScript onclick action that opens the matching editor. * @param array $latest_activity_entry Optional latest activity entry data. * @return string HTML. */ function ai4seo_get_recent_activity_details_subtext_tag( string $details_onclick, array $latest_activity_entry = array() ): string { $details_onclick = trim( $details_onclick ); if ( '' === $details_onclick || true === ( $latest_activity_entry['warning_resolved'] ?? false ) ) { return ''; } $activity_status = sanitize_key( (string) ( $latest_activity_entry['status'] ?? '' ) ); $activity_details = sanitize_text_field( (string) ( $latest_activity_entry['details'] ?? '' ) ); $message_css_class = 'ai4seo-coverage-status'; $icon_name = 'circle'; $icon_css_class = 'ai4seo-dark-gray-icon'; $icon_alt_text = __( 'Recent activity', 'ai-for-seo' ); $message_text = __( 'This entry has recent activity.', 'ai-for-seo' ); if ( 'success' === $activity_status ) { $icon_name = 'circle-check'; $icon_css_class = 'ai4seo-dark-green-icon'; $icon_alt_text = __( 'Recently processed by SEO Autopilot', 'ai-for-seo' ); $message_text = __( 'SEO Autopilot has recently processed this entry.', 'ai-for-seo' ); } elseif ( 'error' === $activity_status ) { $message_css_class = 'ai4seo-coverage-status ai4seo-coverage-status-error'; $icon_name = 'circle-xmark'; $icon_css_class = 'ai4seo-red-icon'; $icon_alt_text = __( 'Recent SEO Autopilot processing failed', 'ai-for-seo' ); $message_text = __( 'Recent SEO Autopilot processing failed.', 'ai-for-seo' ); } $activity_token = ai4seo_get_activity_error_token( $latest_activity_entry ); $output = "
"; $output .= ai4seo_get_svg_tag( $icon_name, esc_html( $icon_alt_text ), $icon_css_class ); $output .= ' '; $output .= esc_html( $message_text ); if ( 'error' === $activity_status && '' !== $activity_details ) { $output .= ' '; $output .= esc_html( $activity_details ); } $output .= ' '; $output .= ai4seo_get_small_icon_button_tag( '', esc_html__( 'Check details', 'ai-for-seo' ), '', $details_onclick ); if ( '' !== $activity_token ) { $dismiss_onclick = 'ai4seo_dismiss_recent_activity_error(this, ' . (int) $latest_activity_entry['post_id'] . ', ' . wp_json_encode( $latest_activity_entry['action'] ) . ', ' . wp_json_encode( $activity_token ) . ');'; $output .= ' ' . ai4seo_get_small_icon_button_tag( '', esc_html__( 'Dismiss', 'ai-for-seo' ), '', $dismiss_onclick ); } $output .= '
'; return $output; } /** * Identify one Autopilot failure without including its mutable acknowledgement. * * @param array $entry Activity entry, including legacy entries without an event ID. * @return string Opaque identity, or empty for entries that are not Autopilot errors. */ function ai4seo_get_activity_error_token( array $entry ): string { if ( 'error' !== ( $entry['status'] ?? '' ) || empty( $entry['post_id'] ) || ! in_array( $entry['action'] ?? '', array( 'metadata-bulk-generated', 'attachment-attributes-bulk-generated' ), true ) ) { return ''; } unset( $entry['warning_resolved'] ); $encoded_entry = wp_json_encode( $entry ); return is_string( $encoded_entry ) ? hash( 'sha256', $encoded_entry ) : ''; } /** * Capture the warning before a manual operation so it cannot resolve a later failure. * * @param int $post_id Target post. * @param string $action Autopilot action for the editor's domain. * @return string Current unresolved error identity, or empty when there is none. */ function ai4seo_get_recent_activity_error_token( int $post_id, string $action ): string { $entries = ai4seo_get_latest_activity_entries_by_post_id( array( $action ) ); $entry = $entries[ $post_id ] ?? array(); return empty( $entry['warning_resolved'] ) ? ai4seo_get_activity_error_token( $entry ) : ''; } /** * Acknowledge an exact current failure while retaining the complete activity history. * * @param int $post_id Target post. * @param string $action Autopilot action for the editor's domain. * @param string $token Identity captured before saving, generating, or dismissing. * @return bool Whether this warning was resolved or expired from history; false covers newer events and storage failures. */ function ai4seo_resolve_recent_activity_error( int $post_id, string $action, string $token ): bool { if ( $post_id <= 0 || ! preg_match( '/^[a-f0-9]{64}$/D', $token ) || ! in_array( $action, array( 'metadata-bulk-generated', 'attachment-attributes-bulk-generated' ), true ) ) { return false; } // Retry against fresh snapshots so acknowledgements never overwrite concurrent log appends. for ( $attempt = 0; $attempt < 5; ++$attempt ) { $snapshot = ai4seo_get_raw_option_snapshot( AI4SEO_LATEST_ACTIVITY_OPTION_NAME ); if ( null === $snapshot || ! is_array( $snapshot['value'] ) ) { return false; } $entries = $snapshot['value']; foreach ( $entries as $index => $entry ) { if ( ! is_array( $entry ) || (int) ( $entry['post_id'] ?? 0 ) !== $post_id || ( $entry['action'] ?? '' ) !== $action ) { continue; } // Only the newest event in this domain can be acknowledged, including repeated requests. if ( ! hash_equals( $token, ai4seo_get_activity_error_token( $entry ) ) ) { return false; } if ( true === ( $entry['warning_resolved'] ?? false ) ) { return true; } $entries[ $index ]['warning_resolved'] = true; $result = ai4seo_compare_and_swap_option_snapshot( AI4SEO_LATEST_ACTIVITY_OPTION_NAME, $snapshot, $entries, false ); if ( null === $result || true === $result ) { return true === $result; } continue 2; } // A manual generation can evict the old failure from the bounded log before acknowledgement. foreach ( $entries as $entry ) { if ( is_array( $entry ) && hash_equals( $token, ai4seo_get_activity_error_token( $entry ) ) ) { return false; } } return true; } return false; } // endregion // ___________________________________________________________________________________________.