Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.27% covered (success)
97.27%
107 / 110
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Playlist_Index
97.27% covered (success)
97.27%
107 / 110
83.33% covered (warning)
83.33%
10 / 12
46
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_playlists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 maybe_schedule_build
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 build
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
3
 index_post
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
8.38
 remove_post
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 extract_records
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
10
 add_post_records
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 find_playlist_blocks
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
8
 without_post
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 indexed_post_types
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Site-wide index of the Video Playlist blocks in published content.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10use WP_Post;
11use 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 */
21class 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}