| 1 |
<?php |
| 2 |
/** |
| 3 |
* Persisted word count per post. |
| 4 |
* |
| 5 |
* @package ThinkRank\SEO |
| 6 |
* @since 2.10.0 |
| 7 |
*/ |
| 8 |
|
| 9 |
declare(strict_types=1); |
| 10 |
|
| 11 |
namespace ThinkRank\SEO; |
| 12 |
|
| 13 |
use ThinkRank\Core\Seo_Text; |
| 14 |
|
| 15 |
// Prevent direct access |
| 16 |
if (!defined('ABSPATH')) { |
| 17 |
exit; |
| 18 |
} |
| 19 |
|
| 20 |
/** |
| 21 |
* Word Count Index |
| 22 |
* |
| 23 |
* "Which of my pages are thin?" is a question about every published page, and |
| 24 |
* answering it honestly means counting the words a visitor actually reads — |
| 25 |
* which on an Elementor, Divi, Oxygen, Beaver or Bricks page are not in |
| 26 |
* `post_content` at all (#565). |
| 27 |
* |
| 28 |
* That makes the count expensive in a way the snippet index's is not. |
| 29 |
* {@see Builder_Content::resolve()} walks a builder's stored tree, so it cannot |
| 30 |
* run over a whole site inside one request. The answer is the same shape as |
| 31 |
* {@see Snippet_Index}: count once, store the number in post meta, and answer |
| 32 |
* every report by SQL over the stored numbers. Counting happens a bounded batch |
| 33 |
* at a time and the caller is told how many are left. |
| 34 |
* |
| 35 |
* Deliberately a **separate** index rather than a fourth field on the snippet |
| 36 |
* index, because the two are invalidated by different things. A word count |
| 37 |
* changes when the body changes; a snippet verdict changes when a title |
| 38 |
* template, the site name or the separator changes. Sharing one entry would |
| 39 |
* mean every edit to an SEO template re-resolved every builder page on the |
| 40 |
* site, which is the one cost this class exists to avoid. |
| 41 |
* |
| 42 |
* The stored value is `{version}:{unit}:{count}`. There is no generation: |
| 43 |
* almost nothing global changes a post's word count, so a post's own edits are |
| 44 |
* what make its entry stale. The version exists so that changing how counting |
| 45 |
* works retires every entry with one constant. |
| 46 |
* |
| 47 |
* The two global things that do change a count are handled explicitly. The |
| 48 |
* **unit** is written into the entry, because a locale switch between words |
| 49 |
* and characters changes every number at once, and an entry is only current |
| 50 |
* while its unit is the one the site counts in now. **Content a post pulls in |
| 51 |
* by reference** (a synced pattern, a navigation menu) is expanded by |
| 52 |
* `do_blocks()` before counting, so editing it changes the count of every post |
| 53 |
* that references it; {@see on_referenced_change()} retires those entries. |
| 54 |
* |
| 55 |
* @since 2.10.0 |
| 56 |
*/ |
| 57 |
class Word_Count_Index { |
| 58 |
|
| 59 |
/** |
| 60 |
* Post meta holding the entry. |
| 61 |
*/ |
| 62 |
public const META_KEY = '_thinkrank_word_count'; |
| 63 |
|
| 64 |
/** |
| 65 |
* Counting rules version. Bump to retire every stored entry. |
| 66 |
* |
| 67 |
* Version 3 stopped counting shortcode syntax as words and stopped merging |
| 68 |
* two words that only a tag separated (#893), so every version 2 entry is |
| 69 |
* recounted once. |
| 70 |
* |
| 71 |
* Version 4 stopped counting tokens with no letter or digit in them. The |
| 72 |
* tag-to-space change in version 3 left the punctuation after an inline |
| 73 |
* tag ("<a>link</a>.") standing alone, where it was counted as a word, so |
| 74 |
* every version 3 entry is recounted once. |
| 75 |
*/ |
| 76 |
public const VERSION = 4; |
| 77 |
|
| 78 |
/** |
| 79 |
* Short codes for the counting unit, as stored in an entry. |
| 80 |
* |
| 81 |
* Version 1 entries carried no unit, so a site that switched between a |
| 82 |
* words locale and a characters one kept its old numbers as "current" and |
| 83 |
* the report printed word counts as character counts. Version 2 is the |
| 84 |
* first to carry it; every version 1 entry is recounted once. |
| 85 |
* |
| 86 |
* @var array<string,string> |
| 87 |
*/ |
| 88 |
private const UNIT_CODES = [ |
| 89 |
'words' => 'w', |
| 90 |
'characters_excluding_spaces' => 'c', |
| 91 |
'characters_including_spaces' => 'cs', |
| 92 |
]; |
| 93 |
|
| 94 |
/** |
| 95 |
* Post types whose content other posts pull in by `"ref":ID` and that |
| 96 |
* `do_blocks()` expands in place: synced patterns (`core/block`) and |
| 97 |
* navigation menus (`core/navigation`). |
| 98 |
* |
| 99 |
* Builder templates are not here. Elementor global widgets and templates, |
| 100 |
* and Bricks templates, are expanded by the builder at render time and are |
| 101 |
* referenced in shapes that differ per builder; a page using one is |
| 102 |
* recounted on its own next edit, or by Rescan. |
| 103 |
* |
| 104 |
* @var string[] |
| 105 |
*/ |
| 106 |
private const REFERENCED_POST_TYPES = ['wp_block', 'wp_navigation']; |
| 107 |
|
| 108 |
/** |
| 109 |
* How deep {@see posts_referencing()} follows a pattern nested inside a |
| 110 |
* pattern. Core refuses to render a pattern inside itself; this bound is |
| 111 |
* for the same reason, so a cycle in stored content cannot loop here. |
| 112 |
*/ |
| 113 |
private const MAX_REFERENCE_DEPTH = 5; |
| 114 |
|
| 115 |
/** |
| 116 |
* Posts per `IN (...)` list when retiring entries in bulk. |
| 117 |
*/ |
| 118 |
private const BULK_CHUNK = 500; |
| 119 |
|
| 120 |
/** |
| 121 |
* Most entries one refresh call will build, and the time it may spend. |
| 122 |
* |
| 123 |
* Much smaller than the snippet index's 500, and for a real reason: |
| 124 |
* resolving a builder tree is orders of magnitude dearer than reading two |
| 125 |
* meta values, so a batch sized for the cheap case would time out on a |
| 126 |
* site built entirely in Elementor. |
| 127 |
*/ |
| 128 |
private const REFRESH_MAX_POSTS = 50; |
| 129 |
private const REFRESH_MAX_SECONDS = 3.0; |
| 130 |
|
| 131 |
/** |
| 132 |
* Memo for {@see watched_meta()}. |
| 133 |
* |
| 134 |
* @var string[]|null |
| 135 |
*/ |
| 136 |
private static $watched_meta = null; |
| 137 |
|
| 138 |
/** |
| 139 |
* Memo for {@see unit()} when it has to switch locale to answer: site |
| 140 |
* locale => unit. |
| 141 |
* |
| 142 |
* @var array<string,string> |
| 143 |
*/ |
| 144 |
private static $site_units = []; |
| 145 |
|
| 146 |
/** |
| 147 |
* Register invalidation hooks. Runs on every request, because posts are |
| 148 |
* edited everywhere and not only on the report's screen. |
| 149 |
* |
| 150 |
* @return void |
| 151 |
*/ |
| 152 |
public function init(): void { |
| 153 |
add_action('save_post', [self::class, 'mark_post_stale'], 99, 1); |
| 154 |
|
| 155 |
add_action('added_post_meta', [self::class, 'on_meta_change'], 10, 3); |
| 156 |
add_action('updated_post_meta', [self::class, 'on_meta_change'], 10, 3); |
| 157 |
add_action('deleted_post_meta', [self::class, 'on_meta_change'], 10, 3); |
| 158 |
|
| 159 |
// A post *leaving* the report is the case no other hook here covers. |
| 160 |
// Trashing, unpublishing or deleting one removes it from every query |
| 161 |
// this class answers, but it changes no entry, so nothing would tell a |
| 162 |
// report derived from the index that its answer had moved. |
| 163 |
add_action('transition_post_status', [self::class, 'on_status_change'], 10, 3); |
| 164 |
add_action('before_delete_post', [self::class, 'on_post_deleted'], 10, 1); |
| 165 |
|
| 166 |
// Content other posts pull in by reference. Saving covers trashing and |
| 167 |
// restoring too (both go through wp_update_post()), and a trashed |
| 168 |
// pattern renders nothing, so the posts that use it lose those words. |
| 169 |
foreach (self::REFERENCED_POST_TYPES as $post_type) { |
| 170 |
add_action('save_post_' . $post_type, [self::class, 'on_referenced_change'], 99, 1); |
| 171 |
} |
| 172 |
} |
| 173 |
|
| 174 |
/** |
| 175 |
* Post meta whose change changes a post's word count. |
| 176 |
* |
| 177 |
* Read from {@see Builder_Content::builder_meta_keys()} rather than listed |
| 178 |
* here, so a builder added to the resolver is invalidated by the same |
| 179 |
* commit that teaches the resolver to read it. Bricks stores its tree |
| 180 |
* outside that list, so it is named explicitly. |
| 181 |
* |
| 182 |
* Memoised because {@see on_meta_change()} is the callback on three hooks |
| 183 |
* that fire for every post meta write anywhere in WordPress — an import |
| 184 |
* writing a dozen fields across a thousand posts rebuilt this list tens of |
| 185 |
* thousands of times to answer the same question. The source is a class |
| 186 |
* constant, so there is nothing for the memo to go stale against. |
| 187 |
* |
| 188 |
* @return string[] |
| 189 |
*/ |
| 190 |
public static function watched_meta(): array { |
| 191 |
if (null === self::$watched_meta) { |
| 192 |
self::$watched_meta = array_values(array_unique(array_merge( |
| 193 |
Builder_Content::builder_meta_keys(), |
| 194 |
['_bricks_page_content_2', '_bricks_editor_mode'] |
| 195 |
))); |
| 196 |
} |
| 197 |
|
| 198 |
return self::$watched_meta; |
| 199 |
} |
| 200 |
|
| 201 |
/** |
| 202 |
* Post meta changed. |
| 203 |
* |
| 204 |
* @param int|array $meta_id Meta ID(s). |
| 205 |
* @param int $object_id Post ID. |
| 206 |
* @param string $meta_key Meta key. |
| 207 |
* @return void |
| 208 |
*/ |
| 209 |
public static function on_meta_change($meta_id, $object_id, $meta_key): void { |
| 210 |
if (in_array($meta_key, self::watched_meta(), true)) { |
| 211 |
self::mark_post_stale($object_id); |
| 212 |
} |
| 213 |
} |
| 214 |
|
| 215 |
/** |
| 216 |
* A post moved between statuses. |
| 217 |
* |
| 218 |
* Only one direction needs announcing: a post *leaving* `publish`. It takes |
| 219 |
* its row out of every query here while its entry sits untouched, so nothing |
| 220 |
* else can tell a report derived from the index that its answer moved. |
| 221 |
* |
| 222 |
* A post *arriving* at `publish` deliberately does not bump. It has no entry |
| 223 |
* yet, so it is pending, and a report is never served from cache while |
| 224 |
* anything is pending — the batch that counts it bumps the revision itself. |
| 225 |
* Bumping here as well would write one option row per post through a bulk |
| 226 |
* import of a thousand published posts, to say something already known. |
| 227 |
* |
| 228 |
* @param string $new_status Status now. |
| 229 |
* @param string $old_status Status before. |
| 230 |
* @param \WP_Post|null $post Post. |
| 231 |
* @return void |
| 232 |
*/ |
| 233 |
public static function on_status_change($new_status, $old_status, $post = null): void { |
| 234 |
if ($new_status === $old_status || !$post instanceof \WP_Post) { |
| 235 |
return; |
| 236 |
} |
| 237 |
|
| 238 |
if ('publish' !== $old_status) { |
| 239 |
return; |
| 240 |
} |
| 241 |
|
| 242 |
if (wp_is_post_revision($post->ID) || wp_is_post_autosave($post->ID)) { |
| 243 |
return; |
| 244 |
} |
| 245 |
|
| 246 |
self::bump_revision(); |
| 247 |
} |
| 248 |
|
| 249 |
/** |
| 250 |
* A post is about to be deleted for good. |
| 251 |
* |
| 252 |
* Hooked before the delete rather than after it, so the entry is still |
| 253 |
* readable: only a post that had been counted can change an answer, and |
| 254 |
* revisions never have an entry, which keeps revision cleanup out of this. |
| 255 |
* |
| 256 |
* @param int|mixed $post_id Post ID. |
| 257 |
* @return void |
| 258 |
*/ |
| 259 |
public static function on_post_deleted($post_id): void { |
| 260 |
$post_id = (int) $post_id; |
| 261 |
if ($post_id <= 0) { |
| 262 |
return; |
| 263 |
} |
| 264 |
|
| 265 |
// A synced pattern or menu being deleted for good takes its words out |
| 266 |
// of every post that referenced it. It never has an entry of its own. |
| 267 |
if (in_array(get_post_type($post_id), self::REFERENCED_POST_TYPES, true)) { |
| 268 |
self::on_referenced_change($post_id); |
| 269 |
return; |
| 270 |
} |
| 271 |
|
| 272 |
if (null !== self::decode((string) get_post_meta($post_id, self::META_KEY, true))) { |
| 273 |
self::bump_revision(); |
| 274 |
} |
| 275 |
} |
| 276 |
|
| 277 |
/** |
| 278 |
* A synced pattern or navigation menu changed. |
| 279 |
* |
| 280 |
* Counting renders a post through `do_blocks()`, which expands |
| 281 |
* `<!-- wp:block {"ref":12} /-->` into pattern 12's content. Nothing about |
| 282 |
* the referencing post changes when pattern 12 is edited, so without this |
| 283 |
* every page using a 400-word pattern kept its 400-word count after the |
| 284 |
* pattern was cut to one line, and was never reported as thin. |
| 285 |
* |
| 286 |
* Found by searching `post_content`, which is a scan of the posts table. |
| 287 |
* That is acceptable here and nowhere hotter: it runs when someone saves a |
| 288 |
* pattern or a menu, which is rare, and it replaces recounting the site. |
| 289 |
* |
| 290 |
* @param int|mixed $post_id Pattern or menu ID. |
| 291 |
* @return void |
| 292 |
*/ |
| 293 |
public static function on_referenced_change($post_id): void { |
| 294 |
$post_id = (int) $post_id; |
| 295 |
if ($post_id <= 0 || wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) { |
| 296 |
return; |
| 297 |
} |
| 298 |
|
| 299 |
self::forget(self::posts_referencing($post_id)); |
| 300 |
} |
| 301 |
|
| 302 |
/** |
| 303 |
* Counted posts whose content references a post by `"ref":ID`, directly or |
| 304 |
* through patterns nested inside patterns. |
| 305 |
* |
| 306 |
* @param int $post_id Referenced post. |
| 307 |
* @return int[] IDs of posts that hold an entry. |
| 308 |
*/ |
| 309 |
private static function posts_referencing(int $post_id): array { |
| 310 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the interpolated fragment is built by reference_sql() through prepare(); every value is a placeholder. Runs on a pattern save, never on a front-end request. |
| 311 |
global $wpdb; |
| 312 |
|
| 313 |
$seen = [$post_id => true]; |
| 314 |
$frontier = [$post_id]; |
| 315 |
$counted = []; |
| 316 |
|
| 317 |
for ($depth = 0; !empty($frontier) && $depth < self::MAX_REFERENCE_DEPTH; $depth++) { |
| 318 |
$match = self::reference_sql($frontier); |
| 319 |
|
| 320 |
// Posts with an entry that use anything in the frontier. |
| 321 |
$ids = $wpdb->get_col($wpdb->prepare( |
| 322 |
"SELECT DISTINCT p.ID FROM {$wpdb->posts} p |
| 323 |
INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s |
| 324 |
WHERE " . $match, |
| 325 |
self::META_KEY |
| 326 |
)); |
| 327 |
foreach ((array) $ids as $id) { |
| 328 |
$counted[(int) $id] = true; |
| 329 |
} |
| 330 |
|
| 331 |
// Patterns that nest anything in the frontier: every post using |
| 332 |
// *them* renders the edited content too. |
| 333 |
$nested = $wpdb->get_col($wpdb->prepare( |
| 334 |
"SELECT p.ID FROM {$wpdb->posts} p |
| 335 |
WHERE p.post_type = %s AND " . $match, |
| 336 |
'wp_block' |
| 337 |
)); |
| 338 |
|
| 339 |
$frontier = []; |
| 340 |
foreach ((array) $nested as $id) { |
| 341 |
$id = (int) $id; |
| 342 |
if ($id > 0 && !isset($seen[$id])) { |
| 343 |
$seen[$id] = true; |
| 344 |
$frontier[] = $id; |
| 345 |
} |
| 346 |
} |
| 347 |
} |
| 348 |
|
| 349 |
return array_keys($counted); |
| 350 |
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 351 |
} |
| 352 |
|
| 353 |
/** |
| 354 |
* WHERE fragment matching `post_content` that references any of these IDs. |
| 355 |
* |
| 356 |
* Core serialises block attributes as compact JSON, so a reference to 12 |
| 357 |
* reads `"ref":12` followed by `}` or `,`. Matching the terminator is what |
| 358 |
* keeps an edit to pattern 12 from recounting every page that uses 120. |
| 359 |
* |
| 360 |
* @param int[] $ids Referenced post IDs. |
| 361 |
* @return string Trusted SQL. |
| 362 |
*/ |
| 363 |
private static function reference_sql(array $ids): string { |
| 364 |
global $wpdb; |
| 365 |
|
| 366 |
$likes = []; |
| 367 |
foreach ($ids as $id) { |
| 368 |
foreach (['}', ','] as $terminator) { |
| 369 |
$likes[] = $wpdb->prepare( |
| 370 |
'p.post_content LIKE %s', |
| 371 |
'%' . $wpdb->esc_like('"ref":' . (int) $id . $terminator) . '%' |
| 372 |
); |
| 373 |
} |
| 374 |
} |
| 375 |
|
| 376 |
return '(' . implode(' OR ', $likes) . ')'; |
| 377 |
} |
| 378 |
|
| 379 |
/** |
| 380 |
* Retire the entries of many posts in one statement per chunk. |
| 381 |
* |
| 382 |
* `delete_post_meta()` per post would be one query and one set of hooks |
| 383 |
* each, for a pattern that can sit on every page of the site. The meta |
| 384 |
* cache is cleared per post so a persistent object cache does not keep |
| 385 |
* serving the entry that was just removed. |
| 386 |
* |
| 387 |
* @param int[] $post_ids Post IDs. |
| 388 |
* @return void |
| 389 |
*/ |
| 390 |
private static function forget(array $post_ids): void { |
| 391 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the IN list is a run of %d built from the chunk size, so the sniff cannot see the placeholders it looks for; every value is still passed to prepare(). The per-post meta cache is cleared below. |
| 392 |
global $wpdb; |
| 393 |
|
| 394 |
$post_ids = array_values(array_unique(array_filter(array_map('intval', $post_ids)))); |
| 395 |
if (empty($post_ids)) { |
| 396 |
return; |
| 397 |
} |
| 398 |
|
| 399 |
foreach (array_chunk($post_ids, self::BULK_CHUNK) as $chunk) { |
| 400 |
$in = implode(',', array_fill(0, count($chunk), '%d')); |
| 401 |
$wpdb->query($wpdb->prepare( |
| 402 |
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id IN ({$in})", |
| 403 |
array_merge([self::META_KEY], $chunk) |
| 404 |
)); |
| 405 |
|
| 406 |
foreach ($chunk as $id) { |
| 407 |
wp_cache_delete($id, 'post_meta'); |
| 408 |
} |
| 409 |
} |
| 410 |
|
| 411 |
self::bump_revision(); |
| 412 |
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 413 |
} |
| 414 |
|
| 415 |
/** |
| 416 |
* Make one post's entry stale. |
| 417 |
* |
| 418 |
* The revision is bumped only when an entry was really removed. A post that |
| 419 |
* had none cannot have contributed to any answer, which keeps a bulk import |
| 420 |
* of fresh posts from writing one option row per post — and it means a |
| 421 |
* change that takes a counted post out of scope without changing its status |
| 422 |
* (adding a password, most of all) still retires the cached report. |
| 423 |
* |
| 424 |
* @param int|mixed $post_id Post ID. |
| 425 |
* @return void |
| 426 |
*/ |
| 427 |
public static function mark_post_stale($post_id): void { |
| 428 |
$post_id = (int) $post_id; |
| 429 |
if ($post_id <= 0 || wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) { |
| 430 |
return; |
| 431 |
} |
| 432 |
|
| 433 |
if (delete_post_meta($post_id, self::META_KEY)) { |
| 434 |
self::bump_revision(); |
| 435 |
} |
| 436 |
} |
| 437 |
|
| 438 |
/** |
| 439 |
* How many times a batch of entries has been rebuilt. |
| 440 |
* |
| 441 |
* A report derived from the whole index needs to know that *some* entry |
| 442 |
* changed, which no single post's meta can tell it. Bumped once per batch |
| 443 |
* rather than once per entry. |
| 444 |
* |
| 445 |
* @return int |
| 446 |
*/ |
| 447 |
public static function revision(): int { |
| 448 |
return (int) get_option('thinkrank_word_count_index_revision', 0); |
| 449 |
} |
| 450 |
|
| 451 |
/** |
| 452 |
* Record that entries changed. Not autoloaded: only the report reads it, |
| 453 |
* and never on a front-end request. |
| 454 |
* |
| 455 |
* @return void |
| 456 |
*/ |
| 457 |
private static function bump_revision(): void { |
| 458 |
update_option('thinkrank_word_count_index_revision', self::revision() + 1, false); |
| 459 |
} |
| 460 |
|
| 461 |
/** |
| 462 |
* Encode an entry. |
| 463 |
* |
| 464 |
* @param int $count Words (or characters, per the unit). |
| 465 |
* @param string|null $unit Unit the count is in. Defaults to the site's. |
| 466 |
* @return string |
| 467 |
*/ |
| 468 |
public static function encode(int $count, ?string $unit = null): string { |
| 469 |
return self::prefix($unit ?? self::unit()) . max(0, $count); |
| 470 |
} |
| 471 |
|
| 472 |
/** |
| 473 |
* Decode an entry. |
| 474 |
* |
| 475 |
* An entry counted in another unit is as stale as one from another |
| 476 |
* version: 40 words and 40 characters are not the same page. |
| 477 |
* |
| 478 |
* @param string $value Stored value. |
| 479 |
* @return int|null Count, or null when malformed, from an older version or |
| 480 |
* in a unit the site no longer counts in. |
| 481 |
*/ |
| 482 |
public static function decode(string $value): ?int { |
| 483 |
$prefix = self::prefix(self::unit()); |
| 484 |
if (0 !== strpos($value, $prefix)) { |
| 485 |
return null; |
| 486 |
} |
| 487 |
|
| 488 |
$count = substr($value, strlen($prefix)); |
| 489 |
|
| 490 |
return '' !== $count && ctype_digit($count) ? (int) $count : null; |
| 491 |
} |
| 492 |
|
| 493 |
/** |
| 494 |
* The part of an entry before the count: version and unit. |
| 495 |
* |
| 496 |
* @param string $unit Counting unit. |
| 497 |
* @return string |
| 498 |
*/ |
| 499 |
private static function prefix(string $unit): string { |
| 500 |
$code = self::UNIT_CODES[$unit] ?? self::UNIT_CODES['characters_excluding_spaces']; |
| 501 |
|
| 502 |
return self::VERSION . ':' . $code . ':'; |
| 503 |
} |
| 504 |
|
| 505 |
/** |
| 506 |
* LIKE pattern matching an entry written by the current rules, in the |
| 507 |
* unit the site counts in now. |
| 508 |
* |
| 509 |
* @return string |
| 510 |
*/ |
| 511 |
private static function current_like(): string { |
| 512 |
global $wpdb; |
| 513 |
|
| 514 |
return $wpdb->esc_like(self::prefix(self::unit())) . '%'; |
| 515 |
} |
| 516 |
|
| 517 |
/** |
| 518 |
* Count the content of a post the way the locale counts it. |
| 519 |
* |
| 520 |
* Two things here are easy to get wrong and invisible in English. |
| 521 |
* |
| 522 |
* **What to count.** `post_content` is empty on a builder page, so counting |
| 523 |
* it reports a 900-word Elementor page as 0 and calls it thin. Every count |
| 524 |
* goes through {@see Builder_Content::resolve()}, which is the same |
| 525 |
* resolution the editor score and the meta description already use. |
| 526 |
* |
| 527 |
* **What a "word" is.** WordPress reads the unit from a per-locale gettext |
| 528 |
* string, and `th`, `ja` and `zh_*` set it to characters rather than words |
| 529 |
* (#687). Splitting those on whitespace returns 1 for an entire article, so |
| 530 |
* a Japanese site would report every page as thin. This mirrors core's own |
| 531 |
* counter: words where the locale counts words, characters otherwise. |
| 532 |
* |
| 533 |
* @param \WP_Post $post Post to count. |
| 534 |
* @return int Count in {@see self::unit()}. |
| 535 |
*/ |
| 536 |
public static function count_post(\WP_Post $post): int { |
| 537 |
return self::count_text(Builder_Content::resolve($post)); |
| 538 |
} |
| 539 |
|
| 540 |
/** |
| 541 |
* Count a string the way the locale counts it. |
| 542 |
* |
| 543 |
* @param string $content HTML or text. |
| 544 |
* @return int |
| 545 |
*/ |
| 546 |
public static function count_text(string $content): int { |
| 547 |
$text = self::reading_text($content); |
| 548 |
|
| 549 |
if ('' === $text) { |
| 550 |
return 0; |
| 551 |
} |
| 552 |
|
| 553 |
switch (self::unit()) { |
| 554 |
case 'characters_including_spaces': |
| 555 |
return mb_strlen($text); |
| 556 |
|
| 557 |
case 'characters_excluding_spaces': |
| 558 |
return mb_strlen(str_replace(' ', '', $text)); |
| 559 |
|
| 560 |
default: |
| 561 |
return self::count_words($text); |
| 562 |
} |
| 563 |
} |
| 564 |
|
| 565 |
/** |
| 566 |
* Count the words in a line of reading text. |
| 567 |
* |
| 568 |
* A word is a whitespace-separated token holding at least one letter or |
| 569 |
* digit, in any script. A token of punctuation or symbols alone is not |
| 570 |
* one: reading_text() turns every tag into a space, so the full stop in |
| 571 |
* "<a>link</a>." and the "৳" in WooCommerce's |
| 572 |
* "<span>৳</span>100" stand on their own, and a dash set between spaces |
| 573 |
* ("one — two") does the same in plain prose. Core's JS word counter drops |
| 574 |
* punctuation too. Letters and digits are matched by Unicode property, so |
| 575 |
* Bengali, Arabic or Cyrillic words still count; a combining mark is part |
| 576 |
* of the letter before it, so a Bengali word keeps its vowel signs. |
| 577 |
* |
| 578 |
* Only the words unit uses this. The character units count every |
| 579 |
* character, as core does, which is how CJK locales are counted. |
| 580 |
* |
| 581 |
* @since 2.14.2 |
| 582 |
* |
| 583 |
* @param string $text Text as returned by {@see self::reading_text()}. |
| 584 |
* @return int |
| 585 |
*/ |
| 586 |
public static function count_words(string $text): int { |
| 587 |
$tokens = preg_split('/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY); |
| 588 |
|
| 589 |
if (!is_array($tokens)) { |
| 590 |
return 0; |
| 591 |
} |
| 592 |
|
| 593 |
return count(preg_grep('/[\p{L}\p{N}]/u', $tokens) ?: []); |
| 594 |
} |
| 595 |
|
| 596 |
/** |
| 597 |
* The text a reader reads in a piece of stored content, as one line. |
| 598 |
* |
| 599 |
* Everything that is markup rather than reading matter is removed: |
| 600 |
* |
| 601 |
* - **Scripts and styles.** A page builder's output can hold a great deal |
| 602 |
* of both. Counting them would make an empty page look substantial, |
| 603 |
* which is the failure that matters here. |
| 604 |
* - **Shortcode syntax** (#893). `[vc_column width="1/2"]` is not three |
| 605 |
* words, and a WPBakery or Divi classic page is mostly made of it, so |
| 606 |
* counting it reported a 216-word page as 325 and called it not thin. |
| 607 |
* The shortcodes are stripped, not rendered: `do_shortcode()` would |
| 608 |
* count what they output more accurately, but it means executing every |
| 609 |
* shortcode on the site inside a batch count, with whatever side effects |
| 610 |
* each one has (#860, #864). A shortcode that renders real prose is |
| 611 |
* undercounted, which is the safe direction for a thin content report. |
| 612 |
* Anything shortcode-shaped is stripped, registered or not, because a |
| 613 |
* builder's shortcodes are often not registered when the count runs. |
| 614 |
* Bracketed prose that looks like one ("see [note 4]") goes with it; |
| 615 |
* "[1]" and "[...]" do not, since a shortcode name starts with a letter. |
| 616 |
* - **Tags**, replaced with a space rather than deleted, the way core's |
| 617 |
* own word counter does, so `<p>five</p><p>six</p>` stays two words. |
| 618 |
* |
| 619 |
* @since 2.14.2 |
| 620 |
* |
| 621 |
* @param string $content HTML or text. |
| 622 |
* @return string Plain text with whitespace collapsed to single spaces. |
| 623 |
*/ |
| 624 |
public static function reading_text(string $content): string { |
| 625 |
// A `/u` pattern answers null on bytes that are not valid UTF-8, and |
| 626 |
// the string casts below turned that into "": one Latin-1 byte from an |
| 627 |
// old import made the whole page read as empty, a word count of 0 in |
| 628 |
// both this report and the SEO score. Replace the bad bytes instead. |
| 629 |
if ('' !== $content && 1 !== preg_match('//u', $content) && function_exists('mb_scrub')) { |
| 630 |
$content = mb_scrub($content, 'UTF-8'); |
| 631 |
} |
| 632 |
|
| 633 |
$text = (string) preg_replace('#<(script|style)\b[^>]*>.*?</\1>#is', ' ', $content); |
| 634 |
|
| 635 |
// Before tags: an attribute value may hold a ">", which would end a |
| 636 |
// tag match early. WordPress does not allow "[" or "]" inside a |
| 637 |
// shortcode's attributes, so neither is crossed; that also keeps a |
| 638 |
// stray "[" in prose from swallowing the text after it. The optional |
| 639 |
// outer brackets take the escaped form "[[name]]" whole, rather than |
| 640 |
// leaving two stray brackets to be counted as words. |
| 641 |
$text = (string) preg_replace('/\[?\[\/?[A-Za-z][\w-]*[^\[\]]*\]\]?/u', ' ', $text); |
| 642 |
|
| 643 |
$text = (string) preg_replace('/<!--.*?-->/s', ' ', $text); |
| 644 |
$text = (string) preg_replace('#</?[A-Za-z][^>]*>#', ' ', $text); |
| 645 |
// Backstop for anything malformed the patterns above did not take. |
| 646 |
$text = wp_strip_all_tags($text); |
| 647 |
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| 648 |
// Non-breaking spaces are spaces to a reader. |
| 649 |
$text = str_replace(["\xc2\xa0", "\xe2\x80\x8b"], ' ', $text); |
| 650 |
|
| 651 |
return trim((string) preg_replace('/\s+/u', ' ', $text)); |
| 652 |
} |
| 653 |
|
| 654 |
/** |
| 655 |
* The unit the site counts in. |
| 656 |
* |
| 657 |
* The **site** locale, not the viewer's. The report is one answer shared by |
| 658 |
* every user, but a REST request from wp-admin loads the translations of |
| 659 |
* the requesting user's profile language (`_locale=user`), which is where |
| 660 |
* core reads the unit from. Asking the loaded locale meant an administrator |
| 661 |
* whose profile is Japanese counted a batch in characters, a colleague in |
| 662 |
* English counted the next batch in words, and the index held both. |
| 663 |
* |
| 664 |
* Switching locale loads core's translations, so the answer is memoised |
| 665 |
* per site locale for the rest of the request. When the loaded locale is |
| 666 |
* already the site's, nothing is switched and nothing is memoised. |
| 667 |
* |
| 668 |
* @return string 'words', 'characters_excluding_spaces' or 'characters_including_spaces'. |
| 669 |
*/ |
| 670 |
public static function unit(): string { |
| 671 |
$site = (string) get_locale(); |
| 672 |
|
| 673 |
// The switcher is created during setup_theme; a count asked for before |
| 674 |
// then (a save during plugins_loaded) cannot switch, and uses what is |
| 675 |
// loaded rather than fataling. |
| 676 |
if (!function_exists('determine_locale') |
| 677 |
|| !function_exists('switch_to_locale') |
| 678 |
|| empty($GLOBALS['wp_locale_switcher']) |
| 679 |
|| determine_locale() === $site |
| 680 |
) { |
| 681 |
return self::loaded_locale_unit(); |
| 682 |
} |
| 683 |
|
| 684 |
if (!isset(self::$site_units[$site])) { |
| 685 |
$switched = switch_to_locale($site); |
| 686 |
try { |
| 687 |
self::$site_units[$site] = self::loaded_locale_unit(); |
| 688 |
} finally { |
| 689 |
if ($switched) { |
| 690 |
restore_previous_locale(); |
| 691 |
} |
| 692 |
} |
| 693 |
} |
| 694 |
|
| 695 |
return self::$site_units[$site]; |
| 696 |
} |
| 697 |
|
| 698 |
/** |
| 699 |
* The unit of whichever locale is loaded right now. |
| 700 |
* |
| 701 |
* @return string |
| 702 |
*/ |
| 703 |
private static function loaded_locale_unit(): string { |
| 704 |
if (Seo_Text::locale_counts_words()) { |
| 705 |
return 'words'; |
| 706 |
} |
| 707 |
|
| 708 |
if (function_exists('wp_get_word_count_type')) { |
| 709 |
$type = (string) wp_get_word_count_type(); |
| 710 |
|
| 711 |
return 'characters_including_spaces' === $type |
| 712 |
? 'characters_including_spaces' |
| 713 |
: 'characters_excluding_spaces'; |
| 714 |
} |
| 715 |
|
| 716 |
return 'characters_excluding_spaces'; |
| 717 |
} |
| 718 |
|
| 719 |
/** |
| 720 |
* Build a bounded batch of missing entries. |
| 721 |
* |
| 722 |
* @param string[] $post_types Post types in scope. |
| 723 |
* @param string[] $statuses Post statuses. |
| 724 |
* @return int How many entries remain to build after this batch. |
| 725 |
*/ |
| 726 |
public static function refresh(array $post_types, array $statuses): int { |
| 727 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the interpolated fragment is built by scope_sql() through prepare(); every value is a placeholder. The index is itself the cache. |
| 728 |
global $wpdb; |
| 729 |
|
| 730 |
if (empty($post_types)) { |
| 731 |
return 0; |
| 732 |
} |
| 733 |
|
| 734 |
$scope = self::scope_sql($post_types, $statuses); |
| 735 |
$started = microtime(true); |
| 736 |
|
| 737 |
$ids = $wpdb->get_col($wpdb->prepare( |
| 738 |
"SELECT p.ID FROM {$wpdb->posts} p |
| 739 |
LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s |
| 740 |
WHERE " . $scope . " |
| 741 |
AND (m.meta_value IS NULL OR m.meta_value NOT LIKE %s) |
| 742 |
ORDER BY p.ID DESC |
| 743 |
LIMIT %d", |
| 744 |
self::META_KEY, |
| 745 |
self::current_like(), |
| 746 |
self::REFRESH_MAX_POSTS |
| 747 |
)); |
| 748 |
|
| 749 |
$ids = array_map('intval', (array) $ids); |
| 750 |
|
| 751 |
if (!empty($ids)) { |
| 752 |
_prime_post_caches($ids, false, true); |
| 753 |
|
| 754 |
foreach ($ids as $post_id) { |
| 755 |
self::build($post_id); |
| 756 |
|
| 757 |
// Checked per post, not per chunk: one Bricks page can take |
| 758 |
// longer than the whole budget, and a batch that only checks |
| 759 |
// between chunks would sail past it. |
| 760 |
if (microtime(true) - $started > self::REFRESH_MAX_SECONDS) { |
| 761 |
break; |
| 762 |
} |
| 763 |
} |
| 764 |
|
| 765 |
self::bump_revision(); |
| 766 |
} |
| 767 |
|
| 768 |
return max(0, self::pending($post_types, $statuses)); |
| 769 |
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 770 |
} |
| 771 |
|
| 772 |
/** |
| 773 |
* Compute and store one entry. |
| 774 |
* |
| 775 |
* @param int $post_id Post ID. |
| 776 |
* @return void |
| 777 |
*/ |
| 778 |
private static function build(int $post_id): void { |
| 779 |
$post = get_post($post_id); |
| 780 |
if (!$post instanceof \WP_Post) { |
| 781 |
return; |
| 782 |
} |
| 783 |
|
| 784 |
update_post_meta($post_id, self::META_KEY, self::encode(self::count_post($post))); |
| 785 |
} |
| 786 |
|
| 787 |
/** |
| 788 |
* How many posts in scope still have no current entry. |
| 789 |
* |
| 790 |
* @param string[] $post_types Post types. |
| 791 |
* @param string[] $statuses Post statuses. |
| 792 |
* @return int |
| 793 |
*/ |
| 794 |
public static function pending(array $post_types, array $statuses): int { |
| 795 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the interpolated fragment is built by scope_sql() through prepare(); every value is a placeholder. The index is itself the cache. |
| 796 |
global $wpdb; |
| 797 |
|
| 798 |
if (empty($post_types)) { |
| 799 |
return 0; |
| 800 |
} |
| 801 |
|
| 802 |
return (int) $wpdb->get_var($wpdb->prepare( |
| 803 |
"SELECT COUNT(*) FROM {$wpdb->posts} p |
| 804 |
LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s |
| 805 |
WHERE " . self::scope_sql($post_types, $statuses) . " |
| 806 |
AND (m.meta_value IS NULL OR m.meta_value NOT LIKE %s)", |
| 807 |
self::META_KEY, |
| 808 |
self::current_like() |
| 809 |
)); |
| 810 |
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 811 |
} |
| 812 |
|
| 813 |
/** |
| 814 |
* Counted posts per post type, and how many of them fall under that post |
| 815 |
* type's threshold. |
| 816 |
* |
| 817 |
* One query for every post type rather than one each, and the comparison |
| 818 |
* happens in SQL so a site with 20,000 products never loads them. |
| 819 |
* |
| 820 |
* @param array<string,int> $thresholds Post type => threshold. |
| 821 |
* @param string[] $statuses Post statuses. |
| 822 |
* @return array<string,array{counted:int, thin:int}> |
| 823 |
*/ |
| 824 |
public static function totals(array $thresholds, array $statuses): array { |
| 825 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the interpolated fragment is built by scope_sql() through prepare(); the CASE compares a column against integers cast from the threshold map. The index is itself the cache. |
| 826 |
global $wpdb; |
| 827 |
|
| 828 |
if (empty($thresholds)) { |
| 829 |
return []; |
| 830 |
} |
| 831 |
|
| 832 |
$count_sql = self::count_sql('m'); |
| 833 |
|
| 834 |
// One CASE arm per post type, each prepared on its own and |
| 835 |
// concatenated rather than left as placeholders in the outer query. |
| 836 |
// |
| 837 |
// The order matters and is easy to get wrong: these placeholders sit |
| 838 |
// in the SELECT clause, *before* the ones in the JOIN and WHERE, so a |
| 839 |
// single prepare() over the whole statement binds them in that order. |
| 840 |
// Getting it wrong does not error — it silently hands `meta_key` a |
| 841 |
// post type name, the INNER JOIN matches nothing and the report reads |
| 842 |
// "0 posts counted" on a site full of content. Preparing each fragment |
| 843 |
// where it is built removes the ordering question entirely. |
| 844 |
$arms = []; |
| 845 |
foreach ($thresholds as $post_type => $threshold) { |
| 846 |
$arms[] = $wpdb->prepare( |
| 847 |
'WHEN p.post_type = %s THEN %d', |
| 848 |
(string) $post_type, |
| 849 |
max(0, (int) $threshold) |
| 850 |
); |
| 851 |
} |
| 852 |
$threshold_sql = 'CASE ' . implode(' ', $arms) . ' ELSE 0 END'; |
| 853 |
|
| 854 |
$scope = self::scope_sql(array_keys($thresholds), $statuses); |
| 855 |
$meta_key_sql = $wpdb->prepare('m.meta_key = %s', self::META_KEY); |
| 856 |
$current_sql = $wpdb->prepare('m.meta_value LIKE %s', self::current_like()); |
| 857 |
|
| 858 |
$rows = $wpdb->get_results( |
| 859 |
"SELECT p.post_type, |
| 860 |
COUNT(*) AS counted, |
| 861 |
SUM({$count_sql} < {$threshold_sql}) AS thin |
| 862 |
FROM {$wpdb->posts} p |
| 863 |
INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND {$meta_key_sql} |
| 864 |
WHERE {$scope} AND {$current_sql} |
| 865 |
GROUP BY p.post_type", |
| 866 |
ARRAY_A |
| 867 |
); |
| 868 |
|
| 869 |
$totals = []; |
| 870 |
foreach ((array) $rows as $row) { |
| 871 |
$totals[(string) $row['post_type']] = [ |
| 872 |
'counted' => (int) $row['counted'], |
| 873 |
'thin' => (int) $row['thin'], |
| 874 |
]; |
| 875 |
} |
| 876 |
|
| 877 |
return $totals; |
| 878 |
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 879 |
} |
| 880 |
|
| 881 |
/** |
| 882 |
* The thinnest posts of one post type, under its threshold. |
| 883 |
* |
| 884 |
* Thinnest first: the emptiest page is the one worth opening, and on a site |
| 885 |
* with hundreds of thin pages the tail is noise. |
| 886 |
* |
| 887 |
* @param string $post_type Post type. |
| 888 |
* @param int $threshold Count below which a post is thin. |
| 889 |
* @param string[] $statuses Post statuses. |
| 890 |
* @param int $limit Most posts to return. |
| 891 |
* @return array<int,array{post_id:int, count:int}> |
| 892 |
*/ |
| 893 |
public static function thinnest(string $post_type, int $threshold, array $statuses, int $limit): array { |
| 894 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the interpolated fragment is built by scope_sql() through prepare(); every value is a placeholder. The index is itself the cache. |
| 895 |
global $wpdb; |
| 896 |
|
| 897 |
$count_sql = self::count_sql('m'); |
| 898 |
|
| 899 |
$rows = $wpdb->get_results($wpdb->prepare( |
| 900 |
"SELECT p.ID, {$count_sql} AS word_count |
| 901 |
FROM {$wpdb->posts} p |
| 902 |
INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = %s |
| 903 |
WHERE " . self::scope_sql([$post_type], $statuses) . " |
| 904 |
AND m.meta_value LIKE %s |
| 905 |
AND {$count_sql} < %d |
| 906 |
ORDER BY word_count ASC, p.ID DESC |
| 907 |
LIMIT %d", |
| 908 |
self::META_KEY, |
| 909 |
self::current_like(), |
| 910 |
max(0, $threshold), |
| 911 |
max(1, $limit) |
| 912 |
), ARRAY_A); |
| 913 |
|
| 914 |
$posts = []; |
| 915 |
foreach ((array) $rows as $row) { |
| 916 |
$posts[] = [ |
| 917 |
'post_id' => (int) $row['ID'], |
| 918 |
'count' => (int) $row['word_count'], |
| 919 |
]; |
| 920 |
} |
| 921 |
|
| 922 |
return $posts; |
| 923 |
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 924 |
} |
| 925 |
|
| 926 |
/** |
| 927 |
* SQL reading the count out of a stored entry. |
| 928 |
* |
| 929 |
* @param string $alias Postmeta table alias. |
| 930 |
* @return string Trusted SQL. |
| 931 |
*/ |
| 932 |
private static function count_sql(string $alias): string { |
| 933 |
$alias = self::alias($alias); |
| 934 |
|
| 935 |
return "CAST(SUBSTRING_INDEX({$alias}.meta_value, ':', -1) AS UNSIGNED)"; |
| 936 |
} |
| 937 |
|
| 938 |
/** |
| 939 |
* WHERE fragment for post types and statuses. |
| 940 |
* |
| 941 |
* @param string[] $post_types Post types. |
| 942 |
* @param string[] $statuses Post statuses. |
| 943 |
* @return string Trusted SQL. |
| 944 |
*/ |
| 945 |
private static function scope_sql(array $post_types, array $statuses): string { |
| 946 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- both IN lists are runs of %s built from the argument counts, so the sniff cannot see the placeholders it looks for; every value is still passed to prepare(). |
| 947 |
global $wpdb; |
| 948 |
|
| 949 |
$post_types = array_values(array_unique(array_filter($post_types, 'is_string'))); |
| 950 |
if (empty($post_types)) { |
| 951 |
// No post type matches nothing. Falling back to every post type |
| 952 |
// would silently widen a scope the caller meant to narrow. |
| 953 |
return '1 = 0'; |
| 954 |
} |
| 955 |
|
| 956 |
$statuses = array_values(array_intersect($statuses, ['publish', 'future', 'draft', 'pending', 'private'])); |
| 957 |
if (empty($statuses)) { |
| 958 |
$statuses = ['publish']; |
| 959 |
} |
| 960 |
|
| 961 |
$types_in = implode(',', array_fill(0, count($post_types), '%s')); |
| 962 |
$statuses_in = implode(',', array_fill(0, count($statuses), '%s')); |
| 963 |
|
| 964 |
return $wpdb->prepare( |
| 965 |
"p.post_type IN ({$types_in}) AND p.post_status IN ({$statuses_in}) AND p.post_password = ''", |
| 966 |
array_merge($post_types, $statuses) |
| 967 |
); |
| 968 |
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching |
| 969 |
} |
| 970 |
|
| 971 |
/** |
| 972 |
* A table alias this class uses, and nothing else. |
| 973 |
* |
| 974 |
* Aliases are interpolated into SQL (identifiers cannot be placeholders), |
| 975 |
* so only the fixed set this class writes is accepted. |
| 976 |
* |
| 977 |
* @param string $alias Requested alias. |
| 978 |
* @return string |
| 979 |
*/ |
| 980 |
private static function alias(string $alias): string { |
| 981 |
return in_array($alias, ['p', 'm'], true) ? $alias : 'm'; |
| 982 |
} |
| 983 |
} |
| 984 |
|