$state aafm_undo_row_state() answer. * @param string $status The row's stored status. * @return array{variant: string, label: string} */ function aafm_changes_status_pill( array $state, string $status ): array { if ( ! empty( $state['undoable'] ) ) { return array( 'variant' => 'success', 'label' => __( 'Can revert', 'agent-abilities-for-mcp' ), ); } if ( 'reverted' === $status ) { return array( 'variant' => 'neutral', 'label' => __( 'Reverted', 'agent-abilities-for-mcp' ), ); } $reason = (string) ( $state['reason'] ?? '' ); if ( 'expired' === $reason ) { return array( 'variant' => 'neutral', 'label' => __( 'Expired', 'agent-abilities-for-mcp' ), ); } return array( 'variant' => 'warn', 'label' => __( 'Cannot revert', 'agent-abilities-for-mcp' ), ); } /** * The text of the Item cell: "Post 12" or "Term 4", by id and never a title. * * @param array $row A change row. * @return string Plain text. */ function aafm_changes_item_label( array $row ): string { return sprintf( /* translators: 1: "Post" or "Term", 2: the numeric id. */ __( '%1$s %2$d', 'agent-abilities-for-mcp' ), 'term' === $row['object_type'] ? __( 'Term', 'agent-abilities-for-mcp' ) : __( 'Post', 'agent-abilities-for-mcp' ), (int) $row['object_id'] ); } /** * The Item cell: a link to the edit screen by id, never a title. * * @param array $row A change row. * @return string HTML, escaped. */ function aafm_changes_item_html( array $row ): string { $id = (int) $row['object_id']; $label = aafm_changes_item_label( $row ); if ( 'term' === $row['object_type'] ) { $perm = (array) json_decode( (string) ( $row['perm_input'] ?? '{}' ), true ); $link = add_query_arg( array( 'taxonomy' => sanitize_key( (string) ( $perm['taxonomy'] ?? '' ) ), 'tag_ID' => $id, ), admin_url( 'term.php' ) ); } else { $link = (string) get_edit_post_link( $id, 'raw' ); } return '' === $link ? esc_html( $label ) : sprintf( '%s', esc_url( $link ), esc_html( $label ) ); } /** * Whether a post change has revisions to compare, for the Compare revisions link. * * @param array $row A change row. * @return string URL or ''. */ function aafm_changes_compare_url( array $row ): string { if ( 'post' !== $row['object_type'] || ! in_array( 'post_content', aafm_undo_field_names( $row ), true ) ) { return ''; } $post = aafm_exact_object( 'post', (int) $row['object_id'] ); if ( ! $post instanceof WP_Post || ! post_type_supports( $post->post_type, 'revisions' ) ) { return ''; } $revisions = wp_get_post_revisions( $post->ID, array( 'numberposts' => 1 ) ); return array() === $revisions ? '' : admin_url( 'revision.php?revision=' . (int) array_key_first( $revisions ) ); } /** * One table row for one change. * * @param array $row A change row. * @return string HTML, escaped. */ function aafm_changes_row_html( array $row ): string { $state = aafm_undo_row_state( $row, 'owner' ); $pill = aafm_changes_status_pill( $state, (string) $row['status'] ); $when = strtotime( (string) $row['created_at'] . ' UTC' ); $user = (int) $row['principal_user_id'] > 0 ? aafm_exact_object( 'user', (int) $row['principal_user_id'] ) : false; $by = $user instanceof WP_User ? (string) $user->display_name : __( 'Deleted user', 'agent-abilities-for-mcp' ); $who = 'owner' === $row['source'] ? __( 'you', 'agent-abilities-for-mcp' ) : __( 'agent', 'agent-abilities-for-mcp' ); $tool = (string) preg_replace( '#^aafm/#', '', (string) $row['ability'] ); $fields = implode( ', ', aafm_undo_field_names( $row ) ); $reason = empty( $state['undoable'] ) && ! empty( $state['reason'] ) ? aafm_undo_reason_message( (string) $state['reason'] ) : ''; $action = ''; if ( ! empty( $state['undoable'] ) ) { $action .= sprintf( '', (int) $row['id'], esc_attr( $fields ), esc_html__( 'Revert', 'agent-abilities-for-mcp' ), esc_attr( sprintf( /* translators: 1: change id, 2: "Post 12" or "Term 4". */ __( 'Revert change %1$d to %2$s', 'agent-abilities-for-mcp' ), (int) $row['id'], aafm_changes_item_label( $row ) ) ) ); } $compare = aafm_changes_compare_url( $row ); if ( '' !== $compare ) { $action .= sprintf( ' %s', esc_url( $compare ), esc_html__( 'Compare revisions', 'agent-abilities-for-mcp' ) ); } return sprintf( '%3$s%4$s%5$s%6$s%8$s%10$s%11$s%12$s', (int) $row['id'], esc_attr( gmdate( 'Y-m-d H:i:s', (int) $when ) . ' UTC' ), esc_html( sprintf( /* translators: %s: a time span such as "3 hours". */ __( '%s ago', 'agent-abilities-for-mcp' ), human_time_diff( (int) $when ) ) ), esc_html( $tool ), aafm_changes_item_html( $row ), esc_attr( $fields ), esc_attr( $by . ' (' . $who . ')' ), esc_html( $by . ' (' . $who . ')' ), esc_attr( $pill['variant'] ), esc_html( $pill['label'] ), '' === $reason ? '' : '
' . esc_html( $reason ) . '
', $action, esc_attr( aafm_changes_item_label( $row ) ) ); } /** * The group row for the changes one replace-sitewide call made: one line, Revert all, and each post listed. * * @param array $lead The newest row of the group. * @param list> $members Every visible row of the group, newest first. * @return string HTML, escaped. */ function aafm_changes_group_html( array $lead, array $members ): string { $when = strtotime( (string) $lead['created_at'] . ' UTC' ); $user = (int) $lead['principal_user_id'] > 0 ? aafm_exact_object( 'user', (int) $lead['principal_user_id'] ) : false; $by = $user instanceof WP_User ? (string) $user->display_name : __( 'Deleted user', 'agent-abilities-for-mcp' ); $items = ''; $any = false; foreach ( $members as $member ) { $state = aafm_undo_row_state( $member, 'owner' ); $any = $any || ! empty( $state['undoable'] ); $pill = aafm_changes_status_pill( $state, (string) $member['status'] ); $items .= sprintf( '
  • %1$s %3$s
  • ', aafm_changes_item_html( $member ), esc_attr( $pill['variant'] ), esc_html( $pill['label'] ), (int) $member['id'] ); } $action = $any ? sprintf( '', esc_attr( (string) $lead['group_id'] ), esc_attr( __( 'post content, on every post listed', 'agent-abilities-for-mcp' ) ), esc_html__( 'Revert all', 'agent-abilities-for-mcp' ), esc_attr( sprintf( /* translators: %d: how many posts one find and replace changed. */ _n( 'Revert all %d change made by one find and replace', 'Revert all %d changes made by one find and replace', count( $members ), 'agent-abilities-for-mcp' ), count( $members ) ) ) ) : ''; return sprintf( '%3$sreplace-sitewide
    %4$s
      %5$s
    %7$s%8$s', esc_attr( (string) $lead['group_id'] ), esc_attr( gmdate( 'Y-m-d H:i:s', (int) $when ) . ' UTC' ), esc_html( sprintf( /* translators: %s: a time span such as "3 hours". */ __( '%s ago', 'agent-abilities-for-mcp' ), human_time_diff( (int) $when ) ) ), esc_html( sprintf( /* translators: %d: how many posts one find and replace changed. */ _n( '%d post changed', '%d posts changed', count( $members ), 'agent-abilities-for-mcp' ), count( $members ) ) ), $items, esc_attr( $by . ' (' . __( 'agent', 'agent-abilities-for-mcp' ) . ')' ), esc_html( $by . ' (' . __( 'agent', 'agent-abilities-for-mcp' ) . ')' ), $action ); } /** * Render the Changes tab. * * @return void */ function aafm_render_changes_tab(): void { aafm_undo_age_batch(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only paging from the URL; nothing is written. $page = isset( $_GET['paged'] ) ? max( 1, absint( wp_unslash( $_GET['paged'] ) ) ) : 1; $listing = aafm_undo_list_for_tab( $page, AAFM_CHANGES_PER_PAGE ); $body = ''; if ( ! $listing['ok'] ) { $body = aafm_get_notice_html( 'error', __( 'The change history could not be read. Try again in a moment.', 'agent-abilities-for-mcp' ) ); } elseif ( array() === $listing['rows'] ) { $body = '
    ' . esc_html__( 'No changes recorded yet. When an agent edits a post, a term, a custom field or SEO data, the change shows up here.', 'agent-abilities-for-mcp' ) . '
    '; } else { $groups = array(); foreach ( $listing['rows'] as $row ) { if ( 'aafm/replace-sitewide' === $row['ability'] && '' !== (string) $row['group_id'] ) { $groups[ (string) $row['group_id'] ][] = $row; } } $rows_html = ''; $done = array(); foreach ( $listing['rows'] as $row ) { $key = (string) $row['group_id']; if ( 'aafm/replace-sitewide' === $row['ability'] && '' !== $key ) { if ( ! isset( $done[ $key ] ) ) { $done[ $key ] = true; $rows_html .= aafm_changes_group_html( $groups[ $key ][0], $groups[ $key ] ); } continue; } $rows_html .= aafm_changes_row_html( $row ); } $headers = array( __( 'When', 'agent-abilities-for-mcp' ), __( 'Tool', 'agent-abilities-for-mcp' ), __( 'Item', 'agent-abilities-for-mcp' ), __( 'Fields', 'agent-abilities-for-mcp' ), __( 'By', 'agent-abilities-for-mcp' ), __( 'Status', 'agent-abilities-for-mcp' ), __( 'Action', 'agent-abilities-for-mcp' ), ); $head = ''; foreach ( $headers as $label ) { $head .= '' . esc_html( $label ) . ''; } $pages = max( 1, (int) ceil( $listing['total'] / AAFM_CHANGES_PER_PAGE ) ); $base = admin_url( 'admin.php?page=agent-abilities-for-mcp&tab=changes' ); $prev = $page > 1 ? sprintf( '%s', esc_url( add_query_arg( 'paged', $page - 1, $base ) ), esc_html__( 'Previous', 'agent-abilities-for-mcp' ) ) : ''; $next = $page < $pages ? sprintf( '%s', esc_url( add_query_arg( 'paged', $page + 1, $base ) ), esc_html__( 'Next', 'agent-abilities-for-mcp' ) ) : ''; $body = sprintf( '
    %1$s%2$s
    %3$s%4$s%5$s
    ', $head, $rows_html, $prev, esc_html( sprintf( /* translators: 1: current page number, 2: total number of pages. */ __( 'Page %1$s of %2$s', 'agent-abilities-for-mcp' ), number_format_i18n( $page ), number_format_i18n( $pages ) ) ), $next ); } if ( aafm_undo_capture_off() ) { $body = aafm_get_notice_html( 'warning', __( 'Change history is turned off, so new changes are not recorded. Earlier ones stay until they expire. You can turn it back on in Settings.', 'agent-abilities-for-mcp' ) ) . $body; } $body .= sprintf( '

    ', esc_html__( 'Clear history', 'agent-abilities-for-mcp' ) ); echo '
    '; aafm_render_section( array( 'id' => 'aafm-changes-card', 'title' => __( 'Changes', 'agent-abilities-for-mcp' ), 'icon' => 'changes', 'description' => __( 'Changes agents made with the post, page, term, custom field and SEO tools are kept for 30 days while the plugin is active. Revert puts a change back if the item has not been edited since. Field names are shown, never their contents. When the history is full, the oldest values go first. Notifications, webhooks and anything already read are not recalled.', 'agent-abilities-for-mcp' ), 'body' => $body, ) ); echo '
    '; } /** * The common start of every Changes AJAX action: nonce, then capability, then an opened log row. * * Answers the request itself on a refusal. A bad nonce or a missing capability is logged inside the * per-IP denial cap and answered 403; a log row that cannot be opened refuses with not_logged (409) * and nothing is written. * * @param string $ability The ability label of the log row. * @return int The opened activity row id. */ function aafm_undo_ajax_begin( string $ability ): int { $nonce_ok = false !== check_ajax_referer( 'aafm_admin', 'nonce', false ); if ( ! $nonce_ok || ! current_user_can( 'manage_options' ) ) { if ( aafm_denial_log_within_cap( 'ud' ) ) { aafm_log_activity( array_merge( aafm_undo_log_principal(), array( 'ability' => $ability, 'status' => $nonce_ok ? 'denied' : 'error', 'event_type' => 'undo_action', 'detail' => $nonce_ok ? 'Refused: not allowed' : 'Refused: bad or missing nonce', ) ) ); } wp_send_json_error( array( 'message' => $nonce_ok ? __( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ) : __( 'Your session expired. Reload the page and try again.', 'agent-abilities-for-mcp' ) ), 403 ); } aafm_undo_age_batch(); $row_id = aafm_log_activity( array_merge( aafm_undo_log_principal(), array( 'ability' => $ability, 'status' => 'started', 'event_type' => 'undo_action', ) ) ); if ( $row_id < 1 ) { wp_send_json_error( array( 'message' => aafm_undo_reason_message( 'not_logged' ) ), 409 ); } return $row_id; } /** * Close the log row of a Changes AJAX action and answer. * * @param int $row_id The opened activity row. * @param string $status success, error or denied. * @param string $detail Ids and reason codes only. * @param array $payload The answer body. * @param bool $ok Whether to answer success or error. * @return never */ function aafm_undo_ajax_finish( int $row_id, string $status, string $detail, array $payload, bool $ok ): void { $closed = aafm_update_activity_status( $row_id, $status, null, $detail ); if ( ! $closed ) { // The change itself already happened, so the answer keeps the real outcome and adds the note. $payload['note'] = __( 'The activity log row for this action could not be finished.', 'agent-abilities-for-mcp' ); } if ( $ok ) { wp_send_json_success( $payload ); } wp_send_json_error( $payload, 'denied' === $status ? 403 : 409 ); } /** * AJAX: revert one change. * * @return void */ function aafm_ajax_undo_revert(): void { $row_id = aafm_undo_ajax_begin( 'aafm/owner-revert' ); // The nonce was checked just above; this only reads the id. $raw = isset( $_POST['change_id'] ) ? wp_unslash( $_POST['change_id'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- cast to an integer on the next line. $id = is_scalar( $raw ) ? absint( $raw ) : 0; $result = aafm_undo_apply( $id, 'owner' ); if ( is_wp_error( $result ) ) { $code = str_replace( 'aafm_undo_', '', (string) $result->get_error_code() ); $denied = 'not_allowed' === $code; /* translators: 1: change id, 2: reason code. */ $detail = sprintf( __( 'Revert refused for change %1$d: %2$s', 'agent-abilities-for-mcp' ), $id, $code ); aafm_undo_ajax_finish( $row_id, $denied ? 'denied' : 'error', $detail, array( 'message' => $result->get_error_message() ), false ); } $target = aafm_undo_get_view( $id )['row']; $detail = sprintf( /* translators: 1: outcome code, 2: change id, 3: object id. */ __( '%1$s: change %2$d on object %3$d', 'agent-abilities-for-mcp' ), (string) $result['outcome'], $id, (int) ( $target['object_id'] ?? 0 ) ); $payload = array( 'outcome' => $result['outcome'], 'new_change_id' => $result['new_change_id'], ); if ( isset( $result['differences'] ) ) { $payload['differences'] = $result['differences']; } aafm_undo_ajax_finish( $row_id, 'success', $detail, $payload, true ); } /** * AJAX: revert every change one replace-sitewide call made, newest first. * * @return void */ function aafm_ajax_undo_revert_group(): void { $row_id = aafm_undo_ajax_begin( 'aafm/owner-revert-group' ); // The nonce was checked just above; this only reads the group id. $group = isset( $_POST['group_id'] ) && is_string( $_POST['group_id'] ) ? wp_unslash( $_POST['group_id'] ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only matched against a strict pattern below, never stored. $members = 1 === preg_match( '/^[A-Za-z0-9]{32}$/', $group ) ? aafm_undo_group_members( $group ) : array( 'ok' => false, 'rows' => array(), ); $bad = ! $members['ok'] || array() === $members['rows']; foreach ( $members['rows'] as $member ) { $bad = $bad || 'aafm/replace-sitewide' !== $member['ability']; } if ( $bad ) { aafm_undo_ajax_finish( $row_id, 'error', 'Revert all refused: not a replace-sitewide group', array( 'message' => __( 'That group of changes cannot be reverted together.', 'agent-abilities-for-mcp' ) ), false ); } $results = array(); $landed = array(); foreach ( $members['rows'] as $member ) { $id = (int) $member['id']; $view = aafm_undo_get_view( $id ); // A member reverted by an earlier click is done, not a refusal: a retry only asks for what is left. if ( $view['ok'] && null !== $view['row'] && 'reverted' === $view['row']['status'] ) { $results[] = array( 'change_id' => $id, 'outcome' => 'already', ); continue; } $result = aafm_undo_apply( $id, 'owner' ); if ( is_wp_error( $result ) ) { $code = str_replace( 'aafm_undo_', '', (string) $result->get_error_code() ); $results[] = array( 'change_id' => $id, // A write that landed in part is a failure; every other no is a refusal. 'outcome' => 'failed' === $code ? 'failed' : 'refused', 'reason' => $code, 'message' => $result->get_error_message(), 'item' => $view['ok'] && null !== $view['row'] ? aafm_changes_item_label( $view['row'] ) : '', ); continue; } $landed[] = $id; $results[] = array( 'change_id' => $id, 'outcome' => $result['outcome'], ); } $skipped = array_filter( $results, static fn( array $r ): bool => in_array( $r['outcome'], array( 'refused', 'failed' ), true ) ); $detail = sprintf( /* translators: 1: how many changes were reverted, 2: how many were in the group, 3: comma-separated change ids. */ __( 'Reverted %1$d of %2$d changes in a group: %3$s', 'agent-abilities-for-mcp' ), count( $landed ), count( $results ), implode( ',', array_map( static fn( array $r ): int => (int) $r['change_id'], $results ) ) ); $payload = array( 'results' => $results ); if ( array() === $landed && array() !== $skipped ) { // Nothing reverted is an error, with the reasons in the message so the page can show them. $payload['message'] = sprintf( /* translators: %s: the changes that were not reverted, each with its reason. */ __( 'Nothing was reverted. %s', 'agent-abilities-for-mcp' ), implode( ' ', array_map( static function ( array $r ): string { $name = (string) ( $r['item'] ?? '' ); return sprintf( '%s: %s', '' !== $name ? $name : '#' . (int) $r['change_id'], (string) ( $r['message'] ?? '' ) ); }, $skipped ) ) ); } $failed = array() === $landed && array() !== $skipped; aafm_undo_ajax_finish( $row_id, $failed ? 'error' : 'success', $detail, $payload, ! $failed ); } /** * AJAX: empty the change history. * * @return void */ function aafm_ajax_undo_clear_history(): void { $row_id = aafm_undo_ajax_begin( 'aafm/owner-clear-history' ); if ( ! aafm_undo_clear() ) { aafm_undo_ajax_finish( $row_id, 'error', 'Clear history failed', array( 'message' => __( 'The change history could not be cleared. Try again in a moment.', 'agent-abilities-for-mcp' ) ), false ); } aafm_undo_ajax_finish( $row_id, 'success', 'Change history cleared', array( 'cleared' => true ), true ); } /** * The upgrade notice: history records from this update. Printed inside the plugin's own admin page, right under * the page head, while capture is on and the notice has not been dismissed. It is not an admin_notices notice: * that hook runs outside the page wrapper, where the plugin's styles do not reach. * * @return void */ function aafm_render_undo_notice(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } if ( aafm_undo_capture_off() || get_option( 'aafm_undo_notice_dismissed', false ) ) { return; } echo '
    '; aafm_render_notice( 'info', sprintf( /* translators: %s: link to the Settings tab, wrapped around the words "turn it off". */ __( 'Change history is now on. From this update, the changes agents make with the post, term, custom field and SEO tools are kept for 30 days so you can revert them from the Changes tab. You can %s in Settings.', 'agent-abilities-for-mcp' ), '' . esc_html__( 'turn it off', 'agent-abilities-for-mcp' ) . '' ) . '', array( 'html' => true ) ); echo '
    '; } /** * AJAX: dismiss the upgrade notice for good. * * @return void */ function aafm_ajax_undo_dismiss_notice(): void { check_ajax_referer( 'aafm_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'You are not allowed to do this.', 'agent-abilities-for-mcp' ) ), 403 ); } if ( ! aafm_update_option_verified( 'aafm_undo_notice_dismissed', '1' ) ) { wp_send_json_error( array( 'message' => __( 'That could not be saved. Try again.', 'agent-abilities-for-mcp' ) ), 409 ); } wp_send_json_success( array( 'dismissed' => true ) ); }