PluginProbe
Yoast SEO – Advanced SEO with real-time guidance and built-in AI / 28.2
Yoast SEO – Advanced SEO with real-time guidance and built-in AI v28.2
28.5 28.4 28.3 28.2 28.1 28.0 27.9 27.8 27.7 27.6 27.5 trunk 18.0 18.1 18.2 18.3 18.4 18.4.1 18.5 18.5.1 18.6 18.7 18.8 18.9 19.0 All 129 releases
wordpress-seo / src / bulk-editor / infrastructure / posts / post-meta-posts-collector.php

post-meta-posts-collector.php in Yoast SEO – Advanced SEO with real-time guidance and built-in AI 28.2, at src/bulk-editor/infrastructure/posts/post-meta-posts-collector.php

236 lines 7.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 // phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4 namespace Yoast\WP\SEO\Bulk_Editor\Infrastructure\Posts;
5
6 use WP_Query;
7 use Yoast\WP\SEO\Bulk_Editor\Application\Posts\Posts_Collector_Interface;
8 use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Post;
9 use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_List;
10 use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_Page;
11 use Yoast\WP\SEO\Bulk_Editor\Domain\Posts\Posts_Query;
12
13 /**
14 * Collects bulk editor posts by reading raw Yoast post meta.
15 *
16 * This is the fallback used when indexables are disabled.
17 */
18 class Post_Meta_Posts_Collector implements Posts_Collector_Interface {
19
20 use Post_Title_Trait;
21 use Searchable_Fields_Trait;
22
23 /**
24 * The Yoast post meta key prefix.
25 */
26 private const META_PREFIX = '_yoast_wpseo_';
27
28 /**
29 * The query var that flags our own query so the search filter only touches it.
30 */
31 private const SEARCH_FLAG = 'yoast_bulk_editor_search';
32
33 /**
34 * The prepared WHERE clause to append while our search query runs.
35 *
36 * @var string
37 */
38 private $search_where = '';
39
40 /**
41 * The resolver for the per-post edit permission.
42 *
43 * @var Post_Editability_Resolver
44 */
45 private $post_editability_resolver;
46
47 /**
48 * The constructor.
49 *
50 * @param Post_Editability_Resolver $post_editability_resolver The resolver for the per-post edit permission.
51 */
52 public function __construct( Post_Editability_Resolver $post_editability_resolver ) {
53 $this->post_editability_resolver = $post_editability_resolver;
54 }
55
56 /**
57 * Collects a page of posts for the given query.
58 *
59 * A single page is fetched and counted through WP_Query; the per-post edit permission is then resolved
60 * for that page so posts the user cannot edit are returned locked and without their SEO data.
61 *
62 * @param Posts_Query $query The query describing the page to collect.
63 *
64 * @return Posts_Page The collected posts together with the totals for pagination.
65 */
66 public function get_posts( Posts_Query $query ): Posts_Page {
67 $wp_query = $this->run_query( $query );
68 $post_ids = \array_map( 'intval', $wp_query->posts );
69
70 $editability = $this->post_editability_resolver->resolve( $post_ids );
71
72 $posts_list = new Posts_List();
73 foreach ( $post_ids as $post_id ) {
74 $posts_list->add( $this->build_post( $post_id, ( $editability[ $post_id ] ?? false ) ) );
75 }
76
77 return new Posts_Page( $posts_list, (int) $wp_query->found_posts, $query->get_page(), $query->get_per_page() );
78 }
79
80 /**
81 * Runs the WP_Query for the given query.
82 *
83 * When a search term is set, the catch-all clause is injected through a scoped posts_where filter:
84 * WP_Query AND-joins its own 's' and 'meta_query', which would miss posts matching only one side, so
85 * a single OR clause covering the post title and the Yoast meta is added instead.
86 *
87 * @param Posts_Query $query The query describing the page to collect.
88 *
89 * @return WP_Query The executed query.
90 */
91 protected function run_query( Posts_Query $query ): WP_Query {
92 $args = $this->build_query_args( $query );
93
94 if ( ! $query->has_search() ) {
95 return new WP_Query( $args );
96 }
97
98 $args[ self::SEARCH_FLAG ] = true;
99 $this->search_where = $this->build_search_where( $query->get_search() );
100
101 \add_filter( 'posts_where', [ $this, 'filter_posts_where' ], 10, 2 );
102 $wp_query = new WP_Query( $args );
103 \remove_filter( 'posts_where', [ $this, 'filter_posts_where' ], 10 );
104
105 $this->search_where = '';
106
107 return $wp_query;
108 }
109
110 /**
111 * Builds the WP_Query arguments for the given query.
112 *
113 * @param Posts_Query $query The query describing the page to collect.
114 *
115 * @return array<string, string|int|bool|array<string>> The WP_Query arguments.
116 */
117 private function build_query_args( Posts_Query $query ): array {
118 $args = [
119 'post_type' => $query->get_content_type(),
120 'post_status' => $query->get_statuses(),
121 // Exclude password-protected posts from bulk editing.
122 'has_password' => false,
123 'fields' => 'ids',
124 'posts_per_page' => $query->get_per_page(),
125 'paged' => $query->get_page(),
126 // Order by post ID so the result matches the indexable collector's ordering.
127 'orderby' => 'ID',
128 'order' => 'DESC',
129 'ignore_sticky_posts' => true,
130 // We render the title, status and Yoast meta, but never the terms, so don't prime the term cache.
131 'update_post_term_cache' => false,
132 ];
133
134 if ( $query->has_author_filter() ) {
135 $args['author'] = $query->get_author_id();
136 }
137
138 return $args;
139 }
140
141 /**
142 * Appends the prepared search clause to our own query's WHERE.
143 *
144 * @param string $where The WHERE clause so far.
145 * @param WP_Query $wp_query The query being filtered.
146 *
147 * @return string The WHERE clause, with the search clause appended for our query.
148 *
149 * @internal Only public because it is registered as a posts_where filter callback.
150 */
151 public function filter_posts_where( $where, $wp_query ): string {
152 if ( $wp_query->get( self::SEARCH_FLAG ) ) {
153 $where .= $this->search_where;
154 }
155
156 return $where;
157 }
158
159 /**
160 * Builds a post from its ID.
161 *
162 * The SEO data and edit link of a post the current user cannot edit are withheld, so the post is
163 * shown in the list but stays locked and does not expose its metadata.
164 *
165 * @param int $post_id The post ID.
166 * @param bool $editable Whether the current user may edit the post.
167 *
168 * @return Post The post.
169 */
170 private function build_post( int $post_id, bool $editable ): Post {
171 $post = \get_post( $post_id );
172 $status = ( $post !== null ) ? (string) $post->post_status : '';
173 $title = $this->get_normalized_title( $post_id );
174
175 if ( ! $editable ) {
176 return new Post( $post_id, $title, $status, '', '', '', '', '', '', false );
177 }
178
179 return new Post(
180 $post_id,
181 $title,
182 $status,
183 (string) \get_edit_post_link( $post_id, 'raw' ),
184 $this->get_meta( $post_id, 'focuskw' ),
185 $this->get_meta( $post_id, 'title' ),
186 $this->get_meta( $post_id, 'metadesc' ),
187 $this->get_meta( $post_id, 'opengraph-title' ),
188 $this->get_meta( $post_id, 'opengraph-description' ),
189 true,
190 );
191 }
192
193 /**
194 * Builds the prepared catch-all search WHERE clause.
195 *
196 * @param string $search The search term.
197 *
198 * @return string The prepared WHERE clause.
199 */
200 private function build_search_where( string $search ): string {
201 global $wpdb;
202
203 $like = '%' . $wpdb->esc_like( $search ) . '%';
204 $meta_keys = \array_map(
205 static function ( $suffix ) {
206 return self::META_PREFIX . $suffix;
207 },
208 \array_values( $this->searchable_fields() ),
209 );
210
211 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Reason: we're passing an array instead.
212 return $wpdb->prepare(
213 ' AND ( %i.post_title LIKE %s'
214 . ' OR %i.ID IN ( SELECT post_id FROM %i'
215 . ' WHERE meta_key IN ( ' . \implode( ', ', \array_fill( 0, \count( $meta_keys ), '%s' ) ) . ' ) AND meta_value LIKE %s ) )',
216 \array_merge( [ $wpdb->posts, $like, $wpdb->posts, $wpdb->postmeta ], $meta_keys, [ $like ] ),
217 );
218 // phpcs:enable
219 }
220
221 /**
222 * Reads a raw Yoast post meta value.
223 *
224 * Reads the raw meta directly so the stored value round-trips with the bulk update endpoint,
225 * regardless of which social options are enabled.
226 *
227 * @param int $post_id The post ID.
228 * @param string $key The meta key, without the Yoast prefix.
229 *
230 * @return string The meta value.
231 */
232 private function get_meta( int $post_id, string $key ): string {
233 return (string) \get_post_meta( $post_id, self::META_PREFIX . $key, true );
234 }
235 }
236