PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-videopress / src / class-playlist-index.php

class-playlist-index.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3, at jetpack_vendor/automattic/jetpack-videopress/src/class-playlist-index.php

352 lines 9.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Site-wide index of the Video Playlist blocks in published content.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8 namespace Automattic\Jetpack\VideoPress;
9
10 use WP_Post;
11 use WP_Query;
12
13 /**
14 * Keeps the `videopress_playlist_index` option in sync with the Video Playlist
15 * blocks found in published content, so the All Playlists block can list them
16 * without scanning the site on every render.
17 *
18 * The option is an object keyed by playlist key; every record holds the
19 * playlist title, description, videos, and the id of the post it lives in.
20 */
21 class Playlist_Index {
22
23 /**
24 * Option holding the index. Never autoloaded: it grows with the site.
25 *
26 * @var string
27 */
28 const OPTION_NAME = 'videopress_playlist_index';
29
30 /**
31 * Option recording which index schema the site has been fully scanned for.
32 * Bump INDEX_VERSION to have every site rescanned on its next update.
33 *
34 * @var string
35 */
36 const VERSION_OPTION_NAME = 'videopress_playlist_index_version';
37
38 /**
39 * Current index schema version.
40 *
41 * @var int
42 */
43 const INDEX_VERSION = 1;
44
45 /**
46 * Cron hook that runs the initial full scan.
47 *
48 * @var string
49 */
50 const BUILD_HOOK = 'videopress_build_playlist_index';
51
52 /**
53 * Name of the block the index tracks.
54 *
55 * @var string
56 */
57 const BLOCK_NAME = 'videopress/playlist';
58
59 /**
60 * Blocks that build a playlist dynamically and wrap a Video Playlist block
61 * as their canvas. They are not playlists of their own, so the scan skips
62 * them and everything inside them.
63 *
64 * @var string[]
65 */
66 const DYNAMIC_PLAYLIST_BLOCKS = array( 'videopress/latest-videos-playlist' );
67
68 /**
69 * Posts fetched per query during a full scan.
70 *
71 * @var int
72 */
73 const SCAN_BATCH_SIZE = 100;
74
75 /**
76 * Hook the index into the post lifecycle and schedule the initial scan.
77 *
78 * @return void
79 */
80 public static function init() {
81 add_action( 'wp_after_insert_post', array( __CLASS__, 'index_post' ), 10, 2 );
82 add_action( 'deleted_post', array( __CLASS__, 'remove_post' ) );
83 add_action( 'admin_init', array( __CLASS__, 'maybe_schedule_build' ) );
84 add_action( self::BUILD_HOOK, array( __CLASS__, 'build' ) );
85 }
86
87 /**
88 * Get every indexed playlist, keyed by playlist key.
89 *
90 * @return array<string, array{title: string, description: string, videos: array, post_id: int}>
91 */
92 public static function get_playlists() {
93 $index = get_option( self::OPTION_NAME, array() );
94
95 return is_array( $index ) ? $index : array();
96 }
97
98 /**
99 * Schedule the one-off full scan when the site has never been indexed for the
100 * current schema, e.g. right after the plugin update that shipped the index.
101 *
102 * @return void
103 */
104 public static function maybe_schedule_build() {
105 if ( (int) get_option( self::VERSION_OPTION_NAME, 0 ) >= self::INDEX_VERSION ) {
106 return;
107 }
108
109 if ( ! wp_next_scheduled( self::BUILD_HOOK ) ) {
110 wp_schedule_single_event( time(), self::BUILD_HOOK );
111 }
112 }
113
114 /**
115 * Rebuild the whole index from the published content that contains a
116 * Video Playlist block, then record the schema version it was built for.
117 *
118 * @return void
119 */
120 public static function build() {
121 $index = array();
122 $paged = 1;
123
124 $add_content_clause = function ( $where ) {
125 global $wpdb;
126
127 return $where . $wpdb->prepare(
128 " AND {$wpdb->posts}.post_content LIKE %s",
129 '%' . $wpdb->esc_like( '<!-- wp:' . self::BLOCK_NAME ) . '%'
130 );
131 };
132
133 add_filter( 'posts_where', $add_content_clause );
134
135 do {
136 $query = new WP_Query(
137 array(
138 'post_type' => self::indexed_post_types(),
139 'post_status' => 'publish',
140 'posts_per_page' => self::SCAN_BATCH_SIZE,
141 'paged' => $paged,
142 'orderby' => 'ID',
143 'order' => 'ASC',
144 'no_found_rows' => true,
145 'ignore_sticky_posts' => true,
146 'update_post_meta_cache' => false,
147 'update_post_term_cache' => false,
148 )
149 );
150
151 foreach ( $query->posts as $post ) {
152 if ( $post instanceof WP_Post ) {
153 self::add_post_records( $index, $post );
154 }
155 }
156
157 $batch_size = count( $query->posts );
158 ++$paged;
159 } while ( self::SCAN_BATCH_SIZE === $batch_size );
160
161 remove_filter( 'posts_where', $add_content_clause );
162
163 self::save( $index );
164 update_option( self::VERSION_OPTION_NAME, self::INDEX_VERSION );
165 }
166
167 /**
168 * Re-index one post after it is saved: its previous records are replaced by
169 * the playlists its content holds now, or dropped when it is no longer public.
170 *
171 * @param int $post_id Post id.
172 * @param WP_Post|null $post The post; looked up when omitted.
173 *
174 * @return void
175 */
176 public static function index_post( $post_id, $post = null ) {
177 $post_id = absint( $post_id );
178 if ( ! $post_id || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
179 return;
180 }
181
182 if ( ! $post instanceof WP_Post ) {
183 $post = get_post( $post_id );
184 }
185 if ( ! $post instanceof WP_Post ) {
186 return;
187 }
188
189 $index = self::without_post( self::get_playlists(), $post_id );
190
191 if ( 'publish' === $post->post_status && in_array( $post->post_type, self::indexed_post_types(), true ) ) {
192 self::add_post_records( $index, $post );
193 }
194
195 self::save( $index );
196 }
197
198 /**
199 * Drop a deleted post's playlists from the index.
200 *
201 * @param int $post_id Post id.
202 *
203 * @return void
204 */
205 public static function remove_post( $post_id ) {
206 $post_id = absint( $post_id );
207 if ( ! $post_id ) {
208 return;
209 }
210
211 self::save( self::without_post( self::get_playlists(), $post_id ) );
212 }
213
214 /**
215 * Extract the playlist records from a post's content.
216 *
217 * Keys come from the block's `playlistId` attribute. Blocks saved before the
218 * attribute existed fall back to `{post id}-{ordinal}`, and a key another
219 * post already owns (a block copied between posts) is suffixed with the post
220 * id so both playlists stay listed.
221 *
222 * @param WP_Post $post The post.
223 *
224 * @return array<string, array> Records keyed by playlist key.
225 */
226 public static function extract_records( WP_Post $post ) {
227 $records = array();
228 if ( ! has_block( self::BLOCK_NAME, $post ) ) {
229 return $records;
230 }
231
232 $ordinal = 0;
233 foreach ( self::find_playlist_blocks( parse_blocks( $post->post_content ) ) as $attrs ) {
234 ++$ordinal;
235
236 $key = isset( $attrs['playlistId'] ) && is_string( $attrs['playlistId'] )
237 ? sanitize_key( $attrs['playlistId'] )
238 : '';
239 if ( '' === $key ) {
240 $key = $post->ID . '-' . $ordinal;
241 }
242
243 $records[ $key ] = array(
244 'title' => isset( $attrs['playlistTitle'] ) && is_string( $attrs['playlistTitle'] )
245 ? sanitize_text_field( $attrs['playlistTitle'] )
246 : '',
247 'description' => isset( $attrs['playlistDescription'] ) && is_string( $attrs['playlistDescription'] )
248 ? sanitize_textarea_field( $attrs['playlistDescription'] )
249 : '',
250 'videos' => Initializer::sanitize_playlist_entries( $attrs['videos'] ?? null ),
251 'post_id' => (int) $post->ID,
252 );
253 }
254
255 return $records;
256 }
257
258 /**
259 * Merge a post's playlist records into an index being built.
260 *
261 * @param array $index The index, modified in place.
262 * @param WP_Post $post The post.
263 *
264 * @return void
265 */
266 private static function add_post_records( array &$index, WP_Post $post ) {
267 foreach ( self::extract_records( $post ) as $key => $record ) {
268 if ( isset( $index[ $key ] ) && (int) $index[ $key ]['post_id'] !== (int) $post->ID ) {
269 $key .= '-' . $post->ID;
270 }
271 $index[ $key ] = $record;
272 }
273 }
274
275 /**
276 * Collect the attributes of every standalone Video Playlist block in a parsed
277 * block tree; dynamic playlist blocks and their inner blocks are skipped.
278 *
279 * @param array $blocks Parsed blocks.
280 *
281 * @return array[] Attribute arrays, in document order.
282 */
283 private static function find_playlist_blocks( array $blocks ) {
284 $found = array();
285
286 foreach ( $blocks as $block ) {
287 if ( in_array( $block['blockName'] ?? null, self::DYNAMIC_PLAYLIST_BLOCKS, true ) ) {
288 continue;
289 }
290
291 if ( self::BLOCK_NAME === ( $block['blockName'] ?? null ) ) {
292 $found[] = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
293 }
294
295 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
296 $found = array_merge( $found, self::find_playlist_blocks( $block['innerBlocks'] ) );
297 }
298 }
299
300 return $found;
301 }
302
303 /**
304 * Return the index without the records that belong to a post.
305 *
306 * @param array $index The index.
307 * @param int $post_id Post id.
308 *
309 * @return array
310 */
311 private static function without_post( array $index, $post_id ) {
312 return array_filter(
313 $index,
314 function ( $record ) use ( $post_id ) {
315 return ! is_array( $record ) || (int) ( $record['post_id'] ?? 0 ) !== (int) $post_id;
316 }
317 );
318 }
319
320 /**
321 * Post types whose content the index covers: everything a visitor can open.
322 *
323 * @return string[]
324 */
325 private static function indexed_post_types() {
326 return array_values(
327 array_filter(
328 get_post_types( array( 'public' => true ) ),
329 function ( $post_type ) {
330 return 'attachment' !== $post_type;
331 }
332 )
333 );
334 }
335
336 /**
337 * Persist the index, without autoloading it.
338 *
339 * @param array $index The index.
340 *
341 * @return void
342 */
343 private static function save( array $index ) {
344 if ( false === get_option( self::OPTION_NAME ) ) {
345 add_option( self::OPTION_NAME, $index, '', false );
346 return;
347 }
348
349 update_option( self::OPTION_NAME, $index, false );
350 }
351 }
352