entity_type ) { return $this->cap( implode( ', ', array_keys( $action->input ) ) ); } if ( null === $action->entity_id ) { return $this->label_without_id( $action ); } if ( in_array( $action->entity_type, self::POST_ENTITIES, true ) ) { $post = get_post( $action->entity_id ); return is_object( $post ) ? $this->cap( (string) $post->post_title ) : null; } if ( 'user' === $action->entity_type ) { $user = get_userdata( $action->entity_id ); return is_object( $user ) ? $this->cap( (string) $user->display_name ) : null; } if ( 'term' === $action->entity_type || 'menu' === $action->entity_type ) { $term = get_term( $action->entity_id ); // get_term() answers with a WP_Error for an unknown taxonomy, // which has no name to read. return is_object( $term ) && ! $term instanceof \WP_Error ? $this->cap( (string) $term->name ) : null; } if ( 'comment' === $action->entity_type ) { $comment = get_comment( $action->entity_id ); return is_object( $comment ) ? $this->cap( (string) $comment->comment_author ) : null; } return null; } /** * Name a target WordPress identifies with something other than a number. * * Themes, plugins, templates and widgets are addressed by a string in the * route, so there is no id to look up - and before AF-12.12 the string was * discarded, leaving a row that said an agent changed "theme" without * saying which. An upload has no id yet either, and names itself from the * title the request supplied. * * @param ActionDescriptor $action Normalized action. */ private function label_without_id( ActionDescriptor $action ): ?string { $slug = $action->entity_slug; if ( 'theme' === $action->entity_type && null !== $slug ) { // wp_get_theme() reads a cached theme object; get_plugins(), by // contrast, needs an admin include and scans the plugin // directory, so plugins keep their file below instead. $theme = wp_get_theme( $slug ); $name = is_object( $theme ) && $theme->exists() ? (string) $theme->get( 'Name' ) : ''; return $this->cap( '' !== $name ? $name : $slug ); } if ( in_array( $action->entity_type, array( 'plugin', 'template', 'widget', 'menu_item', 'block' ), true ) && null !== $slug ) { // The slug already answers "which one": akismet/akismet and // mytheme//home identify their targets completely. return $this->cap( $slug ); } // A post-backed target with no id yet - an upload, most often. if ( in_array( $action->entity_type, self::POST_ENTITIES, true ) && isset( $action->input['title'] ) && is_string( $action->input['title'] ) ) { return $this->cap( $action->input['title'] ); } return null !== $slug ? $this->cap( $slug ) : null; } /** * Current names for a batch of ids of one entity type. * * One query per type per page, never one per row. Read straight from the * table rather than through `WP_Query`, which filters by status and would * drop trashed targets - the ones an audit reader most wants to find. * * @param string $entity_type Entity type. * @param list $ids Entity ids. * @return array Name by id; absent where the entity is gone. */ public function current_names( string $entity_type, array $ids ): array { $ids = array_values( array_unique( array_filter( $ids, static fn ( $id ) => $id > 0 ) ) ); if ( array() === $ids ) { return array(); } $db = $this->db(); if ( null === $db ) { return array(); } $columns = $this->columns_for( $entity_type, $db ); if ( null === $columns ) { return array(); } list( $table, $id_column, $name_column ) = $columns; $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table and column names come from the fixed map above; ids are placeholders. $rows = $db->get_results( $db->prepare( "SELECT {$id_column} AS id, {$name_column} AS name FROM {$table} WHERE {$id_column} IN ( {$placeholders} )", ...$ids ), ARRAY_A ); // phpcs:enable $names = array(); foreach ( is_array( $rows ) ? $rows : array() as $row ) { $name = $this->cap( (string) ( $row['name'] ?? '' ) ); if ( null !== $name ) { $names[ (int) ( $row['id'] ?? 0 ) ] = $name; } } return $names; } /** * Admin URL for editing an entity, or null where there is no such screen. * * Built directly rather than through `get_edit_post_link()`, which loads * the post to answer - one query per row, which is exactly what * `current_names()` exists to avoid. The caller only asks once it knows * the entity still exists, and WordPress gates the screen itself. * * @param string $entity_type Entity type. * @param int $entity_id Entity id. */ public function edit_link( string $entity_type, int $entity_id ): ?string { if ( in_array( $entity_type, self::POST_ENTITIES, true ) ) { return admin_url( 'post.php?post=' . $entity_id . '&action=edit' ); } if ( 'user' === $entity_type ) { return admin_url( 'user-edit.php?user_id=' . $entity_id ); } return null; } /** * Table and columns naming one entity type, or null when it has none. * * @param string $entity_type Entity type. * @param wpdb $db Database handle. * @return array{0: string, 1: string, 2: string}|null */ private function columns_for( string $entity_type, wpdb $db ): ?array { if ( in_array( $entity_type, self::POST_ENTITIES, true ) ) { return array( (string) $db->posts, 'ID', 'post_title' ); } if ( 'user' === $entity_type ) { return array( (string) $db->users, 'ID', 'display_name' ); } if ( 'term' === $entity_type || 'menu' === $entity_type ) { return array( (string) $db->terms, 'term_id', 'name' ); } if ( 'comment' === $entity_type ) { return array( (string) $db->comments, 'comment_ID', 'comment_author' ); } return null; } /** * The database handle, from the constructor or the global. */ private function db(): ?wpdb { if ( $this->wpdb instanceof wpdb ) { return $this->wpdb; } return isset( $GLOBALS['wpdb'] ) && $GLOBALS['wpdb'] instanceof wpdb ? $GLOBALS['wpdb'] : null; } /** * Trim and cap a name, treating an empty one as no name at all. * * A post can genuinely have no title; rendering an empty string beside * the id would read as a failed lookup rather than an untitled post. * * @param string $value Candidate name. */ private function cap( string $value ): ?string { $value = trim( $value ); if ( '' === $value ) { return null; } return strlen( $value ) > self::MAX_LENGTH ? substr( $value, 0, self::MAX_LENGTH ) : $value; } }