| 1 |
<?php |
| 2 |
/** |
| 3 |
* The Overview's content-coverage counts: how many published supported content |
| 4 |
* items have each SEO field set, computed in a single aggregate query and |
| 5 |
* cached in a transient that post/meta writes invalidate. |
| 6 |
* |
| 7 |
* @package automattic/jetpack-seo-package |
| 8 |
*/ |
| 9 |
|
| 10 |
namespace Automattic\Jetpack\SEO; |
| 11 |
|
| 12 |
/** |
| 13 |
* Computes, caches, and invalidates the content-coverage counts. |
| 14 |
*/ |
| 15 |
class Content_Coverage { |
| 16 |
|
| 17 |
/** |
| 18 |
* Post-meta keys mirrored from `Jetpack_SEO_Posts` (in plugins/jetpack). |
| 19 |
* Duplicated here as literals on purpose: that plugin class is NOT reliably |
| 20 |
* loaded in this package's admin context (the `Jetpack_SEO_Utils` |
| 21 |
* `class_exists` guard in {@see Dashboard_Data::get_overview_data()} is there |
| 22 |
* for the same reason), so referencing its constants would fatal. |
| 23 |
* Content-coverage counting only needs the key strings, which are stable. |
| 24 |
*/ |
| 25 |
const META_DESCRIPTION = 'advanced_seo_description'; |
| 26 |
const META_SCHEMA_TYPE = 'jetpack_seo_schema_type'; |
| 27 |
const META_TITLE = 'jetpack_seo_html_title'; |
| 28 |
const META_NOINDEX = 'jetpack_seo_noindex'; |
| 29 |
|
| 30 |
/** |
| 31 |
* Transient holding the coverage counts. |
| 32 |
* |
| 33 |
* Versioned, so a future change to the payload's shape can't read a stale array |
| 34 |
* written by an older version of this code. |
| 35 |
* |
| 36 |
* @var string |
| 37 |
*/ |
| 38 |
const TRANSIENT = 'jetpack_seo_content_coverage_counts_v1'; |
| 39 |
|
| 40 |
/** |
| 41 |
* How long the counts survive without being invalidated. |
| 42 |
* |
| 43 |
* @var int |
| 44 |
*/ |
| 45 |
const TTL = HOUR_IN_SECONDS; |
| 46 |
|
| 47 |
/** |
| 48 |
* Post types the counts span. |
| 49 |
* |
| 50 |
* @return string[] |
| 51 |
*/ |
| 52 |
private static function post_types() { |
| 53 |
return Post_Types::get_supported_content_types(); |
| 54 |
} |
| 55 |
|
| 56 |
/** |
| 57 |
* The SEO post-meta keys the counts read. |
| 58 |
* |
| 59 |
* @return string[] |
| 60 |
*/ |
| 61 |
private static function meta_keys() { |
| 62 |
return array( |
| 63 |
self::META_SCHEMA_TYPE, |
| 64 |
self::META_TITLE, |
| 65 |
self::META_DESCRIPTION, |
| 66 |
self::META_NOINDEX, |
| 67 |
); |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* Factual content-coverage counts for the Overview card: how many published |
| 72 |
* supported content items have each SEO field set. State, not a score — the |
| 73 |
* card shows proportions + raw counts and lets the admin decide what matters. |
| 74 |
* |
| 75 |
* Served from {@see self::TRANSIENT} when it's warm. The counts are read on |
| 76 |
* every load of the SEO page — and by every tab of it, since the dashboard preloads |
| 77 |
* all of its REST reads at once — so without a cache a plain reload pays for the |
| 78 |
* query again having changed nothing. |
| 79 |
* |
| 80 |
* @return array{total:int,with_schema:int,with_title:int,with_description:int,with_search_visible:int} |
| 81 |
*/ |
| 82 |
public static function get() { |
| 83 |
$cached = get_transient( self::TRANSIENT ); |
| 84 |
|
| 85 |
if ( self::is_valid( $cached ) ) { |
| 86 |
return $cached; |
| 87 |
} |
| 88 |
|
| 89 |
$coverage = self::compute(); |
| 90 |
|
| 91 |
set_transient( self::TRANSIENT, $coverage, self::TTL ); |
| 92 |
|
| 93 |
return $coverage; |
| 94 |
} |
| 95 |
|
| 96 |
/** |
| 97 |
* Whether a value read back from the cache is a coverage payload this code can use. |
| 98 |
* |
| 99 |
* @param mixed $value Value read from the transient. |
| 100 |
* @return bool |
| 101 |
*/ |
| 102 |
private static function is_valid( $value ) { |
| 103 |
if ( ! is_array( $value ) ) { |
| 104 |
return false; |
| 105 |
} |
| 106 |
|
| 107 |
foreach ( array( 'total', 'with_schema', 'with_title', 'with_description', 'with_search_visible' ) as $key ) { |
| 108 |
if ( ! isset( $value[ $key ] ) || ! is_int( $value[ $key ] ) ) { |
| 109 |
return false; |
| 110 |
} |
| 111 |
} |
| 112 |
|
| 113 |
return true; |
| 114 |
} |
| 115 |
|
| 116 |
/** |
| 117 |
* Count the coverage metrics straight from the database. |
| 118 |
* |
| 119 |
* @return array{total:int,with_schema:int,with_title:int,with_description:int,with_search_visible:int} |
| 120 |
*/ |
| 121 |
private static function compute() { |
| 122 |
global $wpdb; |
| 123 |
|
| 124 |
$post_types = self::post_types(); |
| 125 |
$meta_keys = self::meta_keys(); |
| 126 |
|
| 127 |
$meta_key_placeholders = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) ); |
| 128 |
$post_type_placeholders = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) ); |
| 129 |
|
| 130 |
/* |
| 131 |
* Driven from `wp_postmeta`, not `wp_posts`: most sites have far more published |
| 132 |
* posts than SEO fields set, so the join starts from the small side, where the |
| 133 |
* `meta_key` index serves `meta_key IN (…)` directly. |
| 134 |
* |
| 135 |
* The aggregate has no GROUP BY, so it still returns its single row — counts at |
| 136 |
* zero, `total` intact — on a site with no SEO meta at all. |
| 137 |
* |
| 138 |
* COUNT( DISTINCT p.ID ) because a post can carry more than one row for the same |
| 139 |
* meta key. `<> ''` counts a field as set; noindex alone is an exact `= '1'`. |
| 140 |
*/ |
| 141 |
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber |
| 142 |
$sql = $wpdb->prepare( |
| 143 |
"SELECT |
| 144 |
( |
| 145 |
SELECT COUNT(*) |
| 146 |
FROM {$wpdb->posts} |
| 147 |
WHERE post_status = 'publish' AND post_type IN ( {$post_type_placeholders} ) |
| 148 |
) AS total, |
| 149 |
COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value <> '' THEN p.ID END ) AS with_schema, |
| 150 |
COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value <> '' THEN p.ID END ) AS with_title, |
| 151 |
COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value <> '' THEN p.ID END ) AS with_description, |
| 152 |
COUNT( DISTINCT CASE WHEN pm.meta_key = %s AND pm.meta_value = '1' THEN p.ID END ) AS noindexed |
| 153 |
FROM {$wpdb->postmeta} pm |
| 154 |
INNER JOIN {$wpdb->posts} p |
| 155 |
ON p.ID = pm.post_id |
| 156 |
AND p.post_status = 'publish' |
| 157 |
AND p.post_type IN ( {$post_type_placeholders} ) |
| 158 |
WHERE pm.meta_key IN ( {$meta_key_placeholders} )", |
| 159 |
array_merge( |
| 160 |
$post_types, |
| 161 |
// The CASE arms above, in the order they appear. |
| 162 |
array( self::META_SCHEMA_TYPE, self::META_TITLE, self::META_DESCRIPTION, self::META_NOINDEX ), |
| 163 |
$post_types, |
| 164 |
$meta_keys |
| 165 |
) |
| 166 |
); |
| 167 |
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber |
| 168 |
|
| 169 |
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Aggregate count with no core API equivalent; $sql is the prepared statement built directly above. The result is cached in self::TRANSIENT by the get() wrapper, which is the only caller — the sniff just can't see across the two methods. |
| 170 |
$row = $wpdb->get_row( $sql, ARRAY_A ); |
| 171 |
|
| 172 |
// Defaults, so a query that returns nothing at all reads as an empty site rather |
| 173 |
// than fataling on a missing key. |
| 174 |
$counts = array_map( |
| 175 |
'intval', |
| 176 |
array_merge( |
| 177 |
array( |
| 178 |
'total' => 0, |
| 179 |
'with_schema' => 0, |
| 180 |
'with_title' => 0, |
| 181 |
'with_description' => 0, |
| 182 |
'noindexed' => 0, |
| 183 |
), |
| 184 |
is_array( $row ) ? $row : array() |
| 185 |
) |
| 186 |
); |
| 187 |
|
| 188 |
return array( |
| 189 |
'total' => $counts['total'], |
| 190 |
'with_schema' => $counts['with_schema'], |
| 191 |
'with_title' => $counts['with_title'], |
| 192 |
'with_description' => $counts['with_description'], |
| 193 |
// Search-engine visibility is the inverse of the per-post noindex meta: a |
| 194 |
// post is visible unless it's explicitly set to noindex (stored as '1'), so |
| 195 |
// most posts (no meta row) count as visible. |
| 196 |
'with_search_visible' => max( 0, $counts['total'] - $counts['noindexed'] ), |
| 197 |
); |
| 198 |
} |
| 199 |
|
| 200 |
/** |
| 201 |
* Hook the writes that can move the coverage counts. |
| 202 |
* |
| 203 |
* @return void |
| 204 |
*/ |
| 205 |
public static function register_invalidation() { |
| 206 |
// Covers publish, unpublish, trash, untrash and scheduled posts going live — every |
| 207 |
// route by which a post enters or leaves the published set. |
| 208 |
add_action( 'transition_post_status', array( __CLASS__, 'invalidate_on_status_change' ), 10, 3 ); |
| 209 |
add_action( 'deleted_post', array( __CLASS__, 'invalidate_on_delete' ), 10, 2 ); |
| 210 |
|
| 211 |
foreach ( array( 'added_post_meta', 'updated_post_meta', 'deleted_post_meta' ) as $hook ) { |
| 212 |
add_action( $hook, array( __CLASS__, 'invalidate_on_meta_change' ), 10, 3 ); |
| 213 |
} |
| 214 |
} |
| 215 |
|
| 216 |
/** |
| 217 |
* Drop the cached counts when a post enters or leaves the published set. |
| 218 |
* |
| 219 |
* Known limitation: this hook only ever sees the post's new type, so converting a |
| 220 |
* published post to an uncounted post type (or the reverse) isn't caught here and |
| 221 |
* leaves `total` stale until the next tracked write or the transient's TTL expiry. |
| 222 |
* A direct `set_post_type()` bypasses every hook anyway, so the TTL backstop is what |
| 223 |
* ultimately bounds that staleness. |
| 224 |
* |
| 225 |
* @param string $new_status Status the post is moving to. |
| 226 |
* @param string $old_status Status the post is moving from. |
| 227 |
* @param \WP_Post|null $post The post being transitioned. |
| 228 |
* @return void |
| 229 |
*/ |
| 230 |
public static function invalidate_on_status_change( $new_status, $old_status, $post ) { |
| 231 |
if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, self::post_types(), true ) ) { |
| 232 |
return; |
| 233 |
} |
| 234 |
|
| 235 |
// Draft to draft, pending to draft, and the like never touch the counts. |
| 236 |
if ( 'publish' !== $new_status && 'publish' !== $old_status ) { |
| 237 |
return; |
| 238 |
} |
| 239 |
|
| 240 |
self::invalidate(); |
| 241 |
} |
| 242 |
|
| 243 |
/** |
| 244 |
* Drop the cached counts when a post is deleted outright. |
| 245 |
* |
| 246 |
* Trashing already goes through `transition_post_status`; this catches a hard delete, |
| 247 |
* which for an already-trashed post transitions nothing. |
| 248 |
* |
| 249 |
* @param int $post_id Deleted post ID. |
| 250 |
* @param \WP_Post|null $post The post that was deleted. |
| 251 |
* @return void |
| 252 |
*/ |
| 253 |
public static function invalidate_on_delete( $post_id, $post = null ) { |
| 254 |
if ( ! $post instanceof \WP_Post || ! in_array( $post->post_type, self::post_types(), true ) ) { |
| 255 |
return; |
| 256 |
} |
| 257 |
|
| 258 |
self::invalidate(); |
| 259 |
} |
| 260 |
|
| 261 |
/** |
| 262 |
* Drop the cached counts when one of the SEO fields they count is written. |
| 263 |
* |
| 264 |
* @param int|int[] $meta_id Meta row ID, or IDs on delete. Unused. |
| 265 |
* @param int $object_id Post the meta belongs to. Unused. |
| 266 |
* @param string $meta_key Meta key written. |
| 267 |
* @return void |
| 268 |
*/ |
| 269 |
public static function invalidate_on_meta_change( $meta_id, $object_id, $meta_key ) { |
| 270 |
if ( ! in_array( $meta_key, self::meta_keys(), true ) ) { |
| 271 |
return; |
| 272 |
} |
| 273 |
|
| 274 |
self::invalidate(); |
| 275 |
} |
| 276 |
|
| 277 |
/** |
| 278 |
* Drop the cached counts. |
| 279 |
* |
| 280 |
* @return void |
| 281 |
*/ |
| 282 |
private static function invalidate() { |
| 283 |
delete_transient( self::TRANSIENT ); |
| 284 |
} |
| 285 |
} |
| 286 |
|